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..b2ea8b3a 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,9 +410,13 @@ 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. + # 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, 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"