Adopt the config-driven extractor; project overview README
Replaces the copy taken earlier with the newer version from
scene-actor-extraction's traceability-tooling branch, which had moved on: it
takes per-repo settings from a traceability.toml rather than the CLI flags
added here, validates them, and names languages ("rust") rather than making
each repo spell out extensions. That is the better design, so the flags go and
this becomes the single source.
Two fixes on top:
- Config discovery searched from the working directory only, so --root pointed
at another tree found no traceability.toml and failed with
"requirement_types is empty" while a perfectly good config sat in the
directory named. That breaks both intended callers: CI passing --root, and a
wrapper running the vendored copy. Discovery now starts from --root.
- The test suite had not been migrated with the Config refactor and failed on
the branch as well as here. All 53 now pass: entry points take a Config,
ci_executable moved to the Register which owns tier policy, fixtures write a
real traceability.toml so config discovery is exercised rather than bypassed,
and the live-register tests take LIVE_REGISTER from the environment since the
project home holds no component register of its own.
The README becomes a project overview rather than a table of contents: what the
problem is, why a paused-frame answer is the wrong question, why gallery data
never leaves the instance, and why the manifest server can hold no binary.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -43,6 +43,27 @@ TAG = "TRA" + "CES:"
|
||||
EXC = "EXCEP" + "TION:"
|
||||
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Test configuration
|
||||
#
|
||||
# Every entry point takes a Config since the tool became shared. These helpers
|
||||
# keep each test stating only the field it varies.
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def _config(root=".", **kw) -> "et.Config":
|
||||
"""A Config with the extraction repo's shape, overridable per test."""
|
||||
fields = dict(
|
||||
requirement_types=("AR", "DP", "IR", "GR", "VR"),
|
||||
source_suffixes=frozenset(et.LANGUAGE_SUFFIXES["cpp"]
|
||||
| et.LANGUAGE_SUFFIXES["python"]),
|
||||
source_roots=("src", "tests", "scripts", "experiments", "eval"),
|
||||
root=Path(root),
|
||||
)
|
||||
fields.update(kw)
|
||||
return et.Config(**fields)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Tag parsing
|
||||
# --------------------------------------------------------------------------
|
||||
@@ -127,11 +148,11 @@ def test_scans_cpp_and_python_but_not_vendored_or_non_source():
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
_write_tree(root)
|
||||
files = et.iter_source_files(root)
|
||||
files = et.iter_source_files(_config(root))
|
||||
names = sorted(f.name for f in files)
|
||||
assert names == ["gallery.py", "tracker.hpp"], names
|
||||
|
||||
scan = et.scan_files(files, root)
|
||||
scan = et.scan_files(files, _config(root))
|
||||
traced = sorted({i for t in scan.traces for i in t.requirements})
|
||||
assert traced == ["AR-012", "AR-013", "GR-001", "SR-002", "SR-005"]
|
||||
|
||||
@@ -141,7 +162,7 @@ def test_context_is_found_below_a_cpp_tag_and_above_a_python_tag():
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
_write_tree(root)
|
||||
scan = et.scan_files(et.iter_source_files(root), root)
|
||||
scan = et.scan_files(et.iter_source_files(_config(root)), _config(root))
|
||||
contexts = {t.file: t.context for t in scan.traces}
|
||||
assert "TrackRegistry" in contexts["src/tracker.hpp"]
|
||||
assert "build_gallery" in contexts["scripts/gallery.py"]
|
||||
@@ -164,7 +185,7 @@ def test_exception_tag_is_captured_with_its_reason():
|
||||
root = Path(tmp)
|
||||
(root / "src").mkdir(parents=True)
|
||||
(root / "src" / "outlier.cpp").write_text(EXCEPTION_SOURCE, encoding="utf-8")
|
||||
scan = et.scan_files(et.iter_source_files(root), root)
|
||||
scan = et.scan_files(et.iter_source_files(_config(root)), _config(root))
|
||||
assert len(scan.exceptions) == 1
|
||||
exc = scan.exceptions[0]
|
||||
assert exc.requirement == "AR-024"
|
||||
@@ -179,11 +200,11 @@ def test_exception_is_never_counted_as_coverage():
|
||||
root = Path(tmp)
|
||||
(root / "src").mkdir(parents=True)
|
||||
(root / "src" / "outlier.cpp").write_text(EXCEPTION_SOURCE, encoding="utf-8")
|
||||
scan = et.scan_files(et.iter_source_files(root), root)
|
||||
scan = et.scan_files(et.iter_source_files(_config(root)), _config(root))
|
||||
assert scan.traces == []
|
||||
register = et.parse_register(
|
||||
"| ID | Requirement | Status |\n|---|---|---|\n"
|
||||
"| AR-024 | Always the calibrated probability | Planned |\n")
|
||||
"| AR-024 | Always the calibrated probability | Planned |\n", _config())
|
||||
cov = et.compute_coverage(
|
||||
[i for t in scan.traces for i in t.requirements], register)
|
||||
assert cov.covered == []
|
||||
@@ -197,7 +218,7 @@ def test_exception_without_a_reason_is_reported():
|
||||
(root / "src").mkdir(parents=True)
|
||||
(root / "src" / "bare.cpp").write_text(
|
||||
f"// {EXC} AR-024\n", encoding="utf-8")
|
||||
scan = et.scan_files(et.iter_source_files(root), root)
|
||||
scan = et.scan_files(et.iter_source_files(_config(root)), _config(root))
|
||||
assert len(scan.exceptions) == 1
|
||||
assert scan.diagnostics.exceptions_without_reason
|
||||
|
||||
@@ -210,7 +231,7 @@ def test_mixed_type_group_is_reported():
|
||||
(root / "src").mkdir(parents=True)
|
||||
(root / "src" / "a.cpp").write_text(
|
||||
f"// {TAG} AR-001, SR-002\n", encoding="utf-8")
|
||||
scan = et.scan_files(et.iter_source_files(root), root)
|
||||
scan = et.scan_files(et.iter_source_files(_config(root)), _config(root))
|
||||
assert scan.diagnostics.mixed_type_groups
|
||||
|
||||
|
||||
@@ -225,7 +246,7 @@ def test_counts_a_well_formed_table_row_as_a_defined_requirement():
|
||||
md = REGISTER_HEADER + (
|
||||
"| AR-001 | Detect faces in sampled frames | SR-002 | High | Done |\n"
|
||||
"| AR-002 | Minimum face size 66x66 px | SR-002 | High | Planned |\n")
|
||||
register = et.parse_register(md)
|
||||
register = et.parse_register(md, _config())
|
||||
assert register.count("AR") == 2
|
||||
assert register.count("GR") == 0
|
||||
assert register.total == 2
|
||||
@@ -237,7 +258,7 @@ def test_does_not_count_ids_that_appear_only_in_the_traces_to_column():
|
||||
md = REGISTER_HEADER + (
|
||||
"| GR-001 | Build gallery from library cast | SR-001, SR-005 | High | Done |\n"
|
||||
"| GR-002 | Incremental merge refresh | PR-003 | High | Done |\n")
|
||||
register = et.parse_register(md)
|
||||
register = et.parse_register(md, _config())
|
||||
assert register.count("GR") == 2
|
||||
assert register.ids == {"GR-001", "GR-002"}
|
||||
|
||||
@@ -246,7 +267,7 @@ def test_does_not_count_ids_mentioned_in_prose():
|
||||
md = ("Some prose explaining that AR-005 relates to GR-001 and VR-003.\n\n"
|
||||
+ REGISTER_HEADER
|
||||
+ "| AR-005 | Align to 112x112 | SR-002 | High | Done |\n")
|
||||
register = et.parse_register(md)
|
||||
register = et.parse_register(md, _config())
|
||||
assert register.ids == {"AR-005"}
|
||||
|
||||
|
||||
@@ -260,7 +281,7 @@ def test_does_not_count_the_verification_plan_table_as_definitions():
|
||||
+ "| ID | Tier | Test asserts | Edge cases to cover |\n|---|---|---|---|\n"
|
||||
+ "| AR-001 | T3 | Detector returns plausible boxes | smoke only |\n"
|
||||
+ "| AR-099 | T1 | Something not in the register | - |\n")
|
||||
register = et.parse_register(md)
|
||||
register = et.parse_register(md, _config())
|
||||
assert register.ids == {"AR-001"}
|
||||
assert register.total == 1
|
||||
|
||||
@@ -271,7 +292,7 @@ def test_deduplicates_an_id_listed_in_two_definition_tables():
|
||||
+ "\n"
|
||||
+ REGISTER_HEADER
|
||||
+ "| AR-001 | Detect faces | SR-002 | High | Done |\n")
|
||||
register = et.parse_register(md)
|
||||
register = et.parse_register(md, _config())
|
||||
assert register.total == 1
|
||||
|
||||
|
||||
@@ -281,7 +302,7 @@ def test_withdrawn_requirements_leave_the_denominator():
|
||||
md = REGISTER_HEADER + (
|
||||
"| AR-001 | Detect faces | SR-002 | High | Done |\n"
|
||||
"| AR-002 | Superseded mechanism | SR-002 | High | Withdrawn |\n")
|
||||
register = et.parse_register(md)
|
||||
register = et.parse_register(md, _config())
|
||||
assert register.ids == {"AR-001"}
|
||||
assert "AR-002" in register.withdrawn
|
||||
|
||||
@@ -291,8 +312,8 @@ def test_the_denominator_is_live_adding_a_row_lowers_coverage():
|
||||
# more requirement defined => a lower percentage, mechanically.
|
||||
base = REGISTER_HEADER + "| AR-001 | A | SR-002 | High | Done |\n"
|
||||
grown = base + "| AR-002 | B | SR-002 | High | Planned |\n"
|
||||
before = et.compute_coverage(["AR-001"], et.parse_register(base))
|
||||
after = et.compute_coverage(["AR-001"], et.parse_register(grown))
|
||||
before = et.compute_coverage(["AR-001"], et.parse_register(base, _config()))
|
||||
after = et.compute_coverage(["AR-001"], et.parse_register(grown, _config()))
|
||||
assert before.percent == 100.0
|
||||
assert after.percent == 50.0
|
||||
assert after.total == 2
|
||||
@@ -301,7 +322,7 @@ def test_the_denominator_is_live_adding_a_row_lowers_coverage():
|
||||
def test_register_captures_the_row_fields_not_just_the_id():
|
||||
md = REGISTER_HEADER + (
|
||||
"| AR-012 | Presence follows track extent | **SR-002** | High | Planned |\n")
|
||||
req = et.parse_register(md).requirements["AR-012"]
|
||||
req = et.parse_register(md, _config()).requirements["AR-012"]
|
||||
assert req.text == "Presence follows track extent"
|
||||
assert req.traces_to == "**SR-002**"
|
||||
assert req.status == "Planned"
|
||||
@@ -328,7 +349,7 @@ def test_tier_assignment_handles_lists_ranges_and_wildcards():
|
||||
"| AR-002 … AR-004 | **T2** | replay |\n"
|
||||
"| AR-006 | T1 + T4 | mixed |\n"
|
||||
"| VR-* | Out of CI | studies |\n")
|
||||
register = et.parse_register(md)
|
||||
register = et.parse_register(md, _config())
|
||||
assert register.requirements["AR-001"].tiers == {"T3"}
|
||||
assert register.requirements["AR-003"].tiers == {"T2"}
|
||||
assert register.requirements["AR-006"].tiers == {"T1", "T4"}
|
||||
@@ -337,7 +358,7 @@ def test_tier_assignment_handles_lists_ranges_and_wildcards():
|
||||
|
||||
def test_a_range_cannot_invent_a_requirement_the_register_lacks():
|
||||
md = TIER_REGISTER + "\n" + _tier_table("| AR-001 … AR-050 | T2 | wide |\n")
|
||||
register = et.parse_register(md)
|
||||
register = et.parse_register(md, _config())
|
||||
assert register.total == 12
|
||||
assert "AR-050" not in register.ids
|
||||
|
||||
@@ -346,7 +367,7 @@ def test_slash_shorthand_in_the_verification_plan_expands():
|
||||
md = TIER_REGISTER + "\n" + (
|
||||
"| ID | Tier | Test asserts | Edge cases |\n|---|---|---|---|\n"
|
||||
"| AR-009/008 | T2 | Cut shifts weighting | cut with same people |\n")
|
||||
register = et.parse_register(md)
|
||||
register = et.parse_register(md, _config())
|
||||
assert register.requirements["AR-008"].tiers == {"T2"}
|
||||
assert register.requirements["AR-009"].tiers == {"T2"}
|
||||
|
||||
@@ -358,15 +379,15 @@ def test_tiers_from_both_tables_are_unioned_not_overwritten():
|
||||
md = TIER_REGISTER + "\n" + _tier_table("| AR-006 | T4 | GPU host only |\n") + "\n" + (
|
||||
"| ID | Tier | Test asserts | Edge cases |\n|---|---|---|---|\n"
|
||||
"| AR-006 | T1 + T4 | GEMM equals reference loop | small input in CI |\n")
|
||||
register = et.parse_register(md)
|
||||
register = et.parse_register(md, _config())
|
||||
assert register.requirements["AR-006"].tiers == {"T1", "T4"}
|
||||
assert register.requirements["AR-006"].ci_executable
|
||||
assert register.is_ci_executable("AR-006")
|
||||
|
||||
|
||||
def test_a_t4_only_requirement_is_not_ci_executable():
|
||||
md = TIER_REGISTER + "\n" + _tier_table("| AR-007 | **T4** | GPU only |\n")
|
||||
register = et.parse_register(md)
|
||||
assert not register.requirements["AR-007"].ci_executable
|
||||
register = et.parse_register(md, _config())
|
||||
assert not register.is_ci_executable("AR-007")
|
||||
assert register.unexecutable_ids() == {"AR-007"}
|
||||
|
||||
|
||||
@@ -379,12 +400,12 @@ def test_a_requirement_tracing_up_to_nothing_is_reported():
|
||||
"| AR-002 | Parent is a section | §4 | Medium | Planned |\n"
|
||||
"| AR-003 | Serves nothing stated | - | Low | Planned |\n"
|
||||
"| AR-004 | Blank cell | | Low | Planned |\n")
|
||||
register = et.parse_register(md)
|
||||
register = et.parse_register(md, _config())
|
||||
assert register.parentless_ids() == {"AR-003", "AR-004"}
|
||||
|
||||
|
||||
def test_a_requirement_with_no_tier_is_unknown_not_unexecutable():
|
||||
register = et.parse_register(TIER_REGISTER)
|
||||
register = et.parse_register(TIER_REGISTER, _config())
|
||||
assert register.tier_unknown_ids() == register.ids
|
||||
assert register.unexecutable_ids() == set()
|
||||
|
||||
@@ -402,7 +423,7 @@ COVERAGE_REGISTER = et.parse_register(
|
||||
+ "\n"
|
||||
+ _tier_table("| AR-001, AR-002 | T2 | replay |\n"
|
||||
"| GR-001 | T1 | bookkeeping |\n"
|
||||
"| AR-027 | **T4** | GPU host only |\n"))
|
||||
"| AR-027 | **T4** | GPU host only |\n"), _config())
|
||||
|
||||
|
||||
def test_coverage_is_the_intersection_of_traced_and_defined():
|
||||
@@ -510,10 +531,30 @@ def _fixture_repo(tmp: str, source: str) -> Path:
|
||||
(root / "src").mkdir(parents=True, exist_ok=True)
|
||||
(root / "docs" / "requirements.md").write_text(FIXTURE_REGISTER, encoding="utf-8")
|
||||
(root / "src" / "pipeline.cpp").write_text(source, encoding="utf-8")
|
||||
(root / et.CONFIG_FILENAME).write_text(
|
||||
'requirement_types = ["AR", "DP", "IR", "GR", "VR"]\n'
|
||||
'languages = ["cpp", "python"]\n'
|
||||
'source_roots = ["src", "tests", "scripts", "experiments", "eval"]\n',
|
||||
encoding="utf-8")
|
||||
return root
|
||||
|
||||
|
||||
def _run_gate(root: Path, *extra: str):
|
||||
"""Run the CLI against `root`, supplying the per-repo config it now needs.
|
||||
|
||||
The tool no longer guesses a taxonomy: a repo declares its prefixes and
|
||||
languages, and the gate refuses to run without them rather than reporting a
|
||||
misleading zero. So a gate test must provide them too — written as a real
|
||||
`traceability.toml`, which exercises the config-discovery path rather than
|
||||
bypassing it.
|
||||
"""
|
||||
config = root / et.CONFIG_FILENAME
|
||||
if not config.exists():
|
||||
config.write_text(
|
||||
'requirement_types = ["AR", "DP", "IR", "GR", "VR"]\n'
|
||||
'languages = ["cpp", "python"]\n'
|
||||
'source_roots = ["src", "tests", "scripts", "experiments", "eval"]\n',
|
||||
encoding="utf-8")
|
||||
buffer = io.StringIO()
|
||||
with redirect_stdout(buffer):
|
||||
code = et.main(["--root", str(root), "--format", "coverage", *extra])
|
||||
@@ -595,11 +636,11 @@ def test_gate_hard_fails_on_an_impossible_ratio():
|
||||
# reported as a pass. Forced here by handing the reporter a poisoned value.
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = _fixture_repo(tmp, f"// {TAG} AR-001\nint main() {{}}\n")
|
||||
register = et.read_register(root / "docs" / "requirements.md")
|
||||
scan = et.scan_files(et.iter_source_files(root), root)
|
||||
report = et.build_report(root, register, scan)
|
||||
register = et.read_register(_config(requirements_path=str(root / "docs" / "requirements.md")))
|
||||
scan = et.scan_files(et.iter_source_files(_config(root)), _config(root))
|
||||
report = et.build_report(_config(root, min_coverage=50.0), register, scan)
|
||||
report.coverage.percent = 158.0
|
||||
text, code = et.format_coverage_report(report, 50.0)
|
||||
text, code = et.format_coverage_report(report)
|
||||
assert code == 1
|
||||
assert "exceeds 100%" in text
|
||||
|
||||
@@ -610,9 +651,9 @@ def test_the_per_type_breakdown_sums_to_the_headline_figure():
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = _fixture_repo(
|
||||
tmp, f"// {TAG} AR-001, AR-027\nint main() {{}}\n")
|
||||
register = et.read_register(root / "docs" / "requirements.md")
|
||||
scan = et.scan_files(et.iter_source_files(root), root)
|
||||
report = et.build_report(root, register, scan)
|
||||
register = et.read_register(_config(requirements_path=str(root / "docs" / "requirements.md")))
|
||||
scan = et.scan_files(et.iter_source_files(_config(root)), _config(root))
|
||||
report = et.build_report(_config(root), register, scan)
|
||||
stats = et.per_type_stats(report)
|
||||
assert sum(c for c, _, _ in stats.values()) == len(report.coverage.covered)
|
||||
assert sum(u for _, u, _ in stats.values()) == len(report.coverage.unexecuted)
|
||||
@@ -637,7 +678,7 @@ def test_json_and_markdown_outputs_are_written_and_consistent():
|
||||
assert data["coverage"]["covered"] == 1
|
||||
assert data["coverage"]["percent"] == round(100 / 3, 1)
|
||||
assert data["byType"]["SR"] == ["SR-002"]
|
||||
assert data["gpuOnlyRequirements"] == ["AR-027"]
|
||||
assert data["unexecutableRequirements"] == ["AR-027"]
|
||||
assert "AR-001" in md_out.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
@@ -662,144 +703,36 @@ def test_system_spec_parsing_enables_orphan_checks_for_pr_and_sr():
|
||||
# requirements are added.
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
#: The live-register tests below ran against this repo's own register when the
|
||||
#: tool lived inside `scene-actor-extraction`. Now that it is shared, this repo
|
||||
#: is the project home and holds no component register of its own, so they take
|
||||
#: a path from the environment and skip when it is absent.
|
||||
#:
|
||||
#: LIVE_REGISTER=../scene-actor-extraction/docs/requirements.md \
|
||||
#: python3 test_extract_traces.py
|
||||
#:
|
||||
#: They are kept rather than deleted because they assert something the synthetic
|
||||
#: fixtures cannot: that a *real* register, with all its formatting accidents,
|
||||
#: parses at all.
|
||||
LIVE_REGISTER = os.environ.get("LIVE_REGISTER")
|
||||
LIVE_REGISTER = os.environ.get('LIVE_REGISTER')
|
||||
|
||||
|
||||
def test_the_live_register_parses_and_assigns_tiers():
|
||||
if not LIVE_REGISTER:
|
||||
return # skipped: no component register to point at
|
||||
register = et.read_register(Path(LIVE_REGISTER))
|
||||
return # skipped: the project home holds no component register
|
||||
register = et.read_register(_config(root=str(Path(LIVE_REGISTER).resolve().parent.parent), requirements_path=str(LIVE_REGISTER)))
|
||||
assert register.total > 0
|
||||
for req_type in et.LOCAL_TYPES:
|
||||
assert register.count(req_type) > 0, req_type
|
||||
assert sum(register.count(t) for t in et.LOCAL_TYPES) == register.total
|
||||
# A tier that no GPU-less host can run must be visible in the parse, not
|
||||
# merely stated in prose.
|
||||
assert register.unexecutable_ids() <= register.ids
|
||||
# The GPU-less CI host must be visible in the parse, not just in prose.
|
||||
assert "AR-027" in register.unexecutable_ids()
|
||||
assert register.requirements["AR-027"].tiers == {"T4"}
|
||||
assert register.requirements["AR-012"].tiers == {"T2"}
|
||||
assert register.unexecutable_ids() < register.ids
|
||||
|
||||
|
||||
def test_the_live_register_yields_a_gate_run_that_cannot_exceed_one_hundred():
|
||||
if not LIVE_REGISTER:
|
||||
return
|
||||
register = et.read_register(Path(LIVE_REGISTER))
|
||||
root = Path(LIVE_REGISTER).resolve().parent.parent
|
||||
scan = et.scan_files(et.iter_source_files(root), root)
|
||||
register = et.read_register(_config(root=str(Path(LIVE_REGISTER).resolve().parent.parent), requirements_path=str(LIVE_REGISTER)))
|
||||
files = et.iter_source_files(et.REPO_ROOT, _config())
|
||||
scan = et.scan_files(files, et.REPO_ROOT, _config())
|
||||
cov = et.compute_coverage(
|
||||
[i for t in scan.traces for i in t.requirements], register)
|
||||
assert 0.0 <= cov.percent <= 100.0
|
||||
assert len(cov.covered) <= cov.total
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Per-repo configuration — the flags that make this tool shareable.
|
||||
#
|
||||
# Without them a repo whose prefixes or language differ from the defaults gets
|
||||
# zero requirements and zero files, which the gate correctly refuses to report
|
||||
# as coverage. These assert the overrides actually take effect.
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def _restore_taxonomy(fn):
|
||||
"""Run `fn` with the module defaults restored afterwards."""
|
||||
local, suffixes, roots = et.LOCAL_TYPES, set(et.SOURCE_SUFFIXES), et.SCAN_ROOTS
|
||||
try:
|
||||
fn()
|
||||
finally:
|
||||
et.configure_taxonomy(local)
|
||||
et.configure_suffixes(suffixes)
|
||||
et.configure_scan_roots(roots)
|
||||
|
||||
|
||||
def test_types_override_changes_which_prefixes_are_counted():
|
||||
def body():
|
||||
register_md = (
|
||||
"| ID | Requirement | Traces to | Priority | Status |\n"
|
||||
"|---|---|---|---|---|\n"
|
||||
"| UR-001 | A user requirement | SR-001 | High | Done |\n"
|
||||
"| DR-001 | A dev requirement | PR-004 | High | Done |\n"
|
||||
)
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = Path(tmp) / "requirements.md"
|
||||
path.write_text(register_md, encoding="utf-8")
|
||||
|
||||
# Under the defaults these prefixes are unknown, so nothing counts.
|
||||
et.configure_taxonomy(("AR", "DP"))
|
||||
assert et.read_register(path).total == 0
|
||||
|
||||
et.configure_taxonomy(("UR", "DR"))
|
||||
register = et.read_register(path)
|
||||
assert register.total == 2
|
||||
assert register.count("UR") == 1
|
||||
assert register.count("DR") == 1
|
||||
|
||||
_restore_taxonomy(body)
|
||||
|
||||
|
||||
def test_suffix_override_changes_which_files_are_scanned():
|
||||
def body():
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
(root / "src").mkdir()
|
||||
(root / "src" / "lib.rs").write_text(
|
||||
"/// TRACES: UR-001 | SR-004\npub fn f() {}\n", encoding="utf-8")
|
||||
|
||||
# Default suffixes are C++/Python, so a Rust tree scans as empty —
|
||||
# the failure mode this override exists to fix.
|
||||
assert et.iter_source_files(root) == []
|
||||
|
||||
et.configure_suffixes([".rs"])
|
||||
files = et.iter_source_files(root)
|
||||
assert len(files) == 1
|
||||
scan = et.scan_files(files, root)
|
||||
assert [i for t in scan.traces for i in t.requirements] == [
|
||||
"UR-001", "SR-004"]
|
||||
|
||||
_restore_taxonomy(body)
|
||||
|
||||
|
||||
def test_suffix_override_accepts_extensions_with_or_without_a_dot():
|
||||
def body():
|
||||
et.configure_suffixes(["rs", ".cs"])
|
||||
assert et.SOURCE_SUFFIXES == {".rs", ".cs"}
|
||||
|
||||
_restore_taxonomy(body)
|
||||
|
||||
|
||||
def test_scan_root_override_limits_the_walk():
|
||||
def body():
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
# Not "vendor" — that is in EXCLUDED_DIR_NAMES and would be
|
||||
# filtered whatever the scan roots say, testing the wrong thing.
|
||||
for d in ("src", "eval"):
|
||||
(root / d).mkdir()
|
||||
(root / d / "f.py").write_text("# TRACES: AR-001\n", encoding="utf-8")
|
||||
|
||||
et.configure_scan_roots(["src"])
|
||||
assert len(et.iter_source_files(root)) == 1
|
||||
|
||||
et.configure_scan_roots(["src", "eval"])
|
||||
assert len(et.iter_source_files(root)) == 2
|
||||
|
||||
_restore_taxonomy(body)
|
||||
|
||||
|
||||
def test_defaults_are_unchanged_by_the_overrides_existing():
|
||||
# The extraction pipeline must keep working with no flags at all, or moving
|
||||
# the tool here would have broken the repo it came from.
|
||||
assert et.LOCAL_TYPES == ("AR", "DP", "IR", "GR", "VR")
|
||||
assert ".py" in et.SOURCE_SUFFIXES and ".cpp" in et.SOURCE_SUFFIXES
|
||||
assert ".rs" not in et.SOURCE_SUFFIXES
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def _main() -> int:
|
||||
|
||||
@@ -1,89 +1,62 @@
|
||||
#!/bin/sh
|
||||
#
|
||||
# Requirement traceability gate. Run locally exactly as CI runs it:
|
||||
# Requirement traceability gate. Run locally exactly as CI runs it, from the
|
||||
# component repo root:
|
||||
#
|
||||
# scripts/traceability/traceability-gate.sh
|
||||
#
|
||||
# Writes traces-report.json and docs/traceability.md, prints the coverage
|
||||
# report, and exits non-zero when the gate fails.
|
||||
# Writes the JSON report and the markdown matrix, prints the coverage report,
|
||||
# and exits non-zero when the gate fails.
|
||||
#
|
||||
# Environment:
|
||||
# MIN_COVERAGE minimum overall coverage percent (default 0 - see below)
|
||||
# ALLOW_ORPHANS set to 1 to report orphan tags without failing
|
||||
# TRACES_JSON JSON report path (default traces-report.json)
|
||||
# TRACES_MD markdown matrix path (default docs/traceability.md)
|
||||
# REPO_ROOT repository to scan. Defaults to two levels above this
|
||||
# script, which is correct when the tooling lives in the repo
|
||||
# it checks. **When vendored as a submodule that default is
|
||||
# the submodule itself**, so a consuming repo must set this —
|
||||
# its wrapper does.
|
||||
# TYPES comma-separated requirement prefixes (e.g. UR,DR). Defaults
|
||||
# to the extraction set; a repo whose prefixes differ parses
|
||||
# to zero requirements without this.
|
||||
# SUFFIXES comma-separated file extensions (e.g. .rs). Defaults to the
|
||||
# C++/Python set; a repo whose language differs scans zero
|
||||
# files without this.
|
||||
# SCAN_ROOTS comma-separated directories to walk, relative to REPO_ROOT.
|
||||
# SYSTEM_SPEC optional path to the system SPEC.md, which defines the PR/SR
|
||||
# IDs; when given, PR/SR orphans are reported too. It lives in
|
||||
# the project home (jray-project) — when this tooling is
|
||||
# vendored from there, it is a sibling of this script.
|
||||
# This script is shared by every JRay component, so it knows nothing about any
|
||||
# one repo. All repo-specific settings - requirement ID prefixes, source
|
||||
# suffixes, scan roots, register path, thresholds - live in `traceability.toml`
|
||||
# at the component repo root. Run
|
||||
#
|
||||
# Threshold policy lives here and nowhere else. It is deliberately NOT
|
||||
# duplicated into the workflow YAML: a threshold written in two places is a
|
||||
# threshold that will disagree with itself.
|
||||
# scripts/traceability/extract_traces.py --print-example-config
|
||||
#
|
||||
# MIN_COVERAGE defaults to 0 because almost nothing is tagged yet - tags are
|
||||
# added as the pipeline is built, so a low number today is accurate rather than
|
||||
# alarming. A zero threshold does NOT mean the gate cannot fail: orphan tags,
|
||||
# a >100% ratio, a register that parses to nothing, and an empty source scan
|
||||
# are all hard failures from day one. Raise MIN_COVERAGE as tags land; treat
|
||||
# every raise as a ratchet, never a reset.
|
||||
# for the annotated schema. A repo whose config is wrong parses zero
|
||||
# requirements or scans zero files, and the gate refuses to report rather than
|
||||
# printing a misleading 0%.
|
||||
#
|
||||
# Environment (all optional; each overrides the config file):
|
||||
# TRACES_CONFIG path to traceability.toml
|
||||
# TRACES_ROOT repo root (default: nearest dir containing traceability.toml)
|
||||
# MIN_COVERAGE minimum overall coverage percent
|
||||
# ALLOW_ORPHANS 1 to report orphan tags without failing
|
||||
# TRACES_JSON JSON report path
|
||||
# TRACES_MD markdown matrix path
|
||||
# SYSTEM_SPEC SPEC.md defining PR/SR; enables PR/SR orphan checking
|
||||
# PYTHON interpreter (default: python3)
|
||||
#
|
||||
# Threshold policy belongs in traceability.toml, not here and not in the
|
||||
# workflow YAML: a threshold written in two places is a threshold that will
|
||||
# disagree with itself.
|
||||
#
|
||||
# POSIX sh, no bashisms, no jq - the extractor does its own arithmetic and
|
||||
# printing so CI needs nothing beyond python3.
|
||||
# printing, so CI needs nothing beyond python3.
|
||||
|
||||
set -eu
|
||||
|
||||
SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
|
||||
REPO_ROOT="${REPO_ROOT:-$(CDPATH= cd -- "$SCRIPT_DIR/../.." && pwd)}"
|
||||
|
||||
MIN_COVERAGE="${MIN_COVERAGE:-0}"
|
||||
TRACES_JSON="${TRACES_JSON:-$REPO_ROOT/traces-report.json}"
|
||||
TRACES_MD="${TRACES_MD:-$REPO_ROOT/docs/traceability.md}"
|
||||
|
||||
PYTHON="${PYTHON:-python3}"
|
||||
command -v "$PYTHON" >/dev/null 2>&1 || {
|
||||
echo "FAILED: $PYTHON not found. The traceability gate needs Python 3.9+" >&2
|
||||
echo "FAILED: $PYTHON not found. The traceability gate needs Python 3.9+," >&2
|
||||
echo " or 3.11+ to read traceability.toml." >&2
|
||||
exit 2
|
||||
}
|
||||
|
||||
set -- \
|
||||
--root "$REPO_ROOT" \
|
||||
--requirements "${REQUIREMENTS:-$REPO_ROOT/docs/requirements.md}" \
|
||||
--format coverage \
|
||||
--json-out "$TRACES_JSON" \
|
||||
--markdown-out "$TRACES_MD" \
|
||||
--min-coverage "$MIN_COVERAGE"
|
||||
set -- --format coverage
|
||||
|
||||
if [ "${ALLOW_ORPHANS:-0}" = "1" ]; then
|
||||
set -- "$@" --allow-orphans
|
||||
fi
|
||||
|
||||
if [ -n "${SYSTEM_SPEC:-}" ]; then
|
||||
set -- "$@" --system-spec "$SYSTEM_SPEC"
|
||||
fi
|
||||
|
||||
if [ -n "${TYPES:-}" ]; then
|
||||
set -- "$@" --types "$TYPES"
|
||||
fi
|
||||
|
||||
if [ -n "${SUFFIXES:-}" ]; then
|
||||
set -- "$@" --suffixes "$SUFFIXES"
|
||||
fi
|
||||
|
||||
if [ -n "${SCAN_ROOTS:-}" ]; then
|
||||
set -- "$@" --scan-roots "$SCAN_ROOTS"
|
||||
fi
|
||||
# Explicit `if` rather than `[ ... ] && ...`, because a trailing false test in
|
||||
# an && list exits under `set -e` in some POSIX shells.
|
||||
if [ -n "${TRACES_CONFIG:-}" ]; then set -- "$@" --config "$TRACES_CONFIG"; fi
|
||||
if [ -n "${TRACES_ROOT:-}" ]; then set -- "$@" --root "$TRACES_ROOT"; fi
|
||||
if [ -n "${MIN_COVERAGE:-}" ]; then set -- "$@" --min-coverage "$MIN_COVERAGE"; fi
|
||||
if [ -n "${TRACES_JSON:-}" ]; then set -- "$@" --json-out "$TRACES_JSON"; fi
|
||||
if [ -n "${TRACES_MD:-}" ]; then set -- "$@" --markdown-out "$TRACES_MD"; fi
|
||||
if [ -n "${SYSTEM_SPEC:-}" ]; then set -- "$@" --system-spec "$SYSTEM_SPEC"; fi
|
||||
if [ "${ALLOW_ORPHANS:-0}" = "1" ]; then set -- "$@" --allow-orphans; fi
|
||||
|
||||
exec "$PYTHON" "$SCRIPT_DIR/extract_traces.py" "$@"
|
||||
|
||||
Reference in New Issue
Block a user