Redesign spine template and move stack pinning to a body table
Rework spine-template.md so it stops forcing fixed structure: guidance
moves into single-line HTML comments (stripped at distill), the always-two
diagrams and empty-mermaid render bugs are gone, and the structural-seed
framing opens up so the operational/environmental envelope isn't skipped.
Stack moves from nested frontmatter into a ## Stack | Name | Version | table.
lint_spine.py drops the frontmatter dep check for find_unpinned_stack, which
parses the Stack table and flags real-name/blank-version rows while skipping
{token} skeletons. Tests reworked to match; 24 passing.
SKILL.md Finalize tightened to act-then-strip template comments and sweep
altitude-owned breadth.
This commit is contained in:
parent
f20aa0defe
commit
1cd98ed0e6
|
|
@ -67,7 +67,7 @@ The spine's pre-handoff review — full mechanics in `references/reviewer-gate.m
|
||||||
|
|
||||||
Walk the sequence; reviewer fixes land before polish.
|
Walk the sequence; reviewer fixes land before polish.
|
||||||
|
|
||||||
1. **Distill.** Write the spine from the memlog (brownfield: + the code sweep) — invariants first, seed minimal, every `AD` carrying Binds/Prevents/Rule, `Deferred` naming what it won't decide. No placeholders; never invent to fill a gap. Sweep the breadth the altitude owns — every structural dimension is decided, deferred, or an open question; a whole dimension left silent (e.g. the operational/environmental envelope: deployment & environments, infra/provider strategy, operations) is the failure, not a clean spine. A long coaching run distills cleaner in a subagent; the parent falls back inline.
|
1. **Distill.** Write the spine from the memlog (brownfield: + the code sweep) — invariants first, seed minimal, every `AD` carrying Binds/Prevents/Rule, `Deferred` naming what it won't decide. No placeholders; never invent to fill a gap. The template's `<!-- -->` notes are guidance — act on them, then strip them; the finished spine carries no template comment, and only the diagrams that convey the structure (as many as the altitude needs, valid mermaid). Sweep the breadth the altitude owns — every structural dimension is decided, deferred, or an open question; a whole dimension left silent (e.g. the operational/environmental envelope: deployment & environments, infra/provider strategy, operations) is the failure, not a clean spine. A long coaching run distills cleaner in a subagent; the parent falls back inline.
|
||||||
2. **Reconcile inputs.** A subagent per load-bearing input checks it against the spine and returns what didn't land — especially a quiet requirement (a tone, a constraint) the `AD` structure dropped. Before the gate.
|
2. **Reconcile inputs.** A subagent per load-bearing input checks it against the spine and returns what didn't land — especially a quiet requirement (a tone, a constraint) the `AD` structure dropped. Before the gate.
|
||||||
3. **Reviewer pass.** Run the Reviewer Gate (`references/reviewer-gate.md`). Resolve before polish.
|
3. **Reviewer pass.** Run the Reviewer Gate (`references/reviewer-gate.md`). Resolve before polish.
|
||||||
4. **Triage.** Open questions and `[ASSUMPTION]` tags: blockers (unsafe for what's next) resolved one at a time; the rest deferred with a revisit condition in the memlog.
|
4. **Triage.** Open questions and `[ASSUMPTION]` tags: blockers (unsafe for what's next) resolved one at a time; the rest deferred with a revisit condition in the memlog.
|
||||||
|
|
|
||||||
|
|
@ -8,106 +8,58 @@ scope: '{what this spine governs}'
|
||||||
status: draft # draft · final
|
status: draft # draft · final
|
||||||
created: '{date}'
|
created: '{date}'
|
||||||
updated: '{date}'
|
updated: '{date}'
|
||||||
stack: # SEED — verified current at authoring; the code owns this once it exists
|
binds: [] # capability / unit IDs governed (from the driving spec; at epic altitude, also the inherited parent AD ids)
|
||||||
languages: []
|
|
||||||
frameworks: []
|
|
||||||
key_deps: [] # name@version
|
|
||||||
binds: [] # capability / unit IDs governed (from the driving spec; at epic altitude, also the parent AD IDs inherited)
|
|
||||||
sources: []
|
sources: []
|
||||||
companions: []
|
companions: []
|
||||||
---
|
---
|
||||||
|
|
||||||
# Architecture Spine — {name}
|
# Architecture Spine — {name}
|
||||||
|
|
||||||
> A consistency contract, not a design document. It fixes the **invariants** that keep the
|
<!-- TEMPLATE GUIDE — act on these comments, then delete them; never emit a comment in the finished spine. This is a shape, not a script: keep only the sections this spine needs and cut the rest (no empty headers). A small intent may be just paradigm + a few ADs + conventions; a platform earns more. An inherited epic spine is usually mostly Inherited Invariants + a thin Deferred. Decisions, not rationale (rationale lives in the memlog). Carry shape in diagrams; prose only where it must. -->
|
||||||
> independently-built level below ({features | epics | stories}) coherent — the durable rules a
|
|
||||||
> clean codebase can't reveal. Structure is **seed**: the code owns the detail, the spine keeps the shape.
|
|
||||||
> Decisions, not rationale (that lives in the memlog). Diagrams over prose.
|
|
||||||
>
|
|
||||||
> **Scale to the job — drop any section a project doesn't need.** A small intent may be just a
|
|
||||||
> paradigm + a few `AD`s + conventions, seed omitted; a platform earns the full set. An inherited
|
|
||||||
> epic spine is usually mostly Inherited Invariants + a thin Deferred. Empty sections are cut, not left as headers.
|
|
||||||
|
|
||||||
## Design Paradigm
|
## Design Paradigm
|
||||||
|
|
||||||
Name the pattern — a known one loads a whole model for free — and map its layers to namespaces /
|
<!-- Name the pattern (a known one loads a whole model for free) and map its layers to namespaces/directories. The smallest, most durable thing here. -->
|
||||||
directories. The smallest, most durable thing in the file.
|
|
||||||
|
|
||||||
## Inherited Invariants
|
## Inherited Invariants
|
||||||
|
|
||||||
Present only when this spine inherits a parent at a higher altitude (e.g. an epic spine under a
|
<!-- Only when this spine inherits a higher-altitude parent. The parent's ADs/conventions/paradigm that bind here, by their ORIGINAL ids — read-only, never renumbered, not re-derived. A local decision that contradicts one is a conflict to surface, not an override. Cut this section otherwise. -->
|
||||||
feature/initiative spine). The parent's `AD`s, conventions, and paradigm that bind here, listed by
|
|
||||||
their original parent IDs — **read-only, never renumbered, not re-derived**. This spine adds only
|
|
||||||
what the parent left open; anything here that a local decision would contradict is a conflict to
|
|
||||||
surface, not override.
|
|
||||||
|
|
||||||
| Inherited | From parent | Binds here |
|
| Inherited | From parent | Binds here |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| {AD-n / convention} | {parent spine} | {what it constrains in this scope} |
|
| {AD-id / convention} | {parent spine} | {what it constrains in this scope} |
|
||||||
|
|
||||||
## Invariants & Rules
|
## Invariants & Rules
|
||||||
|
|
||||||
The durable heart: the calls a future builder can't read from compliant code. Each `AD-n` has a
|
<!-- The durable heart: calls a future builder can't read off compliant code. One block per decision: stable ascending id (never reused/renumbered), Binds, Prevents (the divergence), Rule (enforceable). Tag [ADOPTED] when the user or existing reality settled it. Include a dependency-direction diagram (who may depend on whom) — it IS a rule; author it as valid mermaid, never an empty graph. -->
|
||||||
stable ID (never reused), a binding scope, the divergence it prevents, and an enforceable rule.
|
|
||||||
Cover the boundary/dependency rules (who may depend on whom) and how state is mutated — a
|
|
||||||
dependency-direction diagram says these better than prose. An `AD-n` the user asserted as
|
|
||||||
already-settled (or one verified from existing reality) carries an `[ADOPTED]` tag after its
|
|
||||||
title, so its provenance is legible versus decisions made here.
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
flowchart LR
|
|
||||||
%% arrows = allowed dependency direction (a rule, not just structure)
|
|
||||||
```
|
|
||||||
|
|
||||||
### AD-1 — {decision}
|
### AD-1 — {decision}
|
||||||
|
|
||||||
- **Binds:** {capability / unit IDs, areas, or `all`}
|
- **Binds:** {capability / unit ids / fr/nfr's, areas, or `all`}
|
||||||
- **Prevents:** {the divergence this stops}
|
- **Prevents:** {the divergence this stops}
|
||||||
- **Rule:** {the constraint downstream must follow}
|
- **Rule:** {the constraint downstream must follow}
|
||||||
|
|
||||||
## Consistency Conventions
|
## Consistency Conventions
|
||||||
|
|
||||||
The defaults that bind everything where independent builders would otherwise drift. Cut rows that
|
<!-- Defaults that bind where independent builders would drift. Cut rows that don't apply; add rows the project needs. -->
|
||||||
don't apply.
|
|
||||||
|
|
||||||
| Concern | Convention |
|
| Concern | Convention |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| Naming (entities, files, interfaces, events) | |
|
| Naming (entities, files, interfaces, events) | |
|
||||||
| Data & formats (IDs, dates, error shapes, envelopes) | |
|
| Data & formats (ids, dates, error shapes, envelopes) | |
|
||||||
| State & cross-cutting (mutation, errors, logging, config, auth) | |
|
| State & cross-cutting (mutation, errors, logging, config, auth) | |
|
||||||
|
|
||||||
|
## Stack
|
||||||
|
|
||||||
|
<!-- SEED — verified current at authoring; the code owns this once it exists. Name + version only; the why lives in the memlog. One row per language, framework, key dependency, platform, or chain that's pinned. -->
|
||||||
|
|
||||||
|
| Name | Version |
|
||||||
|
| --- | --- |
|
||||||
|
| {language / framework / key dep / platform / chain} | {pinned version} |
|
||||||
|
|
||||||
## Structural Seed
|
## Structural Seed
|
||||||
|
|
||||||
Cold-start scaffolding, kept minimal — include an item only where its shape is non-obvious at this
|
<!-- The shapes worth fixing at cold-start — not a fixed list. Include only what's non-obvious at this altitude, and use as many diagrams as convey it, each as VALID mermaid (never a placeholder or empty graph). Candidates: system/container/context view; DEPLOYMENT & ENVIRONMENTS and external provider/infra topology (cover the operational envelope here when this altitude owns it — don't let it fall through); core-entity ERD (names + relationships only; an attribute that's itself an invariant is an AD, not a diagram); a minimal source tree. The code owns the detail — this is scaffold, not a mirror to maintain. -->
|
||||||
altitude (at epic altitude the parent usually already fixed it, so the seed is often empty). The code
|
|
||||||
owns the **detail** (every file, every column); once code exists it becomes the source of truth for
|
|
||||||
detail, and this seed is a starting scaffold, not a mirror to maintain against it. Evolve a seed item
|
|
||||||
only when the **shape** itself changes — a new container, a new core entity, a stack bump — and let
|
|
||||||
the memlog keep the history.
|
|
||||||
|
|
||||||
- **Stack & Versions** — the substrate (mirrors frontmatter `stack`).
|
|
||||||
- **System Shape** — a container/context view (at epic altitude, the slice of the parent system this scope touches). Use `flowchart` with a `subgraph` per boundary; C4 mermaid is experimental and won't render in most viewers.
|
|
||||||
- **Core Entities** — an ERD of entities and their relationships. Names and relationships only; attributes belong to the code unless one is itself an invariant (then it's an `AD`, not seed).
|
|
||||||
- **Project Structure** — a minimal source tree, only as deep as consistency needs.
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
flowchart TD
|
|
||||||
user(["{actor}"])
|
|
||||||
subgraph sys["{system boundary}"]
|
|
||||||
a["{container}<br/>{tech} — {role}"]
|
|
||||||
end
|
|
||||||
db[("{datastore}")]
|
|
||||||
ext["{external system}"]
|
|
||||||
user --> a
|
|
||||||
a --> db
|
|
||||||
a -->|{via port}| ext
|
|
||||||
```
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
erDiagram
|
|
||||||
ENTITY_A ||--o{ ENTITY_B : "{relationship}"
|
|
||||||
ENTITY_B ||--o| ENTITY_C : "{relationship}"
|
|
||||||
```
|
|
||||||
|
|
||||||
```text
|
```text
|
||||||
{root}/
|
{root}/
|
||||||
|
|
@ -116,14 +68,12 @@ erDiagram
|
||||||
|
|
||||||
## Capability → Architecture Map
|
## Capability → Architecture Map
|
||||||
|
|
||||||
Bridges the spec's capabilities to the architecture (and is the consistency auditor's checklist).
|
<!-- Present when a spec drove this run. Bridges the spec's capabilities to where they live + what governs them; the consistency auditor's checklist. Cut otherwise. -->
|
||||||
Present when a spec drove this run.
|
|
||||||
|
|
||||||
| Capability / Area | Lives in | Governed by |
|
| Capability / Area | Lives in | Governed by |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| {CAP-n / area} | {component / module} | {AD-n, convention, paradigm} |
|
| {CAP-id / area} | {component / module} | {AD-id, convention, paradigm} |
|
||||||
|
|
||||||
## Deferred
|
## Deferred
|
||||||
|
|
||||||
Decisions intentionally pushed down, each with the reason it can wait. The half of the contract
|
<!-- Decisions intentionally pushed down, each with the reason it can wait — including whole dimensions this altitude doesn't own yet. The half of the contract that keeps the spine lean. -->
|
||||||
that keeps the spine lean.
|
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ It reads ARCHITECTURE-SPINE.md from a workspace and reports, as compact JSON on
|
||||||
- placeholder literal TBD / TODO / "similar to AD-n" / unfilled {template-token}
|
- placeholder literal TBD / TODO / "similar to AD-n" / unfilled {template-token}
|
||||||
- ad_id duplicate or non-monotonic AD-n identifiers
|
- ad_id duplicate or non-monotonic AD-n identifiers
|
||||||
- ad_fields an AD-n block missing Binds / Prevents / Rule
|
- ad_fields an AD-n block missing Binds / Prevents / Rule
|
||||||
- version_pin a frontmatter key_deps entry with no @version
|
- version_pin a ## Stack table row with no version
|
||||||
|
|
||||||
Fenced code blocks are blanked (replaced with equal-count blank lines) before scanning, so
|
Fenced code blocks are blanked (replaced with equal-count blank lines) before scanning, so
|
||||||
mermaid and source trees don't trip false positives AND reported line numbers still line up
|
mermaid and source trees don't trip false positives AND reported line numbers still line up
|
||||||
|
|
@ -150,65 +150,53 @@ def find_ad_issues(body: str, offset: int) -> list[dict]:
|
||||||
return findings
|
return findings
|
||||||
|
|
||||||
|
|
||||||
def find_unpinned_deps(frontmatter: str) -> list[dict]:
|
def find_unpinned_stack(body: str, offset: int) -> list[dict]:
|
||||||
|
"""Flag a `## Stack` table row that names something but leaves its version blank or a
|
||||||
|
placeholder. Pinning lives in the body table now, not frontmatter. A row whose name is
|
||||||
|
still a `{token}` skeleton is left to the placeholder pass, not double-reported here."""
|
||||||
findings: list[dict] = []
|
findings: list[dict] = []
|
||||||
lines = frontmatter.splitlines()
|
in_stack = False
|
||||||
in_key_deps = False
|
header_seen = False
|
||||||
key_indent = 0
|
ver_idx = 1
|
||||||
for raw in lines:
|
for i, raw in enumerate(body.splitlines()):
|
||||||
stripped = raw.strip()
|
if HEADING.match(raw):
|
||||||
if not stripped or stripped.startswith("#"):
|
in_stack = re.match(r"^##\s+Stack\s*$", raw) is not None
|
||||||
|
header_seen = False
|
||||||
|
ver_idx = 1
|
||||||
continue
|
continue
|
||||||
indent = len(raw) - len(raw.lstrip())
|
if not in_stack or not raw.lstrip().startswith("|"):
|
||||||
m = re.match(r"key_deps:\s*(.*)$", stripped)
|
|
||||||
if m:
|
|
||||||
in_key_deps = True
|
|
||||||
key_indent = indent
|
|
||||||
inline = _strip_comment(m.group(1)).strip()
|
|
||||||
if inline and inline not in ("[]", "[ ]"):
|
|
||||||
# inline list form: key_deps: [a@1, b] — consumed here, no block follows
|
|
||||||
for item in re.findall(r"[^\[\],]+", inline.strip("[]")):
|
|
||||||
_check_dep(item.strip().strip("'\""), findings)
|
|
||||||
in_key_deps = False
|
|
||||||
continue
|
continue
|
||||||
if in_key_deps:
|
if set(raw.strip()) <= set("|-: "):
|
||||||
if indent <= key_indent and not stripped.startswith("-"):
|
continue # separator row
|
||||||
in_key_deps = False
|
cells = _table_cells(raw)
|
||||||
|
if not header_seen:
|
||||||
|
header_seen = True
|
||||||
|
for j, c in enumerate(cells):
|
||||||
|
if c.lower() == "version":
|
||||||
|
ver_idx = j
|
||||||
continue
|
continue
|
||||||
if stripped.startswith("-"):
|
name = cells[0] if cells else ""
|
||||||
# block-sequence form: `- name@version`
|
version = cells[ver_idx] if len(cells) > ver_idx else ""
|
||||||
_check_dep(_strip_comment(stripped[1:]).strip().strip("'\""), findings)
|
if not name or TEMPLATE_TOKEN.search(name):
|
||||||
else:
|
continue
|
||||||
# map form: `name: version` — pinned iff a non-empty value is present
|
if not version or TEMPLATE_TOKEN.search(version):
|
||||||
mm = re.match(r"([^:]+):\s*(.*)$", stripped)
|
|
||||||
if mm:
|
|
||||||
name = mm.group(1).strip().strip("'\"")
|
|
||||||
val = _strip_comment(mm.group(2)).strip().strip("'\"")
|
|
||||||
if name and not val:
|
|
||||||
findings.append({
|
findings.append({
|
||||||
"category": "version_pin",
|
"category": "version_pin",
|
||||||
"severity": "medium",
|
"severity": "medium",
|
||||||
"detail": f"key_deps entry {name!r} has no version pin",
|
"detail": f"Stack entry {name!r} has no version",
|
||||||
"location": f"{SPINE} frontmatter stack.key_deps",
|
"location": f"{SPINE} (line {offset + i + 1})",
|
||||||
})
|
})
|
||||||
return findings
|
return findings
|
||||||
|
|
||||||
|
|
||||||
def _strip_comment(s: str) -> str:
|
def _table_cells(row: str) -> list[str]:
|
||||||
"""Drop a trailing YAML ` # comment`, leaving an inline `name@1.2` intact."""
|
"""Split a markdown table row into trimmed cells, dropping the leading/trailing pipe."""
|
||||||
return re.sub(r"(^|\s)#.*$", "", s)
|
s = row.strip()
|
||||||
|
if s.startswith("|"):
|
||||||
|
s = s[1:]
|
||||||
def _check_dep(item: str, findings: list[dict]) -> None:
|
if s.endswith("|"):
|
||||||
if not item or item.startswith("#"):
|
s = s[:-1]
|
||||||
return
|
return [c.strip() for c in s.split("|")]
|
||||||
if "@" not in item:
|
|
||||||
findings.append({
|
|
||||||
"category": "version_pin",
|
|
||||||
"severity": "medium",
|
|
||||||
"detail": f"key_deps entry {item!r} has no @version pin",
|
|
||||||
"location": f"{SPINE} frontmatter stack.key_deps",
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
def lint(text: str) -> dict:
|
def lint(text: str) -> dict:
|
||||||
|
|
@ -217,7 +205,7 @@ def lint(text: str) -> dict:
|
||||||
findings += find_frontmatter_placeholders(frontmatter)
|
findings += find_frontmatter_placeholders(frontmatter)
|
||||||
findings += find_placeholders(body, offset)
|
findings += find_placeholders(body, offset)
|
||||||
findings += find_ad_issues(body, offset)
|
findings += find_ad_issues(body, offset)
|
||||||
findings += find_unpinned_deps(frontmatter)
|
findings += find_unpinned_stack(body, offset)
|
||||||
counts: dict[str, int] = {}
|
counts: dict[str, int] = {}
|
||||||
for f in findings:
|
for f in findings:
|
||||||
counts[f["severity"]] = counts.get(f["severity"], 0) + 1
|
counts[f["severity"]] = counts.get(f["severity"], 0) + 1
|
||||||
|
|
|
||||||
|
|
@ -26,10 +26,6 @@ _SPEC.loader.exec_module(lint_spine)
|
||||||
|
|
||||||
CLEAN = """---
|
CLEAN = """---
|
||||||
name: 'Demo'
|
name: 'Demo'
|
||||||
stack:
|
|
||||||
key_deps:
|
|
||||||
- fastapi@0.115
|
|
||||||
- pydantic@2.9
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Invariants & Rules
|
## Invariants & Rules
|
||||||
|
|
@ -50,6 +46,13 @@ stack:
|
||||||
flowchart LR
|
flowchart LR
|
||||||
A --> B{decision}
|
A --> B{decision}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Stack
|
||||||
|
|
||||||
|
| Name | Version |
|
||||||
|
| --- | --- |
|
||||||
|
| fastapi | 0.115 |
|
||||||
|
| pydantic | 2.9 |
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -108,30 +111,32 @@ def test_missing_field_caught():
|
||||||
|
|
||||||
|
|
||||||
def test_unpinned_dep_caught():
|
def test_unpinned_dep_caught():
|
||||||
text = CLEAN.replace("- fastapi@0.115", "- fastapi")
|
text = CLEAN.replace("| fastapi | 0.115 |", "| fastapi | |")
|
||||||
result = lint_spine.lint(text)
|
result = lint_spine.lint(text)
|
||||||
assert "version_pin" in cats(result)
|
assert "version_pin" in cats(result)
|
||||||
|
|
||||||
|
|
||||||
def test_inline_key_deps_unpinned():
|
def test_placeholder_version_caught():
|
||||||
text = CLEAN.replace(" key_deps:\n - fastapi@0.115\n - pydantic@2.9", " key_deps: [fastapi, redis@7]")
|
text = CLEAN.replace("| fastapi | 0.115 |", "| fastapi | {pin} |")
|
||||||
result = lint_spine.lint(text)
|
result = lint_spine.lint(text)
|
||||||
pins = [f for f in result["findings"] if f["category"] == "version_pin"]
|
assert any(f["category"] == "version_pin" and "fastapi" in f["detail"] for f in result["findings"])
|
||||||
assert len(pins) == 1 and "fastapi" in pins[0]["detail"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_empty_key_deps_ok():
|
def test_no_stack_section_ok():
|
||||||
text = CLEAN.replace(" key_deps:\n - fastapi@0.115\n - pydantic@2.9", " key_deps: []")
|
text = CLEAN.split("## Stack")[0]
|
||||||
result = lint_spine.lint(text)
|
result = lint_spine.lint(text)
|
||||||
assert "version_pin" not in cats(result)
|
assert "version_pin" not in cats(result)
|
||||||
|
|
||||||
|
|
||||||
def test_yaml_comments_not_parsed_as_deps():
|
def test_stack_skeleton_row_not_version_pinned():
|
||||||
# a SEED comment on the key_deps line must not read as an unpinned dependency
|
# a leftover {token} name is the placeholder pass's job, not a double-reported version_pin
|
||||||
text = CLEAN.replace(
|
text = CLEAN.replace("| fastapi | 0.115 |", "| {language / framework} | {pinned version} |")
|
||||||
" key_deps:\n - fastapi@0.115\n - pydantic@2.9",
|
result = lint_spine.lint(text)
|
||||||
" key_deps: # SEED — verified current 2026-06\n - fastapi@0.115 # web framework",
|
assert "version_pin" not in cats(result)
|
||||||
)
|
|
||||||
|
|
||||||
|
def test_stack_html_comment_not_parsed_as_row():
|
||||||
|
text = CLEAN.replace("## Stack\n", "## Stack\n\n<!-- SEED — verified current 2026-06 -->\n")
|
||||||
result = lint_spine.lint(text)
|
result = lint_spine.lint(text)
|
||||||
assert "version_pin" not in cats(result)
|
assert "version_pin" not in cats(result)
|
||||||
|
|
||||||
|
|
@ -153,7 +158,8 @@ def test_no_frontmatter_body_still_scanned():
|
||||||
|
|
||||||
def test_frontmatter_value_with_dashes_not_truncated():
|
def test_frontmatter_value_with_dashes_not_truncated():
|
||||||
# a value containing '---' must not be read as the closing fence (line-exact close)
|
# a value containing '---' must not be read as the closing fence (line-exact close)
|
||||||
text = "---\nscope: 'phase 1 --- phase 2'\nstack:\n key_deps:\n - fastapi\n---\n\n## Invariants\n"
|
text = ("---\nname: 'x'\nscope: 'phase 1 --- phase 2'\n---\n\n"
|
||||||
|
"## Stack\n\n| Name | Version |\n| --- | --- |\n| fastapi | |\n")
|
||||||
result = lint_spine.lint(text)
|
result = lint_spine.lint(text)
|
||||||
assert any(f["category"] == "version_pin" for f in result["findings"]) # read past the inline ---
|
assert any(f["category"] == "version_pin" for f in result["findings"]) # read past the inline ---
|
||||||
|
|
||||||
|
|
@ -168,15 +174,17 @@ def test_ad_heading_in_fence_not_counted():
|
||||||
assert result["ok"] is True # the fenced AD-2 is not a live AD → no ad_fields/ad_id finding
|
assert result["ok"] is True # the fenced AD-2 is not a live AD → no ad_fields/ad_id finding
|
||||||
|
|
||||||
|
|
||||||
def test_map_form_key_deps_unpinned_caught():
|
def test_stack_table_flags_only_the_unpinned_row():
|
||||||
text = "---\nstack:\n key_deps:\n fastapi: '0.115'\n redis:\n---\n\n## Invariants\n"
|
text = ("---\nname: 'x'\n---\n\n## Stack\n\n| Name | Version |\n| --- | --- |\n"
|
||||||
|
"| fastapi | 0.115 |\n| redis | |\n")
|
||||||
result = lint_spine.lint(text)
|
result = lint_spine.lint(text)
|
||||||
pins = [f for f in result["findings"] if f["category"] == "version_pin"]
|
pins = [f for f in result["findings"] if f["category"] == "version_pin"]
|
||||||
assert len(pins) == 1 and "redis" in pins[0]["detail"]
|
assert len(pins) == 1 and "redis" in pins[0]["detail"]
|
||||||
|
|
||||||
|
|
||||||
def test_map_form_key_deps_pinned_ok():
|
def test_stack_table_all_pinned_ok():
|
||||||
text = "---\nstack:\n key_deps:\n fastapi: '0.115'\n---\n\n## Invariants\n"
|
text = ("---\nname: 'x'\n---\n\n## Stack\n\n| Name | Version |\n| --- | --- |\n"
|
||||||
|
"| fastapi | 0.115 |\n")
|
||||||
result = lint_spine.lint(text)
|
result = lint_spine.lint(text)
|
||||||
assert "version_pin" not in cats(result)
|
assert "version_pin" not in cats(result)
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue