Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/fleet-map.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
24 changes: 21 additions & 3 deletions scripts/prose_lint.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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:
Expand All @@ -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,
Expand Down
27 changes: 27 additions & 0 deletions scripts/tests/test_prose_lint.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down