From 6e7f32f3029ff2fa4f34a1d574ce439bd5dcdeb6 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sat, 15 Aug 2026 08:11:57 -0700 Subject: [PATCH 1/2] Exempt hub-hosted paths from the prose gate's dead-path rule A repository that retires its copy of a hub-hosted file per a `retire` disposition still names the hub's copy, which is what GOVERNANCE.md "Hub-Hosted Tooling" requires of carried text naming a tool. The tree holds no such file and git once tracked it, so the dead-path rule reports every such mention. The rule's own exemption reads the scanning tree's spec/files.json, which no repository carries, so downstream the exemption set is empty and the class cannot be recognized. It surfaces at the develop -> main promotion, whose diff base brings the whole retirement into scope, leaving a ruleset bypass as the only local remedy for a linter false positive. Observed on ptr727/PhotoCleaner#51. The set is a literal because the prose-gate action fetches prose_lint.py alone with no hub tree beside it, and a test asserts it against the ledger's retire dispositions so a new retirement cannot land on one side. --- docs/fleet-map.md | 2 +- scripts/prose_lint.py | 21 +++++++++++++++++++-- scripts/tests/test_prose_lint.py | 27 +++++++++++++++++++++++++++ 3 files changed, 47 insertions(+), 3 deletions(-) diff --git a/docs/fleet-map.md b/docs/fleet-map.md index dc86b6a1..67ae96a6 100644 --- a/docs/fleet-map.md +++ b/docs/fleet-map.md @@ -194,7 +194,7 @@ flowchart LR ### G4: Deletion Sweeps Miss Prose (Closed) - **Gap** - A resync that deletes a carried file greps for the path and finds code uses, not prose describing the file without naming its path. A measured incident left a layout section describing a deleted script. -- **Resolution** - Split by what a pattern can reach. The named-path half is mechanized: the `dead-path` rule in [`scripts/prose_lint.py`][prose-lint] reports a Markdown mention (a backtick span, an inline link target, or a reference definition) of a path git once tracked and the tree no longer holds. Keying on deletion history is what scopes it: a proposed file a backlog names, another repository's layout, and a ref like `origin/develop` each have no history here and stay silent, and a manifest-declared carried path is exempt since the hub's own instance retires to a catalog snippet while docs keep naming the carried file. The rule runs in the default set and in CI, where the checkout fetches full history because the rule stands down loudly in a shallow clone rather than pass blind. The name-shaped half, the description that names no path, is `accepted` as manual: no pattern reaches it, the same judgment the home-path rule records, so the [`RESYNC.md`][resync] section 4 read of the layout and operations sections stands and now names the lint beside it. +- **Resolution** - Split by what a pattern can reach. The named-path half is mechanized: the `dead-path` rule in [`scripts/prose_lint.py`][prose-lint] reports a Markdown mention (a backtick span, an inline link target, or a reference definition) of a path git once tracked and the tree no longer holds. Keying on deletion history is what scopes it: a proposed file a backlog names, another repository's layout, and a ref like `origin/develop` each have no history here and stay silent, and a manifest-declared carried path is exempt since the hub's own instance retires to a catalog snippet while docs keep naming the carried file. A hub-hosted path is exempt for the mirror-image reason, measured downstream rather than predicted: a repository that deleted its copy per a `retire` disposition still names the hub's, which is the pointer [`GOVERNANCE.md`][governance] "Hub-Hosted Tooling" requires of it, and the manifest exemption cannot reach that case because no repository carries `spec/files.json`. That set is a literal in the gate, since the prose-gate action can fetch the one file and no ledger beside it, and a test asserts it against the ledger's `retire` dispositions so a new retirement cannot land on one side only. The rule runs in the default set and in CI, where the checkout fetches full history because the rule stands down loudly in a shallow clone rather than pass blind. The name-shaped half, the description that names no path, is `accepted` as manual: no pattern reaches it, the same judgment the home-path rule records, so the [`RESYNC.md`][resync] section 4 read of the layout and operations sections stands and now names the lint beside it. - **Closing test** - `TestDeadPath` in `scripts/tests/test_prose_lint.py`, including the shallow stand-down and the tree-clean assertion. The rule's first tree-wide run caught a real instance, [`docs/host-setup.md`][host-setup-doc] describing bind-mounts in the deleted `.devcontainer/` definitions, fixed by re-pointing at the catalog snippets. ### G5: Intent-Fidelity Drift Is Invisible (Closed) diff --git a/scripts/prose_lint.py b/scripts/prose_lint.py index c3ffff92..87e0c788 100755 --- a/scripts/prose_lint.py +++ b/scripts/prose_lint.py @@ -333,6 +333,18 @@ def path_candidate(token: str, in_span: bool = True) -> str | None: return token.removeprefix("./") +# Paths the hub hosts and no repository carries, per GOVERNANCE.md "Hub-Hosted Tooling". +# A mention of one names the hub's copy rather than a file this tree lost. +# Carried text naming a tool is required to name it that way, so the mention is never a dead path. +# The manifest exemption cannot reach this class, since no repository carries `spec/files.json`. +# Downstream that set is empty, and a repository that retired its copy carries the full signature. +# It surfaces at the promotion, whose diff base brings the retirement and its prose into scope. +# That is the gate with the least room to fix it, and a ruleset bypass is the only local remedy. +# Held as a literal because the prose-gate action fetches this one file with no hub tree beside it. +# The `retire` dispositions in `spec/divergences.json` are the source, and a hub test asserts this. +HUB_HOSTED = frozenset({"repo-config/configure.sh"}) + + @functools.cache def carried_paths(root: str) -> frozenset[str]: """Paths the manifest declares as carried, exempt because docs name them as fleet layout. @@ -374,7 +386,9 @@ def dead_path_findings( Requiring a history is what scopes this to the deletion-sweep shape, a file removed with its describing prose left standing. A path another repository holds, a proposed file a - backlog names, and a layout pattern each have no history here, so none is reported. + backlog names, and a layout pattern each have no history here, so none is reported. A + carried path and a hub-hosted one are exempt with a history, since each names a file that + lives elsewhere by design rather than a description this tree left behind. """ m = REF_DEF.match(line) if m: @@ -396,7 +410,10 @@ def dead_path_findings( tracked_rel = (anchor / rel_path).resolve().relative_to(root.resolve()) except ValueError: continue - if str(tracked_rel) in carried_paths(str(root)): + # Both exemption sets are keyed by the posix path the manifest and the ledger hold. + # The comparison is made in that form rather than in the platform's separator. + key = tracked_rel.as_posix() + if key in carried_paths(str(root)) or key in HUB_HOSTED: continue if once_tracked(str(root), str(tracked_rel)): out.append( diff --git a/scripts/tests/test_prose_lint.py b/scripts/tests/test_prose_lint.py index 5d1147a7..a96c7fdc 100755 --- a/scripts/tests/test_prose_lint.py +++ b/scripts/tests/test_prose_lint.py @@ -2795,6 +2795,33 @@ def test_a_manifest_declared_carried_path_is_exempt(self) -> None: self.addCleanup(prose_lint.carried_paths.cache_clear) self.assertEqual([], self.kinds(root, "Run `scripts/gone.py` to apply.\n")) + def test_a_hub_hosted_path_is_exempt(self) -> None: + """A repo that retired its copy still names the hub's, which is the pointer the rule wants. + + The manifest exemption cannot reach this one, since no repository carries `spec/files.json`, + so without the literal set every retirement fails its own promotion gate. + """ + root = self.tmp / "retired" + (root / "repo-config").mkdir(parents=True) + (root / "repo-config" / "configure.sh").write_text("#!/bin/sh\n", encoding="utf-8") + self.git(root, "init", "-q") + self.git(root, "add", "-A") + self.git(root, "commit", "-qm", "base") + self.git(root, "rm", "-q", "repo-config/configure.sh") + self.git(root, "commit", "-qm", "retire") + self.assertEqual([], self.kinds(root, "Run the hub's `repo-config/configure.sh`.\n")) + + def test_the_hub_hosted_set_matches_the_ledger(self) -> None: + """The literal is a copy of the ledger, so a retirement that misses it fails here loudly.""" + ledger = json.loads((REPO / "spec" / "divergences.json").read_text(encoding="utf-8")) + retired = { + e["path"] + for group in ("dispositions", "gaps") + for e in ledger.get(group, []) + if isinstance(e, dict) and e.get("disposition") == "retire" + } + self.assertEqual(retired, set(prose_lint.HUB_HOSTED)) + def test_without_git_the_rule_stands_down(self) -> None: """No history means no deletion signature, so nothing is reported rather than guessed.""" bare = self.tmp / "bare" From c669cc77a32cb779a4f126ca84237a8a92b255a9 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sat, 15 Aug 2026 08:35:04 -0700 Subject: [PATCH 2/2] Pass the posix key to the git pathspec too The exemption lookup and the history lookup now agree on one canonical form, which is what rel() already assumes of a git pathspec. --- scripts/prose_lint.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/scripts/prose_lint.py b/scripts/prose_lint.py index 87e0c788..b2ea8b3a 100755 --- a/scripts/prose_lint.py +++ b/scripts/prose_lint.py @@ -411,11 +411,12 @@ def dead_path_findings( except ValueError: continue # Both exemption sets are keyed by the posix path the manifest and the ledger hold. - # The comparison is made in that form rather than in the platform's separator. + # A git pathspec is posix too, which `rel` already relies on for the diff scope. + # So one key serves both, rather than the platform's separator reaching either. key = tracked_rel.as_posix() if key in carried_paths(str(root)) or key in HUB_HOSTED: continue - if once_tracked(str(root), str(tracked_rel)): + if once_tracked(str(root), key): out.append( ( lineno,