From 9e4b9138e9fae028d9efb46087ad308b7fec87cf Mon Sep 17 00:00:00 2001
From: xeonvs <11463419+xeonvs@users.noreply.github.com>
Date: Tue, 11 Aug 2026 18:49:37 +0200
Subject: [PATCH 01/11] Start M2 framework coverage
From 6a641fc287eb3d8059d7ae08908451079bdb0440 Mon Sep 17 00:00:00 2001
From: xeonvs <11463419+xeonvs@users.noreply.github.com>
Date: Tue, 11 Aug 2026 18:50:12 +0200
Subject: [PATCH 02/11] Activate M2 framework coverage
---
.next-version | 2 +-
PLANS.md | 92 ++++++++++++++++++++++++++++++++++++++-
ROADMAP.md | 7 +--
changelog.d/76.bugfix.md | 1 +
changelog.d/77.feature.md | 1 +
changelog.d/77.rules.md | 1 +
6 files changed, 98 insertions(+), 6 deletions(-)
create mode 100644 changelog.d/76.bugfix.md
create mode 100644 changelog.d/77.feature.md
create mode 100644 changelog.d/77.rules.md
diff --git a/.next-version b/.next-version
index cb498ab..8f0916f 100644
--- a/.next-version
+++ b/.next-version
@@ -1 +1 @@
-0.4.8
+0.5.0
diff --git a/PLANS.md b/PLANS.md
index a85a9f6..ecfa3a5 100644
--- a/PLANS.md
+++ b/PLANS.md
@@ -2,9 +2,97 @@
Use this file for active, blocked, or recently completed execution work. Update it before implementation and before handoff or commit. Older completed plans are indexed in [the execution-history archive](docs/engineering/execution_history/README.md).
-## Active Plan: Harden GitLab suggestions and add SHA-bound approval for 0.4.7
+## Active Plan: M2 ecosystem and framework coverage for 0.5.0
-Status: active; final release inputs locally validated; release PR and stable delivery pending
+Status: active; implementation checkpoints in progress
+Owner: Codex
+Last Updated: 2026-08-11
+Release Classification: release-required
+Target Stable Version: 0.5.0
+Tracking Issues: #76; feature PR #77
+
+### Goal
+
+Deliver M2 as stable toolkit 0.5.0 with bounded static framework plugins for
+Jinja2, Go web frameworks, Symfony/PHP, and React/TypeScript. Make Jinja and
+Twig template files actually reviewable by the recommended OCR through the
+public synthetic rules pack, expose framework/template state and deltas through
+the existing read-only evidence MCP, and repair issue #76 release recovery.
+Complete one synthetic installed-artifact E2E, then exactly one final local OCR
+review without GitLab posting, before protected feature and stable delivery.
+
+### Decisions
+
+- Release-required target is 0.5.0. Keep this plan active through feature PR,
+ TestPyPI development verification, final release PR, stable registries,
+ provenance, annotated tag, immutable Release, receipt, supported-Python
+ installs, and completed closure of issue #76.
+- Use only anonymized technology selection conclusions from the private
+ inventory. Never persist private host, project, namespace, path, payload, or
+ identifying aggregate data; all public fixtures and examples are synthetic.
+- Add a static package-owned plugin registry. Plugins consume immutable bounded
+ normalized evidence only and cannot load repository code, execute commands,
+ use network access, mutate repositories, or start a second MCP/review flow.
+- Preserve one built-in `ocr_toolkit_evidence` MCP with summary/list/get. Store
+ validated framework and template records before OCR starts; MCP performs no
+ plugin collection at request time.
+- Make `.j2`, `.jinja`, `.jinja2`, extensionless Ansible-role templates, and
+ `.twig` files reviewable through explicit additive `include` patterns plus
+ specific merged rules in `examples/gitlab/rules.json`.
+- After complete E2E, run exactly one final local OCR 1.9.1 review over the M2
+ change range, require real evidence-MCP use, fix actionable findings, and use
+ only deterministic validation afterward.
+
+### Work Queue
+
+1. [x] Reconcile the externally completed 0.4.7 starting point, create the M2
+ branch and draft feature PR #77, activate release-required 0.5.0 planning,
+ and set the next development line to 0.5.0.
+2. [ ] Repair issue #76 draft-Release identity, canonical issue-comment
+ newline, and idempotent skipped-publisher recovery with synthetic tests.
+3. [ ] Implement the bounded static plugin protocol, manifest-root components,
+ closed framework/template records, limits, coverage, and MCP/store contracts.
+4. [ ] Implement Jinja2 dependency and Jinja/Ansible-template evidence plus the
+ additive Jinja rules pack.
+5. [ ] Implement direct Echo/Fiber evidence and conservative related gRPC data.
+6. [ ] Implement Symfony/Twig dependency, configuration, template, and rules
+ evidence.
+7. [ ] Implement React/Next framework evidence with TypeScript/Vite related
+ signals and npm/Yarn/pnpm resolution.
+8. [ ] Complete cross-provider deltas, coverage, bootstrap/MCP projections,
+ documentation, strategy, roadmap, backlog, and milestone reconciliation.
+9. [ ] Run complete Python 3.12-3.14, security, privacy, package, installed
+ artifact, rules-preview, real-MCP-client, and synthetic no-post E2E gates.
+10. [ ] Run exactly one final local full OCR review, fix findings, then finish
+ deterministic validation, Gitleaks, self-review, feature PR checks/threads,
+ merge, and TestPyPI development readback.
+11. [ ] Prepare the final release PR as the last repository mutation, archive
+ completed 0.4.7 history, consume fragments 76 and 77, and reconcile M2 to
+ implemented truth while leaving external publication pending.
+12. [ ] Complete stable 0.5.0 TestPyPI/PyPI, provenance/hash/tag/immutable
+ Release/receipt/Python-install readback and close #76 as completed without
+ another repository PR.
+
+### Initial Evidence
+
+- Clean synchronized `main` was exact annotated tag `v0.4.7` at
+ `3caa50b4fc5026da79c7f2ceae1deef31715f814`; stable 0.4.7 is externally
+ complete, while the retained plan below still records its former pending
+ pre-publication state.
+- OCR 1.9.1 resolves a custom Jinja rule but excludes `.j2` as
+ `unsupported_ext`; adding an explicit `include` pattern makes preview select
+ it. `.j2`, `.jinja`, `.jinja2`, and `.twig` are absent from OCR 1.9.1's
+ built-in extension allowlist.
+- Existing dependency parsers already expose direct declarations and lock facts
+ for Python, Go, Composer, npm, Yarn, and pnpm. M2 adds interpretation,
+ component scoping, template inventory, explicit completeness, and review
+ selection rather than duplicating those parsers.
+- Draft feature PR #77 supplies the real Towncrier identifier for M2 feature
+ and rules fragments. Issue #76 remains open until immutable stable delivery.
+
+## Recently Completed Plan: Harden GitLab suggestions and add SHA-bound approval for 0.4.7
+
+Status: completed; stable 0.4.7 externally delivered and independently read back
Owner: Codex
Last Updated: 2026-08-11
Release Classification: release-required
diff --git a/ROADMAP.md b/ROADMAP.md
index aad29fe..59e34e9 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -9,7 +9,7 @@ The diagram uses the same statuses as colors: green for established, blue for ne
flowchart LR
M0["M0 Foundation
established"] --> M1["M1 Evidence architecture
established"]
M0 --> M3["M3 External MCP hardening
next / planned"]
- M1 --> M2["M2 Remaining evidence and frameworks
planned"]
+ M1 --> M2["M2 Ecosystem and framework coverage
in progress"]
M1 --> M4["M4 Policy and project guidance
planned"]
M1 --> M5["M5 Measurement audit and profiles
planned"]
M5 --> M6["M6 Later and conditional work
conditional"]
@@ -21,7 +21,8 @@ flowchart LR
class M0,M1 established
class M3 next
- class M2,M4,M5 planned
+ class M4,M5 planned
+ class M2 next
class M6 conditional
```
@@ -29,7 +30,7 @@ flowchart LR
| --- | --- | --- | --- | --- |
| M0 Foundation | Established | Durable planning sources, high-signal repository security checks, and repeatable OCR compatibility policy. | Existing CI and the current recommended/tested OCR baseline. | Strategy, roadmap, and backlog agree; Bandit is a bounded repository gate; every unseen stable OCR release receives checksum-verified machine evidence with adjacent comparison identity; only a wholly safe contiguous patch chain may receive one protected bot-ready update patch, while material or ambiguous changes require human qualification and no path writes directly to `main`. |
| M1 Evidence architecture | Established | One bounded evidence model supplies a compact bootstrap and built-in read-only MCP. | Machine-readable OCR capabilities and current context contracts. | Stable v0.4.0 publishes the model, immutable snapshots, typed deltas, bounded private storage, compact bootstrap, built-in MCP, semantic parity/removal, verified real-OCR use, reporting outcomes, and security hardening; TestPyPI/PyPI artifacts, provenance, hashes, annotated tag, immutable GitHub Release, and supported-Python smoke installs are independently verified. |
-| M2 Ecosystem and framework coverage | Planned | Close demonstrated evidence resolution, precedence, completeness, or component-scope gaps and select framework plugins from actual use. | Established evidence model and snapshot/delta semantics; item-specific gaps only. | Remaining formats and selected framework plugins have deterministic fixtures, bounds, provenance, completeness, and source/target deltas without restating established collectors. |
+| M2 Ecosystem and framework coverage | In progress | Close demonstrated evidence resolution, precedence, completeness, or component-scope gaps and select framework plugins from actual use. | Established evidence model and snapshot/delta semantics; item-specific gaps only. | Remaining formats and selected framework plugins have deterministic fixtures, bounds, provenance, completeness, and source/target deltas without restating established collectors. |
| M3 External MCP hardening | Next / planned | Threat-model external references and validate provider-specific read-only examples on the established built-in/external MCP composition boundary. | Existing external MCP and built-in composition for current generic operation; BL-011 before reference detection or provider examples. | Threat model precedes reference detection and provider examples; synthetic YouTrack, Confluence, or documentation examples preserve narrow read-only tools, reserved namespaces, and trust separation. Managed OAuth remains conditional on a named provider requirement. |
| M4 Policy and project guidance | Planned | Supply relevant target-branch decisions and guidance without allowing self-whitelisting. | Evidence scoping and target/source snapshots. | Semi-structured decisions remain backward compatible; guidance paths and hints are bounded, target-derived, and non-authoritative. |
| M5 Profiles and quality measurement | Planned | Audit current OCR telemetry and result-derived review signals before adding profiles or any toolkit metrics. | Established result, discussion, coverage, posting, and MCP-use receipts; the owner-approved matrix is required only for profile implementation. | The audit either proves current bounded reporting sufficient or isolates a separately scoped provider-neutral gap; any later profiles are deterministic and documented without sensitive, high-cardinality, or duplicate data. |
diff --git a/changelog.d/76.bugfix.md b/changelog.d/76.bugfix.md
new file mode 100644
index 0000000..d00f334
--- /dev/null
+++ b/changelog.d/76.bugfix.md
@@ -0,0 +1 @@
+Make stable release recovery idempotent across private draft Releases, already-published registry artifacts, and exact issue-receipt comment readback.
diff --git a/changelog.d/77.feature.md b/changelog.d/77.feature.md
new file mode 100644
index 0000000..82b4623
--- /dev/null
+++ b/changelog.d/77.feature.md
@@ -0,0 +1 @@
+Add bounded framework and template evidence plugins for Jinja2, Go web frameworks, Symfony/PHP, and React/TypeScript through the built-in evidence MCP.
diff --git a/changelog.d/77.rules.md b/changelog.d/77.rules.md
new file mode 100644
index 0000000..2b2314a
--- /dev/null
+++ b/changelog.d/77.rules.md
@@ -0,0 +1 @@
+Make Jinja and Twig templates reviewable through explicit additive includes and template-specific rules in the synthetic GitLab rules pack.
From 12a9211885e8f67c0e6ef1296b715a5d64ffffaa Mon Sep 17 00:00:00 2001
From: xeonvs <11463419+xeonvs@users.noreply.github.com>
Date: Tue, 11 Aug 2026 18:54:43 +0200
Subject: [PATCH 03/11] Make stable release recovery idempotent
---
.github/workflows/release.yml | 155 +++++-----
PLANS.md | 27 +-
docs/release.md | 6 +-
scripts/bounded_github_api.sh | 1 +
scripts/github_release_api.py | 466 +++++++++++++++++++++++++++++++
scripts/release_issue_receipt.py | 4 +-
tests/test_release_receipt.py | 93 +++++-
7 files changed, 668 insertions(+), 84 deletions(-)
create mode 100755 scripts/github_release_api.py
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 016ef51..1f34dbc 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -420,7 +420,9 @@ jobs:
github-release:
name: publish-github-release
needs: [authorize, build, verify-pypi]
- if: needs.verify-pypi.result == 'success'
+ if: >-
+ always() && needs.authorize.result == 'success' && needs.build.result == 'success' &&
+ needs.verify-pypi.result == 'success'
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
@@ -455,7 +457,7 @@ jobs:
destination=$2
max_bytes=$3
test "$(scripts/bounded_github_api.sh \
- "repos/${GITHUB_REPOSITORY}/releases/tags/${TAG}" \
+ "repos/${GITHUB_REPOSITORY}/releases/${release_id}" \
/tmp/github-release-asset-index.json)" = 200
asset_id=$(jq --arg name "${name}" \
'[.assets[] | select(.name == $name)] |
@@ -513,74 +515,88 @@ jobs:
git tag -a "${TAG}" -m "${TAG}" "${EXPECTED_COMMIT}"
git push origin "${TAG}"
fi
- release_status=$(scripts/bounded_github_api.sh \
- "repos/${GITHUB_REPOSITORY}/releases/tags/${TAG}" \
- /tmp/github-release-state.json authenticated 200,404)
- case "${release_status}" in
- 200) release_exists=true ;;
- 404)
- release_exists=false
- gh release create "${TAG}" --draft --verify-tag \
- --title "${TAG}" --notes-file /tmp/release-notes.md
- test "$(scripts/bounded_github_api.sh \
- "repos/${GITHUB_REPOSITORY}/releases/tags/${TAG}" \
- /tmp/github-release-state.json)" = 200
- ;;
+ python scripts/github_release_api.py ensure \
+ --repository "${GITHUB_REPOSITORY}" --tag "${TAG}" \
+ --target "${EXPECTED_COMMIT}" --title "${TAG}" \
+ --notes-file /tmp/release-notes.md --output /tmp/github-release-state.json
+ release_id=$(jq -r .id /tmp/github-release-state.json)
+ case "${release_id}" in
+ *[!0-9]*|'') echo "invalid GitHub Release numeric identity" >&2; exit 1 ;;
esac
+ test "${release_id}" -gt 0
release_is_draft=$(jq -r .draft /tmp/github-release-state.json)
test "${release_is_draft}" = true || test "${release_is_draft}" = false
- if [ "${release_exists}" = false ] || [ "${release_is_draft}" = true ]; then
- jq '{assets}' /tmp/github-release-state.json > /tmp/github-release-upload-assets.json
- receipt_count=$(jq '[.assets[] | select(.name == "release-receipt.json")] | length' \
+ jq '{assets}' /tmp/github-release-state.json > /tmp/github-release-upload-assets.json
+ receipt_count=$(jq '[.assets[] | select(.name == "release-receipt.json")] | length' \
+ /tmp/github-release-upload-assets.json)
+ case "${receipt_count}" in
+ 0)
+ test "${release_is_draft}" = true || {
+ echo "published GitHub Release is missing its receipt" >&2
+ exit 1
+ }
+ python scripts/release_receipt.py \
+ --version "${VERSION}" \
+ --tag "${TAG}" \
+ --release-pr "${RELEASE_PR}" \
+ --issues "${ISSUES}" \
+ --base "${EXPECTED_BASE}" \
+ --head "${EXPECTED_HEAD}" \
+ --merge "${EXPECTED_COMMIT}" \
+ --tree "${EXPECTED_TREE}" \
+ --run-id "${GITHUB_RUN_ID}" \
+ --run-attempt "${GITHUB_RUN_ATTEMPT}" \
+ --authorized-at "${AUTHORIZED_AT}" \
+ --hashes artifact-hashes.json \
+ --output release-receipt.json
+ ;;
+ 1)
+ bounded_release_download release-receipt.json release-receipt.json 1048576
+ python scripts/release_receipt.py \
+ --version "${VERSION}" \
+ --tag "${TAG}" \
+ --release-pr "${RELEASE_PR}" \
+ --issues "${ISSUES}" \
+ --base "${EXPECTED_BASE}" \
+ --head "${EXPECTED_HEAD}" \
+ --merge "${EXPECTED_COMMIT}" \
+ --tree "${EXPECTED_TREE}" \
+ --authorized-at "${AUTHORIZED_AT}" \
+ --hashes artifact-hashes.json \
+ --validate-existing release-receipt.json
+ ;;
+ *) echo "duplicate GitHub Release receipt asset" >&2; exit 1 ;;
+ esac
+ for asset in dist/* artifact-hashes.json SHA256SUMS release-receipt.json; do
+ name=$(basename "${asset}")
+ asset_count=$(jq --arg name "${name}" '[.assets[] | select(.name == $name)] | length' \
/tmp/github-release-upload-assets.json)
- case "${receipt_count}" in
+ case "${asset_count}" in
0)
- python scripts/release_receipt.py \
- --version "${VERSION}" \
- --tag "${TAG}" \
- --release-pr "${RELEASE_PR}" \
- --issues "${ISSUES}" \
- --base "${EXPECTED_BASE}" \
- --head "${EXPECTED_HEAD}" \
- --merge "${EXPECTED_COMMIT}" \
- --tree "${EXPECTED_TREE}" \
- --run-id "${GITHUB_RUN_ID}" \
- --run-attempt "${GITHUB_RUN_ATTEMPT}" \
- --authorized-at "${AUTHORIZED_AT}" \
- --hashes artifact-hashes.json \
- --output release-receipt.json
+ test "${release_is_draft}" = true || {
+ echo "published GitHub Release is missing asset: ${name}" >&2
+ exit 1
+ }
+ python scripts/github_release_api.py upload \
+ --repository "${GITHUB_REPOSITORY}" --release-id "${release_id}" \
+ --tag "${TAG}" --target "${EXPECTED_COMMIT}" --title "${TAG}" \
+ --notes-file /tmp/release-notes.md --asset "${asset}"
;;
1)
- bounded_release_download release-receipt.json release-receipt.json 1048576
- python scripts/release_receipt.py \
- --version "${VERSION}" \
- --tag "${TAG}" \
- --release-pr "${RELEASE_PR}" \
- --issues "${ISSUES}" \
- --base "${EXPECTED_BASE}" \
- --head "${EXPECTED_HEAD}" \
- --merge "${EXPECTED_COMMIT}" \
- --tree "${EXPECTED_TREE}" \
- --authorized-at "${AUTHORIZED_AT}" \
- --hashes artifact-hashes.json \
- --validate-existing release-receipt.json
+ bounded_release_download "${name}" "/tmp/existing-${name}" 10485760
+ cmp "${asset}" "/tmp/existing-${name}"
;;
- *) echo "duplicate GitHub Release receipt asset" >&2; exit 1 ;;
+ *) echo "duplicate GitHub Release asset: ${name}" >&2; exit 1 ;;
esac
- for asset in dist/* artifact-hashes.json SHA256SUMS release-receipt.json; do
- name=$(basename "${asset}")
- asset_count=$(jq --arg name "${name}" '[.assets[] | select(.name == $name)] | length' \
- /tmp/github-release-upload-assets.json)
- case "${asset_count}" in
- 0) gh release upload "${TAG}" "${asset}" ;;
- 1)
- bounded_release_download "${name}" "/tmp/existing-${name}" 10485760
- cmp "${asset}" "/tmp/existing-${name}"
- ;;
- *) echo "duplicate GitHub Release asset: ${name}" >&2; exit 1 ;;
- esac
- done
- fi
+ done
+ python scripts/github_release_api.py publish \
+ --repository "${GITHUB_REPOSITORY}" --release-id "${release_id}" \
+ --tag "${TAG}" --target "${EXPECTED_COMMIT}" --title "${TAG}" \
+ --notes-file /tmp/release-notes.md \
+ --asset-name "open_code_review_toolkit-${VERSION}-py3-none-any.whl" \
+ --asset-name "open_code_review_toolkit-${VERSION}.tar.gz" \
+ --asset-name artifact-hashes.json --asset-name SHA256SUMS \
+ --asset-name release-receipt.json --output /tmp/github-release.json
release_dir=/tmp/github-release-assets
mkdir -p "${release_dir}"
for name in \
@@ -614,23 +630,6 @@ jobs:
--hashes artifact-hashes.json \
--validate-existing "${release_dir}/release-receipt.json"
fi
- test "$(scripts/bounded_github_api.sh \
- "repos/${GITHUB_REPOSITORY}/releases/tags/${TAG}" \
- /tmp/github-release.json)" = 200
- python - <<'PY'
- import json
- from pathlib import Path
- import os
-
- release = json.loads(Path("/tmp/github-release.json").read_text(encoding="utf-8"))
- actual = {"body": release.get("body"), "name": release.get("name")}
- expected_body = Path("/tmp/release-notes.md").read_text(encoding="utf-8")
- if actual != {"body": expected_body, "name": os.environ["TAG"]}:
- raise SystemExit("existing GitHub Release metadata does not match")
- PY
- if [ "${release_is_draft}" = true ]; then
- gh release edit "${TAG}" --draft=false
- fi
export GITHUB_API_VERSION=2026-03-10
for attempt in 1 2 3 4 5; do
test "$(scripts/bounded_github_api.sh \
diff --git a/PLANS.md b/PLANS.md
index ecfa3a5..e94a8a3 100644
--- a/PLANS.md
+++ b/PLANS.md
@@ -42,13 +42,17 @@ review without GitLab posting, before protected feature and stable delivery.
- After complete E2E, run exactly one final local OCR 1.9.1 review over the M2
change range, require real evidence-MCP use, fix actionable findings, and use
only deterministic validation afterward.
+- Between completed checkpoints, query the public project for newly opened OCR
+ compatibility/release issues. If a newer stable OCR appears, qualify its
+ complete adjacent chain and include required contract/rules adaptations in
+ 0.5.0 before E2E and the single final OCR review.
### Work Queue
1. [x] Reconcile the externally completed 0.4.7 starting point, create the M2
branch and draft feature PR #77, activate release-required 0.5.0 planning,
and set the next development line to 0.5.0.
-2. [ ] Repair issue #76 draft-Release identity, canonical issue-comment
+2. [x] Repair issue #76 draft-Release identity, canonical issue-comment
newline, and idempotent skipped-publisher recovery with synthetic tests.
3. [ ] Implement the bounded static plugin protocol, manifest-root components,
closed framework/template records, limits, coverage, and MCP/store contracts.
@@ -73,6 +77,27 @@ review without GitLab posting, before protected feature and stable delivery.
Release/receipt/Python-install readback and close #76 as completed without
another repository PR.
+### Issue #76 Checkpoint
+
+- Stable delivery now retains a validated numeric GitHub Release ID from draft
+ creation/discovery through asset upload and publication. A bounded,
+ redirect-free helper uses closed GitHub API and upload endpoints, exact
+ metadata, unique asset names, regular-file/size checks, and fails closed for
+ duplicate, partial, mismatched, or published-but-incomplete states.
+- The final Release job uses an explicit `always()` success matrix over its
+ direct authorization, build, and registry-verification prerequisites, so
+ idempotently skipped registry publishers cannot suppress final immutable
+ Release and issue closure work while failed or cancelled verification still
+ blocks it.
+- Issue receipts now have one canonical representation ending in exactly one
+ newline. `--body-output` writes that exact representation and bot-comment
+ readback compares it byte-for-byte without accepting altered whitespace,
+ ownership, marker, version, issue, or hash.
+- Focused release authorization/receipt tests pass, including numeric identity,
+ duplicate/mismatched metadata, canonical comment-file bytes, bounded API
+ allowlists, exact recovery workflow structure, and completed issue closure.
+ Durable release documentation now records the numeric-draft boundary.
+
### Initial Evidence
- Clean synchronized `main` was exact annotated tag `v0.4.7` at
diff --git a/docs/release.md b/docs/release.md
index 8bcbf9d..3c222d3 100644
--- a/docs/release.md
+++ b/docs/release.md
@@ -80,7 +80,11 @@ The immutable receipt carries the release PR, reviewed base/head/merge/tree, ori
Recovery dispatch is bound to the original release PR, version, merge commit,
reviewed head, and protected reviewed base. It executes the same trusted-base
authorizer, accepts only exact registry bytes and the existing immutable
-receipt's closed release identity, and rejects unknown receipt fields. If only
+receipt's closed release identity, and rejects unknown receipt fields. Draft
+GitHub Releases are retained and addressed by their numeric release ID until
+publication because tag lookup does not expose private drafts reliably. Every
+numeric-ID read, asset upload, and publication step revalidates the expected
+tag, target commit, title, notes, draft state, unique asset names, and bytes. If only
issue commenting or closure failed, recovery reuses the exact GitHub
Actions-owned receipt comment, accepts an already-completed issue, and does not
change repository files, tag, or immutable Release assets. A user-authored
diff --git a/scripts/bounded_github_api.sh b/scripts/bounded_github_api.sh
index ecafb21..bdcce74 100755
--- a/scripts/bounded_github_api.sh
+++ b/scripts/bounded_github_api.sh
@@ -26,6 +26,7 @@ patterns = (
rf"repos/{repository}/issues/[1-9][0-9]*/comments\?per_page=100&page=[1-5]",
rf"repos/{repository}/issues/[1-9][0-9]*/comments\?per_page=1&page=501",
rf"repos/{repository}/releases/tags/v[0-9]+(?:\.[0-9]+)+",
+ rf"repos/{repository}/releases/[1-9][0-9]*",
rf"repos/{repository}/releases/assets/[1-9][0-9]*",
)
if not any(re.fullmatch(pattern, endpoint) for pattern in patterns):
diff --git a/scripts/github_release_api.py b/scripts/github_release_api.py
new file mode 100755
index 0000000..379d1a9
--- /dev/null
+++ b/scripts/github_release_api.py
@@ -0,0 +1,466 @@
+#!/usr/bin/env python3
+"""Perform bounded numeric-ID GitHub Release creation, upload, and publication."""
+
+from __future__ import annotations
+
+import argparse
+import json
+import os
+import re
+import stat
+import tempfile
+import urllib.error
+import urllib.parse
+import urllib.request
+from pathlib import Path
+from typing import Any
+
+API_ORIGIN = "https://api.github.com"
+UPLOAD_ORIGIN = "https://uploads.github.com"
+API_VERSION = "2026-03-10"
+MAX_JSON_BYTES = 1_048_576
+MAX_ASSET_BYTES = 10_485_760
+REPOSITORY_RE = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$")
+TAG_RE = re.compile(r"^v[0-9]+(?:\.[0-9]+)+$")
+SHA_RE = re.compile(r"^[0-9a-f]{40}$")
+ASSET_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]{0,254}$")
+
+
+class GitHubReleaseError(ValueError):
+ """A GitHub Release response or requested mutation is unsafe or inconsistent."""
+
+
+class _NoRedirect(urllib.request.HTTPRedirectHandler):
+ """Reject redirects so authorization never crosses an endpoint boundary."""
+
+ def redirect_request(
+ self,
+ req: urllib.request.Request,
+ fp: Any,
+ code: int,
+ msg: str,
+ headers: Any,
+ newurl: str,
+ ) -> None:
+ """Return no redirected request for every redirect response."""
+
+ return None
+
+
+def _read_bounded(response: Any, max_bytes: int) -> bytes:
+ """Read a response under a hard byte ceiling before parsing it."""
+
+ payload = response.read(max_bytes + 1)
+ if len(payload) > max_bytes:
+ raise GitHubReleaseError("GitHub Release response exceeds its byte limit")
+ return payload
+
+
+def _request(
+ *,
+ origin: str,
+ endpoint: str,
+ token: str,
+ method: str = "GET",
+ body: bytes | None = None,
+ content_type: str = "application/vnd.github+json",
+ expected: tuple[int, ...] = (200,),
+ max_bytes: int = MAX_JSON_BYTES,
+) -> tuple[int, Any]:
+ """Call one closed GitHub endpoint without redirects and parse bounded JSON."""
+
+ if origin not in {API_ORIGIN, UPLOAD_ORIGIN} or not endpoint.startswith("/repos/"):
+ raise GitHubReleaseError("unsupported GitHub Release endpoint")
+ if not token:
+ raise GitHubReleaseError("GH_TOKEN is required")
+ request = urllib.request.Request(
+ origin + endpoint,
+ data=body,
+ method=method,
+ headers={
+ "Accept": "application/vnd.github+json",
+ "Authorization": f"Bearer {token}",
+ "Content-Type": content_type,
+ "User-Agent": "open-code-review-toolkit-release",
+ "X-GitHub-Api-Version": API_VERSION,
+ },
+ )
+ opener = urllib.request.build_opener(_NoRedirect())
+ try:
+ with opener.open(request, timeout=60) as response:
+ status = response.status
+ raw = _read_bounded(response, max_bytes)
+ except urllib.error.HTTPError as exc:
+ status = exc.code
+ raw = _read_bounded(exc, max_bytes)
+ except (OSError, urllib.error.URLError) as exc:
+ raise GitHubReleaseError("bounded GitHub Release request failed") from exc
+ if status not in expected:
+ raise GitHubReleaseError(f"unexpected GitHub Release status {status}")
+ if status == 404 and not raw:
+ return status, None
+ try:
+ return status, json.loads(raw)
+ except (UnicodeDecodeError, json.JSONDecodeError, RecursionError) as exc:
+ raise GitHubReleaseError("GitHub Release response is not valid bounded JSON") from exc
+
+
+def _metadata(notes_path: Path) -> str:
+ """Read exact release notes under the shared response-size ceiling."""
+
+ if not notes_path.is_file() or notes_path.stat().st_size > MAX_JSON_BYTES:
+ raise GitHubReleaseError("release notes are missing or oversized")
+ return notes_path.read_text(encoding="utf-8")
+
+
+def _validate_inputs(repository: str, tag: str, target: str, title: str) -> None:
+ """Validate the closed release identity supplied by protected workflow data."""
+
+ if (
+ not REPOSITORY_RE.fullmatch(repository)
+ or not TAG_RE.fullmatch(tag)
+ or not SHA_RE.fullmatch(target)
+ or title != tag
+ ):
+ raise GitHubReleaseError("GitHub Release identity is invalid")
+
+
+def validate_release(
+ payload: object,
+ *,
+ repository: str,
+ tag: str,
+ target: str,
+ title: str,
+ notes: str,
+ require_draft: bool | None = None,
+) -> dict[str, Any]:
+ """Return one exact release response with a stable numeric identity."""
+
+ _validate_inputs(repository, tag, target, title)
+ if not isinstance(payload, dict):
+ raise GitHubReleaseError("GitHub Release response must be an object")
+ release_id = payload.get("id")
+ assets = payload.get("assets")
+ if (
+ isinstance(release_id, bool)
+ or not isinstance(release_id, int)
+ or release_id <= 0
+ or payload.get("tag_name") != tag
+ or payload.get("target_commitish") != target
+ or payload.get("name") != title
+ or payload.get("body") != notes
+ or not isinstance(payload.get("draft"), bool)
+ or payload.get("prerelease") is not False
+ or not isinstance(assets, list)
+ ):
+ raise GitHubReleaseError("GitHub Release metadata does not match")
+ if require_draft is not None and payload["draft"] is not require_draft:
+ raise GitHubReleaseError("GitHub Release draft state does not match")
+ for asset in assets:
+ if (
+ not isinstance(asset, dict)
+ or isinstance(asset.get("id"), bool)
+ or not isinstance(asset.get("id"), int)
+ or asset["id"] <= 0
+ or not isinstance(asset.get("name"), str)
+ or not ASSET_RE.fullmatch(asset["name"])
+ ):
+ raise GitHubReleaseError("GitHub Release asset metadata is invalid")
+ names = [asset["name"] for asset in assets]
+ if len(names) != len(set(names)):
+ raise GitHubReleaseError("GitHub Release contains duplicate asset names")
+ return payload
+
+
+def _write_json(path: Path, payload: object) -> None:
+ """Atomically write one owner-only canonical JSON response."""
+
+ path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
+ descriptor, temporary = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent)
+ try:
+ os.fchmod(descriptor, stat.S_IRUSR | stat.S_IWUSR)
+ with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
+ descriptor = -1
+ json.dump(payload, handle, sort_keys=True, separators=(",", ":"))
+ handle.write("\n")
+ handle.flush()
+ os.fsync(handle.fileno())
+ os.replace(temporary, path)
+ os.chmod(path, stat.S_IRUSR | stat.S_IWUSR)
+ finally:
+ if descriptor >= 0:
+ os.close(descriptor)
+ try:
+ os.unlink(temporary)
+ except FileNotFoundError:
+ pass
+
+
+def ensure_release(
+ *, repository: str, tag: str, target: str, title: str, notes: str, token: str
+) -> dict[str, Any]:
+ """Discover one release by tag or numeric draft listing, creating it if absent."""
+
+ encoded_tag = urllib.parse.quote(tag, safe="")
+ status, payload = _request(
+ origin=API_ORIGIN,
+ endpoint=f"/repos/{repository}/releases/tags/{encoded_tag}",
+ token=token,
+ expected=(200, 404),
+ )
+ if status == 200:
+ return validate_release(
+ payload,
+ repository=repository,
+ tag=tag,
+ target=target,
+ title=title,
+ notes=notes,
+ )
+
+ releases: list[object] = []
+ for page in range(1, 6):
+ _status, page_payload = _request(
+ origin=API_ORIGIN,
+ endpoint=f"/repos/{repository}/releases?per_page=100&page={page}",
+ token=token,
+ )
+ if not isinstance(page_payload, list) or len(page_payload) > 100:
+ raise GitHubReleaseError("GitHub Release listing is malformed")
+ releases.extend(page_payload)
+ if len(page_payload) < 100:
+ break
+ else:
+ _status, overflow = _request(
+ origin=API_ORIGIN,
+ endpoint=f"/repos/{repository}/releases?per_page=1&page=501",
+ token=token,
+ )
+ if not isinstance(overflow, list) or overflow:
+ raise GitHubReleaseError("GitHub Release listing exceeds its page bound")
+
+ matches = [item for item in releases if isinstance(item, dict) and item.get("tag_name") == tag]
+ if len(matches) > 1:
+ raise GitHubReleaseError("GitHub Release tag is not unique")
+ if matches:
+ return validate_release(
+ matches[0],
+ repository=repository,
+ tag=tag,
+ target=target,
+ title=title,
+ notes=notes,
+ )
+
+ create_body = json.dumps(
+ {
+ "tag_name": tag,
+ "target_commitish": target,
+ "name": title,
+ "body": notes,
+ "draft": True,
+ "prerelease": False,
+ },
+ sort_keys=True,
+ separators=(",", ":"),
+ ).encode("utf-8")
+ _status, created = _request(
+ origin=API_ORIGIN,
+ endpoint=f"/repos/{repository}/releases",
+ token=token,
+ method="POST",
+ body=create_body,
+ expected=(201,),
+ )
+ return validate_release(
+ created,
+ repository=repository,
+ tag=tag,
+ target=target,
+ title=title,
+ notes=notes,
+ require_draft=True,
+ )
+
+
+def _read_release(
+ *, repository: str, release_id: int, tag: str, target: str, title: str, notes: str, token: str
+) -> dict[str, Any]:
+ """Read and validate one release through its retained numeric identity."""
+
+ _status, payload = _request(
+ origin=API_ORIGIN,
+ endpoint=f"/repos/{repository}/releases/{release_id}",
+ token=token,
+ )
+ return validate_release(
+ payload,
+ repository=repository,
+ tag=tag,
+ target=target,
+ title=title,
+ notes=notes,
+ )
+
+
+def upload_asset(
+ *,
+ repository: str,
+ release_id: int,
+ tag: str,
+ target: str,
+ title: str,
+ notes: str,
+ asset: Path,
+ token: str,
+) -> dict[str, Any]:
+ """Upload one bounded asset to an exact draft numeric release identity."""
+
+ release = _read_release(
+ repository=repository,
+ release_id=release_id,
+ tag=tag,
+ target=target,
+ title=title,
+ notes=notes,
+ token=token,
+ )
+ if release["draft"] is not True:
+ raise GitHubReleaseError("cannot upload an asset to a published Release")
+ name = asset.name
+ metadata = asset.stat(follow_symlinks=False)
+ if (
+ not ASSET_RE.fullmatch(name)
+ or not stat.S_ISREG(metadata.st_mode)
+ or metadata.st_nlink != 1
+ or metadata.st_size <= 0
+ or metadata.st_size > MAX_ASSET_BYTES
+ ):
+ raise GitHubReleaseError("GitHub Release asset is unsafe or oversized")
+ if any(item["name"] == name for item in release["assets"]):
+ raise GitHubReleaseError("GitHub Release asset already exists")
+ body = asset.read_bytes()
+ endpoint = f"/repos/{repository}/releases/{release_id}/assets?" + urllib.parse.urlencode(
+ {"name": name}
+ )
+ _status, uploaded = _request(
+ origin=UPLOAD_ORIGIN,
+ endpoint=endpoint,
+ token=token,
+ method="POST",
+ body=body,
+ content_type="application/octet-stream",
+ expected=(201,),
+ )
+ if (
+ not isinstance(uploaded, dict)
+ or uploaded.get("name") != name
+ or uploaded.get("size") != metadata.st_size
+ or isinstance(uploaded.get("id"), bool)
+ or not isinstance(uploaded.get("id"), int)
+ or uploaded["id"] <= 0
+ ):
+ raise GitHubReleaseError("uploaded GitHub Release asset metadata does not match")
+ return uploaded
+
+
+def publish_release(
+ *,
+ repository: str,
+ release_id: int,
+ tag: str,
+ target: str,
+ title: str,
+ notes: str,
+ expected_assets: list[str],
+ token: str,
+) -> dict[str, Any]:
+ """Publish an exact draft after validating its complete unique asset set."""
+
+ release = _read_release(
+ repository=repository,
+ release_id=release_id,
+ tag=tag,
+ target=target,
+ title=title,
+ notes=notes,
+ token=token,
+ )
+ if sorted(expected_assets) != sorted(set(expected_assets)) or not all(
+ ASSET_RE.fullmatch(name) for name in expected_assets
+ ):
+ raise GitHubReleaseError("expected GitHub Release asset set is invalid")
+ actual_assets = sorted(item["name"] for item in release["assets"])
+ if actual_assets != sorted(expected_assets):
+ raise GitHubReleaseError("GitHub Release asset set does not match")
+ if release["draft"] is False:
+ return release
+ patch = json.dumps({"draft": False}, separators=(",", ":")).encode("utf-8")
+ _status, published = _request(
+ origin=API_ORIGIN,
+ endpoint=f"/repos/{repository}/releases/{release_id}",
+ token=token,
+ method="PATCH",
+ body=patch,
+ )
+ return validate_release(
+ published,
+ repository=repository,
+ tag=tag,
+ target=target,
+ title=title,
+ notes=notes,
+ require_draft=False,
+ )
+
+
+def main() -> int:
+ """Dispatch one protected-workflow Release operation."""
+
+ parser = argparse.ArgumentParser(description=__doc__)
+ subparsers = parser.add_subparsers(dest="action", required=True)
+ for action in ("ensure", "upload", "publish"):
+ command = subparsers.add_parser(action)
+ command.add_argument("--repository", required=True)
+ command.add_argument("--tag", required=True)
+ command.add_argument("--target", required=True)
+ command.add_argument("--title", required=True)
+ command.add_argument("--notes-file", type=Path, required=True)
+ if action != "ensure":
+ command.add_argument("--release-id", type=int, required=True)
+ if action == "ensure":
+ command.add_argument("--output", type=Path, required=True)
+ elif action == "upload":
+ command.add_argument("--asset", type=Path, required=True)
+ else:
+ command.add_argument("--asset-name", action="append", required=True)
+ command.add_argument("--output", type=Path, required=True)
+ args = parser.parse_args()
+ token = os.environ.get("GH_TOKEN", "")
+ notes = _metadata(args.notes_file)
+ common = {
+ "repository": args.repository,
+ "tag": args.tag,
+ "target": args.target,
+ "title": args.title,
+ "notes": notes,
+ "token": token,
+ }
+ if args.action == "ensure":
+ payload = ensure_release(**common)
+ _write_json(args.output, payload)
+ elif args.action == "upload":
+ upload_asset(release_id=args.release_id, asset=args.asset, **common)
+ else:
+ payload = publish_release(
+ release_id=args.release_id,
+ expected_assets=args.asset_name,
+ **common,
+ )
+ _write_json(args.output, payload)
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/scripts/release_issue_receipt.py b/scripts/release_issue_receipt.py
index 19f91d7..51c735f 100755
--- a/scripts/release_issue_receipt.py
+++ b/scripts/release_issue_receipt.py
@@ -27,7 +27,7 @@ def receipt_body(version: str, issue: int, receipt_sha: str) -> str:
marker = f""
return (
f"{marker}\n\nStable v{version} delivery is verified by immutable release asset "
- f"`release-receipt.json` in v{version} (SHA-256 `{receipt_sha}`)."
+ f"`release-receipt.json` in v{version} (SHA-256 `{receipt_sha}`).\n"
)
@@ -119,7 +119,7 @@ def main() -> int:
require_comment=args.require_comment,
)
if args.body_output is not None:
- args.body_output.write_text(body + "\n", encoding="utf-8")
+ args.body_output.write_text(body, encoding="utf-8")
print(state, comment)
return 0
diff --git a/tests/test_release_receipt.py b/tests/test_release_receipt.py
index 4389f48..fd1087a 100644
--- a/tests/test_release_receipt.py
+++ b/tests/test_release_receipt.py
@@ -14,6 +14,7 @@
SCRIPT = ROOT / "scripts" / "release_receipt.py"
PROVENANCE_SCRIPT = ROOT / "scripts" / "verify_registry_provenance.py"
ISSUE_RECEIPT_SCRIPT = ROOT / "scripts" / "release_issue_receipt.py"
+GITHUB_RELEASE_SCRIPT = ROOT / "scripts" / "github_release_api.py"
WORKFLOW = ROOT / ".github" / "workflows" / "release.yml"
@@ -30,6 +31,7 @@ def load_script(path: Path, name: str) -> ModuleType:
receipt = load_script(SCRIPT, "release_receipt_script")
provenance = load_script(PROVENANCE_SCRIPT, "verify_registry_provenance_script")
issue_receipt = load_script(ISSUE_RECEIPT_SCRIPT, "release_issue_receipt_script")
+github_release = load_script(GITHUB_RELEASE_SCRIPT, "github_release_api_script")
def build_receipt(**overrides: Any) -> dict[str, Any]:
@@ -277,6 +279,7 @@ def test_issue_receipt_accepts_only_exact_actions_owned_comment_and_closed_state
)
== "closed"
)
+ assert body.endswith("\n") and not body.endswith("\n\n")
assert issue_receipt.comment_state([comment], body, require_comment=True) == "matched"
forged = {**comment, "user": {"login": "synthetic-user", "id": 7, "type": "User"}}
@@ -320,14 +323,19 @@ def test_release_workflow_builds_reads_back_and_recovers_the_receipt() -> None:
ROOT / "scripts" / "verify_registry_artifacts.sh"
).read_text(encoding="utf-8")
assert "python scripts/release_receipt.py" in workflow
- assert 'release upload "${TAG}" "${asset}"' in workflow
+ assert "python scripts/github_release_api.py ensure" in workflow
+ assert "python scripts/github_release_api.py upload" in workflow
+ assert "python scripts/github_release_api.py publish" in workflow
+ assert '--release-id "${release_id}"' in workflow
assert 'release upload "${TAG}" dist/*' not in workflow
- assert "release upload" in workflow
assert "--clobber" not in workflow
assert "bounded_release_download" in workflow
assert "releases/assets/${asset_id}" in workflow
assert "application/octet-stream" in workflow
assert "gh release download" not in workflow
+ assert "gh release create" not in workflow
+ assert "gh release upload" not in workflow
+ assert "gh release edit" not in workflow
assert "duplicate GitHub Release asset" in workflow
assert '--validate-existing "${release_dir}/release-receipt.json"' in workflow
assert 'cmp release-receipt.json "${release_dir}/release-receipt.json"' in workflow
@@ -347,3 +355,84 @@ def test_release_workflow_builds_reads_back_and_recovers_the_receipt() -> None:
encoding="utf-8"
)
assert "reset_approvals" not in workflow
+ assert "always() && needs.authorize.result == 'success'" in workflow
+ assert "needs.verify-pypi.result == 'success'" in workflow
+
+
+def test_numeric_release_identity_requires_exact_metadata_and_unique_assets() -> None:
+ notes = "## 0.5.0\n"
+ payload = {
+ "id": 91,
+ "tag_name": "v0.5.0",
+ "target_commitish": "a" * 40,
+ "name": "v0.5.0",
+ "body": notes,
+ "draft": True,
+ "prerelease": False,
+ "assets": [{"id": 7, "name": "package.whl", "size": 10}],
+ }
+ validated = github_release.validate_release(
+ payload,
+ repository="synthetic/toolkit",
+ tag="v0.5.0",
+ target="a" * 40,
+ title="v0.5.0",
+ notes=notes,
+ require_draft=True,
+ )
+ assert validated["id"] == 91
+
+ duplicate = {**payload, "assets": [payload["assets"][0], payload["assets"][0]]}
+ with pytest.raises(github_release.GitHubReleaseError, match="duplicate"):
+ github_release.validate_release(
+ duplicate,
+ repository="synthetic/toolkit",
+ tag="v0.5.0",
+ target="a" * 40,
+ title="v0.5.0",
+ notes=notes,
+ )
+
+ with pytest.raises(github_release.GitHubReleaseError, match="metadata"):
+ github_release.validate_release(
+ {**payload, "target_commitish": "b" * 40},
+ repository="synthetic/toolkit",
+ tag="v0.5.0",
+ target="a" * 40,
+ title="v0.5.0",
+ notes=notes,
+ )
+
+
+def test_issue_receipt_cli_writes_the_canonical_terminal_newline(tmp_path: Path) -> None:
+ issue = tmp_path / "issue.json"
+ comments = tmp_path / "comments.json"
+ output = tmp_path / "body.md"
+ issue.write_text(json.dumps({"number": 76, "state": "open", "state_reason": None}))
+ comments.write_text("[]")
+ import subprocess
+
+ completed = subprocess.run(
+ [
+ "python3",
+ str(ISSUE_RECEIPT_SCRIPT),
+ "--issue-json",
+ str(issue),
+ "--comments-json",
+ str(comments),
+ "--issue",
+ "76",
+ "--version",
+ "0.5.0",
+ "--receipt-sha",
+ "a" * 64,
+ "--body-output",
+ str(output),
+ ],
+ check=False,
+ capture_output=True,
+ text=True,
+ )
+ assert completed.returncode == 0, completed.stderr
+ assert output.read_text() == issue_receipt.receipt_body("0.5.0", 76, "a" * 64)
+ assert output.read_bytes().endswith(b".\n")
From f6c658c2a5feed901db470c6b36a2f72f239293b Mon Sep 17 00:00:00 2001
From: xeonvs <11463419+xeonvs@users.noreply.github.com>
Date: Tue, 11 Aug 2026 18:59:23 +0200
Subject: [PATCH 04/11] Add static framework evidence plugins
---
PLANS.md | 36 +-
docs/configuration.md | 6 +-
docs/engineering/toolkit_strategy.md | 6 +-
docs/gitlab.md | 2 +-
examples/gitlab/rules.json | 21 +
src/ocr_toolkit/evidence/collectors.py | 78 +++
src/ocr_toolkit/evidence/framework_plugins.py | 590 ++++++++++++++++++
src/ocr_toolkit/evidence/framework_schema.py | 172 +++++
src/ocr_toolkit/evidence/store.py | 7 +
tests/test_evidence_framework_plugins.py | 255 ++++++++
tests/test_integration_contracts.py | 15 +-
11 files changed, 1178 insertions(+), 10 deletions(-)
create mode 100644 src/ocr_toolkit/evidence/framework_plugins.py
create mode 100644 src/ocr_toolkit/evidence/framework_schema.py
create mode 100644 tests/test_evidence_framework_plugins.py
diff --git a/PLANS.md b/PLANS.md
index e94a8a3..069bd51 100644
--- a/PLANS.md
+++ b/PLANS.md
@@ -54,14 +54,14 @@ review without GitLab posting, before protected feature and stable delivery.
and set the next development line to 0.5.0.
2. [x] Repair issue #76 draft-Release identity, canonical issue-comment
newline, and idempotent skipped-publisher recovery with synthetic tests.
-3. [ ] Implement the bounded static plugin protocol, manifest-root components,
+3. [x] Implement the bounded static plugin protocol, manifest-root components,
closed framework/template records, limits, coverage, and MCP/store contracts.
-4. [ ] Implement Jinja2 dependency and Jinja/Ansible-template evidence plus the
+4. [x] Implement Jinja2 dependency and Jinja/Ansible-template evidence plus the
additive Jinja rules pack.
-5. [ ] Implement direct Echo/Fiber evidence and conservative related gRPC data.
-6. [ ] Implement Symfony/Twig dependency, configuration, template, and rules
+5. [x] Implement direct Echo/Fiber evidence and conservative related gRPC data.
+6. [x] Implement Symfony/Twig dependency, configuration, template, and rules
evidence.
-7. [ ] Implement React/Next framework evidence with TypeScript/Vite related
+7. [x] Implement React/Next framework evidence with TypeScript/Vite related
signals and npm/Yarn/pnpm resolution.
8. [ ] Complete cross-provider deltas, coverage, bootstrap/MCP projections,
documentation, strategy, roadmap, backlog, and milestone reconciliation.
@@ -98,6 +98,32 @@ review without GitLab posting, before protected feature and stable delivery.
allowlists, exact recovery workflow structure, and completed issue closure.
Durable release documentation now records the numeric-draft boundary.
+### Framework Plugins And Template Review Checkpoint
+
+- A static package-owned plugin registry now interprets existing immutable
+ Python, Go, Composer, npm, Yarn, and pnpm evidence without giving plugins Git,
+ filesystem, subprocess, network, mutation, or MCP lifecycle capabilities.
+ Jinja2, Echo/Fiber, Symfony/Twig, and React/Next are direct-declaration
+ providers; gRPC, TypeScript, and Vite are bounded direct related signals.
+- New closed `framework.detected` and `template.file` records preserve semantic
+ component identity while versions, configuration paths, template object IDs,
+ and related signals remain delta values. Nested schemas are revalidated on
+ hostile store load; shared store limits, redaction, coverage, base/head
+ deltas, and existing MCP summary/list/get projection remain authoritative.
+- Jinja `.j2`/`.jinja`/`.jinja2`, extensionless conventional Ansible-role
+ templates, and Twig `.twig` files are inventoried without persisting or
+ rendering content. The public synthetic rules pack adds explicit additive
+ includes and ordered merged Jinja/Twig guidance; direct OCR 1.9.1 rules and
+ preview probes select root, nested, role, and Twig paths that were previously
+ rejected as `unsupported_ext`.
+- Focused self-review confirmed lock/checksum-only packages do not activate a
+ framework, components come from manifest/role roots, plugin failures remain
+ isolated, excludes retain precedence, ordinary supported files remain
+ reviewable, and the MCP requires no new server or tool. The combined evidence
+ and integration suite passes 192 tests; focused Ruff and strict mypy pass.
+ Public configuration, GitLab, and strategy documentation now describe the
+ implemented boundaries and review-selection behavior.
+
### Initial Evidence
- Clean synchronized `main` was exact annotated tag `v0.4.7` at
diff --git a/docs/configuration.md b/docs/configuration.md
index 7e40d95..16938b2 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -84,7 +84,11 @@ The private `.review-context/evidence.json` store and `.review-context/bootstrap
The compact bootstrap contains the same safe inventory of independent server/tool entries that was written to OCR configuration. The mandatory built-in server exposes `ocr_toolkit_evidence`, with `summary`, paginated/filterable `list`, and stable-ID `get` actions. It has no mutation action, network access, or shell execution. Optional MCP entries expose their own allowlisted tools; they can coexist with but cannot remove or shadow the mandatory entry.
-Evidence-store schema v2 adds `repository.evidence-coverage/v1` records keyed by component, domain, scope, immutable ref, and commit. Only `complete` coverage permits a missing positive fact to support an absence claim; absent, `partial`, `runtime-dependent`, and `unavailable` coverage mean unknown. Schema-v1 stores remain readable but are explicitly treated as having unknown completeness. The Ansible adopter recognizes static, plugin-based, and executable inventory sources without execution and models the recursive role `defaults/main/` and `vars/main/` loader surface verified for ansible-core 2.17 through the current 2.x loader contract. Unsupported later loader behavior or bounded read/parser failures degrade coverage rather than becoming false completeness.
+Evidence-store schema v2 includes closed `framework.detected` (`repository.framework-evidence/v1`) and `template.file` (`repository.template-evidence/v1`) facts from package-owned static plugins. Current plugins cover Jinja2, Echo/Fiber, Symfony/Twig, and React/Next with related gRPC, TypeScript, and Vite declarations. Plugins consume only already bounded immutable manifest/tree evidence: they cannot execute repository commands, load repository code, use network access, or start a second MCP server. Detailed declarations, resolutions, configuration/template paths, component scopes, and base/head deltas remain available through the existing `ocr_toolkit_evidence` summary/list/get interface.
+
+The synthetic GitLab `rules.json` uses additive `include` entries for `.j2`, `.jinja`, `.jinja2`, `.twig`, and conventional Ansible-role template paths because OCR 1.9.1 does not review those extensions by default. Explicit excludes still win. The matching Jinja/Twig rules are review guidance; they do not execute or render templates, infer runtime variables, or replace evidence completeness.
+
+Evidence-store schema v2 adds `repository.evidence-coverage/v1` records keyed by component, domain, scope, immutable ref, and commit. Framework plugins publish `framework.declaration`, `framework.resolution`, and `template.inventory` scopes. Only `complete` coverage permits a missing positive fact to support an absence claim; absent, `partial`, `runtime-dependent`, and `unavailable` coverage mean unknown. Schema-v1 stores remain readable but are explicitly treated as having unknown completeness. The Ansible adopter recognizes static, plugin-based, and executable inventory sources without execution and models the recursive role `defaults/main/` and `vars/main/` loader surface verified for ansible-core 2.17 through the current 2.x loader contract. Unsupported later loader behavior or bounded read/parser failures degrade coverage rather than becoming false completeness.
The review step maps OCR's structured `tool_calls.by_tool` counters onto the exact validated registry used for that invocation and stores only positive per-server counts in a schema-versioned `_ocr_toolkit` receipt inside the private result. The later GitLab posting step reads that receipt instead of rebuilding MCP configuration from a possibly changed environment. Its summary omits configured-but-unused servers and all zero counters; the receipt never stores server URLs, commands, arguments, headers, tool inputs, tool results, or repository contents.
diff --git a/docs/engineering/toolkit_strategy.md b/docs/engineering/toolkit_strategy.md
index af3bb16..db3ffdb 100644
--- a/docs/engineering/toolkit_strategy.md
+++ b/docs/engineering/toolkit_strategy.md
@@ -85,9 +85,11 @@ Evidence already distinguishes declared constraints, locked versions, runtime de
Implemented collectors cover Python declarations, requirements, uv, Poetry, Pipenv locks, and standardized locks; JavaScript package metadata plus npm, Yarn, and pnpm locks; Go modules, toolchains, requirements, replacements, and checksums; Composer manifests, locks, and platform evidence; Ansible Galaxy requirements, role topology, inventories, and runtime-dependent coverage; and declarative container and GitLab CI images. Further expansion follows demonstrated repository use and requires synthetic fixtures, deterministic semantics, size bounds, and explicit behavior for malformed or missing files.
-### Planned framework evidence
+### Framework and template evidence
-Framework support is plugin-oriented structured extraction, not a code graph or framework-specific review engine. Useful facts are framework identity, verified version, component scope, important configuration paths, and material source/target changes. Initial plugins are selected from demonstrated repositories and testable fixtures; candidates include common Python, Go, PHP, JavaScript, test, and Ansible frameworks.
+Framework support is package-owned static plugin extraction, not a code graph or framework-specific review engine. The established registry covers Jinja2 and Jinja/Ansible-style templates, Echo/Fiber with direct gRPC stack context, Symfony/Twig, and React/Next with TypeScript/Vite context. Plugins receive only immutable normalized dependency/tree evidence from the core collector, publish closed framework/template facts plus scoped completeness, and cannot read the repository independently, execute commands, fetch the network, mutate state, or create another MCP/review flow.
+
+OCR file selection remains a separate review-engine boundary. The public synthetic rules pack explicitly includes Jinja and Twig template paths that the recommended OCR does not allowlist by default, then supplies narrowly scoped merged rules. Framework identity, versions, component scope, configuration paths, and template deltas are stored once and served on demand by the existing built-in evidence MCP; rules neither duplicate those facts nor render templates.
The design borrows useful CodeGraph principles without adopting CodeGraph: deterministic extraction precedes rendering, work is component-scoped, facts retain provenance and staleness, and OCR retrieves surgical evidence on demand. Route, symbol, and call graphs remain out of scope.
diff --git a/docs/gitlab.md b/docs/gitlab.md
index 1872079..794ba46 100644
--- a/docs/gitlab.md
+++ b/docs/gitlab.md
@@ -6,7 +6,7 @@ The toolkit's first provider adapter posts review results to GitLab merge reques
Install `open-code-review-toolkit` from PyPI. The example obtains the expected toolkit wheel digest from the matching immutable GitHub Release, then uses pip hash-checking and a local install. Install Open Code Review separately and pin `v1.9.1`; verify the release checksum before making the binary executable. The package never downloads OCR.
-Copy and adapt [the synthetic CI example](../examples/gitlab/ocr-review.gitlab-ci.yml). Keep the lint stage before the AI review stage so failed project checks block review. The example downloads a pinned toolkit wheel with bounded retries/timeouts, verifies its SHA-256 before a local `--no-deps` install, generates a private evidence store plus one compact bootstrap, and passes the bootstrap once with `--background-file`.
+Copy and adapt [the synthetic CI example](../examples/gitlab/ocr-review.gitlab-ci.yml). Its rules pack explicitly includes Jinja (`.j2`, `.jinja`, `.jinja2`), extensionless conventional Ansible-role templates, and Twig (`.twig`) because OCR 1.9.1 otherwise reports those paths as `unsupported_ext`; project `exclude` entries still take precedence. Keep the lint stage before the AI review stage so failed project checks block review. The example downloads a pinned toolkit wheel with bounded retries/timeouts, verifies its SHA-256 before a local `--no-deps` install, generates a private evidence store plus one compact bootstrap, and passes the bootstrap once with `--background-file`.
## Required secrets
diff --git a/examples/gitlab/rules.json b/examples/gitlab/rules.json
index 0e1350c..253e488 100644
--- a/examples/gitlab/rules.json
+++ b/examples/gitlab/rules.json
@@ -1,5 +1,20 @@
{
"rules": [
+ {
+ "path": "{roles/*/templates/**,**/roles/*/templates/**}",
+ "rule": "Review this Ansible-style Jinja template for variable, filter, and control-flow correctness; quoting, escaping, and whitespace; runtime-provided variables without assuming they must be declared locally; validity and safety of the rendered target configuration; secret exposure, permissions, idempotency, and deployment compatibility.",
+ "merge_system_rule": true
+ },
+ {
+ "path": "{*.j2,*.jinja,*.jinja2,**/*.j2,**/*.jinja,**/*.jinja2}",
+ "rule": "Review Jinja syntax, variable and filter correctness, context-appropriate escaping, unsafe safe-filter use, include/import boundaries, template injection, whitespace control, and validity of the rendered target identified by the file path and repository evidence.",
+ "merge_system_rule": true
+ },
+ {
+ "path": "{*.twig,**/*.twig}",
+ "rule": "Review Twig syntax, auto-escaping and raw-output boundaries, sandbox assumptions, include/import/inheritance behavior, undefined context, template injection, and validity and security of rendered HTML or configuration output.",
+ "merge_system_rule": true
+ },
{
"path": "{pyproject.toml,uv.lock,**/pyproject.toml,**/uv.lock}",
"rule": "Review dependency changes for credential exposure, runtime compatibility, lockfile consistency, and supply-chain risk.",
@@ -29,5 +44,11 @@
".venv/**",
"__pycache__/**",
"tests/**"
+ ],
+ "include": [
+ "{*.j2,*.jinja,*.jinja2,*.twig}",
+ "**/*.{j2,jinja,jinja2,twig}",
+ "roles/*/templates/**",
+ "**/roles/*/templates/**"
]
}
diff --git a/src/ocr_toolkit/evidence/collectors.py b/src/ocr_toolkit/evidence/collectors.py
index 16796a2..1d9cede 100644
--- a/src/ocr_toolkit/evidence/collectors.py
+++ b/src/ocr_toolkit/evidence/collectors.py
@@ -19,6 +19,13 @@
from ocr_toolkit.evidence.ansible_requirements import parse_galaxy_requirements
from ocr_toolkit.evidence.composer_manifests import parse_composer_json, parse_composer_lock
from ocr_toolkit.evidence.coverage import CoverageObservation, compose_coverage
+from ocr_toolkit.evidence.framework_plugins import (
+ FrameworkPluginContext,
+ PluginCoverage,
+ PluginFact,
+ collect_framework_plugins,
+ collect_template_files,
+)
from ocr_toolkit.evidence.go_manifests import parse_go_mod, parse_go_sum
from ocr_toolkit.evidence.infrastructure import infrastructure_candidate, parse_infrastructure_pins
from ocr_toolkit.evidence.javascript_manifests import (
@@ -557,6 +564,52 @@ def _read_python_requirement_graph(
)
+def _plugin_records(
+ facts: tuple[PluginFact, ...],
+ *,
+ ref: RefRole,
+ commit_sha: str,
+ trust: TrustClass,
+) -> list[EvidenceRecord]:
+ """Attach immutable ref provenance to validated static plugin facts."""
+
+ return [
+ EvidenceRecord(
+ kind=fact.kind,
+ value={"identity": fact.identity, "fact": fact.value},
+ source_path=fact.source_path,
+ ref=ref,
+ commit_sha=commit_sha,
+ component=fact.component,
+ provenance=f"framework plugin:{fact.value['plugin']}",
+ confidence=Confidence.EXACT,
+ trust=trust,
+ )
+ for fact in facts
+ ]
+
+
+def _plugin_coverage(
+ observations: tuple[PluginCoverage, ...], *, ref: RefRole, commit_sha: str
+) -> list[CoverageRecord]:
+ """Compose plugin coverage by semantic component/domain/scope identity."""
+
+ grouped: dict[tuple[str, str, str], list[CoverageObservation]] = {}
+ for item in observations:
+ grouped.setdefault((item.component, item.domain, item.scope), []).append(item.observation)
+ return [
+ compose_coverage(
+ component=component,
+ domain=domain,
+ scope=scope,
+ observations=tuple(values),
+ ref=ref,
+ commit_sha=commit_sha,
+ )
+ for (component, domain, scope), values in sorted(grouped.items())
+ ]
+
+
def collect_ref_facts(
reader: GitRepositoryReader,
commit_sha: str,
@@ -820,6 +873,24 @@ def unavailable_topology(entry: RepositoryObject, reason: str) -> None:
trust=trust,
)
)
+ plugin_context = FrameworkPluginContext(
+ records=tuple(records),
+ entries=entries,
+ omitted_paths=tuple(entry.path for entry in candidates if entry.path not in blobs),
+ ref=ref,
+ commit_sha=commit_sha,
+ )
+ plugin_facts, plugin_observations, plugin_notices = collect_framework_plugins(plugin_context)
+ template_facts, template_observations = collect_template_files(entries)
+ records.extend(
+ _plugin_records(
+ (*plugin_facts, *template_facts),
+ ref=ref,
+ commit_sha=commit_sha,
+ trust=trust,
+ )
+ )
+ diagnostics.extend(f"{ref.value}:{notice}" for notice in plugin_notices)
if coverage_sink is not None:
for (domain, scope), observations in sorted(coverage_observations.items()):
coverage_sink.append(
@@ -832,6 +903,13 @@ def unavailable_topology(entry: RepositoryObject, reason: str) -> None:
commit_sha=commit_sha,
)
)
+ coverage_sink.extend(
+ _plugin_coverage(
+ (*plugin_observations, *template_observations),
+ ref=ref,
+ commit_sha=commit_sha,
+ )
+ )
return records, diagnostics
diff --git a/src/ocr_toolkit/evidence/framework_plugins.py b/src/ocr_toolkit/evidence/framework_plugins.py
new file mode 100644
index 0000000..e8a502f
--- /dev/null
+++ b/src/ocr_toolkit/evidence/framework_plugins.py
@@ -0,0 +1,590 @@
+"""Derive bounded framework and template facts through static built-in plugins."""
+
+from __future__ import annotations
+
+import re
+from collections.abc import Iterable, Mapping
+from dataclasses import dataclass
+from pathlib import PurePosixPath
+from typing import Protocol
+
+from ocr_toolkit.evidence.coverage import CoverageObservation
+from ocr_toolkit.evidence.model import CoverageState, EvidenceRecord, EvidenceValue, RefRole
+from ocr_toolkit.evidence.repository import RepositoryObject
+
+MAX_PLUGIN_FACTS = 512
+MAX_CONFIGURATION_PATHS = 128
+_FRAMEWORK_SCHEMA = "repository.framework-evidence/v1"
+_TEMPLATE_SCHEMA = "repository.template-evidence/v1"
+
+
+@dataclass(frozen=True, slots=True)
+class PluginFact:
+ """Describe one validated plugin fact before ref provenance is attached."""
+
+ kind: str
+ component: str
+ identity: str
+ source_path: str
+ value: Mapping[str, EvidenceValue]
+
+
+@dataclass(frozen=True, slots=True)
+class PluginCoverage:
+ """Describe one plugin-owned scoped coverage observation."""
+
+ component: str
+ domain: str
+ scope: str
+ observation: CoverageObservation
+
+
+@dataclass(frozen=True, slots=True)
+class FrameworkPluginContext:
+ """Expose immutable normalized facts and bounded tree metadata to one plugin."""
+
+ records: tuple[EvidenceRecord, ...]
+ entries: tuple[RepositoryObject, ...]
+ omitted_paths: tuple[str, ...]
+ ref: RefRole
+ commit_sha: str
+
+
+@dataclass(frozen=True, slots=True)
+class FrameworkPluginResult:
+ """Return bounded plugin facts, coverage, and safe machine notices."""
+
+ facts: tuple[PluginFact, ...]
+ coverage: tuple[PluginCoverage, ...]
+ notices: tuple[str, ...] = ()
+
+
+class FrameworkPlugin(Protocol):
+ """Define the package-owned static framework plugin boundary."""
+
+ plugin_id: str
+
+ def collect(self, context: FrameworkPluginContext) -> FrameworkPluginResult:
+ """Derive facts without I/O, execution, network access, or mutation."""
+
+
+def component_root(path: str) -> str:
+ """Return the canonical manifest directory component or repository root."""
+
+ parent = PurePosixPath(path).parent.as_posix()
+ return "repository" if parent == "." else parent
+
+
+def _fact_value(record: EvidenceRecord) -> Mapping[str, EvidenceValue] | None:
+ """Return one normalized manifest fact mapping from a stored evidence record."""
+
+ if not isinstance(record.value, Mapping):
+ return None
+ fact = record.value.get("fact")
+ return fact if isinstance(fact, Mapping) else None
+
+
+def _package(record: EvidenceRecord) -> str | None:
+ """Return the normalized package name carried by one dependency record."""
+
+ fact = _fact_value(record)
+ name = fact.get("name") if fact is not None else None
+ return name.casefold() if isinstance(name, str) else None
+
+
+def _version(record: EvidenceRecord) -> str | None:
+ """Return the resolved version carried by one lock record."""
+
+ fact = _fact_value(record)
+ value = fact.get("version") if fact is not None else None
+ return value if isinstance(value, str) and value else None
+
+
+def _declared_value(record: EvidenceRecord) -> str:
+ """Return the exact bounded declaration value without resolving it."""
+
+ fact = _fact_value(record)
+ if fact is None:
+ return "unspecified"
+ for key in ("requirement", "constraint", "version"):
+ value = fact.get(key)
+ if isinstance(value, str) and value:
+ return value
+ return "unspecified"
+
+
+def _scope(record: EvidenceRecord) -> str:
+ """Return one exact declaration or resolution scope label."""
+
+ fact = _fact_value(record)
+ value = fact.get("scope") if fact is not None else None
+ return value if isinstance(value, str) and value else "unknown"
+
+
+def _is_direct(record: EvidenceRecord, ecosystem: str) -> bool:
+ """Return whether a declaration directly establishes plugin applicability."""
+
+ scope = _scope(record)
+ if ecosystem == "go":
+ return scope == "direct"
+ return scope not in {"indirect", "exclude", "replace", "provide", "conflict"}
+
+
+def _configuration_paths(
+ entries: Iterable[RepositoryObject], patterns: tuple[re.Pattern[str], ...]
+) -> tuple[str, ...]:
+ """Select bounded regular configuration paths without reading their content."""
+
+ selected = sorted(
+ entry.path
+ for entry in entries
+ if entry.object_type == "blob"
+ and not entry.is_symlink
+ and not entry.is_submodule
+ and any(pattern.search(entry.path) for pattern in patterns)
+ )
+ return tuple(selected[:MAX_CONFIGURATION_PATHS])
+
+
+def _related(
+ name: str,
+ role: str,
+ declarations: tuple[EvidenceRecord, ...],
+ resolutions: tuple[EvidenceRecord, ...],
+) -> dict[str, EvidenceValue]:
+ """Build one deterministic related-stack value."""
+
+ value: dict[str, EvidenceValue] = {
+ "name": name,
+ "role": role,
+ }
+ value["declared_values"] = sorted({_declared_value(record) for record in declarations})
+ value["resolved_versions"] = sorted(
+ {version for record in resolutions if (version := _version(record)) is not None}
+ )
+ value["source_paths"] = sorted({record.source_path for record in (*declarations, *resolutions)})
+ return value
+
+
+def _framework_fact(
+ *,
+ plugin: str,
+ framework: str,
+ ecosystem: str,
+ category: str,
+ component: str,
+ declarations: tuple[EvidenceRecord, ...],
+ resolutions: tuple[EvidenceRecord, ...],
+ configuration_paths: tuple[str, ...],
+ related: tuple[dict[str, EvidenceValue], ...] = (),
+ version_state: str | None = None,
+) -> PluginFact:
+ """Build one closed framework fact with version data outside stable identity."""
+
+ versions = sorted(
+ {version for record in resolutions if (version := _version(record)) is not None}
+ )
+ state = version_state or (
+ "declared-only" if not versions else "resolved" if len(versions) == 1 else "conflicting"
+ )
+ declaration_values: list[dict[str, EvidenceValue]] = []
+ for record in declarations:
+ package = _package(record)
+ if package is None:
+ continue
+ declaration_values.append(
+ {
+ "package": package,
+ "scope": _scope(record),
+ "declared_value": _declared_value(record),
+ "source_path": record.source_path,
+ }
+ )
+ resolution_values: list[dict[str, EvidenceValue]] = []
+ for record in resolutions:
+ package = _package(record)
+ version = _version(record)
+ if package is None or version is None:
+ continue
+ resolution_values.append(
+ {
+ "package": package,
+ "version": version,
+ "source": _scope(record),
+ "source_path": record.source_path,
+ }
+ )
+ source_path = min(record.source_path for record in declarations)
+ value: dict[str, EvidenceValue] = {
+ "schema_version": _FRAMEWORK_SCHEMA,
+ "plugin": plugin,
+ "framework": framework,
+ "ecosystem": ecosystem,
+ "category": category,
+ "version_state": state,
+ }
+ value["declarations"] = sorted(
+ declaration_values,
+ key=lambda item: (
+ str(item.get("package")),
+ str(item.get("scope")),
+ str(item.get("source_path")),
+ ),
+ )
+ value["resolutions"] = sorted(
+ resolution_values,
+ key=lambda item: (
+ str(item.get("package")),
+ str(item.get("version")),
+ str(item.get("source_path")),
+ ),
+ )
+ value["configuration_paths"] = list(configuration_paths)
+ value["related"] = list(related)
+ return PluginFact(
+ "framework.detected",
+ component,
+ f"{plugin}:{framework}",
+ source_path,
+ value,
+ )
+
+
+def _coverage(
+ *,
+ component: str,
+ plugin: str,
+ framework: str | None,
+ state: CoverageState,
+ reason: str,
+ positive: bool = False,
+) -> PluginCoverage:
+ """Build one plugin coverage observation using closed scopes."""
+
+ domain = "framework.declaration" if framework is None else "framework.resolution"
+ scope = plugin if framework is None else f"{plugin}:{framework}"
+ return PluginCoverage(component, domain, scope, CoverageObservation(state, reason, positive))
+
+
+def _components(records: tuple[EvidenceRecord, ...], ecosystem: str) -> tuple[str, ...]:
+ """Return supported manifest components for one ecosystem."""
+
+ return tuple(
+ sorted(
+ {
+ component_root(record.source_path)
+ for record in records
+ if record.kind == "repository.manifest" and record.component == ecosystem
+ }
+ )
+ )
+
+
+def _component_records(
+ records: tuple[EvidenceRecord, ...], component: str, ecosystem: str
+) -> tuple[EvidenceRecord, ...]:
+ """Return dependency records rooted in one canonical component."""
+
+ return tuple(
+ record
+ for record in records
+ if record.component == ecosystem
+ and record.kind in {"dependency.declared", "dependency.locked"}
+ and component_root(record.source_path) == component
+ )
+
+
+@dataclass(frozen=True, slots=True)
+class PackageFrameworkSpec:
+ """Declare one directly detectable package-backed framework."""
+
+ framework: str
+ packages: tuple[str, ...]
+ category: str = "framework"
+
+
+@dataclass(frozen=True, slots=True)
+class RelatedSpec:
+ """Declare one directly detectable related package group."""
+
+ name: str
+ role: str
+ packages: tuple[str, ...]
+
+
+@dataclass(frozen=True, slots=True)
+class PackageFrameworkPlugin:
+ """Implement a deterministic package-backed built-in framework plugin."""
+
+ plugin_id: str
+ ecosystem: str
+ frameworks: tuple[PackageFrameworkSpec, ...]
+ related: tuple[RelatedSpec, ...] = ()
+ configuration_patterns: tuple[re.Pattern[str], ...] = ()
+
+ def collect(self, context: FrameworkPluginContext) -> FrameworkPluginResult:
+ """Derive direct framework declarations and matching lock evidence."""
+
+ facts: list[PluginFact] = []
+ coverage: list[PluginCoverage] = []
+ config_paths = _configuration_paths(context.entries, self.configuration_patterns)
+ for component in _components(context.records, self.ecosystem):
+ records = _component_records(context.records, component, self.ecosystem)
+ declarations = tuple(
+ record
+ for record in records
+ if record.kind == "dependency.declared" and _is_direct(record, self.ecosystem)
+ )
+ locked = tuple(record for record in records if record.kind == "dependency.locked")
+ replacements = tuple(
+ record
+ for record in records
+ if self.ecosystem == "go"
+ and record.kind == "dependency.declared"
+ and _scope(record) == "replace"
+ )
+ component_positive = False
+ related_values: list[dict[str, EvidenceValue]] = []
+ for item in self.related:
+ item_declarations = tuple(
+ record for record in declarations if _package(record) in item.packages
+ )
+ if not item_declarations:
+ continue
+ item_locked = tuple(
+ record for record in locked if _package(record) in item.packages
+ )
+ related_values.append(
+ _related(item.name, item.role, item_declarations, item_locked)
+ )
+ for spec in self.frameworks:
+ direct = tuple(
+ record for record in declarations if _package(record) in spec.packages
+ )
+ if not direct:
+ continue
+ component_positive = True
+ resolved = tuple(record for record in locked if _package(record) in spec.packages)
+ replacement = next(
+ (record for record in replacements if _package(record) in spec.packages),
+ None,
+ )
+ replacement_fact = _fact_value(replacement) if replacement is not None else None
+ explicit_state: str | None = None
+ if replacement_fact is not None:
+ replacement_type = replacement_fact.get("replacement_type")
+ replacement_version = replacement_fact.get("replacement_version")
+ if replacement_type == "local":
+ explicit_state = "local-override"
+ resolved = ()
+ elif isinstance(replacement_version, str) and replacement_version:
+ resolved = (*resolved, replacement)
+ component_config = tuple(
+ path
+ for path in config_paths
+ if component == "repository"
+ or path == component
+ or path.startswith(component + "/")
+ )
+ facts.append(
+ _framework_fact(
+ plugin=self.plugin_id,
+ framework=spec.framework,
+ ecosystem=self.ecosystem,
+ category=spec.category,
+ component=component,
+ declarations=direct,
+ resolutions=resolved,
+ configuration_paths=component_config,
+ related=tuple(related_values),
+ version_state=explicit_state,
+ )
+ )
+ coverage.append(
+ _coverage(
+ component=component,
+ plugin=self.plugin_id,
+ framework=spec.framework,
+ state=CoverageState.COMPLETE if resolved else CoverageState.PARTIAL,
+ reason="lock-version-present" if resolved else "lock-version-missing",
+ positive=True,
+ )
+ )
+ coverage.append(
+ _coverage(
+ component=component,
+ plugin=self.plugin_id,
+ framework=None,
+ state=CoverageState.COMPLETE,
+ reason="direct-manifest-complete",
+ positive=component_positive,
+ )
+ )
+ return FrameworkPluginResult(tuple(facts[:MAX_PLUGIN_FACTS]), tuple(coverage))
+
+
+JINJA2_PLUGIN = PackageFrameworkPlugin(
+ "jinja2",
+ "python",
+ (PackageFrameworkSpec("jinja2", ("jinja2",), "template-engine"),),
+ configuration_patterns=(
+ re.compile(r"(^|/)(pyproject\.toml|requirements[^/]*\.(txt|in)|.*\.lock)$", re.I),
+ ),
+)
+GO_WEB_PLUGIN = PackageFrameworkPlugin(
+ "go-web",
+ "go",
+ (
+ PackageFrameworkSpec("echo", ("github.com/labstack/echo/v4",)),
+ PackageFrameworkSpec("fiber", ("github.com/gofiber/fiber/v2",)),
+ ),
+ related=(RelatedSpec("grpc", "rpc-stack", ("google.golang.org/grpc",)),),
+ configuration_patterns=(re.compile(r"(^|/)go\.(mod|sum)$", re.I),),
+)
+SYMFONY_PLUGIN = PackageFrameworkPlugin(
+ "symfony-php",
+ "php",
+ (
+ PackageFrameworkSpec("symfony", ("symfony/framework-bundle", "symfony/symfony")),
+ PackageFrameworkSpec("twig", ("twig/twig", "symfony/twig-bundle"), "template-engine"),
+ ),
+ configuration_patterns=(
+ re.compile(r"(^|/)composer\.(json|lock)$", re.I),
+ re.compile(r"(^|/)config/(bundles\.php|packages/|routes(?:\.|/|$))", re.I),
+ re.compile(r"\.twig$", re.I),
+ ),
+)
+REACT_PLUGIN = PackageFrameworkPlugin(
+ "react-typescript",
+ "javascript",
+ (
+ PackageFrameworkSpec("react", ("react",)),
+ PackageFrameworkSpec("next", ("next",)),
+ ),
+ related=(
+ RelatedSpec("typescript", "language-toolchain", ("typescript",)),
+ RelatedSpec("vite", "build-tool", ("vite",)),
+ ),
+ configuration_patterns=(
+ re.compile(r"(^|/)(package\.json|package-lock\.json|yarn\.lock|pnpm-lock\.yaml)$", re.I),
+ re.compile(r"(^|/)tsconfig[^/]*\.json$", re.I),
+ re.compile(r"(^|/)(vite|next)\.config\.[^.]+$", re.I),
+ ),
+)
+
+BUILTIN_FRAMEWORK_PLUGINS: tuple[FrameworkPlugin, ...] = (
+ JINJA2_PLUGIN,
+ GO_WEB_PLUGIN,
+ SYMFONY_PLUGIN,
+ REACT_PLUGIN,
+)
+
+
+def collect_framework_plugins(
+ context: FrameworkPluginContext,
+) -> tuple[tuple[PluginFact, ...], tuple[PluginCoverage, ...], tuple[str, ...]]:
+ """Run every static plugin independently and return deterministic bounded output."""
+
+ facts: list[PluginFact] = []
+ coverage: list[PluginCoverage] = []
+ notices: list[str] = []
+ for plugin in BUILTIN_FRAMEWORK_PLUGINS:
+ try:
+ result = plugin.collect(context)
+ except (TypeError, ValueError, RecursionError):
+ notices.append(f"framework plugin unavailable: {plugin.plugin_id}")
+ continue
+ facts.extend(result.facts[:MAX_PLUGIN_FACTS])
+ coverage.extend(result.coverage)
+ notices.extend(result.notices)
+ if len(result.facts) > MAX_PLUGIN_FACTS:
+ notices.append(f"framework plugin fact limit reached: {plugin.plugin_id}")
+ return (
+ tuple(sorted(facts, key=lambda item: (item.kind, item.component, item.identity))),
+ tuple(sorted(coverage, key=lambda item: (item.component, item.domain, item.scope))),
+ tuple(dict.fromkeys(notices)),
+ )
+
+
+def framework_schema_versions() -> tuple[str, str]:
+ """Expose the closed plugin schemas for storage validation."""
+
+ return _FRAMEWORK_SCHEMA, _TEMPLATE_SCHEMA
+
+
+def _rendered_extension(path: str) -> str | None:
+ """Return the target extension before a Jinja marker when one is present."""
+
+ suffixes = PurePosixPath(path).suffixes
+ if len(suffixes) >= 2 and suffixes[-1].casefold() in {".j2", ".jinja", ".jinja2"}:
+ return suffixes[-2].casefold()
+ return None
+
+
+def _ansible_template_component(path: str) -> str | None:
+ """Return a role-root component for conventional nested Ansible templates."""
+
+ parts = PurePosixPath(path).parts
+ folded = tuple(part.casefold() for part in parts)
+ for index, part in enumerate(folded):
+ if part == "roles" and index + 3 <= len(parts) and folded[index + 2] == "templates":
+ return "/".join(parts[: index + 2])
+ return None
+
+
+def collect_template_files(
+ entries: tuple[RepositoryObject, ...],
+) -> tuple[tuple[PluginFact, ...], tuple[PluginCoverage, ...]]:
+ """Inventory Jinja/Twig regular blobs without reading or persisting template content."""
+
+ facts: list[PluginFact] = []
+ coverage_components: set[tuple[str, str]] = set()
+ for entry in entries:
+ if entry.object_type != "blob" or entry.is_symlink or entry.is_submodule:
+ continue
+ folded = entry.path.casefold()
+ role_component = _ansible_template_component(entry.path)
+ if folded.endswith((".j2", ".jinja", ".jinja2")):
+ plugin = engine = "jinja2"
+ detection = "jinja-extension"
+ component = role_component or component_root(entry.path)
+ elif role_component is not None:
+ plugin = engine = "jinja2"
+ detection = "ansible-role-template"
+ component = role_component
+ elif folded.endswith(".twig"):
+ plugin = "symfony-php"
+ engine = "twig"
+ detection = "twig-extension"
+ component = component_root(entry.path)
+ else:
+ continue
+ facts.append(
+ PluginFact(
+ "template.file",
+ component,
+ entry.path,
+ entry.path,
+ {
+ "schema_version": _TEMPLATE_SCHEMA,
+ "plugin": plugin,
+ "engine": engine,
+ "detection": detection,
+ "rendered_extension": _rendered_extension(entry.path),
+ "object_sha": entry.object_sha,
+ },
+ )
+ )
+ coverage_components.add((component, plugin))
+ if len(facts) >= MAX_PLUGIN_FACTS:
+ break
+ coverage = tuple(
+ PluginCoverage(
+ component,
+ "template.inventory",
+ plugin,
+ CoverageObservation(CoverageState.COMPLETE, "bounded-tree-complete", positive=True),
+ )
+ for component, plugin in sorted(coverage_components)
+ )
+ return tuple(facts), coverage
diff --git a/src/ocr_toolkit/evidence/framework_schema.py b/src/ocr_toolkit/evidence/framework_schema.py
new file mode 100644
index 0000000..e6df057
--- /dev/null
+++ b/src/ocr_toolkit/evidence/framework_schema.py
@@ -0,0 +1,172 @@
+"""Validate closed framework and template plugin evidence values."""
+
+from __future__ import annotations
+
+import re
+from collections.abc import Mapping
+from pathlib import PurePosixPath
+from typing import cast
+
+from ocr_toolkit.evidence.model import EvidenceValue
+
+FRAMEWORK_SCHEMA = "repository.framework-evidence/v1"
+TEMPLATE_SCHEMA = "repository.template-evidence/v1"
+PLUGIN_IDS = {"jinja2", "go-web", "symfony-php", "react-typescript"}
+ECOSYSTEMS = {"python", "go", "php", "javascript"}
+CATEGORIES = {"framework", "template-engine"}
+VERSION_STATES = {"declared-only", "resolved", "conflicting", "local-override"}
+TEMPLATE_ENGINES = {"jinja2", "twig"}
+TEMPLATE_DETECTIONS = {"jinja-extension", "ansible-role-template", "twig-extension"}
+OBJECT_SHA_RE = re.compile(r"^[0-9a-f]{40}$")
+
+
+def _exact_mapping(value: object, keys: set[str], label: str) -> Mapping[str, EvidenceValue]:
+ """Require one JSON mapping with exactly the closed key set."""
+
+ if not isinstance(value, Mapping) or set(value) != keys:
+ raise ValueError(f"{label} fields are invalid")
+ return cast(Mapping[str, EvidenceValue], value)
+
+
+def _string(value: object, label: str, *, choices: set[str] | None = None) -> str:
+ """Require one bounded safe string and optional closed enum membership."""
+
+ if (
+ not isinstance(value, str)
+ or not value
+ or len(value) > 512
+ or any(ord(character) < 32 for character in value)
+ or (choices is not None and value not in choices)
+ ):
+ raise ValueError(f"{label} is invalid")
+ return value
+
+
+def _path(value: object, label: str) -> str:
+ """Require one normalized repository-relative POSIX path."""
+
+ path = _string(value, label)
+ pure = PurePosixPath(path)
+ if pure.is_absolute() or any(part in {"", ".", ".."} for part in path.split("/")):
+ raise ValueError(f"{label} is invalid")
+ return path
+
+
+def _objects(value: object, label: str, keys: set[str]) -> tuple[Mapping[str, EvidenceValue], ...]:
+ """Require a bounded sequence of closed objects."""
+
+ if not isinstance(value, (list, tuple)) or len(value) > 128:
+ raise ValueError(f"{label} is invalid")
+ return tuple(_exact_mapping(item, keys, label) for item in value)
+
+
+def validate_framework_value(value: EvidenceValue) -> None:
+ """Validate one `framework.detected` fact against its exact nested schema."""
+
+ root = _exact_mapping(
+ value,
+ {
+ "schema_version",
+ "plugin",
+ "framework",
+ "ecosystem",
+ "category",
+ "declarations",
+ "resolutions",
+ "version_state",
+ "configuration_paths",
+ "related",
+ },
+ "framework evidence",
+ )
+ if root["schema_version"] != FRAMEWORK_SCHEMA:
+ raise ValueError("framework evidence schema is unsupported")
+ _string(root["plugin"], "framework plugin", choices=PLUGIN_IDS)
+ _string(root["framework"], "framework name")
+ _string(root["ecosystem"], "framework ecosystem", choices=ECOSYSTEMS)
+ _string(root["category"], "framework category", choices=CATEGORIES)
+ _string(root["version_state"], "framework version state", choices=VERSION_STATES)
+ declarations = _objects(
+ root["declarations"],
+ "framework declaration",
+ {"package", "scope", "declared_value", "source_path"},
+ )
+ if not declarations:
+ raise ValueError("framework evidence requires a direct declaration")
+ for item in declarations:
+ _string(item["package"], "framework package")
+ _string(item["scope"], "framework scope")
+ _string(item["declared_value"], "framework declared value")
+ _path(item["source_path"], "framework declaration source")
+ for item in _objects(
+ root["resolutions"],
+ "framework resolution",
+ {"package", "version", "source", "source_path"},
+ ):
+ _string(item["package"], "framework package")
+ _string(item["version"], "framework version")
+ _string(item["source"], "framework resolution source")
+ _path(item["source_path"], "framework resolution source path")
+ paths = root["configuration_paths"]
+ if not isinstance(paths, (list, tuple)) or len(paths) > 128:
+ raise ValueError("framework configuration paths are invalid")
+ for path in paths:
+ _path(path, "framework configuration path")
+ for item in _objects(
+ root["related"],
+ "related framework evidence",
+ {"name", "role", "declared_values", "resolved_versions", "source_paths"},
+ ):
+ _string(item["name"], "related framework name")
+ _string(item["role"], "related framework role")
+ for key in ("declared_values", "resolved_versions"):
+ values = item[key]
+ if not isinstance(values, (list, tuple)) or len(values) > 128:
+ raise ValueError("related framework versions are invalid")
+ for entry in values:
+ _string(entry, "related framework value")
+ sources = item["source_paths"]
+ if not isinstance(sources, (list, tuple)) or len(sources) > 128:
+ raise ValueError("related framework source paths are invalid")
+ for source in sources:
+ _path(source, "related framework source path")
+
+
+def validate_template_value(value: EvidenceValue) -> None:
+ """Validate one `template.file` fact against its exact nested schema."""
+
+ root = _exact_mapping(
+ value,
+ {
+ "schema_version",
+ "plugin",
+ "engine",
+ "detection",
+ "rendered_extension",
+ "object_sha",
+ },
+ "template evidence",
+ )
+ if root["schema_version"] != TEMPLATE_SCHEMA:
+ raise ValueError("template evidence schema is unsupported")
+ _string(root["plugin"], "template plugin", choices=PLUGIN_IDS)
+ _string(root["engine"], "template engine", choices=TEMPLATE_ENGINES)
+ _string(root["detection"], "template detection", choices=TEMPLATE_DETECTIONS)
+ rendered = root["rendered_extension"]
+ if rendered is not None:
+ _string(rendered, "rendered extension")
+ object_sha = _string(root["object_sha"], "template object SHA")
+ if not OBJECT_SHA_RE.fullmatch(object_sha):
+ raise ValueError("template object SHA is invalid")
+
+
+def validate_plugin_record(kind: str, value: EvidenceValue) -> None:
+ """Validate one plugin fact nested below the common identity envelope."""
+
+ envelope = _exact_mapping(value, {"identity", "fact"}, "plugin evidence envelope")
+ _string(envelope["identity"], "plugin evidence identity")
+ fact = envelope["fact"]
+ if kind == "framework.detected":
+ validate_framework_value(fact)
+ elif kind == "template.file":
+ validate_template_value(fact)
diff --git a/src/ocr_toolkit/evidence/store.py b/src/ocr_toolkit/evidence/store.py
index 668867b..a7e38f1 100644
--- a/src/ocr_toolkit/evidence/store.py
+++ b/src/ocr_toolkit/evidence/store.py
@@ -18,6 +18,7 @@
redact_env_secret_values,
redact_sensitive,
)
+from ocr_toolkit.evidence.framework_schema import validate_plugin_record
from ocr_toolkit.evidence.model import (
CoverageRecord,
EvidenceDelta,
@@ -53,6 +54,8 @@
"ci.image",
"application.version",
"diagnostic.coverage",
+ "framework.detected",
+ "template.file",
}
)
@@ -150,7 +153,11 @@ def add(self, record: EvidenceRecord) -> bool:
if record.kind not in KNOWN_KINDS:
raise EvidenceStoreError(f"unregistered evidence kind: {record.kind}")
try:
+ if record.kind in {"framework.detected", "template.file"}:
+ validate_plugin_record(record.kind, record.value)
redacted_value = _safe_value(record.value, self.limits.max_value_chars)
+ except ValueError as exc:
+ raise EvidenceStoreError(f"invalid {record.kind} evidence value") from exc
except EvidenceStoreError:
self._diagnose_once(f"omitted oversized {record.kind} evidence value")
return False
diff --git a/tests/test_evidence_framework_plugins.py b/tests/test_evidence_framework_plugins.py
new file mode 100644
index 0000000..1c0a00b
--- /dev/null
+++ b/tests/test_evidence_framework_plugins.py
@@ -0,0 +1,255 @@
+"""Synthetic contracts for static framework and template evidence plugins."""
+
+from __future__ import annotations
+
+import json
+import subprocess
+from pathlib import Path
+from typing import Any
+
+import pytest
+
+from ocr_toolkit.evidence.collect import collect_repository_evidence
+from ocr_toolkit.evidence.mcp import call_tool
+from ocr_toolkit.evidence.model import RefRole
+from ocr_toolkit.evidence.store import EvidenceStore, EvidenceStoreError
+
+
+def git(root: Path, *args: str) -> str:
+ """Run one deterministic Git command in a synthetic repository."""
+
+ return subprocess.check_output(["git", *args], cwd=root, text=True).strip()
+
+
+def commit(root: Path, message: str) -> str:
+ """Commit all synthetic files and return the immutable commit SHA."""
+
+ git(root, "add", ".")
+ git(root, "commit", "-qm", message)
+ return git(root, "rev-parse", "HEAD")
+
+
+def mcp_payload(result: dict[str, object]) -> dict[str, Any]:
+ """Decode one MCP text result into its synthetic JSON payload."""
+
+ content = result["content"]
+ assert isinstance(content, list) and isinstance(content[0], dict)
+ return json.loads(content[0]["text"])
+
+
+def framework_records(store: EvidenceStore, ref: RefRole = RefRole.HEAD) -> list[Any]:
+ """Return framework records for one immutable ref."""
+
+ return [
+ record
+ for record in store.records
+ if record.kind == "framework.detected" and record.ref is ref
+ ]
+
+
+def initialize(root: Path) -> None:
+ """Initialize one synthetic repository identity."""
+
+ git(root, "init", "-q")
+ git(root, "config", "user.name", "Synthetic")
+ git(root, "config", "user.email", "synthetic@example.invalid")
+
+
+def test_jinja_framework_and_templates_are_component_scoped_and_visible_in_mcp(
+ tmp_path: Path,
+) -> None:
+ """Expose direct Jinja version evidence and extensionless role templates."""
+
+ initialize(tmp_path)
+ service = tmp_path / "services" / "renderer"
+ role_templates = tmp_path / "collections" / "demo" / "roles" / "web" / "templates"
+ service.mkdir(parents=True)
+ role_templates.mkdir(parents=True)
+ (service / "pyproject.toml").write_text(
+ '[project]\nname="renderer"\nversion="1.0.0"\ndependencies=["Jinja2>=3.1"]\n',
+ encoding="utf-8",
+ )
+ (service / "pylock.toml").write_text(
+ 'lock-version = "1.0"\n[[packages]]\nname = "jinja2"\nversion = "3.1.6"\n',
+ encoding="utf-8",
+ )
+ (service / "templates").mkdir()
+ (service / "templates" / "app.conf.j2").write_text("port={{ port }}\n", encoding="utf-8")
+ (role_templates / "daemon.conf").write_text("user={{ daemon_user }}\n", encoding="utf-8")
+ base = commit(tmp_path, "base")
+ (service / "templates" / "app.conf.j2").write_text(
+ "port={{ port }}\nsecure={{ secure }}\n", encoding="utf-8"
+ )
+ head = commit(tmp_path, "update template")
+
+ store = collect_repository_evidence(tmp_path, base_ref=base, head_ref=head)
+ jinja = next(
+ record
+ for record in framework_records(store)
+ if record.value["fact"]["framework"] == "jinja2"
+ )
+ assert jinja.component == "services/renderer"
+ assert jinja.value["fact"]["version_state"] == "resolved"
+ assert jinja.value["fact"]["resolutions"][0]["version"] == "3.1.6"
+
+ templates = [
+ record
+ for record in store.records
+ if record.kind == "template.file" and record.ref is RefRole.HEAD
+ ]
+ assert {record.source_path for record in templates} == {
+ "services/renderer/templates/app.conf.j2",
+ "collections/demo/roles/web/templates/daemon.conf",
+ }
+ role_template = next(
+ record for record in templates if record.source_path.endswith("daemon.conf")
+ )
+ assert role_template.component == "collections/demo/roles/web"
+ assert role_template.value["fact"]["detection"] == "ansible-role-template"
+ assert any(
+ delta.kind == "template.file"
+ and delta.identity == "services/renderer/templates/app.conf.j2"
+ and delta.change == "changed"
+ for delta in store.deltas
+ )
+
+ listed = mcp_payload(
+ call_tool(
+ store,
+ {
+ "action": "list",
+ "kind": "framework.detected",
+ "component": "services/renderer",
+ "ref": "head",
+ },
+ )
+ )
+ assert [record["value"]["fact"]["framework"] for record in listed["records"]] == ["jinja2"]
+
+
+def test_lock_only_and_transitive_go_packages_do_not_activate_frameworks(tmp_path: Path) -> None:
+ """Require direct declarations rather than lock/checksum package presence."""
+
+ initialize(tmp_path)
+ (tmp_path / "go.mod").write_text(
+ "module synthetic.invalid/service\n\ngo 1.24\n", encoding="utf-8"
+ )
+ (tmp_path / "go.sum").write_text(
+ "github.com/labstack/echo/v4 v4.13.4 h1:synthetic\n", encoding="utf-8"
+ )
+ (tmp_path / "pylock.toml").write_text(
+ 'lock-version = "1.0"\n[[packages]]\nname = "jinja2"\nversion = "3.1.6"\n',
+ encoding="utf-8",
+ )
+ base = commit(tmp_path, "base")
+ (tmp_path / "README.md").write_text("synthetic\n", encoding="utf-8")
+ head = commit(tmp_path, "docs")
+
+ store = collect_repository_evidence(tmp_path, base_ref=base, head_ref=head)
+ assert framework_records(store) == []
+
+
+def test_go_php_and_frontend_plugins_keep_related_signals_in_the_same_component(
+ tmp_path: Path,
+) -> None:
+ """Detect direct core frameworks and related stacks without code graphs."""
+
+ initialize(tmp_path)
+ api = tmp_path / "api"
+ web = tmp_path / "web"
+ ui = tmp_path / "ui"
+ for path in (api, web, ui):
+ path.mkdir()
+ (api / "go.mod").write_text(
+ """module synthetic.invalid/api
+
+go 1.24
+
+require (
+ github.com/labstack/echo/v4 v4.13.4
+ google.golang.org/grpc v1.71.0
+)
+""",
+ encoding="utf-8",
+ )
+ (web / "composer.json").write_text(
+ json.dumps(
+ {
+ "require": {
+ "php": "^8.3",
+ "symfony/framework-bundle": "^7.2",
+ "twig/twig": "^3.0",
+ }
+ }
+ ),
+ encoding="utf-8",
+ )
+ (web / "composer.lock").write_text(
+ json.dumps(
+ {
+ "packages": [
+ {"name": "symfony/framework-bundle", "version": "v7.2.4"},
+ {"name": "twig/twig", "version": "v3.19.0"},
+ ]
+ }
+ ),
+ encoding="utf-8",
+ )
+ (web / "templates").mkdir()
+ (web / "templates" / "page.twig").write_text("{{ title }}\n", encoding="utf-8")
+ (ui / "package.json").write_text(
+ json.dumps(
+ {
+ "dependencies": {"react": "^19.0.0"},
+ "devDependencies": {"typescript": "^5.8", "vite": "^6.2"},
+ }
+ ),
+ encoding="utf-8",
+ )
+ base = commit(tmp_path, "base")
+ (ui / "tsconfig.json").write_text("{}\n", encoding="utf-8")
+ head = commit(tmp_path, "add frontend config")
+
+ store = collect_repository_evidence(tmp_path, base_ref=base, head_ref=head)
+ facts = {
+ (record.component, record.value["fact"]["framework"]): record.value["fact"]
+ for record in framework_records(store)
+ }
+ assert ("api", "echo") in facts
+ assert facts[("api", "echo")]["related"][0]["name"] == "grpc"
+ assert ("web", "symfony") in facts and ("web", "twig") in facts
+ assert facts[("web", "symfony")]["version_state"] == "resolved"
+ assert ("ui", "react") in facts
+ assert {item["name"] for item in facts[("ui", "react")]["related"]} == {
+ "typescript",
+ "vite",
+ }
+ twig = next(
+ record
+ for record in store.records
+ if record.kind == "template.file" and record.source_path.endswith("page.twig")
+ )
+ assert twig.value["fact"]["engine"] == "twig"
+
+
+def test_plugin_nested_schema_is_revalidated_on_store_load(tmp_path: Path) -> None:
+ """Reject unknown nested fields in persisted framework evidence."""
+
+ initialize(tmp_path)
+ (tmp_path / "pyproject.toml").write_text(
+ '[project]\nname="demo"\nversion="1"\ndependencies=["jinja2==3.1.6"]\n',
+ encoding="utf-8",
+ )
+ base = commit(tmp_path, "base")
+ (tmp_path / "README.md").write_text("change\n", encoding="utf-8")
+ head = commit(tmp_path, "head")
+ store = collect_repository_evidence(tmp_path, base_ref=base, head_ref=head)
+ payload = store.to_dict()
+ record = next(item for item in payload["records"] if item["kind"] == "framework.detected")
+ record["value"]["fact"]["unknown"] = True
+ record.pop("id")
+ path = tmp_path / "hostile.json"
+ path.write_text(json.dumps(payload), encoding="utf-8")
+
+ with pytest.raises(EvidenceStoreError, match=r"invalid framework.detected"):
+ EvidenceStore.read(path)
diff --git a/tests/test_integration_contracts.py b/tests/test_integration_contracts.py
index bf6fe7e..524108f 100644
--- a/tests/test_integration_contracts.py
+++ b/tests/test_integration_contracts.py
@@ -11,7 +11,8 @@
def test_project_rules_extend_instead_of_replacing_ocr_system_rules() -> None:
"""Keep project additions narrow so OCR owns generic language guidance."""
- rules = json.loads((HELPER_DIR / "rules.json").read_text(encoding="utf-8"))["rules"]
+ payload = json.loads((HELPER_DIR / "rules.json").read_text(encoding="utf-8"))
+ rules = payload["rules"]
paths = [rule["path"] for rule in rules]
assert not {
@@ -28,6 +29,18 @@ def test_project_rules_extend_instead_of_replacing_ocr_system_rules() -> None:
"{requirements.yml,requirements.yaml,**/requirements.yml,**/requirements.yaml}"
) in paths
assert "{pyproject.toml,uv.lock,**/pyproject.toml,**/uv.lock}" in paths
+ assert payload["include"] == [
+ "{*.j2,*.jinja,*.jinja2,*.twig}",
+ "**/*.{j2,jinja,jinja2,twig}",
+ "roles/*/templates/**",
+ "**/roles/*/templates/**",
+ ]
+ assert paths[:3] == [
+ "{roles/*/templates/**,**/roles/*/templates/**}",
+ "{*.j2,*.jinja,*.jinja2,**/*.j2,**/*.jinja,**/*.jinja2}",
+ "{*.twig,**/*.twig}",
+ ]
+ assert all(rule["merge_system_rule"] is True for rule in rules[:3])
def test_gitlab_example_preserves_review_gating_and_manual_self_test() -> None:
From 9da475e37e99fa7fc3a1792932b05c8de29b7677 Mon Sep 17 00:00:00 2001
From: xeonvs <11463419+xeonvs@users.noreply.github.com>
Date: Tue, 11 Aug 2026 19:01:29 +0200
Subject: [PATCH 05/11] Align release workflow contract tests
---
tests/test_testpypi_preview.py | 8 +++++---
1 file changed, 5 insertions(+), 3 deletions(-)
diff --git a/tests/test_testpypi_preview.py b/tests/test_testpypi_preview.py
index 73eac6c..8475859 100644
--- a/tests/test_testpypi_preview.py
+++ b/tests/test_testpypi_preview.py
@@ -218,10 +218,12 @@ def test_production_release_verifies_reviewed_registry_artifacts() -> None:
assert "attestations: true" in workflow
assert workflow.count("verify_registry_artifacts.sh") == 2
assert workflow.count('python: ["3.12", "3.13", "3.14"]') == 2
- assert "release_exists=false" in workflow
- assert "authenticated 200,404" in workflow
+ assert "python scripts/github_release_api.py ensure" in workflow
+ assert "python scripts/github_release_api.py upload" in workflow
+ assert "python scripts/github_release_api.py publish" in workflow
+ assert "release_id=$(jq -r .id" in workflow
assert "release_is_draft=$(jq -r .draft" in workflow
- assert "existing GitHub Release metadata does not match" in workflow
+ assert "invalid GitHub Release numeric identity" in workflow
assert workflow.count("timeout-minutes:") == 8
assert workflow.count("--max-filesize 10485760") >= 1
assert "--retry 3 --retry-delay 2 --retry-connrefused" in verifier
From 5688342184dcbeb59c535e4a733bfe5eb3d8588c Mon Sep 17 00:00:00 2001
From: xeonvs <11463419+xeonvs@users.noreply.github.com>
Date: Tue, 11 Aug 2026 19:25:31 +0200
Subject: [PATCH 06/11] Harden framework plugin evidence boundaries
---
PLANS.md | 20 +-
docs/configuration.md | 4 +-
docs/engineering/toolkit_strategy.md | 2 +-
src/ocr_toolkit/evidence/collectors.py | 79 ++-
src/ocr_toolkit/evidence/framework_plugins.py | 579 +++++++++++++-----
src/ocr_toolkit/evidence/framework_schema.py | 112 +++-
tests/test_evidence_framework_plugins.py | 293 ++++++++-
7 files changed, 896 insertions(+), 193 deletions(-)
diff --git a/PLANS.md b/PLANS.md
index 069bd51..e1f0008 100644
--- a/PLANS.md
+++ b/PLANS.md
@@ -65,6 +65,8 @@ review without GitLab posting, before protected feature and stable delivery.
signals and npm/Yarn/pnpm resolution.
8. [ ] Complete cross-provider deltas, coverage, bootstrap/MCP projections,
documentation, strategy, roadmap, backlog, and milestone reconciliation.
+ Self-review hardening of provider semantics and bounds is complete; final
+ backlog/roadmap reconciliation remains pending until release-grade gates.
9. [ ] Run complete Python 3.12-3.14, security, privacy, package, installed
artifact, rules-preview, real-MCP-client, and synthetic no-post E2E gates.
10. [ ] Run exactly one final local full OCR review, fix findings, then finish
@@ -117,12 +119,18 @@ review without GitLab posting, before protected feature and stable delivery.
preview probes select root, nested, role, and Twig paths that were previously
rejected as `unsupported_ext`.
- Focused self-review confirmed lock/checksum-only packages do not activate a
- framework, components come from manifest/role roots, plugin failures remain
- isolated, excludes retain precedence, ordinary supported files remain
- reviewable, and the MCP requires no new server or tool. The combined evidence
- and integration suite passes 192 tests; focused Ruff and strict mypy pass.
- Public configuration, GitLab, and strategy documentation now describe the
- implemented boundaries and review-selection behavior.
+ framework, components follow the nearest manifest or conventional role root,
+ and the MCP requires no new server or tool. Follow-up hardening now records
+ exact supported-source states, treats direct/effectively replaced `go.mod`
+ versions correctly, isolates every package-owned provider failure, binds
+ nested plugin/framework/engine identities, and degrades declaration,
+ resolution, configuration, or template coverage on malformed/omitted inputs,
+ item/path/fact limits, local replacements, or unsafe object types. Excludes
+ retain precedence and ordinary supported files remain reviewable. The focused
+ cross-provider suite passes 75 tests; its dedicated plugin suite passes 10,
+ while focused Ruff and strict mypy pass. Public configuration, GitLab, and
+ strategy documentation describe the implemented boundaries, degradation, and
+ review-selection behavior.
### Initial Evidence
diff --git a/docs/configuration.md b/docs/configuration.md
index 16938b2..cf6aaa9 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -84,11 +84,11 @@ The private `.review-context/evidence.json` store and `.review-context/bootstrap
The compact bootstrap contains the same safe inventory of independent server/tool entries that was written to OCR configuration. The mandatory built-in server exposes `ocr_toolkit_evidence`, with `summary`, paginated/filterable `list`, and stable-ID `get` actions. It has no mutation action, network access, or shell execution. Optional MCP entries expose their own allowlisted tools; they can coexist with but cannot remove or shadow the mandatory entry.
-Evidence-store schema v2 includes closed `framework.detected` (`repository.framework-evidence/v1`) and `template.file` (`repository.template-evidence/v1`) facts from package-owned static plugins. Current plugins cover Jinja2, Echo/Fiber, Symfony/Twig, and React/Next with related gRPC, TypeScript, and Vite declarations. Plugins consume only already bounded immutable manifest/tree evidence: they cannot execute repository commands, load repository code, use network access, or start a second MCP server. Detailed declarations, resolutions, configuration/template paths, component scopes, and base/head deltas remain available through the existing `ocr_toolkit_evidence` summary/list/get interface.
+Evidence-store schema v2 includes closed `framework.detected` (`repository.framework-evidence/v1`) and `template.file` (`repository.template-evidence/v1`) facts from package-owned static plugins. Current plugins cover Jinja2, Echo/Fiber, Symfony/Twig, and React/Next with related gRPC, TypeScript, and Vite declarations. Plugins consume only already bounded immutable manifest/tree evidence: they cannot execute repository commands, load repository code, use network access, or start a second MCP server. Framework versions use the ecosystem's deterministic source: lock files for Python, Composer, and JavaScript, but the direct requirement or effective replacement in `go.mod` for Go. Local Go replacements remain explicit partial evidence rather than being mistaken for the replaced module version. Templates and configuration paths belong to the nearest manifest-root component; conventional Ansible-role templates retain the role root. Detailed declarations, resolutions, effective replacements, configuration/template paths, component scopes, and base/head deltas remain available through the existing `ocr_toolkit_evidence` summary/list/get interface.
The synthetic GitLab `rules.json` uses additive `include` entries for `.j2`, `.jinja`, `.jinja2`, `.twig`, and conventional Ansible-role template paths because OCR 1.9.1 does not review those extensions by default. Explicit excludes still win. The matching Jinja/Twig rules are review guidance; they do not execute or render templates, infer runtime variables, or replace evidence completeness.
-Evidence-store schema v2 adds `repository.evidence-coverage/v1` records keyed by component, domain, scope, immutable ref, and commit. Framework plugins publish `framework.declaration`, `framework.resolution`, and `template.inventory` scopes. Only `complete` coverage permits a missing positive fact to support an absence claim; absent, `partial`, `runtime-dependent`, and `unavailable` coverage mean unknown. Schema-v1 stores remain readable but are explicitly treated as having unknown completeness. The Ansible adopter recognizes static, plugin-based, and executable inventory sources without execution and models the recursive role `defaults/main/` and `vars/main/` loader surface verified for ansible-core 2.17 through the current 2.x loader contract. Unsupported later loader behavior or bounded read/parser failures degrade coverage rather than becoming false completeness.
+Evidence-store schema v2 adds `repository.evidence-coverage/v1` records keyed by component, domain, scope, immutable ref, and commit. Framework plugins publish `framework.declaration`, `framework.resolution`, `framework.configuration`, and `template.inventory` scopes. Supported malformed or omitted manifests, source-item limits, configuration/template output limits, unsafe template object types, local Go replacements, and isolated provider failures all prevent a false completeness claim. Only `complete` coverage permits a missing positive fact to support an absence claim; absent, `partial`, `runtime-dependent`, and `unavailable` coverage mean unknown. Schema-v1 stores remain readable but are explicitly treated as having unknown completeness. The Ansible adopter recognizes static, plugin-based, and executable inventory sources without execution and models the recursive role `defaults/main/` and `vars/main/` loader surface verified for ansible-core 2.17 through the current 2.x loader contract. Unsupported later loader behavior or bounded read/parser failures degrade coverage rather than becoming false completeness.
The review step maps OCR's structured `tool_calls.by_tool` counters onto the exact validated registry used for that invocation and stores only positive per-server counts in a schema-versioned `_ocr_toolkit` receipt inside the private result. The later GitLab posting step reads that receipt instead of rebuilding MCP configuration from a possibly changed environment. Its summary omits configured-but-unused servers and all zero counters; the receipt never stores server URLs, commands, arguments, headers, tool inputs, tool results, or repository contents.
diff --git a/docs/engineering/toolkit_strategy.md b/docs/engineering/toolkit_strategy.md
index db3ffdb..aa36fe2 100644
--- a/docs/engineering/toolkit_strategy.md
+++ b/docs/engineering/toolkit_strategy.md
@@ -87,7 +87,7 @@ Implemented collectors cover Python declarations, requirements, uv, Poetry, Pipe
### Framework and template evidence
-Framework support is package-owned static plugin extraction, not a code graph or framework-specific review engine. The established registry covers Jinja2 and Jinja/Ansible-style templates, Echo/Fiber with direct gRPC stack context, Symfony/Twig, and React/Next with TypeScript/Vite context. Plugins receive only immutable normalized dependency/tree evidence from the core collector, publish closed framework/template facts plus scoped completeness, and cannot read the repository independently, execute commands, fetch the network, mutate state, or create another MCP/review flow.
+Framework support is package-owned static plugin extraction, not a code graph or framework-specific review engine. The established registry covers Jinja2 and Jinja/Ansible-style templates, Echo/Fiber with direct gRPC stack context, Symfony/Twig, and React/Next with TypeScript/Vite context. Plugins receive only immutable normalized dependency/tree evidence and exact core-owned source-status records from the collector, publish closed framework/template facts plus scoped completeness, and cannot read the repository independently, execute commands, fetch the network, mutate state, or create another MCP/review flow. Components follow the nearest declaration manifest (or conventional Ansible role), Go uses effective `go.mod` requirement/replacement semantics, and every read, parse, configuration, template, or provider limit degrades its exact scope instead of turning missing facts into proof.
OCR file selection remains a separate review-engine boundary. The public synthetic rules pack explicitly includes Jinja and Twig template paths that the recommended OCR does not allowlist by default, then supplies narrowly scoped merged rules. Framework identity, versions, component scope, configuration paths, and template deltas are stored once and served on demand by the existing built-in evidence MCP; rules neither duplicate those facts nor render templates.
diff --git a/src/ocr_toolkit/evidence/collectors.py b/src/ocr_toolkit/evidence/collectors.py
index 1d9cede..1b105c3 100644
--- a/src/ocr_toolkit/evidence/collectors.py
+++ b/src/ocr_toolkit/evidence/collectors.py
@@ -23,6 +23,7 @@
FrameworkPluginContext,
PluginCoverage,
PluginFact,
+ PluginSourceStatus,
collect_framework_plugins,
collect_template_files,
)
@@ -89,9 +90,10 @@
@dataclass(frozen=True, slots=True)
class ManifestCollector:
- """Bind manifest path matching, ecosystem metadata, and a bounded parser."""
+ """Bind manifest path matching, ecosystem metadata, role, and bounded parser."""
ecosystem: str
+ source_roles: tuple[str, ...]
matches: Callable[[str], bool]
parse: Callable[[str], ManifestParseResult]
@@ -162,26 +164,28 @@ def _is_pylock(path: str) -> bool:
MANIFEST_COLLECTORS = (
- ManifestCollector("python", _name_is("pyproject.toml"), parse_pyproject),
- ManifestCollector("python", _is_python_requirements, parse_requirements),
- ManifestCollector("python", _name_is("uv.lock"), parse_uv_lock),
- ManifestCollector("python", _name_is("poetry.lock"), parse_poetry_lock),
- ManifestCollector("python", _name_is("Pipfile.lock"), parse_pipfile_lock),
- ManifestCollector("python", _is_pylock, parse_pylock),
- ManifestCollector("javascript", _name_is("package.json"), parse_package_json),
+ ManifestCollector("python", ("declaration",), _name_is("pyproject.toml"), parse_pyproject),
+ ManifestCollector("python", ("declaration",), _is_python_requirements, parse_requirements),
+ ManifestCollector("python", ("resolution",), _name_is("uv.lock"), parse_uv_lock),
+ ManifestCollector("python", ("resolution",), _name_is("poetry.lock"), parse_poetry_lock),
+ ManifestCollector("python", ("resolution",), _name_is("Pipfile.lock"), parse_pipfile_lock),
+ ManifestCollector("python", ("resolution",), _is_pylock, parse_pylock),
+ ManifestCollector("javascript", ("declaration",), _name_is("package.json"), parse_package_json),
ManifestCollector(
"javascript",
+ ("resolution",),
_name_is("package-lock.json"),
parse_package_lock,
),
- ManifestCollector("javascript", _name_is("yarn.lock"), parse_yarn_lock),
- ManifestCollector("javascript", _name_is("pnpm-lock.yaml"), parse_pnpm_lock),
- ManifestCollector("go", _name_is("go.mod"), parse_go_mod),
- ManifestCollector("go", _name_is("go.sum"), parse_go_sum),
- ManifestCollector("php", _name_is("composer.json"), parse_composer_json),
- ManifestCollector("php", _name_is("composer.lock"), parse_composer_lock),
+ ManifestCollector("javascript", ("resolution",), _name_is("yarn.lock"), parse_yarn_lock),
+ ManifestCollector("javascript", ("resolution",), _name_is("pnpm-lock.yaml"), parse_pnpm_lock),
+ ManifestCollector("go", ("declaration", "resolution"), _name_is("go.mod"), parse_go_mod),
+ ManifestCollector("go", ("checksum",), _name_is("go.sum"), parse_go_sum),
+ ManifestCollector("php", ("declaration",), _name_is("composer.json"), parse_composer_json),
+ ManifestCollector("php", ("resolution",), _name_is("composer.lock"), parse_composer_lock),
ManifestCollector(
"ansible",
+ ("declaration",),
_name_is("requirements.yml", "requirements.yaml"),
_parse_ansible_requirements,
),
@@ -668,6 +672,16 @@ def unavailable_topology(entry: RepositoryObject, reason: str) -> None:
and not entry.is_submodule
and entry.object_type == "blob"
)
+ source_statuses: dict[str, PluginSourceStatus] = {
+ entry.path: PluginSourceStatus(
+ entry.path,
+ collector.ecosystem,
+ collector.source_roles,
+ "pending",
+ )
+ for entry in candidates
+ if (collector := manifest_collector(entry.path)) is not None
+ }
try:
read = reader.read_candidate_blobs(candidates)
except RepositoryEvidenceError as exc:
@@ -688,6 +702,13 @@ def unavailable_topology(entry: RepositoryObject, reason: str) -> None:
)
return records, diagnostics
blobs = read.blobs
+ for path, status in tuple(source_statuses.items()):
+ source_statuses[path] = PluginSourceStatus(
+ status.path,
+ status.ecosystem,
+ status.roles,
+ "accepted" if path in blobs else "omitted",
+ )
diagnostics.extend(f"{ref.value}:{message}" for message in read.diagnostics)
galaxy_roots = tuple(
entry.path
@@ -777,6 +798,22 @@ def unavailable_topology(entry: RepositoryObject, reason: str) -> None:
raise ValueError("supported manifest has no collector")
parsed = collector.parse(text)
diagnostics.extend(f"{ref.value}:{path}: {notice}" for notice in parsed.notices)
+ source_status = source_statuses.get(path)
+ if any("truncated" in notice for notice in parsed.notices):
+ if source_status is not None:
+ source_statuses[path] = PluginSourceStatus(
+ source_status.path,
+ source_status.ecosystem,
+ source_status.roles,
+ "partial",
+ )
+ elif source_status is not None:
+ source_statuses[path] = PluginSourceStatus(
+ source_status.path,
+ source_status.ecosystem,
+ source_status.roles,
+ "complete",
+ )
facts = [
ManifestFact(
"repository.manifest",
@@ -834,6 +871,14 @@ def unavailable_topology(entry: RepositoryObject, reason: str) -> None:
)
if topology_source and entry is not None:
unavailable_topology(entry, "parse-unavailable")
+ source_status = source_statuses.get(path)
+ if source_status is not None:
+ source_statuses[path] = PluginSourceStatus(
+ source_status.path,
+ source_status.ecosystem,
+ source_status.roles,
+ "unavailable",
+ )
continue
for fact in facts:
if fact.kind.startswith("ansible.") and fact.kind != "dependency.declared":
@@ -876,12 +921,12 @@ def unavailable_topology(entry: RepositoryObject, reason: str) -> None:
plugin_context = FrameworkPluginContext(
records=tuple(records),
entries=entries,
- omitted_paths=tuple(entry.path for entry in candidates if entry.path not in blobs),
+ source_statuses=tuple(sorted(source_statuses.values(), key=lambda item: item.path)),
ref=ref,
commit_sha=commit_sha,
)
plugin_facts, plugin_observations, plugin_notices = collect_framework_plugins(plugin_context)
- template_facts, template_observations = collect_template_files(entries)
+ template_facts, template_observations, template_notices = collect_template_files(plugin_context)
records.extend(
_plugin_records(
(*plugin_facts, *template_facts),
@@ -890,7 +935,7 @@ def unavailable_topology(entry: RepositoryObject, reason: str) -> None:
trust=trust,
)
)
- diagnostics.extend(f"{ref.value}:{notice}" for notice in plugin_notices)
+ diagnostics.extend(f"{ref.value}:{notice}" for notice in (*plugin_notices, *template_notices))
if coverage_sink is not None:
for (domain, scope), observations in sorted(coverage_observations.items()):
coverage_sink.append(
diff --git a/src/ocr_toolkit/evidence/framework_plugins.py b/src/ocr_toolkit/evidence/framework_plugins.py
index e8a502f..ef57edb 100644
--- a/src/ocr_toolkit/evidence/framework_plugins.py
+++ b/src/ocr_toolkit/evidence/framework_plugins.py
@@ -12,7 +12,9 @@
from ocr_toolkit.evidence.model import CoverageState, EvidenceRecord, EvidenceValue, RefRole
from ocr_toolkit.evidence.repository import RepositoryObject
-MAX_PLUGIN_FACTS = 512
+# Each evidence kind shares a 512-record store limit across base and head. Capping
+# each immutable side at half keeps accepted records, deltas, and coverage atomic.
+MAX_PLUGIN_FACTS = 256
MAX_CONFIGURATION_PATHS = 128
_FRAMEWORK_SCHEMA = "repository.framework-evidence/v1"
_TEMPLATE_SCHEMA = "repository.template-evidence/v1"
@@ -39,13 +41,23 @@ class PluginCoverage:
observation: CoverageObservation
+@dataclass(frozen=True, slots=True)
+class PluginSourceStatus:
+ """Describe one supported manifest source and its bounded collection state."""
+
+ path: str
+ ecosystem: str
+ roles: tuple[str, ...]
+ state: str
+
+
@dataclass(frozen=True, slots=True)
class FrameworkPluginContext:
"""Expose immutable normalized facts and bounded tree metadata to one plugin."""
records: tuple[EvidenceRecord, ...]
entries: tuple[RepositoryObject, ...]
- omitted_paths: tuple[str, ...]
+ source_statuses: tuple[PluginSourceStatus, ...]
ref: RefRole
commit_sha: str
@@ -75,10 +87,10 @@ def component_root(path: str) -> str:
return "repository" if parent == "." else parent
-def _fact_value(record: EvidenceRecord) -> Mapping[str, EvidenceValue] | None:
+def _fact_value(record: EvidenceRecord | None) -> Mapping[str, EvidenceValue] | None:
"""Return one normalized manifest fact mapping from a stored evidence record."""
- if not isinstance(record.value, Mapping):
+ if record is None or not isinstance(record.value, Mapping):
return None
fact = record.value.get("fact")
return fact if isinstance(fact, Mapping) else None
@@ -93,7 +105,7 @@ def _package(record: EvidenceRecord) -> str | None:
def _version(record: EvidenceRecord) -> str | None:
- """Return the resolved version carried by one lock record."""
+ """Return the exact version carried by one declaration or lock record."""
fact = _fact_value(record)
value = fact.get("version") if fact is not None else None
@@ -130,20 +142,48 @@ def _is_direct(record: EvidenceRecord, ecosystem: str) -> bool:
return scope not in {"indirect", "exclude", "replace", "provide", "conflict"}
-def _configuration_paths(
- entries: Iterable[RepositoryObject], patterns: tuple[re.Pattern[str], ...]
-) -> tuple[str, ...]:
- """Select bounded regular configuration paths without reading their content."""
-
- selected = sorted(
- entry.path
- for entry in entries
- if entry.object_type == "blob"
- and not entry.is_symlink
- and not entry.is_submodule
- and any(pattern.search(entry.path) for pattern in patterns)
+def _path_is_within(path: str, component: str) -> bool:
+ """Return whether one repository path is inside a canonical component."""
+
+ return component == "repository" or path == component or path.startswith(component + "/")
+
+
+def _owning_component(path: str, components: tuple[str, ...]) -> str | None:
+ """Return the nearest manifest-root component that owns one repository path."""
+
+ matches = tuple(component for component in components if _path_is_within(path, component))
+ return (
+ max(matches, key=lambda item: 0 if item == "repository" else item.count("/") + 1)
+ if matches
+ else None
)
- return tuple(selected[:MAX_CONFIGURATION_PATHS])
+
+
+def _configuration_paths(
+ entries: Iterable[RepositoryObject],
+ patterns: tuple[re.Pattern[str], ...],
+ *,
+ component: str,
+ components: tuple[str, ...],
+) -> tuple[tuple[str, ...], bool]:
+ """Select component-owned regular configuration paths and report truncation."""
+
+ selected: list[str] = []
+ truncated = False
+ for entry in sorted(entries, key=lambda item: item.path):
+ if (
+ entry.object_type != "blob"
+ or entry.is_symlink
+ or entry.is_submodule
+ or _owning_component(entry.path, components) != component
+ or not any(pattern.search(entry.path) for pattern in patterns)
+ ):
+ continue
+ if len(selected) >= MAX_CONFIGURATION_PATHS:
+ truncated = True
+ continue
+ selected.append(entry.path)
+ return tuple(selected), truncated
def _related(
@@ -151,21 +191,80 @@ def _related(
role: str,
declarations: tuple[EvidenceRecord, ...],
resolutions: tuple[EvidenceRecord, ...],
+ *,
+ declarations_resolve: bool = False,
) -> dict[str, EvidenceValue]:
"""Build one deterministic related-stack value."""
- value: dict[str, EvidenceValue] = {
- "name": name,
- "role": role,
- }
+ resolved_records = declarations if declarations_resolve else resolutions
+ value: dict[str, EvidenceValue] = {"name": name, "role": role}
value["declared_values"] = sorted({_declared_value(record) for record in declarations})
value["resolved_versions"] = sorted(
- {version for record in resolutions if (version := _version(record)) is not None}
+ {version for record in resolved_records if (version := _version(record)) is not None}
)
value["source_paths"] = sorted({record.source_path for record in (*declarations, *resolutions)})
return value
+def _replacement_value(record: EvidenceRecord | None) -> dict[str, EvidenceValue] | None:
+ """Return one closed Go replacement object without reading replacement content."""
+
+ fact = _fact_value(record)
+ if fact is None:
+ return None
+ target = fact.get("replacement")
+ replacement_type = fact.get("replacement_type")
+ version = fact.get("replacement_version")
+ if not isinstance(target, str) or replacement_type not in {"local", "module"}:
+ return None
+ return {
+ "target": target,
+ "type": replacement_type,
+ "version": version if isinstance(version, str) and version else None,
+ }
+
+
+def _resolution_values(
+ declarations: tuple[EvidenceRecord, ...],
+ resolutions: tuple[EvidenceRecord, ...],
+ *,
+ replacement: EvidenceRecord | None,
+ declarations_resolve: bool,
+) -> list[dict[str, EvidenceValue]]:
+ """Build deterministic effective resolution rows for one framework."""
+
+ replacement_value = _replacement_value(replacement)
+ if replacement_value is not None:
+ version = replacement_value["version"]
+ package = _package(replacement) if replacement is not None else None
+ if replacement_value["type"] == "module" and isinstance(version, str) and package:
+ return [
+ {
+ "package": package,
+ "version": version,
+ "source": "go.replace",
+ "source_path": replacement.source_path,
+ }
+ ]
+ return []
+
+ records = declarations if declarations_resolve else resolutions
+ values = {
+ (
+ package,
+ version,
+ "go.mod" if declarations_resolve and record in declarations else _scope(record),
+ record.source_path,
+ )
+ for record in records
+ if (package := _package(record)) is not None and (version := _version(record)) is not None
+ }
+ return [
+ {"package": package, "version": version, "source": source, "source_path": path}
+ for package, version, source, path in sorted(values)
+ ]
+
+
def _framework_fact(
*,
plugin: str,
@@ -176,16 +275,29 @@ def _framework_fact(
declarations: tuple[EvidenceRecord, ...],
resolutions: tuple[EvidenceRecord, ...],
configuration_paths: tuple[str, ...],
+ configuration_state: str,
related: tuple[dict[str, EvidenceValue], ...] = (),
- version_state: str | None = None,
+ replacement: EvidenceRecord | None = None,
+ declarations_resolve: bool = False,
) -> PluginFact:
- """Build one closed framework fact with version data outside stable identity."""
+ """Build one closed framework fact with mutable version data outside identity."""
- versions = sorted(
- {version for record in resolutions if (version := _version(record)) is not None}
+ resolution_values = _resolution_values(
+ declarations,
+ resolutions,
+ replacement=replacement,
+ declarations_resolve=declarations_resolve,
)
- state = version_state or (
- "declared-only" if not versions else "resolved" if len(versions) == 1 else "conflicting"
+ versions = sorted({str(item["version"]) for item in resolution_values})
+ replacement_value = _replacement_value(replacement)
+ state = (
+ "local-override"
+ if replacement_value is not None and replacement_value["type"] == "local"
+ else "declared-only"
+ if not versions
+ else "resolved"
+ if len(versions) == 1
+ else "conflicting"
)
declaration_values: list[dict[str, EvidenceValue]] = []
for record in declarations:
@@ -200,20 +312,6 @@ def _framework_fact(
"source_path": record.source_path,
}
)
- resolution_values: list[dict[str, EvidenceValue]] = []
- for record in resolutions:
- package = _package(record)
- version = _version(record)
- if package is None or version is None:
- continue
- resolution_values.append(
- {
- "package": package,
- "version": version,
- "source": _scope(record),
- "source_path": record.source_path,
- }
- )
source_path = min(record.source_path for record in declarations)
value: dict[str, EvidenceValue] = {
"schema_version": _FRAMEWORK_SCHEMA,
@@ -222,6 +320,7 @@ def _framework_fact(
"ecosystem": ecosystem,
"category": category,
"version_state": state,
+ "configuration_state": configuration_state,
}
value["declarations"] = sorted(
declaration_values,
@@ -231,16 +330,10 @@ def _framework_fact(
str(item.get("source_path")),
),
)
- value["resolutions"] = sorted(
- resolution_values,
- key=lambda item: (
- str(item.get("package")),
- str(item.get("version")),
- str(item.get("source_path")),
- ),
- )
+ value["resolutions"] = resolution_values
value["configuration_paths"] = list(configuration_paths)
value["related"] = list(related)
+ value["replacement"] = replacement_value
return PluginFact(
"framework.detected",
component,
@@ -258,42 +351,92 @@ def _coverage(
state: CoverageState,
reason: str,
positive: bool = False,
+ domain: str | None = None,
) -> PluginCoverage:
"""Build one plugin coverage observation using closed scopes."""
- domain = "framework.declaration" if framework is None else "framework.resolution"
+ selected_domain = domain or (
+ "framework.declaration" if framework is None else "framework.resolution"
+ )
scope = plugin if framework is None else f"{plugin}:{framework}"
- return PluginCoverage(component, domain, scope, CoverageObservation(state, reason, positive))
+ return PluginCoverage(
+ component,
+ selected_domain,
+ scope,
+ CoverageObservation(state, reason, positive),
+ )
+
+
+def _components(context: FrameworkPluginContext, ecosystem: str) -> tuple[str, ...]:
+ """Return components with parsed or recognized declaration sources."""
+
+ parsed = {
+ component_root(record.source_path)
+ for record in context.records
+ if record.kind == "repository.manifest" and record.component == ecosystem
+ }
+ recognized = {
+ component_root(source.path)
+ for source in context.source_statuses
+ if source.ecosystem == ecosystem and "declaration" in source.roles
+ }
+ return tuple(sorted(parsed | recognized))
-def _components(records: tuple[EvidenceRecord, ...], ecosystem: str) -> tuple[str, ...]:
- """Return supported manifest components for one ecosystem."""
+def _component_declarations(
+ records: tuple[EvidenceRecord, ...], component: str, ecosystem: str
+) -> tuple[EvidenceRecord, ...]:
+ """Return dependency declarations rooted in one canonical component."""
return tuple(
- sorted(
- {
- component_root(record.source_path)
- for record in records
- if record.kind == "repository.manifest" and record.component == ecosystem
- }
- )
+ record
+ for record in records
+ if record.component == ecosystem
+ and record.kind == "dependency.declared"
+ and component_root(record.source_path) == component
)
-def _component_records(
+def _component_resolutions(
records: tuple[EvidenceRecord, ...], component: str, ecosystem: str
) -> tuple[EvidenceRecord, ...]:
- """Return dependency records rooted in one canonical component."""
+ """Return resolution records rooted in one exact manifest component."""
return tuple(
record
for record in records
if record.component == ecosystem
- and record.kind in {"dependency.declared", "dependency.locked"}
+ and record.kind == "dependency.locked"
and component_root(record.source_path) == component
)
+def _source_observation(
+ context: FrameworkPluginContext,
+ *,
+ component: str,
+ ecosystem: str,
+ role: str,
+) -> CoverageObservation | None:
+ """Return the strongest degradation from exact supported source statuses."""
+
+ states = {
+ source.state
+ for source in context.source_statuses
+ if source.ecosystem == ecosystem
+ and role in source.roles
+ and component_root(source.path) == component
+ }
+ for state, reason in (
+ ("unavailable", "parse-unavailable"),
+ ("omitted", "bounded-source-omission"),
+ ("partial", "source-item-limit"),
+ ):
+ if state in states:
+ return CoverageObservation(CoverageState.PARTIAL, reason, positive=True)
+ return None
+
+
@dataclass(frozen=True, slots=True)
class PackageFrameworkSpec:
"""Declare one directly detectable package-backed framework."""
@@ -323,27 +466,31 @@ class PackageFrameworkPlugin:
configuration_patterns: tuple[re.Pattern[str], ...] = ()
def collect(self, context: FrameworkPluginContext) -> FrameworkPluginResult:
- """Derive direct framework declarations and matching lock evidence."""
+ """Derive direct declarations and nearest deterministic resolution evidence."""
facts: list[PluginFact] = []
coverage: list[PluginCoverage] = []
- config_paths = _configuration_paths(context.entries, self.configuration_patterns)
- for component in _components(context.records, self.ecosystem):
- records = _component_records(context.records, component, self.ecosystem)
+ notices: list[str] = []
+ components = _components(context, self.ecosystem)
+ for component in components:
+ component_declarations = _component_declarations(
+ context.records, component, self.ecosystem
+ )
declarations = tuple(
- record
- for record in records
- if record.kind == "dependency.declared" and _is_direct(record, self.ecosystem)
+ record for record in component_declarations if _is_direct(record, self.ecosystem)
)
- locked = tuple(record for record in records if record.kind == "dependency.locked")
+ locked = _component_resolutions(context.records, component, self.ecosystem)
replacements = tuple(
record
- for record in records
- if self.ecosystem == "go"
- and record.kind == "dependency.declared"
- and _scope(record) == "replace"
+ for record in component_declarations
+ if self.ecosystem == "go" and _scope(record) == "replace"
+ )
+ component_config, config_truncated = _configuration_paths(
+ context.entries,
+ self.configuration_patterns,
+ component=component,
+ components=components,
)
- component_positive = False
related_values: list[dict[str, EvidenceValue]] = []
for item in self.related:
item_declarations = tuple(
@@ -355,8 +502,17 @@ def collect(self, context: FrameworkPluginContext) -> FrameworkPluginResult:
record for record in locked if _package(record) in item.packages
)
related_values.append(
- _related(item.name, item.role, item_declarations, item_locked)
+ _related(
+ item.name,
+ item.role,
+ item_declarations,
+ item_locked,
+ declarations_resolve=self.ecosystem == "go",
+ )
)
+
+ component_positive = False
+ component_fact_limit = False
for spec in self.frameworks:
direct = tuple(
record for record in declarations if _package(record) in spec.packages
@@ -369,23 +525,21 @@ def collect(self, context: FrameworkPluginContext) -> FrameworkPluginResult:
(record for record in replacements if _package(record) in spec.packages),
None,
)
- replacement_fact = _fact_value(replacement) if replacement is not None else None
- explicit_state: str | None = None
- if replacement_fact is not None:
- replacement_type = replacement_fact.get("replacement_type")
- replacement_version = replacement_fact.get("replacement_version")
- if replacement_type == "local":
- explicit_state = "local-override"
- resolved = ()
- elif isinstance(replacement_version, str) and replacement_version:
- resolved = (*resolved, replacement)
- component_config = tuple(
- path
- for path in config_paths
- if component == "repository"
- or path == component
- or path.startswith(component + "/")
- )
+ replacement_value = _replacement_value(replacement)
+ declarations_resolve = self.ecosystem == "go" and replacement is None
+ if len(facts) >= MAX_PLUGIN_FACTS:
+ component_fact_limit = True
+ coverage.append(
+ _coverage(
+ component=component,
+ plugin=self.plugin_id,
+ framework=spec.framework,
+ state=CoverageState.PARTIAL,
+ reason="plugin-fact-limit",
+ positive=True,
+ )
+ )
+ continue
facts.append(
_framework_fact(
plugin=self.plugin_id,
@@ -396,40 +550,117 @@ def collect(self, context: FrameworkPluginContext) -> FrameworkPluginResult:
declarations=direct,
resolutions=resolved,
configuration_paths=component_config,
+ configuration_state="partial" if config_truncated else "complete",
related=tuple(related_values),
- version_state=explicit_state,
+ replacement=replacement,
+ declarations_resolve=declarations_resolve,
)
)
+ resolution_source = _source_observation(
+ context,
+ component=component,
+ ecosystem=self.ecosystem,
+ role="resolution",
+ )
+ if replacement_value is not None and replacement_value["type"] == "local":
+ state = CoverageState.PARTIAL
+ reason = "local-replacement"
+ elif resolution_source is not None:
+ state = resolution_source.state
+ reason = resolution_source.reason
+ elif (
+ resolved
+ or declarations_resolve
+ or (
+ replacement_value is not None
+ and isinstance(replacement_value["version"], str)
+ )
+ ):
+ state = CoverageState.COMPLETE
+ reason = (
+ "direct-version-present" if declarations_resolve else "lock-version-present"
+ )
+ else:
+ state = CoverageState.PARTIAL
+ reason = "lock-version-missing"
coverage.append(
_coverage(
component=component,
plugin=self.plugin_id,
framework=spec.framework,
- state=CoverageState.COMPLETE if resolved else CoverageState.PARTIAL,
- reason="lock-version-present" if resolved else "lock-version-missing",
+ state=state,
+ reason=reason,
positive=True,
)
)
+ coverage.append(
+ _coverage(
+ component=component,
+ plugin=self.plugin_id,
+ framework=spec.framework,
+ domain="framework.configuration",
+ state=CoverageState.PARTIAL if config_truncated else CoverageState.COMPLETE,
+ reason=(
+ "configuration-path-limit"
+ if config_truncated
+ else "bounded-tree-complete"
+ ),
+ positive=bool(component_config),
+ )
+ )
+
+ declaration_source = _source_observation(
+ context,
+ component=component,
+ ecosystem=self.ecosystem,
+ role="declaration",
+ )
+ declaration_state = (
+ CoverageState.PARTIAL
+ if component_fact_limit
+ else declaration_source.state
+ if declaration_source is not None
+ else CoverageState.COMPLETE
+ )
+ declaration_reason = (
+ "plugin-fact-limit"
+ if component_fact_limit
+ else declaration_source.reason
+ if declaration_source is not None
+ else "direct-manifest-complete"
+ )
coverage.append(
_coverage(
component=component,
plugin=self.plugin_id,
framework=None,
- state=CoverageState.COMPLETE,
- reason="direct-manifest-complete",
+ state=declaration_state,
+ reason=declaration_reason,
positive=component_positive,
)
)
- return FrameworkPluginResult(tuple(facts[:MAX_PLUGIN_FACTS]), tuple(coverage))
+ if len(facts) >= MAX_PLUGIN_FACTS:
+ notices.append(f"framework plugin fact limit reached: {self.plugin_id}")
+ return FrameworkPluginResult(tuple(facts), tuple(coverage), tuple(notices))
+_PYTHON_DECLARATIONS = (re.compile(r"(^|/)(pyproject\.toml|requirements[^/]*\.(txt|in))$", re.I),)
+_PYTHON_RESOLUTIONS = (
+ re.compile(r"(^|/)(uv\.lock|poetry\.lock|pipfile\.lock|pylock(?:\.[^/]+)?\.toml)$", re.I),
+)
+_JAVASCRIPT_DECLARATIONS = (re.compile(r"(^|/)package\.json$", re.I),)
+_JAVASCRIPT_RESOLUTIONS = (
+ re.compile(r"(^|/)(package-lock\.json|yarn\.lock|pnpm-lock\.yaml)$", re.I),
+)
+_COMPOSER_DECLARATIONS = (re.compile(r"(^|/)composer\.json$", re.I),)
+_COMPOSER_RESOLUTIONS = (re.compile(r"(^|/)composer\.lock$", re.I),)
+_GO_MOD = (re.compile(r"(^|/)go\.mod$", re.I),)
+
JINJA2_PLUGIN = PackageFrameworkPlugin(
"jinja2",
"python",
(PackageFrameworkSpec("jinja2", ("jinja2",), "template-engine"),),
- configuration_patterns=(
- re.compile(r"(^|/)(pyproject\.toml|requirements[^/]*\.(txt|in)|.*\.lock)$", re.I),
- ),
+ configuration_patterns=(*_PYTHON_DECLARATIONS, *_PYTHON_RESOLUTIONS),
)
GO_WEB_PLUGIN = PackageFrameworkPlugin(
"go-web",
@@ -449,7 +680,8 @@ def collect(self, context: FrameworkPluginContext) -> FrameworkPluginResult:
PackageFrameworkSpec("twig", ("twig/twig", "symfony/twig-bundle"), "template-engine"),
),
configuration_patterns=(
- re.compile(r"(^|/)composer\.(json|lock)$", re.I),
+ *_COMPOSER_DECLARATIONS,
+ *_COMPOSER_RESOLUTIONS,
re.compile(r"(^|/)config/(bundles\.php|packages/|routes(?:\.|/|$))", re.I),
re.compile(r"\.twig$", re.I),
),
@@ -466,7 +698,8 @@ def collect(self, context: FrameworkPluginContext) -> FrameworkPluginResult:
RelatedSpec("vite", "build-tool", ("vite",)),
),
configuration_patterns=(
- re.compile(r"(^|/)(package\.json|package-lock\.json|yarn\.lock|pnpm-lock\.yaml)$", re.I),
+ *_JAVASCRIPT_DECLARATIONS,
+ *_JAVASCRIPT_RESOLUTIONS,
re.compile(r"(^|/)tsconfig[^/]*\.json$", re.I),
re.compile(r"(^|/)(vite|next)\.config\.[^.]+$", re.I),
),
@@ -491,13 +724,40 @@ def collect_framework_plugins(
for plugin in BUILTIN_FRAMEWORK_PLUGINS:
try:
result = plugin.collect(context)
- except (TypeError, ValueError, RecursionError):
+ # A package-owned provider is isolated so one defect cannot suppress siblings.
+ except Exception:
notices.append(f"framework plugin unavailable: {plugin.plugin_id}")
continue
- facts.extend(result.facts[:MAX_PLUGIN_FACTS])
+ ordered = sorted(result.facts, key=lambda item: (item.component, item.identity))
+ remaining = max(0, MAX_PLUGIN_FACTS - len(facts))
+ facts.extend(ordered[:remaining])
coverage.extend(result.coverage)
notices.extend(result.notices)
- if len(result.facts) > MAX_PLUGIN_FACTS:
+ omitted = ordered[remaining:]
+ for fact in omitted:
+ framework = fact.value.get("framework")
+ if isinstance(framework, str):
+ coverage.extend(
+ (
+ _coverage(
+ component=fact.component,
+ plugin=plugin.plugin_id,
+ framework=framework,
+ state=CoverageState.PARTIAL,
+ reason="plugin-fact-limit",
+ positive=True,
+ ),
+ _coverage(
+ component=fact.component,
+ plugin=plugin.plugin_id,
+ framework=None,
+ state=CoverageState.PARTIAL,
+ reason="plugin-fact-limit",
+ positive=True,
+ ),
+ )
+ )
+ if omitted:
notices.append(f"framework plugin fact limit reached: {plugin.plugin_id}")
return (
tuple(sorted(facts, key=lambda item: (item.kind, item.component, item.identity))),
@@ -532,32 +792,70 @@ def _ansible_template_component(path: str) -> str | None:
return None
+def _template_description(
+ path: str, context: FrameworkPluginContext
+) -> tuple[str, str, str, str] | None:
+ """Return plugin, engine, detection, and nearest component for one template path."""
+
+ folded = path.casefold()
+ role_component = _ansible_template_component(path)
+ if folded.endswith((".j2", ".jinja", ".jinja2")):
+ roots = _components(context, "python")
+ component = role_component or _owning_component(path, roots) or component_root(path)
+ return "jinja2", "jinja2", "jinja-extension", component
+ if role_component is not None:
+ return "jinja2", "jinja2", "ansible-role-template", role_component
+ if folded.endswith(".twig"):
+ roots = _components(context, "php")
+ component = _owning_component(path, roots) or component_root(path)
+ return "symfony-php", "twig", "twig-extension", component
+ return None
+
+
+def _applicable_template_components(
+ records: tuple[EvidenceRecord, ...],
+) -> tuple[tuple[str, str], ...]:
+ """Return components with direct Jinja or Twig template-engine declarations."""
+
+ applicable: set[tuple[str, str]] = set()
+ for record in records:
+ if record.kind != "dependency.declared":
+ continue
+ package = _package(record)
+ if package == "jinja2" and _is_direct(record, "python"):
+ applicable.add((component_root(record.source_path), "jinja2"))
+ elif package in {"twig/twig", "symfony/twig-bundle"} and _is_direct(record, "php"):
+ applicable.add((component_root(record.source_path), "symfony-php"))
+ return tuple(sorted(applicable))
+
+
def collect_template_files(
- entries: tuple[RepositoryObject, ...],
-) -> tuple[tuple[PluginFact, ...], tuple[PluginCoverage, ...]]:
- """Inventory Jinja/Twig regular blobs without reading or persisting template content."""
+ context: FrameworkPluginContext,
+) -> tuple[tuple[PluginFact, ...], tuple[PluginCoverage, ...], tuple[str, ...]]:
+ """Inventory Jinja/Twig blobs without reading or persisting template content."""
facts: list[PluginFact] = []
- coverage_components: set[tuple[str, str]] = set()
- for entry in entries:
+ observations: dict[tuple[str, str], list[CoverageObservation]] = {
+ key: [CoverageObservation(CoverageState.COMPLETE, "bounded-tree-complete")]
+ for key in _applicable_template_components(context.records)
+ }
+ truncated = False
+ for entry in sorted(context.entries, key=lambda item: item.path):
+ description = _template_description(entry.path, context)
+ if description is None:
+ continue
+ plugin, engine, detection, component = description
+ key = (component, plugin)
if entry.object_type != "blob" or entry.is_symlink or entry.is_submodule:
+ observations.setdefault(key, []).append(
+ CoverageObservation(CoverageState.PARTIAL, "unsafe-template-source", positive=True)
+ )
continue
- folded = entry.path.casefold()
- role_component = _ansible_template_component(entry.path)
- if folded.endswith((".j2", ".jinja", ".jinja2")):
- plugin = engine = "jinja2"
- detection = "jinja-extension"
- component = role_component or component_root(entry.path)
- elif role_component is not None:
- plugin = engine = "jinja2"
- detection = "ansible-role-template"
- component = role_component
- elif folded.endswith(".twig"):
- plugin = "symfony-php"
- engine = "twig"
- detection = "twig-extension"
- component = component_root(entry.path)
- else:
+ if len(facts) >= MAX_PLUGIN_FACTS:
+ truncated = True
+ observations.setdefault(key, []).append(
+ CoverageObservation(CoverageState.PARTIAL, "template-fact-limit", positive=True)
+ )
continue
facts.append(
PluginFact(
@@ -575,16 +873,13 @@ def collect_template_files(
},
)
)
- coverage_components.add((component, plugin))
- if len(facts) >= MAX_PLUGIN_FACTS:
- break
- coverage = tuple(
- PluginCoverage(
- component,
- "template.inventory",
- plugin,
- CoverageObservation(CoverageState.COMPLETE, "bounded-tree-complete", positive=True),
+ observations.setdefault(key, []).append(
+ CoverageObservation(CoverageState.COMPLETE, "bounded-tree-complete", positive=True)
)
- for component, plugin in sorted(coverage_components)
+ coverage = tuple(
+ PluginCoverage(component, "template.inventory", plugin, observation)
+ for (component, plugin), values in sorted(observations.items())
+ for observation in values
)
- return tuple(facts), coverage
+ notices = ("template plugin fact limit reached",) if truncated else ()
+ return tuple(facts), coverage, notices
diff --git a/src/ocr_toolkit/evidence/framework_schema.py b/src/ocr_toolkit/evidence/framework_schema.py
index e6df057..4415915 100644
--- a/src/ocr_toolkit/evidence/framework_schema.py
+++ b/src/ocr_toolkit/evidence/framework_schema.py
@@ -15,9 +15,27 @@
ECOSYSTEMS = {"python", "go", "php", "javascript"}
CATEGORIES = {"framework", "template-engine"}
VERSION_STATES = {"declared-only", "resolved", "conflicting", "local-override"}
+CONFIGURATION_STATES = {"complete", "partial"}
TEMPLATE_ENGINES = {"jinja2", "twig"}
TEMPLATE_DETECTIONS = {"jinja-extension", "ansible-role-template", "twig-extension"}
OBJECT_SHA_RE = re.compile(r"^[0-9a-f]{40}$")
+PLUGIN_FRAMEWORKS = {
+ "jinja2": {"jinja2"},
+ "go-web": {"echo", "fiber"},
+ "symfony-php": {"symfony", "twig"},
+ "react-typescript": {"react", "next"},
+}
+PLUGIN_ECOSYSTEMS = {
+ "jinja2": "python",
+ "go-web": "go",
+ "symfony-php": "php",
+ "react-typescript": "javascript",
+}
+TEMPLATE_PLUGIN_ENGINES = {"jinja2": "jinja2", "symfony-php": "twig"}
+TEMPLATE_ENGINE_DETECTIONS = {
+ "jinja2": {"jinja-extension", "ansible-role-template"},
+ "twig": {"twig-extension"},
+}
def _exact_mapping(value: object, keys: set[str], label: str) -> Mapping[str, EvidenceValue]:
@@ -60,8 +78,8 @@ def _objects(value: object, label: str, keys: set[str]) -> tuple[Mapping[str, Ev
return tuple(_exact_mapping(item, keys, label) for item in value)
-def validate_framework_value(value: EvidenceValue) -> None:
- """Validate one `framework.detected` fact against its exact nested schema."""
+def validate_framework_value(value: EvidenceValue) -> tuple[str, str]:
+ """Validate one `framework.detected` fact and return plugin/framework identity."""
root = _exact_mapping(
value,
@@ -74,18 +92,31 @@ def validate_framework_value(value: EvidenceValue) -> None:
"declarations",
"resolutions",
"version_state",
+ "configuration_state",
"configuration_paths",
"related",
+ "replacement",
},
"framework evidence",
)
if root["schema_version"] != FRAMEWORK_SCHEMA:
raise ValueError("framework evidence schema is unsupported")
- _string(root["plugin"], "framework plugin", choices=PLUGIN_IDS)
- _string(root["framework"], "framework name")
- _string(root["ecosystem"], "framework ecosystem", choices=ECOSYSTEMS)
- _string(root["category"], "framework category", choices=CATEGORIES)
- _string(root["version_state"], "framework version state", choices=VERSION_STATES)
+ plugin = _string(root["plugin"], "framework plugin", choices=PLUGIN_IDS)
+ framework = _string(root["framework"], "framework name", choices=PLUGIN_FRAMEWORKS[plugin])
+ ecosystem = _string(root["ecosystem"], "framework ecosystem", choices=ECOSYSTEMS)
+ if ecosystem != PLUGIN_ECOSYSTEMS[plugin]:
+ raise ValueError("framework plugin ecosystem is inconsistent")
+ category = _string(root["category"], "framework category", choices=CATEGORIES)
+ if (framework in {"jinja2", "twig"}) != (category == "template-engine"):
+ raise ValueError("framework category is inconsistent")
+ version_state = _string(
+ root["version_state"], "framework version state", choices=VERSION_STATES
+ )
+ configuration_state = _string(
+ root["configuration_state"],
+ "framework configuration state",
+ choices=CONFIGURATION_STATES,
+ )
declarations = _objects(
root["declarations"],
"framework declaration",
@@ -98,20 +129,50 @@ def validate_framework_value(value: EvidenceValue) -> None:
_string(item["scope"], "framework scope")
_string(item["declared_value"], "framework declared value")
_path(item["source_path"], "framework declaration source")
- for item in _objects(
+ resolutions = _objects(
root["resolutions"],
"framework resolution",
{"package", "version", "source", "source_path"},
- ):
+ )
+ for item in resolutions:
_string(item["package"], "framework package")
_string(item["version"], "framework version")
_string(item["source"], "framework resolution source")
_path(item["source_path"], "framework resolution source path")
+ replacement = root["replacement"]
+ if replacement is not None:
+ replacement_value = _exact_mapping(
+ replacement, {"target", "type", "version"}, "framework replacement"
+ )
+ _string(replacement_value["target"], "framework replacement target")
+ replacement_type = _string(
+ replacement_value["type"],
+ "framework replacement type",
+ choices={"local", "module"},
+ )
+ replacement_version = replacement_value["version"]
+ if replacement_version is not None:
+ _string(replacement_version, "framework replacement version")
+ if plugin != "go-web" or (replacement_type == "local") != (
+ version_state == "local-override"
+ ):
+ raise ValueError("framework replacement state is inconsistent")
+ elif version_state == "local-override":
+ raise ValueError("framework local override requires replacement metadata")
+ if version_state == "resolved" and not resolutions:
+ raise ValueError("resolved framework evidence requires a resolution")
+ if version_state == "declared-only" and resolutions:
+ raise ValueError("declared-only framework evidence cannot contain resolutions")
+ if version_state == "conflicting" and len({item["version"] for item in resolutions}) < 2:
+ raise ValueError("conflicting framework evidence requires distinct versions")
paths = root["configuration_paths"]
if not isinstance(paths, (list, tuple)) or len(paths) > 128:
raise ValueError("framework configuration paths are invalid")
- for path in paths:
- _path(path, "framework configuration path")
+ normalized_paths = [_path(path, "framework configuration path") for path in paths]
+ if normalized_paths != sorted(set(normalized_paths)):
+ raise ValueError("framework configuration paths are not canonical")
+ if configuration_state == "partial" and len(normalized_paths) != 128:
+ raise ValueError("partial framework configuration requires the path limit")
for item in _objects(
root["related"],
"related framework evidence",
@@ -130,10 +191,11 @@ def validate_framework_value(value: EvidenceValue) -> None:
raise ValueError("related framework source paths are invalid")
for source in sources:
_path(source, "related framework source path")
+ return plugin, framework
-def validate_template_value(value: EvidenceValue) -> None:
- """Validate one `template.file` fact against its exact nested schema."""
+def validate_template_value(value: EvidenceValue) -> tuple[str, str]:
+ """Validate one `template.file` fact and return plugin/engine identity."""
root = _exact_mapping(
value,
@@ -149,24 +211,34 @@ def validate_template_value(value: EvidenceValue) -> None:
)
if root["schema_version"] != TEMPLATE_SCHEMA:
raise ValueError("template evidence schema is unsupported")
- _string(root["plugin"], "template plugin", choices=PLUGIN_IDS)
- _string(root["engine"], "template engine", choices=TEMPLATE_ENGINES)
- _string(root["detection"], "template detection", choices=TEMPLATE_DETECTIONS)
+ plugin = _string(root["plugin"], "template plugin", choices=set(TEMPLATE_PLUGIN_ENGINES))
+ engine = _string(root["engine"], "template engine", choices=TEMPLATE_ENGINES)
+ if engine != TEMPLATE_PLUGIN_ENGINES[plugin]:
+ raise ValueError("template plugin engine is inconsistent")
+ detection = _string(root["detection"], "template detection", choices=TEMPLATE_DETECTIONS)
+ if detection not in TEMPLATE_ENGINE_DETECTIONS[engine]:
+ raise ValueError("template detection is inconsistent")
rendered = root["rendered_extension"]
if rendered is not None:
- _string(rendered, "rendered extension")
+ rendered_extension = _string(rendered, "rendered extension")
+ if not rendered_extension.startswith(".") or detection != "jinja-extension":
+ raise ValueError("template rendered extension is inconsistent")
object_sha = _string(root["object_sha"], "template object SHA")
if not OBJECT_SHA_RE.fullmatch(object_sha):
raise ValueError("template object SHA is invalid")
+ return plugin, engine
def validate_plugin_record(kind: str, value: EvidenceValue) -> None:
- """Validate one plugin fact nested below the common identity envelope."""
+ """Validate one plugin fact and bind its envelope identity to nested values."""
envelope = _exact_mapping(value, {"identity", "fact"}, "plugin evidence envelope")
- _string(envelope["identity"], "plugin evidence identity")
+ identity = _string(envelope["identity"], "plugin evidence identity")
fact = envelope["fact"]
if kind == "framework.detected":
- validate_framework_value(fact)
+ plugin, framework = validate_framework_value(fact)
+ if identity != f"{plugin}:{framework}":
+ raise ValueError("framework evidence identity is inconsistent")
elif kind == "template.file":
validate_template_value(fact)
+ _path(identity, "template evidence identity")
diff --git a/tests/test_evidence_framework_plugins.py b/tests/test_evidence_framework_plugins.py
index 1c0a00b..d1be825 100644
--- a/tests/test_evidence_framework_plugins.py
+++ b/tests/test_evidence_framework_plugins.py
@@ -5,13 +5,22 @@
import json
import subprocess
from pathlib import Path
-from typing import Any
+from typing import Any, cast
import pytest
from ocr_toolkit.evidence.collect import collect_repository_evidence
+from ocr_toolkit.evidence.framework_plugins import (
+ BUILTIN_FRAMEWORK_PLUGINS,
+ MAX_CONFIGURATION_PATHS,
+ MAX_PLUGIN_FACTS,
+ FrameworkPluginContext,
+ FrameworkPluginResult,
+ collect_framework_plugins,
+)
from ocr_toolkit.evidence.mcp import call_tool
-from ocr_toolkit.evidence.model import RefRole
+from ocr_toolkit.evidence.model import Confidence, EvidenceRecord, RefRole, TrustClass
+from ocr_toolkit.evidence.repository import RepositoryObject
from ocr_toolkit.evidence.store import EvidenceStore, EvidenceStoreError
@@ -105,7 +114,8 @@ def test_jinja_framework_and_templates_are_component_scoped_and_visible_in_mcp(
record for record in templates if record.source_path.endswith("daemon.conf")
)
assert role_template.component == "collections/demo/roles/web"
- assert role_template.value["fact"]["detection"] == "ansible-role-template"
+ role_fact = cast(dict[str, Any], role_template.to_dict()["value"])["fact"]
+ assert role_fact["detection"] == "ansible-role-template"
assert any(
delta.kind == "template.file"
and delta.identity == "services/renderer/templates/app.conf.j2"
@@ -229,7 +239,8 @@ def test_go_php_and_frontend_plugins_keep_related_signals_in_the_same_component(
for record in store.records
if record.kind == "template.file" and record.source_path.endswith("page.twig")
)
- assert twig.value["fact"]["engine"] == "twig"
+ twig_fact = cast(dict[str, Any], twig.to_dict()["value"])["fact"]
+ assert twig_fact["engine"] == "twig"
def test_plugin_nested_schema_is_revalidated_on_store_load(tmp_path: Path) -> None:
@@ -244,7 +255,7 @@ def test_plugin_nested_schema_is_revalidated_on_store_load(tmp_path: Path) -> No
(tmp_path / "README.md").write_text("change\n", encoding="utf-8")
head = commit(tmp_path, "head")
store = collect_repository_evidence(tmp_path, base_ref=base, head_ref=head)
- payload = store.to_dict()
+ payload = cast(dict[str, Any], store.to_dict())
record = next(item for item in payload["records"] if item["kind"] == "framework.detected")
record["value"]["fact"]["unknown"] = True
record.pop("id")
@@ -253,3 +264,275 @@ def test_plugin_nested_schema_is_revalidated_on_store_load(tmp_path: Path) -> No
with pytest.raises(EvidenceStoreError, match=r"invalid framework.detected"):
EvidenceStore.read(path)
+
+
+def coverage_record(
+ store: EvidenceStore, *, component: str, domain: str, scope: str, ref: RefRole = RefRole.HEAD
+) -> Any:
+ """Return one exact framework coverage record from a synthetic store."""
+
+ return next(
+ item
+ for item in store.coverage
+ if item.component == component
+ and item.domain == domain
+ and item.scope == scope
+ and item.ref is ref
+ )
+
+
+def test_go_versions_and_replacements_use_effective_go_mod_semantics(tmp_path: Path) -> None:
+ """Treat direct Go requirements as resolved and replacement targets as effective."""
+
+ initialize(tmp_path)
+ (tmp_path / "go.mod").write_text(
+ """module synthetic.invalid/api
+
+go 1.24
+
+require github.com/labstack/echo/v4 v4.13.4
+replace github.com/labstack/echo/v4 => synthetic.invalid/echo/v4 v4.13.5
+""",
+ encoding="utf-8",
+ )
+ base = commit(tmp_path, "module replacement")
+ (tmp_path / "go.mod").write_text(
+ """module synthetic.invalid/api
+
+go 1.24
+
+require github.com/labstack/echo/v4 v4.13.4
+replace github.com/labstack/echo/v4 => ./local-echo
+""",
+ encoding="utf-8",
+ )
+ head = commit(tmp_path, "local replacement")
+
+ store = collect_repository_evidence(tmp_path, base_ref=base, head_ref=head)
+ base_echo = next(
+ record
+ for record in framework_records(store, RefRole.BASE)
+ if record.value["fact"]["framework"] == "echo"
+ )
+ head_echo = next(
+ record for record in framework_records(store) if record.value["fact"]["framework"] == "echo"
+ )
+ assert base_echo.value["fact"]["version_state"] == "resolved"
+ assert base_echo.value["fact"]["resolutions"] == (
+ {
+ "package": "github.com/labstack/echo/v4",
+ "version": "v4.13.5",
+ "source": "go.replace",
+ "source_path": "go.mod",
+ },
+ )
+ assert head_echo.value["fact"]["version_state"] == "local-override"
+ assert head_echo.value["fact"]["resolutions"] == ()
+ assert head_echo.value["fact"]["replacement"] == {
+ "target": "./local-echo",
+ "type": "local",
+ "version": None,
+ }
+ resolution = coverage_record(
+ store,
+ component="repository",
+ domain="framework.resolution",
+ scope="go-web:echo",
+ )
+ assert resolution.state.value == "partial"
+ assert resolution.reasons == ("local-replacement",)
+
+
+def test_template_components_follow_nearest_manifest_root_and_configuration_isolated(
+ tmp_path: Path,
+) -> None:
+ """Bind templates/configuration to the nearest owning manifest component."""
+
+ initialize(tmp_path)
+ (tmp_path / "pyproject.toml").write_text(
+ '[project]\nname="root"\nversion="1"\ndependencies=["jinja2==3.1.6"]\n',
+ encoding="utf-8",
+ )
+ nested = tmp_path / "services" / "renderer"
+ (nested / "templates").mkdir(parents=True)
+ (nested / "pyproject.toml").write_text(
+ '[project]\nname="renderer"\nversion="1"\ndependencies=["jinja2==3.1.6"]\n',
+ encoding="utf-8",
+ )
+ (nested / "templates" / "service.conf.j2").write_text("x={{ x }}\n", encoding="utf-8")
+ (tmp_path / "root.conf.j2").write_text("root={{ root }}\n", encoding="utf-8")
+ base = commit(tmp_path, "templates")
+ (tmp_path / "README.md").write_text("head\n", encoding="utf-8")
+ head = commit(tmp_path, "head")
+
+ store = collect_repository_evidence(tmp_path, base_ref=base, head_ref=head)
+ templates = {
+ record.source_path: record.component
+ for record in store.records
+ if record.kind == "template.file" and record.ref is RefRole.HEAD
+ }
+ assert templates == {
+ "root.conf.j2": "repository",
+ "services/renderer/templates/service.conf.j2": "services/renderer",
+ }
+ frameworks = {record.component: record.value["fact"] for record in framework_records(store)}
+ assert "services/renderer/pyproject.toml" not in frameworks["repository"]["configuration_paths"]
+ assert (
+ "services/renderer/pyproject.toml" in frameworks["services/renderer"]["configuration_paths"]
+ )
+
+
+def test_configuration_and_template_limits_degrade_exact_coverage(tmp_path: Path) -> None:
+ """Never claim complete inventory after plugin-owned output truncation."""
+
+ initialize(tmp_path)
+ (tmp_path / "package.json").write_text(
+ json.dumps({"dependencies": {"react": "19.0.0"}}), encoding="utf-8"
+ )
+ for index in range(MAX_CONFIGURATION_PATHS + 1):
+ path = tmp_path / f"tsconfig-{index:03}.json"
+ path.write_text("{}\n", encoding="utf-8")
+ for index in range(MAX_PLUGIN_FACTS + 1):
+ path = tmp_path / "templates" / f"page-{index:03}.j2"
+ path.parent.mkdir(exist_ok=True)
+ path.write_text("{{ value }}\n", encoding="utf-8")
+ base = commit(tmp_path, "bounded files")
+ (tmp_path / "README.md").write_text("head\n", encoding="utf-8")
+ head = commit(tmp_path, "head")
+
+ store = collect_repository_evidence(tmp_path, base_ref=base, head_ref=head)
+ react = next(
+ record
+ for record in framework_records(store)
+ if record.value["fact"]["framework"] == "react"
+ )
+ assert react.value["fact"]["configuration_state"] == "partial"
+ assert len(react.value["fact"]["configuration_paths"]) == MAX_CONFIGURATION_PATHS
+ config_coverage = coverage_record(
+ store,
+ component="repository",
+ domain="framework.configuration",
+ scope="react-typescript:react",
+ )
+ assert config_coverage.state.value == "partial"
+ assert config_coverage.reasons == ("configuration-path-limit",)
+ template_coverage = coverage_record(
+ store,
+ component="templates",
+ domain="template.inventory",
+ scope="jinja2",
+ )
+ assert template_coverage.state.value == "partial"
+ assert template_coverage.reasons == ("bounded-tree-complete", "template-fact-limit")
+ assert any("template plugin fact limit reached" in message for message in store.diagnostics)
+
+
+def test_plugin_failure_isolated_from_sibling_provider(monkeypatch: pytest.MonkeyPatch) -> None:
+ """Degrade one package-owned provider without suppressing sibling output."""
+
+ class BrokenPlugin:
+ plugin_id = "broken"
+
+ def collect(self, context: FrameworkPluginContext) -> FrameworkPluginResult:
+ raise RuntimeError("synthetic provider failure")
+
+ jinja_record = EvidenceRecord(
+ kind="repository.manifest",
+ value={"identity": "pyproject.toml", "fact": {"path": "pyproject.toml"}},
+ source_path="pyproject.toml",
+ ref=RefRole.HEAD,
+ commit_sha="a" * 40,
+ component="python",
+ provenance="synthetic",
+ confidence=Confidence.EXACT,
+ trust=TrustClass.SOURCE_REPOSITORY,
+ )
+ declaration = EvidenceRecord(
+ kind="dependency.declared",
+ value={
+ "identity": "pyproject.toml:project:jinja2",
+ "fact": {"name": "jinja2", "version": "3.1.6", "scope": "project"},
+ },
+ source_path="pyproject.toml",
+ ref=RefRole.HEAD,
+ commit_sha="a" * 40,
+ component="python",
+ provenance="synthetic",
+ confidence=Confidence.EXACT,
+ trust=TrustClass.SOURCE_REPOSITORY,
+ )
+ monkeypatch.setattr(
+ "ocr_toolkit.evidence.framework_plugins.BUILTIN_FRAMEWORK_PLUGINS",
+ (BrokenPlugin(), BUILTIN_FRAMEWORK_PLUGINS[0]),
+ )
+ facts, _coverage, notices = collect_framework_plugins(
+ FrameworkPluginContext(
+ records=(jinja_record, declaration),
+ entries=(RepositoryObject("pyproject.toml", "100644", "blob", "b" * 40),),
+ source_statuses=(),
+ ref=RefRole.HEAD,
+ commit_sha="a" * 40,
+ )
+ )
+ assert [fact.identity for fact in facts] == ["jinja2:jinja2"]
+ assert notices == ("framework plugin unavailable: broken",)
+
+
+def test_nested_schema_rejects_identity_and_plugin_relationship_mismatches(tmp_path: Path) -> None:
+ """Bind persisted framework identity, plugin, framework, and ecosystem fields."""
+
+ initialize(tmp_path)
+ (tmp_path / "pyproject.toml").write_text(
+ '[project]\nname="demo"\nversion="1"\ndependencies=["jinja2==3.1.6"]\n',
+ encoding="utf-8",
+ )
+ base = commit(tmp_path, "base")
+ (tmp_path / "README.md").write_text("head\n", encoding="utf-8")
+ head = commit(tmp_path, "head")
+ payload = cast(
+ dict[str, Any],
+ collect_repository_evidence(tmp_path, base_ref=base, head_ref=head).to_dict(),
+ )
+ record = next(item for item in payload["records"] if item["kind"] == "framework.detected")
+ record["value"]["identity"] = "jinja2:react"
+ record.pop("id")
+ path = tmp_path / "mismatch.json"
+ path.write_text(json.dumps(payload), encoding="utf-8")
+
+ with pytest.raises(EvidenceStoreError, match=r"invalid framework.detected"):
+ EvidenceStore.read(path)
+
+
+def test_malformed_and_truncated_manifests_degrade_applicable_coverage(tmp_path: Path) -> None:
+ """Keep absence unknown when a supported declaration source cannot be fully parsed."""
+
+ initialize(tmp_path)
+ (tmp_path / "pyproject.toml").write_text("not = [valid\n", encoding="utf-8")
+ packages = {f"synthetic-{index}": "1.0.0" for index in range(512)}
+ packages["react"] = "19.0.0"
+ (tmp_path / "package.json").write_text(json.dumps({"dependencies": packages}), encoding="utf-8")
+ base = commit(tmp_path, "sources")
+ (tmp_path / "README.md").write_text("head\n", encoding="utf-8")
+ head = commit(tmp_path, "head")
+
+ store = collect_repository_evidence(tmp_path, base_ref=base, head_ref=head)
+ jinja_declarations = coverage_record(
+ store,
+ component="repository",
+ domain="framework.declaration",
+ scope="jinja2",
+ )
+ assert jinja_declarations.state.value == "unavailable"
+ assert jinja_declarations.reasons == ("parse-unavailable",)
+ react_declarations = coverage_record(
+ store,
+ component="repository",
+ domain="framework.declaration",
+ scope="react-typescript",
+ )
+ assert react_declarations.state.value == "partial"
+ assert react_declarations.reasons == ("source-item-limit",)
+ assert not any(
+ cast(dict[str, Any], record.to_dict()["value"])["fact"]["framework"] == "react"
+ for record in framework_records(store)
+ )
From c7482d56b116ca51b3b0272d6ee55f3114ddee49 Mon Sep 17 00:00:00 2001
From: xeonvs <11463419+xeonvs@users.noreply.github.com>
Date: Wed, 12 Aug 2026 10:03:17 +0200
Subject: [PATCH 07/11] Qualify OCR 1.9.2
---
.github/workflows/ocr-compatibility.yml | 2 +-
PLANS.md | 90 ++++++++++++++++------
README.md | 2 +-
changelog.d/78.feature.md | 1 +
compatibility/evidence/ocr-1.9.2.json | 97 ++++++++++++++++++++++++
compatibility/ocr-support.json | 55 +++++++++++++-
docs/compatibility.md | 8 +-
docs/configuration.md | 2 +-
docs/gitlab.md | 4 +-
docs/security.md | 4 +-
examples/gitlab/ocr-review.gitlab-ci.yml | 4 +-
scripts/ocr_compat.py | 17 +----
src/ocr_toolkit/preflight.py | 2 +-
tests/test_evidence_mcp.py | 4 +-
tests/test_integration_contracts.py | 10 ++-
tests/test_ocr_compat.py | 67 ++++++++--------
tests/test_operations_docs.py | 5 ++
tests/test_runtime_helpers.py | 2 +-
18 files changed, 284 insertions(+), 92 deletions(-)
create mode 100644 changelog.d/78.feature.md
create mode 100644 compatibility/evidence/ocr-1.9.2.json
diff --git a/.github/workflows/ocr-compatibility.yml b/.github/workflows/ocr-compatibility.yml
index ecdbf09..8e5142d 100644
--- a/.github/workflows/ocr-compatibility.yml
+++ b/.github/workflows/ocr-compatibility.yml
@@ -213,7 +213,7 @@ jobs:
git switch -C "${branch}" "${GITHUB_SHA}"
git add compatibility/ocr-support.json compatibility/evidence/ \
src/ocr_toolkit/preflight.py examples/gitlab/ocr-review.gitlab-ci.yml \
- README.md docs/gitlab.md docs/security.md changelog.d/
+ changelog.d/
git commit -m "Target OCR ${version}"
git push --force-with-lease="${branch}" origin "${branch}"
existing=$(gh pr list --repo "${REPOSITORY}" --state open --head "${branch}" --json number --jq '.[0].number // empty')
diff --git a/PLANS.md b/PLANS.md
index e1f0008..57aeb9b 100644
--- a/PLANS.md
+++ b/PLANS.md
@@ -6,10 +6,10 @@ Use this file for active, blocked, or recently completed execution work. Update
Status: active; implementation checkpoints in progress
Owner: Codex
-Last Updated: 2026-08-11
+Last Updated: 2026-08-12
Release Classification: release-required
Target Stable Version: 0.5.0
-Tracking Issues: #76; feature PR #77
+Tracking Issues: #76, #78 (OCR 1.9.2); feature PR #77
### Goal
@@ -17,16 +17,19 @@ Deliver M2 as stable toolkit 0.5.0 with bounded static framework plugins for
Jinja2, Go web frameworks, Symfony/PHP, and React/TypeScript. Make Jinja and
Twig template files actually reviewable by the recommended OCR through the
public synthetic rules pack, expose framework/template state and deltas through
-the existing read-only evidence MCP, and repair issue #76 release recovery.
-Complete one synthetic installed-artifact E2E, then exactly one final local OCR
-review without GitLab posting, before protected feature and stable delivery.
+the existing read-only evidence MCP, repair issue #76 release recovery, and
+qualify checksum-pinned OCR 1.9.2 through issue #78 before final validation.
+Deliver completed slices as local checkpoint commits without intermediate
+pushes. Complete one synthetic installed-artifact E2E, then exactly one final
+local OCR review without GitLab posting, and push feature PR #77 only once
+after final local validation, before protected feature and stable delivery.
### Decisions
- Release-required target is 0.5.0. Keep this plan active through feature PR,
TestPyPI development verification, final release PR, stable registries,
provenance, annotated tag, immutable Release, receipt, supported-Python
- installs, and completed closure of issue #76.
+ installs, and completed closure of issues #76 and #78.
- Use only anonymized technology selection conclusions from the private
inventory. Never persist private host, project, namespace, path, payload, or
identifying aggregate data; all public fixtures and examples are synthetic.
@@ -39,13 +42,22 @@ review without GitLab posting, before protected feature and stable delivery.
- Make `.j2`, `.jinja`, `.jinja2`, extensionless Ansible-role templates, and
`.twig` files reviewable through explicit additive `include` patterns plus
specific merged rules in `examples/gitlab/rules.json`.
-- After complete E2E, run exactly one final local OCR 1.9.1 review over the M2
+- Qualify the full adjacent OCR 1.9.1 to 1.9.2 transition from published
+ checksums, source/tag history, release notes, and consumed-contract probes.
+ Classify every upstream item, adapt only demonstrated toolkit contracts, and
+ use qualified OCR 1.9.2 for installed-artifact E2E and the final review.
+- After complete E2E, run exactly one final local OCR 1.9.2 review over the M2
change range, require real evidence-MCP use, fix actionable findings, and use
only deterministic validation afterward.
- Between completed checkpoints, query the public project for newly opened OCR
compatibility/release issues. If a newer stable OCR appears, qualify its
complete adjacent chain and include required contract/rules adaptations in
0.5.0 before E2E and the single final OCR review.
+- Keep completed implementation slices as local checkpoint commits with tests,
+ self-review, documentation, and release-issue monitoring. Do not push each
+ checkpoint or retrigger PR checks; update feature PR #77 once, only after all
+ M2 implementation, installed-artifact E2E, the single final OCR review and
+ its deterministic corrections, and final local validation are complete.
### Work Queue
@@ -63,21 +75,24 @@ review without GitLab posting, before protected feature and stable delivery.
evidence.
7. [x] Implement React/Next framework evidence with TypeScript/Vite related
signals and npm/Yarn/pnpm resolution.
-8. [ ] Complete cross-provider deltas, coverage, bootstrap/MCP projections,
+8. [x] Qualify OCR 1.9.2 against 1.9.1 through canonical issue #78,
+ preserve checksum/source/probe evidence, classify every upstream change,
+ update tested/recommended pins and contracts, and use 1.9.2 thereafter.
+9. [ ] Complete cross-provider deltas, coverage, bootstrap/MCP projections,
documentation, strategy, roadmap, backlog, and milestone reconciliation.
Self-review hardening of provider semantics and bounds is complete; final
backlog/roadmap reconciliation remains pending until release-grade gates.
-9. [ ] Run complete Python 3.12-3.14, security, privacy, package, installed
- artifact, rules-preview, real-MCP-client, and synthetic no-post E2E gates.
-10. [ ] Run exactly one final local full OCR review, fix findings, then finish
+10. [ ] Run complete Python 3.12-3.14, security, privacy, package, installed
+ artifact, rules-preview, real-MCP-client, and synthetic no-post E2E gates.
+11. [ ] Run exactly one final local full OCR review, fix findings, then finish
deterministic validation, Gitleaks, self-review, feature PR checks/threads,
merge, and TestPyPI development readback.
-11. [ ] Prepare the final release PR as the last repository mutation, archive
- completed 0.4.7 history, consume fragments 76 and 77, and reconcile M2 to
- implemented truth while leaving external publication pending.
-12. [ ] Complete stable 0.5.0 TestPyPI/PyPI, provenance/hash/tag/immutable
- Release/receipt/Python-install readback and close #76 as completed without
- another repository PR.
+12. [ ] Prepare the final release PR as the last repository mutation, archive
+ completed 0.4.7 history, consume fragments 76, 77, and 78, and reconcile M2
+ to implemented truth while leaving external publication pending.
+13. [ ] Complete stable 0.5.0 TestPyPI/PyPI, provenance/hash/tag/immutable
+ Release/receipt/Python-install readback and close #76 and #78 as completed
+ without another repository PR.
### Issue #76 Checkpoint
@@ -100,6 +115,35 @@ review without GitLab posting, before protected feature and stable delivery.
allowlists, exact recovery workflow structure, and completed issue closure.
Durable release documentation now records the numeric-draft boundary.
+### OCR 1.9.2 Qualification Checkpoint
+
+- Hosted workflow `31571999318` verified every published release asset against
+ GitHub digest metadata and `sha256sum.txt`, then passed Linux version, CLI,
+ JSON preview, full-review result, additive-thinking, and posting-consumer
+ probes. Canonical issue #78 records the human-review-required lane.
+- Adjacent source review found that the tags diverge only because the retry
+ documentation commit was reapplied on the 1.9.2 line; both commits have the
+ same stable patch ID, so no 1.9.1 runtime behavior was dropped. The effective
+ rules/file-extension set and Go MCP SDK remain unchanged.
+- The only toolkit-consumed source change corrects OCR directory-only gitignore
+ matching for ancestor, glob, and root-anchor semantics. It is a compatible
+ file-selection fix and requires no toolkit runtime adaptation. New built-in
+ LLM providers, Pages/viewer changes, Action pinning, skill/retry/agent
+ documentation, and upstream CI are release-note-only context for this
+ toolkit. No future backlog item is activated.
+- Compatibility evidence, recommended/tested manifest state, runtime preflight,
+ and the checksum-pinned synthetic CI example now target 1.9.2. General user
+ documentation refers to the compatibility manifest instead of duplicating a
+ patch number; promotion automation no longer rewrites those durable pages.
+- The official Darwin arm64 binary was installed atomically only after its
+ published size and SHA-256 matched both release metadata and the checksum
+ file. Installed-path readback and local deterministic contract probes pass.
+ Final focused validation passes 148 tests plus 15 subtests; full quality
+ passes 635 tests plus 85 subtests at 79.77% coverage with Ruff, mypy, and
+ Bandit. Workflow YAML, Towncrier draft, manifest linkage, changed-public-file
+ privacy scan, and `git diff --check` pass. Issue #78 remains open until
+ protected 0.5.0 delivery and immutable release readback, alongside issue #76.
+
### Framework Plugins And Template Review Checkpoint
- A static package-owned plugin registry now interprets existing immutable
@@ -115,7 +159,7 @@ review without GitLab posting, before protected feature and stable delivery.
- Jinja `.j2`/`.jinja`/`.jinja2`, extensionless conventional Ansible-role
templates, and Twig `.twig` files are inventoried without persisting or
rendering content. The public synthetic rules pack adds explicit additive
- includes and ordered merged Jinja/Twig guidance; direct OCR 1.9.1 rules and
+ includes and ordered merged Jinja/Twig guidance; direct OCR rules and
preview probes select root, nested, role, and Twig paths that were previously
rejected as `unsupported_ext`.
- Focused self-review confirmed lock/checksum-only packages do not activate a
@@ -138,16 +182,18 @@ review without GitLab posting, before protected feature and stable delivery.
`3caa50b4fc5026da79c7f2ceae1deef31715f814`; stable 0.4.7 is externally
complete, while the retained plan below still records its former pending
pre-publication state.
-- OCR 1.9.1 resolves a custom Jinja rule but excludes `.j2` as
+- The recommended OCR resolves a custom Jinja rule but excludes `.j2` as
`unsupported_ext`; adding an explicit `include` pattern makes preview select
- it. `.j2`, `.jinja`, `.jinja2`, and `.twig` are absent from OCR 1.9.1's
- built-in extension allowlist.
+ it. `.j2`, `.jinja`, `.jinja2`, and `.twig` are absent from its built-in
+ extension allowlist.
- Existing dependency parsers already expose direct declarations and lock facts
for Python, Go, Composer, npm, Yarn, and pnpm. M2 adds interpretation,
component scoping, template inventory, explicit completeness, and review
selection rather than duplicating those parsers.
- Draft feature PR #77 supplies the real Towncrier identifier for M2 feature
- and rules fragments. Issue #76 remains open until immutable stable delivery.
+ and rules fragments. Canonical OCR 1.9.2 qualification issue #78 is open and
+ already contains passing hosted checksum/contract evidence; issue #76 and #78
+ remain open until immutable stable delivery.
## Recently Completed Plan: Harden GitLab suggestions and add SHA-bound approval for 0.4.7
diff --git a/README.md b/README.md
index d39dc11..e08ebb5 100644
--- a/README.md
+++ b/README.md
@@ -17,7 +17,7 @@ ocr --version
ocr-ci --help
```
-The current compatibility target is OCR `1.9.1`. CI should pin the release and verify its published checksum before execution.
+The exact recommended OCR release and its verified asset checksums live in the [versioned compatibility manifest](compatibility/ocr-support.json). CI should pin that release and checksum before execution.
The [versioned compatibility policy](docs/compatibility.md) records tested assets and evidence and describes the conservative Dependabot-like qualification workflow for later upstream releases.
Review output defaults to English. `OCR_REVIEW_LANGUAGE` accepts another explicit language name when a project needs localized review output; for example, `OCR_REVIEW_LANGUAGE=Russian`.
diff --git a/changelog.d/78.feature.md b/changelog.d/78.feature.md
new file mode 100644
index 0000000..362f26f
--- /dev/null
+++ b/changelog.d/78.feature.md
@@ -0,0 +1 @@
+Target checksum-verified Open Code Review 1.9.2 after adjacent compatibility qualification.
diff --git a/compatibility/evidence/ocr-1.9.2.json b/compatibility/evidence/ocr-1.9.2.json
new file mode 100644
index 0000000..7923ca9
--- /dev/null
+++ b/compatibility/evidence/ocr-1.9.2.json
@@ -0,0 +1,97 @@
+{
+ "assets": [
+ {
+ "name": "opencodereview-darwin-amd64",
+ "sha256": "836d2bbeb4aec19ce420eba5feb26e203ab751daffda7c495f60dee587c6d64a",
+ "size": 47095072
+ },
+ {
+ "name": "opencodereview-darwin-arm64",
+ "sha256": "9f58edafcc324af53fef54b3a5a451c38aea6a073381b499be74de4b81a10024",
+ "size": 44762786
+ },
+ {
+ "name": "opencodereview-linux-amd64",
+ "sha256": "15ed605305fc679604ed0f5fa8715db505af81c10ecb8a7846d5642bce4b834e",
+ "size": 45584546
+ },
+ {
+ "name": "opencodereview-linux-arm64",
+ "sha256": "70014bd1971682e1c702de4715fcd0a9a2d0066e106e1f20c3d89f10474f889f",
+ "size": 42991778
+ },
+ {
+ "name": "opencodereview-windows-amd64.exe",
+ "sha256": "a5bc4ae5c634a27b6a066121133bce602489f7ef0c6fe948b0c784610045ebac",
+ "size": 46817792
+ },
+ {
+ "name": "opencodereview-windows-arm64.exe",
+ "sha256": "59409fdf2ecba31a9491992711e14b2052fff411046768fc619cae49ef8ebdf9",
+ "size": 43611136
+ },
+ {
+ "name": "sha256sum.txt",
+ "sha256": "c5df11b6811799fea887b40207d00d41d6036672ab66ff6a52a2ddf1a1958704",
+ "size": 572
+ }
+ ],
+ "classification": "human-review-required",
+ "classification_reasons": [
+ "release notes contain a material or ambiguous compatibility signal"
+ ],
+ "comparison_version": "1.9.1",
+ "contracts": {
+ "comment_thinking_probe": {
+ "additive_field_preserved": true,
+ "posting_exposes_thinking": false,
+ "result": "passed"
+ },
+ "optional_capabilities": [
+ "llm_result_identity",
+ "per_run_model_override",
+ "per_run_provider_override"
+ ],
+ "preview_probe": {
+ "format": "json",
+ "path": "example.py",
+ "result": "passed",
+ "session_store_created": false
+ },
+ "required_review_flags": [
+ "--audience",
+ "--background-file",
+ "--format",
+ "--from",
+ "--preview",
+ "--rule",
+ "--to"
+ ],
+ "result_contract_probe": {
+ "additive_fields_allowed": true,
+ "comment_fields": [
+ "category",
+ "content",
+ "end_line",
+ "existing_code",
+ "path",
+ "severity",
+ "start_line",
+ "thinking"
+ ],
+ "manifest_schema": "ocr.run-manifest/v1",
+ "normalized_outcome": "clean",
+ "result": "passed"
+ },
+ "version_probe": "passed"
+ },
+ "published_at": "2026-08-12T06:51:06Z",
+ "release_changes": "## 🚀 Features\n\n- feat(pages): redesign hero with two-column layout and three install channels (#408)\n- feat(providers): add siliconflow (GLOBAL) to built-in providers (#772)\n- feat(providers): add siliconflow-cn as a built-in model provider (#775)\n- feat(llm): add novita api as a default provider (#829)\n- feat(viewer): add review comment tag filters (#779)\n- feat(llm): add dynamic cache breakpoint on last message for Anthropic (#828)\n\n## 🐛 Bug Fixes\n\n- fix(diff): match gitignore directory patterns (#853)\n- fix(action): pin nested action references to full commit SHAs (#836)\n- fix(codeql): Workflow does not contain permissions (#814)\n- fix(viewer): stack suggested code panels (#739)\n\n## 📖 Documentation\n\n- docs(skill): avoid output truncation in agent skill instructions (#809)\n- docs: add retry_codes configuration documentation (#827)\n- docs: add AGENTS.md and track CLAUDE.md for shared agent guidelines (#826)\n\n**Full Changelog**: https://github.com/alibaba/open-code-review/compare/v1.9.0...v1.9.2",
+ "release_notes_sha256": "79a26a0cd882cc52d42021d846a14ffb33acfa5f7e52c68d68da951e2b0cd1df",
+ "result": "compatible",
+ "schema_version": 2,
+ "tag": "v1.9.2",
+ "tested_baseline_version": "1.9.1",
+ "upstream_repository": "alibaba/open-code-review",
+ "version": "1.9.2"
+}
diff --git a/compatibility/ocr-support.json b/compatibility/ocr-support.json
index bd78331..01b5303 100644
--- a/compatibility/ocr-support.json
+++ b/compatibility/ocr-support.json
@@ -1,6 +1,6 @@
{
- "monitoring_floor": "1.9.1",
- "recommended_version": "1.9.1",
+ "monitoring_floor": "1.9.2",
+ "recommended_version": "1.9.2",
"releases": [
{
"assets": [
@@ -675,6 +675,57 @@
"release_url": "https://github.com/alibaba/open-code-review/releases/tag/v1.9.1",
"status": "tested",
"version": "1.9.1"
+ },
+ {
+ "assets": [
+ {
+ "name": "opencodereview-darwin-amd64",
+ "sha256": "836d2bbeb4aec19ce420eba5feb26e203ab751daffda7c495f60dee587c6d64a",
+ "size": 47095072
+ },
+ {
+ "name": "opencodereview-darwin-arm64",
+ "sha256": "9f58edafcc324af53fef54b3a5a451c38aea6a073381b499be74de4b81a10024",
+ "size": 44762786
+ },
+ {
+ "name": "opencodereview-linux-amd64",
+ "sha256": "15ed605305fc679604ed0f5fa8715db505af81c10ecb8a7846d5642bce4b834e",
+ "size": 45584546
+ },
+ {
+ "name": "opencodereview-linux-arm64",
+ "sha256": "70014bd1971682e1c702de4715fcd0a9a2d0066e106e1f20c3d89f10474f889f",
+ "size": 42991778
+ },
+ {
+ "name": "opencodereview-windows-amd64.exe",
+ "sha256": "a5bc4ae5c634a27b6a066121133bce602489f7ef0c6fe948b0c784610045ebac",
+ "size": 46817792
+ },
+ {
+ "name": "opencodereview-windows-arm64.exe",
+ "sha256": "59409fdf2ecba31a9491992711e14b2052fff411046768fc619cae49ef8ebdf9",
+ "size": 43611136
+ },
+ {
+ "name": "sha256sum.txt",
+ "sha256": "c5df11b6811799fea887b40207d00d41d6036672ab66ff6a52a2ddf1a1958704",
+ "size": 572
+ }
+ ],
+ "capabilities": [
+ "llm_result_identity",
+ "per_run_model_override",
+ "per_run_provider_override"
+ ],
+ "evidence": "compatibility/evidence/ocr-1.9.2.json",
+ "evidence_sha256": "1351352fa0ceafddb6bdb9104642374241eeeadd9806727332aaf6a6d60e6d2b",
+ "human_conclusion": "Compatible after human adjacent source review in issue #78 and hosted workflow run 31571999318. The release tags diverge only because the retry documentation commit was reapplied with the same stable patch-id; no 1.9.1 runtime behavior is lost. OCR directory-only gitignore matching now follows ancestor, glob, and root-anchor semantics and compatibly improves the toolkit-consumed review-selection boundary. New built-in providers, Pages/viewer, Action pinning, skill/retry/agent documentation, and upstream CI changes require no toolkit adaptation. CLI, preview, result/posting, rules/allowlist, and MCP contracts remain compatible; the Go MCP SDK remains v1.6.1.",
+ "published_at": "2026-08-12T06:51:06Z",
+ "release_url": "https://github.com/alibaba/open-code-review/releases/tag/v1.9.2",
+ "status": "tested",
+ "version": "1.9.2"
}
],
"schema_version": 1,
diff --git a/docs/compatibility.md b/docs/compatibility.md
index 54614fb..8a39ba1 100644
--- a/docs/compatibility.md
+++ b/docs/compatibility.md
@@ -8,13 +8,13 @@ The scheduled **OCR compatibility** workflow discovers stable upstream releases
Candidate execution uses the verified Linux amd64 binary on an Ubuntu runner. The harness checks the reported version, the CLI flags consumed by the GitLab integration, range preview behavior, an actual JSON review through a deterministic local gateway, and the additive JSON fields consumed by posting. For OCR 1.9.0 and later it also requires JSON preview without a session-store side effect and proves that additive comment `thinking` is accepted but not published to GitLab. Upstream source review separately verifies how OCR derives that field; the toolkit probe does not claim to reproduce a provider's private reasoning channel. Evidence permits unknown new fields but requires the fields the toolkit reads. Legacy result statuses and the versioned `ocr.run-manifest/v1` outcome are normalized through one shared toolkit contract; manifest coverage sets, failure classifications, terminal state, and budget attribution must agree before a result can be published.
-Built-in MCP qualification follows the protocol revisions supported by the recommended OCR release's exact MCP SDK. OCR 1.9.1 uses Go MCP SDK v1.6.1 and initiates revision `2025-11-25`; the evidence server also retains `2025-06-18`, `2025-03-26`, and `2024-11-05` for qualified older clients. For an unknown client revision the server follows MCP negotiation semantics by returning its current supported revision, leaving acceptance or termination to the client. Qualification exercises initialize, the initialized notification, ping, tool discovery, and bounded summary/list/get calls through the exact SDK rather than relying only on handcrafted JSON-RPC fixtures.
+Built-in MCP qualification follows the protocol revisions supported by the recommended OCR release's exact MCP SDK. The current recommended release uses Go MCP SDK v1.6.1 and initiates revision `2025-11-25`; the evidence server also retains `2025-06-18`, `2025-03-26`, and `2024-11-05` for qualified older clients. For an unknown client revision the server follows MCP negotiation semantics by returning its current supported revision, leaving acceptance or termination to the client. Qualification exercises initialize, the initialized notification, ping, tool discovery, and bounded summary/list/get calls through the exact SDK rather than relying only on handcrafted JSON-RPC fixtures.
The built-in stdio entry uses the toolkit's current absolute Python executable in isolated mode. OCR therefore does not depend on `PATH` lookup, and untrusted repository modules cannot shadow the installed toolkit when the MCP subprocess starts.
Candidates then take one of two lanes:
-- `automatic-safe`: only an adjacent patch in the already-tested major/minor line, with every probe passing and maintenance-only release notes containing no material compatibility signal. When several releases are discovered together, each keeps the manifest recommendation as its tested baseline but is classified against its adjacent predecessor. The aggregation job prepares one cumulative compatibility patch only when the complete sequence is contiguous and every member is automatic-safe. The patch covers the manifest, evidence, runtime preflight version, GitLab example version/checksum, and public version references. It never writes directly to `main`.
+- `automatic-safe`: only an adjacent patch in the already-tested major/minor line, with every probe passing and maintenance-only release notes containing no material compatibility signal. When several releases are discovered together, each keeps the manifest recommendation as its tested baseline but is classified against its adjacent predecessor. The aggregation job prepares one cumulative compatibility patch only when the complete sequence is contiguous and every member is automatic-safe. The patch covers the manifest, evidence, runtime preflight version, and GitLab example version/checksum. It never writes directly to `main`.
- `human-review-required`: every minor/major release, skipped or non-increasing patch, changed or failed contract, material/security/deprecation/config/provider signal, or ambiguous release notes. The workflow creates or refreshes one qualification issue with machine evidence, a bounded plain-text release-change excerpt, and a human checklist.
Each OCR version owns one stable HTML marker and one qualification issue. The workflow performs a single upsert through bounded direct issue listing rather than GitHub's eventually consistent search index. Historical issues closed with the `duplicate` label remain as incident evidence but do not compete for canonical identity. Any other duplicate state fails closed instead of creating another issue; after operators select and reconcile the canonical issue, reruns update it in place.
@@ -25,6 +25,6 @@ An automatic-safe result is not an automatic stable release. It must still pass
## Promotion and rollback
-Promotion changes `recommended_version`, advances `monitoring_floor`, adds the tested release and evidence, and updates every durable 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.
+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.
-Rollback selects a previously tested manifest entry, restores its runtime/example/documentation pins, and travels through the same release-required path. Do not delete historical evidence: it explains the prior support decision and lets future qualification distinguish a rollback from an unseen release.
+Rollback selects a previously tested manifest entry, restores its runtime and executable-example pins, and travels through the same release-required path. Do not delete historical evidence: it explains the prior support decision and lets future qualification distinguish a rollback from an unseen release.
diff --git a/docs/configuration.md b/docs/configuration.md
index cf6aaa9..2a37ec9 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -86,7 +86,7 @@ The compact bootstrap contains the same safe inventory of independent server/too
Evidence-store schema v2 includes closed `framework.detected` (`repository.framework-evidence/v1`) and `template.file` (`repository.template-evidence/v1`) facts from package-owned static plugins. Current plugins cover Jinja2, Echo/Fiber, Symfony/Twig, and React/Next with related gRPC, TypeScript, and Vite declarations. Plugins consume only already bounded immutable manifest/tree evidence: they cannot execute repository commands, load repository code, use network access, or start a second MCP server. Framework versions use the ecosystem's deterministic source: lock files for Python, Composer, and JavaScript, but the direct requirement or effective replacement in `go.mod` for Go. Local Go replacements remain explicit partial evidence rather than being mistaken for the replaced module version. Templates and configuration paths belong to the nearest manifest-root component; conventional Ansible-role templates retain the role root. Detailed declarations, resolutions, effective replacements, configuration/template paths, component scopes, and base/head deltas remain available through the existing `ocr_toolkit_evidence` summary/list/get interface.
-The synthetic GitLab `rules.json` uses additive `include` entries for `.j2`, `.jinja`, `.jinja2`, `.twig`, and conventional Ansible-role template paths because OCR 1.9.1 does not review those extensions by default. Explicit excludes still win. The matching Jinja/Twig rules are review guidance; they do not execute or render templates, infer runtime variables, or replace evidence completeness.
+The synthetic GitLab `rules.json` uses additive `include` entries for `.j2`, `.jinja`, `.jinja2`, `.twig`, and conventional Ansible-role template paths because the [recommended OCR](compatibility.md) does not review those extensions by default. Explicit excludes still win. The matching Jinja/Twig rules are review guidance; they do not execute or render templates, infer runtime variables, or replace evidence completeness.
Evidence-store schema v2 adds `repository.evidence-coverage/v1` records keyed by component, domain, scope, immutable ref, and commit. Framework plugins publish `framework.declaration`, `framework.resolution`, `framework.configuration`, and `template.inventory` scopes. Supported malformed or omitted manifests, source-item limits, configuration/template output limits, unsafe template object types, local Go replacements, and isolated provider failures all prevent a false completeness claim. Only `complete` coverage permits a missing positive fact to support an absence claim; absent, `partial`, `runtime-dependent`, and `unavailable` coverage mean unknown. Schema-v1 stores remain readable but are explicitly treated as having unknown completeness. The Ansible adopter recognizes static, plugin-based, and executable inventory sources without execution and models the recursive role `defaults/main/` and `vars/main/` loader surface verified for ansible-core 2.17 through the current 2.x loader contract. Unsupported later loader behavior or bounded read/parser failures degrade coverage rather than becoming false completeness.
diff --git a/docs/gitlab.md b/docs/gitlab.md
index 794ba46..9b6bae1 100644
--- a/docs/gitlab.md
+++ b/docs/gitlab.md
@@ -4,9 +4,9 @@ The toolkit's first provider adapter posts review results to GitLab merge reques
## Installation
-Install `open-code-review-toolkit` from PyPI. The example obtains the expected toolkit wheel digest from the matching immutable GitHub Release, then uses pip hash-checking and a local install. Install Open Code Review separately and pin `v1.9.1`; verify the release checksum before making the binary executable. The package never downloads OCR.
+Install `open-code-review-toolkit` from PyPI. The example obtains the expected toolkit wheel digest from the matching immutable GitHub Release, then uses pip hash-checking and a local install. Install Open Code Review separately and use the exact recommended version and asset checksum from the [compatibility manifest](../compatibility/ocr-support.json); the synthetic CI example carries the corresponding executable pin. The package never downloads OCR.
-Copy and adapt [the synthetic CI example](../examples/gitlab/ocr-review.gitlab-ci.yml). Its rules pack explicitly includes Jinja (`.j2`, `.jinja`, `.jinja2`), extensionless conventional Ansible-role templates, and Twig (`.twig`) because OCR 1.9.1 otherwise reports those paths as `unsupported_ext`; project `exclude` entries still take precedence. Keep the lint stage before the AI review stage so failed project checks block review. The example downloads a pinned toolkit wheel with bounded retries/timeouts, verifies its SHA-256 before a local `--no-deps` install, generates a private evidence store plus one compact bootstrap, and passes the bootstrap once with `--background-file`.
+Copy and adapt [the synthetic CI example](../examples/gitlab/ocr-review.gitlab-ci.yml). Its rules pack explicitly includes Jinja (`.j2`, `.jinja`, `.jinja2`), extensionless conventional Ansible-role templates, and Twig (`.twig`) because the recommended OCR does not select those extensions by default; project `exclude` entries still take precedence. Keep the lint stage before the AI review stage so failed project checks block review. The example downloads a pinned toolkit wheel with bounded retries/timeouts, verifies its SHA-256 before a local `--no-deps` install, generates a private evidence store plus one compact bootstrap, and passes the bootstrap once with `--background-file`.
## Required secrets
diff --git a/docs/security.md b/docs/security.md
index 53d19ab..54a35ce 100644
--- a/docs/security.md
+++ b/docs/security.md
@@ -32,13 +32,13 @@ Ansible Galaxy requirement includes use the same immutable-object boundary. Rela
Use a dedicated bot identity and least-privilege `GITLAB_API_TOKEN`. Protect and mask credentials. Do not expose secrets to pipelines for untrusted forks. Begin with manual execution for trusted contributors, review generated notes, and enable automatic posting only after the repository's threat model is accepted.
-Toolkit 0.4.7 adds formal GitLab approval as a default-on write. Set
+Formal GitLab approval is a default-on write. Set
`OCR_AUTO_APPROVE=false` before upgrading if the bot must remain comment-only or
is not an eligible project approver. GitLab approval rules, Code Owners,
protected branches, and reauthentication remain server-side controls; the
toolkit does not bypass them.
-Pin Open Code Review `v1.9.1` and verify its checksum. Pin Python dependencies through `uv.lock` and GitHub Actions by immutable commit SHA. MCP stdio commands and remote endpoints are privileged configuration; allow only reviewed servers and tools.
+Pin the exact recommended Open Code Review release from the [compatibility manifest](../compatibility/ocr-support.json) and verify its listed checksum. Pin Python dependencies through `uv.lock` and GitHub Actions by immutable commit SHA. MCP stdio commands and remote endpoints are privileged configuration; allow only reviewed servers and tools.
The [OCR compatibility policy](compatibility.md) requires double-source asset digest verification, bounded downloads, an executed Linux contract probe, and protected PR/release gates; qualification automation never writes directly to `main` or promotes an ambiguous release.
Stable-release authorization executes from the protected base SHA that predates
diff --git a/examples/gitlab/ocr-review.gitlab-ci.yml b/examples/gitlab/ocr-review.gitlab-ci.yml
index 9bdf2b7..0ec2c70 100644
--- a/examples/gitlab/ocr-review.gitlab-ci.yml
+++ b/examples/gitlab/ocr-review.gitlab-ci.yml
@@ -6,10 +6,10 @@ default:
image: python:3.12-slim
variables:
- OCR_VERSION: "v1.9.1"
+ OCR_VERSION: "v1.9.2"
OCR_TOOLKIT_VERSION: "0.1.0"
OCR_TOOLKIT_CHECKSUMS_URL: "https://github.com/xeonvs/open-code-review-toolkit/releases/download/v0.1.0/SHA256SUMS"
- OCR_SHA256: "9cb546e4f29389e3b7d768becc34a18cf2aaa6635610459fa65a7ea32a6c8bec"
+ OCR_SHA256: "15ed605305fc679604ed0f5fa8715db505af81c10ecb8a7846d5642bce4b834e"
OCR_POST_MODE: "draft"
OCR_STRICT_POSTING: "true"
# Default-on exact-SHA approval; set "false" for a comment-only bot.
diff --git a/scripts/ocr_compat.py b/scripts/ocr_compat.py
index c729118..293fab9 100644
--- a/scripts/ocr_compat.py
+++ b/scripts/ocr_compat.py
@@ -30,9 +30,6 @@
DEFAULT_MANIFEST = ROOT / "compatibility" / "ocr-support.json"
PREFLIGHT = ROOT / "src" / "ocr_toolkit" / "preflight.py"
GITLAB_EXAMPLE = ROOT / "examples" / "gitlab" / "ocr-review.gitlab-ci.yml"
-README = ROOT / "README.md"
-GITLAB_DOC = ROOT / "docs" / "gitlab.md"
-SECURITY_DOC = ROOT / "docs" / "security.md"
UPSTREAM_REPOSITORY = "alibaba/open-code-review"
UPSTREAM_API = f"https://api.github.com/repos/{UPSTREAM_REPOSITORY}"
USER_AGENT = "open-code-review-toolkit-compatibility/1"
@@ -1223,16 +1220,6 @@ def prepare_update(
f'OCR_SHA256: "{linux_asset["sha256"]}"',
source="example checksum",
)
- docs: list[Path] = []
- doc_payloads: list[str] = []
- for source in (README, GITLAB_DOC, SECURITY_DOC):
- path = root / source.relative_to(ROOT)
- text = path.read_text(encoding="utf-8")
- text = _replace_exact(
- text, old_version, version, source=f"{path.relative_to(root)} version"
- )
- docs.append(path)
- doc_payloads.append(text)
changelog_dir = root / "changelog.d"
fragment = changelog_dir / f"{fragment_number}.feature.md"
qualified = version if len(versions) == 1 else f"{versions[0]} through {version}"
@@ -1249,10 +1236,8 @@ def prepare_update(
manifest_path.write_bytes(manifest_payload)
preflight_path.write_text(preflight, encoding="utf-8")
example_path.write_text(example, encoding="utf-8")
- for path, text in zip(docs, doc_payloads, strict=True):
- path.write_text(text, encoding="utf-8")
fragment.write_text(fragment_text, encoding="utf-8")
- return [manifest_path, *destinations, preflight_path, example_path, *docs, fragment]
+ return [manifest_path, *destinations, preflight_path, example_path, fragment]
def render_issue(evidence: dict[str, Any]) -> str:
diff --git a/src/ocr_toolkit/preflight.py b/src/ocr_toolkit/preflight.py
index e16ee19..4db792c 100644
--- a/src/ocr_toolkit/preflight.py
+++ b/src/ocr_toolkit/preflight.py
@@ -24,7 +24,7 @@
"Accept": "application/json",
"User-Agent": "open-code-review-ci-preflight/1.0",
}
-EXPECTED_OCR_VERSION = "1.9.1"
+EXPECTED_OCR_VERSION = "1.9.2"
class PreflightError(Exception):
diff --git a/tests/test_evidence_mcp.py b/tests/test_evidence_mcp.py
index ff9d4b0..300923c 100644
--- a/tests/test_evidence_mcp.py
+++ b/tests/test_evidence_mcp.py
@@ -170,7 +170,7 @@ def test_json_rpc_initialize_lists_read_only_tool_and_returns_safe_errors() -> N
def test_initialize_supports_exact_recommended_ocr_sdk_protocol_revisions() -> None:
- """Negotiate every revision supported by OCR 1.9.1's Go MCP SDK."""
+ """Negotiate every revision supported by the recommended OCR's Go MCP SDK."""
assert PROTOCOL_VERSION == "2025-11-25"
assert {
@@ -189,7 +189,7 @@ def test_initialize_supports_exact_recommended_ocr_sdk_protocol_revisions() -> N
"params": {
"protocolVersion": version,
"capabilities": {},
- "clientInfo": {"name": "ocr", "version": "1.9.1"},
+ "clientInfo": {"name": "ocr", "version": "1.9.2"},
},
},
)
diff --git a/tests/test_integration_contracts.py b/tests/test_integration_contracts.py
index 524108f..091894f 100644
--- a/tests/test_integration_contracts.py
+++ b/tests/test_integration_contracts.py
@@ -61,6 +61,7 @@ def test_gitlab_example_preserves_review_gating_and_manual_self_test() -> None:
def test_gitlab_docs_match_the_current_review_surface() -> None:
"""Keep documented commands and ownership boundaries aligned with CI."""
+ readme = (PROJECT_ROOT / "README.md").read_text(encoding="utf-8")
docs = (PROJECT_ROOT / "docs" / "gitlab.md").read_text(encoding="utf-8")
configuration = (PROJECT_ROOT / "docs" / "configuration.md").read_text(encoding="utf-8")
security = (PROJECT_ROOT / "docs" / "security.md").read_text(encoding="utf-8")
@@ -94,8 +95,11 @@ def test_gitlab_docs_match_the_current_review_surface() -> None:
assert "Target/base guidance may describe policy" in security
assert "changed source/head guidance and accepted decisions cannot authorize" in security
assert f'OCR_VERSION: "v{recommended}"' in workflow
- assert f"v{recommended}" in docs
- assert f"v{recommended}" in security
+ assert "compatibility/ocr-support.json" in readme
+ assert "../compatibility/ocr-support.json" in docs
+ assert "../compatibility/ocr-support.json" in security
+ assert f"v{recommended}" not in docs
+ assert f"v{recommended}" not in security
assert f'OCR_SHA256: "{linux_digest}"' in workflow
assert "`Russian` is one example" in docs
assert "ocr-ci preflight" in workflow
@@ -106,7 +110,7 @@ def test_gitlab_docs_match_the_current_review_surface() -> None:
assert "review-background.md" not in workflow
assert '--from "${CI_MERGE_REQUEST_DIFF_BASE_SHA}"' in workflow
assert '--to "${CI_MERGE_REQUEST_SOURCE_BRANCH_SHA}"' in workflow
- assert f"Pin Open Code Review `v{recommended}` and verify its checksum" in security
+ assert "Pin the exact recommended Open Code Review release" in security
assert "when: manual" in workflow
assert "env -u OCR_LLM_TOKEN" in workflow
diff --git a/tests/test_ocr_compat.py b/tests/test_ocr_compat.py
index 2e1e522..86bccd7 100644
--- a/tests/test_ocr_compat.py
+++ b/tests/test_ocr_compat.py
@@ -43,8 +43,8 @@ def test_committed_manifest_is_valid_and_has_recommended_tested_baseline() -> No
module.validate_manifest(manifest, PROJECT_ROOT)
- assert manifest["recommended_version"] == "1.9.1"
- assert manifest["monitoring_floor"] == "1.9.1"
+ assert manifest["recommended_version"] == "1.9.2"
+ assert manifest["monitoring_floor"] == "1.9.2"
assert [(item["version"], item["status"]) for item in manifest["releases"]] == [
("1.7.17", "tested"),
("1.8.0", "tested"),
@@ -60,6 +60,7 @@ def test_committed_manifest_is_valid_and_has_recommended_tested_baseline() -> No
("1.8.10", "tested"),
("1.9.0", "tested"),
("1.9.1", "tested"),
+ ("1.9.2", "tested"),
]
@@ -122,9 +123,9 @@ def test_discovery_filters_known_prerelease_and_old_versions() -> None:
def test_discovery_pages_until_the_monitoring_floor() -> None:
module = load_script()
manifest = module.load_json(MANIFEST)
- first_page = [release("1.9.2")]
+ first_page = [release("1.9.3")]
first_page.extend({"draft": True} for _ in range(module.MAX_RELEASES_PER_PAGE - 1))
- second_page = [release("1.9.1")]
+ second_page = [release("1.9.2")]
requested: list[str] = []
def fake_request(url: str) -> list[dict[str, Any]]:
@@ -134,14 +135,14 @@ def fake_request(url: str) -> list[dict[str, Any]]:
with patched_attr(module, "_request_json", fake_request):
unseen = module.discover_unseen(manifest)
- assert [item["tag_name"] for item in unseen] == ["v1.9.2"]
+ assert [item["tag_name"] for item in unseen] == ["v1.9.3"]
assert len(requested) == 2
def test_discovery_fails_when_bounded_pages_do_not_reach_floor() -> None:
module = load_script()
manifest = module.load_json(MANIFEST)
- page = [release("1.9.2")]
+ page = [release("1.9.3")]
page.extend({"draft": True} for _ in range(module.MAX_RELEASES_PER_PAGE - 1))
with patched_attr(module, "_request_json", lambda _url: page):
@@ -186,14 +187,14 @@ def test_qualification_matrix_accepts_the_next_manual_patch() -> None:
module = load_script()
manifest = module.load_json(MANIFEST)
- matrix = module.qualification_matrix(manifest, [release("1.9.2")])
+ matrix = module.qualification_matrix(manifest, [release("1.9.3")])
assert matrix == {
"include": [
{
- "comparison_version": "1.9.1",
- "tag": "v1.9.2",
- "tested_baseline_version": "1.9.1",
+ "comparison_version": "1.9.2",
+ "tag": "v1.9.3",
+ "tested_baseline_version": "1.9.2",
}
]
}
@@ -769,9 +770,13 @@ def test_prepare_update_promotes_one_reviewed_release_chain(tmp_path: Path) -> N
'OCR_SHA256: "1f2611766a562aee300af75524270de9b99ab2cf5c63bf75a9546ebf809f78a6"\n',
encoding="utf-8",
)
- (root / "README.md").write_text("OCR 1.8.6 baseline\n", encoding="utf-8")
- (root / "docs" / "gitlab.md").write_text("Pin v1.8.6 in GitLab.\n", encoding="utf-8")
- (root / "docs" / "security.md").write_text("Verify OCR 1.8.6.\n", encoding="utf-8")
+ durable_docs = {
+ root / "README.md": "Use the compatibility manifest as the OCR source of truth.\n",
+ root / "docs" / "gitlab.md": "Use the manifest and executable CI pin.\n",
+ root / "docs" / "security.md": "Verify the manifest-listed checksum.\n",
+ }
+ for path, content in durable_docs.items():
+ path.write_text(content, encoding="utf-8")
assets = json.loads(baseline_evidence.read_text(encoding="utf-8"))["assets"]
assets = [dict(asset) for asset in assets]
for asset in assets:
@@ -825,9 +830,6 @@ def test_prepare_update_promotes_one_reviewed_release_chain(tmp_path: Path) -> N
"compatibility/evidence/ocr-1.8.8.json",
"src/ocr_toolkit/preflight.py",
"examples/gitlab/ocr-review.gitlab-ci.yml",
- "README.md",
- "docs/gitlab.md",
- "docs/security.md",
"changelog.d/42.feature.md",
}
updated = json.loads(manifest_path.read_text(encoding="utf-8"))
@@ -843,7 +845,8 @@ def test_prepare_update_promotes_one_reviewed_release_chain(tmp_path: Path) -> N
example_text = example.read_text(encoding="utf-8")
assert 'OCR_VERSION: "v1.8.8"' in example_text
assert f'OCR_SHA256: "{"b" * 64}"' in example_text
- assert "1.8.8" in (root / "README.md").read_text(encoding="utf-8")
+ for path, content in durable_docs.items():
+ assert path.read_text(encoding="utf-8") == content
fragment = (root / "changelog.d" / "42.feature.md").read_text(encoding="utf-8")
assert "1.8.7 through 1.8.8" in fragment
@@ -852,11 +855,11 @@ def test_prepare_update_rejects_human_review_candidate(tmp_path: Path) -> None:
module = load_script()
evidence = {
"schema_version": 2,
- "version": "1.9.2",
+ "version": "1.9.3",
"result": "compatible",
"classification": "human-review-required",
- "comparison_version": "1.9.1",
- "tested_baseline_version": "1.9.1",
+ "comparison_version": "1.9.2",
+ "tested_baseline_version": "1.9.2",
}
with pytest.raises(module.CompatibilityError, match="bounded conclusion"):
@@ -875,8 +878,8 @@ def test_prepare_update_requires_human_review_for_minor_transition() -> None:
"version": "1.10.0",
"result": "compatible",
"classification": "automatic-safe",
- "comparison_version": "1.9.1",
- "tested_baseline_version": "1.9.1",
+ "comparison_version": "1.9.2",
+ "tested_baseline_version": "1.9.2",
}
with pytest.raises(module.CompatibilityError, match="explicit human review"):
@@ -917,8 +920,8 @@ def test_prepare_update_rejects_nonadjacent_minor_transition() -> None:
"version": "1.11.0",
"result": "compatible",
"classification": "human-review-required",
- "comparison_version": "1.9.1",
- "tested_baseline_version": "1.9.1",
+ "comparison_version": "1.9.2",
+ "tested_baseline_version": "1.9.2",
}
with pytest.raises(module.CompatibilityError, match="contiguous release sequence"):
@@ -935,11 +938,11 @@ def test_prepare_update_rejects_conclusion_outside_evidence_chain() -> None:
module = load_script()
evidence = {
"schema_version": 2,
- "version": "1.9.2",
+ "version": "1.9.3",
"result": "compatible",
"classification": "automatic-safe",
- "comparison_version": "1.9.1",
- "tested_baseline_version": "1.9.1",
+ "comparison_version": "1.9.2",
+ "tested_baseline_version": "1.9.2",
}
with pytest.raises(module.CompatibilityError, match="only evidence versions"):
@@ -947,7 +950,7 @@ def test_prepare_update_rejects_conclusion_outside_evidence_chain() -> None:
manifest_path=MANIFEST,
evidence=evidence,
fragment_number=72,
- human_conclusions={"1.9.3": "Synthetic unrelated conclusion."},
+ human_conclusions={"1.9.4": "Synthetic unrelated conclusion."},
root=PROJECT_ROOT,
)
@@ -959,11 +962,11 @@ def test_prepare_update_rejects_invalid_optional_reviewed_conclusion(
module = load_script()
evidence = {
"schema_version": 2,
- "version": "1.9.2",
+ "version": "1.9.3",
"result": "compatible",
"classification": "automatic-safe",
- "comparison_version": "1.9.1",
- "tested_baseline_version": "1.9.1",
+ "comparison_version": "1.9.2",
+ "tested_baseline_version": "1.9.2",
}
with pytest.raises(module.CompatibilityError, match="bounded plain text"):
@@ -971,6 +974,6 @@ def test_prepare_update_rejects_invalid_optional_reviewed_conclusion(
manifest_path=MANIFEST,
evidence=evidence,
fragment_number=72,
- human_conclusions={"1.9.2": conclusion},
+ human_conclusions={"1.9.3": conclusion},
root=PROJECT_ROOT,
)
diff --git a/tests/test_operations_docs.py b/tests/test_operations_docs.py
index bc28756..5e1c3b8 100644
--- a/tests/test_operations_docs.py
+++ b/tests/test_operations_docs.py
@@ -120,6 +120,11 @@ def test_ocr_compatibility_workflow_is_bounded_and_protected() -> None:
assert "assess-chain" in workflow
assert "pattern: ocr-compatibility-v*" in workflow
assert workflow.count("prepare-update") == 1
+ assert "README.md docs/gitlab.md docs/security.md" not in workflow
+ assert 'README = ROOT / "README.md"' not in qualifier
+ assert 'GITLAB_DOC = ROOT / "docs" / "gitlab.md"' not in qualifier
+ assert 'SECURITY_DOC = ROOT / "docs" / "security.md"' not in qualifier
+ assert "public version references" not in policy
assert "MAX_QUALIFICATION_CHAIN = 10" in qualifier
assert "OCR_UPDATE_BOT_TOKEN" in workflow
assert "gh auth setup-git" in workflow
diff --git a/tests/test_runtime_helpers.py b/tests/test_runtime_helpers.py
index a6e5203..d390351 100644
--- a/tests/test_runtime_helpers.py
+++ b/tests/test_runtime_helpers.py
@@ -835,7 +835,7 @@ def test_invalid_json_error_does_not_echo_secret_payload(self) -> None:
class PreflightTests(unittest.TestCase):
def test_validate_ocr_binary_accepts_supported_version(self) -> None:
completed = subprocess.CompletedProcess(
- args=["ocr", "--version"], returncode=0, stdout="ocr 1.9.1\n", stderr=""
+ args=["ocr", "--version"], returncode=0, stdout="ocr 1.9.2\n", stderr=""
)
with (
patched_attr(preflight.shutil, "which", lambda _name: "/usr/bin/ocr"),
From 2ebc198c66217a49e5fd2aa92ab40d70c6a6d709 Mon Sep 17 00:00:00 2001
From: xeonvs <11463419+xeonvs@users.noreply.github.com>
Date: Wed, 12 Aug 2026 10:35:19 +0200
Subject: [PATCH 08/11] Expose evidence deltas through MCP
---
PLANS.md | 82 ++++++-
README.md | 2 +-
ROADMAP.md | 10 +-
changelog.d/77.feature.md | 2 +-
docs/codex/TASKS_BACKLOG.md | 65 ++----
docs/configuration.md | 4 +-
docs/engineering/toolkit_strategy.md | 18 +-
docs/security.md | 2 +-
src/ocr_toolkit/evidence/collect.py | 21 +-
src/ocr_toolkit/evidence/mcp.py | 75 ++++--
src/ocr_toolkit/evidence/model.py | 53 ++++-
src/ocr_toolkit/evidence/project.py | 9 +-
src/ocr_toolkit/evidence/store.py | 66 +++++-
tests/test_evidence_framework_plugins.py | 278 +++++++++++++++++++++++
tests/test_evidence_mcp.py | 141 ++++++++++++
tests/test_evidence_model.py | 186 +++++++++++++++
tests/test_evidence_repository.py | 83 +++++++
17 files changed, 991 insertions(+), 106 deletions(-)
diff --git a/PLANS.md b/PLANS.md
index 57aeb9b..b1978d2 100644
--- a/PLANS.md
+++ b/PLANS.md
@@ -78,11 +78,12 @@ after final local validation, before protected feature and stable delivery.
8. [x] Qualify OCR 1.9.2 against 1.9.1 through canonical issue #78,
preserve checksum/source/probe evidence, classify every upstream change,
update tested/recommended pins and contracts, and use 1.9.2 thereafter.
-9. [ ] Complete cross-provider deltas, coverage, bootstrap/MCP projections,
- documentation, strategy, roadmap, backlog, and milestone reconciliation.
- Self-review hardening of provider semantics and bounds is complete; final
- backlog/roadmap reconciliation remains pending until release-grade gates.
-10. [ ] Run complete Python 3.12-3.14, security, privacy, package, installed
+9. [x] Complete cross-provider deltas, coverage, bootstrap/MCP projections,
+ documentation, strategy, roadmap, backlog, and current milestone
+ reconciliation. The roadmap remains honestly in progress until installed
+ E2E, final review, and stable delivery; conditional future packs no longer
+ block M2 closure.
+10. [x] Run complete Python 3.12-3.14, security, privacy, package, installed
artifact, rules-preview, real-MCP-client, and synthetic no-post E2E gates.
11. [ ] Run exactly one final local full OCR review, fix findings, then finish
deterministic validation, Gitleaks, self-review, feature PR checks/threads,
@@ -176,6 +177,77 @@ after final local validation, before protected feature and stable delivery.
strategy documentation describe the implemented boundaries, degradation, and
review-selection behavior.
+### Cross-provider Delta And MCP Projection Checkpoint
+
+- The shared evidence MCP now exposes already-collected base/head changes as a
+ first-class `repository.evidence_delta` projection. `delta_kind` narrows the
+ original fact domain, ordinary unfiltered lists remain backward compatible,
+ and stable delta IDs support the existing `get` action without adding a tool,
+ server, plugin-owned lifecycle, filesystem access, or network access.
+- Delta values and metadata are recursively re-redacted, re-bounded, validated
+ against the closed evidence-kind vocabulary, deduplicated after normalization,
+ and only then assigned content-addressed IDs. Persisted delta objects reject
+ unknown fields and over-limit collections. The collector derives typed deltas
+ from canonical records actually accepted by the store, so rejected, omitted,
+ deduplicated, or redaction-equivalent facts cannot leave dangling changes.
+- A synthetic multi-ecosystem contract exercises Jinja2 templates, Go web
+ providers, Symfony/Twig, and React/Next together. It proves framework and
+ template additions, removals, and changes; scoped-completeness transitions;
+ summary and filtered list/get projection for facts, coverage, and deltas; and
+ compact-bootstrap orientation without embedding detailed paths or versions.
+- Durable architecture, configuration, security, roadmap, and backlog text now
+ describes the implemented shared projection. Completed BL-008 and BL-009
+ scope is removed from future work; demand-triggered evidence packs remain a
+ separate conditional item and do not keep M2 permanently open. The roadmap
+ keeps M2 in progress until installed-artifact E2E, the single final OCR
+ review, and independently verified stable delivery complete its signal.
+- Focused evidence, MCP, model, repository, documentation, and integration
+ validation passes. The complete routine quality gate passes 651 tests plus 85
+ subtests at 79.96% coverage with formatting, Ruff, strict mypy, and the
+ medium-confidence/medium-severity Bandit gate clean. Towncrier draft,
+ changed-public-file privacy scan, issue monitoring, and `git diff --check`
+ pass. Full release-grade installed-artifact validation remains next.
+
+### Release-grade Installed-artifact E2E Checkpoint
+
+- Full tests pass independently on every supported Python interpreter. Gitleaks
+ over the unpublished feature range, dependency audit, OCR compatibility
+ manifest validation, changed-shell ShellCheck, and the existing privacy gate
+ pass without relying on hosted PR checks.
+- Two target-version builds are byte-identical. Twine and closed archive-content
+ inspection confirm a runtime-only wheel, the intentionally minimal sdist,
+ zero runtime dependencies, and the supported-Python contract. The wheel
+ SHA-256 is `b713676d47b4c9b8615e6bb81216b4ab1e2133ccd750e793401417b92e565056`;
+ the sdist SHA-256 is
+ `baab422d378caaaa17487ab7bb5d31b3478144bfadf4212b7f71d6baf918eded`.
+- Hash-locked wheel installs pass on the lower and intermediate supported
+ interpreters, and a hash-locked sdist build/install passes on the upper
+ interpreter. Each clean environment passes `pip check`, imports the exact
+ target development version from site-packages under isolated mode despite a
+ hostile repository-local shadow package, and runs the installed `ocr-ci`
+ entry point with a restricted `PATH`.
+- A real installed subprocess follows the generated mandatory MCP command and
+ completes initialize, initialized notification, ping, tool discovery,
+ summary, fact list/get, coverage list, and framework-delta list/get. Stable
+ fact and delta IDs, read-only annotations, exact installed server version,
+ private artifact modes, and the public page-size boundary are verified.
+- OCR rules preview with the qualified binary selects root and nested Jinja,
+ Twig, and extensionless conventional Ansible-role templates without an
+ unsupported-extension result or a preview session side effect.
+- The installed-wheel synthetic OCR E2E runs in a read-only Linux container
+ with no network, using only loopback HTTPS, a process-local CA, the
+ checksum-qualified OCR binary, public rules, and synthetic multi-ecosystem
+ history. The review completes with two real `ocr_toolkit_evidence` calls:
+ summary followed by a filtered framework-delta query. The toolkit receipt
+ matches OCR counters; Jinja2, Echo/Fiber, Symfony/Twig, React/Next,
+ TypeScript/Vite, templates, scoped completeness, and semantic deltas are
+ present; private modes and a clean Git status are preserved; no posting path
+ is invoked.
+- Read-only checkpoint monitoring still finds only issues #76 and #78 open, and
+ the latest upstream stable OCR remains the already qualified 1.9.2. No push
+ was made. The next irreversible model action remains the single final local
+ repository OCR review after this checkpoint is committed.
+
### Initial Evidence
- Clean synchronized `main` was exact annotated tag `v0.4.7` at
diff --git a/README.md b/README.md
index e08ebb5..c1dfd30 100644
--- a/README.md
+++ b/README.md
@@ -40,7 +40,7 @@ Project-wide accepted tradeoffs can be recorded separately in `.opencodereview/a
## Project architecture
-The shipped Repository Evidence Engine reads immutable base/head Git objects, stores bounded typed facts and deltas, creates the compact bootstrap used by OCR, and exposes detailed evidence through the mandatory built-in read-only MCP server. Reviewed external stdio or native HTTPS MCP servers compose alongside it without replacing the built-in evidence boundary.
+The shipped Repository Evidence Engine reads immutable base/head Git objects, stores bounded typed facts and deltas, creates the compact bootstrap used by OCR, and exposes detailed facts, scoped completeness, and base/head changes through the mandatory built-in read-only MCP server. Reviewed external stdio or native HTTPS MCP servers compose alongside it without replacing the built-in evidence boundary.
- [Toolkit strategy](docs/engineering/toolkit_strategy.md) - durable product boundaries, architecture, invariants, and non-goals.
- [Roadmap](ROADMAP.md) - milestone status, dependencies, outcomes, and completion signals.
diff --git a/ROADMAP.md b/ROADMAP.md
index 59e34e9..8808bcf 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -2,8 +2,8 @@
This roadmap describes ordered outcomes rather than release dates. Architecture direction lives in the [toolkit strategy](docs/engineering/toolkit_strategy.md); implementation-ready inactive work lives in the [backlog](docs/codex/TASKS_BACKLOG.md); current execution lives in [PLANS.md](PLANS.md).
-Status vocabulary: **established** means the documented foundation exists, **next** is the nearest implementation horizon, **planned** has defined dependencies, and **conditional** requires its activation signal.
-The diagram uses the same statuses as colors: green for established, blue for next, gray for planned, and amber for conditional. A milestone spanning two phases uses the earliest actionable status color while retaining both phases in its label.
+Status vocabulary: **established** means the documented foundation exists, **in progress** is active release work, **next** is the nearest implementation horizon, **planned** has defined dependencies, and **conditional** requires its activation signal.
+The diagram uses green for established, blue for in-progress or next work, gray for planned work, and amber for conditional work. A milestone spanning two phases uses the earliest actionable status color while retaining both phases in its label.
```mermaid
flowchart LR
@@ -12,7 +12,7 @@ flowchart LR
M1 --> M2["M2 Ecosystem and framework coverage
in progress"]
M1 --> M4["M4 Policy and project guidance
planned"]
M1 --> M5["M5 Measurement audit and profiles
planned"]
- M5 --> M6["M6 Later and conditional work
conditional"]
+ M1 --> M6["M6 Later and conditional work
conditional"]
classDef established fill:#1f883d,stroke:#116329,color:#ffffff
classDef next fill:#0969da,stroke:#0550ae,color:#ffffff
@@ -30,7 +30,7 @@ flowchart LR
| --- | --- | --- | --- | --- |
| M0 Foundation | Established | Durable planning sources, high-signal repository security checks, and repeatable OCR compatibility policy. | Existing CI and the current recommended/tested OCR baseline. | Strategy, roadmap, and backlog agree; Bandit is a bounded repository gate; every unseen stable OCR release receives checksum-verified machine evidence with adjacent comparison identity; only a wholly safe contiguous patch chain may receive one protected bot-ready update patch, while material or ambiguous changes require human qualification and no path writes directly to `main`. |
| M1 Evidence architecture | Established | One bounded evidence model supplies a compact bootstrap and built-in read-only MCP. | Machine-readable OCR capabilities and current context contracts. | Stable v0.4.0 publishes the model, immutable snapshots, typed deltas, bounded private storage, compact bootstrap, built-in MCP, semantic parity/removal, verified real-OCR use, reporting outcomes, and security hardening; TestPyPI/PyPI artifacts, provenance, hashes, annotated tag, immutable GitHub Release, and supported-Python smoke installs are independently verified. |
-| M2 Ecosystem and framework coverage | In progress | Close demonstrated evidence resolution, precedence, completeness, or component-scope gaps and select framework plugins from actual use. | Established evidence model and snapshot/delta semantics; item-specific gaps only. | Remaining formats and selected framework plugins have deterministic fixtures, bounds, provenance, completeness, and source/target deltas without restating established collectors. |
+| M2 Ecosystem and framework coverage | In progress | Supply framework and template evidence selected from demonstrated use without creating framework-specific review engines. | Established evidence, snapshot/delta, scoped-completeness, and built-in MCP contracts. | Selected static plugins and template review rules have deterministic fixtures, bounds, provenance, component ownership, completeness, first-class source/target delta queries, installed-artifact validation, verified use through the existing built-in MCP, and independently read-back stable delivery. |
| M3 External MCP hardening | Next / planned | Threat-model external references and validate provider-specific read-only examples on the established built-in/external MCP composition boundary. | Existing external MCP and built-in composition for current generic operation; BL-011 before reference detection or provider examples. | Threat model precedes reference detection and provider examples; synthetic YouTrack, Confluence, or documentation examples preserve narrow read-only tools, reserved namespaces, and trust separation. Managed OAuth remains conditional on a named provider requirement. |
| M4 Policy and project guidance | Planned | Supply relevant target-branch decisions and guidance without allowing self-whitelisting. | Evidence scoping and target/source snapshots. | Semi-structured decisions remain backward compatible; guidance paths and hints are bounded, target-derived, and non-authoritative. |
| M5 Profiles and quality measurement | Planned | Audit current OCR telemetry and result-derived review signals before adding profiles or any toolkit metrics. | Established result, discussion, coverage, posting, and MCP-use receipts; the owner-approved matrix is required only for profile implementation. | The audit either proves current bounded reporting sufficient or isolates a separately scoped provider-neutral gap; any later profiles are deterministic and documented without sensitive, high-cardinality, or duplicate data. |
@@ -40,7 +40,7 @@ flowchart LR
- OCR compatibility and the established common evidence model now converge at compact-bootstrap/evidence-MCP integration.
- M3 threat modeling can proceed from the established generic composition boundary; provider examples wait for BL-011, while managed OAuth does not block static-header or stdio operation.
-- M2 and M4 can proceed from the stable evidence contracts they consume.
+- M2 repository implementation is in release validation; conditional future ecosystem packs do not block its closure. M4 can proceed independently from the stable evidence contracts it consumes.
- The M5 measurement-gap audit can begin from current lifecycle and result receipts; BL-016 is required only for later named-profile comparisons.
- Versioned documentation remains a separate MCP integration: the toolkit supplies package/version evidence but does not store documentation.
- Additional code-hosting adapters are not ecosystem collectors. They remain conditional because the near-term product is GitLab-first.
diff --git a/changelog.d/77.feature.md b/changelog.d/77.feature.md
index 82b4623..305a267 100644
--- a/changelog.d/77.feature.md
+++ b/changelog.d/77.feature.md
@@ -1 +1 @@
-Add bounded framework and template evidence plugins for Jinja2, Go web frameworks, Symfony/PHP, and React/TypeScript through the built-in evidence MCP.
+Add bounded framework and template evidence plugins for Jinja2, Go web frameworks, Symfony/PHP, and React/TypeScript, including scoped completeness and first-class redacted delta queries through the built-in evidence MCP.
diff --git a/docs/codex/TASKS_BACKLOG.md b/docs/codex/TASKS_BACKLOG.md
index 8d62fbb..e0f860c 100644
--- a/docs/codex/TASKS_BACKLOG.md
+++ b/docs/codex/TASKS_BACKLOG.md
@@ -11,54 +11,8 @@ Statuses are `ready`, `planned`, `parked`, `conditional`, or `owner action`. Rel
| Native fuzzing campaign | Retained and revised | BL-019 connects fuzzing to the future evidence/MCP parser attack surface and keeps bounded CI and corpus ownership as activation requirements. |
| Additional provider adapters | Retained, clarified, and reprioritized | BL-021 is explicitly about code-hosting and review-host adapters beyond GitLab, not repository ecosystem/framework evidence. |
| File-based user configuration | Retained and redesigned | BL-020 waits for profile, MCP, and evidence schemas while preserving environment precedence and excluding secrets. |
-
-## M2 Ecosystem and framework coverage
-
-### BL-008: Close remaining evidence resolution and scoping gaps
-
-- **Status:** planned
-- **Priority:** high
-- **Roadmap theme:** M2 Ecosystem and framework coverage
-- **Dependencies:** Established M1 evidence, immutable snapshot/delta, MCP projection, and scoped-completeness contracts.
-- **Activation trigger:** A demonstrated repository supplies a remaining format, precedence, or component-scope gap with safe synthetic fixtures.
-- **Goal:** Complete only missing repository-derived resolution and scoping semantics without reimplementing established M1 collectors.
-- **Implemented baseline:** Python declarations, recursive requirements, uv, Poetry, Pipenv locks, and standardized locks; JavaScript package metadata and npm v1-v3, Yarn Classic/Modern, and pnpm v5-v9 locks; Go module, language/toolchain/GODEBUG, requirement, replacement, exclusion, tool, retract/ignore, and checksum evidence; Composer declarations, locks, platform constraints/overrides, sources, and resolution policy; Ansible Galaxy requirements/includes, role topology, inventories, and runtime-dependent coverage; declarative container and GitLab CI images; immutable base/head deltas; built-in MCP visibility; and scoped evidence completeness.
-- **Remaining deliverables:** Add only applicable missing formats such as repository-provided installed metadata or demonstrated lock variants; preserve marker, platform, and workspace dimensions; define conflicts and precedence without collapsing declared and resolved facts; represent mutable image tags separately from immutable digests; and close proven component-scope or completeness gaps.
-- **Acceptance criteria:** Each newly supported format has deterministic semantics and fixtures; platform/marker/workspace variants and conflicting sources remain distinct; mutable image tags are never represented as immutable pins; malformed/oversized files degrade without network access; every domain that can support negative inference publishes applicable scoped completeness through the established evidence-coverage contract.
-- **Exclusions:** Reworking implemented collectors without a gap, unused ecosystems, mutable runner inspection, package-registry queries, arbitrary build execution, or treating declarations as resolved versions.
-- **Validation:** Per-format source/target fixtures, conflict and limit cases, and common evidence-model contract tests.
-- **Release classification expectation:** `release-required` for new public evidence behavior; a format-selection audit alone is `no-release`.
-- **Upstream overlap:** OCR file selection and generic rules do not supply repository evidence, resolution semantics, provenance, deltas, or scoped completeness. OCR 1.9.0 adding Nim to its rules and file allowlist changes review-engine scope only; it neither completes nor broadens this narrowed item by itself.
-
-### BL-009: Select and establish framework evidence plugins
-
-- **Status:** planned
-- **Priority:** medium
-- **Roadmap theme:** M2 Ecosystem and framework coverage
-- **Dependencies:** Established M1 evidence/snapshot/plugin boundaries. A selected plugin depends on BL-008 only when it consumes one of that item's specific remaining gaps.
-- **Activation trigger:** An anonymized inventory of pilot repositories identifies at least two high-value framework candidates with safe synthetic fixtures.
-- **Goal:** Select and implement 2-3 framework plugins that improve review evidence without building code graphs.
-- **Scoped deliverables:** Inventory pilot repositories without recording private names or contents; score candidates by prevalence, version-sensitive API surface, deterministic detectability, synthetic-fixture feasibility, and expected review-quality impact; record the selection decision; define a bounded plugin protocol and implement the selected providers. Existing Ansible parser maturity may support, but cannot substitute for, the scored selection.
-- **Acceptance criteria:** The inventory and scoring justify each selected plugin; plugins expose framework identity, verified version, component scope, important configuration paths, material deltas, and applicable scoped completeness; they cannot run arbitrary commands or network requests and avoid whole-repository traversal when changed components are known. Ansible's established coverage implementation is a reusable first adopter, not a substitute for selecting future plugins from demonstrated use.
-- **Exclusions:** Route/call/symbol graphs, framework-specific reviewers, or speculative detection without version evidence.
-- **Validation:** Positive/negative/multi-component fixtures, version-conflict and staleness cases, and plugin isolation tests.
-- **Release classification expectation:** `release-required`.
-- **Upstream overlap:** OCR's built-in Nix, Haskell, and 1.9.0 Nim rules improve language review but do not identify frameworks, versions, component scope, provenance, or completeness. They do not satisfy the plugin selection trigger or any BL-009 acceptance criterion.
-
-### BL-010: Add evidence packs from demonstrated use cases
-
-- **Status:** conditional
-- **Priority:** medium
-- **Roadmap theme:** M2 Ecosystem and framework coverage
-- **Dependencies:** Established M1 extension contracts and only the specific collector or plugin boundary used by the demonstrated pack; broad completion of BL-008 and BL-009 is not required.
-- **Activation trigger:** A real repository need identifies a missing ecosystem or framework and supplies safe synthetic fixtures and deterministic semantics.
-- **Goal:** Extend coverage without accumulating shallow detectors.
-- **Scoped deliverables:** Implement one coherent ecosystem or framework pack per activation, with provenance, bounds, source/target deltas, documentation, and public synthetic examples.
-- **Acceptance criteria:** The use case and completion signal are documented before implementation; false-positive behavior and unsupported versions are explicit through the shared scoped coverage contract.
-- **Exclusions:** Checkbox coverage, network resolution, runtime code execution, or bundles spanning unrelated ecosystems.
-- **Validation:** Pack-specific fixtures plus common evidence and bootstrap/MCP projection contracts.
-- **Release classification expectation:** `release-required`.
-- **Upstream overlap:** Built-in OCR language allowlists and rules are review-engine capabilities, not toolkit evidence packs. OCR 1.8.8 Nix/Haskell and OCR 1.9.0 Nim support create no demonstrated missing-evidence use case and do not activate BL-010.
+| M2 evidence gaps (BL-008) | Completed and removed | Demonstrated framework resolution, component ownership, scoped completeness, and first-class MCP delta projection ship through the shared evidence contracts; unproven formats are not retained as mandatory work. |
+| M2 framework selection (BL-009) | Completed and removed | The anonymized selection produced bounded static Jinja2, Go web, Symfony/Twig, and React/Next providers with synthetic cross-provider validation and no second MCP. |
## M3 External MCP hardening
@@ -186,6 +140,21 @@ Telemetry is intentionally outside M1. OCR owns token, cost, budget, provider-le
## M6 Later and conditional work
+### BL-010: Add evidence packs from demonstrated use cases
+
+- **Status:** conditional
+- **Priority:** medium
+- **Roadmap theme:** M6 Later and conditional work
+- **Dependencies:** Established evidence, snapshot/delta, scoped-completeness, static-plugin, and built-in MCP projection contracts; only the boundary consumed by the demonstrated pack is required.
+- **Activation trigger:** A real repository need identifies a missing ecosystem or framework and supplies safe synthetic fixtures and deterministic semantics.
+- **Goal:** Extend coverage without accumulating shallow detectors.
+- **Scoped deliverables:** Implement one coherent ecosystem or framework pack per activation, with provenance, bounds, source/target deltas, documentation, and public synthetic examples.
+- **Acceptance criteria:** The use case and completion signal are documented before implementation; false-positive behavior and unsupported versions are explicit through the shared scoped coverage contract.
+- **Exclusions:** Checkbox coverage, network resolution, runtime code execution, or bundles spanning unrelated ecosystems.
+- **Validation:** Pack-specific fixtures plus common evidence and bootstrap/MCP projection contracts.
+- **Release classification expectation:** `release-required`.
+- **Upstream overlap:** Built-in OCR language allowlists and rules are review-engine capabilities, not toolkit evidence packs. A new reviewable language alone creates no demonstrated missing-evidence use case and does not activate BL-010.
+
### BL-019: Run a native fuzzing campaign
- **Status:** parked
diff --git a/docs/configuration.md b/docs/configuration.md
index 2a37ec9..6eac150 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -82,9 +82,9 @@ environment variables for policy thresholds or category lists in this release.
The private `.review-context/evidence.json` store and `.review-context/bootstrap.md` projection are internal implementation artifacts, not public path configuration. Keep `.review-context/` ignored. The directory is mode `0700`, files are mode `0600`, and symlink or non-regular-file targets are rejected. 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.
-The compact bootstrap contains the same safe inventory of independent server/tool entries that was written to OCR configuration. The mandatory built-in server exposes `ocr_toolkit_evidence`, with `summary`, paginated/filterable `list`, and stable-ID `get` actions. It has no mutation action, network access, or shell execution. Optional MCP entries expose their own allowlisted tools; they can coexist with but cannot remove or shadow the mandatory entry.
+The compact bootstrap contains the same safe inventory of independent server/tool entries that was written to OCR configuration. The mandatory built-in server exposes `ocr_toolkit_evidence`, with `summary`, paginated/filterable `list`, and stable-ID `get` actions. An explicit `kind=repository.evidence_delta` list query returns redacted base/head changes; `delta_kind` narrows them by their original fact kind, and their stable IDs can be passed to `get`. The ordinary unfiltered list remains facts and scoped coverage only. It has no mutation action, network access, or shell execution. Optional MCP entries expose their own allowlisted tools; they can coexist with but cannot remove or shadow the mandatory entry.
-Evidence-store schema v2 includes closed `framework.detected` (`repository.framework-evidence/v1`) and `template.file` (`repository.template-evidence/v1`) facts from package-owned static plugins. Current plugins cover Jinja2, Echo/Fiber, Symfony/Twig, and React/Next with related gRPC, TypeScript, and Vite declarations. Plugins consume only already bounded immutable manifest/tree evidence: they cannot execute repository commands, load repository code, use network access, or start a second MCP server. Framework versions use the ecosystem's deterministic source: lock files for Python, Composer, and JavaScript, but the direct requirement or effective replacement in `go.mod` for Go. Local Go replacements remain explicit partial evidence rather than being mistaken for the replaced module version. Templates and configuration paths belong to the nearest manifest-root component; conventional Ansible-role templates retain the role root. Detailed declarations, resolutions, effective replacements, configuration/template paths, component scopes, and base/head deltas remain available through the existing `ocr_toolkit_evidence` summary/list/get interface.
+Evidence-store schema v2 includes closed `framework.detected` (`repository.framework-evidence/v1`) and `template.file` (`repository.template-evidence/v1`) facts from package-owned static plugins. Current plugins cover Jinja2, Echo/Fiber, Symfony/Twig, and React/Next with related gRPC, TypeScript, and Vite declarations. Plugins consume only already bounded immutable manifest/tree evidence: they cannot execute repository commands, load repository code, use network access, or start a second MCP server. Framework versions use the ecosystem's deterministic source: lock files for Python, Composer, and JavaScript, but the direct requirement or effective replacement in `go.mod` for Go. Local Go replacements remain explicit partial evidence rather than being mistaken for the replaced module version. Templates and configuration paths belong to the nearest manifest-root component; conventional Ansible-role templates retain the role root. Detailed declarations, resolutions, effective replacements, configuration/template paths, component scopes, and redacted base/head deltas remain available through the same `ocr_toolkit_evidence` summary/list/get interface.
The synthetic GitLab `rules.json` uses additive `include` entries for `.j2`, `.jinja`, `.jinja2`, `.twig`, and conventional Ansible-role template paths because the [recommended OCR](compatibility.md) does not review those extensions by default. Explicit excludes still win. The matching Jinja/Twig rules are review guidance; they do not execute or render templates, infer runtime variables, or replace evidence completeness.
diff --git a/docs/engineering/toolkit_strategy.md b/docs/engineering/toolkit_strategy.md
index aa36fe2..46bae8a 100644
--- a/docs/engineering/toolkit_strategy.md
+++ b/docs/engineering/toolkit_strategy.md
@@ -58,26 +58,26 @@ flowchart LR
Every evidence record should preserve its kind and value together with source path, git ref, component scope, provenance, confidence, and staleness where meaningful. Collection is deterministic and network-independent. Storage has explicit bounds and stable ordering. Rendering never upgrades inferred or untrusted material into authoritative policy.
-The engine separates four implemented responsibilities:
+The engine keeps implemented responsibilities separate:
-1. collectors parse repository material into structured evidence;
-2. bounded storage normalizes and indexes that evidence;
-3. bootstrap planning selects the smallest useful trusted overview;
-4. renderers produce stable text or read-only MCP responses.
+- collectors parse repository material into structured evidence;
+- bounded storage normalizes and indexes that evidence;
+- bootstrap planning selects the smallest useful trusted overview;
+- renderers produce stable text or read-only MCP responses.
The evidence model is the main extension point. Ecosystem and framework plugins may contribute typed facts, but cannot run arbitrary commands, fetch the network, mutate the repository, or introduce a second review workflow.
## Implemented compact bootstrap and built-in evidence MCP
-The OCR background is a compact bootstrap, normally around 1,500-2,500 characters and always below the toolkit/OCR hard limit. It contains authoritative constraints and trust instructions, base/head identity, detected ecosystems, material runtime or dependency changes, the validated composed MCP capability inventory, relevant accepted decisions, and short project-guidance hints. Bootstrap planning and OCR MCP configuration consume the same composition plan so the instructions cannot advertise unavailable tools or omit available allowlisted tools.
+The OCR background is a compact bootstrap bounded below the toolkit/OCR hard limit. It contains authoritative constraints and trust instructions, base/head identity, evidence and delta-kind counts, the validated composed MCP capability inventory, relevant accepted decisions, and short project-guidance hints. Bootstrap planning and OCR MCP configuration consume the same composition plan so the instructions cannot advertise unavailable tools or omit available allowlisted tools.
Complete manifests, dependency inventories, guidance documents, and external issue/page contents do not belong in the bootstrap. Detailed repository facts are available on demand through a built-in server registered under a reserved namespace such as `ocr_toolkit_evidence`, with tools prefixed `ocr_toolkit_`. Candidate tools expose review environment, changed components, dependency state and deltas, framework state, version evidence, and accepted decisions.
-The server is read-only, repository-root constrained, bounded, deterministic, network-independent, and incapable of arbitrary command execution. Its summary, filtered/paginated list, and stable-ID get actions expose scoped evidence completeness: absence supports a negative conclusion only for an applicable complete scope. Reserved server and tool names plus global tool-collision checks prevent downstream configuration from shadowing built-in capabilities.
+The server is read-only, repository-root constrained, bounded, deterministic, network-independent, and incapable of arbitrary command execution. Its summary, filtered/paginated list, and stable-ID get actions expose facts, scoped completeness, and an explicit first-class base/head delta projection. Delta values and metadata are re-redacted and re-bounded before their content-addressed IDs are derived or any list/get response is rendered. Absence supports a negative conclusion only for an applicable complete scope. Reserved server and tool names plus global tool-collision checks prevent downstream configuration from shadowing built-in capabilities.
Compact bootstrap and built-in evidence MCP are one established user-visible unit. Detailed facts removed from the bootstrap remain available on demand through the built-in MCP.
-## Partially implemented evidence domains
+## Established evidence domains and conditional extensions
### Dependencies, runtimes, and components
@@ -89,7 +89,7 @@ Implemented collectors cover Python declarations, requirements, uv, Poetry, Pipe
Framework support is package-owned static plugin extraction, not a code graph or framework-specific review engine. The established registry covers Jinja2 and Jinja/Ansible-style templates, Echo/Fiber with direct gRPC stack context, Symfony/Twig, and React/Next with TypeScript/Vite context. Plugins receive only immutable normalized dependency/tree evidence and exact core-owned source-status records from the collector, publish closed framework/template facts plus scoped completeness, and cannot read the repository independently, execute commands, fetch the network, mutate state, or create another MCP/review flow. Components follow the nearest declaration manifest (or conventional Ansible role), Go uses effective `go.mod` requirement/replacement semantics, and every read, parse, configuration, template, or provider limit degrades its exact scope instead of turning missing facts into proof.
-OCR file selection remains a separate review-engine boundary. The public synthetic rules pack explicitly includes Jinja and Twig template paths that the recommended OCR does not allowlist by default, then supplies narrowly scoped merged rules. Framework identity, versions, component scope, configuration paths, and template deltas are stored once and served on demand by the existing built-in evidence MCP; rules neither duplicate those facts nor render templates.
+OCR file selection remains a separate review-engine boundary. The public synthetic rules pack explicitly includes Jinja and Twig template paths that the recommended OCR does not allowlist by default, then supplies narrowly scoped merged rules. Framework identity, versions, component scope, configuration paths, template inventory, scoped completeness, and their base/head deltas are stored once and served on demand by the existing built-in evidence MCP; rules neither duplicate those facts nor render templates.
The design borrows useful CodeGraph principles without adopting CodeGraph: deterministic extraction precedes rendering, work is component-scoped, facts retain provenance and staleness, and OCR retrieves surgical evidence on demand. Route, symbol, and call graphs remain out of scope.
diff --git a/docs/security.md b/docs/security.md
index 54a35ce..d97daab 100644
--- a/docs/security.md
+++ b/docs/security.md
@@ -24,7 +24,7 @@ The toolkit bridges four trust domains: repository content, OCR and its LLM/MCP
- Human replies are ownership boundaries: automation must not rewrite or resolve a discussion after a human takes part.
- Merge-request source SHA and merge-result SHA remain distinct.
-The evidence engine reads exact base/head Git objects without checkout, refuses symlinks and submodules, stores redacted typed records in owner-only files, and exposes them through a closed read-only MCP tool with bounded requests, responses, filters, and pagination. Target/base guidance may describe policy; changed source/head guidance and accepted decisions cannot authorize the review that introduces them. The compact bootstrap carries only refs, coverage, counts, diagnostics, and MCP usage instructions; detailed values remain in the evidence store.
+The evidence engine reads exact base/head Git objects without checkout, refuses symlinks and submodules, stores redacted typed records and deltas in owner-only files, and exposes them through a closed read-only MCP tool with bounded requests, responses, filters, and pagination. Deltas are recursively re-redacted and re-bounded before list/get projection; their metadata and stable IDs are derived only after that normalization. Target/base guidance may describe policy; changed source/head guidance and accepted decisions cannot authorize the review that introduces them. The compact bootstrap carries only refs, coverage, counts, delta kinds, diagnostics, and MCP usage instructions; detailed values remain in the evidence store.
Ansible Galaxy requirement includes use the same immutable-object boundary. Relative includes may only resolve to YAML blobs inside the authenticated tree; absolute, home-relative, root-escaping, symlink, and submodule targets are rejected. Include depth, file count, graph edges, parser items, and emitted diagnostics have independent limits so adversarial manifests degrade visibly without expanding unbounded work.
diff --git a/src/ocr_toolkit/evidence/collect.py b/src/ocr_toolkit/evidence/collect.py
index 4b9abcc..aa25f4b 100644
--- a/src/ocr_toolkit/evidence/collect.py
+++ b/src/ocr_toolkit/evidence/collect.py
@@ -86,14 +86,9 @@ def collect_repository_evidence(
coverage=tuple(head_coverage),
)
all_coverage = tuple((*base.coverage, *head.coverage))
- store = EvidenceStore(base=base, head=head, deltas=file_deltas(base, head))
+ snapshot_deltas = file_deltas(base, head)
+ store = EvidenceStore(base=base, head=head)
typed_facts = [*base_facts, *head_facts]
- store.deltas = tuple(
- sorted(
- (*store.deltas, *fact_deltas(typed_facts), *coverage_deltas(all_coverage)),
- key=lambda item: (item.kind, item.component, item.identity),
- )
- )
rejected_snapshot_records = [
record for record in (*base.records, *head.records) if not store.add(record)
]
@@ -125,6 +120,18 @@ def collect_repository_evidence(
)
store.add_diagnostic("typed evidence was truncated by store limits")
break
+ # Deltas are projections of canonical accepted store records, never raw facts
+ # or references to values that redaction, deduplication, or a budget omitted.
+ store.deltas = tuple(
+ sorted(
+ (
+ *snapshot_deltas,
+ *fact_deltas(store.records),
+ *coverage_deltas(all_coverage),
+ ),
+ key=lambda item: (item.kind, item.component, item.identity),
+ )
+ )
categories = categorize_paths(list(changed))
categories_truncated = False
for category, paths in sorted(categories.items()):
diff --git a/src/ocr_toolkit/evidence/mcp.py b/src/ocr_toolkit/evidence/mcp.py
index 4c76444..36739fe 100644
--- a/src/ocr_toolkit/evidence/mcp.py
+++ b/src/ocr_toolkit/evidence/mcp.py
@@ -12,7 +12,7 @@
from typing import TextIO, cast
from ocr_toolkit import __version__
-from ocr_toolkit.evidence.model import CoverageRecord, EvidenceRecord
+from ocr_toolkit.evidence.model import CoverageRecord, EvidenceDelta, EvidenceRecord
from ocr_toolkit.evidence.store import EvidenceStore, EvidenceStoreError
TOOL_NAME = "ocr_toolkit_evidence"
@@ -40,6 +40,7 @@ class _Query:
"""Hold normalized list filters used to bind an opaque cursor."""
kind: str | None
+ delta_kind: str | None
component: str | None
ref: str | None
@@ -47,7 +48,12 @@ def key(self) -> str:
"""Return a stable fingerprint for cursor/query binding."""
value = json.dumps(
- {"component": self.component, "kind": self.kind, "ref": self.ref},
+ {
+ "component": self.component,
+ "delta_kind": self.delta_kind,
+ "kind": self.kind,
+ "ref": self.ref,
+ },
sort_keys=True,
separators=(",", ":"),
)
@@ -69,11 +75,13 @@ def evidence_summary(store: EvidenceStore) -> dict[str, object]:
kinds: dict[str, int] = {}
components: dict[str, int] = {}
changes: dict[str, int] = {}
+ delta_kinds: dict[str, int] = {}
for record in store.records:
kinds[record.kind] = kinds.get(record.kind, 0) + 1
components[record.component] = components.get(record.component, 0) + 1
- for delta in store.deltas:
+ for delta in store.safe_deltas:
changes[delta.change] = changes.get(delta.change, 0) + 1
+ delta_kinds[delta.kind] = delta_kinds.get(delta.kind, 0) + 1
coverage_states: dict[str, int] = {}
for coverage_record in store.coverage:
coverage_states[coverage_record.state.value] = (
@@ -90,6 +98,7 @@ def evidence_summary(store: EvidenceStore) -> dict[str, object]:
"kinds": dict(sorted(kinds.items())),
"components": dict(sorted(components.items())),
"deltas": dict(sorted(changes.items())),
+ "delta_kinds": dict(sorted(delta_kinds.items())),
"diagnostics": sorted(store.diagnostics),
}
@@ -139,9 +148,14 @@ def _list_records(store: EvidenceStore, arguments: dict[str, object]) -> dict[st
query = _Query(
kind=_optional_filter(arguments, "kind"),
+ delta_kind=_optional_filter(arguments, "delta_kind"),
component=_optional_filter(arguments, "component"),
ref=_optional_filter(arguments, "ref"),
)
+ if query.delta_kind is not None and query.kind != "repository.evidence_delta":
+ raise EvidenceMCPError("delta_kind requires kind=repository.evidence_delta")
+ if query.kind == "repository.evidence_delta" and query.ref is not None:
+ raise EvidenceMCPError("evidence deltas span base and head and do not accept ref")
if query.ref not in {None, "base", "head", "shared"}:
raise EvidenceMCPError("ref must be base, head, or shared")
page_size = arguments.get("page_size", DEFAULT_PAGE_SIZE)
@@ -150,23 +164,33 @@ def _list_records(store: EvidenceStore, arguments: dict[str, object]) -> dict[st
if not 1 <= page_size <= MAX_PAGE_SIZE:
raise EvidenceMCPError(f"page_size must be between 1 and {MAX_PAGE_SIZE}")
offset = _decode_cursor(arguments.get("cursor"), query)
- all_records: tuple[EvidenceRecord | CoverageRecord, ...] = (
- *store.records,
- *store.coverage,
- )
+ if query.kind == "repository.evidence_delta":
+ candidates: tuple[EvidenceRecord | CoverageRecord | EvidenceDelta, ...] = store.safe_deltas
+ else:
+ candidates = (*store.records, *store.coverage)
records = [
record
- for record in all_records
- if (query.kind is None or record.kind == query.kind)
+ for record in candidates
+ if (query.kind is None or (isinstance(record, EvidenceDelta) or record.kind == query.kind))
+ and (
+ query.delta_kind is None
+ or (isinstance(record, EvidenceDelta) and record.kind == query.delta_kind)
+ )
and (query.component is None or record.component == query.component)
- and (query.ref is None or record.ref.value == query.ref)
+ and (
+ query.ref is None
+ or (not isinstance(record, EvidenceDelta) and record.ref.value == query.ref)
+ )
]
if offset > len(records):
raise EvidenceMCPError("cursor points beyond the available evidence")
page = records[offset : offset + page_size]
next_offset = offset + len(page)
return {
- "records": [record.to_dict() for record in page],
+ "records": [
+ record.to_mcp_dict() if isinstance(record, EvidenceDelta) else record.to_dict()
+ for record in page
+ ],
"next_cursor": _encode_cursor(next_offset, query) if next_offset < len(records) else None,
"returned": len(page),
}
@@ -178,18 +202,21 @@ def _get_record(store: EvidenceStore, arguments: dict[str, object]) -> dict[str,
record_id = arguments.get("id")
valid_id = isinstance(record_id, str) and (
(len(record_id) == 68 and record_id.startswith("ev1_"))
- or (len(record_id) == 69 and record_id.startswith("cov1_"))
+ or (len(record_id) == 69 and record_id.startswith(("cov1_", "del1_")))
)
if not valid_id:
- raise EvidenceMCPError("id must be a stable ev1 or cov1 evidence identifier")
- all_records: tuple[EvidenceRecord | CoverageRecord, ...] = (
+ raise EvidenceMCPError("id must be a stable ev1, cov1, or del1 evidence identifier")
+ all_records: tuple[EvidenceRecord | CoverageRecord | EvidenceDelta, ...] = (
*store.records,
*store.coverage,
+ *store.safe_deltas,
)
record = next((item for item in all_records if item.id == record_id), None)
if record is None:
raise EvidenceMCPError("evidence record was not found")
- return {"record": record.to_dict()}
+ return {
+ "record": (record.to_mcp_dict() if isinstance(record, EvidenceDelta) else record.to_dict())
+ }
def call_tool(store: EvidenceStore, arguments: object) -> dict[str, object]:
@@ -203,7 +230,15 @@ def call_tool(store: EvidenceStore, arguments: object) -> dict[str, object]:
allowed = {"action"}
payload = evidence_summary(store)
elif action == "list":
- allowed = {"action", "kind", "component", "ref", "page_size", "cursor"}
+ allowed = {
+ "action",
+ "kind",
+ "delta_kind",
+ "component",
+ "ref",
+ "page_size",
+ "cursor",
+ }
payload = _list_records(store, typed)
elif action == "get":
allowed = {"action", "id"}
@@ -223,8 +258,9 @@ def _tool_definition() -> dict[str, object]:
"name": TOOL_NAME,
"description": (
"Read bounded, redacted repository evidence for immutable base/head refs. "
- "Use summary first, list to narrow, and get for one stable record. Missing facts "
- "support a negative conclusion only when applicable scoped coverage is complete; "
+ "Use summary first, list to narrow, and get for one stable record. Query "
+ "kind=repository.evidence_delta with optional delta_kind for base/head changes. "
+ "Missing facts support a negative conclusion only when applicable scoped coverage is complete; "
"absent, partial, runtime-dependent, or unavailable coverage means unknown."
),
"inputSchema": {
@@ -234,11 +270,12 @@ def _tool_definition() -> dict[str, object]:
"properties": {
"action": {"type": "string", "enum": ["summary", "list", "get"]},
"kind": {"type": "string", "maxLength": 256},
+ "delta_kind": {"type": "string", "maxLength": 256},
"component": {"type": "string", "maxLength": 256},
"ref": {"type": "string", "enum": ["base", "head", "shared"]},
"page_size": {"type": "integer", "minimum": 1, "maximum": MAX_PAGE_SIZE},
"cursor": {"type": "string", "maxLength": 256},
- "id": {"type": "string", "pattern": "^(ev1|cov1)_[0-9a-f]{64}$"},
+ "id": {"type": "string", "pattern": "^(ev1|cov1|del1)_[0-9a-f]{64}$"},
},
},
"annotations": {"readOnlyHint": True, "destructiveHint": False, "openWorldHint": False},
diff --git a/src/ocr_toolkit/evidence/model.py b/src/ocr_toolkit/evidence/model.py
index a438c92..0fcb495 100644
--- a/src/ocr_toolkit/evidence/model.py
+++ b/src/ocr_toolkit/evidence/model.py
@@ -449,13 +449,62 @@ class EvidenceDelta:
change: str
before: EvidenceValue
after: EvidenceValue
+ id: str = field(init=False)
def __post_init__(self) -> None:
- """Validate the closed delta-state contract."""
+ """Validate the closed delta-state contract and derive its stable identifier."""
- if self.change not in {"added", "removed", "changed", "unknown"}:
+ if (
+ not isinstance(self.kind, str)
+ or not self.kind
+ or len(self.kind) > 256
+ or not all(part.replace("_", "").isalnum() for part in self.kind.split("."))
+ ):
+ raise ValueError("delta kind must be a bounded dotted identifier")
+ for name, value, limit in (
+ ("component", self.component, 256),
+ ("identity", self.identity, 4096),
+ ):
+ if (
+ not isinstance(value, str)
+ or not value
+ or len(value) > limit
+ or any(character == "\x7f" or ord(character) < 32 for character in value)
+ ):
+ raise ValueError(f"delta {name} must contain bounded safe text")
+ if not isinstance(self.change, str) or self.change not in {
+ "added",
+ "removed",
+ "changed",
+ "unknown",
+ }:
raise ValueError("unsupported evidence delta state")
_validate_value(self.before)
_validate_value(self.after)
object.__setattr__(self, "before", _freeze_value(self.before))
object.__setattr__(self, "after", _freeze_value(self.after))
+ identity = {
+ "kind": self.kind,
+ "component": self.component,
+ "identity": self.identity,
+ "change": self.change,
+ "before": _thaw_value(self.before),
+ "after": _thaw_value(self.after),
+ }
+ digest = hashlib.sha256(_canonical_json(identity).encode("utf-8")).hexdigest()
+ object.__setattr__(self, "id", f"del1_{digest}")
+
+ def to_mcp_dict(self) -> dict[str, EvidenceValue]:
+ """Return the closed first-class delta projection exposed by the evidence MCP."""
+
+ return {
+ "id": self.id,
+ "kind": "repository.evidence_delta",
+ "schema_version": "repository.evidence-delta/v1",
+ "delta_kind": self.kind,
+ "component": self.component,
+ "identity": self.identity,
+ "change": self.change,
+ "before": _thaw_value(self.before),
+ "after": _thaw_value(self.after),
+ }
diff --git a/src/ocr_toolkit/evidence/project.py b/src/ocr_toolkit/evidence/project.py
index 88bab7b..3c7963d 100644
--- a/src/ocr_toolkit/evidence/project.py
+++ b/src/ocr_toolkit/evidence/project.py
@@ -61,8 +61,10 @@ def render_bootstrap(
for record in store.records:
kind_counts[record.kind] = kind_counts.get(record.kind, 0) + 1
changes: dict[str, int] = {}
- for delta in store.deltas:
+ delta_kinds: dict[str, int] = {}
+ for delta in store.safe_deltas:
changes[delta.change] = changes.get(delta.change, 0) + 1
+ delta_kinds[delta.kind] = delta_kinds.get(delta.kind, 0) + 1
coverage_states: dict[str, int] = {}
for coverage_record in store.coverage:
coverage_states[coverage_record.state.value] = (
@@ -87,6 +89,7 @@ def render_bootstrap(
f"- components: {', '.join(components) if components else 'none'}",
f"- kinds: {', '.join(f'{kind}={count}' for kind, count in sorted(kind_counts.items())) or 'none'}",
f"- deltas: {', '.join(f'{state}={count}' for state, count in sorted(changes.items())) or 'none'}",
+ f"- delta kinds: {', '.join(f'{kind}={count}' for kind, count in sorted(delta_kinds.items())) or 'none'}",
]
if store.diagnostics:
lines.extend(
@@ -111,6 +114,10 @@ def render_bootstrap(
"Use the built-in `ocr_toolkit_evidence` tool first: start with `action=summary`, "
"narrow with `action=list`, and retrieve one stable record with `action=get`."
)
+ lines.append(
+ "Query base/head changes with `action=list, kind=repository.evidence_delta`; "
+ "optionally narrow the original fact kind with `delta_kind`."
+ )
lines.append(
"A missing fact proves absence only when the applicable component/domain/scope coverage "
"record is `complete`; absent, `partial`, `runtime-dependent`, or `unavailable` "
diff --git a/src/ocr_toolkit/evidence/store.py b/src/ocr_toolkit/evidence/store.py
index a7e38f1..60a2800 100644
--- a/src/ocr_toolkit/evidence/store.py
+++ b/src/ocr_toolkit/evidence/store.py
@@ -134,6 +134,15 @@ def _safe_diagnostic(message: object) -> str:
return redact_env_secret_values(redact_sensitive(message))
+def _safe_delta_metadata(value: str, *, name: str, max_chars: int) -> str:
+ """Redact and bound one repository-derived delta metadata field."""
+
+ redacted = redact_env_secret_values(redact_sensitive(value))
+ if not redacted or len(redacted) > max_chars:
+ raise EvidenceStoreError(f"evidence delta {name} exceeds its metadata budget")
+ return redacted
+
+
@dataclass(slots=True)
class EvidenceStore:
"""Own bounded snapshots, typed deltas, and explicit coverage diagnostics."""
@@ -212,6 +221,42 @@ def add_diagnostic(self, message: str) -> None:
self._diagnose_once(_safe_diagnostic(message))
+ @property
+ def safe_deltas(self) -> tuple[EvidenceDelta, ...]:
+ """Return redacted, bounded deltas in deterministic public ordering."""
+
+ if len(self.deltas) > self.limits.max_records:
+ raise EvidenceStoreError("evidence deltas exceed the configured record budget")
+ if any(
+ delta.kind not in KNOWN_KINDS | {"repository.evidence_coverage"}
+ for delta in self.deltas
+ ):
+ raise EvidenceStoreError("evidence delta kind is unregistered")
+ normalized = (
+ EvidenceDelta(
+ kind=delta.kind,
+ component=_safe_delta_metadata(delta.component, name="component", max_chars=256),
+ identity=_safe_delta_metadata(delta.identity, name="identity", max_chars=4096),
+ change=delta.change,
+ before=_safe_value(delta.before, self.limits.max_value_chars),
+ after=_safe_value(delta.after, self.limits.max_value_chars),
+ )
+ for delta in self.deltas
+ )
+ unique = {delta.id: delta for delta in normalized}
+ return tuple(
+ sorted(
+ unique.values(),
+ key=lambda item: (
+ item.kind,
+ item.component,
+ item.identity,
+ item.change,
+ item.id,
+ ),
+ )
+ )
+
@property
def records(self) -> tuple[EvidenceRecord, ...]:
"""Return all records in deterministic public ordering."""
@@ -255,10 +300,10 @@ def to_dict(self) -> dict[str, object]:
"component": delta.component,
"identity": delta.identity,
"change": delta.change,
- "before": _safe_value(delta.before, self.limits.max_value_chars),
- "after": _safe_value(delta.after, self.limits.max_value_chars),
+ "before": delta.to_mcp_dict()["before"],
+ "after": delta.to_mcp_dict()["after"],
}
- for delta in self.deltas
+ for delta in self.safe_deltas
],
"diagnostics": sorted(_safe_diagnostic(item) for item in self.diagnostics),
"limits": {
@@ -423,22 +468,33 @@ def _read_deltas(self, raw: object) -> None:
if not isinstance(raw, list):
raise EvidenceStoreError("evidence deltas must be a list")
+ if len(raw) > self.limits.max_records:
+ raise EvidenceStoreError("evidence deltas exceed declared limits")
deltas = []
try:
for item in raw:
if not isinstance(item, dict):
raise ValueError("evidence delta must be an object")
+ fields = {"kind", "component", "identity", "change", "before", "after"}
+ if set(item) != fields:
+ raise ValueError("evidence delta fields are invalid")
metadata: dict[str, str] = {}
for name in ("kind", "component", "identity", "change"):
candidate = item.get(name)
if not isinstance(candidate, str):
raise ValueError(f"evidence delta field {name!r} must be a string")
metadata[name] = candidate
+ if metadata["kind"] not in KNOWN_KINDS | {"repository.evidence_coverage"}:
+ raise ValueError("evidence delta kind is unregistered")
deltas.append(
EvidenceDelta(
kind=metadata["kind"],
- component=metadata["component"],
- identity=metadata["identity"],
+ component=_safe_delta_metadata(
+ metadata["component"], name="component", max_chars=256
+ ),
+ identity=_safe_delta_metadata(
+ metadata["identity"], name="identity", max_chars=4096
+ ),
change=metadata["change"],
before=_safe_value(
cast(EvidenceValue, item.get("before")),
diff --git a/tests/test_evidence_framework_plugins.py b/tests/test_evidence_framework_plugins.py
index d1be825..5baf733 100644
--- a/tests/test_evidence_framework_plugins.py
+++ b/tests/test_evidence_framework_plugins.py
@@ -20,6 +20,7 @@
)
from ocr_toolkit.evidence.mcp import call_tool
from ocr_toolkit.evidence.model import Confidence, EvidenceRecord, RefRole, TrustClass
+from ocr_toolkit.evidence.project import render_bootstrap
from ocr_toolkit.evidence.repository import RepositoryObject
from ocr_toolkit.evidence.store import EvidenceStore, EvidenceStoreError
@@ -243,6 +244,283 @@ def test_go_php_and_frontend_plugins_keep_related_signals_in_the_same_component(
assert twig_fact["engine"] == "twig"
+def test_cross_provider_evidence_projects_through_deltas_bootstrap_and_one_mcp(
+ tmp_path: Path,
+) -> None:
+ """Project framework, template, and coverage changes through shared contracts."""
+
+ initialize(tmp_path)
+ renderer = tmp_path / "services" / "renderer"
+ api = tmp_path / "services" / "api"
+ web = tmp_path / "services" / "web"
+ ui = tmp_path / "apps" / "portal"
+ for component in (renderer, api, web, ui):
+ component.mkdir(parents=True)
+
+ (renderer / "pyproject.toml").write_text(
+ '[project]\nname="renderer"\nversion="1"\ndependencies=["jinja2>=3.1"]\n',
+ encoding="utf-8",
+ )
+ (renderer / "pylock.toml").write_text(
+ 'lock-version = "1.0"\n[[packages]]\nname = "jinja2"\nversion = "3.1.5"\n',
+ encoding="utf-8",
+ )
+ (renderer / "templates").mkdir()
+ (renderer / "templates" / "service.conf.j2").write_text("port={{ port }}\n", encoding="utf-8")
+ (api / "go.mod").write_text(
+ """module example.invalid/api
+
+go 1.24
+
+require github.com/labstack/echo/v4 v4.13.4
+""",
+ encoding="utf-8",
+ )
+ (web / "composer.json").write_text(
+ json.dumps(
+ {
+ "require": {
+ "php": "^8.3",
+ "symfony/framework-bundle": "^7.2",
+ "twig/twig": "^3.0",
+ }
+ }
+ ),
+ encoding="utf-8",
+ )
+ (web / "composer.lock").write_text(
+ json.dumps(
+ {
+ "packages": [
+ {"name": "symfony/framework-bundle", "version": "v7.2.4"},
+ {"name": "twig/twig", "version": "v3.19.0"},
+ ],
+ "packages-dev": [],
+ }
+ ),
+ encoding="utf-8",
+ )
+ (web / "templates").mkdir()
+ (web / "templates" / "page.twig").write_text("{{ title }}\n", encoding="utf-8")
+ (ui / "package.json").write_text(
+ json.dumps(
+ {
+ "dependencies": {"react": "^18.3.0"},
+ "devDependencies": {"typescript": "^5.8", "vite": "^6.2"},
+ }
+ ),
+ encoding="utf-8",
+ )
+ (ui / "package-lock.json").write_text(
+ json.dumps(
+ {
+ "lockfileVersion": 3,
+ "packages": {
+ "node_modules/react": {"version": "18.3.1"},
+ "node_modules/typescript": {"version": "5.8.3"},
+ "node_modules/vite": {"version": "6.2.0"},
+ },
+ }
+ ),
+ encoding="utf-8",
+ )
+ base = commit(tmp_path, "base ecosystems")
+
+ # Exercise semantic change, removal, and addition without requiring checkout
+ # or provider-specific projection code after the immutable refs are committed.
+ (renderer / "pylock.toml").unlink()
+ (renderer / "templates" / "service.conf.j2").write_text(
+ "port={{ port }}\ntls={{ tls }}\n", encoding="utf-8"
+ )
+ (api / "go.mod").write_text(
+ """module example.invalid/api
+
+go 1.24
+
+require github.com/gofiber/fiber/v2 v2.52.6
+""",
+ encoding="utf-8",
+ )
+ (web / "composer.lock").write_text(
+ json.dumps(
+ {
+ "packages": [
+ {"name": "symfony/framework-bundle", "version": "v7.2.5"},
+ {"name": "twig/twig", "version": "v3.19.0"},
+ ],
+ "packages-dev": [],
+ }
+ ),
+ encoding="utf-8",
+ )
+ (web / "templates" / "page.twig").unlink()
+ (ui / "package.json").write_text(
+ json.dumps(
+ {
+ "dependencies": {"next": "^15.2.0", "react": "^19.0.0"},
+ "devDependencies": {"typescript": "^5.8", "vite": "^6.2"},
+ }
+ ),
+ encoding="utf-8",
+ )
+ (ui / "package-lock.json").write_text(
+ json.dumps(
+ {
+ "lockfileVersion": 3,
+ "packages": {
+ "node_modules/next": {"version": "15.2.4"},
+ "node_modules/react": {"version": "19.0.0"},
+ "node_modules/typescript": {"version": "5.8.3"},
+ "node_modules/vite": {"version": "6.2.0"},
+ },
+ }
+ ),
+ encoding="utf-8",
+ )
+ (ui / "tsconfig.json").write_text("{}\n", encoding="utf-8")
+ role_templates = tmp_path / "automation" / "roles" / "worker" / "templates"
+ role_templates.mkdir(parents=True)
+ (role_templates / "worker.service").write_text("User={{ worker_user }}\n", encoding="utf-8")
+ head = commit(tmp_path, "head ecosystems")
+
+ store = collect_repository_evidence(tmp_path, base_ref=base, head_ref=head)
+ framework_deltas = {
+ (delta.component, delta.identity): delta.change
+ for delta in store.deltas
+ if delta.kind == "framework.detected"
+ }
+ assert framework_deltas == {
+ ("apps/portal", "react-typescript:next"): "added",
+ ("apps/portal", "react-typescript:react"): "changed",
+ ("services/api", "go-web:echo"): "removed",
+ ("services/api", "go-web:fiber"): "added",
+ ("services/renderer", "jinja2:jinja2"): "changed",
+ ("services/web", "symfony-php:symfony"): "changed",
+ ("services/web", "symfony-php:twig"): "changed",
+ }
+ template_deltas = {
+ delta.identity: delta.change for delta in store.deltas if delta.kind == "template.file"
+ }
+ assert template_deltas == {
+ "automation/roles/worker/templates/worker.service": "added",
+ "services/renderer/templates/service.conf.j2": "changed",
+ "services/web/templates/page.twig": "removed",
+ }
+ coverage_deltas = [
+ delta for delta in store.deltas if delta.kind == "repository.evidence_coverage"
+ ]
+ assert any(
+ delta.component == "services/renderer"
+ and '"framework.resolution","jinja2:jinja2"' in delta.identity
+ and delta.change == "changed"
+ and delta.before == {"state": "complete", "reasons": ("lock-version-present",)}
+ and delta.after == {"state": "partial", "reasons": ("lock-version-missing",)}
+ for delta in coverage_deltas
+ )
+ assert any(
+ delta.component == "automation/roles/worker"
+ and '"template.inventory","jinja2"' in delta.identity
+ and delta.change == "added"
+ for delta in coverage_deltas
+ )
+
+ framework_count = sum(record.kind == "framework.detected" for record in store.records)
+ template_count = sum(record.kind == "template.file" for record in store.records)
+ summary = mcp_payload(call_tool(store, {"action": "summary"}))
+ kinds = cast(dict[str, int], summary["kinds"])
+ assert kinds["framework.detected"] == framework_count
+ assert kinds["template.file"] == template_count
+ assert cast(dict[str, int], summary["delta_kinds"])["framework.detected"] == len(
+ framework_deltas
+ )
+ assert cast(dict[str, int], summary["delta_kinds"])["template.file"] == len(template_deltas)
+ assert cast(dict[str, int], summary["coverage_states"])["partial"] >= 1
+ assert all(summary[key] for key in ("records", "coverage_records", "deltas"))
+
+ listed_frameworks = mcp_payload(
+ call_tool(
+ store,
+ {
+ "action": "list",
+ "kind": "framework.detected",
+ "component": "apps/portal",
+ "ref": "head",
+ },
+ )
+ )
+ framework_rows = cast(list[dict[str, Any]], listed_frameworks["records"])
+ assert {
+ cast(dict[str, Any], cast(dict[str, Any], row["value"])["fact"])["framework"]
+ for row in framework_rows
+ } == {"next", "react"}
+ fetched_framework = mcp_payload(
+ call_tool(store, {"action": "get", "id": framework_rows[0]["id"]})
+ )
+ assert fetched_framework["record"] == framework_rows[0]
+
+ listed_coverage = mcp_payload(
+ call_tool(
+ store,
+ {
+ "action": "list",
+ "kind": "repository.evidence_coverage",
+ "component": "services/renderer",
+ "ref": "head",
+ },
+ )
+ )
+ coverage_rows = cast(list[dict[str, Any]], listed_coverage["records"])
+ assert {row["domain"] for row in coverage_rows} >= {
+ "framework.configuration",
+ "framework.declaration",
+ "framework.resolution",
+ "template.inventory",
+ }
+ resolution_coverage = next(
+ row
+ for row in coverage_rows
+ if row["domain"] == "framework.resolution" and row["scope"] == "jinja2:jinja2"
+ )
+ fetched_coverage = mcp_payload(
+ call_tool(store, {"action": "get", "id": resolution_coverage["id"]})
+ )
+ assert fetched_coverage["record"] == resolution_coverage
+
+ listed_deltas = mcp_payload(
+ call_tool(
+ store,
+ {
+ "action": "list",
+ "kind": "repository.evidence_delta",
+ "delta_kind": "framework.detected",
+ "component": "services/api",
+ },
+ )
+ )
+ delta_rows = cast(list[dict[str, Any]], listed_deltas["records"])
+ assert {(row["identity"], row["change"]) for row in delta_rows} == {
+ ("go-web:echo", "removed"),
+ ("go-web:fiber", "added"),
+ }
+ fetched_delta = mcp_payload(call_tool(store, {"action": "get", "id": delta_rows[0]["id"]}))
+ assert fetched_delta["record"] == delta_rows[0]
+
+ bootstrap = render_bootstrap(store)
+ assert f"framework.detected={framework_count}" in bootstrap
+ assert f"template.file={template_count}" in bootstrap
+ assert f"framework.detected={len(framework_deltas)}" in bootstrap
+ assert f"template.file={len(template_deltas)}" in bootstrap
+ assert "kind=repository.evidence_delta" in bootstrap
+ assert "delta_kind" in bootstrap
+ assert "automation/roles/worker" in bootstrap
+ assert "action=summary" in bootstrap
+ assert "action=list" in bootstrap
+ assert "action=get" in bootstrap
+ assert "service.conf.j2" not in bootstrap
+ assert "page.twig" not in bootstrap
+ assert "v7.2.5" not in bootstrap
+
+
def test_plugin_nested_schema_is_revalidated_on_store_load(tmp_path: Path) -> None:
"""Reject unknown nested fields in persisted framework evidence."""
diff --git a/tests/test_evidence_mcp.py b/tests/test_evidence_mcp.py
index 300923c..c3ad9e7 100644
--- a/tests/test_evidence_mcp.py
+++ b/tests/test_evidence_mcp.py
@@ -14,6 +14,7 @@
from ocr_toolkit.evidence import (
CoverageRecord,
CoverageState,
+ EvidenceDelta,
EvidenceRecord,
EvidenceStore,
RefRole,
@@ -94,6 +95,146 @@ def test_summary_list_get_and_cursor_binding() -> None:
)
+def test_deltas_are_explicitly_filtered_and_addressable_without_changing_default_list() -> None:
+ """Expose typed base/head changes only through the explicit delta projection."""
+
+ store = _store(1)
+ store.deltas = (
+ EvidenceDelta(
+ kind="framework.detected",
+ component="services/api",
+ identity="go-web:echo",
+ change="changed",
+ before={"version": "old"},
+ after={"version": "new"},
+ ),
+ EvidenceDelta(
+ kind="template.file",
+ component="services/api",
+ identity="templates/service.conf.j2",
+ change="added",
+ before=None,
+ after={"object_sha": "b" * 40},
+ ),
+ )
+
+ ordinary = _payload(call_tool(store, {"action": "list"}))
+ listed = _payload(
+ call_tool(
+ store,
+ {
+ "action": "list",
+ "kind": "repository.evidence_delta",
+ "delta_kind": "framework.detected",
+ "component": "services/api",
+ },
+ )
+ )
+ records = listed["records"]
+ assert isinstance(records, list)
+ assert len(records) == 1
+ delta = records[0]
+ assert isinstance(delta, dict)
+ fetched = _payload(call_tool(store, {"action": "get", "id": delta["id"]}))
+
+ summary = _payload(call_tool(store, {"action": "summary"}))
+ assert summary["delta_kinds"] == {"framework.detected": 1, "template.file": 1}
+ assert ordinary["returned"] == 1
+ assert all(record["kind"] != "repository.evidence_delta" for record in ordinary["records"])
+ assert delta == {
+ "id": store.deltas[0].id,
+ "kind": "repository.evidence_delta",
+ "schema_version": "repository.evidence-delta/v1",
+ "delta_kind": "framework.detected",
+ "component": "services/api",
+ "identity": "go-web:echo",
+ "change": "changed",
+ "before": {"version": "old"},
+ "after": {"version": "new"},
+ }
+ assert fetched["record"] == delta
+
+ first_delta_page = _payload(
+ call_tool(
+ store,
+ {"action": "list", "kind": "repository.evidence_delta", "page_size": 1},
+ )
+ )
+ with pytest.raises(ValueError, match="cursor"):
+ call_tool(
+ store,
+ {
+ "action": "list",
+ "kind": "repository.evidence_delta",
+ "delta_kind": "template.file",
+ "cursor": first_delta_page["next_cursor"],
+ },
+ )
+ with pytest.raises(ValueError, match="delta_kind requires"):
+ call_tool(store, {"action": "list", "delta_kind": "framework.detected"})
+ with pytest.raises(ValueError, match="span base and head"):
+ call_tool(
+ store,
+ {"action": "list", "kind": "repository.evidence_delta", "ref": "head"},
+ )
+
+
+def test_delta_projection_redacts_in_memory_values_before_list_and_get() -> None:
+ """Never expose raw collector delta values before persistence normalizes them."""
+
+ store = _store(0)
+ store.deltas = (
+ EvidenceDelta(
+ kind="framework.detected",
+ component="services/token=synthetic-sensitive-value",
+ identity="go-web:echo?token=synthetic-sensitive-value",
+ change="changed",
+ before={"token": "synthetic-sensitive-value"},
+ after={"token": "safe"},
+ ),
+ )
+
+ listed = _payload(call_tool(store, {"action": "list", "kind": "repository.evidence_delta"}))
+ records = listed["records"]
+ assert isinstance(records, list) and isinstance(records[0], dict)
+ delta = records[0]
+ fetched = _payload(call_tool(store, {"action": "get", "id": delta["id"]}))
+
+ assert delta["component"] == "services/token=***"
+ assert delta["identity"] == "go-web:echo?token=***"
+ assert delta["before"] == {"token": "[REDACTED]"}
+ assert delta["after"] == {"token": "[REDACTED]"}
+ assert fetched["record"] == delta
+ assert delta["id"] == store.safe_deltas[0].id
+ assert delta["id"] != store.deltas[0].id
+
+
+def test_delta_projection_deduplicates_values_that_redact_to_one_stable_id() -> None:
+ """Return one addressable result when distinct secrets normalize identically."""
+
+ store = _store(0)
+ store.deltas = tuple(
+ EvidenceDelta(
+ kind="framework.detected",
+ component="services/api",
+ identity="go-web:echo",
+ change="changed",
+ before={"token": value},
+ after=None,
+ )
+ for value in ("first-sensitive-value", "second-sensitive-value")
+ )
+
+ listed = _payload(call_tool(store, {"action": "list", "kind": "repository.evidence_delta"}))
+ records = listed["records"]
+ assert isinstance(records, list) and len(records) == 1
+ assert listed["returned"] == 1
+ assert (
+ _payload(call_tool(store, {"action": "get", "id": records[0]["id"]}))["record"]
+ == records[0]
+ )
+
+
def test_coverage_is_summarized_filtered_and_addressable() -> None:
"""Expose completeness independently from positive facts through MCP."""
diff --git a/tests/test_evidence_model.py b/tests/test_evidence_model.py
index 8038881..8a51151 100644
--- a/tests/test_evidence_model.py
+++ b/tests/test_evidence_model.py
@@ -380,6 +380,61 @@ def test_store_byte_budget_includes_serialized_trailing_newline(tmp_path: Path)
constrained.write(tmp_path / "evidence.json")
+def test_delta_id_and_mcp_projection_are_canonical_and_detached() -> None:
+ """Bind the stable delta ID to semantic content without sharing mutable output."""
+
+ first = EvidenceDelta(
+ kind="framework.detected",
+ component="services/api",
+ identity="go-web:echo",
+ change="changed",
+ before={"version": "old", "paths": ["go.mod"]},
+ after={"version": "new"},
+ )
+ reordered = EvidenceDelta(
+ kind="framework.detected",
+ component="services/api",
+ identity="go-web:echo",
+ change="changed",
+ before={"paths": ["go.mod"], "version": "old"},
+ after={"version": "new"},
+ )
+
+ projection = first.to_mcp_dict()
+ assert first.id == reordered.id
+ assert first.id.startswith("del1_")
+ assert projection["schema_version"] == "repository.evidence-delta/v1"
+ before = projection["before"]
+ assert isinstance(before, dict)
+ before["version"] = "mutated"
+ assert first.to_mcp_dict()["before"] == {"paths": ["go.mod"], "version": "old"}
+
+
+@pytest.mark.parametrize(
+ ("field", "value"),
+ [
+ ("kind", "invalid kind"),
+ ("component", "x" * 257),
+ ("identity", "line\nbreak"),
+ ("change", 1),
+ ],
+)
+def test_delta_rejects_unsafe_or_unbounded_metadata(field: str, value: object) -> None:
+ """Keep delta metadata bounded before it can reach persistence or MCP."""
+
+ arguments = {
+ "kind": "framework.detected",
+ "component": "services/api",
+ "identity": "go-web:echo",
+ "change": "added",
+ "before": None,
+ "after": {},
+ }
+ arguments[field] = value
+ with pytest.raises(ValueError, match="delta"):
+ EvidenceDelta(**arguments) # type: ignore[arg-type]
+
+
def test_store_round_trips_snapshots_and_typed_deltas(tmp_path: Path) -> None:
"""Persist immutable refs and explicit typed changes with the evidence records."""
@@ -505,6 +560,100 @@ def test_store_rejects_non_string_delta_metadata(tmp_path: Path, field: str, val
EvidenceStore.read(path)
+def test_store_rejects_unregistered_in_memory_delta_kind() -> None:
+ """Apply the closed delta vocabulary before serialization or MCP projection."""
+
+ store = EvidenceStore(
+ deltas=(
+ EvidenceDelta(
+ kind="synthetic.unregistered",
+ component="services/api",
+ identity="synthetic",
+ change="added",
+ before=None,
+ after={},
+ ),
+ )
+ )
+
+ with pytest.raises(EvidenceStoreError, match="delta kind is unregistered"):
+ _ = store.safe_deltas
+
+
+def test_store_rejects_unregistered_persisted_delta_kind(tmp_path: Path) -> None:
+ """Keep delta queries inside the registered evidence-domain vocabulary."""
+
+ payload = EvidenceStore(
+ deltas=(
+ EvidenceDelta(
+ kind="framework.detected",
+ component="services/api",
+ identity="go-web:echo",
+ change="added",
+ before=None,
+ after={},
+ ),
+ )
+ ).to_dict()
+ deltas = payload["deltas"]
+ assert isinstance(deltas, list) and isinstance(deltas[0], dict)
+ deltas[0]["kind"] = "synthetic.unregistered"
+ path = tmp_path / "evidence.json"
+ path.write_text(json.dumps(payload), encoding="utf-8")
+
+ with pytest.raises(EvidenceStoreError, match="invalid evidence delta"):
+ EvidenceStore.read(path)
+
+
+def test_store_redacts_persisted_delta_metadata_during_load(tmp_path: Path) -> None:
+ """Normalize hostile persisted metadata before it enters the in-memory store."""
+
+ payload = EvidenceStore().to_dict()
+ payload["deltas"] = [
+ {
+ "kind": "framework.detected",
+ "component": "services/token=first-sensitive-value",
+ "identity": "go-web:echo?token=second-sensitive-value",
+ "change": "added",
+ "before": None,
+ "after": {},
+ }
+ ]
+ path = tmp_path / "evidence.json"
+ path.write_text(json.dumps(payload), encoding="utf-8")
+
+ restored = EvidenceStore.read(path)
+
+ assert restored.deltas[0].component == "services/token=***"
+ assert restored.deltas[0].identity == "go-web:echo?token=***"
+ assert restored.safe_deltas == restored.deltas
+
+
+def test_store_rejects_unknown_persisted_delta_fields(tmp_path: Path) -> None:
+ """Keep the persisted delta object closed before MCP projection."""
+
+ payload = EvidenceStore(
+ deltas=(
+ EvidenceDelta(
+ kind="framework.detected",
+ component="services/api",
+ identity="go-web:echo",
+ change="added",
+ before=None,
+ after={},
+ ),
+ )
+ ).to_dict()
+ deltas = payload["deltas"]
+ assert isinstance(deltas, list) and isinstance(deltas[0], dict)
+ deltas[0]["unknown"] = True
+ path = tmp_path / "evidence.json"
+ path.write_text(json.dumps(payload), encoding="utf-8")
+
+ with pytest.raises(EvidenceStoreError, match="invalid evidence delta"):
+ EvidenceStore.read(path)
+
+
def test_store_rejects_oversized_delta_values_on_write_and_read(tmp_path: Path) -> None:
"""Apply the configured value budget before any delta is emitted or accepted."""
@@ -545,6 +694,43 @@ def test_store_rejects_oversized_delta_values_on_write_and_read(tmp_path: Path)
EvidenceStore.read(path)
+def test_store_rejects_deltas_beyond_the_declared_record_budget(tmp_path: Path) -> None:
+ """Bound delta iteration separately from the serialized byte budget."""
+
+ limits = EvidenceStoreLimits(max_records=1, max_records_per_kind=1)
+ deltas = tuple(
+ EvidenceDelta(
+ kind="framework.detected",
+ component="services/api",
+ identity=f"go-web:framework-{index}",
+ change="added",
+ before=None,
+ after={},
+ )
+ for index in range(2)
+ )
+ store = EvidenceStore(limits=limits, deltas=deltas)
+ with pytest.raises(EvidenceStoreError, match="record budget"):
+ store.to_json()
+
+ payload = EvidenceStore(limits=limits).to_dict()
+ payload["deltas"] = [
+ {
+ "kind": delta.kind,
+ "component": delta.component,
+ "identity": delta.identity,
+ "change": delta.change,
+ "before": None,
+ "after": {},
+ }
+ for delta in deltas
+ ]
+ path = tmp_path / "evidence.json"
+ path.write_text(json.dumps(payload), encoding="utf-8")
+ with pytest.raises(EvidenceStoreError, match="deltas exceed declared limits"):
+ EvidenceStore.read(path)
+
+
def test_store_revalidates_diagnostics_on_read(tmp_path: Path) -> None:
"""Redact accepted diagnostics and reject invalid top-level text."""
diff --git a/tests/test_evidence_repository.py b/tests/test_evidence_repository.py
index 81adb84..50badc1 100644
--- a/tests/test_evidence_repository.py
+++ b/tests/test_evidence_repository.py
@@ -257,6 +257,89 @@ def reject_one_file(store: EvidenceStore, record: EvidenceRecord) -> bool:
collect_repository_evidence(root, base_ref=base, head_ref=head)
+def test_collection_never_keeps_deltas_for_rejected_typed_records(
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ """Build typed deltas only from facts accepted into the common store."""
+
+ root = tmp_path / "repository"
+ root.mkdir()
+ git(root, "init", "-q")
+ manifest = root / "requirements.txt"
+ manifest.write_text("demo==1\n", encoding="utf-8")
+ git(root, "add", "requirements.txt")
+ git(root, "commit", "-qm", "base")
+ base = git(root, "rev-parse", "HEAD")
+ manifest.write_text("demo==2\n", encoding="utf-8")
+ git(root, "commit", "-qam", "head")
+ head = git(root, "rev-parse", "HEAD")
+ original_add = EvidenceStore.add
+
+ def reject_dependency(store: EvidenceStore, item: EvidenceRecord) -> bool:
+ if item.kind == "dependency.declared":
+ return False
+ return original_add(store, item)
+
+ monkeypatch.setattr(EvidenceStore, "add", reject_dependency)
+ store = collect_repository_evidence(root, base_ref=base, head_ref=head)
+
+ assert not any(record.kind == "dependency.declared" for record in store.records)
+ assert not any(delta.kind == "dependency.declared" for delta in store.deltas)
+ assert "typed evidence was truncated by store limits" in store.diagnostics
+
+
+def test_collection_builds_deltas_from_canonical_redacted_store_records(
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ """Do not retain a typed change that differs only by redacted secret values."""
+
+ root = tmp_path / "repository"
+ root.mkdir()
+ git(root, "init", "-q")
+ (root / "README.md").write_text("base\n", encoding="utf-8")
+ git(root, "add", "README.md")
+ git(root, "commit", "-qm", "base")
+ base = git(root, "rev-parse", "HEAD")
+ (root / "README.md").write_text("head\n", encoding="utf-8")
+ git(root, "commit", "-qam", "head")
+ head = git(root, "rev-parse", "HEAD")
+
+ def synthetic_facts(
+ _reader: object, commit_sha: str, ref: RefRole, **_kwargs: object
+ ) -> tuple[list[EvidenceRecord], list[str]]:
+ secret = "first-sensitive-value" if ref is RefRole.BASE else "second-sensitive-value"
+ return (
+ [
+ EvidenceRecord(
+ kind="dependency.declared",
+ value={
+ "identity": "requirements.txt:requirements:demo",
+ "fact": {"name": "demo", "token": secret},
+ },
+ source_path="requirements.txt",
+ ref=ref,
+ commit_sha=commit_sha,
+ component="python",
+ provenance="synthetic parser",
+ trust=(
+ TrustClass.TARGET_REPOSITORY
+ if ref is RefRole.BASE
+ else TrustClass.SOURCE_REPOSITORY
+ ),
+ )
+ ],
+ [],
+ )
+
+ monkeypatch.setattr("ocr_toolkit.evidence.collect.collect_ref_facts", synthetic_facts)
+ store = collect_repository_evidence(root, base_ref=base, head_ref=head)
+
+ facts = [record for record in store.records if record.kind == "dependency.declared"]
+ assert len(facts) == 2
+ assert all(record.value["fact"]["token"] == "[REDACTED]" for record in facts)
+ assert not any(delta.kind == "dependency.declared" for delta in store.deltas)
+
+
def test_deleted_path_keeps_a_base_trust_change_category(tmp_path: Path) -> None:
"""Represent deleted-path categories without inventing a head-tree source."""
From a05399fd02916493bd516caae313b618becda221 Mon Sep 17 00:00:00 2001
From: xeonvs <11463419+xeonvs@users.noreply.github.com>
Date: Wed, 12 Aug 2026 13:28:32 +0200
Subject: [PATCH 09/11] Remediate M2 review findings and organize framework
plugins
---
PLANS.md | 134 +++++-
changelog.d/76.bugfix.md | 2 +-
changelog.d/77.feature.md | 2 +-
docs/development.md | 6 +
docs/engineering/toolkit_strategy.md | 2 +
scripts/github_release_api.py | 85 +++-
scripts/release_issue_receipt.py | 47 +-
src/ocr_toolkit/evidence/collectors.py | 93 +++-
.../evidence/frameworks/__init__.py | 38 ++
.../evidence/frameworks/contracts.py | 79 ++++
.../detection.py} | 436 ++++--------------
.../evidence/frameworks/providers/__init__.py | 8 +
.../evidence/frameworks/providers/frontend.py | 35 ++
.../evidence/frameworks/providers/go.py | 22 +
.../evidence/frameworks/providers/php.py | 25 +
.../evidence/frameworks/providers/python.py | 19 +
.../evidence/frameworks/registry.py | 79 ++++
.../schema.py} | 2 +
.../evidence/frameworks/templates.py | 137 ++++++
src/ocr_toolkit/evidence/store.py | 6 +-
tests/test_evidence_collectors.py | 159 +++++++
tests/test_evidence_framework_plugins.py | 123 ++++-
tests/test_evidence_model.py | 21 +
tests/test_release_receipt.py | 184 +++++++-
24 files changed, 1346 insertions(+), 398 deletions(-)
create mode 100644 src/ocr_toolkit/evidence/frameworks/__init__.py
create mode 100644 src/ocr_toolkit/evidence/frameworks/contracts.py
rename src/ocr_toolkit/evidence/{framework_plugins.py => frameworks/detection.py} (58%)
create mode 100644 src/ocr_toolkit/evidence/frameworks/providers/__init__.py
create mode 100644 src/ocr_toolkit/evidence/frameworks/providers/frontend.py
create mode 100644 src/ocr_toolkit/evidence/frameworks/providers/go.py
create mode 100644 src/ocr_toolkit/evidence/frameworks/providers/php.py
create mode 100644 src/ocr_toolkit/evidence/frameworks/providers/python.py
create mode 100644 src/ocr_toolkit/evidence/frameworks/registry.py
rename src/ocr_toolkit/evidence/{framework_schema.py => frameworks/schema.py} (99%)
create mode 100644 src/ocr_toolkit/evidence/frameworks/templates.py
diff --git a/PLANS.md b/PLANS.md
index b1978d2..c18c41e 100644
--- a/PLANS.md
+++ b/PLANS.md
@@ -20,9 +20,11 @@ public synthetic rules pack, expose framework/template state and deltas through
the existing read-only evidence MCP, repair issue #76 release recovery, and
qualify checksum-pinned OCR 1.9.2 through issue #78 before final validation.
Deliver completed slices as local checkpoint commits without intermediate
-pushes. Complete one synthetic installed-artifact E2E, then exactly one final
-local OCR review without GitLab posting, and push feature PR #77 only once
-after final local validation, before protected feature and stable delivery.
+pushes. Complete the synthetic installed-artifact E2E and the already-run local
+review, remediate it, finish the framework-package slice and full deterministic
+validation, then run one additional full local OCR review with concurrency 2.
+Remediate that review and push feature PR #77 only once after final local
+validation, before protected feature and stable delivery.
### Decisions
@@ -45,19 +47,36 @@ after final local validation, before protected feature and stable delivery.
- Qualify the full adjacent OCR 1.9.1 to 1.9.2 transition from published
checksums, source/tag history, release notes, and consumed-contract probes.
Classify every upstream item, adapt only demonstrated toolkit contracts, and
- use qualified OCR 1.9.2 for installed-artifact E2E and the final review.
-- After complete E2E, run exactly one final local OCR 1.9.2 review over the M2
- change range, require real evidence-MCP use, fix actionable findings, and use
- only deterministic validation afterward.
+ use qualified OCR 1.9.2 for installed-artifact E2E and local reviews.
+- The original one-review limit governed the first full local OCR 1.9.2 review
+ and its deterministic remediation. The owner explicitly superseded that limit
+ on 2026-08-12: after complete deterministic validation, run one additional
+ full repository review with OCR concurrency 2, require real evidence-MCP use,
+ keep posting disabled and artifacts private, then fix actionable findings and
+ return to deterministic validation without another OCR rerun.
- Between completed checkpoints, query the public project for newly opened OCR
compatibility/release issues. If a newer stable OCR appears, qualify its
complete adjacent chain and include required contract/rules adaptations in
- 0.5.0 before E2E and the single final OCR review.
+ 0.5.0 before the next pending full OCR review.
- Keep completed implementation slices as local checkpoint commits with tests,
self-review, documentation, and release-issue monitoring. Do not push each
checkpoint or retrigger PR checks; update feature PR #77 once, only after all
- M2 implementation, installed-artifact E2E, the single final OCR review and
- its deterministic corrections, and final local validation are complete.
+ M2 implementation, installed-artifact E2E, both authorized OCR review cycles,
+ and final local validation are complete.
+- Before that single push, partially rewrite the unpublished feature history
+ into several signed functional slices. Absorb plan-only and fixup commits into
+ the implementation they describe rather than making one monolithic squash.
+ Prove the corrected tree is unchanged by the rewrite, refresh recorded commit
+ identities, scan the complete rewritten first-parent range, and update the
+ existing draft branch once with `--force-with-lease`.
+- After deterministic remediation of the final OCR findings, make framework
+ support one behavior-preserving structural slice under
+ `ocr_toolkit.evidence.frameworks`. Separate plugin contracts, generic package
+ detection, package-owned ecosystem provider declarations, template inventory,
+ closed schemas, and the static registry; remove the old flat framework modules
+ without compatibility shims. Do not create competing `frameworks/` and
+ `plugins/` trees, move core Git/tree/manifest collection or MCP lifecycle into
+ the package, or change evidence schemas and public behavior in that slice.
### Work Queue
@@ -85,13 +104,18 @@ after final local validation, before protected feature and stable delivery.
block M2 closure.
10. [x] Run complete Python 3.12-3.14, security, privacy, package, installed
artifact, rules-preview, real-MCP-client, and synthetic no-post E2E gates.
-11. [ ] Run exactly one final local full OCR review, fix findings, then finish
- deterministic validation, Gitleaks, self-review, feature PR checks/threads,
- merge, and TestPyPI development readback.
-12. [ ] Prepare the final release PR as the last repository mutation, archive
+11. [x] Finish deterministic remediation of the first full OCR review, complete
+ and prove the behavior-preserving framework package reorganization, then run
+ the complete deterministic package/E2E/privacy validation without OCR.
+12. [ ] Run the owner-authorized additional full local OCR review with concurrency
+ 2, real built-in evidence-MCP use, private artifacts, and no posting. Fix its
+ actionable findings, audit sibling boundaries, repeat deterministic validation
+ without another OCR run, then finish Gitleaks, self-review, feature PR
+ checks/threads, merge, and TestPyPI development readback.
+13. [ ] Prepare the final release PR as the last repository mutation, archive
completed 0.4.7 history, consume fragments 76, 77, and 78, and reconcile M2
to implemented truth while leaving external publication pending.
-13. [ ] Complete stable 0.5.0 TestPyPI/PyPI, provenance/hash/tag/immutable
+14. [ ] Complete stable 0.5.0 TestPyPI/PyPI, provenance/hash/tag/immutable
Release/receipt/Python-install readback and close #76 and #78 as completed
without another repository PR.
@@ -199,8 +223,8 @@ after final local validation, before protected feature and stable delivery.
describes the implemented shared projection. Completed BL-008 and BL-009
scope is removed from future work; demand-triggered evidence packs remain a
separate conditional item and do not keep M2 permanently open. The roadmap
- keeps M2 in progress until installed-artifact E2E, the single final OCR
- review, and independently verified stable delivery complete its signal.
+ keeps M2 in progress until installed-artifact E2E, the authorized local OCR
+ review cycles, and independently verified stable delivery complete its signal.
- Focused evidence, MCP, model, repository, documentation, and integration
validation passes. The complete routine quality gate passes 651 tests plus 85
subtests at 79.96% coverage with formatting, Ruff, strict mypy, and the
@@ -245,8 +269,80 @@ after final local validation, before protected feature and stable delivery.
is invoked.
- Read-only checkpoint monitoring still finds only issues #76 and #78 open, and
the latest upstream stable OCR remains the already qualified 1.9.2. No push
- was made. The next irreversible model action remains the single final local
- repository OCR review after this checkpoint is committed.
+ was made. The first full local review described below has since run; the next
+ authorized OCR action is the additional concurrency-2 review only after the
+ current remediation and complete deterministic validation finish.
+
+### Pre-additional-review Deterministic Validation Checkpoint
+
+- Signed local checkpoint `1f64c8a112c54df289649fcbc0d06ef9f91dbe84`
+ is the exact validated tree; its signature, clean worktree, and ancestry from
+ stable 0.4.7 are verified. No branch push occurred.
+- Routine formatting, Ruff, strict mypy, Bandit, branch coverage, and the full
+ test suite pass. Independent full test runs pass on each supported Python
+ interpreter. Complete first-parent Gitleaks and dependency audit are clean.
+- Two target-version builds are byte-identical and pass Twine plus closed wheel
+ and sdist inspection. The wheel contains the framework package/provider
+ layout, omits the removed flat modules, and retains zero runtime dependencies.
+ Hash-locked wheel installs on the lower and intermediate supported Python
+ versions and a hash-locked sdist install on the upper version pass `pip check`,
+ exact-version import, hostile-shadow isolation, restricted-`PATH` CLI smoke,
+ and private permissions.
+- The installed artifact collects a private synthetic multi-ecosystem base/head
+ store and serves it through a real stdio MCP process. Initialize, initialized,
+ ping, tool discovery, summary, filtered framework fact/list/get, and filtered
+ framework delta/list/get all pass with read-only annotations, exact installed
+ server version, framework/template facts, scoped coverage, and semantic deltas.
+- The effective OCR binary remains checksum-qualified 1.9.2. Its JSON preview
+ selects root and nested Jinja, Twig, and extensionless conventional role
+ templates alongside an ordinary supported file, reports no unsupported
+ extension, and creates no session store.
+- Towncrier draft, OCR compatibility manifest, lockfile, complete-range source
+ integrity, changed-public-content privacy, tracked-artifact exclusion,
+ `git diff --check`, and private receipt modes pass. Work Queue item 12 and the
+ owner-authorized additional full OCR review with concurrency 2 are now the
+ only next review-stage actions.
+
+### First Full OCR Review And Remediation Checkpoint
+
+- The first full local OCR review ran once over exact range
+ `3caa50b4fc5026da79c7f2ceae1deef31715f814..69a44f7efb053ff11cfc28da1ae910e8f34a8d0b`
+ with the checksum-qualified OCR 1.9.2 binary through an exact-HEAD installed
+ wheel. No GitLab posting command or credential was used. Private result,
+ stderr, bootstrap, and evidence artifacts retain owner-only permissions and
+ the ignored review context is absent from the tracked range.
+- OCR completed most selected items and stopped two evidence modules at its
+ tool-round budget. The accepted partial result contains eight findings and
+ records 261 mandatory `ocr_toolkit_evidence` calls; the toolkit-authored
+ receipt matches that counter and persisted evidence is bound to the exact
+ reviewed refs. The original no-rerun rule was honored until the owner
+ explicitly authorized one additional full review on 2026-08-12.
+- Deterministic remediation is complete for every reported defect class.
+ Release notes, assets, and issue evidence are read through validated stable
+ descriptors; manifest include degradation reaches only affected roots; plugin
+ kinds remain closed; Go replacements obey source-version applicability and
+ exact replacements outrank package-wide fallbacks; bounded store omissions do
+ not become hard validation errors; truncation fixtures are order-independent;
+ and subprocess tests use the active interpreter.
+- The sibling-boundary audit covered the parallel release-receipt reader,
+ Python and Ansible include graphs, replacement precedence, store exception
+ hierarchy, and the built-in MCP delta/query path. Manual review of the two
+ budget-stopped evidence modules found no additional MCP lifecycle or generic
+ detector defect requiring a change.
+- Framework support now has one internal `ocr_toolkit.evidence.frameworks`
+ ownership package. Immutable contracts, the closed schema, generic detection,
+ template inventory, static registry, and ecosystem provider declarations are
+ separate modules; core Git/tree/manifest collection, storage, and MCP serving
+ remain outside. The old flat modules are absent without compatibility shims.
+ An architecture contract rejects provider I/O and dynamic discovery, locks
+ immutable context fields and provider order, and keeps Jinja2 first.
+- Focused regression, full routine quality/security, and every supported-Python
+ test run pass. Built-wheel inspection proves the new package layout, old-module
+ absence, unchanged schema versions/provider order, zero runtime dependencies,
+ and isolated installed import/CLI behavior. Towncrier draft, OCR compatibility
+ manifest, lockfile, public-content privacy scan, and `git diff --check` pass.
+ Work Queue item 11 remains open until the exact committed tree completes the
+ full reproducible package, installed-artifact/MCP/E2E, and privacy gates.
### Initial Evidence
diff --git a/changelog.d/76.bugfix.md b/changelog.d/76.bugfix.md
index d00f334..ebdc16c 100644
--- a/changelog.d/76.bugfix.md
+++ b/changelog.d/76.bugfix.md
@@ -1 +1 @@
-Make stable release recovery idempotent across private draft Releases, already-published registry artifacts, and exact issue-receipt comment readback.
+Make stable release recovery idempotent across private draft Releases, already-published registry artifacts, and exact issue-receipt comment readback, while binding release notes, assets, and issue evidence to their validated file descriptors.
diff --git a/changelog.d/77.feature.md b/changelog.d/77.feature.md
index 305a267..425f6dd 100644
--- a/changelog.d/77.feature.md
+++ b/changelog.d/77.feature.md
@@ -1 +1 @@
-Add bounded framework and template evidence plugins for Jinja2, Go web frameworks, Symfony/PHP, and React/TypeScript, including scoped completeness and first-class redacted delta queries through the built-in evidence MCP.
+Add bounded framework and template evidence plugins for Jinja2, Go web frameworks, Symfony/PHP, and React/TypeScript, including applicability-aware Go replacements, include-graph completeness, scoped coverage, and first-class redacted delta queries through the built-in evidence MCP.
diff --git a/docs/development.md b/docs/development.md
index 544e6e3..8edc85f 100644
--- a/docs/development.md
+++ b/docs/development.md
@@ -22,6 +22,12 @@ For artifact smoke tests, install the wheel and sdist into separate temporary vi
GitHub Actions storage is repository-owned infrastructure. CI restores setup-uv caches on pull requests but saves them only from `main`; CodeQL TRAP caching and the separately controlled v4 overlay-database mode are disabled, so the small repository receives a full analysis without per-run CodeQL cache writes. Workflow artifacts use a seven-day handoff window. The weekly **Actions storage maintenance** workflow deletes all CodeQL caches, non-main or superseded setup-uv caches, superseded Gitleaks caches, artifacts older than seven days, ordinary logs older than 14 days, and release/TestPyPI logs older than 30 days. It deletes only log archives, never workflow runs or check metadata. Scheduled log cleanup uses a bounded 14-day retry window so immutable run history does not get scanned and retried forever. Manual dispatch is a dry run unless `execute` is selected; the same plan is available locally with `python scripts/actions_cleanup.py`, requires `--execute` for deletion, and accepts `--include-all-old-logs` for a deliberate one-time historical cleanup.
+## Extending framework evidence
+
+Framework support lives under `src/ocr_toolkit/evidence/frameworks/`. Add an ecosystem declaration under `frameworks/providers/` and register it explicitly in `frameworks/registry.py`; keep Jinja2 first in the bounded priority order. Reuse the generic package detector where its direct-declaration and resolution semantics fit. Extend the closed schema and generic detector deliberately when a demonstrated provider needs different normalized semantics. Do not add entry-point discovery, compatibility shims, repository reads, filesystem access, subprocesses, network calls, mutation, or another MCP lifecycle to this package. Git/tree/manifest collection, storage, and serving remain core-owned boundaries.
+
+Every provider change needs synthetic tests for direct activation, lock-only non-activation, component ownership, malformed and bounded source degradation, fact/configuration limits, schema reload, base/head deltas, and the existing MCP projection as applicable. Template engines also need explicit OCR include/rule fixtures because evidence collection does not alter OCR file selection. Update the strategy and changelog when the supported public behavior changes.
+
## Boundary-focused test checklist
Before closing a parser, repository reader, persisted schema, subprocess, or report-rendering change, add the applicable boundary tests:
diff --git a/docs/engineering/toolkit_strategy.md b/docs/engineering/toolkit_strategy.md
index 46bae8a..c81c4e2 100644
--- a/docs/engineering/toolkit_strategy.md
+++ b/docs/engineering/toolkit_strategy.md
@@ -89,6 +89,8 @@ Implemented collectors cover Python declarations, requirements, uv, Poetry, Pipe
Framework support is package-owned static plugin extraction, not a code graph or framework-specific review engine. The established registry covers Jinja2 and Jinja/Ansible-style templates, Echo/Fiber with direct gRPC stack context, Symfony/Twig, and React/Next with TypeScript/Vite context. Plugins receive only immutable normalized dependency/tree evidence and exact core-owned source-status records from the collector, publish closed framework/template facts plus scoped completeness, and cannot read the repository independently, execute commands, fetch the network, mutate state, or create another MCP/review flow. Components follow the nearest declaration manifest (or conventional Ansible role), Go uses effective `go.mod` requirement/replacement semantics, and every read, parse, configuration, template, or provider limit degrades its exact scope instead of turning missing facts into proof.
+The implementation boundary is the internal `ocr_toolkit.evidence.frameworks` package. It owns immutable plugin contracts, closed framework/template schemas, generic package detection, template inventory, a static ordered registry, and package-owned declarations under `frameworks.providers`. The core collector remains responsible for Git/tree/manifest reads and passes only bounded immutable inputs; the evidence store and built-in MCP remain outside the package. There are no runtime discovery hooks, legacy import shims, plugin-owned I/O, or framework-specific MCP services. A new demonstrated provider extends this one registry and the shared schemas instead of adding another collection or serving path.
+
OCR file selection remains a separate review-engine boundary. The public synthetic rules pack explicitly includes Jinja and Twig template paths that the recommended OCR does not allowlist by default, then supplies narrowly scoped merged rules. Framework identity, versions, component scope, configuration paths, template inventory, scoped completeness, and their base/head deltas are stored once and served on demand by the existing built-in evidence MCP; rules neither duplicate those facts nor render templates.
The design borrows useful CodeGraph principles without adopting CodeGraph: deterministic extraction precedes rendering, work is component-scoped, facts retain provenance and staleness, and OCR retrieves surgical evidence on demand. Route, symbol, and call graphs remain out of scope.
diff --git a/scripts/github_release_api.py b/scripts/github_release_api.py
index 379d1a9..ad93bd5 100755
--- a/scripts/github_release_api.py
+++ b/scripts/github_release_api.py
@@ -105,12 +105,71 @@ def _request(
raise GitHubReleaseError("GitHub Release response is not valid bounded JSON") from exc
+def _read_regular_file(path: Path, *, max_bytes: int, require_nonempty: bool, label: str) -> bytes:
+ """Read one regular single-link file through its validated descriptor."""
+
+ flags = os.O_RDONLY
+ for name in ("O_CLOEXEC", "O_NOFOLLOW", "O_NONBLOCK", "O_BINARY"):
+ flags |= getattr(os, name, 0)
+ descriptor = -1
+ try:
+ path_metadata = os.stat(path, follow_symlinks=False)
+ if not stat.S_ISREG(path_metadata.st_mode) or path_metadata.st_nlink != 1:
+ raise GitHubReleaseError(f"{label} is unsafe or oversized")
+ descriptor = os.open(path, flags)
+ opened = os.fstat(descriptor)
+ # Comparing the pathname identity to the opened descriptor also closes
+ # the lstat/open race on platforms without O_NOFOLLOW.
+ if (path_metadata.st_dev, path_metadata.st_ino) != (opened.st_dev, opened.st_ino):
+ raise GitHubReleaseError(f"{label} changed while being opened")
+ if (
+ not stat.S_ISREG(opened.st_mode)
+ or opened.st_nlink != 1
+ or (require_nonempty and opened.st_size <= 0)
+ or opened.st_size > max_bytes
+ ):
+ raise GitHubReleaseError(f"{label} is unsafe or oversized")
+ chunks: list[bytes] = []
+ remaining = max_bytes + 1
+ while remaining:
+ chunk = os.read(descriptor, min(65_536, remaining))
+ if not chunk:
+ break
+ chunks.append(chunk)
+ remaining -= len(chunk)
+ payload = b"".join(chunks)
+ final = os.fstat(descriptor)
+ stable_fields = ("st_dev", "st_ino", "st_mode", "st_size")
+ if (
+ len(payload) > max_bytes
+ or len(payload) != opened.st_size
+ or any(getattr(opened, name) != getattr(final, name) for name in stable_fields)
+ or getattr(opened, "st_mtime_ns", None) != getattr(final, "st_mtime_ns", None)
+ ):
+ raise GitHubReleaseError(f"{label} changed while being read")
+ return payload
+ except GitHubReleaseError:
+ raise
+ except OSError as exc:
+ raise GitHubReleaseError(f"{label} is unsafe or unavailable") from exc
+ finally:
+ if descriptor >= 0:
+ os.close(descriptor)
+
+
def _metadata(notes_path: Path) -> str:
- """Read exact release notes under the shared response-size ceiling."""
+ """Read exact release notes from one bounded validated descriptor."""
- if not notes_path.is_file() or notes_path.stat().st_size > MAX_JSON_BYTES:
- raise GitHubReleaseError("release notes are missing or oversized")
- return notes_path.read_text(encoding="utf-8")
+ payload = _read_regular_file(
+ notes_path,
+ max_bytes=MAX_JSON_BYTES,
+ require_nonempty=False,
+ label="release notes",
+ )
+ try:
+ return payload.decode("utf-8")
+ except UnicodeDecodeError as exc:
+ raise GitHubReleaseError("release notes are not valid UTF-8") from exc
def _validate_inputs(repository: str, tag: str, target: str, title: str) -> None:
@@ -329,18 +388,16 @@ def upload_asset(
if release["draft"] is not True:
raise GitHubReleaseError("cannot upload an asset to a published Release")
name = asset.name
- metadata = asset.stat(follow_symlinks=False)
- if (
- not ASSET_RE.fullmatch(name)
- or not stat.S_ISREG(metadata.st_mode)
- or metadata.st_nlink != 1
- or metadata.st_size <= 0
- or metadata.st_size > MAX_ASSET_BYTES
- ):
+ if not ASSET_RE.fullmatch(name):
raise GitHubReleaseError("GitHub Release asset is unsafe or oversized")
if any(item["name"] == name for item in release["assets"]):
raise GitHubReleaseError("GitHub Release asset already exists")
- body = asset.read_bytes()
+ body = _read_regular_file(
+ asset,
+ max_bytes=MAX_ASSET_BYTES,
+ require_nonempty=True,
+ label="GitHub Release asset",
+ )
endpoint = f"/repos/{repository}/releases/{release_id}/assets?" + urllib.parse.urlencode(
{"name": name}
)
@@ -356,7 +413,7 @@ def upload_asset(
if (
not isinstance(uploaded, dict)
or uploaded.get("name") != name
- or uploaded.get("size") != metadata.st_size
+ or uploaded.get("size") != len(body)
or isinstance(uploaded.get("id"), bool)
or not isinstance(uploaded.get("id"), int)
or uploaded["id"] <= 0
diff --git a/scripts/release_issue_receipt.py b/scripts/release_issue_receipt.py
index 51c735f..46d2fd5 100755
--- a/scripts/release_issue_receipt.py
+++ b/scripts/release_issue_receipt.py
@@ -5,7 +5,9 @@
import argparse
import json
+import os
import re
+import stat
from pathlib import Path
from typing import Any
@@ -75,12 +77,49 @@ def comment_state(comments: list[Any], expected_body: str, *, require_comment: b
def load_json(path: Path, *, max_bytes: int) -> Any:
- """Load one already network-bounded JSON file under a local size ceiling."""
+ """Load one bounded regular JSON file through its validated descriptor."""
- if path.stat().st_size > max_bytes:
- raise IssueReceiptError("release issue evidence exceeds its byte limit")
+ flags = os.O_RDONLY
+ for name in ("O_CLOEXEC", "O_NOFOLLOW", "O_NONBLOCK", "O_BINARY"):
+ flags |= getattr(os, name, 0)
+ descriptor = -1
try:
- return json.loads(path.read_bytes())
+ path_metadata = os.stat(path, follow_symlinks=False)
+ if not stat.S_ISREG(path_metadata.st_mode) or path_metadata.st_nlink != 1:
+ raise IssueReceiptError("release issue evidence is unsafe")
+ descriptor = os.open(path, flags)
+ opened = os.fstat(descriptor)
+ if (path_metadata.st_dev, path_metadata.st_ino) != (opened.st_dev, opened.st_ino):
+ raise IssueReceiptError("release issue evidence changed while being opened")
+ if not stat.S_ISREG(opened.st_mode) or opened.st_nlink != 1 or opened.st_size > max_bytes:
+ raise IssueReceiptError("release issue evidence exceeds its byte limit")
+ chunks: list[bytes] = []
+ remaining = max_bytes + 1
+ while remaining:
+ chunk = os.read(descriptor, min(65_536, remaining))
+ if not chunk:
+ break
+ chunks.append(chunk)
+ remaining -= len(chunk)
+ payload = b"".join(chunks)
+ final = os.fstat(descriptor)
+ if (
+ len(payload) > max_bytes
+ or len(payload) != opened.st_size
+ or (opened.st_dev, opened.st_ino, opened.st_mode, opened.st_size)
+ != (final.st_dev, final.st_ino, final.st_mode, final.st_size)
+ or getattr(opened, "st_mtime_ns", None) != getattr(final, "st_mtime_ns", None)
+ ):
+ raise IssueReceiptError("release issue evidence changed while being read")
+ except IssueReceiptError:
+ raise
+ except OSError as exc:
+ raise IssueReceiptError("release issue evidence is unsafe or unavailable") from exc
+ finally:
+ if descriptor >= 0:
+ os.close(descriptor)
+ try:
+ return json.loads(payload)
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
raise IssueReceiptError("release issue evidence is not valid JSON") from exc
diff --git a/src/ocr_toolkit/evidence/collectors.py b/src/ocr_toolkit/evidence/collectors.py
index 1b105c3..8f61498 100644
--- a/src/ocr_toolkit/evidence/collectors.py
+++ b/src/ocr_toolkit/evidence/collectors.py
@@ -19,7 +19,7 @@
from ocr_toolkit.evidence.ansible_requirements import parse_galaxy_requirements
from ocr_toolkit.evidence.composer_manifests import parse_composer_json, parse_composer_lock
from ocr_toolkit.evidence.coverage import CoverageObservation, compose_coverage
-from ocr_toolkit.evidence.framework_plugins import (
+from ocr_toolkit.evidence.frameworks import (
FrameworkPluginContext,
PluginCoverage,
PluginFact,
@@ -100,20 +100,22 @@ class ManifestCollector:
@dataclass(frozen=True, slots=True)
class ManifestBlobSet:
- """Return immutable Galaxy blobs and explicit graph-read diagnostics."""
+ """Return immutable Galaxy blobs, diagnostics, and affected graph roots."""
blobs: dict[str, bytes]
galaxy_paths: tuple[str, ...]
diagnostics: tuple[str, ...]
+ degraded_roots: tuple[tuple[str, str], ...] = ()
@dataclass(frozen=True, slots=True)
class PythonRequirementBlobSet:
- """Return immutable requirements blobs and graph-read diagnostics."""
+ """Return immutable requirements blobs, diagnostics, and affected roots."""
blobs: dict[str, bytes]
requirement_paths: tuple[str, ...]
diagnostics: tuple[str, ...]
+ degraded_roots: tuple[tuple[str, str], ...] = ()
def _parse_ansible_requirements(text: str) -> ManifestParseResult:
@@ -377,6 +379,42 @@ def _bound_include_diagnostics(
)
+def _roots_reaching_graph_degradation(
+ roots: tuple[str, ...],
+ edges: Mapping[str, tuple[str, ...]],
+ degraded_paths: Mapping[str, str],
+) -> tuple[tuple[str, str], ...]:
+ """Return roots whose accepted graph reaches a bounded degraded source."""
+
+ supported_reasons = {"bounded-source-omission", "include-graph-truncation"}
+ if any(reason not in supported_reasons for reason in degraded_paths.values()):
+ raise ValueError("include graph has an unsupported degradation reason")
+ affected: list[tuple[str, str]] = []
+ for root in sorted(set(roots)):
+ pending = [root]
+ visited: set[str] = set()
+ reasons: set[str] = set()
+ while pending:
+ path = pending.pop()
+ if path in visited:
+ continue
+ visited.add(path)
+ reason = degraded_paths.get(path)
+ if reason is not None:
+ reasons.add(reason)
+ pending.extend(reversed(edges.get(path, ())))
+ if reasons:
+ # A bounded omission is stronger than a traversal/item limit because
+ # the source itself was never parsed.
+ reason = (
+ "bounded-source-omission"
+ if "bounded-source-omission" in reasons
+ else "include-graph-truncation"
+ )
+ affected.append((root, reason))
+ return tuple(affected)
+
+
def _read_manifest_graph(
reader: GitRepositoryReader,
entries_by_path: Mapping[str, RepositoryObject],
@@ -391,6 +429,7 @@ def _read_manifest_graph(
admitted = set(initial_paths)
root_paths = set(initial_paths)
edges: dict[str, list[str]] = {}
+ degraded_paths: dict[str, str] = {}
pending = [(path, "") for path in initial_paths]
included_files = 0
file_limit_reported = False
@@ -426,6 +465,7 @@ def _read_manifest_graph(
f"{MAX_MANIFEST_INCLUDE_FILES} files"
)
file_limit_reported = True
+ degraded_paths[path] = "include-graph-truncation"
continue
admitted.add(path)
included_files += 1
@@ -436,6 +476,8 @@ def _read_manifest_graph(
read = reader.read_candidate_blobs(tuple(to_read))
blobs.update(read.blobs)
diagnostics.extend(read.diagnostics)
+ for path in (entry.path for entry in to_read if entry.path not in read.blobs):
+ degraded_paths[path] = "bounded-source-omission"
for path in process_paths:
visited.add(path)
blob = blobs.get(path)
@@ -443,6 +485,8 @@ def _read_manifest_graph(
continue
try:
parsed = parse_galaxy_requirements(blob.decode("utf-8"))
+ if any("truncated" in notice for notice in parsed.notices):
+ degraded_paths[path] = "include-graph-truncation"
except UnicodeDecodeError:
diagnostics.append(f"{path}: Ansible Galaxy include is not UTF-8")
continue
@@ -458,6 +502,7 @@ def _read_manifest_graph(
f"{MAX_MANIFEST_INCLUDE_EDGES} edges"
)
edge_limit_reported = True
+ degraded_paths[path] = "include-graph-truncation"
continue
included_edges += 1
edges.setdefault(path, []).append(resolved)
@@ -465,12 +510,18 @@ def _read_manifest_graph(
diagnostics.append(
f"{path}: Ansible Galaxy include depth exceeded at {resolved}"
)
+ degraded_paths[path] = "include-graph-truncation"
else:
pending.append((resolved, path))
normalized_edges = {path: tuple(dict.fromkeys(targets)) for path, targets in edges.items()}
diagnostics.extend(_include_cycle_diagnostics(normalized_edges))
galaxy_paths = tuple(sorted(path for path in visited if path in blobs))
- return ManifestBlobSet(blobs, galaxy_paths, _bound_include_diagnostics(diagnostics))
+ return ManifestBlobSet(
+ blobs,
+ galaxy_paths,
+ _bound_include_diagnostics(diagnostics),
+ _roots_reaching_graph_degradation(initial_paths, normalized_edges, degraded_paths),
+ )
def _read_python_requirement_graph(
@@ -485,6 +536,8 @@ def _read_python_requirement_graph(
diagnostics: list[str] = []
visited: set[str] = set()
admitted = set(initial_paths)
+ edges: dict[str, list[str]] = {}
+ degraded_paths: dict[str, str] = {}
pending = [(path, "") for path in initial_paths]
included_files = 0
included_edges = 0
@@ -517,6 +570,7 @@ def _read_python_requirement_graph(
f"{MAX_MANIFEST_INCLUDE_FILES} files"
)
file_limit_reported = True
+ degraded_paths[path] = "include-graph-truncation"
continue
admitted.add(path)
included_files += 1
@@ -526,12 +580,16 @@ def _read_python_requirement_graph(
read = reader.read_candidate_blobs(tuple(sorted(to_read, key=lambda item: item.path)))
blobs.update(read.blobs)
diagnostics.extend(read.diagnostics)
+ for path in (entry.path for entry in to_read if entry.path not in read.blobs):
+ degraded_paths[path] = "bounded-source-omission"
for path in process_paths:
visited.add(path)
if path not in blobs:
continue
try:
parsed = parse_requirements(blobs[path].decode("utf-8"))
+ if any("truncated" in notice for notice in parsed.notices):
+ degraded_paths[path] = "include-graph-truncation"
except UnicodeDecodeError:
diagnostics.append(f"{path}: Python requirements include is not UTF-8")
continue
@@ -549,15 +607,19 @@ def _read_python_requirement_graph(
f"{MAX_MANIFEST_INCLUDE_EDGES} edges"
)
edge_limit_reported = True
+ degraded_paths[path] = "include-graph-truncation"
continue
included_edges += 1
+ edges.setdefault(path, []).append(resolved)
if depth == MAX_MANIFEST_INCLUDE_DEPTH:
diagnostics.append(
f"{path}: Python requirements include depth exceeded at {resolved}"
)
+ degraded_paths[path] = "include-graph-truncation"
else:
pending.append((resolved, path))
requirement_paths = tuple(sorted(path for path in visited if path in blobs))
+ normalized_edges = {path: tuple(dict.fromkeys(targets)) for path, targets in edges.items()}
return PythonRequirementBlobSet(
blobs,
requirement_paths,
@@ -565,6 +627,7 @@ def _read_python_requirement_graph(
diagnostics,
truncation_notice="Python requirements include diagnostics were truncated",
),
+ _roots_reaching_graph_degradation(initial_paths, normalized_edges, degraded_paths),
)
@@ -730,9 +793,22 @@ def unavailable_topology(entry: RepositoryObject, reason: str) -> None:
except RepositoryEvidenceError as exc:
diagnostics.append(f"Python requirements include batch read failed: {exc}")
python_graph = PythonRequirementBlobSet(graph.blobs, python_roots, ())
+ degraded_roots = dict((*graph.degraded_roots, *python_graph.degraded_roots))
+ for path, reason in sorted(degraded_roots.items()):
+ status = source_statuses.get(path)
+ if status is not None and status.state not in {"omitted", "unavailable"}:
+ source_statuses[path] = PluginSourceStatus(
+ status.path,
+ status.ecosystem,
+ status.roles,
+ "partial",
+ reason,
+ )
blobs = python_graph.blobs
- diagnostics.extend(f"{ref.value}:{message}" for message in graph.diagnostics)
- diagnostics.extend(f"{ref.value}:{message}" for message in python_graph.diagnostics)
+ for message in (*graph.diagnostics, *python_graph.diagnostics):
+ qualified = f"{ref.value}:{message}"
+ if qualified not in diagnostics:
+ diagnostics.append(qualified)
paths = dict.fromkeys(
(
*tuple(entry.path for entry in candidates),
@@ -800,14 +876,15 @@ def unavailable_topology(entry: RepositoryObject, reason: str) -> None:
diagnostics.extend(f"{ref.value}:{path}: {notice}" for notice in parsed.notices)
source_status = source_statuses.get(path)
if any("truncated" in notice for notice in parsed.notices):
- if source_status is not None:
+ if source_status is not None and source_status.state != "partial":
source_statuses[path] = PluginSourceStatus(
source_status.path,
source_status.ecosystem,
source_status.roles,
"partial",
+ "source-item-limit",
)
- elif source_status is not None:
+ elif source_status is not None and source_status.state != "partial":
source_statuses[path] = PluginSourceStatus(
source_status.path,
source_status.ecosystem,
diff --git a/src/ocr_toolkit/evidence/frameworks/__init__.py b/src/ocr_toolkit/evidence/frameworks/__init__.py
new file mode 100644
index 0000000..dc5ebc0
--- /dev/null
+++ b/src/ocr_toolkit/evidence/frameworks/__init__.py
@@ -0,0 +1,38 @@
+"""Bounded static framework providers and template evidence inventory."""
+
+from ocr_toolkit.evidence.frameworks.contracts import (
+ MAX_CONFIGURATION_PATHS,
+ MAX_PLUGIN_FACTS,
+ FrameworkPluginContext,
+ FrameworkPluginResult,
+ PluginCoverage,
+ PluginFact,
+ PluginSourceStatus,
+)
+from ocr_toolkit.evidence.frameworks.registry import (
+ BUILTIN_FRAMEWORK_PLUGINS,
+ collect_framework_plugins,
+)
+from ocr_toolkit.evidence.frameworks.schema import FRAMEWORK_SCHEMA, TEMPLATE_SCHEMA
+from ocr_toolkit.evidence.frameworks.templates import collect_template_files
+
+
+def framework_schema_versions() -> tuple[str, str]:
+ """Expose the closed plugin schemas for storage validation."""
+
+ return FRAMEWORK_SCHEMA, TEMPLATE_SCHEMA
+
+
+__all__ = [
+ "BUILTIN_FRAMEWORK_PLUGINS",
+ "MAX_CONFIGURATION_PATHS",
+ "MAX_PLUGIN_FACTS",
+ "FrameworkPluginContext",
+ "FrameworkPluginResult",
+ "PluginCoverage",
+ "PluginFact",
+ "PluginSourceStatus",
+ "collect_framework_plugins",
+ "collect_template_files",
+ "framework_schema_versions",
+]
diff --git a/src/ocr_toolkit/evidence/frameworks/contracts.py b/src/ocr_toolkit/evidence/frameworks/contracts.py
new file mode 100644
index 0000000..f6377cf
--- /dev/null
+++ b/src/ocr_toolkit/evidence/frameworks/contracts.py
@@ -0,0 +1,79 @@
+"""Static framework plugin contracts shared by collectors and providers."""
+
+from __future__ import annotations
+
+from collections.abc import Mapping
+from dataclasses import dataclass
+from typing import TYPE_CHECKING, Protocol
+
+from ocr_toolkit.evidence.coverage import CoverageObservation
+from ocr_toolkit.evidence.model import EvidenceRecord, EvidenceValue, RefRole
+
+if TYPE_CHECKING:
+ from ocr_toolkit.evidence.repository import RepositoryObject
+
+# Each evidence kind shares a 512-record store limit across base and head. Capping
+# each immutable side at half keeps accepted records, deltas, and coverage atomic.
+MAX_PLUGIN_FACTS = 256
+MAX_CONFIGURATION_PATHS = 128
+
+
+@dataclass(frozen=True, slots=True)
+class PluginFact:
+ """Describe one validated plugin fact before ref provenance is attached."""
+
+ kind: str
+ component: str
+ identity: str
+ source_path: str
+ value: Mapping[str, EvidenceValue]
+
+
+@dataclass(frozen=True, slots=True)
+class PluginCoverage:
+ """Describe one plugin-owned scoped coverage observation."""
+
+ component: str
+ domain: str
+ scope: str
+ observation: CoverageObservation
+
+
+@dataclass(frozen=True, slots=True)
+class PluginSourceStatus:
+ """Describe one supported manifest source and its bounded collection state."""
+
+ path: str
+ ecosystem: str
+ roles: tuple[str, ...]
+ state: str
+ reason: str | None = None
+
+
+@dataclass(frozen=True, slots=True)
+class FrameworkPluginContext:
+ """Expose immutable normalized facts and bounded tree metadata to one plugin."""
+
+ records: tuple[EvidenceRecord, ...]
+ entries: tuple[RepositoryObject, ...]
+ source_statuses: tuple[PluginSourceStatus, ...]
+ ref: RefRole
+ commit_sha: str
+
+
+@dataclass(frozen=True, slots=True)
+class FrameworkPluginResult:
+ """Return bounded plugin facts, coverage, and safe machine notices."""
+
+ facts: tuple[PluginFact, ...]
+ coverage: tuple[PluginCoverage, ...]
+ notices: tuple[str, ...] = ()
+
+
+class FrameworkPlugin(Protocol):
+ """Define the package-owned static framework plugin boundary."""
+
+ plugin_id: str
+
+ def collect(self, context: FrameworkPluginContext) -> FrameworkPluginResult:
+ """Derive facts without I/O, execution, network access, or mutation."""
diff --git a/src/ocr_toolkit/evidence/framework_plugins.py b/src/ocr_toolkit/evidence/frameworks/detection.py
similarity index 58%
rename from src/ocr_toolkit/evidence/framework_plugins.py
rename to src/ocr_toolkit/evidence/frameworks/detection.py
index ef57edb..240698d 100644
--- a/src/ocr_toolkit/evidence/framework_plugins.py
+++ b/src/ocr_toolkit/evidence/frameworks/detection.py
@@ -1,4 +1,4 @@
-"""Derive bounded framework and template facts through static built-in plugins."""
+"""Generic bounded package-backed framework detection without repository I/O."""
from __future__ import annotations
@@ -6,78 +6,22 @@
from collections.abc import Iterable, Mapping
from dataclasses import dataclass
from pathlib import PurePosixPath
-from typing import Protocol
+from typing import TYPE_CHECKING
from ocr_toolkit.evidence.coverage import CoverageObservation
-from ocr_toolkit.evidence.model import CoverageState, EvidenceRecord, EvidenceValue, RefRole
-from ocr_toolkit.evidence.repository import RepositoryObject
-
-# Each evidence kind shares a 512-record store limit across base and head. Capping
-# each immutable side at half keeps accepted records, deltas, and coverage atomic.
-MAX_PLUGIN_FACTS = 256
-MAX_CONFIGURATION_PATHS = 128
-_FRAMEWORK_SCHEMA = "repository.framework-evidence/v1"
-_TEMPLATE_SCHEMA = "repository.template-evidence/v1"
-
-
-@dataclass(frozen=True, slots=True)
-class PluginFact:
- """Describe one validated plugin fact before ref provenance is attached."""
-
- kind: str
- component: str
- identity: str
- source_path: str
- value: Mapping[str, EvidenceValue]
-
-
-@dataclass(frozen=True, slots=True)
-class PluginCoverage:
- """Describe one plugin-owned scoped coverage observation."""
-
- component: str
- domain: str
- scope: str
- observation: CoverageObservation
-
-
-@dataclass(frozen=True, slots=True)
-class PluginSourceStatus:
- """Describe one supported manifest source and its bounded collection state."""
-
- path: str
- ecosystem: str
- roles: tuple[str, ...]
- state: str
-
-
-@dataclass(frozen=True, slots=True)
-class FrameworkPluginContext:
- """Expose immutable normalized facts and bounded tree metadata to one plugin."""
-
- records: tuple[EvidenceRecord, ...]
- entries: tuple[RepositoryObject, ...]
- source_statuses: tuple[PluginSourceStatus, ...]
- ref: RefRole
- commit_sha: str
-
-
-@dataclass(frozen=True, slots=True)
-class FrameworkPluginResult:
- """Return bounded plugin facts, coverage, and safe machine notices."""
-
- facts: tuple[PluginFact, ...]
- coverage: tuple[PluginCoverage, ...]
- notices: tuple[str, ...] = ()
-
-
-class FrameworkPlugin(Protocol):
- """Define the package-owned static framework plugin boundary."""
-
- plugin_id: str
+from ocr_toolkit.evidence.frameworks.contracts import (
+ MAX_CONFIGURATION_PATHS,
+ MAX_PLUGIN_FACTS,
+ FrameworkPluginContext,
+ FrameworkPluginResult,
+ PluginCoverage,
+ PluginFact,
+)
+from ocr_toolkit.evidence.frameworks.schema import FRAMEWORK_SCHEMA
+from ocr_toolkit.evidence.model import CoverageState, EvidenceRecord, EvidenceValue
- def collect(self, context: FrameworkPluginContext) -> FrameworkPluginResult:
- """Derive facts without I/O, execution, network access, or mutation."""
+if TYPE_CHECKING:
+ from ocr_toolkit.evidence.repository import RepositoryObject
def component_root(path: str) -> str:
@@ -96,7 +40,7 @@ def _fact_value(record: EvidenceRecord | None) -> Mapping[str, EvidenceValue] |
return fact if isinstance(fact, Mapping) else None
-def _package(record: EvidenceRecord) -> str | None:
+def package_name(record: EvidenceRecord) -> str | None:
"""Return the normalized package name carried by one dependency record."""
fact = _fact_value(record)
@@ -133,7 +77,7 @@ def _scope(record: EvidenceRecord) -> str:
return value if isinstance(value, str) and value else "unknown"
-def _is_direct(record: EvidenceRecord, ecosystem: str) -> bool:
+def is_direct_declaration(record: EvidenceRecord, ecosystem: str) -> bool:
"""Return whether a declaration directly establishes plugin applicability."""
scope = _scope(record)
@@ -148,7 +92,7 @@ def _path_is_within(path: str, component: str) -> bool:
return component == "repository" or path == component or path.startswith(component + "/")
-def _owning_component(path: str, components: tuple[str, ...]) -> str | None:
+def owning_component(path: str, components: tuple[str, ...]) -> str | None:
"""Return the nearest manifest-root component that owns one repository path."""
matches = tuple(component for component in components if _path_is_within(path, component))
@@ -175,7 +119,7 @@ def _configuration_paths(
entry.object_type != "blob"
or entry.is_symlink
or entry.is_submodule
- or _owning_component(entry.path, components) != component
+ or owning_component(entry.path, components) != component
or not any(pattern.search(entry.path) for pattern in patterns)
):
continue
@@ -206,6 +150,23 @@ def _related(
return value
+def _replacement_applies(
+ replacement: EvidenceRecord, declarations: tuple[EvidenceRecord, ...]
+) -> bool:
+ """Return whether one Go replacement applies to a direct required version."""
+
+ package = package_name(replacement)
+ source_version = _version(replacement)
+ if package is None:
+ return False
+ if source_version is None:
+ return any(package_name(record) == package for record in declarations)
+ return any(
+ package_name(record) == package and _version(record) == source_version
+ for record in declarations
+ )
+
+
def _replacement_value(record: EvidenceRecord | None) -> dict[str, EvidenceValue] | None:
"""Return one closed Go replacement object without reading replacement content."""
@@ -236,7 +197,7 @@ def _resolution_values(
replacement_value = _replacement_value(replacement)
if replacement_value is not None:
version = replacement_value["version"]
- package = _package(replacement) if replacement is not None else None
+ package = package_name(replacement) if replacement is not None else None
if replacement_value["type"] == "module" and isinstance(version, str) and package:
return [
{
@@ -257,7 +218,8 @@ def _resolution_values(
record.source_path,
)
for record in records
- if (package := _package(record)) is not None and (version := _version(record)) is not None
+ if (package := package_name(record)) is not None
+ and (version := _version(record)) is not None
}
return [
{"package": package, "version": version, "source": source, "source_path": path}
@@ -301,7 +263,7 @@ def _framework_fact(
)
declaration_values: list[dict[str, EvidenceValue]] = []
for record in declarations:
- package = _package(record)
+ package = package_name(record)
if package is None:
continue
declaration_values.append(
@@ -314,7 +276,7 @@ def _framework_fact(
)
source_path = min(record.source_path for record in declarations)
value: dict[str, EvidenceValue] = {
- "schema_version": _FRAMEWORK_SCHEMA,
+ "schema_version": FRAMEWORK_SCHEMA,
"plugin": plugin,
"framework": framework,
"ecosystem": ecosystem,
@@ -343,7 +305,7 @@ def _framework_fact(
)
-def _coverage(
+def coverage_observation(
*,
component: str,
plugin: str,
@@ -367,7 +329,7 @@ def _coverage(
)
-def _components(context: FrameworkPluginContext, ecosystem: str) -> tuple[str, ...]:
+def plugin_components(context: FrameworkPluginContext, ecosystem: str) -> tuple[str, ...]:
"""Return components with parsed or recognized declaration sources."""
parsed = {
@@ -420,20 +382,28 @@ def _source_observation(
) -> CoverageObservation | None:
"""Return the strongest degradation from exact supported source statuses."""
- states = {
- source.state
- for source in context.source_statuses
- if source.ecosystem == ecosystem
- and role in source.roles
- and component_root(source.path) == component
- }
for state, reason in (
("unavailable", "parse-unavailable"),
("omitted", "bounded-source-omission"),
("partial", "source-item-limit"),
):
- if state in states:
- return CoverageObservation(CoverageState.PARTIAL, reason, positive=True)
+ matching = tuple(
+ source
+ for source in context.source_statuses
+ if source.ecosystem == ecosystem
+ and role in source.roles
+ and component_root(source.path) == component
+ and source.state == state
+ )
+ if matching:
+ selected_reason = next(
+ (source.reason for source in matching if source.reason is not None), reason
+ )
+ return CoverageObservation(
+ CoverageState.PARTIAL,
+ selected_reason,
+ positive=state == "partial",
+ )
return None
@@ -471,13 +441,15 @@ def collect(self, context: FrameworkPluginContext) -> FrameworkPluginResult:
facts: list[PluginFact] = []
coverage: list[PluginCoverage] = []
notices: list[str] = []
- components = _components(context, self.ecosystem)
+ components = plugin_components(context, self.ecosystem)
for component in components:
component_declarations = _component_declarations(
context.records, component, self.ecosystem
)
declarations = tuple(
- record for record in component_declarations if _is_direct(record, self.ecosystem)
+ record
+ for record in component_declarations
+ if is_direct_declaration(record, self.ecosystem)
)
locked = _component_resolutions(context.records, component, self.ecosystem)
replacements = tuple(
@@ -494,12 +466,12 @@ def collect(self, context: FrameworkPluginContext) -> FrameworkPluginResult:
related_values: list[dict[str, EvidenceValue]] = []
for item in self.related:
item_declarations = tuple(
- record for record in declarations if _package(record) in item.packages
+ record for record in declarations if package_name(record) in item.packages
)
if not item_declarations:
continue
item_locked = tuple(
- record for record in locked if _package(record) in item.packages
+ record for record in locked if package_name(record) in item.packages
)
related_values.append(
_related(
@@ -515,22 +487,36 @@ def collect(self, context: FrameworkPluginContext) -> FrameworkPluginResult:
component_fact_limit = False
for spec in self.frameworks:
direct = tuple(
- record for record in declarations if _package(record) in spec.packages
+ record for record in declarations if package_name(record) in spec.packages
)
if not direct:
continue
component_positive = True
- resolved = tuple(record for record in locked if _package(record) in spec.packages)
- replacement = next(
- (record for record in replacements if _package(record) in spec.packages),
- None,
+ resolved = tuple(
+ record for record in locked if package_name(record) in spec.packages
+ )
+ applicable_replacements = tuple(
+ record
+ for record in replacements
+ if package_name(record) in spec.packages
+ and _replacement_applies(record, direct)
+ )
+ replacement = min(
+ applicable_replacements,
+ key=lambda record: (
+ _version(record) is None,
+ _version(record) or "",
+ str(_replacement_value(record)),
+ record.source_path,
+ ),
+ default=None,
)
replacement_value = _replacement_value(replacement)
declarations_resolve = self.ecosystem == "go" and replacement is None
if len(facts) >= MAX_PLUGIN_FACTS:
component_fact_limit = True
coverage.append(
- _coverage(
+ coverage_observation(
component=component,
plugin=self.plugin_id,
framework=spec.framework,
@@ -584,7 +570,7 @@ def collect(self, context: FrameworkPluginContext) -> FrameworkPluginResult:
state = CoverageState.PARTIAL
reason = "lock-version-missing"
coverage.append(
- _coverage(
+ coverage_observation(
component=component,
plugin=self.plugin_id,
framework=spec.framework,
@@ -594,7 +580,7 @@ def collect(self, context: FrameworkPluginContext) -> FrameworkPluginResult:
)
)
coverage.append(
- _coverage(
+ coverage_observation(
component=component,
plugin=self.plugin_id,
framework=spec.framework,
@@ -630,256 +616,18 @@ def collect(self, context: FrameworkPluginContext) -> FrameworkPluginResult:
else "direct-manifest-complete"
)
coverage.append(
- _coverage(
+ coverage_observation(
component=component,
plugin=self.plugin_id,
framework=None,
state=declaration_state,
reason=declaration_reason,
- positive=component_positive,
+ positive=(
+ component_positive
+ or (declaration_source is not None and declaration_source.positive)
+ ),
)
)
if len(facts) >= MAX_PLUGIN_FACTS:
notices.append(f"framework plugin fact limit reached: {self.plugin_id}")
return FrameworkPluginResult(tuple(facts), tuple(coverage), tuple(notices))
-
-
-_PYTHON_DECLARATIONS = (re.compile(r"(^|/)(pyproject\.toml|requirements[^/]*\.(txt|in))$", re.I),)
-_PYTHON_RESOLUTIONS = (
- re.compile(r"(^|/)(uv\.lock|poetry\.lock|pipfile\.lock|pylock(?:\.[^/]+)?\.toml)$", re.I),
-)
-_JAVASCRIPT_DECLARATIONS = (re.compile(r"(^|/)package\.json$", re.I),)
-_JAVASCRIPT_RESOLUTIONS = (
- re.compile(r"(^|/)(package-lock\.json|yarn\.lock|pnpm-lock\.yaml)$", re.I),
-)
-_COMPOSER_DECLARATIONS = (re.compile(r"(^|/)composer\.json$", re.I),)
-_COMPOSER_RESOLUTIONS = (re.compile(r"(^|/)composer\.lock$", re.I),)
-_GO_MOD = (re.compile(r"(^|/)go\.mod$", re.I),)
-
-JINJA2_PLUGIN = PackageFrameworkPlugin(
- "jinja2",
- "python",
- (PackageFrameworkSpec("jinja2", ("jinja2",), "template-engine"),),
- configuration_patterns=(*_PYTHON_DECLARATIONS, *_PYTHON_RESOLUTIONS),
-)
-GO_WEB_PLUGIN = PackageFrameworkPlugin(
- "go-web",
- "go",
- (
- PackageFrameworkSpec("echo", ("github.com/labstack/echo/v4",)),
- PackageFrameworkSpec("fiber", ("github.com/gofiber/fiber/v2",)),
- ),
- related=(RelatedSpec("grpc", "rpc-stack", ("google.golang.org/grpc",)),),
- configuration_patterns=(re.compile(r"(^|/)go\.(mod|sum)$", re.I),),
-)
-SYMFONY_PLUGIN = PackageFrameworkPlugin(
- "symfony-php",
- "php",
- (
- PackageFrameworkSpec("symfony", ("symfony/framework-bundle", "symfony/symfony")),
- PackageFrameworkSpec("twig", ("twig/twig", "symfony/twig-bundle"), "template-engine"),
- ),
- configuration_patterns=(
- *_COMPOSER_DECLARATIONS,
- *_COMPOSER_RESOLUTIONS,
- re.compile(r"(^|/)config/(bundles\.php|packages/|routes(?:\.|/|$))", re.I),
- re.compile(r"\.twig$", re.I),
- ),
-)
-REACT_PLUGIN = PackageFrameworkPlugin(
- "react-typescript",
- "javascript",
- (
- PackageFrameworkSpec("react", ("react",)),
- PackageFrameworkSpec("next", ("next",)),
- ),
- related=(
- RelatedSpec("typescript", "language-toolchain", ("typescript",)),
- RelatedSpec("vite", "build-tool", ("vite",)),
- ),
- configuration_patterns=(
- *_JAVASCRIPT_DECLARATIONS,
- *_JAVASCRIPT_RESOLUTIONS,
- re.compile(r"(^|/)tsconfig[^/]*\.json$", re.I),
- re.compile(r"(^|/)(vite|next)\.config\.[^.]+$", re.I),
- ),
-)
-
-BUILTIN_FRAMEWORK_PLUGINS: tuple[FrameworkPlugin, ...] = (
- JINJA2_PLUGIN,
- GO_WEB_PLUGIN,
- SYMFONY_PLUGIN,
- REACT_PLUGIN,
-)
-
-
-def collect_framework_plugins(
- context: FrameworkPluginContext,
-) -> tuple[tuple[PluginFact, ...], tuple[PluginCoverage, ...], tuple[str, ...]]:
- """Run every static plugin independently and return deterministic bounded output."""
-
- facts: list[PluginFact] = []
- coverage: list[PluginCoverage] = []
- notices: list[str] = []
- for plugin in BUILTIN_FRAMEWORK_PLUGINS:
- try:
- result = plugin.collect(context)
- # A package-owned provider is isolated so one defect cannot suppress siblings.
- except Exception:
- notices.append(f"framework plugin unavailable: {plugin.plugin_id}")
- continue
- ordered = sorted(result.facts, key=lambda item: (item.component, item.identity))
- remaining = max(0, MAX_PLUGIN_FACTS - len(facts))
- facts.extend(ordered[:remaining])
- coverage.extend(result.coverage)
- notices.extend(result.notices)
- omitted = ordered[remaining:]
- for fact in omitted:
- framework = fact.value.get("framework")
- if isinstance(framework, str):
- coverage.extend(
- (
- _coverage(
- component=fact.component,
- plugin=plugin.plugin_id,
- framework=framework,
- state=CoverageState.PARTIAL,
- reason="plugin-fact-limit",
- positive=True,
- ),
- _coverage(
- component=fact.component,
- plugin=plugin.plugin_id,
- framework=None,
- state=CoverageState.PARTIAL,
- reason="plugin-fact-limit",
- positive=True,
- ),
- )
- )
- if omitted:
- notices.append(f"framework plugin fact limit reached: {plugin.plugin_id}")
- return (
- tuple(sorted(facts, key=lambda item: (item.kind, item.component, item.identity))),
- tuple(sorted(coverage, key=lambda item: (item.component, item.domain, item.scope))),
- tuple(dict.fromkeys(notices)),
- )
-
-
-def framework_schema_versions() -> tuple[str, str]:
- """Expose the closed plugin schemas for storage validation."""
-
- return _FRAMEWORK_SCHEMA, _TEMPLATE_SCHEMA
-
-
-def _rendered_extension(path: str) -> str | None:
- """Return the target extension before a Jinja marker when one is present."""
-
- suffixes = PurePosixPath(path).suffixes
- if len(suffixes) >= 2 and suffixes[-1].casefold() in {".j2", ".jinja", ".jinja2"}:
- return suffixes[-2].casefold()
- return None
-
-
-def _ansible_template_component(path: str) -> str | None:
- """Return a role-root component for conventional nested Ansible templates."""
-
- parts = PurePosixPath(path).parts
- folded = tuple(part.casefold() for part in parts)
- for index, part in enumerate(folded):
- if part == "roles" and index + 3 <= len(parts) and folded[index + 2] == "templates":
- return "/".join(parts[: index + 2])
- return None
-
-
-def _template_description(
- path: str, context: FrameworkPluginContext
-) -> tuple[str, str, str, str] | None:
- """Return plugin, engine, detection, and nearest component for one template path."""
-
- folded = path.casefold()
- role_component = _ansible_template_component(path)
- if folded.endswith((".j2", ".jinja", ".jinja2")):
- roots = _components(context, "python")
- component = role_component or _owning_component(path, roots) or component_root(path)
- return "jinja2", "jinja2", "jinja-extension", component
- if role_component is not None:
- return "jinja2", "jinja2", "ansible-role-template", role_component
- if folded.endswith(".twig"):
- roots = _components(context, "php")
- component = _owning_component(path, roots) or component_root(path)
- return "symfony-php", "twig", "twig-extension", component
- return None
-
-
-def _applicable_template_components(
- records: tuple[EvidenceRecord, ...],
-) -> tuple[tuple[str, str], ...]:
- """Return components with direct Jinja or Twig template-engine declarations."""
-
- applicable: set[tuple[str, str]] = set()
- for record in records:
- if record.kind != "dependency.declared":
- continue
- package = _package(record)
- if package == "jinja2" and _is_direct(record, "python"):
- applicable.add((component_root(record.source_path), "jinja2"))
- elif package in {"twig/twig", "symfony/twig-bundle"} and _is_direct(record, "php"):
- applicable.add((component_root(record.source_path), "symfony-php"))
- return tuple(sorted(applicable))
-
-
-def collect_template_files(
- context: FrameworkPluginContext,
-) -> tuple[tuple[PluginFact, ...], tuple[PluginCoverage, ...], tuple[str, ...]]:
- """Inventory Jinja/Twig blobs without reading or persisting template content."""
-
- facts: list[PluginFact] = []
- observations: dict[tuple[str, str], list[CoverageObservation]] = {
- key: [CoverageObservation(CoverageState.COMPLETE, "bounded-tree-complete")]
- for key in _applicable_template_components(context.records)
- }
- truncated = False
- for entry in sorted(context.entries, key=lambda item: item.path):
- description = _template_description(entry.path, context)
- if description is None:
- continue
- plugin, engine, detection, component = description
- key = (component, plugin)
- if entry.object_type != "blob" or entry.is_symlink or entry.is_submodule:
- observations.setdefault(key, []).append(
- CoverageObservation(CoverageState.PARTIAL, "unsafe-template-source", positive=True)
- )
- continue
- if len(facts) >= MAX_PLUGIN_FACTS:
- truncated = True
- observations.setdefault(key, []).append(
- CoverageObservation(CoverageState.PARTIAL, "template-fact-limit", positive=True)
- )
- continue
- facts.append(
- PluginFact(
- "template.file",
- component,
- entry.path,
- entry.path,
- {
- "schema_version": _TEMPLATE_SCHEMA,
- "plugin": plugin,
- "engine": engine,
- "detection": detection,
- "rendered_extension": _rendered_extension(entry.path),
- "object_sha": entry.object_sha,
- },
- )
- )
- observations.setdefault(key, []).append(
- CoverageObservation(CoverageState.COMPLETE, "bounded-tree-complete", positive=True)
- )
- coverage = tuple(
- PluginCoverage(component, "template.inventory", plugin, observation)
- for (component, plugin), values in sorted(observations.items())
- for observation in values
- )
- notices = ("template plugin fact limit reached",) if truncated else ()
- return tuple(facts), coverage, notices
diff --git a/src/ocr_toolkit/evidence/frameworks/providers/__init__.py b/src/ocr_toolkit/evidence/frameworks/providers/__init__.py
new file mode 100644
index 0000000..1a4d916
--- /dev/null
+++ b/src/ocr_toolkit/evidence/frameworks/providers/__init__.py
@@ -0,0 +1,8 @@
+"""Package-owned built-in framework provider declarations."""
+
+from ocr_toolkit.evidence.frameworks.providers.frontend import REACT_PLUGIN
+from ocr_toolkit.evidence.frameworks.providers.go import GO_WEB_PLUGIN
+from ocr_toolkit.evidence.frameworks.providers.php import SYMFONY_PLUGIN
+from ocr_toolkit.evidence.frameworks.providers.python import JINJA2_PLUGIN
+
+__all__ = ["GO_WEB_PLUGIN", "JINJA2_PLUGIN", "REACT_PLUGIN", "SYMFONY_PLUGIN"]
diff --git a/src/ocr_toolkit/evidence/frameworks/providers/frontend.py b/src/ocr_toolkit/evidence/frameworks/providers/frontend.py
new file mode 100644
index 0000000..11bf603
--- /dev/null
+++ b/src/ocr_toolkit/evidence/frameworks/providers/frontend.py
@@ -0,0 +1,35 @@
+"""Package-owned React and TypeScript ecosystem provider declaration."""
+
+from __future__ import annotations
+
+import re
+
+from ocr_toolkit.evidence.frameworks.detection import (
+ PackageFrameworkPlugin,
+ PackageFrameworkSpec,
+ RelatedSpec,
+)
+
+_JAVASCRIPT_DECLARATIONS = (re.compile(r"(^|/)package\.json$", re.I),)
+_JAVASCRIPT_RESOLUTIONS = (
+ re.compile(r"(^|/)(package-lock\.json|yarn\.lock|pnpm-lock\.yaml)$", re.I),
+)
+
+REACT_PLUGIN = PackageFrameworkPlugin(
+ "react-typescript",
+ "javascript",
+ (
+ PackageFrameworkSpec("react", ("react",)),
+ PackageFrameworkSpec("next", ("next",)),
+ ),
+ related=(
+ RelatedSpec("typescript", "language-toolchain", ("typescript",)),
+ RelatedSpec("vite", "build-tool", ("vite",)),
+ ),
+ configuration_patterns=(
+ *_JAVASCRIPT_DECLARATIONS,
+ *_JAVASCRIPT_RESOLUTIONS,
+ re.compile(r"(^|/)tsconfig[^/]*\.json$", re.I),
+ re.compile(r"(^|/)(vite|next)\.config\.[^.]+$", re.I),
+ ),
+)
diff --git a/src/ocr_toolkit/evidence/frameworks/providers/go.py b/src/ocr_toolkit/evidence/frameworks/providers/go.py
new file mode 100644
index 0000000..10e6361
--- /dev/null
+++ b/src/ocr_toolkit/evidence/frameworks/providers/go.py
@@ -0,0 +1,22 @@
+"""Package-owned Go web framework provider declaration."""
+
+from __future__ import annotations
+
+import re
+
+from ocr_toolkit.evidence.frameworks.detection import (
+ PackageFrameworkPlugin,
+ PackageFrameworkSpec,
+ RelatedSpec,
+)
+
+GO_WEB_PLUGIN = PackageFrameworkPlugin(
+ "go-web",
+ "go",
+ (
+ PackageFrameworkSpec("echo", ("github.com/labstack/echo/v4",)),
+ PackageFrameworkSpec("fiber", ("github.com/gofiber/fiber/v2",)),
+ ),
+ related=(RelatedSpec("grpc", "rpc-stack", ("google.golang.org/grpc",)),),
+ configuration_patterns=(re.compile(r"(^|/)go\.(mod|sum)$", re.I),),
+)
diff --git a/src/ocr_toolkit/evidence/frameworks/providers/php.py b/src/ocr_toolkit/evidence/frameworks/providers/php.py
new file mode 100644
index 0000000..18566af
--- /dev/null
+++ b/src/ocr_toolkit/evidence/frameworks/providers/php.py
@@ -0,0 +1,25 @@
+"""Package-owned Symfony and Twig provider declaration."""
+
+from __future__ import annotations
+
+import re
+
+from ocr_toolkit.evidence.frameworks.detection import PackageFrameworkPlugin, PackageFrameworkSpec
+
+_COMPOSER_DECLARATIONS = (re.compile(r"(^|/)composer\.json$", re.I),)
+_COMPOSER_RESOLUTIONS = (re.compile(r"(^|/)composer\.lock$", re.I),)
+
+SYMFONY_PLUGIN = PackageFrameworkPlugin(
+ "symfony-php",
+ "php",
+ (
+ PackageFrameworkSpec("symfony", ("symfony/framework-bundle", "symfony/symfony")),
+ PackageFrameworkSpec("twig", ("twig/twig", "symfony/twig-bundle"), "template-engine"),
+ ),
+ configuration_patterns=(
+ *_COMPOSER_DECLARATIONS,
+ *_COMPOSER_RESOLUTIONS,
+ re.compile(r"(^|/)config/(bundles\.php|packages/|routes(?:\.|/|$))", re.I),
+ re.compile(r"\.twig$", re.I),
+ ),
+)
diff --git a/src/ocr_toolkit/evidence/frameworks/providers/python.py b/src/ocr_toolkit/evidence/frameworks/providers/python.py
new file mode 100644
index 0000000..5fa2cb7
--- /dev/null
+++ b/src/ocr_toolkit/evidence/frameworks/providers/python.py
@@ -0,0 +1,19 @@
+"""Package-owned Jinja2 provider declaration."""
+
+from __future__ import annotations
+
+import re
+
+from ocr_toolkit.evidence.frameworks.detection import PackageFrameworkPlugin, PackageFrameworkSpec
+
+_PYTHON_DECLARATIONS = (re.compile(r"(^|/)(pyproject\.toml|requirements[^/]*\.(txt|in))$", re.I),)
+_PYTHON_RESOLUTIONS = (
+ re.compile(r"(^|/)(uv\.lock|poetry\.lock|pipfile\.lock|pylock(?:\.[^/]+)?\.toml)$", re.I),
+)
+
+JINJA2_PLUGIN = PackageFrameworkPlugin(
+ "jinja2",
+ "python",
+ (PackageFrameworkSpec("jinja2", ("jinja2",), "template-engine"),),
+ configuration_patterns=(*_PYTHON_DECLARATIONS, *_PYTHON_RESOLUTIONS),
+)
diff --git a/src/ocr_toolkit/evidence/frameworks/registry.py b/src/ocr_toolkit/evidence/frameworks/registry.py
new file mode 100644
index 0000000..67fa256
--- /dev/null
+++ b/src/ocr_toolkit/evidence/frameworks/registry.py
@@ -0,0 +1,79 @@
+"""Static package-owned framework provider registry and failure isolation."""
+
+from __future__ import annotations
+
+from ocr_toolkit.evidence.frameworks.contracts import (
+ MAX_PLUGIN_FACTS,
+ FrameworkPlugin,
+ FrameworkPluginContext,
+ PluginCoverage,
+ PluginFact,
+)
+from ocr_toolkit.evidence.frameworks.detection import coverage_observation
+from ocr_toolkit.evidence.frameworks.providers import (
+ GO_WEB_PLUGIN,
+ JINJA2_PLUGIN,
+ REACT_PLUGIN,
+ SYMFONY_PLUGIN,
+)
+from ocr_toolkit.evidence.model import CoverageState
+
+BUILTIN_FRAMEWORK_PLUGINS: tuple[FrameworkPlugin, ...] = (
+ JINJA2_PLUGIN,
+ GO_WEB_PLUGIN,
+ SYMFONY_PLUGIN,
+ REACT_PLUGIN,
+)
+
+
+def collect_framework_plugins(
+ context: FrameworkPluginContext,
+) -> tuple[tuple[PluginFact, ...], tuple[PluginCoverage, ...], tuple[str, ...]]:
+ """Run every static plugin independently and return deterministic bounded output."""
+
+ facts: list[PluginFact] = []
+ coverage: list[PluginCoverage] = []
+ notices: list[str] = []
+ for plugin in BUILTIN_FRAMEWORK_PLUGINS:
+ try:
+ result = plugin.collect(context)
+ # A package-owned provider is isolated so one defect cannot suppress siblings.
+ except Exception:
+ notices.append(f"framework plugin unavailable: {plugin.plugin_id}")
+ continue
+ ordered = sorted(result.facts, key=lambda item: (item.component, item.identity))
+ remaining = max(0, MAX_PLUGIN_FACTS - len(facts))
+ facts.extend(ordered[:remaining])
+ coverage.extend(result.coverage)
+ notices.extend(result.notices)
+ omitted = ordered[remaining:]
+ for fact in omitted:
+ framework = fact.value.get("framework")
+ if isinstance(framework, str):
+ coverage.extend(
+ (
+ coverage_observation(
+ component=fact.component,
+ plugin=plugin.plugin_id,
+ framework=framework,
+ state=CoverageState.PARTIAL,
+ reason="plugin-fact-limit",
+ positive=True,
+ ),
+ coverage_observation(
+ component=fact.component,
+ plugin=plugin.plugin_id,
+ framework=None,
+ state=CoverageState.PARTIAL,
+ reason="plugin-fact-limit",
+ positive=True,
+ ),
+ )
+ )
+ if omitted:
+ notices.append(f"framework plugin fact limit reached: {plugin.plugin_id}")
+ return (
+ tuple(sorted(facts, key=lambda item: (item.kind, item.component, item.identity))),
+ tuple(sorted(coverage, key=lambda item: (item.component, item.domain, item.scope))),
+ tuple(dict.fromkeys(notices)),
+ )
diff --git a/src/ocr_toolkit/evidence/framework_schema.py b/src/ocr_toolkit/evidence/frameworks/schema.py
similarity index 99%
rename from src/ocr_toolkit/evidence/framework_schema.py
rename to src/ocr_toolkit/evidence/frameworks/schema.py
index 4415915..5f2442f 100644
--- a/src/ocr_toolkit/evidence/framework_schema.py
+++ b/src/ocr_toolkit/evidence/frameworks/schema.py
@@ -242,3 +242,5 @@ def validate_plugin_record(kind: str, value: EvidenceValue) -> None:
elif kind == "template.file":
validate_template_value(fact)
_path(identity, "template evidence identity")
+ else:
+ raise ValueError("plugin evidence kind is unsupported")
diff --git a/src/ocr_toolkit/evidence/frameworks/templates.py b/src/ocr_toolkit/evidence/frameworks/templates.py
new file mode 100644
index 0000000..32c86ca
--- /dev/null
+++ b/src/ocr_toolkit/evidence/frameworks/templates.py
@@ -0,0 +1,137 @@
+"""Bounded Jinja and Twig template inventory over immutable tree metadata."""
+
+from __future__ import annotations
+
+from pathlib import PurePosixPath
+
+from ocr_toolkit.evidence.coverage import CoverageObservation
+from ocr_toolkit.evidence.frameworks.contracts import (
+ MAX_PLUGIN_FACTS,
+ FrameworkPluginContext,
+ PluginCoverage,
+ PluginFact,
+)
+from ocr_toolkit.evidence.frameworks.detection import (
+ component_root,
+ is_direct_declaration,
+ owning_component,
+ package_name,
+ plugin_components,
+)
+from ocr_toolkit.evidence.frameworks.schema import TEMPLATE_SCHEMA
+from ocr_toolkit.evidence.model import CoverageState, EvidenceRecord
+
+
+def _rendered_extension(path: str) -> str | None:
+ """Return the target extension before a Jinja marker when one is present."""
+
+ suffixes = PurePosixPath(path).suffixes
+ if len(suffixes) >= 2 and suffixes[-1].casefold() in {".j2", ".jinja", ".jinja2"}:
+ return suffixes[-2].casefold()
+ return None
+
+
+def _ansible_template_component(path: str) -> str | None:
+ """Return a role-root component for conventional nested Ansible templates."""
+
+ parts = PurePosixPath(path).parts
+ folded = tuple(part.casefold() for part in parts)
+ for index, part in enumerate(folded):
+ if part == "roles" and index + 3 <= len(parts) and folded[index + 2] == "templates":
+ return "/".join(parts[: index + 2])
+ return None
+
+
+def _template_description(
+ path: str, context: FrameworkPluginContext
+) -> tuple[str, str, str, str] | None:
+ """Return plugin, engine, detection, and nearest component for one template path."""
+
+ folded = path.casefold()
+ role_component = _ansible_template_component(path)
+ if folded.endswith((".j2", ".jinja", ".jinja2")):
+ roots = plugin_components(context, "python")
+ component = role_component or owning_component(path, roots) or component_root(path)
+ return "jinja2", "jinja2", "jinja-extension", component
+ if role_component is not None:
+ return "jinja2", "jinja2", "ansible-role-template", role_component
+ if folded.endswith(".twig"):
+ roots = plugin_components(context, "php")
+ component = owning_component(path, roots) or component_root(path)
+ return "symfony-php", "twig", "twig-extension", component
+ return None
+
+
+def _applicable_template_components(
+ records: tuple[EvidenceRecord, ...],
+) -> tuple[tuple[str, str], ...]:
+ """Return components with direct Jinja or Twig template-engine declarations."""
+
+ applicable: set[tuple[str, str]] = set()
+ for record in records:
+ if record.kind != "dependency.declared":
+ continue
+ package = package_name(record)
+ if package == "jinja2" and is_direct_declaration(record, "python"):
+ applicable.add((component_root(record.source_path), "jinja2"))
+ elif package in {"twig/twig", "symfony/twig-bundle"} and is_direct_declaration(
+ record, "php"
+ ):
+ applicable.add((component_root(record.source_path), "symfony-php"))
+ return tuple(sorted(applicable))
+
+
+def collect_template_files(
+ context: FrameworkPluginContext,
+) -> tuple[tuple[PluginFact, ...], tuple[PluginCoverage, ...], tuple[str, ...]]:
+ """Inventory Jinja/Twig blobs without reading or persisting template content."""
+
+ facts: list[PluginFact] = []
+ observations: dict[tuple[str, str], list[CoverageObservation]] = {
+ key: [CoverageObservation(CoverageState.COMPLETE, "bounded-tree-complete")]
+ for key in _applicable_template_components(context.records)
+ }
+ truncated = False
+ for entry in sorted(context.entries, key=lambda item: item.path):
+ description = _template_description(entry.path, context)
+ if description is None:
+ continue
+ plugin, engine, detection, component = description
+ key = (component, plugin)
+ if entry.object_type != "blob" or entry.is_symlink or entry.is_submodule:
+ observations.setdefault(key, []).append(
+ CoverageObservation(CoverageState.PARTIAL, "unsafe-template-source", positive=True)
+ )
+ continue
+ if len(facts) >= MAX_PLUGIN_FACTS:
+ truncated = True
+ observations.setdefault(key, []).append(
+ CoverageObservation(CoverageState.PARTIAL, "template-fact-limit", positive=True)
+ )
+ continue
+ facts.append(
+ PluginFact(
+ "template.file",
+ component,
+ entry.path,
+ entry.path,
+ {
+ "schema_version": TEMPLATE_SCHEMA,
+ "plugin": plugin,
+ "engine": engine,
+ "detection": detection,
+ "rendered_extension": _rendered_extension(entry.path),
+ "object_sha": entry.object_sha,
+ },
+ )
+ )
+ observations.setdefault(key, []).append(
+ CoverageObservation(CoverageState.COMPLETE, "bounded-tree-complete", positive=True)
+ )
+ coverage = tuple(
+ PluginCoverage(component, "template.inventory", plugin, observation)
+ for (component, plugin), values in sorted(observations.items())
+ for observation in values
+ )
+ notices = ("template plugin fact limit reached",) if truncated else ()
+ return tuple(facts), coverage, notices
diff --git a/src/ocr_toolkit/evidence/store.py b/src/ocr_toolkit/evidence/store.py
index 60a2800..b2245e6 100644
--- a/src/ocr_toolkit/evidence/store.py
+++ b/src/ocr_toolkit/evidence/store.py
@@ -18,7 +18,7 @@
redact_env_secret_values,
redact_sensitive,
)
-from ocr_toolkit.evidence.framework_schema import validate_plugin_record
+from ocr_toolkit.evidence.frameworks.schema import validate_plugin_record
from ocr_toolkit.evidence.model import (
CoverageRecord,
EvidenceDelta,
@@ -165,11 +165,11 @@ def add(self, record: EvidenceRecord) -> bool:
if record.kind in {"framework.detected", "template.file"}:
validate_plugin_record(record.kind, record.value)
redacted_value = _safe_value(record.value, self.limits.max_value_chars)
- except ValueError as exc:
- raise EvidenceStoreError(f"invalid {record.kind} evidence value") from exc
except EvidenceStoreError:
self._diagnose_once(f"omitted oversized {record.kind} evidence value")
return False
+ except ValueError as exc:
+ raise EvidenceStoreError(f"invalid {record.kind} evidence value") from exc
redacted = EvidenceRecord(
kind=record.kind,
value=redacted_value,
diff --git a/tests/test_evidence_collectors.py b/tests/test_evidence_collectors.py
index 19c5fe7..7ed63ba 100644
--- a/tests/test_evidence_collectors.py
+++ b/tests/test_evidence_collectors.py
@@ -388,6 +388,84 @@ def test_python_requirements_includes_are_recursive_bounded_and_safe(tmp_path: P
)
+def test_python_requirements_include_limit_degrades_framework_completeness(
+ tmp_path: Path,
+) -> None:
+ """Bind a truncated Python include graph to its owning declaration source."""
+
+ _git(tmp_path, "init", "-q")
+ _git(tmp_path, "config", "user.email", "agent@example.invalid")
+ _git(tmp_path, "config", "user.name", "Synthetic Agent")
+ include_count = MAX_MANIFEST_INCLUDE_FILES + 1
+ (tmp_path / "requirements.txt").write_text(
+ "jinja2==3.1.6\n"
+ + "".join(f"-r requirements/item-{index}.txt\n" for index in range(include_count)),
+ encoding="utf-8",
+ )
+ requirements = tmp_path / "requirements"
+ requirements.mkdir()
+ for index in range(include_count):
+ (requirements / f"item-{index}.txt").write_text(
+ f"synthetic-package-{index}==1.0\n", encoding="utf-8"
+ )
+ _git(tmp_path, "add", "requirements.txt", "requirements")
+ _git(tmp_path, "commit", "-qm", "wide Python requirements")
+ head = _git(tmp_path, "rev-parse", "HEAD")
+ coverage = []
+
+ _records, diagnostics = collect_ref_facts(
+ GitRepositoryReader(tmp_path),
+ head,
+ RefRole.HEAD,
+ coverage_sink=coverage,
+ )
+
+ declaration = next(
+ item
+ for item in coverage
+ if item.component == "repository"
+ and item.domain == "framework.declaration"
+ and item.scope == "jinja2"
+ )
+ assert declaration.state.value == "partial"
+ assert declaration.reasons == ("include-graph-truncation",)
+ assert sum("Python requirements includes were truncated" in item for item in diagnostics) == 1
+
+
+def test_python_requirements_omitted_include_degrades_root_completeness(
+ tmp_path: Path,
+) -> None:
+ """Propagate a bounded included-blob omission to its owning Python root."""
+
+ _git(tmp_path, "init", "-q")
+ _git(tmp_path, "config", "user.email", "agent@example.invalid")
+ _git(tmp_path, "config", "user.name", "Synthetic Agent")
+ (tmp_path / "requirements.txt").write_text("jinja2==3.1.6\n-r nested.txt\n", encoding="utf-8")
+ (tmp_path / "nested.txt").write_text("x" * 64, encoding="utf-8")
+ _git(tmp_path, "add", "requirements.txt", "nested.txt")
+ _git(tmp_path, "commit", "-qm", "oversized Python include")
+ head = _git(tmp_path, "rev-parse", "HEAD")
+ coverage = []
+
+ _records, diagnostics = collect_ref_facts(
+ GitRepositoryReader(tmp_path, max_file_bytes=32),
+ head,
+ RefRole.HEAD,
+ coverage_sink=coverage,
+ )
+
+ declaration = next(
+ item
+ for item in coverage
+ if item.component == "repository"
+ and item.domain == "framework.declaration"
+ and item.scope == "jinja2"
+ )
+ assert declaration.state.value == "partial"
+ assert declaration.reasons == ("bounded-source-omission",)
+ assert diagnostics == ["head:omitted nested.txt: blob exceeds 32 bytes"]
+
+
def test_python_requirements_refuse_symlink_and_submodule_includes(tmp_path: Path) -> None:
"""Never dereference include targets that are not regular immutable blobs."""
@@ -980,6 +1058,87 @@ def test_ansible_requirement_include_file_limit_is_reported_once(tmp_path: Path)
]
+def test_ansible_include_limit_marks_only_its_root_source_partial(
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ """Expose graph truncation as structured status on the affected Galaxy root."""
+
+ _git(tmp_path, "init", "-q")
+ _git(tmp_path, "config", "user.email", "agent@example.invalid")
+ _git(tmp_path, "config", "user.name", "Synthetic Agent")
+ include_count = MAX_MANIFEST_INCLUDE_FILES + 1
+ (tmp_path / "requirements.yml").write_text(
+ "".join(f"- include: requirements/item-{index}.yml\n" for index in range(include_count)),
+ encoding="utf-8",
+ )
+ (tmp_path / "services").mkdir()
+ (tmp_path / "services/requirements.yml").write_text(
+ "- name: synthetic.unrelated\n", encoding="utf-8"
+ )
+ requirements = tmp_path / "requirements"
+ requirements.mkdir()
+ for index in range(include_count):
+ (requirements / f"item-{index}.yml").write_text(
+ f"- name: synthetic.role_{index}\n", encoding="utf-8"
+ )
+ _git(tmp_path, "add", "requirements.yml", "requirements", "services/requirements.yml")
+ _git(tmp_path, "commit", "-qm", "wide Galaxy graph")
+ head = _git(tmp_path, "rev-parse", "HEAD")
+ captured = []
+
+ def capture(context: object) -> tuple[tuple[()], tuple[()], tuple[()]]:
+ captured.append(context)
+ return (), (), ()
+
+ monkeypatch.setattr("ocr_toolkit.evidence.collectors.collect_framework_plugins", capture)
+
+ collect_ref_facts(GitRepositoryReader(tmp_path), head, RefRole.HEAD)
+
+ assert len(captured) == 1
+ statuses = {item.path: item for item in captured[0].source_statuses}
+ assert statuses["requirements.yml"].state == "partial"
+ assert statuses["requirements.yml"].reason == "include-graph-truncation"
+ assert statuses["services/requirements.yml"].state == "complete"
+ assert statuses["services/requirements.yml"].reason is None
+
+
+def test_ansible_omitted_include_marks_only_its_owning_root_partial(
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ """Propagate one bounded Galaxy include omission without degrading siblings."""
+
+ _git(tmp_path, "init", "-q")
+ _git(tmp_path, "config", "user.email", "agent@example.invalid")
+ _git(tmp_path, "config", "user.name", "Synthetic Agent")
+ (tmp_path / "requirements.yml").write_text("- include: nested.yml\n", encoding="utf-8")
+ (tmp_path / "nested.yml").write_text("#" + "x" * 64, encoding="utf-8")
+ (tmp_path / "services").mkdir()
+ (tmp_path / "services/requirements.yml").write_text(
+ "- name: synthetic.unrelated\n", encoding="utf-8"
+ )
+ _git(tmp_path, "add", "requirements.yml", "nested.yml", "services/requirements.yml")
+ _git(tmp_path, "commit", "-qm", "oversized Galaxy include")
+ head = _git(tmp_path, "rev-parse", "HEAD")
+ captured = []
+
+ def capture(context: object) -> tuple[tuple[()], tuple[()], tuple[()]]:
+ captured.append(context)
+ return (), (), ()
+
+ monkeypatch.setattr("ocr_toolkit.evidence.collectors.collect_framework_plugins", capture)
+
+ _records, diagnostics = collect_ref_facts(
+ GitRepositoryReader(tmp_path, max_file_bytes=32), head, RefRole.HEAD
+ )
+
+ statuses = {item.path: item for item in captured[0].source_statuses}
+ assert statuses["requirements.yml"].state == "partial"
+ assert statuses["requirements.yml"].reason == "bounded-source-omission"
+ assert statuses["services/requirements.yml"].state == "complete"
+ assert statuses["services/requirements.yml"].reason is None
+ assert diagnostics == ["head:omitted nested.yml: blob exceeds 32 bytes"]
+
+
def test_ansible_requirement_include_depth_is_reported_once(tmp_path: Path) -> None:
"""Keep a deep include chain bounded while retaining its admitted facts."""
diff --git a/tests/test_evidence_framework_plugins.py b/tests/test_evidence_framework_plugins.py
index 5baf733..bac60bb 100644
--- a/tests/test_evidence_framework_plugins.py
+++ b/tests/test_evidence_framework_plugins.py
@@ -2,15 +2,17 @@
from __future__ import annotations
+import ast
import json
import subprocess
+from dataclasses import FrozenInstanceError, fields
from pathlib import Path
from typing import Any, cast
import pytest
from ocr_toolkit.evidence.collect import collect_repository_evidence
-from ocr_toolkit.evidence.framework_plugins import (
+from ocr_toolkit.evidence.frameworks import (
BUILTIN_FRAMEWORK_PLUGINS,
MAX_CONFIGURATION_PATHS,
MAX_PLUGIN_FACTS,
@@ -18,6 +20,13 @@
FrameworkPluginResult,
collect_framework_plugins,
)
+from ocr_toolkit.evidence.frameworks.providers import (
+ GO_WEB_PLUGIN,
+ JINJA2_PLUGIN,
+ REACT_PLUGIN,
+ SYMFONY_PLUGIN,
+)
+from ocr_toolkit.evidence.manifest_model import MAX_MANIFEST_ITEMS
from ocr_toolkit.evidence.mcp import call_tool
from ocr_toolkit.evidence.model import Confidence, EvidenceRecord, RefRole, TrustClass
from ocr_toolkit.evidence.project import render_bootstrap
@@ -65,6 +74,42 @@ def initialize(root: Path) -> None:
git(root, "config", "user.email", "synthetic@example.invalid")
+def test_framework_package_keeps_one_static_immutable_plugin_boundary() -> None:
+ """Lock provider order, immutable input, and the package's no-I/O boundary."""
+
+ assert BUILTIN_FRAMEWORK_PLUGINS == (
+ JINJA2_PLUGIN,
+ GO_WEB_PLUGIN,
+ SYMFONY_PLUGIN,
+ REACT_PLUGIN,
+ )
+ assert tuple(field.name for field in fields(FrameworkPluginContext)) == (
+ "records",
+ "entries",
+ "source_statuses",
+ "ref",
+ "commit_sha",
+ )
+ context = FrameworkPluginContext((), (), (), RefRole.HEAD, "a" * 40)
+ with pytest.raises(FrozenInstanceError):
+ context.commit_sha = "b" * 40 # type: ignore[misc]
+
+ package_root = Path(__file__).parents[1] / "src/ocr_toolkit/evidence/frameworks"
+ forbidden_imports = {"http", "importlib", "os", "requests", "socket", "subprocess", "urllib"}
+ forbidden_calls = {"__import__", "eval", "exec", "open"}
+ for source_path in sorted(package_root.rglob("*.py")):
+ tree = ast.parse(source_path.read_text(encoding="utf-8"), filename=str(source_path))
+ for node in ast.walk(tree):
+ if isinstance(node, ast.Import):
+ assert not {alias.name.split(".", 1)[0] for alias in node.names} & forbidden_imports
+ elif isinstance(node, ast.ImportFrom) and node.module:
+ assert node.module.split(".", 1)[0] not in forbidden_imports
+ if node.module == "pathlib":
+ assert {alias.name for alias in node.names} == {"PurePosixPath"}
+ elif isinstance(node, ast.Call) and isinstance(node.func, ast.Name):
+ assert node.func.id not in forbidden_calls
+
+
def test_jinja_framework_and_templates_are_component_scoped_and_visible_in_mcp(
tmp_path: Path,
) -> None:
@@ -621,6 +666,78 @@ def test_go_versions_and_replacements_use_effective_go_mod_semantics(tmp_path: P
assert resolution.reasons == ("local-replacement",)
+def test_version_scoped_go_replacement_applies_only_to_its_declared_version(
+ tmp_path: Path,
+) -> None:
+ """Ignore a Go replacement scoped to a version that is not required."""
+
+ initialize(tmp_path)
+ (tmp_path / "go.mod").write_text(
+ """module synthetic.invalid/api
+
+go 1.24
+
+require github.com/labstack/echo/v4 v4.13.4
+replace github.com/labstack/echo/v4 v4.12.0 => ./legacy-echo
+""",
+ encoding="utf-8",
+ )
+ base = commit(tmp_path, "version-scoped replacement")
+ (tmp_path / "README.md").write_text("head\n", encoding="utf-8")
+ head = commit(tmp_path, "head")
+
+ store = collect_repository_evidence(tmp_path, base_ref=base, head_ref=head)
+ echo = next(
+ record for record in framework_records(store) if record.value["fact"]["framework"] == "echo"
+ )
+
+ assert echo.value["fact"]["version_state"] == "resolved"
+ assert echo.value["fact"]["replacement"] is None
+ assert echo.value["fact"]["resolutions"] == (
+ {
+ "package": "github.com/labstack/echo/v4",
+ "version": "v4.13.4",
+ "source": "go.mod",
+ "source_path": "go.mod",
+ },
+ )
+
+
+def test_exact_go_replacement_wins_over_package_wide_replacement(
+ tmp_path: Path,
+) -> None:
+ """Prefer the exact source-version replacement regardless of declaration order."""
+
+ initialize(tmp_path)
+ (tmp_path / "go.mod").write_text(
+ """module synthetic.invalid/api
+
+go 1.24
+
+require github.com/labstack/echo/v4 v4.13.4
+replace (
+ github.com/labstack/echo/v4 => synthetic.invalid/echo/v4 v4.13.5
+ github.com/labstack/echo/v4 v4.13.4 => synthetic.invalid/echo/v4 v4.13.6
+)
+""",
+ encoding="utf-8",
+ )
+ base = commit(tmp_path, "ordered replacements")
+ (tmp_path / "README.md").write_text("head\n", encoding="utf-8")
+ head = commit(tmp_path, "head")
+
+ store = collect_repository_evidence(tmp_path, base_ref=base, head_ref=head)
+ echo = next(
+ record for record in framework_records(store) if record.value["fact"]["framework"] == "echo"
+ )
+
+ assert echo.value["fact"]["replacement"] == {
+ "target": "synthetic.invalid/echo/v4",
+ "type": "module",
+ "version": "v4.13.6",
+ }
+
+
def test_template_components_follow_nearest_manifest_root_and_configuration_isolated(
tmp_path: Path,
) -> None:
@@ -740,7 +857,7 @@ def collect(self, context: FrameworkPluginContext) -> FrameworkPluginResult:
trust=TrustClass.SOURCE_REPOSITORY,
)
monkeypatch.setattr(
- "ocr_toolkit.evidence.framework_plugins.BUILTIN_FRAMEWORK_PLUGINS",
+ "ocr_toolkit.evidence.frameworks.registry.BUILTIN_FRAMEWORK_PLUGINS",
(BrokenPlugin(), BUILTIN_FRAMEWORK_PLUGINS[0]),
)
facts, _coverage, notices = collect_framework_plugins(
@@ -786,7 +903,7 @@ def test_malformed_and_truncated_manifests_degrade_applicable_coverage(tmp_path:
initialize(tmp_path)
(tmp_path / "pyproject.toml").write_text("not = [valid\n", encoding="utf-8")
- packages = {f"synthetic-{index}": "1.0.0" for index in range(512)}
+ packages = {f"aaa-synthetic-{index:04}": "1.0.0" for index in range(MAX_MANIFEST_ITEMS)}
packages["react"] = "19.0.0"
(tmp_path / "package.json").write_text(json.dumps({"dependencies": packages}), encoding="utf-8")
base = commit(tmp_path, "sources")
diff --git a/tests/test_evidence_model.py b/tests/test_evidence_model.py
index 8a51151..c0115f2 100644
--- a/tests/test_evidence_model.py
+++ b/tests/test_evidence_model.py
@@ -21,6 +21,7 @@
TrustClass,
)
from ocr_toolkit.evidence.coverage import CoverageObservation, compose_coverage
+from ocr_toolkit.evidence.frameworks.schema import validate_plugin_record
BASE_SHA = "a" * 40
HEAD_SHA = "b" * 40
@@ -312,6 +313,26 @@ def test_store_deduplicates_and_reports_deterministic_limits() -> None:
]
+def test_plugin_schema_rejects_unknown_record_kinds() -> None:
+ """Keep the plugin validator closed when called outside the store registry."""
+
+ with pytest.raises(ValueError, match="unsupported"):
+ validate_plugin_record(
+ "synthetic.plugin",
+ {"identity": "synthetic", "fact": {}},
+ )
+
+
+def test_store_omits_an_oversized_ordinary_record_without_aborting() -> None:
+ """Treat the store value budget as bounded omission rather than invalid input."""
+
+ store = EvidenceStore(EvidenceStoreLimits(max_value_chars=8))
+
+ assert not store.add(record("x" * 9))
+ assert store.records == ()
+ assert store.diagnostics == ["omitted oversized dependency.declared evidence value"]
+
+
def test_store_rejects_unknown_kinds_and_schema_versions(tmp_path: Path) -> None:
"""Fail closed for unregistered kinds and incompatible envelopes."""
diff --git a/tests/test_release_receipt.py b/tests/test_release_receipt.py
index fd1087a..8a403b1 100644
--- a/tests/test_release_receipt.py
+++ b/tests/test_release_receipt.py
@@ -4,6 +4,8 @@
import importlib.util
import json
+import os
+import sys
from pathlib import Path
from types import ModuleType
from typing import Any
@@ -404,6 +406,41 @@ def test_numeric_release_identity_requires_exact_metadata_and_unique_assets() ->
)
+def test_issue_receipt_json_read_is_bound_to_one_descriptor(
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ """Prevent a post-validation pathname swap from changing issue evidence."""
+
+ evidence = tmp_path / "issue.json"
+ replacement = tmp_path / "replacement.json"
+ original = {"number": 76, "state": "open", "state_reason": None}
+ changed = {"number": 99, "state": "closed", "state_reason": "completed"}
+ evidence.write_text(json.dumps(original), encoding="utf-8")
+ replacement.write_text(json.dumps(changed), encoding="utf-8")
+ real_fstat = os.fstat
+ swapped = False
+
+ def swap_path() -> None:
+ nonlocal swapped
+ if not swapped:
+ os.replace(replacement, evidence)
+ swapped = True
+
+ def racing_fstat(descriptor: int) -> os.stat_result:
+ metadata = real_fstat(descriptor)
+ swap_path()
+ return metadata
+
+ monkeypatch.setattr(issue_receipt.os, "fstat", racing_fstat)
+ monkeypatch.setattr(
+ Path,
+ "read_bytes",
+ lambda *_args, **_kwargs: pytest.fail("issue evidence reopened by pathname"),
+ )
+
+ assert issue_receipt.load_json(evidence, max_bytes=1024) == original
+
+
def test_issue_receipt_cli_writes_the_canonical_terminal_newline(tmp_path: Path) -> None:
issue = tmp_path / "issue.json"
comments = tmp_path / "comments.json"
@@ -414,7 +451,7 @@ def test_issue_receipt_cli_writes_the_canonical_terminal_newline(tmp_path: Path)
completed = subprocess.run(
[
- "python3",
+ sys.executable,
str(ISSUE_RECEIPT_SCRIPT),
"--issue-json",
str(issue),
@@ -436,3 +473,148 @@ def test_issue_receipt_cli_writes_the_canonical_terminal_newline(tmp_path: Path)
assert completed.returncode == 0, completed.stderr
assert output.read_text() == issue_receipt.receipt_body("0.5.0", 76, "a" * 64)
assert output.read_bytes().endswith(b".\n")
+
+
+def test_release_notes_are_read_from_one_open_descriptor(
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ """Keep validated release-note bytes bound to the descriptor opened first."""
+
+ notes = tmp_path / "notes.md"
+ replacement = tmp_path / "replacement.md"
+ original = b"synthetic release notes\n"
+ changed = b"substituted release notes\n"
+ notes.write_bytes(original)
+ replacement.write_bytes(changed)
+ real_fstat = os.fstat
+ swapped = False
+
+ def swap_path() -> None:
+ nonlocal swapped
+ if not swapped:
+ os.replace(replacement, notes)
+ swapped = True
+
+ def racing_fstat(descriptor: int) -> os.stat_result:
+ metadata = real_fstat(descriptor)
+ swap_path()
+ return metadata
+
+ monkeypatch.setattr(github_release.os, "fstat", racing_fstat)
+ monkeypatch.setattr(
+ Path,
+ "read_text",
+ lambda *_args, **_kwargs: pytest.fail("release notes reopened by pathname"),
+ )
+
+ assert github_release._metadata(notes) == original.decode("utf-8")
+
+
+def test_release_notes_reject_growth_during_the_descriptor_read(
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ """Reject notes whose byte identity changes after descriptor validation."""
+
+ notes = tmp_path / "notes.md"
+ notes.write_bytes(b"bounded notes\n")
+ real_fstat = os.fstat
+ grown = False
+
+ def grow_file() -> None:
+ nonlocal grown
+ if not grown:
+ with notes.open("ab") as handle:
+ handle.write(b"late bytes\n")
+ grown = True
+
+ def racing_fstat(descriptor: int) -> os.stat_result:
+ metadata = real_fstat(descriptor)
+ grow_file()
+ return metadata
+
+ monkeypatch.setattr(github_release.os, "fstat", racing_fstat)
+ monkeypatch.setattr(
+ Path,
+ "read_text",
+ lambda *_args, **_kwargs: pytest.fail("release notes reopened by pathname"),
+ )
+
+ with pytest.raises(github_release.GitHubReleaseError, match="changed while being read"):
+ github_release._metadata(notes)
+
+
+def test_release_notes_reject_symbolic_links(tmp_path: Path) -> None:
+ """Do not follow a release-note pathname outside its validated file identity."""
+
+ target = tmp_path / "actual-notes.md"
+ target.write_text("synthetic notes\n", encoding="utf-8")
+ link = tmp_path / "notes.md"
+ link.symlink_to(target.name)
+
+ with pytest.raises(github_release.GitHubReleaseError, match="unsafe"):
+ github_release._metadata(link)
+
+
+def test_release_asset_upload_uses_bytes_from_the_validated_descriptor(
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ """Prevent a pathname swap from changing bytes sent to the upload endpoint."""
+
+ asset = tmp_path / "package.whl"
+ replacement = tmp_path / "replacement.whl"
+ original = b"original-asset"
+ changed = b"replaced-asset"
+ assert len(original) == len(changed)
+ asset.write_bytes(original)
+ replacement.write_bytes(changed)
+ release = {
+ "id": 91,
+ "tag_name": "v0.5.0",
+ "target_commitish": "a" * 40,
+ "name": "v0.5.0",
+ "body": "notes\n",
+ "draft": True,
+ "prerelease": False,
+ "assets": [],
+ }
+ monkeypatch.setattr(github_release, "_read_release", lambda **_kwargs: release)
+ real_fstat = os.fstat
+ swapped = False
+ uploaded_body = b""
+
+ def swap_path() -> None:
+ nonlocal swapped
+ if not swapped:
+ os.replace(replacement, asset)
+ swapped = True
+
+ def racing_fstat(descriptor: int) -> os.stat_result:
+ metadata = real_fstat(descriptor)
+ swap_path()
+ return metadata
+
+ def fake_request(**kwargs: Any) -> tuple[int, dict[str, Any]]:
+ nonlocal uploaded_body
+ uploaded_body = kwargs["body"]
+ return 201, {"id": 7, "name": asset.name, "size": len(original)}
+
+ monkeypatch.setattr(github_release.os, "fstat", racing_fstat)
+ monkeypatch.setattr(
+ Path,
+ "read_bytes",
+ lambda *_args, **_kwargs: pytest.fail("release asset reopened by pathname"),
+ )
+ monkeypatch.setattr(github_release, "_request", fake_request)
+
+ github_release.upload_asset(
+ repository="synthetic/toolkit",
+ release_id=91,
+ tag="v0.5.0",
+ target="a" * 40,
+ title="v0.5.0",
+ notes="notes\n",
+ asset=asset,
+ token="synthetic-token",
+ )
+
+ assert uploaded_body == original
From b35bb286938d923a29e8c51d87b152e6595e6825 Mon Sep 17 00:00:00 2001
From: xeonvs <11463419+xeonvs@users.noreply.github.com>
Date: Wed, 12 Aug 2026 14:42:35 +0200
Subject: [PATCH 10/11] Remediate additional M2 review findings
---
PLANS.md | 64 +++++-
changelog.d/77.feature.md | 2 +-
docs/configuration.md | 2 +-
docs/development.md | 2 +
docs/engineering/toolkit_strategy.md | 4 +-
scripts/github_release_api.py | 20 +-
src/ocr_toolkit/evidence/collectors.py | 17 ++
.../evidence/frameworks/__init__.py | 4 +
.../evidence/frameworks/contracts.py | 2 +
.../evidence/frameworks/detection.py | 8 +-
.../evidence/frameworks/registry.py | 150 ++++++++++---
src/ocr_toolkit/evidence/frameworks/schema.py | 45 +++-
.../evidence/frameworks/templates.py | 13 +-
src/ocr_toolkit/evidence/store.py | 4 +-
tests/test_evidence_collectors.py | 45 +++-
tests/test_evidence_framework_plugins.py | 208 +++++++++++++++++-
tests/test_evidence_model.py | 35 +++
tests/test_release_receipt.py | 60 +++++
18 files changed, 614 insertions(+), 71 deletions(-)
diff --git a/PLANS.md b/PLANS.md
index c18c41e..51219e8 100644
--- a/PLANS.md
+++ b/PLANS.md
@@ -77,6 +77,23 @@ validation, before protected feature and stable delivery.
without compatibility shims. Do not create competing `frameworks/` and
`plugins/` trees, move core Git/tree/manifest collection or MCP lifecycle into
the package, or change evidence schemas and public behavior in that slice.
+- M2 component scoping may make a clean 0.5.0 schema-semantic change without
+ preserving branch-only legacy behavior. Use `.` as the canonical repository-root
+ component and treat `repository` as an ordinary real top-level directory; update
+ facts, coverage, deltas, MCP filters, tests, and durable documentation atomically.
+ Raise any affected closed schema version when its serialized meaning changes, and
+ do not add aliases, projections, or compatibility shims.
+- After remediating the additional OCR findings, make normalized source parsers
+ one separate behavior-preserving structural slice under
+ `ocr_toolkit.evidence.ecosystems`. Keep common manifest contracts plus Python,
+ JavaScript, Go, and PHP adapters there; place Ansible Galaxy requirements and
+ topology/inventory analysis under `ecosystems.ansible`. This package remains
+ below `frameworks`: it parses bounded immutable blobs into normalized facts
+ and source coverage, while framework plugins derive higher-level evidence.
+ Keep Git/tree orchestration in `collectors.py`, cross-ecosystem container/CI
+ extraction in `infrastructure.py`, and store/MCP lifecycle outside both
+ packages. Remove old flat parser modules without compatibility shims and make
+ no schema or behavior change in the structural slice.
### Work Queue
@@ -107,11 +124,11 @@ validation, before protected feature and stable delivery.
11. [x] Finish deterministic remediation of the first full OCR review, complete
and prove the behavior-preserving framework package reorganization, then run
the complete deterministic package/E2E/privacy validation without OCR.
-12. [ ] Run the owner-authorized additional full local OCR review with concurrency
- 2, real built-in evidence-MCP use, private artifacts, and no posting. Fix its
- actionable findings, audit sibling boundaries, repeat deterministic validation
- without another OCR run, then finish Gitleaks, self-review, feature PR
- checks/threads, merge, and TestPyPI development readback.
+12. [ ] Remediate the completed owner-authorized additional OCR review, audit
+ sibling boundaries, and complete the separate `evidence.ecosystems` source-
+ adapter reorganization. Repeat deterministic validation without another OCR
+ run, then finish Gitleaks, self-review, feature PR checks/threads, merge, and
+ TestPyPI development readback.
13. [ ] Prepare the final release PR as the last repository mutation, archive
completed 0.4.7 history, consume fragments 76, 77, and 78, and reconcile M2
to implemented truth while leaving external publication pending.
@@ -273,6 +290,43 @@ validation, before protected feature and stable delivery.
authorized OCR action is the additional concurrency-2 review only after the
current remediation and complete deterministic validation finish.
+### Additional OCR Review Remediation Checkpoint
+
+- The owner-authorized additional full OCR review completed successfully over
+ exact range `3caa50b4fc5026da79c7f2ceae1deef31715f814..4fe85549d66acd9fba57fb2ad39cf173b4d91053`
+ with checksum-qualified OCR 1.9.2, configured concurrency 2, the public rules
+ pack, and exact-HEAD installed wheel version `0.5.0.dev0+g4fe85549`.
+- All selected items completed with no failed or waived coverage. The result has
+ terminal state `complete`, contains eight findings, and records 113 mandatory
+ `ocr_toolkit_evidence` calls; the toolkit receipt matches the OCR counter.
+ Evidence base/head refs and the result manifest both match the requested exact
+ range. No further OCR rerun is authorized.
+- The review used an isolated owner-only HOME, only the built-in evidence MCP,
+ and an environment with GitLab token variables removed. No posting command was
+ invoked. Result, stderr, bootstrap, store, config, and receipts are ignored,
+ owner-only private artifacts; private-marker and posting scans are clean, and
+ the tracked worktree remained unchanged.
+- All review findings now have focused regressions that first failed at the
+ reported boundary and pass after deterministic correction. Release creation
+ validates protected identity before discovery or mutation and checks the first
+ page outside its bounded scan. Arbitrary Python requirement includes receive
+ exact source status. Framework scoping uses `.` for the root and ordinary paths
+ for every named directory, including MCP fact/delta filters. Provider output is
+ bounded and admitted atomically; template-limit coverage is emitted once per
+ scope; manifest scalars use field-specific bounds; persisted plugin schemas are
+ validated after redaction and total-value bounding.
+- Sibling audits covered other bounded GitHub pagination, all framework component
+ consumers, provider facts/coverage/notices, path versus manifest-scalar limits,
+ and every store reload path. The focused release/evidence suites pass. The root
+ semantic is a clean unreleased 0.5.0 contract change, not a compatibility shim;
+ nested fact schema versions remain unchanged because component lives in the
+ common evidence envelope and its closed shape did not change.
+- After this remediation checkpoint, complete the separately bounded
+ `evidence.ecosystems` structural slice described in Decisions. It is not part
+ of `frameworks`: manifest and Ansible source adapters feed normalized evidence
+ into the higher framework layer. Deterministic validation follows both slices
+ and OCR will not be run again.
+
### Pre-additional-review Deterministic Validation Checkpoint
- Signed local checkpoint `1f64c8a112c54df289649fcbc0d06ef9f91dbe84`
diff --git a/changelog.d/77.feature.md b/changelog.d/77.feature.md
index 425f6dd..33c6d8a 100644
--- a/changelog.d/77.feature.md
+++ b/changelog.d/77.feature.md
@@ -1 +1 @@
-Add bounded framework and template evidence plugins for Jinja2, Go web frameworks, Symfony/PHP, and React/TypeScript, including applicability-aware Go replacements, include-graph completeness, scoped coverage, and first-class redacted delta queries through the built-in evidence MCP.
+Add bounded framework and template evidence plugins for Jinja2, Go web frameworks, Symfony/PHP, and React/TypeScript, including unambiguous root components, applicability-aware Go replacements, include-graph completeness, fail-closed provider isolation, scoped coverage, and first-class redacted delta queries through the built-in evidence MCP.
diff --git a/docs/configuration.md b/docs/configuration.md
index 6eac150..d2069ec 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -84,7 +84,7 @@ The private `.review-context/evidence.json` store and `.review-context/bootstrap
The compact bootstrap contains the same safe inventory of independent server/tool entries that was written to OCR configuration. The mandatory built-in server exposes `ocr_toolkit_evidence`, with `summary`, paginated/filterable `list`, and stable-ID `get` actions. An explicit `kind=repository.evidence_delta` list query returns redacted base/head changes; `delta_kind` narrows them by their original fact kind, and their stable IDs can be passed to `get`. The ordinary unfiltered list remains facts and scoped coverage only. It has no mutation action, network access, or shell execution. Optional MCP entries expose their own allowlisted tools; they can coexist with but cannot remove or shadow the mandatory entry.
-Evidence-store schema v2 includes closed `framework.detected` (`repository.framework-evidence/v1`) and `template.file` (`repository.template-evidence/v1`) facts from package-owned static plugins. Current plugins cover Jinja2, Echo/Fiber, Symfony/Twig, and React/Next with related gRPC, TypeScript, and Vite declarations. Plugins consume only already bounded immutable manifest/tree evidence: they cannot execute repository commands, load repository code, use network access, or start a second MCP server. Framework versions use the ecosystem's deterministic source: lock files for Python, Composer, and JavaScript, but the direct requirement or effective replacement in `go.mod` for Go. Local Go replacements remain explicit partial evidence rather than being mistaken for the replaced module version. Templates and configuration paths belong to the nearest manifest-root component; conventional Ansible-role templates retain the role root. Detailed declarations, resolutions, effective replacements, configuration/template paths, component scopes, and redacted base/head deltas remain available through the same `ocr_toolkit_evidence` summary/list/get interface.
+Evidence-store schema v2 includes closed `framework.detected` (`repository.framework-evidence/v1`) and `template.file` (`repository.template-evidence/v1`) facts from package-owned static plugins. Current plugins cover Jinja2, Echo/Fiber, Symfony/Twig, and React/Next with related gRPC, TypeScript, and Vite declarations. Plugins consume only already bounded immutable manifest/tree evidence: they cannot execute repository commands, load repository code, use network access, or start a second MCP server. Framework versions use the ecosystem's deterministic source: lock files for Python, Composer, and JavaScript, but the direct requirement or effective replacement in `go.mod` for Go. Local Go replacements remain explicit partial evidence rather than being mistaken for the replaced module version. Templates and configuration paths belong to the nearest manifest-root component; conventional Ansible-role templates retain the role root. The exact component `.` denotes the repository root, while names such as `repository` are ordinary top-level paths; the same identities filter facts, coverage, and deltas through `ocr_toolkit_evidence`. Detailed declarations, resolutions, effective replacements, configuration/template paths, component scopes, and redacted base/head deltas remain available through its summary/list/get actions.
The synthetic GitLab `rules.json` uses additive `include` entries for `.j2`, `.jinja`, `.jinja2`, `.twig`, and conventional Ansible-role template paths because the [recommended OCR](compatibility.md) does not review those extensions by default. Explicit excludes still win. The matching Jinja/Twig rules are review guidance; they do not execute or render templates, infer runtime variables, or replace evidence completeness.
diff --git a/docs/development.md b/docs/development.md
index 8edc85f..c0528fd 100644
--- a/docs/development.md
+++ b/docs/development.md
@@ -28,6 +28,8 @@ Framework support lives under `src/ocr_toolkit/evidence/frameworks/`. Add an eco
Every provider change needs synthetic tests for direct activation, lock-only non-activation, component ownership, malformed and bounded source degradation, fact/configuration limits, schema reload, base/head deltas, and the existing MCP projection as applicable. Template engines also need explicit OCR include/rule fixtures because evidence collection does not alter OCR file selection. Update the strategy and changelog when the supported public behavior changes.
+Provider results are admitted atomically: facts, coverage observations, and notices must all satisfy their package limits and immutable contracts before any of them reach shared registry output. Use `.` for a declaration manifest at the repository root; never overload a valid path such as `repository` as a root sentinel. Keep identifier/path bounds separate from longer manifest-derived scalar bounds, and validate plugin records only after the store applies its persistence redaction and total-value budget.
+
## Boundary-focused test checklist
Before closing a parser, repository reader, persisted schema, subprocess, or report-rendering change, add the applicable boundary tests:
diff --git a/docs/engineering/toolkit_strategy.md b/docs/engineering/toolkit_strategy.md
index c81c4e2..b2cef47 100644
--- a/docs/engineering/toolkit_strategy.md
+++ b/docs/engineering/toolkit_strategy.md
@@ -87,9 +87,9 @@ Implemented collectors cover Python declarations, requirements, uv, Poetry, Pipe
### Framework and template evidence
-Framework support is package-owned static plugin extraction, not a code graph or framework-specific review engine. The established registry covers Jinja2 and Jinja/Ansible-style templates, Echo/Fiber with direct gRPC stack context, Symfony/Twig, and React/Next with TypeScript/Vite context. Plugins receive only immutable normalized dependency/tree evidence and exact core-owned source-status records from the collector, publish closed framework/template facts plus scoped completeness, and cannot read the repository independently, execute commands, fetch the network, mutate state, or create another MCP/review flow. Components follow the nearest declaration manifest (or conventional Ansible role), Go uses effective `go.mod` requirement/replacement semantics, and every read, parse, configuration, template, or provider limit degrades its exact scope instead of turning missing facts into proof.
+Framework support is package-owned static plugin extraction, not a code graph or framework-specific review engine. The established registry covers Jinja2 and Jinja/Ansible-style templates, Echo/Fiber with direct gRPC stack context, Symfony/Twig, and React/Next with TypeScript/Vite context. Plugins receive only immutable normalized dependency/tree evidence and exact core-owned source-status records from the collector, publish closed framework/template facts plus scoped completeness, and cannot read the repository independently, execute commands, fetch the network, mutate state, or create another MCP/review flow. Components follow the nearest declaration manifest (or conventional Ansible role); `.` is the unambiguous repository-root component and every other value is a real path. Go uses effective `go.mod` requirement/replacement semantics, and every read, parse, configuration, template, or provider limit degrades its exact scope instead of turning missing facts into proof.
-The implementation boundary is the internal `ocr_toolkit.evidence.frameworks` package. It owns immutable plugin contracts, closed framework/template schemas, generic package detection, template inventory, a static ordered registry, and package-owned declarations under `frameworks.providers`. The core collector remains responsible for Git/tree/manifest reads and passes only bounded immutable inputs; the evidence store and built-in MCP remain outside the package. There are no runtime discovery hooks, legacy import shims, plugin-owned I/O, or framework-specific MCP services. A new demonstrated provider extends this one registry and the shared schemas instead of adding another collection or serving path.
+The implementation boundary is the internal `ocr_toolkit.evidence.frameworks` package. It owns immutable plugin contracts, closed framework/template schemas, generic package detection, template inventory, a static ordered registry, and package-owned declarations under `frameworks.providers`. The core collector remains responsible for Git/tree/manifest reads and passes only bounded immutable inputs; the evidence store and built-in MCP remain outside the package. Provider results are bounded and committed atomically so malformed facts, coverage, or notices from one provider cannot leak partial state or suppress its siblings. There are no runtime discovery hooks, legacy import shims, plugin-owned I/O, or framework-specific MCP services. A new demonstrated provider extends this one registry and the shared schemas instead of adding another collection or serving path.
OCR file selection remains a separate review-engine boundary. The public synthetic rules pack explicitly includes Jinja and Twig template paths that the recommended OCR does not allowlist by default, then supplies narrowly scoped merged rules. Framework identity, versions, component scope, configuration paths, template inventory, scoped completeness, and their base/head deltas are stored once and served on demand by the existing built-in evidence MCP; rules neither duplicate those facts nor render templates.
diff --git a/scripts/github_release_api.py b/scripts/github_release_api.py
index ad93bd5..1797f50 100755
--- a/scripts/github_release_api.py
+++ b/scripts/github_release_api.py
@@ -20,6 +20,8 @@
API_VERSION = "2026-03-10"
MAX_JSON_BYTES = 1_048_576
MAX_ASSET_BYTES = 10_485_760
+MAX_RELEASE_LIST_PAGES = 5
+RELEASES_PER_PAGE = 100
REPOSITORY_RE = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$")
TAG_RE = re.compile(r"^v[0-9]+(?:\.[0-9]+)+$")
SHA_RE = re.compile(r"^[0-9a-f]{40}$")
@@ -261,6 +263,9 @@ def ensure_release(
) -> dict[str, Any]:
"""Discover one release by tag or numeric draft listing, creating it if absent."""
+ # Protected workflow identity must be closed before even a discovery request:
+ # the same function can later create the Release when discovery finds none.
+ _validate_inputs(repository, tag, target, title)
encoded_tag = urllib.parse.quote(tag, safe="")
status, payload = _request(
origin=API_ORIGIN,
@@ -279,24 +284,27 @@ def ensure_release(
)
releases: list[object] = []
- for page in range(1, 6):
+ for page in range(1, MAX_RELEASE_LIST_PAGES + 1):
_status, page_payload = _request(
origin=API_ORIGIN,
- endpoint=f"/repos/{repository}/releases?per_page=100&page={page}",
+ endpoint=(f"/repos/{repository}/releases?per_page={RELEASES_PER_PAGE}&page={page}"),
token=token,
)
- if not isinstance(page_payload, list) or len(page_payload) > 100:
+ if not isinstance(page_payload, list) or len(page_payload) > RELEASES_PER_PAGE:
raise GitHubReleaseError("GitHub Release listing is malformed")
releases.extend(page_payload)
- if len(page_payload) < 100:
+ if len(page_payload) < RELEASES_PER_PAGE:
break
else:
_status, overflow = _request(
origin=API_ORIGIN,
- endpoint=f"/repos/{repository}/releases?per_page=1&page=501",
+ endpoint=(
+ f"/repos/{repository}/releases?per_page={RELEASES_PER_PAGE}"
+ f"&page={MAX_RELEASE_LIST_PAGES + 1}"
+ ),
token=token,
)
- if not isinstance(overflow, list) or overflow:
+ if not isinstance(overflow, list) or len(overflow) > RELEASES_PER_PAGE or overflow:
raise GitHubReleaseError("GitHub Release listing exceeds its page bound")
matches = [item for item in releases if isinstance(item, dict) and item.get("tag_name") == tag]
diff --git a/src/ocr_toolkit/evidence/collectors.py b/src/ocr_toolkit/evidence/collectors.py
index 8f61498..838ab64 100644
--- a/src/ocr_toolkit/evidence/collectors.py
+++ b/src/ocr_toolkit/evidence/collectors.py
@@ -793,6 +793,23 @@ def unavailable_topology(entry: RepositoryObject, reason: str) -> None:
except RepositoryEvidenceError as exc:
diagnostics.append(f"Python requirements include batch read failed: {exc}")
python_graph = PythonRequirementBlobSet(graph.blobs, python_roots, ())
+ # Included requirements may use arbitrary .txt/.in names that the initial
+ # manifest registry intentionally does not match. They still feed framework
+ # declarations, so register their exact source state before graph/parser
+ # degradation is projected into completeness.
+ python_declaration = manifest_collector("requirements.txt")
+ if python_declaration is None: # pragma: no cover - static registry invariant
+ raise ValueError("Python requirements collector is unavailable")
+ for path in python_graph.requirement_paths:
+ source_statuses.setdefault(
+ path,
+ PluginSourceStatus(
+ path,
+ python_declaration.ecosystem,
+ python_declaration.source_roles,
+ "accepted",
+ ),
+ )
degraded_roots = dict((*graph.degraded_roots, *python_graph.degraded_roots))
for path, reason in sorted(degraded_roots.items()):
status = source_statuses.get(path)
diff --git a/src/ocr_toolkit/evidence/frameworks/__init__.py b/src/ocr_toolkit/evidence/frameworks/__init__.py
index dc5ebc0..e248b1a 100644
--- a/src/ocr_toolkit/evidence/frameworks/__init__.py
+++ b/src/ocr_toolkit/evidence/frameworks/__init__.py
@@ -2,7 +2,9 @@
from ocr_toolkit.evidence.frameworks.contracts import (
MAX_CONFIGURATION_PATHS,
+ MAX_PLUGIN_COVERAGE,
MAX_PLUGIN_FACTS,
+ MAX_PLUGIN_NOTICES,
FrameworkPluginContext,
FrameworkPluginResult,
PluginCoverage,
@@ -26,7 +28,9 @@ def framework_schema_versions() -> tuple[str, str]:
__all__ = [
"BUILTIN_FRAMEWORK_PLUGINS",
"MAX_CONFIGURATION_PATHS",
+ "MAX_PLUGIN_COVERAGE",
"MAX_PLUGIN_FACTS",
+ "MAX_PLUGIN_NOTICES",
"FrameworkPluginContext",
"FrameworkPluginResult",
"PluginCoverage",
diff --git a/src/ocr_toolkit/evidence/frameworks/contracts.py b/src/ocr_toolkit/evidence/frameworks/contracts.py
index f6377cf..dc44e69 100644
--- a/src/ocr_toolkit/evidence/frameworks/contracts.py
+++ b/src/ocr_toolkit/evidence/frameworks/contracts.py
@@ -15,6 +15,8 @@
# Each evidence kind shares a 512-record store limit across base and head. Capping
# each immutable side at half keeps accepted records, deltas, and coverage atomic.
MAX_PLUGIN_FACTS = 256
+MAX_PLUGIN_COVERAGE = 1_024
+MAX_PLUGIN_NOTICES = 64
MAX_CONFIGURATION_PATHS = 128
diff --git a/src/ocr_toolkit/evidence/frameworks/detection.py b/src/ocr_toolkit/evidence/frameworks/detection.py
index 240698d..51b3dbd 100644
--- a/src/ocr_toolkit/evidence/frameworks/detection.py
+++ b/src/ocr_toolkit/evidence/frameworks/detection.py
@@ -23,12 +23,14 @@
if TYPE_CHECKING:
from ocr_toolkit.evidence.repository import RepositoryObject
+ROOT_COMPONENT = "."
+
def component_root(path: str) -> str:
"""Return the canonical manifest directory component or repository root."""
parent = PurePosixPath(path).parent.as_posix()
- return "repository" if parent == "." else parent
+ return ROOT_COMPONENT if parent == "." else parent
def _fact_value(record: EvidenceRecord | None) -> Mapping[str, EvidenceValue] | None:
@@ -89,7 +91,7 @@ def is_direct_declaration(record: EvidenceRecord, ecosystem: str) -> bool:
def _path_is_within(path: str, component: str) -> bool:
"""Return whether one repository path is inside a canonical component."""
- return component == "repository" or path == component or path.startswith(component + "/")
+ return component == ROOT_COMPONENT or path == component or path.startswith(component + "/")
def owning_component(path: str, components: tuple[str, ...]) -> str | None:
@@ -97,7 +99,7 @@ def owning_component(path: str, components: tuple[str, ...]) -> str | None:
matches = tuple(component for component in components if _path_is_within(path, component))
return (
- max(matches, key=lambda item: 0 if item == "repository" else item.count("/") + 1)
+ max(matches, key=lambda item: 0 if item == ROOT_COMPONENT else item.count("/") + 1)
if matches
else None
)
diff --git a/src/ocr_toolkit/evidence/frameworks/registry.py b/src/ocr_toolkit/evidence/frameworks/registry.py
index 67fa256..5caa8c2 100644
--- a/src/ocr_toolkit/evidence/frameworks/registry.py
+++ b/src/ocr_toolkit/evidence/frameworks/registry.py
@@ -2,10 +2,17 @@
from __future__ import annotations
+from collections.abc import Mapping
+from pathlib import PurePosixPath
+
+from ocr_toolkit.evidence.coverage import compose_coverage
from ocr_toolkit.evidence.frameworks.contracts import (
+ MAX_PLUGIN_COVERAGE,
MAX_PLUGIN_FACTS,
+ MAX_PLUGIN_NOTICES,
FrameworkPlugin,
FrameworkPluginContext,
+ FrameworkPluginResult,
PluginCoverage,
PluginFact,
)
@@ -16,6 +23,7 @@
REACT_PLUGIN,
SYMFONY_PLUGIN,
)
+from ocr_toolkit.evidence.frameworks.schema import validate_plugin_record
from ocr_toolkit.evidence.model import CoverageState
BUILTIN_FRAMEWORK_PLUGINS: tuple[FrameworkPlugin, ...] = (
@@ -26,6 +34,79 @@
)
+def _safe_component(value: object) -> bool:
+ """Return whether one component is the root marker or a normalized bounded path."""
+
+ return isinstance(value, str) and (
+ value == "."
+ or (
+ 0 < len(value) <= 256
+ and not PurePosixPath(value).is_absolute()
+ and all(part not in {"", ".", ".."} for part in value.split("/"))
+ and not any(ord(character) < 32 for character in value)
+ )
+ )
+
+
+def _safe_source_path(value: object) -> bool:
+ """Return whether one plugin source is a normalized bounded repository path."""
+
+ return (
+ isinstance(value, str)
+ and 0 < len(value) <= 4_096
+ and not PurePosixPath(value).is_absolute()
+ and all(part not in {"", ".", ".."} for part in value.split("/"))
+ and not any(ord(character) < 32 for character in value)
+ )
+
+
+def _bounded_result(
+ plugin: FrameworkPlugin, context: FrameworkPluginContext
+) -> FrameworkPluginResult:
+ """Validate one provider result before any output reaches shared registry state."""
+
+ result = plugin.collect(context)
+ if (
+ not isinstance(result, FrameworkPluginResult)
+ or not isinstance(result.facts, tuple)
+ or len(result.facts) > MAX_PLUGIN_FACTS
+ or not all(
+ isinstance(fact, PluginFact) and isinstance(fact.value, Mapping)
+ for fact in result.facts
+ )
+ or not isinstance(result.coverage, tuple)
+ or len(result.coverage) > MAX_PLUGIN_COVERAGE
+ or not all(isinstance(item, PluginCoverage) for item in result.coverage)
+ or not isinstance(result.notices, tuple)
+ or len(result.notices) > MAX_PLUGIN_NOTICES
+ or not all(
+ isinstance(notice, str) and 0 < len(notice) <= 1_024 for notice in result.notices
+ )
+ ):
+ raise TypeError("framework plugin result is malformed")
+ for fact in result.facts:
+ if not _safe_component(fact.component) or not _safe_source_path(fact.source_path):
+ raise TypeError("framework plugin fact metadata is malformed")
+ validate_plugin_record(
+ fact.kind,
+ {"identity": fact.identity, "fact": fact.value},
+ )
+ for item in result.coverage:
+ if not _safe_component(item.component):
+ raise TypeError("framework plugin coverage metadata is malformed")
+ # Apply the exact closed coverage contract before shared registry state
+ # receives any observation from this provider.
+ compose_coverage(
+ component=item.component,
+ domain=item.domain,
+ scope=item.scope,
+ observations=(item.observation,),
+ ref=context.ref,
+ commit_sha=context.commit_sha,
+ )
+ return result
+
+
def collect_framework_plugins(
context: FrameworkPluginContext,
) -> tuple[tuple[PluginFact, ...], tuple[PluginCoverage, ...], tuple[str, ...]]:
@@ -36,42 +117,47 @@ def collect_framework_plugins(
notices: list[str] = []
for plugin in BUILTIN_FRAMEWORK_PLUGINS:
try:
- result = plugin.collect(context)
+ result = _bounded_result(plugin, context)
+ ordered = sorted(result.facts, key=lambda item: (item.component, item.identity))
+ remaining = max(0, MAX_PLUGIN_FACTS - len(facts))
+ accepted = ordered[:remaining]
+ omitted = ordered[remaining:]
+ plugin_coverage = list(result.coverage)
+ plugin_notices = list(result.notices)
+ for fact in omitted:
+ framework = fact.value.get("framework")
+ if isinstance(framework, str):
+ plugin_coverage.extend(
+ (
+ coverage_observation(
+ component=fact.component,
+ plugin=plugin.plugin_id,
+ framework=framework,
+ state=CoverageState.PARTIAL,
+ reason="plugin-fact-limit",
+ positive=True,
+ ),
+ coverage_observation(
+ component=fact.component,
+ plugin=plugin.plugin_id,
+ framework=None,
+ state=CoverageState.PARTIAL,
+ reason="plugin-fact-limit",
+ positive=True,
+ ),
+ )
+ )
+ if omitted:
+ plugin_notices.append(f"framework plugin fact limit reached: {plugin.plugin_id}")
# A package-owned provider is isolated so one defect cannot suppress siblings.
except Exception:
notices.append(f"framework plugin unavailable: {plugin.plugin_id}")
continue
- ordered = sorted(result.facts, key=lambda item: (item.component, item.identity))
- remaining = max(0, MAX_PLUGIN_FACTS - len(facts))
- facts.extend(ordered[:remaining])
- coverage.extend(result.coverage)
- notices.extend(result.notices)
- omitted = ordered[remaining:]
- for fact in omitted:
- framework = fact.value.get("framework")
- if isinstance(framework, str):
- coverage.extend(
- (
- coverage_observation(
- component=fact.component,
- plugin=plugin.plugin_id,
- framework=framework,
- state=CoverageState.PARTIAL,
- reason="plugin-fact-limit",
- positive=True,
- ),
- coverage_observation(
- component=fact.component,
- plugin=plugin.plugin_id,
- framework=None,
- state=CoverageState.PARTIAL,
- reason="plugin-fact-limit",
- positive=True,
- ),
- )
- )
- if omitted:
- notices.append(f"framework plugin fact limit reached: {plugin.plugin_id}")
+ # Commit one provider's output only after every validation and derived
+ # truncation observation succeeded, so failure cannot leak partial state.
+ facts.extend(accepted)
+ coverage.extend(plugin_coverage)
+ notices.extend(plugin_notices)
return (
tuple(sorted(facts, key=lambda item: (item.kind, item.component, item.identity))),
tuple(sorted(coverage, key=lambda item: (item.component, item.domain, item.scope))),
diff --git a/src/ocr_toolkit/evidence/frameworks/schema.py b/src/ocr_toolkit/evidence/frameworks/schema.py
index 5f2442f..5fff749 100644
--- a/src/ocr_toolkit/evidence/frameworks/schema.py
+++ b/src/ocr_toolkit/evidence/frameworks/schema.py
@@ -36,6 +36,9 @@
"jinja2": {"jinja-extension", "ansible-role-template"},
"twig": {"twig-extension"},
}
+MAX_IDENTIFIER_CHARS = 512
+MAX_REPOSITORY_PATH_CHARS = 4_096
+MAX_MANIFEST_SCALAR_CHARS = 64_000
def _exact_mapping(value: object, keys: set[str], label: str) -> Mapping[str, EvidenceValue]:
@@ -46,13 +49,19 @@ def _exact_mapping(value: object, keys: set[str], label: str) -> Mapping[str, Ev
return cast(Mapping[str, EvidenceValue], value)
-def _string(value: object, label: str, *, choices: set[str] | None = None) -> str:
+def _string(
+ value: object,
+ label: str,
+ *,
+ choices: set[str] | None = None,
+ max_length: int = MAX_IDENTIFIER_CHARS,
+) -> str:
"""Require one bounded safe string and optional closed enum membership."""
if (
not isinstance(value, str)
or not value
- or len(value) > 512
+ or len(value) > max_length
or any(ord(character) < 32 for character in value)
or (choices is not None and value not in choices)
):
@@ -63,7 +72,7 @@ def _string(value: object, label: str, *, choices: set[str] | None = None) -> st
def _path(value: object, label: str) -> str:
"""Require one normalized repository-relative POSIX path."""
- path = _string(value, label)
+ path = _string(value, label, max_length=MAX_REPOSITORY_PATH_CHARS)
pure = PurePosixPath(path)
if pure.is_absolute() or any(part in {"", ".", ".."} for part in path.split("/")):
raise ValueError(f"{label} is invalid")
@@ -127,7 +136,11 @@ def validate_framework_value(value: EvidenceValue) -> tuple[str, str]:
for item in declarations:
_string(item["package"], "framework package")
_string(item["scope"], "framework scope")
- _string(item["declared_value"], "framework declared value")
+ _string(
+ item["declared_value"],
+ "framework declared value",
+ max_length=MAX_MANIFEST_SCALAR_CHARS,
+ )
_path(item["source_path"], "framework declaration source")
resolutions = _objects(
root["resolutions"],
@@ -136,7 +149,11 @@ def validate_framework_value(value: EvidenceValue) -> tuple[str, str]:
)
for item in resolutions:
_string(item["package"], "framework package")
- _string(item["version"], "framework version")
+ _string(
+ item["version"],
+ "framework version",
+ max_length=MAX_MANIFEST_SCALAR_CHARS,
+ )
_string(item["source"], "framework resolution source")
_path(item["source_path"], "framework resolution source path")
replacement = root["replacement"]
@@ -144,7 +161,11 @@ def validate_framework_value(value: EvidenceValue) -> tuple[str, str]:
replacement_value = _exact_mapping(
replacement, {"target", "type", "version"}, "framework replacement"
)
- _string(replacement_value["target"], "framework replacement target")
+ _string(
+ replacement_value["target"],
+ "framework replacement target",
+ max_length=MAX_MANIFEST_SCALAR_CHARS,
+ )
replacement_type = _string(
replacement_value["type"],
"framework replacement type",
@@ -152,7 +173,11 @@ def validate_framework_value(value: EvidenceValue) -> tuple[str, str]:
)
replacement_version = replacement_value["version"]
if replacement_version is not None:
- _string(replacement_version, "framework replacement version")
+ _string(
+ replacement_version,
+ "framework replacement version",
+ max_length=MAX_MANIFEST_SCALAR_CHARS,
+ )
if plugin != "go-web" or (replacement_type == "local") != (
version_state == "local-override"
):
@@ -185,7 +210,11 @@ def validate_framework_value(value: EvidenceValue) -> tuple[str, str]:
if not isinstance(values, (list, tuple)) or len(values) > 128:
raise ValueError("related framework versions are invalid")
for entry in values:
- _string(entry, "related framework value")
+ _string(
+ entry,
+ "related framework value",
+ max_length=MAX_MANIFEST_SCALAR_CHARS,
+ )
sources = item["source_paths"]
if not isinstance(sources, (list, tuple)) or len(sources) > 128:
raise ValueError("related framework source paths are invalid")
diff --git a/src/ocr_toolkit/evidence/frameworks/templates.py b/src/ocr_toolkit/evidence/frameworks/templates.py
index 32c86ca..c8dad8d 100644
--- a/src/ocr_toolkit/evidence/frameworks/templates.py
+++ b/src/ocr_toolkit/evidence/frameworks/templates.py
@@ -91,6 +91,7 @@ def collect_template_files(
key: [CoverageObservation(CoverageState.COMPLETE, "bounded-tree-complete")]
for key in _applicable_template_components(context.records)
}
+ limited_components: set[tuple[str, str]] = set()
truncated = False
for entry in sorted(context.entries, key=lambda item: item.path):
description = _template_description(entry.path, context)
@@ -105,9 +106,15 @@ def collect_template_files(
continue
if len(facts) >= MAX_PLUGIN_FACTS:
truncated = True
- observations.setdefault(key, []).append(
- CoverageObservation(CoverageState.PARTIAL, "template-fact-limit", positive=True)
- )
+ if key not in limited_components:
+ limited_components.add(key)
+ observations.setdefault(key, []).append(
+ CoverageObservation(
+ CoverageState.PARTIAL,
+ "template-fact-limit",
+ positive=True,
+ )
+ )
continue
facts.append(
PluginFact(
diff --git a/src/ocr_toolkit/evidence/store.py b/src/ocr_toolkit/evidence/store.py
index b2245e6..a58a4d6 100644
--- a/src/ocr_toolkit/evidence/store.py
+++ b/src/ocr_toolkit/evidence/store.py
@@ -162,9 +162,9 @@ def add(self, record: EvidenceRecord) -> bool:
if record.kind not in KNOWN_KINDS:
raise EvidenceStoreError(f"unregistered evidence kind: {record.kind}")
try:
- if record.kind in {"framework.detected", "template.file"}:
- validate_plugin_record(record.kind, record.value)
redacted_value = _safe_value(record.value, self.limits.max_value_chars)
+ if record.kind in {"framework.detected", "template.file"}:
+ validate_plugin_record(record.kind, redacted_value)
except EvidenceStoreError:
self._diagnose_once(f"omitted oversized {record.kind} evidence value")
return False
diff --git a/tests/test_evidence_collectors.py b/tests/test_evidence_collectors.py
index 7ed63ba..dac2d0f 100644
--- a/tests/test_evidence_collectors.py
+++ b/tests/test_evidence_collectors.py
@@ -25,6 +25,7 @@
manifest_collector,
parse_manifest,
)
+from ocr_toolkit.evidence.manifest_model import MAX_MANIFEST_ITEMS
from ocr_toolkit.evidence.mcp import handle_request
from ocr_toolkit.evidence.python_manifests import parse_requirements
from ocr_toolkit.evidence.repository import BoundedBlobRead, RepositoryObject
@@ -388,6 +389,46 @@ def test_python_requirements_includes_are_recursive_bounded_and_safe(tmp_path: P
)
+def test_graph_discovered_python_source_degrades_its_framework_component(
+ tmp_path: Path,
+) -> None:
+ """Track arbitrary included requirement paths through parser truncation."""
+
+ _git(tmp_path, "init", "-q")
+ _git(tmp_path, "config", "user.email", "agent@example.invalid")
+ _git(tmp_path, "config", "user.name", "Synthetic Agent")
+ constraints = tmp_path / "constraints"
+ constraints.mkdir()
+ (tmp_path / "requirements.txt").write_text("-r constraints/base.in\n", encoding="utf-8")
+ declarations = ["jinja2==3.1.6"]
+ declarations.extend(f"synthetic-package-{index}==1.0" for index in range(MAX_MANIFEST_ITEMS))
+ (constraints / "base.in").write_text("\n".join(declarations) + "\n", encoding="utf-8")
+ _git(tmp_path, "add", "requirements.txt", "constraints/base.in")
+ _git(tmp_path, "commit", "-qm", "truncated arbitrary include")
+ head = _git(tmp_path, "rev-parse", "HEAD")
+ coverage = []
+
+ _records, diagnostics = collect_ref_facts(
+ GitRepositoryReader(tmp_path),
+ head,
+ RefRole.HEAD,
+ coverage_sink=coverage,
+ )
+
+ declaration = next(
+ item
+ for item in coverage
+ if item.component == "constraints"
+ and item.domain == "framework.declaration"
+ and item.scope == "jinja2"
+ )
+ assert declaration.state.value == "partial"
+ assert declaration.reasons == ("source-item-limit",)
+ assert any(
+ "constraints/base.in: Python requirements were truncated" in item for item in diagnostics
+ )
+
+
def test_python_requirements_include_limit_degrades_framework_completeness(
tmp_path: Path,
) -> None:
@@ -423,7 +464,7 @@ def test_python_requirements_include_limit_degrades_framework_completeness(
declaration = next(
item
for item in coverage
- if item.component == "repository"
+ if item.component == "."
and item.domain == "framework.declaration"
and item.scope == "jinja2"
)
@@ -457,7 +498,7 @@ def test_python_requirements_omitted_include_degrades_root_completeness(
declaration = next(
item
for item in coverage
- if item.component == "repository"
+ if item.component == "."
and item.domain == "framework.declaration"
and item.scope == "jinja2"
)
diff --git a/tests/test_evidence_framework_plugins.py b/tests/test_evidence_framework_plugins.py
index bac60bb..93acb4d 100644
--- a/tests/test_evidence_framework_plugins.py
+++ b/tests/test_evidence_framework_plugins.py
@@ -19,6 +19,7 @@
FrameworkPluginContext,
FrameworkPluginResult,
collect_framework_plugins,
+ collect_template_files,
)
from ocr_toolkit.evidence.frameworks.providers import (
GO_WEB_PLUGIN,
@@ -658,7 +659,7 @@ def test_go_versions_and_replacements_use_effective_go_mod_semantics(tmp_path: P
}
resolution = coverage_record(
store,
- component="repository",
+ component=".",
domain="framework.resolution",
scope="go-web:echo",
)
@@ -767,16 +768,148 @@ def test_template_components_follow_nearest_manifest_root_and_configuration_isol
if record.kind == "template.file" and record.ref is RefRole.HEAD
}
assert templates == {
- "root.conf.j2": "repository",
+ "root.conf.j2": ".",
"services/renderer/templates/service.conf.j2": "services/renderer",
}
frameworks = {record.component: record.value["fact"] for record in framework_records(store)}
- assert "services/renderer/pyproject.toml" not in frameworks["repository"]["configuration_paths"]
+ assert "services/renderer/pyproject.toml" not in frameworks["."]["configuration_paths"]
assert (
"services/renderer/pyproject.toml" in frameworks["services/renderer"]["configuration_paths"]
)
+def test_repository_root_and_named_repository_directory_are_distinct_components(
+ tmp_path: Path,
+) -> None:
+ """Use a non-path root component without merging a real repository directory."""
+
+ initialize(tmp_path)
+ (tmp_path / "package.json").write_text(
+ json.dumps({"dependencies": {"react": "19.0.0"}}), encoding="utf-8"
+ )
+ nested = tmp_path / "repository"
+ nested.mkdir()
+ (nested / "package.json").write_text(
+ json.dumps({"dependencies": {"next": "15.2.0"}}), encoding="utf-8"
+ )
+ base = commit(tmp_path, "distinct component roots")
+ (tmp_path / "package.json").write_text(
+ json.dumps({"dependencies": {"react": "19.1.0"}}), encoding="utf-8"
+ )
+ (nested / "package.json").write_text(
+ json.dumps({"dependencies": {"next": "15.3.0"}}), encoding="utf-8"
+ )
+ head = commit(tmp_path, "change both component declarations")
+
+ store = collect_repository_evidence(tmp_path, base_ref=base, head_ref=head)
+ facts = {
+ (record.component, record.value["fact"]["framework"]): record.value["fact"]
+ for record in framework_records(store)
+ }
+
+ assert set(facts) == {(".", "react"), ("repository", "next")}
+ assert [item["source_path"] for item in facts[(".", "react")]["declarations"]] == [
+ "package.json"
+ ]
+ assert [item["source_path"] for item in facts[("repository", "next")]["declarations"]] == [
+ "repository/package.json"
+ ]
+
+ root_list = mcp_payload(
+ call_tool(
+ store,
+ {"action": "list", "kind": "framework.detected", "component": ".", "ref": "head"},
+ )
+ )
+ named_list = mcp_payload(
+ call_tool(
+ store,
+ {
+ "action": "list",
+ "kind": "framework.detected",
+ "component": "repository",
+ "ref": "head",
+ },
+ )
+ )
+ assert [item["value"]["fact"]["framework"] for item in root_list["records"]] == ["react"]
+ assert [item["value"]["fact"]["framework"] for item in named_list["records"]] == ["next"]
+
+ root_deltas = mcp_payload(
+ call_tool(
+ store,
+ {
+ "action": "list",
+ "kind": "repository.evidence_delta",
+ "delta_kind": "framework.detected",
+ "component": ".",
+ },
+ )
+ )
+ named_deltas = mcp_payload(
+ call_tool(
+ store,
+ {
+ "action": "list",
+ "kind": "repository.evidence_delta",
+ "delta_kind": "framework.detected",
+ "component": "repository",
+ },
+ )
+ )
+ assert [(item["identity"], item["change"]) for item in root_deltas["records"]] == [
+ ("react-typescript:react", "changed")
+ ]
+ assert [(item["identity"], item["change"]) for item in named_deltas["records"]] == [
+ ("react-typescript:next", "changed")
+ ]
+
+
+def test_template_fact_limit_emits_one_observation_per_component() -> None:
+ """Bound post-limit template coverage work by semantic component scope."""
+
+ entries = tuple(
+ RepositoryObject(
+ f"templates/page-{index:04}.j2",
+ "100644",
+ "blob",
+ f"{index:040x}",
+ )
+ for index in range(MAX_PLUGIN_FACTS + 32)
+ )
+
+ facts, coverage, notices = collect_template_files(
+ FrameworkPluginContext((), entries, (), RefRole.HEAD, "a" * 40)
+ )
+
+ limited = [
+ item
+ for item in coverage
+ if item.component == "templates"
+ and item.scope == "jinja2"
+ and item.observation.reason == "template-fact-limit"
+ ]
+ assert len(facts) == MAX_PLUGIN_FACTS
+ assert len(limited) == 1
+ assert notices == ("template plugin fact limit reached",)
+
+
+def test_long_manifest_requirement_remains_valid_framework_evidence(tmp_path: Path) -> None:
+ """Allow bounded manifest scalars to exceed identifier-oriented limits."""
+
+ initialize(tmp_path)
+ requirement = "jinja2 @ https://packages.example.invalid/" + "a" * 768 + ".whl"
+ (tmp_path / "requirements.txt").write_text(requirement + "\n", encoding="utf-8")
+ base = commit(tmp_path, "long direct requirement")
+ (tmp_path / "README.md").write_text("head\n", encoding="utf-8")
+ head = commit(tmp_path, "head")
+
+ store = collect_repository_evidence(tmp_path, base_ref=base, head_ref=head)
+ jinja = next(record for record in framework_records(store) if record.component == ".")
+
+ assert jinja.value["fact"]["declarations"][0]["declared_value"] == requirement
+
+
def test_configuration_and_template_limits_degrade_exact_coverage(tmp_path: Path) -> None:
"""Never claim complete inventory after plugin-owned output truncation."""
@@ -805,7 +938,7 @@ def test_configuration_and_template_limits_degrade_exact_coverage(tmp_path: Path
assert len(react.value["fact"]["configuration_paths"]) == MAX_CONFIGURATION_PATHS
config_coverage = coverage_record(
store,
- component="repository",
+ component=".",
domain="framework.configuration",
scope="react-typescript:react",
)
@@ -873,6 +1006,69 @@ def collect(self, context: FrameworkPluginContext) -> FrameworkPluginResult:
assert notices == ("framework plugin unavailable: broken",)
+def test_malformed_provider_result_isolated_from_sibling_provider(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ """Discard malformed post-processing output without suppressing siblings."""
+
+ class MalformedResult:
+ facts: tuple[()] = ()
+ notices: tuple[()] = ()
+
+ @property
+ def coverage(self) -> tuple[()]:
+ raise RuntimeError("synthetic malformed coverage")
+
+ class MalformedPlugin:
+ plugin_id = "malformed"
+
+ def collect(self, context: FrameworkPluginContext) -> FrameworkPluginResult:
+ return cast(FrameworkPluginResult, MalformedResult())
+
+ declaration = EvidenceRecord(
+ kind="dependency.declared",
+ value={
+ "identity": "pyproject.toml:project:jinja2",
+ "fact": {"name": "jinja2", "version": "3.1.6", "scope": "project"},
+ },
+ source_path="pyproject.toml",
+ ref=RefRole.HEAD,
+ commit_sha="a" * 40,
+ component="python",
+ provenance="synthetic",
+ confidence=Confidence.EXACT,
+ trust=TrustClass.SOURCE_REPOSITORY,
+ )
+ manifest = EvidenceRecord(
+ kind="repository.manifest",
+ value={"identity": "pyproject.toml", "fact": {"path": "pyproject.toml"}},
+ source_path="pyproject.toml",
+ ref=RefRole.HEAD,
+ commit_sha="a" * 40,
+ component="python",
+ provenance="synthetic",
+ confidence=Confidence.EXACT,
+ trust=TrustClass.SOURCE_REPOSITORY,
+ )
+ monkeypatch.setattr(
+ "ocr_toolkit.evidence.frameworks.registry.BUILTIN_FRAMEWORK_PLUGINS",
+ (MalformedPlugin(), JINJA2_PLUGIN),
+ )
+
+ facts, _coverage, notices = collect_framework_plugins(
+ FrameworkPluginContext(
+ (manifest, declaration),
+ (RepositoryObject("pyproject.toml", "100644", "blob", "b" * 40),),
+ (),
+ RefRole.HEAD,
+ "a" * 40,
+ )
+ )
+
+ assert [fact.identity for fact in facts] == ["jinja2:jinja2"]
+ assert notices == ("framework plugin unavailable: malformed",)
+
+
def test_nested_schema_rejects_identity_and_plugin_relationship_mismatches(tmp_path: Path) -> None:
"""Bind persisted framework identity, plugin, framework, and ecosystem fields."""
@@ -913,7 +1109,7 @@ def test_malformed_and_truncated_manifests_degrade_applicable_coverage(tmp_path:
store = collect_repository_evidence(tmp_path, base_ref=base, head_ref=head)
jinja_declarations = coverage_record(
store,
- component="repository",
+ component=".",
domain="framework.declaration",
scope="jinja2",
)
@@ -921,7 +1117,7 @@ def test_malformed_and_truncated_manifests_degrade_applicable_coverage(tmp_path:
assert jinja_declarations.reasons == ("parse-unavailable",)
react_declarations = coverage_record(
store,
- component="repository",
+ component=".",
domain="framework.declaration",
scope="react-typescript",
)
diff --git a/tests/test_evidence_model.py b/tests/test_evidence_model.py
index c0115f2..95dd3a5 100644
--- a/tests/test_evidence_model.py
+++ b/tests/test_evidence_model.py
@@ -323,6 +323,41 @@ def test_plugin_schema_rejects_unknown_record_kinds() -> None:
)
+def test_store_validates_plugin_schema_after_redaction(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ """Never persist a plugin value whose schema-significant field was redacted."""
+
+ secret_sha = "a" * 40
+ monkeypatch.setenv("OCR_LLM_TOKEN", secret_sha)
+ template = EvidenceRecord(
+ kind="template.file",
+ value={
+ "identity": "templates/service.conf.j2",
+ "fact": {
+ "schema_version": "repository.template-evidence/v1",
+ "plugin": "jinja2",
+ "engine": "jinja2",
+ "detection": "jinja-extension",
+ "rendered_extension": ".conf",
+ "object_sha": secret_sha,
+ },
+ },
+ source_path="templates/service.conf.j2",
+ ref=RefRole.HEAD,
+ commit_sha=HEAD_SHA,
+ component="templates",
+ provenance="framework plugin:jinja2",
+ trust=TrustClass.SOURCE_REPOSITORY,
+ )
+ store = EvidenceStore()
+
+ with pytest.raises(EvidenceStoreError, match=r"invalid template\.file"):
+ store.add(template)
+
+ assert store.records == ()
+
+
def test_store_omits_an_oversized_ordinary_record_without_aborting() -> None:
"""Treat the store value budget as bounded omission rather than invalid input."""
diff --git a/tests/test_release_receipt.py b/tests/test_release_receipt.py
index 8a403b1..e47a861 100644
--- a/tests/test_release_receipt.py
+++ b/tests/test_release_receipt.py
@@ -406,6 +406,66 @@ def test_numeric_release_identity_requires_exact_metadata_and_unique_assets() ->
)
+def test_release_ensure_validates_identity_before_any_request(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ """Reject malformed protected identity before release discovery or mutation."""
+
+ requests: list[dict[str, Any]] = []
+
+ def record_request(**kwargs: Any) -> tuple[int, object]:
+ requests.append(kwargs)
+ return 404, None
+
+ monkeypatch.setattr(github_release, "_request", record_request)
+
+ with pytest.raises(github_release.GitHubReleaseError, match="identity"):
+ github_release.ensure_release(
+ repository="synthetic/toolkit",
+ tag="not-a-release-tag",
+ target="a" * 40,
+ title="not-a-release-tag",
+ notes="synthetic notes\n",
+ token="synthetic-token",
+ )
+
+ assert requests == []
+
+
+def test_release_ensure_checks_the_first_page_beyond_its_listing_bound(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ """Fail closed on page six after five full bounded release pages."""
+
+ endpoints: list[str] = []
+
+ def fake_request(**kwargs: Any) -> tuple[int, object]:
+ endpoint = kwargs["endpoint"]
+ endpoints.append(endpoint)
+ if "/releases/tags/" in endpoint:
+ return 404, None
+ if endpoint.endswith("/releases?per_page=100&page=6"):
+ return 200, [{"tag_name": "v0.4.9"}]
+ if "per_page=100" in endpoint:
+ return 200, [{"tag_name": f"v0.0.{index}"} for index in range(100)]
+ pytest.fail(f"unexpected release request: {endpoint}")
+
+ monkeypatch.setattr(github_release, "_request", fake_request)
+
+ with pytest.raises(github_release.GitHubReleaseError, match="page bound"):
+ github_release.ensure_release(
+ repository="synthetic/toolkit",
+ tag="v0.5.0",
+ target="a" * 40,
+ title="v0.5.0",
+ notes="synthetic notes\n",
+ token="synthetic-token",
+ )
+
+ assert endpoints[-1] == "/repos/synthetic/toolkit/releases?per_page=100&page=6"
+ assert not any(endpoint == "/repos/synthetic/toolkit/releases" for endpoint in endpoints)
+
+
def test_issue_receipt_json_read_is_bound_to_one_descriptor(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
From a0a9e33ceda170c6339cbcc255b59d3a1538f74e Mon Sep 17 00:00:00 2001
From: xeonvs <11463419+xeonvs@users.noreply.github.com>
Date: Wed, 12 Aug 2026 14:47:55 +0200
Subject: [PATCH 11/11] Organize ecosystem evidence adapters
---
PLANS.md | 189 ++++++++++++++----
changelog.d/77.feature.md | 2 +-
docs/configuration.md | 2 +
docs/development.md | 6 +
docs/engineering/toolkit_strategy.md | 2 +
src/ocr_toolkit/evidence/collectors.py | 48 ++---
.../evidence/ecosystems/__init__.py | 1 +
.../evidence/ecosystems/ansible/__init__.py | 1 +
.../ansible/requirements.py} | 0
.../ansible/topology.py} | 0
.../contracts.py} | 0
.../{go_manifests.py => ecosystems/go.py} | 2 +-
.../javascript.py} | 2 +-
.../php.py} | 2 +-
.../python.py} | 2 +-
src/ocr_toolkit/evidence/infrastructure.py | 2 +-
tests/test_distribution_contents.py | 45 +++++
tests/test_evidence_ansible.py | 8 +-
tests/test_evidence_collectors.py | 12 +-
tests/test_evidence_composer.py | 4 +-
tests/test_evidence_ecosystems.py | 85 ++++++++
tests/test_evidence_framework_plugins.py | 2 +-
tests/test_evidence_go.py | 4 +-
tests/test_evidence_infrastructure.py | 2 +-
tests/test_evidence_javascript.py | 4 +-
25 files changed, 338 insertions(+), 89 deletions(-)
create mode 100644 src/ocr_toolkit/evidence/ecosystems/__init__.py
create mode 100644 src/ocr_toolkit/evidence/ecosystems/ansible/__init__.py
rename src/ocr_toolkit/evidence/{ansible_requirements.py => ecosystems/ansible/requirements.py} (100%)
rename src/ocr_toolkit/evidence/{ansible.py => ecosystems/ansible/topology.py} (100%)
rename src/ocr_toolkit/evidence/{manifest_model.py => ecosystems/contracts.py} (100%)
rename src/ocr_toolkit/evidence/{go_manifests.py => ecosystems/go.py} (99%)
rename src/ocr_toolkit/evidence/{javascript_manifests.py => ecosystems/javascript.py} (99%)
rename src/ocr_toolkit/evidence/{composer_manifests.py => ecosystems/php.py} (99%)
rename src/ocr_toolkit/evidence/{python_manifests.py => ecosystems/python.py} (99%)
create mode 100644 tests/test_evidence_ecosystems.py
diff --git a/PLANS.md b/PLANS.md
index 51219e8..5342c2a 100644
--- a/PLANS.md
+++ b/PLANS.md
@@ -4,7 +4,7 @@ Use this file for active, blocked, or recently completed execution work. Update
## Active Plan: M2 ecosystem and framework coverage for 0.5.0
-Status: active; implementation checkpoints in progress
+Status: active; implementation and local validation complete; history and external delivery pending
Owner: Codex
Last Updated: 2026-08-12
Release Classification: release-required
@@ -19,12 +19,11 @@ Twig template files actually reviewable by the recommended OCR through the
public synthetic rules pack, expose framework/template state and deltas through
the existing read-only evidence MCP, repair issue #76 release recovery, and
qualify checksum-pinned OCR 1.9.2 through issue #78 before final validation.
-Deliver completed slices as local checkpoint commits without intermediate
-pushes. Complete the synthetic installed-artifact E2E and the already-run local
-review, remediate it, finish the framework-package slice and full deterministic
-validation, then run one additional full local OCR review with concurrency 2.
-Remediate that review and push feature PR #77 only once after final local
-validation, before protected feature and stable delivery.
+The implementation, installed-artifact E2E, both owner-authorized local OCR
+review cycles, deterministic remediation, package reorganizations, and final
+local validation are complete in signed local checkpoints without intermediate
+pushes. Preserve that validated tree while consolidating unpublished history,
+then update feature PR #77 once before protected feature and stable delivery.
### Decisions
@@ -55,9 +54,10 @@ validation, before protected feature and stable delivery.
keep posting disabled and artifacts private, then fix actionable findings and
return to deterministic validation without another OCR rerun.
- Between completed checkpoints, query the public project for newly opened OCR
- compatibility/release issues. If a newer stable OCR appears, qualify its
- complete adjacent chain and include required contract/rules adaptations in
- 0.5.0 before the next pending full OCR review.
+ compatibility/release issues. If a newer stable OCR appears before 0.5.0
+ delivery, qualify its complete adjacent chain and include required
+ contract/rules adaptations in this release. Do not repeat a full local OCR
+ review without separate owner authorization.
- Keep completed implementation slices as local checkpoint commits with tests,
self-review, documentation, and release-issue monitoring. Do not push each
checkpoint or retrigger PR checks; update feature PR #77 once, only after all
@@ -124,15 +124,25 @@ validation, before protected feature and stable delivery.
11. [x] Finish deterministic remediation of the first full OCR review, complete
and prove the behavior-preserving framework package reorganization, then run
the complete deterministic package/E2E/privacy validation without OCR.
-12. [ ] Remediate the completed owner-authorized additional OCR review, audit
- sibling boundaries, and complete the separate `evidence.ecosystems` source-
- adapter reorganization. Repeat deterministic validation without another OCR
- run, then finish Gitleaks, self-review, feature PR checks/threads, merge, and
- TestPyPI development readback.
-13. [ ] Prepare the final release PR as the last repository mutation, archive
+12. [x] Remediate the completed owner-authorized additional OCR review, audit
+ sibling boundaries, complete the separate `evidence.ecosystems` source-
+ adapter reorganization, and repeat deterministic package/E2E/privacy
+ validation without another OCR run.
+13. [x] Partially rewrite unpublished history into logical signed slices, absorb
+ plan-only checkpoints into their owning functionality, verify every
+ signature, and prove exact final-tree plus complete base-diff equivalence
+ before recording the rewritten identities below.
+14. [x] Rerun complete-history signature, Gitleaks, privacy, full quality and
+ supported-Python gates; reproduce the target-version artifacts; verify
+ hash-locked installs, installed MCP, template rules preview, and static
+ workflow boundaries; then recheck public OCR release and issue/PR state.
+15. [ ] Update feature PR #77 once with `--force-with-lease`, read back its exact
+ head, finish checks and review threads, merge it, and independently verify
+ TestPyPI development delivery.
+16. [ ] Prepare the final release PR as the last repository mutation, archive
completed 0.4.7 history, consume fragments 76, 77, and 78, and reconcile M2
to implemented truth while leaving external publication pending.
-14. [ ] Complete stable 0.5.0 TestPyPI/PyPI, provenance/hash/tag/immutable
+17. [ ] Complete stable 0.5.0 TestPyPI/PyPI, provenance/hash/tag/immutable
Release/receipt/Python-install readback and close #76 and #78 as completed
without another repository PR.
@@ -284,18 +294,19 @@ validation, before protected feature and stable delivery.
TypeScript/Vite, templates, scoped completeness, and semantic deltas are
present; private modes and a clean Git status are preserved; no posting path
is invoked.
-- Read-only checkpoint monitoring still finds only issues #76 and #78 open, and
- the latest upstream stable OCR remains the already qualified 1.9.2. No push
- was made. The first full local review described below has since run; the next
- authorized OCR action is the additional concurrency-2 review only after the
- current remediation and complete deterministic validation finish.
+- Read-only checkpoint monitoring found only issues #76 and #78 open, and the
+ latest upstream stable OCR remained the already qualified 1.9.2. No push was
+ made. The first and additional owner-authorized local review cycles described
+ below subsequently completed; no further OCR review is authorized.
### Additional OCR Review Remediation Checkpoint
- The owner-authorized additional full OCR review completed successfully over
exact range `3caa50b4fc5026da79c7f2ceae1deef31715f814..4fe85549d66acd9fba57fb2ad39cf173b4d91053`
with checksum-qualified OCR 1.9.2, configured concurrency 2, the public rules
- pack, and exact-HEAD installed wheel version `0.5.0.dev0+g4fe85549`.
+ pack, and exact-HEAD installed wheel version `0.5.0.dev0+g4fe85549`. That
+ pre-rewrite head remains the immutable review-receipt identity; rewritten
+ signed checkpoint `a05399fd02916493bd516caae313b618becda221` has its exact tree.
- All selected items completed with no failed or waived coverage. The result has
terminal state `complete`, contains eight findings, and records 113 mandatory
`ocr_toolkit_evidence` calls; the toolkit receipt matches the OCR counter.
@@ -317,21 +328,115 @@ validation, before protected feature and stable delivery.
validated after redaction and total-value bounding.
- Sibling audits covered other bounded GitHub pagination, all framework component
consumers, provider facts/coverage/notices, path versus manifest-scalar limits,
- and every store reload path. The focused release/evidence suites pass. The root
- semantic is a clean unreleased 0.5.0 contract change, not a compatibility shim;
- nested fact schema versions remain unchanged because component lives in the
- common evidence envelope and its closed shape did not change.
-- After this remediation checkpoint, complete the separately bounded
- `evidence.ecosystems` structural slice described in Decisions. It is not part
- of `frameworks`: manifest and Ansible source adapters feed normalized evidence
- into the higher framework layer. Deterministic validation follows both slices
- and OCR will not be run again.
+ and every store reload path. The focused release/evidence suites and complete
+ routine quality/security gate pass. Rewritten signed checkpoint
+ `b35bb286938d923a29e8c51d87b152e6595e6825` contains the remediation with the
+ exact pre-rewrite checkpoint tree and remains unpushed. The root semantic is
+ a clean unreleased 0.5.0 contract change, not a compatibility shim; nested fact
+ schema versions remain unchanged because component lives in the common evidence
+ envelope and its closed shape did not change.
+- The separately bounded `evidence.ecosystems` structural slice described in
+ Decisions subsequently completed. It remains below `frameworks`: manifest and
+ Ansible source adapters feed normalized evidence into the higher framework
+ layer. Final deterministic validation covers both slices, and OCR was not run
+ again.
+
+### Ecosystem Adapter Package Checkpoint
+
+- Normalized source adapters now form one lower-level
+ `ocr_toolkit.evidence.ecosystems` package: shared contracts plus Python,
+ JavaScript, Go, and PHP modules, with Galaxy requirements and
+ topology/inventory split under `ecosystems.ansible`. Ansible remains an
+ automation ecosystem feeding normalized evidence, not a framework provider.
+- Git/tree reads, include-graph orchestration, source statuses, and parser
+ registration remain in `collectors.py`; cross-ecosystem container/CI facts
+ remain in `infrastructure.py`; framework derivation, store, and MCP remain
+ higher independent layers. The old flat parser modules are absent without
+ aliases or compatibility shims.
+- An architecture contract locks the exact package layout and rejects adapter
+ I/O, dynamic imports, and upward dependencies on collectors, frameworks,
+ repository plumbing, store, or MCP. Parser, collector, framework, repository,
+ model, and MCP suites pass, and a clean wheel-content test proves the package
+ layout and absence of old modules. No evidence schema or parser behavior
+ changed in this structural slice.
+
+### Unpublished History Consolidation Checkpoint
+
+- The unpublished range after the existing remote feature tip was consolidated
+ into several coherent signed functional slices. Plan-only installed-E2E,
+ pre-review, and final-validation commits were absorbed into the MCP,
+ framework-remediation, and ecosystem slices they describe; the OCR
+ qualification and additional-remediation slices remain distinct.
+- A private owner-only pre-rewrite receipt and backup ref preserve the old tip.
+ Before this metadata reconciliation, the rewritten tip had the same exact Git
+ tree and binary diff from the remote tip as the validated pre-rewrite tip; a
+ fixed-mtime archive of that tree was byte-identical. Every rewritten commit
+ verifies with the configured signing identity, and the worktree was clean.
+- OCR review ranges continue to name the commits actually reviewed. The plan
+ records signed rewritten commits with identical corresponding trees rather
+ than pretending the historical review executed against new commit objects.
+ The next gate scans and builds the complete rewritten range before its single
+ `--force-with-lease` branch update.
+
+### Rewritten-range Validation And Handoff Checkpoint
+
+- Every commit from stable 0.4.7 through the consolidated tip verifies with the
+ configured signing identity. The complete rewritten range passes Gitleaks,
+ owner-private marker and tracked-artifact scans, `git diff --check`, routine
+ quality/security, and independent tests on each supported Python version.
+- Two explicit 0.5.0 target-development builds are byte-identical and pass
+ Twine, closed wheel/sdist inspection, zero-runtime-dependency, ecosystem and
+ framework layout, and removed-module checks. Hash-locked wheel installs on
+ the lower and intermediate supported versions plus an sdist install on the
+ upper version pass isolated import, hostile shadow, `pip check`, restricted
+ `PATH`, and module-layout probes.
+- The installed artifact completes the real stdio MCP protocol with the one
+ read-only `ocr_toolkit_evidence` tool. Synthetic summary, fact, coverage, and
+ delta list/get calls preserve root and named-directory components and expose
+ Jinja2/Twig template evidence. The checksum-qualified OCR binary only runs a
+ JSON rules preview: ordinary source, Jinja/Twig files, and an extensionless
+ conventional role template are selected with no session artifact or LLM run.
+- Static shell/YAML checks, lock and compatibility manifests, dependency audit,
+ Towncrier draft, and clean worktree checks pass. Read-only public readback
+ still reports OCR 1.9.2 as latest, issues #76 and #78 open, and draft feature
+ PR #77 at the old clean remote tip. The single branch update, hosted checks,
+ merge, and development publication remain pending; no further OCR review is
+ authorized.
+
+### Final Local Deterministic Validation Checkpoint
+
+- Package, install, MCP, and privacy receipts remain bound to signed
+ pre-rewrite implementation checkpoint `14b074aab85f92a883b46a3994c4ae46a5e54598`.
+ The rewritten ecosystem/validation slice preserves all non-plan content and
+ absorbs only the final plan reconciliation; stable 0.4.7 ancestry and its
+ signatures are verified, and the branch remains unpushed after the owner's
+ push-policy correction.
+- Routine formatting, Ruff, strict mypy, Bandit, coverage, and the complete test
+ suite pass. Independent full tests also pass on each supported Python version;
+ the lockfile, dependency audit, OCR compatibility manifest, changed shell and
+ YAML files, and complete-range Gitleaks scan are clean.
+- Two target-version builds are byte-identical and pass Twine plus closed archive
+ inspection. The wheel and sdist retain zero runtime dependencies, contain the
+ ecosystem/framework package layout, and omit removed flat modules. Hash-locked
+ wheel and sdist installs pass supported-Python, hostile-shadow, isolated-import,
+ `pip check`, and restricted-`PATH` command checks.
+- An installed artifact completes the real stdio MCP protocol flow with the one
+ read-only `ocr_toolkit_evidence` tool. Synthetic fact, coverage, and delta
+ list/get checks preserve `.` as root and `repository` as an ordinary path;
+ Jinja2 and Twig template evidence is present and private modes remain intact.
+- Checksum-qualified OCR 1.9.2 preview selects ordinary source plus Jinja, Twig,
+ and extensionless conventional role templates without an unsupported-extension
+ result or session artifacts. This is a rules-selection probe, not another OCR
+ review. Towncrier draft, source-integrity/privacy checks, and `git diff --check`
+ pass. M2 implementation is locally complete; the roadmap remains in progress
+ until feature and stable 0.5.0 delivery are independently read back.
### Pre-additional-review Deterministic Validation Checkpoint
-- Signed local checkpoint `1f64c8a112c54df289649fcbc0d06ef9f91dbe84`
- is the exact validated tree; its signature, clean worktree, and ancestry from
- stable 0.4.7 are verified. No branch push occurred.
+- Rewritten signed checkpoint `a05399fd02916493bd516caae313b618becda221`
+ has the exact pre-additional-review tree after absorbing its plan-only receipt;
+ its signature, clean-tree evidence, and ancestry from stable 0.4.7 are verified.
+ No branch push occurred.
- Routine formatting, Ruff, strict mypy, Bandit, branch coverage, and the full
test suite pass. Independent full test runs pass on each supported Python
interpreter. Complete first-parent Gitleaks and dependency audit are clean.
@@ -353,18 +458,20 @@ validation, before protected feature and stable delivery.
extension, and creates no session store.
- Towncrier draft, OCR compatibility manifest, lockfile, complete-range source
integrity, changed-public-content privacy, tracked-artifact exclusion,
- `git diff --check`, and private receipt modes pass. Work Queue item 12 and the
- owner-authorized additional full OCR review with concurrency 2 are now the
- only next review-stage actions.
+ `git diff --check`, and private receipt modes pass. At this checkpoint, the
+ additional concurrency-2 review and its bounded remediation remained; both
+ subsequently completed as recorded above.
### First Full OCR Review And Remediation Checkpoint
- The first full local OCR review ran once over exact range
`3caa50b4fc5026da79c7f2ceae1deef31715f814..69a44f7efb053ff11cfc28da1ae910e8f34a8d0b`
with the checksum-qualified OCR 1.9.2 binary through an exact-HEAD installed
- wheel. No GitLab posting command or credential was used. Private result,
- stderr, bootstrap, and evidence artifacts retain owner-only permissions and
- the ignored review context is absent from the tracked range.
+ wheel. That pre-rewrite head remains the immutable review-receipt identity;
+ rewritten signed checkpoint `2ebc198c66217a49e5fd2aa92ab40d70c6a6d709`
+ has its exact tree. No GitLab posting command or credential was used. Private
+ result, stderr, bootstrap, and evidence artifacts retain owner-only permissions,
+ and the ignored review context is absent from the tracked range.
- OCR completed most selected items and stopped two evidence modules at its
tool-round budget. The accepted partial result contains eight findings and
records 261 mandatory `ocr_toolkit_evidence` calls; the toolkit-authored
diff --git a/changelog.d/77.feature.md b/changelog.d/77.feature.md
index 33c6d8a..70516a1 100644
--- a/changelog.d/77.feature.md
+++ b/changelog.d/77.feature.md
@@ -1 +1 @@
-Add bounded framework and template evidence plugins for Jinja2, Go web frameworks, Symfony/PHP, and React/TypeScript, including unambiguous root components, applicability-aware Go replacements, include-graph completeness, fail-closed provider isolation, scoped coverage, and first-class redacted delta queries through the built-in evidence MCP.
+Add a bounded ecosystem-adapter layer plus framework and template evidence plugins for Jinja2, Go web frameworks, Symfony/PHP, and React/TypeScript, including unambiguous root components, applicability-aware Go replacements, include-graph completeness, fail-closed provider isolation, scoped coverage, and first-class redacted delta queries through the built-in evidence MCP.
diff --git a/docs/configuration.md b/docs/configuration.md
index d2069ec..e9c4e56 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -86,6 +86,8 @@ The compact bootstrap contains the same safe inventory of independent server/too
Evidence-store schema v2 includes closed `framework.detected` (`repository.framework-evidence/v1`) and `template.file` (`repository.template-evidence/v1`) facts from package-owned static plugins. Current plugins cover Jinja2, Echo/Fiber, Symfony/Twig, and React/Next with related gRPC, TypeScript, and Vite declarations. Plugins consume only already bounded immutable manifest/tree evidence: they cannot execute repository commands, load repository code, use network access, or start a second MCP server. Framework versions use the ecosystem's deterministic source: lock files for Python, Composer, and JavaScript, but the direct requirement or effective replacement in `go.mod` for Go. Local Go replacements remain explicit partial evidence rather than being mistaken for the replaced module version. Templates and configuration paths belong to the nearest manifest-root component; conventional Ansible-role templates retain the role root. The exact component `.` denotes the repository root, while names such as `repository` are ordinary top-level paths; the same identities filter facts, coverage, and deltas through `ocr_toolkit_evidence`. Detailed declarations, resolutions, effective replacements, configuration/template paths, component scopes, and redacted base/head deltas remain available through its summary/list/get actions.
+Implementation-wise, package and automation metadata is normalized by the internal `ocr_toolkit.evidence.ecosystems` source-adapter layer before framework plugins consume it. This is not a user-configurable runtime plugin namespace: adapter registration, bounded immutable reads, storage, and MCP serving remain toolkit-owned closed contracts.
+
The synthetic GitLab `rules.json` uses additive `include` entries for `.j2`, `.jinja`, `.jinja2`, `.twig`, and conventional Ansible-role template paths because the [recommended OCR](compatibility.md) does not review those extensions by default. Explicit excludes still win. The matching Jinja/Twig rules are review guidance; they do not execute or render templates, infer runtime variables, or replace evidence completeness.
Evidence-store schema v2 adds `repository.evidence-coverage/v1` records keyed by component, domain, scope, immutable ref, and commit. Framework plugins publish `framework.declaration`, `framework.resolution`, `framework.configuration`, and `template.inventory` scopes. Supported malformed or omitted manifests, source-item limits, configuration/template output limits, unsafe template object types, local Go replacements, and isolated provider failures all prevent a false completeness claim. Only `complete` coverage permits a missing positive fact to support an absence claim; absent, `partial`, `runtime-dependent`, and `unavailable` coverage mean unknown. Schema-v1 stores remain readable but are explicitly treated as having unknown completeness. The Ansible adopter recognizes static, plugin-based, and executable inventory sources without execution and models the recursive role `defaults/main/` and `vars/main/` loader surface verified for ansible-core 2.17 through the current 2.x loader contract. Unsupported later loader behavior or bounded read/parser failures degrade coverage rather than becoming false completeness.
diff --git a/docs/development.md b/docs/development.md
index c0528fd..5ec0786 100644
--- a/docs/development.md
+++ b/docs/development.md
@@ -22,6 +22,12 @@ For artifact smoke tests, install the wheel and sdist into separate temporary vi
GitHub Actions storage is repository-owned infrastructure. CI restores setup-uv caches on pull requests but saves them only from `main`; CodeQL TRAP caching and the separately controlled v4 overlay-database mode are disabled, so the small repository receives a full analysis without per-run CodeQL cache writes. Workflow artifacts use a seven-day handoff window. The weekly **Actions storage maintenance** workflow deletes all CodeQL caches, non-main or superseded setup-uv caches, superseded Gitleaks caches, artifacts older than seven days, ordinary logs older than 14 days, and release/TestPyPI logs older than 30 days. It deletes only log archives, never workflow runs or check metadata. Scheduled log cleanup uses a bounded 14-day retry window so immutable run history does not get scanned and retried forever. Manual dispatch is a dry run unless `execute` is selected; the same plan is available locally with `python scripts/actions_cleanup.py`, requires `--execute` for deletion, and accepts `--include-all-old-logs` for a deliberate one-time historical cleanup.
+## Extending ecosystem evidence
+
+Normalized source adapters live under `src/ocr_toolkit/evidence/ecosystems/`. Shared parser result contracts belong in `ecosystems/contracts.py`; Python, JavaScript, Go, and PHP package metadata each have one adapter module. Ansible keeps Galaxy requirements and topology/inventory analysis as separate modules under `ecosystems/ansible/`. These adapters consume text or already bounded metadata and return normalized facts: they do not own Git or filesystem reads, subprocesses, network access, framework derivation, persistence, or MCP lifecycle. Register path matching and immutable blob orchestration in `evidence/collectors.py`; keep cross-ecosystem container and CI extraction in `evidence/infrastructure.py`.
+
+Do not add a flat compatibility module when moving or adding an adapter. Parser changes need semantic-variant fixtures, explicit item/include bounds, malformed-input behavior, redaction checks, and collector/delta/MCP coverage where applicable. A new framework that interprets those normalized facts belongs in `evidence/frameworks/`, not in the source adapter.
+
## Extending framework evidence
Framework support lives under `src/ocr_toolkit/evidence/frameworks/`. Add an ecosystem declaration under `frameworks/providers/` and register it explicitly in `frameworks/registry.py`; keep Jinja2 first in the bounded priority order. Reuse the generic package detector where its direct-declaration and resolution semantics fit. Extend the closed schema and generic detector deliberately when a demonstrated provider needs different normalized semantics. Do not add entry-point discovery, compatibility shims, repository reads, filesystem access, subprocesses, network calls, mutation, or another MCP lifecycle to this package. Git/tree/manifest collection, storage, and serving remain core-owned boundaries.
diff --git a/docs/engineering/toolkit_strategy.md b/docs/engineering/toolkit_strategy.md
index b2cef47..13f5d1a 100644
--- a/docs/engineering/toolkit_strategy.md
+++ b/docs/engineering/toolkit_strategy.md
@@ -85,6 +85,8 @@ Evidence already distinguishes declared constraints, locked versions, runtime de
Implemented collectors cover Python declarations, requirements, uv, Poetry, Pipenv locks, and standardized locks; JavaScript package metadata plus npm, Yarn, and pnpm locks; Go modules, toolchains, requirements, replacements, and checksums; Composer manifests, locks, and platform evidence; Ansible Galaxy requirements, role topology, inventories, and runtime-dependent coverage; and declarative container and GitLab CI images. Further expansion follows demonstrated repository use and requires synthetic fixtures, deterministic semantics, size bounds, and explicit behavior for malformed or missing files.
+The normalized adapters form the internal `ocr_toolkit.evidence.ecosystems` layer below framework derivation. Shared fact/result contracts plus Python, JavaScript, Go, and PHP adapters live directly in that package; Ansible Galaxy and topology/inventory adapters live under `ecosystems.ansible` because they are distinct inputs from one automation ecosystem, not framework plugins. `collectors.py` retains immutable Git/tree orchestration and source-status ownership, while cross-ecosystem container/CI extraction, storage, and MCP serving remain outside the adapter package. The package has no old flat-module shims, dynamic discovery, repository I/O, or upward dependency on frameworks or lifecycle services.
+
### Framework and template evidence
Framework support is package-owned static plugin extraction, not a code graph or framework-specific review engine. The established registry covers Jinja2 and Jinja/Ansible-style templates, Echo/Fiber with direct gRPC stack context, Symfony/Twig, and React/Next with TypeScript/Vite context. Plugins receive only immutable normalized dependency/tree evidence and exact core-owned source-status records from the collector, publish closed framework/template facts plus scoped completeness, and cannot read the repository independently, execute commands, fetch the network, mutate state, or create another MCP/review flow. Components follow the nearest declaration manifest (or conventional Ansible role); `.` is the unambiguous repository-root component and every other value is a real path. Go uses effective `go.mod` requirement/replacement semantics, and every read, parse, configuration, template, or provider limit degrades its exact scope instead of turning missing facts into proof.
diff --git a/src/ocr_toolkit/evidence/collectors.py b/src/ocr_toolkit/evidence/collectors.py
index 838ab64..928dec3 100644
--- a/src/ocr_toolkit/evidence/collectors.py
+++ b/src/ocr_toolkit/evidence/collectors.py
@@ -8,7 +8,9 @@
from dataclasses import dataclass
from pathlib import PurePosixPath
-from ocr_toolkit.evidence.ansible import (
+from ocr_toolkit.evidence.coverage import CoverageObservation, compose_coverage
+from ocr_toolkit.evidence.ecosystems.ansible.requirements import parse_galaxy_requirements
+from ocr_toolkit.evidence.ecosystems.ansible.topology import (
collect_topology,
inventory_scope,
role_coverage_scope,
@@ -16,9 +18,27 @@
topology_candidate,
topology_coverage,
)
-from ocr_toolkit.evidence.ansible_requirements import parse_galaxy_requirements
-from ocr_toolkit.evidence.composer_manifests import parse_composer_json, parse_composer_lock
-from ocr_toolkit.evidence.coverage import CoverageObservation, compose_coverage
+from ocr_toolkit.evidence.ecosystems.contracts import (
+ MAX_MANIFEST_ITEMS,
+ ManifestFact,
+ ManifestParseResult,
+)
+from ocr_toolkit.evidence.ecosystems.go import parse_go_mod, parse_go_sum
+from ocr_toolkit.evidence.ecosystems.javascript import (
+ parse_package_json,
+ parse_package_lock,
+ parse_pnpm_lock,
+ parse_yarn_lock,
+)
+from ocr_toolkit.evidence.ecosystems.php import parse_composer_json, parse_composer_lock
+from ocr_toolkit.evidence.ecosystems.python import (
+ parse_pipfile_lock,
+ parse_poetry_lock,
+ parse_pylock,
+ parse_pyproject,
+ parse_requirements,
+ parse_uv_lock,
+)
from ocr_toolkit.evidence.frameworks import (
FrameworkPluginContext,
PluginCoverage,
@@ -27,19 +47,7 @@
collect_framework_plugins,
collect_template_files,
)
-from ocr_toolkit.evidence.go_manifests import parse_go_mod, parse_go_sum
from ocr_toolkit.evidence.infrastructure import infrastructure_candidate, parse_infrastructure_pins
-from ocr_toolkit.evidence.javascript_manifests import (
- parse_package_json,
- parse_package_lock,
- parse_pnpm_lock,
- parse_yarn_lock,
-)
-from ocr_toolkit.evidence.manifest_model import (
- MAX_MANIFEST_ITEMS,
- ManifestFact,
- ManifestParseResult,
-)
from ocr_toolkit.evidence.model import (
Confidence,
CoverageRecord,
@@ -50,14 +58,6 @@
RefRole,
TrustClass,
)
-from ocr_toolkit.evidence.python_manifests import (
- parse_pipfile_lock,
- parse_poetry_lock,
- parse_pylock,
- parse_pyproject,
- parse_requirements,
- parse_uv_lock,
-)
from ocr_toolkit.evidence.repository import (
GitRepositoryReader,
RepositoryEvidenceError,
diff --git a/src/ocr_toolkit/evidence/ecosystems/__init__.py b/src/ocr_toolkit/evidence/ecosystems/__init__.py
new file mode 100644
index 0000000..cd22776
--- /dev/null
+++ b/src/ocr_toolkit/evidence/ecosystems/__init__.py
@@ -0,0 +1 @@
+"""Bounded source adapters that normalize supported repository ecosystems."""
diff --git a/src/ocr_toolkit/evidence/ecosystems/ansible/__init__.py b/src/ocr_toolkit/evidence/ecosystems/ansible/__init__.py
new file mode 100644
index 0000000..f218def
--- /dev/null
+++ b/src/ocr_toolkit/evidence/ecosystems/ansible/__init__.py
@@ -0,0 +1 @@
+"""Bounded Ansible Galaxy and topology source adapters."""
diff --git a/src/ocr_toolkit/evidence/ansible_requirements.py b/src/ocr_toolkit/evidence/ecosystems/ansible/requirements.py
similarity index 100%
rename from src/ocr_toolkit/evidence/ansible_requirements.py
rename to src/ocr_toolkit/evidence/ecosystems/ansible/requirements.py
diff --git a/src/ocr_toolkit/evidence/ansible.py b/src/ocr_toolkit/evidence/ecosystems/ansible/topology.py
similarity index 100%
rename from src/ocr_toolkit/evidence/ansible.py
rename to src/ocr_toolkit/evidence/ecosystems/ansible/topology.py
diff --git a/src/ocr_toolkit/evidence/manifest_model.py b/src/ocr_toolkit/evidence/ecosystems/contracts.py
similarity index 100%
rename from src/ocr_toolkit/evidence/manifest_model.py
rename to src/ocr_toolkit/evidence/ecosystems/contracts.py
diff --git a/src/ocr_toolkit/evidence/go_manifests.py b/src/ocr_toolkit/evidence/ecosystems/go.py
similarity index 99%
rename from src/ocr_toolkit/evidence/go_manifests.py
rename to src/ocr_toolkit/evidence/ecosystems/go.py
index bc01b4d..5e2f307 100644
--- a/src/ocr_toolkit/evidence/go_manifests.py
+++ b/src/ocr_toolkit/evidence/ecosystems/go.py
@@ -2,7 +2,7 @@
from __future__ import annotations
-from ocr_toolkit.evidence.manifest_model import (
+from ocr_toolkit.evidence.ecosystems.contracts import (
MAX_MANIFEST_ITEMS,
ManifestFact,
ManifestParseResult,
diff --git a/src/ocr_toolkit/evidence/javascript_manifests.py b/src/ocr_toolkit/evidence/ecosystems/javascript.py
similarity index 99%
rename from src/ocr_toolkit/evidence/javascript_manifests.py
rename to src/ocr_toolkit/evidence/ecosystems/javascript.py
index ed5194d..f279c87 100644
--- a/src/ocr_toolkit/evidence/javascript_manifests.py
+++ b/src/ocr_toolkit/evidence/ecosystems/javascript.py
@@ -7,7 +7,7 @@
from urllib.parse import urlsplit
from ocr_toolkit.common.redaction import redact_url_userinfo
-from ocr_toolkit.evidence.manifest_model import (
+from ocr_toolkit.evidence.ecosystems.contracts import (
MAX_MANIFEST_ITEMS,
ManifestFact,
ManifestParseResult,
diff --git a/src/ocr_toolkit/evidence/composer_manifests.py b/src/ocr_toolkit/evidence/ecosystems/php.py
similarity index 99%
rename from src/ocr_toolkit/evidence/composer_manifests.py
rename to src/ocr_toolkit/evidence/ecosystems/php.py
index 71f29d5..f5a0703 100644
--- a/src/ocr_toolkit/evidence/composer_manifests.py
+++ b/src/ocr_toolkit/evidence/ecosystems/php.py
@@ -7,7 +7,7 @@
from urllib.parse import urlsplit
from ocr_toolkit.common.redaction import redact_url_userinfo
-from ocr_toolkit.evidence.manifest_model import (
+from ocr_toolkit.evidence.ecosystems.contracts import (
MAX_MANIFEST_ITEMS,
ManifestFact,
ManifestParseResult,
diff --git a/src/ocr_toolkit/evidence/python_manifests.py b/src/ocr_toolkit/evidence/ecosystems/python.py
similarity index 99%
rename from src/ocr_toolkit/evidence/python_manifests.py
rename to src/ocr_toolkit/evidence/ecosystems/python.py
index 155c1ed..9842b41 100644
--- a/src/ocr_toolkit/evidence/python_manifests.py
+++ b/src/ocr_toolkit/evidence/ecosystems/python.py
@@ -9,7 +9,7 @@
import tomllib # type: ignore[import-untyped]
from ocr_toolkit.common.redaction import redact_url_userinfo
-from ocr_toolkit.evidence.manifest_model import (
+from ocr_toolkit.evidence.ecosystems.contracts import (
MAX_MANIFEST_ITEMS,
ManifestFact,
ManifestParseResult,
diff --git a/src/ocr_toolkit/evidence/infrastructure.py b/src/ocr_toolkit/evidence/infrastructure.py
index 3a00d53..eb6afe0 100644
--- a/src/ocr_toolkit/evidence/infrastructure.py
+++ b/src/ocr_toolkit/evidence/infrastructure.py
@@ -6,7 +6,7 @@
from pathlib import PurePosixPath
from ocr_toolkit.common.redaction import redact_sensitive, redact_url_userinfo
-from ocr_toolkit.evidence.manifest_model import (
+from ocr_toolkit.evidence.ecosystems.contracts import (
MAX_MANIFEST_ITEMS,
ManifestFact,
ManifestParseResult,
diff --git a/tests/test_distribution_contents.py b/tests/test_distribution_contents.py
index ea132f2..d669dea 100644
--- a/tests/test_distribution_contents.py
+++ b/tests/test_distribution_contents.py
@@ -1,5 +1,6 @@
"""Contracts for the intentionally small published distribution contents."""
+import zipfile
from pathlib import Path
PROJECT_ROOT = Path(__file__).parents[1]
@@ -46,3 +47,47 @@ def test_review_runner_is_inside_the_wheel_runtime_package() -> None:
assert (PROJECT_ROOT / "src/ocr_toolkit/review_runner.py").is_file()
assert '[tool.hatch.build.targets.wheel]\npackages = ["src/ocr_toolkit"]' in pyproject
+
+
+def test_built_wheel_contains_ecosystem_packages_without_flat_parser_shims(
+ tmp_path: Path,
+) -> None:
+ """Lock the installed source-adapter layout rather than source imports alone."""
+
+ import subprocess
+ import sys
+
+ output = tmp_path / "dist"
+ subprocess.run(
+ [sys.executable, "-m", "build", "--wheel", "--outdir", str(output)],
+ cwd=PROJECT_ROOT,
+ check=True,
+ capture_output=True,
+ text=True,
+ )
+ wheel = next(output.glob("*.whl"))
+ with zipfile.ZipFile(wheel) as archive:
+ names = set(archive.namelist())
+
+ expected = {
+ "ocr_toolkit/evidence/ecosystems/__init__.py",
+ "ocr_toolkit/evidence/ecosystems/ansible/__init__.py",
+ "ocr_toolkit/evidence/ecosystems/ansible/requirements.py",
+ "ocr_toolkit/evidence/ecosystems/ansible/topology.py",
+ "ocr_toolkit/evidence/ecosystems/contracts.py",
+ "ocr_toolkit/evidence/ecosystems/go.py",
+ "ocr_toolkit/evidence/ecosystems/javascript.py",
+ "ocr_toolkit/evidence/ecosystems/php.py",
+ "ocr_toolkit/evidence/ecosystems/python.py",
+ }
+ removed = {
+ "ocr_toolkit/evidence/ansible.py",
+ "ocr_toolkit/evidence/ansible_requirements.py",
+ "ocr_toolkit/evidence/composer_manifests.py",
+ "ocr_toolkit/evidence/go_manifests.py",
+ "ocr_toolkit/evidence/javascript_manifests.py",
+ "ocr_toolkit/evidence/manifest_model.py",
+ "ocr_toolkit/evidence/python_manifests.py",
+ }
+ assert expected <= names
+ assert not removed & names
diff --git a/tests/test_evidence_ansible.py b/tests/test_evidence_ansible.py
index 4fab43a..331476a 100644
--- a/tests/test_evidence_ansible.py
+++ b/tests/test_evidence_ansible.py
@@ -5,15 +5,15 @@
import subprocess
from pathlib import Path
-from ocr_toolkit.evidence.ansible import (
+from ocr_toolkit.evidence.collect import collect_repository_evidence
+from ocr_toolkit.evidence.collectors import MAX_TOPOLOGY_FACTS_PER_KIND
+from ocr_toolkit.evidence.ecosystems.ansible.requirements import parse_galaxy_requirements
+from ocr_toolkit.evidence.ecosystems.ansible.topology import (
collect_topology,
selected_role_paths,
topology_candidate,
topology_coverage,
)
-from ocr_toolkit.evidence.ansible_requirements import parse_galaxy_requirements
-from ocr_toolkit.evidence.collect import collect_repository_evidence
-from ocr_toolkit.evidence.collectors import MAX_TOPOLOGY_FACTS_PER_KIND
from ocr_toolkit.evidence.mcp import call_tool
from ocr_toolkit.evidence.model import CoverageState
diff --git a/tests/test_evidence_collectors.py b/tests/test_evidence_collectors.py
index dac2d0f..46bde59 100644
--- a/tests/test_evidence_collectors.py
+++ b/tests/test_evidence_collectors.py
@@ -10,10 +10,6 @@
import pytest
from ocr_toolkit.evidence import GitRepositoryReader, RefRole
-from ocr_toolkit.evidence.ansible_requirements import (
- MAX_GALAXY_REQUIREMENTS,
- parse_galaxy_requirements,
-)
from ocr_toolkit.evidence.collect import collect_repository_evidence
from ocr_toolkit.evidence.collectors import (
MAX_MANIFEST_INCLUDE_DIAGNOSTICS,
@@ -25,9 +21,13 @@
manifest_collector,
parse_manifest,
)
-from ocr_toolkit.evidence.manifest_model import MAX_MANIFEST_ITEMS
+from ocr_toolkit.evidence.ecosystems.ansible.requirements import (
+ MAX_GALAXY_REQUIREMENTS,
+ parse_galaxy_requirements,
+)
+from ocr_toolkit.evidence.ecosystems.contracts import MAX_MANIFEST_ITEMS
+from ocr_toolkit.evidence.ecosystems.python import parse_requirements
from ocr_toolkit.evidence.mcp import handle_request
-from ocr_toolkit.evidence.python_manifests import parse_requirements
from ocr_toolkit.evidence.repository import BoundedBlobRead, RepositoryObject
diff --git a/tests/test_evidence_composer.py b/tests/test_evidence_composer.py
index 1037a22..df0afb0 100644
--- a/tests/test_evidence_composer.py
+++ b/tests/test_evidence_composer.py
@@ -11,8 +11,8 @@
from ocr_toolkit.evidence.collect import collect_repository_evidence
from ocr_toolkit.evidence.collectors import collect_ref_facts, manifest_collector
-from ocr_toolkit.evidence.composer_manifests import parse_composer_json, parse_composer_lock
-from ocr_toolkit.evidence.manifest_model import MAX_MANIFEST_ITEMS, ManifestParseResult
+from ocr_toolkit.evidence.ecosystems.contracts import MAX_MANIFEST_ITEMS, ManifestParseResult
+from ocr_toolkit.evidence.ecosystems.php import parse_composer_json, parse_composer_lock
from ocr_toolkit.evidence.mcp import handle_request
from ocr_toolkit.evidence.model import RefRole
from ocr_toolkit.evidence.repository import GitRepositoryReader
diff --git a/tests/test_evidence_ecosystems.py b/tests/test_evidence_ecosystems.py
new file mode 100644
index 0000000..ba23df3
--- /dev/null
+++ b/tests/test_evidence_ecosystems.py
@@ -0,0 +1,85 @@
+"""Architecture contracts for bounded ecosystem source adapters."""
+
+from __future__ import annotations
+
+import ast
+from pathlib import Path
+
+PROJECT_ROOT = Path(__file__).parents[1]
+EVIDENCE_ROOT = PROJECT_ROOT / "src/ocr_toolkit/evidence"
+ECOSYSTEMS_ROOT = EVIDENCE_ROOT / "ecosystems"
+
+
+def test_ecosystem_adapters_have_one_closed_package_layout_without_flat_shims() -> None:
+ """Keep source parsers below frameworks in one explicit internal package."""
+
+ expected = {
+ "__init__.py",
+ "ansible/__init__.py",
+ "ansible/requirements.py",
+ "ansible/topology.py",
+ "contracts.py",
+ "go.py",
+ "javascript.py",
+ "php.py",
+ "python.py",
+ }
+ actual = {
+ path.relative_to(ECOSYSTEMS_ROOT).as_posix() for path in ECOSYSTEMS_ROOT.rglob("*.py")
+ }
+ assert actual == expected
+
+ removed_flat_modules = {
+ "ansible.py",
+ "ansible_requirements.py",
+ "composer_manifests.py",
+ "go_manifests.py",
+ "javascript_manifests.py",
+ "manifest_model.py",
+ "python_manifests.py",
+ }
+ assert not any((EVIDENCE_ROOT / name).exists() for name in removed_flat_modules)
+
+
+def test_ecosystem_adapters_cannot_own_or_call_higher_evidence_layers() -> None:
+ """Prevent normalized source adapters from growing I/O or lifecycle ownership."""
+
+ forbidden_imports = {
+ "http",
+ "importlib",
+ "os",
+ "requests",
+ "socket",
+ "subprocess",
+ "urllib.error",
+ "urllib.request",
+ }
+ forbidden_evidence_modules = {
+ "ocr_toolkit.evidence.collect",
+ "ocr_toolkit.evidence.collectors",
+ "ocr_toolkit.evidence.frameworks",
+ "ocr_toolkit.evidence.infrastructure",
+ "ocr_toolkit.evidence.mcp",
+ "ocr_toolkit.evidence.repository",
+ "ocr_toolkit.evidence.store",
+ }
+ forbidden_calls = {"__import__", "eval", "exec", "open"}
+
+ for source_path in sorted(ECOSYSTEMS_ROOT.rglob("*.py")):
+ tree = ast.parse(source_path.read_text(encoding="utf-8"), filename=str(source_path))
+ assert ast.get_docstring(tree)
+ for node in ast.walk(tree):
+ if isinstance(node, ast.Import):
+ imported = {alias.name for alias in node.names}
+ assert not imported & forbidden_imports
+ assert not imported & forbidden_evidence_modules
+ elif isinstance(node, ast.ImportFrom) and node.module:
+ assert node.module not in forbidden_imports
+ assert not any(
+ node.module == module or node.module.startswith(module + ".")
+ for module in forbidden_evidence_modules
+ )
+ if node.module == "pathlib":
+ assert {alias.name for alias in node.names} == {"PurePosixPath"}
+ elif isinstance(node, ast.Call) and isinstance(node.func, ast.Name):
+ assert node.func.id not in forbidden_calls
diff --git a/tests/test_evidence_framework_plugins.py b/tests/test_evidence_framework_plugins.py
index 93acb4d..5241a6d 100644
--- a/tests/test_evidence_framework_plugins.py
+++ b/tests/test_evidence_framework_plugins.py
@@ -12,6 +12,7 @@
import pytest
from ocr_toolkit.evidence.collect import collect_repository_evidence
+from ocr_toolkit.evidence.ecosystems.contracts import MAX_MANIFEST_ITEMS
from ocr_toolkit.evidence.frameworks import (
BUILTIN_FRAMEWORK_PLUGINS,
MAX_CONFIGURATION_PATHS,
@@ -27,7 +28,6 @@
REACT_PLUGIN,
SYMFONY_PLUGIN,
)
-from ocr_toolkit.evidence.manifest_model import MAX_MANIFEST_ITEMS
from ocr_toolkit.evidence.mcp import call_tool
from ocr_toolkit.evidence.model import Confidence, EvidenceRecord, RefRole, TrustClass
from ocr_toolkit.evidence.project import render_bootstrap
diff --git a/tests/test_evidence_go.py b/tests/test_evidence_go.py
index c705801..e40cbda 100644
--- a/tests/test_evidence_go.py
+++ b/tests/test_evidence_go.py
@@ -8,8 +8,8 @@
from ocr_toolkit.evidence.collect import collect_repository_evidence
from ocr_toolkit.evidence.collectors import collect_ref_facts, manifest_collector
-from ocr_toolkit.evidence.go_manifests import parse_go_mod, parse_go_sum
-from ocr_toolkit.evidence.manifest_model import MAX_MANIFEST_ITEMS
+from ocr_toolkit.evidence.ecosystems.contracts import MAX_MANIFEST_ITEMS
+from ocr_toolkit.evidence.ecosystems.go import parse_go_mod, parse_go_sum
from ocr_toolkit.evidence.mcp import handle_request
from ocr_toolkit.evidence.model import RefRole
from ocr_toolkit.evidence.repository import GitRepositoryReader
diff --git a/tests/test_evidence_infrastructure.py b/tests/test_evidence_infrastructure.py
index 63e2c78..3c8ebdc 100644
--- a/tests/test_evidence_infrastructure.py
+++ b/tests/test_evidence_infrastructure.py
@@ -8,8 +8,8 @@
from pathlib import Path
from ocr_toolkit.evidence.collect import collect_repository_evidence
+from ocr_toolkit.evidence.ecosystems.contracts import MAX_MANIFEST_ITEMS
from ocr_toolkit.evidence.infrastructure import infrastructure_candidate, parse_infrastructure_pins
-from ocr_toolkit.evidence.manifest_model import MAX_MANIFEST_ITEMS
from ocr_toolkit.evidence.mcp import handle_request
diff --git a/tests/test_evidence_javascript.py b/tests/test_evidence_javascript.py
index 5a62120..f6094cd 100644
--- a/tests/test_evidence_javascript.py
+++ b/tests/test_evidence_javascript.py
@@ -11,13 +11,13 @@
from ocr_toolkit.evidence.collect import collect_repository_evidence
from ocr_toolkit.evidence.collectors import collect_ref_facts, parse_manifest
-from ocr_toolkit.evidence.javascript_manifests import (
+from ocr_toolkit.evidence.ecosystems.contracts import MAX_MANIFEST_ITEMS
+from ocr_toolkit.evidence.ecosystems.javascript import (
parse_package_json,
parse_package_lock,
parse_pnpm_lock,
parse_yarn_lock,
)
-from ocr_toolkit.evidence.manifest_model import MAX_MANIFEST_ITEMS
from ocr_toolkit.evidence.mcp import handle_request
from ocr_toolkit.evidence.model import RefRole
from ocr_toolkit.evidence.repository import GitRepositoryReader