diff --git a/config/template-drift-allowlist.txt b/config/template-drift-allowlist.txt index e1a1c235b..ec7d53cc5 100644 --- a/config/template-drift-allowlist.txt +++ b/config/template-drift-allowlist.txt @@ -46,7 +46,7 @@ template = templates/consumer-repo/.github/workflows/agents-issue-intake.yml main_sha256 = 756b0e4deaf5efd4138f785b857d57ed271bd30f9facc3daa4a6125db14edbb2 template_sha256 = 9176b7cffc68dba50fa7ff9c6a2386383c237433ac5a656053eccdd202628c6d divergence = Named-secrets rollout 2026-08-23: both surfaces now pass named setup-api-client secret inputs instead of the whole-secrets-context blob, applied identically to root and consumer, and scoped to each workflow's DECLARED workflow_call secrets where that context is a closed set. Removing that handoff is the demonstrated remedy for GitHub's suspicious-workflow hold (agents-dedup then ran at run_attempt 1 with nothing approved after 22 days held). Prior divergence unchanged: Intentional divergence re-reviewed 2026-08-16: root and consumer intake surfaces both remove the operator draft toggle and always hand off ready-for-review automation PRs; root retains its richer failure summary while the consumer remains a pinned, minimal bridge contract. -divergence_reviewed = 2026-08-23 +divergence_reviewed = 2026-08-16 fingerprint_refreshed = 2026-08-23 [pair.2] @@ -55,7 +55,7 @@ template = templates/consumer-repo/.github/workflows/agents-71-codex-belt-dispat main_sha256 = 3c82e9805bcc8995d5bb157b0e8582675c05450d51dea6c548ace338d96200ab template_sha256 = 95a70c45498449e6c15f8e231a38a808824e9a42ed16c583219f4a5c71d363d2 divergence = client-id rename 2026-08-24: every create-github-app-token step moved from the deprecated `app-id` input to `client-id`, applied identically to root and consumer, with the secret VALUE unchanged (the action forwards either input to the same appId slot -- main.js:21, lib/main.js:37). Fingerprints refreshed for that change; the divergence itself is unchanged and was NOT re-reviewed, which is why divergence_reviewed keeps its old date. Prior divergence unchanged: Named-secrets rollout 2026-08-23: both surfaces now pass named setup-api-client secret inputs instead of the whole-secrets-context blob, applied identically to root and consumer, and scoped to each workflow's DECLARED workflow_call secrets where that context is a closed set. Removing that handoff is the demonstrated remedy for GitHub's suspicious-workflow hold (agents-dedup then ran at run_attempt 1 with nothing approved after 22 days held). Prior divergence unchanged: Intentional divergence re-baselined 2026-08-16: workflow_dispatch inputs (force_issue, agent_key) are now passed to the github-script step via step-level env: and read through process.env instead of being interpolated into the script body, removing a script-injection surface that caused GitHub to block the workflow as possibly malicious in consumer repos. Applied identically to both surfaces; consumer action pinning and Codex-specific wording preserved. -divergence_reviewed = 2026-08-23 +divergence_reviewed = 2026-08-16 fingerprint_refreshed = 2026-08-24 [pair.3] @@ -64,7 +64,7 @@ template = templates/consumer-repo/.github/workflows/agents-72-codex-belt-worker main_sha256 = d38c8fa8c9a0b8d22cc2618073f3df7e66e5933cedbda9adfe696ebe26bfc56a template_sha256 = abea1d00c85b0d2207746e916a3d1e581fc30f21274ea0c5b45479a604e558cb divergence = client-id rename 2026-08-24: every create-github-app-token step moved from the deprecated `app-id` input to `client-id`, applied identically to root and consumer, with the secret VALUE unchanged (the action forwards either input to the same appId slot -- main.js:21, lib/main.js:37). Fingerprints refreshed for that change; the divergence itself is unchanged and was NOT re-reviewed, which is why divergence_reviewed keeps its old date. Prior divergence unchanged: Named-secrets rollout 2026-08-23: both surfaces now pass named setup-api-client secret inputs instead of the whole-secrets-context blob, applied identically to root and consumer, and scoped to each workflow's DECLARED workflow_call secrets where that context is a closed set. Removing that handoff is the demonstrated remedy for GitHub's suspicious-workflow hold (agents-dedup then ran at run_attempt 1 with nothing approved after 22 days held). Prior divergence unchanged: Intentional divergence re-reviewed 2026-06-20: exported Orchestrator skill inputs were added to both root and consumer worker workflow_call surfaces while preserving consumer action pinning and guarded merge wording. -divergence_reviewed = 2026-08-23 +divergence_reviewed = 2026-06-20 fingerprint_refreshed = 2026-08-24 [pair.4] @@ -73,7 +73,7 @@ template = templates/consumer-repo/.github/workflows/agents-73-codex-belt-convey main_sha256 = 0e1002a98faad1c4444923a7924dc2fd2a1e4f0115d074cd130e8f35e3209033 template_sha256 = 07d5ef489140f7d55a6733d4e1c73d5d3bc17bad33ce9df216767ecb1395be79 divergence = client-id rename 2026-08-24: every create-github-app-token step moved from the deprecated `app-id` input to `client-id`, applied identically to root and consumer, with the secret VALUE unchanged (the action forwards either input to the same appId slot -- main.js:21, lib/main.js:37). Fingerprints refreshed for that change; the divergence itself is unchanged and was NOT re-reviewed, which is why divergence_reviewed keeps its old date. Prior divergence unchanged: Named-secrets rollout 2026-08-23: both surfaces now pass named setup-api-client secret inputs instead of the whole-secrets-context blob, applied identically to root and consumer, and scoped to each workflow's DECLARED workflow_call secrets where that context is a closed set. Removing that handoff is the demonstrated remedy for GitHub's suspicious-workflow hold (agents-dedup then ran at run_attempt 1 with nothing approved after 22 days held). Prior divergence unchanged: Intentional divergence re-reviewed 2026-08-10: root conveyor labels Gate and changed-file checks as authoritative; the consumer adds explicit best-effort rationale for only post-merge cleanup calls while preserving its action pins and Codex-specific behavior. -divergence_reviewed = 2026-08-23 +divergence_reviewed = 2026-08-10 fingerprint_refreshed = 2026-08-24 [pair.5] @@ -82,7 +82,7 @@ template = templates/consumer-repo/.github/workflows/agents-auto-label.yml main_sha256 = 84edb688a3f3629e206432bc63a891f7b7b6e8737d53044ddecce50f97f64231 template_sha256 = 7ca1550aa40b09a0c3f42d2ce3450551008aec62ed8dc94b6dc59a98bff16c78 divergence = Intentional divergence updated 2026-08-05: root and consumer workflows both isolate the eligibility sparse checkout under eligibility-source/ while retaining consumer SHA pins and auth plumbing. Do not align wholesale: that would strip the consumer security contract. -divergence_reviewed = 2026-08-23 +divergence_reviewed = 2026-08-05 fingerprint_refreshed = 2026-08-23 [pair.6] @@ -100,7 +100,7 @@ template = templates/consumer-repo/.github/workflows/agents-capability-check.yml main_sha256 = fd15a87ee9e5dfa7f93c48aa372d87b4c7172a2148f90aba6f25a1f6c9b5ccbe template_sha256 = d64c56f967166f803f054b9d81e2390dadd8dc5984bb280271f09bf4fffd9702 divergence = Named-secrets rollout 2026-08-23: both surfaces now pass named setup-api-client secret inputs instead of the whole-secrets-context blob, applied identically to root and consumer, and scoped to each workflow's DECLARED workflow_call secrets where that context is a closed set. Removing that handoff is the demonstrated remedy for GitHub's suspicious-workflow hold (agents-dedup then ran at run_attempt 1 with nothing approved after 22 days held). Prior divergence unchanged: Intentional divergence re-baselined 2026-08-22 after both surfaces migrated from the retired check_capability alias to classify_capabilities. The consumer template retains SHA-pinned actions and LangSmith tracing while the root workflow retains in-repo concurrency and sparse-checkout plumbing; do not align wholesale because that would strip the consumer security contract. -divergence_reviewed = 2026-08-23 +divergence_reviewed = 2026-08-22 fingerprint_refreshed = 2026-08-23 [pair.8] @@ -109,7 +109,7 @@ template = templates/consumer-repo/.github/workflows/agents-decompose.yml main_sha256 = 66b4596b399d478f5070cedd81cd92b8952f2f82dc17bdf129c200614e09da13 template_sha256 = 48cb4ecee47f756d64c3c76a8f47cac01468c9df5ceae50a86508eadb7d8b589 divergence = Named-secrets rollout 2026-08-23: both surfaces now pass named setup-api-client secret inputs instead of the whole-secrets-context blob, applied identically to root and consumer, and scoped to each workflow's DECLARED workflow_call secrets where that context is a closed set. Removing that handoff is the demonstrated remedy for GitHub's suspicious-workflow hold (agents-dedup then ran at run_attempt 1 with nothing approved after 22 days held). Prior divergence unchanged: Intentional divergence re-baselined 2026-08-09: root-only warning-only REST triage marker added; do not align wholesale because consumer pins and retry plumbing are contractually distinct. -divergence_reviewed = 2026-08-23 +divergence_reviewed = 2026-08-09 fingerprint_refreshed = 2026-08-23 [pair.9] @@ -127,7 +127,7 @@ template = templates/consumer-repo/.github/workflows/agents-guard.yml main_sha256 = db19a7be90004acf658ace7ba2e635dc26f918ed8a52934f5c09d8923a0962e8 template_sha256 = b62efff58577347cf8e868a4915dba9ba2553e281b06f205e9a667a3e6dc1237 divergence = client-id rename 2026-08-24: every create-github-app-token step moved from the deprecated `app-id` input to `client-id`, applied identically to root and consumer, with the secret VALUE unchanged (the action forwards either input to the same appId slot -- main.js:21, lib/main.js:37). Fingerprints refreshed for that change; the divergence itself is unchanged and was NOT re-reviewed, which is why divergence_reviewed keeps its old date. Prior divergence unchanged: Named-secrets rollout 2026-08-23: both surfaces now pass named setup-api-client secret inputs instead of the whole-secrets-context blob, applied identically to root and consumer, and scoped to each workflow's DECLARED workflow_call secrets where that context is a closed set. Removing that handoff is the demonstrated remedy for GitHub's suspicious-workflow hold (agents-dedup then ran at run_attempt 1 with nothing approved after 22 days held). Prior divergence unchanged: Intentional divergence re-baselined 2026-06-30: root and consumer guard workflows differ for pinned consumer actions/App-token setup; stranske/Workflows digest pins were refreshed together in root and consumer guard surfaces after Renovate moved the Workflows digest to ebef44a. -divergence_reviewed = 2026-08-23 +divergence_reviewed = 2026-06-30 fingerprint_refreshed = 2026-08-24 [pair.11] @@ -136,7 +136,7 @@ template = templates/consumer-repo/.github/workflows/agents-issue-optimizer.yml main_sha256 = b61916120daf41bf2786b801d1a4a22e68304730b33cb539495c7ed420a15158 template_sha256 = 45fdba5ac386129273a32fac7d40fd13f9b8b28de06f8fc90254353be7435a97 divergence = client-id rename 2026-08-24: every create-github-app-token step moved from the deprecated `app-id` input to `client-id`, applied identically to root and consumer, with the secret VALUE unchanged (the action forwards either input to the same appId slot -- main.js:21, lib/main.js:37). Fingerprints refreshed for that change; the divergence itself is unchanged and was NOT re-reviewed, which is why divergence_reviewed keeps its old date. Prior divergence unchanged: Fingerprints refreshed 2026-08-24: both surfaces now link recursion-pause comments to the managed WORKFLOW_USER_GUIDE recovery section instead of a nonexistent Agents.md anchor. The Workflows-local surface links to templates/consumer-repo/WORKFLOW_USER_GUIDE.md because there is no root guide, while the distributed consumer links to its root WORKFLOW_USER_GUIDE.md. Prior fingerprint refresh 2026-08-24: both surfaces treat automated issues as format-ineligible, complementing the shared guard cleanup that removes stale agents:format leases from exempt issues. Underlying divergence unchanged: root remains in-tree (scripts/langchain + .github/scripts/issue_format.py); consumer vendors those via Workflows sparse-checkout under workflows-scripts/. Do not align wholesale — that would strip consumer action pins/token setup. -divergence_reviewed = 2026-08-24 +divergence_reviewed = 2026-08-23 fingerprint_refreshed = 2026-08-24 [pair.12] @@ -145,7 +145,7 @@ template = templates/consumer-repo/.github/workflows/agents-keepalive-loop-repor main_sha256 = b57ab568475f34cab079bdbb3b55229b393c2bd482dd91951b331b78a039c097 template_sha256 = fc2e824dc22b1f7677ca92f9877d0a27898975adb56e26b2933190f87787a2ea divergence = client-id rename 2026-08-24: every create-github-app-token step moved from the deprecated `app-id` input to `client-id`, applied identically to root and consumer, with the secret VALUE unchanged (the action forwards either input to the same appId slot -- main.js:21, lib/main.js:37). Fingerprints refreshed for that change; the divergence itself is unchanged and was NOT re-reviewed, which is why divergence_reviewed keeps its old date. Prior divergence unchanged: Named-secrets rollout 2026-08-23: both surfaces now pass named setup-api-client secret inputs instead of the whole-secrets-context blob, applied identically to root and consumer, and scoped to each workflow's DECLARED workflow_call secrets where that context is a closed set. Removing that handoff is the demonstrated remedy for GitHub's suspicious-workflow hold (agents-dedup then ran at run_attempt 1 with nothing approved after 22 days held). Prior divergence unchanged: Intentional divergence re-reviewed 2026-08-15: both reporters mint the dedicated KEEPALIVE_APP token with WORKFLOWS_APP fallback, fail closed before trusted summary writes, and record the selected App writer. The root keeps its in-repo setup helpers while the consumer retains state fingerprinting and SHA-pinned actions; do not align wholesale. -divergence_reviewed = 2026-08-23 +divergence_reviewed = 2026-08-15 fingerprint_refreshed = 2026-08-24 [pair.13] @@ -154,7 +154,7 @@ template = templates/consumer-repo/.github/workflows/agents-keepalive-sweep.yml main_sha256 = d298749854d5a646fae5197fd242ef656487b7a57d6f2e690cd52e838d2fb9e1 template_sha256 = 147d4da9048717e1cf86bcacfd51b4080cc3e228ff7851c3acf2d8fefc2a54ac divergence = Intentional divergence re-reviewed 2026-08-13: root and consumer sweeps share HMAC-signed due-authority claims bound to repository, PR, fingerprint, nonce, and exact sweep run; missing signing material fails closed to ordinary non-forced rechecks. Both bypass state debounce for ordinary sweep recovery, retain completed-runner debounce for ordinary wakeups, and bypass runner debounce only for a verified signed due claim, while the consumer retains its consolidated gate-followup dispatch path, inverted mode guard, and pinned action contract. -divergence_reviewed = 2026-08-23 +divergence_reviewed = 2026-08-13 fingerprint_refreshed = 2026-08-23 [pair.14] @@ -172,7 +172,7 @@ template = templates/consumer-repo/.github/workflows/agents-weekly-metrics.yml main_sha256 = fbb51ad2a0f26947a12c4f02b42d6c193c1ac5f1a1489348a9b6e72b451e3dab template_sha256 = ee03c1c1168a8e127e863b22e1e45591ba9f6975a8bc04a6b9c92a06a4835921 divergence = client-id rename 2026-08-24: every create-github-app-token step moved from the deprecated `app-id` input to `client-id`, applied identically to root and consumer, with the secret VALUE unchanged (the action forwards either input to the same appId slot -- main.js:21, lib/main.js:37). Fingerprints refreshed for that change; the divergence itself is unchanged and was NOT re-reviewed, which is why divergence_reviewed keeps its old date. Prior divergence unchanged: Named-secrets rollout 2026-08-23: both surfaces now pass named setup-api-client secret inputs instead of the whole-secrets-context blob, applied identically to root and consumer, and scoped to each workflow's DECLARED workflow_call secrets where that context is a closed set. Removing that handoff is the demonstrated remedy for GitHub's suspicious-workflow hold (agents-dedup then ran at run_attempt 1 with nothing approved after 22 days held). Prior divergence unchanged: Intentional divergence (re-baselined 2026-07-14): consumer template SHA-pins third-party actions per the fleet action-pin contract (docs/HISTORY.md, PR #1925) and sets LangSmith tracing env; root uses floating major tags with repo-internal concurrency + sparse-checkout (docs/fixes/sparse-checkout-audit-2026-02-03.csv). Fingerprints refreshed after the durable-label guard was added to the template. Do not align: would strip consumer action pins. -divergence_reviewed = 2026-08-23 +divergence_reviewed = 2026-07-14 fingerprint_refreshed = 2026-08-24 [pair.16] @@ -181,7 +181,7 @@ template = templates/consumer-repo/.github/workflows/agents-auto-pilot.yml main_sha256 = 1af7c27fc8f3751e708d3bdd6af6eb5f33faec31a896ec307f852cfe9498a095 template_sha256 = 3bf6229c41eae2862f79b6977274f91a0b7cf0ec842ac20e5d6e45f8d310ee4e divergence = client-id rename 2026-08-24: every create-github-app-token step moved from the deprecated `app-id` input to `client-id`, applied identically to root and consumer, with the secret VALUE unchanged (the action forwards either input to the same appId slot -- main.js:21, lib/main.js:37). Fingerprints refreshed for that change; the divergence itself is unchanged and was NOT re-reviewed, which is why divergence_reviewed keeps its old date. Prior divergence unchanged: Fingerprints refreshed 2026-08-23: both surfaces now pass the named setup-api-client secret inputs instead of the whole-secrets-context blob, applied identically to root and consumer so it introduces no new divergence. That removal is what cleared GitHub's suspicious-workflow hold on agents-dedup (#3185), which then ran at run_attempt 1 with nothing approved after 22 days held. Underlying divergence unchanged: Intentional divergence reviewed 2026-08-21: the Workflows-local auto-pilot retains Workflows-only PR-meta and keepalive fallbacks, while the consumer template dispatches only the consolidated Agents 80 and Agents 81 entry points. The sync manifest delivers the consumer-specific template so retired Workflows-local targets cannot be restored in consumer repositories. -divergence_reviewed = 2026-08-23 +divergence_reviewed = 2026-08-21 fingerprint_refreshed = 2026-08-24 [pair.17] diff --git a/scripts/check_template_drift.py b/scripts/check_template_drift.py index fb96c72d5..e87f212a1 100644 --- a/scripts/check_template_drift.py +++ b/scripts/check_template_drift.py @@ -9,6 +9,7 @@ import re import sys from dataclasses import dataclass +from datetime import date, datetime from pathlib import Path import yaml @@ -31,6 +32,8 @@ class AllowlistEntry: main_sha256: str template_sha256: str reason: str + divergence_reviewed: str = "" + fingerprint_refreshed: str = "" def allows( self, @@ -79,6 +82,8 @@ class PairResult: main_sha256: str template_sha256: str reason: str = "" + divergence_reviewed: str = "" + fingerprint_refreshed: str = "" # Matches a GitHub Actions `uses:` line and captures the action path (owner/repo, @@ -183,6 +188,10 @@ def read_allowlist(path: Path) -> TemplateDriftAllowlist: "divergence", fallback=parser.get(section, "reason", fallback=""), ), + divergence_reviewed=parser.get(section, "divergence_reviewed", fallback="").strip(), + fingerprint_refreshed=parser.get( + section, "fingerprint_refreshed", fallback="" + ).strip(), ) ) return TemplateDriftAllowlist(tuple(entries)) @@ -255,6 +264,8 @@ def check_pairs( template_text = pair.template_path.read_text(encoding="utf-8") template_hash = normalized_sha256(template_text) + reviewed = "" + refreshed = "" if normalize_text(main_text) == normalize_text(template_text): status = "in_sync" reason = "" @@ -265,17 +276,20 @@ def check_pairs( template_text=template_text, ): status = "allowlisted" - reason = next( + matched = next( ( - entry.reason + entry for entry in allowlist.entries if entry.main_path == main_rel and entry.template_path == template_rel and entry.main_sha256 == main_hash and entry.template_sha256 == template_hash ), - "", + None, ) + reason = matched.reason if matched else "" + reviewed = matched.divergence_reviewed if matched else "" + refreshed = matched.fingerprint_refreshed if matched else "" else: status = "drift" reason = "unallowlisted content differs" @@ -288,11 +302,26 @@ def check_pairs( main_sha256=main_hash, template_sha256=template_hash, reason=reason, + divergence_reviewed=reviewed, + fingerprint_refreshed=refreshed, ) ) return results +def _days_since(iso_date: str, *, today: date | None = None) -> int | None: + """Whole days since ``iso_date``, or None when it does not parse. + + None is returned rather than 0 so an unparseable date reads as "unknown" and + not as "reviewed today". + """ + try: + parsed = datetime.strptime(iso_date.strip(), "%Y-%m-%d").date() + except (ValueError, AttributeError): + return None + return ((today or date.today()) - parsed).days + + def render_summary(results: list[PairResult]) -> str: lines = ["# Template Drift Report", ""] counts = { @@ -319,12 +348,43 @@ def render_summary(results: list[PairResult]) -> str: lines.append("") if counts["allowlisted"]: + allowlisted = [result for result in results if result.status == "allowlisted"] + # THE COUPLED-BUMP COUNT, REPORTED WHERE THE PAIRS ARE READ. + # `divergence_reviewed` is a judgement that two files SHOULD differ; + # `fingerprint_refreshed` is a mechanical hash bump. Equal dates on many pairs + # is the signature of the second being copied into the first, which is the + # false claim the field split existed to remove. Reported, never enforced: a + # stale review date must not block a fingerprint refresh, or the gate blocks + # its own drain. + coupled = [ + result + for result in allowlisted + if result.divergence_reviewed + and result.divergence_reviewed == result.fingerprint_refreshed + ] lines.extend(["## Allowlisted Baseline Drift", ""]) - for result in results: - if result.status == "allowlisted": - lines.append(f"- `{result.main_path}` -> `{result.template_path}`") - if result.reason: - lines.append(f" - reason: {result.reason}") + lines.append( + f"- pairs where divergence_reviewed == fingerprint_refreshed: " + f"{len(coupled)} of {len(allowlisted)}" + ) + lines.append("") + for result in allowlisted: + lines.append(f"- `{result.main_path}` -> `{result.template_path}`") + if result.divergence_reviewed: + age = _days_since(result.divergence_reviewed) + age_text = f"{age}d ago" if age is not None else "unparseable date" + marker = ( + " (same date as fingerprint_refreshed)" + if result.divergence_reviewed == result.fingerprint_refreshed + else "" + ) + lines.append( + f" - divergence reviewed: {result.divergence_reviewed} " + f"({age_text}); fingerprint refreshed: " + f"{result.fingerprint_refreshed or 'unset'}{marker}" + ) + if result.reason: + lines.append(f" - reason: {result.reason}") lines.append("") return "\n".join(lines) @@ -340,10 +400,9 @@ def print_allowlist_template(results: list[PairResult]) -> None: print(f"template = {result.template_path}") print(f"main_sha256 = {result.main_sha256}") print(f"template_sha256 = {result.template_sha256}") - print( - "reason = Existing reviewed baseline drift; align the template or " - "update this fingerprint deliberately." - ) + print("divergence = Explain why these files intentionally differ.") + print("divergence_reviewed = YYYY-MM-DD # date that rationale was reviewed") + print("fingerprint_refreshed = YYYY-MM-DD # date hashes were refreshed") print() diff --git a/tests/scripts/test_sync_manifest_docs.py b/tests/scripts/test_sync_manifest_docs.py index 1f80f88f2..270664d6b 100644 --- a/tests/scripts/test_sync_manifest_docs.py +++ b/tests/scripts/test_sync_manifest_docs.py @@ -28,8 +28,17 @@ def _unpaired_issue_references(line: str) -> list[str]: return [match.group(0) for match in ISSUE_REFERENCE.finditer(residue)] -def test_manifest_issue_citations_are_explicitly_stateful() -> None: - """Manifest citations name whether the referenced issue is open or resolved.""" +def test_manifest_issue_references_are_open() -> None: + """Every manifest issue citation names whether that issue is open or resolved. + + This is the strict check, and it is the one #3183's acceptance criterion cited by + node id. It ran under the name `test_manifest_issue_citations_are_explicitly_stateful` + while the name `test_manifest_issue_references_are_open` belonged to the + network-gated probe below -- which only iterates citations marked `open:`, of which + the manifest has ZERO (five are `resolved:`). So the cited node id resolved to a + test that verified nothing even with a token present. The names are now swapped so + the cited id names the check that does the work. + """ offenders = [ line.strip() for line in MANIFEST.read_text(encoding="utf-8").splitlines() @@ -46,8 +55,14 @@ def test_issue_state_parser_associates_each_citation() -> None: assert _unpaired_issue_references("open: #2158; also #2157") == ["#2157"] -def test_manifest_issue_references_are_open() -> None: - """Live guard for manifest citations explicitly marked as open.""" +def test_open_manifest_issue_citations_are_still_open() -> None: + """Live guard for the subset of citations explicitly marked `open:`. + + NAME SAYS THE SCOPE ON PURPOSE: this iterates only `open:` citations, so when the + manifest has none it is vacuous by construction, not broken. That is fine for a + live probe and fatal for an acceptance criterion, which is why the strict offline + check above now carries the cited name. + """ if not (os.environ.get("GH_TOKEN") or os.environ.get("GITHUB_TOKEN")): pytest.skip("GH_TOKEN or GITHUB_TOKEN is required to verify manifest issue references") diff --git a/tests/scripts/test_template_drift_allowlist.py b/tests/scripts/test_template_drift_allowlist.py index 7f9b6ede3..15a4d8358 100644 --- a/tests/scripts/test_template_drift_allowlist.py +++ b/tests/scripts/test_template_drift_allowlist.py @@ -1,6 +1,9 @@ from __future__ import annotations +import collections import configparser +import re +from datetime import date from pathlib import Path from scripts.check_template_drift import read_allowlist @@ -8,21 +11,157 @@ ROOT = Path(__file__).resolve().parents[2] ALLOWLIST = ROOT / "config/template-drift-allowlist.txt" +# The prose records a REVIEW as a verb followed by a date: "re-reviewed 2026-06-20", +# "re-baselined 2026-06-30", "updated 2026-08-05", "reviewed 2026-08-21". A bare date +# is NOT a review — most of these rationales open with a mechanical event ("client-id +# rename 2026-08-24: ... the divergence itself is unchanged and was NOT re-reviewed"), +# and reading that date as a review is exactly the conflation this file now forbids. +# The rationales are layered newest-first, so the FIRST match is the most recent review. +_REVIEW_DATE = re.compile( + r"(?:re-reviewed|reviewed|re-baselined|baselined|updated)\s+(20\d\d-\d\d-\d\d)", + re.IGNORECASE, +) -def test_every_pair_states_its_divergence() -> None: +# At most this many pairs may leave their review date unstated in prose. It is not +# zero because one pair's rationale is entirely fingerprint refreshes and the +# Non-Goals forbid rewriting the twenty rationales; it is not unbounded because +# "the prose says nothing" must not become the way every pair escapes the check. +MAX_PAIRS_WITHOUT_A_STATED_REVIEW = 1 + + +def stated_review_date(divergence: str) -> str | None: + """The review date this rationale states, or None when it states none.""" + match = _REVIEW_DATE.search(divergence) + return match.group(1) if match else None + + +def is_calendar_date(value: str) -> bool: + try: + date.fromisoformat(value) + except ValueError: + return False + return True + + +def _pairs() -> dict[str, configparser.SectionProxy]: parser = configparser.ConfigParser(interpolation=None) parser.read(ALLOWLIST, encoding="utf-8") + return {section: parser[section] for section in parser.sections()} + +def test_every_pair_states_its_divergence() -> None: + """Each pair's `divergence_reviewed` must equal the date its own prose records. + + Non-emptiness was the old assertion, and a blanket `divergence_reviewed = ` + on all twenty pairs passed it. The field is only worth having if it can be checked + against something, and the only independent source is the rationale beside it. + """ + pairs = _pairs() assert ALLOWLIST.name == "template-drift-allowlist.txt" - assert len(parser.sections()) >= 1 + assert len(pairs) >= 1 - for section in parser.sections(): - divergence = parser.get(section, "divergence", fallback="").strip() - reviewed = parser.get(section, "divergence_reviewed", fallback="").strip() - refreshed = parser.get(section, "fingerprint_refreshed", fallback="").strip() + unstated: list[str] = [] + mismatched: list[str] = [] + for section, entry in pairs.items(): + divergence = entry.get("divergence", "").strip() + reviewed = entry.get("divergence_reviewed", "").strip() + refreshed = entry.get("fingerprint_refreshed", "").strip() assert divergence and "Existing reviewed baseline drift" not in divergence - assert reviewed - assert refreshed + assert is_calendar_date( + reviewed + ), f"{section}: divergence_reviewed must be a calendar date, got {reviewed!r}" + assert is_calendar_date( + refreshed + ), f"{section}: fingerprint_refreshed must be a calendar date, got {refreshed!r}" + + stated = stated_review_date(divergence) + if stated is None: + unstated.append(section) + elif stated != reviewed: + mismatched.append( + f"{section}: prose records a review on {stated}, " + f"divergence_reviewed says {reviewed}" + ) + + assert ( + mismatched == [] + ), "divergence_reviewed disagrees with the pair's own rationale:\n" + "\n".join(mismatched) + assert len(unstated) <= MAX_PAIRS_WITHOUT_A_STATED_REVIEW, ( + f"{len(unstated)} pairs state no review date in their rationale, so their " + f"divergence_reviewed cannot be checked against anything: {unstated}" + ) + + +def test_divergence_reviewed_is_not_a_blanket_stamp() -> None: + """No single `divergence_reviewed` value may cover half the pairs or more. + + Twenty pairs carrying one date is not twenty reviews on one day; it is one write + asserting twenty judgements. Measured before this fix: 19 of 20 read 2026-08-23. + """ + pairs = _pairs() + counts = collections.Counter( + entry.get("divergence_reviewed", "").strip() for entry in pairs.values() + ) + value, multiplicity = counts.most_common(1)[0] + assert multiplicity < len(pairs) / 2, ( + f"{multiplicity} of {len(pairs)} pairs share divergence_reviewed = {value!r}; " + "that is a blanket stamp, not a per-pair review" + ) + + +def test_divergence_reviewed_is_not_a_copy_of_fingerprint_refreshed() -> None: + """The two dates may coincide only when the pair's own prose names that date. + + A mechanical hash refresh must not be able to move the review claim. Equal dates + are allowed where a genuine same-day review is recorded in the rationale, and + forbidden where the only evidence is that the fingerprint moved. + """ + offenders = [] + for section, entry in _pairs().items(): + reviewed = entry.get("divergence_reviewed", "").strip() + refreshed = entry.get("fingerprint_refreshed", "").strip() + if reviewed != refreshed: + continue + if stated_review_date(entry.get("divergence", "")) == reviewed: + continue + offenders.append( + f"{section}: divergence_reviewed == fingerprint_refreshed == {reviewed}, " + "and the rationale does not record a review on that date" + ) + + assert ( + offenders == [] + ), "a fingerprint refresh appears to have carried the review claim with it:\n" + "\n".join( + offenders + ) + + +def test_review_date_parser_reads_the_verb_not_a_bare_date() -> None: + """The parser must not treat a mechanical event's date as a review.""" + assert stated_review_date("Intentional divergence re-reviewed 2026-06-20: ...") == ( + "2026-06-20" + ) + assert stated_review_date("Intentional divergence (re-baselined 2026-07-14): ...") == ( + "2026-07-14" + ) + assert stated_review_date("... last updated 2026-08-05 ...") == "2026-08-05" + + # A bare date with no review verb is not a review. + assert stated_review_date("client-id rename 2026-08-24: fingerprints refreshed") is None + assert stated_review_date("no dates at all here") is None + + # Layered rationales are newest-first, so the FIRST review verb wins. + layered = ( + "Named-secrets rollout 2026-08-23: applied identically. " + "Prior divergence unchanged: Intentional divergence re-reviewed 2026-08-16: ..." + ) + assert stated_review_date(layered) == "2026-08-16" + + +def test_calendar_date_validation_rejects_date_shaped_typos() -> None: + assert is_calendar_date("2026-08-24") + assert not is_calendar_date("2026-99-99") + assert not is_calendar_date("2026-02-30") def test_read_allowlist_prefers_divergence_and_supports_legacy_reason(tmp_path: Path) -> None: