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/.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/.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..5342c2a 100644
--- a/PLANS.md
+++ b/PLANS.md
@@ -2,9 +2,531 @@
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 and local validation complete; history and external delivery pending
+Owner: Codex
+Last Updated: 2026-08-12
+Release Classification: release-required
+Target Stable Version: 0.5.0
+Tracking Issues: #76, #78 (OCR 1.9.2); 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, repair issue #76 release recovery, and
+qualify checksum-pinned OCR 1.9.2 through issue #78 before final validation.
+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
+
+- 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 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.
+- 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`.
+- 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 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 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
+ 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.
+- 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
+
+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. [x] Repair issue #76 draft-Release identity, canonical issue-comment
+ newline, and idempotent skipped-publisher recovery with synthetic tests.
+3. [x] Implement the bounded static plugin protocol, manifest-root components,
+ closed framework/template records, limits, coverage, and MCP/store contracts.
+4. [x] Implement Jinja2 dependency and Jinja/Ansible-template evidence plus the
+ additive Jinja rules pack.
+5. [x] Implement direct Echo/Fiber evidence and conservative related gRPC data.
+6. [x] Implement Symfony/Twig dependency, configuration, template, and rules
+ evidence.
+7. [x] Implement React/Next framework evidence with TypeScript/Vite related
+ signals and npm/Yarn/pnpm resolution.
+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. [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. [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. [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.
+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.
+
+### 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.
+
+### 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
+ 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 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 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.
+
+### 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 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
+ 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 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`. 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.
+ 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 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
+
+- 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.
+- 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. 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. 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
+ 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
+
+- 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.
+- 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 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. 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
+
+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/README.md b/README.md
index d39dc11..c1dfd30 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`.
@@ -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 aad29fe..8808bcf 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -2,17 +2,17 @@
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
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"]
+ M1 --> M6["M6 Later and conditional work
conditional"]
classDef established fill:#1f883d,stroke:#116329,color:#ffffff
classDef next fill:#0969da,stroke:#0550ae,color:#ffffff
@@ -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 | 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. |
@@ -39,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/76.bugfix.md b/changelog.d/76.bugfix.md
new file mode 100644
index 0000000..ebdc16c
--- /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, 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
new file mode 100644
index 0000000..70516a1
--- /dev/null
+++ b/changelog.d/77.feature.md
@@ -0,0 +1 @@
+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/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.
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/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/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 7e40d95..e9c4e56 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -82,9 +82,15 @@ 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 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. 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.
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/development.md b/docs/development.md
index 544e6e3..5ec0786 100644
--- a/docs/development.md
+++ b/docs/development.md
@@ -22,6 +22,20 @@ 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.
+
+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 af3bb16..13f5d1a 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
@@ -85,9 +85,15 @@ 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
+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 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 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.
+
+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.
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..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). 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/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/docs/security.md b/docs/security.md
index 53d19ab..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.
@@ -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/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/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..1797f50
--- /dev/null
+++ b/scripts/github_release_api.py
@@ -0,0 +1,531 @@
+#!/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
+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}$")
+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 _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 from one bounded validated descriptor."""
+
+ 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:
+ """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."""
+
+ # 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,
+ 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, MAX_RELEASE_LIST_PAGES + 1):
+ _status, page_payload = _request(
+ origin=API_ORIGIN,
+ endpoint=(f"/repos/{repository}/releases?per_page={RELEASES_PER_PAGE}&page={page}"),
+ token=token,
+ )
+ 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) < RELEASES_PER_PAGE:
+ break
+ else:
+ _status, overflow = _request(
+ origin=API_ORIGIN,
+ 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 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]
+ 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
+ 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 = _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}
+ )
+ _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") != len(body)
+ 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/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/scripts/release_issue_receipt.py b/scripts/release_issue_receipt.py
index 19f91d7..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
@@ -27,7 +29,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"
)
@@ -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
@@ -119,7 +158,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/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/collectors.py b/src/ocr_toolkit/evidence/collectors.py
index 16796a2..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,22 +18,36 @@
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.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 (
+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.manifest_model import (
- MAX_MANIFEST_ITEMS,
- ManifestFact,
- ManifestParseResult,
+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,
+ PluginFact,
+ PluginSourceStatus,
+ collect_framework_plugins,
+ collect_template_files,
)
+from ocr_toolkit.evidence.infrastructure import infrastructure_candidate, parse_infrastructure_pins
from ocr_toolkit.evidence.model import (
Confidence,
CoverageRecord,
@@ -42,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,
@@ -82,29 +90,32 @@
@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]
@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:
@@ -155,26 +166,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,
),
@@ -366,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],
@@ -380,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
@@ -415,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
@@ -425,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)
@@ -432,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
@@ -447,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)
@@ -454,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(
@@ -474,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
@@ -506,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
@@ -515,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
@@ -538,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,
@@ -554,9 +627,56 @@ def _read_python_requirement_graph(
diagnostics,
truncation_notice="Python requirements include diagnostics were truncated",
),
+ _roots_reaching_graph_degradation(initial_paths, normalized_edges, degraded_paths),
)
+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,
@@ -615,6 +735,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:
@@ -635,6 +765,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
@@ -656,9 +793,39 @@ 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)
+ 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),
@@ -724,6 +891,23 @@ 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 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 and source_status.state != "partial":
+ source_statuses[path] = PluginSourceStatus(
+ source_status.path,
+ source_status.ecosystem,
+ source_status.roles,
+ "complete",
+ )
facts = [
ManifestFact(
"repository.manifest",
@@ -781,6 +965,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":
@@ -820,6 +1012,24 @@ def unavailable_topology(entry: RepositoryObject, reason: str) -> None:
trust=trust,
)
)
+ plugin_context = FrameworkPluginContext(
+ records=tuple(records),
+ entries=entries,
+ 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, template_notices = collect_template_files(plugin_context)
+ 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, *template_notices))
if coverage_sink is not None:
for (domain, scope), observations in sorted(coverage_observations.items()):
coverage_sink.append(
@@ -832,6 +1042,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/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/frameworks/__init__.py b/src/ocr_toolkit/evidence/frameworks/__init__.py
new file mode 100644
index 0000000..e248b1a
--- /dev/null
+++ b/src/ocr_toolkit/evidence/frameworks/__init__.py
@@ -0,0 +1,42 @@
+"""Bounded static framework providers and template evidence inventory."""
+
+from ocr_toolkit.evidence.frameworks.contracts import (
+ MAX_CONFIGURATION_PATHS,
+ MAX_PLUGIN_COVERAGE,
+ MAX_PLUGIN_FACTS,
+ MAX_PLUGIN_NOTICES,
+ 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_COVERAGE",
+ "MAX_PLUGIN_FACTS",
+ "MAX_PLUGIN_NOTICES",
+ "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..dc44e69
--- /dev/null
+++ b/src/ocr_toolkit/evidence/frameworks/contracts.py
@@ -0,0 +1,81 @@
+"""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_PLUGIN_COVERAGE = 1_024
+MAX_PLUGIN_NOTICES = 64
+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/frameworks/detection.py b/src/ocr_toolkit/evidence/frameworks/detection.py
new file mode 100644
index 0000000..51b3dbd
--- /dev/null
+++ b/src/ocr_toolkit/evidence/frameworks/detection.py
@@ -0,0 +1,635 @@
+"""Generic bounded package-backed framework detection without repository I/O."""
+
+from __future__ import annotations
+
+import re
+from collections.abc import Iterable, Mapping
+from dataclasses import dataclass
+from pathlib import PurePosixPath
+from typing import TYPE_CHECKING
+
+from ocr_toolkit.evidence.coverage import CoverageObservation
+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
+
+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 ROOT_COMPONENT if parent == "." else parent
+
+
+def _fact_value(record: EvidenceRecord | None) -> Mapping[str, EvidenceValue] | None:
+ """Return one normalized manifest fact mapping from a stored evidence record."""
+
+ 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
+
+
+def package_name(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 exact version carried by one declaration or 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_declaration(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 _path_is_within(path: str, component: str) -> bool:
+ """Return whether one repository path is inside a canonical component."""
+
+ return component == ROOT_COMPONENT 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 == ROOT_COMPONENT else item.count("/") + 1)
+ if matches
+ else None
+ )
+
+
+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(
+ name: str,
+ role: str,
+ declarations: tuple[EvidenceRecord, ...],
+ resolutions: tuple[EvidenceRecord, ...],
+ *,
+ declarations_resolve: bool = False,
+) -> dict[str, EvidenceValue]:
+ """Build one deterministic related-stack value."""
+
+ 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 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_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."""
+
+ 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_name(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_name(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,
+ framework: str,
+ ecosystem: str,
+ category: str,
+ component: str,
+ declarations: tuple[EvidenceRecord, ...],
+ resolutions: tuple[EvidenceRecord, ...],
+ configuration_paths: tuple[str, ...],
+ configuration_state: str,
+ related: tuple[dict[str, EvidenceValue], ...] = (),
+ replacement: EvidenceRecord | None = None,
+ declarations_resolve: bool = False,
+) -> PluginFact:
+ """Build one closed framework fact with mutable version data outside identity."""
+
+ resolution_values = _resolution_values(
+ declarations,
+ resolutions,
+ replacement=replacement,
+ declarations_resolve=declarations_resolve,
+ )
+ 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:
+ package = package_name(record)
+ if package is None:
+ continue
+ declaration_values.append(
+ {
+ "package": package,
+ "scope": _scope(record),
+ "declared_value": _declared_value(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,
+ "configuration_state": configuration_state,
+ }
+ value["declarations"] = sorted(
+ declaration_values,
+ key=lambda item: (
+ str(item.get("package")),
+ str(item.get("scope")),
+ 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,
+ f"{plugin}:{framework}",
+ source_path,
+ value,
+ )
+
+
+def coverage_observation(
+ *,
+ component: str,
+ plugin: str,
+ framework: str | None,
+ state: CoverageState,
+ reason: str,
+ positive: bool = False,
+ domain: str | None = None,
+) -> PluginCoverage:
+ """Build one plugin coverage observation using closed scopes."""
+
+ 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,
+ selected_domain,
+ scope,
+ CoverageObservation(state, reason, positive),
+ )
+
+
+def plugin_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 _component_declarations(
+ records: tuple[EvidenceRecord, ...], component: str, ecosystem: str
+) -> tuple[EvidenceRecord, ...]:
+ """Return dependency declarations rooted in one canonical component."""
+
+ return tuple(
+ record
+ for record in records
+ if record.component == ecosystem
+ and record.kind == "dependency.declared"
+ and component_root(record.source_path) == component
+ )
+
+
+def _component_resolutions(
+ records: tuple[EvidenceRecord, ...], component: str, ecosystem: str
+) -> tuple[EvidenceRecord, ...]:
+ """Return resolution records rooted in one exact manifest component."""
+
+ return tuple(
+ record
+ for record in records
+ if record.component == ecosystem
+ 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."""
+
+ for state, reason in (
+ ("unavailable", "parse-unavailable"),
+ ("omitted", "bounded-source-omission"),
+ ("partial", "source-item-limit"),
+ ):
+ 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
+
+
+@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 declarations and nearest deterministic resolution evidence."""
+
+ facts: list[PluginFact] = []
+ coverage: list[PluginCoverage] = []
+ notices: list[str] = []
+ 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_declaration(record, self.ecosystem)
+ )
+ locked = _component_resolutions(context.records, component, self.ecosystem)
+ replacements = tuple(
+ record
+ 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,
+ )
+ related_values: list[dict[str, EvidenceValue]] = []
+ for item in self.related:
+ item_declarations = tuple(
+ 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_name(record) in item.packages
+ )
+ related_values.append(
+ _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_name(record) in spec.packages
+ )
+ if not direct:
+ continue
+ component_positive = True
+ 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_observation(
+ 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,
+ framework=spec.framework,
+ ecosystem=self.ecosystem,
+ category=spec.category,
+ component=component,
+ declarations=direct,
+ resolutions=resolved,
+ configuration_paths=component_config,
+ configuration_state="partial" if config_truncated else "complete",
+ related=tuple(related_values),
+ 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_observation(
+ component=component,
+ plugin=self.plugin_id,
+ framework=spec.framework,
+ state=state,
+ reason=reason,
+ positive=True,
+ )
+ )
+ coverage.append(
+ coverage_observation(
+ 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_observation(
+ component=component,
+ plugin=self.plugin_id,
+ framework=None,
+ state=declaration_state,
+ reason=declaration_reason,
+ 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))
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..5caa8c2
--- /dev/null
+++ b/src/ocr_toolkit/evidence/frameworks/registry.py
@@ -0,0 +1,165 @@
+"""Static package-owned framework provider registry and failure isolation."""
+
+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,
+)
+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.frameworks.schema import validate_plugin_record
+from ocr_toolkit.evidence.model import CoverageState
+
+BUILTIN_FRAMEWORK_PLUGINS: tuple[FrameworkPlugin, ...] = (
+ JINJA2_PLUGIN,
+ GO_WEB_PLUGIN,
+ SYMFONY_PLUGIN,
+ REACT_PLUGIN,
+)
+
+
+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, ...]]:
+ """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 = _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
+ # 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))),
+ tuple(dict.fromkeys(notices)),
+ )
diff --git a/src/ocr_toolkit/evidence/frameworks/schema.py b/src/ocr_toolkit/evidence/frameworks/schema.py
new file mode 100644
index 0000000..5fff749
--- /dev/null
+++ b/src/ocr_toolkit/evidence/frameworks/schema.py
@@ -0,0 +1,275 @@
+"""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"}
+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"},
+}
+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]:
+ """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,
+ 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) > max_length
+ 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, 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")
+ 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) -> tuple[str, str]:
+ """Validate one `framework.detected` fact and return plugin/framework identity."""
+
+ root = _exact_mapping(
+ value,
+ {
+ "schema_version",
+ "plugin",
+ "framework",
+ "ecosystem",
+ "category",
+ "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")
+ 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",
+ {"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",
+ max_length=MAX_MANIFEST_SCALAR_CHARS,
+ )
+ _path(item["source_path"], "framework declaration source")
+ 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",
+ max_length=MAX_MANIFEST_SCALAR_CHARS,
+ )
+ _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",
+ max_length=MAX_MANIFEST_SCALAR_CHARS,
+ )
+ 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",
+ max_length=MAX_MANIFEST_SCALAR_CHARS,
+ )
+ 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")
+ 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",
+ {"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",
+ 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")
+ for source in sources:
+ _path(source, "related framework source path")
+ return plugin, framework
+
+
+def validate_template_value(value: EvidenceValue) -> tuple[str, str]:
+ """Validate one `template.file` fact and return plugin/engine identity."""
+
+ 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")
+ 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:
+ 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 and bind its envelope identity to nested values."""
+
+ envelope = _exact_mapping(value, {"identity", "fact"}, "plugin evidence envelope")
+ identity = _string(envelope["identity"], "plugin evidence identity")
+ fact = envelope["fact"]
+ if kind == "framework.detected":
+ 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")
+ 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..c8dad8d
--- /dev/null
+++ b/src/ocr_toolkit/evidence/frameworks/templates.py
@@ -0,0 +1,144 @@
+"""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)
+ }
+ 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)
+ 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
+ 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(
+ "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/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/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 668867b..a58a4d6 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.frameworks.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",
}
)
@@ -131,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."""
@@ -151,9 +163,13 @@ def add(self, record: EvidenceRecord) -> bool:
raise EvidenceStoreError(f"unregistered evidence kind: {record.kind}")
try:
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
+ except ValueError as exc:
+ raise EvidenceStoreError(f"invalid {record.kind} evidence value") from exc
redacted = EvidenceRecord(
kind=record.kind,
value=redacted_value,
@@ -205,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."""
@@ -248,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": {
@@ -416,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/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_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 19c5fe7..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,8 +21,13 @@
manifest_collector,
parse_manifest,
)
+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
@@ -388,6 +389,124 @@ 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:
+ """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 == "."
+ 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 == "."
+ 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 +1099,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_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
new file mode 100644
index 0000000..5241a6d
--- /dev/null
+++ b/tests/test_evidence_framework_plugins.py
@@ -0,0 +1,1129 @@
+"""Synthetic contracts for static framework and template evidence plugins."""
+
+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.ecosystems.contracts import MAX_MANIFEST_ITEMS
+from ocr_toolkit.evidence.frameworks import (
+ BUILTIN_FRAMEWORK_PLUGINS,
+ MAX_CONFIGURATION_PATHS,
+ MAX_PLUGIN_FACTS,
+ FrameworkPluginContext,
+ FrameworkPluginResult,
+ collect_framework_plugins,
+ collect_template_files,
+)
+from ocr_toolkit.evidence.frameworks.providers import (
+ GO_WEB_PLUGIN,
+ JINJA2_PLUGIN,
+ REACT_PLUGIN,
+ SYMFONY_PLUGIN,
+)
+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
+
+
+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_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:
+ """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"
+ 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"
+ 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")
+ )
+ twig_fact = cast(dict[str, Any], twig.to_dict()["value"])["fact"]
+ 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."""
+
+ 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 = 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")
+ 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)
+
+
+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=".",
+ domain="framework.resolution",
+ scope="go-web:echo",
+ )
+ assert resolution.state.value == "partial"
+ 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:
+ """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": ".",
+ "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["."]["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."""
+
+ 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=".",
+ 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.frameworks.registry.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_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."""
+
+ 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"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")
+ (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=".",
+ domain="framework.declaration",
+ scope="jinja2",
+ )
+ assert jinja_declarations.state.value == "unavailable"
+ assert jinja_declarations.reasons == ("parse-unavailable",)
+ react_declarations = coverage_record(
+ store,
+ component=".",
+ 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)
+ )
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
diff --git a/tests/test_evidence_mcp.py b/tests/test_evidence_mcp.py
index ff9d4b0..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."""
@@ -170,7 +311,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 +330,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_evidence_model.py b/tests/test_evidence_model.py
index 8038881..95dd3a5 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,61 @@ 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_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."""
+
+ 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."""
@@ -380,6 +436,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 +616,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 +750,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."""
diff --git a/tests/test_integration_contracts.py b/tests/test_integration_contracts.py
index bf6fe7e..091894f 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:
@@ -48,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")
@@ -81,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
@@ -93,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_release_receipt.py b/tests/test_release_receipt.py
index 4389f48..e47a861 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
@@ -14,6 +16,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 +33,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 +281,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 +325,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 +357,324 @@ 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_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:
+ """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"
+ 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(
+ [
+ sys.executable,
+ 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")
+
+
+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
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"),
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