diff --git a/README.md b/README.md index 678a1df3..df380d1d 100644 --- a/README.md +++ b/README.md @@ -215,7 +215,7 @@ A rule that cannot be diverged from is a rule people work around silently, which **Check the unit's fidelity level first, because most apparent divergences are not divergences at all.** Each [spec/files.json][files] entry declares one of `verbatim`, `interface`, `intent`, or `presence`, defaulting to `presence`, and the two permissive levels already hand the repo its content outright. A repo rewriting a `presence` or `intent` file to suit itself is exercising the freedom the level grants rather than breaking a rule, and the audit asserts nothing beyond presence for it. See [spec/fidelity-model.md][fidelity-model] for which unit sits where and why. -**A divergence from a `verbatim` or `interface` unit is recorded, not hidden.** The ledger is [spec/divergences.json][divergences], where each entry names the path, the repos, a disposition, and a reason, and `spec/fidelity_honesty.py --report` joins it against live fleet reality to regenerate [reports/divergences.md][divergences-report]. A divergence recorded as `accepted` is a legitimate permanent one and no further action is owed. A live divergence with no entry renders as `UNTRIAGED`, which is the point: the audit does not care whether a difference is deliberate, only whether someone decided about it. Edit the ledger and regenerate the report, never the report. +**A divergence from a `verbatim` or `interface` unit is recorded, not hidden.** The ledger is [spec/divergences.json][divergences]. Every entry names a path, a disposition, and a reason. A `dispositions` entry also names the repos it covers. An `investigate` entry carries a `tracking` value naming where its pending decision is being made, and every other disposition may leave that value null. `spec/fidelity_honesty.py --report` joins the ledger against live fleet reality to regenerate [reports/divergences.md][divergences-report]. A divergence recorded as `accepted` is a legitimate permanent one and no further action is owed. A live divergence with no entry renders as `UNTRIAGED`, which is the point: the audit does not care whether a difference is deliberate, only whether someone decided about it. Edit the ledger and regenerate the report, never the report. **A rule that is wrong for many repositories is a hub defect rather than a per-repo exception.** [AUDIT.md][audit] section 9 says so directly, that a repeated letter miss many repos share is a signal the spec needs adjusting, so raise it here instead of accumulating one exception per repository. diff --git a/scripts/tests/test_spec_validate.py b/scripts/tests/test_spec_validate.py index 97a45eb2..8e678502 100755 --- a/scripts/tests/test_spec_validate.py +++ b/scripts/tests/test_spec_validate.py @@ -1552,5 +1552,187 @@ def test_a_non_string_is_rejected_rather_than_read_as_absent(self) -> None: self.assertEqual(len(self.errors(value)), 1) +class InvestigateTrackingCase(unittest.TestCase): + """`investigate` is the one disposition naming no outcome, so it names where its decision is being made.""" + + def errors(self, entry: dict) -> list[str]: + return validate.investigate_tracking_errors("gap 'pyproject.toml'", entry) + + def test_a_null_tracking_value_is_rejected(self) -> None: + self.assertEqual( + self.errors({"disposition": "investigate", "tracking": None}), + [ + "divergences.json: gap 'pyproject.toml' disposition 'investigate' requires a non-empty tracking value naming where the pending decision is being made, such as 'owner/repo#123'. Where no decision is pending, record the disposition that names the outcome rather than a tracking value written to satisfy this check" + ], + ) + + def test_an_absent_tracking_key_is_rejected_rather_than_read_as_tracked(self) -> None: + self.assertEqual(len(self.errors({"disposition": "investigate"})), 1) + + def test_a_whitespace_only_tracking_value_is_rejected(self) -> None: + """A blank string satisfies the string type the schema declares while naming nothing.""" + self.assertEqual(len(self.errors({"disposition": "investigate", "tracking": " "})), 1) + + def test_a_tracked_entry_is_accepted(self) -> None: + self.assertEqual( + self.errors({"disposition": "investigate", "tracking": "owner/repo#123"}), + [], + ) + + def test_every_other_disposition_may_leave_tracking_null(self) -> None: + """The requirement is scoped to the disposition that defers, not applied to the whole vocabulary.""" + for disposition in ("re-vendor", "track", "accepted", "upstream-candidate", "retire"): + with self.subTest(disposition=disposition): + self.assertEqual(self.errors({"disposition": disposition, "tracking": None}), []) + + +class InvestigateTrackingWiringCase(unittest.TestCase): + """The helper is wired into both ledger loops, which no test of the helper alone can show. + + Deleting either call site, or handing the `dispositions` loop the `gap` label, leaves every helper + test green and `spec/validate.py` green too, since the live ledger's one `investigate` row is + compliant. So this runs the real script against a scratch tree whose ledger defers in both arrays. + """ + + MESSAGE = "disposition 'investigate' requires a non-empty tracking value" + + def run_against(self, tracking: object) -> str: + with tempfile.TemporaryDirectory() as d: + root = Path(d) + shutil.copytree(validate.ROOT / "spec", root / "spec") + (root / "registry").mkdir() + # The gate reads the schema's own property names to check for an unknown key, so the scratch tree carries it. + shutil.copy( + validate.ROOT / "registry" / "repos.schema.json", + root / "registry" / "repos.schema.json", + ) + (root / "registry" / "repos.json").write_text( + json.dumps( + { + "defaults": {"workflowModel": "release"}, + "repos": [ + { + "name": "Fixture", + "url": "https://github.com/owner/fixture", + "status": "backlog", + "classificationPending": True, + } + ], + } + ), + encoding="utf-8", + ) + # One marker per array, each carrying a defect its loop always reports whatever the tracking value is. + # Keying the proof on the entries under test would leave the absence assertion below passing on a run that never reached them. + (root / "spec" / "divergences.json").write_text( + json.dumps( + { + "dispositions": [ + { + "path": "AGENTS.md", + "repos": ["Fixture"], + "disposition": "investigate", + "reason": "Deferred in the dispositions array.", + "tracking": tracking, + }, + { + "path": "WORKFLOW.md", + "repos": ["Fixture"], + "disposition": "retire", + "reason": "", + "tracking": None, + }, + ], + "gaps": [ + { + "path": "fixture/deferred-here.txt", + "disposition": "investigate", + "reason": "Deferred in the gaps array.", + "tracking": tracking, + }, + { + "path": "fixture/marker.txt", + "disposition": "retire", + "reason": "", + "tracking": None, + }, + ], + } + ), + encoding="utf-8", + ) + result = subprocess.run( + [sys.executable, str(root / "spec" / "validate.py")], + capture_output=True, + text=True, + timeout=60, + check=False, + ) + output = result.stdout + result.stderr + self.assertNotIn("Traceback", output) + for marker in ( + "divergences.json: 'WORKFLOW.md' reason must be a non-empty string", + "divergences.json: gap 'fixture/marker.txt' reason must be a non-empty string", + ): + self.assertIn( + marker, + output, + "a ledger loop was never reached, so an absence assertion would be vacuous", + ) + return output + + def test_both_arrays_refuse_an_untracked_investigate_entry(self) -> None: + output = self.run_against(None) + self.assertIn(f"divergences.json: 'AGENTS.md' {self.MESSAGE}", output) + self.assertIn(f"divergences.json: gap 'fixture/deferred-here.txt' {self.MESSAGE}", output) + + def test_a_tracked_investigate_entry_passes_both_arrays(self) -> None: + self.assertNotIn(self.MESSAGE, self.run_against("owner/repo#123")) + + +class InvestigateTrackingSchemaMirrorCase(unittest.TestCase): + """spec/divergences.schema.json is advisory, since CI runs no JSON-schema validation, so it answers to spec/validate.py. + + The conditional has to stay present for both arrays, since a schema that stops asserting the rule leaves + an editor green on exactly the entry the gate refuses. The two are deliberately unequal, and in one + direction only: the schema refuses an absent, null, empty, or non-string value, and the gate refuses every + one of those plus a value that is only whitespace. A schema pattern closing that last gap would refuse a + value the gate accepts, which is the direction #1504 reverted for the registry schema, for this same + reason: U+FEFF is an ECMA-262 `\\s` character and `str.strip` does not remove it, so a `\\S` pattern would + refuse a value this gate keeps. + """ + + def setUp(self) -> None: + self.schema = json.loads( + (validate.ROOT / "spec" / "divergences.schema.json").read_text(encoding="utf-8") + ) + + def test_both_arrays_carry_the_conditional(self) -> None: + for array in ("dispositions", "gaps"): + with self.subTest(array=array): + self.assertEqual( + self.schema["properties"][array]["items"]["allOf"], + [{"$ref": "#/$defs/investigateNeedsTracking"}], + ) + + def test_the_conditional_requires_a_non_empty_tracking_value(self) -> None: + rule = self.schema["$defs"]["investigateNeedsTracking"] + self.assertEqual(rule["if"]["properties"]["disposition"]["const"], "investigate") + self.assertEqual(rule["then"]["required"], ["tracking"]) + self.assertEqual(rule["then"]["properties"]["tracking"]["type"], "string") + self.assertEqual(rule["then"]["properties"]["tracking"]["minLength"], 1) + + def test_the_gate_is_the_stricter_of_the_two(self) -> None: + """A whitespace-only value satisfies minLength and is refused by the gate, which is the safe direction.""" + self.assertEqual( + len( + validate.investigate_tracking_errors( + "gap 'x'", {"disposition": "investigate", "tracking": " "} + ) + ), + 1, + ) + + if __name__ == "__main__": unittest.main() diff --git a/spec/divergences.json b/spec/divergences.json index 82a5bd12..0aa02c02 100644 --- a/spec/divergences.json +++ b/spec/divergences.json @@ -1,6 +1,6 @@ { "$schema": "./divergences.schema.json", - "note": "Curated dispositions for known fleet divergences from the manifest canonicals - the burn-down ledger. spec/fidelity_honesty.py --report joins this against live fleet reality to write reports/divergences.md. A recorded divergence still present renders as a burn-down task with its disposition. A live divergence absent here renders as UNTRIAGED. A recorded divergence no longer live renders as resolved. Edit this file (not the generated report) and regenerate. dispositions cover per-repo divergences from a verbatim or intent canonical - a whole file, or a verbatim section whose unit key is the file path, then a space-greater-space delimiter, then the section name (for example GOVERNANCE.md > Git and Commit Rules). gaps cover files carried by the fleet but absent from spec/files.json. disposition vocabulary: re-vendor (drift-to-fix, copy the current canonical down), track (a gap to add to the manifest), accepted (a legitimate permanent divergence, no action), upstream-candidate (the downstream carries an improvement the hub should adopt, then re-vendor), investigate (recorded, decision pending), retire (the hub hosts the file and a downstream copy is deleted rather than converged, per GOVERNANCE.md 'Hub-Hosted Tooling').", + "note": "Curated dispositions for known fleet divergences from the manifest canonicals - the burn-down ledger. spec/fidelity_honesty.py --report joins this against live fleet reality to write reports/divergences.md. A recorded divergence still present renders as a burn-down task with its disposition. A live divergence absent here renders as UNTRIAGED. A recorded divergence no longer live renders as resolved. Edit this file (not the generated report) and regenerate. dispositions cover per-repo divergences from a verbatim or intent canonical - a whole file, or a verbatim section whose unit key is the file path, then a space-greater-space delimiter, then the section name (for example GOVERNANCE.md > Git and Commit Rules). gaps cover files carried by the fleet but absent from spec/files.json. disposition vocabulary: re-vendor (drift-to-fix, copy the current canonical down), track (a gap to add to the manifest), accepted (a legitimate permanent divergence, no action), upstream-candidate (the downstream carries an improvement the hub should adopt, then re-vendor), investigate (recorded, decision pending, and the only disposition whose tracking value is required rather than optional, since it names no outcome and the tracking value is what names where the decision is being made), retire (the hub hosts the file and a downstream copy is deleted rather than converged, per GOVERNANCE.md 'Hub-Hosted Tooling').", "dispositions": [ { "path": ".editorconfig-checker.json", "repos": ["HomeAutomation-Config", "HolidayLights"], "disposition": "accepted", "reason": "Both carry a legitimate repo-specific Exclude list (HomeAutomation-Config excludes a Vantage/ subtree, HolidayLights excludes .fseq sequence files). The uniform Disable block is carried intent-equivalent. Exclude is inherently repo-local, which is why the unit is intent, not verbatim.", "tracking": null }, { "path": ".markdownlint-cli2.jsonc", "repos": ["aiopurpleair", "PhotoCleaner", "AudioCleaner"], "disposition": "re-vendor", "reason": "Verbatim config held as a hand-modified copy rather than a past hub revision. Restore the current canonical.", "tracking": null }, @@ -23,7 +23,7 @@ { "path": "scripts/README.md", "disposition": "accepted", "reason": "A path collision rather than a carry. KiCadLibrary's copy documents its own KiCad tooling (common.py, verify_library.py, build_library.py) beside the scripts it describes, and shares nothing with the hub's fleet-gate documentation. Verified by reading it on 2026-08-10. scripts/ is a generic path, so a repo with its own tooling directory matches this check without carrying anything of the hub's.", "tracking": null }, { "path": ".github/actionlint.yaml", "disposition": "accepted", "reason": "A path collision rather than a carry. HomeAutomation-Config's own copy declares self-hosted-runner labels (homelab, ubuntu-24.04) for its self-hosted CI runner, entirely different content from the hub's own file at this path, which configures $/ self-reference ignore rules for the hub's own workflows. Verified by reading both copies on 2026-08-25.", "tracking": null }, { "path": ".github/actions/validate/action.yml", "disposition": "accepted", "reason": "A path collision rather than a carry. HomeAutomation-Config's own copy overrides the interface-workflow validate hook, per RESYNC.md 'Apply, in This Order' item 4, 'Interface workflows': 'Honor the named contract... rather than copying bytes. The body is the repository's own.' It runs its CloudInit/ nested Python project through uv/ruff/pyright/pytest. The hub's own file at this same path is a different override, its own registry/spec self-test suite. A repo declaring its own .github/actions/validate/action.yml is the documented, intended override mechanism, not drift to reconcile. Verified by reading both copies on 2026-08-25.", "tracking": null }, - { "path": "pyproject.toml", "disposition": "investigate", "reason": "The hub gained a config-only Scripts-profile pyproject.toml in #388. Decide whether to track it (intent, appliesTo python) after confirming the Python repos carry an equivalent. reports/divergences.md already shows the carriers. The decision is tracked in ptr727/ProjectTemplate#669, so the remaining call is the fleet-wide new-findings tradeoff, which stays the maintainer's.", "tracking": "ptr727/ProjectTemplate#669" }, + { "path": "pyproject.toml", "disposition": "investigate", "reason": "The hub gained a config-only Scripts-profile pyproject.toml in #388. Decide whether to track it (intent, appliesTo python) after confirming the Python repos carry an equivalent. reports/divergences.md already shows the carriers. The decision is tracked in ptr727/ProjectTemplate#1553, so the remaining call is the fleet-wide new-findings tradeoff, which stays the maintainer's.", "tracking": "ptr727/ProjectTemplate#1553" }, { "path": ".github/workflows/get-version-task.yml", "disposition": "retire", "reason": "The task is hub-hosted rather than carried, per GOVERNANCE.md \"Hub-Hosted Tooling\", so it is no manifest entry and a downstream copy is retired rather than re-vendored. Every copy is the hub's own NBGV logic with nothing per-repo in it beyond the action pins Dependabot already owns. The carriers, read from the fleet on 2026-08-15, are ESPHome-NonRoot, NxWitness, PhotoCleaner, PlexCleaner, VSCode-Server-DotNetCore, KiCadLibrary, aiopurpleair, and homeassistant-purpleair. Delete the copy and reach the hub task by pin as each repo is next visited, per docs/reusable-workflows.md \"Adopting the Pure Functions\".", "tracking": null }, { "path": ".github/workflows/publish-plan-task.yml", "disposition": "retire", "reason": "The task is hub-hosted rather than carried, per GOVERNANCE.md \"Hub-Hosted Tooling\", so it is no manifest entry and a downstream copy is retired rather than re-vendored. The carriers, read from the fleet on 2026-08-15, are ESPHome-NonRoot, NxWitness, and Utilities, and all three carry a strict subset of the canonical, missing the -E in set -Eeuo pipefail and the ::warning:: branch for an unrecognized actor pushing to main (WORKFLOW.md D8.4). Delete the copy and reach the hub task by pin as each repo is next visited, per docs/reusable-workflows.md \"Adopting the Pure Functions\".", "tracking": null }, { "path": ".github/workflows/validate-task.yml", "disposition": "retire", "reason": "The file is hub-hosted as a workflow_call task rather than carried, per GOVERNANCE.md \"Hub-Hosted Tooling\" and docs/reusable-workflows.md \"Stage 2: The Gates\". The fleet doc-lint block, the language lint, the prose gate, and the repo gate move into the hub task, and a repo's own domain checks move into its own .github/actions/validate/action.yml hook instead, so a downstream copy is retired rather than re-vendored. The thirteen repos carrying a copy as of 2026-08-16 were PhotoCleaner, PlexCleaner, LanguageTags, Utilities, MediaTools, AudioCleaner, aiopurpleair, Financial-Modeling, Blog, ESPHome-NonRoot, NxWitness, VSCode-Server-DotNetCore, and HomeAutomation-Config, and the live current carrier list above may have moved on since. Delete the copy and adopt the caller stub in docs/reusable-workflows.md \"Adopting the Gates\" as each repo is next visited.", "tracking": null }, diff --git a/spec/divergences.schema.json b/spec/divergences.schema.json index c17bf251..01365886 100644 --- a/spec/divergences.schema.json +++ b/spec/divergences.schema.json @@ -18,8 +18,12 @@ "repos": { "type": "array", "items": { "type": "string" }, "minItems": 1 }, "disposition": { "enum": ["re-vendor", "track", "accepted", "upstream-candidate", "investigate", "retire"] }, "reason": { "type": "string" }, - "tracking": { "type": ["string", "null"] } - } + "tracking": { + "type": ["string", "null"], + "description": "Where the decision or the remediation this entry records is tracked, such as owner/repo#123. Required on an investigate entry, per $defs/investigateNeedsTracking." + } + }, + "allOf": [{ "$ref": "#/$defs/investigateNeedsTracking" }] } }, "gaps": { @@ -32,7 +36,30 @@ "path": { "type": "string" }, "disposition": { "enum": ["re-vendor", "track", "accepted", "upstream-candidate", "investigate", "retire"] }, "reason": { "type": "string" }, - "tracking": { "type": ["string", "null"] } + "tracking": { + "type": ["string", "null"], + "description": "Where the decision or the remediation this entry records is tracked, such as owner/repo#123. Required on an investigate entry, per $defs/investigateNeedsTracking." + } + }, + "allOf": [{ "$ref": "#/$defs/investigateNeedsTracking" }] + } + } + }, + "$defs": { + "investigateNeedsTracking": { + "title": "An investigate disposition names where its pending decision is being made", + "if": { + "required": ["disposition"], + "properties": { "disposition": { "const": "investigate" } } + }, + "then": { + "required": ["tracking"], + "properties": { + "tracking": { + "type": "string", + "minLength": 1, + "description": "Where the pending decision is being made, such as owner/repo#123. spec/validate.py additionally refuses a value that is only whitespace, so this schema is the more permissive of the two by design." + } } } } diff --git a/spec/validate.py b/spec/validate.py index c8a5ff33..f7e422b4 100755 --- a/spec/validate.py +++ b/spec/validate.py @@ -178,6 +178,21 @@ def is_str_list(v): return isinstance(v, list) and all(isinstance(x, str) for x in v) +def investigate_tracking_errors(label, entry): + """Require a tracking value on an `investigate` disposition, the one disposition that names no outcome. + + Every other value in the vocabulary states what happens to the divergence, where this one states that nobody has decided yet. With `tracking` null beside it the ledger records a decision as owed while naming nowhere it is being made, so the deferral reads as an omission instead. The field stays optional under every other disposition, which already names its own outcome. + """ + if entry.get("disposition") != "investigate": + return [] + tracking = entry.get("tracking") + if isinstance(tracking, str) and tracking.strip(): + return [] + return [ + f"divergences.json: {label} disposition 'investigate' requires a non-empty tracking value naming where the pending decision is being made, such as 'owner/repo#123'. Where no decision is pending, record the disposition that names the outcome rather than a tracking value written to satisfy this check" + ] + + def escapes_repo_root(value): """Whether `ROOT / value` could resolve outside ROOT on some host. @@ -1335,6 +1350,7 @@ def check_selector(where, applies_to): errors.append(f"divergences.json: '{p}' reason must be a non-empty string") if not (d.get("tracking") is None or isinstance(d.get("tracking"), str)): errors.append(f"divergences.json: '{p}' tracking must be a string or null") + errors.extend(investigate_tracking_errors(f"'{p}'", d)) for g in div_gaps: if not isinstance(g, dict): errors.append(f"divergences.json: gap {g!r} is not an object") @@ -1355,6 +1371,7 @@ def check_selector(where, applies_to): errors.append(f"divergences.json: gap '{gp}' reason must be a non-empty string") if not (g.get("tracking") is None or isinstance(g.get("tracking"), str)): errors.append(f"divergences.json: gap '{gp}' tracking must be a string or null") + errors.extend(investigate_tracking_errors(f"gap '{gp}'", g)) if errors: print("Spec validation FAILED:")