From db27f37845e97f12aaba2c36cf0d4200576b9081 Mon Sep 17 00:00:00 2001 From: xeonvs <11463419+xeonvs@users.noreply.github.com> Date: Fri, 23 Oct 2026 18:08:25 +0200 Subject: [PATCH 1/8] Plan v0.13.1 gitlink partial review --- PLANS.md | 133 ++++++++++++++++++++- docs/codex/ENGINEERING_WORKFLOW_STATE.yaml | 2 +- 2 files changed, 131 insertions(+), 4 deletions(-) diff --git a/PLANS.md b/PLANS.md index cce9cdf..94f1276 100644 --- a/PLANS.md +++ b/PLANS.md @@ -1,8 +1,135 @@ # Execution Plans -Use this file for active or blocked repository work. Update it before implementation and -before handoff or commit. Completed stable plans are indexed in -[the execution-history archive](docs/engineering/execution_history/README.md). +## Active Plan: Toolkit 0.13.1 partial review of changes containing gitlinks + +plan_schema_version: 2 +Status: active +Owner: root +Last Updated: 2026-10-23 +Release classification: #268 `release-required`, target v0.13.1. + +### Goal + +Deliver #268 as a toolkit-only, explicitly partial and non-approvable review of ordinary changes alongside safely separable gitlinks, then publish and independently reconcile stable v0.13.1. Preserve the original immutable review identities and native OCR result truth; keep `.next-version=0.14.0`. + +### Plan Origin + +`plan_mode_approved` + +### Requested Scope + +Implement private input planning, OCR 1.13.3-only process-local Git projection, preview/production qualification, typed effective coverage and no-model statuses, shared local/GitLab reporting, safe posting/lifecycle, synthetic/adversarial and real-binary checks, public contracts and Feature notes. Create a v0.13.1 milestone for #268. Reconcile current v0.13.0 status documents against the published release without rewriting its archived plan. Use a plan-only first commit, self-review each logical change, protected feature/release PRs and external stable readback. + +### Requirement Traceability + +| Requirement | Outcome | Queue | Status | +| --- | --- | --- | --- | +| REQ-001 | Classify full before/after tree shape and immutable review identity; admit only pure gitlink add/update/delete/unambiguous move with no submodule reads or fetches | WQ-02, WQ-06 | pending | +| REQ-002 | Qualify OCR 1.13.3 process-local projection against independent ordinary diff, bounded preview and production manifest; fail closed on drift, diagnostics or mismatch | WQ-03, WQ-06 | pending | +| REQ-003 | Preserve native result truth; version closed toolkit coverage receipt v12 and pre-execution status v6; preserve v11 history | WQ-04, WQ-06 | pending | +| REQ-004 | Report partial/no-supported-work outcomes locally and in GitLab; block unsupported findings, approval and automatic cleanup; retain failure precedence | WQ-04, WQ-05, WQ-06 | pending | +| REQ-005 | Prove pure and hostile shapes with checksum-verified OCR 1.13.3/local stub, focused contracts, complete quality, security and installed-artifact checks | WQ-02–WQ-07 | pending | +| REQ-006 | Update operator, security, decision-flow, compatibility, GitLab and changelog contracts without a new CLI flag or rules-contract change | WQ-06 | pending | +| REQ-007 | Protected feature PR, development artifact readback and protected v0.13.1 release PR with exact metadata and archived plan | WQ-07, WQ-08 | pending | +| REQ-008 | After release merge, verify stable registries, provenance, tag, immutable Release/receipt, #268/milestone, site notification and external state | WQ-09; Post-Close Delivery | out_of_scope | +| REQ-009 | Apply bounded tool-use decisions from prior release-chat audits while preserving mandatory gates | WQ-01–WQ-09 | pending | +| REQ-010 | Correct current status-bearing v0.13.0 docs from live evidence; keep archived prepublication snapshot intact | WQ-06, WQ-08 | pending | + +### Explicit Non-Goals + +No OCR fork/upstream change, consumer-CI bypass, submodule initialization/fetch/worktree content review, synthetic sanitized repository, new provider/LLM/public flag, automatic approval or cleanup of unsupported changes, related-MR work, or activation of conditional BL-019/BL-025. Qualification fixtures make no paid model call or forge write. Unproven shapes retain the no-model stop. + +### Constraints + +- Keep original GitLab H/B/T/P, diff version, job, rules and local immutable from/to identities. Unqualified or stale identity never gains publication or approval authority. +- Build a bounded private plan from full tree mode/OID entries, unfiltered submodule inventory, relevant `.gitmodules` blobs and consulted local Git inputs; reject ambiguity, limits and drift. Keep paths, OIDs, URLs and sensitive digests out of public receipts. +- For the OCR child only, scrub conflicting Git environment, set `diff.ignoreSubmodules=all` and exact `submodule..ignore=all`, and freeze the same args/env/runtime inputs for preview and production. Do not persist Git config, modify refs or execute hooks, external diff, textconv, credentials or submodule helpers. +- Validate whole bounded preview and ordinary-change identities, not only exit code/stderr/counts. Check native manifest against admitted work and disallow reuse here. Native failure, DLP and write errors outrank partial coverage. +- Version exact receipt/status schemas rather than appending fields to v11/v5. `ocr-ci post` independently validates identity-bound facts. Never alter native OCR coverage to claim repository coverage. +- No OCR-selected ordinary work: `ocr-ci review` returns 2; GitLab `post` publishes advisory status and returns 0, or 1 in existing strict mode. Local output carries the same closed reason without model execution. +- Protected release PR is the final repository mutation; external publication/readback remains required post-close delivery. + +### Inputs And Sources + +- Approved user plan and issue #268: https://github.com/xeonvs/open-code-review-toolkit/issues/268. +- Canonical owners: `docs/engineering/project_principles.md`, `docs/development.md`, `docs/release.md`, `docs/review-decision-flow.md`, `docs/operations.md`, `docs/local.md`, `docs/security.md`, and installed engineering-workflow planning reference. +- Runtime owners: `evidence/gitlinks.py`, `review_runner.py`, `review_receipt.py`, `pre_execution.py`, `result_contract.py`, `reporting/`, `posting/`. +- Baseline: clean synchronized `main`, published v0.13.0, `.next-version=0.14.0`, no active plan, #268 only open issue; remaining backlog triggers unmet. +- Pre-implementation proof: official OCR 1.13.3 Darwin arm64 SHA-256 `fa9b8530c0a35e6106a9ab2e81c21d98ef26d3f31c1f39ad3ef4c2528047c4f4`. Five mixed pure-gitlink synthetic forms had clean preview and production manifest `complete`, exactly ordinary selected/completed items, zero failed/reused; gitlink-only preview `files=[]`; both type-transition directions had duplicate same-path entries, one with a warning. This proves measured forms, not the integrated toolkit or all inputs. +- Prior release chats were read as historical process evidence, not authorization. Their command counts span multiple releases; repeated broad searches/unchanged CI reads motivate decision-bound calls without an arbitrary call quota. + +### User Decisions And Answers + +Target v0.13.1, keep 0.14.0 development marker; include #268 and required adversarial/contract checks, leave conditional backlog inactive; no-model GitLab post is advisory by default and strict only under existing strict mode; embed tool-use improvements in this execution, not a separate workflow PR; implement through protected feature and stable release lifecycle. + +### Completed Baseline State + +v0.13.0 is published with immutable assets and #264 closed. Its archived plan is a historical prepublication snapshot; current roadmap/index wording may need factual correction. OCR 1.13.3 is qualified in `compatibility/ocr-support.json`, while host default `ocr` is 1.13.2. The feasibility probe used a local fake provider and left no repository changes. `codex/gitlink-partial-review-0131` was created from clean synchronized main. + +### Current Work Queue + +| Queue | Requirements | Exit evidence | Status | +| --- | --- | --- | --- | +| WQ-01 | REQ-009 | Full plan/state pointer, reviewed plan-only signed commit, #268 milestone | in_progress | +| WQ-02 | REQ-001, REQ-005 | Private before/after shape and safe `.gitmodules`/Git inventory, adversarial tests, self-reviewed commit | pending | +| WQ-03 | REQ-002, REQ-005 | Frozen OCR invocation/environment, independent diff, preview/native-manifest qualification, real-binary regression, self-reviewed commit | pending | +| WQ-04 | REQ-003, REQ-004 | Exact receipt v12/status v6, effective coverage/reporting and local no-model path, hostile/legacy tests, self-reviewed commit | pending | +| WQ-05 | REQ-004 | GitLab posting, finding admission, approval blocker and partial lifecycle retention with failure/freshness tests, self-reviewed commit | pending | +| WQ-06 | REQ-005, REQ-006, REQ-010 | Synthetic/installed OCR matrix, public contracts, threat model, decision flow, examples, Feature fragment, factual status correction, self-reviewed commit | pending | +| WQ-07 | REQ-005, REQ-007 | Scoped/final quality, build, secret/dependency gates, security diff scan, real local OCR review, aggregate self-review, protected feature PR/merge, dev artifacts | pending | +| WQ-08 | REQ-007, REQ-010 | Release markers/metadata/epoch/notes, checked plan archive/reset, release validation, protected signed release PR | pending | +| WQ-09 | REQ-008 | Protected release merge, stable publication, receipt and external reconciliation | out_of_scope | + +### Locked Decisions + +Only proven OCR 1.13.3 pure-pointer projection proceeds. Unique identical-OID before/after pairs may count as moves; ambiguity stops. Keep OCR selection native and validate observable output. Gitlink-only or no selected ordinary work stops before model. Native `complete` on projected selection remains native complete; toolkit repository coverage is partial. Any changed unsupported gitlink blocks approval regardless of OCR result or project exclusions. Partial publication retains prior finding threads. Receipt v12/status v6 are closed; historical schemas retain their original meaning. + +### Verification + +- Tree/`.gitmodules`/config: add/update/delete/unique move/multiple/unchanged, absent target objects/worktrees, sparse/shallow sufficient/insufficient evidence, file/executable/symlink/directory transitions, collisions, malformed mappings/includes/hostile names, limits and drift. +- Preview: independent unfiltered/projected diff identity, `.gitmodules ignore=none`, conflicting inherited config, ordinary rename/deletion, generated/ignored/excluded/binary/large cases, malformed/oversized/duplicate preview, diagnostics and zero selected ordinary work. +- Result/publication: native complete plus effective partial; failed/partial/budget/DLP precedence; wrong refs/manifest/reuse; stale/cross-job receipt; old v11; omitted-path findings; reports/approval/thread retention; failed writes and later complete reconciliation. +- Installed checksum-verified OCR 1.13.3 with deterministic local fake provider must reproduce original refs and ordinary selected/completed manifest for proven forms, without paid model or forge writes. +- Focused suites per change; Ruff format check and staged Gitleaks before commits; final `scripts/quality.sh check`, installed wheel/sdist boundary, tree/history Gitleaks/dependency audit before push, security diff scan, one correctly invoked local OCR review and protected PR/release gates. Rerun affected checks when inputs change or evidence is incomplete. + +### Latest Validation Results + +2026-10-23 pre-implementation OCR 1.13.3 probe passed for mixed update/add/delete/move/multiple pure gitlinks and demonstrated no-model gitlink-only preview plus unsafe type transitions. Digest matches the pinned manifest. Integrated validation is pending; no feature-branch implementation or release check has run. + +### Risks And Recovery + +Git configuration precedence or local input drift can hide ordinary changes: revalidate consulted inputs and independent diff; stop on uncertainty. False-complete receipt could launder approval/cleanup: exact parsing, original identity binding, independent approval blocker and thread retention. If installed proof fails for a proposed safe form, narrow it and test the fallback; if core mixed behavior cannot be proved, keep the plan active/blocked and do not publish. External publication failures resume only through exact-artifact documented recovery after readback. + +### Resume Point + +WQ-01: review this plan/state pointer, run lifecycle/instruction checks and staged secret scan, then make a signed plan-only commit. Create the v0.13.1 milestone for #268 before WQ-02. + +### Plan Fidelity Check + +- [x] User outcomes, issue acceptance, version and release lifecycle map to REQ and ordered WQ items. +- [x] Non-goals preserve conditional backlog and prevent unqualified/upstream expansion. +- [x] Feasibility results are distinguished from pending integrated qualification; each acceptance family has a verification owner. +- [x] Constraints, failure precedence, external owner, recovery and exact first safe action are recorded. +- [x] Decisions on version, bundle, advisory status and process optimization are retained. + +### Reconciliation Check + +- [x] Clean baseline, published v0.13.0, open #268 and OCR pin were checked before activation. +- [ ] Reconcile changed implementation, test, PR and release facts before dependent transitions and archive. + +### Closure Gate + +- [ ] All in-scope repository requirements and queue items are terminal with validation evidence; external delivery is classified below. +- [ ] No current issue, document, backlog, PR or release candidate state contradicts the result. +- [ ] Checked lifecycle archives the full plan and resets active state in the release PR. + +### Post-Close Delivery + +Required after repository-plan closure: merge exact protected release PR; verify stable TestPyPI/PyPI distributions, provenance, attestations, annotated tag, immutable Release and v2 receipt; close #268/milestone only after receipt readback; confirm site notification/live state; independently compare external facts with receipt and Actions-owned issue comments. No extra repository closure PR. + +### Handoff Notes + +Active on `codex/gitlink-partial-review-0131`; first commit contains only this plan and its active-plan pointer. Keep private probe/model logs out of commits/issues. Before each tool call identify the decision and preserve status/errors; batch only known independent reads/checks, and use completion-driven waits for long tests and CI. ## Recently Completed diff --git a/docs/codex/ENGINEERING_WORKFLOW_STATE.yaml b/docs/codex/ENGINEERING_WORKFLOW_STATE.yaml index 3119227..5c7c490 100644 --- a/docs/codex/ENGINEERING_WORKFLOW_STATE.yaml +++ b/docs/codex/ENGINEERING_WORKFLOW_STATE.yaml @@ -32,7 +32,7 @@ plan_archive_indexes: - docs/engineering/execution_history/README.md - docs/engineering/README.md - docs/README.md -active_plan: null +active_plan: PLANS.md runtime_agent_config_managed: true instruction_contract_version: 3 planning_contract_version: 2 From 203a50e499a8a6d0be33a6450fbe3dd64690a61b Mon Sep 17 00:00:00 2001 From: xeonvs <11463419+xeonvs@users.noreply.github.com> Date: Fri, 23 Oct 2026 18:14:23 +0200 Subject: [PATCH 2/8] Plan immutable gitlink input scope --- PLANS.md | 10 +- src/ocr_toolkit/evidence/gitlinks.py | 152 ++++++++++++++++++++++--- src/ocr_toolkit/evidence/repository.py | 60 ++++++++++ tests/test_gitlinks.py | 150 +++++++++++++++++++++++- 4 files changed, 351 insertions(+), 21 deletions(-) diff --git a/PLANS.md b/PLANS.md index 94f1276..f133d05 100644 --- a/PLANS.md +++ b/PLANS.md @@ -70,9 +70,9 @@ v0.13.0 is published with immutable assets and #264 closed. Its archived plan is | Queue | Requirements | Exit evidence | Status | | --- | --- | --- | --- | -| WQ-01 | REQ-009 | Full plan/state pointer, reviewed plan-only signed commit, #268 milestone | in_progress | -| WQ-02 | REQ-001, REQ-005 | Private before/after shape and safe `.gitmodules`/Git inventory, adversarial tests, self-reviewed commit | pending | -| WQ-03 | REQ-002, REQ-005 | Frozen OCR invocation/environment, independent diff, preview/native-manifest qualification, real-binary regression, self-reviewed commit | pending | +| WQ-01 | REQ-009 | Full plan/state pointer, reviewed plan-only signed commit, #268 milestone | done | +| WQ-02 | REQ-001, REQ-005 | Private before/after shape and safe `.gitmodules`/Git inventory, adversarial tests, self-reviewed commit | done | +| WQ-03 | REQ-002, REQ-005 | Frozen OCR invocation/environment, independent diff, preview/native-manifest qualification, real-binary regression, self-reviewed commit | in_progress | | WQ-04 | REQ-003, REQ-004 | Exact receipt v12/status v6, effective coverage/reporting and local no-model path, hostile/legacy tests, self-reviewed commit | pending | | WQ-05 | REQ-004 | GitLab posting, finding admission, approval blocker and partial lifecycle retention with failure/freshness tests, self-reviewed commit | pending | | WQ-06 | REQ-005, REQ-006, REQ-010 | Synthetic/installed OCR matrix, public contracts, threat model, decision flow, examples, Feature fragment, factual status correction, self-reviewed commit | pending | @@ -94,7 +94,7 @@ Only proven OCR 1.13.3 pure-pointer projection proceeds. Unique identical-OID be ### Latest Validation Results -2026-10-23 pre-implementation OCR 1.13.3 probe passed for mixed update/add/delete/move/multiple pure gitlinks and demonstrated no-model gitlink-only preview plus unsafe type transitions. Digest matches the pinned manifest. Integrated validation is pending; no feature-branch implementation or release check has run. +2026-10-23 pre-implementation OCR 1.13.3 probe passed for mixed update/add/delete/move/multiple pure gitlinks and demonstrated no-model gitlink-only preview plus unsafe type transitions. Digest matches the pinned manifest. The signed plan-only commit is `db27f37`; plan-lifecycle and instruction-contract checks and staged Gitleaks passed. Milestone v0.13.1 (#22) tracks #268. The WQ-02 shape/parser layer passes 19 focused tests (three opt-in real-binary tests skipped), Ruff, formatting, mypy and scoped diff review. Full integrated validation remains pending. ### Risks And Recovery @@ -102,7 +102,7 @@ Git configuration precedence or local input drift can hide ordinary changes: rev ### Resume Point -WQ-01: review this plan/state pointer, run lifecycle/instruction checks and staged secret scan, then make a signed plan-only commit. Create the v0.13.1 milestone for #268 before WQ-02. +WQ-03: freeze the OCR child invocation/environment and qualify preview against an independent ordinary diff, then verify the native manifest. Start with the exact synthetic 1.13.3 shape proven before implementation. ### Plan Fidelity Check diff --git a/src/ocr_toolkit/evidence/gitlinks.py b/src/ocr_toolkit/evidence/gitlinks.py index e143acb..fc76b47 100644 --- a/src/ocr_toolkit/evidence/gitlinks.py +++ b/src/ocr_toolkit/evidence/gitlinks.py @@ -12,7 +12,7 @@ RepositoryObject, ) -GitlinkChangeKind = Literal["added", "updated", "deleted"] +GitlinkChangeKind = Literal["added", "updated", "deleted", "transition"] _SHA_RE = re.compile(r"[0-9a-f]{40}\Z") @@ -24,6 +24,46 @@ class GitlinkChange: kind: GitlinkChangeKind before_oid: str | None after_oid: str | None + before_entry: RepositoryObject | None = None + after_entry: RepositoryObject | None = None + + +@dataclass(frozen=True, slots=True) +class OrdinaryTreeChange: + """One original non-gitlink tree delta, including changes OCR may exclude.""" + + path: str + before: RepositoryObject | None + after: RepositoryObject | None + + +@dataclass(frozen=True, slots=True) +class GitlinkInputPlan: + """Private immutable scope for one original commit range.""" + + base_sha: str + head_sha: str + changes: tuple[GitlinkChange, ...] + moves: tuple[tuple[str, str], ...] + ordinary: tuple[OrdinaryTreeChange, ...] + config_names: tuple[str, ...] + before_gitmodules: tuple[tuple[str, str], ...] + after_gitmodules: tuple[tuple[str, str], ...] + + @property + def counts(self) -> dict[str, int]: + moved_paths = {path for pair in self.moves for path in pair} + return { + "added": sum( + change.kind == "added" and change.path not in moved_paths for change in self.changes + ), + "updated": sum(change.kind == "updated" for change in self.changes), + "deleted": sum( + change.kind == "deleted" and change.path not in moved_paths + for change in self.changes + ), + "moved": len(self.moves), + } def _entries_by_path(entries: tuple[RepositoryObject, ...]) -> dict[str, RepositoryObject]: @@ -31,12 +71,38 @@ def _entries_by_path(entries: tuple[RepositoryObject, ...]) -> dict[str, Reposit for entry in entries: if entry.path in by_path: raise RepositoryEvidenceError("Git tree contains a duplicate path") - if (entry.mode == "160000") != (entry.object_type == "commit"): - raise RepositoryEvidenceError("Git tree contains an invalid gitlink entry") + expected_type = { + "100644": "blob", + "100755": "blob", + "120000": "blob", + "160000": "commit", + }.get(entry.mode) + if expected_type != entry.object_type: + raise RepositoryEvidenceError("Git tree contains an unsupported entry mode or type") by_path[entry.path] = entry return by_path +def _changes_from_maps( + before: dict[str, RepositoryObject], after: dict[str, RepositoryObject] +) -> tuple[GitlinkChange, ...]: + changes: list[GitlinkChange] = [] + for path in sorted(before.keys() | after.keys()): + prior = before.get(path) + current = after.get(path) + prior_oid = prior.object_sha if prior is not None and prior.is_submodule else None + current_oid = current.object_sha if current is not None and current.is_submodule else None + if prior_oid == current_oid: + continue + kind: GitlinkChangeKind + if prior is not None and current is not None and prior.is_submodule != current.is_submodule: + kind = "transition" + else: + kind = "added" if prior_oid is None else "deleted" if current_oid is None else "updated" + changes.append(GitlinkChange(path, kind, prior_oid, current_oid, prior, current)) + return tuple(changes) + + def changed_gitlinks( reader: GitRepositoryReader, base_sha: str, head_sha: str ) -> tuple[GitlinkChange, ...]: @@ -51,16 +117,72 @@ def changed_gitlinks( raise RepositoryEvidenceError("gitlink inspection requires immutable commit IDs") before = _entries_by_path(reader.list_objects(base_sha)) after = _entries_by_path(reader.list_objects(head_sha)) - changes: list[GitlinkChange] = [] - for path in sorted(before.keys() | after.keys()): - prior = before.get(path) - current = after.get(path) - prior_oid = prior.object_sha if prior is not None and prior.is_submodule else None - current_oid = current.object_sha if current is not None and current.is_submodule else None - if prior_oid == current_oid: - continue - kind: GitlinkChangeKind = ( - "added" if prior_oid is None else "deleted" if current_oid is None else "updated" + return _changes_from_maps(before, after) + + +def plan_gitlink_input( + reader: GitRepositoryReader, base_sha: str, head_sha: str +) -> GitlinkInputPlan | None: + """Qualify changed pure pointers and retain every ordinary original delta. + + No submodule target object is opened. Unsafe transitions and ambiguous move + pairing fail before any OCR preview or model call can use the plan. + """ + + if _SHA_RE.fullmatch(base_sha) is None or _SHA_RE.fullmatch(head_sha) is None: + raise RepositoryEvidenceError("gitlink planning requires immutable commit IDs") + before = _entries_by_path(reader.list_objects(base_sha)) + after = _entries_by_path(reader.list_objects(head_sha)) + changes = _changes_from_maps(before, after) + if not changes: + return None + for change in changes: + if change.kind == "transition": + raise RepositoryEvidenceError("gitlink type transition is unsupported") + other_tree = before if change.kind == "added" else after if change.kind == "deleted" else {} + if any(path.startswith(change.path + "/") for path in other_tree): + raise RepositoryEvidenceError("gitlink directory transition is unsupported") + + additions: dict[str, list[str]] = {} + deletions: dict[str, list[str]] = {} + for change in changes: + if change.kind == "added": + assert change.after_oid is not None + additions.setdefault(change.after_oid, []).append(change.path) + elif change.kind == "deleted": + assert change.before_oid is not None + deletions.setdefault(change.before_oid, []).append(change.path) + moves: list[tuple[str, str]] = [] + for oid in additions.keys() & deletions.keys(): + if len(additions[oid]) != 1 or len(deletions[oid]) != 1: + raise RepositoryEvidenceError("gitlink move pairing is ambiguous") + moves.append((deletions[oid][0], additions[oid][0])) + + before_modules = reader.gitmodules_paths(base_sha) + after_modules = reader.gitmodules_paths(head_sha) + changed_paths = {change.path for change in changes} + names = { + name + for mapping in (before_modules, after_modules) + for name, path in mapping.items() + if path in changed_paths + } + ordinary = tuple( + OrdinaryTreeChange(path, prior, current) + for path in sorted(before.keys() | after.keys()) + if (prior := before.get(path)) != (current := after.get(path)) + and not ( + (prior is not None and prior.is_submodule) + or (current is not None and current.is_submodule) ) - changes.append(GitlinkChange(path, kind, prior_oid, current_oid)) - return tuple(changes) + ) + return GitlinkInputPlan( + base_sha, + head_sha, + changes, + tuple(sorted(moves)), + ordinary, + tuple(sorted(names)), + tuple(sorted(before_modules.items())), + tuple(sorted(after_modules.items())), + ) diff --git a/src/ocr_toolkit/evidence/repository.py b/src/ocr_toolkit/evidence/repository.py index 8cd4ee1..67c7282 100644 --- a/src/ocr_toolkit/evidence/repository.py +++ b/src/ocr_toolkit/evidence/repository.py @@ -27,6 +27,9 @@ MAX_BATCH_BLOB_BYTES = 32_000_000 MAX_GIT_TEXT_BYTES = 8_000_000 MAX_ANCESTRY_FETCH_DEPTH = 128 +MAX_GITMODULES_BYTES = 128_000 +MAX_GITMODULES_ENTRIES = 256 +MAX_SUBMODULE_NAME_BYTES = 256 def _git_environment() -> dict[str, str]: @@ -325,6 +328,63 @@ def read_blob(self, ref: str, path: str) -> bytes | None: raise RepositoryEvidenceError("failed to read the complete repository blob") return content.stdout + def gitmodules_paths(self, ref: str) -> dict[str, str]: + """Parse one committed .gitmodules blob without includes or URL access. + + The mapping is name to normalized path. Callers retain it privately and + only use names as data in process-local Git configuration, never a shell. + """ + + entry = self.object_at(ref, ".gitmodules") + if entry is None: + return {} + if entry.mode != "100644" or entry.object_type != "blob": + raise RepositoryEvidenceError(".gitmodules is not a regular configuration blob") + if self.bounded_regular_blob_size(entry) > MAX_GITMODULES_BYTES: + raise RepositoryEvidenceError(".gitmodules exceeds the bounded byte limit") + result = self._run( + ["config", f"--blob={entry.object_sha}", "--no-includes", "-z", "--list"], + text=False, + ) + assert isinstance(result.stdout, bytes) + if result.returncode != 0 or len(result.stdout) > MAX_GITMODULES_BYTES * 2: + raise RepositoryEvidenceError(".gitmodules configuration is invalid or too large") + records = result.stdout.split(b"\0") + if records[-1] != b"": + raise RepositoryEvidenceError(".gitmodules configuration is truncated") + records.pop() + if len(records) > MAX_GITMODULES_ENTRIES: + raise RepositoryEvidenceError(".gitmodules has too many entries") + paths: dict[str, str] = {} + seen_paths: set[str] = set() + for record in records: + if record.count(b"\n") != 1: + raise RepositoryEvidenceError(".gitmodules contains an invalid entry") + raw_key, raw_value = record.split(b"\n", 1) + try: + key = raw_key.decode("utf-8") + value = raw_value.decode("utf-8") + except UnicodeDecodeError as exc: + raise RepositoryEvidenceError(".gitmodules contains invalid UTF-8") from exc + if key.startswith(("include.", "includeif.")): + raise RepositoryEvidenceError(".gitmodules includes are unsupported") + if not key.startswith("submodule.") or not key.endswith(".path"): + continue + name = key[len("submodule.") : -len(".path")] + if ( + not name + or len(name.encode("utf-8")) > MAX_SUBMODULE_NAME_BYTES + or any(ord(char) < 32 or ord(char) == 127 for char in name) + or name in paths + ): + raise RepositoryEvidenceError(".gitmodules submodule name is ambiguous") + path = normalize_repo_path(value) + if path in seen_paths: + raise RepositoryEvidenceError(".gitmodules path is ambiguous") + paths[name] = path + seen_paths.add(path) + return paths + def bounded_regular_blob_size(self, entry: RepositoryObject) -> int: """Validate one exact entry as a bounded regular blob without reading its content.""" diff --git a/tests/test_gitlinks.py b/tests/test_gitlinks.py index 5fb1017..fe29143 100644 --- a/tests/test_gitlinks.py +++ b/tests/test_gitlinks.py @@ -11,7 +11,7 @@ from ocr_toolkit import review_runner from ocr_toolkit.evidence.artifacts import prepare_artifact_directory, repository_artifacts -from ocr_toolkit.evidence.gitlinks import changed_gitlinks +from ocr_toolkit.evidence.gitlinks import changed_gitlinks, plan_gitlink_input from ocr_toolkit.evidence.repository import GitRepositoryReader, RepositoryEvidenceError from ocr_toolkit.preflight import validate_ocr_binary @@ -83,6 +83,154 @@ def test_unchanged_gitlink_is_not_reported(tmp_path: Path) -> None: assert changed_gitlinks(reader, base, head)[0].kind == "updated" +@pytest.mark.parametrize("kind", ["added", "updated", "deleted"]) +def test_private_input_plan_retains_original_ordinary_delta(tmp_path: Path, kind: str) -> None: + base, head = _fixture(tmp_path, kind) + plan = plan_gitlink_input(GitRepositoryReader(tmp_path), base, head) + assert plan is not None + assert plan.base_sha == base and plan.head_sha == head + assert plan.counts[kind] == 1 + assert plan.config_names == () + assert [change.path for change in plan.ordinary] == ["main.php"] + assert plan.ordinary[0].before is not None + assert plan.ordinary[0].after is not None + + +def test_unique_gitlink_move_keeps_changed_gitmodules_reviewable(tmp_path: Path) -> None: + _git(tmp_path, "init", "-q") + _git(tmp_path, "config", "user.name", "Synthetic Reviewer") + _git(tmp_path, "config", "user.email", "reviewer@example.invalid") + (tmp_path / "main.php").write_text(" None: + _git(tmp_path, "init", "-q") + _git(tmp_path, "config", "user.name", "Synthetic Reviewer") + _git(tmp_path, "config", "user.email", "reviewer@example.invalid") + if to_gitlink: + (tmp_path / "schema").write_text("ordinary\n", encoding="utf-8") + _git(tmp_path, "add", "schema") + else: + _git(tmp_path, "update-index", "--add", "--cacheinfo", f"160000,{OLD_OID},schema") + _git(tmp_path, "commit", "-qm", "base") + base = _git(tmp_path, "rev-parse", "HEAD") + if to_gitlink: + _git(tmp_path, "update-index", "--cacheinfo", f"160000,{NEW_OID},schema") + else: + (tmp_path / "schema").write_text("ordinary\n", encoding="utf-8") + _git(tmp_path, "add", "schema") + _git(tmp_path, "commit", "-qm", "head") + head = _git(tmp_path, "rev-parse", "HEAD") + + change = changed_gitlinks(GitRepositoryReader(tmp_path), base, head)[0] + assert change.kind == "transition" + assert change.before_entry is not None and change.after_entry is not None + assert {change.before_entry.mode, change.after_entry.mode} == {"100644", "160000"} + with pytest.raises(RepositoryEvidenceError, match="type transition"): + plan_gitlink_input(GitRepositoryReader(tmp_path), base, head) + + +def test_directory_to_gitlink_transition_is_rejected(tmp_path: Path) -> None: + _git(tmp_path, "init", "-q") + _git(tmp_path, "config", "user.name", "Synthetic Reviewer") + _git(tmp_path, "config", "user.email", "reviewer@example.invalid") + (tmp_path / "schema").mkdir() + (tmp_path / "schema" / "part.py").write_text("value = 1\n", encoding="utf-8") + _git(tmp_path, "add", "schema/part.py") + _git(tmp_path, "commit", "-qm", "base") + base = _git(tmp_path, "rev-parse", "HEAD") + _git(tmp_path, "rm", "-q", "--cached", "schema/part.py") + _git(tmp_path, "update-index", "--add", "--cacheinfo", f"160000,{NEW_OID},schema") + _git(tmp_path, "commit", "-qm", "head") + head = _git(tmp_path, "rev-parse", "HEAD") + with pytest.raises(RepositoryEvidenceError, match="directory transition"): + plan_gitlink_input(GitRepositoryReader(tmp_path), base, head) + + +def test_gitlink_to_directory_transition_is_rejected(tmp_path: Path) -> None: + _git(tmp_path, "init", "-q") + _git(tmp_path, "config", "user.name", "Synthetic Reviewer") + _git(tmp_path, "config", "user.email", "reviewer@example.invalid") + _git(tmp_path, "update-index", "--add", "--cacheinfo", f"160000,{OLD_OID},schema") + _git(tmp_path, "commit", "-qm", "base") + base = _git(tmp_path, "rev-parse", "HEAD") + _git(tmp_path, "update-index", "--force-remove", "schema") + (tmp_path / "schema").mkdir() + (tmp_path / "schema" / "part.py").write_text("value = 2\n", encoding="utf-8") + _git(tmp_path, "add", "schema/part.py") + _git(tmp_path, "commit", "-qm", "head") + head = _git(tmp_path, "rev-parse", "HEAD") + with pytest.raises(RepositoryEvidenceError, match="directory transition"): + plan_gitlink_input(GitRepositoryReader(tmp_path), base, head) + + +@pytest.mark.parametrize( + ("content", "error"), + [ + ( + '[submodule "one"]\npath = schema\n[submodule "two"]\npath = schema\n', + "path is ambiguous", + ), + ("[include]\npath = ../ignored.conf\n", "includes are unsupported"), + ('[submodule "one"]\npath = ../outside\n', "normalized and relative"), + ], +) +def test_untrusted_gitmodules_cannot_authorize_projection( + tmp_path: Path, content: str, error: str +) -> None: + base, _head = _fixture(tmp_path, "updated") + (tmp_path / ".gitmodules").write_text(content, encoding="utf-8") + _git(tmp_path, "add", ".gitmodules") + _git(tmp_path, "commit", "-qm", "untrusted mapping") + head = _git(tmp_path, "rev-parse", "HEAD") + with pytest.raises(RepositoryEvidenceError, match=error): + plan_gitlink_input(GitRepositoryReader(tmp_path), base, head) + + +def test_ambiguous_gitlink_move_pairing_is_rejected(tmp_path: Path) -> None: + _git(tmp_path, "init", "-q") + _git(tmp_path, "config", "user.name", "Synthetic Reviewer") + _git(tmp_path, "config", "user.email", "reviewer@example.invalid") + for path in ("old-a", "old-b"): + _git(tmp_path, "update-index", "--add", "--cacheinfo", f"160000,{OLD_OID},{path}") + _git(tmp_path, "commit", "-qm", "base") + base = _git(tmp_path, "rev-parse", "HEAD") + for path in ("old-a", "old-b"): + _git(tmp_path, "update-index", "--force-remove", path) + for path in ("new-a", "new-b"): + _git(tmp_path, "update-index", "--add", "--cacheinfo", f"160000,{OLD_OID},{path}") + _git(tmp_path, "commit", "-qm", "head") + head = _git(tmp_path, "rev-parse", "HEAD") + with pytest.raises(RepositoryEvidenceError, match="ambiguous"): + plan_gitlink_input(GitRepositoryReader(tmp_path), base, head) + + @pytest.mark.parametrize("kind", ["added", "updated", "deleted"]) def test_changed_gitlink_closes_preview_before_model( tmp_path: Path, kind: str, monkeypatch: pytest.MonkeyPatch From 827fc3b2b9da06bc02698bd51d6cef758f7993f3 Mon Sep 17 00:00:00 2001 From: xeonvs <11463419+xeonvs@users.noreply.github.com> Date: Fri, 23 Oct 2026 18:35:30 +0200 Subject: [PATCH 3/8] Qualify OCR gitlink projection and native coverage --- PLANS.md | 12 +- .../evidence/gitlink_projection.py | 615 ++++++++++++++++++ src/ocr_toolkit/review_runner.py | 104 ++- tests/test_gitlinks.py | 511 ++++++++++++++- tests/test_review_runner.py | 2 +- 5 files changed, 1228 insertions(+), 16 deletions(-) create mode 100644 src/ocr_toolkit/evidence/gitlink_projection.py diff --git a/PLANS.md b/PLANS.md index f133d05..0e102df 100644 --- a/PLANS.md +++ b/PLANS.md @@ -48,6 +48,7 @@ No OCR fork/upstream change, consumer-CI bypass, submodule initialization/fetch/ - Version exact receipt/status schemas rather than appending fields to v11/v5. `ocr-ci post` independently validates identity-bound facts. Never alter native OCR coverage to claim repository coverage. - No OCR-selected ordinary work: `ocr-ci review` returns 2; GitLab `post` publishes advisory status and returns 0, or 1 in existing strict mode. Local output carries the same closed reason without model execution. - Protected release PR is the final repository mutation; external publication/readback remains required post-close delivery. +- Feature PR #269 is an early draft containing only plan commit `db27f37`; implementation commits remain local until final feature push. Before that push, finish the codex-security diff review and then a local review through this toolkit with the configured ChatGPT provider and qualified OCR. Fix findings and repeat invalidated checks. Increase test coverage in every touched runtime/contract/reporting module. ### Inputs And Sources @@ -57,10 +58,11 @@ No OCR fork/upstream change, consumer-CI bypass, submodule initialization/fetch/ - Baseline: clean synchronized `main`, published v0.13.0, `.next-version=0.14.0`, no active plan, #268 only open issue; remaining backlog triggers unmet. - Pre-implementation proof: official OCR 1.13.3 Darwin arm64 SHA-256 `fa9b8530c0a35e6106a9ab2e81c21d98ef26d3f31c1f39ad3ef4c2528047c4f4`. Five mixed pure-gitlink synthetic forms had clean preview and production manifest `complete`, exactly ordinary selected/completed items, zero failed/reused; gitlink-only preview `files=[]`; both type-transition directions had duplicate same-path entries, one with a warning. This proves measured forms, not the integrated toolkit or all inputs. - Prior release chats were read as historical process evidence, not authorization. Their command counts span multiple releases; repeated broad searches/unchanged CI reads motivate decision-bound calls without an arbitrary call quota. +- Draft feature PR: https://github.com/xeonvs/open-code-review-toolkit/pull/269 at plan-only head `db27f37`. The user's later workflow clarification governs the final feature push and review order. ### User Decisions And Answers -Target v0.13.1, keep 0.14.0 development marker; include #268 and required adversarial/contract checks, leave conditional backlog inactive; no-model GitLab post is advisory by default and strict only under existing strict mode; embed tool-use improvements in this execution, not a separate workflow PR; implement through protected feature and stable release lifecycle. +Target v0.13.1, keep 0.14.0 development marker; include #268 and required adversarial/contract checks, leave conditional backlog inactive; no-model GitLab post is advisory by default and strict only under existing strict mode; embed tool-use improvements in this execution, not a separate workflow PR; implement through protected feature and stable release lifecycle. Use a plan-only early draft PR, no intermediate feature pushes, broader tests for all touched files, logical signed commits with self-review/amend for fixes, codex-security before the final push and local toolkit/OCR review with ChatGPT after it but still before the push. ### Completed Baseline State @@ -72,8 +74,8 @@ v0.13.0 is published with immutable assets and #264 closed. Its archived plan is | --- | --- | --- | --- | | WQ-01 | REQ-009 | Full plan/state pointer, reviewed plan-only signed commit, #268 milestone | done | | WQ-02 | REQ-001, REQ-005 | Private before/after shape and safe `.gitmodules`/Git inventory, adversarial tests, self-reviewed commit | done | -| WQ-03 | REQ-002, REQ-005 | Frozen OCR invocation/environment, independent diff, preview/native-manifest qualification, real-binary regression, self-reviewed commit | in_progress | -| WQ-04 | REQ-003, REQ-004 | Exact receipt v12/status v6, effective coverage/reporting and local no-model path, hostile/legacy tests, self-reviewed commit | pending | +| WQ-03 | REQ-002, REQ-005 | Frozen OCR invocation/environment, independent diff, preview/native-manifest qualification, real-binary regression, self-reviewed commit | done | +| WQ-04 | REQ-003, REQ-004 | Exact receipt v12/status v6, effective coverage/reporting and local no-model path, hostile/legacy tests, self-reviewed commit | in_progress | | WQ-05 | REQ-004 | GitLab posting, finding admission, approval blocker and partial lifecycle retention with failure/freshness tests, self-reviewed commit | pending | | WQ-06 | REQ-005, REQ-006, REQ-010 | Synthetic/installed OCR matrix, public contracts, threat model, decision flow, examples, Feature fragment, factual status correction, self-reviewed commit | pending | | WQ-07 | REQ-005, REQ-007 | Scoped/final quality, build, secret/dependency gates, security diff scan, real local OCR review, aggregate self-review, protected feature PR/merge, dev artifacts | pending | @@ -94,7 +96,7 @@ Only proven OCR 1.13.3 pure-pointer projection proceeds. Unique identical-OID be ### Latest Validation Results -2026-10-23 pre-implementation OCR 1.13.3 probe passed for mixed update/add/delete/move/multiple pure gitlinks and demonstrated no-model gitlink-only preview plus unsafe type transitions. Digest matches the pinned manifest. The signed plan-only commit is `db27f37`; plan-lifecycle and instruction-contract checks and staged Gitleaks passed. Milestone v0.13.1 (#22) tracks #268. The WQ-02 shape/parser layer passes 19 focused tests (three opt-in real-binary tests skipped), Ruff, formatting, mypy and scoped diff review. Full integrated validation remains pending. +2026-10-23 pre-implementation OCR 1.13.3 probe passed for mixed update/add/delete/move/multiple pure gitlinks and demonstrated no-model gitlink-only preview plus unsafe type transitions. Digest matches the pinned manifest. The signed plan-only commit is `db27f37`; plan-lifecycle and instruction-contract checks and staged Gitleaks passed. Milestone v0.13.1 (#22) tracks #268. Draft feature PR #269 has only the plan commit at its remote head. Local WQ-02 commit `203a50e` is not pushed. WQ-03 adds bounded unfiltered/projected Git inventory, original patch fingerprints and line counts, one frozen OCR child environment, exact preview and native manifest/source-artifact comparison, and before/after drift checks. The checksum-verified OCR 1.13.3 and local fake provider passed add/update/delete/mixed ordinary rename/add/delete/gitlink move forms; the focused runner/gitlink suites passed 247 tests before the final symlink-negative addition. Ruff, formatting and mypy passed. Toolkit-wide integrated production validation and effective-coverage contracts remain pending. ### Risks And Recovery @@ -102,7 +104,7 @@ Git configuration precedence or local input drift can hide ordinary changes: rev ### Resume Point -WQ-03: freeze the OCR child invocation/environment and qualify preview against an independent ordinary diff, then verify the native manifest. Start with the exact synthetic 1.13.3 shape proven before implementation. +WQ-04: define exact receipt v12 and pre-execution status v6 effective coverage, preserve v11/v5, and carry no-model/partial outcomes into local and GitLab reports. Begin by tracing the existing receipt parser and post advisory branch; retain native OCR manifest unchanged. ### Plan Fidelity Check diff --git a/src/ocr_toolkit/evidence/gitlink_projection.py b/src/ocr_toolkit/evidence/gitlink_projection.py new file mode 100644 index 0000000..18daf76 --- /dev/null +++ b/src/ocr_toolkit/evidence/gitlink_projection.py @@ -0,0 +1,615 @@ +"""Private, independently checked Git input for OCR 1.13.3 gitlink reviews.""" + +from __future__ import annotations + +import hashlib +import json +import os +import re +import stat +import subprocess +import tempfile +from dataclasses import dataclass +from pathlib import Path + +from ocr_toolkit.common.git import read_only_git_prefix +from ocr_toolkit.evidence.gitlinks import GitlinkInputPlan +from ocr_toolkit.evidence.repository import GitRepositoryReader, RepositoryEvidenceError + +_SAFE_CONFIG_NAME = re.compile(r"[A-Za-z0-9_][A-Za-z0-9_./-]{0,127}\Z") +_RAW_HEADER = re.compile( + rb":(?P[0-7]{6}) (?P[0-7]{6}) " + rb"(?P[0-9a-f]{40}) (?P[0-9a-f]{40}) " + rb"(?P[AMDRT])(?P[0-9]{0,3})\Z" +) +_ZERO_OID = "0" * 40 +_GIT_INPUT_LIMIT = 512_000 +_DIFF_OUTPUT_LIMIT = 8_000_000 +_DIFF_ENTRY_LIMIT = 20_000 +_LOCAL_INPUTS = (".gitignore", ".gitmodules", ".gitattributes") +_GIT_METADATA_INPUTS = ("config", "info/attributes", "info/exclude") +_PREVIEW_REASONS = frozenset( + { + "user_exclude", + "unsupported_ext", + "default_path", + "secret_exclude", + "provider_directory", + "deleted", + "binary", + "too_large", + } +) + + +@dataclass(frozen=True, slots=True) +class RawDiffEntry: + """One complete raw diff record, preserving both tree endpoints.""" + + old_path: str | None + new_path: str | None + old_mode: str + new_mode: str + old_oid: str + new_oid: str + status: str + + @property + def path(self) -> str: + return self.new_path or self.old_path or "" + + @property + def preview_status(self) -> str: + return { + "A": "added", + "M": "modified", + "D": "deleted", + "R": "renamed", + }[self.status] + + @property + def is_gitlink(self) -> bool: + return "160000" in (self.old_mode, self.new_mode) + + +@dataclass(frozen=True, slots=True) +class GitlinkProjection: + """Frozen original identity, projected ordinary diff and consulted inputs.""" + + plan: GitlinkInputPlan + original_diff: tuple[RawDiffEntry, ...] + ordinary_diff: tuple[RawDiffEntry, ...] + gitlink_diff: tuple[RawDiffEntry, ...] + fingerprints: tuple[tuple[str, str], ...] + line_counts: tuple[tuple[str, int, int], ...] + environment: tuple[tuple[str, str], ...] + input_digest: str + + def child_environment(self) -> dict[str, str]: + return dict(self.environment) + + +def _read_input(path: Path) -> bytes | None: + try: + metadata = path.lstat() + except FileNotFoundError: + return None + if not stat.S_ISREG(metadata.st_mode) or metadata.st_size > _GIT_INPUT_LIMIT: + raise RepositoryEvidenceError("OCR local Git input is unsafe or too large") + flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_NONBLOCK", 0) + descriptor = os.open(path, flags) + try: + opened = os.fstat(descriptor) + if ( + not stat.S_ISREG(opened.st_mode) + or opened.st_ino != metadata.st_ino + or opened.st_dev != metadata.st_dev + or opened.st_size > _GIT_INPUT_LIMIT + ): + raise RepositoryEvidenceError("OCR local Git input changed or is unsafe") + payload = os.read(descriptor, _GIT_INPUT_LIMIT + 1) + if len(payload) > _GIT_INPUT_LIMIT or len(payload) != opened.st_size: + raise RepositoryEvidenceError("OCR local Git input changed or is too large") + return payload + finally: + os.close(descriptor) + + +def _input_digest(root: Path, plan: GitlinkInputPlan) -> str: + paths = {root / path for path in _LOCAL_INPUTS} + git_dir = root / ".git" + try: + git_dir_mode = git_dir.lstat().st_mode + except OSError as exc: + raise RepositoryEvidenceError("OCR Git directory is unavailable") from exc + if not stat.S_ISDIR(git_dir_mode): + # Linked worktrees have an indirect common configuration; leave this + # shape closed until both metadata locations can be pinned. + raise RepositoryEvidenceError("OCR gitlink projection requires a direct Git directory") + paths.update(git_dir / path for path in _GIT_METADATA_INPUTS) + for change in plan.ordinary: + parts = change.path.split("/")[:-1] + for depth in range(1, len(parts) + 1): + paths.add(root.joinpath(*parts[:depth], ".gitattributes")) + digest = hashlib.sha256() + for path in sorted(paths): + payload = _read_input(path) + digest.update(str(path.relative_to(root)).encode("utf-8")) + digest.update(b"\0") + digest.update(b"M" if payload is None else b"P") + if payload is not None: + if path == git_dir / "config" and re.search( + rb"(?im)^\s*\[\s*include(?:if)?(?:\s|\])", payload + ): + raise RepositoryEvidenceError("OCR local Git config includes are unsupported") + digest.update(hashlib.sha256(payload).digest()) + return digest.hexdigest() + + +def _projection_environment(plan: GitlinkInputPlan) -> dict[str, str]: + # The same inherited environment is frozen once for preview and production. + environment = { + key: value + for key, value in os.environ.items() + if not key.startswith("GIT_") and key != "OCR_RAW_LOGGING" + } + environment.update( + { + "GIT_CONFIG_GLOBAL": os.devnull, + "GIT_CONFIG_NOSYSTEM": "1", + "GIT_CONFIG_SYSTEM": os.devnull, + "GIT_NO_REPLACE_OBJECTS": "1", + "GIT_OPTIONAL_LOCKS": "0", + "GIT_PAGER": "cat", + "GIT_TERMINAL_PROMPT": "0", + "GIT_ATTR_NOSYSTEM": "1", + "LC_ALL": "C", + } + ) + overrides = [("diff.ignoreSubmodules", "all")] + for name in plan.config_names: + if _SAFE_CONFIG_NAME.fullmatch(name) is None or ".." in name or name.endswith("/"): + raise RepositoryEvidenceError(".gitmodules name is unsafe for OCR Git config") + overrides.append((f"submodule.{name}.ignore", "all")) + environment["GIT_CONFIG_COUNT"] = str(len(overrides)) + for index, (key, value) in enumerate(overrides): + environment[f"GIT_CONFIG_KEY_{index}"] = key + environment[f"GIT_CONFIG_VALUE_{index}"] = value + return environment + + +def _git_output(root: Path, args: list[str], environment: dict[str, str]) -> bytes: + try: + with tempfile.TemporaryFile() as stdout, tempfile.TemporaryFile() as stderr: + result = subprocess.run( + [*read_only_git_prefix(root), *args], + stdout=stdout, + stderr=stderr, + check=False, + env=environment, + timeout=30, + ) + if result.returncode != 0 or stderr.tell() != 0 or stdout.tell() > _DIFF_OUTPUT_LIMIT: + raise RepositoryEvidenceError( + "OCR Git diff inventory failed or produced diagnostics" + ) + stdout.seek(0) + return stdout.read(_DIFF_OUTPUT_LIMIT + 1) + except (OSError, subprocess.TimeoutExpired) as exc: + raise RepositoryEvidenceError("OCR Git diff inventory could not complete") from exc + + +def _raw_diff( + root: Path, plan: GitlinkInputPlan, environment: dict[str, str], *, reveal_gitlinks: bool +) -> tuple[RawDiffEntry, ...]: + args = [ + "diff", + "--raw", + "-z", + "--no-abbrev", + "--no-ext-diff", + "--no-textconv", + "--find-renames", + ] + if reveal_gitlinks: + args.append("--ignore-submodules=none") + args.extend(["--end-of-options", plan.base_sha, plan.head_sha, "--"]) + raw = _git_output(root, args, environment) + if not raw: + return () + fields = raw.split(b"\0") + if fields[-1] != b"": + raise RepositoryEvidenceError("OCR Git raw diff is truncated") + fields.pop() + entries: list[RawDiffEntry] = [] + index = 0 + while index < len(fields): + if len(entries) >= _DIFF_ENTRY_LIMIT: + raise RepositoryEvidenceError("OCR Git raw diff exceeds its entry bound") + match = _RAW_HEADER.fullmatch(fields[index]) + if match is None: + raise RepositoryEvidenceError("OCR Git raw diff contains an unsupported record") + status = match.group("status").decode("ascii") + index += 1 + path_count = 2 if status == "R" else 1 + if index + path_count > len(fields): + raise RepositoryEvidenceError("OCR Git raw diff is truncated") + try: + paths = [field.decode("utf-8") for field in fields[index : index + path_count]] + except UnicodeDecodeError as exc: + raise RepositoryEvidenceError("OCR Git raw diff has non-UTF-8 paths") from exc + index += path_count + from ocr_toolkit.evidence.repository import normalize_repo_path + + if any(normalize_repo_path(path) != path for path in paths): + raise RepositoryEvidenceError("OCR Git raw diff path is unsafe") + entries.append( + RawDiffEntry( + None if status == "A" else paths[0], + None if status == "D" else paths[-1], + match.group("old_mode").decode("ascii"), + match.group("new_mode").decode("ascii"), + match.group("old_oid").decode("ascii"), + match.group("new_oid").decode("ascii"), + status, + ) + ) + return tuple(entries) + + +def _check_tree_identity(plan: GitlinkInputPlan, entries: tuple[RawDiffEntry, ...]) -> None: + expected_before = { + change.path: change.before for change in plan.ordinary if change.before is not None + } + expected_after = { + change.path: change.after for change in plan.ordinary if change.after is not None + } + expected_before.update( + {change.path: change.before_entry for change in plan.changes if change.before_entry} + ) + expected_after.update( + {change.path: change.after_entry for change in plan.changes if change.after_entry} + ) + covered_before: set[str] = set() + covered_after: set[str] = set() + for entry in entries: + for path, expected, mode, oid, covered in ( + (entry.old_path, expected_before, entry.old_mode, entry.old_oid, covered_before), + (entry.new_path, expected_after, entry.new_mode, entry.new_oid, covered_after), + ): + if path is None: + if mode != "000000" or oid != _ZERO_OID: + raise RepositoryEvidenceError("OCR Git raw diff has an invalid absent endpoint") + continue + object_entry = expected.get(path) + if ( + object_entry is None + or path in covered + or object_entry.mode != mode + or object_entry.object_sha != oid + ): + raise RepositoryEvidenceError("OCR Git raw diff disagrees with immutable trees") + covered.add(path) + if covered_before != set(expected_before) or covered_after != set(expected_after): + raise RepositoryEvidenceError("OCR Git raw diff omitted an immutable tree change") + + +def _manifest_id(entry: RawDiffEntry) -> str: + old_path = entry.old_path or entry.new_path or "" + identity = "\0".join(("review", "range", old_path, entry.new_path or "")) + return hashlib.sha256(identity.encode("utf-8")).hexdigest() + + +def _patch_fingerprints( + root: Path, + plan: GitlinkInputPlan, + environment: dict[str, str], + entries: tuple[RawDiffEntry, ...], +) -> tuple[tuple[tuple[str, str], ...], tuple[tuple[str, int, int], ...]]: + """Reproduce the qualified OCR 1.13.3 raw patch fingerprint from Git.""" + + patch = _git_output( + root, + [ + "-c", + "core.quotepath=false", + "diff", + "--no-ext-diff", + "--no-textconv", + "--find-renames", + "--src-prefix=a/", + "--dst-prefix=b/", + "--no-color", + "-U3", + "--end-of-options", + plan.base_sha, + plan.head_sha, + "--", + ], + environment, + ) + try: + text = patch.decode("utf-8") + except UnicodeDecodeError as exc: + raise RepositoryEvidenceError("OCR projected patch is not UTF-8") from exc + sections: list[list[str]] = [] + for raw_line in text.split("\n"): + line = raw_line.removesuffix("\r") + if line.startswith("diff --git "): + sections.append([]) + if sections: + sections[-1].append(line) + elif line: + raise RepositoryEvidenceError("OCR projected patch has unexpected prelude") + if len(sections) != len(entries): + raise RepositoryEvidenceError("OCR projected patch disagrees with raw diff") + fingerprints: list[tuple[str, str]] = [] + line_counts: list[tuple[str, int, int]] = [] + for entry, lines in zip(entries, sections, strict=True): + old_path = entry.old_path or entry.new_path or "" + new_path = entry.new_path or entry.old_path or "" + if lines[0] != f"diff --git a/{old_path} b/{new_path}": + raise RepositoryEvidenceError("OCR projected patch path is ambiguous") + in_hunk = False + insertions = 0 + deletions = 0 + filtered: list[str] = [] + for line in lines: + if line.startswith("@@"): + in_hunk = True + if not in_hunk and line.startswith("index "): + continue + if in_hunk and line.startswith("+"): + insertions += 1 + elif in_hunk and line.startswith("-"): + deletions += 1 + filtered.append(line) + diff_text = "\n".join(filtered).rstrip("\r\n") + fingerprint_source = "\0".join(("range", old_path, new_path, diff_text)) + fingerprints.append( + (_manifest_id(entry), hashlib.sha256(fingerprint_source.encode("utf-8")).hexdigest()) + ) + line_counts.append((entry.path, insertions, deletions)) + if len({item_id for item_id, _ in fingerprints}) != len(fingerprints): + raise RepositoryEvidenceError("OCR projected patch has duplicate identities") + return tuple(fingerprints), tuple(line_counts) + + +def make_gitlink_projection( + reader: GitRepositoryReader, plan: GitlinkInputPlan +) -> GitlinkProjection: + """Qualify the original range and freeze exact OCR Git inputs.""" + + environment = _projection_environment(plan) + root = reader.root + merge_base = ( + _git_output(root, ["merge-base", plan.base_sha, plan.head_sha], environment) + .decode("ascii") + .strip() + ) + if merge_base != plan.base_sha: + raise RepositoryEvidenceError("OCR range merge-base differs from the planned base") + input_digest = _input_digest(root, plan) + unfiltered = _raw_diff(root, plan, environment, reveal_gitlinks=True) + _check_tree_identity(plan, unfiltered) + projected = _raw_diff(root, plan, environment, reveal_gitlinks=False) + ordinary = tuple(entry for entry in unfiltered if not entry.is_gitlink) + if projected != ordinary: + raise RepositoryEvidenceError("OCR Git config did not isolate the ordinary diff") + if any( + entry.status == "T" + or entry.old_mode not in {"000000", "100644", "100755"} + or entry.new_mode not in {"000000", "100644", "100755"} + for entry in ordinary + ): + raise RepositoryEvidenceError("OCR non-regular ordinary change is unsupported") + fingerprints, line_counts = _patch_fingerprints(root, plan, environment, projected) + if input_digest != _input_digest(root, plan): + raise RepositoryEvidenceError("OCR local Git inputs changed during qualification") + return GitlinkProjection( + plan, + unfiltered, + ordinary, + tuple(entry for entry in unfiltered if entry.is_gitlink), + fingerprints, + line_counts, + tuple(sorted(environment.items())), + input_digest, + ) + + +def verify_gitlink_projection(reader: GitRepositoryReader, projection: GitlinkProjection) -> None: + """Recheck every original endpoint, consulted input and projected record.""" + + from ocr_toolkit.evidence.gitlinks import plan_gitlink_input + + current = plan_gitlink_input(reader, projection.plan.base_sha, projection.plan.head_sha) + if ( + current != projection.plan + or _input_digest(reader.root, projection.plan) != projection.input_digest + ): + raise RepositoryEvidenceError("OCR gitlink review inputs drifted") + environment = projection.child_environment() + if ( + _raw_diff(reader.root, projection.plan, environment, reveal_gitlinks=True) + != projection.original_diff + ): + raise RepositoryEvidenceError("OCR original Git diff changed") + if ( + _raw_diff(reader.root, projection.plan, environment, reveal_gitlinks=False) + != projection.ordinary_diff + ): + raise RepositoryEvidenceError("OCR projected Git diff changed") + if _patch_fingerprints(reader.root, projection.plan, environment, projection.ordinary_diff) != ( + projection.fingerprints, + projection.line_counts, + ): + raise RepositoryEvidenceError("OCR projected Git patch changed") + + +def qualify_gitlink_preview(projection: GitlinkProjection, raw: bytes) -> tuple[RawDiffEntry, ...]: + """Accept only the complete 1.13.3 preview for the independently proven diff.""" + + try: + payload = json.loads(raw) + except (UnicodeDecodeError, json.JSONDecodeError) as exc: + raise RepositoryEvidenceError("OCR gitlink preview is not valid JSON") from exc + if not isinstance(payload, dict) or set(payload) != { + "files", + "total_insertions", + "total_deletions", + "total_files", + "reviewable_count", + "excluded_count", + }: + raise RepositoryEvidenceError("OCR gitlink preview has an unsupported schema") + files = payload["files"] + if not isinstance(files, list) or len(files) != len(projection.ordinary_diff): + raise RepositoryEvidenceError("OCR gitlink preview omitted or added diff records") + expected = {entry.path: entry for entry in projection.ordinary_diff} + counts = { + path: (insertions, deletions) for path, insertions, deletions in projection.line_counts + } + if len(expected) != len(projection.ordinary_diff): + raise RepositoryEvidenceError("OCR gitlink projection has a duplicate path") + selected: list[RawDiffEntry] = [] + seen: set[str] = set() + insertions = 0 + deletions = 0 + for item in files: + if ( + not isinstance(item, dict) + or not { + "path", + "status", + "insertions", + "deletions", + "will_review", + } + <= set(item) + or set(item) + - { + "path", + "status", + "insertions", + "deletions", + "will_review", + "exclude_reason", + } + ): + raise RepositoryEvidenceError("OCR gitlink preview file has an unsupported schema") + path = item["path"] + if not isinstance(path, str) or path in seen or path not in expected: + raise RepositoryEvidenceError("OCR gitlink preview has an unknown or duplicate path") + seen.add(path) + entry = expected[path] + if item["status"] != entry.preview_status: + raise RepositoryEvidenceError("OCR gitlink preview disagrees with Git status") + for field in ("insertions", "deletions"): + value = item[field] + if type(value) is not int or not 0 <= value <= 1_000_000_000: + raise RepositoryEvidenceError("OCR gitlink preview has invalid line counts") + if (item["insertions"], item["deletions"]) != counts[path]: + raise RepositoryEvidenceError("OCR gitlink preview line counts disagree with Git") + insertions += item["insertions"] + deletions += item["deletions"] + will_review = item["will_review"] + reason = item.get("exclude_reason", "") + if type(will_review) is not bool or not isinstance(reason, str): + raise RepositoryEvidenceError("OCR gitlink preview has invalid selection") + if will_review: + if reason: + raise RepositoryEvidenceError( + "OCR gitlink preview selection conflicts with exclusion" + ) + selected.append(entry) + elif reason not in _PREVIEW_REASONS: + raise RepositoryEvidenceError("OCR gitlink preview has an unknown exclusion") + if seen != set(expected): + raise RepositoryEvidenceError("OCR gitlink preview omitted an ordinary path") + for field, value in ( + ("total_insertions", insertions), + ("total_deletions", deletions), + ("total_files", len(files)), + ("reviewable_count", len(selected)), + ("excluded_count", len(files) - len(selected)), + ): + if type(payload[field]) is not int or payload[field] != value: + raise RepositoryEvidenceError("OCR gitlink preview totals are inconsistent") + return tuple(selected) + + +def qualify_gitlink_manifest( + projection: GitlinkProjection, + selected: tuple[RawDiffEntry, ...], + result: dict[str, object], +) -> None: + """Bind native completed coverage to every preview-selected ordinary item.""" + + manifest = result.get("manifest") + if not isinstance(manifest, dict) or manifest.get("schema_version") != "ocr.run-manifest/v1": + raise RepositoryEvidenceError("OCR gitlink result lacks the qualified manifest") + if result.get("status") != "complete" or manifest.get("terminal_state") != "complete": + raise RepositoryEvidenceError("OCR gitlink result is not native complete") + native_input = manifest.get("input") + if not isinstance(native_input, dict) or any( + native_input.get(key) != value + for key, value in ( + ("mode", "range"), + ("requested_from", projection.plan.base_sha), + ("requested_head", projection.plan.head_sha), + ("resolved_base", projection.plan.base_sha), + ("resolved_head", projection.plan.head_sha), + ("exact_range", f"{projection.plan.base_sha}..{projection.plan.head_sha}"), + ) + ): + raise RepositoryEvidenceError("OCR gitlink manifest has a different review identity") + execution = manifest.get("execution") + if not isinstance(execution, dict) or execution.get("ocr_version") != "v1.13.3": + raise RepositoryEvidenceError("OCR gitlink manifest has an unsupported binary version") + coverage = manifest.get("coverage") + if not isinstance(coverage, dict): + raise RepositoryEvidenceError("OCR gitlink manifest coverage is invalid") + expected = {_manifest_id(entry): entry for entry in selected} + fingerprints = dict(projection.fingerprints) + if len(expected) != len(selected): + raise RepositoryEvidenceError("OCR gitlink selected identities are ambiguous") + source_hash = hashlib.sha256() + for item_id in sorted(expected): + for field in (item_id, fingerprints[item_id]): + raw = field.encode("utf-8") + source_hash.update(len(raw).to_bytes(8, "big")) + source_hash.update(raw) + if native_input.get("source_artifact_sha256") != source_hash.hexdigest(): + raise RepositoryEvidenceError("OCR gitlink source artifact differs from the Git patch") + for key in ("selected", "completed"): + records = coverage.get(key) + if not isinstance(records, list) or len(records) != len(expected): + raise RepositoryEvidenceError("OCR gitlink manifest omitted selected work") + seen: set[str] = set() + for record in records: + if not isinstance(record, dict): + raise RepositoryEvidenceError("OCR gitlink manifest item is invalid") + item_id = record.get("item_id") + if not isinstance(item_id, str) or item_id not in expected or item_id in seen: + raise RepositoryEvidenceError("OCR gitlink manifest item is unexpected") + seen.add(item_id) + entry = expected[item_id] + if ( + record.get("path") != entry.path + or record.get("old_path", "") != (entry.old_path if entry.status == "R" else "") + or record.get("fingerprint") != fingerprints.get(item_id) + ): + raise RepositoryEvidenceError("OCR gitlink manifest item disagrees with Git") + for key in ("reused", "failed", "waived"): + if coverage.get(key) != []: + raise RepositoryEvidenceError("OCR gitlink manifest contains unreviewed work") + if manifest.get("run_failure") is not None or manifest.get("parent_run_id"): + raise RepositoryEvidenceError("OCR gitlink result is not a fresh complete review") + comments = result.get("comments") + if not isinstance(comments, list): + raise RepositoryEvidenceError("OCR gitlink result comments are invalid") + selected_paths = {entry.path for entry in selected} + for comment in comments: + if not isinstance(comment, dict) or comment.get("path") not in selected_paths: + raise RepositoryEvidenceError("OCR gitlink result has a finding outside selected files") diff --git a/src/ocr_toolkit/review_runner.py b/src/ocr_toolkit/review_runner.py index c57732a..8fc3432 100644 --- a/src/ocr_toolkit/review_runner.py +++ b/src/ocr_toolkit/review_runner.py @@ -64,7 +64,15 @@ write_private_text, ) from ocr_toolkit.evidence.collect import collect_repository_evidence -from ocr_toolkit.evidence.gitlinks import changed_gitlinks +from ocr_toolkit.evidence.gitlink_projection import ( + GitlinkProjection, + RawDiffEntry, + make_gitlink_projection, + qualify_gitlink_manifest, + qualify_gitlink_preview, + verify_gitlink_projection, +) +from ocr_toolkit.evidence.gitlinks import plan_gitlink_input from ocr_toolkit.evidence.invocation import collect_invocation_evidence from ocr_toolkit.evidence.mcp import ( COVERAGE_TOOL_NAME, @@ -297,6 +305,7 @@ class BackgroundQualification: advisory: OcrToolkitAdvisory | None = None operator_notices: tuple[str, ...] = () + selected_diff: tuple[RawDiffEntry, ...] = () class BackgroundQualificationRejected(ReviewRunnerError): @@ -1696,6 +1705,8 @@ def _finalize_ocr_result( report_consumer: Callable[[ReviewReport], None] | None = None, state: ReviewRunState | None = None, private_diagnostics: bool = False, + projection: GitlinkProjection | None = None, + selected_diff: tuple[RawDiffEntry, ...] = (), ) -> tuple[dict[str, int], bool, dict[str, object]]: """Validate, DLP-project, and receipt-bind one result in one atomic read/replace.""" @@ -1731,6 +1742,11 @@ def finalize(payload: dict[str, object]) -> dict[str, object]: warnings = payload.get("warnings", []) if not isinstance(warnings, list): raise OcrResultMalformed("OCR result warnings must be a list") + if projection is not None: + try: + qualify_gitlink_manifest(projection, selected_diff, payload) + except RepositoryEvidenceError as exc: + raise ReviewRunnerError("OCR gitlink manifest did not prove selected work") from exc failure_telemetry = _tool_failure_telemetry(payload.get("tool_calls")) if state is not None: state.enter("mcp-use") @@ -2067,7 +2083,11 @@ def _parse_background_preview(*, returncode: int, stderr: bytes) -> BackgroundQu def _qualify_review_background( - ocr_args: list[str], *, ocr_binary: str, session_home: Path + ocr_args: list[str], + *, + ocr_binary: str, + session_home: Path, + projection: GitlinkProjection | None = None, ) -> BackgroundQualification: """Ask the installed qualified OCR to classify the exact production background.""" @@ -2091,15 +2111,28 @@ def _qualify_review_background( stdin=subprocess.DEVNULL, stdout=stdout_file, stderr=stderr_file, - env=_ocr_child_environment(), + env=( + projection.child_environment() + if projection is not None + else _ocr_child_environment() + ), ) except OSError as exc: raise ReviewRunnerError(f"could not execute OCR background preview: {exc}") from exc - _read_bounded_artifact(stdout_path, limit=2_000_000, label="stdout") + stdout = _read_bounded_artifact(stdout_path, limit=2_000_000, label="stdout") stderr = _read_bounded_artifact( stderr_path, limit=BACKGROUND_PREVIEW_STDERR_BYTES, label="stderr" ) - return _parse_background_preview(returncode=completed.returncode, stderr=stderr) + qualification = _parse_background_preview(returncode=completed.returncode, stderr=stderr) + if projection is not None: + try: + selected = qualify_gitlink_preview(projection, stdout) + except RepositoryEvidenceError as exc: + raise ReviewRunnerError( + "OCR gitlink preview did not match the ordinary diff" + ) from exc + return replace(qualification, selected_diff=selected) + return qualification finally: active_error = sys.exception() os.umask(previous_umask) @@ -2123,6 +2156,7 @@ def _run_background_qualified_review( refs: ReviewRefs, identity: ReviewIdentity, gitlink_detected: bool = False, + projection: GitlinkProjection | None = None, state: ReviewRunState | None = None, ) -> tuple[int, BackgroundQualification]: """Run the model review only after installed OCR accepts its background.""" @@ -2154,6 +2188,7 @@ def _run_background_qualified_review( production_args, ocr_binary=ocr_binary, session_home=session_home, + **({"projection": projection} if projection is not None else {}), ) except BackgroundQualificationRejected as exc: if state is not None: @@ -2219,6 +2254,18 @@ def _run_background_qualified_review( print(f"OCR argument qualification notice: {notice}", file=sys.stderr) if state is not None: state.observe("preview", "degraded" if qualification.advisory is not None else "passed") + if projection is not None: + if not qualification.selected_diff: + print( + "OCR gitlink qualification: no ordinary files selected; model review did not run", + file=sys.stderr, + ) + return 2, qualification + try: + verify_gitlink_projection(GitRepositoryReader(Path.cwd()), projection) + except RepositoryEvidenceError as exc: + raise ReviewRunnerError("OCR gitlink inputs drifted before model execution") from exc + if state is not None: state.enter("subprocess") try: code = run_review( @@ -2226,6 +2273,11 @@ def _run_background_qualified_review( stderr_path, production_args, ocr_binary=ocr_binary, + **( + {"child_environment": projection.child_environment()} + if projection is not None + else {} + ), ) finally: if state is not None and state.debug is not None: @@ -3159,11 +3211,13 @@ def _run_evidence_review( native_store = None native_binary = None + native_version: str | None = None try: if local_chatgpt.auth_mode(os.environ, local=state.local) == "chatgpt": local_chatgpt.provider_entry(os.environ) native_binary = _resolve_ocr_binary() version = preflight.validate_ocr_binary(executable=native_binary, announce=False) + native_version = version local_chatgpt.require_supported_version(version) native_store = local_chatgpt.native_store(os.environ) except (local_chatgpt.LocalChatGPTError, preflight.PreflightError) as exc: @@ -3359,10 +3413,23 @@ def _run_evidence_review( ] state.enter("preview") ocr_binary = native_binary or _resolve_ocr_binary() + projection: GitlinkProjection | None = None + gitlink_detected = False try: - gitlink_detected = bool( - changed_gitlinks(GitRepositoryReader(Path.cwd()), refs.base, refs.head) - ) + reader = GitRepositoryReader(Path.cwd()) + gitlink_plan = plan_gitlink_input(reader, refs.base, refs.head) + if gitlink_plan is not None: + if native_version is None: + try: + native_version = preflight.validate_ocr_binary( + executable=ocr_binary, announce=False + ) + except preflight.PreflightError as exc: + raise ReviewRunnerError(str(exc)) from exc + if native_version == "1.13.3": + projection = make_gitlink_projection(reader, gitlink_plan) + else: + gitlink_detected = True except RepositoryEvidenceError as exc: raise ReviewRunnerError("changed gitlink evidence could not be qualified") from exc exit_code, background_qualification = _run_background_qualified_review( @@ -3375,6 +3442,7 @@ def _run_evidence_review( refs=refs, identity=identity, gitlink_detected=gitlink_detected, + projection=projection, **({"state": state} if state.local or state.progress is not None else {}), ) finally: @@ -3407,6 +3475,13 @@ def _run_evidence_review( os.environ["HOME"] = previous_home if cleanup_error is None and exit_code == 0: state.enter("result-validation") + if projection is not None: + try: + verify_gitlink_projection(GitRepositoryReader(Path.cwd()), projection) + except RepositoryEvidenceError as exc: + raise ReviewRunnerError( + "OCR gitlink inputs drifted during model execution" + ) from exc forbidden: tuple[str | ForbiddenValue, ...] = tuple( ForbiddenValue(v, "operator_secret") for v in composition.secret_values ) @@ -3427,6 +3502,14 @@ def _run_evidence_review( forbidden=forbidden, dlp_enabled=dlp_enabled, toolkit_advisory=background_qualification.advisory, + **( + { + "projection": projection, + "selected_diff": background_qualification.selected_diff, + } + if projection is not None + else {} + ), **({"private_diagnostics": True} if preserve_authorized else {}), **({"report_consumer": state.admit_report} if state.local else {}), **({"state": state} if state.local or state.progress is not None else {}), @@ -3595,6 +3678,7 @@ def run_review( ocr_args: list[str], *, ocr_binary: str = "ocr", + child_environment: dict[str, str] | None = None, ) -> int: """Execute `ocr review`, retain private artifacts, and report safe failures.""" @@ -3622,7 +3706,9 @@ def run_review( stdin=subprocess.DEVNULL, stdout=result_file, stderr=stderr_file, - env=_ocr_child_environment(), + env=child_environment + if child_environment is not None + else _ocr_child_environment(), ) except OSError as exc: raise ReviewRunnerError(f"could not execute OCR: {exc}") from exc diff --git a/tests/test_gitlinks.py b/tests/test_gitlinks.py index fe29143..10bc789 100644 --- a/tests/test_gitlinks.py +++ b/tests/test_gitlinks.py @@ -11,6 +11,12 @@ from ocr_toolkit import review_runner from ocr_toolkit.evidence.artifacts import prepare_artifact_directory, repository_artifacts +from ocr_toolkit.evidence.gitlink_projection import ( + make_gitlink_projection, + qualify_gitlink_manifest, + qualify_gitlink_preview, + verify_gitlink_projection, +) from ocr_toolkit.evidence.gitlinks import changed_gitlinks, plan_gitlink_input from ocr_toolkit.evidence.repository import GitRepositoryReader, RepositoryEvidenceError from ocr_toolkit.preflight import validate_ocr_binary @@ -50,6 +56,39 @@ def _fixture(root: Path, kind: str) -> tuple[str, str]: return base, _git(root, "rev-parse", "HEAD") +def _mixed_fixture(root: Path) -> tuple[str, str]: + _git(root, "init", "-q") + _git(root, "config", "user.name", "Synthetic Reviewer") + _git(root, "config", "user.email", "reviewer@example.invalid") + (root / "main.py").write_text("value = 1\n", encoding="utf-8") + (root / "old.py").write_text("old = True\n", encoding="utf-8") + (root / "gone.py").write_text("gone = True\n", encoding="utf-8") + _git(root, "add", "main.py", "old.py", "gone.py") + _git(root, "update-index", "--add", "--cacheinfo", f"160000,{OLD_OID},schema") + _git(root, "commit", "-qm", "base") + base = _git(root, "rev-parse", "HEAD") + (root / "main.py").write_text("value = 2\n", encoding="utf-8") + (root / "new.py").write_text("new = True\n", encoding="utf-8") + _git(root, "mv", "old.py", "renamed.py") + _git(root, "rm", "gone.py") + _git(root, "add", "main.py", "new.py") + _git(root, "update-index", "--add", "--cacheinfo", f"160000,{NEW_OID},schema") + _git(root, "commit", "-qm", "head") + return base, _git(root, "rev-parse", "HEAD") + + +def _gitlink_only_fixture(root: Path) -> tuple[str, str]: + _git(root, "init", "-q") + _git(root, "config", "user.name", "Synthetic Reviewer") + _git(root, "config", "user.email", "reviewer@example.invalid") + _git(root, "update-index", "--add", "--cacheinfo", f"160000,{OLD_OID},schema") + _git(root, "commit", "-qm", "base") + base = _git(root, "rev-parse", "HEAD") + _git(root, "update-index", "--add", "--cacheinfo", f"160000,{NEW_OID},schema") + _git(root, "commit", "-qm", "head") + return base, _git(root, "rev-parse", "HEAD") + + @pytest.mark.parametrize("kind", ["added", "updated", "deleted"]) def test_changed_gitlinks_use_immutable_tree_modes_with_ordinary_file( tmp_path: Path, kind: str @@ -96,7 +135,391 @@ def test_private_input_plan_retains_original_ordinary_delta(tmp_path: Path, kind assert plan.ordinary[0].after is not None -def test_unique_gitlink_move_keeps_changed_gitmodules_reviewable(tmp_path: Path) -> None: +@pytest.mark.parametrize("kind", ["added", "updated", "deleted"]) +def test_projection_preserves_original_ordinary_diff(tmp_path: Path, kind: str) -> None: + base, head = _fixture(tmp_path, kind) + reader = GitRepositoryReader(tmp_path) + plan = plan_gitlink_input(reader, base, head) + assert plan is not None + projection = make_gitlink_projection(reader, plan) + assert len(projection.original_diff) == 2 + assert [entry.path for entry in projection.ordinary_diff] == ["main.php"] + assert [entry.path for entry in projection.gitlink_diff] == ["schema"] + assert projection.child_environment()["GIT_CONFIG_KEY_0"] == "diff.ignoreSubmodules" + verify_gitlink_projection(reader, projection) + + +def _preview(path: str = "main.php", *, selected: bool = True) -> bytes: + item: dict[str, object] = { + "path": path, + "status": "modified", + "insertions": 1, + "deletions": 1, + "will_review": selected, + } + if not selected: + item["exclude_reason"] = "user_exclude" + return json.dumps( + { + "files": [item], + "total_insertions": 1, + "total_deletions": 1, + "total_files": 1, + "reviewable_count": int(selected), + "excluded_count": int(not selected), + } + ).encode() + + +def test_projection_qualifies_exact_preview_and_zero_selection(tmp_path: Path) -> None: + base, head = _fixture(tmp_path, "updated") + reader = GitRepositoryReader(tmp_path) + plan = plan_gitlink_input(reader, base, head) + assert plan is not None + projection = make_gitlink_projection(reader, plan) + assert qualify_gitlink_preview(projection, _preview()) == projection.ordinary_diff + assert qualify_gitlink_preview(projection, _preview(selected=False)) == () + + +@pytest.mark.parametrize( + "mutate", + [ + lambda payload: payload["files"].append(dict(payload["files"][0])), + lambda payload: payload["files"][0].update(path="schema"), + lambda payload: payload["files"][0].update(status="added"), + lambda payload: payload.update(reviewable_count=0), + lambda payload: payload["files"][0].update( + exclude_reason="future_reason", will_review=False + ), + ], +) +def test_projection_rejects_wrong_preview(tmp_path: Path, mutate: object) -> None: + base, head = _fixture(tmp_path, "updated") + reader = GitRepositoryReader(tmp_path) + plan = plan_gitlink_input(reader, base, head) + assert plan is not None + projection = make_gitlink_projection(reader, plan) + payload = json.loads(_preview()) + mutate(payload) # type: ignore[operator] + with pytest.raises(RepositoryEvidenceError, match="preview"): + qualify_gitlink_preview(projection, json.dumps(payload).encode()) + + +def test_projection_detects_local_git_input_drift(tmp_path: Path) -> None: + base, head = _fixture(tmp_path, "updated") + reader = GitRepositoryReader(tmp_path) + plan = plan_gitlink_input(reader, base, head) + assert plan is not None + projection = make_gitlink_projection(reader, plan) + (tmp_path / ".gitignore").write_text("main.php\n", encoding="utf-8") + with pytest.raises(RepositoryEvidenceError, match="drifted"): + verify_gitlink_projection(reader, projection) + + +def test_projection_scrubs_inherited_git_overrides( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + base, head = _fixture(tmp_path, "updated") + _git(tmp_path, "config", "diff.ignoreSubmodules", "none") + reader = GitRepositoryReader(tmp_path) + plan = plan_gitlink_input(reader, base, head) + assert plan is not None + monkeypatch.setenv("GIT_CONFIG_COUNT", "1") + monkeypatch.setenv("GIT_CONFIG_KEY_0", "diff.ignoreSubmodules") + monkeypatch.setenv("GIT_CONFIG_VALUE_0", "none") + monkeypatch.setenv("GIT_WORK_TREE", "/outside") + monkeypatch.setenv("GIT_EXTERNAL_DIFF", "/outside/diff") + projection = make_gitlink_projection(reader, plan) + env = projection.child_environment() + assert env["GIT_CONFIG_COUNT"] == "1" + assert env["GIT_CONFIG_KEY_0"] == "diff.ignoreSubmodules" + assert env["GIT_CONFIG_VALUE_0"] == "all" + assert "GIT_WORK_TREE" not in env and "GIT_EXTERNAL_DIFF" not in env + assert [entry.path for entry in projection.ordinary_diff] == ["main.php"] + + +def test_projection_uses_exact_slash_qualified_gitmodules_name(tmp_path: Path) -> None: + _git(tmp_path, "init", "-q") + _git(tmp_path, "config", "user.name", "Synthetic Reviewer") + _git(tmp_path, "config", "user.email", "reviewer@example.invalid") + (tmp_path / "main.py").write_text("value = 1\n", encoding="utf-8") + (tmp_path / ".gitmodules").write_text( + '[submodule "vendor/schema"]\npath = schema\nurl = https://example.invalid/repo\n', + encoding="utf-8", + ) + _git(tmp_path, "add", "main.py", ".gitmodules") + _git(tmp_path, "update-index", "--add", "--cacheinfo", f"160000,{OLD_OID},schema") + _git(tmp_path, "commit", "-qm", "base") + base = _git(tmp_path, "rev-parse", "HEAD") + (tmp_path / "main.py").write_text("value = 2\n", encoding="utf-8") + _git(tmp_path, "add", "main.py") + _git(tmp_path, "update-index", "--add", "--cacheinfo", f"160000,{NEW_OID},schema") + _git(tmp_path, "commit", "-qm", "head") + head = _git(tmp_path, "rev-parse", "HEAD") + reader = GitRepositoryReader(tmp_path) + plan = plan_gitlink_input(reader, base, head) + assert plan is not None and plan.config_names == ("vendor/schema",) + projection = make_gitlink_projection(reader, plan) + assert projection.child_environment()["GIT_CONFIG_KEY_1"] == "submodule.vendor/schema.ignore" + + +def test_gitlink_only_projection_has_no_selected_work(tmp_path: Path) -> None: + base, head = _gitlink_only_fixture(tmp_path) + reader = GitRepositoryReader(tmp_path) + plan = plan_gitlink_input(reader, base, head) + assert plan is not None + projection = make_gitlink_projection(reader, plan) + assert projection.ordinary_diff == () + assert len(projection.gitlink_diff) == 1 + empty_preview = { + "files": [], + "total_insertions": 0, + "total_deletions": 0, + "total_files": 0, + "reviewable_count": 0, + "excluded_count": 0, + } + assert qualify_gitlink_preview(projection, json.dumps(empty_preview).encode()) == () + + +def test_runner_uses_one_projected_environment_and_skips_empty_model( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + repo = tmp_path / "repo" + repo.mkdir() + base, head = _gitlink_only_fixture(repo) + reader = GitRepositoryReader(repo) + plan = plan_gitlink_input(reader, base, head) + assert plan is not None + projection = make_gitlink_projection(reader, plan) + artifacts = repository_artifacts(repo) + prepare_artifact_directory(artifacts) + home = tmp_path / "home" + home.mkdir(mode=0o700) + preview = { + "files": [], + "total_insertions": 0, + "total_deletions": 0, + "total_files": 0, + "reviewable_count": 0, + "excluded_count": 0, + } + calls: list[list[str]] = [] + + def fake_run(argv: list[str], **kwargs: object) -> subprocess.CompletedProcess[bytes]: + calls.append(argv) + assert kwargs["env"] == projection.child_environment() + kwargs["stdout"].write(json.dumps(preview).encode()) # type: ignore[union-attr] + return subprocess.CompletedProcess(argv, 0) + + monkeypatch.setattr(review_runner.subprocess, "run", fake_run) + monkeypatch.setattr( + review_runner, + "run_review", + lambda *_args, **_kwargs: pytest.fail("model must not run for gitlink-only input"), + ) + identity = review_runner.ReviewIdentity( + source_sha=head, + policy_sha="a" * 40, + mr_author_id=None, + context_mode="off", + context=None, + target_sha=base, + target_protection="local", + ) + args = ["--from", base, "--to", head, "--format", "json"] + code, qualification = review_runner._run_background_qualified_review( + tmp_path / "result.json", + tmp_path / "stderr.log", + args, + ocr_binary="/synthetic/ocr", + session_home=home, + artifacts=artifacts, + refs=review_runner.ReviewRefs(base, head), + identity=identity, + projection=projection, + ) + assert code == 2 and qualification.selected_diff == () + assert calls == [["/synthetic/ocr", "review", *args, "--preview"]] + assert list(home.iterdir()) == [] + + +@pytest.mark.parametrize("drift", [False, True]) +def test_runner_rechecks_projection_before_using_frozen_child_environment( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, drift: bool +) -> None: + repo = tmp_path / "repo" + repo.mkdir() + base, head = _fixture(repo, "updated") + reader = GitRepositoryReader(repo) + plan = plan_gitlink_input(reader, base, head) + assert plan is not None + projection = make_gitlink_projection(reader, plan) + artifacts = repository_artifacts(repo) + prepare_artifact_directory(artifacts) + home = tmp_path / "home" + home.mkdir(mode=0o700) + calls: list[tuple[str, object]] = [] + + def qualify(*_args: object, **_kwargs: object) -> review_runner.BackgroundQualification: + calls.append(("preview", _kwargs.get("projection"))) + return review_runner.BackgroundQualification(selected_diff=projection.ordinary_diff) + + def verify(*_args: object) -> None: + calls.append(("recheck", projection)) + if drift: + raise RepositoryEvidenceError("synthetic drift") + + def production(*_args: object, **kwargs: object) -> int: + calls.append(("production", kwargs.get("child_environment"))) + return 0 + + monkeypatch.setattr(review_runner, "_qualify_review_background", qualify) + monkeypatch.setattr(review_runner, "verify_gitlink_projection", verify) + monkeypatch.setattr(review_runner, "run_review", production) + identity = review_runner.ReviewIdentity( + source_sha=head, + policy_sha="a" * 40, + mr_author_id=None, + context_mode="off", + context=None, + target_sha=base, + target_protection="local", + ) + monkeypatch.chdir(repo) + args = ["--from", base, "--to", head, "--format", "json"] + if drift: + with pytest.raises(review_runner.ReviewRunnerError, match="drifted before"): + review_runner._run_background_qualified_review( + tmp_path / "result.json", + tmp_path / "stderr.log", + args, + ocr_binary="/synthetic/ocr", + session_home=home, + artifacts=artifacts, + refs=review_runner.ReviewRefs(base, head), + identity=identity, + projection=projection, + ) + assert [call[0] for call in calls] == ["preview", "recheck"] + else: + code, qualification = review_runner._run_background_qualified_review( + tmp_path / "result.json", + tmp_path / "stderr.log", + args, + ocr_binary="/synthetic/ocr", + session_home=home, + artifacts=artifacts, + refs=review_runner.ReviewRefs(base, head), + identity=identity, + projection=projection, + ) + assert code == 0 and qualification.selected_diff == projection.ordinary_diff + assert calls == [ + ("preview", projection), + ("recheck", projection), + ("production", projection.child_environment()), + ] + + +def test_projection_rejects_local_git_config_include(tmp_path: Path) -> None: + base, head = _fixture(tmp_path, "updated") + reader = GitRepositoryReader(tmp_path) + plan = plan_gitlink_input(reader, base, head) + assert plan is not None + _git(tmp_path, "config", "include.path", "/tmp/unused-config") + with pytest.raises(RepositoryEvidenceError, match="includes"): + make_gitlink_projection(reader, plan) + + +def test_projection_rejects_changed_symlink_with_gitlink(tmp_path: Path) -> None: + _git(tmp_path, "init", "-q") + _git(tmp_path, "config", "user.name", "Synthetic Reviewer") + _git(tmp_path, "config", "user.email", "reviewer@example.invalid") + (tmp_path / "alias.py").symlink_to("first.py") + _git(tmp_path, "add", "alias.py") + _git(tmp_path, "update-index", "--add", "--cacheinfo", f"160000,{OLD_OID},schema") + _git(tmp_path, "commit", "-qm", "base") + base = _git(tmp_path, "rev-parse", "HEAD") + (tmp_path / "alias.py").unlink() + (tmp_path / "alias.py").symlink_to("second.py") + _git(tmp_path, "add", "alias.py") + _git(tmp_path, "update-index", "--add", "--cacheinfo", f"160000,{NEW_OID},schema") + _git(tmp_path, "commit", "-qm", "head") + head = _git(tmp_path, "rev-parse", "HEAD") + reader = GitRepositoryReader(tmp_path) + plan = plan_gitlink_input(reader, base, head) + assert plan is not None + with pytest.raises(RepositoryEvidenceError, match="non-regular"): + make_gitlink_projection(reader, plan) + + +def test_projection_rejects_wrong_manifest_identity_or_reuse(tmp_path: Path) -> None: + base, head = _fixture(tmp_path, "updated") + reader = GitRepositoryReader(tmp_path) + plan = plan_gitlink_input(reader, base, head) + assert plan is not None + projection = make_gitlink_projection(reader, plan) + item_id, fingerprint = projection.fingerprints[0] + item = {"item_id": item_id, "path": "main.php", "fingerprint": fingerprint} + import hashlib + + source = hashlib.sha256() + for field in (item_id, fingerprint): + raw = field.encode() + source.update(len(raw).to_bytes(8, "big")) + source.update(raw) + payload: dict[str, object] = { + "status": "complete", + "comments": [], + "manifest": { + "schema_version": "ocr.run-manifest/v1", + "terminal_state": "complete", + "input": { + "mode": "range", + "requested_from": base, + "requested_head": head, + "resolved_base": base, + "resolved_head": head, + "exact_range": f"{base}..{head}", + "source_artifact_sha256": source.hexdigest(), + }, + "execution": {"ocr_version": "v1.13.3"}, + "coverage": { + "selected": [item], + "completed": [item], + "reused": [], + "failed": [], + "waived": [], + }, + }, + } + qualify_gitlink_manifest(projection, projection.ordinary_diff, payload) + manifest = payload["manifest"] + assert isinstance(manifest, dict) + native_input = manifest["input"] + assert isinstance(native_input, dict) + native_input["resolved_head"] = base + with pytest.raises(RepositoryEvidenceError, match="identity"): + qualify_gitlink_manifest(projection, projection.ordinary_diff, payload) + native_input["resolved_head"] = head + native_input["source_artifact_sha256"] = "0" * 64 + with pytest.raises(RepositoryEvidenceError, match="source artifact"): + qualify_gitlink_manifest(projection, projection.ordinary_diff, payload) + native_input["source_artifact_sha256"] = source.hexdigest() + coverage = manifest["coverage"] + assert isinstance(coverage, dict) + bad_item = dict(item, fingerprint="0" * 64) + coverage["completed"] = [bad_item] + with pytest.raises(RepositoryEvidenceError, match="disagrees with Git"): + qualify_gitlink_manifest(projection, projection.ordinary_diff, payload) + coverage["completed"] = [item] + coverage["reused"] = [item] + with pytest.raises(RepositoryEvidenceError, match="unreviewed"): + qualify_gitlink_manifest(projection, projection.ordinary_diff, payload) + + +def _move_fixture(tmp_path: Path) -> tuple[str, str]: _git(tmp_path, "init", "-q") _git(tmp_path, "config", "user.name", "Synthetic Reviewer") _git(tmp_path, "config", "user.email", "reviewer@example.invalid") @@ -119,6 +542,11 @@ def test_unique_gitlink_move_keeps_changed_gitmodules_reviewable(tmp_path: Path) _git(tmp_path, "update-index", "--add", "--cacheinfo", f"160000,{OLD_OID},schema-new") _git(tmp_path, "commit", "-qm", "head") head = _git(tmp_path, "rev-parse", "HEAD") + return base, head + + +def test_unique_gitlink_move_keeps_changed_gitmodules_reviewable(tmp_path: Path) -> None: + base, head = _move_fixture(tmp_path) plan = plan_gitlink_input(GitRepositoryReader(tmp_path), base, head) assert plan is not None @@ -327,3 +755,84 @@ def test_installed_ocr_gitlink_preview_qualification( assert completed.stderr.startswith(b"[ocr] WARNING: cannot read file schema at ref ") assert completed.stderr.endswith(b": exit status 128\n") assert changed_gitlinks(GitRepositoryReader(repo), base, head)[0].kind == kind + + +@pytest.mark.parametrize("kind", ["added", "updated", "deleted", "mixed", "move"]) +def test_installed_ocr_projected_review_matches_native_manifest( + tmp_path: Path, kind: str, monkeypatch: pytest.MonkeyPatch +) -> None: + """Opt-in production proof against the official binary and local fake provider.""" + + binary = os.environ.get("OCR_GITLINK_QUALIFY_BINARY") + if binary is None: + pytest.skip("set OCR_GITLINK_QUALIFY_BINARY to the checksum-verified OCR 1.13.3") + assert validate_ocr_binary(executable=binary, announce=False) == "1.13.3" + monkeypatch.syspath_prepend(str(Path(__file__).resolve().parents[1] / "scripts")) + from ocr_compat import _stub_gateway + + repo = tmp_path / "repo" + repo.mkdir() + base, head = ( + _mixed_fixture(repo) + if kind == "mixed" + else _move_fixture(repo) + if kind == "move" + else _fixture(repo, kind) + ) + reader = GitRepositoryReader(repo) + plan = plan_gitlink_input(reader, base, head) + assert plan is not None + projection = make_gitlink_projection(reader, plan) + home = tmp_path / "ocr-home" + home.mkdir(mode=0o700) + env = projection.child_environment() + env["HOME"] = str(home) + args = [ + binary, + "review", + "--from", + base, + "--to", + head, + "--format", + "json", + "--audience", + "agent", + "--concurrency", + "1", + ] + preview = subprocess.run( + [*args, "--preview"], + cwd=repo, + env=env, + capture_output=True, + check=False, + timeout=30, + ) + assert preview.returncode == 0 and preview.stderr == b"" + selected = qualify_gitlink_preview(projection, preview.stdout) + assert selected + assert all(entry.status != "D" for entry in selected) + if kind in {"added", "updated", "deleted"}: + assert selected == projection.ordinary_diff + with _stub_gateway() as gateway: + env.update( + { + "OCR_LLM_URL": gateway, + "OCR_LLM_TOKEN": "synthetic-token", + "OCR_LLM_MODEL": "synthetic-model", + "OCR_LLM_PROTOCOL": "openai", + "OCR_TELEMETRY_ENABLED": "false", + } + ) + production = subprocess.run( + args, + cwd=repo, + env=env, + capture_output=True, + check=False, + timeout=90, + ) + assert production.returncode == 0 and production.stderr == b"" + qualify_gitlink_manifest(projection, selected, json.loads(production.stdout)) + verify_gitlink_projection(reader, projection) diff --git a/tests/test_review_runner.py b/tests/test_review_runner.py index b041b8e..feac014 100644 --- a/tests/test_review_runner.py +++ b/tests/test_review_runner.py @@ -4813,7 +4813,7 @@ def bootstrap(*_args: object, **kwargs: object) -> str: "_immutable_review_refs", lambda _refs: review_runner.ReviewRefs("a" * 40, "b" * 40), ), - patched_attr(review_runner, "changed_gitlinks", lambda *_args: ()), + patched_attr(review_runner, "plan_gitlink_input", lambda *_args: None), patched_attr(review_runner, "_write_isolated_runtime_config", lambda: None), patched_attr( review_runner, From 7f17a3f25398e25ba764a7846ec344570c65b534 Mon Sep 17 00:00:00 2001 From: xeonvs <11463419+xeonvs@users.noreply.github.com> Date: Fri, 23 Oct 2026 18:50:16 +0200 Subject: [PATCH 4/8] Report and enforce partial gitlink coverage --- PLANS.md | 8 +- .../evidence/gitlink_projection.py | 24 + src/ocr_toolkit/ocr_result.py | 4 +- src/ocr_toolkit/posting/formatting.py | 3 + src/ocr_toolkit/posting/freshness.py | 10 +- src/ocr_toolkit/posting/workflow.py | 152 +++++- src/ocr_toolkit/pre_execution.py | 77 ++- src/ocr_toolkit/providers/local.py | 17 +- src/ocr_toolkit/reporting/model.py | 15 +- src/ocr_toolkit/repository_coverage.py | 137 ++++++ src/ocr_toolkit/review_receipt.py | 17 +- src/ocr_toolkit/review_runner.py | 74 ++- tests/test_gitlinks.py | 55 +++ tests/test_repository_coverage.py | 439 ++++++++++++++++++ 14 files changed, 996 insertions(+), 36 deletions(-) create mode 100644 src/ocr_toolkit/repository_coverage.py create mode 100644 tests/test_repository_coverage.py diff --git a/PLANS.md b/PLANS.md index 0e102df..1cacbb4 100644 --- a/PLANS.md +++ b/PLANS.md @@ -75,8 +75,8 @@ v0.13.0 is published with immutable assets and #264 closed. Its archived plan is | WQ-01 | REQ-009 | Full plan/state pointer, reviewed plan-only signed commit, #268 milestone | done | | WQ-02 | REQ-001, REQ-005 | Private before/after shape and safe `.gitmodules`/Git inventory, adversarial tests, self-reviewed commit | done | | WQ-03 | REQ-002, REQ-005 | Frozen OCR invocation/environment, independent diff, preview/native-manifest qualification, real-binary regression, self-reviewed commit | done | -| WQ-04 | REQ-003, REQ-004 | Exact receipt v12/status v6, effective coverage/reporting and local no-model path, hostile/legacy tests, self-reviewed commit | in_progress | -| WQ-05 | REQ-004 | GitLab posting, finding admission, approval blocker and partial lifecycle retention with failure/freshness tests, self-reviewed commit | pending | +| WQ-04 | REQ-003, REQ-004 | Exact receipt v12/status v6, effective coverage/reporting and local no-model path, hostile/legacy tests, self-reviewed commit | done | +| WQ-05 | REQ-004 | GitLab posting, finding admission, approval blocker and partial lifecycle retention with failure/freshness tests, self-reviewed commit | done | | WQ-06 | REQ-005, REQ-006, REQ-010 | Synthetic/installed OCR matrix, public contracts, threat model, decision flow, examples, Feature fragment, factual status correction, self-reviewed commit | pending | | WQ-07 | REQ-005, REQ-007 | Scoped/final quality, build, secret/dependency gates, security diff scan, real local OCR review, aggregate self-review, protected feature PR/merge, dev artifacts | pending | | WQ-08 | REQ-007, REQ-010 | Release markers/metadata/epoch/notes, checked plan archive/reset, release validation, protected signed release PR | pending | @@ -96,7 +96,7 @@ Only proven OCR 1.13.3 pure-pointer projection proceeds. Unique identical-OID be ### Latest Validation Results -2026-10-23 pre-implementation OCR 1.13.3 probe passed for mixed update/add/delete/move/multiple pure gitlinks and demonstrated no-model gitlink-only preview plus unsafe type transitions. Digest matches the pinned manifest. The signed plan-only commit is `db27f37`; plan-lifecycle and instruction-contract checks and staged Gitleaks passed. Milestone v0.13.1 (#22) tracks #268. Draft feature PR #269 has only the plan commit at its remote head. Local WQ-02 commit `203a50e` is not pushed. WQ-03 adds bounded unfiltered/projected Git inventory, original patch fingerprints and line counts, one frozen OCR child environment, exact preview and native manifest/source-artifact comparison, and before/after drift checks. The checksum-verified OCR 1.13.3 and local fake provider passed add/update/delete/mixed ordinary rename/add/delete/gitlink move forms; the focused runner/gitlink suites passed 247 tests before the final symlink-negative addition. Ruff, formatting and mypy passed. Toolkit-wide integrated production validation and effective-coverage contracts remain pending. +2026-10-23 pre-implementation OCR 1.13.3 probe passed for mixed update/add/delete/move/multiple pure gitlinks and demonstrated no-model gitlink-only preview plus unsafe type transitions. Digest matches the pinned manifest. The signed plan-only commit is `db27f37`; plan-lifecycle and instruction-contract checks and staged Gitleaks passed. Milestone v0.13.1 (#22) tracks #268. Draft feature PR #269 has only the plan commit at its remote head. Local WQ-02 commit `203a50e` and WQ-03 commit `827fc3b` are not pushed. WQ-03 has bounded unfiltered/projected Git inventory, original patch fingerprints and line counts, one frozen OCR child environment, exact preview and native manifest/source-artifact comparison, and before/after drift checks. WQ-04/05 add closed v12 effective coverage and v6 no-model status while preserving v11/v5, local/GitLab partial notices, independent post readback of original trees and native selected work, an approval blocker, and prior finding-thread retention. The checksum-verified OCR 1.13.3 and local fake provider passed add/update/delete/mixed ordinary rename/add/delete/gitlink move forms. Current focused runner/gitlink/coverage suite passed 269 tests; focused GitLab posting suite passed 364 tests and 299 subtests. Ruff, formatter and mypy passed. Toolkit-wide integrated production validation and final gates remain pending. ### Risks And Recovery @@ -104,7 +104,7 @@ Git configuration precedence or local input drift can hide ordinary changes: rev ### Resume Point -WQ-04: define exact receipt v12 and pre-execution status v6 effective coverage, preserve v11/v5, and carry no-model/partial outcomes into local and GitLab reports. Begin by tracing the existing receipt parser and post advisory branch; retain native OCR manifest unchanged. +WQ-06: extend the installed-toolkit matrix and hostile shape checks, update operator/security/decision-flow/example contracts, add the Feature fragment, and reconcile status-bearing v0.13.0 documents. Preserve the draft feature PR's plan-only remote head until the final security and local OCR reviews. ### Plan Fidelity Check diff --git a/src/ocr_toolkit/evidence/gitlink_projection.py b/src/ocr_toolkit/evidence/gitlink_projection.py index 18daf76..b9c154e 100644 --- a/src/ocr_toolkit/evidence/gitlink_projection.py +++ b/src/ocr_toolkit/evidence/gitlink_projection.py @@ -613,3 +613,27 @@ def qualify_gitlink_manifest( for comment in comments: if not isinstance(comment, dict) or comment.get("path") not in selected_paths: raise RepositoryEvidenceError("OCR gitlink result has a finding outside selected files") + + +def selected_gitlink_manifest_entries( + projection: GitlinkProjection, result: dict[str, object] +) -> tuple[RawDiffEntry, ...]: + """Recover the native selected subset for independent posting readback.""" + + manifest = result.get("manifest") + coverage = manifest.get("coverage") if isinstance(manifest, dict) else None + records = coverage.get("selected") if isinstance(coverage, dict) else None + if not isinstance(records, list): + raise RepositoryEvidenceError("OCR gitlink manifest selected items are unavailable") + expected = {_manifest_id(entry): entry for entry in projection.ordinary_diff} + if len(expected) != len(projection.ordinary_diff): + raise RepositoryEvidenceError("OCR gitlink ordinary identities are ambiguous") + selected: list[RawDiffEntry] = [] + seen: set[str] = set() + for record in records: + item_id = record.get("item_id") if isinstance(record, dict) else None + if not isinstance(item_id, str) or item_id not in expected or item_id in seen: + raise RepositoryEvidenceError("OCR gitlink manifest selected item is invalid") + seen.add(item_id) + selected.append(expected[item_id]) + return tuple(selected) diff --git a/src/ocr_toolkit/ocr_result.py b/src/ocr_toolkit/ocr_result.py index a39f506..ad3a72f 100644 --- a/src/ocr_toolkit/ocr_result.py +++ b/src/ocr_toolkit/ocr_result.py @@ -21,9 +21,11 @@ TOOLKIT_RESULT_KEY = "_ocr_toolkit" TOOLKIT_PRIVATE_DIAGNOSTIC_KEY = "_ocr_toolkit_private_diagnostic" TOOLKIT_RESULT_SCHEMA_VERSION = 11 +PARTIAL_TOOLKIT_RESULT_SCHEMA_VERSION = 12 HISTORICAL_TOOLKIT_RESULT_SCHEMA_VERSIONS = frozenset({9, 10}) SUPPORTED_TOOLKIT_RESULT_SCHEMA_VERSIONS = HISTORICAL_TOOLKIT_RESULT_SCHEMA_VERSIONS | { - TOOLKIT_RESULT_SCHEMA_VERSION + TOOLKIT_RESULT_SCHEMA_VERSION, + PARTIAL_TOOLKIT_RESULT_SCHEMA_VERSION, } TOOLKIT_ADVISORY_KEY = "_ocr_toolkit_advisory" TOOLKIT_ADVISORY_SCHEMA_VERSION = "ocr.toolkit-advisory/v1" diff --git a/src/ocr_toolkit/posting/formatting.py b/src/ocr_toolkit/posting/formatting.py index fc32da3..b258b2d 100644 --- a/src/ocr_toolkit/posting/formatting.py +++ b/src/ocr_toolkit/posting/formatting.py @@ -719,6 +719,7 @@ def summarize_result( outcome_status: str = "success", outcome_message: str = "", coverage_summary: str = "", + repository_coverage_summary: str = "", coverage_diagnostics: CoverageDiagnostics | None = None, warnings: Sequence[Any] = (), suppressed_count: int = 0, @@ -743,6 +744,8 @@ def summarize_result( emoji=use_emoji, ) lines = ["## Open Code Review", "", outcome_line] + if repository_coverage_summary: + lines.extend(["", repository_coverage_summary]) if protected_policy_anchor_sha: lines.append( PROTECTED_POLICY_ANCHOR.format( diff --git a/src/ocr_toolkit/posting/freshness.py b/src/ocr_toolkit/posting/freshness.py index 5eea162..bb12615 100644 --- a/src/ocr_toolkit/posting/freshness.py +++ b/src/ocr_toolkit/posting/freshness.py @@ -13,7 +13,10 @@ RepositoryEvidenceError, ShallowAncestryUnavailable, ) -from ocr_toolkit.ocr_result import TOOLKIT_RESULT_SCHEMA_VERSION +from ocr_toolkit.ocr_result import ( + PARTIAL_TOOLKIT_RESULT_SCHEMA_VERSION, + TOOLKIT_RESULT_SCHEMA_VERSION, +) from ocr_toolkit.providers.gitlab import ( GitLabMergeRequestStale, GitLabMergeRequestTerminal, @@ -65,7 +68,10 @@ def validate_review_freshness( ) -> ReviewFreshnessResult: """Require the current receipt tuple or a proven policy fast-forward.""" - if identity.schema_version != TOOLKIT_RESULT_SCHEMA_VERSION or any( + if identity.schema_version not in { + TOOLKIT_RESULT_SCHEMA_VERSION, + PARTIAL_TOOLKIT_RESULT_SCHEMA_VERSION, + } or any( value is None for value in ( identity.project_id, diff --git a/src/ocr_toolkit/posting/workflow.py b/src/ocr_toolkit/posting/workflow.py index 8c15894..756e4ea 100644 --- a/src/ocr_toolkit/posting/workflow.py +++ b/src/ocr_toolkit/posting/workflow.py @@ -16,7 +16,16 @@ from ocr_toolkit.common.git import isolated_git_environment, read_only_git_prefix from ocr_toolkit.common.markdown import neutralize_quick_actions from ocr_toolkit.evidence.artifacts import repository_artifacts +from ocr_toolkit.evidence.gitlink_projection import ( + make_gitlink_projection, + qualify_gitlink_manifest, + selected_gitlink_manifest_entries, + verify_gitlink_projection, +) +from ocr_toolkit.evidence.gitlinks import plan_gitlink_input +from ocr_toolkit.evidence.repository import GitRepositoryReader, RepositoryEvidenceError from ocr_toolkit.ocr_result import ( + PARTIAL_TOOLKIT_RESULT_SCHEMA_VERSION, TOOLKIT_ADVISORY_KEY, TOOLKIT_PRIVATE_DIAGNOSTIC_KEY, TOOLKIT_RESULT_KEY, @@ -108,7 +117,9 @@ from ocr_toolkit.pre_execution import ( BACKGROUND_REASONS, GITLAB_MERGE_REQUEST_TERMINAL, + GITLINK_NO_REVIEWABLE_FILES, GITLINK_PREVIEW_UNSUPPORTED, + GITLINK_PROJECTION_UNQUALIFIED, PROTECTED_POLICY_RULE_PATH_PENDING, PROTECTED_TARGET_RULE_PATH_PENDING, REVIEW_IDENTITY_UNAVAILABLE, @@ -129,12 +140,23 @@ GitLabProviderError, parse_merge_request_lifecycle, ) +from ocr_toolkit.repository_coverage import ( + RepositoryCoverage, + RepositoryCoverageError, + coverage_from_projection, + parse_repository_coverage, + repository_coverage_summary, +) from ocr_toolkit.result_contract import OcrResultContractError, ReviewOutcome, parse_result_outcome from ocr_toolkit.review_identity import effective_reviewed_sha, full_sha from ocr_toolkit.review_receipt import ReceiptReviewIdentity, validated_review_identity # Kept as a module-level compatibility seam for tests and external monkey-patching. post_review_note = gitlab_api.post_review_note + +CURRENT_POSTING_RECEIPT_VERSIONS = frozenset( + {TOOLKIT_RESULT_SCHEMA_VERSION, PARTIAL_TOOLKIT_RESULT_SCHEMA_VERSION} +) from ocr_toolkit.posting.settings import ( auto_approve, max_post_comments, @@ -236,7 +258,7 @@ def unprotected_target_limitation(toolkit_metadata: Any) -> bool: identity is not None and identity.target_protection == "unprotected" and not ( - identity.schema_version == TOOLKIT_RESULT_SCHEMA_VERSION + identity.schema_version in CURRENT_POSTING_RECEIPT_VERSIONS and identity.protection_mode == "auto" and identity.policy_protection == "protected" ) @@ -249,7 +271,7 @@ def protected_policy_anchor_sha(toolkit_metadata: Any) -> str: identity = validated_review_identity(toolkit_metadata) if ( identity is not None - and identity.schema_version == TOOLKIT_RESULT_SCHEMA_VERSION + and identity.schema_version in CURRENT_POSTING_RECEIPT_VERSIONS and identity.protection_mode == "auto" and identity.target_protection == "unprotected" and identity.policy_protection == "protected" @@ -263,7 +285,7 @@ def validate_posting_review_identity( ) -> ReviewFreshnessResult: """Bind the validated receipt to this job before a current-result write.""" - if identity.schema_version != TOOLKIT_RESULT_SCHEMA_VERSION: + if identity.schema_version not in CURRENT_POSTING_RECEIPT_VERSIONS: raise ReviewFreshnessUnavailable("receipt_unavailable") if ( identity.project_id != config.project_id @@ -384,7 +406,7 @@ def exact_receipt_diff_refs(identity: ReceiptReviewIdentity) -> dict[str, str]: """Use the reviewed diff tuple, never a provider version matched only by H.""" if ( - identity.schema_version != TOOLKIT_RESULT_SCHEMA_VERSION + identity.schema_version not in CURRENT_POSTING_RECEIPT_VERSIONS or identity.diff_base_sha is None or identity.diff_start_sha is None or identity.diff_head_sha is None @@ -953,6 +975,61 @@ def remap_existing_code_location( return matches[0] if len(matches) == 1 else (path, 0) +def verified_partial_repository_coverage( + result: dict[str, Any], identity: ReceiptReviewIdentity +) -> RepositoryCoverage: + """Rebuild the original trees and native selected work before GitLab writes.""" + + if identity.diff_base_sha is None: + raise RepositoryEvidenceError("partial review has no original diff identity") + reader = GitRepositoryReader(Path.cwd()) + plan = plan_gitlink_input(reader, identity.diff_base_sha, identity.source_sha) + if plan is None: + raise RepositoryEvidenceError("partial review has no changed gitlink") + projection = make_gitlink_projection(reader, plan) + selected = selected_gitlink_manifest_entries(projection, result) + qualify_gitlink_manifest(projection, selected, result) + try: + actual = coverage_from_projection(projection, selected, state="partial") + except RepositoryCoverageError as exc: + raise RepositoryEvidenceError("partial review selected coverage is invalid") from exc + metadata = result.get(TOOLKIT_RESULT_KEY) + try: + claimed = parse_repository_coverage( + metadata["repository_coverage"], required_state="partial" + ) + except (KeyError, TypeError, RepositoryCoverageError) as exc: + raise RepositoryEvidenceError("partial review coverage is invalid") from exc + if claimed != actual: + raise RepositoryEvidenceError("partial review coverage disagrees with Git and OCR") + verify_gitlink_projection(reader, projection) + return actual + + +def verified_no_model_repository_coverage(status: PreExecutionStatus) -> RepositoryCoverage: + """Rebuild a path-free gitlink-only advisory before publishing it.""" + + reader = GitRepositoryReader(Path.cwd()) + plan = plan_gitlink_input(reader, status.diff_base_sha, status.source_sha) + if plan is None: + raise RepositoryEvidenceError("gitlink advisory has no changed gitlink") + projection = make_gitlink_projection(reader, plan) + try: + actual = coverage_from_projection(projection, (), state="not_started") + except RepositoryCoverageError as exc: + raise RepositoryEvidenceError("gitlink advisory coverage is invalid") from exc + try: + claimed = parse_repository_coverage( + status.repository_coverage, required_state="not_started" + ) + except RepositoryCoverageError as exc: + raise RepositoryEvidenceError("gitlink advisory coverage is invalid") from exc + if claimed != actual: + raise RepositoryEvidenceError("gitlink advisory coverage disagrees with Git") + verify_gitlink_projection(reader, projection) + return actual + + def post_results(config: GitLabConfig, result: dict[str, Any]) -> int: """Post OCR result comments to GitLab and return a process exit code.""" @@ -987,7 +1064,7 @@ def post_results(config: GitLabConfig, result: dict[str, Any]) -> int: return 1 if ( receipt_identity is not None - and receipt_identity.schema_version != TOOLKIT_RESULT_SCHEMA_VERSION + and receipt_identity.schema_version not in CURRENT_POSTING_RECEIPT_VERSIONS ): print( "Historical toolkit receipt is readback-only; current publication requires " @@ -995,6 +1072,20 @@ def post_results(config: GitLabConfig, result: dict[str, Any]) -> int: file=sys.stderr, ) return 1 + repository_coverage: RepositoryCoverage | None = None + if ( + receipt_identity is not None + and receipt_identity.schema_version == PARTIAL_TOOLKIT_RESULT_SCHEMA_VERSION + ): + try: + repository_coverage = verified_partial_repository_coverage(result, receipt_identity) + except (RepositoryEvidenceError, OSError) as exc: + print( + f"Partial review could not be revalidated ({exc}); no merge-request note " + "was written. Previous review preserved.", + file=sys.stderr, + ) + return 1 constrained_target = unprotected_target_limitation(toolkit_metadata) policy_anchor_sha = protected_policy_anchor_sha(toolkit_metadata) publication = toolkit_metadata.get("publication") if toolkit_metadata is not None else None @@ -1184,6 +1275,11 @@ def render_no_comments_summary(approval_result: ApprovalResult) -> str: outcome_status=summary_status, outcome_message=outcome_message, coverage_summary=summary_outcome.coverage_summary, + repository_coverage_summary=( + repository_coverage_summary(repository_coverage) + if repository_coverage is not None + else "" + ), coverage_diagnostics=coverage_diagnostics, warnings=warnings, suppressed_count=suppressed_count, @@ -1410,6 +1506,11 @@ def render_findings_summary(approval_result: ApprovalResult) -> str: outcome_status=summary_status, outcome_message=outcome_message, coverage_summary=summary_outcome.coverage_summary, + repository_coverage_summary=( + repository_coverage_summary(repository_coverage) + if repository_coverage is not None + else "" + ), coverage_diagnostics=coverage_diagnostics, warnings=warnings, suppressed_count=suppressed_count, @@ -1479,6 +1580,10 @@ def finalize_previous_review_state( if review_identity is not None: if transaction is None: raise ValueError("current-receipt cleanup requires the posting transaction") + partial_effective = ( + outcome.kind == "partial" + or review_identity.schema_version == PARTIAL_TOOLKIT_RESULT_SCHEMA_VERSION + ) cleanup_started = False def remove_if_current(delete: Callable[..., bool], *args: object) -> bool: @@ -1510,7 +1615,7 @@ def remove_if_current(delete: Callable[..., bool], *args: object) -> bool: return False return True - if outcome.kind == "partial": + if partial_effective: note_ids = previous_refs.summary_plain_note_ids draft_ids = previous_refs.summary_draft_note_ids discussion_refs: list[tuple[str, int]] = [] @@ -1527,13 +1632,14 @@ def remove_if_current(delete: Callable[..., bool], *args: object) -> bool: for note_id in draft_ids: if not remove_if_current(gitlab_api.delete_draft_note, note_id): return False - if outcome.kind == "partial": + if partial_effective: for note_id in previous_refs.terminal_plain_note_ids: if not remove_if_current(gitlab_api.delete_plain_note, note_id): return False - for discussion_id in previous_refs.discussions_to_resolve: - if not remove_if_current(resolve_discussion, discussion_id): - return False + if not partial_effective: + for discussion_id in previous_refs.discussions_to_resolve: + if not remove_if_current(resolve_discussion, discussion_id): + return False return True if outcome.kind == "partial": @@ -1884,6 +1990,16 @@ def post_pre_execution_status(config: GitLabConfig, status: PreExecutionStatus) } if pre_execution_status_fresh_or_exit(config, status, exact_policy=exact_policy) is not None: return 1 + no_model_coverage: RepositoryCoverage | None = None + if status.reason == GITLINK_NO_REVIEWABLE_FILES: + try: + no_model_coverage = verified_no_model_repository_coverage(status) + except (RepositoryEvidenceError, OSError) as exc: + print( + f"Gitlink advisory could not be revalidated ({exc}); previous review preserved.", + file=sys.stderr, + ) + return 1 published_draft = False cleanup_started = False @@ -1933,9 +2049,21 @@ def current_or_rollback( file=sys.stderr, ) return 1 - if status.reason in {GITLINK_PREVIEW_UNSUPPORTED, UNCLASSIFIED_PREVIEW_DIAGNOSTICS}: + if status.reason in { + GITLINK_PREVIEW_UNSUPPORTED, + GITLINK_NO_REVIEWABLE_FILES, + GITLINK_PROJECTION_UNQUALIFIED, + UNCLASSIFIED_PREVIEW_DIAGNOSTICS, + }: transaction = PostingTransaction() - if status.reason == GITLINK_PREVIEW_UNSUPPORTED: + if status.reason == GITLINK_NO_REVIEWABLE_FILES: + heading = "**Open Code Review: changed gitlinks remain unreviewed**" + body = ( + repository_coverage_summary(no_model_coverage) + + " Ordinary files were not reviewed in this run. Previous review " + "comments were preserved." + ) + elif status.reason in {GITLINK_PREVIEW_UNSUPPORTED, GITLINK_PROJECTION_UNQUALIFIED}: heading = "**Open Code Review preview could not cover a changed gitlink**" body = ( "Review not started: a changed Git submodule pointer cannot be safely covered " diff --git a/src/ocr_toolkit/pre_execution.py b/src/ocr_toolkit/pre_execution.py index b4ac225..26dfbd2 100644 --- a/src/ocr_toolkit/pre_execution.py +++ b/src/ocr_toolkit/pre_execution.py @@ -11,11 +11,14 @@ from pathlib import Path from typing import Any +from ocr_toolkit.repository_coverage import RepositoryCoverageError, parse_repository_coverage from ocr_toolkit.review_receipt import ReceiptReviewIdentity, _parse_review_identity STATUS_SCHEMA = "ocr.pre-execution-status/v5" +GITLINK_STATUS_SCHEMA = "ocr.pre-execution-status/v6" # v5 embeds the frozen v11 review tuple; a new receipt shape needs a new status schema. STATUS_REVIEW_RECEIPT_VERSION = 11 +GITLINK_STATUS_REVIEW_RECEIPT_VERSION = 12 PROTECTED_TARGET_RULE_PATH_PENDING = "protected_target_rule_path_pending" PROTECTED_POLICY_RULE_PATH_PENDING = "protected_policy_rule_path_pending" GITLAB_MERGE_REQUEST_TERMINAL = "gitlab_merge_request_terminal" @@ -25,6 +28,8 @@ BACKGROUND_FILE_SIZE_LIMIT_REASON = "ocr_background_file_size_limit" UNCLASSIFIED_PREVIEW_DIAGNOSTICS = "unclassified_preview_diagnostics" GITLINK_PREVIEW_UNSUPPORTED = "gitlink_preview_unsupported" +GITLINK_NO_REVIEWABLE_FILES = "gitlink_no_reviewable_files" +GITLINK_PROJECTION_UNQUALIFIED = "gitlink_projection_unqualified" EVIDENCE_PREFLIGHT_STAGE = "evidence_preflight" OCR_PREVIEW_STAGE = "ocr_preview" MODEL_NOT_STARTED = "not_started" @@ -70,6 +75,7 @@ class PreExecutionStatus: project_id: str | None = None change_id: str | None = None terminal_state: str | None = None + repository_coverage: dict[str, object] | None = None def _sha(value: str) -> bool: @@ -77,12 +83,17 @@ def _sha(value: str) -> bool: def _validate(status: PreExecutionStatus) -> None: - if status.schema_version != STATUS_SCHEMA or any( + if status.schema_version not in {STATUS_SCHEMA, GITLINK_STATUS_SCHEMA} or any( not _sha(value) for value in (status.diff_base_sha, status.source_sha) ): raise PreExecutionStatusError("pre-execution status fields are invalid") if status.model_execution != MODEL_NOT_STARTED: raise PreExecutionStatusError("pre-execution status fields are invalid") + if status.schema_version == GITLINK_STATUS_SCHEMA: + _validate_gitlink_status(status) + return + if status.repository_coverage is not None: + raise PreExecutionStatusError("pre-execution status fields are invalid") if status.reason in NOTE_REASONS: review = _parse_review_identity(status.review, STATUS_REVIEW_RECEIPT_VERSION) if ( @@ -218,6 +229,44 @@ def _validate(status: PreExecutionStatus) -> None: raise PreExecutionStatusError("pre-execution status fields are invalid") +def _validate_gitlink_status(status: PreExecutionStatus) -> None: + if ( + status.reason not in {GITLINK_NO_REVIEWABLE_FILES, GITLINK_PROJECTION_UNQUALIFIED} + or status.stage != OCR_PREVIEW_STAGE + or not isinstance(status.policy_sha, str) + or not _sha(status.policy_sha) + or any( + value is not None + for value in ( + status.diagnostic_count, + status.actual, + status.limit, + status.unit, + status.provider, + status.project_id, + status.change_id, + status.terminal_state, + ) + ) + ): + raise PreExecutionStatusError("pre-execution status fields are invalid") + review = _parse_review_identity(status.review, GITLINK_STATUS_REVIEW_RECEIPT_VERSION) + if ( + review is None + or review.diff_base_sha != status.diff_base_sha + or review.source_sha != status.source_sha + or review.policy_sha != status.policy_sha + ): + raise PreExecutionStatusError("pre-execution status review identity is invalid") + if status.reason == GITLINK_NO_REVIEWABLE_FILES: + try: + parse_repository_coverage(status.repository_coverage, required_state="not_started") + except RepositoryCoverageError as exc: + raise PreExecutionStatusError("pre-execution gitlink coverage is invalid") from exc + elif status.repository_coverage is not None: + raise PreExecutionStatusError("pre-execution gitlink coverage is invalid") + + def _valid_provider_ids(*values: str | None) -> bool: return all( isinstance(value, str) @@ -234,7 +283,12 @@ def pre_execution_review_identity(status: PreExecutionStatus) -> ReceiptReviewId """Return the status-versioned identity only from a valid private envelope.""" _validate(status) - return _parse_review_identity(status.review, STATUS_REVIEW_RECEIPT_VERSION) + version = ( + GITLINK_STATUS_REVIEW_RECEIPT_VERSION + if status.schema_version == GITLINK_STATUS_SCHEMA + else STATUS_REVIEW_RECEIPT_VERSION + ) + return _parse_review_identity(status.review, version) def _pairs(pairs: list[tuple[str, Any]]) -> dict[str, Any]: @@ -272,8 +326,11 @@ def write_pre_execution_status(path: Path, status: PreExecutionStatus) -> None: """Atomically create an owner-only exact envelope without following links.""" _validate(status) + fields = asdict(status) + if status.schema_version == STATUS_SCHEMA: + fields.pop("repository_coverage") payload = ( - json.dumps(asdict(status), sort_keys=True, separators=(",", ":"), ensure_ascii=True) + "\n" + json.dumps(fields, sort_keys=True, separators=(",", ":"), ensure_ascii=True) + "\n" ).encode("ascii") if len(payload) > MAX_STATUS_BYTES: raise PreExecutionStatusError("pre-execution status exceeds its byte limit") @@ -365,7 +422,7 @@ def read_pre_execution_status( value = json.loads(raw.decode("utf-8", errors="strict"), object_pairs_hook=_pairs) except (UnicodeDecodeError, json.JSONDecodeError, RecursionError) as exc: raise PreExecutionStatusError("pre-execution status is malformed") from exc - if not isinstance(value, dict) or set(value) != { + legacy_fields = { "schema_version", "reason", "diff_base_sha", @@ -382,7 +439,17 @@ def read_pre_execution_status( "project_id", "change_id", "terminal_state", - }: + } + if ( + not isinstance(value, dict) + or value.get("schema_version") not in {STATUS_SCHEMA, GITLINK_STATUS_SCHEMA} + or set(value) + != ( + legacy_fields | {"repository_coverage"} + if value.get("schema_version") == GITLINK_STATUS_SCHEMA + else legacy_fields + ) + ): raise PreExecutionStatusError("pre-execution status fields are invalid") if any( not isinstance(value.get(key), str) diff --git a/src/ocr_toolkit/providers/local.py b/src/ocr_toolkit/providers/local.py index c596846..5a02eaf 100644 --- a/src/ocr_toolkit/providers/local.py +++ b/src/ocr_toolkit/providers/local.py @@ -20,6 +20,7 @@ from ocr_toolkit.reporting.outcome import FindingVisibility, review_outcome_line from ocr_toolkit.reporting.sections import report_sections from ocr_toolkit.reporting.text import clean_text, code_text, comment_line +from ocr_toolkit.repository_coverage import repository_coverage_summary def local_summary(report: ReviewReport) -> str: @@ -42,12 +43,22 @@ def local_summary(report: ReviewReport) -> str: ), ] if report.failure_stage is not None: + lines.extend( + ["", f"Stopped at: {inline_code(report.failure_stage, escape_controls=True)}."] + ) + if report.repository_coverage is None: + lines.append( + "Findings, coverage and execution usage are unavailable or untrusted. " + "See the diagnostic output for the blocking check." + ) + if report.repository_coverage is not None: + lines.extend(["", repository_coverage_summary(report.repository_coverage)]) + elif report.gitlink_unqualified: lines.extend( [ "", - f"Stopped at: {inline_code(report.failure_stage, escape_controls=True)}.", - "Findings, coverage and execution usage are unavailable or untrusted. " - "See the diagnostic output for the blocking check.", + "Review not started: changed Git submodule pointer(s) could not be " + "qualified and were not reviewed.", ] ) lines.extend(report_sections(report.comments, report.diagnostics, report.warnings)) diff --git a/src/ocr_toolkit/reporting/model.py b/src/ocr_toolkit/reporting/model.py index 0aa332b..d122908 100644 --- a/src/ocr_toolkit/reporting/model.py +++ b/src/ocr_toolkit/reporting/model.py @@ -16,6 +16,7 @@ format_verified_mcp_usage, validated_completed_actions, ) +from ocr_toolkit.repository_coverage import RepositoryCoverage from ocr_toolkit.result_contract import OcrResultContractError, ReviewOutcome, parse_result_outcome FailureStage = Literal[ @@ -47,6 +48,7 @@ class ExecutionFacts: advisory: OcrToolkitAdvisory | None = None federation: dict[str, Any] | None = None legacy_policy: bool = False + repository_coverage: RepositoryCoverage | None = None @dataclass(frozen=True, slots=True) @@ -65,6 +67,8 @@ class ReviewReport: publication: dict[str, Any] | None = None advisory: OcrToolkitAdvisory | None = None failure_stage: FailureStage | None = None + repository_coverage: RepositoryCoverage | None = None + gitlink_unqualified: bool = False def report_from_result( @@ -113,10 +117,17 @@ def report_from_result( mcp_usage_summary=mcp_summary, publication=deepcopy(execution.publication), advisory=execution.advisory, + repository_coverage=execution.repository_coverage, ) -def failed_report(stage: FailureStage, *, reviewed_sha: str = "") -> ReviewReport: +def failed_report( + stage: FailureStage, + *, + reviewed_sha: str = "", + repository_coverage: RepositoryCoverage | None = None, + gitlink_unqualified: bool = False, +) -> ReviewReport: """Represent unavailable review data without rendering raw exceptions or output.""" if stage not in get_args(FailureStage): @@ -129,4 +140,6 @@ def failed_report(stage: FailureStage, *, reviewed_sha: str = "") -> ReviewRepor diagnostics=CoverageDiagnostics((), 0, 0, 0, 0), reviewed_sha=safe_sha, failure_stage=stage, + repository_coverage=repository_coverage, + gitlink_unqualified=gitlink_unqualified, ) diff --git a/src/ocr_toolkit/repository_coverage.py b/src/ocr_toolkit/repository_coverage.py new file mode 100644 index 0000000..d5f4ab8 --- /dev/null +++ b/src/ocr_toolkit/repository_coverage.py @@ -0,0 +1,137 @@ +"""Closed, path-free repository coverage for qualified partial gitlink reviews.""" + +from __future__ import annotations + +from dataclasses import asdict, dataclass +from typing import Any, Literal + +from ocr_toolkit.evidence.gitlink_projection import GitlinkProjection, RawDiffEntry + +REPOSITORY_COVERAGE_SCHEMA = "ocr.repository-coverage/v1" +GITLINK_PARTIAL_REASON = "changed_gitlink_not_reviewed" +MAX_COVERAGE_COUNT = 20_000 +CoverageState = Literal["partial", "not_started"] + + +class RepositoryCoverageError(ValueError): + """Reject an unsupported or inconsistent effective-coverage statement.""" + + +@dataclass(frozen=True, slots=True) +class RepositoryCoverage: + """Public counts without paths, object IDs, URLs or provider content.""" + + schema_version: str + state: CoverageState + reason: str + ordinary_changed: int + ordinary_selected: int + ordinary_completed: int + ordinary_excluded: int + gitlink_changes: int + gitlink_added: int + gitlink_updated: int + gitlink_deleted: int + gitlink_moved: int + + def payload(self) -> dict[str, object]: + return asdict(self) + + +def parse_repository_coverage( + value: Any, *, required_state: CoverageState | None = None +) -> RepositoryCoverage: + """Parse one exact versioned effective coverage without coercion.""" + + if not isinstance(value, dict) or set(value) != set(RepositoryCoverage.__dataclass_fields__): + raise RepositoryCoverageError("repository coverage has an unsupported shape") + if ( + value.get("schema_version") != REPOSITORY_COVERAGE_SCHEMA + or value.get("state") not in {"partial", "not_started"} + or value.get("reason") != GITLINK_PARTIAL_REASON + or (required_state is not None and value.get("state") != required_state) + ): + raise RepositoryCoverageError("repository coverage has unsupported values") + count_fields = ( + "ordinary_changed", + "ordinary_selected", + "ordinary_completed", + "ordinary_excluded", + "gitlink_changes", + "gitlink_added", + "gitlink_updated", + "gitlink_deleted", + "gitlink_moved", + ) + if any( + type(value[field]) is not int or not 0 <= value[field] <= MAX_COVERAGE_COUNT + for field in count_fields + ): + raise RepositoryCoverageError("repository coverage has invalid counts") + if ( + value["ordinary_selected"] > value["ordinary_changed"] + or value["ordinary_completed"] > value["ordinary_selected"] + or value["ordinary_excluded"] != value["ordinary_changed"] - value["ordinary_selected"] + or value["gitlink_changes"] == 0 + or value["gitlink_changes"] + != value["gitlink_added"] + + value["gitlink_updated"] + + value["gitlink_deleted"] + + 2 * value["gitlink_moved"] + or ( + value["state"] == "partial" + and ( + value["ordinary_selected"] == 0 + or value["ordinary_completed"] != value["ordinary_selected"] + ) + ) + or ( + value["state"] == "not_started" + and (value["ordinary_selected"] != 0 or value["ordinary_completed"] != 0) + ) + ): + raise RepositoryCoverageError("repository coverage counts are inconsistent") + return RepositoryCoverage(**value) + + +def coverage_from_projection( + projection: GitlinkProjection, + selected: tuple[RawDiffEntry, ...], + *, + state: CoverageState, +) -> RepositoryCoverage: + """Construct verified public counters from the frozen private input plan.""" + + counts = projection.plan.counts + selected_count = len(selected) + value = { + "schema_version": REPOSITORY_COVERAGE_SCHEMA, + "state": state, + "reason": GITLINK_PARTIAL_REASON, + "ordinary_changed": len(projection.ordinary_diff), + "ordinary_selected": selected_count, + "ordinary_completed": selected_count if state == "partial" else 0, + "ordinary_excluded": len(projection.ordinary_diff) - selected_count, + "gitlink_changes": len(projection.plan.changes), + "gitlink_added": counts["added"], + "gitlink_updated": counts["updated"], + "gitlink_deleted": counts["deleted"], + "gitlink_moved": counts["moved"], + } + return parse_repository_coverage(value, required_state=state) + + +def repository_coverage_summary(coverage: RepositoryCoverage) -> str: + """Render one path-free, non-approval coverage statement.""" + + if coverage.state == "not_started": + return ( + "Review not started: no ordinary files were selected; " + f"{coverage.gitlink_changes} changed gitlink pointer(s) remain unreviewed." + ) + return ( + "Repository coverage: partial; " + f"{coverage.ordinary_completed} of {coverage.ordinary_changed} ordinary diff record(s) " + "completed; " + f"{coverage.gitlink_changes} changed gitlink pointer(s) were not reviewed." + ) diff --git a/src/ocr_toolkit/review_receipt.py b/src/ocr_toolkit/review_receipt.py index cb8598d..8e60d64 100644 --- a/src/ocr_toolkit/review_receipt.py +++ b/src/ocr_toolkit/review_receipt.py @@ -13,6 +13,7 @@ MAX_TOOLKIT_MCP_TOOLS_PER_SERVER, MAX_TOOLKIT_MCP_USAGE_COUNT, MAX_TOOLKIT_MCP_USAGE_SERVERS, + PARTIAL_TOOLKIT_RESULT_SCHEMA_VERSION, SUPPORTED_TOOLKIT_RESULT_SCHEMA_VERSIONS, TOOLKIT_MCP_SERVER_NAME_RE, ) @@ -21,20 +22,24 @@ publication_outcome_for_summary as publication_outcome_for_summary, ) from ocr_toolkit.reporting.federation import valid_reconciliation +from ocr_toolkit.repository_coverage import RepositoryCoverageError, parse_repository_coverage INVALID_APPROVAL_RECEIPT_REASON = "the review-time approval receipt is missing or invalid" # Keep historical wire shapes explicit; changing the current producer version # must never reinterpret a persisted v11 receipt as a future format. RECEIPT_V11 = 11 +RECEIPT_V12 = PARTIAL_TOOLKIT_RESULT_SCHEMA_VERSION _FEDERATION_SCHEMA_BY_RECEIPT_VERSION = { 9: "ocr.federation/v1", 10: "ocr.federation/v2", RECEIPT_V11: "ocr.federation/v2", + RECEIPT_V12: "ocr.federation/v2", } _PUBLICATION_SCHEMA_BY_RECEIPT_VERSION = { 9: None, 10: "ocr.publication/v2", RECEIPT_V11: "ocr.publication/v2", + RECEIPT_V12: "ocr.publication/v2", } UNPROTECTED_APPROVAL_REASON = ( "the GitLab target branch was unprotected; limited reviews are comment-only" @@ -238,7 +243,7 @@ def _parse_review_identity(review: Any, receipt_version: int) -> ReceiptReviewId schema_version=receipt_version, policy_sha=review["policy_sha"], ) - if receipt_version != RECEIPT_V11 or set(review) not in { + if receipt_version not in {RECEIPT_V11, RECEIPT_V12} or set(review) not in { _V11_REVIEW_KEYS, _V11_REVIEW_KEYS | {"ci_job_id"}, }: @@ -335,12 +340,20 @@ def automatic_approval_metadata_reason(toolkit_metadata: Any) -> str: "tool_execution", "cleanup", } + | ({"repository_coverage"} if receipt_version == RECEIPT_V12 else set()) ): return invalid review_identity = _parse_review_identity(toolkit_metadata.get("review"), receipt_version) if review_identity is None: return invalid + if receipt_version == RECEIPT_V12: + try: + parse_repository_coverage( + toolkit_metadata["repository_coverage"], required_state="partial" + ) + except RepositoryCoverageError: + return invalid context = toolkit_metadata.get("context") if not isinstance(context, dict) or set(context) != { @@ -600,6 +613,8 @@ def automatic_approval_metadata_reason(toolkit_metadata: Any) -> str: return "mutable review context was admitted" if external and federation["state"] == "degraded": return "external MCP use was advisory, incomplete or accounting-mismatched" + if receipt_version == RECEIPT_V12: + return "changed Git submodule pointers were not reviewed; automatic approval is unavailable" return "" diff --git a/src/ocr_toolkit/review_runner.py b/src/ocr_toolkit/review_runner.py index 8fc3432..d0bf940 100644 --- a/src/ocr_toolkit/review_runner.py +++ b/src/ocr_toolkit/review_runner.py @@ -72,7 +72,7 @@ qualify_gitlink_preview, verify_gitlink_projection, ) -from ocr_toolkit.evidence.gitlinks import plan_gitlink_input +from ocr_toolkit.evidence.gitlinks import changed_gitlinks, plan_gitlink_input from ocr_toolkit.evidence.invocation import collect_invocation_evidence from ocr_toolkit.evidence.mcp import ( COVERAGE_TOOL_NAME, @@ -99,6 +99,7 @@ from ocr_toolkit.federation.contracts import Registry from ocr_toolkit.ocr_result import ( MAX_TOOLKIT_MCP_USAGE_COUNT, + PARTIAL_TOOLKIT_RESULT_SCHEMA_VERSION, PUBLIC_REVIEW_TOOL_CALL_NAMES, TOOLKIT_ADVISORY_KEY, TOOLKIT_PRIVATE_DIAGNOSTIC_KEY, @@ -120,7 +121,9 @@ BACKGROUND_CHARACTER_LIMIT_REASON, BACKGROUND_FILE_SIZE_LIMIT_REASON, GITLAB_MERGE_REQUEST_TERMINAL, - GITLINK_PREVIEW_UNSUPPORTED, + GITLINK_NO_REVIEWABLE_FILES, + GITLINK_PROJECTION_UNQUALIFIED, + GITLINK_STATUS_SCHEMA, OCR_PREVIEW_STAGE, PROTECTED_POLICY_RULE_PATH_PENDING, PROTECTED_TARGET_RULE_PATH_PENDING, @@ -163,6 +166,7 @@ failed_report, report_from_result, ) +from ocr_toolkit.repository_coverage import RepositoryCoverage, coverage_from_projection from ocr_toolkit.result_contract import OcrResultContractError, ReviewOutcome, parse_result_outcome from ocr_toolkit.result_usage import normalize_token_usage, token_usage_mapping from ocr_toolkit.review_debug import DebugBundle, DebugStatus @@ -402,6 +406,8 @@ class ReviewRunState: report: ReviewReport | None = None debug: DebugBundle | None = None progress: ReviewProgress | None = None + repository_coverage: RepositoryCoverage | None = None + gitlink_unqualified: bool = False def enter(self, stage: FailureStage) -> None: """Enter a real execution phase and notify the optional progress observer.""" @@ -1786,7 +1792,15 @@ def finalize(payload: dict[str, object]) -> dict[str, object]: ) state.enter("result-validation") metadata["publication"] = publication - metadata["schema_version"] = TOOLKIT_RESULT_SCHEMA_VERSION + effective_coverage: RepositoryCoverage | None = None + if projection is not None: + effective_coverage = coverage_from_projection( + projection, selected_diff, state="partial" + ) + metadata["repository_coverage"] = effective_coverage.payload() + metadata["schema_version"] = PARTIAL_TOOLKIT_RESULT_SCHEMA_VERSION + else: + metadata["schema_version"] = TOOLKIT_RESULT_SCHEMA_VERSION provider_receipt = identity.target_protection in {"protected", "unprotected"} if provider_receipt: if not toolkit_receipt_is_valid(metadata): @@ -1816,6 +1830,7 @@ def finalize(payload: dict[str, object]) -> dict[str, object]: toolkit_advisory, mcp.get("federation") if isinstance(mcp, dict) else None, enrichment.legacy_policy if enrichment is not None else False, + effective_coverage, ), reviewed_sha=identity.source_sha, ) @@ -2163,12 +2178,14 @@ def _run_background_qualified_review( status_review = _status_review(identity) if gitlink_detected: + if state is not None: + state.gitlink_unqualified = True if status_review is not None: write_pre_execution_status( artifacts.pre_execution_status, PreExecutionStatus( - schema_version=STATUS_SCHEMA, - reason=GITLINK_PREVIEW_UNSUPPORTED, + schema_version=GITLINK_STATUS_SCHEMA, + reason=GITLINK_PROJECTION_UNQUALIFIED, diff_base_sha=refs.base, source_sha=refs.head, policy_sha=identity.policy_sha, @@ -2256,6 +2273,28 @@ def _run_background_qualified_review( state.observe("preview", "degraded" if qualification.advisory is not None else "passed") if projection is not None: if not qualification.selected_diff: + coverage = coverage_from_projection(projection, (), state="not_started") + if state is not None: + state.repository_coverage = coverage + if status_review is not None: + try: + write_pre_execution_status( + artifacts.pre_execution_status, + PreExecutionStatus( + schema_version=GITLINK_STATUS_SCHEMA, + reason=GITLINK_NO_REVIEWABLE_FILES, + diff_base_sha=refs.base, + source_sha=refs.head, + policy_sha=identity.policy_sha, + review=status_review, + stage=OCR_PREVIEW_STAGE, + repository_coverage=coverage.payload(), + ), + ) + except PreExecutionStatusError as exc: + raise ReviewRunnerError( + "OCR gitlink no-model status could not be persisted safely" + ) from exc print( "OCR gitlink qualification: no ordinary files selected; model review did not run", file=sys.stderr, @@ -3100,7 +3139,12 @@ def run_evidence_review( report = ( state.report if code == 0 - else failed_report(state.stage, reviewed_sha=state.reviewed_sha) + else failed_report( + state.stage, + reviewed_sha=state.reviewed_sha, + repository_coverage=state.repository_coverage, + gitlink_unqualified=state.gitlink_unqualified, + ) ) assert report is not None state.enter("reporting") @@ -3415,6 +3459,7 @@ def _run_evidence_review( ocr_binary = native_binary or _resolve_ocr_binary() projection: GitlinkProjection | None = None gitlink_detected = False + gitlink_plan = None try: reader = GitRepositoryReader(Path.cwd()) gitlink_plan = plan_gitlink_input(reader, refs.base, refs.head) @@ -3431,7 +3476,22 @@ def _run_evidence_review( else: gitlink_detected = True except RepositoryEvidenceError as exc: - raise ReviewRunnerError("changed gitlink evidence could not be qualified") from exc + if gitlink_plan is not None: + gitlink_detected = True + else: + try: + gitlink_detected = bool(changed_gitlinks(reader, refs.base, refs.head)) + except RepositoryEvidenceError: + raise ReviewRunnerError( + "changed gitlink evidence could not be qualified" + ) from exc + if not gitlink_detected: + raise ReviewRunnerError("changed gitlink evidence could not be qualified") from exc + print( + "OCR gitlink qualification: immutable changed pointer is outside the " + "qualified subset; model review did not run", + file=sys.stderr, + ) exit_code, background_qualification = _run_background_qualified_review( result_path, stderr_path, diff --git a/tests/test_gitlinks.py b/tests/test_gitlinks.py index 10bc789..afc1d74 100644 --- a/tests/test_gitlinks.py +++ b/tests/test_gitlinks.py @@ -19,6 +19,11 @@ ) from ocr_toolkit.evidence.gitlinks import changed_gitlinks, plan_gitlink_input from ocr_toolkit.evidence.repository import GitRepositoryReader, RepositoryEvidenceError +from ocr_toolkit.pre_execution import ( + GITLINK_PROJECTION_UNQUALIFIED, + GITLINK_STATUS_SCHEMA, + read_pre_execution_status, +) from ocr_toolkit.preflight import validate_ocr_binary OLD_OID = "1" * 40 @@ -697,6 +702,56 @@ def test_changed_gitlink_closes_preview_before_model( assert not artifacts.pre_execution_status.exists() +def test_unqualified_gitlink_writes_versioned_no_model_status(tmp_path: Path) -> None: + repo = tmp_path / "repo" + repo.mkdir() + base, head = _fixture(repo, "updated") + artifacts = repository_artifacts(repo) + prepare_artifact_directory(artifacts) + home = tmp_path / "home" + home.mkdir(mode=0o700) + identity = review_runner.ReviewIdentity( + source_sha=head, + policy_sha=base, + mr_author_id=41, + context_mode="off", + context=None, + target_sha=base, + target_protection="protected", + project_id="1", + merge_request_iid="2", + diff_base_sha=base, + diff_start_sha=base, + diff_head_sha=head, + diff_version_id=7, + target_branch="main", + policy_project_id="1", + policy_branch="main", + policy_protection="protected", + protection_mode="auto", + ) + code, _qualification = review_runner._run_background_qualified_review( + tmp_path / "result.json", + tmp_path / "stderr.log", + [], + ocr_binary="/private/ocr", + session_home=home, + artifacts=artifacts, + refs=review_runner.ReviewRefs(base, head), + identity=identity, + gitlink_detected=True, + ) + assert code == 2 + status = read_pre_execution_status( + artifacts.pre_execution_status, + expected_diff_base_sha=base, + expected_source_sha=head, + ) + assert status.schema_version == GITLINK_STATUS_SCHEMA + assert status.reason == GITLINK_PROJECTION_UNQUALIFIED + assert status.repository_coverage is None + + @pytest.mark.parametrize("kind", ["added", "updated", "deleted"]) def test_installed_ocr_gitlink_preview_qualification( tmp_path: Path, kind: str, monkeypatch: pytest.MonkeyPatch diff --git a/tests/test_repository_coverage.py b/tests/test_repository_coverage.py new file mode 100644 index 0000000..46d82d9 --- /dev/null +++ b/tests/test_repository_coverage.py @@ -0,0 +1,439 @@ +"""Closed v12 partial coverage and v6 no-model status contracts.""" + +from __future__ import annotations + +import hashlib +import json +from copy import deepcopy +from dataclasses import replace +from pathlib import Path + +import pytest + +from ocr_toolkit.evidence.gitlink_projection import make_gitlink_projection +from ocr_toolkit.evidence.gitlinks import plan_gitlink_input +from ocr_toolkit.evidence.repository import GitRepositoryReader, RepositoryEvidenceError +from ocr_toolkit.posting import workflow +from ocr_toolkit.posting.snapshot import BotCommentRefs +from ocr_toolkit.posting.transaction import PostingTransaction +from ocr_toolkit.pre_execution import ( + GITLINK_NO_REVIEWABLE_FILES, + GITLINK_PREVIEW_UNSUPPORTED, + GITLINK_PROJECTION_UNQUALIFIED, + GITLINK_STATUS_SCHEMA, + OCR_PREVIEW_STAGE, + STATUS_SCHEMA, + PreExecutionStatus, + PreExecutionStatusError, + pre_execution_review_identity, + read_pre_execution_status, + write_pre_execution_status, +) +from ocr_toolkit.providers.local import local_summary +from ocr_toolkit.reporting.model import failed_report +from ocr_toolkit.repository_coverage import ( + GITLINK_PARTIAL_REASON, + REPOSITORY_COVERAGE_SCHEMA, + RepositoryCoverageError, + coverage_from_projection, + parse_repository_coverage, +) +from ocr_toolkit.result_contract import ReviewOutcome +from ocr_toolkit.review_receipt import ( + automatic_approval_metadata_reason, + receipt_review_identity, + toolkit_receipt_is_valid, +) +from tests.support import gitlab_config +from tests.test_gitlinks import _fixture, _gitlink_only_fixture, _move_fixture +from tests.test_review_receipt_v11 import _receipt_v11 + + +def _coverage(*, state: str = "partial") -> dict[str, object]: + selected = 1 if state == "partial" else 0 + return { + "schema_version": REPOSITORY_COVERAGE_SCHEMA, + "state": state, + "reason": GITLINK_PARTIAL_REASON, + "ordinary_changed": 1, + "ordinary_selected": selected, + "ordinary_completed": selected, + "ordinary_excluded": 1 - selected, + "gitlink_changes": 1, + "gitlink_added": 0, + "gitlink_updated": 1, + "gitlink_deleted": 0, + "gitlink_moved": 0, + } + + +@pytest.mark.parametrize( + ("field", "value"), + [ + ("reason", "future_reason"), + ("state", "complete"), + ("ordinary_selected", True), + ("ordinary_completed", 0), + ("ordinary_excluded", 1), + ("gitlink_changes", 0), + ("gitlink_moved", 1), + ], +) +def test_repository_coverage_rejects_tampered_closed_fields(field: str, value: object) -> None: + payload = _coverage() + payload[field] = value + with pytest.raises(RepositoryCoverageError): + parse_repository_coverage(payload, required_state="partial") + + +def test_effective_coverage_counts_both_endpoints_of_gitlink_move(tmp_path: Path) -> None: + base, head = _move_fixture(tmp_path) + reader = GitRepositoryReader(tmp_path) + plan = plan_gitlink_input(reader, base, head) + assert plan is not None + projection = make_gitlink_projection(reader, plan) + coverage = coverage_from_projection(projection, projection.ordinary_diff, state="partial") + assert coverage.gitlink_changes == 2 + assert coverage.gitlink_moved == 1 + assert coverage.gitlink_added == coverage.gitlink_deleted == 0 + assert coverage.ordinary_completed == len(projection.ordinary_diff) + + +def test_v12_receipt_is_valid_but_cannot_approve_or_upgrade_v11() -> None: + old = _receipt_v11() + assert toolkit_receipt_is_valid(old) + assert receipt_review_identity(old).schema_version == 11 # type: ignore[union-attr] + + partial = deepcopy(old) + partial["schema_version"] = 12 + partial["repository_coverage"] = _coverage() + assert toolkit_receipt_is_valid(partial) + assert receipt_review_identity(partial).schema_version == 12 # type: ignore[union-attr] + assert "submodule pointers were not reviewed" in automatic_approval_metadata_reason(partial) + + missing = deepcopy(partial) + missing.pop("repository_coverage") + assert not toolkit_receipt_is_valid(missing) + old_with_new_field = deepcopy(old) + old_with_new_field["repository_coverage"] = _coverage() + assert not toolkit_receipt_is_valid(old_with_new_field) + malformed = deepcopy(partial) + malformed["repository_coverage"]["gitlink_changes"] = 2 + assert not toolkit_receipt_is_valid(malformed) + + +def test_v6_no_model_status_round_trips_without_changing_v5_wire_shape(tmp_path: Path) -> None: + review = _receipt_v11()["review"] + status_path = tmp_path / "status.json" + status = PreExecutionStatus( + schema_version=GITLINK_STATUS_SCHEMA, + reason=GITLINK_NO_REVIEWABLE_FILES, + diff_base_sha=review["diff_base_sha"], + source_sha=review["source_sha"], + policy_sha=review["policy_sha"], + review=review, + stage=OCR_PREVIEW_STAGE, + repository_coverage=_coverage(state="not_started"), + ) + write_pre_execution_status(status_path, status) + loaded = read_pre_execution_status( + status_path, + expected_diff_base_sha=review["diff_base_sha"], + expected_source_sha=review["source_sha"], + ) + assert loaded == status + identity = pre_execution_review_identity(loaded) + assert identity is not None and identity.schema_version == 12 + + legacy = PreExecutionStatus( + schema_version=STATUS_SCHEMA, + reason=GITLINK_PREVIEW_UNSUPPORTED, + diff_base_sha=review["diff_base_sha"], + source_sha=review["source_sha"], + policy_sha=review["policy_sha"], + review=review, + stage=OCR_PREVIEW_STAGE, + ) + write_pre_execution_status(status_path, legacy) + assert "repository_coverage" not in json.loads(status_path.read_text()) + assert ( + read_pre_execution_status( + status_path, + expected_diff_base_sha=review["diff_base_sha"], + expected_source_sha=review["source_sha"], + ) + == legacy + ) + + +def test_v6_no_model_status_rejects_wrong_coverage(tmp_path: Path) -> None: + review = _receipt_v11()["review"] + status = PreExecutionStatus( + schema_version=GITLINK_STATUS_SCHEMA, + reason=GITLINK_NO_REVIEWABLE_FILES, + diff_base_sha=review["diff_base_sha"], + source_sha=review["source_sha"], + policy_sha=review["policy_sha"], + review=review, + stage=OCR_PREVIEW_STAGE, + repository_coverage=_coverage(state="partial"), + ) + with pytest.raises(PreExecutionStatusError, match="coverage"): + write_pre_execution_status(tmp_path / "status.json", status) + + +def test_v6_unqualified_status_has_no_claimed_coverage(tmp_path: Path) -> None: + review = _receipt_v11()["review"] + status = PreExecutionStatus( + schema_version=GITLINK_STATUS_SCHEMA, + reason=GITLINK_PROJECTION_UNQUALIFIED, + diff_base_sha=review["diff_base_sha"], + source_sha=review["source_sha"], + policy_sha=review["policy_sha"], + review=review, + stage=OCR_PREVIEW_STAGE, + ) + path = tmp_path / "status.json" + write_pre_execution_status(path, status) + assert ( + read_pre_execution_status( + path, + expected_diff_base_sha=review["diff_base_sha"], + expected_source_sha=review["source_sha"], + ) + == status + ) + with pytest.raises(PreExecutionStatusError, match="coverage"): + write_pre_execution_status(path, replace(status, repository_coverage=_coverage())) + with pytest.raises(PreExecutionStatusError, match="identity"): + write_pre_execution_status(path, replace(status, review=None)) + + +def test_local_report_names_partial_and_no_model_gitlink_coverage() -> None: + stopped = failed_report( + "preview", + repository_coverage=parse_repository_coverage(_coverage(state="not_started")), + ) + no_model = local_summary(stopped) + assert "no ordinary files were selected" in no_model + assert "1 changed gitlink pointer(s) remain unreviewed" in no_model + assert "coverage and execution usage are unavailable" not in no_model + + partial = replace( + stopped, + outcome=ReviewOutcome(status="complete", kind="clean", budget_exceeded=False), + failure_stage=None, + repository_coverage=parse_repository_coverage(_coverage()), + ) + rendered = local_summary(partial) + assert "Repository coverage: partial" in rendered + assert "1 of 1 ordinary diff record(s) completed" in rendered + unqualified = local_summary(failed_report("preview", gitlink_unqualified=True)) + assert "could not be qualified and were not reviewed" in unqualified + + +def test_post_rebuilds_native_partial_coverage_and_rejects_tampering( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + base, head = _fixture(tmp_path, "updated") + monkeypatch.chdir(tmp_path) + reader = GitRepositoryReader(tmp_path) + plan = plan_gitlink_input(reader, base, head) + assert plan is not None + projection = make_gitlink_projection(reader, plan) + item_id, fingerprint = projection.fingerprints[0] + item = {"item_id": item_id, "path": "main.php", "fingerprint": fingerprint} + source = hashlib.sha256() + for field in (item_id, fingerprint): + raw = field.encode() + source.update(len(raw).to_bytes(8, "big")) + source.update(raw) + result = { + "status": "complete", + "comments": [], + "manifest": { + "schema_version": "ocr.run-manifest/v1", + "terminal_state": "complete", + "input": { + "mode": "range", + "requested_from": base, + "requested_head": head, + "resolved_base": base, + "resolved_head": head, + "exact_range": f"{base}..{head}", + "source_artifact_sha256": source.hexdigest(), + }, + "execution": {"ocr_version": "v1.13.3"}, + "coverage": { + "selected": [item], + "completed": [item], + "reused": [], + "failed": [], + "waived": [], + }, + }, + "_ocr_toolkit": {"repository_coverage": _coverage()}, + } + identity = replace( + receipt_review_identity(_receipt_v11()), # type: ignore[arg-type] + schema_version=12, + diff_base_sha=base, + source_sha=head, + ) + assert workflow.verified_partial_repository_coverage(result, identity).ordinary_completed == 1 + result["_ocr_toolkit"]["repository_coverage"]["ordinary_completed"] = 0 + with pytest.raises(RepositoryEvidenceError, match="coverage"): + workflow.verified_partial_repository_coverage(result, identity) + result["_ocr_toolkit"]["repository_coverage"] = _coverage() + result["manifest"]["coverage"]["selected"] = [] + result["manifest"]["coverage"]["completed"] = [] + result["manifest"]["input"]["source_artifact_sha256"] = hashlib.sha256().hexdigest() + with pytest.raises(RepositoryEvidenceError, match="selected coverage"): + workflow.verified_partial_repository_coverage(result, identity) + + +def test_post_rechecks_gitlink_only_advisory( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + base, head = _gitlink_only_fixture(tmp_path) + monkeypatch.chdir(tmp_path) + coverage = _coverage(state="not_started") + coverage["ordinary_changed"] = 0 + coverage["ordinary_excluded"] = 0 + review = dict(_receipt_v11()["review"]) + review.update( + source_sha=head, + diff_base_sha=base, + diff_start_sha=base, + diff_head_sha=head, + target_sha=base, + policy_sha=base, + ) + status = PreExecutionStatus( + schema_version=GITLINK_STATUS_SCHEMA, + reason=GITLINK_NO_REVIEWABLE_FILES, + diff_base_sha=base, + source_sha=head, + policy_sha=base, + review=review, + stage=OCR_PREVIEW_STAGE, + repository_coverage=coverage, + ) + assert pre_execution_review_identity(status) is not None + assert workflow.verified_no_model_repository_coverage(status).gitlink_changes == 1 + coverage["gitlink_changes"] = 2 + with pytest.raises(RepositoryEvidenceError, match="coverage"): + workflow.verified_no_model_repository_coverage( + replace(status, repository_coverage=coverage) + ) + + +def test_partial_effective_cleanup_keeps_old_findings_and_discussions( + monkeypatch: pytest.MonkeyPatch, +) -> None: + identity = replace( + receipt_review_identity(_receipt_v11()), # type: ignore[arg-type] + schema_version=12, + ) + previous = BotCommentRefs( + plain_note_ids=[11], + summary_plain_note_ids=[12], + discussions_to_resolve=["discussion-13"], + ) + deleted: list[int] = [] + monkeypatch.setattr(workflow, "publication_freshness_or_exit", lambda *_a, **_k: None) + monkeypatch.setattr(workflow, "current_publication_or_rollback", lambda *_a, **_k: True) + monkeypatch.setattr( + workflow.gitlab_api, "delete_plain_note", lambda _config, note: deleted.append(note) or True + ) + monkeypatch.setattr( + workflow, "resolve_discussion", lambda *_a: pytest.fail("must preserve old threads") + ) + assert workflow.finalize_previous_review_state( + None, # type: ignore[arg-type] + previous, + ReviewOutcome(status="complete", kind="clean", budget_exceeded=False), + review_identity=identity, + transaction=PostingTransaction(), + ) + assert deleted == [12] + + +def test_partial_post_rejects_unproven_coverage_before_gitlab_write( + monkeypatch: pytest.MonkeyPatch, +) -> None: + receipt = _receipt_v11() + receipt["schema_version"] = 12 + receipt["repository_coverage"] = _coverage() + assert toolkit_receipt_is_valid(receipt) + monkeypatch.setattr( + workflow, + "verified_partial_repository_coverage", + lambda *_args: (_ for _ in ()).throw(RepositoryEvidenceError("synthetic mismatch")), + ) + monkeypatch.setattr( + workflow, + "collect_previous_bot_comment_refs", + lambda *_args: pytest.fail("GitLab must not be read for unproven coverage"), + ) + assert workflow.post_results(gitlab_config(), {"_ocr_toolkit": receipt}) == 1 + + +def test_v12_identity_uses_current_publication_freshness( + monkeypatch: pytest.MonkeyPatch, +) -> None: + identity = replace( + receipt_review_identity(_receipt_v11()), # type: ignore[arg-type] + schema_version=12, + ) + seen: list[int] = [] + expected = workflow.ReviewFreshnessResult(identity.policy_sha, False) + + def check(_environment: object, current: object, _root: object) -> object: + seen.append(current.schema_version) # type: ignore[attr-defined] + return expected + + monkeypatch.setattr(workflow, "validate_review_freshness", check) + assert workflow.validate_posting_review_identity(gitlab_config(), identity) == expected + assert seen == [12] + + +@pytest.mark.parametrize("strict", [False, True]) +def test_no_model_gitlink_post_is_advisory_with_closed_coverage( + monkeypatch: pytest.MonkeyPatch, strict: bool +) -> None: + review = _receipt_v11()["review"] + status = PreExecutionStatus( + schema_version=GITLINK_STATUS_SCHEMA, + reason=GITLINK_NO_REVIEWABLE_FILES, + diff_base_sha=review["diff_base_sha"], + source_sha=review["source_sha"], + policy_sha=review["policy_sha"], + review=review, + stage=OCR_PREVIEW_STAGE, + repository_coverage=_coverage(state="not_started"), + ) + notes: list[str] = [] + monkeypatch.setattr(workflow, "pre_execution_status_fresh_or_exit", lambda *_a, **_k: None) + monkeypatch.setattr( + workflow, + "verified_no_model_repository_coverage", + lambda *_a: parse_repository_coverage(_coverage(state="not_started")), + ) + monkeypatch.setattr( + workflow, + "current_merge_request_lifecycle", + lambda *_a: workflow.GitLabMergeRequestLifecycle("1", "2", status.source_sha, "opened"), + ) + monkeypatch.setattr(workflow, "collect_previous_bot_comment_refs", lambda *_a: BotCommentRefs()) + monkeypatch.setattr( + workflow, + "post_review_note_bounded", + lambda _config, _title, body, _transaction: notes.append(body) or {"id": 1}, + ) + monkeypatch.setattr(workflow, "finalize_posting", lambda *_a: True) + monkeypatch.setattr(workflow, "strict_posting", lambda: strict) + assert workflow.post_pre_execution_status(gitlab_config(), status) == int(strict) + assert len(notes) == 1 + assert "no ordinary files were selected" in notes[0] + assert "changed gitlink pointer(s) remain unreviewed" in notes[0] From 4183c1d9bc96895db1d8fdb48e4e1b88b48d0d82 Mon Sep 17 00:00:00 2001 From: xeonvs <11463419+xeonvs@users.noreply.github.com> Date: Fri, 23 Oct 2026 19:13:29 +0200 Subject: [PATCH 5/8] Document partial gitlink coverage and prove installed toolkit route --- PLANS.md | 8 +- README.md | 6 + ROADMAP.md | 7 +- changelog.d/268.feature.md | 1 + docs/compatibility.md | 7 + docs/configuration.md | 16 +- docs/development.md | 14 +- docs/engineering/execution_history/README.md | 2 +- docs/engineering/toolkit_strategy.md | 8 + docs/gitlab.md | 11 ++ docs/local.md | 9 ++ docs/operations.md | 15 ++ docs/review-decision-flow.md | 54 +++++-- docs/security.md | 20 ++- examples/gitlab/README.md | 9 ++ tests/test_gitlinks.py | 159 +++++++++++++++++++ tests/test_operations_docs.py | 19 ++- tests/test_pre_execution_status.py | 3 +- tests/test_review_receipt_v11.py | 3 +- 19 files changed, 330 insertions(+), 41 deletions(-) create mode 100644 changelog.d/268.feature.md diff --git a/PLANS.md b/PLANS.md index 1cacbb4..8eda559 100644 --- a/PLANS.md +++ b/PLANS.md @@ -66,7 +66,7 @@ Target v0.13.1, keep 0.14.0 development marker; include #268 and required advers ### Completed Baseline State -v0.13.0 is published with immutable assets and #264 closed. Its archived plan is a historical prepublication snapshot; current roadmap/index wording may need factual correction. OCR 1.13.3 is qualified in `compatibility/ocr-support.json`, while host default `ocr` is 1.13.2. The feasibility probe used a local fake provider and left no repository changes. `codex/gitlink-partial-review-0131` was created from clean synchronized main. +v0.13.0 is published with immutable assets and #264 closed. Its archived plan is a historical prepublication snapshot; current roadmap/index wording now reflects live release evidence. OCR 1.13.3 is qualified in `compatibility/ocr-support.json`, while host default `ocr` is 1.13.2. The feasibility probe used a local fake provider and left no repository changes. `codex/gitlink-partial-review-0131` was created from clean synchronized main. ### Current Work Queue @@ -77,7 +77,7 @@ v0.13.0 is published with immutable assets and #264 closed. Its archived plan is | WQ-03 | REQ-002, REQ-005 | Frozen OCR invocation/environment, independent diff, preview/native-manifest qualification, real-binary regression, self-reviewed commit | done | | WQ-04 | REQ-003, REQ-004 | Exact receipt v12/status v6, effective coverage/reporting and local no-model path, hostile/legacy tests, self-reviewed commit | done | | WQ-05 | REQ-004 | GitLab posting, finding admission, approval blocker and partial lifecycle retention with failure/freshness tests, self-reviewed commit | done | -| WQ-06 | REQ-005, REQ-006, REQ-010 | Synthetic/installed OCR matrix, public contracts, threat model, decision flow, examples, Feature fragment, factual status correction, self-reviewed commit | pending | +| WQ-06 | REQ-005, REQ-006, REQ-010 | Synthetic/installed OCR matrix, public contracts, threat model, decision flow, examples, Feature fragment, factual status correction, self-reviewed commit | done | | WQ-07 | REQ-005, REQ-007 | Scoped/final quality, build, secret/dependency gates, security diff scan, real local OCR review, aggregate self-review, protected feature PR/merge, dev artifacts | pending | | WQ-08 | REQ-007, REQ-010 | Release markers/metadata/epoch/notes, checked plan archive/reset, release validation, protected signed release PR | pending | | WQ-09 | REQ-008 | Protected release merge, stable publication, receipt and external reconciliation | out_of_scope | @@ -96,7 +96,7 @@ Only proven OCR 1.13.3 pure-pointer projection proceeds. Unique identical-OID be ### Latest Validation Results -2026-10-23 pre-implementation OCR 1.13.3 probe passed for mixed update/add/delete/move/multiple pure gitlinks and demonstrated no-model gitlink-only preview plus unsafe type transitions. Digest matches the pinned manifest. The signed plan-only commit is `db27f37`; plan-lifecycle and instruction-contract checks and staged Gitleaks passed. Milestone v0.13.1 (#22) tracks #268. Draft feature PR #269 has only the plan commit at its remote head. Local WQ-02 commit `203a50e` and WQ-03 commit `827fc3b` are not pushed. WQ-03 has bounded unfiltered/projected Git inventory, original patch fingerprints and line counts, one frozen OCR child environment, exact preview and native manifest/source-artifact comparison, and before/after drift checks. WQ-04/05 add closed v12 effective coverage and v6 no-model status while preserving v11/v5, local/GitLab partial notices, independent post readback of original trees and native selected work, an approval blocker, and prior finding-thread retention. The checksum-verified OCR 1.13.3 and local fake provider passed add/update/delete/mixed ordinary rename/add/delete/gitlink move forms. Current focused runner/gitlink/coverage suite passed 269 tests; focused GitLab posting suite passed 364 tests and 299 subtests. Ruff, formatter and mypy passed. Toolkit-wide integrated production validation and final gates remain pending. +2026-10-23 pre-implementation OCR 1.13.3 probe passed for mixed update/add/delete/move/multiple pure gitlinks and demonstrated no-model gitlink-only preview plus unsafe type transitions. Digest matches the pinned manifest. The signed plan-only commit is `db27f37`; plan-lifecycle and instruction-contract checks and staged Gitleaks passed. Milestone v0.13.1 (#22) tracks #268. Draft feature PR #269 has only the plan commit at its remote head. Local WQ-02 commit `203a50e`, WQ-03 commit `827fc3b`, and WQ-04/05 commit `7f17a3f` are not pushed. WQ-03 has bounded unfiltered/projected Git inventory, original patch fingerprints and line counts, one frozen OCR child environment, exact preview and native manifest/source-artifact comparison, and before/after drift checks. WQ-04/05 add closed v12 effective coverage and v6 no-model status while preserving v11/v5, local/GitLab partial notices, independent post readback of original trees and native selected work, an approval blocker, and prior finding-thread retention. The checksum-verified OCR 1.13.3 and local fake provider passed add/update/delete/mixed ordinary rename/add/delete/gitlink move forms. The integrated local toolkit test also passed an ordinary-plus-gitlink review through the real OCR binary and mandatory evidence MCP, and a gitlink-only no-model case; its loopback fake-provider HTTP allowance exists only inside the test process, with product HTTPS validation intact. Focused runner/gitlink/coverage suite passed 269 tests before this integrated addition; focused GitLab posting suite passed 364 tests and 299 subtests. Towncrier rendered the 0.13.1 Feature draft and plan lifecycle check passed. The first full quality run reached 86.25% coverage but found 10 stale documentation/v5/v11 test expectations; corrected targeted contracts passed 98/98. The corrected-tree full `scripts/quality.sh check` then passed. The checksum-verified installed OCR matrix passed 49/49 including integrated toolkit proof. Final build, secret/dependency and review gates remain pending. ### Risks And Recovery @@ -104,7 +104,7 @@ Git configuration precedence or local input drift can hide ordinary changes: rev ### Resume Point -WQ-06: extend the installed-toolkit matrix and hostile shape checks, update operator/security/decision-flow/example contracts, add the Feature fragment, and reconcile status-bearing v0.13.0 documents. Preserve the draft feature PR's plan-only remote head until the final security and local OCR reviews. +WQ-07: complete the corrected-tree quality and installed-artifact gates, tree/history secret and dependency gates, codex-security diff scan, aggregate self-review, and one correctly invoked local toolkit/OCR review with ChatGPT before the single final feature push. Preserve the draft feature PR's plan-only remote head until those reviews pass. ### Plan Fidelity Check diff --git a/README.md b/README.md index 7deee2e..fecda9a 100644 --- a/README.md +++ b/README.md @@ -90,6 +90,12 @@ On a successful rerun, the toolkit replaces untouched OCR-only notes instead of Suppression uses both the GitLab diff position and a stable finding fingerprint, so ordinary line shifts do not normally bring the same bug back. A materially changed finding can still receive a new discussion. See [GitLab review operations](docs/operations.md) for the complete lifecycle, posting modes, permissions, failure behavior, and Mermaid state diagram. +For a safely separable changed Git submodule pointer under qualified OCR +1.13.3, the toolkit reviews selected ordinary files and labels repository +coverage partial. It leaves the pointer and submodule contents unreviewed, +preserves prior finding threads and cannot automatically approve. A diff with +no selected ordinary files starts no model review and posts an advisory status. + After every current review note publishes, the GitLab adapter can add a conservative approval bound to receipt v11's exact reviewed diff, actual target, policy authority, merge-request author, DLP mode, and reconciled federation use. This write is enabled by default; set `OCR_AUTO_APPROVE=false` when the bot must remain comment-only. DLP-clean metadata, generic discussions, protected same-revision CI outcomes, unused governed services, and successful `review_read` tool use do not independently block approval. An unprotected target, disabled DLP, degraded metadata, a DLP rejection, required context degradation, admitted remediation history, older receipts, publication filtering, used advisory tools, uncertain federation accounting, author movement, or bot self-authorship prevents an approval write. A CI status is review context, never approval authority. GitLab approval rules, Code Owners, and protected-branch policy remain authoritative merge policy. They are not prerequisites for OCR execution. The toolkit only adds an eligible approval; it never removes an existing approval when a later review is ineligible or disabled. An intentionally absent toolkit receipt retains compatible direct posting outside diff --git a/ROADMAP.md b/ROADMAP.md index 1495a3a..8fa41e0 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -77,12 +77,13 @@ flowchart LR trusted base. Protected stable publication, external readback, and live site refresh completed on 2026-10-22. -- The v0.13.0 release candidate separates the current GitLab MR diff and actual +- The published v0.13.0 release separates the current GitLab MR diff and actual target from protected policy authority. The absent protection setting selects `auto`; an unprotected target can use its same-project protected default policy for single-MR review while remaining comment-only. Receipt v11 and publication - freshness checks bind these identities. Stable delivery and external readback - are pending. Optional related-MR review remains inactive planning in BL-025. + freshness checks bind these identities. The immutable GitHub Release was + published and #264 closed on 2026-10-23. Optional related-MR review remains + inactive planning in BL-025. - OCR compatibility and the established common evidence model converge at compact-bootstrap/evidence-MCP integration. - M3 preserves BL-011's historical characterization. M7 supersedes that runtime path with governed federation; the old direct composition and adapter execution contracts are not current fallback paths. diff --git a/changelog.d/268.feature.md b/changelog.d/268.feature.md new file mode 100644 index 0000000..40a0abf --- /dev/null +++ b/changelog.d/268.feature.md @@ -0,0 +1 @@ +GitLab and local reviews can now cover selected ordinary files alongside safely separable changed Git submodule pointers with qualified OCR 1.13.3. Reports identify the unreviewed pointers as partial repository coverage; these runs cannot automatically approve or clear earlier finding discussions. A change with no selected ordinary files stops before the model and posts an advisory GitLab status. diff --git a/docs/compatibility.md b/docs/compatibility.md index 13a8071..4b6303b 100644 --- a/docs/compatibility.md +++ b/docs/compatibility.md @@ -364,6 +364,13 @@ There is no toolkit CI adaptation beyond version and checksum pins. The hosted probe does not claim configured-model review quality. Native ChatGPT remains local-only; final release review uses the checksum-verified Darwin arm64 binary. +Toolkit 0.13.1 uses this same qualified OCR 1.13.3 as a narrower changed-gitlink +boundary. It verifies process-local Git projection, the full preview selection, +and native manifest/source-artifact fingerprints against the original immutable +range. This does not extend OCR review to submodule contents or qualify a later +OCR version for partial repository coverage; other versions retain the +no-model stop until separately proven. + ## Promotion and rollback Promotion changes `recommended_version`, advances `monitoring_floor`, adds the tested release and evidence, and updates every machine-readable or executable version/checksum pin. Never edit only one copy. Human-qualified candidates must record the compatibility conclusion and release-note impact; an automatic-safe candidate may also record a reviewed conclusion when it is delivered with a human-reviewed chain instead of using the generic machine conclusion. Conclusions may name only versions present in that promotion. A reviewed promotion may cross only one adjacent semantic-version boundary at a time: the next patch, the next minor at `.0`, or the next major at `.0.0`; minor and major transitions always require an explicit human conclusion. Automatic-safe preparation remains limited to adjacent patches in the already-tested major/minor line and retains the same protected review boundary even though the patch itself is mechanical. diff --git a/docs/configuration.md b/docs/configuration.md index d690b1f..356f7c8 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -309,11 +309,23 @@ partial/budget outcomes, any receipt other than exact v11, an unprotected actual and findings omitted by `OCR_MAX_POST_COMMENTS`. For receipt v11 on a protected target, complete metadata, complete non-remediation enrichment, private-only sanitization, unused federation services, successful `review_read` use, and the built-in evidence/context MCP are not blockers. GitLab posting revalidates the bound source/diff, actual target, policy authority and author before publication and again before any eligible approval. A changed or unverifiable tuple preserves the prior review; a same protected default-policy branch may advance only with proven ancestry while the completed review remains bound to captured P. The read-before-write check leaves a provider race. There are intentionally no environment variables for policy thresholds or category lists in this release. -`ocr-ci review --result PATH --stderr PATH -- ...` executes OCR without posting, creates private artifacts, and prints a bounded redacted stderr excerpt to the CI log when OCR fails. It accepts only a regular, single-link result artifact and atomically replaces it with the admitted owner-only copy. A validated GitLab merge-request review receives receipt v11; a local review remains receipt-less because local Git state cannot establish GitLab protection. A detached GitLab CI post requires v11 for normal results; the compatible receipt-less posting path is standalone outside that CI profile and cannot use receipt-derived federation status, the unprotected-target limitation, or automatic approval. Errors without an authenticated full review identity remain in the job rather than creating a potentially stale MR note. For local diagnosis, `--preserve-private-artifacts` retains owner-only private review material without a posting receipt. Inspect it locally before considering `OCR_DLP_ENABLED=false`, never upload or post it, and remove it afterward. The validated GitLab profile rejects this flag before OCR execution. Disabling DLP can expose sensitive content through model/service egress, local Markdown or GitLab and cannot retract prior disclosure. +`ocr-ci review --result PATH --stderr PATH -- ...` executes OCR without posting, creates private artifacts, and prints a bounded redacted stderr excerpt to the CI log when OCR fails. It accepts only a regular, single-link result artifact and atomically replaces it with the admitted owner-only copy. A validated ordinary GitLab merge-request review receives receipt v11; qualified partial gitlink reviews receive exact receipt v12. A local review remains receipt-less because local Git state cannot establish GitLab protection. A detached GitLab CI post requires v11 for ordinary results or v12 for qualified partial gitlink results; the compatible receipt-less posting path is standalone outside that CI profile and cannot use receipt-derived federation status, the unprotected-target limitation, or automatic approval. Errors without an authenticated full review identity remain in the job rather than creating a potentially stale MR note. For local diagnosis, `--preserve-private-artifacts` retains owner-only private review material without a posting receipt. Inspect it locally before considering `OCR_DLP_ENABLED=false`, never upload or post it, and remove it afterward. The validated GitLab profile rejects this flag before OCR execution. Disabling DLP can expose sensitive content through model/service egress, local Markdown or GitLab and cannot retract prior disclosure. ## Repository evidence -`ocr-ci review` owns this lifecycle. Before OCR starts it collects the exact immutable `--from`/`--to` refs (or the parent/commit pair selected by `--commit`), validates the exact GitLab MR identity and lifecycle, writes bounded redacted schema-versioned evidence, builds OCR's MCP registry with the mandatory evidence entry plus each independently configured optional server, reads the registry back, self-queries the evidence summary/list/get contract, and supplies the matching compact bootstrap to OCR. An already `merged` or `closed` MR stops before evidence and OCR with only an identity-bound `ocr.pre-execution-status/v5`; `post` upserts the corresponding static toolkit note. Those parent-process preflight calls are not counted as model use. The same preflight-qualified OCR executable first receives the exact production refs, rules, selection options, and background under `review --preview`; there is no toolkit threshold setting or duplicated OCR threshold constant. An exact recognized OCR soft background diagnostic becomes a toolkit-authored numeric `ocr.toolkit-advisory/v1` value only after publication DLP and appears in the bounded CI log plus GitLab Technical details. It is not an OCR warning, receipt or DLP input, coverage signal, telemetry field, or approval blocker. An exact recognized hard character/file-size rejection stops before model execution and leaves only an identity-bound numeric `ocr.pre-execution-status/v5` outcome for static GitLab reporting; the private path and raw OCR diagnostic are not published. Unclassified preview diagnostics fail closed before model execution with only an identity-bound v5 stage and bounded count; changed gitlinks have their own closed preview status. Neither retains raw stderr in the status. The actual review independently revalidates the background. During OCR, the built-in MCP records count-only attempted and completed `summary`, `list`, `get`, `search`, and `coverage` actions without arguments, queries, IDs, paths, results, or content. Unknown or malformed MCP-dispatched primary-tool actions increment only a closed `unattributed` attempt counter. OCR also counts a dynamic tool request before parsing its JSON arguments; a parse failure never reaches the MCP owner, so receipt finalization adds only that count-only by-tool residual to `unattributed`. The parent reads and removes private action receipt v3 before cleanup. Receipt v11 is approval-valid only when MCP-received attempts do not exceed OCR's authoritative `tool_calls.by_tool` entries and every residual OCR attempt is accounted as unattributed; failed or malformed attempts cannot satisfy the mandatory completed `summary`, become successful evidence use, or authorize approval. Missing, malformed, raced, or mismatched attribution fails review finalization before a normal publishable result exists. OCR `--background-file` takes precedence over inline `--background`, so `ocr-ci review` rejects caller forms of both options, including split and `--option=value` syntax, and remains the sole owner of the bootstrap input; caller `--preview` is likewise rejected because the toolkit owns this gate. OCR provides `--output`, but `ocr-ci review` rejects its long, equals, short, and attached forms because the toolkit must remain the sole owner of the private result descriptor, atomic parsing, cleanup, and posting handoff. The stage-grouped OCR terminal retry report remains private and does not enter toolkit telemetry, receipts, DLP, findings, severity, outcomes, or approval. A completed OCR review is accepted only when structured `tool_calls.by_tool` proves at least one `ocr_toolkit_evidence` attempt and action receipt v3 proves a completed summary; a legitimately skipped no-supported-files review remains exempt. +`ocr-ci review` owns this lifecycle. Before OCR starts it collects the exact immutable `--from`/`--to` refs (or the parent/commit pair selected by `--commit`), validates the exact GitLab MR identity and lifecycle, writes bounded redacted schema-versioned evidence, builds OCR's MCP registry with the mandatory evidence entry plus each independently configured optional server, reads the registry back, self-queries the evidence summary/list/get contract, and supplies the matching compact bootstrap to OCR. An already `merged` or `closed` MR stops before evidence and OCR with only an identity-bound `ocr.pre-execution-status/v5`; `post` upserts the corresponding static toolkit note. Those parent-process preflight calls are not counted as model use. The same preflight-qualified OCR executable first receives the exact production refs, rules, selection options, and background under `review --preview`; there is no toolkit threshold setting or duplicated OCR threshold constant. An exact recognized OCR soft background diagnostic becomes a toolkit-authored numeric `ocr.toolkit-advisory/v1` value only after publication DLP and appears in the bounded CI log plus GitLab Technical details. It is not an OCR warning, receipt or DLP input, coverage signal, telemetry field, or approval blocker. An exact recognized hard character/file-size rejection stops before model execution and leaves only an identity-bound numeric `ocr.pre-execution-status/v5` outcome for static GitLab reporting; the private path and raw OCR diagnostic are not published. Unclassified preview diagnostics fail closed before model execution with only an identity-bound v5 stage and bounded count; qualified changed gitlinks use v6 for no-model or unqualified status and v12 for successful partial review. Neither retains raw stderr in the status. The actual review independently revalidates the background. During OCR, the built-in MCP records count-only attempted and completed `summary`, `list`, `get`, `search`, and `coverage` actions without arguments, queries, IDs, paths, results, or content. Unknown or malformed MCP-dispatched primary-tool actions increment only a closed `unattributed` attempt counter. OCR also counts a dynamic tool request before parsing its JSON arguments; a parse failure never reaches the MCP owner, so receipt finalization adds only that count-only by-tool residual to `unattributed`. The parent reads and removes private action receipt v3 before cleanup. Receipt v11 is approval-valid only when MCP-received attempts do not exceed OCR's authoritative `tool_calls.by_tool` entries and every residual OCR attempt is accounted as unattributed; failed or malformed attempts cannot satisfy the mandatory completed `summary`, become successful evidence use, or authorize approval. Missing, malformed, raced, or mismatched attribution fails review finalization before a normal publishable result exists. OCR `--background-file` takes precedence over inline `--background`, so `ocr-ci review` rejects caller forms of both options, including split and `--option=value` syntax, and remains the sole owner of the bootstrap input; caller `--preview` is likewise rejected because the toolkit owns this gate. OCR provides `--output`, but `ocr-ci review` rejects its long, equals, short, and attached forms because the toolkit must remain the sole owner of the private result descriptor, atomic parsing, cleanup, and posting handoff. The stage-grouped OCR terminal retry report remains private and does not enter toolkit telemetry, receipts, DLP, findings, severity, outcomes, or approval. A completed OCR review is accepted only when structured `tool_calls.by_tool` proves at least one `ocr_toolkit_evidence` attempt and action receipt v3 proves a completed summary; a legitimately skipped no-supported-files review remains exempt. + +For a changed Git submodule pointer, the toolkit admits only pure pointer +additions, updates, deletions, or unambiguous moves under checksum-qualified +OCR 1.13.3. It reads before/after trees and `.gitmodules` blobs, never their +URLs or submodule worktrees, and compares an explicit unfiltered Git diff with +the projected ordinary diff. The OCR child receives process-local Git overrides +for preview and production; repository config and refs remain untouched. A +selected ordinary file must appear with the same identity and fingerprint in +OCR's completed native manifest. Receipt v12 adds path-free effective +repository-coverage counts without changing that manifest. An unsupported +transition, unsafe Git input, drift, preview mismatch, or wrong manifest stops +without a publishable review. No public CLI flag changes this behavior. The private `.review-context/evidence.json`, `.review-context/bootstrap.md`, repository-policy `.review-context/policy-rules.json`, count-only evidence-action receipt/lock, and persistent `.review-context/.review-run.lock` are internal implementation details, not public path configuration. Keep `.review-context/` ignored. The directory is mode `0700`, regular files are mode `0600`, and symlink, hard-link, non-regular, or unexpectedly permissive receipt and review-lock targets are rejected. In GitLab MR pipelines, the provider captures the actual target T and selected policy commit P. It fetches immutable objects as needed and materializes an in-repository `--rule` blob from P. Explicit absolute operator Rules retain their existing contract when the actual target is protected; both unprotected-target paths require in-repository Rules, supplied as a relative path or an absolute path within the checkout. OCR reviews the validated current MR `B..H` range even when B differs from T. The collector reads Git objects without checkout, does not follow repository symlinks or submodules, never executes repository content, and treats source-ref policy changes as untrusted. diff --git a/docs/development.md b/docs/development.md index 8f24b16..6b89f3a 100644 --- a/docs/development.md +++ b/docs/development.md @@ -189,12 +189,14 @@ Apply the [cohesive-module invariant](engineering/project_principles.md#product- ## Review reporting and provider adapters -`ocr_result.TOOLKIT_RESULT_SCHEMA_VERSION` owns the current receipt number; -posting, freshness, and approval select it through that constant. The v9/v10 -readback rules and v11 tuple parser are version-specific contracts. When a new -receipt version is introduced, add the prior current version to the historical -set, implement its new parser and nested-schema mapping explicitly, and version -the private pre-execution status if its embedded review tuple changes. Verify +`ocr_result.TOOLKIT_RESULT_SCHEMA_VERSION` owns ordinary receipt v11; +`PARTIAL_TOOLKIT_RESULT_SCHEMA_VERSION` owns the qualified gitlink partial +receipt v12. Posting and freshness accept the exact current form for each +route; automatic approval accepts only eligible v11. The v9/v10 readback +rules, v11/v12 tuple parser, and pre-execution status v5/v6 are separate +version-specific contracts. When a new receipt version is introduced, preserve +prior wire meanings, implement its parser and nested-schema mapping explicitly, +and version the private pre-execution status if its embedded review tuple changes. Verify old readback, new producer/readback, static-note handoff, normal publication, approval, and cross-job rejection separately. A version bump alone must fail closed rather than silently treating the new shape as the old one. diff --git a/docs/engineering/execution_history/README.md b/docs/engineering/execution_history/README.md index 90e6faa..914d2b4 100644 --- a/docs/engineering/execution_history/README.md +++ b/docs/engineering/execution_history/README.md @@ -14,7 +14,7 @@ | Stable tag | Primary archived plan | Related context in the same archive | | --- | --- | --- | -| `v0.13.0` | [Protected policy fallback for single-MR reviews](plans/2026-10-23-toolkit-0-13-0-protected-default-policy-fallback-for-single-mr-review.md) | #264 protected-policy authority with distinct code target, #265 conditional BL-025 planning, security dependency audit, protected feature merge and verified development artifacts; stable-delivery handoff pending. | +| `v0.13.0` | [Protected policy fallback for single-MR reviews](plans/2026-10-23-toolkit-0-13-0-protected-default-policy-fallback-for-single-mr-review.md) | #264 protected-policy authority with distinct code target, #265 conditional BL-025 planning, security dependency audit, protected feature merge and verified development artifacts; immutable GitHub Release published and #264 closed on 2026-10-23. The archived plan remains a prepublication snapshot. | | `v0.12.1` | [Review diagnostics, DLP and OCR 1.13.3](releases.md#plan-toolkit-0-12-1) | Protected feature/release merges, local OCR and security review, verified development and stable artifacts, immutable release, issue/milestone closure, live site refresh, and external reconciliation. | | `v0.12.0` | [OCR 1.13.2, external-tool context and optional local ChatGPT auth](releases.md#plan-toolkit-0-12-0) | Adjacent local OCR qualification, protected MR-context navigation, optional local native authentication and compatible dependency/security updates; protected stable delivery and external reconciliation verified. | | `v0.11.3` | [OCR 1.12.13 and reviewed qualification signals](releases.md#plan-toolkit-0-11-3) | Adjacent 1.12.12–1.12.13 qualification, conservative provider/model/deadline review routing, and preserved timeout outcomes; verified stable delivery and external reconciliation. | diff --git a/docs/engineering/toolkit_strategy.md b/docs/engineering/toolkit_strategy.md index 79d0c03..633ae20 100644 --- a/docs/engineering/toolkit_strategy.md +++ b/docs/engineering/toolkit_strategy.md @@ -139,6 +139,14 @@ M5's foundation is established in v0.7.0. It extends the v0.6.3 selection/approv For the current GitLab profile, a closed receipt v11 binds source H, authenticated current MR diff base/version B, actual target branch/head T and protection, and protected policy project/branch/commit P as separate identities. An absent `OCR_GITLAB_TARGET_PROTECTION_MODE` selects `auto`: a protected actual target supplies P itself, while an unprotected target can use the same project's captured protected default branch P for rules and eligible context/tool policy without gaining approval authority. Explicit `required` still rejects an unprotected target; explicit `unprotected` keeps the historical limited comment-only profile. The actual review remains B..H even when B differs from T. Detached MR source identity is required; synthetic merged-results and merge-train commits are not silently treated as H. The local entry point does not select GitLab policy or create a platform receipt. +Receipt v12 extends that exact tuple only for a proven partial repository +review containing safely separable changed gitlinks. A private immutable-tree +plan and qualified OCR projection identify selected ordinary work, while the +native manifest keeps its OCR coverage meaning. The public receipt carries +only closed effective-coverage counts; no skipped pointer or submodule +contents gain finding or approval authority. Receipt v11 retains its ordinary +review meaning. A v6 pre-execution status records a no-model gitlink stop. + When a protected policy authority is available, the review acquires only the current MR's authorized forge context before OCR. An immutable `.opencodereview/review-context-policy.json` read only from captured P controls admission, retrieval, model egress, publication, and retention. Protected policy is separate from untrusted source/target code, MR prose, and target-context evidence. The provider authorizes the exact project and current MR, normalizes bounded fields through DLP, and commits admitted records to a run-local context store before opaque handles are minted. Handles bind run, source, projection and expiry without exposing upstream identifiers. During OCR, the model may list or read only minted handles through fixed toolkit-authored closed-schema tools projected by the mandatory toolkit MCP process. Current policy v4 has no external references; v1-v3 references are parse-only and never execute. Optional external tools use the M7 governed federation gateway with frozen allowlists, admission bounds, DLP and content-free accounting. Context budgets cannot evict repository evidence. Unknown identity, authorization, DLP, completeness, version, protocol or OCR capability fails closed; unavailable, partial, stale or mutated context stays visible and cannot prove absence. DLP-clean metadata and generic discussions do not independently block approval; required-source degradation, any DLP rejection or an admitted remediation thread does. Context cannot change policy, tools, permissions, lifecycle commands, suppression, posting authority or approval. diff --git a/docs/gitlab.md b/docs/gitlab.md index 4b9d2ac..cb97fe0 100644 --- a/docs/gitlab.md +++ b/docs/gitlab.md @@ -31,6 +31,17 @@ OCR semantically groups related changed files before review and filters candidat The summary keeps four inputs separate: OCR manifest coverage, publication integrity, published findings, and an optional OCR core advisory. Complete manifest coverage that later loses public fields is labelled `Review complete with publication filtering`; it is warning-bearing and approval-ineligible, but it is not called incomplete OCR coverage and does not invent failed-file diagnostics. Actual partial, failed, waived, or budget-stopped manifest coverage keeps its stronger status. Ordinary OCR warnings retain their own complete-with-warnings state. The reviewer guide ranks bounded focus areas only when at least two findings are published; one finding remains in its inline or fallback discussion without a duplicate snippet. Security focus uses explicit published metadata, strong vulnerability terms, and closed injection classes rather than the standalone word `injection`, so neutral knowledge/dependency terminology cannot increase its count or effort estimate. +Toolkit 0.13.1 adds a separate effective repository-coverage line when the MR +changes a qualified Git submodule pointer. The installed OCR 1.13.3 reviews +selected ordinary files against the original MR `B..H` range; the changed +pointer and submodule contents remain unreviewed. Receipt v12 records only +closed counts and the partial reason. The GitLab adapter checks those counts +against Git and OCR's native selected/completed manifest before posting, keeps +prior finding discussions, and never automatically approves this run. A +gitlink-only or fully excluded ordinary diff produces a v6 no-model advisory +note; default posting exits 0 and strict posting exits 1. Older or unqualified +OCR/gitlink shapes stop before the model and cannot claim a partial review. + The collapsed technical details keep aggregate input/output/cache token usage separate from OCR tool activity. The existing `all OCR tool calls` line lists every non-zero count for the closed review set (`file_read`, `file_read_diff`, `file_find`, `code_search`, `code_comment`, `task_done`, `ocr_toolkit_evidence`, `ocr_toolkit_evidence_search`, `ocr_toolkit_evidence_coverage`, `context_list`, and `context_get`) and is omitted when that admitted list is empty. These counts help explain whether OCR read context, searched the repository, checked cross-file diffs, consulted toolkit evidence, searched unknown evidence identities, checked absence coverage, or emitted review output; they are not per-tool token attribution. Dynamic external MCP tool names remain private and appear only through the existing verified per-server aggregate. A separate action line contains only reconciled non-zero `summary/list/get/search/coverage` counts; arguments, queries, scopes, IDs, and results stay private. Tool and token lines remain independent of `passed`, `private-sanitized`, or `publication-filtered` receipt state. If OCR accepts a background above its recommended character count, a separate `OCR core advisory` line contains only the actual and recommended counts. It does not become a warning or change review completeness or approval eligibility. ## Choose one operating mode diff --git a/docs/local.md b/docs/local.md index 8f73145..3470949 100644 --- a/docs/local.md +++ b/docs/local.md @@ -211,6 +211,15 @@ configuration or unsafe-destination failures print only to the console. A failed artifact or console delivery returns nonzero; a completed artifact remains available if the console fails afterward. +With qualified OCR 1.13.3, a changed Git submodule pointer can be excluded +automatically while selected ordinary files are reviewed. Local Markdown +states the effective repository coverage separately from the native OCR +manifest; the changed pointer and submodule contents remain unreviewed. A +gitlink-only or fully excluded ordinary diff stops before the model with exit +2 and a closed no-model coverage statement. An unsafe or unqualified pointer +shape stops without a coverage claim. The toolkit never initializes or fetches +submodules, and MCP context cannot turn a skipped pointer into a reviewed file. + Before disabling DLP to diagnose a suspected false positive, run `ocr-ci review --local --preserve-private-artifacts` with the normal private result, stderr, and report paths. After valid bounded output, it retains diff --git a/docs/operations.md b/docs/operations.md index bea493f..076ce47 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -49,6 +49,21 @@ findings; DLP omission totals and their bounded overlapping source-class counts are shown separately. Previous comments are preserved with the actual incomplete stage reason instead of being replaced by a falsely clean review. +When a changed Git submodule pointer is safely separable under the qualified +OCR 1.13.3 executable, the toolkit reviews only the selected ordinary files. +The summary labels effective repository coverage **partial** and gives path-free +ordinary-file and changed-pointer counts. OCR's native `complete` manifest then +means only that its selected ordinary work completed. The pointer and submodule +contents remain unreviewed; the toolkit does not initialize or fetch a +submodule. No setting or MCP tool has to opt into this behavior. A partial +repository review cannot automatically approve, delete previous finding +threads, or resolve `/ocr resolve` discussions. Findings on skipped pointers +or files outside the selected ordinary set are rejected before publication. +If preview selects no ordinary files, `ocr-ci review` exits 2 without starting +the model; `ocr-ci post` publishes an advisory note with the same closed +coverage counts and exits 0, or 1 with `OCR_STRICT_POSTING=true`. Unsafe or +unqualified gitlink forms also stop before the model and never claim coverage. + `OCR_DLP_ENABLED` defaults to strict `true`. Exact `false` disables context, federation and publication DLP for that run, emits a prominent warning in the summary and Technical details, and makes automatic approval unreachable. It can diff --git a/docs/review-decision-flow.md b/docs/review-decision-flow.md index 0f2e670..96c5715 100644 --- a/docs/review-decision-flow.md +++ b/docs/review-decision-flow.md @@ -117,12 +117,17 @@ flowchart TD registry --> acquire_ok{Mandatory evidence, context, and
federation readiness valid?} acquire_ok -- No --> preflight_error acquire_ok -- Yes --> gitlink_check{Changed gitlink?} - gitlink_check -- Yes --> gitlink[Closed gitlink status;
model not started; ordinary files not reviewed] - gitlink_check -- No --> preview[Run exact OCR preview with
toolkit-owned child environment] + gitlink_check -- Yes --> gitlink_scope{Pure pointer shape and
qualified OCR 1.13.3?} + gitlink_scope -- No --> gitlink[Closed v6 unqualified status;
model not started] + gitlink_scope -- Yes --> gitlink_projection[Freeze original trees, ordinary diff,
Git inputs and child overrides] + gitlink_projection --> preview[Run exact OCR preview with
toolkit-owned child environment] + gitlink_check -- No --> preview preview --> preview_ok{Preview accepted?} preview_ok -- Unclassified diagnostics --> preview_unclassified[Closed stage/count status;
model not started] preview_ok -- Other failure --> preview_error[OCR not started; bounded failure path] - preview_ok -- Yes --> review[Run OCR against exact from/to refs
with internal MCP plus fixed relay;
guide summary and direct MR intent read,
then relevant allowlisted tools when useful] + preview_ok -- Yes --> selected{Qualified gitlink and
no ordinary file selected?} + selected -- Yes --> no_work[Closed v6 no-model coverage;
advisory post, strict mode nonzero] + selected -- No --> review[Run OCR against exact from/to refs
with internal MCP plus fixed relay;
guide summary and direct MR intent read,
then relevant allowlisted tools when useful] review --> exit{Review exit code} exit -- No private handoff (75) --> no_handoff[Job-only failure;
do not read another run's status or contact GitLab] exit -- Non-zero --> runtime_error[No normal result publication;
failure remains in job without full review identity] @@ -154,7 +159,7 @@ flowchart TD runtime_error -- Explicit local --> local_failure[Closed failure summary to console;
artifact if destination was accepted] integrity_error -- Explicit local --> local_failure local_output -- No --> local_handoff[Private JSON handoff only] - mode -- GitLab MR --> receipt[Attach exact receipt v11:
diff, target T, policy P and CI job] + mode -- GitLab MR --> receipt[Attach exact v11 ordinary or v12 partial receipt:
diff, target T, policy P and CI job] receipt --> post{Posting input valid at readback?} retained_rejected --> posting_error post -- No --> posting_error[Job-local publication-policy error;
findings transaction not started] @@ -166,6 +171,7 @@ flowchart TD transaction_ok -- Yes --> published{Projection complete?} published -- Yes --> success[Review signal published] published -- Safe partial --> warning[Safe review subset published;
not a complete public review] + published -- Repository partial v12 --> warning classDef success fill:#d1fae5,stroke:#15803d,color:#14532d,stroke-width:2px; classDef warning fill:#ffedd5,stroke:#ea580c,color:#7c2d12,stroke-width:2px; @@ -177,15 +183,18 @@ flowchart TD class warning warning; class local auxiliary; class preflight_error,terminal_strict_error,preview_error,gitlink,preview_unclassified,no_handoff,runtime_error,integrity_error,posting_error error; - class start,api,native_auth,terminal_route,target_policy,unprotected_rules,separate_policy,acquire,registry,preview,review,finalize,diag,dlp,disabled,publishable,partial,receipt,transaction auxiliary; - class auth,local_auth,input,profile,detached,initial_state,terminal_gate,current_diff,protected,selector,limited,default_policy,acquire_ok,gitlink_check,preview_ok,exit,core,dlp_mode,dlp_state,mode,post,live_state,transaction_ok,published decision; + class no_work warning; + class start,api,native_auth,terminal_route,target_policy,unprotected_rules,separate_policy,acquire,registry,gitlink_projection,preview,review,finalize,diag,dlp,disabled,publishable,partial,receipt,transaction auxiliary; + class auth,local_auth,input,profile,detached,initial_state,terminal_gate,current_diff,protected,selector,limited,default_policy,acquire_ok,gitlink_check,gitlink_scope,preview_ok,selected,exit,core,dlp_mode,dlp_state,mode,post,live_state,transaction_ok,published decision; ``` -Recognized stops record an identity-bound `ocr.pre-execution-status/v5` with a closed -stage and `model_execution=not_started`. A stale merge-request revision is -reported only in the old job's local output; it never posts into the newer -merge request. A changed gitlink stops preview qualification before model -review. Note-capable setup and preview statuses carry the complete authenticated +Recognized stops record an identity-bound `ocr.pre-execution-status/v5` or +gitlink-specific v6 with a closed stage and `model_execution=not_started`. +A stale merge-request revision is reported only in the old job's local output; +it never posts into the newer merge request. Pure changed gitlinks under OCR +1.13.3 are projected from the original trees before preview; unsafe forms +stop. Zero selected ordinary files also stop without model execution and use +an advisory v6 status. Note-capable setup and preview statuses carry the complete authenticated B/H/T/P identity for live revalidation before each write; setup also requires the exact captured P. Unclassified preview diagnostics retain only a bounded count, never raw stderr. Generic OCR, parse, schema and provider failures @@ -211,6 +220,15 @@ reasons from private-only sanitized fields and reasons. The v4 summary signal and receipt v11 expose those closed counts without converting OCR coverage or provider posting counts. +Receipt v12 adds separate path-free effective repository coverage only for a +qualified partial gitlink review. Native OCR `complete` then covers its selected +ordinary files; it does not claim the skipped pointer or submodule contents. +Posting independently rebuilds original Git inputs and checks the native +selected/completed manifest before a current-result write. This route blocks +automatic approval, old finding-thread deletion, and requested discussion +resolution even when the native manifest is complete. v11 retains its exact +ordinary-review meaning. + The core integrity boundary deliberately precedes additive diagnostics. A malformed result, contradictory manifest, stale identity, failed cleanup, or missing/mismatched toolkit action receipt blocks finalization. Mandatory evidence rejection emits safe facts before cleanup; authorized local diagnostic retention preserves a bounded private envelope, while its marker blocks even receipt-less posting. Failed diagnostic retention never replaces the original rejection. By contrast, OCR's additive failed-tool envelope is diagnostic: it @@ -280,9 +298,13 @@ flowchart TD receipt -- No --> ci_context{GitLab CI MR context?} ci_context -- Yes --> schema_error ci_context -- No --> direct[Standalone compatible receipt-less posting path] - receipt -- Yes --> exact{Exact current v11 receipt valid?} + receipt -- Yes --> exact{Exact current v11 or v12 receipt valid?} exact -- No --> schema_error[Job-local publication-policy error] - exact -- Yes --> fresh{Current CI job, diff, target
and policy authority valid?} + exact -- Yes --> partial_check{v12 partial gitlink receipt?} + partial_check -- Yes --> source_proof{Original Git trees, counts and
native selected work match?} + source_proof -- No --> schema_error + source_proof -- Yes --> fresh{Current CI job, diff, target
and policy authority valid?} + partial_check -- No --> fresh fresh -- No --> stale_error[Stale or unavailable;
preserve prior review] fresh -- Yes --> projection{Publication projection} direct --> publish[Publish findings and summary] @@ -293,7 +315,7 @@ flowchart TD writes -- No attempt or direct failure --> post_error[Posting failed;
previous review state preserved] writes -- Draft publish attempted, partial or unknown --> draft_partial[Stop nonzero; retain attempted notes;
attempt cleanup of unattempted drafts;
preserve prior review] writes -- Yes --> public_ok[Review signal published] - public_ok --> action{Optional later action eligible?} + public_ok --> action{Optional later action eligible?
v12 always ineligible} action -- Yes --> fresh_again{Identity and authority
still current before write?} fresh_again -- Yes --> action_ok[Review published; later action completed] fresh_again -- No --> action_stale[Current publication stops nonzero;
previous review preserved;
published drafts may remain visible] @@ -301,7 +323,7 @@ flowchart TD action_ok --> cleanup{Old-review cleanup
before each mutation} action_warn --> cleanup cleanup -- Before first mutation, stale --> action_stale - cleanup -- All confirmed --> complete[Replacement complete] + cleanup -- All confirmed --> complete[Replacement complete;
v12 preserves prior finding threads] cleanup -- After cleanup starts, drift or unconfirmed write --> cleanup_partial[Stop nonzero; old cleanup partial;
retain current-run comments] classDef success fill:#d1fae5,stroke:#15803d,color:#14532d,stroke-width:2px; @@ -314,7 +336,7 @@ flowchart TD class publish_partial,action_warn warning; class schema_error,stale_error,post_error,draft_partial,action_stale,cleanup_partial error; class handoff,direct,publish auxiliary; - class receipt,ci_context,exact,fresh,projection,writes,action,fresh_again,cleanup decision; + class receipt,ci_context,exact,partial_check,source_proof,fresh,projection,writes,action,fresh_again,cleanup decision; ``` The current GitLab later action is an optional receipt-bound approval write. It is only one diff --git a/docs/security.md b/docs/security.md index 0f4b4b2..32dcbdc 100644 --- a/docs/security.md +++ b/docs/security.md @@ -33,8 +33,24 @@ The main trust transitions are: only frozen `server__tool` aliases through a fixed private relay; upstream credentials never enter OCR configuration or arguments. 5. OCR output crosses result validation, publication projection, stage-aware DLP, - cleanup, receipt v11, current-MR freshness checks and the provider posting - transaction. + cleanup, a versioned review receipt, current-MR freshness checks and the + provider posting transaction. + +Changed Git submodule pointers are a separate repository-coverage boundary. +For qualified OCR 1.13.3 only, the toolkit compares complete before/after Git +trees, the explicit unfiltered diff, and the ordinary-file projection before +the model runs. It reads `.gitmodules` from immutable blobs without using its +URLs, freezes process-local Git overrides for preview and production, then +checks the native manifest against the ordinary files selected by preview. +Type transitions, ambiguous moves, unsafe configuration, unknown diagnostics, +drift, reuse, or mismatched manifest evidence stop the review. Neither the +internal evidence MCP nor an external MCP server can grant review coverage for +a skipped gitlink or authorize a finding about its contents. A v12 receipt +states path-free partial repository coverage and blocks automatic approval and +old finding-thread cleanup; the native OCR manifest retains its own meaning. +`post` independently rechecks the original Git trees and selected native work +before a GitLab write. A v6 no-model status contains only closed counts or a +closed unqualified reason, with the authenticated MR identity. The `auto` fallback is sensitive to wrong-project source substitution, retargeting, default-branch or protection changes, stale diff versions, diff --git a/examples/gitlab/README.md b/examples/gitlab/README.md index 42a5a54..40210c2 100644 --- a/examples/gitlab/README.md +++ b/examples/gitlab/README.md @@ -8,6 +8,15 @@ before model execution; no new project variable is needed. The pipeline pins the exact qualified OCR release from the [compatibility manifest](../../compatibility/ocr-support.json) and explicitly sets `OCR_REVIEW_EFFORT=medium`, which permits two review rounds. Use `low` for one round when latency/cost is the priority, or `high` for three only after accepting the additional provider work. OCR uses a 15-minute subtask base and scales it to 15/30/45 minutes for low/medium/high; the example's 45-minute job timeout accommodates every preset without adding another environment control. Semantic grouping and filtering are OCR behavior shared by every context mode; they do not change which merge-request text a mode admits. `OCR_MAX_TOOLS=0` selects the embedded template default `100`. Values `1-49` report normalization to `50` but remain effectively `100`; only values above `100` raise the per-subtask cap. `OCR_MAX_TOKENS_BUDGET` and the optional completion cap remain independent controls. +Toolkit 0.13.1 automatically handles safely separable changed Git submodule +pointers with its qualified OCR 1.13.3 binary. Selected ordinary files can +receive findings, while changed pointers and submodule contents remain +unreviewed and the GitLab summary explicitly reports partial repository +coverage. This route cannot automatically approve or resolve prior finding +threads. A gitlink-only diff starts no model review and posts an advisory +status; set the existing `OCR_STRICT_POSTING=true` only if that advisory should +fail the job. No extra CI variable or MCP capability is needed. + | Mode | Recipe | MR text admitted | External access | Automatic approval | | --- | --- | --- | --- | --- | | Identity only | [`modes/identity-only.gitlab-ci.yml`](modes/identity-only.gitlab-ci.yml) | None | None | May remain enabled | diff --git a/tests/test_gitlinks.py b/tests/test_gitlinks.py index afc1d74..d1827bb 100644 --- a/tests/test_gitlinks.py +++ b/tests/test_gitlinks.py @@ -5,7 +5,9 @@ import json import os import subprocess +from io import BytesIO from pathlib import Path +from urllib.parse import urlsplit import pytest @@ -891,3 +893,160 @@ def test_installed_ocr_projected_review_matches_native_manifest( assert production.returncode == 0 and production.stderr == b"" qualify_gitlink_manifest(projection, selected, json.loads(production.stdout)) verify_gitlink_projection(reader, projection) + + +@pytest.mark.parametrize("kind", ["updated", "only"]) +def test_installed_ocr_toolkit_local_gitlink_route( + tmp_path: Path, kind: str, monkeypatch: pytest.MonkeyPatch +) -> None: + """Exercise the toolkit and real OCR together without a paid model or forge.""" + + binary = os.environ.get("OCR_GITLINK_QUALIFY_BINARY") + if binary is None: + pytest.skip("set OCR_GITLINK_QUALIFY_BINARY to the checksum-verified OCR 1.13.3") + assert validate_ocr_binary(executable=binary, announce=False) == "1.13.3" + monkeypatch.syspath_prepend(str(Path(__file__).resolve().parents[1] / "scripts")) + import http.server + import threading + + from ocr_compat import _StubHandler + + from ocr_toolkit import cli, provider_config + + class EvidenceStub(_StubHandler): + request_count = 0 + request_stages: list[str] = [] + issued_evidence = False + + def do_POST(self) -> None: + length = int(self.headers.get("Content-Length", "0")) + raw = self.rfile.read(length) + request = json.loads(raw) + tools = request.get("tools", []) + names = { + function["name"] + for tool in tools + if isinstance(tool, dict) + if isinstance((function := tool.get("function")), dict) + if isinstance(function.get("name"), str) + } + if "ocr_toolkit_evidence" not in names or "approve_all_comments" in names: + self.rfile = BytesIO(raw) + super().do_POST() + return + cls = type(self) + cls.request_count += 1 + if not cls.issued_evidence: + cls.issued_evidence = True + cls.request_stages.append("evidence") + function = {"name": "ocr_toolkit_evidence", "arguments": '{"action":"summary"}'} + call_id = "call-evidence" + else: + cls.request_stages.append("done") + function = {"name": "task_done", "arguments": "{}"} + call_id = "call-done" + payload = json.dumps( + { + "id": "toolkit-gitlink-proof", + "object": "chat.completion", + "model": "synthetic-model", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": None, + "tool_calls": [ + { + "id": call_id, + "type": "function", + "function": function, + } + ], + }, + "finish_reason": "tool_calls", + } + ], + "usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2}, + } + ).encode() + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(payload))) + self.end_headers() + self.wfile.write(payload) + self.wfile.flush() + self.close_connection = True + + repo = tmp_path / "repo" + repo.mkdir() + base, head = _gitlink_only_fixture(repo) if kind == "only" else _fixture(repo, kind) + home = tmp_path / "home" + home.mkdir(mode=0o700) + bin_dir = tmp_path / "bin" + bin_dir.mkdir() + (bin_dir / "ocr").symlink_to(binary) + monkeypatch.setenv("HOME", str(home)) + monkeypatch.setenv("PATH", f"{bin_dir}{os.pathsep}{os.environ['PATH']}") + monkeypatch.setenv("GIT_CONFIG_GLOBAL", os.devnull) + monkeypatch.setenv("GIT_CONFIG_SYSTEM", os.devnull) + monkeypatch.setenv("OCR_LLM_TOKEN", "synthetic-token") + monkeypatch.setenv("OCR_LLM_MODEL", "synthetic-model") + monkeypatch.setenv("OCR_LLM_PROTOCOL", "openai") + monkeypatch.setenv("OCR_TELEMETRY_ENABLED", "false") + monkeypatch.setenv("OCR_REVIEW_CONTEXT_MODE", "off") + monkeypatch.setenv("OCR_REVIEW_PROGRESS", "false") + # The product continues to require HTTPS. This disposable in-process fixture + # admits only its own loopback server so OCR can use an HTTP fake provider. + original_url_parser = provider_config._parse_https_url + + def local_url_parser(value: str, name: str): # type: ignore[no-untyped-def] + if value == local_url: + return urlsplit(value) + return original_url_parser(value, name) + + server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), EvidenceStub) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + local_url = f"http://127.0.0.1:{server.server_port}/v1" + monkeypatch.setenv("OCR_LLM_URL", local_url) + monkeypatch.setattr(provider_config, "_parse_https_url", local_url_parser) + monkeypatch.chdir(repo) + result = tmp_path / "review.json" + stderr = tmp_path / "review.stderr" + report = tmp_path / "review.md" + try: + code = cli.main( + [ + "review", + "--local", + "--result", + str(result), + "--stderr", + str(stderr), + "--report", + str(report), + "--", + "--from", + base, + "--to", + head, + "--concurrency", + "1", + ] + ) + finally: + server.shutdown() + thread.join(timeout=5) + server.server_close() + if kind == "only": + assert code == 2 + assert EvidenceStub.request_count == 0 + assert "no ordinary files" in report.read_text(encoding="utf-8").lower() + else: + assert code == 0, stderr.read_text(encoding="utf-8")[:2000] + assert EvidenceStub.issued_evidence + assert "evidence" in EvidenceStub.request_stages + assert "partial" in report.read_text(encoding="utf-8").lower() + payload = json.loads(result.read_text(encoding="utf-8")) + assert payload["status"] == "complete" diff --git a/tests/test_operations_docs.py b/tests/test_operations_docs.py index e5c6539..18260cf 100644 --- a/tests/test_operations_docs.py +++ b/tests/test_operations_docs.py @@ -82,8 +82,8 @@ def test_readme_install_is_isolated_checksum_pinned_and_no_llm() -> None: assert "not the installation\nsmoke test" in install -def test_current_operator_guidance_uses_manifest_without_ocr_release_literals() -> None: - """Keep living guidance tied to qualified pins without per-release text churn.""" +def test_current_operator_guidance_uses_manifest_with_only_gitlink_ocr_literal() -> None: + """Keep general pins in the manifest while naming the fixed gitlink boundary.""" for document in ( README, @@ -94,7 +94,10 @@ def test_current_operator_guidance_uses_manifest_without_ocr_release_literals() ): content = document.read_text(encoding="utf-8") assert "compatibility/ocr-support.json" in content - assert not re.search(r"(?:OCR|Open Code Review) v?\d+\.\d+\.\d+", content) + assert all( + match.group() == "OCR 1.13.3" + for match in re.finditer(r"(?:OCR|Open Code Review) v?\d+\.\d+\.\d+", content) + ) assert not re.search(r"open-code-review/releases/tag/v\d+\.\d+\.\d+", content) configuration = CONFIGURATION.read_text(encoding="utf-8") assert "Qualified OCR pin for the example's toolkit release" in configuration @@ -221,9 +224,15 @@ def test_release_archive_reconciles_current_external_delivery_truth() -> None: assert "### Post-Close Delivery" in archived_plan assert "outside the closed repository plan" in archived_plan assert f"plans/{archive_path.name}" in current_index_row - assert "stable-delivery handoff" in current_index_row + if "stable-delivery handoff" not in current_index_row: + assert "immutable GitHub Release published" in current_index_row + assert "archived plan remains a prepublication snapshot" in current_index_row assert "external reconciliation verified" not in current_index_row - assert "## Active Plan:" not in (PROJECT_ROOT / "PLANS.md").read_text(encoding="utf-8") + active_plan = (PROJECT_ROOT / "PLANS.md").read_text(encoding="utf-8") + active_match = re.search(r"^## Active Plan: Toolkit (\d+\.\d+\.\d+)\b", active_plan, re.M) + if active_match: + active_version_key = tuple(int(part) for part in active_match.group(1).split(".")) + assert active_version_key > release_version_key def test_review_signal_audit_keeps_group_data_outside_toolkit_authority() -> None: diff --git a/tests/test_pre_execution_status.py b/tests/test_pre_execution_status.py index 1733b66..8c64c4b 100644 --- a/tests/test_pre_execution_status.py +++ b/tests/test_pre_execution_status.py @@ -389,7 +389,8 @@ def test_note_status_rejects_missing_or_malformed_review_tuple( tmp_path: Path, mutation: dict[str, object] ) -> None: path = private_directory(tmp_path) / "pre-execution-status.json" - payload = {**asdict(status()), **mutation} + write_pre_execution_status(path, status()) + payload = {**json.loads(path.read_text(encoding="utf-8")), **mutation} path.write_text(json.dumps(payload), encoding="utf-8") path.chmod(0o600) with pytest.raises(PreExecutionStatusError, match="review identity"): diff --git a/tests/test_review_receipt_v11.py b/tests/test_review_receipt_v11.py index 3afee85..a336abd 100644 --- a/tests/test_review_receipt_v11.py +++ b/tests/test_review_receipt_v11.py @@ -220,7 +220,8 @@ def test_v11_review_keys_and_federation_version_are_exact() -> None: def test_future_receipt_number_cannot_reinterpret_v11_payload() -> None: receipt = _receipt_v11(external=True) receipt["schema_version"] = 12 - assert receipt_review_identity(receipt) is None + identity = receipt_review_identity(receipt) + assert identity is not None and identity.schema_version == 12 assert validated_review_identity(receipt) is None assert not toolkit_receipt_is_valid(receipt) From 9f5de349029aa54d236b4620e65dec2e77df095f Mon Sep 17 00:00:00 2001 From: xeonvs <11463419+xeonvs@users.noreply.github.com> Date: Fri, 23 Oct 2026 22:07:29 +0200 Subject: [PATCH 6/8] Fix partial gitlink review findings from local OCR --- PLANS.md | 4 +- docs/review-decision-flow.md | 5 +- docs/security.md | 7 + .../evidence/gitlink_projection.py | 173 ++++++++++++-- src/ocr_toolkit/ocr_result.py | 1 + src/ocr_toolkit/posting/workflow.py | 14 +- src/ocr_toolkit/pre_execution.py | 3 +- src/ocr_toolkit/repository_coverage.py | 3 +- src/ocr_toolkit/review_runner.py | 42 +++- tests/test_gitlinks.py | 88 ++++++++ tests/test_repository_coverage.py | 211 +++++++++++++++++- 11 files changed, 510 insertions(+), 41 deletions(-) diff --git a/PLANS.md b/PLANS.md index 8eda559..fc335c1 100644 --- a/PLANS.md +++ b/PLANS.md @@ -98,13 +98,15 @@ Only proven OCR 1.13.3 pure-pointer projection proceeds. Unique identical-OID be 2026-10-23 pre-implementation OCR 1.13.3 probe passed for mixed update/add/delete/move/multiple pure gitlinks and demonstrated no-model gitlink-only preview plus unsafe type transitions. Digest matches the pinned manifest. The signed plan-only commit is `db27f37`; plan-lifecycle and instruction-contract checks and staged Gitleaks passed. Milestone v0.13.1 (#22) tracks #268. Draft feature PR #269 has only the plan commit at its remote head. Local WQ-02 commit `203a50e`, WQ-03 commit `827fc3b`, and WQ-04/05 commit `7f17a3f` are not pushed. WQ-03 has bounded unfiltered/projected Git inventory, original patch fingerprints and line counts, one frozen OCR child environment, exact preview and native manifest/source-artifact comparison, and before/after drift checks. WQ-04/05 add closed v12 effective coverage and v6 no-model status while preserving v11/v5, local/GitLab partial notices, independent post readback of original trees and native selected work, an approval blocker, and prior finding-thread retention. The checksum-verified OCR 1.13.3 and local fake provider passed add/update/delete/mixed ordinary rename/add/delete/gitlink move forms. The integrated local toolkit test also passed an ordinary-plus-gitlink review through the real OCR binary and mandatory evidence MCP, and a gitlink-only no-model case; its loopback fake-provider HTTP allowance exists only inside the test process, with product HTTPS validation intact. Focused runner/gitlink/coverage suite passed 269 tests before this integrated addition; focused GitLab posting suite passed 364 tests and 299 subtests. Towncrier rendered the 0.13.1 Feature draft and plan lifecycle check passed. The first full quality run reached 86.25% coverage but found 10 stale documentation/v5/v11 test expectations; corrected targeted contracts passed 98/98. The corrected-tree full `scripts/quality.sh check` then passed. The checksum-verified installed OCR matrix passed 49/49 including integrated toolkit proof. Final build, secret/dependency and review gates remain pending. +The first local ChatGPT OCR review of `bdf5832..4183c1d` completed with a native complete manifest (14 selected/completed, no reuse/failure/waiver), DLP admission, and six actionable findings. This is correction evidence, not an approval of the range. The local follow-up fixes preserve a path-free selected-work proof when publication DLP removes unsafe findings, reject unhashable v12/v6 fields, remove or privately mark raw output on post-model drift, and bound Git patch output during execution. Focused runner/gitlink/coverage/status tests passed 296 with 10 environment-gated skips; the installed OCR gitlink suite passed 52/52. These edits change the reviewed range, so final quality, security diff scan and local OCR review must be repeated before push. + ### Risks And Recovery Git configuration precedence or local input drift can hide ordinary changes: revalidate consulted inputs and independent diff; stop on uncertainty. False-complete receipt could launder approval/cleanup: exact parsing, original identity binding, independent approval blocker and thread retention. If installed proof fails for a proposed safe form, narrow it and test the fallback; if core mixed behavior cannot be proved, keep the plan active/blocked and do not publish. External publication failures resume only through exact-artifact documented recovery after readback. ### Resume Point -WQ-07: complete the corrected-tree quality and installed-artifact gates, tree/history secret and dependency gates, codex-security diff scan, aggregate self-review, and one correctly invoked local toolkit/OCR review with ChatGPT before the single final feature push. Preserve the draft feature PR's plan-only remote head until those reviews pass. +WQ-07: self-review and commit the six local OCR corrections, then repeat final quality, installed-artifact, secret/dependency, codex-security diff scan and local toolkit/OCR ChatGPT gates on the new immutable HEAD before the single final feature push. Preserve the draft feature PR's plan-only remote head until those reviews pass. ### Plan Fidelity Check diff --git a/docs/review-decision-flow.md b/docs/review-decision-flow.md index 96c5715..577a7e3 100644 --- a/docs/review-decision-flow.md +++ b/docs/review-decision-flow.md @@ -224,7 +224,10 @@ Receipt v12 adds separate path-free effective repository coverage only for a qualified partial gitlink review. Native OCR `complete` then covers its selected ordinary files; it does not claim the skipped pointer or submodule contents. Posting independently rebuilds original Git inputs and checks the native -selected/completed manifest before a current-result write. This route blocks +selected/completed manifest before a current-result write. If publication DLP +filters the result, the producer first validates that native manifest and +passes only a path-free selection proof; posting checks the proof, original +coverage counts and remaining findings against Git. This route blocks automatic approval, old finding-thread deletion, and requested discussion resolution even when the native manifest is complete. v11 retains its exact ordinary-review meaning. diff --git a/docs/security.md b/docs/security.md index 32dcbdc..6266d36 100644 --- a/docs/security.md +++ b/docs/security.md @@ -51,6 +51,13 @@ old finding-thread cleanup; the native OCR manifest retains its own meaning. `post` independently rechecks the original Git trees and selected native work before a GitLab write. A v6 no-model status contains only closed counts or a closed unqualified reason, with the authenticated MR identity. +If publication DLP removes a finding or warning, the filtered handoff omits +the native manifest. Only after validating that manifest, the toolkit carries +a path-free selection proof made of item digests and the source-artifact digest; +`post` checks it against immutable Git changes and the original complete +coverage before publishing any retained finding. A missing or altered proof +stops publication. Git diff output is also bounded while the child runs, so +an oversized patch cannot fill temporary storage before the limit is checked. The `auto` fallback is sensitive to wrong-project source substitution, retargeting, default-branch or protection changes, stale diff versions, diff --git a/src/ocr_toolkit/evidence/gitlink_projection.py b/src/ocr_toolkit/evidence/gitlink_projection.py index b9c154e..a0cea40 100644 --- a/src/ocr_toolkit/evidence/gitlink_projection.py +++ b/src/ocr_toolkit/evidence/gitlink_projection.py @@ -6,15 +6,17 @@ import json import os import re +import selectors import stat import subprocess -import tempfile +import time from dataclasses import dataclass from pathlib import Path from ocr_toolkit.common.git import read_only_git_prefix from ocr_toolkit.evidence.gitlinks import GitlinkInputPlan from ocr_toolkit.evidence.repository import GitRepositoryReader, RepositoryEvidenceError +from ocr_toolkit.ocr_result import TOOLKIT_GITLINK_SELECTION_KEY _SAFE_CONFIG_NAME = re.compile(r"[A-Za-z0-9_][A-Za-z0-9_./-]{0,127}\Z") _RAW_HEADER = re.compile( @@ -25,7 +27,9 @@ _ZERO_OID = "0" * 40 _GIT_INPUT_LIMIT = 512_000 _DIFF_OUTPUT_LIMIT = 8_000_000 +_GIT_TIMEOUT_SECONDS = 30 _DIFF_ENTRY_LIMIT = 20_000 +_GITLINK_SELECTION_SCHEMA = "ocr.gitlink-selection/v1" _LOCAL_INPUTS = (".gitignore", ".gitmodules", ".gitattributes") _GIT_METADATA_INPUTS = ("config", "info/attributes", "info/exclude") _PREVIEW_REASONS = frozenset( @@ -179,24 +183,62 @@ def _projection_environment(plan: GitlinkInputPlan) -> dict[str, str]: def _git_output(root: Path, args: list[str], environment: dict[str, str]) -> bytes: + process: subprocess.Popen[bytes] | None = None try: - with tempfile.TemporaryFile() as stdout, tempfile.TemporaryFile() as stderr: - result = subprocess.run( - [*read_only_git_prefix(root), *args], - stdout=stdout, - stderr=stderr, - check=False, - env=environment, - timeout=30, - ) - if result.returncode != 0 or stderr.tell() != 0 or stdout.tell() > _DIFF_OUTPUT_LIMIT: - raise RepositoryEvidenceError( - "OCR Git diff inventory failed or produced diagnostics" - ) - stdout.seek(0) - return stdout.read(_DIFF_OUTPUT_LIMIT + 1) + process = subprocess.Popen( + [*read_only_git_prefix(root), *args], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + env=environment, + ) + assert process.stdout is not None and process.stderr is not None + output = bytearray() + deadline = time.monotonic() + _GIT_TIMEOUT_SECONDS + with selectors.DefaultSelector() as selector: + selector.register(process.stdout, selectors.EVENT_READ, "stdout") + selector.register(process.stderr, selectors.EVENT_READ, "stderr") + while selector.get_map(): + remaining = deadline - time.monotonic() + if remaining <= 0: + raise subprocess.TimeoutExpired(process.args, _GIT_TIMEOUT_SECONDS) + ready = selector.select(remaining) + if not ready: + raise subprocess.TimeoutExpired(process.args, _GIT_TIMEOUT_SECONDS) + for key, _ in ready: + limit = _DIFF_OUTPUT_LIMIT + 1 - len(output) + chunk = os.read(key.fileobj.fileno(), min(65_536, max(1, limit))) + if not chunk: + selector.unregister(key.fileobj) + elif key.data == "stderr": + raise RepositoryEvidenceError( + "OCR Git diff inventory failed or produced diagnostics" + ) + else: + output.extend(chunk) + if len(output) > _DIFF_OUTPUT_LIMIT: + raise RepositoryEvidenceError( + "OCR Git diff inventory exceeded its output limit" + ) + remaining = deadline - time.monotonic() + if remaining <= 0: + raise subprocess.TimeoutExpired(process.args, _GIT_TIMEOUT_SECONDS) + if process.wait(timeout=remaining) != 0: + raise RepositoryEvidenceError("OCR Git diff inventory failed or produced diagnostics") + return bytes(output) except (OSError, subprocess.TimeoutExpired) as exc: raise RepositoryEvidenceError("OCR Git diff inventory could not complete") from exc + finally: + if process is not None: + if process.poll() is None: + try: + process.kill() + except ProcessLookupError: + pass + process.wait() + if process.stdout is not None: + process.stdout.close() + if process.stderr is not None: + process.stderr.close() def _raw_diff( @@ -300,6 +342,90 @@ def _manifest_id(entry: RawDiffEntry) -> str: return hashlib.sha256(identity.encode("utf-8")).hexdigest() +def gitlink_selection_proof( + projection: GitlinkProjection, selected: tuple[RawDiffEntry, ...] +) -> dict[str, object]: + """Carry only closed, path-free selected identities across DLP projection.""" + + fingerprints = dict(projection.fingerprints) + item_ids = sorted(_manifest_id(entry) for entry in selected) + if len(item_ids) != len(set(item_ids)) or any( + item_id not in fingerprints for item_id in item_ids + ): + raise RepositoryEvidenceError("OCR gitlink selected identities are ambiguous") + source_hash = hashlib.sha256() + for item_id in item_ids: + for field in (item_id, fingerprints[item_id]): + raw = field.encode("utf-8") + source_hash.update(len(raw).to_bytes(8, "big")) + source_hash.update(raw) + return { + "schema_version": _GITLINK_SELECTION_SCHEMA, + "selected_item_ids": item_ids, + "source_artifact_sha256": source_hash.hexdigest(), + } + + +def selected_gitlink_proof_entries( + projection: GitlinkProjection, result: dict[str, object] +) -> tuple[RawDiffEntry, ...]: + """Rebuild DLP-filtered selection from the producer's path-free witness.""" + + proof = result.get(TOOLKIT_GITLINK_SELECTION_KEY) + metadata = result.get("_ocr_toolkit") + publication = metadata.get("publication") if isinstance(metadata, dict) else None + original = publication.get("original") if isinstance(publication, dict) else None + if ( + result.get("status") != "completed_with_errors" + or result.get("manifest") is not None + or not isinstance(publication, dict) + or publication.get("state") != "publication-filtered" + or not isinstance(proof, dict) + or set(proof) != {"schema_version", "selected_item_ids", "source_artifact_sha256"} + or proof.get("schema_version") != _GITLINK_SELECTION_SCHEMA + or not isinstance(proof.get("selected_item_ids"), list) + or not isinstance(original, dict) + ): + raise RepositoryEvidenceError("OCR gitlink filtered selection proof is invalid") + item_ids = proof["selected_item_ids"] + if ( + not item_ids + or len(item_ids) > _DIFF_ENTRY_LIMIT + or any(not isinstance(item_id, str) for item_id in item_ids) + or item_ids != sorted(set(item_ids)) + ): + raise RepositoryEvidenceError("OCR gitlink filtered selection identities are invalid") + expected = {_manifest_id(entry): entry for entry in projection.ordinary_diff} + if len(expected) != len(projection.ordinary_diff) or any( + item_id not in expected for item_id in item_ids + ): + raise RepositoryEvidenceError("OCR gitlink filtered selection differs from Git") + selected = tuple(expected[item_id] for item_id in item_ids) + if proof != gitlink_selection_proof(projection, selected): + raise RepositoryEvidenceError("OCR gitlink filtered selection fingerprint differs from Git") + if original.get("outcome") != "clean" or any( + type(original.get(key)) is not int or original[key] != expected_count + for key, expected_count in ( + ("selected", len(selected)), + ("completed", len(selected)), + ("reused", 0), + ("failed", 0), + ("waived", 0), + ) + ): + raise RepositoryEvidenceError("OCR gitlink filtered original coverage is invalid") + comments = result.get("comments") + selected_paths = {entry.path for entry in selected} + if not isinstance(comments, list) or any( + not isinstance(comment, dict) + or not isinstance(comment.get("path"), str) + or comment["path"] not in selected_paths + for comment in comments + ): + raise RepositoryEvidenceError("OCR gitlink filtered finding is outside selected files") + return selected + + def _patch_fingerprints( root: Path, plan: GitlinkInputPlan, @@ -574,13 +700,8 @@ def qualify_gitlink_manifest( fingerprints = dict(projection.fingerprints) if len(expected) != len(selected): raise RepositoryEvidenceError("OCR gitlink selected identities are ambiguous") - source_hash = hashlib.sha256() - for item_id in sorted(expected): - for field in (item_id, fingerprints[item_id]): - raw = field.encode("utf-8") - source_hash.update(len(raw).to_bytes(8, "big")) - source_hash.update(raw) - if native_input.get("source_artifact_sha256") != source_hash.hexdigest(): + proof = gitlink_selection_proof(projection, selected) + if native_input.get("source_artifact_sha256") != proof["source_artifact_sha256"]: raise RepositoryEvidenceError("OCR gitlink source artifact differs from the Git patch") for key in ("selected", "completed"): records = coverage.get(key) @@ -611,7 +732,11 @@ def qualify_gitlink_manifest( raise RepositoryEvidenceError("OCR gitlink result comments are invalid") selected_paths = {entry.path for entry in selected} for comment in comments: - if not isinstance(comment, dict) or comment.get("path") not in selected_paths: + if ( + not isinstance(comment, dict) + or not isinstance(comment.get("path"), str) + or comment["path"] not in selected_paths + ): raise RepositoryEvidenceError("OCR gitlink result has a finding outside selected files") diff --git a/src/ocr_toolkit/ocr_result.py b/src/ocr_toolkit/ocr_result.py index ad3a72f..9eb5570 100644 --- a/src/ocr_toolkit/ocr_result.py +++ b/src/ocr_toolkit/ocr_result.py @@ -20,6 +20,7 @@ MAX_RESULT_BYTES_HARD_LIMIT = 20_000_000 TOOLKIT_RESULT_KEY = "_ocr_toolkit" TOOLKIT_PRIVATE_DIAGNOSTIC_KEY = "_ocr_toolkit_private_diagnostic" +TOOLKIT_GITLINK_SELECTION_KEY = "_ocr_toolkit_gitlink_selection" TOOLKIT_RESULT_SCHEMA_VERSION = 11 PARTIAL_TOOLKIT_RESULT_SCHEMA_VERSION = 12 HISTORICAL_TOOLKIT_RESULT_SCHEMA_VERSIONS = frozenset({9, 10}) diff --git a/src/ocr_toolkit/posting/workflow.py b/src/ocr_toolkit/posting/workflow.py index 756e4ea..36f4531 100644 --- a/src/ocr_toolkit/posting/workflow.py +++ b/src/ocr_toolkit/posting/workflow.py @@ -20,6 +20,7 @@ make_gitlink_projection, qualify_gitlink_manifest, selected_gitlink_manifest_entries, + selected_gitlink_proof_entries, verify_gitlink_projection, ) from ocr_toolkit.evidence.gitlinks import plan_gitlink_input @@ -27,6 +28,7 @@ from ocr_toolkit.ocr_result import ( PARTIAL_TOOLKIT_RESULT_SCHEMA_VERSION, TOOLKIT_ADVISORY_KEY, + TOOLKIT_GITLINK_SELECTION_KEY, TOOLKIT_PRIVATE_DIAGNOSTIC_KEY, TOOLKIT_RESULT_KEY, TOOLKIT_RESULT_SCHEMA_VERSION, @@ -987,13 +989,19 @@ def verified_partial_repository_coverage( if plan is None: raise RepositoryEvidenceError("partial review has no changed gitlink") projection = make_gitlink_projection(reader, plan) - selected = selected_gitlink_manifest_entries(projection, result) - qualify_gitlink_manifest(projection, selected, result) + metadata = result.get(TOOLKIT_RESULT_KEY) + publication = metadata.get("publication") if isinstance(metadata, dict) else None + if isinstance(publication, dict) and publication.get("state") == "publication-filtered": + selected = selected_gitlink_proof_entries(projection, result) + else: + if TOOLKIT_GITLINK_SELECTION_KEY in result: + raise RepositoryEvidenceError("OCR gitlink selection proof is unexpected") + selected = selected_gitlink_manifest_entries(projection, result) + qualify_gitlink_manifest(projection, selected, result) try: actual = coverage_from_projection(projection, selected, state="partial") except RepositoryCoverageError as exc: raise RepositoryEvidenceError("partial review selected coverage is invalid") from exc - metadata = result.get(TOOLKIT_RESULT_KEY) try: claimed = parse_repository_coverage( metadata["repository_coverage"], required_state="partial" diff --git a/src/ocr_toolkit/pre_execution.py b/src/ocr_toolkit/pre_execution.py index 26dfbd2..ec94c62 100644 --- a/src/ocr_toolkit/pre_execution.py +++ b/src/ocr_toolkit/pre_execution.py @@ -442,7 +442,8 @@ def read_pre_execution_status( } if ( not isinstance(value, dict) - or value.get("schema_version") not in {STATUS_SCHEMA, GITLINK_STATUS_SCHEMA} + or not isinstance(value.get("schema_version"), str) + or value["schema_version"] not in {STATUS_SCHEMA, GITLINK_STATUS_SCHEMA} or set(value) != ( legacy_fields | {"repository_coverage"} diff --git a/src/ocr_toolkit/repository_coverage.py b/src/ocr_toolkit/repository_coverage.py index d5f4ab8..1b84945 100644 --- a/src/ocr_toolkit/repository_coverage.py +++ b/src/ocr_toolkit/repository_coverage.py @@ -47,7 +47,8 @@ def parse_repository_coverage( raise RepositoryCoverageError("repository coverage has an unsupported shape") if ( value.get("schema_version") != REPOSITORY_COVERAGE_SCHEMA - or value.get("state") not in {"partial", "not_started"} + or not isinstance(value.get("state"), str) + or value["state"] not in {"partial", "not_started"} or value.get("reason") != GITLINK_PARTIAL_REASON or (required_state is not None and value.get("state") != required_state) ): diff --git a/src/ocr_toolkit/review_runner.py b/src/ocr_toolkit/review_runner.py index d0bf940..9ed00dc 100644 --- a/src/ocr_toolkit/review_runner.py +++ b/src/ocr_toolkit/review_runner.py @@ -67,6 +67,7 @@ from ocr_toolkit.evidence.gitlink_projection import ( GitlinkProjection, RawDiffEntry, + gitlink_selection_proof, make_gitlink_projection, qualify_gitlink_manifest, qualify_gitlink_preview, @@ -102,6 +103,7 @@ PARTIAL_TOOLKIT_RESULT_SCHEMA_VERSION, PUBLIC_REVIEW_TOOL_CALL_NAMES, TOOLKIT_ADVISORY_KEY, + TOOLKIT_GITLINK_SELECTION_KEY, TOOLKIT_PRIVATE_DIAGNOSTIC_KEY, TOOLKIT_RESULT_KEY, TOOLKIT_RESULT_SCHEMA_VERSION, @@ -1698,6 +1700,24 @@ def _retain_private_diagnostic_result(result_path: Path, *, reason: str) -> bool return True +def _verify_gitlink_after_execution( + result_path: Path, projection: GitlinkProjection, *, preserve_authorized: bool +) -> None: + """Reject drift and dispose of raw model output before any publication DLP work.""" + + try: + verify_gitlink_projection(GitRepositoryReader(Path.cwd()), projection) + except RepositoryEvidenceError as exc: + if preserve_authorized: + _retain_private_diagnostic_result(result_path, reason="gitlink-input-drift") + else: + try: + result_path.unlink(missing_ok=True) + except OSError: + pass + raise ReviewRunnerError("OCR gitlink inputs drifted during model execution") from exc + + def _finalize_ocr_result( result_path: Path, composition: mcp_config.MCPComposition, @@ -1742,7 +1762,12 @@ def code_proof(comment: dict[str, object]) -> OpaqueCodeProof: def finalize(payload: dict[str, object]) -> dict[str, object]: nonlocal failure_telemetry, filtered, publication, usage, report - for reserved in (TOOLKIT_RESULT_KEY, TOOLKIT_ADVISORY_KEY, TOOLKIT_PRIVATE_DIAGNOSTIC_KEY): + for reserved in ( + TOOLKIT_RESULT_KEY, + TOOLKIT_ADVISORY_KEY, + TOOLKIT_PRIVATE_DIAGNOSTIC_KEY, + TOOLKIT_GITLINK_SELECTION_KEY, + ): if reserved in payload: raise OcrResultMalformed(f"OCR result contains reserved field {reserved!r}") warnings = payload.get("warnings", []) @@ -1811,6 +1836,10 @@ def finalize(payload: dict[str, object]) -> dict[str, object]: raw_usage = mcp.get("usage") if isinstance(mcp, dict) else None usage = dict(raw_usage) if isinstance(raw_usage, dict) else {} finalized = {**projected, TOOLKIT_RESULT_KEY: metadata} if provider_receipt else projected + if projection is not None and filtered and provider_receipt: + finalized[TOOLKIT_GITLINK_SELECTION_KEY] = gitlink_selection_proof( + projection, selected_diff + ) if toolkit_advisory is not None and provider_receipt: finalized[TOOLKIT_ADVISORY_KEY] = toolkit_advisory_payload(toolkit_advisory) if report_consumer is not None and ( @@ -3535,13 +3564,6 @@ def _run_evidence_review( os.environ["HOME"] = previous_home if cleanup_error is None and exit_code == 0: state.enter("result-validation") - if projection is not None: - try: - verify_gitlink_projection(GitRepositoryReader(Path.cwd()), projection) - except RepositoryEvidenceError as exc: - raise ReviewRunnerError( - "OCR gitlink inputs drifted during model execution" - ) from exc forbidden: tuple[str | ForbiddenValue, ...] = tuple( ForbiddenValue(v, "operator_secret") for v in composition.secret_values ) @@ -3552,6 +3574,10 @@ def _run_evidence_review( forbidden += enrichment.forbidden_sources or tuple( ForbiddenValue(v, "other") for v in enrichment.forbidden_publication ) + if projection is not None: + _verify_gitlink_after_execution( + result_path, projection, preserve_authorized=preserve_authorized + ) try: usage, publication_filtered, publication = _finalize_ocr_result( result_path, diff --git a/tests/test_gitlinks.py b/tests/test_gitlinks.py index d1827bb..3411db1 100644 --- a/tests/test_gitlinks.py +++ b/tests/test_gitlinks.py @@ -5,6 +5,7 @@ import json import os import subprocess +import sys from io import BytesIO from pathlib import Path from urllib.parse import urlsplit @@ -12,6 +13,7 @@ import pytest from ocr_toolkit import review_runner +from ocr_toolkit.evidence import gitlink_projection from ocr_toolkit.evidence.artifacts import prepare_artifact_directory, repository_artifacts from ocr_toolkit.evidence.gitlink_projection import ( make_gitlink_projection, @@ -429,6 +431,88 @@ def production(*_args: object, **kwargs: object) -> int: ] +@pytest.mark.parametrize("preserve_authorized", [False, True]) +def test_post_model_gitlink_drift_disposes_or_marks_raw_result( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, preserve_authorized: bool +) -> None: + base, head = _fixture(tmp_path, "updated") + reader = GitRepositoryReader(tmp_path) + plan = plan_gitlink_input(reader, base, head) + assert plan is not None + projection = make_gitlink_projection(reader, plan) + result = tmp_path / "result.json" + result.write_text('{"status":"complete","message":"private model output"}') + monkeypatch.chdir(tmp_path) + + def drift(*_args: object) -> None: + raise RepositoryEvidenceError("synthetic post-model drift") + + monkeypatch.setattr(review_runner, "verify_gitlink_projection", drift) + with pytest.raises(review_runner.ReviewRunnerError, match="drifted during"): + review_runner._verify_gitlink_after_execution( + result, projection, preserve_authorized=preserve_authorized + ) + if preserve_authorized: + retained = json.loads(result.read_text()) + assert retained["_ocr_toolkit_private_diagnostic"]["reason"] == "gitlink-input-drift" + assert retained["result"]["message"] == "private model output" + else: + assert not result.exists() + + +def test_git_inventory_kills_stream_when_byte_budget_is_exceeded( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + script = "import os; os.write(1, b'x' * 131072)" + monkeypatch.setattr(gitlink_projection, "_DIFF_OUTPUT_LIMIT", 1024) + monkeypatch.setattr( + gitlink_projection, + "read_only_git_prefix", + lambda _root: [sys.executable, "-c", script], + ) + with pytest.raises(RepositoryEvidenceError, match="exceeded its output limit"): + gitlink_projection._git_output(tmp_path, ["ignored"], os.environ.copy()) + + +@pytest.mark.parametrize( + ("script", "expected"), + [ + ("import os; os.write(1, b'bounded-result')", b"bounded-result"), + ("import os; os.write(2, b'diagnostic')", None), + ("import sys; sys.exit(2)", None), + ], +) +def test_git_inventory_requires_clean_exit_and_no_diagnostics( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + script: str, + expected: bytes | None, +) -> None: + monkeypatch.setattr( + gitlink_projection, + "read_only_git_prefix", + lambda _root: [sys.executable, "-c", script], + ) + if expected is None: + with pytest.raises(RepositoryEvidenceError, match="diagnostics"): + gitlink_projection._git_output(tmp_path, [], os.environ.copy()) + else: + assert gitlink_projection._git_output(tmp_path, [], os.environ.copy()) == expected + + +def test_git_inventory_times_out_without_unbounded_wait( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr(gitlink_projection, "_GIT_TIMEOUT_SECONDS", 0.2) + monkeypatch.setattr( + gitlink_projection, + "read_only_git_prefix", + lambda _root: [sys.executable, "-c", "import time; time.sleep(5)"], + ) + with pytest.raises(RepositoryEvidenceError, match="could not complete"): + gitlink_projection._git_output(tmp_path, [], os.environ.copy()) + + def test_projection_rejects_local_git_config_include(tmp_path: Path) -> None: base, head = _fixture(tmp_path, "updated") reader = GitRepositoryReader(tmp_path) @@ -524,6 +608,10 @@ def test_projection_rejects_wrong_manifest_identity_or_reuse(tmp_path: Path) -> coverage["reused"] = [item] with pytest.raises(RepositoryEvidenceError, match="unreviewed"): qualify_gitlink_manifest(projection, projection.ordinary_diff, payload) + coverage["reused"] = [] + payload["comments"] = [{"path": []}] + with pytest.raises(RepositoryEvidenceError, match="outside selected"): + qualify_gitlink_manifest(projection, projection.ordinary_diff, payload) def _move_fixture(tmp_path: Path) -> tuple[str, str]: diff --git a/tests/test_repository_coverage.py b/tests/test_repository_coverage.py index 46d82d9..cd3c2c4 100644 --- a/tests/test_repository_coverage.py +++ b/tests/test_repository_coverage.py @@ -10,9 +10,15 @@ import pytest -from ocr_toolkit.evidence.gitlink_projection import make_gitlink_projection +from ocr_toolkit import review_runner +from ocr_toolkit.evidence.gitlink_projection import ( + gitlink_selection_proof, + make_gitlink_projection, +) from ocr_toolkit.evidence.gitlinks import plan_gitlink_input from ocr_toolkit.evidence.repository import GitRepositoryReader, RepositoryEvidenceError +from ocr_toolkit.mcp_config import MCPCapability, MCPComposition +from ocr_toolkit.ocr_result import TOOLKIT_GITLINK_SELECTION_KEY, TOOLKIT_RESULT_KEY from ocr_toolkit.posting import workflow from ocr_toolkit.posting.snapshot import BotCommentRefs from ocr_toolkit.posting.transaction import PostingTransaction @@ -45,8 +51,14 @@ toolkit_receipt_is_valid, ) from tests.support import gitlab_config -from tests.test_gitlinks import _fixture, _gitlink_only_fixture, _move_fixture +from tests.test_gitlinks import _fixture, _gitlink_only_fixture, _mixed_fixture, _move_fixture from tests.test_review_receipt_v11 import _receipt_v11 +from tests.test_review_runner import ( + BUILTIN_EVIDENCE_TOOLS, + DEFAULT_IDENTITY, + SUMMARY_ACTION_COUNTS, + complete_review_payload, +) def _coverage(*, state: str = "partial") -> dict[str, object]: @@ -72,6 +84,8 @@ def _coverage(*, state: str = "partial") -> dict[str, object]: [ ("reason", "future_reason"), ("state", "complete"), + ("state", []), + ("state", {}), ("ordinary_selected", True), ("ordinary_completed", 0), ("ordinary_excluded", 1), @@ -166,6 +180,34 @@ def test_v6_no_model_status_round_trips_without_changing_v5_wire_shape(tmp_path: ) +@pytest.mark.parametrize("schema_version", [[], {}]) +def test_v6_status_rejects_unhashable_schema_version( + tmp_path: Path, schema_version: object +) -> None: + review = _receipt_v11()["review"] + path = tmp_path / "status.json" + status = PreExecutionStatus( + schema_version=GITLINK_STATUS_SCHEMA, + reason=GITLINK_NO_REVIEWABLE_FILES, + diff_base_sha=review["diff_base_sha"], + source_sha=review["source_sha"], + policy_sha=review["policy_sha"], + review=review, + stage=OCR_PREVIEW_STAGE, + repository_coverage=_coverage(state="not_started"), + ) + write_pre_execution_status(path, status) + payload = json.loads(path.read_text()) + payload["schema_version"] = schema_version + path.write_text(json.dumps(payload)) + with pytest.raises(PreExecutionStatusError, match="fields are invalid"): + read_pre_execution_status( + path, + expected_diff_base_sha=review["diff_base_sha"], + expected_source_sha=review["source_sha"], + ) + + def test_v6_no_model_status_rejects_wrong_coverage(tmp_path: Path) -> None: review = _receipt_v11()["review"] status = PreExecutionStatus( @@ -292,6 +334,171 @@ def test_post_rebuilds_native_partial_coverage_and_rejects_tampering( workflow.verified_partial_repository_coverage(result, identity) +def test_dlp_filtered_gitlink_review_keeps_safe_finding_and_verifiable_coverage( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + base, head = _fixture(tmp_path, "updated") + monkeypatch.chdir(tmp_path) + reader = GitRepositoryReader(tmp_path) + plan = plan_gitlink_input(reader, base, head) + assert plan is not None + projection = make_gitlink_projection(reader, plan) + selected = projection.ordinary_diff + proof = gitlink_selection_proof(projection, selected) + item_id, fingerprint = projection.fingerprints[0] + item = {"item_id": item_id, "path": "main.php", "fingerprint": fingerprint} + payload = complete_review_payload( + tool_calls={"total": 1, "by_tool": {"ocr_toolkit_evidence": 1}} + ) + payload["comments"] = [ + { + "path": "main.php", + "line": 2, + "content": "Safe actionable finding.", + "severity": "medium", + "category": "bug", + }, + { + "path": "main.php", + "line": 2, + "content": "synthetic-secret-token must not be published", + "severity": "medium", + "category": "bug", + }, + ] + manifest = payload["manifest"] + assert isinstance(manifest, dict) + manifest["input"] = { + "mode": "range", + "requested_from": base, + "requested_head": head, + "resolved_base": base, + "resolved_head": head, + "exact_range": f"{base}..{head}", + "source_artifact_sha256": proof["source_artifact_sha256"], + } + manifest["execution"] = {"ocr_version": "v1.13.3"} + manifest["coverage"] = { + "selected": [item], + "completed": [item], + "reused": [], + "failed": [], + "waived": [], + } + result_path = tmp_path / "result.json" + result_path.write_text(json.dumps(payload), encoding="utf-8") + identity = replace( + DEFAULT_IDENTITY, + source_sha=head, + policy_sha=base, + target_sha=base, + diff_base_sha=base, + diff_start_sha=base, + diff_head_sha=head, + ) + composition = MCPComposition( + payload={}, + capabilities=(MCPCapability("ocr_toolkit_evidence", BUILTIN_EVIDENCE_TOOLS, True),), + external_servers=(), + secret_values=(), + ) + _usage, filtered, publication = review_runner._finalize_ocr_result( + result_path, + composition, + identity, + None, + SUMMARY_ACTION_COUNTS, + forbidden=("synthetic-secret-token",), + projection=projection, + selected_diff=selected, + ) + assert filtered and publication["state"] == "publication-filtered" + result = json.loads(result_path.read_text()) + assert result["status"] == "completed_with_errors" + assert "manifest" not in result + assert len(result["comments"]) == 1 + assert result["comments"][0]["content"] == "Safe actionable finding." + assert "synthetic-secret-token" not in result_path.read_text() + assert result[TOOLKIT_GITLINK_SELECTION_KEY] == proof + receipt = result[TOOLKIT_RESULT_KEY] + assert toolkit_receipt_is_valid(receipt) + receipt_identity = receipt_review_identity(receipt) + assert receipt_identity is not None + assert ( + workflow.verified_partial_repository_coverage(result, receipt_identity).ordinary_completed + == 1 + ) + assert "publication DLP filtered" in automatic_approval_metadata_reason(receipt) + + for mutation in ( + "missing", + "item", + "fingerprint", + "original", + "gitlink_comment", + "unhashable_comment", + ): + tampered = deepcopy(result) + if mutation == "missing": + tampered.pop(TOOLKIT_GITLINK_SELECTION_KEY) + elif mutation == "item": + tampered[TOOLKIT_GITLINK_SELECTION_KEY]["selected_item_ids"] = ["0" * 64] + elif mutation == "fingerprint": + tampered[TOOLKIT_GITLINK_SELECTION_KEY]["source_artifact_sha256"] = "0" * 64 + elif mutation == "original": + tampered[TOOLKIT_RESULT_KEY]["publication"]["original"]["completed"] = 0 + elif mutation == "gitlink_comment": + tampered["comments"][0]["path"] = "schema" + else: + tampered["comments"][0]["path"] = [] + with pytest.raises(RepositoryEvidenceError): + workflow.verified_partial_repository_coverage(tampered, receipt_identity) + + +def test_filtered_selection_proof_preserves_native_subset_not_all_ordinary_files( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + base, head = _mixed_fixture(tmp_path) + monkeypatch.chdir(tmp_path) + reader = GitRepositoryReader(tmp_path) + plan = plan_gitlink_input(reader, base, head) + assert plan is not None + projection = make_gitlink_projection(reader, plan) + assert len(projection.ordinary_diff) > 1 + selected = (next(entry for entry in projection.ordinary_diff if entry.status != "D"),) + coverage = coverage_from_projection(projection, selected, state="partial") + proof = gitlink_selection_proof(projection, selected) + result = { + "status": "completed_with_errors", + "comments": [{"path": selected[0].path}], + TOOLKIT_GITLINK_SELECTION_KEY: proof, + TOOLKIT_RESULT_KEY: { + "publication": { + "state": "publication-filtered", + "original": { + "outcome": "clean", + "selected": 1, + "completed": 1, + "reused": 0, + "failed": 0, + "waived": 0, + }, + }, + "repository_coverage": coverage.payload(), + }, + } + identity = replace( + receipt_review_identity(_receipt_v11()), # type: ignore[arg-type] + schema_version=12, + diff_base_sha=base, + source_sha=head, + ) + actual = workflow.verified_partial_repository_coverage(result, identity) + assert actual.ordinary_selected == 1 + assert actual.ordinary_excluded == len(projection.ordinary_diff) - 1 + assert selected[0].path not in json.dumps(proof) + + def test_post_rechecks_gitlink_only_advisory( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: From f65d519dd48c51004d156a03d647d1701842fcf0 Mon Sep 17 00:00:00 2001 From: xeonvs <11463419+xeonvs@users.noreply.github.com> Date: Fri, 23 Oct 2026 22:34:33 +0200 Subject: [PATCH 7/8] Harden gitlink review verification and review workflow --- AGENTS.md | 8 +-- PLANS.md | 12 +++- docs/codex/AGENT_EXECUTION_PITFALLS.md | 2 +- docs/development.md | 11 +++ docs/local.md | 8 ++- docs/security.md | 2 + .../evidence/gitlink_projection.py | 7 +- src/ocr_toolkit/review_runner.py | 4 +- tests/test_gitlinks.py | 71 ++++++++++++++++--- 9 files changed, 103 insertions(+), 22 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 6ea40eb..bc44f74 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -47,8 +47,8 @@ Read the matching owners and run their applicable guards. The general repository | `long-running-execution` | Long-running command, waiter, or process cleanup | Project principles and development procedures | Completion evidence and bounded output | - -| `local-ocr-review` | Authorized real local OCR review | Local review and development procedures | Mode, binary, flags, private paths, terminal status, report and findings | + +| `local-ocr-review` | Authorized real local OCR review | Local review and development procedures | Mode, binary, flags, private paths, result, findings and failed reads | | `workflow-instructions` | Instruction owner, route, or incident change | Installed instruction lifecycle and project principles | `lint:instruction-contract` | @@ -59,8 +59,8 @@ Read the matching owners and run their applicable guards. The general repository | `release-lifecycle` | Release authorization, stable delivery, or closure | Release guide | Protected release gate and external receipt review | - -| `development-validation` | Status or changed-boundary validation | Development guide | Status review and Python quality matrix | + +| `development-validation` | Status or changed-boundary validation | Development guide | Status and handoff review; Python quality matrix | | `dependency-audit` | Every push and dependency or audit-workflow change | Development guide | Shared local and hosted dependency audit | diff --git a/PLANS.md b/PLANS.md index fc335c1..6e63012 100644 --- a/PLANS.md +++ b/PLANS.md @@ -34,6 +34,8 @@ Implement private input planning, OCR 1.13.3-only process-local Git projection, | REQ-008 | After release merge, verify stable registries, provenance, tag, immutable Release/receipt, #268/milestone, site notification and external state | WQ-09; Post-Close Delivery | out_of_scope | | REQ-009 | Apply bounded tool-use decisions from prior release-chat audits while preserving mandatory gates | WQ-01–WQ-09 | pending | | REQ-010 | Correct current status-bearing v0.13.0 docs from live evidence; keep archived prepublication snapshot intact | WQ-06, WQ-08 | pending | +| REQ-011 | Make the review-found boundary gaps a durable, narrowly routed validation control for future changes | WQ-07 | pending | +| REQ-012 | Audit recurring OCR `file_read` failures against toolkit input, failure telemetry and review acceptance; correct only a demonstrated toolkit defect | WQ-07 | pending | ### Explicit Non-Goals @@ -62,7 +64,7 @@ No OCR fork/upstream change, consumer-CI bypass, submodule initialization/fetch/ ### User Decisions And Answers -Target v0.13.1, keep 0.14.0 development marker; include #268 and required adversarial/contract checks, leave conditional backlog inactive; no-model GitLab post is advisory by default and strict only under existing strict mode; embed tool-use improvements in this execution, not a separate workflow PR; implement through protected feature and stable release lifecycle. Use a plan-only early draft PR, no intermediate feature pushes, broader tests for all touched files, logical signed commits with self-review/amend for fixes, codex-security before the final push and local toolkit/OCR review with ChatGPT after it but still before the push. +Target v0.13.1, keep 0.14.0 development marker; include #268 and required adversarial/contract checks, leave conditional backlog inactive; no-model GitLab post is advisory by default and strict only under existing strict mode; embed tool-use improvements in this execution, not a separate workflow PR; implement through protected feature and stable release lifecycle. Use a plan-only early draft PR, no intermediate feature pushes, broader tests for all touched files, logical signed commits with self-review/amend for fixes, codex-security before the final push and local toolkit/OCR review with ChatGPT after it but still before the push. After review findings, make a prospective cross-stage validation control in the existing canonical development owner, preserving route-only `AGENTS.md` and a non-normative incident catalogue. Audit repeated native `file_read` failures without conflating built-in OCR tools with the toolkit MCP federation. ### Completed Baseline State @@ -78,7 +80,7 @@ v0.13.0 is published with immutable assets and #264 closed. Its archived plan is | WQ-04 | REQ-003, REQ-004 | Exact receipt v12/status v6, effective coverage/reporting and local no-model path, hostile/legacy tests, self-reviewed commit | done | | WQ-05 | REQ-004 | GitLab posting, finding admission, approval blocker and partial lifecycle retention with failure/freshness tests, self-reviewed commit | done | | WQ-06 | REQ-005, REQ-006, REQ-010 | Synthetic/installed OCR matrix, public contracts, threat model, decision flow, examples, Feature fragment, factual status correction, self-reviewed commit | done | -| WQ-07 | REQ-005, REQ-007 | Scoped/final quality, build, secret/dependency gates, security diff scan, real local OCR review, aggregate self-review, protected feature PR/merge, dev artifacts | pending | +| WQ-07 | REQ-005, REQ-007, REQ-011, REQ-012 | Scoped/final quality, boundary-validation process correction, native failed-tool audit, build, secret/dependency gates, security diff scan, real local OCR review, aggregate self-review, protected feature PR/merge, dev artifacts | pending | | WQ-08 | REQ-007, REQ-010 | Release markers/metadata/epoch/notes, checked plan archive/reset, release validation, protected signed release PR | pending | | WQ-09 | REQ-008 | Protected release merge, stable publication, receipt and external reconciliation | out_of_scope | @@ -100,13 +102,17 @@ Only proven OCR 1.13.3 pure-pointer projection proceeds. Unique identical-OID be The first local ChatGPT OCR review of `bdf5832..4183c1d` completed with a native complete manifest (14 selected/completed, no reuse/failure/waiver), DLP admission, and six actionable findings. This is correction evidence, not an approval of the range. The local follow-up fixes preserve a path-free selected-work proof when publication DLP removes unsafe findings, reject unhashable v12/v6 fields, remove or privately mark raw output on post-model drift, and bound Git patch output during execution. Focused runner/gitlink/coverage/status tests passed 296 with 10 environment-gated skips; the installed OCR gitlink suite passed 52/52. These edits change the reviewed range, so final quality, security diff scan and local OCR review must be repeated before push. +The signed correction commit `9f5de34` passed full quality (2369 tests, 12 skipped, 357 subtests, 86.35% total coverage), installed wheel/sdist MCP execution, real OCR gitlink tests, Gitleaks and dependency audit. Codex Security diff scan of `bdf5832..9f5de34` sealed with zero findings and no deferred files. The second local ChatGPT OCR review completed with native complete coverage (14 selected/completed, zero reuse/failure/waiver), 8 internal evidence-MCP calls and two actionable findings: post-model input read/timeouts missed result disposal, and user Git attributes could differ between temporary review HOME and normal posting HOME. The local correction now catches all three verification failure classes before/after execution, disables user attributes in the projected Git child and tests a changed HOME with hostile attributes; 63/63 real-OCR gitlink tests passed. The external governed federation was not part of this local review. No feature implementation has been pushed after the plan-only draft PR. + +Native OCR generated four invalid `file_read` requests in the first model review and one in the second (bad path or out-of-range lines). Toolkit code does not construct those requests. Its existing bounded failure telemetry records them privately, strips arguments/details from published results and blocks automatic approval for verified failures; focused telemetry tests passed 10/10. The missing operational control was accepting a local complete manifest without assessing material auxiliary read failures. The local review route now requires that assessment. A proposed bootstrap instruction was rejected: the clean installed wheel/sdist test showed that it displaced higher-priority project guidance under the existing 2,300-character cap. The canonical development validation owner now calls for a small changed-handoff map and integrated mixed/error fixtures; existing incident classes cover the observed causes. Instruction-contract validation passed. The initial quality run on the oversized-bootstrap commit failed only the installed-artifact test (2375 passed, 12 skipped, 357 subtests, 86.35% coverage); the bootstrap addition was removed before final validation. + ### Risks And Recovery Git configuration precedence or local input drift can hide ordinary changes: revalidate consulted inputs and independent diff; stop on uncertainty. False-complete receipt could launder approval/cleanup: exact parsing, original identity binding, independent approval blocker and thread retention. If installed proof fails for a proposed safe form, narrow it and test the fallback; if core mixed behavior cannot be proved, keep the plan active/blocked and do not publish. External publication failures resume only through exact-artifact documented recovery after readback. ### Resume Point -WQ-07: self-review and commit the six local OCR corrections, then repeat final quality, installed-artifact, secret/dependency, codex-security diff scan and local toolkit/OCR ChatGPT gates on the new immutable HEAD before the single final feature push. Preserve the draft feature PR's plan-only remote head until those reviews pass. +WQ-07: self-review and commit the second OCR corrections and workflow/tool-use controls, then repeat affected quality/installed-OCR, secret/dependency, codex-security diff scan and local toolkit/OCR ChatGPT gates on the new immutable HEAD before the single final feature push. Preserve the draft feature PR's plan-only remote head until those reviews pass. ### Plan Fidelity Check diff --git a/docs/codex/AGENT_EXECUTION_PITFALLS.md b/docs/codex/AGENT_EXECUTION_PITFALLS.md index 257c01b..ddc7f85 100644 --- a/docs/codex/AGENT_EXECUTION_PITFALLS.md +++ b/docs/codex/AGENT_EXECUTION_PITFALLS.md @@ -168,7 +168,7 @@ This is a diagnostic catalogue of recurring incident classes. Current requiremen - Owner: docs/engineering/project_principles.md#outcome-consistency - Route: trust-boundary - Guard: test:scripts/quality.sh check -- Evidence: [0.4.7 final OCR correction](../engineering/execution_history/releases.md#plan-toolkit-0-4-7). Prior control: table-driven result and posting tests cover skipped, clean, warning, error, finding, partial, and zero-value cases through shared contracts. Historical cause: outcomes were assembled independently and tests asserted prose rather than one result invariant. +- Evidence: [0.4.7 final OCR correction](../engineering/execution_history/releases.md#plan-toolkit-0-4-7) and the v0.13.1 gitlink review's safe-plus-filtered finding case in `tests/test_repository_coverage.py`. Prior control: table-driven result and posting tests cover skipped, clean, warning, error, finding, partial, and zero-value cases through shared contracts. Historical cause: outcomes were assembled independently and tests asserted prose rather than one result invariant; the v0.13.1 first review found that producer and consumer tests still missed a transformed handoff. - Status: guarded - Retirement: Retire when this historical class no longer improves diagnosis and its owner, route, and guard remain established. diff --git a/docs/development.md b/docs/development.md index 6b89f3a..0f0cc87 100644 --- a/docs/development.md +++ b/docs/development.md @@ -183,6 +183,17 @@ The maintained [test evidence matrix](engineering/test_evidence_matrix.md) recor Treat one confirmed boundary or parser defect as a risk class: inspect sibling implementations, make negative tests reach the intended rejection or degradation branch, and assert that contract rather than an unrelated earlier failure. Before implementing a new parser or trust boundary, record its grammar, normalization, degradation, budget units, inherited-process state, and adversarial fixtures in the active plan or focused tests. +For a change crossing more than one execution owner, map the changed producer, +each transformation or persisted handoff, and the final consumer before the +first complete quality gate. Name the evidence carried across each edge and +what happens after a partial, filtered, malformed, or failed result. Exercise +the changed edge through its real owner with a mixed success/failure fixture; +also check cleanup after external work, hostile wire types, and process-local +inputs where they can change that edge. Review the observed consumer result and +remaining private artifacts, not only each producer's unit result. Keep this +small map in the active plan or focused test cases; do not create a standing +checklist for unchanged branches. + New runtime modules, classes, and functions need purpose-focused docstrings. Comments at non-obvious security, compatibility, ownership, and state-transition boundaries explain why the constraint exists rather than narrating the code. Do not add legacy namespace shims or historical integrations outside the public contract. Apply the [cohesive-module invariant](engineering/project_principles.md#product-and-architecture) during self-review. Prefer an extract-and-delegate refactor that moves already characterized functions or classes intact, preserves the intentional package facade, and reruns the same contract suite before and after each move. Split on distinct responsibility and dependency direction, not an arbitrary line count; do not rewrite a working algorithm merely to make a file shorter. Architecture tests should protect required owners and forbidden upward dependencies without freezing every future helper-module name. diff --git a/docs/local.md b/docs/local.md index 3470949..a370a56 100644 --- a/docs/local.md +++ b/docs/local.md @@ -38,8 +38,12 @@ an OCR JSON file alone does not establish successful local delivery. After a zero exit, read the complete private Markdown and JSON: require `status=complete`, compare selected/completed/reused/failed/waived coverage, inspect DLP admission and every admitted finding, and inspect stderr for failed auxiliary tool reads that -could limit the conclusion. Preserve these artifacts until findings are fixed -and the corrected head has been reviewed. +could limit the conclusion. For each failed `file_read`, compare its path and +range with the reviewed tree and decide whether the missed context bears on +the changed code or a finding. A native complete manifest proves selected-file +completion, not that every auxiliary read succeeded; resolve a material gap +before accepting the local review. Preserve these artifacts until findings are +fixed and the corrected head has been reviewed. For a range, replace `--commit HEAD` with `--from BASE --to HEAD`. Both ends are resolved to immutable commits before execution. Working-tree snapshots, repository diff --git a/docs/security.md b/docs/security.md index 6266d36..55a5321 100644 --- a/docs/security.md +++ b/docs/security.md @@ -42,6 +42,8 @@ trees, the explicit unfiltered diff, and the ordinary-file projection before the model runs. It reads `.gitmodules` from immutable blobs without using its URLs, freezes process-local Git overrides for preview and production, then checks the native manifest against the ordinary files selected by preview. +User and XDG Git attributes are disabled for this projection; repository +attributes remain input-bound across review and posting. Type transitions, ambiguous moves, unsafe configuration, unknown diagnostics, drift, reuse, or mismatched manifest evidence stop the review. Neither the internal evidence MCP nor an external MCP server can grant review coverage for diff --git a/src/ocr_toolkit/evidence/gitlink_projection.py b/src/ocr_toolkit/evidence/gitlink_projection.py index a0cea40..22c609f 100644 --- a/src/ocr_toolkit/evidence/gitlink_projection.py +++ b/src/ocr_toolkit/evidence/gitlink_projection.py @@ -170,7 +170,12 @@ def _projection_environment(plan: GitlinkInputPlan) -> dict[str, str]: "LC_ALL": "C", } ) - overrides = [("diff.ignoreSubmodules", "all")] + overrides = [ + ("diff.ignoreSubmodules", "all"), + # OCR uses a temporary HOME, while posting uses the operator's HOME. + # Keep user/XDG attributes out of both Git projections. + ("core.attributesFile", os.devnull), + ] for name in plan.config_names: if _SAFE_CONFIG_NAME.fullmatch(name) is None or ".." in name or name.endswith("/"): raise RepositoryEvidenceError(".gitmodules name is unsafe for OCR Git config") diff --git a/src/ocr_toolkit/review_runner.py b/src/ocr_toolkit/review_runner.py index 9ed00dc..682ee72 100644 --- a/src/ocr_toolkit/review_runner.py +++ b/src/ocr_toolkit/review_runner.py @@ -1707,7 +1707,7 @@ def _verify_gitlink_after_execution( try: verify_gitlink_projection(GitRepositoryReader(Path.cwd()), projection) - except RepositoryEvidenceError as exc: + except (RepositoryEvidenceError, OSError, subprocess.TimeoutExpired) as exc: if preserve_authorized: _retain_private_diagnostic_result(result_path, reason="gitlink-input-drift") else: @@ -2331,7 +2331,7 @@ def _run_background_qualified_review( return 2, qualification try: verify_gitlink_projection(GitRepositoryReader(Path.cwd()), projection) - except RepositoryEvidenceError as exc: + except (RepositoryEvidenceError, OSError, subprocess.TimeoutExpired) as exc: raise ReviewRunnerError("OCR gitlink inputs drifted before model execution") from exc if state is not None: state.enter("subprocess") diff --git a/tests/test_gitlinks.py b/tests/test_gitlinks.py index 3411db1..33a2bd9 100644 --- a/tests/test_gitlinks.py +++ b/tests/test_gitlinks.py @@ -240,13 +240,47 @@ def test_projection_scrubs_inherited_git_overrides( monkeypatch.setenv("GIT_EXTERNAL_DIFF", "/outside/diff") projection = make_gitlink_projection(reader, plan) env = projection.child_environment() - assert env["GIT_CONFIG_COUNT"] == "1" + assert env["GIT_CONFIG_COUNT"] == "2" assert env["GIT_CONFIG_KEY_0"] == "diff.ignoreSubmodules" assert env["GIT_CONFIG_VALUE_0"] == "all" + assert env["GIT_CONFIG_KEY_1"] == "core.attributesFile" + assert env["GIT_CONFIG_VALUE_1"] == os.devnull assert "GIT_WORK_TREE" not in env and "GIT_EXTERNAL_DIFF" not in env assert [entry.path for entry in projection.ordinary_diff] == ["main.php"] +def test_projection_ignores_user_attributes_when_home_changes( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + base, head = _fixture(tmp_path, "updated") + reader = GitRepositoryReader(tmp_path) + plan = plan_gitlink_input(reader, base, head) + assert plan is not None + review_home = tmp_path / "review-home" + review_home.mkdir() + posting_home = tmp_path / "posting-home" + attributes = posting_home / ".config" / "git" / "attributes" + attributes.parent.mkdir(parents=True) + attributes.write_text("*.php -diff\n", encoding="utf-8") + monkeypatch.delenv("XDG_CONFIG_HOME", raising=False) + monkeypatch.setenv("HOME", str(posting_home)) + observed = subprocess.run( + ["git", "check-attr", "diff", "--", "main.php"], + cwd=tmp_path, + capture_output=True, + check=True, + ) + assert observed.stdout.strip() == b"main.php: diff: unset" + + monkeypatch.setenv("HOME", str(review_home)) + projection = make_gitlink_projection(reader, plan) + monkeypatch.setenv("HOME", str(posting_home)) + rebuilt = make_gitlink_projection(reader, plan) + assert rebuilt.fingerprints == projection.fingerprints + assert rebuilt.line_counts == projection.line_counts + verify_gitlink_projection(reader, projection) + + def test_projection_uses_exact_slash_qualified_gitmodules_name(tmp_path: Path) -> None: _git(tmp_path, "init", "-q") _git(tmp_path, "config", "user.name", "Synthetic Reviewer") @@ -269,7 +303,7 @@ def test_projection_uses_exact_slash_qualified_gitmodules_name(tmp_path: Path) - plan = plan_gitlink_input(reader, base, head) assert plan is not None and plan.config_names == ("vendor/schema",) projection = make_gitlink_projection(reader, plan) - assert projection.child_environment()["GIT_CONFIG_KEY_1"] == "submodule.vendor/schema.ignore" + assert projection.child_environment()["GIT_CONFIG_KEY_2"] == "submodule.vendor/schema.ignore" def test_gitlink_only_projection_has_no_selected_work(tmp_path: Path) -> None: @@ -353,9 +387,17 @@ def fake_run(argv: list[str], **kwargs: object) -> subprocess.CompletedProcess[b assert list(home.iterdir()) == [] -@pytest.mark.parametrize("drift", [False, True]) +@pytest.mark.parametrize( + "failure", + [ + None, + RepositoryEvidenceError("synthetic drift"), + OSError("synthetic local input failure"), + subprocess.TimeoutExpired(["git", "diff"], 30), + ], +) def test_runner_rechecks_projection_before_using_frozen_child_environment( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch, drift: bool + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, failure: Exception | None ) -> None: repo = tmp_path / "repo" repo.mkdir() @@ -376,8 +418,8 @@ def qualify(*_args: object, **_kwargs: object) -> review_runner.BackgroundQualif def verify(*_args: object) -> None: calls.append(("recheck", projection)) - if drift: - raise RepositoryEvidenceError("synthetic drift") + if failure is not None: + raise failure def production(*_args: object, **kwargs: object) -> int: calls.append(("production", kwargs.get("child_environment"))) @@ -397,7 +439,7 @@ def production(*_args: object, **kwargs: object) -> int: ) monkeypatch.chdir(repo) args = ["--from", base, "--to", head, "--format", "json"] - if drift: + if failure is not None: with pytest.raises(review_runner.ReviewRunnerError, match="drifted before"): review_runner._run_background_qualified_review( tmp_path / "result.json", @@ -432,8 +474,19 @@ def production(*_args: object, **kwargs: object) -> int: @pytest.mark.parametrize("preserve_authorized", [False, True]) +@pytest.mark.parametrize( + "failure", + [ + RepositoryEvidenceError("synthetic post-model drift"), + OSError("synthetic local input failure"), + subprocess.TimeoutExpired(["git", "diff"], 30), + ], +) def test_post_model_gitlink_drift_disposes_or_marks_raw_result( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch, preserve_authorized: bool + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + preserve_authorized: bool, + failure: Exception, ) -> None: base, head = _fixture(tmp_path, "updated") reader = GitRepositoryReader(tmp_path) @@ -445,7 +498,7 @@ def test_post_model_gitlink_drift_disposes_or_marks_raw_result( monkeypatch.chdir(tmp_path) def drift(*_args: object) -> None: - raise RepositoryEvidenceError("synthetic post-model drift") + raise failure monkeypatch.setattr(review_runner, "verify_gitlink_projection", drift) with pytest.raises(review_runner.ReviewRunnerError, match="drifted during"): From 1770a0cf077b8d2b6b03b38079d30c56206f7c2c Mon Sep 17 00:00:00 2001 From: xeonvs <11463419+xeonvs@users.noreply.github.com> Date: Fri, 23 Oct 2026 23:05:04 +0200 Subject: [PATCH 8/8] Guard gitlink Git inputs and local report destination --- PLANS.md | 6 ++- docs/codex/AGENT_EXECUTION_PITFALLS.md | 2 +- docs/local.md | 8 ++-- .../evidence/gitlink_projection.py | 7 +-- src/ocr_toolkit/providers/local.py | 4 ++ tests/providers/test_local_artifact.py | 44 ++++++++++++++++++- tests/test_gitlinks.py | 32 ++++++++++++++ 7 files changed, 92 insertions(+), 11 deletions(-) diff --git a/PLANS.md b/PLANS.md index 6e63012..02e1f76 100644 --- a/PLANS.md +++ b/PLANS.md @@ -82,7 +82,7 @@ v0.13.0 is published with immutable assets and #264 closed. Its archived plan is | WQ-06 | REQ-005, REQ-006, REQ-010 | Synthetic/installed OCR matrix, public contracts, threat model, decision flow, examples, Feature fragment, factual status correction, self-reviewed commit | done | | WQ-07 | REQ-005, REQ-007, REQ-011, REQ-012 | Scoped/final quality, boundary-validation process correction, native failed-tool audit, build, secret/dependency gates, security diff scan, real local OCR review, aggregate self-review, protected feature PR/merge, dev artifacts | pending | | WQ-08 | REQ-007, REQ-010 | Release markers/metadata/epoch/notes, checked plan archive/reset, release validation, protected signed release PR | pending | -| WQ-09 | REQ-008 | Protected release merge, stable publication, receipt and external reconciliation | out_of_scope | +| WQ-09 | REQ-008 | Protected release merge, stable publication, receipt and external reconciliation | pending | ### Locked Decisions @@ -106,13 +106,15 @@ The signed correction commit `9f5de34` passed full quality (2369 tests, 12 skipp Native OCR generated four invalid `file_read` requests in the first model review and one in the second (bad path or out-of-range lines). Toolkit code does not construct those requests. Its existing bounded failure telemetry records them privately, strips arguments/details from published results and blocks automatic approval for verified failures; focused telemetry tests passed 10/10. The missing operational control was accepting a local complete manifest without assessing material auxiliary read failures. The local review route now requires that assessment. A proposed bootstrap instruction was rejected: the clean installed wheel/sdist test showed that it displaced higher-priority project guidance under the existing 2,300-character cap. The canonical development validation owner now calls for a small changed-handoff map and integrated mixed/error fixtures; existing incident classes cover the observed causes. Instruction-contract validation passed. The initial quality run on the oversized-bootstrap commit failed only the installed-artifact test (2375 passed, 12 skipped, 357 subtests, 86.35% coverage); the bootstrap addition was removed before final validation. +The signed `f65d519` correction passed full quality, installed wheel/sdist execution, real OCR gitlink tests, secret and dependency gates. Codex Security completed `bdf5832..f65d519` with zero findings. A third local ChatGPT OCR run completed natively with 14/14 selected files and six internal evidence-MCP calls; its JSON identified two valid Git input gaps: a BOM-prefixed local config include and direct `config.worktree`. The local Markdown delivery failed because the operator supplied a symlinked `/tmp` report parent, repeating the v0.12.1 mistake despite the documented physical-path example. The current correction pins and rejects those Git inputs, and rejects unsafe local report parents before model execution while rechecking at publication. New tests cover real Git parsing, input drift, pre-model rejection, and the host's actual `/tmp` alias state. The checksum-verified real OCR suite passed 65/65; local artifact tests passed 20/20; full quality passed 2380 tests with 12 environment-gated skips, 357 subtests and 86.35% total coverage. This third review is finding evidence, not an accepted final review; a corrected physical-path local review remains required. + ### Risks And Recovery Git configuration precedence or local input drift can hide ordinary changes: revalidate consulted inputs and independent diff; stop on uncertainty. False-complete receipt could launder approval/cleanup: exact parsing, original identity binding, independent approval blocker and thread retention. If installed proof fails for a proposed safe form, narrow it and test the fallback; if core mixed behavior cannot be proved, keep the plan active/blocked and do not publish. External publication failures resume only through exact-artifact documented recovery after readback. ### Resume Point -WQ-07: self-review and commit the second OCR corrections and workflow/tool-use controls, then repeat affected quality/installed-OCR, secret/dependency, codex-security diff scan and local toolkit/OCR ChatGPT gates on the new immutable HEAD before the single final feature push. Preserve the draft feature PR's plan-only remote head until those reviews pass. +WQ-07: self-review and commit the third OCR corrections and early local report-path guard. Run affected quality and installed-OCR tests, staged secret check, and focused security/local OCR review of the correction delta with a physical private output path. Do not start another full-range model scan unless a concrete new finding requires it. Reconcile the complete reviewed range, then perform the single final feature push; the draft PR's remote head stays plan-only until these gates pass. ### Plan Fidelity Check diff --git a/docs/codex/AGENT_EXECUTION_PITFALLS.md b/docs/codex/AGENT_EXECUTION_PITFALLS.md index ddc7f85..e9a22a1 100644 --- a/docs/codex/AGENT_EXECUTION_PITFALLS.md +++ b/docs/codex/AGENT_EXECUTION_PITFALLS.md @@ -180,6 +180,6 @@ This is a diagnostic catalogue of recurring incident classes. Current requiremen - Owner: docs/local.md#review-preparation - Route: local-ocr-review - Guard: manual_review:verify selected auth mode qualified executable native model reachability supported local flags physical private paths terminal status report and admitted findings -- Evidence: The 2026-10-23 v0.13.0 local review attempts rejected absent `OCR_LLM_MODEL` and `--background-file` before model execution; the corrected OCR 1.13.3 invocation completed with 29/29 selected items and an admitted finding. The earlier [v0.12.1 review](../engineering/execution_history/releases.md#plan-toolkit-0-12-1) exposed the symlinked-path report failure. The preparation route now names each check at the launch boundary. +- Evidence: The 2026-10-23 v0.13.0 local review attempts rejected absent `OCR_LLM_MODEL` and `--background-file` before model execution; the corrected OCR 1.13.3 invocation completed with 29/29 selected items and an admitted finding. The [v0.12.1 review](../engineering/execution_history/releases.md#plan-toolkit-0-12-1) and v0.13.1 review both exposed symlinked-path report failures. The v0.13.1 runner now validates the report parent before model execution and the publisher checks again after execution; `docs/local.md` shows physical-path preparation. - Status: guarded - Retirement: Retire when the owner, route, and guard remain established and the historical class no longer improves launch diagnosis. diff --git a/docs/local.md b/docs/local.md index a370a56..5291b45 100644 --- a/docs/local.md +++ b/docs/local.md @@ -31,9 +31,11 @@ ocr-ci review --local --result "$review_dir/review.json" --stderr "$review_dir/r ``` Keep all three output paths in that new private directory. Resolve its physical -path before launch: on macOS `/tmp` commonly points through a symlink to -`/private/tmp`, and the report writer rejects a symlinked parent after OCR has -already run. Check the report path is fresh and wait for the `ocr-ci` exit status; +path before launch. `/tmp` can be a symlink on macOS or Linux, while a mounted +directory can have no symlink at all. The runner checks the actual path +components and rejects symlinks and parent-opening errors before OCR starts. +It reopens the destination directory when publishing the report. Check the +report path is fresh and wait for the `ocr-ci` exit status; an OCR JSON file alone does not establish successful local delivery. After a zero exit, read the complete private Markdown and JSON: require `status=complete`, compare selected/completed/reused/failed/waived coverage, inspect DLP admission and diff --git a/src/ocr_toolkit/evidence/gitlink_projection.py b/src/ocr_toolkit/evidence/gitlink_projection.py index 22c609f..ee22ea4 100644 --- a/src/ocr_toolkit/evidence/gitlink_projection.py +++ b/src/ocr_toolkit/evidence/gitlink_projection.py @@ -31,7 +31,7 @@ _DIFF_ENTRY_LIMIT = 20_000 _GITLINK_SELECTION_SCHEMA = "ocr.gitlink-selection/v1" _LOCAL_INPUTS = (".gitignore", ".gitmodules", ".gitattributes") -_GIT_METADATA_INPUTS = ("config", "info/attributes", "info/exclude") +_GIT_METADATA_INPUTS = ("config", "config.worktree", "info/attributes", "info/exclude") _PREVIEW_REASONS = frozenset( { "user_exclude", @@ -142,8 +142,9 @@ def _input_digest(root: Path, plan: GitlinkInputPlan) -> str: digest.update(b"\0") digest.update(b"M" if payload is None else b"P") if payload is not None: - if path == git_dir / "config" and re.search( - rb"(?im)^\s*\[\s*include(?:if)?(?:\s|\])", payload + if path in {git_dir / "config", git_dir / "config.worktree"} and re.search( + rb"(?im)^\s*\[\s*include(?:if)?(?:\s|\])", + payload.removeprefix(b"\xef\xbb\xbf"), ): raise RepositoryEvidenceError("OCR local Git config includes are unsupported") digest.update(hashlib.sha256(payload).digest()) diff --git a/src/ocr_toolkit/providers/local.py b/src/ocr_toolkit/providers/local.py index 5a02eaf..b33e89e 100644 --- a/src/ocr_toolkit/providers/local.py +++ b/src/ocr_toolkit/providers/local.py @@ -125,6 +125,10 @@ def prepare_local_report_path(path: Path, *, other_outputs: tuple[Path, ...]) -> raise ValueError("local report path must differ from result and stderr") if path.is_symlink() or path.exists(): raise ValueError("local report path must be fresh") + # Catch symlinked ancestors before model execution. Publication opens and + # pins the then-current parent again before writing. + parent_descriptor, _ = open_private_parent_directory(path) + os.close(parent_descriptor) def publish_local_report(report: ReviewReport, path: Path) -> None: diff --git a/tests/providers/test_local_artifact.py b/tests/providers/test_local_artifact.py index 5965342..55d765c 100644 --- a/tests/providers/test_local_artifact.py +++ b/tests/providers/test_local_artifact.py @@ -2,6 +2,7 @@ import io import os +import tempfile from pathlib import Path import pytest @@ -51,6 +52,40 @@ def test_parent_symlink_is_rejected(tmp_path: Path) -> None: local.publish_local_report(failed_report("identity"), parent / "report.md") +def test_parent_symlink_is_rejected_before_model_execution( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + parent = tmp_path / "link" + parent.symlink_to(tmp_path, target_is_directory=True) + monkeypatch.setattr( + review_runner, "_run_evidence_review", lambda *_a, **_kw: pytest.fail("model ran") + ) + with pytest.raises(review_runner.ReviewRunnerError, match="destination"): + review_runner.run_evidence_review( + tmp_path / "result.json", + tmp_path / "stderr.log", + [], + local=True, + report_path=parent / "report.md", + ) + assert not (tmp_path / "report.md").exists() + + +def test_tmp_alias_follows_host_filesystem_state() -> None: + if not Path("/tmp").is_dir(): + pytest.skip("host has no /tmp directory") + with tempfile.TemporaryDirectory(prefix="ocr-report-parent-", dir="/tmp") as directory: + alias = Path(directory) / "report.md" + physical = Path(directory).resolve() / "report.md" + if alias.absolute() != physical: + with pytest.raises(ValueError, match="parent"): + local.prepare_local_report_path(alias, other_outputs=()) + else: + local.prepare_local_report_path(alias, other_outputs=()) + local.publish_local_report(failed_report("identity"), physical) + assert physical.is_file() + + def test_partial_render_is_never_published(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: def fail(*_args: object) -> None: raise OSError("write failed") @@ -94,15 +129,20 @@ def test_parent_replacement_cannot_redirect_private_report( replacement = tmp_path / "replacement" replacement.mkdir() original = local.open_private_parent_directory + calls = 0 def replace_after_open(path: Path) -> tuple[int, str]: + nonlocal calls + calls += 1 descriptor, name = original(path) - parent.rename(tmp_path / "moved") - parent.symlink_to(replacement, target_is_directory=True) + if calls == 2: + parent.rename(tmp_path / "moved") + parent.symlink_to(replacement, target_is_directory=True) return descriptor, name monkeypatch.setattr(local, "open_private_parent_directory", replace_after_open) local.publish_local_report(failed_report("identity"), destination) + assert calls == 2 assert (tmp_path / "moved" / "report.md").is_file() assert not (replacement / "report.md").exists() diff --git a/tests/test_gitlinks.py b/tests/test_gitlinks.py index 33a2bd9..7aa4688 100644 --- a/tests/test_gitlinks.py +++ b/tests/test_gitlinks.py @@ -576,6 +576,38 @@ def test_projection_rejects_local_git_config_include(tmp_path: Path) -> None: make_gitlink_projection(reader, plan) +def test_projection_rejects_bom_prefixed_local_git_config_include(tmp_path: Path) -> None: + base, head = _fixture(tmp_path, "updated") + reader = GitRepositoryReader(tmp_path) + plan = plan_gitlink_input(reader, base, head) + assert plan is not None + git_dir = tmp_path / ".git" + original = (git_dir / "config").read_bytes() + (git_dir / "included.conf").write_text("[core]\n abbrev = 7\n", encoding="utf-8") + (git_dir / "config").write_bytes(b"\xef\xbb\xbf[include]\n path = included.conf\n" + original) + assert _git(tmp_path, "config", "--local", "--includes", "--get", "core.abbrev") == "7" + with pytest.raises(RepositoryEvidenceError, match="includes"): + make_gitlink_projection(reader, plan) + + +def test_projection_pins_and_rejects_worktree_config(tmp_path: Path) -> None: + base, head = _fixture(tmp_path, "updated") + _git(tmp_path, "config", "extensions.worktreeConfig", "true") + worktree_config = tmp_path / ".git" / "config.worktree" + worktree_config.write_text("[core]\n abbrev = 7\n", encoding="utf-8") + reader = GitRepositoryReader(tmp_path) + plan = plan_gitlink_input(reader, base, head) + assert plan is not None + assert _git(tmp_path, "config", "--worktree", "--get", "core.abbrev") == "7" + projection = make_gitlink_projection(reader, plan) + worktree_config.write_text("[core]\n abbrev = 8\n", encoding="utf-8") + with pytest.raises(RepositoryEvidenceError, match="drifted"): + verify_gitlink_projection(reader, projection) + worktree_config.write_text("[include]\n path = missing.conf\n", encoding="utf-8") + with pytest.raises(RepositoryEvidenceError, match="includes"): + make_gitlink_projection(reader, plan) + + def test_projection_rejects_changed_symlink_with_gitlink(tmp_path: Path) -> None: _git(tmp_path, "init", "-q") _git(tmp_path, "config", "user.name", "Synthetic Reviewer")