Snapshot WIP: solver HP epic progress, BPHX/HX physics, BMAD skill refresh.
Some checks failed
CI / check (push) Has been cancelled
Some checks failed
CI / check (push) Has been cancelled
Capture uncommitted solver robustness work (regularization, domain errors, linear solver lifecycle, tube DP/MSH), web workbench updates, and synced BMAD skills across IDE agent folders before starting BPHX pressure-drop. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
257
.agent/skills/bmad-architecture/scripts/lint_spine.py
Normal file
257
.agent/skills/bmad-architecture/scripts/lint_spine.py
Normal file
@@ -0,0 +1,257 @@
|
||||
#!/usr/bin/env python3
|
||||
# /// script
|
||||
# requires-python = ">=3.10"
|
||||
# ///
|
||||
"""lint-spine — the mechanical half of spine decision-integrity, done deterministically.
|
||||
|
||||
LLMs miscount IDs and miss literal placeholders; a grep does not. This linter owns the
|
||||
checks a script does better than a prompt, and leaves the semantic half (is each Rule
|
||||
actually enforceable? does the boundary make sense?) to the rubric walker.
|
||||
|
||||
It reads ARCHITECTURE-SPINE.md from a workspace and reports, as compact JSON on stdout:
|
||||
|
||||
- placeholder literal TBD / TODO / "similar to AD-n" / unfilled {template-token}
|
||||
- ad_id duplicate or non-monotonic AD-n identifiers
|
||||
- ad_fields an AD-n block missing Binds / Prevents / Rule
|
||||
- version_pin a ## Stack table row with no version
|
||||
|
||||
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
|
||||
with the real file. Reported lines are absolute file lines (frontmatter offset added). Exit
|
||||
code is always 0 — findings travel in the JSON; the caller (Reviewer Gate / rubric walker)
|
||||
decides what to do with them.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
SPINE = "ARCHITECTURE-SPINE.md"
|
||||
|
||||
AD_HEADING = re.compile(r"^#{2,4}\s*AD-(\d+)\b(.*)$", re.MULTILINE)
|
||||
HEADING = re.compile(r"^#{1,6}\s", re.MULTILINE)
|
||||
FENCE = re.compile(r"```.*?```", re.DOTALL)
|
||||
PLACEHOLDER_WORD = re.compile(r"\b(TBD|TODO|FIXME|XXX)\b")
|
||||
SIMILAR_TO = re.compile(r"similar to AD-\d+", re.IGNORECASE)
|
||||
TEMPLATE_TOKEN = re.compile(r"\{[a-z_][a-z0-9_ /.-]*\}")
|
||||
|
||||
|
||||
def split_frontmatter(text: str) -> tuple[str, str, int]:
|
||||
"""Return (frontmatter, body, body_line_offset).
|
||||
|
||||
Frontmatter is the content between the first two lines that are *exactly* `---`
|
||||
(line-exact, like memlog.split — a `---` inside a value or a body thematic break never
|
||||
truncates it). body_line_offset is the number of file lines before the body begins, so a
|
||||
body-relative line number plus the offset gives the absolute file line. Absent frontmatter
|
||||
→ ('', text, 0)."""
|
||||
lines = text.split("\n")
|
||||
if lines and lines[0] == "---":
|
||||
for i in range(1, len(lines)):
|
||||
if lines[i] == "---":
|
||||
fm = "\n".join(lines[1:i])
|
||||
body = "\n".join(lines[i + 1:])
|
||||
return fm, body, i + 1
|
||||
return "", text, 0
|
||||
|
||||
|
||||
def blank_fences(text: str) -> str:
|
||||
"""Replace each fenced block with the same number of newlines, so scanning skips fenced
|
||||
content while every line number outside the fence stays put."""
|
||||
return FENCE.sub(lambda m: "\n" * m.group(0).count("\n"), text)
|
||||
|
||||
|
||||
def line_of(text: str, idx: int) -> int:
|
||||
return text.count("\n", 0, idx) + 1
|
||||
|
||||
|
||||
def find_placeholders(body: str, offset: int) -> list[dict]:
|
||||
findings: list[dict] = []
|
||||
scan = blank_fences(body)
|
||||
# (regex, label, severity) — TBD/TODO and dangling cross-refs are unambiguous; a bare
|
||||
# {template-token} can be legitimate brace prose, so it is flagged low ("possible") to keep
|
||||
# the mechanical pass near-zero false-positive rather than train reviewers to ignore it.
|
||||
for rx, label, severity in (
|
||||
(PLACEHOLDER_WORD, "placeholder marker", "high"),
|
||||
(SIMILAR_TO, "unresolved cross-reference", "high"),
|
||||
(TEMPLATE_TOKEN, "possible unfilled template token (verify)", "low"),
|
||||
):
|
||||
for m in rx.finditer(scan):
|
||||
findings.append({
|
||||
"category": "placeholder",
|
||||
"severity": severity,
|
||||
"detail": f"{label}: {m.group(0)!r}",
|
||||
"location": f"{SPINE} (line {offset + line_of(scan, m.start())})",
|
||||
})
|
||||
return findings
|
||||
|
||||
|
||||
def find_frontmatter_placeholders(frontmatter: str) -> list[dict]:
|
||||
"""Catch unfilled tokens left in frontmatter (e.g. paradigm/scope/date) — part of the
|
||||
spine contract, but outside the body that find_placeholders scans."""
|
||||
findings: list[dict] = []
|
||||
for rx, label, severity in (
|
||||
(PLACEHOLDER_WORD, "placeholder marker", "high"),
|
||||
(TEMPLATE_TOKEN, "possible unfilled template token (verify)", "low"),
|
||||
):
|
||||
for m in rx.finditer(frontmatter):
|
||||
findings.append({
|
||||
"category": "placeholder",
|
||||
"severity": severity,
|
||||
"detail": f"frontmatter {label}: {m.group(0)!r}",
|
||||
"location": f"{SPINE} frontmatter (line {1 + line_of(frontmatter, m.start())})",
|
||||
})
|
||||
return findings
|
||||
|
||||
|
||||
def find_ad_issues(body: str, offset: int) -> list[dict]:
|
||||
findings: list[dict] = []
|
||||
scan = blank_fences(body) # AD headings shown inside a code fence are not live ADs
|
||||
matches = list(AD_HEADING.finditer(scan))
|
||||
seen: dict[int, int] = {}
|
||||
prev: int | None = None
|
||||
for m in matches:
|
||||
num = int(m.group(1))
|
||||
file_line = offset + line_of(scan, m.start())
|
||||
loc = f"{SPINE} AD-{num} (line {file_line})"
|
||||
if num in seen:
|
||||
findings.append({
|
||||
"category": "ad_id",
|
||||
"severity": "high",
|
||||
"detail": f"AD-{num} id reused (also at line {seen[num]})",
|
||||
"location": loc,
|
||||
})
|
||||
else:
|
||||
seen[num] = file_line
|
||||
if prev is not None and num <= prev:
|
||||
findings.append({
|
||||
"category": "ad_id",
|
||||
"severity": "high",
|
||||
"detail": f"AD-{num} is non-monotonic (follows AD-{prev}); ids must ascend and never renumber",
|
||||
"location": loc,
|
||||
})
|
||||
prev = num if prev is None else max(prev, num)
|
||||
|
||||
# block text = from this heading to the next heading of any level
|
||||
start = m.end()
|
||||
nxt = HEADING.search(scan, start)
|
||||
block = scan[start:nxt.start()] if nxt else scan[start:]
|
||||
low = block.lower()
|
||||
missing = [f for f in ("binds", "prevents", "rule") if f not in low]
|
||||
if missing:
|
||||
findings.append({
|
||||
"category": "ad_fields",
|
||||
"severity": "high",
|
||||
"detail": f"AD-{num} missing required field(s): {', '.join(missing)}",
|
||||
"location": loc,
|
||||
})
|
||||
return findings
|
||||
|
||||
|
||||
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.
|
||||
|
||||
Fences are blanked first (like find_placeholders / find_ad_issues), so a pipe-row or
|
||||
heading inside a code block is never read as live Stack content. The heading match is
|
||||
`## Stack` with a word boundary, so a renamed heading (`## Stack & Versions`) still
|
||||
counts. Name and Version columns are located from the header row, so a reordered table
|
||||
pairs name to version correctly; both default to the canonical positions (0, 1)."""
|
||||
findings: list[dict] = []
|
||||
in_stack = False
|
||||
header_seen = False
|
||||
name_idx, ver_idx = 0, 1
|
||||
scan = blank_fences(body)
|
||||
for i, raw in enumerate(scan.splitlines()):
|
||||
if HEADING.match(raw):
|
||||
in_stack = re.match(r"^##\s+Stack\b", raw) is not None
|
||||
header_seen = False
|
||||
name_idx, ver_idx = 0, 1
|
||||
continue
|
||||
if not in_stack or not raw.lstrip().startswith("|"):
|
||||
continue
|
||||
if set(raw.strip()) <= set("|-: "):
|
||||
continue # separator row
|
||||
cells = _table_cells(raw)
|
||||
if not header_seen:
|
||||
header_seen = True
|
||||
for j, c in enumerate(cells):
|
||||
if c.lower() == "name":
|
||||
name_idx = j
|
||||
elif c.lower() == "version":
|
||||
ver_idx = j
|
||||
continue
|
||||
name = cells[name_idx] if len(cells) > name_idx else ""
|
||||
version = cells[ver_idx] if len(cells) > ver_idx else ""
|
||||
if not name or TEMPLATE_TOKEN.search(name):
|
||||
continue
|
||||
if not version or TEMPLATE_TOKEN.search(version):
|
||||
findings.append({
|
||||
"category": "version_pin",
|
||||
"severity": "medium",
|
||||
"detail": f"Stack entry {name!r} has no version",
|
||||
"location": f"{SPINE} (line {offset + i + 1})",
|
||||
})
|
||||
return findings
|
||||
|
||||
|
||||
def _table_cells(row: str) -> list[str]:
|
||||
"""Split a markdown table row into trimmed cells, dropping the leading/trailing pipe."""
|
||||
s = row.strip()
|
||||
if s.startswith("|"):
|
||||
s = s[1:]
|
||||
if s.endswith("|"):
|
||||
s = s[:-1]
|
||||
return [c.strip() for c in s.split("|")]
|
||||
|
||||
|
||||
def lint(text: str) -> dict:
|
||||
frontmatter, body, offset = split_frontmatter(text)
|
||||
findings: list[dict] = []
|
||||
findings += find_frontmatter_placeholders(frontmatter)
|
||||
findings += find_placeholders(body, offset)
|
||||
findings += find_ad_issues(body, offset)
|
||||
findings += find_unpinned_stack(body, offset)
|
||||
counts: dict[str, int] = {}
|
||||
for f in findings:
|
||||
counts[f["severity"]] = counts.get(f["severity"], 0) + 1
|
||||
return {
|
||||
"ok": len(findings) == 0,
|
||||
"spine": SPINE,
|
||||
"total_findings": len(findings),
|
||||
"by_severity": counts,
|
||||
"findings": findings,
|
||||
}
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
ap = argparse.ArgumentParser(description="Lint an architecture spine for mechanical integrity.")
|
||||
ap.add_argument("--workspace", required=True, help="run folder containing ARCHITECTURE-SPINE.md")
|
||||
ap.add_argument("-o", "--output", help="write JSON here instead of stdout")
|
||||
args = ap.parse_args(argv)
|
||||
|
||||
spine_path = Path(args.workspace) / SPINE
|
||||
if not spine_path.exists():
|
||||
result = {"ok": False, "error": f"{spine_path} not found", "findings": [], "total_findings": 0}
|
||||
else:
|
||||
try:
|
||||
text = spine_path.read_text(encoding="utf-8")
|
||||
except (OSError, UnicodeDecodeError) as e:
|
||||
# honor the "exit code is always 0" contract: a read/decode failure travels in JSON
|
||||
result = {"ok": False, "error": f"could not read {spine_path}: {e}", "findings": [], "total_findings": 0}
|
||||
else:
|
||||
result = lint(text)
|
||||
|
||||
out = json.dumps(result, indent=2)
|
||||
if args.output:
|
||||
Path(args.output).write_text(out + "\n", encoding="utf-8")
|
||||
else:
|
||||
print(out)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
270
.agent/skills/bmad-architecture/scripts/tests/test_lint_spine.py
Normal file
270
.agent/skills/bmad-architecture/scripts/tests/test_lint_spine.py
Normal file
@@ -0,0 +1,270 @@
|
||||
# /// script
|
||||
# requires-python = ">=3.10"
|
||||
# dependencies = ["pytest>=8.0"]
|
||||
# ///
|
||||
"""Tests for lint_spine.py. Run: uv run --with pytest pytest scripts/tests/test_lint_spine.py
|
||||
|
||||
The spine under test: a clean spine lints empty; the linter catches exactly the
|
||||
mechanical defects a prompt is unreliable at — literal placeholders, AD-n id breakage,
|
||||
AD-n blocks missing required fields, and unpinned Stack versions.
|
||||
"""
|
||||
import importlib.util
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
_SPEC = importlib.util.spec_from_file_location(
|
||||
"lint_spine", Path(__file__).resolve().parent.parent / "lint_spine.py"
|
||||
)
|
||||
lint_spine = importlib.util.module_from_spec(_SPEC)
|
||||
sys.modules["lint_spine"] = lint_spine
|
||||
_SPEC.loader.exec_module(lint_spine)
|
||||
|
||||
|
||||
CLEAN = """---
|
||||
name: 'Demo'
|
||||
---
|
||||
|
||||
## Invariants & Rules
|
||||
|
||||
### AD-1 — single write path
|
||||
|
||||
- **Binds:** all
|
||||
- **Prevents:** divergent mutation
|
||||
- **Rule:** state changes only through the command bus
|
||||
|
||||
### AD-2 — layered deps `[ADOPTED]`
|
||||
|
||||
- **Binds:** all
|
||||
- **Prevents:** import cycles
|
||||
- **Rule:** ui -> app -> domain, never backward
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A --> B{decision}
|
||||
```
|
||||
|
||||
## Stack
|
||||
|
||||
| Name | Version |
|
||||
| --- | --- |
|
||||
| fastapi | 0.115 |
|
||||
| pydantic | 2.9 |
|
||||
"""
|
||||
|
||||
|
||||
def cats(result):
|
||||
return sorted(f["category"] for f in result["findings"])
|
||||
|
||||
|
||||
def test_clean_spine_passes():
|
||||
result = lint_spine.lint(CLEAN)
|
||||
assert result["ok"] is True
|
||||
assert result["total_findings"] == 0
|
||||
|
||||
|
||||
def test_mermaid_braces_not_flagged():
|
||||
# the {decision} node lives in a fenced block and must not read as a template token
|
||||
result = lint_spine.lint(CLEAN)
|
||||
assert "placeholder" not in cats(result)
|
||||
|
||||
|
||||
def test_placeholder_markers_caught():
|
||||
text = CLEAN.replace("the command bus", "TBD")
|
||||
result = lint_spine.lint(text)
|
||||
assert "placeholder" in cats(result)
|
||||
|
||||
|
||||
def test_similar_to_caught():
|
||||
text = CLEAN.replace("import cycles", "similar to AD-1")
|
||||
result = lint_spine.lint(text)
|
||||
assert any("cross-reference" in f["detail"] for f in result["findings"])
|
||||
|
||||
|
||||
def test_unfilled_template_token_caught():
|
||||
text = CLEAN.replace("single write path", "{decision}")
|
||||
result = lint_spine.lint(text)
|
||||
assert any(f["category"] == "placeholder" for f in result["findings"])
|
||||
|
||||
|
||||
def test_duplicate_ad_id_caught():
|
||||
text = CLEAN.replace("### AD-2 — layered deps `[ADOPTED]`", "### AD-1 — layered deps")
|
||||
result = lint_spine.lint(text)
|
||||
assert "ad_id" in cats(result)
|
||||
|
||||
|
||||
def test_non_monotonic_ad_id_caught():
|
||||
text = CLEAN.replace("### AD-2 — layered deps `[ADOPTED]`", "### AD-5 — layered deps").replace(
|
||||
"### AD-1 — single write path", "### AD-9 — single write path"
|
||||
)
|
||||
result = lint_spine.lint(text)
|
||||
assert any("non-monotonic" in f["detail"] for f in result["findings"])
|
||||
|
||||
|
||||
def test_missing_field_caught():
|
||||
text = CLEAN.replace("- **Rule:** state changes only through the command bus\n", "")
|
||||
result = lint_spine.lint(text)
|
||||
assert any(f["category"] == "ad_fields" and "rule" in f["detail"] for f in result["findings"])
|
||||
|
||||
|
||||
def test_unpinned_dep_caught():
|
||||
text = CLEAN.replace("| fastapi | 0.115 |", "| fastapi | |")
|
||||
result = lint_spine.lint(text)
|
||||
assert "version_pin" in cats(result)
|
||||
|
||||
|
||||
def test_placeholder_version_caught():
|
||||
text = CLEAN.replace("| fastapi | 0.115 |", "| fastapi | {pin} |")
|
||||
result = lint_spine.lint(text)
|
||||
assert any(f["category"] == "version_pin" and "fastapi" in f["detail"] for f in result["findings"])
|
||||
|
||||
|
||||
def test_no_stack_section_ok():
|
||||
text = CLEAN.split("## Stack")[0]
|
||||
result = lint_spine.lint(text)
|
||||
assert "version_pin" not in cats(result)
|
||||
|
||||
|
||||
def test_stack_skeleton_row_not_version_pinned():
|
||||
# a leftover {token} name is the placeholder pass's job, not a double-reported version_pin
|
||||
text = CLEAN.replace("| fastapi | 0.115 |", "| {language / framework} | {pinned version} |")
|
||||
result = lint_spine.lint(text)
|
||||
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)
|
||||
assert "version_pin" not in cats(result)
|
||||
|
||||
|
||||
def test_template_token_is_low_severity():
|
||||
# a bare {token} can be legitimate brace prose; it is flagged, but low (not high) so the
|
||||
# mechanical pass stays near-zero false-positive
|
||||
text = CLEAN.replace("single write path", "{decision}")
|
||||
result = lint_spine.lint(text)
|
||||
toks = [f for f in result["findings"] if f["category"] == "placeholder" and "template token" in f["detail"]]
|
||||
assert toks and all(f["severity"] == "low" for f in toks)
|
||||
|
||||
|
||||
def test_no_frontmatter_body_still_scanned():
|
||||
text = "## Invariants\n\n### AD-1 — x\n\n- **Binds:** all\n- **Prevents:** drift\n- **Rule:** TBD\n"
|
||||
result = lint_spine.lint(text)
|
||||
assert "placeholder" in cats(result) # TBD caught even with no frontmatter
|
||||
|
||||
|
||||
def test_frontmatter_value_with_dashes_not_truncated():
|
||||
# a value containing '---' must not be read as the closing fence (line-exact close)
|
||||
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)
|
||||
assert any(f["category"] == "version_pin" for f in result["findings"]) # read past the inline ---
|
||||
|
||||
|
||||
def test_ad_heading_in_fence_not_counted():
|
||||
text = (
|
||||
"---\nname: 'x'\n---\n\n"
|
||||
"### AD-1 — real\n\n- **Binds:** all\n- **Prevents:** drift\n- **Rule:** do x\n\n"
|
||||
"## Docs\n\n```text\n### AD-2 — illustrative only, no fields\n```\n"
|
||||
)
|
||||
result = lint_spine.lint(text)
|
||||
assert result["ok"] is True # the fenced AD-2 is not a live AD → no ad_fields/ad_id finding
|
||||
|
||||
|
||||
def test_stack_table_flags_only_the_unpinned_row():
|
||||
text = ("---\nname: 'x'\n---\n\n## Stack\n\n| Name | Version |\n| --- | --- |\n"
|
||||
"| fastapi | 0.115 |\n| redis | |\n")
|
||||
result = lint_spine.lint(text)
|
||||
pins = [f for f in result["findings"] if f["category"] == "version_pin"]
|
||||
assert len(pins) == 1 and "redis" in pins[0]["detail"]
|
||||
|
||||
|
||||
def test_stack_table_all_pinned_ok():
|
||||
text = ("---\nname: 'x'\n---\n\n## Stack\n\n| Name | Version |\n| --- | --- |\n"
|
||||
"| fastapi | 0.115 |\n")
|
||||
result = lint_spine.lint(text)
|
||||
assert "version_pin" not in cats(result)
|
||||
|
||||
|
||||
def test_fenced_stack_rows_not_parsed():
|
||||
# an illustrative fenced table under ## Stack must not be read as live rows (fences are
|
||||
# blanked first, like every other pass) — a blank-version row inside a fence is not a finding
|
||||
text = ("---\nname: 'x'\n---\n\n## Stack\n\n| Name | Version |\n| --- | --- |\n"
|
||||
"| fastapi | 0.115 |\n\n```text\n| example | |\n```\n")
|
||||
result = lint_spine.lint(text)
|
||||
assert "version_pin" not in cats(result)
|
||||
|
||||
|
||||
def test_fenced_stack_heading_not_live():
|
||||
# a `## Stack` heading shown inside a code fence is not the live Stack section
|
||||
text = ("---\nname: 'x'\n---\n\n## Docs\n\n```md\n## Stack\n\n| foo | |\n```\n")
|
||||
result = lint_spine.lint(text)
|
||||
assert "version_pin" not in cats(result)
|
||||
|
||||
|
||||
def test_renamed_stack_heading_still_scanned():
|
||||
# the heading match is word-boundary, so a varied `## Stack` heading still counts
|
||||
text = ("---\nname: 'x'\n---\n\n## Stack & Versions\n\n| Name | Version |\n| --- | --- |\n"
|
||||
"| redis | |\n")
|
||||
result = lint_spine.lint(text)
|
||||
pins = [f for f in result["findings"] if f["category"] == "version_pin"]
|
||||
assert len(pins) == 1 and "redis" in pins[0]["detail"]
|
||||
|
||||
|
||||
def test_reordered_columns_pair_name_to_version():
|
||||
# Version-then-Name header: the unpinned row must still be flagged by its real name
|
||||
text = ("---\nname: 'x'\n---\n\n## Stack\n\n| Version | Name |\n| --- | --- |\n"
|
||||
"| 0.115 | fastapi |\n| | redis |\n")
|
||||
result = lint_spine.lint(text)
|
||||
pins = [f for f in result["findings"] if f["category"] == "version_pin"]
|
||||
assert len(pins) == 1 and "redis" in pins[0]["detail"]
|
||||
|
||||
|
||||
def test_placeholder_line_number_is_absolute():
|
||||
# a TBD after a multi-line fence reports its real file line (fence blanked, not collapsed)
|
||||
text = (
|
||||
"---\nname: 'x'\n---\n\n"
|
||||
"## A\n\n"
|
||||
"```text\nf1\nf2\nf3\n```\n\n"
|
||||
"TBD here\n"
|
||||
)
|
||||
result = lint_spine.lint(text)
|
||||
ph = next(f for f in result["findings"] if "TBD" in f["detail"])
|
||||
n = int(re.search(r"line (\d+)", ph["location"]).group(1))
|
||||
assert n == 13
|
||||
|
||||
|
||||
def test_missing_spine_file_reports_error(tmp_path, capsys):
|
||||
rc = lint_spine.main(["--workspace", str(tmp_path)])
|
||||
out = json.loads(capsys.readouterr().out)
|
||||
assert rc == 0 and out["ok"] is False and "not found" in out["error"]
|
||||
|
||||
|
||||
def test_frontmatter_unfilled_token_caught():
|
||||
# an unfilled {scope}/{paradigm}/{date} in frontmatter is part of the contract and must lint
|
||||
text = "---\nname: 'x'\nscope: '{what this spine governs}'\n---\n\n## Invariants\n"
|
||||
result = lint_spine.lint(text)
|
||||
fm = [f for f in result["findings"] if f["category"] == "placeholder" and "frontmatter" in f["detail"]]
|
||||
assert fm and any("template token" in f["detail"] for f in fm)
|
||||
|
||||
|
||||
def test_frontmatter_tbd_caught():
|
||||
text = "---\nname: 'x'\nstatus: TBD\n---\n\n## Invariants\n"
|
||||
result = lint_spine.lint(text)
|
||||
assert any(f["category"] == "placeholder" and "frontmatter" in f["detail"] and "TBD" in f["detail"]
|
||||
for f in result["findings"])
|
||||
|
||||
|
||||
def test_unreadable_spine_returns_error_not_crash(tmp_path, capsys):
|
||||
# a spine that exists but can't be UTF-8 decoded must yield error JSON + exit 0, not a traceback
|
||||
(tmp_path / lint_spine.SPINE).write_bytes(b"\xff\xfe bad bytes not utf-8")
|
||||
rc = lint_spine.main(["--workspace", str(tmp_path)])
|
||||
out = json.loads(capsys.readouterr().out)
|
||||
assert rc == 0 and out["ok"] is False and "could not read" in out["error"]
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(pytest.main([__file__, "-q"]))
|
||||
Reference in New Issue
Block a user