From 6d211690ba600324cda728ba57c286315c688b1d Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Tue, 27 Oct 2026 16:04:23 -0700 Subject: [PATCH 1/8] Keep Hub tests/ Paths Out of the Hub-Only Detector The hub's tests/ tree is never carried, so a carrier's tests/ file at a hub test path is a path collision rather than a hub-hosted copy. Declare tests as a repo-owned tree in spec/files.json, and have hub_only_paths drop every hub path below a declared repo-owned tree. Co-Authored-By: Claude Opus 5.5 --- spec/audit.py | 12 ++++++++++-- spec/files.json | 3 +++ spec/files.schema.json | 13 +++++++++++++ tests/test_audit.py | 44 +++++++++++++++++++++++++++++++++++++----- 4 files changed, 65 insertions(+), 7 deletions(-) diff --git a/spec/audit.py b/spec/audit.py index 5e3d85d0f..202f6874d 100755 --- a/spec/audit.py +++ b/spec/audit.py @@ -100,7 +100,10 @@ def hub_tracked(rev=None): def hub_only_paths(spec, rev=None): - """Hub-tracked paths the manifest does not declare, so a downstream copy is hub-hosted content rather than a carry.""" + """Hub-tracked paths the manifest does not declare, so a downstream copy is hub-hosted content rather than a carry. + + A path below a declared repo-owned tree is left out, since a downstream file there is that repo's own. + """ declared = {e["path"] for e in spec["files"]["baseline"]} tree_paths = set() for declaration in spec["files"].get("trees", []): @@ -111,7 +114,12 @@ def hub_only_paths(spec, rev=None): if path.startswith(root) and tree_path_included(path.removeprefix(root), declaration["include"]) ) - return hub_tracked(rev) - declared - tree_paths + owned_roots = tuple( + declaration["source"].rstrip("/") + "/" + for declaration in spec["files"].get("repoOwnedTrees", []) + ) + owned_paths = {path for path in hub_tracked(rev) if path.startswith(owned_roots)} + return hub_tracked(rev) - declared - tree_paths - owned_paths def gap_dispositions(spec): diff --git a/spec/files.json b/spec/files.json index e29a3b69a..94dbb0e74 100644 --- a/spec/files.json +++ b/spec/files.json @@ -5,6 +5,9 @@ { "source": ".github/skills", "target": ".github/skills", "fidelity": "verbatim-tree", "appliesTo": "*", "include": ["**/*"], "prune": true }, { "source": "lib/sh", "target": "lib/fleet/sh", "fidelity": "verbatim-tree", "appliesTo": ["host-secrets"], "include": ["**/*"], "prune": true } ], + "repoOwnedTrees": [ + { "source": "tests", "reason": "The hub's own test suite is never carried. Every repo with code keeps its own tests here, so a shared file name is a path collision rather than a carry." } + ], "baseline": [ { "path": "CLAUDE.md", "fidelity": "verbatim", "whole": true, "appliesTo": "*" }, { "path": "AGENTS.md", "fidelity": "intent", "sections": [{ "name": "Fleet Bootstrap", "fidelity": "verbatim" }, diff --git a/spec/files.schema.json b/spec/files.schema.json index d363e6a8c..93b4e16b3 100644 --- a/spec/files.schema.json +++ b/spec/files.schema.json @@ -24,6 +24,19 @@ } } }, + "repoOwnedTrees": { + "description": "Hub directories no repo carries, where a downstream file is that repo's own content. The hub-only check in spec/audit.py skips every hub path below one.", + "type": "array", + "items": { + "type": "object", + "required": ["source", "reason"], + "additionalProperties": false, + "properties": { + "source": { "type": "string", "minLength": 1, "not": { "const": "." } }, + "reason": { "type": "string", "minLength": 1 } + } + } + }, "baseline": { "type": "array", "items": { diff --git a/tests/test_audit.py b/tests/test_audit.py index 5207b47c3..a19d63363 100755 --- a/tests/test_audit.py +++ b/tests/test_audit.py @@ -79,23 +79,30 @@ def __call__(self, path: str, ok404: bool = False) -> Any: def run_audit( - tree: set[str], modes: dict[str, str] | None = None, unread: frozenset[str] = frozenset() + tree: set[str], + modes: dict[str, str] | None = None, + unread: frozenset[str] = frozenset(), + hub: frozenset[str] = frozenset(), ) -> tuple[list[tuple[str, str]], FakeGh]: """Audit a Python repository carrying `tree`, where a test module makes codecov.yml owed. - The hub's tracked paths come from a fetch of its origin. Answering them as empty keeps the - run offline and leaves the checkout the suite runs in untouched. + The hub's tracked paths come from a fetch of its origin. Answering them with `hub` keeps the + run offline and leaves the checkout the suite runs in untouched. The repo-owned trees are the + shipped declaration, so a test reads the exclusion the real audit applies. """ spec = { "registry": {"repos": [ENTRY], "defaults": {}}, "settings": audit.load("repo-config/settings.json"), "secrets": audit.load("spec/secrets.json"), - "files": {"baseline": [CODECOV]}, + "files": { + "baseline": [CODECOV], + "repoOwnedTrees": audit.load("spec/files.json")["repoOwnedTrees"], + }, } fake = FakeGh(tree, modes, unread) with ( mock.patch.object(audit, "gh", fake), - mock.patch.object(audit, "hub_tracked", lambda rev=None: frozenset()), + mock.patch.object(audit, "hub_tracked", lambda rev=None: hub), ): findings, _ = audit.audit_repo(ENTRY, spec) return findings, fake @@ -195,5 +202,32 @@ def test_a_failed_shebang_read_raises_rather_than_reading_as_undecided(self) -> run_audit(tree, {"bin/Run_Backup": "100755"}, frozenset({"bin/Run_Backup"})) +class AuditRepoHubOnlyCase(unittest.TestCase): + """audit_repo leaves a carrier's own test module out of the hub-only check. + + The hub's tests are never carried, so a carrier's file at a hub test path is a collision. + Reporting it would ask for the deletion of that repo's own suite. + """ + + HUB = frozenset({"tests/test_a.py", "tools/hub_tool.py"}) + + def hub_only_findings(self, findings: list[tuple[str, str]]) -> list[str]: + return [text for _, text in findings if text.startswith("hub-only:")] + + def test_a_test_module_sharing_a_hub_test_name_is_not_reported(self) -> None: + findings, _ = run_audit(CARRIED | {".codecov.yml"}, hub=self.HUB) + + self.assertEqual(self.hub_only_findings(findings), []) + + def test_a_hub_only_path_outside_tests_is_still_reported(self) -> None: + """The control, showing the check under test ran and reports a hub-only path it sees.""" + findings, _ = run_audit(CARRIED | {".codecov.yml", "tools/hub_tool.py"}, hub=self.HUB) + + hub_only = self.hub_only_findings(findings) + self.assertTrue( + any(text.startswith("hub-only: tools/hub_tool.py ") for text in hub_only), hub_only + ) + + if __name__ == "__main__": unittest.main() From 252300005073679c7c4070dcfeccf63a066afdff Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Tue, 27 Oct 2026 16:11:54 -0700 Subject: [PATCH 2/8] Share the Hub-Path Walk and State the Repo-Owned Exclusion Factor the declared-source walk into hub_paths_below, used by both hub_only_paths loops and check_tree_verbatim. Test the shipped repoOwnedTrees shape, since no gate reads the schema. Name the exclusion in AUDIT.md's carried-scope bullet. Co-Authored-By: Claude Opus 5.5 --- AUDIT.md | 2 +- spec/audit.py | 38 +++++++++++++++++--------------------- spec/files.schema.json | 2 +- tests/test_audit.py | 10 ++++++++++ 4 files changed, 29 insertions(+), 23 deletions(-) diff --git a/AUDIT.md b/AUDIT.md index 9e18a647e..ff98b89e2 100644 --- a/AUDIT.md +++ b/AUDIT.md @@ -85,7 +85,7 @@ A check with `intentRef`/`workflowRef` points at the prose section that owns the - **docker** - registry layer cache (`buildcache-`, never `type=gha`), the size-limited Docker Hub README is published via the docker-readme task, and the image always re-pushes on publish. - **hugo** - the build fails on a generator warning, the URL-parity gate asserts a length floor before comparing, the rendered output is untracked, the generator is pinned by version and checksum and declared once, a vendored tree records its upstream ref, and the deploy asserts what the host serves (the release id and the environment). Retention is bounded by a declared count with one side recorded as owning the prune, which is the deploy where its credential can observe the destination and the host where that credential is confined write-only, so grade which shape the repo uses rather than looking for a prune step. Deploy credentials are per-environment, which `spec/secrets.json` cannot express, so a clean **repo-setup** verdict says nothing about whether the environments are configured. - **branch-model** - `main` and `develop` both exist and are protected, and the live rulesets match [`repo-config/*.json`][repo-config] by normalized diff (below). -- **carried-scope** - the repo carries no file the hub hosts rather than carries. The set is derived, not listed: the hub's git-tracked paths minus the [`spec/files.json`][files] baseline, so a file dropped from the manifest starts being reported on the next run with no retirement list to remember to edit. The remedy is the opposite of every other file finding, a **deletion**, since the repo reaches the hub's copy per [GOVERNANCE.md "Hub-Hosted Tooling"][governance-hub-hosted-tooling]. The match is on path alone, so a hit is a candidate and not a verdict: a repo's own content at a path the hub also uses matches while carrying nothing of the hub's, which the first fleet run showed twice, a KiCad tooling doc at `scripts/README.md` and per-repo formatting hooks at `.husky/pre-commit`. A [`spec/divergences.json`][divergences] `gaps` disposition decides which case a hit is, so only `retire` asserts a deletion, `accepted` closes a collision or a repo-owned file, and an untriaged hit is read before it is acted on. +- **carried-scope** - the repo carries no file the hub hosts rather than carries. The set is derived, not listed: the hub's git-tracked paths minus the [`spec/files.json`][files] baseline, so a file dropped from the manifest starts being reported on the next run with no retirement list to remember to edit. The manifest's `repoOwnedTrees` entries name hub directories no repo carries, such as `tests`, and every hub path below one is left out of the set. The remedy is the opposite of every other file finding, a **deletion**, since the repo reaches the hub's copy per [GOVERNANCE.md "Hub-Hosted Tooling"][governance-hub-hosted-tooling]. The match is on path alone, so a hit is a candidate and not a verdict: a repo's own content at a path the hub also uses matches while carrying nothing of the hub's, which the first fleet run showed twice, a KiCad tooling doc at `scripts/README.md` and per-repo formatting hooks at `.husky/pre-commit`. A [`spec/divergences.json`][divergences] `gaps` disposition decides which case a hit is, so only `retire` asserts a deletion, `accepted` closes a collision or a repo-owned file, and an untriaged hit is read before it is acted on. - **verbatim-tree** - every applicable `trees[]` declaration in [`spec/files.json`][files] owns its target tree. The audit reports missing files as letter findings, stale or modified bytes as drift, and extra files under a pruned target as drift. An unreadable or truncated repository tree is undecided and produces drift rather than a clean result. - **repo-setup** - every required secret for the repo's publish mechanisms is configured, and no forbidden secret is present (per [`spec/secrets.json`][secrets]). - **runtime-secrets** - a repo-scoped runtime-secrets directory, when present, is named `.secrets/` (dotted, not a bare `secrets/`), and a single opaque credential file carries no extension. Every real secret file has a tracked `.example` beside it cataloged in `.secrets/README.md`. Those parts are N/A for a repo with no such directory. See [GOVERNANCE.md "Repo-Scoped Secrets"][governance-repo-scoped-secrets]. Every env and secret file the repo names, tracked or not, follows the env and secret file rule of [CODESTYLE.md "Script and Utility File Names"][codestyle-file-names]. `spec/audit.py` reports a tracked name that breaks the rule's form at **drift**. It reads each file directly in a `.secrets/` directory and each `.env` file elsewhere whose name does not open on a dot. It sets a trailing `.example` aside first, and the directory's own `README.md`, `.gitignore`, and `.gitkeep` are not secret files. The rest is judged by hand. A hyphen joins words only inside a host field, which takes the DNS label form. The check passes a hyphenated word in any field, since it cannot tell which field it is. A host field is a short name, never a fully qualified one. The check cannot tell an extension from a dot-joined last field of an extensionless name, a two-label host included. A consumer limit applies only where a name is used as a Docker secret or maps to a variable. A host-side name is judged where the repo's docs and scripts spell it out, never by reading a host. An `ENV_FILE` path and a compose `env_file` are such places. Renaming a file on a host is the maintainer's step, listed in the resync pull request. diff --git a/spec/audit.py b/spec/audit.py index 202f6874d..dece26762 100755 --- a/spec/audit.py +++ b/spec/audit.py @@ -99,27 +99,29 @@ def hub_tracked(rev=None): return frozenset(paths) +def hub_paths_below(source, include=("**/*",), rev=None): + """Hub-tracked paths below a declared source directory, mapped from the path relative to it, that match its include patterns.""" + root = source.rstrip("/") + "/" + return { + path.removeprefix(root): path + for path in hub_tracked(rev) + if path.startswith(root) and tree_path_included(path.removeprefix(root), include) + } + + def hub_only_paths(spec, rev=None): """Hub-tracked paths the manifest does not declare, so a downstream copy is hub-hosted content rather than a carry. A path below a declared repo-owned tree is left out, since a downstream file there is that repo's own. """ - declared = {e["path"] for e in spec["files"]["baseline"]} - tree_paths = set() + excluded = {e["path"] for e in spec["files"]["baseline"]} for declaration in spec["files"].get("trees", []): - root = declaration["source"].rstrip("/") + "/" - tree_paths.update( - path - for path in hub_tracked(rev) - if path.startswith(root) - and tree_path_included(path.removeprefix(root), declaration["include"]) + excluded.update( + hub_paths_below(declaration["source"], declaration["include"], rev).values() ) - owned_roots = tuple( - declaration["source"].rstrip("/") + "/" - for declaration in spec["files"].get("repoOwnedTrees", []) - ) - owned_paths = {path for path in hub_tracked(rev) if path.startswith(owned_roots)} - return hub_tracked(rev) - declared - tree_paths - owned_paths + for declaration in spec["files"].get("repoOwnedTrees", []): + excluded.update(hub_paths_below(declaration["source"], rev=rev).values()) + return hub_tracked(rev) - excluded def gap_dispositions(spec): @@ -2087,14 +2089,8 @@ def check_tree_verbatim(run): for declaration in run.spec["files"].get("trees", []): if not applies(declaration.get("appliesTo", "*"), run.sel): continue - source_root = declaration["source"].rstrip("/") target_root = declaration["target"].rstrip("/") - source_files = { - path.removeprefix(source_root + "/"): path - for path in hub_tracked() - if path.startswith(source_root + "/") - and tree_path_included(path.removeprefix(source_root + "/"), declaration["include"]) - } + source_files = hub_paths_below(declaration["source"], declaration["include"]) target_files = { path.removeprefix(target_root + "/"): path for path in run.entries diff --git a/spec/files.schema.json b/spec/files.schema.json index 93b4e16b3..34142d733 100644 --- a/spec/files.schema.json +++ b/spec/files.schema.json @@ -32,7 +32,7 @@ "required": ["source", "reason"], "additionalProperties": false, "properties": { - "source": { "type": "string", "minLength": 1, "not": { "const": "." } }, + "source": { "type": "string", "minLength": 1 }, "reason": { "type": "string", "minLength": 1 } } } diff --git a/tests/test_audit.py b/tests/test_audit.py index a19d63363..2c1c00df5 100755 --- a/tests/test_audit.py +++ b/tests/test_audit.py @@ -219,6 +219,16 @@ def test_a_test_module_sharing_a_hub_test_name_is_not_reported(self) -> None: self.assertEqual(self.hub_only_findings(findings), []) + def test_the_shipped_declaration_holds_the_shape_the_audit_reads(self) -> None: + """No gate reads spec/files.schema.json, so a malformed entry would crash the audit run instead.""" + owned = audit.load("spec/files.json")["repoOwnedTrees"] + + self.assertIn("tests", [entry.get("source") for entry in owned]) + for entry in owned: + with self.subTest(entry=entry): + self.assertEqual(set(entry), {"source", "reason"}) + self.assertTrue(all(isinstance(v, str) and v for v in entry.values())) + def test_a_hub_only_path_outside_tests_is_still_reported(self) -> None: """The control, showing the check under test ran and reports a hub-only path it sees.""" findings, _ = run_audit(CARRIED | {".codecov.yml", "tools/hub_tool.py"}, hub=self.HUB) From 30a9ff09f5e40e72aaba57471e1060b8a1751044 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Tue, 27 Oct 2026 16:14:46 -0700 Subject: [PATCH 3/8] Drop the Repo-Owned Declaration Shape Test The audit cases already load the shipped declaration, so a missing source fails them loudly, and no code reads the reason field. Co-Authored-By: Claude Opus 5.5 --- tests/test_audit.py | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/tests/test_audit.py b/tests/test_audit.py index 2c1c00df5..a19d63363 100755 --- a/tests/test_audit.py +++ b/tests/test_audit.py @@ -219,16 +219,6 @@ def test_a_test_module_sharing_a_hub_test_name_is_not_reported(self) -> None: self.assertEqual(self.hub_only_findings(findings), []) - def test_the_shipped_declaration_holds_the_shape_the_audit_reads(self) -> None: - """No gate reads spec/files.schema.json, so a malformed entry would crash the audit run instead.""" - owned = audit.load("spec/files.json")["repoOwnedTrees"] - - self.assertIn("tests", [entry.get("source") for entry in owned]) - for entry in owned: - with self.subTest(entry=entry): - self.assertEqual(set(entry), {"source", "reason"}) - self.assertTrue(all(isinstance(v, str) and v for v in entry.values())) - def test_a_hub_only_path_outside_tests_is_still_reported(self) -> None: """The control, showing the check under test ran and reports a hub-only path it sees.""" findings, _ = run_audit(CARRIED | {".codecov.yml", "tools/hub_tool.py"}, hub=self.HUB) From db231921eb242c06cb01fe6445cad547c462abba Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Tue, 27 Oct 2026 16:21:57 -0700 Subject: [PATCH 4/8] Validate Repo-Owned Trees and Name Them in the Carried-Scope Assertion spec/validate.py refuses a repoOwnedTrees entry with no usable source or reason, a source at or above the repository root, and a source that is not a directory, since a misspelled one would exclude nothing. The carried.hubonly.absent assertion now names the exclusion. Co-Authored-By: Claude Opus 5.5 --- spec/project-types.json | 2 +- spec/validate.py | 22 +++++++++++++++ tests/test_spec_validate.py | 53 +++++++++++++++++++++++++++++++++++++ 3 files changed, 76 insertions(+), 1 deletion(-) diff --git a/spec/project-types.json b/spec/project-types.json index 16454ef83..ca2cac24d 100644 --- a/spec/project-types.json +++ b/spec/project-types.json @@ -158,7 +158,7 @@ "carried-scope": { "appliesTo": "*", "checks": [ - { "id": "carried.hubonly.absent", "verdict": "letter", "assert": "The repo carries no file the hub hosts rather than carries. spec/audit.py derives the set as the hub's git-tracked paths minus the spec/files.json baseline, and reports each one the repo's ground-truth branch also holds. The remedy is a deletion rather than a re-vendor, since the repo reaches the hub's copy per GOVERNANCE.md 'Hub-Hosted Tooling'. The match is on path alone, so a repo's own content at a path the hub also uses matches without carrying anything of the hub's, and a spec/divergences.json 'gaps' disposition decides which case a hit is: 'retire' asserts a deletion, 'accepted' closes a path collision or a repo-owned file, and an untriaged hit is read before it is acted on.", "intentRef": "GOVERNANCE.md#hub-hosted-tooling" } + { "id": "carried.hubonly.absent", "verdict": "letter", "assert": "The repo carries no file the hub hosts rather than carries. spec/audit.py derives the set as the hub's git-tracked paths minus the spec/files.json baseline, and reports each one the repo's ground-truth branch also holds. Every hub path below a spec/files.json 'repoOwnedTrees' entry, such as tests, is left out of the set, since no repo carries it. The remedy is a deletion rather than a re-vendor, since the repo reaches the hub's copy per GOVERNANCE.md 'Hub-Hosted Tooling'. The match is on path alone, so a repo's own content at a path the hub also uses matches without carrying anything of the hub's, and a spec/divergences.json 'gaps' disposition decides which case a hit is: 'retire' asserts a deletion, 'accepted' closes a path collision or a repo-owned file, and an untriaged hit is read before it is acted on.", "intentRef": "GOVERNANCE.md#hub-hosted-tooling" } ] }, "repo-setup": { diff --git a/spec/validate.py b/spec/validate.py index c368e0dca..0f9771351 100755 --- a/spec/validate.py +++ b/spec/validate.py @@ -1457,6 +1457,28 @@ def check_selector(where, applies_to): f"files.json: tree targets {left_target} and {right_target} have overlapping ownership" ) + owned_trees = files.get("repoOwnedTrees", []) + if not isinstance(owned_trees, list): + errors.append("files.json: 'repoOwnedTrees' must be an array") + owned_trees = [] + for owned in owned_trees: + if not isinstance(owned, dict): + errors.append(f"files.json: repo-owned tree {owned!r} is not an object") + continue + source = owned.get("source") + if not isinstance(source, str) or not source: + errors.append(f"files.json: repo-owned tree has an invalid source: {owned!r}") + continue + reason = owned.get("reason") + if not isinstance(reason, str) or not reason: + errors.append(f"files.json: repo-owned tree {source} reason must be a non-empty string") + if reduces_to_repo_root(source) or escapes_repo_root(source): + errors.append( + f"files.json: repo-owned tree {source} source must be a path below the repository root" + ) + elif not (ROOT / source).is_dir(): + errors.append(f"files.json: repo-owned tree {source} is missing or not a directory") + copilot_path = ROOT / ".github/copilot-instructions.md" try: copilot_instructions = copilot_path.read_text(encoding="utf-8") diff --git a/tests/test_spec_validate.py b/tests/test_spec_validate.py index 5a10c594a..39aff638b 100755 --- a/tests/test_spec_validate.py +++ b/tests/test_spec_validate.py @@ -671,6 +671,59 @@ def test_every_tree_the_live_manifest_declares_passes_the_path_checks(self) -> N self.assertIsNone(validate.symlinked_component(validate.ROOT, value)) +class RepoOwnedTreeGateCase(unittest.TestCase): + """A repo-owned tree declaration the audit cannot use is refused, run as the real script against a scratch tree. + + Every run adds a marker entry with no reason, which the loop always reports, so each absence assertion proves the loop ran. + """ + + def run_against(self, owned: list[object]) -> str: + with tempfile.TemporaryDirectory() as d: + root = Path(d) + shutil.copytree(validate.ROOT / "spec", root / "spec") + shutil.copytree(validate.ROOT / "registry", root / "registry") + (root / "real" / "sub").mkdir(parents=True) + files_path = root / "spec" / "files.json" + files = json.loads(files_path.read_text(encoding="utf-8")) + files["repoOwnedTrees"] = [*owned, {"source": "real"}] + files_path.write_text(json.dumps(files), encoding="utf-8") + result = subprocess.run( + [sys.executable, str(root / "spec" / "validate.py")], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + check=False, + ) + output = result.stdout + result.stderr + self.assertIn( + "files.json: repo-owned tree real reason must be a non-empty string", + output, + "the repo-owned tree loop was never reached, so an absence assertion would be vacuous", + ) + return output + + def test_a_misspelled_source_is_refused(self) -> None: + output = self.run_against([{"source": "rael", "reason": "r"}]) + self.assertIn("files.json: repo-owned tree rael is missing or not a directory", output) + + def test_a_root_or_escaping_source_is_refused(self) -> None: + for source in (".", "..", "../real"): + with self.subTest(source=source): + self.assertIn( + f"files.json: repo-owned tree {source} source must be a path below the repository root", + self.run_against([{"source": source, "reason": "r"}]), + ) + + def test_an_entry_without_a_source_is_refused(self) -> None: + output = self.run_against([{"path": "real", "reason": "r"}]) + self.assertIn("files.json: repo-owned tree has an invalid source:", output) + + def test_a_plain_entry_draws_no_error(self) -> None: + output = self.run_against([{"source": "real/sub", "reason": "r"}]) + self.assertNotIn("repo-owned tree real/sub", output) + + class RegistryNameUniquenessCase(unittest.TestCase): """A registry name is the key both configure.sh and audit.py resolve an entry by. From 5b304797ebcd56930cecea1591b87b62f18ed816 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Tue, 27 Oct 2026 16:26:35 -0700 Subject: [PATCH 5/8] Name the Repo-Owned Exclusion in RESYNC.md's Deletion Section Co-Authored-By: Claude Opus 5.5 --- RESYNC.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/RESYNC.md b/RESYNC.md index ff1e4e493..255a7ad0e 100644 --- a/RESYNC.md +++ b/RESYNC.md @@ -89,11 +89,11 @@ The order is load-bearing. Each step below either changes the rules the later st Every other finding in this procedure is satisfied by adding or replacing content. This one is satisfied by removing it, which makes it the only class where acting on a wrong finding loses something. -The detector is derived rather than listed: the hub's git-tracked paths minus the [`spec/files.json`][files] baseline is what the hub hosts and no repository carries. That means a file dropped from the manifest starts being reported on the next run with no retirement list to maintain, and it also means **the match is on path alone**, so a hit is a candidate and not a verdict. +The detector is derived rather than listed: the hub's git-tracked paths minus the [`spec/files.json`][files] baseline is what the hub hosts and no repository carries. Every hub path below a `repoOwnedTrees` entry in that file, such as `tests`, is left out. That means a file dropped from the manifest starts being reported on the next run with no retirement list to maintain, and it also means **the match is on path alone**, so a hit is a candidate and not a verdict. ```mermaid flowchart LR - candidate["hub git-tracked path, not in spec/files.json"] --> disp["check spec/divergences.json disposition"] + candidate["hub git-tracked path, not in spec/files.json or a repo-owned tree"] --> disp["check spec/divergences.json disposition"] disp -->|"retire"| delete["delete the file"] disp -->|"accepted"| keep["keep, closed permanently"] disp -->|"untriaged"| read["read before touching"] From aceb6583d3dd434072ee058dce42959358295b3d Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Tue, 27 Oct 2026 16:35:20 -0700 Subject: [PATCH 6/8] Refuse an Unnormalized Repo-Owned Source and Share the Gate Runner A source such as ./tests passes the directory check but excludes nothing, so the gate now refuses any source that is not in normalized form. Both scratch-tree gate cases run through one helper. Co-Authored-By: Claude Opus 5.5 --- spec/validate.py | 8 ++- tests/test_spec_validate.py | 99 +++++++++++++++++-------------------- 2 files changed, 51 insertions(+), 56 deletions(-) diff --git a/spec/validate.py b/spec/validate.py index 0f9771351..ffee60bb7 100755 --- a/spec/validate.py +++ b/spec/validate.py @@ -1472,9 +1472,13 @@ def check_selector(where, applies_to): reason = owned.get("reason") if not isinstance(reason, str) or not reason: errors.append(f"files.json: repo-owned tree {source} reason must be a non-empty string") - if reduces_to_repo_root(source) or escapes_repo_root(source): + if ( + reduces_to_repo_root(source) + or escapes_repo_root(source) + or pathlib.PurePosixPath(source).as_posix() != source + ): errors.append( - f"files.json: repo-owned tree {source} source must be a path below the repository root" + f"files.json: repo-owned tree {source} source must be a normalized path below the repository root" ) elif not (ROOT / source).is_dir(): errors.append(f"files.json: repo-owned tree {source} is missing or not a directory") diff --git a/tests/test_spec_validate.py b/tests/test_spec_validate.py index 39aff638b..6032a6a1c 100755 --- a/tests/test_spec_validate.py +++ b/tests/test_spec_validate.py @@ -568,6 +568,33 @@ def test_accepts_a_source_below_the_root(self) -> None: self.assertFalse(validate.reduces_to_repo_root(value)) +def run_gate_on_files(overrides: dict[str, object], make_tree=None) -> str: + """Run the real spec gate against a scratch tree whose files.json carries `overrides`, returning its output. + + The scratch tree holds spec/, registry/, and an empty real/sub directory for a declared source to name. + """ + with tempfile.TemporaryDirectory() as d: + root = Path(d) + shutil.copytree(validate.ROOT / "spec", root / "spec") + shutil.copytree(validate.ROOT / "registry", root / "registry") + (root / "real" / "sub").mkdir(parents=True) + if make_tree is not None: + make_tree(root) + files_path = root / "spec" / "files.json" + files = json.loads(files_path.read_text(encoding="utf-8")) + files.update(overrides) + files_path.write_text(json.dumps(files), encoding="utf-8") + result = subprocess.run( + [sys.executable, str(root / "spec" / "validate.py")], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + check=False, + ) + return result.stdout + result.stderr + + class TreePathGateCase(unittest.TestCase): """A tree path `scripts/carry.py` refuses is refused here too, run as the real script against a scratch tree. @@ -576,33 +603,14 @@ class TreePathGateCase(unittest.TestCase): """ def run_against(self, trees: list[dict], make_tree=None) -> str: - with tempfile.TemporaryDirectory() as d: - root = Path(d) - shutil.copytree(validate.ROOT / "spec", root / "spec") - shutil.copytree(validate.ROOT / "registry", root / "registry") - (root / "real" / "sub").mkdir(parents=True) - if make_tree is not None: - make_tree(root) - files_path = root / "spec" / "files.json" - files = json.loads(files_path.read_text(encoding="utf-8")) - marker = self.tree("real", "marker-target", fidelity="verbatim") - files["trees"] = trees + [marker] - files_path.write_text(json.dumps(files), encoding="utf-8") - result = subprocess.run( - [sys.executable, str(root / "spec" / "validate.py")], - capture_output=True, - text=True, - encoding="utf-8", - timeout=60, - check=False, - ) - output = result.stdout + result.stderr - self.assertIn( - "files.json: tree real fidelity must be 'verbatim-tree'", - output, - "the tree loop was never reached, so an absence assertion would be vacuous", - ) - return output + marker = self.tree("real", "marker-target", fidelity="verbatim") + output = run_gate_on_files({"trees": trees + [marker]}, make_tree) + self.assertIn( + "files.json: tree real fidelity must be 'verbatim-tree'", + output, + "the tree loop was never reached, so an absence assertion would be vacuous", + ) + return output @staticmethod def tree(source: str, target: str, fidelity: str = "verbatim-tree") -> dict: @@ -678,40 +686,23 @@ class RepoOwnedTreeGateCase(unittest.TestCase): """ def run_against(self, owned: list[object]) -> str: - with tempfile.TemporaryDirectory() as d: - root = Path(d) - shutil.copytree(validate.ROOT / "spec", root / "spec") - shutil.copytree(validate.ROOT / "registry", root / "registry") - (root / "real" / "sub").mkdir(parents=True) - files_path = root / "spec" / "files.json" - files = json.loads(files_path.read_text(encoding="utf-8")) - files["repoOwnedTrees"] = [*owned, {"source": "real"}] - files_path.write_text(json.dumps(files), encoding="utf-8") - result = subprocess.run( - [sys.executable, str(root / "spec" / "validate.py")], - capture_output=True, - text=True, - encoding="utf-8", - timeout=60, - check=False, - ) - output = result.stdout + result.stderr - self.assertIn( - "files.json: repo-owned tree real reason must be a non-empty string", - output, - "the repo-owned tree loop was never reached, so an absence assertion would be vacuous", - ) - return output + output = run_gate_on_files({"repoOwnedTrees": [*owned, {"source": "real"}]}) + self.assertIn( + "files.json: repo-owned tree real reason must be a non-empty string", + output, + "the repo-owned tree loop was never reached, so an absence assertion would be vacuous", + ) + return output def test_a_misspelled_source_is_refused(self) -> None: output = self.run_against([{"source": "rael", "reason": "r"}]) self.assertIn("files.json: repo-owned tree rael is missing or not a directory", output) - def test_a_root_or_escaping_source_is_refused(self) -> None: - for source in (".", "..", "../real"): + def test_a_root_escaping_or_unnormalized_source_is_refused(self) -> None: + for source in (".", "..", "../real", "./real", "real/.", "real//", "real/"): with self.subTest(source=source): self.assertIn( - f"files.json: repo-owned tree {source} source must be a path below the repository root", + f"files.json: repo-owned tree {source} source must be a normalized path below the repository root", self.run_against([{"source": source, "reason": "r"}]), ) From 4d1a409cd3056e6e110dca69ba2a2eecfba8ce52 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Tue, 27 Oct 2026 16:42:19 -0700 Subject: [PATCH 7/8] Refuse a Symlinked Repo-Owned Source git tracks no file below a symlink, so a source with a symlinked component passes the directory check but excludes nothing. Co-Authored-By: Claude Opus 5.5 --- spec/validate.py | 4 ++++ tests/test_spec_validate.py | 14 ++++++++++++-- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/spec/validate.py b/spec/validate.py index ffee60bb7..707eba4a2 100755 --- a/spec/validate.py +++ b/spec/validate.py @@ -1480,6 +1480,10 @@ def check_selector(where, applies_to): errors.append( f"files.json: repo-owned tree {source} source must be a normalized path below the repository root" ) + elif (link := symlinked_component(ROOT, source)) is not None: + errors.append( + f"files.json: repo-owned tree {source} has a symlinked component {link}, below which git tracks no file" + ) elif not (ROOT / source).is_dir(): errors.append(f"files.json: repo-owned tree {source} is missing or not a directory") diff --git a/tests/test_spec_validate.py b/tests/test_spec_validate.py index 6032a6a1c..42bca8554 100755 --- a/tests/test_spec_validate.py +++ b/tests/test_spec_validate.py @@ -685,8 +685,8 @@ class RepoOwnedTreeGateCase(unittest.TestCase): Every run adds a marker entry with no reason, which the loop always reports, so each absence assertion proves the loop ran. """ - def run_against(self, owned: list[object]) -> str: - output = run_gate_on_files({"repoOwnedTrees": [*owned, {"source": "real"}]}) + def run_against(self, owned: list[object], make_tree=None) -> str: + output = run_gate_on_files({"repoOwnedTrees": [*owned, {"source": "real"}]}, make_tree) self.assertIn( "files.json: repo-owned tree real reason must be a non-empty string", output, @@ -706,6 +706,16 @@ def test_a_root_escaping_or_unnormalized_source_is_refused(self) -> None: self.run_against([{"source": source, "reason": "r"}]), ) + def test_a_symlinked_source_component_is_refused(self) -> None: + for source in ("alias", "alias/sub"): + with self.subTest(source=source): + self.assertIn( + f"files.json: repo-owned tree {source} has a symlinked component alias, below which git tracks no file", + self.run_against( + [{"source": source, "reason": "r"}], TreePathGateCase.link_alias + ), + ) + def test_an_entry_without_a_source_is_refused(self) -> None: output = self.run_against([{"path": "real", "reason": "r"}]) self.assertIn("files.json: repo-owned tree has an invalid source:", output) From 1fa73a0f68736b0875ee575726b13b159d5f465b Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Tue, 27 Oct 2026 16:49:43 -0700 Subject: [PATCH 8/8] Give the Scratch Gate Tree the Shipped Repo-Owned Directory The copied manifest declares tests as repo-owned, so the scratch tree creates it, and a new case proves the shipped declaration passes. Co-Authored-By: Claude Opus 5.5 --- tests/test_spec_validate.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/tests/test_spec_validate.py b/tests/test_spec_validate.py index 42bca8554..2ce5fe116 100755 --- a/tests/test_spec_validate.py +++ b/tests/test_spec_validate.py @@ -571,13 +571,14 @@ def test_accepts_a_source_below_the_root(self) -> None: def run_gate_on_files(overrides: dict[str, object], make_tree=None) -> str: """Run the real spec gate against a scratch tree whose files.json carries `overrides`, returning its output. - The scratch tree holds spec/, registry/, and an empty real/sub directory for a declared source to name. + The scratch tree holds spec/, registry/, an empty real/sub directory for a declared source to name, and the shipped repo-owned tests directory. """ with tempfile.TemporaryDirectory() as d: root = Path(d) shutil.copytree(validate.ROOT / "spec", root / "spec") shutil.copytree(validate.ROOT / "registry", root / "registry") (root / "real" / "sub").mkdir(parents=True) + (root / "tests").mkdir() if make_tree is not None: make_tree(root) files_path = root / "spec" / "files.json" @@ -724,6 +725,13 @@ def test_a_plain_entry_draws_no_error(self) -> None: output = self.run_against([{"source": "real/sub", "reason": "r"}]) self.assertNotIn("repo-owned tree real/sub", output) + def test_the_shipped_declaration_draws_no_error(self) -> None: + shipped = json.loads((validate.ROOT / "spec" / "files.json").read_text(encoding="utf-8")) + output = self.run_against(shipped["repoOwnedTrees"]) + for owned in shipped["repoOwnedTrees"]: + with self.subTest(source=owned["source"]): + self.assertNotIn(f"repo-owned tree {owned['source']} ", output) + class RegistryNameUniquenessCase(unittest.TestCase): """A registry name is the key both configure.sh and audit.py resolve an entry by.