diff --git a/lib/test/pin-corpus-lint.py b/lib/test/pin-corpus-lint.py index ac9370831..d784e1296 100644 --- a/lib/test/pin-corpus-lint.py +++ b/lib/test/pin-corpus-lint.py @@ -1347,6 +1347,30 @@ class GuardSite(NamedTuple): # The resolved member files of a concatenated bundle target, set only when # ``target_path`` is None because the target is a runtime bundle (issue #956). target_members: tuple | None = None + # The classifier-equivalent resolved-target token (issue #1006): a repo- + # relative POSIX path for a file target, the ``/__pin_corpus_runtime__/`` + # placeholder for a runtime bundle, or None for a defaulted/unresolvable + # target -- the same three shapes ``pin-corpus-classifier.py`` writes into the + # retirement manifests' ``resolved_target`` column. Retirement is keyed on + # (source_file, helper, literal, this token) so a literal retired at one + # site does not poison a retained pin sharing that literal at a different site. + resolved_target_token: str | None = None + + def retirement_key(self): + """This site's retirement identity, or None when it has no literal. + + The single place a site is turned into a retirement key (issue #1006), + so ``scan_changed_sources``' two loops cannot disagree about what a + site's identity is. + """ + if self.literal is None: + return None + return _site_retirement_key( + self.source_path, + self.helper, + self.literal, + self.resolved_target_token, + ) class FilePatch(NamedTuple): @@ -1643,6 +1667,89 @@ def _literal_adjudication_key(literal): return f"literal:{hashlib.sha256(literal.encode('utf-8')).hexdigest()}" +# Coupled with pin-corpus-classifier.py's recover_override_names (which writes +# f"/__pin_corpus_runtime__/{name}" into the manifests' resolved_target cell): the +# two scripts share no module, so this sentinel is a coupled invariant -- change one +# spelling and every runtime-bundle retirement key silently stops matching (#1006). +_RUNTIME_TARGET_PREFIX = "/__pin_corpus_runtime__/" + + +def _repo_relative_or_none(repo_root, target): + """Return TARGET as a repo-relative POSIX path, or None when it is outside + the repository. The one copy of the abspath/commonpath/relpath computation + the file otherwise repeats (see also the raising ``_relative_target_path``).""" + try: + root = os.path.abspath(repo_root) + absolute = os.path.abspath(target) + if os.path.commonpath((root, absolute)) != root: + return None + except ValueError: + return None + return os.path.relpath(absolute, root).replace(os.sep, "/") + + +def _resolved_target_token(target_path, var_name, members, repo_root): + """The classifier-equivalent resolved-target token for a guard site (#1006). + + Matches ``pin-corpus-classifier.py``'s ``_portable_target`` for an in-repo file + (a repo-relative POSIX path) and ``recover_override_names`` for a runtime bundle + (the ``/__pin_corpus_runtime__/`` placeholder), so a live site's token + equals the manifest's ``resolved_target`` cell for the same site. Returns None + for a target that is defaulted or an unresolvable bundle, AND -- unlike the + classifier, which keeps the raw path -- for a target OUTSIDE the repository: + the fail-toward-not-matched direction, which routes such a site through the + ordinary policy rather than treating it as retired. (No pin target in this repo + is out-of-repo, so the divergence is unreachable today; it is deliberately the + safe direction if one ever is.) + """ + if target_path is not None: + return _repo_relative_or_none(repo_root, target_path) + if members is not None and var_name: + return f"{_RUNTIME_TARGET_PREFIX}{var_name}" + return None + + +def _normalize_retirement_target(token): + """Normalize a resolved-target token to the current state-directory spelling. + + The frozen manifests were written before the ``.devflow/`` -> ``.prflow/`` + rename (issue #1002) and record the superseded spelling, while a live site's + token carries the current one. Applied symmetrically to both sides so a + manifest ``.devflow/...`` target and a live ``.prflow/...`` target for one + asset compare equal. Only the state-directory prefix is rewritten -- never + arbitrary ``devflow`` tokens inside a filename -- so a frozen path like + ``docs/DEVFLOW_SYSTEM_OVERVIEW.md`` is left byte-identical. The #1002 rename + also moved the vendored plugin sub-path (``vendor/devflow`` -> ``vendor/prflow``); + that is deliberately NOT normalized here because no pin ``resolved_target`` is a + vendored path (confirmed against all three frozen manifests), and the safe + direction if one ever were is the loud fail-toward-not-matched of #1006. + """ + if token is None: + return None + if token.startswith(_SUPERSEDED_STATE_DIR_PREFIX): + return _STATE_DIR_PREFIX + token[len(_SUPERSEDED_STATE_DIR_PREFIX):] + return token + + +def _site_retirement_key(source_file, helper, literal, target_token): + """The retirement identity of a pin site: (source_file, helper, literal, + resolved target). Keyed on the same manifest fields ``RevivalAuthorization`` + carries, so a retired literal covers only the site(s) it was retired at and a + retained pin sharing that literal at a different site is not swept up (#1006). + """ + payload = json.dumps( + [ + source_file, + helper or "", + literal, + _normalize_retirement_target(target_token), + ], + ensure_ascii=True, + separators=(",", ":"), + ) + return f"retire-site:{hashlib.sha256(payload.encode('utf-8')).hexdigest()}" + + def _strict_retirement_manifest_literals(text, path, spec): header, allowed_dispositions, retired_dispositions = spec if "\r" in text: @@ -1664,7 +1771,10 @@ def _strict_retirement_manifest_literals(text, path, spec): raise InfrastructureError(f"retirement manifest is malformed TSV: {path}: {exc}") from exc if not rows or tuple(rows[0]) != header: raise InfrastructureError(f"retirement manifest has invalid header: {path}") + source_file_index = header.index("source_file") + helper_index = header.index("helper") literal_index = header.index("literal") + target_index = header.index("resolved_target") disposition_index = header.index("disposition") retired = set() for number, row in enumerate(rows[1:], start=2): @@ -1687,8 +1797,32 @@ def _strict_retirement_manifest_literals(text, path, spec): raise InfrastructureError( f"retirement manifest literal is not a string or null: {path}:{number}" ) + # source_file/resolved_target are encode_cell (JSON) values; helper is a + # bare cell -- the shapes pin-corpus-classifier.py wrote (issue #1006). + try: + source_file = json.loads(row[source_file_index]) + resolved_target = json.loads(row[target_index]) + except json.JSONDecodeError as exc: + raise InfrastructureError( + f"retirement manifest site field is invalid JSON: {path}:{number}" + ) from exc + if not isinstance(source_file, str): + raise InfrastructureError( + f"retirement manifest source_file is not a string: {path}:{number}" + ) + if resolved_target is not None and not isinstance(resolved_target, str): + raise InfrastructureError( + f"retirement manifest resolved_target is not a string or null: " + f"{path}:{number}" + ) + helper = row[helper_index] if disposition in retired_dispositions and literal is not None: - retired.add(_literal_adjudication_key(literal)) + # Key on SITE identity, not the literal alone: a literal retired at one + # (source_file, helper, resolved_target) site must not poison a retained + # pin sharing that literal at a different site (issue #1006). + retired.add( + _site_retirement_key(source_file, helper, literal, resolved_target) + ) return retired @@ -3750,6 +3884,8 @@ def lexical_scope(node): target_path, declaration, error, + None, + _resolved_target_token(target_path, None, None, repo_root), ) ) return sites @@ -3850,6 +3986,7 @@ def _raw_guard_site( declaration, error, members, + _resolved_target_token(target_abs, var_name, members, repo_root), ) @@ -3933,6 +4070,7 @@ def extract_guard_sites(text, source_path, repo_root): args, spec, literal_vars, path_vars, lib ) members = None + target_variable = None if target is None: target_variable = _guard_target_variable(args, spec) if target_variable is not None: @@ -3957,6 +4095,9 @@ def extract_guard_sites(text, source_path, repo_root): declaration, error, members, + _resolved_target_token( + target, target_variable, members, repo_root + ), ) ) continue @@ -4050,14 +4191,9 @@ def _normalized_revival_authorization(site, repo_root): or site.declaration_error is not None ): return None - repo_root = os.path.abspath(repo_root) - target_path = os.path.abspath(site.target_path) - try: - if os.path.commonpath((repo_root, target_path)) != repo_root: - return None - except ValueError: + relative_target = _repo_relative_or_none(repo_root, site.target_path) + if relative_target is None: return None - relative_target = os.path.relpath(target_path, repo_root).replace(os.sep, "/") return RevivalAuthorization( site.source_path, site.family, @@ -4564,8 +4700,11 @@ def scan_changed_sources( for site in new_candidates: if site.literal is None: continue - literal_key = _literal_adjudication_key(site.literal) - if literal_key not in retired_literal_keys: + # ``retired_literal_keys`` holds SITE keys (issue #1006): membership is the + # site's own (source_file, helper, literal, resolved_target), not the + # literal alone, so a retained pin sharing a retired twin's literal at a + # different site is not swept into the revival population. + if site.retirement_key() not in retired_literal_keys: continue normalized = _normalized_revival_authorization(site, repo_root) if normalized is None: @@ -4580,12 +4719,17 @@ def scan_changed_sources( consumed_revivals = set() findings = [] for site in new_candidates: + # ``literal_key`` keys the literal-scoped adjudication LEDGER (delta and + # current state); ``retirement_key`` keys SITE-scoped retirement (#1006). + # They are deliberately distinct: the ledger records a decision per literal, + # while retirement covers a specific site. literal_key = ( _literal_adjudication_key(site.literal) if site.literal is not None else None ) - if literal_key in retired_literal_keys: + retirement_key = site.retirement_key() + if retirement_key in retired_literal_keys: normalized = _normalized_revival_authorization(site, repo_root) exact_authorization = ( normalized is not None and normalized in revival_authorizations @@ -4658,7 +4802,7 @@ def scan_changed_sources( # structural boundary) is exactly the pre-#948 prose test — a boundary # row alone must not make a revival valid, and both ladder steps rest on # such a row. So a retired literal keeps the pre-#948 behavior verbatim. - ladder_applies = literal_key not in retired_literal_keys + ladder_applies = retirement_key not in retired_literal_keys # Step 1: a program demonstrably reads the literal. No human needed. if ( ladder_applies diff --git a/lib/test/test_pin_corpus_lint.py b/lib/test/test_pin_corpus_lint.py index 7d630fe2b..ade406232 100755 --- a/lib/test/test_pin_corpus_lint.py +++ b/lib/test/test_pin_corpus_lint.py @@ -2846,26 +2846,39 @@ def _commit(self, root, message): text=True, ).stdout.strip() - def _write_retirement_manifests(self, root): + def _write_retirement_manifests( + self, + root, + retired_source="lib/test/new.sh", + retired_helper="assert_pin_unique", + ): + # Every source_file/literal/resolved_target cell is encode_cell (JSON) + # inside a CSV cell -- the exact double-encoding pin-corpus-classifier.py + # writes and _strict_retirement_manifest_literals now reads for all three + # (issue #1006). The RETIRE_PROSE row's (source_file, helper, + # resolved_target) matches the revival site the tests build below + # (`lib/test/new.sh`, assert_pin_unique, docs/x.md), so retirement covers + # that site by default; a caller passes ``retired_source`` to retire the + # literal at a DIFFERENT site instead. manifests = { ".prflow/logs/residual-prose-retirement-manifest.tsv": ( "source_file\thelper\tassertion_name\tliteral\tresolved_target\t" "target_defaulted\tsurface\tdisposition\trationale\n" - '"lib/test/old.sh"\tassert_pin_unique\t"old"\t' - f'"""{self.LITERAL}"""\t"docs/x.md"\tfalse\tReview\t' + f'"""{retired_source}"""\t{retired_helper}\t"old"\t' + f'"""{self.LITERAL}"""\t"""docs/x.md"""\tfalse\tReview\t' "RETIRE_PROSE\tretired prose\n" ), ".prflow/logs/residual-required-copy-retirement-manifest.tsv": ( "source_file\thelper\tassertion_name\tliteral\tresolved_target\t" "target_defaulted\tdisposition\trationale\n" - '"lib/test/kept.sh"\tassert_pin_unique\t"kept"\t' - '"""NOT RETIRED"""\t"docs/x.md"\tfalse\tRETAIN_BOUNDARY\tkept\n' + '"""lib/test/kept.sh"""\tassert_pin_unique\t"kept"\t' + '"""NOT RETIRED"""\t"""docs/x.md"""\tfalse\tRETAIN_BOUNDARY\tkept\n' ), ".prflow/logs/red-on-removal-retirement-manifest.tsv": ( "source_file\thelper\tassertion_name\tliteral\tresolved_target\t" "target_defaulted\tdisposition\tcall_sha256\n" - '"lib/test/converted.sh"\tassert_pin_red_on_removal\t"converted"\t' - '"""NOT RETIRED EITHER"""\t"docs/x.md"\tfalse\tconvert_presence\t-\n' + '"""lib/test/converted.sh"""\tassert_pin_red_on_removal\t"converted"\t' + '"""NOT RETIRED EITHER"""\t"""docs/x.md"""\tfalse\tconvert_presence\t-\n' ), } for relative, text in manifests.items(): @@ -2873,7 +2886,15 @@ def _write_retirement_manifests(self, root): path.parent.mkdir(parents=True, exist_ok=True) path.write_text(text, encoding="utf-8") - def _repo(self, root, *, base_source="", active_adjudication=True): + def _repo( + self, + root, + *, + base_source="", + active_adjudication=True, + retired_source="lib/test/new.sh", + retired_helper="assert_pin_unique", + ): subprocess.run(["git", "init", "-q"], cwd=root, check=True) subprocess.run( ["git", "config", "user.email", "test@example.com"], @@ -2885,7 +2906,9 @@ def _repo(self, root, *, base_source="", active_adjudication=True): cwd=root, check=True, ) - self._write_retirement_manifests(root) + self._write_retirement_manifests( + root, retired_source=retired_source, retired_helper=retired_helper + ) target = root / "docs/x.md" target.parent.mkdir(parents=True) target.write_text("```text\nMACHINE SENTINEL\n```\n", encoding="utf-8") @@ -3178,9 +3201,13 @@ def test_convert_presence_is_not_a_retired_wording_disposition(self): root = Path(td) base, _table = self._repo(root) retired = self.mod.load_retired_wording_literal_keys(root, base) - converted = ( - "literal:" - + hashlib.sha256("NOT RETIRED EITHER".encode("utf-8")).hexdigest() + # convert_presence is not a retired disposition: the site key for the + # converted row must not be in the retired SITE set (issue #1006). + converted = self.mod._site_retirement_key( + "lib/test/converted.sh", + "assert_pin_red_on_removal", + "NOT RETIRED EITHER", + "docs/x.md", ) self.assertNotIn(converted, retired) @@ -3192,7 +3219,10 @@ def test_count_helpers_cannot_bypass_retired_literal_policy(self): ) with self.subTest(helper=helper), tempfile.TemporaryDirectory() as td: root = Path(td) - base, table = self._repo(root) + # Retire the literal at the count helper's OWN site (issue #1006: + # helper is part of site identity), so this exercises a count + # helper re-adding a literal retired AT that count-helper site. + base, table = self._repo(root, retired_helper=helper) self._commit_revival(root, table, source=source) analysis = self._analysis(root, base) findings = self._scan_sources(root, base, analysis) @@ -3206,7 +3236,7 @@ def test_count_helper_exact_authorized_structural_revival_passes(self): ) with tempfile.TemporaryDirectory() as td: root = Path(td) - base, table = self._repo(root) + base, table = self._repo(root, retired_helper="pin_count") self._commit_revival( root, table, @@ -3219,6 +3249,167 @@ def test_count_helper_exact_authorized_structural_revival_passes(self): analysis = self._analysis(root, base) self.assertEqual([], self._scan_sources(root, base, analysis)) + def test_retirement_is_scoped_to_the_retired_site(self): + # Issue #1006, driven end-to-end through scan_changed_sources: + # - "different-site" (AC2): the literal is retired at lib/test/old.sh, so a + # fully-declared boundary pin sharing that literal at lib/test/new.sh is a + # DIFFERENT site and is not swept into the retired-revival population -- + # it routes through the ordinary ladder and passes with NO finding. Under + # the old literal-only keying this same input was reported as a + # "retired wording-pin revival" with no exit, which is the bug. + # - "own-site" (AC3, the negative control that distinguishes a fix from a + # hole): retiring the SAME literal at lib/test/new.sh -- the pin's own + # site -- still reports it, so the gate did not simply go quiet. + cases = ( + ("different-site", "lib/test/old.sh", 0), + ("own-site", "lib/test/new.sh", 1), + ) + for label, retired_source, expected in cases: + with self.subTest(case=label), tempfile.TemporaryDirectory() as td: + root = Path(td) + base, table = self._repo(root, retired_source=retired_source) + # A genuine, declared machine-boundary pin (docs/x.md is a fenced + # machine sentinel) with NO revival authorization. + self._commit_revival(root, table) + analysis = self._analysis(root, base) + findings = self._scan_sources(root, base, analysis) + self.assertEqual(expected, len(findings), findings) + if expected: + self.assertIn("retired wording-pin", findings[0]) + + def test_conflicting_retain_and_retire_dispositions_resolve_per_site(self): + # AC4 (issue #1006): one literal recorded RETIRE_PROSE at one target and + # RETAIN_BOUNDARY at another (same source_file + helper) resolves to the + # disposition recorded for EACH site. Literal-keying collapsed both to one + # key and could not express this; site-keying keys on the resolved target + # too, so only the retire target's site key is retired. + with tempfile.TemporaryDirectory() as td: + root = Path(td) + subprocess.run(["git", "init", "-q"], cwd=root, check=True) + subprocess.run( + ["git", "config", "user.email", "test@example.com"], cwd=root, check=True + ) + subprocess.run( + ["git", "config", "user.name", "Test User"], cwd=root, check=True + ) + prose = root / ".prflow/logs/residual-prose-retirement-manifest.tsv" + prose.parent.mkdir(parents=True, exist_ok=True) + prose.write_text( + "source_file\thelper\tassertion_name\tliteral\tresolved_target\t" + "target_defaulted\tsurface\tdisposition\trationale\n" + '"""lib/test/run.sh"""\tassert_pin_unique\t"retire"\t' + f'"""{self.LITERAL}"""\t"""docs/retired.md"""\tfalse\tReview\t' + "RETIRE_PROSE\tprose\n" + '"""lib/test/run.sh"""\tassert_pin_unique\t"retain"\t' + f'"""{self.LITERAL}"""\t"""docs/retained.md"""\tfalse\tReview\t' + "RETAIN_BOUNDARY\tkept\n", + encoding="utf-8", + ) + # The other two manifests are read too; write valid header-only files. + (root / ".prflow/logs/residual-required-copy-retirement-manifest.tsv").write_text( + "source_file\thelper\tassertion_name\tliteral\tresolved_target\t" + "target_defaulted\tdisposition\trationale\n", + encoding="utf-8", + ) + (root / ".prflow/logs/red-on-removal-retirement-manifest.tsv").write_text( + "source_file\thelper\tassertion_name\tliteral\tresolved_target\t" + "target_defaulted\tdisposition\tcall_sha256\n", + encoding="utf-8", + ) + subprocess.run(["git", "add", "."], cwd=root, check=True) + subprocess.run(["git", "commit", "-qm", "manifests"], cwd=root, check=True) + base = subprocess.run( + ["git", "rev-parse", "HEAD"], + cwd=root, check=True, capture_output=True, text=True + ).stdout.strip() + retired = self.mod.load_retired_wording_literal_keys(root, base) + retire_key = self.mod._site_retirement_key( + "lib/test/run.sh", "assert_pin_unique", self.LITERAL, "docs/retired.md" + ) + retain_key = self.mod._site_retirement_key( + "lib/test/run.sh", "assert_pin_unique", self.LITERAL, "docs/retained.md" + ) + self.assertIn(retire_key, retired) + self.assertNotIn(retain_key, retired) + + def test_resolved_target_token_matches_the_classifier_three_shapes(self): + # The site-side token must equal the classifier's resolved_target cell + # (issue #1006). Three shapes, and the .devflow/ -> .prflow/ normalization + # that lets a frozen (pre-#1002) manifest target match a current-spelling + # live site -- a live path exercised by real manifest rows. + root = "/repo" + # In-repo file target (absolute) -> repo-relative POSIX path. + self.assertEqual( + "docs/x.md", + self.mod._resolved_target_token("/repo/docs/x.md", None, None, root), + ) + # Runtime bundle -> the /__pin_corpus_runtime__/ placeholder, + # mirroring pin-corpus-classifier.py's recover_override_names sentinel. + self.assertEqual( + "/__pin_corpus_runtime__/CI_BUNDLE", + self.mod._resolved_target_token(None, "CI_BUNDLE", ("a", "b"), root), + ) + # Defaulted / out-of-repo / unresolvable -> None (fail-toward-not-matched). + self.assertIsNone(self.mod._resolved_target_token(None, None, None, root)) + self.assertIsNone( + self.mod._resolved_target_token("/elsewhere/y.md", None, None, root) + ) + # The state-dir rename is applied symmetrically inside _site_retirement_key, + # so a manifest .devflow/ target and a live .prflow/ token for one asset + # produce EQUAL keys; a DEVFLOW-bearing filename is left byte-identical. + self.assertEqual( + self.mod._site_retirement_key( + "lib/test/run.sh", "assert_pin_unique", self.LITERAL, + ".devflow/prompt-extensions/implement.md", + ), + self.mod._site_retirement_key( + "lib/test/run.sh", "assert_pin_unique", self.LITERAL, + ".prflow/prompt-extensions/implement.md", + ), + ) + self.assertNotEqual( + self.mod._site_retirement_key( + "lib/test/run.sh", "h", self.LITERAL, "docs/DEVFLOW_SYSTEM_OVERVIEW.md", + ), + self.mod._site_retirement_key( + "lib/test/run.sh", "h", self.LITERAL, "docs/PRFLOW_SYSTEM_OVERVIEW.md", + ), + ) + + def test_malformed_retirement_manifest_site_fields_fail_closed(self): + # The new source_file/resolved_target JSON parse must fail CLOSED + # (InfrastructureError), matching the pre-existing literal-cell arm and + # the repo's adversarial-input-shape convention for frozen parsers (#1006). + base_header = ( + "source_file\thelper\tassertion_name\tliteral\tresolved_target\t" + "target_defaulted\tsurface\tdisposition\trationale\n" + ) + cases = { + "invalid-json-source": ( + "not-json\tassert_pin_unique\t\"a\"\t" + f'"""{self.LITERAL}"""\t"""docs/x.md"""\tfalse\tReview\t' + "RETIRE_PROSE\tp\n" + ), + "non-string-source": ( + "123\tassert_pin_unique\t\"a\"\t" + f'"""{self.LITERAL}"""\t"""docs/x.md"""\tfalse\tReview\t' + "RETIRE_PROSE\tp\n" + ), + "non-string-target": ( + '"""lib/test/run.sh"""\tassert_pin_unique\t"a"\t' + f'"""{self.LITERAL}"""\t123\tfalse\tReview\t' + "RETIRE_PROSE\tp\n" + ), + } + prose_path = ".prflow/logs/residual-prose-retirement-manifest.tsv" + spec = self.mod._RETIREMENT_MANIFEST_SPECS[prose_path] + for label, row in cases.items(): + with self.subTest(case=label): + with self.assertRaises(self.mod.InfrastructureError): + self.mod._strict_retirement_manifest_literals( + base_header + row, prose_path, spec + ) + class StaticPinWorktreeCompositionTests(unittest.TestCase): @classmethod @@ -4024,10 +4215,19 @@ def test_committed_prose_target_cannot_be_laundered_by_dirty_fenced_target(self) self.assertIn("resolves into prose", dirty_stdout) def test_authorized_retired_revival_cannot_launder_committed_prose_target(self): - literal = "Step 3.6 fresh-context audit" + # Site-keying (issue #1006): the literal must be revived at ITS OWN retired + # site for the pre-948 contract to apply, so this rewrites the fixture to a + # literal the frozen prose manifest retires at (lib/test/run.sh, + # assert_pin_unique, agents/checklist-verifier.md). The literal is not a + # live boundary (absent from the adjudication table), so appending its + # deliberate-boundary row below does not duplicate an existing key. The + # target is committed prose, so a full authorization still cannot launder + # it into a machine sentinel and the revival is still reported. + literal = "#504 displaced-path routing." literal_key = ( "literal:" + hashlib.sha256(literal.encode("utf-8")).hexdigest() ) + target_rel = "agents/checklist-verifier.md" rationale = "the token is claimed as an executable machine sentinel" marker = ( "# structural-pin-ok: machine-sentinel-provenance -- " + rationale @@ -4036,18 +4236,20 @@ def test_authorized_retired_revival_cannot_launder_committed_prose_target(self): root = Path(td) self._repo(root) self.assertIn( - literal_key, + self.mod._site_retirement_key( + "lib/test/run.sh", "assert_pin_unique", literal, target_rel + ), self.mod.load_retired_wording_literal_keys(root, "HEAD"), ) subprocess.run(["git", "switch", "-qc", "topic"], cwd=root, check=True) - target = root / "docs/retired-pin-target.md" + target = root / target_rel target.parent.mkdir(parents=True, exist_ok=True) target.write_text(f"## {literal}\n", encoding="utf-8") source = root / "lib/test/run.sh" source.write_text( source.read_text(encoding="utf-8") + f"\nassert_pin_unique 'retired prose target' '{literal}' " - + f"\"$LIB/../docs/retired-pin-target.md\" {marker}\n", + + f"\"$LIB/../{target_rel}\" {marker}\n", encoding="utf-8", ) table = root / "lib/test/pin-corpus-adjudications.tsv" @@ -4072,7 +4274,7 @@ def test_authorized_retired_revival_cannot_launder_committed_prose_target(self): "source_path\tfamily\thelper\tliteral_key\ttarget_path\t" "structural_category\tstructural_rationale\n" f"lib/test/run.sh\tstatic-helper\tassert_pin_unique\t{literal_key}\t" - "docs/retired-pin-target.md\tmachine-sentinel-provenance\t" + f"{target_rel}\tmachine-sentinel-provenance\t" f"{rationale}\n", encoding="utf-8", ) @@ -4729,6 +4931,13 @@ def test_a_retired_literal_keeps_the_pre_948_revival_contract(self): with tempfile.TemporaryDirectory() as td: root, source = self._fixture(td, marker=self.MARKER) key = self._key() + # Retirement now keys on SITE identity (issue #1006): the site this + # fixture builds is (lib/test/a.sh, assert_pin_unique, LITERAL, TARGET). + # The ledger delta/current-state below still key on the literal, so both + # keys appear -- exactly the two-key split scan_changed_sources makes. + retirement_key = self.mod._site_retirement_key( + "lib/test/a.sh", "assert_pin_unique", self.LITERAL, self.TARGET + ) state = ("boundary", self.RATIONALE) authorization = self.mod.RevivalAuthorization( "lib/test/a.sh", @@ -4742,7 +4951,7 @@ def test_a_retired_literal_keeps_the_pre_948_revival_contract(self): findings = self._scan( root, source, - retired_literal_keys=frozenset({key}), + retired_literal_keys=frozenset({retirement_key}), revival_authorizations=frozenset({authorization}), adjudication_delta={key: (None, state)}, current_adjudications={key: state},