From 03b26dce2fe8c32700fcd47ed95eb82405958298 Mon Sep 17 00:00:00 2001 From: iperev Date: Mon, 13 Jul 2026 02:35:28 +0200 Subject: [PATCH] feat: add bounded semantic requirement workspace --- .github/workflows/ci.yml | 50 ++ .github/workflows/release.yml | 6 + .gitignore | 2 + CONTRIBUTING.md | 6 + README.md | 12 +- docs/proofkit-contract-map.md | 15 +- .../proofkit-spec-proof-core/overview.md | 15 + .../requirements.v1.json | 65 ++ .../proofkit-supply-chain-quality/overview.md | 9 + .../requirements.v1.json | 26 + internal/app/app.go | 25 +- internal/app/app_test.go | 153 +++- internal/app/cli_abi_test.go | 14 + internal/app/cli_contract_test.go | 56 +- internal/app/command_coverage_routes.go | 4 + internal/app/command_coverage_test.go | 2 + internal/app/command_descriptors.go | 8 +- .../app/command_family_catalog_generated.go | 3 +- internal/app/command_flag_constraints.go | 30 +- internal/app/command_help.go | 6 +- internal/app/command_registry.go | 6 + internal/app/io.go | 2 +- internal/app/json_layout.go | 59 ++ internal/app/requirement_browser_command.go | 44 +- internal/app/requirement_commands.go | 6 +- internal/app/requirement_context_command.go | 65 ++ internal/app/usage.go | 2 +- internal/command/agentroute/agentroute.go | 220 ++++-- .../command/agentroute/agentroute_test.go | 50 ++ .../externalconsumer/externalconsumer.go | 2 +- .../json_report_cli_adapter_source.go | 17 +- .../json_report_cli_adapter_source_test.go | 10 +- .../requirementbinding/requirementbinding.go | 55 ++ internal/command/requirementbrowser/assets.go | 12 + .../assets/selection-authority.js | 30 + .../requirementbrowser/assets/workspace.css | 21 + .../requirementbrowser/assets/workspace.js | 478 +++++++++++++ .../requirementbrowser/handoff_bounds_test.go | 118 +++ .../requirementbrowser/http_handler.go | 673 ++++++++++++++++++ .../requirementbrowser/requirementbrowser.go | 23 +- internal/command/requirementbrowser/server.go | 176 +++-- .../command/requirementbrowser/workspace.go | 228 ++++++ .../workspace_handoff_boundary_test.go | 334 +++++++++ .../requirementbrowser/workspace_test.go | 429 +++++++++++ .../command/requirementcontext/compose.go | 352 +++++++++ internal/command/requirementcontext/model.go | 411 +++++++++++ internal/command/requirementcontext/query.go | 131 ++++ .../requirementcontext_test.go | 368 ++++++++++ internal/command/requirementcontext/slice.go | 485 +++++++++++++ .../requirementcontext/slice_closure_test.go | 238 +++++++ .../output_admission.go | 212 ++++++ .../requirementcoverageview_test.go | 22 + .../command/requirementdiff/change_order.go | 38 + .../requirementdiff/output_admission.go | 177 +++++ .../requirementdiff/requirementdiff.go | 317 +++++++++ .../requirementdiff/requirementdiff_test.go | 211 ++++++ .../requirementgraph/output_admission.go | 361 ++++++++++ .../requirementgraph/requirementgraph.go | 580 +++++++++++++++ .../requirementgraph/requirementgraph_test.go | 162 +++++ .../requirementsourceadmission/projection.go | 89 +++ .../requirementsourceadmission_test.go | 32 + .../requirementspectree.go | 113 ++- internal/command/requirementspectree/types.go | 20 +- .../kernel/pathpattern/pathpattern_test.go | 2 +- internal/kernel/stablejson/stablejson.go | 75 +- internal/kernel/stablejson/stablejson_test.go | 33 + .../testsupport/browserfixture/fixture.go | 71 ++ .../browserproofverify/artifact_paths.go | 209 ++++++ .../browserproofverify/artifact_paths_test.go | 73 ++ .../browserproofverify/input_manifest.go | 116 +++ .../browserproofverify/input_resolution.go | 221 ++++++ internal/tools/browserproofverify/main.go | 433 +++++++++++ .../tools/browserproofverify/main_test.go | 233 ++++++ .../browserproofverify/playwright_report.go | 365 ++++++++++ .../playwright_report_test.go | 193 +++++ internal/tools/browsertestserver/main.go | 29 + internal/tools/commandfamilygen/main.go | 2 +- internal/tools/coveragemetrics/main.go | 2 +- internal/tools/coveragemetrics/main_test.go | 2 +- internal/tools/packageverify/main.go | 59 +- internal/tools/packageverify/main_test.go | 13 +- package-lock.json | 490 +++++++++++++ package.json | 14 +- playwright.config.mjs | 28 + ...-contract.v1.json => cli-contract.v2.json} | 160 ++++- proofkit/command-families.v1.json | 11 + proofkit/requirement-bindings.json | 214 ++++++ proofkit/witness-plan.json | 129 +++- scripts/browser-proof-execution.mjs | 203 ++++++ scripts/browser-proof-execution.test.mjs | 149 ++++ scripts/browser-proof-inputs.mjs | 71 ++ scripts/browser-proof-inputs.test.mjs | 188 +++++ scripts/browser-runtime-proof-inputs.v1.json | 21 + scripts/browser-selection-authority.test.mjs | 48 ++ scripts/workflow_package_gate_oracle_test.go | 51 +- scripts/write-browser-proof.mjs | 58 ++ tests/browser/workspace.spec.mjs | 217 ++++++ tsconfig.browser.json | 12 + 98 files changed, 11846 insertions(+), 235 deletions(-) create mode 100644 internal/app/json_layout.go create mode 100644 internal/app/requirement_context_command.go create mode 100644 internal/command/requirementbrowser/assets.go create mode 100644 internal/command/requirementbrowser/assets/selection-authority.js create mode 100644 internal/command/requirementbrowser/assets/workspace.css create mode 100644 internal/command/requirementbrowser/assets/workspace.js create mode 100644 internal/command/requirementbrowser/handoff_bounds_test.go create mode 100644 internal/command/requirementbrowser/http_handler.go create mode 100644 internal/command/requirementbrowser/workspace.go create mode 100644 internal/command/requirementbrowser/workspace_handoff_boundary_test.go create mode 100644 internal/command/requirementbrowser/workspace_test.go create mode 100644 internal/command/requirementcontext/compose.go create mode 100644 internal/command/requirementcontext/model.go create mode 100644 internal/command/requirementcontext/query.go create mode 100644 internal/command/requirementcontext/requirementcontext_test.go create mode 100644 internal/command/requirementcontext/slice.go create mode 100644 internal/command/requirementcontext/slice_closure_test.go create mode 100644 internal/command/requirementcoverageview/output_admission.go create mode 100644 internal/command/requirementdiff/change_order.go create mode 100644 internal/command/requirementdiff/output_admission.go create mode 100644 internal/command/requirementdiff/requirementdiff.go create mode 100644 internal/command/requirementdiff/requirementdiff_test.go create mode 100644 internal/command/requirementgraph/output_admission.go create mode 100644 internal/command/requirementgraph/requirementgraph.go create mode 100644 internal/command/requirementgraph/requirementgraph_test.go create mode 100644 internal/command/requirementsourceadmission/projection.go create mode 100644 internal/testsupport/browserfixture/fixture.go create mode 100644 internal/tools/browserproofverify/artifact_paths.go create mode 100644 internal/tools/browserproofverify/artifact_paths_test.go create mode 100644 internal/tools/browserproofverify/input_manifest.go create mode 100644 internal/tools/browserproofverify/input_resolution.go create mode 100644 internal/tools/browserproofverify/main.go create mode 100644 internal/tools/browserproofverify/main_test.go create mode 100644 internal/tools/browserproofverify/playwright_report.go create mode 100644 internal/tools/browserproofverify/playwright_report_test.go create mode 100644 internal/tools/browsertestserver/main.go create mode 100644 package-lock.json create mode 100644 playwright.config.mjs rename proofkit/{cli-contract.v1.json => cli-contract.v2.json} (94%) create mode 100644 scripts/browser-proof-execution.mjs create mode 100644 scripts/browser-proof-execution.test.mjs create mode 100644 scripts/browser-proof-inputs.mjs create mode 100644 scripts/browser-proof-inputs.test.mjs create mode 100644 scripts/browser-runtime-proof-inputs.v1.json create mode 100644 scripts/browser-selection-authority.test.mjs create mode 100644 scripts/write-browser-proof.mjs create mode 100644 tests/browser/workspace.spec.mjs create mode 100644 tsconfig.browser.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f651533..de1328f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -39,6 +39,9 @@ jobs: - name: Install verified npm uses: ./.github/actions/setup-verified-npm + - name: Install locked browser tooling + run: npm ci --ignore-scripts + - name: Setup Go uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 with: @@ -72,6 +75,9 @@ jobs: - name: Verify Mermaid diagrams run: npm run mermaid:check + - name: Verify browser source types + run: npm run browser:static-check + - name: Verify Go formatting run: npm run go:fmt @@ -148,18 +154,62 @@ jobs: npm run npm:version npm run platform:smoke + browser-runtime: + name: quality / browser runtime + runs-on: ubuntu-24.04 + timeout-minutes: 20 + steps: + - name: Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + + - name: Setup Node + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: 24.18.0 + package-manager-cache: false + + - name: Install verified npm + uses: ./.github/actions/setup-verified-npm + + - name: Setup Go + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 + with: + go-version-file: go.mod + cache: true + + - name: Install locked browser tooling + run: npm ci --ignore-scripts + + - name: Install pinned browser engines + run: npx playwright install --with-deps chromium firefox webkit + + - name: Run browser proof + run: npm run browser:check + + - name: Upload browser proof + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: browser-runtime-proof-${{ github.sha }} + path: artifacts/proofkit/browser-runtime-proof.json + if-no-files-found: error + retention-days: 14 + ci-required-gate: name: quality / required aggregate runs-on: ubuntu-24.04 timeout-minutes: 5 if: ${{ always() }} needs: + - browser-runtime - source-quality - platform-smoke steps: - name: Verify required quality results run: | set -euo pipefail + test "${{ needs.browser-runtime.result }}" = "success" test "${{ needs.source-quality.result }}" = "success" test "${{ needs.platform-smoke.result }}" = "success" printf 'OK: all required quality checks passed\n' diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d65cdb7..bdb1128 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -62,6 +62,12 @@ jobs: - name: Install verified npm uses: ./.github/actions/setup-verified-npm + - name: Install locked browser tooling + run: npm ci --ignore-scripts + + - name: Install pinned browser engines + run: npx playwright install --with-deps chromium firefox webkit + - name: Verify source package identity env: EXPECTED_VERSION: ${{ inputs.expected_version }} diff --git a/.gitignore b/.gitignore index 7caa0a8..2e55bea 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,8 @@ dist/ artifacts/ node_modules/ +playwright-report/ +test-results/ *.tgz .DS_Store diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 52123bb..9d4fc98 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -25,10 +25,16 @@ admission, and rollout approval belong in consuming repositories. Run before proposing a non-trivial change: ```bash +npm ci --ignore-scripts +npx playwright install chromium firefox webkit npm run check git diff --check ``` +The browser engine installation is a one-time prerequisite for the pinned +rendered-runtime gate. CI installs the same engines with their Linux system +dependencies before running that gate. + If your local project uses Bun, `bun run check` is acceptable as a convenience runner only when it invokes the same scripts and leaves `npm run check` equivalent. Release and package-authority proof remains npm-owned. diff --git a/README.md b/README.md index 8fd2c94..cdfa1e3 100644 --- a/README.md +++ b/README.md @@ -154,7 +154,7 @@ agentic-proofkit repo-profile-admission --help Command-specific help is derived from the private command descriptor table and does not read stdin. The full machine-readable command inventory remains -`proofkit/cli-contract.v1.json`; the human route map is +`proofkit/cli-contract.v2.json`; the human route map is `docs/proofkit-contract-map.md`. | Repository state | Minimal first route | Stop condition | @@ -165,6 +165,16 @@ does not read stdin. The full machine-readable command inventory remains | Current code must be audited before it becomes a contract | `capability-map-admission` with `trustMode: "audit_from_code"` | Stop at owner questions and candidate-only records | | Legacy repository has local proof infrastructure | `migration-parity-admission`, then `migration-plan` | Stop before deleting local proof owners without parity evidence | | A change set needs bounded checks | `changed-path-set`, optional `impact`, then `selective-gate-plan` and `selective-gate-evidence` | Stop on unknown scope, missing routes, or stale receipts | +| An agent needs only one specification subtree | `requirement-context-compose --repo-root . --input context-catalog.json`, then `requirement-context-slice` | Stop before treating a bounded slice as complete repository truth | +| A human needs semantic navigation, comparison, or traceability | `requirement-browser-server --view workspace --serve` over an admitted workspace input | Browser output, annotations, diff, and graph remain derived and non-authoritative | + +JSON commands default to readable output. Agents can request the same JSON +value with lower transport overhead by placing the process option before the +command: + +```bash +agentic-proofkit --json-layout compact requirement-context-slice --input slice-input.json +``` Use `secret-scan` only when the caller provides an explicit file inventory with content. It is a dedicated secret-like text detector for admitted inventory diff --git a/docs/proofkit-contract-map.md b/docs/proofkit-contract-map.md index 36c9444..8a5db92 100644 --- a/docs/proofkit-contract-map.md +++ b/docs/proofkit-contract-map.md @@ -9,7 +9,7 @@ Owner: `proofkit`. This map helps consuming repositories choose the smallest Proofkit CLI command or JSON contract without loading the full README or source tree. It is not an exhaustive schema reference. The canonical command inventory is -`proofkit/cli-contract.v1.json`. +`proofkit/cli-contract.v2.json`. Formal rule: @@ -92,7 +92,7 @@ returns deterministic JSON from explicit caller-owned facts; the envelope is an opt-in derived projection over the same report. This map explains the route families without becoming an execution, freshness, or merge decision. The exact route input vocabulary is machine-readable in -`proofkit/cli-contract.v1.json` under `agent-route.inputContract`; the Go +`proofkit/cli-contract.v2.json` under `agent-route.inputContract`; the Go admission implementation and shipped CLI contract are parity-tested. Formal rule: @@ -107,6 +107,10 @@ goal plus caller-owned state Decision tree: +Semantic context routes are `requirement-context-compose`, +`requirement-context-slice`, `requirement-semantic-diff`, and +`requirement-traceability-graph`. + | State or goal | Next Proofkit route | Stop or escalation condition | |---|---|---| | The agent does not know where to start. | `init` or `init --preset fresh|code-baseline|code-audit|legacy|change-set` | Treat output as dry-run route guidance only. Stop before scanning, writing files, or making requirements authoritative. | @@ -116,14 +120,15 @@ Decision tree: | Temporary external design, implementation-plan, PR, code, or test observations may contain durable requirements. | `requirement-authoring-plan` | Treat output as candidate-only; stop before writing `requirements.v1.json`, retaining temporary documents, or claiming requirement meaning. | | Requirement records exist. | `requirement-source-admission`; use `requirement-source-transition` for lifecycle changes. | Escalate when blocking requirements lack proof routes or lifecycle replacement ids are incomplete. | | Humans or agents need meta/module/submodule navigation. | `requirement-spec-tree`, then `requirement-spec-tree-view` or `requirement-browser-server --view spec-tree` from the same caller-owned tree input. | Stop before inferring hierarchy from paths. The consumer owns source hierarchy; CLI/browser outputs remain presentation only and are not committed by default. | +| An agent needs a bounded semantic subset instead of whole specification files. | `requirement-context-compose --repo-root ` over an explicit catalog, then `requirement-context-slice` over the materialized snapshot. | The snapshot and slice are content-bound derived projections. Stop before inferring hierarchy, scanning ambient paths, treating omissions as absence, or promoting the slice to requirement, proof, freshness, or merge authority. | | Overview prose may contain durable claims. | `spec-overview-claims` | Escalate when normative claims are not tied to `REQ-*` records. | | Requirements have no verified proof route. | `requirement-bindings`, `witness-plan` from either an explicit `witness_command_catalog` or a complete `binding_witness_plan_input`, `proof-slice`, or `requirement-proof-resolver` | Stop before claiming proof adequacy; native witness semantics stay with the consumer. A binding-derived witness plan still needs caller-owned vocabulary and conservative command policy. | | Tests or proof evidence need inventory. | `test-evidence-inventory`; use `--projection discovery-draft` only for explicit discovered-test facts, then `requirement-coverage-input-compose` when an aggregate `coverage_compose_input` exists, then `requirement-coverage-view` when a `coverage_view_input` exists | Compose only from explicit caller-owned facts. Discovery drafts are candidate-only and cannot close coverage. Use `failureClassifications[]`, `warningClassifications[]`, and `agentActionPlan[]` for machine routing. Escalate when tests are route-only, weak-oracle, unbound, or outside the caller-owned coverage universe. | | A change set is known. | `changed-path-set`, optionally `requirement-impact-input-compose`, `impact`, then `selective-gate-plan --agent-envelope` | Raw `knownChangedPaths` in `agent-route` are diagnostic only. Materialize a caller-owned `changed_path_set`, compose a caller-owned `impact_input` before `impact`, and compose a caller-owned `selective_gate_plan_input` before `selective-gate-plan`. Use `scanObligation` to name whether a `text-policy`, `secret-scan`, or caller-owned external scanner is required. Fail closed on unknown scope, dynamic edges, missing owner routes, unbound proof-like paths, or full-gate escalation. | | Caller-owned file contents need secret-like text detection. | `secret-scan` | Provide explicit sorted file inventory with content. Stop before claiming repository-wide discovery, credential validity, provider ingestion, merge readiness, or replacement of GitHub secret scanning. | -| Does a TypeScript package public API match a caller-owned manifest? | `agent-route` with `goal: "verify_typescript_public_api"` and explicit `typescript_public_api_manifest` plus `typescript_public_api_repo_root`, then `typescript-public-api-surfaces --repo-root ` | The manifest must name each referenced `package.json`, sorted-unique export conditions, and a non-JSX `.ts`, `.mts`, or `.cts` `sourcePath` whose canonical target has the same admitted extension class. The bounded scanner accepts only the fail-closed export grammar in `proofkit/cli-contract.v1.json`; it does not parse unrestricted TypeScript or TSX, infer conventional layouts, or prove compiler output provenance, checkout freshness, package-manager truth, or merge readiness. | +| Does a TypeScript package public API match a caller-owned manifest? | `agent-route` with `goal: "verify_typescript_public_api"` and explicit `typescript_public_api_manifest` plus `typescript_public_api_repo_root`, then `typescript-public-api-surfaces --repo-root ` | The manifest must name each referenced `package.json`, sorted-unique export conditions, and a non-JSX `.ts`, `.mts`, or `.cts` `sourcePath` whose canonical target has the same admitted extension class. The bounded scanner accepts only the fail-closed export grammar in `proofkit/cli-contract.v2.json`; it does not parse unrestricted TypeScript or TSX, infer conventional layouts, or prove compiler output provenance, checkout freshness, package-manager truth, or merge readiness. | | Receipts are available for planned checks. | `selective-gate-evidence --agent-envelope`; then materialize a caller-owned `obligation_decision_input` from the evidence output plus command routes, currentness, and trust facts; then run `selective-gate-obligation-decision-input`; then materialize the resulting `obligation_decision` input and run `obligation-decision --agent-envelope` | Escalate on missing, stale, invalid, untrusted, blocked, unavailable, or unknown-scope evidence. | -| Human inspection is needed. | `requirement-source-view`, `requirement-proof-view`, `requirement-coverage-view`, `requirement-spec-tree-view`, or `requirement-browser-server` | `agent-route` emits browser commands as plan-only by default. Use `browserMode: "serve_local_view"` for `--serve` and `openBrowser: true` for `--open`. Rendered HTML and Markdown are presentation only unless the consumer admits a tracked artifact freshness gate. | +| Human inspection, semantic comparison, or traceability navigation is needed. | `requirement-source-view`, `requirement-proof-view`, `requirement-coverage-view`, `requirement-spec-tree-view`, `requirement-semantic-diff`, `requirement-traceability-graph`, or `requirement-browser-server` | Semantic diff compares admitted owner fields rather than lines. Traceability keeps specification, proof, code, and native execution evidence planes separate. Browser and rendered outputs remain presentation only unless the consumer admits a tracked artifact freshness gate. | | Temporary external document lifecycle facts, generated views, or rendered views need authority classification. | `document-lifecycle-boundary` | Treat lifecycle records as caller-owned metadata. Temporary design docs and implementation plans are not retained repository authority unless rewritten into deterministic specs, proof bindings, tests, package-public docs, or backlog rows. | | A JavaScript/TypeScript consumer needs less wrapper code. | `json-report-cli-adapter-source --language typescript --format json` | Generated adapter source is caller-owned after materialization. The consumer still owns package pin, binary path, repo paths, local policy, and freshness proof. It is a CLI runner adapter, not a separate SDK authority. | | A Python consumer needs Proofkit from Python tooling. | Install the Python package when available and invoke the same CLI/JSON contract. | The Python package is a runner wrapper over the Go CLI, not a Python SDK or alternate schema owner. | @@ -133,7 +138,7 @@ Decision tree: ## Routing Rules -1. Start from `proofkit/cli-contract.v1.json` when a machine needs the exact +1. Start from `proofkit/cli-contract.v2.json` when a machine needs the exact command, flags, input mode, output mode, scope class, or `agent-route` input contract. 2. Start from this map when a human or agent only needs the correct command diff --git a/docs/specs/proofkit-spec-proof-core/overview.md b/docs/specs/proofkit-spec-proof-core/overview.md index ed848d4..2733891 100644 --- a/docs/specs/proofkit-spec-proof-core/overview.md +++ b/docs/specs/proofkit-spec-proof-core/overview.md @@ -97,6 +97,21 @@ execution receipts, and merge policy. runtime navigation projection, and adds opt-in family help while preserving existing help invocation forms, process channels, no-input behavior, and leaf dispatch; descriptor and help truth remains owned by `REQ-PROOFKIT-QUALITY-004`. +- `REQ-PROOFKIT-SPEC-019`: explicit catalogs compose content-bound semantic + context snapshots through existing source, tree, proof, and coverage owners + without ambient repository discovery. +- `REQ-PROOFKIT-SPEC-020`: bounded context queries select parent-before-child, + role-aware, reference-closed semantic subsets by stable identity and report + each active bound without treating bounded absence as source absence. +- `REQ-PROOFKIT-SPEC-021`: the loopback workspace progressively presents + immutable context, semantic diff, traceability trust states, authority + boundaries, and non-claims, then emits a bounded source-bound question packet + only after explicit user submission. +- `REQ-PROOFKIT-SPEC-022`: semantic diff compares admitted requirement fields + by owner-declared scalar, set, and map semantics rather than textual order. +- `REQ-PROOFKIT-SPEC-023`: traceability graphs preserve specification, proof, + code traceability, and native execution as distinct evidence planes and + accept code topology only as explicit caller-owned input. ## Non-Claims diff --git a/docs/specs/proofkit-spec-proof-core/requirements.v1.json b/docs/specs/proofkit-spec-proof-core/requirements.v1.json index 3d1aaec..7181b73 100644 --- a/docs/specs/proofkit-spec-proof-core/requirements.v1.json +++ b/docs/specs/proofkit-spec-proof-core/requirements.v1.json @@ -490,6 +490,71 @@ "requiresImpactDeclaration": true, "requiresProofBindingReview": true } + }, + { + "requirementId": "REQ-PROOFKIT-SPEC-019", + "ownerId": "proofkit.spec-proof-core", + "invariant": "Requirement context composition reads only caller-selected repository-relative catalog paths through a confined filesystem root, admits each source through its semantic owner, records current and optional expected digests, and emits one bounded content-bound derived snapshot without ambient scanning or new requirement, proof, coverage, freshness, or merge authority.", + "claimLevel": "blocking", + "riskClass": "high", + "proofBindingRefs": ["proofkit/requirement-bindings.json"], + "nonClaimRefs": ["NC-PROOFKIT-SPEC-019"], + "nonClaims": ["This requirement does not claim ambient repository discovery, later-checkout freshness, requirement meaning, proof adequacy, native witness execution, merge approval, release approval, rollout approval, or production readiness."], + "lifecycle": {"state": "active", "replacementRequirementIds": [], "evidenceRefs": []}, + "deferral": null, + "updatePolicy": {"reviewOwnerId": "proofkit.spec-proof-core", "requiresImpactDeclaration": true, "requiresProofBindingReview": true} + }, + { + "requirementId": "REQ-PROOFKIT-SPEC-020", + "ownerId": "proofkit.spec-proof-core", + "invariant": "Requirement context slicing strictly re-admits snapshot identity, applies closed profiles and intersecting stable-id selectors under deterministic bounds, rejects unknown explicit targets, retains selected ancestors before descendants, preserves source-role and lifecycle reference closure, and distinctly reports depth, node-count, and requirement-count omissions without treating bounded absence as source absence.", + "claimLevel": "blocking", + "riskClass": "high", + "proofBindingRefs": ["proofkit/requirement-bindings.json"], + "nonClaimRefs": ["NC-PROOFKIT-SPEC-020"], + "nonClaims": ["This requirement does not claim source completeness outside the admitted snapshot, product meaning, proof freshness, native execution, merge approval, release approval, rollout approval, or production readiness."], + "lifecycle": {"state": "active", "replacementRequirementIds": [], "evidenceRefs": []}, + "deferral": null, + "updatePolicy": {"reviewOwnerId": "proofkit.spec-proof-core", "requiresImpactDeclaration": true, "requiresProofBindingReview": true} + }, + { + "requirementId": "REQ-PROOFKIT-SPEC-021", + "ownerId": "proofkit.spec-proof-core", + "invariant": "The loopback requirement workspace visibly preserves presentation-only authority boundaries, non-claims, snapshot and baseline identity, and trust-significant graph states while rendering immutable admitted context, optional semantic diff, and optional traceability graph through exact allowlisted routes, a per-process capability, same-origin handoff admission, source-digest and JSON-pointer anchors, Unicode code-point quote coordinates, bounded browser-session question packets after explicit UI submission across the admitted tree domain subject to explicit packet byte bounds, and terminal one-shot concurrent session semantics without persistence, command execution, provider access, or authority promotion.", + "claimLevel": "blocking", + "riskClass": "critical", + "proofBindingRefs": ["proofkit/requirement-bindings.json"], + "nonClaimRefs": ["NC-PROOFKIT-SPEC-021"], + "nonClaims": ["This requirement does not authenticate the surrounding browser profile or operating-system user and does not claim remote access, annotation persistence, agent execution, provider delivery, proof freshness, merge approval, release approval, rollout approval, branded Safari behavior, or production readiness."], + "lifecycle": {"state": "active", "replacementRequirementIds": [], "evidenceRefs": []}, + "deferral": null, + "updatePolicy": {"reviewOwnerId": "proofkit.spec-proof-core", "requiresImpactDeclaration": true, "requiresProofBindingReview": true} + }, + { + "requirementId": "REQ-PROOFKIT-SPEC-022", + "ownerId": "proofkit.spec-proof-core", + "invariant": "Requirement semantic diff compares two admitted context snapshots by stable requirement identity and owner-declared scalar, set, and map field classes, producing deterministic added, removed, and modified facts without textual diff, inferred move semantics, or authority over requirement meaning.", + "claimLevel": "blocking", + "riskClass": "medium", + "proofBindingRefs": ["proofkit/requirement-bindings.json"], + "nonClaimRefs": ["NC-PROOFKIT-SPEC-022"], + "nonClaims": ["This requirement does not claim textual equivalence, inferred moves, product meaning, proof freshness, merge approval, release approval, rollout approval, or production readiness."], + "lifecycle": {"state": "active", "replacementRequirementIds": [], "evidenceRefs": []}, + "deferral": null, + "updatePolicy": {"reviewOwnerId": "proofkit.spec-proof-core", "requiresImpactDeclaration": true, "requiresProofBindingReview": true} + }, + { + "requirementId": "REQ-PROOFKIT-SPEC-023", + "ownerId": "proofkit.spec-proof-core", + "invariant": "Requirement traceability graph deterministically projects admitted specification and proof topology plus explicit caller-owned code topology while preserving specification, proof, code traceability, and native execution as distinct evidence planes and rejecting dangling topology references.", + "claimLevel": "blocking", + "riskClass": "high", + "proofBindingRefs": ["proofkit/requirement-bindings.json"], + "nonClaimRefs": ["NC-PROOFKIT-SPEC-023"], + "nonClaims": ["This requirement does not infer code topology, line or branch coverage, native execution, proof freshness, merge approval, release approval, rollout approval, or production readiness."], + "lifecycle": {"state": "active", "replacementRequirementIds": [], "evidenceRefs": []}, + "deferral": null, + "updatePolicy": {"reviewOwnerId": "proofkit.spec-proof-core", "requiresImpactDeclaration": true, "requiresProofBindingReview": true} } ], "nonClaims": [ diff --git a/docs/specs/proofkit-supply-chain-quality/overview.md b/docs/specs/proofkit-supply-chain-quality/overview.md index 24b53e6..6cc05e2 100644 --- a/docs/specs/proofkit-supply-chain-quality/overview.md +++ b/docs/specs/proofkit-supply-chain-quality/overview.md @@ -89,6 +89,15 @@ vulnerability absence, or consumer rollout safety by itself. state before mutation, stays confined to the repository across symlinks, binds non-empty generated content to stable source and execution-context snapshots, and emits a schema-versioned execution record. +- `REQ-PROOFKIT-QUALITY-021`: CLI contract v2 owns one leading pretty or + compact JSON layout option through a descriptor-aware token-role + preclassification at process output boundaries while canonical identity + serialization remains unchanged. +- `REQ-PROOFKIT-QUALITY-022`: the requirement workspace uses an explicit + embedded asset set, strict authored-JavaScript type checking, exact secured + routes, bounded server cleanup, repository-confined non-symlink proof + artifacts, and machine-admitted per-project rendered engine evidence without + runtime dependencies or a production bundler. ## Non-Claims diff --git a/docs/specs/proofkit-supply-chain-quality/requirements.v1.json b/docs/specs/proofkit-supply-chain-quality/requirements.v1.json index bda389b..4685aae 100644 --- a/docs/specs/proofkit-supply-chain-quality/requirements.v1.json +++ b/docs/specs/proofkit-supply-chain-quality/requirements.v1.json @@ -268,6 +268,32 @@ "lifecycle": {"state": "active", "replacementRequirementIds": [], "evidenceRefs": []}, "deferral": null, "updatePolicy": {"reviewOwnerId": "proofkit.supply-chain-quality", "requiresImpactDeclaration": true, "requiresProofBindingReview": true} + }, + { + "requirementId": "REQ-PROOFKIT-QUALITY-021", + "ownerId": "proofkit.supply-chain-quality", + "invariant": "The public CLI v2 process contract admits one leading pretty or compact JSON layout option, preserves pretty as the default, classifies descriptor-admitted flag and value token roles before output policy so option-shaped values cannot alter output classification, emits sorted valid JSON with one trailing LF in both layouts, applies the selected layout only at JSON stdout or admitted JSON file sinks, and leaves canonical digest and identity serialization unchanged.", + "claimLevel": "blocking", + "riskClass": "high", + "proofBindingRefs": ["proofkit/requirement-bindings.json"], + "nonClaimRefs": ["NC-PROOFKIT-QUALITY-021"], + "nonClaims": ["This requirement does not change canonical source storage, semantic selection, stable identity, text output, help output, browser sessions, merge approval, release approval, or rollout readiness."], + "lifecycle": {"state": "active", "replacementRequirementIds": [], "evidenceRefs": []}, + "deferral": null, + "updatePolicy": {"reviewOwnerId": "proofkit.supply-chain-quality", "requiresImpactDeclaration": true, "requiresProofBindingReview": true} + }, + { + "requirementId": "REQ-PROOFKIT-QUALITY-022", + "ownerId": "proofkit.supply-chain-quality", + "invariant": "Authored requirement workspace JavaScript and CSS are embedded through an explicit compile-time asset set, served only by exact routes with restrictive response headers, strictly type-checked without a production bundler, and covered by Go lifecycle, concurrent terminal-state, HTTP security, and repository-confined non-symlink artifact falsifiers plus a machine-admitted per-project execution report proving the same non-empty passed test identities in pinned Chromium, Firefox, and Playwright WebKit, with bounded browser-server cleanup and no added runtime package dependencies.", + "claimLevel": "blocking", + "riskClass": "high", + "proofBindingRefs": ["proofkit/requirement-bindings.json"], + "nonClaimRefs": ["NC-PROOFKIT-QUALITY-022"], + "nonClaims": ["This requirement does not claim full WCAG conformance, every branded browser, provider CI ingestion, registry publication, release approval, rollout approval, or production readiness."], + "lifecycle": {"state": "active", "replacementRequirementIds": [], "evidenceRefs": []}, + "deferral": null, + "updatePolicy": {"reviewOwnerId": "proofkit.supply-chain-quality", "requiresImpactDeclaration": true, "requiresProofBindingReview": true} } ] } diff --git a/internal/app/app.go b/internal/app/app.go index fc493a9..87f839d 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -19,7 +19,16 @@ import ( const maxInputBytes = 32 << 20 func Run(ctx context.Context, args []string, stdin io.Reader, stdout io.Writer, stderr io.Writer) int { + args, layout, layoutExplicit, err := parseProcessOptions(args) + if err != nil { + writeDiagnostic(stderr, err) + return 1 + } if len(args) == 0 || args[0] == "--help" || args[0] == "-h" { + if layoutExplicit { + writeDiagnosticf(stderr, "--json-layout is valid only for JSON command output") + return 1 + } return writeText(usage(), 0, nil, stdout, stderr) } descriptor, ok := commandDescriptorFor(args[0]) @@ -28,9 +37,19 @@ func Run(ctx context.Context, args []string, stdin io.Reader, stdout io.Writer, return 1 } if isCommandHelpRequest(args) { + if layoutExplicit { + writeDiagnosticf(stderr, "--json-layout is valid only for JSON command output") + return 1 + } return writeText(commandUsage(descriptor), 0, nil, stdout, stderr) } - if err := validateFlagConstraints(descriptor, args[1:]); err != nil { + parsedArguments := classifyDescriptorArguments(descriptor, args[1:]) + if err := validateJSONLayoutUse(descriptor, parsedArguments, layoutExplicit); err != nil { + writeDiagnostic(stderr, err) + return 1 + } + stdout = layoutWriter{Writer: stdout, layout: layout} + if err := validateFlagConstraints(descriptor, parsedArguments); err != nil { writeDiagnostic(stderr, err) return 1 } @@ -136,6 +155,8 @@ func Run(ctx context.Context, args []string, stdin io.Reader, stdout io.Writer, return runRequirementProofResolver(args[1:], stdin, stdout, stderr) case commandRunnerRequirementBrowserServer: return runRequirementBrowserServer(ctx, args[1:], stdin, stdout, stderr) + case commandRunnerRequirementContextCompose: + return runRequirementContextCompose(args[1:], stdin, stdout, stderr) case commandRunnerRequirementView: return runRequirementView(args[0], args[1:], stdin, stdout, stderr) case commandRunnerTestEvidenceInventory: @@ -213,6 +234,8 @@ type requirementBrowserArgs struct { port int portSet bool scope string + sessionMode string + sessionTimeoutSeconds int serve bool view string } diff --git a/internal/app/app_test.go b/internal/app/app_test.go index 598f716..f83885e 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -10,6 +10,7 @@ import ( "os" "os/exec" "path/filepath" + "reflect" "slices" "strings" "sync" @@ -57,6 +58,156 @@ func TestSelfCheckReadsExplicitJSONAndEmitsReport(t *testing.T) { } } +func TestCompactJSONLayoutPreservesValue(t *testing.T) { + var pretty bytes.Buffer + var compact bytes.Buffer + var stderr bytes.Buffer + input := `{"ok":true}` + if status := Run(t.Context(), []string{"self-check", "--input", "-"}, strings.NewReader(input), &pretty, &stderr); status != 0 { + t.Fatalf("pretty status=%d stderr=%q", status, stderr.String()) + } + stderr.Reset() + if status := Run(t.Context(), []string{"--json-layout", "compact", "self-check", "--input", "-"}, strings.NewReader(input), &compact, &stderr); status != 0 { + t.Fatalf("compact status=%d stderr=%q", status, stderr.String()) + } + if strings.Contains(compact.String(), "\n ") { + t.Fatalf("compact output contains indentation: %q", compact.String()) + } + var prettyValue any + var compactValue any + if err := json.Unmarshal(pretty.Bytes(), &prettyValue); err != nil { + t.Fatalf("decode pretty output: %v", err) + } + if err := json.Unmarshal(compact.Bytes(), &compactValue); err != nil { + t.Fatalf("decode compact output: %v", err) + } + if !reflect.DeepEqual(prettyValue, compactValue) { + t.Fatalf("layout changed JSON value: pretty=%v compact=%v", prettyValue, compactValue) + } +} + +func TestJSONLayoutRejectsNonJSONAndNonLeadingUseBeforeInput(t *testing.T) { + tests := [][]string{ + {"--json-layout", "compact", "help"}, + {"--json-layout", "compact", "requirement-source-view", "--input", "-", "--format", "markdown"}, + {"--json-layout", "compact", "requirement-browser-server", "--input", "-", "--view", "spec-tree", "--serve"}, + {"self-check", "--json-layout", "compact", "--input", "-"}, + } + for _, args := range tests { + t.Run(strings.Join(args, " "), func(t *testing.T) { + var stdout bytes.Buffer + var stderr bytes.Buffer + status := Run(t.Context(), args, panicReader{}, &stdout, &stderr) + if status == 0 || stdout.Len() != 0 || stderr.Len() == 0 { + t.Fatalf("status=%d stdout=%q stderr=%q", status, stdout.String(), stderr.String()) + } + }) + } +} + +func TestJSONLayoutRejectsEveryTextCommandHelpForm(t *testing.T) { + for _, descriptor := range commandDescriptors { + for _, helpFlag := range []string{"--help", "-h"} { + args := []string{"--json-layout", "compact", descriptor.name, helpFlag} + t.Run(descriptor.name+"/"+helpFlag, func(t *testing.T) { + var stdout bytes.Buffer + var stderr bytes.Buffer + status := Run(t.Context(), args, panicReader{}, &stdout, &stderr) + if status == 0 || stdout.Len() != 0 || !strings.Contains(stderr.String(), "--json-layout is valid only for JSON command output") { + t.Fatalf("status=%d stdout=%q stderr=%q", status, stdout.String(), stderr.String()) + } + }) + } + } +} + +func TestJSONLayoutUsesDescriptorParsedFlags(t *testing.T) { + browser, ok := commandDescriptorFor("requirement-browser-server") + if !ok { + t.Fatal("requirement-browser-server descriptor is unavailable") + } + inputNamedLikeFlag := classifyDescriptorArguments(browser, []string{"--input", "--serve", "--view", "spec-tree"}) + if inputNamedLikeFlag.present["--serve"] || inputNamedLikeFlag.values["--input"][0] != "--serve" { + t.Fatalf("descriptor parser reclassified an input value as a flag: %#v", inputNamedLikeFlag) + } + if err := validateJSONLayoutUse(browser, inputNamedLikeFlag, true); err != nil { + t.Fatalf("JSON layout rejected a flag-shaped input value: %v", err) + } + + served := classifyDescriptorArguments(browser, []string{"--input", "workspace.json", "--view", "workspace", "--serve"}) + if err := validateJSONLayoutUse(browser, served, true); err == nil { + t.Fatal("JSON layout accepted an actual browser serve flag") + } + + view, ok := commandDescriptorFor("requirement-source-view") + if !ok { + t.Fatal("requirement-source-view descriptor is unavailable") + } + formatNamedInput := classifyDescriptorArguments(view, []string{"--input", "--format", "--format", "json"}) + if formatNamedInput.values["--input"][0] != "--format" || !slices.Equal(formatNamedInput.values["--format"], []string{"json"}) { + t.Fatalf("descriptor parser did not preserve flag-shaped values: %#v", formatNamedInput) + } + if err := validateJSONLayoutUse(view, formatNamedInput, true); err != nil { + t.Fatalf("JSON layout rejected an actual JSON format after a flag-shaped input value: %v", err) + } +} + +func TestJSONLayoutPreservesFlagShapedInputPathAtProcessBoundary(t *testing.T) { + t.Chdir(t.TempDir()) + treeInput := `{"schemaVersion":2,"treeId":"consumer.tree","rootNodeId":"spec.root","callerAnnotations":[],"edges":[],"overlays":[],"nodes":[{"nodeId":"spec.root","nodeKind":"meta_spec","label":"Root","displayOrder":1,"callerAnnotations":[],"sourceRefs":[{"sourceRefId":"spec.root.requirements","sourceRefKind":"source_id","sourceRole":"requirements","sourceId":"consumer.requirements"}]}]}` + if err := os.WriteFile("--serve", []byte(treeInput), 0o600); err != nil { + t.Fatal(err) + } + var stdout bytes.Buffer + var stderr bytes.Buffer + status := Run(t.Context(), []string{"--json-layout", "compact", "requirement-browser-server", "--input", "--serve", "--view", "spec-tree"}, panicReader{}, &stdout, &stderr) + if status != 0 || stderr.Len() != 0 { + t.Fatalf("flag-shaped input path changed command semantics: status=%d stdout=%q stderr=%q", status, stdout.String(), stderr.String()) + } + var plan map[string]any + if err := json.Unmarshal(stdout.Bytes(), &plan); err != nil { + t.Fatalf("compact browser plan is not JSON: %v", err) + } + if plan["view"] != "spec-tree" || plan["url"] != nil { + t.Fatalf("unexpected browser plan for flag-shaped input path: %#v", plan) + } + + requirementSource := `{"schemaVersion":1,"sourceId":"consumer.requirements","specPackagePath":"docs/specs/consumer","overviewPath":"docs/specs/consumer/overview.md","requirementsPath":"docs/specs/consumer/requirements.v1.json","requirements":[{"requirementId":"REQ-CONSUMER-001","ownerId":"consumer.owner","invariant":"The system preserves semantic identity.","claimLevel":"blocking","riskClass":"high","proofBindingRefs":["proofkit/requirement-bindings.json"],"nonClaimRefs":["NC-CONSUMER-001"],"nonClaims":["This requirement does not approve merge."],"lifecycle":{"state":"active","replacementRequirementIds":[],"evidenceRefs":[]},"deferral":null,"updatePolicy":{"reviewOwnerId":"consumer.owner","requiresImpactDeclaration":true,"requiresProofBindingReview":true}}],"nonClaims":["Consumer repositories own requirement meaning."]}` + if err := os.WriteFile("--format", []byte(requirementSource), 0o600); err != nil { + t.Fatal(err) + } + stdout.Reset() + stderr.Reset() + status = Run(t.Context(), []string{"--json-layout", "compact", "requirement-source-view", "--input", "--format", "--format", "json"}, panicReader{}, &stdout, &stderr) + if status != 0 || stderr.Len() != 0 { + t.Fatalf("format-shaped input path changed command semantics: status=%d stdout=%q stderr=%q", status, stdout.String(), stderr.String()) + } + var view map[string]any + if err := json.Unmarshal(stdout.Bytes(), &view); err != nil { + t.Fatalf("compact requirement view is not JSON: %v", err) + } + if view["viewKind"] != "proofkit.requirement-source-view" { + t.Fatalf("unexpected requirement view for flag-shaped input path: %#v", view) + } +} + +func TestCompactJSONLayoutAppliesToRequirementBrowserPlan(t *testing.T) { + input := `{"schemaVersion":2,"treeId":"consumer.tree","rootNodeId":"spec.root","callerAnnotations":[],"edges":[],"overlays":[],"nodes":[{"nodeId":"spec.root","nodeKind":"meta_spec","label":"Root","displayOrder":1,"callerAnnotations":[],"sourceRefs":[{"sourceRefId":"spec.root.requirements","sourceRefKind":"source_id","sourceRole":"requirements","sourceId":"consumer.requirements"}]}]}` + var stdout bytes.Buffer + var stderr bytes.Buffer + status := Run(t.Context(), []string{"--json-layout", "compact", "requirement-browser-server", "--input", "-", "--view", "spec-tree"}, strings.NewReader(input), &stdout, &stderr) + if status != 0 || stderr.Len() != 0 || strings.Contains(stdout.String(), "\n ") { + t.Fatalf("status=%d stdout=%q stderr=%q", status, stdout.String(), stderr.String()) + } + var plan map[string]any + if err := json.Unmarshal(stdout.Bytes(), &plan); err != nil { + t.Fatal(err) + } + if plan["portSelection"] != "ephemeral" || plan["url"] != nil { + t.Fatalf("default browser plan must defer its ephemeral origin: %#v", plan) + } +} + func TestSelfCheckRejectsDuplicateKeys(t *testing.T) { commandcoverage.SemanticRoute(t, "proofkit.command_coverage.source_oracle.v1.061049109061347524269448772857617649849822202469664158122537165529475398131547") var stdout bytes.Buffer @@ -521,7 +672,7 @@ func assertDescriptorAdmitsAgentArgv(t *testing.T, argv []string) { index++ } } - if err := validateFlagConstraints(descriptor, argv[2:]); err != nil { + if err := validateFlagConstraints(descriptor, classifyDescriptorArguments(descriptor, argv[2:])); err != nil { t.Fatalf("agent route argv is rejected by %s descriptor: %v; argv=%v", descriptor.name, err, argv) } } diff --git a/internal/app/cli_abi_test.go b/internal/app/cli_abi_test.go index 4ee5cfe..6850565 100644 --- a/internal/app/cli_abi_test.go +++ b/internal/app/cli_abi_test.go @@ -542,6 +542,20 @@ func TestRequirementSpecTreeViewOutputPathAdmission(t *testing.T) { t.Fatalf("written JSON view missing expected content:\n%s", string(jsonContent)) } + stdout.Reset() + stderr.Reset() + status = Run(t.Context(), []string{"--json-layout", "compact", "requirement-spec-tree-view", "--input", "-", "--format", "json", "--output", "out/spec-tree-compact.json"}, strings.NewReader(cliRequirementSpecTreeInput()), &stdout, &stderr) + if status != 0 || stdout.Len() != 0 || stderr.Len() != 0 { + t.Fatalf("unexpected compact JSON output write result status=%d stdout=%s stderr=%s", status, stdout.String(), stderr.String()) + } + compactContent, err := os.ReadFile(filepath.Join("out", "spec-tree-compact.json")) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(compactContent), "\n ") || !json.Valid(compactContent) { + t.Fatalf("compact file output is not valid compact JSON: %q", compactContent) + } + stdout.Reset() stderr.Reset() status = Run(t.Context(), []string{"requirement-spec-tree-view", "--input", "-", "--format", "markdown", "--output", "../spec-tree.md"}, strings.NewReader(cliRequirementSpecTreeInput()), &stdout, &stderr) diff --git a/internal/app/cli_contract_test.go b/internal/app/cli_contract_test.go index 92c5a66..e48477a 100644 --- a/internal/app/cli_contract_test.go +++ b/internal/app/cli_contract_test.go @@ -19,7 +19,7 @@ import ( ) const ( - cliContractPublicABISHA256 = "9923feb596ae4334e24d728b6156df3dbc6ac3464ed0b593e924a86c4d934b84" + cliContractPublicABISHA256 = "10d5eb817410b82f4bae384806f1e87449860b4fcf5c8e5f875ab5a1a204627d" maxAggregateFileReadBytesForContractTest = 64 << 20 maxPackageManifestBytesForContractTest = 256 << 10 maxSourceFileBytesForContractTest = 8 << 20 @@ -126,6 +126,33 @@ func TestCLIContractMatchesDispatcherAndHelp(t *testing.T) { } } +func TestRequirementBrowserHelpMatchesWorkspaceInputContract(t *testing.T) { + contract := readCLIContract(t) + var browser cliContractCommand + for _, command := range contract.Commands { + if command.Command == "requirement-browser-server" { + browser = command + break + } + } + if browser.Command == "" { + t.Fatal("requirement-browser-server contract missing") + } + inputContract := canonicalJSONValue(t, browser.InputContract).(map[string]any) + optionalFields := inputContract["workspaceOptionalFields"].([]any) + descriptor, ok := commandDescriptorFor("requirement-browser-server") + if !ok { + t.Fatal("requirement-browser-server descriptor missing") + } + summary := strings.Join(descriptor.inputSchemaSummary, "\n") + for _, rawField := range optionalFields { + field := rawField.(string) + if !strings.Contains(summary, "workspace mode: "+field+"=") { + t.Fatalf("requirement-browser-server help omits workspace optional field %q", field) + } + } +} + func TestProofkitContractMapRoutesRequiredInputCommands(t *testing.T) { contract := readCLIContract(t) documentBytes, err := os.ReadFile(filepath.Join(repoRoot(t), "docs", "proofkit-contract-map.md")) @@ -314,7 +341,7 @@ func TestCLIContractModeSpecificPromises(t *testing.T) { assertCommand(t, commands["requirement-source-view"], "required", []string{"--format", "--input", "--input-pointer"}, []string{"html", "json", "markdown"}) assertCommand(t, commands["requirement-spec-tree"], "required", []string{"--input", "--input-pointer"}, []string{"json"}) assertCommand(t, commands["requirement-spec-tree-view"], "required", []string{"--format", "--input", "--input-pointer", "--output"}, []string{"html", "json", "markdown"}) - assertCommand(t, commands["requirement-browser-server"], "required", []string{"--empty-local-environment-policy", "--host", "--input", "--input-pointer", "--local-environment-class", "--open", "--port", "--scope", "--serve", "--view"}, []string{"json", "server"}) + assertCommand(t, commands["requirement-browser-server"], "required", []string{"--empty-local-environment-policy", "--host", "--input", "--input-pointer", "--local-environment-class", "--open", "--port", "--scope", "--serve", "--session-mode", "--session-timeout-seconds", "--view"}, []string{"json", "server"}) assertCommand(t, commands["test-evidence-inventory"], "required", []string{"--input", "--input-pointer", "--normalized-inventory", "--projection"}, []string{"json", "normalized-inventory"}) assertCommand(t, commands["workspace-manifest-facts"], "required", []string{"--input", "--input-pointer"}, []string{"json"}) assertCommand(t, commands["requirement-impact-input-compose"], "required", []string{"--input", "--input-pointer"}, []string{"json"}) @@ -692,7 +719,8 @@ func TestDescriptorFlagConstraintsAreRenderedTruthfully(t *testing.T) { "adoption-contract-envelope": "agentic-proofkit adoption-contract-envelope --input [--agent-envelope] [--checked-scope ] [--guidance-mode ] [--materialization-manifest] --mode [--pilot ] [--touched-rule-id ]", "conformance-profile": "agentic-proofkit conformance-profile --input [--format ] [--input-pointer ] (--list | --profile | --verify)", "json-report-cli-adapter-source": "agentic-proofkit json-report-cli-adapter-source [--format ] --language ", - "requirement-browser-server": "agentic-proofkit requirement-browser-server --input [--empty-local-environment-policy] [--host 127.0.0.1|::1] [--input-pointer ] [--local-environment-class ] [--open] [--port ] [--scope ] [--serve] --view ", + "requirement-browser-server": "agentic-proofkit requirement-browser-server --input [--empty-local-environment-policy] [--host 127.0.0.1|::1] [--input-pointer ] [--local-environment-class ] [--open] [--port ] [--scope ] [--serve] [--session-mode browse|one-shot-question] [--session-timeout-seconds <1..7200>] --view ", + "requirement-context-compose": "agentic-proofkit requirement-context-compose --input [--input-pointer ] --repo-root ", "requirement-proof-resolver": "agentic-proofkit requirement-proof-resolver --input [--input-pointer ] (--empty-local-environment-policy | --local-environment-class )", "stack-preset": "agentic-proofkit stack-preset --preset ", "typescript-public-api-surfaces": "agentic-proofkit typescript-public-api-surfaces --input [--input-pointer ] --repo-root ", @@ -740,7 +768,7 @@ func TestDescriptorFlagConstraintsExecuteBeforeCommandDispatch(t *testing.T) { } for _, item := range cases { descriptor := commandDescriptorByName[item.command] - if err := validateFlagConstraints(descriptor, item.args); err == nil { + if err := validateFlagConstraints(descriptor, classifyDescriptorArguments(descriptor, item.args)); err == nil { t.Fatalf("%s invalid argv was admitted by descriptor owner", item.command) } } @@ -773,7 +801,7 @@ type cliContractCommand struct { func readCLIContract(t *testing.T) cliContract { t.Helper() - file, err := os.Open(filepath.Join(repoRoot(t), "proofkit", "cli-contract.v1.json")) + file, err := os.Open(filepath.Join(repoRoot(t), "proofkit", "cli-contract.v2.json")) if err != nil { t.Fatalf("open CLI contract: %v", err) } @@ -787,7 +815,7 @@ func readCLIContract(t *testing.T) cliContract { func assertCLIContractSchema(t *testing.T) { t.Helper() - file, err := os.Open(filepath.Join(repoRoot(t), "proofkit", "cli-contract.v1.json")) + file, err := os.Open(filepath.Join(repoRoot(t), "proofkit", "cli-contract.v2.json")) if err != nil { t.Fatalf("open CLI contract: %v", err) } @@ -801,7 +829,21 @@ func assertCLIContractSchema(t *testing.T) { if err := json.Unmarshal(record["processContract"], &processContract); err != nil { t.Fatalf("decode process contract: %v", err) } - assertKeys(t, "CLI process contract", keys(processContract), []string{"failureExitCode", "helpGrammar", "stderr", "stdout", "successExitCode"}) + assertKeys(t, "CLI process contract", keys(processContract), []string{"failureExitCode", "globalOptions", "helpGrammar", "stderr", "stdout", "successExitCode"}) + var globalOptions map[string]any + if err := json.Unmarshal(processContract["globalOptions"], &globalOptions); err != nil { + t.Fatalf("decode global options: %v", err) + } + assertKeys(t, "CLI global options", keysAny(globalOptions), []string{"jsonLayout"}) + jsonLayout, ok := globalOptions["jsonLayout"].(map[string]any) + if !ok { + t.Fatalf("CLI jsonLayout option must be an object: %#v", globalOptions["jsonLayout"]) + } + assertKeys(t, "CLI jsonLayout option", keysAny(jsonLayout), []string{"default", "flag", "position", "scope", "values"}) + assertStringSet(t, stringsFromAny(jsonLayout["values"].([]any)), []string{"compact", "pretty"}, "JSON layout values") + if jsonLayout["flag"] != "--json-layout" || jsonLayout["position"] != "before_command" || jsonLayout["default"] != "pretty" || jsonLayout["scope"] != "json_output_only" { + t.Fatalf("CLI jsonLayout option does not describe runtime admission: %#v", jsonLayout) + } var helpGrammar map[string]any if err := json.Unmarshal(processContract["helpGrammar"], &helpGrammar); err != nil { t.Fatalf("decode help grammar: %v", err) diff --git a/internal/app/command_coverage_routes.go b/internal/app/command_coverage_routes.go index 733a0ca..ab84c6b 100644 --- a/internal/app/command_coverage_routes.go +++ b/internal/app/command_coverage_routes.go @@ -104,6 +104,8 @@ var commandCoverageRoutes = map[string][]commandCoverageRoute{ "repo-profile-admission": {requiredInputAdmissionRoute, packageFalsifierRoute("internal/command/repoprofileadmission/repo_profile_admission_test.go", "TestBuildAdmitsValidRepoProfileAndRejectsRootPackageMismatch", semanticRouteProof("repo_profile_admission.build_admits_valid_repo_profile_and_rejects_root_package_mismatch", commandCoverageExpectedPublicOutcome), "Repo profile admission must reject mismatch between profile root package and observed package facts.")}, "requirement-bindings": {requiredInputAdmissionRoute, packageFalsifierRoute("internal/command/requirementbinding/projections_test.go", "TestBuildReportFailsUnknownRequirementBinding", semanticRouteProof("projections.build_report_fails_unknown_requirement_binding", commandCoverageExpectedPublicOutcome), "Requirement binding reports must fail closed when bindings reference unknown requirements.")}, "requirement-browser-server": {requiredInputAdmissionRoute, packageFalsifierRoute("internal/command/requirementbrowser/server_test.go", "TestStartServerFailsClosedForNonLoopbackHosts", semanticRouteProof("server.start_server_fails_closed_for_non_loopback_hosts", commandCoverageExpectedPublicOutcome), "Requirement browser server must reject non-loopback host binding."), directCLIRoute("internal/app/cli_abi_test.go", "TestRequirementBrowserServerSpecTreeCLIABI", semanticRouteProof("cli_abi.requirement_browser_server_spec_tree_cliabi", commandCoverageExpectedPublicOutcome), "Requirement browser server CLI ABI must admit explicit spec-tree view routing and emit a presentation-only browser plan.")}, + "requirement-context-compose": {requiredInputAdmissionRoute, packageFalsifierRoute("internal/command/requirementcontext/requirementcontext_test.go", "TestComposeAndSliceRoundTrip", semanticRouteProof("requirementcontext.compose_and_slice_round_trip", commandCoverageExpectedPublicOutcome), "Requirement context composition must read only an explicit catalog and produce a content-bound snapshot accepted unchanged by the slice owner.")}, + "requirement-context-slice": {requiredInputAdmissionRoute, packageFalsifierRoute("internal/command/requirementcontext/requirementcontext_test.go", "TestSliceRejectsTamperedSnapshotAndUnknownNode", semanticRouteProof("requirementcontext.slice_rejects_tampered_snapshot_and_unknown_node", commandCoverageExpectedPublicOutcome), "Requirement context slicing must reject stale snapshot identity and unknown explicit semantic targets.")}, "requirement-coverage-input-compose": { requiredInputAdmissionRoute, packageFalsifierRoute("internal/command/requirementcoverageinput/requirementcoverageinput_test.go", "TestBuildComposesInputPreservesDeclaredUniverseAndAllowsDownstreamFailures", semanticRouteProof("requirementcoverageinput.build_composes_input_preserves_declared_universe_and_allows_downstream_failures", commandCoverageExpectedPublicOutcome), "Requirement coverage input composition must preserve declared universe facts while keeping downstream coverage failures separate from composition admission."), @@ -114,6 +116,7 @@ var commandCoverageRoutes = map[string][]commandCoverageRoute{ "requirement-impact-input-compose": {requiredInputAdmissionRoute, packageFalsifierRoute("internal/command/requirementimpactinput/requirementimpactinput_test.go", "TestBuildComposesInputAndRoutesChangedBlockingRequirement", semanticRouteProof("requirementimpactinput.build_composes_input_and_routes_changed_blocking_requirement", commandCoverageExpectedPublicOutcome), "Requirement impact input composition must emit direct impact inputs from admitted caller-owned sources while preserving downstream impact semantics.")}, "requirement-proof-resolver": {requiredInputAdmissionRoute, packageFalsifierRoute("internal/command/requirementbinding/compact_contract_test.go", "TestBuildResolverRejectsUnscopedCompactIdentity", semanticRouteProof("compact_contract.build_resolver_rejects_unscoped_compact_identity", commandCoverageExpectedPublicOutcome), "Requirement proof resolver must fail closed on unscoped scenario ids and unadmitted witness selector identities."), packageFalsifierRoute("internal/command/requirementbinding/compact_contract_test.go", "TestBuildResolverEmitsNamedLookupFacts", semanticRouteProof("compact_contract.build_resolver_emits_named_lookup_facts", commandCoverageExpectedPublicOutcome), "Requirement proof resolver must emit deterministic named lookup facts for commands, environment classes, surfaces, scenarios, and witness selectors.")}, "requirement-proof-source-set": {requiredInputAdmissionRoute, packageFalsifierRoute("internal/command/requirementproofsourceset/requirementproofsourceset_test.go", "TestBuildSelectsSourceSetRowsAndEmitsResolverInput", semanticRouteProof("requirementproofsourceset.build_selects_source_set_rows_and_emits_resolver_input", commandCoverageExpectedPublicOutcome), "Requirement proof source-set normalization must select caller-owned source rows and emit resolver-compatible projections without scanning repositories.")}, + "requirement-semantic-diff": {requiredInputAdmissionRoute, packageFalsifierRoute("internal/command/requirementdiff/requirementdiff_test.go", "TestBuildClassifiesOwnerAwareRequirementChanges", semanticRouteProof("requirementdiff.build_classifies_owner_aware_requirement_changes", commandCoverageExpectedPublicOutcome), "Requirement semantic diff must compare admitted requirement fields by owner-declared field class instead of textual order.")}, "requirement-proof-view": {requiredInputAdmissionRoute, packageFalsifierRoute("internal/command/requirementproofview/requirementproofview_test.go", "TestBuildMarkdownEscapesCallerControlledCompactFields", semanticRouteProof("requirementproofview.build_markdown_escapes_caller_controlled_compact_fields", commandCoverageExpectedPublicOutcome), "Requirement proof view must escape caller-controlled compact binding fields.")}, "requirement-authoring-plan": {requiredInputAdmissionRoute, packageFalsifierRoute("internal/command/requirementauthoringplan/requirement_authoring_plan_test.go", "TestBuildRejectsCandidateSourceAdmissionFailure", semanticRouteProof("requirement_authoring_plan.build_rejects_candidate_source_admission_failure", commandCoverageExpectedPublicOutcome), "Requirement authoring plans must keep candidate source previews candidate-only and fail closed when the composed source cannot pass requirement-source admission.")}, "requirement-source-admission": {requiredInputAdmissionRoute, packageFalsifierRoute("internal/command/requirementsourceadmission/requirementsourceadmission_test.go", "TestEvaluateRejectsBlockingRequirementWithoutProofRoute", semanticRouteProof("requirementsourceadmission.evaluate_rejects_blocking_requirement_without_proof_route", commandCoverageExpectedPublicOutcome), "Requirement source admission must reject blocking active requirements without proof binding routes.")}, @@ -121,6 +124,7 @@ var commandCoverageRoutes = map[string][]commandCoverageRoute{ "requirement-source-view": {requiredInputAdmissionRoute, packageFalsifierRoute("internal/command/requirementsourceview/requirementsourceview_test.go", "TestBuildMarkdownEscapesCallerControlledText", semanticRouteProof("requirementsourceview.build_markdown_escapes_caller_controlled_text", commandCoverageExpectedPublicOutcome), "Requirement source view must escape caller-controlled requirement text.")}, "requirement-spec-tree": {requiredInputAdmissionRoute, packageFalsifierRoute("internal/command/requirementspectree/requirementspectree_test.go", "TestBuildRejectsDAGAndStaleDigest", semanticRouteProof("requirementspectree.build_rejects_dagand_stale_digest", commandCoverageExpectedPublicOutcome), "Requirement spec tree admission must reject DAG topology and stale caller-provided source digest facts.")}, "requirement-spec-tree-view": {requiredInputAdmissionRoute, packageFalsifierRoute("internal/command/requirementspectree/requirementspectree_test.go", "TestBuildViewMarkdownAndHTMLAreDeterministicAndEscaped", semanticRouteProof("requirementspectree.build_view_markdown_and_htmlare_deterministic_and_escaped", commandCoverageExpectedPublicOutcome), "Requirement spec tree views must reuse admitted spec-tree input and escape caller-controlled text in deterministic HTML and Markdown projections.")}, + "requirement-traceability-graph": {requiredInputAdmissionRoute, packageFalsifierRoute("internal/command/requirementgraph/requirementgraph_test.go", "TestBuildKeepsTraceabilityEvidencePlanesDistinct", semanticRouteProof("requirementgraph.build_keeps_traceability_evidence_planes_distinct", commandCoverageExpectedPublicOutcome), "Requirement traceability graph must keep specification, proof, code traceability, and native execution evidence planes distinct.")}, "scaffold-profile-plan": {requiredInputAdmissionRoute, packageFalsifierRoute("internal/command/scaffoldprofileplan/scaffoldprofileplan_test.go", "TestBuildAcceptsCommandMatcherHints", semanticRouteProof("scaffoldprofileplan.build_accepts_command_matcher_hints", commandCoverageExpectedPublicOutcome), "Scaffold profile planning must preserve caller-reviewed command matcher hints as deterministic profile draft data.")}, "scaffold-project-structure": {requiredInputAdmissionRoute, packageFalsifierRoute("internal/command/projectstructure/projectstructure_test.go", "TestBuildAdmitsProjectStructureScaffoldAndEmitsBoundedEnvelope", semanticRouteProof("projectstructure.build_admits_project_structure_scaffold_and_emits_bounded_envelope", commandCoverageExpectedPublicOutcome), "Project structure scaffold must emit deterministic source-report identity and bounded agent guidance without writing files."), packageFalsifierRoute("internal/command/projectstructure/projectstructure_test.go", "TestBuildRejectsProjectStructurePathDriftAndUnsafePaths", semanticRouteProof("projectstructure.build_rejects_project_structure_path_drift_and_unsafe_paths", commandCoverageExpectedPublicOutcome), "Project structure scaffold must reject unsafe paths and inconsistent bootstrap/profile proof paths.")}, "selective-gate-evidence": {requiredInputAdmissionRoute, packageFalsifierRoute("internal/command/selectivegateevidence/selectivegateevidence_test.go", "TestBuildRejectsMergeSatisfyingEvidenceWithoutProducerAdmission", semanticRouteProof("selectivegateevidence.build_rejects_merge_satisfying_evidence_without_producer_admission", commandCoverageExpectedPublicOutcome), "Selective gate evidence must reject merge-satisfying evidence without producer admission."), packageFalsifierRoute("internal/command/selectivegateevidence/selectivegateevidence_test.go", "TestBuildReportsMergeEvidenceWithoutApprovingMerge", semanticRouteProof("selectivegateevidence.build_reports_merge_evidence_without_approving_merge", commandCoverageExpectedPublicOutcome), "Selective gate evidence must report merge evidence facts without approving consumer-owned merge admission.")}, diff --git a/internal/app/command_coverage_test.go b/internal/app/command_coverage_test.go index 2325e87..c53376e 100644 --- a/internal/app/command_coverage_test.go +++ b/internal/app/command_coverage_test.go @@ -555,6 +555,8 @@ func malformedInputExtraArgs(command string) []string { return []string{"--pilot", "first"} case "requirement-browser-server": return []string{"--view", "source"} + case "requirement-context-compose": + return []string{"--repo-root", "."} case "requirement-proof-resolver": return []string{"--empty-local-environment-policy"} case "requirement-proof-view": diff --git a/internal/app/command_descriptors.go b/internal/app/command_descriptors.go index 390fd10..c1d7a0f 100644 --- a/internal/app/command_descriptors.go +++ b/internal/app/command_descriptors.go @@ -31,6 +31,7 @@ const ( commandRunnerPlanning commandRunner = "planning" commandRunnerProjectStructure commandRunner = "project_structure" commandRunnerRequirementBrowserServer commandRunner = "requirement_browser_server" + commandRunnerRequirementContextCompose commandRunner = "requirement_context_compose" commandRunnerRequirementProofResolver commandRunner = "requirement_proof_resolver" commandRunnerRequirementView commandRunner = "requirement_view" commandRunnerStackPreset commandRunner = "stack_preset" @@ -113,18 +114,22 @@ var commandDescriptors = []commandDescriptor{ command("repo-profile-admission", commandInputRequired, flags("--input", "--input-pointer"), modes("json"), ownerDirs("repoprofileadmission")), command("requirement-authoring-plan", commandInputRequired, flags("--input", "--input-pointer"), modes("json"), ownerDirs("requirementauthoringplan")), command("requirement-bindings", commandInputRequired, flags("--input", "--input-pointer"), modes("json"), ownerDirs("requirementbinding")), - command("requirement-browser-server", commandInputRequired, flags("--empty-local-environment-policy", "--host", "--input", "--input-pointer", "--local-environment-class", "--open", "--port", "--scope", "--serve", "--view"), modes("json", "server"), ownerDirs("requirementbrowser"), withRunner(commandRunnerRequirementBrowserServer), withSemanticAppTests("TestRequirementBrowserServerSpecTreeCLIABI"), withRequiredFlags("--view")), + command("requirement-browser-server", commandInputRequired, flags("--empty-local-environment-policy", "--host", "--input", "--input-pointer", "--local-environment-class", "--open", "--port", "--scope", "--serve", "--session-mode", "--session-timeout-seconds", "--view"), modes("json", "server"), ownerDirs("requirementbrowser"), withRunner(commandRunnerRequirementBrowserServer), withSemanticAppTests("TestRequirementBrowserServerSpecTreeCLIABI"), withRequiredFlags("--view"), withFlagValueRequirement("--session-mode", "one-shot-question", "--open", "--serve", "--view"), withInputSchemaSummary("workspace mode: schemaVersion=1", "workspace mode: workspaceId", "workspace mode: context=proofkit.requirement-context", "workspace mode: diffInput=proofkit.requirement-semantic-diff-input (optional)", "workspace mode: graphInput=proofkit.requirement-traceability-graph-input (optional)", "--session-mode values: browse|one-shot-question", "one-shot-question requires --view workspace --serve --open", "--session-timeout-seconds is 1..7200 and requires one-shot-question", "source|proof|coverage|spec-tree modes retain their owner input contracts")), + command("requirement-context-compose", commandInputRequired, flags("--input", "--input-pointer", "--repo-root"), modes("json"), ownerDirs("requirementcontext"), withRunner(commandRunnerRequirementContextCompose), withScopeClass(commandScopeExplicitFileSystemScan), withRequiredFlags("--repo-root"), withInputSchemaSummary("schemaVersion=1", "catalogId", "specTree.path", "requirementSources[] (non-empty)", "requirementSources[].nodeId", "requirementSources[].path", "expectedSourceDigest (optional sha256 ref)", "proofBinding.path (optional)", "coverage.path (optional)", "exact catalog paths only; no discovery")), + command("requirement-context-slice", commandInputRequired, flags("--input", "--input-pointer"), modes("json"), ownerDirs("requirementcontext"), withInputSchemaSummary("schemaVersion=1", "sliceId", "context=proofkit.requirement-context", "query.profile=routing|specification|proof|coverage|review", "query.nodeIds[]|requirementIds[]|ownerIds[]|lifecycleStates[]", "query.maxDepth=0..512", "query.maxNodes=1..4096", "query.maxRequirements=1..16384")), command("requirement-coverage-input-compose", commandInputRequired, flags("--input", "--input-pointer"), modes("json"), ownerDirs("requirementcoverageinput")), command("requirement-coverage-view", commandInputRequired, flags("--agent-envelope", "--format", "--input", "--input-pointer"), modes("html", "json", "markdown"), ownerDirs("requirementcoverageview"), withRunner(commandRunnerRequirementView), withAgentEnvelope()), command("requirement-impact-input-compose", commandInputRequired, flags("--input", "--input-pointer"), modes("json"), ownerDirs("requirementimpactinput")), command("requirement-proof-resolver", commandInputRequired, flags("--empty-local-environment-policy", "--input", "--input-pointer", "--local-environment-class"), modes("json"), ownerDirs("requirementbinding"), withRunner(commandRunnerRequirementProofResolver), withExactlyOneOfFlags("--empty-local-environment-policy", "--local-environment-class")), command("requirement-proof-source-set", commandInputRequired, flags("--input", "--input-pointer"), modes("json"), ownerDirs("requirementproofsourceset")), command("requirement-proof-view", commandInputRequired, flags("--empty-local-environment-policy", "--format", "--input", "--input-pointer", "--local-environment-class", "--scope"), modes("html", "json", "markdown"), ownerDirs("requirementproofview"), withRunner(commandRunnerRequirementView)), + command("requirement-semantic-diff", commandInputRequired, flags("--input", "--input-pointer"), modes("json"), ownerDirs("requirementdiff"), withInputSchemaSummary("schemaVersion=1", "diffId", "baseContext=proofkit.requirement-context", "currentContext=proofkit.requirement-context", "query.requirementIds[] (optional)", "query.ownerIds[] (optional)", "query.maxChanges=1..8192 (optional)")), command("requirement-source-admission", commandInputRequired, flags("--input", "--input-pointer"), modes("json"), ownerDirs("requirementsourceadmission")), command("requirement-source-transition", commandInputRequired, flags("--input", "--input-pointer"), modes("json"), ownerDirs("requirementsourcetransition")), command("requirement-source-view", commandInputRequired, flags("--format", "--input", "--input-pointer"), modes("html", "json", "markdown"), ownerDirs("requirementsourceview"), withRunner(commandRunnerRequirementView)), command("requirement-spec-tree", commandInputRequired, flags("--input", "--input-pointer"), modes("json"), ownerDirs("requirementspectree")), command("requirement-spec-tree-view", commandInputRequired, flags("--format", "--input", "--input-pointer", "--output"), modes("html", "json", "markdown"), ownerDirs("requirementspectree"), withRunner(commandRunnerRequirementView)), + command("requirement-traceability-graph", commandInputRequired, flags("--input", "--input-pointer"), modes("json"), ownerDirs("requirementgraph"), withInputSchemaSummary("schemaVersion=1", "graphId", "context=proofkit.requirement-context", "codeSources[].path+content (optional, bounded UTF-8)", "codeTopology.topologyId", "codeTopology.nodes[].abstractionLevel=repository|package|module|file|symbol|source_range", "codeTopology.nodes[].sourceDigest+currentnessState", "codeTopology.edges[].evidenceRefs+authorityClass+currentnessState", "codeTopology.nativeCoverage[].producerId+evidenceRef+authorityClass+currentnessState+state")), command("scaffold-profile-plan", commandInputRequired, flags("--input", "--input-pointer"), modes("json"), ownerDirs("scaffoldprofileplan")), command("scaffold-project-structure", commandInputRequired, flags("--agent-envelope", "--input", "--input-pointer"), modes("json"), ownerDirs("projectstructure"), withRunner(commandRunnerProjectStructure), withAgentEnvelope()), command("selective-gate-evidence", commandInputRequired, flags("--agent-envelope", "--input", "--input-pointer"), modes("json"), ownerDirs("selectivegateevidence"), withRunner(commandRunnerPlanning), withAgentEnvelope()), @@ -163,6 +168,7 @@ var knownCommandRunners = map[commandRunner]struct{}{ commandRunnerPlanning: {}, commandRunnerProjectStructure: {}, commandRunnerRequirementBrowserServer: {}, + commandRunnerRequirementContextCompose: {}, commandRunnerRequirementProofResolver: {}, commandRunnerRequirementView: {}, commandRunnerStackPreset: {}, diff --git a/internal/app/command_family_catalog_generated.go b/internal/app/command_family_catalog_generated.go index f2cad39..4fc55b1 100644 --- a/internal/app/command_family_catalog_generated.go +++ b/internal/app/command_family_catalog_generated.go @@ -1,7 +1,7 @@ // Code generated by internal/tools/commandfamilygen; DO NOT EDIT. package app -const commandFamilyCatalogSourceSHA256 = "9dd3f9d95c430ab623f2c64794fb3541124f8b967012094d1e46753b367512d7" +const commandFamilyCatalogSourceSHA256 = "5373aceca3b2d69d0d687e68b3801c6b914c2259f71569e476aa7a38383cd166" func generatedCommandFamilyCatalog() commandFamilyCatalog { return commandFamilyCatalog{ @@ -16,6 +16,7 @@ func generatedCommandFamilyCatalog() commandFamilyCatalog { {ID: "receipt-authority", Label: "Receipt authority", Purpose: "Admit receipts, producer compatibility, currentness, and trust classes.", Commands: []string{"producer-policy-self-proof", "proof-receipt-admission", "receipt-currentness-scope", "receipt-producer-admission", "receipt-trust-class"}}, {ID: "release-artifact-consumption", Label: "Release artifact consumption", Purpose: "Check package release facts and external or registry consumption.", Commands: []string{"external-consumer", "registry-consumer", "registry-consumer-proof-input-compose", "release-authority"}}, {ID: "repository-facts-and-scanning", Label: "Repository facts and scanning", Purpose: "Admit explicit repository facts and bounded policy scans.", Commands: []string{"repo-profile-admission", "secret-scan", "text-policy", "typescript-public-api-surfaces", "workspace-manifest-facts"}}, + {ID: "requirement-context-and-traceability", Label: "Requirement context and traceability", Purpose: "Compose bounded semantic context and derive diff and traceability projections.", Commands: []string{"requirement-context-compose", "requirement-context-slice", "requirement-semantic-diff", "requirement-traceability-graph"}}, {ID: "requirement-navigation-and-rendering", Label: "Requirement navigation and rendering", Purpose: "Build and present requirement, proof, and coverage views.", Commands: []string{"requirement-browser-server", "requirement-coverage-view", "requirement-proof-view", "requirement-source-view", "requirement-spec-tree", "requirement-spec-tree-view"}}, {ID: "requirement-source-lifecycle", Label: "Requirement source lifecycle", Purpose: "Author, admit, transition, and check requirement source claims.", Commands: []string{"requirement-authoring-plan", "requirement-source-admission", "requirement-source-transition", "spec-overview-claims"}}, {ID: "scaffolding-and-capability-capture", Label: "Scaffolding and capability capture", Purpose: "Produce candidate adoption structures and capability seeds.", Commands: []string{"adoption-contract-envelope", "capability-map-admission", "scaffold-profile-plan", "scaffold-project-structure", "stack-preset"}}, diff --git a/internal/app/command_flag_constraints.go b/internal/app/command_flag_constraints.go index 5c6e115..5de00c0 100644 --- a/internal/app/command_flag_constraints.go +++ b/internal/app/command_flag_constraints.go @@ -5,34 +5,42 @@ import ( "slices" ) -func validateFlagConstraints(descriptor commandDescriptor, args []string) error { - present := map[string]bool{} - values := map[string][]string{} +type descriptorArguments struct { + present map[string]bool + values map[string][]string +} + +func classifyDescriptorArguments(descriptor commandDescriptor, args []string) descriptorArguments { + parsed := descriptorArguments{present: map[string]bool{}, values: map[string][]string{}} for index := 0; index < len(args); index++ { argument := args[index] if !slices.Contains(descriptor.allowedFlags, argument) { continue } - present[argument] = true + parsed.present[argument] = true if flagRequiresValue(argument) { - if index+1 < len(args) && !slices.Contains(descriptor.allowedFlags, args[index+1]) { - values[argument] = append(values[argument], args[index+1]) + if index+1 < len(args) { + parsed.values[argument] = append(parsed.values[argument], args[index+1]) index++ } } } - if descriptor.input == commandInputRequired && !present["--input"] { + return parsed +} + +func validateFlagConstraints(descriptor commandDescriptor, parsed descriptorArguments) error { + if descriptor.input == commandInputRequired && !parsed.present["--input"] { return fmt.Errorf("%s requires --input ", descriptor.name) } for _, flag := range descriptor.requiredFlags { - if !present[flag] { + if !parsed.present[flag] { return fmt.Errorf("%s requires %s", descriptor.name, flag) } } for _, group := range descriptor.exactlyOneOfFlagGroups { count := 0 for _, flag := range group { - if present[flag] { + if parsed.present[flag] { count++ } } @@ -41,11 +49,11 @@ func validateFlagConstraints(descriptor commandDescriptor, args []string) error } } for _, requirement := range descriptor.flagValueRequirements { - if !slices.Contains(values[requirement.Flag], requirement.Value) { + if !slices.Contains(parsed.values[requirement.Flag], requirement.Value) { continue } for _, flag := range requirement.RequiredFlags { - if !present[flag] { + if !parsed.present[flag] { return fmt.Errorf("%s %s %s requires %s", descriptor.name, requirement.Flag, requirement.Value, flag) } } diff --git a/internal/app/command_help.go b/internal/app/command_help.go index 39f2975..2a2fe31 100644 --- a/internal/app/command_help.go +++ b/internal/app/command_help.go @@ -62,7 +62,7 @@ func commandUsage(descriptor commandDescriptor) string { } } lines = append(lines, "", "Public contract:") - lines = append(lines, " CLI/JSON input, output modes, exit codes, and flags are owned by proofkit/cli-contract.v1.json.") + lines = append(lines, " CLI/JSON input, output modes, exit codes, and flags are owned by proofkit/cli-contract.v2.json.") return strings.Join(lines, "\n") + "\n" } @@ -92,6 +92,10 @@ func commandUsageLine(descriptor commandDescriptor) string { segments = append(segments, "[--host 127.0.0.1|::1]") case "--port": segments = append(segments, "[--port ]") + case "--session-mode": + segments = append(segments, "[--session-mode browse|one-shot-question]") + case "--session-timeout-seconds": + segments = append(segments, "[--session-timeout-seconds <1..7200>]") case "--scope": segments = append(segments, "[--scope ]") case "--local-environment-class": diff --git a/internal/app/command_registry.go b/internal/app/command_registry.go index de11cf1..af6dd77 100644 --- a/internal/app/command_registry.go +++ b/internal/app/command_registry.go @@ -31,7 +31,10 @@ import ( "github.com/research-engineering/agentic-proofkit/internal/command/repoprofileadmission" "github.com/research-engineering/agentic-proofkit/internal/command/requirementauthoringplan" "github.com/research-engineering/agentic-proofkit/internal/command/requirementbinding" + "github.com/research-engineering/agentic-proofkit/internal/command/requirementcontext" "github.com/research-engineering/agentic-proofkit/internal/command/requirementcoverageinput" + "github.com/research-engineering/agentic-proofkit/internal/command/requirementdiff" + "github.com/research-engineering/agentic-proofkit/internal/command/requirementgraph" "github.com/research-engineering/agentic-proofkit/internal/command/requirementimpactinput" "github.com/research-engineering/agentic-proofkit/internal/command/requirementproofsourceset" "github.com/research-engineering/agentic-proofkit/internal/command/requirementsourceadmission" @@ -84,6 +87,9 @@ var genericCommandBuilders = mustGenericCommandBuilders(map[string]genericComman "requirement-authoring-plan": outputWithExit(requirementauthoringplan.Build), "requirement-bindings": reportOutput(requirementbinding.BuildReport), "requirement-coverage-input-compose": outputWithExit(requirementcoverageinput.Build), + "requirement-context-slice": zeroExitOutput(requirementcontext.Slice), + "requirement-semantic-diff": zeroExitOutput(requirementdiff.Build), + "requirement-traceability-graph": zeroExitOutput(requirementgraph.Build), "requirement-impact-input-compose": outputWithExit(requirementimpactinput.Build), "requirement-proof-source-set": requirementproofsourceset.Build, "requirement-source-admission": reportOutput(requirementsourceadmission.Build), diff --git a/internal/app/io.go b/internal/app/io.go index ac79cfd..be0545e 100644 --- a/internal/app/io.go +++ b/internal/app/io.go @@ -15,7 +15,7 @@ func writeJSON(value any, exitCode int, err error, stdout io.Writer, stderr io.W writeDiagnostic(stderr, err) return 1 } - output, err := stablejson.Marshal(value) + output, err := stablejson.MarshalLayout(value, jsonLayoutFromWriter(stdout)) if err != nil { writeDiagnostic(stderr, err) return 1 diff --git a/internal/app/json_layout.go b/internal/app/json_layout.go new file mode 100644 index 0000000..e830ca1 --- /dev/null +++ b/internal/app/json_layout.go @@ -0,0 +1,59 @@ +package app + +import ( + "fmt" + "io" + "slices" + + "github.com/research-engineering/agentic-proofkit/internal/kernel/stablejson" +) + +type layoutWriter struct { + io.Writer + layout stablejson.Layout +} + +func parseProcessOptions(args []string) ([]string, stablejson.Layout, bool, error) { + layout := stablejson.LayoutPretty + explicit := false + for len(args) > 0 && args[0] == "--json-layout" { + if explicit { + return nil, "", false, fmt.Errorf("--json-layout may be specified only once") + } + if len(args) < 2 { + return nil, "", false, fmt.Errorf("--json-layout requires pretty or compact") + } + layout = stablejson.Layout(args[1]) + if layout != stablejson.LayoutPretty && layout != stablejson.LayoutCompact { + return nil, "", false, fmt.Errorf("--json-layout must be pretty or compact") + } + explicit = true + args = args[2:] + } + return args, layout, explicit, nil +} + +func jsonLayoutFromWriter(writer io.Writer) stablejson.Layout { + if typed, ok := writer.(layoutWriter); ok { + return typed.layout + } + return stablejson.LayoutPretty +} + +func validateJSONLayoutUse(descriptor commandDescriptor, parsed descriptorArguments, explicit bool) error { + if !explicit { + return nil + } + if !slices.Contains(descriptor.outputModes, "json") { + return fmt.Errorf("--json-layout is valid only for JSON command output") + } + if descriptor.name == "requirement-browser-server" && parsed.present["--serve"] { + return fmt.Errorf("--json-layout is invalid when requirement-browser-server serves a browser session") + } + for _, format := range parsed.values["--format"] { + if format != "json" { + return fmt.Errorf("--json-layout is valid only with --format json") + } + } + return nil +} diff --git a/internal/app/requirement_browser_command.go b/internal/app/requirement_browser_command.go index 3bd8c09..f74b9f8 100644 --- a/internal/app/requirement_browser_command.go +++ b/internal/app/requirement_browser_command.go @@ -2,12 +2,14 @@ package app import ( "context" + "errors" "fmt" "io" "os" "os/signal" "strconv" "syscall" + "time" "github.com/research-engineering/agentic-proofkit/internal/command/requirementbrowser" "github.com/research-engineering/agentic-proofkit/internal/kernel/compactproofcontract" @@ -40,12 +42,17 @@ func runRequirementBrowserServer(ctx context.Context, args []string, stdin io.Re Port: options.port, PortSet: options.portSet, ProofViewScope: options.scope, + SessionMode: options.sessionMode, + SessionTimeout: time.Duration(options.sessionTimeoutSeconds) * time.Second, View: options.view, } if options.serve { signalContext, stop := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM) defer stop() if err := requirementbrowser.Serve(signalContext, input, browserOptions, stdout); err != nil { + if errors.Is(err, requirementbrowser.ErrOneShotTerminal) { + return 1 + } writeDiagnostic(stderr, err) return 1 } @@ -56,8 +63,10 @@ func runRequirementBrowserServer(ctx context.Context, args []string, stdin io.Re } func parseRequirementBrowserArgs(args []string) (requirementBrowserArgs, error) { - options := requirementBrowserArgs{host: "127.0.0.1", port: 4177} + options := requirementBrowserArgs{host: "127.0.0.1", port: 0, sessionMode: "browse"} inputPointerSeen := false + sessionModeSeen := false + sessionTimeoutSeen := false for index := 0; index < len(args); index++ { switch args[index] { case "--input": @@ -74,8 +83,8 @@ func parseRequirementBrowserArgs(args []string) (requirementBrowserArgs, error) options.inputPointer = args[index+1] index++ case "--view": - if index+1 >= len(args) || (args[index+1] != "source" && args[index+1] != "proof" && args[index+1] != "coverage" && args[index+1] != "spec-tree") { - return requirementBrowserArgs{}, fmt.Errorf("--view requires source, proof, coverage, or spec-tree") + if index+1 >= len(args) || (args[index+1] != "source" && args[index+1] != "proof" && args[index+1] != "coverage" && args[index+1] != "spec-tree" && args[index+1] != "workspace") { + return requirementBrowserArgs{}, fmt.Errorf("--view requires source, proof, coverage, spec-tree, or workspace") } options.view = args[index+1] index++ @@ -100,6 +109,24 @@ func parseRequirementBrowserArgs(args []string) (requirementBrowserArgs, error) options.open = true case "--serve": options.serve = true + case "--session-mode": + if sessionModeSeen || index+1 >= len(args) || (args[index+1] != "browse" && args[index+1] != "one-shot-question") { + return requirementBrowserArgs{}, fmt.Errorf("--session-mode requires browse or one-shot-question") + } + sessionModeSeen = true + options.sessionMode = args[index+1] + index++ + case "--session-timeout-seconds": + if sessionTimeoutSeen || index+1 >= len(args) { + return requirementBrowserArgs{}, fmt.Errorf("--session-timeout-seconds requires an integer from 1 to 7200") + } + sessionTimeoutSeen = true + seconds, err := strconv.Atoi(args[index+1]) + if err != nil || seconds < 1 || seconds > 7200 { + return requirementBrowserArgs{}, fmt.Errorf("--session-timeout-seconds requires an integer from 1 to 7200") + } + options.sessionTimeoutSeconds = seconds + index++ case "--scope": if index+1 >= len(args) || (args[index+1] != "graph" && args[index+1] != "slice") { return requirementBrowserArgs{}, fmt.Errorf("--scope requires graph or slice") @@ -123,7 +150,7 @@ func parseRequirementBrowserArgs(args []string) (requirementBrowserArgs, error) } } if options.view == "" { - return requirementBrowserArgs{}, fmt.Errorf("requirement-browser-server requires --view source, proof, coverage, or spec-tree") + return requirementBrowserArgs{}, fmt.Errorf("requirement-browser-server requires --view source, proof, coverage, spec-tree, or workspace") } if options.inputPath == "" { return requirementBrowserArgs{}, fmt.Errorf("--input is required") @@ -131,6 +158,15 @@ func parseRequirementBrowserArgs(args []string) (requirementBrowserArgs, error) if options.open && !options.serve { return requirementBrowserArgs{}, fmt.Errorf("--open requires --serve for requirement-browser-server") } + if options.sessionMode == "one-shot-question" && (!options.serve || !options.open || options.view != "workspace") { + return requirementBrowserArgs{}, fmt.Errorf("one-shot-question requires --view workspace --serve --open") + } + if sessionModeSeen && (options.view != "workspace" || !options.serve) { + return requirementBrowserArgs{}, fmt.Errorf("--session-mode is valid only with --view workspace --serve") + } + if options.sessionTimeoutSeconds != 0 && options.sessionMode != "one-shot-question" { + return requirementBrowserArgs{}, fmt.Errorf("--session-timeout-seconds is valid only for one-shot-question") + } if options.view != "proof" && options.scope != "" { return requirementBrowserArgs{}, fmt.Errorf("--scope is valid only for requirement-proof-view or proof requirement-browser-server") } diff --git a/internal/app/requirement_commands.go b/internal/app/requirement_commands.go index 09ed178..48ca1ef 100644 --- a/internal/app/requirement_commands.go +++ b/internal/app/requirement_commands.go @@ -200,17 +200,17 @@ func writeSpecTreeView(input any, options requirementViewArgs, stdout io.Writer, } output, exitCode, err := requirementspectree.BuildViewJSON(input) if options.outputPath != "" { - return writeViewJSON(output, exitCode, err, options.outputPath, stderr) + return writeViewJSON(output, exitCode, err, options.outputPath, jsonLayoutFromWriter(stdout), stderr) } return writeJSON(output, exitCode, err, stdout, stderr) } -func writeViewJSON(output any, exitCode int, err error, outputPath string, stderr io.Writer) int { +func writeViewJSON(output any, exitCode int, err error, outputPath string, layout stablejson.Layout, stderr io.Writer) int { if err != nil { writeDiagnostic(stderr, err) return 1 } - bytes, err := stablejson.Marshal(output) + bytes, err := stablejson.MarshalLayout(output, layout) if err != nil { writeDiagnostic(stderr, err) return 1 diff --git a/internal/app/requirement_context_command.go b/internal/app/requirement_context_command.go new file mode 100644 index 0000000..f3ee3c4 --- /dev/null +++ b/internal/app/requirement_context_command.go @@ -0,0 +1,65 @@ +package app + +import ( + "fmt" + "io" + + "github.com/research-engineering/agentic-proofkit/internal/command/requirementcontext" + "github.com/research-engineering/agentic-proofkit/internal/kernel/jsonpointer" +) + +func runRequirementContextCompose(args []string, stdin io.Reader, stdout io.Writer, stderr io.Writer) int { + inputPath, inputPointer, repoRoot, err := parseRequirementContextComposeArgs(args) + if err != nil { + writeDiagnostic(stderr, err) + return 1 + } + input, err := readInput(inputPath, stdin) + if err != nil { + writeDiagnostic(stderr, err) + return 1 + } + if inputPointer != "" { + input, err = jsonpointer.Select(input, inputPointer) + if err != nil { + writeDiagnostic(stderr, err) + return 1 + } + } + output, err := requirementcontext.Compose(repoRoot, input) + return writeJSON(output, 0, err, stdout, stderr) +} + +func parseRequirementContextComposeArgs(args []string) (string, string, string, error) { + inputPath := "" + inputPointer := "" + repoRoot := "" + for index := 0; index < len(args); index++ { + switch args[index] { + case "--input": + if inputPath != "" || index+1 >= len(args) || args[index+1] == "" { + return "", "", "", fmt.Errorf("requirement-context-compose requires --input ") + } + inputPath = args[index+1] + index++ + case "--input-pointer": + if inputPointer != "" || index+1 >= len(args) { + return "", "", "", fmt.Errorf("--input-pointer requires a JSON pointer") + } + inputPointer = args[index+1] + index++ + case "--repo-root": + if repoRoot != "" || index+1 >= len(args) || args[index+1] == "" { + return "", "", "", fmt.Errorf("--repo-root requires a path") + } + repoRoot = args[index+1] + index++ + default: + return "", "", "", fmt.Errorf("unsupported argument for requirement-context-compose: %s", args[index]) + } + } + if inputPath == "" || repoRoot == "" { + return "", "", "", fmt.Errorf("requirement-context-compose requires --input and --repo-root ") + } + return inputPath, inputPointer, repoRoot, nil +} diff --git a/internal/app/usage.go b/internal/app/usage.go index a86948b..c71b4d6 100644 --- a/internal/app/usage.go +++ b/internal/app/usage.go @@ -3,7 +3,7 @@ package app import "strings" func usage() string { - lines := []string{"Usage:"} + lines := []string{"Usage:", " agentic-proofkit [--json-layout pretty|compact] [arguments]", "", "Commands:"} for _, descriptor := range commandDescriptors { lines = append(lines, " "+commandUsageLine(descriptor)) } diff --git a/internal/command/agentroute/agentroute.go b/internal/command/agentroute/agentroute.go index eb2d1e3..ef95c2d 100644 --- a/internal/command/agentroute/agentroute.go +++ b/internal/command/agentroute/agentroute.go @@ -11,56 +11,65 @@ import ( var ( goalValues = map[string]struct{}{ - "admit_receipts": {}, - "adopt_repository": {}, - "author_requirements": {}, - "bind_requirement_proofs": {}, - "check_overview_claims": {}, - "decide_obligations": {}, - "inspect_coverage": {}, - "inventory_tests": {}, - "plan_selective_checks": {}, - "release_or_deploy_evidence": {}, - "render_human_view": {}, - "retire_local_infrastructure": {}, - "scaffold_first_module": {}, - "unknown": {}, - "validate_requirement_source": {}, - "verify_typescript_public_api": {}, + "admit_receipts": {}, + "adopt_repository": {}, + "author_requirements": {}, + "bind_requirement_proofs": {}, + "check_overview_claims": {}, + "decide_obligations": {}, + "inspect_coverage": {}, + "inspect_requirement_context": {}, + "inspect_requirement_traceability": {}, + "inventory_tests": {}, + "plan_selective_checks": {}, + "release_or_deploy_evidence": {}, + "render_human_view": {}, + "review_requirement_change": {}, + "retire_local_infrastructure": {}, + "scaffold_first_module": {}, + "unknown": {}, + "validate_requirement_source": {}, + "verify_typescript_public_api": {}, } modeValues = adoptionmode.ValuesMap() inputKindValues = map[string]struct{}{ - "adoption_workflow": {}, - "authoring_plan": {}, - "binding_witness_plan_input": {}, - "capability_map": {}, - "changed_path_set": {}, - "coverage_compose_input": {}, - "coverage_view_input": {}, - "compact_proof_binding": {}, - "deployment_evidence_input": {}, - "impact_input": {}, - "migration_plan": {}, - "migration_parity": {}, - "obligation_decision": {}, - "obligation_decision_input": {}, - "overview_claims": {}, - "proof_binding": {}, - "readiness_closeout_input": {}, - "registry_consumer_input": {}, - "release_authority_input": {}, - "requirement_source": {}, - "requirement_source_transition": {}, - "scaffold_profile_plan": {}, - "scaffold_project_structure": {}, - "selective_evidence": {}, - "selective_gate_plan_input": {}, - "spec_tree_bundle": {}, - "test_discovery": {}, - "test_inventory": {}, - "typescript_public_api_manifest": {}, - "typescript_public_api_repo_root": {}, - "witness_command_catalog": {}, + "adoption_workflow": {}, + "authoring_plan": {}, + "binding_witness_plan_input": {}, + "capability_map": {}, + "changed_path_set": {}, + "coverage_compose_input": {}, + "coverage_view_input": {}, + "compact_proof_binding": {}, + "deployment_evidence_input": {}, + "impact_input": {}, + "migration_plan": {}, + "migration_parity": {}, + "obligation_decision": {}, + "obligation_decision_input": {}, + "overview_claims": {}, + "proof_binding": {}, + "readiness_closeout_input": {}, + "registry_consumer_input": {}, + "release_authority_input": {}, + "requirement_source": {}, + "requirement_source_transition": {}, + "requirement_context_catalog": {}, + "requirement_context_repo_root": {}, + "requirement_context_slice_input": {}, + "requirement_semantic_diff_input": {}, + "requirement_traceability_graph_input": {}, + "requirement_workspace_input": {}, + "scaffold_profile_plan": {}, + "scaffold_project_structure": {}, + "selective_evidence": {}, + "selective_gate_plan_input": {}, + "spec_tree_bundle": {}, + "test_discovery": {}, + "test_inventory": {}, + "typescript_public_api_manifest": {}, + "typescript_public_api_repo_root": {}, + "witness_command_catalog": {}, } reportKindValues = map[string]struct{}{ "adoption": {}, @@ -110,13 +119,14 @@ type observedReport struct { } type routeSpec struct { - RouteFamily routeFamily - RequiredAny [][]string - NextCommands []commandSpec - StopConditions []string - Escalations []string - SliceSummary string - SliceNonClaims []string + RouteFamily routeFamily + RequiredAny [][]string + RequiredBundles [][]string + NextCommands []commandSpec + StopConditions []string + Escalations []string + SliceSummary string + SliceNonClaims []string } type routeFamily string @@ -236,6 +246,41 @@ var routeSpecs = map[string]routeSpec{ SliceSummary: "Route coverage inspection through admitted requirement, proof-binding, and test inventory inputs.", SliceNonClaims: []string{"The coverage slice does not claim inventory completeness outside the caller-owned universe."}, }, + "inspect_requirement_context": { + RouteFamily: routeFamilyRenderedViews, + RequiredBundles: [][]string{{"requirement_context_catalog", "requirement_context_repo_root"}, {"requirement_context_slice_input"}, {"requirement_workspace_input"}}, + NextCommands: []commandSpec{ + {Command: "requirement-context-compose", InputKind: "requirement_context_catalog", ArgInputs: []commandArgInput{{Flag: "--repo-root", InputKind: "requirement_context_repo_root"}}, Why: "An explicit catalog and caller-selected root compose a content-bound semantic context without ambient scanning."}, + {Command: "requirement-context-slice", InputKind: "requirement_context_slice_input", Why: "A materialized slice input selects the smallest bounded reference-closed semantic context."}, + {Command: "requirement-browser-server", InputKind: "requirement_workspace_input", ExtraArgs: []string{"--view", "workspace"}, Why: "A materialized workspace input can be inspected through the presentation-only loopback browser."}, + }, + StopConditions: []string{"Stop before inventing a catalog, repo root, selector, or intermediate operation input; caller materialization owns those transitions."}, + Escalations: []string{"Escalate specification meaning, source freshness, and omitted semantic classes to the consuming repository owner."}, + SliceSummary: "Route bounded semantic context inspection through explicit composition, materialized slicing, or a materialized browser workspace.", + SliceNonClaims: []string{"The semantic context route does not read unlisted files, materialize intermediate JSON, or promote a derived slice to requirement authority."}, + }, + "review_requirement_change": { + RouteFamily: routeFamilyRenderedViews, + RequiredAny: [][]string{{"requirement_semantic_diff_input"}}, + NextCommands: []commandSpec{ + {Command: "requirement-semantic-diff", InputKind: "requirement_semantic_diff_input", Why: "A materialized baseline/current diff input preserves owner-declared comparison semantics and snapshot identities."}, + }, + StopConditions: []string{"Stop before fabricating baseline/current snapshots or treating semantic diff as Git, freshness, or merge evidence."}, + Escalations: []string{"Escalate requirement meaning and acceptance of changes to the consuming repository owner."}, + SliceSummary: "Route requirement change review through a caller-materialized semantic diff input.", + SliceNonClaims: []string{"The semantic diff route does not create baselines, read Git, or approve a change."}, + }, + "inspect_requirement_traceability": { + RouteFamily: routeFamilyRenderedViews, + RequiredAny: [][]string{{"requirement_traceability_graph_input"}}, + NextCommands: []commandSpec{ + {Command: "requirement-traceability-graph", InputKind: "requirement_traceability_graph_input", Why: "A materialized graph input keeps specification, proof, code traceability, and native execution evidence planes distinct."}, + }, + StopConditions: []string{"Stop before scanning code, inferring topology, authenticating caller-reported evidence, or synthesizing cross-plane coverage."}, + Escalations: []string{"Escalate code topology, native execution truth, and evidence currentness to their consuming-repository owners."}, + SliceSummary: "Route traceability inspection through an explicit caller-materialized graph input.", + SliceNonClaims: []string{"The traceability route does not discover code, execute tests, or turn caller-reported evidence into verified coverage."}, + }, "inventory_tests": { RouteFamily: routeFamilyTestInventoryAndCoverage, RequiredAny: [][]string{{"test_discovery", "test_inventory", "coverage_compose_input", "coverage_view_input"}}, @@ -605,7 +650,7 @@ func admitAvailableInputs(raw any) (map[string]string, error) { } func admitAvailableInputRef(kind string, value string, context string) (string, error) { - if kind == "typescript_public_api_repo_root" && value == "." { + if (kind == "typescript_public_api_repo_root" || kind == "requirement_context_repo_root") && value == "." { return value, nil } return admit.SafeRepoRelativePath(value, context) @@ -689,6 +734,17 @@ func admitOptionalCallerNonClaims(raw any) ([]string, error) { func missingRequiredInputs(spec routeSpec, input routeInput) []map[string]any { missing := []map[string]any{} + if len(spec.RequiredBundles) > 0 && !anyInputBundleSatisfied(spec.RequiredBundles, input.AvailableInputs) { + bundles := make([]any, 0, len(spec.RequiredBundles)) + for _, bundle := range spec.RequiredBundles { + values := make([]any, len(bundle)) + for index, value := range bundle { + values[index] = value + } + bundles = append(bundles, values) + } + missing = append(missing, map[string]any{"oneOfBundles": bundles, "reason": "The selected route requires one complete caller-owned input bundle before a safe next command can run."}) + } for _, group := range spec.RequiredAny { if inputGroupSatisfied(group, input.AvailableInputs) { continue @@ -723,6 +779,22 @@ func missingRequiredInputs(spec routeSpec, input routeInput) []map[string]any { return missing } +func anyInputBundleSatisfied(bundles [][]string, available map[string]string) bool { + for _, bundle := range bundles { + complete := true + for _, kind := range bundle { + if _, ok := available[kind]; !ok { + complete = false + break + } + } + if complete { + return true + } + } + return false +} + func inputKindGroupContains(values []string, target string) bool { for _, value := range values { if value == target { @@ -818,6 +890,9 @@ func buildReport(input routeInput, spec routeSpec, state string, missing []map[s func commandReports(commands []commandSpec, input routeInput) []any { result := []any{} for _, command := range commands { + if !commandInputsAvailable(command, input.AvailableInputs) { + continue + } argv := []any{"agentic-proofkit", command.Command} for _, arg := range command.ExtraArgs { argv = append(argv, arg) @@ -855,6 +930,20 @@ func commandReports(commands []commandSpec, input routeInput) []any { return result } +func commandInputsAvailable(command commandSpec, available map[string]string) bool { + if command.InputKind != "" { + if _, ok := available[command.InputKind]; !ok { + return false + } + } + for _, arg := range command.ArgInputs { + if _, ok := available[arg.InputKind]; !ok { + return false + } + } + return true +} + func browserModeForCommand(command commandSpec, input routeInput) any { if command.Command != "requirement-browser-server" { return nil @@ -865,15 +954,21 @@ func browserModeForCommand(command commandSpec, input routeInput) any { func omittedReports(commands []commandSpec, available map[string]string) []any { result := []any{} for _, command := range commands { - if command.InputKind == "" { + if commandInputsAvailable(command, available) { continue } - if _, ok := available[command.InputKind]; ok { - continue + missing := command.InputKind + if missing == "" || hasInputKind(available, missing) { + for _, arg := range command.ArgInputs { + if !hasInputKind(available, arg.InputKind) { + missing = arg.InputKind + break + } + } } result = append(result, map[string]any{ "command": command.Command, - "missingInputKind": command.InputKind, + "missingInputKind": missing, "reason": omittedReason(command), "safePlaceholderUse": false, }) @@ -881,6 +976,11 @@ func omittedReports(commands []commandSpec, available map[string]string) []any { return result } +func hasInputKind(available map[string]string, kind string) bool { + _, ok := available[kind] + return ok +} + func omittedReason(command commandSpec) string { if command.Command == "selective-gate-obligation-decision-input" { return "Command is not emitted because it requires a caller-owned obligation_decision_input composed from the selective-gate-evidence output plus command routes, currentness, and trust facts." @@ -980,7 +1080,7 @@ func guidanceSliceReport(goal string, spec routeSpec) map[string]any { "selector": "Agent Decision Procedure", }, map[string]any{ - "path": "proofkit/cli-contract.v1.json", + "path": "proofkit/cli-contract.v2.json", "reason": "Machine-readable CLI command and flag contract.", "refId": "proofkit.agent-route.cli-contract", "role": "command_registry", diff --git a/internal/command/agentroute/agentroute_test.go b/internal/command/agentroute/agentroute_test.go index b485a37..43659b1 100644 --- a/internal/command/agentroute/agentroute_test.go +++ b/internal/command/agentroute/agentroute_test.go @@ -873,6 +873,50 @@ func TestInputContractMatchesAdmissionVocabulary(t *testing.T) { } } +func TestSemanticContextGoalsEmitOnlyMaterializedExecutableRoutes(t *testing.T) { + t.Parallel() + + contextReport, exitCode, err := Build(map[string]any{ + "schemaVersion": jsonNumber("1"), "routeId": "consumer.route.context", "goal": "inspect_requirement_context", "mode": "observe", + "availableInputs": []any{ + map[string]any{"kind": "requirement_context_catalog", "ref": "proofkit/context-catalog.json"}, + map[string]any{"kind": "requirement_context_repo_root", "ref": "."}, + }, + }) + if err != nil || exitCode != 0 { + t.Fatalf("context route error=%v exitCode=%d", err, exitCode) + } + argv := findCommandArgv(t, contextReport["nextCommands"].([]any), "requirement-context-compose") + assertArgvContainsPair(t, argv, "--input", "proofkit/context-catalog.json") + assertArgvContainsPair(t, argv, "--repo-root", ".") + + for _, test := range []struct { + goal string + kind string + command string + }{ + {goal: "review_requirement_change", kind: "requirement_semantic_diff_input", command: "requirement-semantic-diff"}, + {goal: "inspect_requirement_traceability", kind: "requirement_traceability_graph_input", command: "requirement-traceability-graph"}, + } { + report, code, err := Build(map[string]any{ + "schemaVersion": jsonNumber("1"), "routeId": "consumer.route." + test.kind, "goal": test.goal, "mode": "observe", + "availableInputs": []any{map[string]any{"kind": test.kind, "ref": "artifacts/" + test.kind + ".json"}}, + }) + if err != nil || code != 0 { + t.Fatalf("%s route error=%v exitCode=%d", test.goal, err, code) + } + findCommandArgv(t, report["nextCommands"].([]any), test.command) + } + + blocked, code, err := Build(map[string]any{ + "schemaVersion": jsonNumber("1"), "routeId": "consumer.route.context.blocked", "goal": "inspect_requirement_context", "mode": "observe", + "availableInputs": []any{map[string]any{"kind": "requirement_context_catalog", "ref": "proofkit/context-catalog.json"}}, + }) + if err != nil || code != 1 || blocked["state"] != "blocked_missing_input" || len(blocked["nextCommands"].([]any)) != 0 { + t.Fatalf("incomplete context bundle was not blocked: report=%#v code=%d err=%v", blocked, code, err) + } +} + func TestRouteSpecsUseAdmittedCommandInputKindMatrix(t *testing.T) { t.Parallel() @@ -1459,14 +1503,20 @@ func admittedRouteCommandInputKindMatrix() map[string]struct{} { {"requirement-browser-server", "compact_proof_binding"}, {"requirement-browser-server", "proof_binding"}, {"requirement-browser-server", "requirement_source"}, + {"requirement-browser-server", "requirement_workspace_input"}, {"requirement-browser-server", "spec_tree_bundle"}, {"requirement-coverage-view", "coverage_view_input"}, + {"requirement-context-compose", "requirement_context_catalog"}, + {"requirement-context-compose", "requirement_context_repo_root"}, + {"requirement-context-slice", "requirement_context_slice_input"}, {"requirement-proof-view", "proof_binding"}, {"requirement-proof-view", "compact_proof_binding"}, {"requirement-source-admission", "requirement_source"}, {"requirement-source-transition", "requirement_source_transition"}, {"requirement-source-view", "requirement_source"}, + {"requirement-semantic-diff", "requirement_semantic_diff_input"}, {"requirement-spec-tree-view", "spec_tree_bundle"}, + {"requirement-traceability-graph", "requirement_traceability_graph_input"}, {"scaffold-profile-plan", "scaffold_profile_plan"}, {"scaffold-project-structure", "scaffold_project_structure"}, {"selective-gate-evidence", "selective_evidence"}, diff --git a/internal/command/externalconsumer/externalconsumer.go b/internal/command/externalconsumer/externalconsumer.go index 6f28f5f..75733ad 100644 --- a/internal/command/externalconsumer/externalconsumer.go +++ b/internal/command/externalconsumer/externalconsumer.go @@ -40,7 +40,7 @@ var requiredPackedFiles = []string{ "SECURITY.md", "dist/agentic-proofkit", "package.json", - "proofkit/cli-contract.v1.json", + "proofkit/cli-contract.v2.json", "proofkit/receipt-producer-policy.json", "proofkit/requirement-bindings.json", "proofkit/witness-plan.json", diff --git a/internal/command/jsonreportcliadaptersource/json_report_cli_adapter_source.go b/internal/command/jsonreportcliadaptersource/json_report_cli_adapter_source.go index 342aa3b..26bfac6 100644 --- a/internal/command/jsonreportcliadaptersource/json_report_cli_adapter_source.go +++ b/internal/command/jsonreportcliadaptersource/json_report_cli_adapter_source.go @@ -172,6 +172,7 @@ export interface ProofkitReportInputReadOptions { export interface ProofkitJsonReportOutputOptions { readonly cwd?: string; + readonly jsonLayout?: "pretty" | "compact"; readonly writeStdout?: (text: string) => void; } @@ -180,6 +181,7 @@ export interface ProofkitCommandRunOptions { readonly cwd: string; readonly env?: Readonly>; readonly inputMode?: "none" | "required"; + readonly jsonLayout?: "pretty" | "compact"; readonly maxBuffer?: number; } @@ -283,8 +285,8 @@ function stableJsonValueAtDepth(value: unknown, depth: number, active: WeakSet process.stdout.write(text)))(output); return; @@ -518,6 +520,9 @@ export function runProofkitTextCommand( args: readonly string[], options: ProofkitCommandRunOptions, ): ProofkitTextCommandResult { + if (options.jsonLayout !== undefined) { + throw new Error("Proofkit jsonLayout is valid only for JSON command output"); + } const {child, outputFile} = runProofkitCommand(command, input, args, options); if (child.status !== 0 && child.stdout.length === 0) { throw new Error(formatProofkitCliError(child.stderr.trim() || command + " failed with exit code " + String(child.status))); @@ -542,7 +547,8 @@ function runProofkitCommand(command: string, input: unknown, args: readonly stri } catch (error) { throw new Error(formatProofkitCliError(error)); } - const childArgs = options.inputMode === "none" ? [command, ...prepared.args] : [command, "--input", "-", ...prepared.args]; + const processArgs = options.jsonLayout === undefined ? [] : ["--json-layout", options.jsonLayout]; + const childArgs = options.inputMode === "none" ? [...processArgs, command, ...prepared.args] : [...processArgs, command, "--input", "-", ...prepared.args]; const child = spawnSync(options.binaryPath, childArgs, { cwd: options.cwd, encoding: "utf8", @@ -975,6 +981,9 @@ function admitRunOptions(options: ProofkitCommandRunOptions): void { if (options.cwd.length === 0) { throw new Error("Proofkit cwd is required"); } + if (options.jsonLayout !== undefined && options.jsonLayout !== "pretty" && options.jsonLayout !== "compact") { + throw new Error("Proofkit jsonLayout must be pretty or compact"); + } } function formatReadFailure(filePath: string, error: unknown, options: ProofkitReportInputReadOptions): string { diff --git a/internal/command/jsonreportcliadaptersource/json_report_cli_adapter_source_test.go b/internal/command/jsonreportcliadaptersource/json_report_cli_adapter_source_test.go index 1fc28d0..e02507b 100644 --- a/internal/command/jsonreportcliadaptersource/json_report_cli_adapter_source_test.go +++ b/internal/command/jsonreportcliadaptersource/json_report_cli_adapter_source_test.go @@ -15,7 +15,7 @@ import ( "github.com/research-engineering/agentic-proofkit/internal/testsupport/commandcoverage" ) -const expectedTypeScriptSourceSha256 = "sha256:a3b55d9b408d13bc0094dd08c21d9f29a8c9662bab7f7bb2a864b16e9d40ddbb" +const expectedTypeScriptSourceSha256 = "sha256:dc1478adc582d4bdfeab52ca1e3facaac23cfe9a78e9351620e11edae9cd9ba0" func TestBuildEmitsDeterministicTypeScriptSourceBundle(t *testing.T) { if !slices.IsSorted(exportedSymbols) { @@ -206,7 +206,8 @@ func exportedDeclarations(source string) []string { const fakeProofkitBinarySource = `#!/usr/bin/env node import { writeFileSync } from "node:fs"; -const command = process.argv[2]; +const commandIndex = process.argv[2] === "--json-layout" ? 4 : 2; +const command = process.argv[commandIndex]; let input = ""; process.stdin.on("data", (chunk) => { input += chunk; @@ -318,6 +319,7 @@ assert.throws( assert.equal(helpText, "help text"); assert.equal(proofkitStableJsonString({z: 1, a: true}), "{\n \"a\": true,\n \"z\": 1\n}\n"); +assert.equal(proofkitStableJsonString({z: 1, a: true}, "compact"), "{\"a\":true,\"z\":1}\n"); const prototypeKey = JSON.parse('{"__proto__":{"polluted":true}}'); assert.equal(proofkitStableJsonString(prototypeKey), '{\n "__proto__": {\n "polluted": true\n }\n}\n'); assert.equal(({}).polluted, undefined); @@ -431,6 +433,9 @@ const pass = runProofkitJsonCommand("json-pass", {z: 1, a: true}, [], {binaryPat assert.equal(pass.status, 0); assert.equal(pass.value.state, "passed"); assert.deepEqual(pass.value.received, {a: true, z: 1}); +const compactPass = runProofkitJsonCommand("json-pass", {z: 1, a: true}, [], {binaryPath: fakeProofkitPath, cwd: repositoryRoot, jsonLayout: "compact"}); +assert.equal(compactPass.status, 0); +assert.deepEqual(compactPass.value.received, {a: true, z: 1}); const fail = runProofkitJsonCommand("json-fail", {ok: false}, [], {binaryPath: fakeProofkitPath, cwd: repositoryRoot}); assert.equal(fail.status, 1); @@ -512,6 +517,7 @@ assert.throws( const text = runProofkitTextCommand("text-pass", {}, [], {binaryPath: fakeProofkitPath, cwd: repositoryRoot}); assert.equal(text.status, 0); assert.equal(text.text, "text result"); +assert.throws(() => runProofkitTextCommand("text-pass", {}, [], {binaryPath: fakeProofkitPath, cwd: repositoryRoot, jsonLayout: "compact"}), /only for JSON/); const textOutput = runProofkitTextCommand("text-pass", {}, ["--output", "proofkit-output.txt"], {binaryPath: fakeProofkitPath, cwd: repositoryRoot}); assert.equal(textOutput.status, 0); assert.equal(textOutput.stdout, ""); diff --git a/internal/command/requirementbinding/requirementbinding.go b/internal/command/requirementbinding/requirementbinding.go index 72c06af..c05f181 100644 --- a/internal/command/requirementbinding/requirementbinding.go +++ b/internal/command/requirementbinding/requirementbinding.go @@ -96,6 +96,61 @@ type CompactFalsificationRoute struct { VerifyCommands []string } +// SelectRequirements returns a reference-closed owner projection containing +// only the selected requirements, bindings, and commands they reference. +func SelectRequirements(input Input, selected map[string]struct{}) Input { + result := Input{ + BindingID: input.BindingID, + NonClaims: append([]string{}, input.NonClaims...), + } + selectedOwned := map[string]struct{}{} + commandIDs := map[string]struct{}{} + for _, requirement := range input.Requirements { + if _, ok := selected[requirement.RequirementID]; ok { + result.Requirements = append(result.Requirements, requirement) + selectedOwned[requirement.RequirementID] = struct{}{} + } + } + result.Selection = Selection{RequirementIDs: sortedSetKeys(selectedOwned)} + for _, binding := range input.Bindings { + if _, ok := selected[binding.RequirementID]; !ok { + continue + } + result.Bindings = append(result.Bindings, binding) + for _, commandID := range binding.CommandIDs { + commandIDs[commandID] = struct{}{} + } + } + for _, command := range input.WitnessCommands { + if _, ok := commandIDs[command.CommandID]; ok { + result.WitnessCommands = append(result.WitnessCommands, command) + } + } + return result +} + +// SelectionFragmentValue returns a lookup-only subset. It deliberately does +// not retain the complete binding identity, so consumers cannot re-admit a +// bounded fragment as the caller-owned proof-binding source. +func SelectionFragmentValue(input Input, selected map[string]struct{}) map[string]any { + projected := SelectRequirements(input, selected) + value := InputValue(projected) + delete(value, "bindingId") + value["authority"] = "lookup_fragment_only" + value["projectionKind"] = "proofkit.requirement-binding-fragment" + value["sourceBindingId"] = input.BindingID + return value +} + +func sortedSetKeys(values map[string]struct{}) []string { + result := make([]string, 0, len(values)) + for value := range values { + result = append(result, value) + } + sort.Strings(result) + return result +} + func BuildResolver(raw any, options ResolverOptions) (any, int, error) { contract, err := compactproofcontract.Admit(raw) if err != nil { diff --git a/internal/command/requirementbrowser/assets.go b/internal/command/requirementbrowser/assets.go new file mode 100644 index 0000000..1a19ea1 --- /dev/null +++ b/internal/command/requirementbrowser/assets.go @@ -0,0 +1,12 @@ +package requirementbrowser + +import _ "embed" + +//go:embed assets/workspace.js +var workspaceJavaScript []byte + +//go:embed assets/selection-authority.js +var selectionAuthorityJavaScript []byte + +//go:embed assets/workspace.css +var workspaceCSS []byte diff --git a/internal/command/requirementbrowser/assets/selection-authority.js b/internal/command/requirementbrowser/assets/selection-authority.js new file mode 100644 index 0000000..2d1d905 --- /dev/null +++ b/internal/command/requirementbrowser/assets/selection-authority.js @@ -0,0 +1,30 @@ +// @ts-check + +/** @typedef {{anchorId: string, exactQuote: string, startCodePoint: number, endCodePoint: number}} SelectionTarget */ +/** @typedef {{mode: "none" | "button" | "text", targets: SelectionTarget[]}} SelectionState */ +/** @typedef {{kind: "button" | "text", targets: SelectionTarget[]} | {kind: "collapse" | "clear"}} SelectionEvent */ + +/** @returns {SelectionState} */ +export function emptySelectionState() { + return {mode: "none", targets: []}; +} + +/** @param {SelectionState} state @param {SelectionEvent} event @returns {SelectionState} */ +export function transitionSelection(state, event) { + switch (event.kind) { + case "button": + if (event.targets.length !== 1) throw new Error("button selection requires exactly one target"); + return selectionState("button", event.targets); + case "text": + return event.targets.length === 0 ? emptySelectionState() : selectionState("text", event.targets); + case "collapse": + return state.mode === "text" ? emptySelectionState() : state; + case "clear": + return emptySelectionState(); + } +} + +/** @param {"button" | "text"} mode @param {SelectionTarget[]} targets @returns {SelectionState} */ +function selectionState(mode, targets) { + return {mode, targets: targets.map((target) => ({...target}))}; +} diff --git a/internal/command/requirementbrowser/assets/workspace.css b/internal/command/requirementbrowser/assets/workspace.css new file mode 100644 index 0000000..c53b0ae --- /dev/null +++ b/internal/command/requirementbrowser/assets/workspace.css @@ -0,0 +1,21 @@ +:root { color-scheme: light dark; font-family: ui-sans-serif, system-ui, sans-serif; } +body { margin: 0; display: grid; grid-template-columns: minmax(0, 1fr) 22rem; min-height: 100vh; } +header, main, aside { padding: 1rem 1.5rem; } +header { grid-column: 1 / -1; border-bottom: 1px solid CanvasText; } +header p { margin: 0; font-size: .8rem; text-transform: uppercase; } +h1 { margin: .25rem 0; } +nav { display: flex; gap: .5rem; margin-bottom: 1rem; } +button { min-height: 2.5rem; } +article { border-top: 1px solid color-mix(in srgb, CanvasText 25%, transparent); padding: .75rem 0; } +aside { border-left: 1px solid CanvasText; } +textarea { box-sizing: border-box; min-height: 8rem; width: 100%; } +pre { max-width: 100%; overflow: auto; white-space: pre-wrap; } +svg { display: block; min-width: 50rem; width: 100%; } +.graph-viewport { border: 1px solid color-mix(in srgb, CanvasText 35%, transparent); max-height: 42rem; overflow: auto; } +[data-select-anchor][aria-pressed="true"] { outline: 3px solid #1f6feb; outline-offset: 2px; } +svg rect { fill: Canvas; stroke: CanvasText; } +svg line { stroke: CanvasText; stroke-width: 1.5; } +svg text { fill: CanvasText; font-size: 13px; } +table { border-collapse: collapse; width: 100%; } +th, td { border: 1px solid color-mix(in srgb, CanvasText 35%, transparent); padding: .4rem; text-align: left; } +@media (max-width: 48rem) { body { display: block; } aside { border-left: 0; border-top: 1px solid CanvasText; } } diff --git a/internal/command/requirementbrowser/assets/workspace.js b/internal/command/requirementbrowser/assets/workspace.js new file mode 100644 index 0000000..ebbb6c8 --- /dev/null +++ b/internal/command/requirementbrowser/assets/workspace.js @@ -0,0 +1,478 @@ +// @ts-check + +import {emptySelectionState, transitionSelection} from "./selection-authority.js"; + +export {}; + +/** @typedef {import("./selection-authority.js").SelectionTarget} SelectionTarget */ + +const capabilityElement = document.querySelector('meta[name="proofkit-browser-capability"]'); +if (!(capabilityElement instanceof HTMLMetaElement)) throw new Error("Missing browser capability"); +const capability = capabilityElement.content; +capabilityElement.remove(); + +const headers = {"Content-Type": "application/json", "X-Proofkit-Browser-Capability": capability}; +const contentElement = document.querySelector("#workspace-content"); +if (!(contentElement instanceof HTMLElement)) throw new Error("Missing workspace content region"); +const content = /** @type {HTMLElement} */ (contentElement); + +const manifest = await fetchJSON("/api/v1/manifest", {headers: {"X-Proofkit-Browser-Capability": capability}}); +const authorityElement = document.querySelector("#workspace-authority"); +if (!(authorityElement instanceof HTMLElement)) throw new Error("Missing workspace authority boundary"); +const authorityText = authorityElement.querySelector("[data-authority]"); +const authorityNonClaims = authorityElement.querySelector("[data-non-claims]"); +if (!(authorityText instanceof HTMLElement) || !(authorityNonClaims instanceof HTMLUListElement)) throw new Error("Missing workspace authority fields"); +authorityText.textContent = `Authority: ${manifest.authority}. Snapshot: ${manifest.snapshotId}. Baseline: ${manifest.baselineVerification}.`; +appendTextItems(authorityNonClaims, manifest.nonClaims ?? []); +let activeRequestId = ""; +let requestSequence = 0; +/** @type {AbortController | null} */ +let activeViewController = null; +let selectionState = emptySelectionState(); + +/** @param {string} prefix */ +function nextRequestId(prefix) { + requestSequence += 1; + activeRequestId = `${prefix}.${requestSequence.toString(36)}`; + return activeRequestId; +} + +/** @param {string} path @param {RequestInit} init @returns {Promise} */ +async function fetchJSON(path, init) { + const response = await fetch(path, init); + if (!response.ok) throw new Error(`Workspace request failed: ${response.status}`); + return response.json(); +} + +/** @param {string} path @param {any} body @param {AbortSignal} [signal] @returns {Promise} */ +async function post(path, body, signal) { + return fetchJSON(path, {method: "POST", headers, body: JSON.stringify(body), signal}); +} + +/** @param {string} title @param {string} requestPrefix */ +function beginView(title, requestPrefix) { + activeViewController?.abort(); + activeViewController = new AbortController(); + const requestId = nextRequestId(requestPrefix); + clearSelection(); + content.replaceChildren(); + const heading = document.createElement("h2"); + heading.textContent = title; + const status = document.createElement("p"); + status.setAttribute("role", "status"); + status.setAttribute("aria-live", "polite"); + status.dataset.state = "loading"; + status.textContent = "Loading admitted data..."; + content.append(heading, status); + return {requestId, signal: activeViewController.signal, status}; +} + +/** @param {HTMLElement} status @param {unknown} error */ +function failView(status, error) { + status.dataset.state = "failed"; + status.textContent = error instanceof Error ? error.message : "Workspace request failed."; +} + +/** @param {number} [offset] */ +async function renderSpecifications(offset = 0) { + const {requestId, signal, status} = beginView("Specifications", "browser.specifications"); + try { + const response = await post("/api/v1/requirements", { + requestId, + snapshotId: manifest.snapshotId, + query: {maxRecords: 256, offset}, + }, signal); + if (signal.aborted || requestId !== activeRequestId || response.requestId !== requestId || response.snapshotId !== manifest.snapshotId) return; + status.remove(); + const tree = document.createElement("div"); + tree.setAttribute("role", "tree"); + tree.setAttribute("aria-label", "Specification requirements"); + const requirements = response.projection?.requirements ?? []; + let itemIndex = 0; + for (const requirement of requirements) { + const article = document.createElement("article"); + article.setAttribute("role", "treeitem"); + article.tabIndex = itemIndex === 0 ? 0 : -1; + article.dataset.requirementId = requirement.requirementId; + const title = document.createElement("h3"); + title.textContent = requirement.requirementId; + const boundary = document.createElement("section"); + boundary.className = "requirement-boundary"; + boundary.setAttribute("aria-label", `Boundary for ${requirement.requirementId}`); + const ownership = document.createElement("p"); + ownership.textContent = `Owner: ${requirement.ownerId}. Claim level: ${requirement.claimLevel}.`; + const nonClaims = document.createElement("ul"); + appendTextItems(nonClaims, [...(requirement.sourceNonClaims ?? []), ...(requirement.nonClaims ?? [])]); + boundary.append(ownership, nonClaims); + const invariant = document.createElement("p"); + const anchorId = requirement.anchor.anchorId; + invariant.dataset.anchorId = anchorId; + invariant.textContent = requirement.invariant; + const choose = document.createElement("button"); + choose.type = "button"; + choose.dataset.selectAnchor = anchorId; + choose.setAttribute("aria-pressed", "false"); + choose.textContent = "Select invariant"; + choose.addEventListener("click", () => { + for (const control of content.querySelectorAll("[data-select-anchor]")) control.setAttribute("aria-pressed", "false"); + choose.setAttribute("aria-pressed", "true"); + selectionState = transitionSelection(selectionState, {kind: "button", targets: [{anchorId, exactQuote: requirement.invariant, startCodePoint: 0, endCodePoint: [...requirement.invariant].length}]}); + announceSelection(); + }); + article.append(title, boundary, invariant, choose); + tree.append(article); + itemIndex += 1; + } + if (itemIndex === 0) { + status.dataset.state = "no-match"; + status.textContent = "No requirements matched the admitted query."; + content.append(status); + return; + } + tree.addEventListener("keydown", moveTreeFocus); + content.append(tree); + appendPagingControls("specifications", offset, response.projection.selectedRequirementCount ?? 0, response.projection.availableRequirementCount ?? 0); + } catch (error) { + if (signal.aborted) return; + failView(status, error); + } +} + +/** @param {KeyboardEvent} event */ +function moveTreeFocus(event) { + if (!["ArrowDown", "ArrowUp"].includes(event.key)) return; + const items = /** @type {HTMLElement[]} */ ([...content.querySelectorAll('[role="treeitem"]')]); + const active = document.activeElement; + if (!(active instanceof HTMLElement)) return; + const current = items.indexOf(active); + if (current < 0) return; + event.preventDefault(); + const direction = event.key === "ArrowDown" ? 1 : -1; + const next = Math.max(0, Math.min(items.length - 1, current + direction)); + const nextItem = items[next]; + if (!nextItem) return; + for (const item of items) item.tabIndex = item === nextItem ? 0 : -1; + nextItem.focus(); +} + +/** @param {number} [offset] */ +async function renderDiff(offset = 0) { + const {requestId, signal, status} = beginView("Semantic diff", "browser.diff"); + if (!manifest.diffAvailable) { + status.dataset.state = "unavailable"; + status.textContent = "No admitted semantic diff was supplied."; + return; + } + try { + const response = await post("/api/v1/diff", {requestId, snapshotId: manifest.snapshotId, query: {maxRecords: 512, offset}}, signal); + if (signal.aborted || requestId !== activeRequestId || response.requestId !== requestId || response.snapshotId !== manifest.snapshotId) return; + status.remove(); + appendProjectionBoundary(response.projection.authority, response.projection.nonClaims ?? [], [ + `Base snapshot: ${response.projection.baseSnapshotId} (${response.projection.baseBaselineVerification}).`, + `Current snapshot: ${response.projection.currentSnapshotId} (${response.projection.currentBaselineVerification}).`, + ]); + for (const change of response.projection.changes ?? []) { + const article = document.createElement("article"); + article.dataset.changeId = change.changeId; + const title = document.createElement("h3"); + title.textContent = `${change.changeClass}: ${change.entityId}`; + const pointer = document.createElement("p"); + pointer.textContent = change.jsonPointer; + const sourceDigests = document.createElement("p"); + sourceDigests.textContent = `Source digests: ${change.baseSourceDigest ?? "not-recorded"} -> ${change.currentSourceDigest ?? "not-recorded"}`; + const values = document.createElement("pre"); + values.textContent = `${JSON.stringify(change.before, null, 2)}\n->\n${JSON.stringify(change.after, null, 2)}`; + article.append(title, pointer, sourceDigests, values); + content.append(article); + } + appendPagingControls("diff", offset, response.projection.selectedChangeCount ?? 0, response.projection.availableChangeCount ?? 0); + } catch (error) { + if (signal.aborted) return; + failView(status, error); + } +} + +/** @param {number} [offset] */ +async function renderGraph(offset = 0, edgeOffset = 0) { + const {requestId, signal, status} = beginView("Traceability graph", "browser.graph"); + if (!manifest.graphAvailable) { + status.dataset.state = "unavailable"; + status.textContent = "No admitted traceability graph was supplied."; + return; + } + try { + const response = await post("/api/v1/graph", {requestId, snapshotId: manifest.snapshotId, query: {edgeOffset, maxEdges: 2048, maxRecords: 256, offset}}, signal); + if (signal.aborted || requestId !== activeRequestId || response.requestId !== requestId || response.snapshotId !== manifest.snapshotId) return; + status.remove(); + const graph = response.projection; + appendProjectionBoundary(graph.authority, graph.nonClaims ?? [], [`Source snapshot: ${graph.sourceSnapshotId}.`]); + const nodes = /** @type {any[]} */ (graph.nodes ?? []); + const edges = /** @type {any[]} */ (graph.edges ?? []); + const positions = new Map(nodes.map((node, index) => [node.nodeId, {x: 28 + (index % 2) * 390, y: 28 + Math.floor(index / 2) * 76}])); + const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); + svg.setAttribute("role", "img"); + svg.setAttribute("aria-label", "Non-authoritative layout of admitted traceability nodes and edges"); + svg.setAttribute("viewBox", `0 0 800 ${Math.max(180, Math.ceil(nodes.length / 2) * 76 + 40)}`); + svg.dataset.nodeIds = nodes.map((node) => node.nodeId).join(" "); + svg.dataset.edgeIds = edges.map((edge) => edge.edgeId).join(" "); + for (const edge of edges) { + const from = positions.get(edge.fromNodeId); + const to = positions.get(edge.toNodeId); + if (!from || !to) throw new Error("Graph edge endpoint is not present in admitted nodes"); + const line = document.createElementNS(svg.namespaceURI, "line"); + line.setAttribute("data-edge-id", edge.edgeId); + line.setAttribute("x1", String(from.x + 180)); + line.setAttribute("y1", String(from.y + 24)); + line.setAttribute("x2", String(to.x + 180)); + line.setAttribute("y2", String(to.y + 24)); + svg.append(line); + } + for (const node of nodes) { + const position = positions.get(node.nodeId); + if (!position) throw new Error("Graph node position is unavailable"); + const group = document.createElementNS(svg.namespaceURI, "g"); + group.setAttribute("data-node-id", node.nodeId); + const box = document.createElementNS(svg.namespaceURI, "rect"); + box.setAttribute("x", String(position.x)); + box.setAttribute("y", String(position.y)); + box.setAttribute("width", "350"); + box.setAttribute("height", "48"); + box.setAttribute("rx", "4"); + const label = document.createElementNS(svg.namespaceURI, "text"); + label.setAttribute("x", String(position.x + 10)); + label.setAttribute("y", String(position.y + 29)); + const fullLabel = `${node.evidencePlane}: ${node.label}`; + label.textContent = [...fullLabel].length > 48 ? `${[...fullLabel].slice(0, 47).join("")}...` : fullLabel; + const accessibleLabel = document.createElementNS(svg.namespaceURI, "title"); + accessibleLabel.textContent = fullLabel; + group.append(accessibleLabel, box, label); + svg.append(group); + } + const viewport = document.createElement("div"); + viewport.className = "graph-viewport"; + viewport.append(svg); + content.append(viewport, graphTable( + "Admitted traceability nodes", + ["Node", "Kind", "Evidence plane", "Source", "Authority", "Currentness", "Verification", "State", "Producer"], + nodes.map((node) => [node.nodeId, node.kind, node.evidencePlane, node.sourceId, node.authorityClass, node.currentnessState, node.rangeVerification, node.state, node.producerId]), + "node", + )); + content.append(graphTable( + "Admitted traceability edges", + ["Edge", "Kind", "From", "To", "Authority", "Currentness", "Evidence"], + edges.map((edge) => [edge.edgeId, edge.edgeKind, edge.fromNodeId, edge.toNodeId, edge.authorityClass, edge.currentnessState, displayList(edge.evidenceRefs)]), + "edge", + )); + appendPagingControls("graph", offset, graph.primaryNodeCount ?? 0, graph.availableNodeCount ?? 0); + appendGraphEdgeControls(offset, edgeOffset, graph.selectedEdgeCount ?? 0, graph.availableIncidentEdgeCount ?? 0); + } catch (error) { + if (signal.aborted) return; + failView(status, error); + } +} + +/** @param {"specifications" | "diff" | "graph"} view @param {number} offset @param {number} selectedCount @param {number} availableCount */ +function appendPagingControls(view, offset, selectedCount, availableCount) { + const summary = document.createElement("p"); + const first = selectedCount === 0 ? 0 : offset + 1; + summary.textContent = `Showing ${first}-${offset + selectedCount} of ${availableCount} ${view} records.`; + content.append(summary); + if (offset === 0 && selectedCount >= availableCount) return; + const controls = document.createElement("nav"); + controls.setAttribute("aria-label", `${view} pages`); + const pageSize = view === "diff" ? 512 : 256; + if (offset > 0) { + const previous = document.createElement("button"); + previous.type = "button"; + previous.textContent = `Previous ${view} page`; + previous.addEventListener("click", () => void (view === "diff" ? renderDiff(Math.max(0, offset - pageSize)) : view === "graph" ? renderGraph(Math.max(0, offset - pageSize)) : renderSpecifications(Math.max(0, offset - pageSize)))); + controls.append(previous); + } + if (offset + selectedCount < availableCount) { + const next = document.createElement("button"); + next.type = "button"; + next.textContent = `Next ${view} page`; + next.addEventListener("click", () => void (view === "diff" ? renderDiff(offset + selectedCount) : view === "graph" ? renderGraph(offset + selectedCount) : renderSpecifications(offset + selectedCount))); + controls.append(next); + } + content.append(controls); +} + +/** @param {number} nodeOffset @param {number} edgeOffset @param {number} selectedCount @param {number} availableCount */ +function appendGraphEdgeControls(nodeOffset, edgeOffset, selectedCount, availableCount) { + const summary = document.createElement("p"); + const first = selectedCount === 0 ? 0 : edgeOffset + 1; + summary.textContent = `Showing ${first}-${edgeOffset + selectedCount} of ${availableCount} incident graph relations.`; + content.append(summary); + if (edgeOffset === 0 && selectedCount >= availableCount) return; + const controls = document.createElement("nav"); + controls.setAttribute("aria-label", "graph relation pages"); + if (edgeOffset > 0) { + const previous = document.createElement("button"); + previous.type = "button"; + previous.textContent = "Previous graph relation page"; + previous.addEventListener("click", () => void renderGraph(nodeOffset, Math.max(0, edgeOffset - 2048))); + controls.append(previous); + } + if (edgeOffset + selectedCount < availableCount) { + const next = document.createElement("button"); + next.type = "button"; + next.textContent = "Next graph relation page"; + next.addEventListener("click", () => void renderGraph(nodeOffset, edgeOffset + selectedCount)); + controls.append(next); + } + content.append(controls); +} + +/** @param {string} captionText @param {string[]} headings @param {string[][]} rows @param {string} identityKind */ +function graphTable(captionText, headings, rows, identityKind) { + const table = document.createElement("table"); + table.dataset.identityKind = identityKind; + const caption = document.createElement("caption"); + caption.textContent = captionText; + const head = document.createElement("thead"); + const headRow = document.createElement("tr"); + for (const label of headings) { + const cell = document.createElement("th"); + cell.textContent = label; + headRow.append(cell); + } + head.append(headRow); + const body = document.createElement("tbody"); + for (const values of rows) { + const row = document.createElement("tr"); + row.dataset.identity = values[0] ?? ""; + for (const value of values) { + const cell = document.createElement("td"); + cell.textContent = value ?? ""; + row.append(cell); + } + body.append(row); + } + table.append(caption, head, body); + return table; +} + +/** @param {unknown} value */ +function displayList(value) { + return Array.isArray(value) ? value.join(", ") : value; +} + +/** @param {HTMLUListElement} list @param {unknown[]} values */ +function appendTextItems(list, values) { + for (const value of values) { + const item = document.createElement("li"); + item.textContent = String(value); + list.append(item); + } +} + +/** @param {unknown} authority @param {unknown[]} nonClaims @param {string[]} details */ +function appendProjectionBoundary(authority, nonClaims, details) { + const section = document.createElement("section"); + section.className = "projection-boundary"; + section.setAttribute("aria-label", "Projection boundary"); + const heading = document.createElement("h3"); + heading.textContent = "Projection boundary"; + const authorityText = document.createElement("p"); + authorityText.textContent = `Authority: ${String(authority)}.`; + section.append(heading, authorityText); + for (const detail of details) { + const paragraph = document.createElement("p"); + paragraph.textContent = detail; + section.append(paragraph); + } + const list = document.createElement("ul"); + appendTextItems(list, nonClaims); + section.append(list); + content.append(section); +} + +document.querySelectorAll("[data-view]").forEach((button) => button.addEventListener("click", () => { + if (!(button instanceof HTMLButtonElement)) return; + if (button.dataset.view === "specifications") void renderSpecifications(); + if (button.dataset.view === "diff") void renderDiff(); + if (button.dataset.view === "graph") void renderGraph(); +})); + +document.addEventListener("selectionchange", () => { + const selection = window.getSelection(); + if (!selection || selection.isCollapsed || selection.rangeCount !== 1) { + const nextState = transitionSelection(selectionState, {kind: "collapse"}); + if (nextState !== selectionState) { + selectionState = nextState; + resetPressedSelectionControls(); + announceSelection(); + } + return; + } + const range = selection.getRangeAt(0); + /** @type {SelectionTarget[]} */ + const targets = []; + for (const element of content.querySelectorAll("[data-anchor-id]")) { + if (!(element instanceof HTMLElement) || !range.intersectsNode(element) || !element.firstChild) continue; + const local = document.createRange(); + local.selectNodeContents(element); + if (element.contains(range.startContainer)) local.setStart(range.startContainer, range.startOffset); + if (element.contains(range.endContainer)) local.setEnd(range.endContainer, range.endOffset); + const exactQuote = local.toString(); + if (!exactQuote) continue; + const prefix = document.createRange(); + prefix.selectNodeContents(element); + prefix.setEnd(local.startContainer, local.startOffset); + const startCodePoint = [...prefix.toString()].length; + const anchorId = element.dataset.anchorId; + if (!anchorId) continue; + targets.push({anchorId, exactQuote, startCodePoint, endCodePoint: startCodePoint + [...exactQuote].length}); + } + selectionState = transitionSelection(selectionState, {kind: "text", targets}); + resetPressedSelectionControls(); + announceSelection(); +}); + +const questionInputElement = document.querySelector("#annotation-question"); +const statusElement = document.querySelector("#handoff-status"); +const packetElement = document.querySelector("#handoff-packet"); +const submitElement = document.querySelector("#submit-question"); +if (!(questionInputElement instanceof HTMLTextAreaElement) || !(statusElement instanceof HTMLElement) || !(packetElement instanceof HTMLElement) || !(submitElement instanceof HTMLButtonElement)) throw new Error("Missing handoff controls"); +const questionInput = /** @type {HTMLTextAreaElement} */ (questionInputElement); +const status = /** @type {HTMLElement} */ (statusElement); +const packetView = /** @type {HTMLElement} */ (packetElement); +const submit = /** @type {HTMLButtonElement} */ (submitElement); + +function announceSelection() { + status.textContent = selectionState.targets.length === 0 ? "No source-bound text selected." : `${selectionState.targets.length} source-bound target(s) selected.`; +} + +function resetPressedSelectionControls() { + for (const control of content.querySelectorAll("[data-select-anchor]")) control.setAttribute("aria-pressed", "false"); +} + +function clearSelection() { + selectionState = transitionSelection(selectionState, {kind: "clear"}); + resetPressedSelectionControls(); + const selection = window.getSelection(); + if (selection && !selection.isCollapsed) selection.removeAllRanges(); + status.textContent = "No source-bound text selected."; +} +submit.addEventListener("click", async () => { + if (submit.disabled) return; + const question = questionInput.value.trim(); + if (selectionState.targets.length === 0 || !question) { + status.textContent = "Select invariant text and enter a question."; + return; + } + submit.disabled = true; + status.textContent = "Creating handoff packet..."; + try { + const packet = await post("/api/v1/handoff", {annotations: selectionState.targets.map((target) => ({...target, question}))}); + packetView.textContent = JSON.stringify(packet, null, 2); + status.textContent = "Handoff packet created."; + } catch (error) { + status.textContent = error instanceof Error ? error.message : "Handoff was rejected."; + } finally { + submit.disabled = false; + } +}); + +void renderSpecifications(); diff --git a/internal/command/requirementbrowser/handoff_bounds_test.go b/internal/command/requirementbrowser/handoff_bounds_test.go new file mode 100644 index 0000000..94634d3 --- /dev/null +++ b/internal/command/requirementbrowser/handoff_bounds_test.go @@ -0,0 +1,118 @@ +package requirementbrowser + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "strconv" + "strings" + "testing" + + "github.com/research-engineering/agentic-proofkit/internal/kernel/stablejson" +) + +func TestHandoffRequestByteBoundary(t *testing.T) { + session := workspaceSessionForInvariant(t, "The system preserves semantic identity.") + body := encodedHandoffBody(t, []any{handoffAnnotation(11, 20, "preserves", "Does this remain true?")}) + exact := append(body, bytes.Repeat([]byte(" "), maxHandoffRequestBytes-len(body))...) + if _, err := buildHandoffPacket(httptest.NewRequest(http.MethodPost, "/api/v1/handoff", bytes.NewReader(exact)), session); err != nil { + t.Fatalf("exact request byte bound was rejected: %v", err) + } + over := append(exact, ' ') + if _, err := buildHandoffPacket(httptest.NewRequest(http.MethodPost, "/api/v1/handoff", bytes.NewReader(over)), session); err == nil || !strings.Contains(err.Error(), "handoff exceeds byte limit") { + t.Fatalf("request above byte bound was not rejected: %v", err) + } +} + +func TestHandoffAnnotationCountBoundary(t *testing.T) { + session := workspaceSessionForInvariant(t, "The system preserves semantic identity. "+strings.Repeat("x", maxHandoffAnnotations+1)) + anchor := session.Anchors["requirement:REQ-CONSUMER-001:invariant"] + first := strings.Index(anchor.Text, strings.Repeat("x", 3)) + annotations := make([]any, 0, maxHandoffAnnotations+1) + for index := 0; index <= maxHandoffAnnotations; index++ { + annotations = append(annotations, handoffAnnotation(first+index, first+index+1, "x", "Does this remain true?")) + } + if _, err := buildHandoffPacket(handoffRequest(t, annotations[:maxHandoffAnnotations]), session); err != nil { + t.Fatalf("exact annotation count bound was rejected: %v", err) + } + if _, err := buildHandoffPacket(handoffRequest(t, annotations), session); err == nil || !strings.Contains(err.Error(), "1 to 64 records") { + t.Fatalf("annotation count above bound was not rejected: %v", err) + } +} + +func TestHandoffQuoteAndQuestionByteBoundaries(t *testing.T) { + invariant := strings.Repeat("q", maxHandoffQuoteBytes+1) + session := workspaceSessionForInvariant(t, invariant) + anchorID := "requirement:REQ-CONSUMER-001:invariant" + if _, err := admitAnnotation(handoffAnnotationRecord(anchorID, 0, maxHandoffQuoteBytes, invariant[:maxHandoffQuoteBytes], strings.Repeat("a", maxHandoffQuestionBytes)), session); err != nil { + t.Fatalf("exact quote and question bounds were rejected: %v", err) + } + if _, err := admitAnnotation(handoffAnnotationRecord(anchorID, 0, maxHandoffQuoteBytes+1, invariant, "Why?"), session); err == nil || !strings.Contains(err.Error(), "quote is invalid") { + t.Fatalf("quote above byte bound was not rejected: %v", err) + } + if _, err := admitAnnotation(handoffAnnotationRecord(anchorID, 0, 1, "q", strings.Repeat("a", maxHandoffQuestionBytes+1)), session); err == nil || !strings.Contains(err.Error(), "question exceeds byte limit") { + t.Fatalf("question above byte bound was not rejected: %v", err) + } +} + +func TestHandoffDerivedContextByteBoundary(t *testing.T) { + session := workspaceSessionForInvariant(t, strings.Repeat("c", maxHandoffContextBytes)) + if _, err := buildHandoffPacket(handoffRequest(t, []any{handoffAnnotation(0, 1, "c", "Why?")}), session); err == nil || !strings.Contains(err.Error(), "review context exceeds byte limit") { + t.Fatalf("oversized derived context was not rejected: %v", err) + } +} + +func TestHandoffFinalPacketByteBoundaryIsReachable(t *testing.T) { + const invariantBytes = maxHandoffContextBytes - 2532 + const quoteBytes = 16204 + session := workspaceSessionForInvariant(t, strings.Repeat("z", invariantBytes)) + anchor := session.Anchors["requirement:REQ-CONSUMER-001:invariant"] + annotations := make([]any, maxHandoffAnnotations) + for index := range annotations { + annotations[index] = handoffAnnotation(index, index+quoteBytes, anchor.Text[index:index+quoteBytes], "Why?") + } + if size := len(encodedHandoffBody(t, annotations)); size > maxHandoffRequestBytes { + t.Fatalf("calibrated final-packet fixture exceeds request bound: %d", size) + } + if _, err := buildHandoffPacket(handoffRequest(t, annotations), session); err == nil || !strings.Contains(err.Error(), "packet exceeds byte limit") { + t.Fatalf("composed payload above final packet bound was not rejected: %v", err) + } +} + +func workspaceSessionForInvariant(t *testing.T, invariant string) workspaceSession { + t.Helper() + session, _, err := buildWorkspace(workspaceFixtureWithInvariant(t, invariant)) + if err != nil { + t.Fatal(err) + } + return session +} + +func handoffRequest(t *testing.T, annotations []any) *http.Request { + t.Helper() + return httptest.NewRequest(http.MethodPost, "/api/v1/handoff", bytes.NewReader(encodedHandoffBody(t, annotations))) +} + +func encodedHandoffBody(t *testing.T, annotations []any) []byte { + t.Helper() + encoded, err := stablejson.Marshal(map[string]any{"annotations": annotations}) + if err != nil { + t.Fatal(err) + } + return encoded +} + +func handoffAnnotation(start, end int, quote, question string) map[string]any { + return handoffAnnotationRecord("requirement:REQ-CONSUMER-001:invariant", start, end, quote, question) +} + +func handoffAnnotationRecord(anchorID string, start, end int, quote, question string) map[string]any { + return map[string]any{ + "anchorId": anchorID, + "endCodePoint": json.Number(strconv.Itoa(end)), + "exactQuote": quote, + "question": question, + "startCodePoint": json.Number(strconv.Itoa(start)), + } +} diff --git a/internal/command/requirementbrowser/http_handler.go b/internal/command/requirementbrowser/http_handler.go new file mode 100644 index 0000000..d58ca29 --- /dev/null +++ b/internal/command/requirementbrowser/http_handler.go @@ -0,0 +1,673 @@ +package requirementbrowser + +import ( + "crypto/rand" + "crypto/subtle" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "net/http" + "sort" + "strconv" + "strings" + + "github.com/research-engineering/agentic-proofkit/internal/command/requirementcontext" + "github.com/research-engineering/agentic-proofkit/internal/kernel/admission" + "github.com/research-engineering/agentic-proofkit/internal/kernel/admit" + "github.com/research-engineering/agentic-proofkit/internal/kernel/digest" + "github.com/research-engineering/agentic-proofkit/internal/kernel/secretjson" + "github.com/research-engineering/agentic-proofkit/internal/kernel/stablejson" +) + +const ( + maxHandoffRequestBytes = 1 << 20 + maxHandoffAnnotations = 64 + maxHandoffQuoteBytes = 64 << 10 + maxHandoffQuestionBytes = 4 << 10 + maxHandoffContextBytes = 1 << 20 + maxHandoffPacketBytes = 2 << 20 +) + +func browserHandler(view string, rendered renderedView, expectedAuthority, capability string, oneShot bool, terminal *terminalArbiter) http.Handler { + return http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { + if request.Host != expectedAuthority { + forbidden(response, request.Method) + return + } + expectedOrigin := "http://" + expectedAuthority + if origin := request.Header.Get("origin"); origin != "" && origin != expectedOrigin { + forbidden(response, request.Method) + return + } + method := request.Method + switch request.URL.Path { + case "/", "/index.html": + serveIndex(response, method, rendered) + case "/healthz": + serveHealth(response, method, view, rendered) + case "/assets/workspace.js": + serveWorkspaceAsset(response, method, workspaceJavaScript, "text/javascript; charset=utf-8") + case "/assets/selection-authority.js": + serveWorkspaceAsset(response, method, selectionAuthorityJavaScript, "text/javascript; charset=utf-8") + case "/assets/workspace.css": + serveWorkspaceAsset(response, method, workspaceCSS, "text/css; charset=utf-8") + case "/api/v1/manifest": + if method != http.MethodGet && method != http.MethodHead { + methodNotAllowed(response, method, "GET, HEAD") + return + } + if rendered.workspace == nil || !validCapability(request, capability) { + forbidden(response, method) + return + } + serveWorkspaceJSON(response, method, rendered.workspace.Manifest) + case "/api/v1/query": + if method != http.MethodPost { + methodNotAllowed(response, method, "POST") + return + } + serveWorkspaceQuery(response, request, expectedOrigin, capability, rendered.workspace) + case "/api/v1/requirements": + if method != http.MethodPost { + methodNotAllowed(response, method, "POST") + return + } + serveWorkspaceRequirements(response, request, expectedOrigin, capability, rendered.workspace) + case "/api/v1/diff", "/api/v1/graph": + if method != http.MethodPost { + methodNotAllowed(response, method, "POST") + return + } + serveWorkspaceProjection(response, request, expectedOrigin, capability, rendered.workspace) + case "/api/v1/cancel": + if method != http.MethodPost { + methodNotAllowed(response, method, "POST") + return + } + serveCancel(response, request, expectedOrigin, capability, rendered.workspace, oneShot, terminal) + case "/api/v1/handoff": + if method != http.MethodPost { + methodNotAllowed(response, method, "POST") + return + } + serveHandoff(response, request, expectedOrigin, capability, rendered.workspace, oneShot, terminal) + default: + response.WriteHeader(http.StatusNotFound) + writeBody(response, method, []byte("not found\n")) + } + }) +} + +func serveWorkspaceRequirements(response http.ResponseWriter, request *http.Request, expectedOrigin, capability string, session *workspaceSession) { + record, err := admitAPIRequest(request, expectedOrigin, capability, session, []string{"query", "requestId", "snapshotId"}) + if err != nil { + writeAPIError(response, request.Method, err) + return + } + requestID, err := admit.RuleID(record["requestId"], "requirement browser requirements requestId") + if err != nil { + writeAPIError(response, request.Method, err) + return + } + query, err := admitProjectionQuery(record["query"]) + if err != nil { + writeAPIError(response, request.Method, err) + return + } + projection, state := requirementWindow(session.Requirements, query) + serveWorkspaceJSON(response, request.Method, map[string]any{"projection": projection, "requestId": requestID, "schemaVersion": json.Number("1"), "snapshotId": session.SnapshotID, "state": state}) +} + +func requirementWindow(requirements []any, query projectionQuery) (map[string]any, string) { + start := min(query.Offset, len(requirements)) + end := min(start+query.MaxRecords, len(requirements)) + selected := append([]any{}, requirements[start:end]...) + state := "complete" + if start > 0 || end < len(requirements) { + state = "partial_with_omissions" + } + return map[string]any{"authority": "lookup_fragment_only", "availableRequirementCount": len(requirements), "omittedRequirementCount": len(requirements) - len(selected), "projectionKind": "proofkit.requirement-browser-requirement-fragment", "requirements": selected, "selectedRequirementCount": len(selected)}, state +} + +func serveWorkspaceQuery(response http.ResponseWriter, request *http.Request, expectedOrigin, capability string, session *workspaceSession) { + record, err := admitAPIRequest(request, expectedOrigin, capability, session, []string{"query", "requestId", "snapshotId"}) + if err != nil { + writeAPIError(response, request.Method, err) + return + } + requestID, err := admit.RuleID(record["requestId"], "requirement browser query requestId") + if err != nil { + writeAPIError(response, request.Method, err) + return + } + query, ok := record["query"].(map[string]any) + if !ok { + writeAPIError(response, request.Method, fmt.Errorf("requirement browser query must be an object")) + return + } + queryBytes, err := stablejson.Marshal(query) + if err != nil { + writeAPIError(response, request.Method, err) + return + } + queryID := digest.SHA256TextRef(string(queryBytes)) + slice, err := requirementcontext.Slice(map[string]any{ + "context": session.ContextValue, + "query": query, + "schemaVersion": json.Number("1"), + "sliceId": "browser.query:" + requestID, + }) + if err != nil { + writeAPIError(response, request.Method, err) + return + } + serveWorkspaceJSON(response, request.Method, map[string]any{ + "queryId": queryID, + "requestId": requestID, + "schemaVersion": json.Number("1"), + "slice": slice, + "snapshotId": session.SnapshotID, + "state": slice["state"], + }) +} + +func serveWorkspaceProjection(response http.ResponseWriter, request *http.Request, expectedOrigin, capability string, session *workspaceSession) { + record, err := admitAPIRequest(request, expectedOrigin, capability, session, []string{"query", "requestId", "snapshotId"}) + if err != nil { + writeAPIError(response, request.Method, err) + return + } + requestID, err := admit.RuleID(record["requestId"], "requirement browser projection requestId") + if err != nil { + writeAPIError(response, request.Method, err) + return + } + var projection map[string]any + if request.URL.Path == "/api/v1/diff" { + projection = session.Diff + } else { + projection = session.Graph + } + if projection == nil { + response.WriteHeader(http.StatusNotFound) + return + } + query, err := admitProjectionQuery(record["query"]) + if err != nil { + writeAPIError(response, request.Method, err) + return + } + state := "complete" + if request.URL.Path == "/api/v1/diff" { + projection, state = diffWindow(projection, query) + } else { + projection, state = graphWindow(projection, query) + } + serveWorkspaceJSON(response, request.Method, map[string]any{"projection": projection, "requestId": requestID, "schemaVersion": json.Number("1"), "snapshotId": session.SnapshotID, "state": state}) +} + +type projectionQuery struct { + EdgeOffset int + MaxEdges int + MaxRecords int + Offset int +} + +func admitProjectionQuery(raw any) (projectionQuery, error) { + query := projectionQuery{MaxEdges: 2048, MaxRecords: 256} + if raw == nil { + return query, nil + } + record, ok := raw.(map[string]any) + if !ok { + return projectionQuery{}, fmt.Errorf("browser projection query must be an object") + } + if err := admit.KnownKeys(record, []string{"edgeOffset", "maxEdges", "maxRecords", "offset"}, "browser projection query"); err != nil { + return projectionQuery{}, err + } + var err error + if record["edgeOffset"] != nil { + query.EdgeOffset, err = nonNegativeJSONInteger(record["edgeOffset"], "browser projection query edgeOffset") + if err != nil || query.EdgeOffset > 80_000 { + return projectionQuery{}, fmt.Errorf("browser projection query edgeOffset must be between 0 and 80000") + } + } + if record["offset"] != nil { + query.Offset, err = nonNegativeJSONInteger(record["offset"], "browser projection query offset") + if err != nil || query.Offset > 20_000 { + return projectionQuery{}, fmt.Errorf("browser projection query offset must be between 0 and 20000") + } + } + if record["maxRecords"] != nil { + query.MaxRecords, err = positiveJSONInteger(record["maxRecords"], "browser projection query maxRecords") + if err != nil || query.MaxRecords > 2048 { + return projectionQuery{}, fmt.Errorf("browser projection query maxRecords must be between 1 and 2048") + } + } + if record["maxEdges"] != nil { + query.MaxEdges, err = positiveJSONInteger(record["maxEdges"], "browser projection query maxEdges") + if err != nil || query.MaxEdges > 16_384 { + return projectionQuery{}, fmt.Errorf("browser projection query maxEdges must be between 1 and 16384") + } + } + return query, nil +} + +func diffWindow(full map[string]any, query projectionQuery) (map[string]any, string) { + changes := full["changes"].([]any) + start := min(query.Offset, len(changes)) + end := min(start+query.MaxRecords, len(changes)) + selected := append([]any{}, changes[start:end]...) + state := "complete" + if start > 0 || end < len(changes) { + state = "partial_with_omissions" + } + return map[string]any{ + "authority": "lookup_fragment_only", + "availableChangeCount": len(changes), + "baseBaselineVerification": full["baseBaselineVerification"], + "baseSnapshotId": full["baseSnapshotId"], + "changes": selected, + "currentBaselineVerification": full["currentBaselineVerification"], + "currentSnapshotId": full["currentSnapshotId"], + "nonClaims": full["nonClaims"], + "omittedChangeCount": len(changes) - len(selected), + "projectionKind": "proofkit.requirement-semantic-diff-fragment", + "selectedChangeCount": len(selected), + "sourceDiffId": full["diffId"], + }, state +} + +func graphWindow(full map[string]any, query projectionQuery) (map[string]any, string) { + nodes := full["nodes"].([]any) + start := min(query.Offset, len(nodes)) + end := min(start+query.MaxRecords, len(nodes)) + primaryNodes := append([]any{}, nodes[start:end]...) + primaryIDs := map[string]struct{}{} + for _, raw := range primaryNodes { + primaryIDs[raw.(map[string]any)["nodeId"].(string)] = struct{}{} + } + incidentEdges := []any{} + for _, raw := range full["edges"].([]any) { + edge := raw.(map[string]any) + _, from := primaryIDs[edge["fromNodeId"].(string)] + _, to := primaryIDs[edge["toNodeId"].(string)] + if from || to { + incidentEdges = append(incidentEdges, edge) + } + } + edgeStart := min(query.EdgeOffset, len(incidentEdges)) + edgeEnd := min(edgeStart+query.MaxEdges, len(incidentEdges)) + selectedEdges := append([]any{}, incidentEdges[edgeStart:edgeEnd]...) + selectedIDs := map[string]struct{}{} + for id := range primaryIDs { + selectedIDs[id] = struct{}{} + } + for _, raw := range selectedEdges { + edge := raw.(map[string]any) + selectedIDs[edge["fromNodeId"].(string)] = struct{}{} + selectedIDs[edge["toNodeId"].(string)] = struct{}{} + } + selectedNodes := make([]any, 0, len(selectedIDs)) + for _, raw := range nodes { + if _, ok := selectedIDs[raw.(map[string]any)["nodeId"].(string)]; ok { + selectedNodes = append(selectedNodes, raw) + } + } + availableEdges := len(full["edges"].([]any)) + omittedNodes := len(nodes) - len(selectedNodes) + omittedPrimaryNodes := len(nodes) - len(primaryNodes) + omittedEdges := availableEdges - len(selectedEdges) + state := "complete" + if omittedNodes > 0 || omittedEdges > 0 || edgeStart > 0 || edgeEnd < len(incidentEdges) { + state = "partial_with_omissions" + } + return map[string]any{ + "authority": "lookup_fragment_only", + "availableEdgeCount": availableEdges, + "availableIncidentEdgeCount": len(incidentEdges), + "availableNodeCount": len(nodes), + "boundaryNodeCount": len(selectedNodes) - len(primaryNodes), + "edgeOffset": edgeStart, + "edges": selectedEdges, + "nodes": selectedNodes, + "nonClaims": full["nonClaims"], + "omittedEdgeCount": omittedEdges, + "omittedIncidentEdgeCount": len(incidentEdges) - len(selectedEdges), + "omittedNodeCount": omittedNodes, + "omittedPrimaryNodeCount": omittedPrimaryNodes, + "primaryNodeCount": len(primaryNodes), + "projectionKind": "proofkit.requirement-traceability-graph-fragment", + "selectedEdgeCount": len(selectedEdges), + "selectedNodeCount": len(selectedNodes), + "sourceGraphId": full["graphId"], + "sourceSnapshotId": full["snapshotId"], + }, state +} + +func serveCancel(response http.ResponseWriter, request *http.Request, expectedOrigin, capability string, session *workspaceSession, oneShot bool, terminal *terminalArbiter) { + record, err := admitAPIRequest(request, expectedOrigin, capability, session, []string{"requestId", "snapshotId"}) + if err != nil { + writeAPIError(response, request.Method, err) + return + } + if _, err := admit.RuleID(record["requestId"], "requirement browser cancel requestId"); err != nil { + writeAPIError(response, request.Method, err) + return + } + packet := map[string]any{"handoffKind": "proofkit.requirement-browser-question", "nonClaims": admit.StringSliceToAny(serverNonClaims), "schemaVersion": json.Number("1"), "snapshotRefs": []any{map[string]any{"role": "current", "snapshotId": session.SnapshotID}}, "state": "cancelled"} + if oneShot { + if !terminal.TryCommit(packet) { + response.WriteHeader(http.StatusConflict) + return + } + } + serveWorkspaceJSON(response, request.Method, packet) +} + +func admitAPIRequest(request *http.Request, expectedOrigin, capability string, session *workspaceSession, keys []string) (map[string]any, error) { + if session == nil || request.Header.Get("origin") != expectedOrigin || request.Header.Get("content-type") != "application/json" || !validCapability(request, capability) { + return nil, fmt.Errorf("unauthorized browser API request") + } + if request.ContentLength > maxHandoffRequestBytes { + return nil, fmt.Errorf("browser API request exceeds byte limit") + } + raw, err := admission.DecodeJSON(io.LimitReader(request.Body, maxHandoffRequestBytes+1), maxHandoffRequestBytes) + if err != nil { + return nil, err + } + record, ok := raw.(map[string]any) + if !ok { + return nil, fmt.Errorf("browser API request must be an object") + } + if err := admit.KnownKeys(record, keys, "browser API request"); err != nil { + return nil, err + } + if record["snapshotId"] != session.SnapshotID { + return nil, fmt.Errorf("browser API request snapshot is stale") + } + return record, nil +} + +func writeAPIError(response http.ResponseWriter, method string, err error) { + status := http.StatusBadRequest + if strings.Contains(err.Error(), "unauthorized") { + status = http.StatusForbidden + } else if strings.Contains(err.Error(), "stale") { + status = http.StatusConflict + } + response.WriteHeader(status) + writeBody(response, method, []byte("request rejected\n")) +} + +func serveIndex(response http.ResponseWriter, method string, rendered renderedView) { + if method != http.MethodGet && method != http.MethodHead { + methodNotAllowed(response, method, "GET, HEAD") + return + } + response.Header().Set("cache-control", "no-store") + response.Header().Set("content-type", "text/html; charset=utf-8") + if rendered.workspace != nil { + setWorkspaceSecurityHeaders(response) + } + response.WriteHeader(http.StatusOK) + writeBody(response, method, []byte(rendered.html)) +} + +func serveHealth(response http.ResponseWriter, method, view string, rendered renderedView) { + if method != http.MethodGet && method != http.MethodHead { + methodNotAllowed(response, method, "GET, HEAD") + return + } + body, err := stablejson.Marshal(map[string]any{"authority": "presentation_adapter_status", "nonClaims": admit.StringSliceToAny(serverNonClaims), "state": "ok", "view": view, "viewKind": rendered.viewKind}) + if err != nil { + response.WriteHeader(http.StatusInternalServerError) + return + } + response.Header().Set("cache-control", "no-store") + response.Header().Set("content-type", "application/json; charset=utf-8") + response.WriteHeader(http.StatusOK) + writeBody(response, method, body) +} + +func serveHandoff(response http.ResponseWriter, request *http.Request, expectedOrigin, capability string, session *workspaceSession, oneShot bool, terminal *terminalArbiter) { + if session == nil || request.Method != http.MethodPost || request.Header.Get("origin") != expectedOrigin || request.Header.Get("content-type") != "application/json" || !validCapability(request, capability) { + forbidden(response, request.Method) + return + } + packet, err := buildHandoffPacket(request, *session) + if err != nil { + response.WriteHeader(http.StatusBadRequest) + writeBody(response, request.Method, []byte("invalid handoff\n")) + return + } + if oneShot { + if !terminal.TryCommit(packet) { + response.WriteHeader(http.StatusConflict) + return + } + } + serveWorkspaceJSON(response, request.Method, packet) +} + +func browserCapability() (string, error) { + value := make([]byte, 32) + if _, err := rand.Read(value); err != nil { + return "", fmt.Errorf("generate browser capability: %w", err) + } + return base64.RawURLEncoding.EncodeToString(value), nil +} +func validCapability(request *http.Request, capability string) bool { + expected, err := base64.RawURLEncoding.DecodeString(capability) + if err != nil || len(expected) != 32 { + return false + } + provided, err := base64.RawURLEncoding.DecodeString(request.Header.Get("X-Proofkit-Browser-Capability")) + if err != nil || len(provided) != len(expected) { + return false + } + return subtle.ConstantTimeCompare(provided, expected) == 1 +} +func setWorkspaceSecurityHeaders(response http.ResponseWriter) { + response.Header().Set("content-security-policy", "default-src 'none'; script-src 'self'; style-src 'self'; connect-src 'self'; img-src 'self'; worker-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'") + response.Header().Set("cross-origin-opener-policy", "same-origin") + response.Header().Set("cross-origin-resource-policy", "same-origin") + response.Header().Set("permissions-policy", "accelerometer=(), camera=(), geolocation=(), gyroscope=(), microphone=(), payment=(), usb=()") + response.Header().Set("x-content-type-options", "nosniff") + response.Header().Set("referrer-policy", "no-referrer") +} +func serveWorkspaceAsset(response http.ResponseWriter, method string, body []byte, contentType string) { + if method != http.MethodGet && method != http.MethodHead { + methodNotAllowed(response, method, "GET, HEAD") + return + } + response.Header().Set("cache-control", "no-store") + response.Header().Set("content-type", contentType) + setWorkspaceSecurityHeaders(response) + response.WriteHeader(http.StatusOK) + writeBody(response, method, body) +} +func serveWorkspaceJSON(response http.ResponseWriter, method string, value any) { + body, err := stablejson.Marshal(value) + if err != nil { + response.WriteHeader(http.StatusInternalServerError) + return + } + response.Header().Set("cache-control", "no-store") + response.Header().Set("content-type", "application/json; charset=utf-8") + setWorkspaceSecurityHeaders(response) + response.WriteHeader(http.StatusOK) + writeBody(response, method, body) +} +func methodNotAllowed(response http.ResponseWriter, method, allow string) { + response.Header().Set("allow", allow) + response.WriteHeader(http.StatusMethodNotAllowed) + writeBody(response, method, []byte("method not allowed\n")) +} +func forbidden(response http.ResponseWriter, method string) { + response.Header().Set("content-type", "text/plain; charset=utf-8") + response.WriteHeader(http.StatusForbidden) + writeBody(response, method, []byte("forbidden\n")) +} + +func buildHandoffPacket(request *http.Request, session workspaceSession) (map[string]any, error) { + if request.ContentLength > 1<<20 { + return nil, fmt.Errorf("handoff exceeds byte limit") + } + raw, err := admission.DecodeJSON(io.LimitReader(request.Body, (1<<20)+1), 1<<20) + if err != nil { + return nil, err + } + record, ok := raw.(map[string]any) + if !ok { + return nil, fmt.Errorf("handoff must be an object") + } + if err := admit.KnownKeys(record, []string{"annotations"}, "browser handoff"); err != nil { + return nil, err + } + values, ok := record["annotations"].([]any) + if !ok || len(values) == 0 || len(values) > maxHandoffAnnotations { + return nil, fmt.Errorf("handoff annotations must contain 1 to 64 records") + } + annotations := make([]any, 0, len(values)) + requirementIDs := map[string]struct{}{} + seenTargets := map[string]struct{}{} + for _, value := range values { + annotation, ok := value.(map[string]any) + if !ok { + return nil, fmt.Errorf("handoff annotation must be an object") + } + admitted, err := admitAnnotation(annotation, session) + if err != nil { + return nil, err + } + targetID, err := handoffTargetID(admitted) + if err != nil { + return nil, err + } + if _, exists := seenTargets[targetID]; exists { + return nil, fmt.Errorf("handoff target ids must be unique") + } + seenTargets[targetID] = struct{}{} + admitted["targetId"] = targetID + anchor := admitted["anchor"].(map[string]any) + requirementIDs[anchor["requirementId"].(string)] = struct{}{} + annotations = append(annotations, admitted) + } + orderedRequirementIDs := make([]string, 0, len(requirementIDs)) + for requirementID := range requirementIDs { + orderedRequirementIDs = append(orderedRequirementIDs, requirementID) + } + sort.Strings(orderedRequirementIDs) + selectedRequirementIDs := make([]any, len(orderedRequirementIDs)) + for index, requirementID := range orderedRequirementIDs { + selectedRequirementIDs[index] = requirementID + } + slice, err := requirementcontext.Slice(map[string]any{ + "context": session.ContextValue, + "query": map[string]any{"maxNodes": json.Number("4096"), "maxRequirements": json.Number("16384"), "profile": "review", "requirementIds": selectedRequirementIDs}, + "schemaVersion": json.Number("1"), + "sliceId": "browser.handoff.context", + }) + if err != nil { + return nil, err + } + contextBytes, err := stablejson.Marshal(slice) + if err != nil || len(contextBytes) > maxHandoffContextBytes { + return nil, fmt.Errorf("handoff review context exceeds byte limit") + } + packet := map[string]any{ + "annotations": annotations, + "context": slice, + "handoffKind": "proofkit.requirement-browser-question", + "instructionAuthority": "browser_session_submission", + "nonClaims": admit.StringSliceToAny(serverNonClaims), + "schemaVersion": json.Number("1"), + "snapshotRefs": []any{map[string]any{"role": "current", "snapshotId": session.SnapshotID}}, + "sourceTextAuthority": "untrusted_context", + "state": "submitted", + } + findings, err := secretjson.Scan(packet, "browser_handoff") + if err != nil || len(findings) > 0 { + return nil, fmt.Errorf("handoff packet contains secret-shaped data") + } + encoded, err := stablejson.Marshal(packet) + if err != nil || len(encoded) > maxHandoffPacketBytes { + return nil, fmt.Errorf("handoff packet exceeds byte limit") + } + return packet, nil +} + +func handoffTargetID(annotation map[string]any) (string, error) { + identity := map[string]any{ + "anchor": annotation["anchor"], + "endCodePoint": annotation["endCodePoint"], + "exactQuote": annotation["exactQuote"], + "prefix": annotation["prefix"], + "startCodePoint": annotation["startCodePoint"], + "suffix": annotation["suffix"], + } + encoded, err := stablejson.Marshal(identity) + if err != nil { + return "", err + } + return digest.SHA256TextRef(string(encoded)), nil +} + +func admitAnnotation(annotation map[string]any, session workspaceSession) (map[string]any, error) { + if err := admit.KnownKeys(annotation, []string{"anchorId", "endCodePoint", "exactQuote", "question", "startCodePoint"}, "browser handoff annotation"); err != nil { + return nil, err + } + anchorID, err := admit.RuleID(annotation["anchorId"], "browser handoff anchorId") + if err != nil { + return nil, err + } + anchor, ok := session.Anchors[anchorID] + if !ok { + return nil, fmt.Errorf("handoff references unknown anchor") + } + quote, ok := annotation["exactQuote"].(string) + if !ok || strings.TrimSpace(quote) == "" || len([]byte(quote)) > maxHandoffQuoteBytes || admit.ContainsSecretLikeValue(quote) { + return nil, fmt.Errorf("handoff quote is invalid") + } + start, err := nonNegativeJSONInteger(annotation["startCodePoint"], "browser handoff startCodePoint") + if err != nil { + return nil, err + } + end, err := nonNegativeJSONInteger(annotation["endCodePoint"], "browser handoff endCodePoint") + anchorRunes := []rune(anchor.Text) + if err != nil || start >= end || end > len(anchorRunes) || string(anchorRunes[start:end]) != quote { + return nil, fmt.Errorf("handoff quote does not resolve inside anchor") + } + question, err := admit.NonEmptyText(annotation["question"], "browser handoff question") + if err != nil { + return nil, err + } + if len([]byte(question)) > maxHandoffQuestionBytes { + return nil, fmt.Errorf("browser handoff question exceeds byte limit") + } + prefixStart := max(0, start-32) + suffixEnd := min(len(anchorRunes), end+32) + return map[string]any{"anchor": anchorValue(anchor), "endCodePoint": end, "exactQuote": quote, "prefix": string(anchorRunes[prefixStart:start]), "question": question, "startCodePoint": start, "suffix": string(anchorRunes[end:suffixEnd])}, nil +} + +func nonNegativeJSONInteger(raw any, context string) (int, error) { + number, ok := raw.(json.Number) + if !ok { + return 0, fmt.Errorf("%s must be a non-negative integer", context) + } + value, err := strconv.Atoi(number.String()) + if err != nil || value < 0 { + return 0, fmt.Errorf("%s must be a non-negative integer", context) + } + return value, nil +} + +func positiveJSONInteger(raw any, context string) (int, error) { + value, err := nonNegativeJSONInteger(raw, context) + if err != nil || value == 0 { + return 0, fmt.Errorf("%s must be a positive integer", context) + } + return value, nil +} diff --git a/internal/command/requirementbrowser/requirementbrowser.go b/internal/command/requirementbrowser/requirementbrowser.go index 445afc5..301c207 100644 --- a/internal/command/requirementbrowser/requirementbrowser.go +++ b/internal/command/requirementbrowser/requirementbrowser.go @@ -3,6 +3,7 @@ package requirementbrowser import ( "fmt" "strings" + "time" "github.com/research-engineering/agentic-proofkit/internal/command/requirementcoverageview" "github.com/research-engineering/agentic-proofkit/internal/command/requirementproofview" @@ -13,7 +14,7 @@ import ( const ( defaultHost = "127.0.0.1" - defaultPort = 4177 + defaultPort = 0 ) var serverNonClaims = []string{ @@ -22,6 +23,7 @@ var serverNonClaims = []string{ "Requirement browser servers do not execute native witnesses.", "Requirement browser servers do not prove receipt freshness, merge approval, or rollout readiness.", "Requirement browser servers do not persist browser annotations into source files.", + "Requirement browser servers do not authenticate the surrounding browser profile or operating-system user.", } type Options struct { @@ -32,6 +34,8 @@ type Options struct { Port int PortSet bool ProofViewScope string + SessionMode string + SessionTimeout time.Duration View string } @@ -52,6 +56,12 @@ func BuildPlan(raw any, options Options) (map[string]any, int, error) { if err != nil { return nil, 1, err } + portSelection := "fixed" + var plannedURL any = browserURL(options.Host, options.Port) + if options.Port == 0 { + portSelection = "ephemeral" + plannedURL = nil + } return map[string]any{ "authority": "presentation_adapter_plan", "host": options.Host, @@ -59,10 +69,11 @@ func BuildPlan(raw any, options Options) (map[string]any, int, error) { "nonClaims": admit.StringSliceToAny(serverNonClaims), "planKind": "proofkit.requirement-browser-server-plan", "port": options.Port, + "portSelection": portSelection, "renderedAuthority": rendered.authority, "renderedViewKind": rendered.viewKind, "schemaVersion": 1, - "url": browserURL(options.Host, options.Port), + "url": plannedURL, "view": options.View, }, 0, nil } @@ -71,9 +82,17 @@ type renderedView struct { authority string html string viewKind string + workspace *workspaceSession } func render(raw any, options Options) (renderedView, error) { + if options.View == "workspace" { + session, document, err := buildWorkspace(raw) + if err != nil { + return renderedView{}, err + } + return renderedView{authority: "presentation_adapter", html: document, viewKind: "proofkit.requirement-workspace", workspace: &session}, nil + } if options.View == "source" { view, html, err := requirementsourceview.BuildBrowserDocument(raw) if err != nil { diff --git a/internal/command/requirementbrowser/server.go b/internal/command/requirementbrowser/server.go index 0633767..69f9b58 100644 --- a/internal/command/requirementbrowser/server.go +++ b/internal/command/requirementbrowser/server.go @@ -2,6 +2,7 @@ package requirementbrowser import ( "context" + "encoding/json" "errors" "fmt" "io" @@ -10,6 +11,8 @@ import ( "os/exec" "runtime" "strconv" + "strings" + "sync" "time" "github.com/research-engineering/agentic-proofkit/internal/kernel/admit" @@ -19,17 +22,44 @@ import ( const ( serverIdleTimeout = 30 * time.Second serverReadHeaderTimeout = 5 * time.Second + serverReadTimeout = 15 * time.Second serverShutdownTimeout = 5 * time.Second serverWriteTimeout = 15 * time.Second ) +var ErrOneShotTerminal = errors.New("requirement browser one-shot terminal state") + type ServerHandle struct { - Host string - Port int - URL string + Host string + Port int + SnapshotID string + URL string + Handoff <-chan map[string]any + + close func(context.Context) error + done <-chan error + terminal *terminalArbiter +} - close func(context.Context) error - done <-chan error +type terminalArbiter struct { + mu sync.Mutex + committed bool + packets chan map[string]any +} + +func newTerminalArbiter() *terminalArbiter { + return &terminalArbiter{packets: make(chan map[string]any, 1)} +} + +func (arbiter *terminalArbiter) TryCommit(packet map[string]any) bool { + arbiter.mu.Lock() + defer arbiter.mu.Unlock() + if arbiter.committed { + return false + } + arbiter.committed = true + arbiter.packets <- packet + return true } func (handle ServerHandle) Close(ctx context.Context) error { @@ -70,10 +100,25 @@ func StartServer(raw any, options Options) (ServerHandle, error) { return ServerHandle{}, err } expectedAuthority := net.JoinHostPort(options.Host, strconv.Itoa(actualPort)) + capability, err := browserCapability() + if err != nil { + _ = listener.Close() + return ServerHandle{}, err + } + if rendered.workspace != nil { + marker := `content="` + workspaceCapabilityPlaceholder + `"` + if strings.Count(rendered.html, marker) != 1 { + _ = listener.Close() + return ServerHandle{}, fmt.Errorf("requirement browser capability marker must occur exactly once") + } + rendered.html = strings.Replace(rendered.html, marker, `content="`+capability+`"`, 1) + } + terminal := newTerminalArbiter() server := &http.Server{ - Handler: browserHandler(options.View, rendered, expectedAuthority), + Handler: browserHandler(options.View, rendered, expectedAuthority, capability, options.SessionMode == "one-shot-question", terminal), IdleTimeout: serverIdleTimeout, ReadHeaderTimeout: serverReadHeaderTimeout, + ReadTimeout: serverReadTimeout, WriteTimeout: serverWriteTimeout, } done := make(chan error, 1) @@ -86,9 +131,11 @@ func StartServer(raw any, options Options) (ServerHandle, error) { done <- nil }() return ServerHandle{ - Host: options.Host, - Port: actualPort, - URL: browserURL(options.Host, actualPort), + Host: options.Host, + Port: actualPort, + SnapshotID: workspaceSnapshotID(rendered.workspace), + URL: browserURL(options.Host, actualPort), + Handoff: terminal.packets, close: func(ctx context.Context) error { shutdownErr := server.Shutdown(ctx) if shutdownErr == nil { @@ -96,7 +143,8 @@ func StartServer(raw any, options Options) (ServerHandle, error) { } return errors.Join(shutdownErr, server.Close()) }, - done: done, + done: done, + terminal: terminal, }, nil } @@ -111,6 +159,24 @@ func Serve(ctx context.Context, raw any, options Options, stdout io.Writer) erro return err } } + if options.SessionMode == "one-shot-question" { + timeout := options.SessionTimeout + if timeout == 0 { + timeout = 30 * time.Minute + } + timer := time.NewTimer(timeout) + defer timer.Stop() + select { + case packet := <-handle.Handoff: + return writeOneShotPacket(stdout, packet, packet["state"] != "submitted") + case <-timer.C: + return commitOrReadTerminal(handle, terminalPacket("expired", handle), stdout) + case <-ctx.Done(): + return commitOrReadTerminal(handle, terminalPacket("cancelled", handle), stdout) + case err := <-handle.Done(): + return err + } + } if _, err := fmt.Fprintf(stdout, "Proofkit requirement browser: %s\n", handle.URL); err != nil { return err } @@ -125,56 +191,46 @@ func Serve(ctx context.Context, raw any, options Options, stdout io.Writer) erro } } -func browserHandler(view string, rendered renderedView, expectedAuthority string) http.Handler { - return http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { - if request.Host != expectedAuthority { - response.Header().Set("content-type", "text/plain; charset=utf-8") - response.WriteHeader(http.StatusForbidden) - writeBody(response, request.Method, []byte("forbidden host\n")) - return - } - expectedOrigin := "http://" + expectedAuthority - if origin := request.Header.Get("origin"); origin != "" && origin != expectedOrigin { - response.Header().Set("content-type", "text/plain; charset=utf-8") - response.WriteHeader(http.StatusForbidden) - writeBody(response, request.Method, []byte("forbidden origin\n")) - return - } - method := request.Method - if method != http.MethodGet && method != http.MethodHead { - response.Header().Set("allow", "GET, HEAD") - response.Header().Set("content-type", "text/plain; charset=utf-8") - response.WriteHeader(http.StatusMethodNotAllowed) - writeBody(response, method, []byte("method not allowed\n")) - return - } - switch request.URL.Path { - case "/", "/index.html": - response.Header().Set("cache-control", "no-store") - response.Header().Set("content-type", "text/html; charset=utf-8") - response.WriteHeader(http.StatusOK) - writeBody(response, method, []byte(rendered.html)) - case "/healthz": - response.Header().Set("cache-control", "no-store") - response.Header().Set("content-type", "application/json; charset=utf-8") - response.WriteHeader(http.StatusOK) - body, err := stablejson.Marshal(map[string]any{ - "authority": "presentation_adapter_status", - "nonClaims": admit.StringSliceToAny(serverNonClaims), - "state": "ok", - "view": view, - "viewKind": rendered.viewKind, - }) - if err != nil { - return - } - writeBody(response, method, append(body, '\n')) - default: - response.Header().Set("content-type", "text/plain; charset=utf-8") - response.WriteHeader(http.StatusNotFound) - writeBody(response, method, []byte("not found\n")) - } - }) +func commitOrReadTerminal(handle ServerHandle, candidate map[string]any, stdout io.Writer) error { + if handle.terminal.TryCommit(candidate) { + return writeOneShotPacket(stdout, candidate, true) + } + winner := <-handle.Handoff + return writeOneShotPacket(stdout, winner, winner["state"] != "submitted") +} + +func terminalPacket(state string, handle ServerHandle) map[string]any { + packet := map[string]any{ + "handoffKind": "proofkit.requirement-browser-question", + "nonClaims": admit.StringSliceToAny(serverNonClaims), + "schemaVersion": json.Number("1"), + "state": state, + } + if handle.SnapshotID != "" { + packet["snapshotRefs"] = []any{map[string]any{"role": "current", "snapshotId": handle.SnapshotID}} + } + return packet +} + +func workspaceSnapshotID(session *workspaceSession) string { + if session == nil { + return "" + } + return session.SnapshotID +} + +func writeOneShotPacket(stdout io.Writer, packet map[string]any, unsuccessful bool) error { + output, err := stablejson.MarshalLayout(packet, stablejson.LayoutCompact) + if err != nil { + return err + } + if _, err := stdout.Write(output); err != nil { + return err + } + if unsuccessful { + return ErrOneShotTerminal + } + return nil } func writeBody(response http.ResponseWriter, method string, body []byte) { diff --git a/internal/command/requirementbrowser/workspace.go b/internal/command/requirementbrowser/workspace.go new file mode 100644 index 0000000..4bb4fd0 --- /dev/null +++ b/internal/command/requirementbrowser/workspace.go @@ -0,0 +1,228 @@ +package requirementbrowser + +import ( + "encoding/json" + "fmt" + "html" + "sort" + "strings" + + "github.com/research-engineering/agentic-proofkit/internal/command/requirementcontext" + "github.com/research-engineering/agentic-proofkit/internal/command/requirementdiff" + "github.com/research-engineering/agentic-proofkit/internal/command/requirementgraph" + "github.com/research-engineering/agentic-proofkit/internal/kernel/admit" +) + +const workspaceCapabilityPlaceholder = "PROOFKIT_BROWSER_CAPABILITY_PLACEHOLDER" + +type workspaceAnchor struct { + AnchorID string + JSONPointer string + RequirementID string + SourceDigest string + Text string +} + +type workspaceSession struct { + Anchors map[string]workspaceAnchor + ContextValue map[string]any + Diff map[string]any + Graph map[string]any + Manifest map[string]any + Requirements []any + SnapshotID string +} + +func buildWorkspace(raw any) (workspaceSession, string, error) { + record, ok := raw.(map[string]any) + if !ok { + return workspaceSession{}, "", fmt.Errorf("requirement browser workspace input must be an object") + } + if err := admit.KnownKeys(record, []string{"context", "diffInput", "graphInput", "schemaVersion", "workspaceId"}, "requirement browser workspace input"); err != nil { + return workspaceSession{}, "", err + } + if !admit.JSONNumberEquals(record["schemaVersion"], 1) { + return workspaceSession{}, "", fmt.Errorf("requirement browser workspace schemaVersion must be 1") + } + workspaceID, err := admit.RuleID(record["workspaceId"], "requirement browser workspaceId") + if err != nil { + return workspaceSession{}, "", err + } + snapshot, err := requirementcontext.AdmitSnapshot(record["context"]) + if err != nil { + return workspaceSession{}, "", err + } + anchors, requirements, err := workspaceRequirements(snapshot) + if err != nil { + return workspaceSession{}, "", err + } + var diff map[string]any + if record["diffInput"] != nil { + diff, err = requirementdiff.Build(record["diffInput"]) + if err != nil { + return workspaceSession{}, "", err + } + if diff["currentSnapshotId"] != snapshot.SnapshotID { + return workspaceSession{}, "", fmt.Errorf("requirement browser diff input current context must equal workspace context") + } + diff, err = requirementdiff.AdmitOutput(diff, snapshot.SnapshotID) + if err != nil { + return workspaceSession{}, "", err + } + } + var graph map[string]any + if record["graphInput"] != nil { + graph, err = requirementgraph.Build(record["graphInput"]) + if err != nil { + return workspaceSession{}, "", err + } + if graph["snapshotId"] != snapshot.SnapshotID { + return workspaceSession{}, "", fmt.Errorf("requirement browser graph input context must equal workspace context") + } + graph, err = requirementgraph.AdmitOutput(graph, snapshot.SnapshotID) + if err != nil { + return workspaceSession{}, "", err + } + if err := validateGraphSnapshotClosure(snapshot, graph); err != nil { + return workspaceSession{}, "", err + } + } + manifest := map[string]any{ + "authority": "presentation_adapter", + "availableViews": []any{"specifications", "diff", "graph"}, + "baselineVerification": snapshot.BaselineVerification, + "diffAvailable": diff != nil, + "graphAvailable": graph != nil, + "nonClaims": admit.StringSliceToAny(serverNonClaims), + "requirementCount": len(requirements), + "schemaVersion": json.Number("1"), + "snapshotId": snapshot.SnapshotID, + "workspaceId": workspaceID, + } + return workspaceSession{Anchors: anchors, ContextValue: requirementcontext.SnapshotValue(snapshot), Diff: diff, Graph: graph, Manifest: manifest, Requirements: requirements, SnapshotID: snapshot.SnapshotID}, workspaceHTML(workspaceID), nil +} + +func validateGraphSnapshotClosure(snapshot requirementcontext.Snapshot, graph map[string]any) error { + expectedSpecs := map[string]struct{}{} + for _, node := range snapshot.Tree.Nodes { + expectedSpecs["spec:"+node.NodeID] = struct{}{} + } + expectedRequirements := map[string]struct{}{} + for _, source := range snapshot.RequirementSources { + for _, requirement := range source.Requirements { + expectedRequirements["requirement:"+requirement.RequirementID] = struct{}{} + } + } + expectedProof := map[string]struct{}{} + if snapshot.ProofBinding != nil { + for _, binding := range snapshot.ProofBinding.Bindings { + expectedProof[proofClosureKey(binding.RequirementID, binding.ScenarioID, binding.WitnessID, binding.WitnessKind, binding.WitnessPath)] = struct{}{} + } + } + actualSpecs := map[string]struct{}{} + actualRequirements := map[string]struct{}{} + actualProof := map[string]struct{}{} + for _, raw := range graph["nodes"].([]any) { + node := raw.(map[string]any) + switch node["evidencePlane"] { + case "specification_coverage": + if node["kind"] == "requirement" { + actualRequirements[node["nodeId"].(string)] = struct{}{} + } else { + actualSpecs[node["nodeId"].(string)] = struct{}{} + } + case "proof_coverage": + actualProof[proofClosureKey(node["requirementId"].(string), node["scenarioId"].(string), node["witnessId"].(string), node["witnessKind"].(string), node["witnessPath"].(string))] = struct{}{} + } + } + if !sameStringSet(expectedSpecs, actualSpecs) || !sameStringSet(expectedRequirements, actualRequirements) || !sameStringSet(expectedProof, actualProof) { + return fmt.Errorf("requirement browser graph does not close over the admitted context") + } + return nil +} + +func proofClosureKey(values ...string) string { + return strings.Join(values, "\x00") +} + +func sameStringSet(left, right map[string]struct{}) bool { + if len(left) != len(right) { + return false + } + for value := range left { + if _, ok := right[value]; !ok { + return false + } + } + return true +} + +func workspaceRequirements(snapshot requirementcontext.Snapshot) (map[string]workspaceAnchor, []any, error) { + rawSources, ok := snapshot.Projections["requirementSources"].([]any) + if !ok { + return nil, nil, fmt.Errorf("requirement browser workspace requires requirement source projections") + } + digestBySource := map[string]string{} + for _, source := range snapshot.Sources { + if source.Kind == "requirement_source" { + digestBySource[source.SourceRef] = source.CurrentDigest + } + } + anchors := map[string]workspaceAnchor{} + requirements := []any{} + for sourceIndex, rawSource := range rawSources { + source, ok := rawSource.(map[string]any) + if !ok { + return nil, nil, fmt.Errorf("requirement browser workspace source projection is invalid") + } + sourceID, _ := source["sourceId"].(string) + digest := digestBySource[sourceID] + rawRequirements, ok := source["requirements"].([]any) + if !ok { + return nil, nil, fmt.Errorf("requirement browser workspace requirements projection is invalid") + } + for requirementIndex, rawRequirement := range rawRequirements { + requirement, ok := rawRequirement.(map[string]any) + if !ok { + return nil, nil, fmt.Errorf("requirement browser workspace requirement projection is invalid") + } + id, _ := requirement["requirementId"].(string) + invariant, _ := requirement["invariant"].(string) + anchorID := "requirement:" + id + ":invariant" + anchor := workspaceAnchor{AnchorID: anchorID, JSONPointer: fmt.Sprintf("/projections/requirementSources/%d/requirements/%d/invariant", sourceIndex, requirementIndex), RequirementID: id, SourceDigest: digest, Text: invariant} + anchors[anchorID] = anchor + requirements = append(requirements, map[string]any{ + "anchor": anchorValue(anchor), + "claimLevel": requirement["claimLevel"], + "invariant": invariant, + "nonClaims": requirement["nonClaims"], + "ownerId": requirement["ownerId"], + "requirementId": id, + "sourceNonClaims": source["nonClaims"], + }) + } + } + sort.Slice(requirements, func(left, right int) bool { + return requirements[left].(map[string]any)["requirementId"].(string) < requirements[right].(map[string]any)["requirementId"].(string) + }) + return anchors, requirements, nil +} + +func anchorValue(anchor workspaceAnchor) map[string]any { + return map[string]any{"anchorId": anchor.AnchorID, "jsonPointer": anchor.JSONPointer, "requirementId": anchor.RequirementID, "sourceDigest": anchor.SourceDigest} +} + +func workspaceHTML(workspaceID string) string { + return strings.Join([]string{ + "", + "", + "", + "" + html.EscapeString(workspaceID) + " - Proofkit workspace", + "", + "

Proofkit semantic workspace

" + html.EscapeString(workspaceID) + "

Authority boundary

    ", + "
    ", + "
    ", + "", + "\n", + }, "") +} diff --git a/internal/command/requirementbrowser/workspace_handoff_boundary_test.go b/internal/command/requirementbrowser/workspace_handoff_boundary_test.go new file mode 100644 index 0000000..36ea0a1 --- /dev/null +++ b/internal/command/requirementbrowser/workspace_handoff_boundary_test.go @@ -0,0 +1,334 @@ +package requirementbrowser + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" +) + +func TestOneShotHandoffTrustBoundaryRejectsWithoutTerminalCommit(t *testing.T) { + for _, item := range []struct { + name string + origin string + capability string + contentType string + }{ + {name: "missing origin", contentType: "application/json"}, + {name: "foreign origin", origin: "https://attacker.invalid", contentType: "application/json"}, + {name: "missing capability", origin: "expected", contentType: "application/json"}, + {name: "wrong capability", origin: "expected", capability: strings.Repeat("A", 43), contentType: "application/json"}, + {name: "wrong content type", origin: "expected", capability: "expected", contentType: "text/plain"}, + } { + t.Run(item.name, func(t *testing.T) { + handle, capability := startWorkspaceTestServer(t, workspaceFixture(t), true) + origin := item.origin + if origin == "expected" { + origin = strings.TrimSuffix(handle.URL, "/") + } + providedCapability := item.capability + if providedCapability == "expected" { + providedCapability = capability + } + request := workspaceHandoffRequest(t, handle.URL, origin, item.contentType, providedCapability) + response, err := http.DefaultClient.Do(request) + if err != nil { + t.Fatal(err) + } + _ = response.Body.Close() + if response.StatusCode != http.StatusForbidden { + t.Fatalf("rejected handoff status=%d, want forbidden", response.StatusCode) + } + valid := postWorkspaceHandoff(t, handle.URL, capability) + _ = valid.Body.Close() + if valid.StatusCode != http.StatusOK { + t.Fatalf("valid handoff after rejection status=%d, want success", valid.StatusCode) + } + select { + case <-handle.Handoff: + case <-time.After(time.Second): + t.Fatal("valid handoff did not own the terminal state") + } + }) + } +} + +func TestOneShotConcurrentHandoffsHaveExactlyOneWinner(t *testing.T) { + handle, capability := startWorkspaceTestServer(t, workspaceFixture(t), true) + start := make(chan struct{}) + statuses := make(chan int, 2) + errors := make(chan error, 2) + var workers sync.WaitGroup + for range 2 { + workers.Add(1) + go func() { + defer workers.Done() + <-start + request := workspaceHandoffRequest(t, handle.URL, strings.TrimSuffix(handle.URL, "/"), "application/json", capability) + response, err := http.DefaultClient.Do(request) + if err != nil { + errors <- err + return + } + _ = response.Body.Close() + statuses <- response.StatusCode + }() + } + close(start) + workers.Wait() + close(statuses) + close(errors) + for err := range errors { + t.Fatal(err) + } + counts := map[int]int{} + for status := range statuses { + counts[status]++ + } + if counts[http.StatusOK] != 1 || counts[http.StatusConflict] != 1 { + t.Fatalf("concurrent handoff statuses=%v, want one success and one conflict", counts) + } + select { + case <-handle.Handoff: + case <-time.After(time.Second): + t.Fatal("concurrent handoff winner was not published") + } +} + +func TestTerminalArbiterLinearizesHandoffAgainstTimeout(t *testing.T) { + for iteration := 0; iteration < 100; iteration++ { + arbiter := newTerminalArbiter() + start := make(chan struct{}) + results := make(chan bool, 2) + for _, state := range []string{"submitted", "expired"} { + state := state + go func() { + <-start + results <- arbiter.TryCommit(map[string]any{"state": state}) + }() + } + close(start) + if first, second := <-results, <-results; first == second { + t.Fatalf("iteration %d produced %v/%v, want exactly one terminal winner", iteration, first, second) + } + if packet := <-arbiter.packets; packet["state"] != "submitted" && packet["state"] != "expired" { + t.Fatalf("iteration %d published invalid terminal state: %#v", iteration, packet) + } + } +} + +func TestHandoffSupportsTheAdmittedTreeDepthDomain(t *testing.T) { + for _, nodeCount := range []int{256, 257, 512} { + t.Run(fmt.Sprint(nodeCount), func(t *testing.T) { + workspace, _, err := buildWorkspace(deepWorkspaceFixture(t, nodeCount)) + if err != nil { + t.Fatal(err) + } + request := httptest.NewRequest(http.MethodPost, "/api/v1/handoff", strings.NewReader(workspaceHandoffBody)) + packet, err := buildHandoffPacket(request, workspace) + if err != nil { + t.Fatalf("handoff rejected admitted tree depth %d: %v", nodeCount, err) + } + tree := packet["context"].(map[string]any)["projections"].(map[string]any)["specTree"].(map[string]any) + if got := len(tree["nodes"].([]any)); got != nodeCount { + t.Fatalf("handoff tree nodes=%d, want %d", got, nodeCount) + } + }) + } +} + +func TestHandoffRetainsAncestorClosureAcrossDeepBranches(t *testing.T) { + workspace, _, err := buildWorkspace(branchedWorkspaceFixture(t, 300)) + if err != nil { + t.Fatal(err) + } + body := `{"annotations":[{"anchorId":"requirement:REQ-CONSUMER-001:invariant","exactQuote":"preserves","startCodePoint":11,"endCodePoint":20,"question":"Does this remain true?"},{"anchorId":"requirement:REQ-CONSUMER-002:invariant","exactQuote":"preserves","startCodePoint":11,"endCodePoint":20,"question":"Does this remain true?"}]}` + request := httptest.NewRequest(http.MethodPost, "/api/v1/handoff", strings.NewReader(body)) + packet, err := buildHandoffPacket(request, workspace) + if err != nil { + t.Fatalf("handoff rejected multi-branch ancestor closure: %v", err) + } + tree := packet["context"].(map[string]any)["projections"].(map[string]any)["specTree"].(map[string]any) + if got := len(tree["nodes"].([]any)); got != 601 { + t.Fatalf("handoff tree nodes=%d, want complete 601-node branch closure", got) + } +} + +func startWorkspaceTestServer(t *testing.T, fixture map[string]any, oneShot bool) (ServerHandle, string) { + t.Helper() + mode := "browse" + if oneShot { + mode = "one-shot-question" + } + handle, err := StartServer(fixture, Options{Host: "127.0.0.1", Port: 0, PortSet: true, SessionMode: mode, View: "workspace"}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + _ = handle.Close(ctx) + }) + response, err := http.Get(handle.URL) + if err != nil { + t.Fatal(err) + } + body, _ := io.ReadAll(response.Body) + _ = response.Body.Close() + match := capabilityPattern.FindSubmatch(body) + if len(match) != 2 { + t.Fatal("workspace capability missing") + } + return handle, string(match[1]) +} + +func postWorkspaceHandoff(t *testing.T, url, capability string) *http.Response { + t.Helper() + request := workspaceHandoffRequest(t, url, strings.TrimSuffix(url, "/"), "application/json", capability) + response, err := http.DefaultClient.Do(request) + if err != nil { + t.Fatal(err) + } + return response +} + +const workspaceHandoffBody = `{"annotations":[{"anchorId":"requirement:REQ-CONSUMER-001:invariant","exactQuote":"preserves","startCodePoint":11,"endCodePoint":20,"question":"Does this remain true?"}]}` + +func workspaceHandoffRequest(t *testing.T, url, origin, contentType, capability string) *http.Request { + t.Helper() + request, err := http.NewRequest(http.MethodPost, url+"api/v1/handoff", strings.NewReader(workspaceHandoffBody)) + if err != nil { + t.Fatal(err) + } + if origin != "" { + request.Header.Set("Origin", origin) + } + if contentType != "" { + request.Header.Set("Content-Type", contentType) + } + if capability != "" { + request.Header.Set("X-Proofkit-Browser-Capability", capability) + } + return request +} + +func deepWorkspaceFixture(t *testing.T, nodeCount int) map[string]any { + t.Helper() + fixture := workspaceFixture(t) + contextValue := fixture["context"].(map[string]any) + tree := contextValue["projections"].(map[string]any)["specTree"].(map[string]any) + nodes := make([]any, nodeCount) + edges := make([]any, 0, nodeCount-1) + for index := 0; index < nodeCount; index++ { + nodeID := fmt.Sprintf("spec.%04d", index) + role := "overview" + sourceID := fmt.Sprintf("consumer.overview.%04d", index) + if index == nodeCount-1 { + role = "requirements" + sourceID = "consumer.requirements" + } + kind := "module_spec" + if index == 0 { + kind = "meta_spec" + } + nodes[index] = map[string]any{ + "callerAnnotations": []any{}, "displayOrder": json.Number(fmt.Sprint(index + 1)), "label": nodeID, + "nodeId": nodeID, "nodeKind": kind, "sourceRefs": []any{map[string]any{ + "sourceId": sourceID, "sourceRefId": nodeID + ".source", "sourceRefKind": "source_id", "sourceRole": role, + }}, + } + if index > 0 { + edges = append(edges, map[string]any{"childNodeId": nodeID, "parentNodeId": fmt.Sprintf("spec.%04d", index-1)}) + } + } + tree["rootNodeId"] = "spec.0000" + tree["nodes"] = nodes + tree["edges"] = edges + contextValue["sources"].([]any)[0].(map[string]any)["nodeId"] = fmt.Sprintf("spec.%04d", nodeCount-1) + resignWorkspaceSnapshot(t, contextValue) + return fixture +} + +func branchedWorkspaceFixture(t *testing.T, branchDepth int) map[string]any { + t.Helper() + fixture := workspaceFixture(t) + contextValue := fixture["context"].(map[string]any) + projections := contextValue["projections"].(map[string]any) + firstSource := projections["requirementSources"].([]any)[0].(map[string]any) + secondSource := map[string]any{ + "schemaVersion": json.Number("1"), + "sourceId": "consumer.requirements.second", + "specPackagePath": "docs/specs/consumer-second", + "overviewPath": "docs/specs/consumer-second/overview.md", + "requirementsPath": "docs/specs/consumer-second/requirements.v1.json", + "nonClaims": []any{"Consumer source does not approve merge."}, + "requirements": []any{map[string]any{ + "requirementId": "REQ-CONSUMER-002", + "ownerId": "consumer.owner", + "claimLevel": "blocking", + "riskClass": "high", + "invariant": "The system preserves semantic identity.", + "proofBindingRefs": []any{"proofkit/requirement-bindings.json"}, + "nonClaimRefs": []any{"NC-CONSUMER-002"}, + "nonClaims": []any{"This requirement does not approve merge."}, + "lifecycle": map[string]any{"state": "active", "replacementRequirementIds": []any{}, "evidenceRefs": []any{}}, + "updatePolicy": map[string]any{"reviewOwnerId": "consumer.owner", "requiresImpactDeclaration": true, "requiresProofBindingReview": true}, + }}, + } + projections["requirementSources"] = []any{firstSource, secondSource} + + tree := projections["specTree"].(map[string]any) + nodes := []any{map[string]any{ + "callerAnnotations": []any{}, "displayOrder": json.Number("1"), "label": "Root", "nodeId": "spec.root", "nodeKind": "meta_spec", + "sourceRefs": []any{map[string]any{"sourceId": "consumer.overview", "sourceRefId": "spec.root.overview", "sourceRefKind": "source_id", "sourceRole": "overview"}}, + }} + edges := []any{} + leafIDs := make([]string, 2) + for branch := 0; branch < 2; branch++ { + parentID := "spec.root" + for depth := 1; depth <= branchDepth; depth++ { + nodeID := fmt.Sprintf("spec.branch-%d.%03d", branch+1, depth) + displayOrder := branch*branchDepth + depth + 1 + role := "overview" + sourceID := fmt.Sprintf("consumer.overview.branch-%d.%03d", branch+1, depth) + if depth == branchDepth { + role = "requirements" + if branch == 0 { + sourceID = "consumer.requirements" + } else { + sourceID = "consumer.requirements.second" + } + leafIDs[branch] = nodeID + } + nodes = append(nodes, map[string]any{ + "callerAnnotations": []any{}, "displayOrder": json.Number(fmt.Sprint(displayOrder)), "label": nodeID, "nodeId": nodeID, "nodeKind": "module_spec", + "sourceRefs": []any{map[string]any{"sourceId": sourceID, "sourceRefId": nodeID + ".source", "sourceRefKind": "source_id", "sourceRole": role}}, + }) + edges = append(edges, map[string]any{"childNodeId": nodeID, "parentNodeId": parentID}) + parentID = nodeID + } + } + tree["nodes"] = nodes + tree["edges"] = edges + + rawSources := contextValue["sources"].([]any) + firstMetadata := rawSources[0].(map[string]any) + firstMetadata["nodeId"] = leafIDs[0] + secondMetadata := map[string]any{ + "currentDigest": "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "kind": "requirement_source", + "nodeId": leafIDs[1], + "path": "docs/specs/consumer-second/requirements.v1.json", + "sourceRef": "consumer.requirements.second", + "sourceRole": "requirements", + } + contextValue["sources"] = []any{firstMetadata, secondMetadata, rawSources[1]} + resignWorkspaceSnapshot(t, contextValue) + return fixture +} diff --git a/internal/command/requirementbrowser/workspace_test.go b/internal/command/requirementbrowser/workspace_test.go new file mode 100644 index 0000000..c4c6569 --- /dev/null +++ b/internal/command/requirementbrowser/workspace_test.go @@ -0,0 +1,429 @@ +package requirementbrowser + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "regexp" + "strings" + "testing" + "time" + + "github.com/research-engineering/agentic-proofkit/internal/command/requirementcontext" + "github.com/research-engineering/agentic-proofkit/internal/command/requirementsourceadmission" + "github.com/research-engineering/agentic-proofkit/internal/command/requirementspectree" + "github.com/research-engineering/agentic-proofkit/internal/kernel/admission" + "github.com/research-engineering/agentic-proofkit/internal/kernel/digest" + "github.com/research-engineering/agentic-proofkit/internal/kernel/stablejson" +) + +var capabilityPattern = regexp.MustCompile(`name="proofkit-browser-capability" content="([A-Za-z0-9_-]{43})"`) + +func TestWorkspaceServerEnforcesCapabilityAndBuildsSourceBoundHandoff(t *testing.T) { + handle, err := StartServer(workspaceFixture(t), Options{Host: "127.0.0.1", Port: 0, PortSet: true, View: "workspace"}) + if err != nil { + t.Fatalf("StartServer() error = %v", err) + } + defer func() { + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + _ = handle.Close(ctx) + }() + indexResponse, err := http.Get(handle.URL) + if err != nil { + t.Fatalf("GET workspace: %v", err) + } + indexBody, _ := io.ReadAll(indexResponse.Body) + _ = indexResponse.Body.Close() + if indexResponse.StatusCode != http.StatusOK || !strings.Contains(indexResponse.Header.Get("Content-Security-Policy"), "default-src 'none'") { + t.Fatalf("workspace security response status=%d headers=%v", indexResponse.StatusCode, indexResponse.Header) + } + match := capabilityPattern.FindSubmatch(indexBody) + if len(match) != 2 { + t.Fatalf("workspace capability missing from shell") + } + capability := string(match[1]) + unauthorized, _ := http.Get(handle.URL + "api/v1/manifest") + if unauthorized.StatusCode != http.StatusForbidden { + t.Fatalf("unauthorized session status=%d", unauthorized.StatusCode) + } + _ = unauthorized.Body.Close() + sessionRequest, _ := http.NewRequest(http.MethodGet, handle.URL+"api/v1/manifest", nil) + sessionRequest.Header.Set("X-Proofkit-Browser-Capability", capability) + sessionResponse, err := http.DefaultClient.Do(sessionRequest) + if err != nil { + t.Fatal(err) + } + if sessionResponse.StatusCode != http.StatusOK { + t.Fatalf("session status=%d", sessionResponse.StatusCode) + } + manifestBody, _ := io.ReadAll(sessionResponse.Body) + _ = sessionResponse.Body.Close() + manifestValue, err := admission.DecodeJSON(bytes.NewReader(manifestBody), int64(len(manifestBody))) + if err != nil { + t.Fatal(err) + } + manifest := manifestValue.(map[string]any) + if manifest["baselineVerification"] != "unverified" || manifest["snapshotId"] != handle.SnapshotID { + t.Fatalf("workspace manifest lost baseline identity: %#v", manifest) + } + handoff := `{"annotations":[{"anchorId":"requirement:REQ-CONSUMER-001:invariant","exactQuote":"preserves","startCodePoint":11,"endCodePoint":20,"question":"Does this remain true?"}]}` + handoffRequest, _ := http.NewRequest(http.MethodPost, handle.URL+"api/v1/handoff", strings.NewReader(handoff)) + handoffRequest.Header.Set("Origin", strings.TrimSuffix(handle.URL, "/")) + handoffRequest.Header.Set("Content-Type", "application/json") + handoffRequest.Header.Set("X-Proofkit-Browser-Capability", capability) + handoffResponse, err := http.DefaultClient.Do(handoffRequest) + if err != nil { + t.Fatal(err) + } + body, _ := io.ReadAll(handoffResponse.Body) + _ = handoffResponse.Body.Close() + if handoffResponse.StatusCode != http.StatusOK || !bytes.Contains(body, []byte(`"handoffKind": "proofkit.requirement-browser-question"`)) { + t.Fatalf("handoff status=%d body=%s", handoffResponse.StatusCode, body) + } + packet, err := admission.DecodeJSON(bytes.NewReader(body), int64(len(body))) + if err != nil { + t.Fatal(err) + } + packetRecord := packet.(map[string]any) + annotation := packetRecord["annotations"].([]any)[0].(map[string]any) + anchor := annotation["anchor"].(map[string]any) + if anchor["jsonPointer"] != "/projections/requirementSources/0/requirements/0/invariant" || anchor["sourceDigest"] != "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" || packetRecord["snapshotRefs"].([]any)[0].(map[string]any)["snapshotId"] != handle.SnapshotID { + t.Fatalf("handoff lost source identity: %#v", packetRecord) + } + spaceQuote := `{"annotations":[{"anchorId":"requirement:REQ-CONSUMER-001:invariant","exactQuote":" system","startCodePoint":3,"endCodePoint":10,"question":"Does whitespace remain source-bound?"}]}` + spaceRequest, _ := http.NewRequest(http.MethodPost, handle.URL+"api/v1/handoff", strings.NewReader(spaceQuote)) + spaceRequest.Header.Set("Origin", strings.TrimSuffix(handle.URL, "/")) + spaceRequest.Header.Set("Content-Type", "application/json") + spaceRequest.Header.Set("X-Proofkit-Browser-Capability", capability) + spaceResponse, err := http.DefaultClient.Do(spaceRequest) + if err != nil { + t.Fatal(err) + } + defer spaceResponse.Body.Close() + if spaceResponse.StatusCode != http.StatusOK { + t.Fatalf("source-exact whitespace quote status=%d", spaceResponse.StatusCode) + } +} + +func TestWorkspaceAssetsHaveExactSecureHTTPContract(t *testing.T) { + handle, err := StartServer(workspaceFixture(t), Options{Host: "127.0.0.1", Port: 0, PortSet: true, View: "workspace"}) + if err != nil { + t.Fatalf("StartServer() error = %v", err) + } + t.Cleanup(func() { _ = handle.Close(t.Context()) }) + client := http.Client{Timeout: 5 * time.Second} + for _, item := range []struct { + method string + path string + wantAllow string + wantBody []byte + wantType string + wantStatus int + wantSuccess bool + }{ + {method: http.MethodGet, path: "assets/workspace.js", wantBody: workspaceJavaScript, wantType: "text/javascript; charset=utf-8", wantStatus: http.StatusOK, wantSuccess: true}, + {method: http.MethodHead, path: "assets/workspace.js", wantBody: []byte{}, wantType: "text/javascript; charset=utf-8", wantStatus: http.StatusOK, wantSuccess: true}, + {method: http.MethodPost, path: "assets/workspace.js", wantAllow: "GET, HEAD", wantBody: []byte("method not allowed\n"), wantStatus: http.StatusMethodNotAllowed}, + {method: http.MethodGet, path: "assets/workspace.js/", wantBody: []byte("not found\n"), wantStatus: http.StatusNotFound}, + {method: http.MethodGet, path: "assets/selection-authority.js", wantBody: selectionAuthorityJavaScript, wantType: "text/javascript; charset=utf-8", wantStatus: http.StatusOK, wantSuccess: true}, + {method: http.MethodHead, path: "assets/selection-authority.js", wantBody: []byte{}, wantType: "text/javascript; charset=utf-8", wantStatus: http.StatusOK, wantSuccess: true}, + {method: http.MethodPost, path: "assets/selection-authority.js", wantAllow: "GET, HEAD", wantBody: []byte("method not allowed\n"), wantStatus: http.StatusMethodNotAllowed}, + {method: http.MethodGet, path: "assets/selection-authority.js/", wantBody: []byte("not found\n"), wantStatus: http.StatusNotFound}, + {method: http.MethodGet, path: "assets/workspace.css", wantBody: workspaceCSS, wantType: "text/css; charset=utf-8", wantStatus: http.StatusOK, wantSuccess: true}, + {method: http.MethodHead, path: "assets/workspace.css", wantBody: []byte{}, wantType: "text/css; charset=utf-8", wantStatus: http.StatusOK, wantSuccess: true}, + {method: http.MethodPost, path: "assets/workspace.css", wantAllow: "GET, HEAD", wantBody: []byte("method not allowed\n"), wantStatus: http.StatusMethodNotAllowed}, + {method: http.MethodGet, path: "assets/workspace.css/", wantBody: []byte("not found\n"), wantStatus: http.StatusNotFound}, + } { + t.Run(item.method+" "+item.path, func(t *testing.T) { + request, err := http.NewRequest(item.method, handle.URL+item.path, nil) + if err != nil { + t.Fatal(err) + } + response, err := client.Do(request) + if err != nil { + t.Fatal(err) + } + body, readErr := io.ReadAll(response.Body) + _ = response.Body.Close() + if readErr != nil { + t.Fatal(readErr) + } + if response.StatusCode != item.wantStatus || !bytes.Equal(body, item.wantBody) || response.Header.Get("allow") != item.wantAllow { + t.Fatalf("asset response status=%d allow=%q body=%q", response.StatusCode, response.Header.Get("allow"), body) + } + if item.wantSuccess { + assertWorkspaceAssetSecurityHeaders(t, response.Header, item.wantType) + } + }) + } +} + +func assertWorkspaceAssetSecurityHeaders(t *testing.T, header http.Header, contentType string) { + t.Helper() + want := map[string]string{ + "cache-control": "no-store", + "content-security-policy": "default-src 'none'; script-src 'self'; style-src 'self'; connect-src 'self'; img-src 'self'; worker-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'", + "content-type": contentType, + "cross-origin-opener-policy": "same-origin", + "cross-origin-resource-policy": "same-origin", + "permissions-policy": "accelerometer=(), camera=(), geolocation=(), gyroscope=(), microphone=(), payment=(), usb=()", + "referrer-policy": "no-referrer", + "x-content-type-options": "nosniff", + } + for name, value := range want { + if header.Get(name) != value { + t.Fatalf("header %s = %q, want %q", name, header.Get(name), value) + } + } +} + +func TestWorkspaceDefaultUsesFreshEphemeralOrigin(t *testing.T) { + first, err := StartServer(workspaceFixture(t), Options{Host: "127.0.0.1", View: "workspace"}) + if err != nil { + t.Fatal(err) + } + second, err := StartServer(workspaceFixture(t), Options{Host: "127.0.0.1", View: "workspace"}) + if err != nil { + _ = first.Close(t.Context()) + t.Fatal(err) + } + t.Cleanup(func() { + _ = first.Close(t.Context()) + _ = second.Close(t.Context()) + }) + if first.Port == 0 || second.Port == 0 || first.Port == second.Port { + t.Fatalf("default browser origins are not fresh: first=%s second=%s", first.URL, second.URL) + } +} + +func TestWorkspaceRejectsGraphInputForAnotherContext(t *testing.T) { + fixture := workspaceFixture(t) + encoded, err := stablejson.Marshal(fixture["context"]) + if err != nil { + t.Fatal(err) + } + decoded, err := admission.DecodeJSON(bytes.NewReader(encoded), int64(len(encoded))) + if err != nil { + t.Fatal(err) + } + otherContext := decoded.(map[string]any) + otherContext["catalogId"] = "consumer.other-context" + resignWorkspaceSnapshot(t, otherContext) + fixture["graphInput"] = map[string]any{"context": otherContext, "graphId": "consumer.workspace.graph", "schemaVersion": json.Number("1")} + if _, _, err := buildWorkspace(fixture); err == nil { + t.Fatal("buildWorkspace accepted graph input for a different context") + } +} + +func TestWorkspaceAdmitsMultiFieldSemanticDiffFromProducer(t *testing.T) { + fixture := workspaceFixture(t) + current := fixture["context"].(map[string]any) + encoded, err := stablejson.Marshal(current) + if err != nil { + t.Fatal(err) + } + decoded, err := admission.DecodeJSON(bytes.NewReader(encoded), int64(len(encoded))) + if err != nil { + t.Fatal(err) + } + base := decoded.(map[string]any) + requirement := base["projections"].(map[string]any)["requirementSources"].([]any)[0].(map[string]any)["requirements"].([]any)[0].(map[string]any) + requirement["invariant"] = "The system preserved the previous semantic identity." + requirement["riskClass"] = "medium" + resignWorkspaceSnapshot(t, base) + fixture["diffInput"] = map[string]any{"baseContext": base, "currentContext": current, "diffId": "consumer.workspace.diff", "schemaVersion": json.Number("1")} + + workspace, _, err := buildWorkspace(fixture) + if err != nil { + t.Fatalf("workspace rejected producer-owned multi-field diff: %v", err) + } + if len(workspace.Diff["changes"].([]any)) != 2 { + t.Fatalf("workspace diff = %#v, want two changes", workspace.Diff) + } +} + +func TestOneShotTerminalStateIsLinearizedAfterWinnerIsDrained(t *testing.T) { + handle, capability := startWorkspaceTestServer(t, workspaceFixture(t), true) + first := postWorkspaceHandoff(t, handle.URL, capability) + if first.StatusCode != http.StatusOK { + t.Fatalf("first handoff status=%d", first.StatusCode) + } + _ = first.Body.Close() + <-handle.Handoff + second := postWorkspaceHandoff(t, handle.URL, capability) + if second.StatusCode != http.StatusConflict { + t.Fatalf("second handoff status=%d, want conflict", second.StatusCode) + } + _ = second.Body.Close() +} + +func TestWorkspaceHandoffRetainsLifecycleReplacementClosure(t *testing.T) { + fixture := workspaceFixture(t) + contextValue := fixture["context"].(map[string]any) + source := contextValue["projections"].(map[string]any)["requirementSources"].([]any)[0].(map[string]any) + requirements := source["requirements"].([]any) + first := requirements[0].(map[string]any) + first["claimLevel"] = "advisory" + first["lifecycle"] = map[string]any{"state": "superseded", "replacementRequirementIds": []any{"REQ-CONSUMER-002"}, "evidenceRefs": []any{"consumer.lifecycle.migration"}} + second := map[string]any{"requirementId": "REQ-CONSUMER-002", "ownerId": "consumer.owner", "claimLevel": "blocking", "riskClass": "high", "invariant": "The replacement preserves semantic identity.", "proofBindingRefs": []any{"proofkit/requirement-bindings.json"}, "nonClaimRefs": []any{"NC-CONSUMER-002"}, "nonClaims": []any{"This replacement requirement does not approve merge."}, "lifecycle": map[string]any{"state": "active", "replacementRequirementIds": []any{}, "evidenceRefs": []any{}}, "updatePolicy": map[string]any{"reviewOwnerId": "consumer.owner", "requiresImpactDeclaration": true, "requiresProofBindingReview": true}} + source["requirements"] = append(requirements, second) + resignWorkspaceSnapshot(t, contextValue) + handle, capability := startWorkspaceTestServer(t, fixture, false) + response := postWorkspaceHandoff(t, handle.URL, capability) + body, _ := io.ReadAll(response.Body) + _ = response.Body.Close() + if response.StatusCode != http.StatusOK { + t.Fatalf("handoff status=%d body=%s", response.StatusCode, body) + } + decoded, err := admission.DecodeJSON(bytes.NewReader(body), int64(len(body))) + if err != nil { + t.Fatal(err) + } + contextSlice := decoded.(map[string]any)["context"].(map[string]any) + selected := contextSlice["projections"].(map[string]any)["requirementSources"].([]any)[0].(map[string]any)["requirements"].([]any) + if len(selected) != 2 { + t.Fatalf("handoff lifecycle closure=%#v, want selected requirement plus replacement", selected) + } +} + +func TestWorkspaceCapabilityReplacementIsConfinedToBootstrapMeta(t *testing.T) { + fixture := workspaceFixture(t) + fixture["workspaceId"] = workspaceCapabilityPlaceholder + handle, capability := startWorkspaceTestServer(t, fixture, false) + response, err := http.Get(handle.URL) + if err != nil { + t.Fatal(err) + } + body, _ := io.ReadAll(response.Body) + _ = response.Body.Close() + text := string(body) + if strings.Count(text, capability) != 1 || !strings.Contains(text, "

    "+workspaceCapabilityPlaceholder+"

    ") { + t.Fatalf("capability replacement escaped bootstrap boundary: %s", text) + } +} + +func TestOneShotTimeoutEmitsOneCompactTerminalPacket(t *testing.T) { + var stdout bytes.Buffer + err := Serve(t.Context(), workspaceFixture(t), Options{Host: "127.0.0.1", Port: 0, PortSet: true, SessionMode: "one-shot-question", SessionTimeout: 10 * time.Millisecond, View: "workspace"}, &stdout) + if !errors.Is(err, ErrOneShotTerminal) { + t.Fatalf("Serve() error = %v, want terminal state", err) + } + if strings.Count(stdout.String(), "\n") != 1 || strings.Contains(stdout.String(), "\n ") || !strings.Contains(stdout.String(), `"state":"expired"`) { + t.Fatalf("unexpected one-shot packet: %q", stdout.String()) + } +} + +func TestGraphWindowRetainsCrossPageRelationsWithEndpointClosure(t *testing.T) { + nodes := make([]any, 257) + for index := range nodes { + nodes[index] = map[string]any{"nodeId": fmt.Sprintf("node.%03d", index)} + } + edge := map[string]any{"edgeId": "edge.cross-page", "fromNodeId": "node.255", "toNodeId": "node.256"} + fragment, state := graphWindow(map[string]any{"edges": []any{edge}, "graphId": "consumer.graph", "nodes": nodes}, projectionQuery{MaxEdges: 10, MaxRecords: 256}) + if state != "complete" || fragment["selectedEdgeCount"] != 1 || fragment["primaryNodeCount"] != 256 || fragment["boundaryNodeCount"] != 1 { + t.Fatalf("cross-page graph fragment lost relation closure: %#v", fragment) + } + selected := fragment["nodes"].([]any) + if selected[len(selected)-1].(map[string]any)["nodeId"] != "node.256" { + t.Fatalf("cross-page endpoint missing: %#v", selected[len(selected)-1]) + } + if fragment["availableNodeCount"] != fragment["selectedNodeCount"].(int)+fragment["omittedNodeCount"].(int) || fragment["omittedNodeCount"] != 0 || fragment["omittedPrimaryNodeCount"] != 1 { + t.Fatalf("graph node accounting is not set-consistent: %#v", fragment) + } +} + +func TestRequirementWindowMakesEveryBoundedPageReachable(t *testing.T) { + requirements := make([]any, 257) + for index := range requirements { + requirements[index] = map[string]any{"requirementId": fmt.Sprintf("REQ-%03d", index)} + } + first, firstState := requirementWindow(requirements, projectionQuery{MaxRecords: 256}) + second, secondState := requirementWindow(requirements, projectionQuery{MaxRecords: 256, Offset: 256}) + if firstState != "partial_with_omissions" || first["selectedRequirementCount"] != 256 || secondState != "partial_with_omissions" || second["selectedRequirementCount"] != 1 { + t.Fatalf("requirement pagination is not omission-honest: first=%#v second=%#v", first, second) + } + last := second["requirements"].([]any)[0].(map[string]any)["requirementId"] + if last != "REQ-256" { + t.Fatalf("last requirement is unreachable: %v", last) + } +} + +func workspaceFixture(t *testing.T) map[string]any { + return workspaceFixtureWithInvariant(t, "The system preserves semantic identity. "+strings.Repeat("x", maxHandoffAnnotations+1)) +} + +func workspaceFixtureWithInvariant(t *testing.T, invariant string) map[string]any { + t.Helper() + projections := map[string]any{ + "requirementSources": []any{map[string]any{ + "schemaVersion": json.Number("1"), + "sourceId": "consumer.requirements", + "specPackagePath": "docs/specs/consumer", + "overviewPath": "docs/specs/consumer/overview.md", + "requirementsPath": "docs/specs/consumer/requirements.v1.json", + "nonClaims": []any{"Consumer source does not approve merge."}, + "requirements": []any{map[string]any{ + "requirementId": "REQ-CONSUMER-001", + "ownerId": "consumer.owner", + "claimLevel": "blocking", + "riskClass": "high", + "invariant": invariant, + "proofBindingRefs": []any{"proofkit/requirement-bindings.json"}, + "nonClaimRefs": []any{"NC-CONSUMER-001"}, + "nonClaims": []any{"This requirement does not approve merge."}, + "lifecycle": map[string]any{"state": "active", "replacementRequirementIds": []any{}, "evidenceRefs": []any{}}, + "updatePolicy": map[string]any{"reviewOwnerId": "consumer.owner", "requiresImpactDeclaration": true, "requiresProofBindingReview": true}, + }}, + }}, + "specTree": map[string]any{"schemaVersion": json.Number("2"), "treeId": "consumer.spec-tree", "rootNodeId": "spec.root", "callerAnnotations": []any{}, "edges": []any{}, "overlays": []any{}, "nodes": []any{map[string]any{"nodeId": "spec.root", "nodeKind": "meta_spec", "label": "Root", "displayOrder": json.Number("1"), "callerAnnotations": []any{}, "sourceRefs": []any{map[string]any{"sourceRefId": "spec.root.requirements", "sourceRefKind": "source_id", "sourceRole": "requirements", "sourceId": "consumer.requirements"}}}}}, + } + sources := []requirementcontext.Source{{CurrentDigest: "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", Kind: "requirement_source", NodeID: "spec.root", Path: "docs/specs/consumer/requirements.v1.json", SourceRef: "consumer.requirements", SourceRole: "requirements"}, {CurrentDigest: "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", Kind: "spec_tree", Path: "proofkit/spec-tree.json", SourceRef: "spec_tree:consumer.spec-tree"}} + identity := map[string]any{"catalogId": "consumer.context", "projections": projections, "sources": []any{map[string]any{"currentDigest": sources[0].CurrentDigest, "expectedDigest": "", "kind": sources[0].Kind, "nodeId": sources[0].NodeID, "path": sources[0].Path, "sourceRef": sources[0].SourceRef, "sourceRole": sources[0].SourceRole}, map[string]any{"currentDigest": sources[1].CurrentDigest, "expectedDigest": "", "kind": sources[1].Kind, "path": sources[1].Path, "sourceRef": sources[1].SourceRef}}} + encoded, err := stablejson.Marshal(identity) + if err != nil { + t.Fatal(err) + } + contextValue := requirementcontext.SnapshotValue(requirementcontext.Snapshot{BaselineVerification: "unverified", CatalogID: "consumer.context", Projections: projections, SnapshotID: digest.SHA256TextRef(string(encoded)), Sources: sources}) + return map[string]any{"schemaVersion": json.Number("1"), "workspaceId": "consumer.workspace", "context": contextValue} +} + +func resignWorkspaceSnapshot(t *testing.T, contextValue map[string]any) { + t.Helper() + projections := contextValue["projections"].(map[string]any) + canonicalSources := make([]any, 0, len(projections["requirementSources"].([]any))) + for _, raw := range projections["requirementSources"].([]any) { + result, err := requirementsourceadmission.Evaluate(raw) + if err != nil || result.ExitCode != 0 { + t.Fatalf("canonicalize requirement source: exit=%d err=%v failures=%v", result.ExitCode, err, result.Failures) + } + canonicalSources = append(canonicalSources, requirementsourceadmission.SourceValue(result.Source)) + } + projections["requirementSources"] = canonicalSources + treeResult, err := requirementspectree.Evaluate(projections["specTree"]) + if err != nil || treeResult.ExitCode != 0 { + t.Fatalf("canonicalize specification tree: exit=%d err=%v report=%#v", treeResult.ExitCode, err, treeResult.Report) + } + projections["specTree"] = requirementspectree.TreeValue(treeResult.Tree) + rawSources := contextValue["sources"].([]any) + identitySources := make([]any, 0, len(rawSources)) + for _, raw := range rawSources { + source := raw.(map[string]any) + identity := map[string]any{"currentDigest": source["currentDigest"], "expectedDigest": "", "kind": source["kind"], "path": source["path"], "sourceRef": source["sourceRef"]} + for _, key := range []string{"expectedDigest", "nodeId", "sourceRole"} { + if source[key] != nil { + identity[key] = source[key] + } + } + identitySources = append(identitySources, identity) + } + encoded, err := stablejson.Marshal(map[string]any{"catalogId": contextValue["catalogId"], "projections": projections, "sources": identitySources}) + if err != nil { + t.Fatal(err) + } + contextValue["snapshotId"] = digest.SHA256TextRef(string(encoded)) +} diff --git a/internal/command/requirementcontext/compose.go b/internal/command/requirementcontext/compose.go new file mode 100644 index 0000000..064c326 --- /dev/null +++ b/internal/command/requirementcontext/compose.go @@ -0,0 +1,352 @@ +package requirementcontext + +import ( + "bytes" + "fmt" + "io" + "os" + "sort" + + "github.com/research-engineering/agentic-proofkit/internal/command/requirementbinding" + "github.com/research-engineering/agentic-proofkit/internal/command/requirementcoverageview" + "github.com/research-engineering/agentic-proofkit/internal/command/requirementsourceadmission" + "github.com/research-engineering/agentic-proofkit/internal/command/requirementspectree" + "github.com/research-engineering/agentic-proofkit/internal/kernel/admission" + "github.com/research-engineering/agentic-proofkit/internal/kernel/admit" + "github.com/research-engineering/agentic-proofkit/internal/kernel/digest" + "github.com/research-engineering/agentic-proofkit/internal/kernel/stablejson" +) + +const ( + maxSourceBytes = 4 << 20 + maxTotalBytes = 12 << 20 +) + +type catalogSource struct { + ExpectedDigest string + Kind string + NodeID string + Path string + SourceRef string +} + +func Compose(repoRoot string, raw any) (map[string]any, error) { + catalogID, entries, err := admitCatalog(raw) + if err != nil { + return nil, err + } + root, err := os.OpenRoot(repoRoot) + if err != nil { + return nil, fmt.Errorf("open repository root: %w", err) + } + defer root.Close() + projections := map[string]any{} + requirementSources := []any{} + requirementSourceIDs := map[string]struct{}{} + requirementIDs := map[string]struct{}{} + requirementSourceNodes := map[string]string{} + treeRequirementSourceIDs := map[string]struct{}{} + treeRequirementSourceNodes := map[string]map[string]struct{}{} + sources := make([]Source, 0, len(entries)) + totalBytes := 0 + for _, entry := range entries { + value, content, currentDigest, err := readCatalogSource(root, entry.Path) + if err != nil { + return nil, err + } + totalBytes += len(content) + if totalBytes > maxTotalBytes { + return nil, fmt.Errorf("requirement context sources exceed total byte limit") + } + if entry.ExpectedDigest != "" && entry.ExpectedDigest != currentDigest { + return nil, fmt.Errorf("requirement context expected digest mismatch") + } + switch entry.Kind { + case "spec_tree": + result, err := requirementspectree.Evaluate(value) + if err != nil || result.ExitCode != 0 { + return nil, fmt.Errorf("admit specification tree") + } + projections["specTree"] = requirementspectree.TreeValue(result.Tree) + entry.SourceRef = "spec_tree:" + result.Tree.TreeID + for _, node := range result.Tree.Nodes { + for _, ref := range node.SourceRefs { + if ref.SourceRole == "requirements" && ref.SourceID != "" { + treeRequirementSourceIDs[ref.SourceID] = struct{}{} + if treeRequirementSourceNodes[ref.SourceID] == nil { + treeRequirementSourceNodes[ref.SourceID] = map[string]struct{}{} + } + treeRequirementSourceNodes[ref.SourceID][node.NodeID] = struct{}{} + } + } + } + case "requirement_source": + result, err := requirementsourceadmission.Evaluate(value) + if err != nil || result.ExitCode != 0 { + return nil, fmt.Errorf("admit requirement source") + } + if _, exists := requirementSourceIDs[result.Source.SourceID]; exists { + return nil, fmt.Errorf("requirement context requirement source ids must be unique") + } + requirementSources = append(requirementSources, requirementsourceadmission.SourceValue(result.Source)) + entry.SourceRef = result.Source.SourceID + requirementSourceIDs[result.Source.SourceID] = struct{}{} + requirementSourceNodes[result.Source.SourceID] = entry.NodeID + for _, requirement := range result.Source.Requirements { + if _, exists := requirementIDs[requirement.RequirementID]; exists { + return nil, fmt.Errorf("requirement context requirement ids must be unique across sources") + } + requirementIDs[requirement.RequirementID] = struct{}{} + } + case "proof_binding": + result, err := requirementbinding.Build(value) + if err != nil || result.Record.State != "passed" { + return nil, fmt.Errorf("admit proof binding") + } + projections["proofBinding"] = requirementbinding.InputValue(result.Input) + entry.SourceRef = "proof_binding:" + result.Input.BindingID + case "coverage": + view, exitCode, err := requirementcoverageview.BuildJSON(value, requirementcoverageview.Options{}) + if err != nil || exitCode != 0 { + return nil, fmt.Errorf("admit coverage input") + } + admittedView, err := requirementcoverageview.AdmitOutput(view) + if err != nil { + return nil, fmt.Errorf("admit coverage output: %w", err) + } + projections["coverage"] = admittedView + entry.SourceRef = "coverage:" + admittedView["viewInputId"].(string) + } + sourceRole := "" + if entry.Kind == "requirement_source" { + sourceRole = "requirements" + } + sources = append(sources, Source{CurrentDigest: currentDigest, ExpectedDigest: entry.ExpectedDigest, Kind: entry.Kind, NodeID: entry.NodeID, Path: entry.Path, SourceRef: entry.SourceRef, SourceRole: sourceRole}) + } + if len(requirementSources) == 0 || projections["specTree"] == nil { + return nil, fmt.Errorf("requirement context requires a spec tree and at least one requirement source") + } + for sourceID := range requirementSourceIDs { + if _, ok := treeRequirementSourceIDs[sourceID]; !ok { + return nil, fmt.Errorf("requirement context source is not referenced by the specification tree") + } + if _, ok := treeRequirementSourceNodes[sourceID][requirementSourceNodes[sourceID]]; !ok { + return nil, fmt.Errorf("requirement context source node does not match the specification tree") + } + } + if proof, ok := projections["proofBinding"].(map[string]any); ok { + for _, key := range []string{"requirements", "bindings"} { + for _, rawValue := range proof[key].([]any) { + requirementID := rawValue.(map[string]any)["requirementId"].(string) + if _, exists := requirementIDs[requirementID]; !exists { + return nil, fmt.Errorf("requirement context proof binding references a requirement outside the context") + } + } + } + } + if coverage, ok := projections["coverage"].(map[string]any); ok { + for _, rawValue := range coverage["requirementCoverage"].([]any) { + requirementID := rawValue.(map[string]any)["requirementId"].(string) + if _, exists := requirementIDs[requirementID]; !exists { + return nil, fmt.Errorf("requirement context coverage references a requirement outside the context") + } + } + } + for sourceID := range treeRequirementSourceIDs { + if _, ok := requirementSourceIDs[sourceID]; !ok { + return nil, fmt.Errorf("requirement context specification tree references an unavailable requirement source") + } + } + sort.Slice(requirementSources, func(left, right int) bool { + return requirementSources[left].(map[string]any)["sourceId"].(string) < requirementSources[right].(map[string]any)["sourceId"].(string) + }) + sort.Slice(sources, func(left, right int) bool { return sources[left].SourceRef < sources[right].SourceRef }) + projections["requirementSources"] = requirementSources + verification := baselineVerification(sources) + snapshot := Snapshot{BaselineVerification: verification, CatalogID: catalogID, Projections: projections, Sources: sources} + identityValue := map[string]any{"catalogId": catalogID, "projections": projections, "sources": sourceIdentityValues(sources)} + encoded, err := stablejson.Marshal(identityValue) + if err != nil { + return nil, err + } + snapshot.SnapshotID = digest.SHA256TextRef(string(encoded)) + output := SnapshotValue(snapshot) + admitted, err := AdmitSnapshot(output) + if err != nil { + return nil, fmt.Errorf("self-admit composed requirement context: %w", err) + } + output = SnapshotValue(admitted) + encoded, err = stablejson.Marshal(output) + if err != nil { + return nil, err + } + if len(encoded) > maxSnapshotBytes { + return nil, fmt.Errorf("requirement context snapshot exceeds byte limit") + } + return output, nil +} + +func admitCatalog(raw any) (string, []catalogSource, error) { + record, ok := raw.(map[string]any) + if !ok { + return "", nil, fmt.Errorf("requirement context catalog must be an object") + } + if err := admit.KnownKeys(record, []string{"catalogId", "coverage", "proofBinding", "requirementSources", "schemaVersion", "specTree"}, "requirement context catalog"); err != nil { + return "", nil, err + } + if !admit.JSONNumberEquals(record["schemaVersion"], 1) { + return "", nil, fmt.Errorf("requirement context catalog schemaVersion must be 1") + } + catalogID, err := admit.RuleID(record["catalogId"], "requirement context catalogId") + if err != nil { + return "", nil, err + } + entries := []catalogSource{} + specTree, err := admitCatalogEntry(record["specTree"], "spec_tree", "spec-tree") + if err != nil { + return "", nil, err + } + entries = append(entries, specTree) + values, ok := record["requirementSources"].([]any) + if !ok || len(values) == 0 { + return "", nil, fmt.Errorf("requirement context catalog requirementSources must be a non-empty array") + } + for index, value := range values { + entry, err := admitCatalogEntry(value, "requirement_source", fmt.Sprintf("requirement-source-%d", index+1)) + if err != nil { + return "", nil, err + } + entries = append(entries, entry) + } + for _, optional := range []struct{ key, kind, ref string }{{"proofBinding", "proof_binding", "proof-binding"}, {"coverage", "coverage", "coverage"}} { + if record[optional.key] != nil { + entry, err := admitCatalogEntry(record[optional.key], optional.kind, optional.ref) + if err != nil { + return "", nil, err + } + entries = append(entries, entry) + } + } + seenPaths := map[string]struct{}{} + seenRefs := map[string]struct{}{} + for _, entry := range entries { + if _, exists := seenPaths[entry.Path]; exists { + return "", nil, fmt.Errorf("requirement context catalog paths must be unique") + } + if _, exists := seenRefs[entry.SourceRef]; exists { + return "", nil, fmt.Errorf("requirement context catalog source refs must be unique") + } + seenPaths[entry.Path] = struct{}{} + seenRefs[entry.SourceRef] = struct{}{} + } + sort.Slice(entries, func(left, right int) bool { return entries[left].SourceRef < entries[right].SourceRef }) + return catalogID, entries, nil +} + +func admitCatalogEntry(raw any, kind, defaultRef string) (catalogSource, error) { + record, ok := raw.(map[string]any) + if !ok { + return catalogSource{}, fmt.Errorf("requirement context catalog entry must be an object") + } + if err := admit.KnownKeys(record, []string{"expectedSourceDigest", "nodeId", "path", "sourceRef"}, "requirement context catalog entry"); err != nil { + return catalogSource{}, err + } + pathText, err := admit.NonEmptyText(record["path"], "requirement context catalog entry path") + if err != nil { + return catalogSource{}, err + } + path, err := admit.SafeRepoRelativePath(pathText, "requirement context catalog entry path") + if err != nil { + return catalogSource{}, err + } + sourceRef := defaultRef + if record["sourceRef"] != nil { + sourceRef, err = admit.RuleID(record["sourceRef"], "requirement context catalog entry sourceRef") + if err != nil { + return catalogSource{}, err + } + } + expected := "" + if record["expectedSourceDigest"] != nil { + expected, err = admitDigestRef(record["expectedSourceDigest"], "requirement context catalog entry expectedSourceDigest") + if err != nil { + return catalogSource{}, err + } + } + nodeID := "" + if kind == "requirement_source" { + nodeID, err = admit.RuleID(record["nodeId"], "requirement context catalog entry nodeId") + if err != nil { + return catalogSource{}, err + } + } else if record["nodeId"] != nil { + return catalogSource{}, fmt.Errorf("requirement context non-requirement catalog entries must not declare nodeId") + } + return catalogSource{ExpectedDigest: expected, Kind: kind, NodeID: nodeID, Path: path, SourceRef: sourceRef}, nil +} + +func readCatalogSource(root *os.Root, path string) (any, []byte, string, error) { + info, err := root.Lstat(path) + if err != nil || info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() { + return nil, nil, "", fmt.Errorf("requirement context source must be a regular non-symlink file") + } + file, err := root.Open(path) + if err != nil { + return nil, nil, "", fmt.Errorf("open requirement context source") + } + defer file.Close() + openedInfo, err := file.Stat() + if err != nil || !openedInfo.Mode().IsRegular() || !os.SameFile(info, openedInfo) { + return nil, nil, "", fmt.Errorf("requirement context source changed between path and handle admission") + } + content, err := io.ReadAll(io.LimitReader(file, maxSourceBytes+1)) + if err != nil || len(content) > maxSourceBytes { + return nil, nil, "", fmt.Errorf("read requirement context source within byte limit") + } + finalInfo, err := file.Stat() + if err != nil || !os.SameFile(openedInfo, finalInfo) || finalInfo.Size() != int64(len(content)) { + return nil, nil, "", fmt.Errorf("requirement context source changed while reading") + } + value, err := admission.DecodeJSON(bytes.NewReader(content), maxSourceBytes) + if err != nil { + return nil, nil, "", err + } + return value, content, digest.SHA256TextRef(string(content)), nil +} + +func baselineVerification(sources []Source) string { + verified := 0 + for _, source := range sources { + if source.ExpectedDigest != "" { + verified++ + } + } + if verified == 0 { + return "unverified" + } + if verified == len(sources) { + return "verified" + } + return "partially_verified" +} + +func sourceIdentityValues(sources []Source) []any { + values := make([]any, 0, len(sources)) + for _, source := range sources { + value := map[string]any{ + "currentDigest": source.CurrentDigest, + "expectedDigest": source.ExpectedDigest, + "kind": source.Kind, + "path": source.Path, + "sourceRef": source.SourceRef, + } + if source.NodeID != "" { + value["nodeId"] = source.NodeID + } + if source.SourceRole != "" { + value["sourceRole"] = source.SourceRole + } + values = append(values, value) + } + return values +} diff --git a/internal/command/requirementcontext/model.go b/internal/command/requirementcontext/model.go new file mode 100644 index 0000000..f613979 --- /dev/null +++ b/internal/command/requirementcontext/model.go @@ -0,0 +1,411 @@ +package requirementcontext + +import ( + "encoding/json" + "fmt" + "sort" + "strings" + + "github.com/research-engineering/agentic-proofkit/internal/command/requirementbinding" + "github.com/research-engineering/agentic-proofkit/internal/command/requirementcoverageview" + "github.com/research-engineering/agentic-proofkit/internal/command/requirementsourceadmission" + "github.com/research-engineering/agentic-proofkit/internal/command/requirementspectree" + "github.com/research-engineering/agentic-proofkit/internal/kernel/admit" + "github.com/research-engineering/agentic-proofkit/internal/kernel/digest" + "github.com/research-engineering/agentic-proofkit/internal/kernel/stablejson" +) + +const ( + ContextKind = "proofkit.requirement-context" + maxSnapshotBytes = 8 << 20 +) + +var boundaryNonClaims = []string{ + "Requirement context is a derived projection and is not requirement, proof, coverage, merge, release, rollout, or readiness authority.", + "Requirement context does not execute native witnesses or prove source freshness after composition.", +} + +type Source struct { + CurrentDigest string + ExpectedDigest string + Kind string + NodeID string + Path string + SourceRef string + SourceRole string +} + +type Snapshot struct { + BaselineVerification string + CatalogID string + Coverage map[string]any + ProofBinding *requirementbinding.Input + Projections map[string]any + RequirementSources []requirementsourceadmission.Source + SnapshotID string + Sources []Source + Tree requirementspectree.Tree +} + +func SnapshotValue(snapshot Snapshot) map[string]any { + sources := make([]any, 0, len(snapshot.Sources)) + for _, source := range snapshot.Sources { + record := map[string]any{ + "currentDigest": source.CurrentDigest, + "kind": source.Kind, + "path": source.Path, + "sourceRef": source.SourceRef, + } + if source.ExpectedDigest != "" { + record["expectedDigest"] = source.ExpectedDigest + } + if source.NodeID != "" { + record["nodeId"] = source.NodeID + } + if source.SourceRole != "" { + record["sourceRole"] = source.SourceRole + } + sources = append(sources, record) + } + return map[string]any{ + "baselineVerification": snapshot.BaselineVerification, + "catalogId": snapshot.CatalogID, + "contextKind": ContextKind, + "nonClaims": admit.StringSliceToAny(boundaryNonClaims), + "projections": snapshot.Projections, + "schemaVersion": json.Number("1"), + "snapshotId": snapshot.SnapshotID, + "sources": sources, + } +} + +func AdmitSnapshot(raw any) (Snapshot, error) { + record, ok := raw.(map[string]any) + if !ok { + return Snapshot{}, fmt.Errorf("requirement context must be an object") + } + if err := admit.KnownKeys(record, []string{"baselineVerification", "catalogId", "contextKind", "nonClaims", "projections", "schemaVersion", "snapshotId", "sources"}, "requirement context"); err != nil { + return Snapshot{}, err + } + if !admit.JSONNumberEquals(record["schemaVersion"], 1) || record["contextKind"] != ContextKind { + return Snapshot{}, fmt.Errorf("requirement context identity is invalid") + } + catalogID, err := admit.RuleID(record["catalogId"], "requirement context catalogId") + if err != nil { + return Snapshot{}, err + } + snapshotID, err := admitDigestRef(record["snapshotId"], "requirement context snapshotId") + if err != nil { + return Snapshot{}, err + } + verification, err := admit.Enum(record["baselineVerification"], map[string]struct{}{"verified": {}, "partially_verified": {}, "unverified": {}}, "requirement context baselineVerification") + if err != nil { + return Snapshot{}, err + } + projections, ok := record["projections"].(map[string]any) + if !ok { + return Snapshot{}, fmt.Errorf("requirement context projections must be an object") + } + if err := admit.KnownKeys(projections, []string{"coverage", "proofBinding", "requirementSources", "specTree"}, "requirement context projections"); err != nil { + return Snapshot{}, err + } + tree, requirementSources, proofBinding, coverage, canonicalProjections, err := admitSnapshotProjections(projections) + if err != nil { + return Snapshot{}, err + } + sources, err := admitSources(record["sources"]) + if err != nil { + return Snapshot{}, err + } + if err := validateProjectionSources(tree, requirementSources, proofBinding, coverage, sources); err != nil { + return Snapshot{}, err + } + derivedVerification := baselineVerification(sources) + if verification != derivedVerification { + return Snapshot{}, fmt.Errorf("requirement context baselineVerification does not match admitted sources") + } + if err := admitExactNonClaims(record["nonClaims"]); err != nil { + return Snapshot{}, err + } + identityValue := map[string]any{"catalogId": catalogID, "projections": canonicalProjections, "sources": sourceIdentityValues(sources)} + encoded, err := stablejson.Marshal(identityValue) + if err != nil { + return Snapshot{}, err + } + if digest.SHA256TextRef(string(encoded)) != snapshotID { + return Snapshot{}, fmt.Errorf("requirement context snapshotId does not match admitted content") + } + snapshot := Snapshot{ + BaselineVerification: verification, + CatalogID: catalogID, + Coverage: coverage, + ProofBinding: proofBinding, + Projections: canonicalProjections, + RequirementSources: requirementSources, + SnapshotID: snapshotID, + Sources: sources, + Tree: tree, + } + fullValue, err := stablejson.Marshal(SnapshotValue(snapshot)) + if err != nil { + return Snapshot{}, err + } + if len(fullValue) > maxSnapshotBytes { + return Snapshot{}, fmt.Errorf("requirement context snapshot exceeds byte limit") + } + return snapshot, nil +} + +func validateProjectionSources(tree requirementspectree.Tree, requirementSources []requirementsourceadmission.Source, proofBinding *requirementbinding.Input, coverage map[string]any, sources []Source) error { + expected := map[string]string{"spec_tree": "spec_tree:" + tree.TreeID} + requirementNodes := map[string]map[string]struct{}{} + for _, node := range tree.Nodes { + for _, ref := range node.SourceRefs { + if ref.SourceRole != "requirements" || ref.SourceID == "" { + continue + } + if requirementNodes[ref.SourceID] == nil { + requirementNodes[ref.SourceID] = map[string]struct{}{} + } + requirementNodes[ref.SourceID][node.NodeID] = struct{}{} + } + } + for _, source := range requirementSources { + expected["requirement_source:"+source.SourceID] = source.SourceID + } + if len(requirementNodes) != len(requirementSources) { + return fmt.Errorf("requirement context specification tree requirement refs do not match requirement projections") + } + if proofBinding != nil { + expected["proof_binding"] = "proof_binding:" + proofBinding.BindingID + } + if coverage != nil { + viewInputID, err := admit.RuleID(coverage["viewInputId"], "requirement context coverage viewInputId") + if err != nil { + return err + } + expected["coverage"] = "coverage:" + viewInputID + } + seen := map[string]struct{}{} + for _, source := range sources { + key := source.Kind + if source.Kind == "requirement_source" { + key += ":" + source.SourceRef + if _, ok := requirementNodes[source.SourceRef][source.NodeID]; !ok || source.SourceRole != "requirements" { + return fmt.Errorf("requirement context requirement source node does not match the specification tree") + } + } + ref, ok := expected[key] + if !ok || ref != source.SourceRef { + return fmt.Errorf("requirement context source inventory does not match projections") + } + if _, duplicate := seen[key]; duplicate { + return fmt.Errorf("requirement context source inventory contains duplicate projection owners") + } + seen[key] = struct{}{} + } + if len(seen) != len(expected) { + return fmt.Errorf("requirement context source inventory does not match requirement projections") + } + knownRequirements := map[string]struct{}{} + for _, source := range requirementSources { + if _, ok := requirementNodes[source.SourceID]; !ok { + return fmt.Errorf("requirement context requirement projection is not referenced by the specification tree") + } + for _, requirement := range source.Requirements { + knownRequirements[requirement.RequirementID] = struct{}{} + } + } + if proofBinding != nil { + for _, requirement := range proofBinding.Requirements { + if _, ok := knownRequirements[requirement.RequirementID]; !ok { + return fmt.Errorf("requirement context proof binding references a requirement outside the context") + } + } + for _, binding := range proofBinding.Bindings { + if _, ok := knownRequirements[binding.RequirementID]; !ok { + return fmt.Errorf("requirement context proof binding references a requirement outside the context") + } + } + } + if coverage != nil { + for _, raw := range coverage["requirementCoverage"].([]any) { + if _, ok := knownRequirements[raw.(map[string]any)["requirementId"].(string)]; !ok { + return fmt.Errorf("requirement context coverage references a requirement outside the context") + } + } + } + return nil +} + +func admitSnapshotProjections(projections map[string]any) (requirementspectree.Tree, []requirementsourceadmission.Source, *requirementbinding.Input, map[string]any, map[string]any, error) { + treeResult, err := requirementspectree.Evaluate(projections["specTree"]) + if err != nil || treeResult.ExitCode != 0 { + return requirementspectree.Tree{}, nil, nil, nil, nil, fmt.Errorf("requirement context spec tree projection is invalid") + } + rawSources, ok := projections["requirementSources"].([]any) + if !ok || len(rawSources) == 0 { + return requirementspectree.Tree{}, nil, nil, nil, nil, fmt.Errorf("requirement context requirementSources must be a non-empty array") + } + seen := map[string]struct{}{} + seenRequirements := map[string]struct{}{} + requirementSources := make([]requirementsourceadmission.Source, 0, len(rawSources)) + for _, rawSource := range rawSources { + result, err := requirementsourceadmission.Evaluate(rawSource) + if err != nil { + return requirementspectree.Tree{}, nil, nil, nil, nil, fmt.Errorf("requirement context requirement source projection is invalid: %w", err) + } + if result.ExitCode != 0 { + return requirementspectree.Tree{}, nil, nil, nil, nil, fmt.Errorf("requirement context requirement source projection failed admission") + } + if _, exists := seen[result.Source.SourceID]; exists { + return requirementspectree.Tree{}, nil, nil, nil, nil, fmt.Errorf("requirement context requirement source ids must be unique") + } + seen[result.Source.SourceID] = struct{}{} + for _, requirement := range result.Source.Requirements { + if _, duplicate := seenRequirements[requirement.RequirementID]; duplicate { + return requirementspectree.Tree{}, nil, nil, nil, nil, fmt.Errorf("requirement context requirement ids must be unique across sources") + } + seenRequirements[requirement.RequirementID] = struct{}{} + } + requirementSources = append(requirementSources, result.Source) + } + sort.Slice(requirementSources, func(left, right int) bool { + return requirementSources[left].SourceID < requirementSources[right].SourceID + }) + var proofBinding *requirementbinding.Input + if rawProof, ok := projections["proofBinding"]; ok { + result, err := requirementbinding.Build(rawProof) + if err != nil || result.Record.State != "passed" { + return requirementspectree.Tree{}, nil, nil, nil, nil, fmt.Errorf("requirement context proof binding projection is invalid") + } + proofBinding = &result.Input + } + var coverage map[string]any + if rawCoverage, ok := projections["coverage"]; ok { + coverage, err = requirementcoverageview.AdmitOutput(rawCoverage) + if err != nil { + return requirementspectree.Tree{}, nil, nil, nil, nil, fmt.Errorf("requirement context coverage projection is invalid: %w", err) + } + } + canonical := map[string]any{ + "requirementSources": requirementSourceValues(requirementSources), + "specTree": requirementspectree.TreeValue(treeResult.Tree), + } + if proofBinding != nil { + canonical["proofBinding"] = requirementbinding.InputValue(*proofBinding) + } + if coverage != nil { + canonical["coverage"] = coverage + } + return treeResult.Tree, requirementSources, proofBinding, coverage, canonical, nil +} + +func requirementSourceValues(sources []requirementsourceadmission.Source) []any { + values := make([]any, 0, len(sources)) + for _, source := range sources { + values = append(values, requirementsourceadmission.SourceValue(source)) + } + return values +} + +func admitExactNonClaims(raw any) error { + values, ok := raw.([]any) + if !ok || len(values) != len(boundaryNonClaims) { + return fmt.Errorf("requirement context nonClaims must equal the command-owned boundary") + } + for index, expected := range boundaryNonClaims { + if values[index] != expected { + return fmt.Errorf("requirement context nonClaims must equal the command-owned boundary") + } + } + return nil +} + +func admitSources(raw any) ([]Source, error) { + values, ok := raw.([]any) + if !ok || len(values) == 0 { + return nil, fmt.Errorf("requirement context sources must be a non-empty array") + } + result := make([]Source, 0, len(values)) + seenPaths := map[string]struct{}{} + seenRefs := map[string]struct{}{} + for index, value := range values { + record, ok := value.(map[string]any) + if !ok { + return nil, fmt.Errorf("requirement context sources[%d] must be an object", index) + } + if err := admit.KnownKeys(record, []string{"currentDigest", "expectedDigest", "kind", "nodeId", "path", "sourceRef", "sourceRole"}, "requirement context source"); err != nil { + return nil, err + } + pathText, err := admit.NonEmptyText(record["path"], "requirement context source path") + if err != nil { + return nil, err + } + path, err := admit.SafeRepoRelativePath(pathText, "requirement context source path") + if err != nil { + return nil, err + } + if _, exists := seenPaths[path]; exists { + return nil, fmt.Errorf("requirement context source paths must be unique") + } + seenPaths[path] = struct{}{} + currentDigest, err := admitDigestRef(record["currentDigest"], "requirement context source currentDigest") + if err != nil { + return nil, err + } + expectedDigest := "" + if record["expectedDigest"] != nil { + expectedDigest, err = admitDigestRef(record["expectedDigest"], "requirement context source expectedDigest") + if err != nil { + return nil, err + } + } + kind, err := admit.Enum(record["kind"], map[string]struct{}{"coverage": {}, "proof_binding": {}, "requirement_source": {}, "spec_tree": {}}, "requirement context source kind") + if err != nil { + return nil, err + } + sourceRef, err := admit.RuleID(record["sourceRef"], "requirement context source sourceRef") + if err != nil { + return nil, err + } + if _, exists := seenRefs[sourceRef]; exists { + return nil, fmt.Errorf("requirement context source refs must be unique") + } + seenRefs[sourceRef] = struct{}{} + if expectedDigest != "" && expectedDigest != currentDigest { + return nil, fmt.Errorf("requirement context source expectedDigest must equal currentDigest") + } + nodeID := "" + sourceRole := "" + if kind == "requirement_source" { + nodeID, err = admit.RuleID(record["nodeId"], "requirement context source nodeId") + if err != nil { + return nil, err + } + sourceRole, err = admit.Enum(record["sourceRole"], map[string]struct{}{"requirements": {}}, "requirement context source sourceRole") + if err != nil { + return nil, err + } + } else if record["nodeId"] != nil || record["sourceRole"] != nil { + return nil, fmt.Errorf("requirement context non-requirement sources must not declare nodeId or sourceRole") + } + result = append(result, Source{CurrentDigest: currentDigest, ExpectedDigest: expectedDigest, Kind: kind, NodeID: nodeID, Path: path, SourceRef: sourceRef, SourceRole: sourceRole}) + } + sort.Slice(result, func(left, right int) bool { return result[left].SourceRef < result[right].SourceRef }) + return result, nil +} + +func admitDigestRef(raw any, context string) (string, error) { + value, err := admit.NonEmptyText(raw, context) + if err != nil { + return "", err + } + if !strings.HasPrefix(value, "sha256:") { + return "", fmt.Errorf("%s must be a sha256 digest reference", context) + } + if _, err := admit.LowercaseSHA256(strings.TrimPrefix(value, "sha256:"), context); err != nil { + return "", err + } + return value, nil +} diff --git a/internal/command/requirementcontext/query.go b/internal/command/requirementcontext/query.go new file mode 100644 index 0000000..e45eabc --- /dev/null +++ b/internal/command/requirementcontext/query.go @@ -0,0 +1,131 @@ +package requirementcontext + +import ( + "encoding/json" + "fmt" + "sort" + "strconv" + + "github.com/research-engineering/agentic-proofkit/internal/kernel/admit" +) + +func admitSliceQuery(raw any) (SliceQuery, error) { + record, ok := raw.(map[string]any) + if !ok { + return SliceQuery{}, fmt.Errorf("requirement context slice query must be an object") + } + if err := admit.KnownKeys(record, []string{"lifecycleStates", "maxDepth", "maxNodes", "maxRequirements", "nodeIds", "ownerIds", "profile", "requirementIds"}, "requirement context slice query"); err != nil { + return SliceQuery{}, err + } + profile, err := admit.Enum(record["profile"], map[string]struct{}{"coverage": {}, "proof": {}, "review": {}, "routing": {}, "specification": {}}, "requirement context slice profile") + if err != nil { + return SliceQuery{}, err + } + nodeIDs, err := admittedIDs(record["nodeIds"], "requirement context slice nodeIds") + if err != nil { + return SliceQuery{}, err + } + requirementIDs, err := admittedIDs(record["requirementIds"], "requirement context slice requirementIds") + if err != nil { + return SliceQuery{}, err + } + ownerIDs, err := admittedIDs(record["ownerIds"], "requirement context slice ownerIds") + if err != nil { + return SliceQuery{}, err + } + lifecycleStates, err := admittedEnums(record["lifecycleStates"], map[string]struct{}{"active": {}, "deprecated": {}, "removed": {}, "superseded": {}}, "requirement context slice lifecycleStates") + if err != nil { + return SliceQuery{}, err + } + maxNodes, err := optionalPositiveInteger(record["maxNodes"], 256, "requirement context slice maxNodes") + if err != nil || maxNodes > 4096 { + return SliceQuery{}, fmt.Errorf("requirement context slice maxNodes must be between 1 and 4096") + } + maxRequirements, err := optionalPositiveInteger(record["maxRequirements"], 2048, "requirement context slice maxRequirements") + if err != nil || maxRequirements > 16384 { + return SliceQuery{}, fmt.Errorf("requirement context slice maxRequirements must be between 1 and 16384") + } + var maxDepth *int + if record["maxDepth"] != nil { + if len(nodeIDs) == 0 { + return SliceQuery{}, fmt.Errorf("requirement context slice maxDepth requires nodeIds") + } + depth, err := nonNegativeInteger(record["maxDepth"], "requirement context slice maxDepth") + if err != nil || depth > 512 { + return SliceQuery{}, fmt.Errorf("requirement context slice maxDepth must be between 0 and 512") + } + maxDepth = &depth + } + if len(nodeIDs)+len(requirementIDs)+len(ownerIDs)+len(lifecycleStates) == 0 && profile != "routing" { + return SliceQuery{}, fmt.Errorf("requirement context slice requires a selector outside routing profile") + } + return SliceQuery{MaxDepth: maxDepth, MaxNodes: maxNodes, MaxRequirements: maxRequirements, NodeIDs: nodeIDs, OwnerIDs: ownerIDs, Profile: profile, RequirementIDs: requirementIDs, LifecycleStates: lifecycleStates}, nil +} + +func admittedIDs(raw any, context string) ([]string, error) { + if raw == nil { + return nil, nil + } + values, ok := raw.([]any) + if !ok { + return nil, fmt.Errorf("%s must be an array", context) + } + result := make([]string, 0, len(values)) + seen := map[string]struct{}{} + for _, value := range values { + id, err := admit.RuleID(value, context) + if err != nil { + return nil, err + } + if _, ok := seen[id]; ok { + return nil, fmt.Errorf("%s must be unique", context) + } + seen[id] = struct{}{} + result = append(result, id) + } + sort.Strings(result) + return result, nil +} + +func admittedEnums(raw any, allowed map[string]struct{}, context string) ([]string, error) { + if raw == nil { + return nil, nil + } + values, ok := raw.([]any) + if !ok { + return nil, fmt.Errorf("%s must be an array", context) + } + result := make([]string, 0, len(values)) + seen := map[string]struct{}{} + for _, value := range values { + item, err := admit.Enum(value, allowed, context) + if err != nil { + return nil, err + } + if _, ok := seen[item]; ok { + return nil, fmt.Errorf("%s must be unique", context) + } + seen[item] = struct{}{} + result = append(result, item) + } + sort.Strings(result) + return result, nil +} + +func optionalPositiveInteger(raw any, fallback int, context string) (int, error) { + if raw == nil { + return fallback, nil + } + return admit.PositiveInteger(raw, context) +} +func nonNegativeInteger(raw any, context string) (int, error) { + number, ok := raw.(json.Number) + if !ok { + return 0, fmt.Errorf("%s must be a non-negative integer", context) + } + value, err := strconv.Atoi(number.String()) + if err != nil || value < 0 { + return 0, fmt.Errorf("%s must be a non-negative integer", context) + } + return value, nil +} diff --git a/internal/command/requirementcontext/requirementcontext_test.go b/internal/command/requirementcontext/requirementcontext_test.go new file mode 100644 index 0000000..2413a3c --- /dev/null +++ b/internal/command/requirementcontext/requirementcontext_test.go @@ -0,0 +1,368 @@ +package requirementcontext + +import ( + "bytes" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/research-engineering/agentic-proofkit/internal/kernel/admission" + "github.com/research-engineering/agentic-proofkit/internal/kernel/digest" + "github.com/research-engineering/agentic-proofkit/internal/kernel/stablejson" + "github.com/research-engineering/agentic-proofkit/internal/testsupport/commandcoverage" +) + +func TestComposeAndSliceRoundTrip(t *testing.T) { + commandcoverage.SemanticRoute(t, "proofkit.command_coverage.source_oracle.v1.039174333591545173112362713528481218186528989446819372039588644025024411317142") + root := fixtureRepository(t) + contextValue, err := Compose(root, fixtureCatalog()) + if err != nil { + t.Fatalf("Compose() error = %v", err) + } + snapshot, err := AdmitSnapshot(contextValue) + if err != nil { + t.Fatalf("AdmitSnapshot() error = %v", err) + } + if snapshot.BaselineVerification != "unverified" { + t.Fatalf("baseline verification = %q, want unverified", snapshot.BaselineVerification) + } + output, err := Slice(map[string]any{ + "schemaVersion": json.Number("1"), + "sliceId": "consumer.context.slice", + "context": contextValue, + "query": map[string]any{ + "profile": "specification", + "nodeIds": []any{"spec.root"}, + }, + }) + if err != nil { + t.Fatalf("Slice() error = %v", err) + } + if output["state"] != "selected" || output["snapshotId"] != snapshot.SnapshotID { + t.Fatalf("unexpected slice output: %#v", output) + } + projections := output["projections"].(map[string]any) + sources := projections["requirementSources"].([]any) + if len(sources) != 1 { + t.Fatalf("selected source count = %d, want 1", len(sources)) + } + requirements := sources[0].(map[string]any)["requirements"].([]any) + if len(requirements) == 0 { + t.Fatal("slice omitted all requirements from explicitly selected source") + } +} + +func TestSnapshotAdmissionRejectsForgedBaselineAndProjectionLedger(t *testing.T) { + contextValue, err := Compose(fixtureRepository(t), fixtureCatalog()) + if err != nil { + t.Fatal(err) + } + forged := deepClone(t, contextValue) + forgedSources := forged["sources"].([]any) + for _, raw := range forgedSources { + source := raw.(map[string]any) + source["expectedDigest"] = "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" + } + forged["baselineVerification"] = "verified" + resignSnapshot(t, forged) + if _, err := AdmitSnapshot(forged); err == nil { + t.Fatal("AdmitSnapshot accepted mismatched expected digests as verified") + } + + phantom := deepClone(t, contextValue) + phantom["sources"] = append(phantom["sources"].([]any), map[string]any{"currentDigest": "sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", "kind": "coverage", "path": "artifacts/coverage.json", "sourceRef": "coverage:phantom.coverage"}) + resignSnapshot(t, phantom) + if _, err := AdmitSnapshot(phantom); err == nil { + t.Fatal("AdmitSnapshot accepted a source record without its owner projection") + } + + oversized := deepClone(t, contextValue) + requirement := oversized["projections"].(map[string]any)["requirementSources"].([]any)[0].(map[string]any)["requirements"].([]any)[0].(map[string]any) + requirement["invariant"] = strings.Repeat("a", maxSnapshotBytes) + resignSnapshot(t, oversized) + if _, err := AdmitSnapshot(oversized); err == nil { + t.Fatal("AdmitSnapshot accepted a snapshot larger than the producer bound") + } +} + +func TestSliceUsesExplicitLookupFragments(t *testing.T) { + contextValue, err := Compose(fixtureRepository(t), fixtureCatalog()) + if err != nil { + t.Fatal(err) + } + output, err := Slice(map[string]any{"context": contextValue, "query": map[string]any{"maxRequirements": json.Number("1"), "profile": "specification", "requirementIds": []any{"REQ-PROOFKIT-SPEC-001"}}, "schemaVersion": json.Number("1"), "sliceId": "consumer.fragment"}) + if err != nil { + t.Fatal(err) + } + projections := output["projections"].(map[string]any) + source := projections["requirementSources"].([]any)[0].(map[string]any) + if source["projectionKind"] != "proofkit.requirement-source-fragment" || source["authority"] != "lookup_fragment_only" || source["selectedRequirementCount"] != 1 { + t.Fatalf("unexpected source fragment: %#v", source) + } + tree := projections["specTree"].(map[string]any) + if tree["projectionKind"] != "proofkit.requirement-spec-tree-fragment" || tree["authority"] != "lookup_fragment_only" { + t.Fatalf("unexpected tree fragment: %#v", tree) + } +} + +func TestSlicePreservesTransitiveLifecycleReplacementClosure(t *testing.T) { + root := fixtureRepository(t) + path := filepath.Join(root, "docs/specs/proofkit-spec-proof-core/requirements.v1.json") + content, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + decoded, err := admission.DecodeJSON(bytes.NewReader(content), int64(len(content))) + if err != nil { + t.Fatal(err) + } + source := decoded.(map[string]any) + requirements := source["requirements"].([]any) + first := requirements[0].(map[string]any) + second := requirements[1].(map[string]any) + firstID := first["requirementId"].(string) + secondID := second["requirementId"].(string) + first["claimLevel"] = "advisory" + first["lifecycle"] = map[string]any{"evidenceRefs": []any{"proof.lifecycle.first"}, "replacementRequirementIds": []any{secondID}, "state": "superseded"} + second["lifecycle"] = map[string]any{"evidenceRefs": []any{}, "replacementRequirementIds": []any{}, "state": "active"} + updated, err := stablejson.Marshal(source) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, updated, 0o644); err != nil { + t.Fatal(err) + } + contextValue, err := Compose(root, fixtureCatalog()) + if err != nil { + t.Fatal(err) + } + output, err := Slice(map[string]any{"context": contextValue, "query": map[string]any{"maxRequirements": json.Number("2"), "profile": "review", "requirementIds": []any{firstID}}, "schemaVersion": json.Number("1"), "sliceId": "consumer.lifecycle-closure"}) + if err != nil { + t.Fatal(err) + } + selected := output["projections"].(map[string]any)["requirementSources"].([]any)[0].(map[string]any)["requirements"].([]any) + got := make([]string, 0, len(selected)) + for _, raw := range selected { + got = append(got, raw.(map[string]any)["requirementId"].(string)) + } + if !containsAll(got, firstID, secondID) { + t.Fatalf("selected lifecycle closure=%v", got) + } + if _, err := Slice(map[string]any{"context": contextValue, "query": map[string]any{"maxRequirements": json.Number("1"), "profile": "review", "requirementIds": []any{firstID}}, "schemaVersion": json.Number("1"), "sliceId": "consumer.lifecycle-closure-bounded"}); err == nil { + t.Fatal("Slice accepted a bound that cannot retain mandatory lifecycle closure") + } +} + +func containsAll(values []string, expected ...string) bool { + set := map[string]struct{}{} + for _, value := range values { + set[value] = struct{}{} + } + for _, value := range expected { + if _, ok := set[value]; !ok { + return false + } + } + return true +} + +func deepClone(t *testing.T, value map[string]any) map[string]any { + t.Helper() + encoded, err := stablejson.Marshal(value) + if err != nil { + t.Fatal(err) + } + decoded, err := admission.DecodeJSON(bytes.NewReader(encoded), int64(len(encoded))) + if err != nil { + t.Fatal(err) + } + return decoded.(map[string]any) +} + +func resignSnapshot(t *testing.T, value map[string]any) { + t.Helper() + sources := value["sources"].([]any) + identitySources := make([]any, 0, len(sources)) + for _, raw := range sources { + source := raw.(map[string]any) + identity := map[string]any{"currentDigest": source["currentDigest"], "expectedDigest": "", "kind": source["kind"], "path": source["path"], "sourceRef": source["sourceRef"]} + for _, key := range []string{"expectedDigest", "nodeId", "sourceRole"} { + if source[key] != nil { + identity[key] = source[key] + } + } + identitySources = append(identitySources, identity) + } + encoded, err := stablejson.Marshal(map[string]any{"catalogId": value["catalogId"], "projections": value["projections"], "sources": identitySources}) + if err != nil { + t.Fatal(err) + } + value["snapshotId"] = digest.SHA256TextRef(string(encoded)) +} + +func TestSliceRejectsTamperedSnapshotAndUnknownNode(t *testing.T) { + commandcoverage.SemanticRoute(t, "proofkit.command_coverage.source_oracle.v1.085802294599012556735496328767610257842830634122573088743085183818381349380300") + root := fixtureRepository(t) + contextValue, err := Compose(root, fixtureCatalog()) + if err != nil { + t.Fatalf("Compose() error = %v", err) + } + tampered := cloneMap(contextValue) + tampered["catalogId"] = "consumer.other.catalog" + if _, err := AdmitSnapshot(tampered); err == nil { + t.Fatal("AdmitSnapshot accepted content with a stale snapshotId") + } + _, err = Slice(map[string]any{ + "schemaVersion": json.Number("1"), + "sliceId": "consumer.context.slice", + "context": contextValue, + "query": map[string]any{ + "profile": "specification", + "nodeIds": []any{"spec.unknown"}, + }, + }) + if err == nil { + t.Fatal("Slice accepted an unknown explicit node") + } + _, err = Slice(map[string]any{ + "schemaVersion": json.Number("1"), "sliceId": "consumer.context.slice", "context": contextValue, + "query": map[string]any{"profile": "specification", "requirementIds": []any{"REQ-UNKNOWN-001"}}, + }) + if err == nil { + t.Fatal("Slice accepted an unknown explicit requirement") + } + bounded, err := Slice(map[string]any{ + "schemaVersion": json.Number("1"), "sliceId": "consumer.context.slice", "context": contextValue, + "query": map[string]any{"profile": "specification", "nodeIds": []any{"spec.root"}, "maxNodes": json.Number("1")}, + }) + if err != nil { + t.Fatalf("bounded Slice() error = %v", err) + } + omissions := bounded["omissions"].([]any) + if len(omissions) != 1 || omissions[0].(map[string]any)["count"] != 1 { + t.Fatalf("bounded omissions = %#v, want one omitted node", omissions) + } +} + +func TestComposeRejectsRepositoryBoundaryAndFreshnessViolations(t *testing.T) { + t.Run("parent escape", func(t *testing.T) { + catalog := fixtureCatalog() + catalog["specTree"].(map[string]any)["path"] = "../outside.json" + if _, err := Compose(fixtureRepository(t), catalog); err == nil { + t.Fatal("Compose accepted parent-directory escape") + } + }) + t.Run("symlink source", func(t *testing.T) { + root := fixtureRepository(t) + path := filepath.Join(root, "proofkit/spec-tree.json") + content, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + target := filepath.Join(root, "proofkit/real-tree.json") + if err := os.WriteFile(target, content, 0o644); err != nil { + t.Fatal(err) + } + if err := os.Remove(path); err != nil { + t.Fatal(err) + } + if err := os.Symlink("real-tree.json", path); err != nil { + t.Fatal(err) + } + if _, err := Compose(root, fixtureCatalog()); err == nil { + t.Fatal("Compose accepted symlink source") + } + }) + t.Run("oversized source", func(t *testing.T) { + root := fixtureRepository(t) + if err := os.WriteFile(filepath.Join(root, "proofkit/spec-tree.json"), bytes.Repeat([]byte(" "), maxSourceBytes+1), 0o644); err != nil { + t.Fatal(err) + } + if _, err := Compose(root, fixtureCatalog()); err == nil { + t.Fatal("Compose accepted oversized source") + } + }) + t.Run("expected digest mismatch", func(t *testing.T) { + catalog := fixtureCatalog() + catalog["specTree"].(map[string]any)["expectedSourceDigest"] = "sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd" + if _, err := Compose(fixtureRepository(t), catalog); err == nil { + t.Fatal("Compose accepted mismatched expected source digest") + } + }) +} + +func fixtureRepository(t *testing.T) string { + t.Helper() + root := t.TempDir() + repoRoot := filepath.Clean(filepath.Join("..", "..", "..")) + source, err := os.ReadFile(filepath.Join(repoRoot, "docs/specs/proofkit-spec-proof-core/requirements.v1.json")) + if err != nil { + t.Fatalf("read requirement source fixture: %v", err) + } + writeFixture(t, root, "docs/specs/proofkit-spec-proof-core/requirements.v1.json", source) + tree := map[string]any{ + "schemaVersion": json.Number("2"), + "treeId": "proofkit.spec-tree", + "rootNodeId": "spec.root", + "callerAnnotations": []any{}, + "edges": []any{map[string]any{"parentNodeId": "spec.root", "childNodeId": "spec.child"}}, + "overlays": []any{}, + "nodes": []any{map[string]any{ + "nodeId": "spec.root", + "nodeKind": "meta_spec", + "label": "Specification root", + "displayOrder": json.Number("1"), + "callerAnnotations": []any{}, + "sourceRefs": []any{map[string]any{ + "sourceRefId": "spec.root.requirements", + "sourceRefKind": "source_id", + "sourceRole": "requirements", + "sourceId": "proofkit.spec-proof-core.requirements", + }}, + }, map[string]any{ + "nodeId": "spec.child", "nodeKind": "module_spec", "label": "Child specification", "displayOrder": json.Number("2"), "callerAnnotations": []any{}, + "sourceRefs": []any{map[string]any{"sourceRefId": "spec.child.requirements", "sourceRefKind": "source_id", "sourceRole": "requirements", "sourceId": "proofkit.spec-proof-core.requirements"}}, + }}, + } + encoded, err := stablejson.Marshal(tree) + if err != nil { + t.Fatalf("marshal tree fixture: %v", err) + } + writeFixture(t, root, "proofkit/spec-tree.json", encoded) + return root +} + +func fixtureCatalog() map[string]any { + return map[string]any{ + "schemaVersion": json.Number("1"), + "catalogId": "consumer.spec-context", + "specTree": map[string]any{ + "path": "proofkit/spec-tree.json", + }, + "requirementSources": []any{map[string]any{ + "nodeId": "spec.root", + "path": "docs/specs/proofkit-spec-proof-core/requirements.v1.json", + }}, + } +} + +func cloneMap(value map[string]any) map[string]any { + result := make(map[string]any, len(value)) + for key, item := range value { + result[key] = item + } + return result +} + +func writeFixture(t *testing.T, root, path string, content []byte) { + t.Helper() + target := filepath.Join(root, filepath.FromSlash(path)) + if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { + t.Fatalf("create fixture parent: %v", err) + } + if err := os.WriteFile(target, content, 0o644); err != nil { + t.Fatalf("write fixture: %v", err) + } +} diff --git a/internal/command/requirementcontext/slice.go b/internal/command/requirementcontext/slice.go new file mode 100644 index 0000000..9f1ffcf --- /dev/null +++ b/internal/command/requirementcontext/slice.go @@ -0,0 +1,485 @@ +package requirementcontext + +import ( + "encoding/json" + "fmt" + "sort" + + "github.com/research-engineering/agentic-proofkit/internal/command/requirementbinding" + "github.com/research-engineering/agentic-proofkit/internal/command/requirementcoverageview" + "github.com/research-engineering/agentic-proofkit/internal/command/requirementsourceadmission" + "github.com/research-engineering/agentic-proofkit/internal/command/requirementspectree" + "github.com/research-engineering/agentic-proofkit/internal/kernel/admit" + "github.com/research-engineering/agentic-proofkit/internal/kernel/stablejson" +) + +const maxOperationBytes = 24 << 20 + +type SliceQuery struct { + MaxDepth *int + MaxNodes int + MaxRequirements int + NodeIDs []string + OwnerIDs []string + Profile string + RequirementIDs []string + LifecycleStates []string +} + +type nodeSelection struct { + maxDepthOmitted int + maxNodesOmitted int + selected map[string]struct{} + sourceIDs map[string]struct{} +} + +func Slice(raw any) (map[string]any, error) { + record, ok := raw.(map[string]any) + if !ok { + return nil, fmt.Errorf("requirement context slice input must be an object") + } + if err := admit.KnownKeys(record, []string{"context", "query", "schemaVersion", "sliceId"}, "requirement context slice input"); err != nil { + return nil, err + } + if !admit.JSONNumberEquals(record["schemaVersion"], 1) { + return nil, fmt.Errorf("requirement context slice schemaVersion must be 1") + } + sliceID, err := admit.RuleID(record["sliceId"], "requirement context sliceId") + if err != nil { + return nil, err + } + snapshot, err := AdmitSnapshot(record["context"]) + if err != nil { + return nil, err + } + query, err := admitSliceQuery(record["query"]) + if err != nil { + return nil, err + } + output, err := buildSlice(sliceID, snapshot, query) + if err != nil { + return nil, err + } + encoded, err := stablejson.Marshal(output) + if err != nil { + return nil, err + } + if len(encoded) > maxOperationBytes { + return nil, fmt.Errorf("requirement context slice exceeds byte limit") + } + return output, nil +} + +func buildSlice(sliceID string, snapshot Snapshot, query SliceQuery) (map[string]any, error) { + if err := validateSelectorVocabulary(snapshot.RequirementSources, query); err != nil { + return nil, err + } + nodeResult, err := selectNodes(snapshot.Tree, query) + if err != nil { + return nil, err + } + selectedNodes := nodeResult.selected + treeScopeActive := len(query.NodeIDs) > 0 || query.Profile == "routing" + selectedSources, selectedRequirements, omittedRequirements, requirementSet, selectedSourceIDs, err := selectRequirements(snapshot.RequirementSources, nodeResult.sourceIDs, treeScopeActive, query) + if err != nil { + return nil, err + } + if !treeScopeActive && len(selectedSourceIDs) > 0 { + derived := query + depth := 0 + derived.MaxDepth = &depth + derived.NodeIDs = sourceNodeIDs(snapshot.Sources, selectedSourceIDs) + nodeResult, err = selectNodes(snapshot.Tree, derived) + if err != nil { + return nil, err + } + // The derived depth-zero scope requests source nodes and their ancestors; + // descendants were never part of the caller's selection domain. + nodeResult.maxDepthOmitted = 0 + nodeResult.maxNodesOmitted = 0 + selectedNodes = nodeResult.selected + } + projections := map[string]any{ + "requirementSources": requirementSourceFragmentValues(snapshot.RequirementSources, selectedSources), + "specTree": treeSliceValue(snapshot.Tree, selectedNodes, selectedSourceIDs), + } + if query.Profile == "proof" || query.Profile == "review" { + if snapshot.ProofBinding != nil { + projections["proofBinding"] = requirementbinding.SelectionFragmentValue(*snapshot.ProofBinding, requirementSet) + } + } + if query.Profile == "coverage" || query.Profile == "review" { + if snapshot.Coverage != nil { + projections["coverage"] = requirementcoverageview.SelectRequirements(snapshot.Coverage, requirementSet) + } + } + state := "selected" + if selectedRequirements == 0 && len(selectedNodes) == 0 { + state = "no_match" + } + omissions := []any{} + if nodeResult.maxDepthOmitted > 0 { + omissions = append(omissions, map[string]any{"count": nodeResult.maxDepthOmitted, "kind": "nodes", "reason": "max_depth"}) + } + if nodeResult.maxNodesOmitted > 0 { + omissions = append(omissions, map[string]any{"count": nodeResult.maxNodesOmitted, "kind": "nodes", "reason": "max_nodes"}) + } + if omittedRequirements > 0 { + omissions = append(omissions, map[string]any{"count": omittedRequirements, "kind": "requirements", "reason": "max_requirements"}) + } + return map[string]any{ + "contextKind": "proofkit.requirement-context-slice", + "nonClaims": admit.StringSliceToAny(boundaryNonClaims), + "omissions": omissions, + "profile": query.Profile, + "projections": projections, + "schemaVersion": json.Number("1"), + "sliceId": sliceID, + "snapshotId": snapshot.SnapshotID, + "state": state, + }, nil +} + +func selectNodes(tree requirementspectree.Tree, query SliceQuery) (nodeSelection, error) { + nodeByID := map[string]requirementspectree.Node{} + children := map[string][]string{} + parents := map[string]string{} + for _, node := range tree.Nodes { + nodeByID[node.NodeID] = node + } + for _, edge := range tree.Edges { + children[edge.ParentNodeID] = append(children[edge.ParentNodeID], edge.ChildNodeID) + parents[edge.ChildNodeID] = edge.ParentNodeID + } + for parentID := range children { + sort.Slice(children[parentID], func(left, right int) bool { + leftNode := nodeByID[children[parentID][left]] + rightNode := nodeByID[children[parentID][right]] + if leftNode.DisplayOrder != rightNode.DisplayOrder { + return leftNode.DisplayOrder < rightNode.DisplayOrder + } + return leftNode.NodeID < rightNode.NodeID + }) + } + roots := query.NodeIDs + if len(roots) == 0 && query.Profile == "routing" { + roots = []string{tree.RootNodeID} + } + fullReachable := map[string]struct{}{} + depthEligible := map[string]struct{}{} + queue := []struct { + id string + depth int + }{} + for _, id := range roots { + if _, ok := nodeByID[id]; !ok { + return nodeSelection{}, fmt.Errorf("requirement context slice references unknown node") + } + queue = append(queue, struct { + id string + depth int + }{id, 0}) + } + for len(queue) > 0 { + item := queue[0] + queue = queue[1:] + if _, exists := fullReachable[item.id]; exists { + continue + } + fullReachable[item.id] = struct{}{} + if query.MaxDepth == nil || item.depth <= *query.MaxDepth { + depthEligible[item.id] = struct{}{} + } + for _, child := range children[item.id] { + queue = append(queue, struct { + id string + depth int + }{child, item.depth + 1}) + } + } + selected := map[string]struct{}{} + for _, id := range roots { + for current := id; current != ""; current = parents[current] { + selected[current] = struct{}{} + } + } + if len(selected) > query.MaxNodes { + return nodeSelection{}, fmt.Errorf("requirement context slice maxNodes cannot retain mandatory ancestor closure") + } + for _, id := range parentBeforeChildOrder(tree.RootNodeID, children) { + if len(selected) >= query.MaxNodes { + break + } + if _, ok := depthEligible[id]; ok { + selected[id] = struct{}{} + } + } + maxDepthOmitted := 0 + for id := range fullReachable { + if _, ok := depthEligible[id]; !ok { + maxDepthOmitted++ + } + } + maxNodesOmitted := 0 + for id := range depthEligible { + if _, ok := selected[id]; !ok { + maxNodesOmitted++ + } + } + sourceIDs := map[string]struct{}{} + for id := range selected { + for _, ref := range nodeByID[id].SourceRefs { + if ref.SourceRole == "requirements" && ref.SourceID != "" { + sourceIDs[ref.SourceID] = struct{}{} + } + } + } + return nodeSelection{maxDepthOmitted: maxDepthOmitted, maxNodesOmitted: maxNodesOmitted, selected: selected, sourceIDs: sourceIDs}, nil +} + +func parentBeforeChildOrder(rootID string, children map[string][]string) []string { + ordered := []string{} + queue := []string{rootID} + for len(queue) > 0 { + id := queue[0] + queue = queue[1:] + ordered = append(ordered, id) + queue = append(queue, children[id]...) + } + return ordered +} + +func selectRequirements(values []requirementsourceadmission.Source, sourceIDs map[string]struct{}, treeScopeActive bool, query SliceQuery) ([]requirementsourceadmission.Source, int, int, map[string]struct{}, map[string]struct{}, error) { + requirementFilter := stringSet(query.RequirementIDs) + ownerFilter := stringSet(query.OwnerIDs) + lifecycleFilter := stringSet(query.LifecycleStates) + selectedSources := []requirementsourceadmission.Source{} + selectedSourceIDs := map[string]struct{}{} + selectedIDs := map[string]struct{}{} + knownIDs := map[string]struct{}{} + type location struct { + requirement requirementsourceadmission.Requirement + sourceIndex int + } + locations := map[string]location{} + candidates := []string{} + eligibleIDs := map[string]struct{}{} + for sourceIndex, source := range values { + _, selectedByNode := sourceIDs[source.SourceID] + for _, requirement := range source.Requirements { + id := requirement.RequirementID + knownIDs[id] = struct{}{} + locations[id] = location{requirement: requirement, sourceIndex: sourceIndex} + owner := requirement.OwnerID + lifecycle := requirement.Lifecycle.State + if !selectedByNode && treeScopeActive { + continue + } + if len(requirementFilter) > 0 { + if _, ok := requirementFilter[id]; !ok { + continue + } + } + if len(ownerFilter) > 0 { + if _, ok := ownerFilter[owner]; !ok { + continue + } + } + if len(lifecycleFilter) > 0 { + if _, ok := lifecycleFilter[lifecycle]; !ok { + continue + } + } + candidates = append(candidates, id) + eligibleIDs[id] = struct{}{} + } + } + for _, id := range query.RequirementIDs { + if _, ok := knownIDs[id]; !ok { + return nil, 0, 0, nil, nil, fmt.Errorf("requirement context slice references unknown requirement") + } + } + initialCount := len(candidates) + if initialCount > query.MaxRequirements { + initialCount = query.MaxRequirements + } + for _, id := range candidates[:initialCount] { + selectedIDs[id] = struct{}{} + } + for _, id := range query.RequirementIDs { + if _, eligible := eligibleIDs[id]; eligible { + if _, selected := selectedIDs[id]; !selected { + return nil, 0, 0, nil, nil, fmt.Errorf("requirement context slice maxRequirements cannot retain all explicit requirements") + } + } + } + queue := make([]string, 0, len(selectedIDs)) + for id := range selectedIDs { + queue = append(queue, id) + } + for len(queue) > 0 { + id := queue[0] + queue = queue[1:] + for _, replacementID := range locations[id].requirement.Lifecycle.ReplacementRequirementIDs { + if _, ok := locations[replacementID]; !ok { + return nil, 0, 0, nil, nil, fmt.Errorf("requirement context slice lifecycle reference does not resolve") + } + if _, exists := selectedIDs[replacementID]; exists { + continue + } + if len(selectedIDs) >= query.MaxRequirements { + return nil, 0, 0, nil, nil, fmt.Errorf("requirement context slice maxRequirements cannot retain mandatory lifecycle closure") + } + selectedIDs[replacementID] = struct{}{} + queue = append(queue, replacementID) + } + } + omitted := 0 + for _, id := range candidates { + if _, selected := selectedIDs[id]; !selected { + omitted++ + } + } + for sourceIndex, source := range values { + selected := []requirementsourceadmission.Requirement{} + for _, requirement := range source.Requirements { + if _, ok := selectedIDs[requirement.RequirementID]; ok && locations[requirement.RequirementID].sourceIndex == sourceIndex { + selected = append(selected, requirement) + } + } + if len(selected) == 0 { + continue + } + copySource := source + copySource.Requirements = selected + selectedSources = append(selectedSources, copySource) + selectedSourceIDs[source.SourceID] = struct{}{} + } + return selectedSources, len(selectedIDs), omitted, selectedIDs, selectedSourceIDs, nil +} + +func treeSliceValue(tree requirementspectree.Tree, selected, selectedSourceIDs map[string]struct{}) map[string]any { + value := requirementspectree.TreeValue(tree) + nodes := []any{} + retainedSourceRefs := map[string]struct{}{} + for _, raw := range value["nodes"].([]any) { + node := raw.(map[string]any) + if _, ok := selected[node["nodeId"].(string)]; ok { + refs := []any{} + for _, rawRef := range node["sourceRefs"].([]any) { + ref := rawRef.(map[string]any) + if ref["sourceRole"] == "requirements" && ref["sourceRefKind"] == "source_id" { + if _, ok := selectedSourceIDs[ref["sourceId"].(string)]; !ok { + continue + } + } + refID, ok := ref["sourceRefId"].(string) + if !ok { + continue + } + refs = append(refs, ref) + retainedSourceRefs[refID] = struct{}{} + } + node["sourceRefs"] = refs + nodes = append(nodes, node) + } + } + edges := []any{} + for _, raw := range value["edges"].([]any) { + edge := raw.(map[string]any) + _, parent := selected[edge["parentNodeId"].(string)] + _, child := selected[edge["childNodeId"].(string)] + if parent && child { + edges = append(edges, edge) + } + } + overlays := []any{} + for _, raw := range value["overlays"].([]any) { + overlay := raw.(map[string]any) + if _, ok := selected[overlay["targetNodeId"].(string)]; !ok { + continue + } + if overlay["refKind"] == "source_ref" { + if _, ok := retainedSourceRefs[overlay["refId"].(string)]; !ok { + continue + } + } + overlays = append(overlays, overlay) + } + value["nodes"] = nodes + value["edges"] = edges + value["overlays"] = overlays + value["authority"] = "lookup_fragment_only" + value["projectionKind"] = "proofkit.requirement-spec-tree-fragment" + value["sourceTreeId"] = value["treeId"] + delete(value, "treeId") + if len(nodes) == 0 { + value["rootNodeId"] = nil + } + return value +} + +func requirementSourceFragmentValues(all, selected []requirementsourceadmission.Source) []any { + totals := map[string]int{} + for _, source := range all { + totals[source.SourceID] = len(source.Requirements) + } + values := make([]any, 0, len(selected)) + for _, source := range selected { + requirements := make([]any, 0, len(source.Requirements)) + for _, requirement := range source.Requirements { + requirements = append(requirements, requirementsourceadmission.RequirementValue(requirement)) + } + values = append(values, map[string]any{ + "authority": "lookup_fragment_only", "omittedRequirementCount": totals[source.SourceID] - len(source.Requirements), + "projectionKind": "proofkit.requirement-source-fragment", "requirements": requirements, + "selectedRequirementCount": len(source.Requirements), "sourceId": source.SourceID, + "totalRequirementCount": totals[source.SourceID], + }) + } + return values +} + +func stringSet(values []string) map[string]struct{} { + result := map[string]struct{}{} + for _, value := range values { + result[value] = struct{}{} + } + return result +} + +func sourceNodeIDs(sources []Source, selectedSourceIDs map[string]struct{}) []string { + result := []string{} + for _, source := range sources { + if source.Kind != "requirement_source" { + continue + } + if _, ok := selectedSourceIDs[source.SourceRef]; ok { + result = append(result, source.NodeID) + } + } + return result + +} + +func validateSelectorVocabulary(sources []requirementsourceadmission.Source, query SliceQuery) error { + knownOwners := map[string]struct{}{} + knownLifecycles := map[string]struct{}{} + for _, source := range sources { + for _, requirement := range source.Requirements { + knownOwners[requirement.OwnerID] = struct{}{} + knownLifecycles[requirement.Lifecycle.State] = struct{}{} + } + } + for _, ownerID := range query.OwnerIDs { + if _, ok := knownOwners[ownerID]; !ok { + return fmt.Errorf("requirement context slice references unknown owner") + } + } + for _, lifecycle := range query.LifecycleStates { + if _, ok := knownLifecycles[lifecycle]; !ok { + return fmt.Errorf("requirement context slice references unavailable lifecycle state") + } + } + return nil +} diff --git a/internal/command/requirementcontext/slice_closure_test.go b/internal/command/requirementcontext/slice_closure_test.go new file mode 100644 index 0000000..7027dbb --- /dev/null +++ b/internal/command/requirementcontext/slice_closure_test.go @@ -0,0 +1,238 @@ +package requirementcontext + +import ( + "encoding/json" + "fmt" + "testing" + + "github.com/research-engineering/agentic-proofkit/internal/kernel/digest" + "github.com/research-engineering/agentic-proofkit/internal/kernel/stablejson" +) + +func TestSlicePreservesRequirementSourceRole(t *testing.T) { + contextValue := sliceTopologyFixture(t) + output, err := Slice(map[string]any{ + "context": contextValue, "schemaVersion": json.Number("1"), "sliceId": "consumer.role-slice", + "query": map[string]any{"maxDepth": json.Number("0"), "nodeIds": []any{"spec.root"}, "profile": "specification"}, + }) + if err != nil { + t.Fatal(err) + } + assertSelectedSourceIDs(t, output, "consumer.requirements-a") + tree := output["projections"].(map[string]any)["specTree"].(map[string]any) + refs := tree["nodes"].([]any)[0].(map[string]any)["sourceRefs"].([]any) + if len(refs) != 3 || refs[0].(map[string]any)["sourceRole"] != "overview" || refs[1].(map[string]any)["sourceId"] != "consumer.requirements-a" || refs[2].(map[string]any)["sourceRefKind"] != "path_digest" { + t.Fatalf("tree fragment lost source-role identity: %#v", refs) + } + overlays := tree["overlays"].([]any) + if len(overlays) != 2 || overlays[0].(map[string]any)["refId"] != "spec.root.overview-b" || overlays[1].(map[string]any)["refId"] != "spec.root.requirements-path" { + t.Fatalf("tree fragment lost retained source-ref overlay: %#v", overlays) + } +} + +func TestRoutingSliceRestrictsSourcesToSelectedTree(t *testing.T) { + output, err := Slice(map[string]any{ + "context": sliceTopologyFixture(t), "schemaVersion": json.Number("1"), "sliceId": "consumer.routing-slice", + "query": map[string]any{"maxNodes": json.Number("1"), "profile": "routing"}, + }) + if err != nil { + t.Fatal(err) + } + assertSelectedSourceIDs(t, output, "consumer.requirements-a") + omissions := output["omissions"].([]any) + if len(omissions) != 1 || omissions[0].(map[string]any)["kind"] != "nodes" || omissions[0].(map[string]any)["count"] != 1 { + t.Fatalf("routing omissions = %#v, want one max_nodes omission", omissions) + } +} + +func TestSliceSelectorIntersectionReturnsNoMatch(t *testing.T) { + output, err := Slice(map[string]any{ + "context": sliceTopologyFixture(t), "schemaVersion": json.Number("1"), "sliceId": "consumer.empty-intersection", + "query": map[string]any{"ownerIds": []any{"consumer.owner-b"}, "profile": "specification", "requirementIds": []any{"REQ-CONSUMER-A"}}, + }) + if err != nil { + t.Fatalf("valid empty selector intersection was rejected: %v", err) + } + if output["state"] != "no_match" { + t.Fatalf("state = %v, want no_match", output["state"]) + } + assertSelectedSourceIDs(t, output) +} + +func TestRequirementSelectorClosesTreeOverSelectedSource(t *testing.T) { + contextValue := sliceTopologyFixture(t) + input := map[string]any{ + "context": contextValue, "schemaVersion": json.Number("1"), "sliceId": "consumer.requirement-tree-closure", + "query": map[string]any{"maxNodes": json.Number("2"), "profile": "specification", "requirementIds": []any{"REQ-CONSUMER-B"}}, + } + output, err := Slice(input) + if err != nil { + t.Fatal(err) + } + assertSelectedSourceIDs(t, output, "consumer.requirements-b") + nodes := output["projections"].(map[string]any)["specTree"].(map[string]any)["nodes"].([]any) + if len(nodes) != 2 { + t.Fatalf("selected source node closure = %#v, want root and child", nodes) + } + input["query"].(map[string]any)["maxNodes"] = json.Number("1") + if _, err := Slice(input); err == nil { + t.Fatal("Slice accepted a bound that cannot retain selected source ancestor closure") + } +} + +func TestRequirementSelectorDoesNotReportInternalTreeDerivationAsOmission(t *testing.T) { + output, err := Slice(map[string]any{ + "context": threeLevelSliceTopologyFixture(t, [3]int{1, 2, 3}), "schemaVersion": json.Number("1"), "sliceId": "consumer.requirement-derived-tree", + "query": map[string]any{"maxNodes": json.Number("2"), "profile": "specification", "requirementIds": []any{"REQ-CONSUMER-B"}}, + }) + if err != nil { + t.Fatal(err) + } + if omissions := output["omissions"].([]any); len(omissions) != 0 { + t.Fatalf("internal source-node derivation leaked as caller omission: %#v", omissions) + } + nodes := output["projections"].(map[string]any)["specTree"].(map[string]any)["nodes"].([]any) + if len(nodes) != 2 { + t.Fatalf("derived tree closure = %#v, want source node and ancestor only", nodes) + } +} + +func TestSliceNodeBoundsPreserveAncestorsAcrossDisplayOrders(t *testing.T) { + orders := [][3]int{{1, 2, 3}, {3, 2, 1}, {2, 3, 1}, {2, 1, 3}} + for _, order := range orders { + t.Run(fmt.Sprint(order), func(t *testing.T) { + contextValue := threeLevelSliceTopologyFixture(t, order) + output, err := Slice(map[string]any{ + "context": contextValue, "schemaVersion": json.Number("1"), "sliceId": "consumer.ancestor-closure", + "query": map[string]any{"maxNodes": json.Number("2"), "nodeIds": []any{"spec.root"}, "profile": "specification"}, + }) + if err != nil { + t.Fatal(err) + } + nodes := output["projections"].(map[string]any)["specTree"].(map[string]any)["nodes"].([]any) + selected := map[string]bool{} + for _, raw := range nodes { + selected[raw.(map[string]any)["nodeId"].(string)] = true + } + if !selected["spec.root"] || !selected["spec.child"] || selected["spec.grandchild"] { + t.Fatalf("bounded selection lost ancestor closure: %#v", selected) + } + }) + } +} + +func TestSliceReportsDepthAndNodeOmissionsAsDisjointSets(t *testing.T) { + output, err := Slice(map[string]any{ + "context": threeLevelSliceTopologyFixture(t, [3]int{3, 2, 1}), "schemaVersion": json.Number("1"), "sliceId": "consumer.depth-omissions", + "query": map[string]any{"maxDepth": json.Number("0"), "maxNodes": json.Number("1"), "nodeIds": []any{"spec.root"}, "profile": "specification"}, + }) + if err != nil { + t.Fatal(err) + } + omissions := output["omissions"].([]any) + if len(omissions) != 1 || omissions[0].(map[string]any)["reason"] != "max_depth" || omissions[0].(map[string]any)["count"] != 2 { + t.Fatalf("depth omissions = %#v, want two max_depth nodes", omissions) + } +} + +func threeLevelSliceTopologyFixture(t *testing.T, displayOrders [3]int) map[string]any { + t.Helper() + contextValue := sliceTopologyFixture(t) + tree := contextValue["projections"].(map[string]any)["specTree"].(map[string]any) + nodes := tree["nodes"].([]any) + nodes[0].(map[string]any)["displayOrder"] = json.Number(fmt.Sprint(displayOrders[0])) + nodes[1].(map[string]any)["displayOrder"] = json.Number(fmt.Sprint(displayOrders[1])) + grandchild := map[string]any{ + "callerAnnotations": []any{}, "displayOrder": json.Number(fmt.Sprint(displayOrders[2])), "label": "Grandchild", "nodeId": "spec.grandchild", "nodeKind": "submodule_spec", + "sourceRefs": []any{map[string]any{"sourceId": "consumer.requirements-b", "sourceRefId": "spec.grandchild.requirements-b", "sourceRefKind": "source_id", "sourceRole": "requirements"}}, + } + tree["nodes"] = append(nodes, grandchild) + tree["edges"] = append(tree["edges"].([]any), map[string]any{"childNodeId": "spec.grandchild", "parentNodeId": "spec.child"}) + canonicalizeAndResignSnapshot(t, contextValue) + return contextValue +} + +func canonicalizeAndResignSnapshot(t *testing.T, value map[string]any) { + t.Helper() + _, _, _, _, canonical, err := admitSnapshotProjections(value["projections"].(map[string]any)) + if err != nil { + t.Fatal(err) + } + value["projections"] = canonical + resignSnapshot(t, value) +} + +func assertSelectedSourceIDs(t *testing.T, output map[string]any, expected ...string) { + t.Helper() + sources := output["projections"].(map[string]any)["requirementSources"].([]any) + if len(sources) != len(expected) { + t.Fatalf("selected sources = %#v, want %v", sources, expected) + } + for index, sourceID := range expected { + if sources[index].(map[string]any)["sourceId"] != sourceID { + t.Fatalf("selected sources = %#v, want %v", sources, expected) + } + } +} + +func sliceTopologyFixture(t *testing.T) map[string]any { + t.Helper() + requirementA := sliceRequirement("REQ-CONSUMER-A", "consumer.owner-a", "blocking", "active", []any{}) + requirementB := sliceRequirement("REQ-CONSUMER-B", "consumer.owner-b", "blocking", "active", []any{}) + sourceA := sliceRequirementSource("consumer.requirements-a", requirementA) + sourceB := sliceRequirementSource("consumer.requirements-b", requirementB) + tree := map[string]any{ + "callerAnnotations": []any{}, + "edges": []any{map[string]any{"childNodeId": "spec.child", "parentNodeId": "spec.root"}}, + "nodes": []any{ + map[string]any{"callerAnnotations": []any{}, "displayOrder": json.Number("1"), "label": "Root", "nodeId": "spec.root", "nodeKind": "meta_spec", "sourceRefs": []any{ + map[string]any{"sourceId": "consumer.requirements-b", "sourceRefId": "spec.root.overview-b", "sourceRefKind": "source_id", "sourceRole": "overview"}, + map[string]any{"sourceId": "consumer.requirements-a", "sourceRefId": "spec.root.requirements-a", "sourceRefKind": "source_id", "sourceRole": "requirements"}, + map[string]any{ + "currentSourceDigest": digest.SHA256TextRef("auxiliary"), "digestAlgorithm": "sha256", + "recordedSourceDigest": digest.SHA256TextRef("auxiliary"), "sourcePath": "docs/specs/auxiliary/requirements.v1.json", + "sourceRefId": "spec.root.requirements-path", "sourceRefKind": "path_digest", "sourceRole": "requirements", + }, + }}, + map[string]any{"callerAnnotations": []any{}, "displayOrder": json.Number("2"), "label": "Child", "nodeId": "spec.child", "nodeKind": "module_spec", "sourceRefs": []any{ + map[string]any{"sourceId": "consumer.requirements-b", "sourceRefId": "spec.child.requirements-b", "sourceRefKind": "source_id", "sourceRole": "requirements"}, + }}, + }, + "overlays": []any{map[string]any{ + "callerAnnotations": []any{}, "label": "Root overview", "overlayId": "overlay.root.overview", + "overlayKind": "source", "refId": "spec.root.overview-b", "refKind": "source_ref", "targetNodeId": "spec.root", + }, map[string]any{ + "callerAnnotations": []any{}, "label": "Auxiliary requirements", "overlayId": "overlay.root.requirements-path", + "overlayKind": "source", "refId": "spec.root.requirements-path", "refKind": "source_ref", "targetNodeId": "spec.root", + }}, "rootNodeId": "spec.root", "schemaVersion": json.Number("2"), "treeId": "consumer.slice-tree", + } + projections := map[string]any{"requirementSources": []any{sourceA, sourceB}, "specTree": tree} + sources := []Source{ + {CurrentDigest: digest.SHA256TextRef("consumer.requirements-a"), Kind: "requirement_source", NodeID: "spec.root", Path: "docs/specs/a/requirements.v1.json", SourceRef: "consumer.requirements-a", SourceRole: "requirements"}, + {CurrentDigest: digest.SHA256TextRef("consumer.requirements-b"), Kind: "requirement_source", NodeID: "spec.child", Path: "docs/specs/b/requirements.v1.json", SourceRef: "consumer.requirements-b", SourceRole: "requirements"}, + {CurrentDigest: digest.SHA256TextRef("consumer.slice-tree"), Kind: "spec_tree", Path: "proofkit/spec-tree.json", SourceRef: "spec_tree:consumer.slice-tree"}, + } + identity, err := stablejson.Marshal(map[string]any{"catalogId": "consumer.slice-context", "projections": projections, "sources": sourceIdentityValues(sources)}) + if err != nil { + t.Fatal(err) + } + return SnapshotValue(Snapshot{BaselineVerification: "unverified", CatalogID: "consumer.slice-context", Projections: projections, SnapshotID: digest.SHA256TextRef(string(identity)), Sources: sources}) +} + +func sliceRequirement(id, owner, claimLevel, lifecycleState string, replacementIDs []any) map[string]any { + return map[string]any{ + "claimLevel": claimLevel, "invariant": "The selected contract remains explicit.", + "lifecycle": map[string]any{"evidenceRefs": []any{}, "replacementRequirementIds": replacementIDs, "state": lifecycleState}, + "nonClaimRefs": []any{"NC-CONSUMER-001"}, "nonClaims": []any{"This requirement does not approve merge."}, + "ownerId": owner, "proofBindingRefs": []any{"proofkit/requirement-bindings.json"}, "requirementId": id, "riskClass": "high", + "updatePolicy": map[string]any{"requiresImpactDeclaration": true, "requiresProofBindingReview": true, "reviewOwnerId": owner}, + } +} + +func sliceRequirementSource(sourceID string, requirement map[string]any) map[string]any { + return map[string]any{ + "nonClaims": []any{"Consumer source does not approve merge."}, "overviewPath": "docs/specs/consumer/overview.md", + "requirements": []any{requirement}, "requirementsPath": "docs/specs/consumer/requirements.v1.json", + "schemaVersion": json.Number("1"), "sourceId": sourceID, "specPackagePath": "docs/specs/consumer", + } +} diff --git a/internal/command/requirementcoverageview/output_admission.go b/internal/command/requirementcoverageview/output_admission.go new file mode 100644 index 0000000..019386a --- /dev/null +++ b/internal/command/requirementcoverageview/output_admission.go @@ -0,0 +1,212 @@ +package requirementcoverageview + +import ( + "bytes" + "encoding/json" + "fmt" + + "github.com/research-engineering/agentic-proofkit/internal/kernel/admission" + "github.com/research-engineering/agentic-proofkit/internal/kernel/admit" + "github.com/research-engineering/agentic-proofkit/internal/kernel/secretjson" + "github.com/research-engineering/agentic-proofkit/internal/kernel/stablejson" +) + +var outputKeys = []string{ + "authority", "bindingId", "commandCoverage", "commandCoverageCount", + "completenessDeclaration", "contractId", "coverageUniverseId", "deadZones", + "failureClassifications", "failureCount", "failures", "guidanceSummary", + "nonClaims", "ownerInvariantCoverage", "ownerInvariantCoverageCount", + "ownerInvariantRegistryId", "proofMode", "requirementCoverage", + "requirementCoverageCount", "schemaVersion", "sourceId", "state", + "testInventoryId", "viewInputId", "viewKind", "warningClassifications", + "warningCount", "warnings", +} + +// AdmitOutput preserves the requirement-coverage owner's wire projection for +// parent commands without reinterpreting coverage semantics. +func AdmitOutput(raw any) (map[string]any, error) { + record, ok := raw.(map[string]any) + if !ok { + return nil, fmt.Errorf("requirement coverage output must be an object") + } + if err := admit.KnownKeys(record, outputKeys, "requirement coverage output"); err != nil { + return nil, err + } + if !admit.JSONNumberEquals(record["schemaVersion"], 1) && record["schemaVersion"] != 1 { + return nil, fmt.Errorf("requirement coverage output schemaVersion must be 1") + } + if record["viewKind"] != "proofkit.requirement-coverage-view" || record["authority"] != "lookup_only" { + return nil, fmt.Errorf("requirement coverage output identity is invalid") + } + for _, key := range []string{"viewInputId", "coverageUniverseId", "sourceId"} { + if _, err := admit.RuleID(record[key], "requirement coverage output "+key); err != nil { + return nil, err + } + } + if _, err := admit.Enum(record["state"], map[string]struct{}{"failed": {}, "passed": {}}, "requirement coverage output state"); err != nil { + return nil, err + } + for _, row := range []struct { + rowsKey string + countKey string + idKey string + }{ + {rowsKey: "requirementCoverage", countKey: "requirementCoverageCount", idKey: "requirementId"}, + {rowsKey: "ownerInvariantCoverage", countKey: "ownerInvariantCoverageCount", idKey: "ownerInvariantId"}, + {rowsKey: "commandCoverage", countKey: "commandCoverageCount", idKey: "commandId"}, + } { + if err := admitCoverageOutputRows(record, row.rowsKey, row.countKey, row.idKey); err != nil { + return nil, err + } + } + for _, pair := range [][2]string{{"failures", "failureCount"}, {"warnings", "warningCount"}} { + if err := admitCountedArray(record, pair[0], pair[1]); err != nil { + return nil, err + } + } + if _, ok := record["deadZones"].([]any); !ok { + return nil, fmt.Errorf("requirement coverage output deadZones must be an array") + } + findings, err := secretjson.Scan(record, "requirement_coverage_output") + if err != nil || len(findings) > 0 { + return nil, fmt.Errorf("requirement coverage output contains secret-shaped data") + } + encoded, err := stablejson.Marshal(record) + if err != nil { + return nil, err + } + decoded, err := admission.DecodeJSON(bytes.NewReader(encoded), int64(len(encoded))) + if err != nil { + return nil, err + } + return decoded.(map[string]any), nil +} + +// SelectRequirements returns a bounded lookup fragment. It is deliberately not +// another complete coverage report, so omitted rows cannot affect report state +// or dead-zone claims. +func SelectRequirements(output map[string]any, selected map[string]struct{}) map[string]any { + rows := make([]any, 0) + for _, raw := range output["requirementCoverage"].([]any) { + row := raw.(map[string]any) + if _, ok := selected[row["requirementId"].(string)]; ok { + rows = append(rows, row) + } + } + return map[string]any{ + "authority": "lookup_fragment_only", + "nonClaims": admit.StringSliceToAny(defaultNonClaims), + "requirementCoverage": rows, + "requirementCoverageCount": len(rows), + "schemaVersion": json.Number("1"), + "sourceViewInputId": output["viewInputId"], + "viewKind": "proofkit.requirement-coverage-fragment", + } +} + +func admitCoverageOutputRows(record map[string]any, rowsKey, countKey, idKey string) error { + rows, ok := record[rowsKey].([]any) + if !ok { + return fmt.Errorf("requirement coverage output %s must be an array", rowsKey) + } + if !wireCountEquals(record[countKey], len(rows)) { + return fmt.Errorf("requirement coverage output %s does not match %s", countKey, rowsKey) + } + seen := map[string]struct{}{} + for index, raw := range rows { + row, ok := raw.(map[string]any) + if !ok { + return fmt.Errorf("requirement coverage output %s[%d] must be an object", rowsKey, index) + } + if err := admit.KnownKeys(row, coverageRowKeys(rowsKey), "requirement coverage output "+rowsKey); err != nil { + return err + } + if err := admitCoverageNestedRows(row, rowsKey); err != nil { + return err + } + id, err := admit.RuleID(row[idKey], fmt.Sprintf("requirement coverage output %s[%d].%s", rowsKey, index, idKey)) + if err != nil { + return err + } + if _, exists := seen[id]; exists { + return fmt.Errorf("requirement coverage output %s identities must be unique", rowsKey) + } + seen[id] = struct{}{} + } + return nil +} + +func admitCoverageNestedRows(row map[string]any, rowsKey string) error { + if rowsKey == "commandCoverage" { + return nil + } + if err := admitNestedRecords(row["tests"], []string{"commandRefs", "evidenceClass", "expectedPublicOutcome", "negativeCaseId", "nonClaims", "oracleKind", "oracleSummary", "qualityFindings", "selector", "sourcePath", "testId", "witnessRefs", "wrongImplementationClassId"}, "requirement coverage output tests"); err != nil { + return err + } + for _, raw := range row["tests"].([]any) { + test := raw.(map[string]any) + if err := admitNestedRecords(test["qualityFindings"], []string{"class", "evidenceRefs", "findingId", "nonClaims", "ownerReviewState", "severity"}, "requirement coverage output quality findings"); err != nil { + return err + } + } + if rowsKey == "requirementCoverage" { + return admitNestedRecords(row["scenarios"], []string{"commandIds", "environmentClasses", "scenarioId", "witnessId", "witnessKind", "witnessPath"}, "requirement coverage output scenarios") + } + return nil +} + +func admitNestedRecords(raw any, keys []string, context string) error { + values, ok := raw.([]any) + if !ok { + return fmt.Errorf("%s must be an array", context) + } + for index, value := range values { + record, ok := value.(map[string]any) + if !ok { + return fmt.Errorf("%s[%d] must be an object", context, index) + } + if err := admit.KnownKeys(record, keys, context); err != nil { + return err + } + } + return nil +} + +func coverageRowKeys(rowsKey string) []string { + switch rowsKey { + case "requirementCoverage": + return []string{"claimLevel", "commandIds", "coverageState", "environmentClasses", "evidenceClass", "failures", "invariant", "lifecycleState", "nonClaims", "ownerId", "proofState", "requirementId", "scenarioCount", "scenarios", "specPath", "testIds", "tests", "verifyCommands", "witnessRefs", "witnessSelectors"} + case "ownerInvariantCoverage": + return []string{"coverageState", "evidenceClass", "nonClaims", "ownerId", "ownerInvariantId", "sourcePath", "summary", "testIds", "tests", "warnings"} + case "commandCoverage": + return []string{"commandId", "coverageState", "failures", "testIds"} + default: + return nil + } +} + +func admitCountedArray(record map[string]any, rowsKey, countKey string) error { + rows, ok := record[rowsKey].([]any) + if !ok { + return fmt.Errorf("requirement coverage output %s must be an array", rowsKey) + } + if !wireCountEquals(record[countKey], len(rows)) { + return fmt.Errorf("requirement coverage output %s does not match %s", countKey, rowsKey) + } + return nil +} + +func wireCountEquals(raw any, expected int) bool { + switch value := raw.(type) { + case int: + return value == expected + case json.Number: + if expected == 0 { + return admit.JSONNumberEquals(value, 0) + } + actual, err := admit.PositiveInteger(value, "requirement coverage output count") + return err == nil && actual == expected + default: + return false + } +} diff --git a/internal/command/requirementcoverageview/requirementcoverageview_test.go b/internal/command/requirementcoverageview/requirementcoverageview_test.go index f2d428a..bf1b887 100644 --- a/internal/command/requirementcoverageview/requirementcoverageview_test.go +++ b/internal/command/requirementcoverageview/requirementcoverageview_test.go @@ -34,6 +34,28 @@ func TestBuildJSONBuildsSemanticRequirementAndCommandCoverage(t *testing.T) { } } +func TestAdmitOutputRejectsUnknownNestedFieldsAndBreaksCallerAliases(t *testing.T) { + view, _, err := BuildJSON(validCoverageInput(t), Options{}) + if err != nil { + t.Fatalf("BuildJSON() error = %v", err) + } + record := view.(map[string]any) + test := record["requirementCoverage"].([]any)[0].(map[string]any)["tests"].([]any)[0].(map[string]any) + test["unownedField"] = true + if _, err := AdmitOutput(record); err == nil || !strings.Contains(err.Error(), "unsupported field") { + t.Fatalf("AdmitOutput() error=%v, want unknown nested field rejection", err) + } + delete(test, "unownedField") + admitted, err := AdmitOutput(record) + if err != nil { + t.Fatalf("AdmitOutput() error = %v", err) + } + record["requirementCoverage"].([]any)[0].(map[string]any)["coverageState"] = "mutated" + if got := admitted["requirementCoverage"].([]any)[0].(map[string]any)["coverageState"]; got == "mutated" { + t.Fatalf("AdmitOutput() retained caller alias") + } +} + func TestBuildJSONDoesNotPromoteProofRouteCandidateToSemanticCoverage(t *testing.T) { input := validCoverageInput(t) entry := inventoryEntry(input) diff --git a/internal/command/requirementdiff/change_order.go b/internal/command/requirementdiff/change_order.go new file mode 100644 index 0000000..5fbcb03 --- /dev/null +++ b/internal/command/requirementdiff/change_order.go @@ -0,0 +1,38 @@ +package requirementdiff + +import "sort" + +type changeOrderKey struct { + EntityID string + JSONPointer string + ChangeClass string + ChangeID string +} + +func canonicalChangeOrderKey(change map[string]any) changeOrderKey { + return changeOrderKey{ + EntityID: change["entityId"].(string), + JSONPointer: change["jsonPointer"].(string), + ChangeClass: change["changeClass"].(string), + ChangeID: change["changeId"].(string), + } +} + +func sortChangesCanonical(changes []any) { + sort.Slice(changes, func(left, right int) bool { + return changeOrderLess(canonicalChangeOrderKey(changes[left].(map[string]any)), canonicalChangeOrderKey(changes[right].(map[string]any))) + }) +} + +func changeOrderLess(left, right changeOrderKey) bool { + if left.EntityID != right.EntityID { + return left.EntityID < right.EntityID + } + if left.JSONPointer != right.JSONPointer { + return left.JSONPointer < right.JSONPointer + } + if left.ChangeClass != right.ChangeClass { + return left.ChangeClass < right.ChangeClass + } + return left.ChangeID < right.ChangeID +} diff --git a/internal/command/requirementdiff/output_admission.go b/internal/command/requirementdiff/output_admission.go new file mode 100644 index 0000000..e727d63 --- /dev/null +++ b/internal/command/requirementdiff/output_admission.go @@ -0,0 +1,177 @@ +package requirementdiff + +import ( + "bytes" + "encoding/json" + "fmt" + "strings" + + "github.com/research-engineering/agentic-proofkit/internal/kernel/admission" + "github.com/research-engineering/agentic-proofkit/internal/kernel/admit" + "github.com/research-engineering/agentic-proofkit/internal/kernel/digest" + "github.com/research-engineering/agentic-proofkit/internal/kernel/secretjson" + "github.com/research-engineering/agentic-proofkit/internal/kernel/stablejson" +) + +var changeClasses = map[string]struct{}{ + "entity_added": {}, "entity_removed": {}, "lifecycle_transition": {}, + "opaque_value_changed": {}, "reference_changed": {}, "scalar_changed": {}, + "sequence_changed": {}, "set_membership_changed": {}, "tree_parent_changed": {}, +} + +func AdmitOutput(raw any, currentSnapshotID string) (map[string]any, error) { + record, ok := raw.(map[string]any) + if !ok { + return nil, fmt.Errorf("requirement semantic diff output must be an object") + } + if err := admit.KnownKeys(record, []string{"baseBaselineVerification", "baseSnapshotId", "changeCount", "changes", "currentBaselineVerification", "currentSnapshotId", "diffId", "diffKind", "nonClaims", "schemaVersion"}, "requirement semantic diff output"); err != nil { + return nil, err + } + if !admit.JSONNumberEquals(record["schemaVersion"], 1) && record["schemaVersion"] != 1 { + return nil, fmt.Errorf("requirement semantic diff output schemaVersion must be 1") + } + if record["diffKind"] != "proofkit.requirement-semantic-diff" || record["currentSnapshotId"] != currentSnapshotID { + return nil, fmt.Errorf("requirement semantic diff output identity is invalid") + } + for _, key := range []string{"baseSnapshotId", "currentSnapshotId"} { + if _, err := digestRef(record[key], "requirement semantic diff output "+key); err != nil { + return nil, err + } + } + for _, key := range []string{"baseBaselineVerification", "currentBaselineVerification"} { + if _, err := admit.Enum(record[key], map[string]struct{}{"partially_verified": {}, "unverified": {}, "verified": {}}, "requirement semantic diff output "+key); err != nil { + return nil, err + } + } + changes, ok := record["changes"].([]any) + if !ok || !countEquals(record["changeCount"], len(changes)) { + return nil, fmt.Errorf("requirement semantic diff changeCount must match changes") + } + seen := map[string]struct{}{} + var previousOrder *changeOrderKey + for index, rawChange := range changes { + change, ok := rawChange.(map[string]any) + if !ok { + return nil, fmt.Errorf("requirement semantic diff changes[%d] must be an object", index) + } + if err := admit.KnownKeys(change, []string{"after", "baseSourceDigest", "before", "changeClass", "changeId", "currentSourceDigest", "entityId", "entityKind", "jsonPointer"}, "requirement semantic diff change"); err != nil { + return nil, err + } + changeID, err := digestRef(change["changeId"], "requirement semantic diff changeId") + if err != nil { + return nil, err + } + if _, exists := seen[changeID]; exists { + return nil, fmt.Errorf("requirement semantic diff change ids must be unique") + } + seen[changeID] = struct{}{} + changeClass, err := admit.Enum(change["changeClass"], changeClasses, "requirement semantic diff changeClass") + if err != nil { + return nil, err + } + if change["entityKind"] != "requirement" { + return nil, fmt.Errorf("requirement semantic diff entityKind must be requirement") + } + entityID, err := admit.RuleID(change["entityId"], "requirement semantic diff entityId") + if err != nil { + return nil, err + } + pointer, err := admit.NonEmptyText(change["jsonPointer"], "requirement semantic diff jsonPointer") + expectedPrefix := "/requirements/" + escapePointer(entityID) + if err != nil || (pointer != expectedPrefix && !strings.HasPrefix(pointer, expectedPrefix+"/")) { + return nil, fmt.Errorf("requirement semantic diff jsonPointer must target requirements") + } + order := changeOrderKey{EntityID: entityID, JSONPointer: pointer, ChangeClass: changeClass, ChangeID: changeID} + if previousOrder != nil && !changeOrderLess(*previousOrder, order) { + return nil, fmt.Errorf("requirement semantic diff changes must use canonical semantic order") + } + previousOrder = &order + identity := map[string]any{"after": change["after"], "baseSnapshotId": record["baseSnapshotId"], "baseSourceDigest": change["baseSourceDigest"], "before": change["before"], "changeClass": changeClass, "currentSnapshotId": record["currentSnapshotId"], "currentSourceDigest": change["currentSourceDigest"], "entityId": entityID, "entityKind": "requirement", "jsonPointer": pointer} + encodedIdentity, err := stablejson.Marshal(identity) + if err != nil || digest.SHA256TextRef(string(encodedIdentity)) != changeID { + return nil, fmt.Errorf("requirement semantic diff changeId does not match change identity") + } + if err := admitOptionalDigest(change["baseSourceDigest"], "requirement semantic diff baseSourceDigest"); err != nil { + return nil, err + } + if err := admitOptionalDigest(change["currentSourceDigest"], "requirement semantic diff currentSourceDigest"); err != nil { + return nil, err + } + if changeClass == "entity_added" && change["before"] != nil { + return nil, fmt.Errorf("added requirement semantic diff change must not have a before value") + } + if changeClass == "entity_removed" && change["after"] != nil { + return nil, fmt.Errorf("removed requirement semantic diff change must not have an after value") + } + } + findings, err := secretjson.Scan(record, "semantic_diff") + if err != nil { + return nil, err + } + if len(findings) > 0 { + return nil, fmt.Errorf("requirement semantic diff output contains secret-shaped data") + } + if err := exactNonClaims(record["nonClaims"]); err != nil { + return nil, err + } + return canonicalCopy(record) +} + +func exactNonClaims(raw any) error { + values, ok := raw.([]any) + if !ok || len(values) != len(nonClaims) { + return fmt.Errorf("requirement semantic diff nonClaims must equal the command-owned boundary") + } + for index, expected := range nonClaims { + if values[index] != expected { + return fmt.Errorf("requirement semantic diff nonClaims must equal the command-owned boundary") + } + } + return nil +} + +func canonicalCopy(record map[string]any) (map[string]any, error) { + encoded, err := stablejson.Marshal(record) + if err != nil { + return nil, err + } + decoded, err := admission.DecodeJSON(bytes.NewReader(encoded), int64(len(encoded))) + if err != nil { + return nil, err + } + return decoded.(map[string]any), nil +} + +func admitOptionalDigest(raw any, context string) error { + if raw == nil || raw == "" { + return nil + } + _, err := digestRef(raw, context) + return err +} + +func digestRef(raw any, context string) (string, error) { + value, err := admit.NonEmptyText(raw, context) + if err != nil || !strings.HasPrefix(value, "sha256:") { + return "", fmt.Errorf("%s must be a sha256 digest reference", context) + } + if _, err := admit.LowercaseSHA256(strings.TrimPrefix(value, "sha256:"), context); err != nil { + return "", err + } + return value, nil +} + +func countEquals(raw any, expected int) bool { + if value, ok := raw.(int); ok { + return value == expected + } + number, ok := raw.(json.Number) + if !ok { + return false + } + if expected == 0 { + return admit.JSONNumberEquals(number, 0) + } + value, err := admit.PositiveInteger(number, "semantic diff count") + return err == nil && value == expected +} diff --git a/internal/command/requirementdiff/requirementdiff.go b/internal/command/requirementdiff/requirementdiff.go new file mode 100644 index 0000000..37d8d89 --- /dev/null +++ b/internal/command/requirementdiff/requirementdiff.go @@ -0,0 +1,317 @@ +package requirementdiff + +import ( + "encoding/json" + "fmt" + "reflect" + "sort" + "strings" + + "github.com/research-engineering/agentic-proofkit/internal/command/requirementcontext" + "github.com/research-engineering/agentic-proofkit/internal/command/requirementsourceadmission" + "github.com/research-engineering/agentic-proofkit/internal/kernel/admit" + "github.com/research-engineering/agentic-proofkit/internal/kernel/digest" + "github.com/research-engineering/agentic-proofkit/internal/kernel/stablejson" +) + +const maxChanges = 8192 + +var nonClaims = []string{ + "Semantic diff is a derived comparison and does not own requirement meaning, proof freshness, merge, release, or rollout decisions.", + "Semantic diff compares only admitted owner-declared requirement fields and is not a textual or Git diff.", +} + +type query struct { + MaxChanges int + OwnerIDs map[string]struct{} + RequirementIDs map[string]struct{} +} + +type requirementRecord struct { + Digest string + Requirement requirementsourceadmission.Requirement +} + +func Build(raw any) (map[string]any, error) { + record, ok := raw.(map[string]any) + if !ok { + return nil, fmt.Errorf("requirement semantic diff input must be an object") + } + if err := admit.KnownKeys(record, []string{"baseContext", "currentContext", "diffId", "query", "schemaVersion"}, "requirement semantic diff input"); err != nil { + return nil, err + } + if !admit.JSONNumberEquals(record["schemaVersion"], 1) { + return nil, fmt.Errorf("requirement semantic diff schemaVersion must be 1") + } + diffID, err := admit.RuleID(record["diffId"], "requirement semantic diff diffId") + if err != nil { + return nil, err + } + base, err := requirementcontext.AdmitSnapshot(record["baseContext"]) + if err != nil { + return nil, err + } + current, err := requirementcontext.AdmitSnapshot(record["currentContext"]) + if err != nil { + return nil, err + } + diffQuery, err := admitQuery(record["query"]) + if err != nil { + return nil, err + } + baseRequirements := requirementsByID(base, diffQuery.RequirementIDs) + currentRequirements := requirementsByID(current, diffQuery.RequirementIDs) + changes, err := compareRequirements(base, current, baseRequirements, currentRequirements, diffQuery.OwnerIDs, diffQuery.MaxChanges) + if err != nil { + return nil, err + } + return map[string]any{ + "baseBaselineVerification": base.BaselineVerification, + "baseSnapshotId": base.SnapshotID, + "changeCount": len(changes), + "changes": changes, + "currentBaselineVerification": current.BaselineVerification, + "currentSnapshotId": current.SnapshotID, + "diffId": diffID, + "diffKind": "proofkit.requirement-semantic-diff", + "nonClaims": admit.StringSliceToAny(nonClaims), + "schemaVersion": json.Number("1"), + }, nil +} + +func admitQuery(raw any) (query, error) { + if raw == nil { + return query{MaxChanges: maxChanges, OwnerIDs: map[string]struct{}{}, RequirementIDs: map[string]struct{}{}}, nil + } + record, ok := raw.(map[string]any) + if !ok { + return query{}, fmt.Errorf("requirement semantic diff query must be an object") + } + if err := admit.KnownKeys(record, []string{"maxChanges", "ownerIds", "requirementIds"}, "requirement semantic diff query"); err != nil { + return query{}, err + } + limit := maxChanges + if record["maxChanges"] != nil { + value, err := admit.PositiveInteger(record["maxChanges"], "requirement semantic diff maxChanges") + if err != nil || value > maxChanges { + return query{}, fmt.Errorf("requirement semantic diff maxChanges must be between 1 and %d", maxChanges) + } + limit = value + } + owners, err := admitIDSet(record["ownerIds"], "requirement semantic diff ownerIds") + if err != nil { + return query{}, err + } + requirements, err := admitIDSet(record["requirementIds"], "requirement semantic diff requirementIds") + if err != nil { + return query{}, err + } + return query{MaxChanges: limit, OwnerIDs: owners, RequirementIDs: requirements}, nil +} + +func admitIDSet(raw any, context string) (map[string]struct{}, error) { + result := map[string]struct{}{} + if raw == nil { + return result, nil + } + values, ok := raw.([]any) + if !ok { + return nil, fmt.Errorf("%s must be an array", context) + } + for _, rawValue := range values { + value, err := admit.RuleID(rawValue, context) + if err != nil { + return nil, err + } + if _, exists := result[value]; exists { + return nil, fmt.Errorf("%s must be unique", context) + } + result[value] = struct{}{} + } + return result, nil +} + +func requirementsByID(snapshot requirementcontext.Snapshot, requirementFilter map[string]struct{}) map[string]requirementRecord { + digestBySource := map[string]string{} + for _, source := range snapshot.Sources { + if source.Kind == "requirement_source" { + digestBySource[source.SourceRef] = source.CurrentDigest + } + } + result := map[string]requirementRecord{} + for _, source := range snapshot.RequirementSources { + for _, requirement := range source.Requirements { + if len(requirementFilter) > 0 { + if _, ok := requirementFilter[requirement.RequirementID]; !ok { + continue + } + } + result[requirement.RequirementID] = requirementRecord{Digest: digestBySource[source.SourceID], Requirement: requirement} + } + } + return result +} + +func compareRequirements(base, current requirementcontext.Snapshot, before, after map[string]requirementRecord, ownerFilter map[string]struct{}, limit int) ([]any, error) { + ids := map[string]struct{}{} + for id := range before { + ids[id] = struct{}{} + } + for id := range after { + ids[id] = struct{}{} + } + ordered := make([]string, 0, len(ids)) + for id := range ids { + ordered = append(ordered, id) + } + sort.Strings(ordered) + changes := []any{} + for _, id := range ordered { + baseRecord, beforeOK := before[id] + currentRecord, afterOK := after[id] + if !matchesOwnerFilter(baseRecord, beforeOK, currentRecord, afterOK, ownerFilter) { + continue + } + if !beforeOK || !afterOK { + changeClass := "entity_added" + var beforeValue any + var afterValue any + if !afterOK { + changeClass = "entity_removed" + beforeValue = requirementsourceadmission.RequirementValue(baseRecord.Requirement) + } else { + afterValue = requirementsourceadmission.RequirementValue(currentRecord.Requirement) + } + change, err := changeValue(base, current, id, "", changeClass, baseRecord, currentRecord, beforeValue, afterValue) + if err != nil { + return nil, err + } + changes = append(changes, change) + } else { + fieldChanges, err := compareFields(base, current, baseRecord, currentRecord) + if err != nil { + return nil, err + } + changes = append(changes, fieldChanges...) + } + if len(changes) > limit { + return nil, fmt.Errorf("requirement semantic diff exceeds maxChanges") + } + } + sortChangesCanonical(changes) + return changes, nil +} + +func matchesOwnerFilter(before requirementRecord, beforeOK bool, after requirementRecord, afterOK bool, owners map[string]struct{}) bool { + if len(owners) == 0 { + return true + } + if beforeOK { + if _, ok := owners[before.Requirement.OwnerID]; ok { + return true + } + } + if afterOK { + if _, ok := owners[after.Requirement.OwnerID]; ok { + return true + } + } + return false +} + +func compareFields(base, current requirementcontext.Snapshot, before, after requirementRecord) ([]any, error) { + left := requirementsourceadmission.ComparisonFields(before.Requirement) + right := requirementsourceadmission.ComparisonFields(after.Requirement) + if len(left) != len(right) { + return nil, fmt.Errorf("requirement semantic diff owner comparison projections are incompatible") + } + changes := []any{} + for index := range left { + if left[index].Name != right[index].Name || left[index].Class != right[index].Class { + return nil, fmt.Errorf("requirement semantic diff owner comparison metadata is incompatible") + } + beforeValue := normalizedField(left[index].Value, left[index].Class) + afterValue := normalizedField(right[index].Value, right[index].Class) + if reflect.DeepEqual(beforeValue, afterValue) { + continue + } + changeClass := comparisonChangeClass(left[index].Name, left[index].Class) + change, err := changeValue(base, current, before.Requirement.RequirementID, left[index].Name, changeClass, before, after, beforeValue, afterValue) + if err != nil { + return nil, err + } + changes = append(changes, change) + } + return changes, nil +} + +func comparisonChangeClass(field, class string) string { + if field == "lifecycle" { + return "lifecycle_transition" + } + switch class { + case "scalar": + return "scalar_changed" + case "set": + return "set_membership_changed" + default: + return "opaque_value_changed" + } +} + +func changeValue(base, current requirementcontext.Snapshot, requirementID, field, changeClass string, before, after requirementRecord, beforeValue, afterValue any) (map[string]any, error) { + pointer := "/requirements/" + escapePointer(requirementID) + if field != "" { + pointer += "/" + escapePointer(field) + } + identity := map[string]any{ + "after": afterValue, + "baseSnapshotId": base.SnapshotID, + "baseSourceDigest": before.Digest, + "before": beforeValue, + "changeClass": changeClass, + "currentSnapshotId": current.SnapshotID, + "currentSourceDigest": after.Digest, + "entityId": requirementID, + "entityKind": "requirement", + "jsonPointer": pointer, + } + encoded, err := stablejson.Marshal(identity) + if err != nil { + return nil, err + } + return map[string]any{ + "after": afterValue, + "baseSourceDigest": before.Digest, + "before": beforeValue, + "changeClass": changeClass, + "changeId": digest.SHA256TextRef(string(encoded)), + "currentSourceDigest": after.Digest, + "entityId": requirementID, + "entityKind": "requirement", + "jsonPointer": pointer, + }, nil +} + +func normalizedField(value any, class string) any { + if class != "set" { + return value + } + values, _ := value.([]any) + texts := make([]string, 0, len(values)) + for _, item := range values { + if text, ok := item.(string); ok { + texts = append(texts, text) + } + } + sort.Strings(texts) + result := make([]any, len(texts)) + for index, item := range texts { + result[index] = item + } + return result +} + +func escapePointer(value string) string { + return strings.ReplaceAll(strings.ReplaceAll(value, "~", "~0"), "/", "~1") +} diff --git a/internal/command/requirementdiff/requirementdiff_test.go b/internal/command/requirementdiff/requirementdiff_test.go new file mode 100644 index 0000000..db7c588 --- /dev/null +++ b/internal/command/requirementdiff/requirementdiff_test.go @@ -0,0 +1,211 @@ +package requirementdiff + +import ( + "bytes" + "encoding/json" + "testing" + + "github.com/research-engineering/agentic-proofkit/internal/command/requirementcontext" + "github.com/research-engineering/agentic-proofkit/internal/kernel/admission" + "github.com/research-engineering/agentic-proofkit/internal/kernel/digest" + "github.com/research-engineering/agentic-proofkit/internal/kernel/stablejson" + "github.com/research-engineering/agentic-proofkit/internal/testsupport/commandcoverage" +) + +func TestBuildClassifiesOwnerAwareRequirementChanges(t *testing.T) { + commandcoverage.SemanticRoute(t, "proofkit.command_coverage.source_oracle.v1.070793924321910266811017548213459952142613821158702213873670242368963866904489") + baseline := contextFixture(t, "The system preserves the baseline.") + current := contextFixture(t, "The system preserves the revised invariant.") + output, err := Build(map[string]any{"schemaVersion": json.Number("1"), "diffId": "consumer.requirement.diff", "baseContext": baseline, "currentContext": current, "query": map[string]any{"requirementIds": []any{"REQ-CONSUMER-001"}}}) + if err != nil { + t.Fatalf("Build() error = %v", err) + } + if output["changeCount"] != 1 { + t.Fatalf("changeCount = %v, want 1", output["changeCount"]) + } + change := output["changes"].([]any)[0].(map[string]any) + if change["changeClass"] != "scalar_changed" { + t.Fatalf("change class = %v", change["changeClass"]) + } + if change["jsonPointer"] != "/requirements/REQ-CONSUMER-001/invariant" { + t.Fatalf("change pointer = %v", change["jsonPointer"]) + } + encoded, err := stablejson.Marshal(output) + if err != nil { + t.Fatal(err) + } + decoded, err := admission.DecodeJSON(bytes.NewReader(encoded), 1<<20) + if err != nil { + t.Fatal(err) + } + if _, err := AdmitOutput(decoded, current["snapshotId"].(string)); err != nil { + t.Fatalf("AdmitOutput() error = %v", err) + } + decoded.(map[string]any)["changes"].([]any)[0].(map[string]any)["after"] = "tampered without updating identity" + if _, err := AdmitOutput(decoded, current["snapshotId"].(string)); err == nil { + t.Fatal("AdmitOutput accepted changed semantic facts under a stale changeId") + } +} + +func TestBuildOutputIsClosedUnderAdmissionForMultipleChanges(t *testing.T) { + baseline := contextFixture(t, "The system preserves the baseline.") + current := contextFixture(t, "The system preserves the revised invariant.") + requirement := current["projections"].(map[string]any)["requirementSources"].([]any)[0].(map[string]any)["requirements"].([]any)[0].(map[string]any) + requirement["claimLevel"] = "advisory" + requirement["nonClaims"] = []any{"The revised requirement does not approve merge."} + requirement["ownerId"] = "consumer.next-owner" + requirement["riskClass"] = "critical" + requirement["updatePolicy"].(map[string]any)["reviewOwnerId"] = "consumer.next-owner" + resignContextFixture(t, current) + + output, err := Build(map[string]any{"baseContext": baseline, "currentContext": current, "diffId": "consumer.multi-field.diff", "schemaVersion": json.Number("1")}) + if err != nil { + t.Fatal(err) + } + if output["changeCount"] != 6 { + t.Fatalf("changeCount = %v, want 6", output["changeCount"]) + } + encoded, err := stablejson.Marshal(output) + if err != nil { + t.Fatal(err) + } + decoded, err := admission.DecodeJSON(bytes.NewReader(encoded), int64(len(encoded))) + if err != nil { + t.Fatal(err) + } + if _, err := AdmitOutput(decoded, current["snapshotId"].(string)); err != nil { + t.Fatalf("producer output was rejected by its admission owner: %v", err) + } + changes := decoded.(map[string]any)["changes"].([]any) + changes[0], changes[1] = changes[1], changes[0] + if _, err := AdmitOutput(decoded, current["snapshotId"].(string)); err == nil { + t.Fatal("AdmitOutput accepted non-canonical semantic change order") + } +} + +func TestOwnerFilterPreservesStableIdentityAcrossOwnershipChange(t *testing.T) { + baseline := contextFixture(t, "The system preserves the same invariant.") + current := contextFixture(t, "The system preserves the same invariant.") + requirement := current["projections"].(map[string]any)["requirementSources"].([]any)[0].(map[string]any)["requirements"].([]any)[0].(map[string]any) + requirement["ownerId"] = "consumer.next-owner" + resignContextFixture(t, current) + + for _, item := range []struct { + ownerID string + wantChange bool + }{ + {ownerID: "consumer.owner", wantChange: true}, + {ownerID: "consumer.next-owner", wantChange: true}, + {ownerID: "consumer.unrelated-owner", wantChange: false}, + } { + t.Run(item.ownerID, func(t *testing.T) { + output, err := Build(map[string]any{ + "baseContext": baseline, "currentContext": current, "diffId": "consumer.owner-transition.diff", "schemaVersion": json.Number("1"), + "query": map[string]any{"ownerIds": []any{item.ownerID}}, + }) + if err != nil { + t.Fatal(err) + } + changes := output["changes"].([]any) + if !item.wantChange { + if len(changes) != 0 { + t.Fatalf("unrelated owner selected changes: %#v", changes) + } + return + } + if len(changes) != 1 { + t.Fatalf("changes = %#v, want one owner transition", changes) + } + change := changes[0].(map[string]any) + if change["changeClass"] != "scalar_changed" || change["jsonPointer"] != "/requirements/REQ-CONSUMER-001/ownerId" || change["before"] != "consumer.owner" || change["after"] != "consumer.next-owner" { + t.Fatalf("owner transition lost stable identity: %#v", change) + } + }) + } +} + +func TestBuildIncludesDeferralAndAdmissionBindsChangeIdentity(t *testing.T) { + base := deferredContextFixture(t, "Review after the migration window.") + current := deferredContextFixture(t, "Review after the compatibility window.") + output, err := Build(map[string]any{"baseContext": base, "currentContext": current, "diffId": "consumer.deferral.diff", "schemaVersion": json.Number("1")}) + if err != nil { + t.Fatal(err) + } + if output["changeCount"] != 1 || output["changes"].([]any)[0].(map[string]any)["jsonPointer"] != "/requirements/REQ-CONSUMER-001/deferral" { + t.Fatalf("deferral change was not projected: %#v", output) + } + encoded, _ := stablejson.Marshal(output) + decoded, err := admission.DecodeJSON(bytes.NewReader(encoded), int64(len(encoded))) + if err != nil { + t.Fatal(err) + } + tampered := decoded.(map[string]any) + tampered["changes"].([]any)[0].(map[string]any)["changeId"] = "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + if _, err := AdmitOutput(tampered, current["snapshotId"].(string)); err == nil { + t.Fatal("AdmitOutput accepted a changeId unrelated to the admitted change") + } +} + +func deferredContextFixture(t *testing.T, reviewCondition string) map[string]any { + t.Helper() + value := contextFixture(t, "The deferred contract remains explicit.") + projections := value["projections"].(map[string]any) + requirement := projections["requirementSources"].([]any)[0].(map[string]any)["requirements"].([]any)[0].(map[string]any) + requirement["claimLevel"] = "deferred" + requirement["deferral"] = map[string]any{"evidenceRefs": []any{"docs/evidence/deferral.json"}, "expiryRef": "consumer.expiry", "mergePolicy": "consumer.deferred", "ownerId": "consumer.owner", "reviewCondition": reviewCondition, "riskAcceptedBy": "consumer.owner"} + identity := map[string]any{"catalogId": value["catalogId"], "projections": projections, "sources": []any{map[string]any{"currentDigest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "expectedDigest": "", "kind": "requirement_source", "nodeId": "spec.root", "path": "docs/specs/consumer/requirements.v1.json", "sourceRef": "consumer.requirements", "sourceRole": "requirements"}, map[string]any{"currentDigest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "expectedDigest": "", "kind": "spec_tree", "path": "proofkit/spec-tree.json", "sourceRef": "spec_tree:consumer.spec-tree"}}} + encoded, err := stablejson.Marshal(identity) + if err != nil { + t.Fatal(err) + } + value["snapshotId"] = digest.SHA256TextRef(string(encoded)) + return value +} + +func contextFixture(t *testing.T, invariant string) map[string]any { + t.Helper() + projections := map[string]any{ + "specTree": treeFixture(), + "requirementSources": []any{map[string]any{ + "schemaVersion": json.Number("1"), "sourceId": "consumer.requirements", "specPackagePath": "docs/specs/consumer", "overviewPath": "docs/specs/consumer/overview.md", "requirementsPath": "docs/specs/consumer/requirements.v1.json", "nonClaims": []any{"Consumer source does not approve merge."}, + "requirements": []any{map[string]any{"requirementId": "REQ-CONSUMER-001", "ownerId": "consumer.owner", "invariant": invariant, "claimLevel": "blocking", "riskClass": "high", "proofBindingRefs": []any{"proofkit/requirement-bindings.json"}, "nonClaimRefs": []any{"NC-CONSUMER-001"}, "nonClaims": []any{"This requirement does not approve merge."}, "lifecycle": map[string]any{"state": "active", "replacementRequirementIds": []any{}, "evidenceRefs": []any{}}, "updatePolicy": map[string]any{"reviewOwnerId": "consumer.owner", "requiresImpactDeclaration": true, "requiresProofBindingReview": true}}}, + }}, + } + sources := []requirementcontext.Source{{CurrentDigest: "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", Kind: "requirement_source", NodeID: "spec.root", Path: "docs/specs/consumer/requirements.v1.json", SourceRef: "consumer.requirements", SourceRole: "requirements"}, {CurrentDigest: "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", Kind: "spec_tree", Path: "proofkit/spec-tree.json", SourceRef: "spec_tree:consumer.spec-tree"}} + identity := map[string]any{"catalogId": "consumer.context", "projections": projections, "sources": []any{map[string]any{"currentDigest": sources[0].CurrentDigest, "expectedDigest": "", "kind": sources[0].Kind, "nodeId": sources[0].NodeID, "path": sources[0].Path, "sourceRef": sources[0].SourceRef, "sourceRole": sources[0].SourceRole}, map[string]any{"currentDigest": sources[1].CurrentDigest, "expectedDigest": "", "kind": sources[1].Kind, "path": sources[1].Path, "sourceRef": sources[1].SourceRef}}} + encoded, err := stablejson.Marshal(identity) + if err != nil { + t.Fatal(err) + } + return requirementcontext.SnapshotValue(requirementcontext.Snapshot{BaselineVerification: "unverified", CatalogID: "consumer.context", Projections: projections, SnapshotID: digest.SHA256TextRef(string(encoded)), Sources: sources}) +} + +func resignContextFixture(t *testing.T, value map[string]any) { + t.Helper() + identitySources := make([]any, 0, len(value["sources"].([]any))) + for _, raw := range value["sources"].([]any) { + source := raw.(map[string]any) + identity := map[string]any{ + "currentDigest": source["currentDigest"], + "expectedDigest": "", + "kind": source["kind"], + "path": source["path"], + "sourceRef": source["sourceRef"], + } + for _, key := range []string{"expectedDigest", "nodeId", "sourceRole"} { + if source[key] != nil { + identity[key] = source[key] + } + } + identitySources = append(identitySources, identity) + } + encoded, err := stablejson.Marshal(map[string]any{"catalogId": value["catalogId"], "projections": value["projections"], "sources": identitySources}) + if err != nil { + t.Fatal(err) + } + value["snapshotId"] = digest.SHA256TextRef(string(encoded)) +} + +func treeFixture() map[string]any { + return map[string]any{"schemaVersion": json.Number("2"), "treeId": "consumer.spec-tree", "rootNodeId": "spec.root", "callerAnnotations": []any{}, "edges": []any{}, "overlays": []any{}, "nodes": []any{map[string]any{"nodeId": "spec.root", "nodeKind": "meta_spec", "label": "Root", "displayOrder": json.Number("1"), "callerAnnotations": []any{}, "sourceRefs": []any{map[string]any{"sourceRefId": "spec.root.requirements", "sourceRefKind": "source_id", "sourceRole": "requirements", "sourceId": "consumer.requirements"}}}}} +} diff --git a/internal/command/requirementgraph/output_admission.go b/internal/command/requirementgraph/output_admission.go new file mode 100644 index 0000000..6f7cb58 --- /dev/null +++ b/internal/command/requirementgraph/output_admission.go @@ -0,0 +1,361 @@ +package requirementgraph + +import ( + "bytes" + "encoding/json" + "fmt" + "regexp" + "sort" + "strings" + + "github.com/research-engineering/agentic-proofkit/internal/kernel/admission" + "github.com/research-engineering/agentic-proofkit/internal/kernel/admit" + "github.com/research-engineering/agentic-proofkit/internal/kernel/secretjson" + "github.com/research-engineering/agentic-proofkit/internal/kernel/stablejson" +) + +var derivedGraphIDPattern = regexp.MustCompile(`^(proof|proof-edge|spec-edge|declaration-edge|code-parent-edge|code-edge|execution-edge):[a-f0-9]{64}$`) + +func AdmitOutput(raw any, snapshotID string) (map[string]any, error) { + record, ok := raw.(map[string]any) + if !ok { + return nil, fmt.Errorf("requirement traceability graph output must be an object") + } + if err := admit.KnownKeys(record, []string{"edgeCount", "edges", "graphId", "graphKind", "nodeCount", "nodes", "nonClaims", "schemaVersion", "snapshotId"}, "requirement traceability graph output"); err != nil { + return nil, err + } + if (!admit.JSONNumberEquals(record["schemaVersion"], 1) && record["schemaVersion"] != 1) || record["graphKind"] != "proofkit.requirement-traceability-graph" || record["snapshotId"] != snapshotID { + return nil, fmt.Errorf("requirement traceability graph output identity is invalid") + } + if _, err := admit.RuleID(record["graphId"], "requirement traceability graph graphId"); err != nil { + return nil, err + } + rawNodes, ok := record["nodes"].([]any) + if !ok { + return nil, fmt.Errorf("requirement traceability graph nodes must be an array") + } + rawEdges, ok := record["edges"].([]any) + if !ok { + return nil, fmt.Errorf("requirement traceability graph edges must be an array") + } + if !graphCountEquals(record["nodeCount"], len(rawNodes)) || !graphCountEquals(record["edgeCount"], len(rawEdges)) { + return nil, fmt.Errorf("requirement traceability graph counts must match records") + } + nodes := make([]map[string]any, 0, len(rawNodes)) + nodesByID := map[string]map[string]any{} + for index, rawNode := range rawNodes { + node, ok := rawNode.(map[string]any) + if !ok { + return nil, fmt.Errorf("requirement traceability graph nodes[%d] must be an object", index) + } + if err := admitGraphNode(node); err != nil { + return nil, err + } + nodes = append(nodes, node) + nodesByID[node["nodeId"].(string)] = node + } + edges := make([]map[string]any, 0, len(rawEdges)) + for index, rawEdge := range rawEdges { + edge, ok := rawEdge.(map[string]any) + if !ok { + return nil, fmt.Errorf("requirement traceability graph edges[%d] must be an object", index) + } + if err := admitGraphEdge(edge); err != nil { + return nil, err + } + if err := admitGraphRelation(edge, nodesByID); err != nil { + return nil, err + } + edges = append(edges, edge) + } + if err := uniqueGraphIdentities(nodes, edges); err != nil { + return nil, err + } + findings, err := secretjson.Scan(record, "traceability_graph") + if err != nil { + return nil, err + } + if len(findings) > 0 { + return nil, fmt.Errorf("requirement traceability graph output contains secret-shaped data") + } + if err := exactGraphNonClaims(record["nonClaims"]); err != nil { + return nil, err + } + if !sort.SliceIsSorted(nodes, func(left, right int) bool { return nodes[left]["nodeId"].(string) < nodes[right]["nodeId"].(string) }) || !sort.SliceIsSorted(edges, func(left, right int) bool { return edges[left]["edgeId"].(string) < edges[right]["edgeId"].(string) }) { + return nil, fmt.Errorf("requirement traceability graph records must be canonically sorted") + } + encoded, err := stablejson.Marshal(record) + if err != nil { + return nil, err + } + decoded, err := admission.DecodeJSON(bytes.NewReader(encoded), int64(len(encoded))) + if err != nil { + return nil, err + } + return decoded.(map[string]any), nil +} + +func admitGraphNode(node map[string]any) error { + plane, err := admit.Enum(node["evidencePlane"], map[string]struct{}{"code_traceability": {}, "native_execution_coverage": {}, "proof_coverage": {}, "specification_coverage": {}}, "requirement traceability graph node evidencePlane") + if err != nil { + return err + } + keys := []string{"evidencePlane", "kind", "label", "nodeId", "sourceId"} + if plane == "code_traceability" { + keys = append(keys, "byteEnd", "byteStart", "coordinateUnit", "currentnessState", "parentNodeId", "rangeVerification", "sourceDigest", "symbolId") + } + if plane == "native_execution_coverage" { + keys = append(keys, "authorityClass", "currentnessState", "producerId", "state") + } + if plane == "proof_coverage" { + keys = append(keys, "requirementId", "scenarioId", "witnessId", "witnessKind", "witnessPath") + } + if plane == "specification_coverage" { + if _, err := admit.Enum(node["kind"], map[string]struct{}{"capability_spec": {}, "meta_spec": {}, "module_spec": {}, "requirement": {}, "submodule_spec": {}}, "requirement traceability graph specification node kind"); err != nil { + return err + } + } + if plane == "proof_coverage" { + if node["kind"] != "scenario" { + return fmt.Errorf("requirement traceability graph proof node kind must be scenario") + } + for _, key := range []string{"requirementId", "scenarioId", "witnessId", "witnessKind"} { + if _, err := admit.RuleID(node[key], "requirement traceability graph proof node "+key); err != nil { + return err + } + } + witnessPath, err := admit.NonEmptyText(node["witnessPath"], "requirement traceability graph proof node witnessPath") + if err != nil { + return err + } + if _, err := admit.SafeRepoRelativePath(witnessPath, "requirement traceability graph proof node witnessPath"); err != nil { + return err + } + } + if err := admit.KnownKeys(node, keys, "requirement traceability graph node"); err != nil { + return err + } + if _, err := admitGraphID(node["nodeId"], "requirement traceability graph nodeId"); err != nil { + return err + } + if _, err := admit.NonEmptyText(node["label"], "requirement traceability graph node label"); err != nil { + return err + } + if _, err := admit.NonEmptyText(node["kind"], "requirement traceability graph node kind"); err != nil { + return err + } + if _, err := admit.NonEmptyText(node["sourceId"], "requirement traceability graph node sourceId"); err != nil { + return err + } + if err := admitGraphNodeIdentity(node); err != nil { + return err + } + if plane == "code_traceability" { + if _, err := admit.Enum(node["kind"], map[string]struct{}{"file": {}, "module": {}, "package": {}, "repository": {}, "source_range": {}, "symbol": {}}, "requirement traceability graph code node kind"); err != nil { + return err + } + if _, err := admit.Enum(node["currentnessState"], map[string]struct{}{"current": {}, "stale": {}, "unverified": {}}, "requirement traceability graph code node currentnessState"); err != nil { + return err + } + if _, err := digestRef(node["sourceDigest"], "requirement traceability graph code node sourceDigest"); err != nil { + return err + } + if node["kind"] == "source_range" { + if node["coordinateUnit"] != "utf8_byte" { + return fmt.Errorf("requirement traceability graph range coordinateUnit must be utf8_byte") + } + if _, err := admit.Enum(node["rangeVerification"], map[string]struct{}{"unverified": {}, "verified": {}}, "requirement traceability graph rangeVerification"); err != nil { + return err + } + start, err := nonNegativeInteger(node["byteStart"], "requirement traceability graph byteStart") + if err != nil { + return err + } + end, err := nonNegativeInteger(node["byteEnd"], "requirement traceability graph byteEnd") + if err != nil || end <= start { + return fmt.Errorf("requirement traceability graph range must be non-empty and half-open") + } + } + } + if plane == "native_execution_coverage" { + if _, err := admit.Enum(node["authorityClass"], map[string]struct{}{"caller_reported": {}, "receipt_admitted": {}}, "requirement traceability graph execution authorityClass"); err != nil { + return err + } + if _, err := admit.Enum(node["currentnessState"], map[string]struct{}{"current": {}, "stale": {}, "unverified": {}}, "requirement traceability graph execution currentnessState"); err != nil { + return err + } + if _, err := admit.Enum(node["state"], map[string]struct{}{"failed": {}, "passed": {}, "skipped": {}, "unavailable": {}}, "requirement traceability graph execution state"); err != nil { + return err + } + if _, err := admit.RuleID(node["producerId"], "requirement traceability graph execution producerId"); err != nil { + return err + } + } + return nil +} + +func admitGraphEdge(edge map[string]any) error { + plane, err := admit.Enum(edge["evidencePlane"], map[string]struct{}{"code_traceability": {}, "native_execution_coverage": {}, "proof_coverage": {}, "specification_coverage": {}}, "requirement traceability graph edge evidencePlane") + if err != nil { + return err + } + keys := []string{"edgeId", "edgeKind", "evidencePlane", "fromNodeId", "toNodeId"} + if plane == "code_traceability" && edge["edgeKind"] == "traced_to" { + keys = append(keys, "authorityClass", "currentnessState", "evidenceRefs") + } + if plane == "native_execution_coverage" && edge["edgeKind"] == "observed_by" { + keys = append(keys, "codeNodeId") + } + if plane == "native_execution_coverage" && edge["edgeKind"] == "observed_by" { + if _, err := admit.RuleID(edge["codeNodeId"], "requirement traceability graph edge codeNodeId"); err != nil { + return err + } + } + if err := admit.KnownKeys(edge, keys, "requirement traceability graph edge"); err != nil { + return err + } + if _, err := admitGraphID(edge["edgeId"], "requirement traceability graph edgeId"); err != nil { + return err + } + if _, err := admit.Enum(edge["edgeKind"], map[string]struct{}{"contains": {}, "declares": {}, "observed_by": {}, "proved_by_candidate": {}, "traced_to": {}}, "requirement traceability graph edgeKind"); err != nil { + return err + } + for _, key := range []string{"fromNodeId", "toNodeId"} { + if _, err := admit.RuleID(edge[key], "requirement traceability graph edge "+key); err != nil { + return err + } + } + if plane == "code_traceability" && edge["edgeKind"] == "traced_to" { + if _, err := admit.Enum(edge["authorityClass"], map[string]struct{}{"caller_reported": {}, "owner_admitted": {}}, "requirement traceability graph edge authorityClass"); err != nil { + return err + } + if _, err := admit.Enum(edge["currentnessState"], map[string]struct{}{"current": {}, "stale": {}, "unverified": {}}, "requirement traceability graph edge currentnessState"); err != nil { + return err + } + values, err := admittedRuleIDArray(edge["evidenceRefs"], "requirement traceability graph edge evidenceRefs") + if err != nil || len(values) == 0 { + return fmt.Errorf("requirement traceability graph edge evidenceRefs must be non-empty") + } + } + if err := admitGraphEdgeIdentity(edge); err != nil { + return err + } + return nil +} + +func admitGraphNodeIdentity(node map[string]any) error { + nodeID := node["nodeId"].(string) + sourceID := node["sourceId"].(string) + switch node["evidencePlane"] { + case "specification_coverage": + if node["kind"] == "requirement" { + if nodeID != "requirement:"+sourceID || node["label"] != sourceID { + return fmt.Errorf("requirement traceability graph requirement node identity is invalid") + } + } else if nodeID != "spec:"+sourceID { + return fmt.Errorf("requirement traceability graph specification node identity is invalid") + } + case "proof_coverage": + identity := map[string]any{"requirementId": node["requirementId"], "scenarioId": node["scenarioId"], "witnessId": node["witnessId"], "witnessKind": node["witnessKind"], "witnessPath": node["witnessPath"]} + expected, err := semanticGraphID("proof", identity) + if err != nil || nodeID != expected || node["label"] != node["scenarioId"] || sourceID != node["witnessId"] { + return fmt.Errorf("requirement traceability graph proof node identity is invalid") + } + case "native_execution_coverage": + if nodeID != "execution:"+sourceID || node["label"] != sourceID { + return fmt.Errorf("requirement traceability graph execution node identity is invalid") + } + } + return nil +} + +func admitGraphEdgeIdentity(edge map[string]any) error { + edgeID := edge["edgeId"].(string) + fromID := edge["fromNodeId"].(string) + toID := edge["toNodeId"].(string) + prefix := strings.SplitN(edgeID, ":", 2)[0] + var identity map[string]any + switch prefix { + case "spec-edge", "declaration-edge", "code-parent-edge": + identity = map[string]any{"edgeKind": edge["edgeKind"], "fromNodeId": fromID, "toNodeId": toID} + case "proof-edge": + identity = map[string]any{"fromNodeId": fromID, "toNodeId": toID} + case "code-edge": + identity = map[string]any{"authorityClass": edge["authorityClass"], "codeNodeId": strings.TrimPrefix(toID, "code:"), "currentnessState": edge["currentnessState"], "evidenceRefs": edge["evidenceRefs"], "requirementId": strings.TrimPrefix(fromID, "requirement:")} + case "execution-edge": + identity = map[string]any{"codeNodeId": strings.TrimPrefix(edge["codeNodeId"].(string), "code:"), "evidenceRef": strings.TrimPrefix(toID, "execution:"), "requirementId": strings.TrimPrefix(fromID, "requirement:")} + default: + return nil + } + expected, err := semanticGraphID(prefix, identity) + if err != nil || edgeID != expected { + return fmt.Errorf("requirement traceability graph edge identity is invalid") + } + return nil +} + +func admitGraphID(raw any, context string) (string, error) { + value, ok := raw.(string) + if ok && derivedGraphIDPattern.MatchString(value) { + return value, nil + } + return admit.RuleID(raw, context) +} + +func admitGraphRelation(edge map[string]any, nodes map[string]map[string]any) error { + from := nodes[edge["fromNodeId"].(string)] + to := nodes[edge["toNodeId"].(string)] + if from == nil || to == nil { + return fmt.Errorf("requirement traceability graph edge endpoint must resolve") + } + plane := edge["evidencePlane"].(string) + kind := edge["edgeKind"].(string) + valid := false + switch plane + ":" + kind { + case "specification_coverage:contains": + valid = from["evidencePlane"] == plane && to["evidencePlane"] == plane && from["kind"] != "requirement" && to["kind"] != "requirement" + case "specification_coverage:declares": + valid = from["evidencePlane"] == plane && from["kind"] != "requirement" && to["evidencePlane"] == plane && to["kind"] == "requirement" + case "proof_coverage:proved_by_candidate": + valid = from["evidencePlane"] == "specification_coverage" && from["kind"] == "requirement" && to["evidencePlane"] == plane && to["kind"] == "scenario" + case "code_traceability:contains": + valid = from["evidencePlane"] == plane && to["evidencePlane"] == plane + case "code_traceability:traced_to": + valid = from["evidencePlane"] == "specification_coverage" && from["kind"] == "requirement" && to["evidencePlane"] == plane + case "native_execution_coverage:observed_by": + codeNode := nodes[edge["codeNodeId"].(string)] + valid = from["evidencePlane"] == "specification_coverage" && from["kind"] == "requirement" && to["evidencePlane"] == plane && codeNode != nil && codeNode["evidencePlane"] == "code_traceability" + } + if !valid { + return fmt.Errorf("requirement traceability graph relation is incompatible with its evidence plane and endpoint kinds") + } + return nil +} + +func exactGraphNonClaims(raw any) error { + values, ok := raw.([]any) + if !ok || len(values) != len(nonClaims) { + return fmt.Errorf("requirement traceability graph nonClaims must equal the command-owned boundary") + } + for index, expected := range nonClaims { + if values[index] != expected { + return fmt.Errorf("requirement traceability graph nonClaims must equal the command-owned boundary") + } + } + return nil +} + +func graphCountEquals(raw any, expected int) bool { + if value, ok := raw.(int); ok { + return value == expected + } + number, ok := raw.(json.Number) + if !ok { + return false + } + if expected == 0 { + return admit.JSONNumberEquals(number, 0) + } + value, err := admit.PositiveInteger(number, "graph count") + return err == nil && value == expected +} diff --git a/internal/command/requirementgraph/requirementgraph.go b/internal/command/requirementgraph/requirementgraph.go new file mode 100644 index 0000000..4ff2c77 --- /dev/null +++ b/internal/command/requirementgraph/requirementgraph.go @@ -0,0 +1,580 @@ +package requirementgraph + +import ( + "encoding/json" + "fmt" + "reflect" + "sort" + "strconv" + "strings" + "unicode/utf8" + + "github.com/research-engineering/agentic-proofkit/internal/command/requirementcontext" + "github.com/research-engineering/agentic-proofkit/internal/command/requirementspectree" + "github.com/research-engineering/agentic-proofkit/internal/kernel/admit" + "github.com/research-engineering/agentic-proofkit/internal/kernel/digest" + "github.com/research-engineering/agentic-proofkit/internal/kernel/stablejson" +) + +const maxCodeSourceBytes = 4 << 20 + +const ( + maxGraphNodes = 20_000 + maxGraphEdges = 80_000 + maxGraphOutputBytes = 24 << 20 +) + +type codeSource struct { + content []byte + digest string +} + +var nonClaims = []string{ + "Traceability graph is a derived projection and does not infer code topology, native execution coverage, proof freshness, merge, release, or rollout readiness.", + "Specification, proof, code traceability, and native execution remain distinct evidence planes.", +} + +func Build(raw any) (map[string]any, error) { + record, ok := raw.(map[string]any) + if !ok { + return nil, fmt.Errorf("requirement traceability graph input must be an object") + } + if err := admit.KnownKeys(record, []string{"codeSources", "codeTopology", "context", "graphId", "schemaVersion"}, "requirement traceability graph input"); err != nil { + return nil, err + } + if !admit.JSONNumberEquals(record["schemaVersion"], 1) { + return nil, fmt.Errorf("requirement traceability graph schemaVersion must be 1") + } + graphID, err := admit.RuleID(record["graphId"], "requirement traceability graph graphId") + if err != nil { + return nil, err + } + snapshot, err := requirementcontext.AdmitSnapshot(record["context"]) + if err != nil { + return nil, err + } + nodes := []map[string]any{} + edges := []map[string]any{} + for _, node := range snapshot.Tree.Nodes { + nodes = append(nodes, map[string]any{"evidencePlane": "specification_coverage", "kind": node.NodeKind, "label": node.Label, "nodeId": "spec:" + node.NodeID, "sourceId": node.NodeID}) + } + for _, edge := range snapshot.Tree.Edges { + fromNodeID := "spec:" + edge.ParentNodeID + toNodeID := "spec:" + edge.ChildNodeID + edgeID, err := semanticGraphID("spec-edge", map[string]any{"edgeKind": "contains", "fromNodeId": fromNodeID, "toNodeId": toNodeID}) + if err != nil { + return nil, err + } + edges = append(edges, map[string]any{"edgeId": edgeID, "edgeKind": "contains", "evidencePlane": "specification_coverage", "fromNodeId": fromNodeID, "toNodeId": toNodeID}) + } + requirementNodeIDs, err := appendRequirementNodes(snapshot, &nodes) + if err != nil { + return nil, err + } + if err := appendSpecificationRequirementEdges(snapshot, snapshot.Tree, &edges); err != nil { + return nil, err + } + if err := appendProofEdges(snapshot, requirementNodeIDs, &nodes, &edges); err != nil { + return nil, err + } + codeSources, err := admitCodeSources(record["codeSources"]) + if err != nil { + return nil, err + } + if record["codeTopology"] != nil { + if err := appendCodeTopology(record["codeTopology"], codeSources, requirementNodeIDs, &nodes, &edges); err != nil { + return nil, err + } + } + if len(nodes) > maxGraphNodes || len(edges) > maxGraphEdges { + return nil, fmt.Errorf("requirement traceability graph exceeds node or edge limit") + } + if err := uniqueGraphIdentities(nodes, edges); err != nil { + return nil, err + } + sort.Slice(nodes, func(left, right int) bool { return nodes[left]["nodeId"].(string) < nodes[right]["nodeId"].(string) }) + sort.Slice(edges, func(left, right int) bool { return edges[left]["edgeId"].(string) < edges[right]["edgeId"].(string) }) + output := map[string]any{"edgeCount": len(edges), "edges": mapsToAny(edges), "graphId": graphID, "graphKind": "proofkit.requirement-traceability-graph", "nodeCount": len(nodes), "nodes": mapsToAny(nodes), "nonClaims": admit.StringSliceToAny(nonClaims), "schemaVersion": json.Number("1"), "snapshotId": snapshot.SnapshotID} + encoded, err := stablejson.Marshal(output) + if err != nil || len(encoded) > maxGraphOutputBytes { + return nil, fmt.Errorf("requirement traceability graph exceeds output byte limit") + } + return output, nil +} + +func appendSpecificationRequirementEdges(snapshot requirementcontext.Snapshot, tree requirementspectree.Tree, edges *[]map[string]any) error { + requirementsBySource := map[string][]string{} + for _, source := range snapshot.RequirementSources { + for _, requirement := range source.Requirements { + requirementsBySource[source.SourceID] = append(requirementsBySource[source.SourceID], requirement.RequirementID) + } + } + for _, node := range tree.Nodes { + for _, ref := range node.SourceRefs { + if ref.SourceRole != "requirements" { + continue + } + for _, requirementID := range requirementsBySource[ref.SourceID] { + if len(*edges) >= maxGraphEdges { + return fmt.Errorf("requirement traceability graph exceeds edge limit") + } + fromNodeID := "spec:" + node.NodeID + toNodeID := "requirement:" + requirementID + edgeID, err := semanticGraphID("declaration-edge", map[string]any{"edgeKind": "declares", "fromNodeId": fromNodeID, "toNodeId": toNodeID}) + if err != nil { + return err + } + *edges = append(*edges, map[string]any{"edgeId": edgeID, "edgeKind": "declares", "evidencePlane": "specification_coverage", "fromNodeId": fromNodeID, "toNodeId": toNodeID}) + } + } + } + return nil +} + +func uniqueGraphIdentities(nodes, edges []map[string]any) error { + seenNodes := map[string]struct{}{} + for _, node := range nodes { + id := node["nodeId"].(string) + if _, exists := seenNodes[id]; exists { + return fmt.Errorf("requirement traceability graph node ids must be unique") + } + seenNodes[id] = struct{}{} + } + seenEdges := map[string]struct{}{} + for _, edge := range edges { + id := edge["edgeId"].(string) + if _, exists := seenEdges[id]; exists { + return fmt.Errorf("requirement traceability graph edge ids must be unique") + } + seenEdges[id] = struct{}{} + if _, ok := seenNodes[edge["fromNodeId"].(string)]; !ok { + return fmt.Errorf("requirement traceability graph edge source must resolve") + } + if _, ok := seenNodes[edge["toNodeId"].(string)]; !ok { + return fmt.Errorf("requirement traceability graph edge target must resolve") + } + } + return nil +} + +func appendRequirementNodes(snapshot requirementcontext.Snapshot, nodes *[]map[string]any) (map[string]struct{}, error) { + ids := map[string]struct{}{} + for _, source := range snapshot.RequirementSources { + for _, requirement := range source.Requirements { + id := requirement.RequirementID + if _, exists := ids[id]; exists { + return nil, fmt.Errorf("requirement traceability graph requirement ids must be unique") + } + ids[id] = struct{}{} + *nodes = append(*nodes, map[string]any{"evidencePlane": "specification_coverage", "kind": "requirement", "label": id, "nodeId": "requirement:" + id, "sourceId": id}) + } + } + return ids, nil +} + +func appendProofEdges(snapshot requirementcontext.Snapshot, requirementIDs map[string]struct{}, nodes *[]map[string]any, edges *[]map[string]any) error { + if snapshot.ProofBinding == nil { + return nil + } + seenBindings := map[string]struct{}{} + for _, binding := range snapshot.ProofBinding.Bindings { + requirementID := binding.RequirementID + if _, ok := requirementIDs[requirementID]; !ok { + continue + } + identity := map[string]any{"requirementId": requirementID, "scenarioId": binding.ScenarioID, "witnessId": binding.WitnessID, "witnessKind": binding.WitnessKind, "witnessPath": binding.WitnessPath} + nodeID, err := semanticGraphID("proof", identity) + if err != nil { + return err + } + if _, duplicate := seenBindings[nodeID]; duplicate { + return fmt.Errorf("requirement traceability graph proof bindings must be unique") + } + seenBindings[nodeID] = struct{}{} + if len(*nodes) >= maxGraphNodes || len(*edges) >= maxGraphEdges { + return fmt.Errorf("requirement traceability graph exceeds node or edge limit") + } + *nodes = append(*nodes, map[string]any{"evidencePlane": "proof_coverage", "kind": "scenario", "label": binding.ScenarioID, "nodeId": nodeID, "requirementId": requirementID, "scenarioId": binding.ScenarioID, "sourceId": binding.WitnessID, "witnessId": binding.WitnessID, "witnessKind": binding.WitnessKind, "witnessPath": binding.WitnessPath}) + edgeID, err := semanticGraphID("proof-edge", map[string]any{"fromNodeId": "requirement:" + requirementID, "toNodeId": nodeID}) + if err != nil { + return err + } + *edges = append(*edges, map[string]any{"edgeId": edgeID, "edgeKind": "proved_by_candidate", "evidencePlane": "proof_coverage", "fromNodeId": "requirement:" + requirementID, "toNodeId": nodeID}) + } + return nil +} + +func appendCodeTopology(raw any, codeSources map[string]codeSource, requirementIDs map[string]struct{}, nodes *[]map[string]any, edges *[]map[string]any) error { + record, ok := raw.(map[string]any) + if !ok { + return fmt.Errorf("codeTopology must be an object") + } + if err := admit.KnownKeys(record, []string{"edges", "nativeCoverage", "nodes", "topologyId"}, "codeTopology"); err != nil { + return err + } + if _, err := admit.RuleID(record["topologyId"], "codeTopology topologyId"); err != nil { + return err + } + rawNodes, ok := record["nodes"].([]any) + if !ok { + return fmt.Errorf("codeTopology nodes must be an array") + } + type admittedCodeNode struct { + level string + parent string + } + levels := map[string]int{"repository": 0, "package": 1, "module": 2, "file": 3, "symbol": 4, "source_range": 5} + known := map[string]admittedCodeNode{} + for _, rawNode := range rawNodes { + node, ok := rawNode.(map[string]any) + if !ok { + return fmt.Errorf("codeTopology node must be an object") + } + if err := admit.KnownKeys(node, []string{"abstractionLevel", "byteEnd", "byteStart", "currentnessState", "label", "nodeId", "parentNodeId", "sourceDigest", "sourcePath", "symbolId"}, "codeTopology node"); err != nil { + return err + } + id, err := admit.RuleID(node["nodeId"], "codeTopology nodeId") + if err != nil { + return err + } + if _, exists := known[id]; exists { + return fmt.Errorf("codeTopology node ids must be unique") + } + label, err := admit.NonEmptyText(node["label"], "codeTopology node label") + if err != nil { + return err + } + level, err := admit.Enum(node["abstractionLevel"], map[string]struct{}{"file": {}, "module": {}, "package": {}, "repository": {}, "source_range": {}, "symbol": {}}, "codeTopology node abstractionLevel") + if err != nil { + return err + } + parentID := "" + if node["parentNodeId"] != nil { + parentID, err = admit.RuleID(node["parentNodeId"], "codeTopology node parentNodeId") + if err != nil { + return err + } + } + if (level == "repository") != (parentID == "") { + return fmt.Errorf("codeTopology repository nodes must be roots and every other node must declare parentNodeId") + } + pathText, err := admit.NonEmptyText(node["sourcePath"], "codeTopology node sourcePath") + if err != nil { + return err + } + path, err := admit.SafeRepoRelativePath(pathText, "codeTopology node sourcePath") + if err != nil { + return err + } + digestRef, err := digestRef(node["sourceDigest"], "codeTopology node sourceDigest") + if err != nil { + return err + } + currentness, err := admit.Enum(node["currentnessState"], map[string]struct{}{"current": {}, "stale": {}, "unverified": {}}, "codeTopology node currentnessState") + if err != nil { + return err + } + projected := map[string]any{"currentnessState": currentness, "evidencePlane": "code_traceability", "kind": level, "label": label, "nodeId": "code:" + id, "sourceDigest": digestRef, "sourceId": path} + if parentID != "" { + projected["parentNodeId"] = "code:" + parentID + } + if node["symbolId"] != nil { + symbolID, err := admit.RuleID(node["symbolId"], "codeTopology node symbolId") + if err != nil { + return err + } + projected["symbolId"] = symbolID + } + hasRange := node["byteStart"] != nil || node["byteEnd"] != nil + if (level == "source_range") != hasRange { + return fmt.Errorf("codeTopology source_range nodes require byteStart and byteEnd, and other levels must not declare them") + } + if hasRange { + start, err := nonNegativeInteger(node["byteStart"], "codeTopology node byteStart") + if err != nil { + return err + } + end, err := nonNegativeInteger(node["byteEnd"], "codeTopology node byteEnd") + if err != nil || end <= start { + return fmt.Errorf("codeTopology node byte range must be non-empty and half-open") + } + projected["byteEnd"] = end + projected["byteStart"] = start + projected["coordinateUnit"] = "utf8_byte" + projected["sourceDigest"] = digestRef + projected["rangeVerification"] = "unverified" + if source, exists := codeSources[path]; exists { + if source.digest != digestRef { + return fmt.Errorf("codeTopology node sourceDigest does not match codeSources content") + } + if end > len(source.content) || !utf8Boundary(source.content, start) || !utf8Boundary(source.content, end) { + return fmt.Errorf("codeTopology node byte range must resolve to UTF-8 boundaries in codeSources content") + } + projected["rangeVerification"] = "verified" + } + } + known[id] = admittedCodeNode{level: level, parent: parentID} + *nodes = append(*nodes, projected) + } + for id, node := range known { + if node.parent == "" { + continue + } + parent, exists := known[node.parent] + if !exists { + return fmt.Errorf("codeTopology node references unknown parent") + } + if levels[parent.level] >= levels[node.level] { + return fmt.Errorf("codeTopology parent abstraction level must be broader than child level") + } + fromNodeID := "code:" + node.parent + toNodeID := "code:" + id + edgeID, err := semanticGraphID("code-parent-edge", map[string]any{"edgeKind": "contains", "fromNodeId": fromNodeID, "toNodeId": toNodeID}) + if err != nil { + return err + } + *edges = append(*edges, map[string]any{"edgeId": edgeID, "edgeKind": "contains", "evidencePlane": "code_traceability", "fromNodeId": fromNodeID, "toNodeId": toNodeID}) + } + rawEdges, ok := record["edges"].([]any) + if !ok { + return fmt.Errorf("codeTopology edges must be an array") + } + seenRelations := map[string]struct{}{} + for _, rawEdge := range rawEdges { + edge, ok := rawEdge.(map[string]any) + if !ok { + return fmt.Errorf("codeTopology edge must be an object") + } + if err := admit.KnownKeys(edge, []string{"authorityClass", "codeNodeId", "currentnessState", "evidenceRefs", "requirementId"}, "codeTopology edge"); err != nil { + return err + } + codeID, err := admit.RuleID(edge["codeNodeId"], "codeTopology edge codeNodeId") + if err != nil { + return err + } + requirementID, err := admit.RuleID(edge["requirementId"], "codeTopology edge requirementId") + if err != nil { + return err + } + if _, ok := known[codeID]; !ok { + return fmt.Errorf("codeTopology edge references unknown code node") + } + if _, ok := requirementIDs[requirementID]; !ok { + return fmt.Errorf("codeTopology edge references unknown requirement") + } + evidenceRefs, err := admittedRuleIDArray(edge["evidenceRefs"], "codeTopology edge evidenceRefs") + if err != nil || len(evidenceRefs) == 0 { + return fmt.Errorf("codeTopology edge evidenceRefs must be a non-empty unique array") + } + authority, err := admit.Enum(edge["authorityClass"], map[string]struct{}{"caller_reported": {}, "owner_admitted": {}}, "codeTopology edge authorityClass") + if err != nil { + return err + } + currentness, err := admit.Enum(edge["currentnessState"], map[string]struct{}{"current": {}, "stale": {}, "unverified": {}}, "codeTopology edge currentnessState") + if err != nil { + return err + } + relation := map[string]any{"authorityClass": authority, "codeNodeId": codeID, "currentnessState": currentness, "evidenceRefs": admit.StringSliceToAny(evidenceRefs), "requirementId": requirementID} + edgeID, err := semanticGraphID("code-edge", relation) + if err != nil { + return err + } + if _, duplicate := seenRelations[edgeID]; duplicate { + return fmt.Errorf("codeTopology traceability relations must be unique") + } + seenRelations[edgeID] = struct{}{} + if len(*edges) >= maxGraphEdges { + return fmt.Errorf("requirement traceability graph exceeds edge limit") + } + *edges = append(*edges, map[string]any{"authorityClass": authority, "currentnessState": currentness, "edgeId": edgeID, "edgeKind": "traced_to", "evidencePlane": "code_traceability", "evidenceRefs": admit.StringSliceToAny(evidenceRefs), "fromNodeId": "requirement:" + requirementID, "toNodeId": "code:" + codeID}) + } + if rawCoverage := record["nativeCoverage"]; rawCoverage != nil { + coverage, ok := rawCoverage.([]any) + if !ok { + return fmt.Errorf("codeTopology nativeCoverage must be an array") + } + seenEvidence := map[string]map[string]any{} + seenObservations := map[string]struct{}{} + for _, raw := range coverage { + item, ok := raw.(map[string]any) + if !ok { + return fmt.Errorf("codeTopology native coverage must be an object") + } + if err := admit.KnownKeys(item, []string{"authorityClass", "codeNodeId", "currentnessState", "evidenceRef", "producerId", "requirementId", "state"}, "codeTopology native coverage"); err != nil { + return err + } + codeID, err := admit.RuleID(item["codeNodeId"], "codeTopology native coverage codeNodeId") + if err != nil { + return err + } + if _, ok := known[codeID]; !ok { + return fmt.Errorf("codeTopology native coverage references unknown code node") + } + requirementID, err := admit.RuleID(item["requirementId"], "codeTopology native coverage requirementId") + if err != nil { + return err + } + if _, ok := requirementIDs[requirementID]; !ok { + return fmt.Errorf("codeTopology native coverage references unknown requirement") + } + evidenceRef, err := admit.RuleID(item["evidenceRef"], "codeTopology native coverage evidenceRef") + if err != nil { + return err + } + state, err := admit.Enum(item["state"], map[string]struct{}{"failed": {}, "passed": {}, "skipped": {}, "unavailable": {}}, "codeTopology native coverage state") + if err != nil { + return err + } + authority, err := admit.Enum(item["authorityClass"], map[string]struct{}{"caller_reported": {}, "receipt_admitted": {}}, "codeTopology native coverage authorityClass") + if err != nil { + return err + } + currentness, err := admit.Enum(item["currentnessState"], map[string]struct{}{"current": {}, "stale": {}, "unverified": {}}, "codeTopology native coverage currentnessState") + if err != nil { + return err + } + producerID, err := admit.RuleID(item["producerId"], "codeTopology native coverage producerId") + if err != nil { + return err + } + nodeID := "execution:" + evidenceRef + evidenceNode := map[string]any{"authorityClass": authority, "currentnessState": currentness, "evidencePlane": "native_execution_coverage", "kind": "execution_evidence", "label": evidenceRef, "nodeId": nodeID, "producerId": producerID, "sourceId": evidenceRef, "state": state} + if previous, exists := seenEvidence[evidenceRef]; exists { + if !reflect.DeepEqual(previous, evidenceNode) { + return fmt.Errorf("codeTopology native coverage evidence identity has conflicting facts") + } + } else { + *nodes = append(*nodes, evidenceNode) + seenEvidence[evidenceRef] = evidenceNode + } + edgeID, err := semanticGraphID("execution-edge", map[string]any{"codeNodeId": codeID, "evidenceRef": evidenceRef, "requirementId": requirementID}) + if err != nil { + return err + } + if _, duplicate := seenObservations[edgeID]; duplicate { + return fmt.Errorf("codeTopology native coverage observations must be unique") + } + seenObservations[edgeID] = struct{}{} + if len(*nodes) > maxGraphNodes || len(*edges) >= maxGraphEdges { + return fmt.Errorf("requirement traceability graph exceeds node or edge limit") + } + *edges = append(*edges, map[string]any{"codeNodeId": "code:" + codeID, "edgeId": edgeID, "edgeKind": "observed_by", "evidencePlane": "native_execution_coverage", "fromNodeId": "requirement:" + requirementID, "toNodeId": nodeID}) + } + } + return nil +} + +func semanticGraphID(prefix string, identity map[string]any) (string, error) { + encoded, err := stablejson.Marshal(identity) + if err != nil { + return "", err + } + return prefix + ":" + strings.TrimPrefix(digest.SHA256TextRef(string(encoded)), "sha256:"), nil +} + +func admitCodeSources(raw any) (map[string]codeSource, error) { + if raw == nil { + return map[string]codeSource{}, nil + } + values, ok := raw.([]any) + if !ok { + return nil, fmt.Errorf("requirement traceability graph codeSources must be an array") + } + result := make(map[string]codeSource, len(values)) + totalBytes := 0 + for index, rawValue := range values { + record, ok := rawValue.(map[string]any) + if !ok { + return nil, fmt.Errorf("requirement traceability graph codeSources[%d] must be an object", index) + } + if err := admit.KnownKeys(record, []string{"content", "path"}, "requirement traceability graph code source"); err != nil { + return nil, err + } + pathText, err := admit.NonEmptyText(record["path"], "requirement traceability graph code source path") + if err != nil { + return nil, err + } + path, err := admit.SafeRepoRelativePath(pathText, "requirement traceability graph code source path") + if err != nil { + return nil, err + } + if _, exists := result[path]; exists { + return nil, fmt.Errorf("requirement traceability graph code source paths must be unique") + } + content, ok := record["content"].(string) + if !ok || !utf8.ValidString(content) { + return nil, fmt.Errorf("requirement traceability graph code source content must be UTF-8 text") + } + if admit.ContainsSecretLikeValue(content) { + return nil, fmt.Errorf("requirement traceability graph code source content must not contain secret-like values") + } + totalBytes += len(content) + if totalBytes > maxCodeSourceBytes { + return nil, fmt.Errorf("requirement traceability graph code sources exceed byte limit") + } + result[path] = codeSource{content: []byte(content), digest: digest.SHA256TextRef(content)} + } + return result, nil +} + +func utf8Boundary(content []byte, offset int) bool { + return offset == 0 || offset == len(content) || (offset > 0 && offset < len(content) && utf8.RuneStart(content[offset])) +} + +func admittedRuleIDArray(raw any, context string) ([]string, error) { + values, ok := raw.([]any) + if !ok { + return nil, fmt.Errorf("%s must be an array", context) + } + result := make([]string, 0, len(values)) + seen := map[string]struct{}{} + for _, rawValue := range values { + value, err := admit.RuleID(rawValue, context) + if err != nil { + return nil, err + } + if _, exists := seen[value]; exists { + return nil, fmt.Errorf("%s must be unique", context) + } + seen[value] = struct{}{} + result = append(result, value) + } + sort.Strings(result) + return result, nil +} + +func nonNegativeInteger(raw any, context string) (int, error) { + if value, ok := raw.(int); ok { + if value < 0 { + return 0, fmt.Errorf("%s must be a non-negative integer", context) + } + return value, nil + } + number, ok := raw.(json.Number) + if !ok { + return 0, fmt.Errorf("%s must be a non-negative integer", context) + } + value, err := strconv.Atoi(number.String()) + if err != nil || value < 0 { + return 0, fmt.Errorf("%s must be a non-negative integer", context) + } + return value, nil +} +func digestRef(raw any, context string) (string, error) { + value, err := admit.NonEmptyText(raw, context) + if err != nil || !strings.HasPrefix(value, "sha256:") { + return "", fmt.Errorf("%s must be a sha256 digest reference", context) + } + if _, err := admit.LowercaseSHA256(strings.TrimPrefix(value, "sha256:"), context); err != nil { + return "", err + } + return value, nil +} + +func mapsToAny(values []map[string]any) []any { + result := make([]any, len(values)) + for index, value := range values { + result[index] = value + } + return result +} diff --git a/internal/command/requirementgraph/requirementgraph_test.go b/internal/command/requirementgraph/requirementgraph_test.go new file mode 100644 index 0000000..d1429bb --- /dev/null +++ b/internal/command/requirementgraph/requirementgraph_test.go @@ -0,0 +1,162 @@ +package requirementgraph + +import ( + "bytes" + "encoding/json" + "testing" + + "github.com/research-engineering/agentic-proofkit/internal/command/requirementbinding" + "github.com/research-engineering/agentic-proofkit/internal/command/requirementcontext" + "github.com/research-engineering/agentic-proofkit/internal/kernel/admission" + "github.com/research-engineering/agentic-proofkit/internal/kernel/digest" + "github.com/research-engineering/agentic-proofkit/internal/kernel/stablejson" + "github.com/research-engineering/agentic-proofkit/internal/testsupport/commandcoverage" +) + +func TestBuildKeepsTraceabilityEvidencePlanesDistinct(t *testing.T) { + commandcoverage.SemanticRoute(t, "proofkit.command_coverage.source_oracle.v1.040236281331857613367866543934119806645341297834533138126303789519826727502569") + contextValue := graphContextFixture(t) + code := "package handler\n\nfunc Handle() { return }\n" + codeDigest := digest.SHA256TextRef(code) + output, err := Build(map[string]any{ + "schemaVersion": json.Number("1"), "graphId": "consumer.traceability", "context": contextValue, + "codeSources": []any{map[string]any{"path": "src/handler.go", "content": code}}, + "codeTopology": map[string]any{ + "topologyId": "consumer.code-topology", + "nodes": []any{ + map[string]any{"nodeId": "code.repository", "abstractionLevel": "repository", "label": "Repository", "sourcePath": "src/handler.go", "sourceDigest": codeDigest, "currentnessState": "current"}, + map[string]any{"nodeId": "code.handler", "parentNodeId": "code.repository", "abstractionLevel": "source_range", "label": "Handle", "sourcePath": "src/handler.go", "sourceDigest": codeDigest, "currentnessState": "current", "byteStart": json.Number("17"), "byteEnd": json.Number("30")}, + }, + "edges": []any{map[string]any{"codeNodeId": "code.handler", "requirementId": "REQ-CONSUMER-001", "evidenceRefs": []any{"consumer.trace.handler"}, "authorityClass": "caller_reported", "currentnessState": "current"}}, + "nativeCoverage": []any{map[string]any{"codeNodeId": "code.handler", "requirementId": "REQ-CONSUMER-001", "evidenceRef": "consumer.test.handler", "producerId": "consumer.test-runner", "authorityClass": "caller_reported", "currentnessState": "current", "state": "passed"}}, + }, + }) + if err != nil { + t.Fatalf("Build() error = %v", err) + } + planes := map[string]struct{}{} + for _, raw := range output["nodes"].([]any) { + planes[raw.(map[string]any)["evidencePlane"].(string)] = struct{}{} + } + for _, expected := range []string{"code_traceability", "native_execution_coverage", "proof_coverage", "specification_coverage"} { + if _, ok := planes[expected]; !ok { + t.Fatalf("missing evidence plane %s: %v", expected, planes) + } + } + encoded, err := stablejson.Marshal(output) + if err != nil { + t.Fatal(err) + } + decoded, err := admission.DecodeJSON(bytes.NewReader(encoded), 1<<20) + if err != nil { + t.Fatal(err) + } + if _, err := AdmitOutput(decoded, contextValue["snapshotId"].(string)); err != nil { + t.Fatalf("AdmitOutput() error = %v", err) + } + for _, raw := range decoded.(map[string]any)["nodes"].([]any) { + node := raw.(map[string]any) + if node["evidencePlane"] == "proof_coverage" { + node["scenarioId"] = "consumer.scenario.tampered" + break + } + } + if _, err := AdmitOutput(decoded, contextValue["snapshotId"].(string)); err == nil { + t.Fatal("AdmitOutput accepted proof node facts that do not match nodeId") + } +} + +func TestGraphIdentityIsPermutationStableAndRelationsAreTyped(t *testing.T) { + input := graphPermutationInput(t) + first, err := Build(input) + if err != nil { + t.Fatal(err) + } + topology := input["codeTopology"].(map[string]any) + edges := topology["edges"].([]any) + edges[0], edges[1] = edges[1], edges[0] + coverage := topology["nativeCoverage"].([]any) + coverage[0], coverage[1] = coverage[1], coverage[0] + second, err := Build(input) + if err != nil { + t.Fatal(err) + } + firstJSON, _ := stablejson.Marshal(first) + secondJSON, _ := stablejson.Marshal(second) + if !bytes.Equal(firstJSON, secondJSON) { + t.Fatal("semantic graph changed after caller array permutation") + } + + tamperedRaw, err := admission.DecodeJSON(bytes.NewReader(firstJSON), int64(len(firstJSON))) + if err != nil { + t.Fatal(err) + } + tampered := tamperedRaw.(map[string]any) + for _, raw := range tampered["edges"].([]any) { + edge := raw.(map[string]any) + if edge["edgeKind"] == "traced_to" { + edge["evidencePlane"] = "native_execution_coverage" + break + } + } + if _, err := AdmitOutput(tampered, input["context"].(map[string]any)["snapshotId"].(string)); err == nil { + t.Fatal("AdmitOutput accepted an edge laundered into another evidence plane") + } +} + +func graphPermutationInput(t *testing.T) map[string]any { + t.Helper() + contextValue := graphContextFixture(t) + code := "package handler\n\nfunc Handle() { return }\n" + codeDigest := digest.SHA256TextRef(code) + return map[string]any{ + "schemaVersion": json.Number("1"), "graphId": "consumer.traceability.permutation", "context": contextValue, + "codeSources": []any{map[string]any{"path": "src/handler.go", "content": code}}, + "codeTopology": map[string]any{ + "topologyId": "consumer.code-topology.permutation", + "nodes": []any{ + map[string]any{"nodeId": "code.repository", "abstractionLevel": "repository", "label": "Repository", "sourcePath": "src/handler.go", "sourceDigest": codeDigest, "currentnessState": "current"}, + map[string]any{"nodeId": "code.handler", "parentNodeId": "code.repository", "abstractionLevel": "source_range", "label": "Handle", "sourcePath": "src/handler.go", "sourceDigest": codeDigest, "currentnessState": "current", "byteStart": json.Number("17"), "byteEnd": json.Number("30")}, + }, + "edges": []any{ + map[string]any{"codeNodeId": "code.handler", "requirementId": "REQ-CONSUMER-001", "evidenceRefs": []any{"consumer.trace.a"}, "authorityClass": "caller_reported", "currentnessState": "current"}, + map[string]any{"codeNodeId": "code.handler", "requirementId": "REQ-CONSUMER-001", "evidenceRefs": []any{"consumer.trace.b"}, "authorityClass": "caller_reported", "currentnessState": "current"}, + }, + "nativeCoverage": []any{ + map[string]any{"codeNodeId": "code.handler", "requirementId": "REQ-CONSUMER-001", "evidenceRef": "consumer.test.a", "producerId": "consumer.test-runner", "authorityClass": "caller_reported", "currentnessState": "current", "state": "passed"}, + map[string]any{"codeNodeId": "code.handler", "requirementId": "REQ-CONSUMER-001", "evidenceRef": "consumer.test.b", "producerId": "consumer.test-runner", "authorityClass": "caller_reported", "currentnessState": "current", "state": "passed"}, + }, + }, + } +} + +func graphContextFixture(t *testing.T) map[string]any { + t.Helper() + projections := map[string]any{ + "specTree": map[string]any{"schemaVersion": json.Number("2"), "treeId": "consumer.spec-tree", "rootNodeId": "spec.root", "callerAnnotations": []any{}, "edges": []any{}, "overlays": []any{}, "nodes": []any{map[string]any{"nodeId": "spec.root", "nodeKind": "meta_spec", "label": "Root", "displayOrder": json.Number("1"), "callerAnnotations": []any{}, "sourceRefs": []any{map[string]any{"sourceRefId": "spec.root.requirements", "sourceRefKind": "source_id", "sourceRole": "requirements", "sourceId": "consumer.requirements"}}}}}, + "requirementSources": []any{map[string]any{ + "schemaVersion": json.Number("1"), "sourceId": "consumer.requirements", "specPackagePath": "docs/specs/consumer", "overviewPath": "docs/specs/consumer/overview.md", "requirementsPath": "docs/specs/consumer/requirements.v1.json", "nonClaims": []any{"Consumer source does not approve merge."}, + "requirements": []any{map[string]any{"requirementId": "REQ-CONSUMER-001", "ownerId": "consumer.owner", "invariant": "The system preserves semantic identity.", "claimLevel": "blocking", "riskClass": "high", "proofBindingRefs": []any{"proofkit/requirement-bindings.json"}, "nonClaimRefs": []any{"NC-CONSUMER-001"}, "nonClaims": []any{"This requirement does not approve merge."}, "lifecycle": map[string]any{"state": "active", "replacementRequirementIds": []any{}, "evidenceRefs": []any{}}, "updatePolicy": map[string]any{"reviewOwnerId": "consumer.owner", "requiresImpactDeclaration": true, "requiresProofBindingReview": true}}}, + }}, + "proofBinding": map[string]any{ + "schemaVersion": json.Number("1"), "bindingId": "consumer.proof-bindings", + "requirements": []any{map[string]any{"claimLevel": "blocking", "nonClaims": []any{"Fixture proof requirement does not approve merge."}, "ownerId": "consumer.owner", "proofState": "witness_backed", "requirementId": "REQ-CONSUMER-001", "specPath": "docs/specs/consumer/requirements.v1.json"}}, + "bindings": []any{map[string]any{"commandIds": []any{"consumer.test"}, "environmentClasses": []any{"local-go"}, "requirementId": "REQ-CONSUMER-001", "scenarioId": "consumer.scenario", "witnessId": "consumer.witness", "witnessKind": "contract", "witnessPath": "internal/consumer_test.go"}}, + "witnessCommands": []any{map[string]any{"command": "go test ./internal", "commandId": "consumer.test", "environmentClass": "local-go"}}, + "selection": map[string]any{"changedPaths": []any{}, "ownerIds": []any{}, "requirementIds": []any{}}, + "nonClaims": []any{"Fixture proof bindings do not execute witnesses."}, + }, + } + proofResult, err := requirementbinding.Build(projections["proofBinding"]) + if err != nil || proofResult.Record.State != "passed" { + t.Fatalf("admit graph proof fixture: %v state=%v", err, proofResult.Record.State) + } + projections["proofBinding"] = requirementbinding.InputValue(proofResult.Input) + sources := []requirementcontext.Source{{CurrentDigest: "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", Kind: "requirement_source", NodeID: "spec.root", Path: "docs/specs/consumer/requirements.v1.json", SourceRef: "consumer.requirements", SourceRole: "requirements"}, {CurrentDigest: "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", Kind: "proof_binding", Path: "proofkit/requirement-bindings.json", SourceRef: "proof_binding:consumer.proof-bindings"}, {CurrentDigest: "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", Kind: "spec_tree", Path: "proofkit/spec-tree.json", SourceRef: "spec_tree:consumer.spec-tree"}} + identity := map[string]any{"catalogId": "consumer.context", "projections": projections, "sources": []any{map[string]any{"currentDigest": sources[0].CurrentDigest, "expectedDigest": "", "kind": sources[0].Kind, "nodeId": sources[0].NodeID, "path": sources[0].Path, "sourceRef": sources[0].SourceRef, "sourceRole": sources[0].SourceRole}, map[string]any{"currentDigest": sources[1].CurrentDigest, "expectedDigest": "", "kind": sources[1].Kind, "path": sources[1].Path, "sourceRef": sources[1].SourceRef}, map[string]any{"currentDigest": sources[2].CurrentDigest, "expectedDigest": "", "kind": sources[2].Kind, "path": sources[2].Path, "sourceRef": sources[2].SourceRef}}} + encoded, err := stablejson.Marshal(identity) + if err != nil { + t.Fatal(err) + } + return requirementcontext.SnapshotValue(requirementcontext.Snapshot{BaselineVerification: "unverified", CatalogID: "consumer.context", Projections: projections, SnapshotID: digest.SHA256TextRef(string(encoded)), Sources: sources}) +} diff --git a/internal/command/requirementsourceadmission/projection.go b/internal/command/requirementsourceadmission/projection.go new file mode 100644 index 0000000..13a9a35 --- /dev/null +++ b/internal/command/requirementsourceadmission/projection.go @@ -0,0 +1,89 @@ +package requirementsourceadmission + +import "encoding/json" + +type ComparisonField struct { + Class string + Name string + Value any +} + +func SourceValue(source Source) map[string]any { + requirements := make([]any, 0, len(source.Requirements)) + for _, requirement := range source.Requirements { + requirements = append(requirements, RequirementValue(requirement)) + } + return map[string]any{ + "nonClaims": stringValues(source.NonClaims), + "overviewPath": source.OverviewPath, + "requirements": requirements, + "requirementsPath": source.RequirementsPath, + "schemaVersion": json.Number("1"), + "sourceId": source.SourceID, + "specPackagePath": source.SpecPackagePath, + } +} + +func RequirementValue(requirement Requirement) map[string]any { + record := map[string]any{ + "claimLevel": requirement.ClaimLevel, + "invariant": requirement.Invariant, + "lifecycle": lifecycleValue(requirement.Lifecycle), + "nonClaimRefs": stringValues(requirement.NonClaimRefs), + "nonClaims": stringValues(requirement.NonClaims), + "ownerId": requirement.OwnerID, + "proofBindingRefs": stringValues(requirement.ProofBindingRefs), + "requirementId": requirement.RequirementID, + "riskClass": requirement.RiskClass, + "updatePolicy": map[string]any{ + "requiresImpactDeclaration": requirement.UpdatePolicy.RequiresImpactDeclaration, + "requiresProofBindingReview": requirement.UpdatePolicy.RequiresProofBindingReview, + "reviewOwnerId": requirement.UpdatePolicy.ReviewOwnerID, + }, + } + if requirement.Deferral != nil { + record["deferral"] = map[string]any{ + "evidenceRefs": stringValues(requirement.Deferral.EvidenceRefs), + "expiryRef": requirement.Deferral.ExpiryRef, + "mergePolicy": requirement.Deferral.MergePolicy, + "ownerId": requirement.Deferral.OwnerID, + "reviewCondition": requirement.Deferral.ReviewCondition, + "riskAcceptedBy": requirement.Deferral.RiskAcceptedBy, + } + } + return record +} + +// ComparisonFields is the requirement owner's exhaustive review projection. +// Adding a review-significant field requires changing this owner-local list. +func ComparisonFields(requirement Requirement) []ComparisonField { + value := RequirementValue(requirement) + return []ComparisonField{ + {Name: "claimLevel", Class: "scalar", Value: value["claimLevel"]}, + {Name: "deferral", Class: "map", Value: value["deferral"]}, + {Name: "invariant", Class: "scalar", Value: value["invariant"]}, + {Name: "lifecycle", Class: "map", Value: value["lifecycle"]}, + {Name: "nonClaimRefs", Class: "set", Value: value["nonClaimRefs"]}, + {Name: "nonClaims", Class: "set", Value: value["nonClaims"]}, + {Name: "ownerId", Class: "scalar", Value: value["ownerId"]}, + {Name: "proofBindingRefs", Class: "set", Value: value["proofBindingRefs"]}, + {Name: "riskClass", Class: "scalar", Value: value["riskClass"]}, + {Name: "updatePolicy", Class: "map", Value: value["updatePolicy"]}, + } +} + +func lifecycleValue(lifecycle Lifecycle) map[string]any { + return map[string]any{ + "evidenceRefs": stringValues(lifecycle.EvidenceRefs), + "replacementRequirementIds": stringValues(lifecycle.ReplacementRequirementIDs), + "state": lifecycle.State, + } +} + +func stringValues(values []string) []any { + result := make([]any, len(values)) + for index, value := range values { + result[index] = value + } + return result +} diff --git a/internal/command/requirementsourceadmission/requirementsourceadmission_test.go b/internal/command/requirementsourceadmission/requirementsourceadmission_test.go index 388c144..d8ef5da 100644 --- a/internal/command/requirementsourceadmission/requirementsourceadmission_test.go +++ b/internal/command/requirementsourceadmission/requirementsourceadmission_test.go @@ -3,10 +3,42 @@ package requirementsourceadmission import ( "encoding/json" "github.com/research-engineering/agentic-proofkit/internal/testsupport/commandcoverage" + "sort" "strings" "testing" ) +func TestComparisonFieldsExhaustRequirementProjection(t *testing.T) { + result, err := Evaluate(validSource()) + if err != nil { + t.Fatalf("Evaluate() error = %v", err) + } + requirement := result.Source.Requirements[0] + requirement.Deferral = &Deferral{} + projection := RequirementValue(requirement) + want := make([]string, 0, len(projection)-1) + for key := range projection { + if key != "requirementId" { + want = append(want, key) + } + } + sort.Strings(want) + fields := ComparisonFields(requirement) + got := make([]string, 0, len(fields)) + seen := map[string]struct{}{} + for _, field := range fields { + if _, exists := seen[field.Name]; exists { + t.Fatalf("ComparisonFields() contains duplicate %q", field.Name) + } + seen[field.Name] = struct{}{} + got = append(got, field.Name) + } + sort.Strings(got) + if strings.Join(got, ",") != strings.Join(want, ",") { + t.Fatalf("ComparisonFields()=%v, want exhaustive projection fields %v", got, want) + } +} + func TestEvaluateAcceptsActiveBlockingRequirementWithProofRoute(t *testing.T) { result, err := Evaluate(validSource()) if err != nil { diff --git a/internal/command/requirementspectree/requirementspectree.go b/internal/command/requirementspectree/requirementspectree.go index 938bd51..bbc9889 100644 --- a/internal/command/requirementspectree/requirementspectree.go +++ b/internal/command/requirementspectree/requirementspectree.go @@ -1,16 +1,121 @@ package requirementspectree -import "github.com/research-engineering/agentic-proofkit/internal/kernel/report" +import ( + "encoding/json" + "strconv" + + "github.com/research-engineering/agentic-proofkit/internal/kernel/report" +) + +type Result struct { + ExitCode int + Report report.Record + Tree Tree +} func Build(raw any) (report.Record, int, error) { - input, err := admitInput(raw) + result, err := Evaluate(raw) if err != nil { return report.Record{}, 1, err } + return result.Report, result.ExitCode, nil +} + +func Evaluate(raw any) (Result, error) { + input, err := admitInput(raw) + if err != nil { + return Result{}, err + } validation := validate(input) record := buildRecord(input, validation) + exitCode := 1 if record.State == "passed" { - return record, 0, nil + exitCode = 0 + } + return Result{ExitCode: exitCode, Report: record, Tree: input}, nil +} + +func TreeValue(tree Tree) map[string]any { + nodes := make([]any, 0, len(tree.Nodes)) + for _, node := range tree.Nodes { + sourceRefs := make([]any, 0, len(node.SourceRefs)) + for _, ref := range node.SourceRefs { + record := map[string]any{ + "sourceRefId": ref.SourceRefID, + "sourceRefKind": ref.SourceRefKind, + "sourceRole": ref.SourceRole, + } + if ref.CurrentSourceDigest != "" { + record["currentSourceDigest"] = ref.CurrentSourceDigest + } + if ref.DigestAlgorithm != "" { + record["digestAlgorithm"] = ref.DigestAlgorithm + } + if ref.RecordedSourceDigest != "" { + record["recordedSourceDigest"] = ref.RecordedSourceDigest + } + if ref.SourceID != "" { + record["sourceId"] = ref.SourceID + } + if ref.SourcePath != "" { + record["sourcePath"] = ref.SourcePath + } + sourceRefs = append(sourceRefs, record) + } + nodes = append(nodes, map[string]any{ + "callerAnnotations": stringsToAny(node.CallerAnnotations), + "displayOrder": json.Number(strconv.Itoa(node.DisplayOrder)), + "label": node.Label, + "nodeId": node.NodeID, + "nodeKind": node.NodeKind, + "sourceRefs": sourceRefs, + }) + } + edges := make([]any, 0, len(tree.Edges)) + for _, edge := range tree.Edges { + edges = append(edges, map[string]any{"childNodeId": edge.ChildNodeID, "parentNodeId": edge.ParentNodeID}) + } + return map[string]any{ + "callerAnnotations": stringsToAny(tree.CallerAnnotations), + "edges": edges, + "nodes": nodes, + "overlays": overlaysValue(tree.Overlays), + "rootNodeId": tree.RootNodeID, + "schemaVersion": json.Number("2"), + "treeId": tree.TreeID, + } +} + +func overlaysValue(overlays []Overlay) []any { + values := make([]any, 0, len(overlays)) + for _, overlay := range overlays { + record := map[string]any{ + "callerAnnotations": stringsToAny(overlay.CallerAnnotations), + "label": overlay.Label, + "overlayId": overlay.OverlayID, + "overlayKind": overlay.OverlayKind, + "refId": overlay.RefID, + "refKind": overlay.RefKind, + "targetNodeId": overlay.TargetNodeID, + } + if overlay.DigestAlgorithm != "" { + record["digestAlgorithm"] = overlay.DigestAlgorithm + } + if overlay.RefDigest != "" { + record["refDigest"] = overlay.RefDigest + } + if overlay.RefPath != "" { + record["refPath"] = overlay.RefPath + } + values = append(values, record) + } + return values +} + +func stringsToAny(values []string) []any { + result := make([]any, len(values)) + for index, value := range values { + result[index] = value } - return record, 1, nil + return result } diff --git a/internal/command/requirementspectree/types.go b/internal/command/requirementspectree/types.go index 2dd6d5d..9c4c449 100644 --- a/internal/command/requirementspectree/types.go +++ b/internal/command/requirementspectree/types.go @@ -51,7 +51,7 @@ var boundaryNonClaims = []string{ "Requirement spec tree reports do not validate requirement meaning, proof adequacy, coverage truth, or receipt freshness.", } -type sourceRef struct { +type SourceRef struct { CurrentSourceDigest string DigestAlgorithm string RecordedSourceDigest string @@ -62,7 +62,9 @@ type sourceRef struct { SourceRole string } -type node struct { +type sourceRef = SourceRef + +type Node struct { CallerAnnotations []string DisplayOrder int Label string @@ -71,12 +73,16 @@ type node struct { SourceRefs []sourceRef } -type edge struct { +type node = Node + +type Edge struct { ChildNodeID string ParentNodeID string } -type overlay struct { +type edge = Edge + +type Overlay struct { CallerAnnotations []string DigestAlgorithm string Label string @@ -89,7 +95,9 @@ type overlay struct { TargetNodeID string } -type admittedInput struct { +type overlay = Overlay + +type Tree struct { CallerAnnotations []string Edges []edge Nodes []node @@ -97,3 +105,5 @@ type admittedInput struct { RootNodeID string TreeID string } + +type admittedInput = Tree diff --git a/internal/kernel/pathpattern/pathpattern_test.go b/internal/kernel/pathpattern/pathpattern_test.go index b660e72..615b35c 100644 --- a/internal/kernel/pathpattern/pathpattern_test.go +++ b/internal/kernel/pathpattern/pathpattern_test.go @@ -33,7 +33,7 @@ func TestMatchAnyAndValidateShareOwnerSemantics(t *testing.T) { if err := Validate("../docs/**/*.md", "test path pattern"); err == nil { t.Fatalf("Validate() accepted escaping pattern") } - if !MatchAny([]string{"docs/*.md", "proofkit/**/*.json"}, "proofkit/cli-contract.v1.json") { + if !MatchAny([]string{"docs/*.md", "proofkit/**/*.json"}, "proofkit/cli-contract.v2.json") { t.Fatalf("MatchAny() did not match proofkit contract") } if MatchAny([]string{"docs/*.md"}, "src/main.go") { diff --git a/internal/kernel/stablejson/stablejson.go b/internal/kernel/stablejson/stablejson.go index a0d0ede..8e3bad3 100644 --- a/internal/kernel/stablejson/stablejson.go +++ b/internal/kernel/stablejson/stablejson.go @@ -11,16 +11,30 @@ import ( var jsonNumberPattern = regexp.MustCompile(`^-?(?:0|[1-9][0-9]*)(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?$`) +type Layout string + +const ( + LayoutPretty Layout = "pretty" + LayoutCompact Layout = "compact" +) + func Marshal(value any) ([]byte, error) { + return MarshalLayout(value, LayoutPretty) +} + +func MarshalLayout(value any, layout Layout) ([]byte, error) { + if layout != LayoutPretty && layout != LayoutCompact { + return nil, fmt.Errorf("unsupported JSON layout: %s", layout) + } var builder strings.Builder - if err := writeValue(&builder, value, 0); err != nil { + if err := writeValue(&builder, value, 0, layout); err != nil { return nil, err } builder.WriteByte('\n') return []byte(builder.String()), nil } -func writeValue(builder *strings.Builder, value any, depth int) error { +func writeValue(builder *strings.Builder, value any, depth int, layout Layout) error { switch typed := value.(type) { case nil: builder.WriteString("null") @@ -40,9 +54,9 @@ func writeValue(builder *strings.Builder, value any, depth int) error { case int: builder.WriteString(fmt.Sprintf("%d", typed)) case []any: - return writeArray(builder, typed, depth) + return writeArray(builder, typed, depth, layout) case map[string]any: - return writeObject(builder, typed, depth) + return writeObject(builder, typed, depth, layout) default: return fmt.Errorf("unsupported JSON value %T", value) } @@ -53,28 +67,38 @@ func isJSONNumberToken(value string) bool { return jsonNumberPattern.MatchString(value) } -func writeArray(builder *strings.Builder, values []any, depth int) error { +func writeArray(builder *strings.Builder, values []any, depth int, layout Layout) error { if len(values) == 0 { builder.WriteString("[]") return nil } - builder.WriteString("[\n") + builder.WriteByte('[') + if layout == LayoutPretty { + builder.WriteByte('\n') + } for index, value := range values { if index > 0 { - builder.WriteString(",\n") + builder.WriteByte(',') + if layout == LayoutPretty { + builder.WriteByte('\n') + } + } + if layout == LayoutPretty { + writeIndent(builder, depth+1) } - writeIndent(builder, depth+1) - if err := writeValue(builder, value, depth+1); err != nil { + if err := writeValue(builder, value, depth+1, layout); err != nil { return err } } - builder.WriteByte('\n') - writeIndent(builder, depth) + if layout == LayoutPretty { + builder.WriteByte('\n') + writeIndent(builder, depth) + } builder.WriteByte(']') return nil } -func writeObject(builder *strings.Builder, values map[string]any, depth int) error { +func writeObject(builder *strings.Builder, values map[string]any, depth int, layout Layout) error { if len(values) == 0 { builder.WriteString("{}") return nil @@ -84,20 +108,33 @@ func writeObject(builder *strings.Builder, values map[string]any, depth int) err keys = append(keys, key) } sort.Strings(keys) - builder.WriteString("{\n") + builder.WriteByte('{') + if layout == LayoutPretty { + builder.WriteByte('\n') + } for index, key := range keys { if index > 0 { - builder.WriteString(",\n") + builder.WriteByte(',') + if layout == LayoutPretty { + builder.WriteByte('\n') + } + } + if layout == LayoutPretty { + writeIndent(builder, depth+1) } - writeIndent(builder, depth+1) builder.WriteString(quote(key)) - builder.WriteString(": ") - if err := writeValue(builder, values[key], depth+1); err != nil { + builder.WriteByte(':') + if layout == LayoutPretty { + builder.WriteByte(' ') + } + if err := writeValue(builder, values[key], depth+1, layout); err != nil { return err } } - builder.WriteByte('\n') - writeIndent(builder, depth) + if layout == LayoutPretty { + builder.WriteByte('\n') + writeIndent(builder, depth) + } builder.WriteByte('}') return nil } diff --git a/internal/kernel/stablejson/stablejson_test.go b/internal/kernel/stablejson/stablejson_test.go index 19d1fa7..f3c831b 100644 --- a/internal/kernel/stablejson/stablejson_test.go +++ b/internal/kernel/stablejson/stablejson_test.go @@ -2,6 +2,7 @@ package stablejson import ( "encoding/json" + "fmt" "testing" ) @@ -19,6 +20,38 @@ func TestMarshalSortsObjectKeys(t *testing.T) { } } +func TestMarshalLayoutCompactPreservesSortedJSONValue(t *testing.T) { + value := map[string]any{"z": "last", "a": []any{"first", true}} + pretty, err := MarshalLayout(value, LayoutPretty) + if err != nil { + t.Fatalf("marshal pretty failed: %v", err) + } + compact, err := MarshalLayout(value, LayoutCompact) + if err != nil { + t.Fatalf("marshal compact failed: %v", err) + } + if got, want := string(compact), "{\"a\":[\"first\",true],\"z\":\"last\"}\n"; got != want { + t.Fatalf("compact output = %q, want %q", got, want) + } + var prettyValue any + var compactValue any + if err := json.Unmarshal(pretty, &prettyValue); err != nil { + t.Fatalf("decode pretty output: %v", err) + } + if err := json.Unmarshal(compact, &compactValue); err != nil { + t.Fatalf("decode compact output: %v", err) + } + if fmt.Sprint(prettyValue) != fmt.Sprint(compactValue) { + t.Fatalf("layout changed JSON value: pretty=%v compact=%v", prettyValue, compactValue) + } +} + +func TestMarshalLayoutRejectsUnknownLayout(t *testing.T) { + if _, err := MarshalLayout(map[string]any{}, Layout("dense")); err == nil { + t.Fatal("MarshalLayout accepted an unknown layout") + } +} + func TestMarshalRejectsNonNumberJSONNumberTokens(t *testing.T) { for _, value := range []json.Number{ "true", diff --git a/internal/testsupport/browserfixture/fixture.go b/internal/testsupport/browserfixture/fixture.go new file mode 100644 index 0000000..5fd0224 --- /dev/null +++ b/internal/testsupport/browserfixture/fixture.go @@ -0,0 +1,71 @@ +package browserfixture + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/research-engineering/agentic-proofkit/internal/command/requirementcontext" + "github.com/research-engineering/agentic-proofkit/internal/kernel/digest" + "github.com/research-engineering/agentic-proofkit/internal/kernel/stablejson" +) + +const RequirementID = "REQ-CONSUMER-001" + +func Workspace() (map[string]any, error) { + base, err := snapshot("The system preserves the original semantic identity.") + if err != nil { + return nil, err + } + current, err := snapshot("The system preserves semantic identity for retry \U0001F680.") + if err != nil { + return nil, err + } + diffInput := map[string]any{ + "baseContext": base, "currentContext": current, "diffId": "browser.fixture.diff", + "query": map[string]any{"requirementIds": []any{RequirementID}}, "schemaVersion": json.Number("1"), + } + code := "package retry\n\nfunc Retry() {}\n" + start := strings.Index(code, "func Retry") + graphInput := map[string]any{ + "codeSources": []any{map[string]any{"content": code, "path": "src/retry.go"}}, + "codeTopology": map[string]any{ + "edges": []any{map[string]any{"authorityClass": "owner_admitted", "codeNodeId": "code.retry", "currentnessState": "current", "evidenceRefs": []any{"browser.fixture.trace"}, "requirementId": RequirementID}}, + "nativeCoverage": []any{ + map[string]any{"authorityClass": "caller_reported", "codeNodeId": "code.retry", "currentnessState": "unverified", "evidenceRef": "browser.fixture.candidate", "producerId": "browser.fixture.candidate-runner", "requirementId": RequirementID, "state": "failed"}, + map[string]any{"authorityClass": "receipt_admitted", "codeNodeId": "code.retry", "currentnessState": "current", "evidenceRef": "browser.fixture.execution", "producerId": "browser.fixture.runner", "requirementId": RequirementID, "state": "passed"}, + }, + "nodes": []any{ + map[string]any{"abstractionLevel": "repository", "currentnessState": "stale", "label": "Fixture repository with a deliberately long traceability label preserved in the accessible title", "nodeId": "code.repository", "sourceDigest": digest.SHA256TextRef(code), "sourcePath": "src/retry.go"}, + map[string]any{"abstractionLevel": "source_range", "byteEnd": json.Number(fmt.Sprint(start + len("func Retry() {}"))), "byteStart": json.Number(fmt.Sprint(start)), "currentnessState": "current", "label": "Retry", "nodeId": "code.retry", "parentNodeId": "code.repository", "sourceDigest": digest.SHA256TextRef(code), "sourcePath": "src/retry.go"}, + }, + "topologyId": "browser.fixture.topology", + }, + "context": current, "graphId": "browser.fixture.graph", "schemaVersion": json.Number("1"), + } + return map[string]any{"context": current, "diffInput": diffInput, "graphInput": graphInput, "schemaVersion": json.Number("1"), "workspaceId": "browser.fixture.workspace"}, nil +} + +func snapshot(invariant string) (map[string]any, error) { + tree := map[string]any{"callerAnnotations": []any{}, "edges": []any{}, "nodes": []any{map[string]any{"callerAnnotations": []any{}, "displayOrder": json.Number("1"), "label": "Fixture specification", "nodeId": "spec.root", "nodeKind": "meta_spec", "sourceRefs": []any{map[string]any{"sourceId": "browser.fixture.requirements", "sourceRefId": "spec.root.requirements", "sourceRefKind": "source_id", "sourceRole": "requirements"}}}}, "overlays": []any{}, "rootNodeId": "spec.root", "schemaVersion": json.Number("2"), "treeId": "browser.fixture.tree"} + requirementSource := map[string]any{ + "nonClaims": []any{"Fixture requirements do not approve merge."}, "overviewPath": "docs/specs/browser-fixture/overview.md", + "requirements": []any{map[string]any{"claimLevel": "blocking", "invariant": invariant, "lifecycle": map[string]any{"evidenceRefs": []any{}, "replacementRequirementIds": []any{}, "state": "active"}, "nonClaimRefs": []any{"NC-CONSUMER-001"}, "nonClaims": []any{"This requirement does not approve merge."}, "ownerId": "browser.fixture.owner", "proofBindingRefs": []any{"proofkit/requirement-bindings.json"}, "requirementId": RequirementID, "riskClass": "high", "updatePolicy": map[string]any{"requiresImpactDeclaration": true, "requiresProofBindingReview": true, "reviewOwnerId": "browser.fixture.owner"}}}, + "requirementsPath": "docs/specs/browser-fixture/requirements.v1.json", "schemaVersion": json.Number("1"), "sourceId": "browser.fixture.requirements", "specPackagePath": "docs/specs/browser-fixture", + } + projections := map[string]any{"requirementSources": []any{requirementSource}, "specTree": tree} + treeBytes, err := stablejson.Marshal(tree) + if err != nil { + return nil, err + } + sources := []requirementcontext.Source{ + {CurrentDigest: digest.SHA256TextRef(invariant), Kind: "requirement_source", NodeID: "spec.root", Path: "docs/specs/browser-fixture/requirements.v1.json", SourceRef: "browser.fixture.requirements", SourceRole: "requirements"}, + {CurrentDigest: digest.SHA256TextRef(string(treeBytes)), Kind: "spec_tree", Path: "proofkit/browser-fixture-tree.json", SourceRef: "spec_tree:browser.fixture.tree"}, + } + identity := map[string]any{"catalogId": "browser.fixture.context", "projections": projections, "sources": []any{map[string]any{"currentDigest": sources[0].CurrentDigest, "expectedDigest": "", "kind": sources[0].Kind, "nodeId": sources[0].NodeID, "path": sources[0].Path, "sourceRef": sources[0].SourceRef, "sourceRole": sources[0].SourceRole}, map[string]any{"currentDigest": sources[1].CurrentDigest, "expectedDigest": "", "kind": sources[1].Kind, "path": sources[1].Path, "sourceRef": sources[1].SourceRef}}} + encoded, err := stablejson.Marshal(identity) + if err != nil { + return nil, err + } + return requirementcontext.SnapshotValue(requirementcontext.Snapshot{BaselineVerification: "unverified", CatalogID: "browser.fixture.context", Projections: projections, SnapshotID: digest.SHA256TextRef(string(encoded)), Sources: sources}), nil +} diff --git a/internal/tools/browserproofverify/artifact_paths.go b/internal/tools/browserproofverify/artifact_paths.go new file mode 100644 index 0000000..0f5a1c7 --- /dev/null +++ b/internal/tools/browserproofverify/artifact_paths.go @@ -0,0 +1,209 @@ +package main + +import ( + "crypto/rand" + "encoding/hex" + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "strings" + + "github.com/research-engineering/agentic-proofkit/internal/kernel/admission" + "github.com/research-engineering/agentic-proofkit/internal/kernel/stablejson" +) + +const ( + browserArtifactsDirectory = "artifacts" + browserProofDirectory = "artifacts/proofkit" + browserProofCandidateName = "browser-runtime-proof.candidate.json" + browserRunDirectoryEnvironment = "PROOFKIT_BROWSER_RUN_DIRECTORY" + browserProofCandidateEnvironment = "PROOFKIT_BROWSER_PROOF_CANDIDATE_PATH" +) + +type browserProofRunPaths struct { + CandidatePath string + RunDirectory string +} + +func prepareBrowserProofRun(root string) (browserProofRunPaths, error) { + rootFS, err := os.OpenRoot(root) + if err != nil { + return browserProofRunPaths{}, fmt.Errorf("open browser proof repository root: %w", err) + } + defer rootFS.Close() + if err := ensureRootedDirectory(rootFS, browserArtifactsDirectory, 0o755); err != nil { + return browserProofRunPaths{}, err + } + if err := ensureRootedDirectory(rootFS, browserProofDirectory, 0o755); err != nil { + return browserProofRunPaths{}, err + } + if err := admitRootedDestination(rootFS, proofPath); err != nil { + return browserProofRunPaths{}, err + } + for attempt := 0; attempt < 16; attempt++ { + var nonce [16]byte + if _, err := rand.Read(nonce[:]); err != nil { + return browserProofRunPaths{}, fmt.Errorf("create browser proof run nonce: %w", err) + } + runDirectory := filepath.Join(browserArtifactsDirectory, "browser-run-"+hex.EncodeToString(nonce[:])) + if err := rootFS.Mkdir(runDirectory, 0o700); errors.Is(err, fs.ErrExist) { + continue + } else if err != nil { + return browserProofRunPaths{}, fmt.Errorf("create confined browser proof run directory: %w", err) + } + return browserProofRunPaths{ + CandidatePath: filepath.ToSlash(filepath.Join(runDirectory, browserProofCandidateName)), + RunDirectory: filepath.ToSlash(runDirectory), + }, nil + } + return browserProofRunPaths{}, fmt.Errorf("create browser proof run directory: exhausted collision budget") +} + +func cleanupBrowserProofRun(root, runDirectory string) error { + rootFS, err := os.OpenRoot(root) + if err != nil { + return err + } + defer rootFS.Close() + return rootFS.RemoveAll(filepath.FromSlash(runDirectory)) +} + +func readRootedJSON(root, path string, maxBytes int64) (any, error) { + rootFS, err := os.OpenRoot(root) + if err != nil { + return nil, err + } + defer rootFS.Close() + localPath := filepath.FromSlash(path) + if err := admitRootedRegularFile(rootFS, localPath); err != nil { + return nil, err + } + file, err := rootFS.Open(localPath) + if err != nil { + return nil, err + } + defer file.Close() + return admission.DecodeJSON(file, maxBytes) +} + +func writeRootedJSON(root, path string, value any) error { + encoded, err := stablejson.MarshalLayout(value, stablejson.LayoutPretty) + if err != nil { + return err + } + encoded = append(encoded, '\n') + rootFS, err := os.OpenRoot(root) + if err != nil { + return err + } + defer rootFS.Close() + localPath := filepath.FromSlash(path) + if err := ensureRootedDirectory(rootFS, filepath.Dir(localPath), 0o755); err != nil { + return err + } + if err := admitRootedDestination(rootFS, localPath); err != nil { + return err + } + var nonce [16]byte + if _, err := rand.Read(nonce[:]); err != nil { + return err + } + temporaryPath := localPath + "." + hex.EncodeToString(nonce[:]) + ".tmp" + temporary, err := rootFS.OpenFile(temporaryPath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) + if err != nil { + return err + } + removeTemporary := true + defer func() { + if removeTemporary { + _ = rootFS.Remove(temporaryPath) + } + }() + if _, err := temporary.Write(encoded); err != nil { + temporary.Close() + return err + } + if err := temporary.Sync(); err != nil { + temporary.Close() + return err + } + if err := temporary.Chmod(0o644); err != nil { + temporary.Close() + return err + } + if err := temporary.Close(); err != nil { + return err + } + if err := rootFS.Rename(temporaryPath, localPath); err != nil { + return err + } + removeTemporary = false + return nil +} + +func ensureRootedDirectory(rootFS *os.Root, path string, mode fs.FileMode) error { + cleanPath := filepath.Clean(path) + if cleanPath == "." || !filepath.IsLocal(cleanPath) { + return fmt.Errorf("browser proof artifact directory must stay repository-relative") + } + current := "" + for _, part := range strings.Split(cleanPath, string(filepath.Separator)) { + current = filepath.Join(current, part) + info, err := rootFS.Lstat(current) + if errors.Is(err, fs.ErrNotExist) { + if err := rootFS.Mkdir(current, mode); err != nil { + return err + } + continue + } + if err != nil { + return err + } + if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() { + return fmt.Errorf("browser proof artifact directory traverses a symlink or non-directory") + } + } + return nil +} + +func admitRootedDestination(rootFS *os.Root, path string) error { + info, err := rootFS.Lstat(path) + if errors.Is(err, fs.ErrNotExist) { + return nil + } + if err != nil { + return err + } + if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() { + return fmt.Errorf("browser proof artifact destination must be a regular non-symlink file") + } + return nil +} + +func admitRootedRegularFile(rootFS *os.Root, path string) error { + cleanPath := filepath.Clean(path) + if cleanPath == "." || !filepath.IsLocal(cleanPath) { + return fmt.Errorf("browser proof artifact path must stay repository-relative") + } + current := "" + parts := strings.Split(cleanPath, string(filepath.Separator)) + for index, part := range parts { + current = filepath.Join(current, part) + info, err := rootFS.Lstat(current) + if err != nil { + return err + } + if info.Mode()&os.ModeSymlink != 0 { + return fmt.Errorf("browser proof artifact path traverses a symlink") + } + if index < len(parts)-1 && !info.IsDir() { + return fmt.Errorf("browser proof artifact path traverses a non-directory") + } + if index == len(parts)-1 && !info.Mode().IsRegular() { + return fmt.Errorf("browser proof artifact must be a regular file") + } + } + return nil +} diff --git a/internal/tools/browserproofverify/artifact_paths_test.go b/internal/tools/browserproofverify/artifact_paths_test.go new file mode 100644 index 0000000..ed6a41a --- /dev/null +++ b/internal/tools/browserproofverify/artifact_paths_test.go @@ -0,0 +1,73 @@ +package main + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestPrepareBrowserProofRunCreatesAConfinedDisposableDirectory(t *testing.T) { + root := t.TempDir() + paths, err := prepareBrowserProofRun(root) + if err != nil { + t.Fatal(err) + } + if !strings.HasPrefix(paths.RunDirectory, "artifacts/browser-run-") || paths.CandidatePath != paths.RunDirectory+"/"+browserProofCandidateName { + t.Fatalf("unexpected confined run paths: %#v", paths) + } + info, err := os.Lstat(filepath.Join(root, filepath.FromSlash(paths.RunDirectory))) + if err != nil || !info.IsDir() || info.Mode()&os.ModeSymlink != 0 { + t.Fatalf("run directory is not a confined directory: info=%v err=%v", info, err) + } + if err := cleanupBrowserProofRun(root, paths.RunDirectory); err != nil { + t.Fatal(err) + } + if _, err := os.Lstat(filepath.Join(root, filepath.FromSlash(paths.RunDirectory))); !os.IsNotExist(err) { + t.Fatalf("run directory survived cleanup: %v", err) + } +} + +func TestPrepareBrowserProofRunRejectsArtifactRootSymlink(t *testing.T) { + root := t.TempDir() + external := t.TempDir() + sentinel := filepath.Join(external, "sentinel.txt") + if err := os.WriteFile(sentinel, []byte("unchanged"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.Symlink(external, filepath.Join(root, browserArtifactsDirectory)); err != nil { + t.Skipf("symlink unavailable: %v", err) + } + if _, err := prepareBrowserProofRun(root); err == nil { + t.Fatal("prepareBrowserProofRun accepted a symlinked artifact root") + } + content, err := os.ReadFile(sentinel) + if err != nil || string(content) != "unchanged" { + t.Fatalf("external sentinel changed: content=%q err=%v", content, err) + } + entries, err := os.ReadDir(external) + if err != nil || len(entries) != 1 { + t.Fatalf("external directory was mutated: entries=%v err=%v", entries, err) + } +} + +func TestWriteRootedJSONRejectsSymlinkDestination(t *testing.T) { + root := t.TempDir() + external := filepath.Join(t.TempDir(), "external.json") + if err := os.WriteFile(external, []byte("sentinel"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Join(root, browserProofDirectory), 0o755); err != nil { + t.Fatal(err) + } + if err := os.Symlink(external, filepath.Join(root, filepath.FromSlash(proofPath))); err != nil { + t.Skipf("symlink unavailable: %v", err) + } + if err := writeRootedJSON(root, proofPath, map[string]any{"state": "passed"}); err == nil { + t.Fatal("writeRootedJSON accepted a symlink destination") + } + content, err := os.ReadFile(external) + if err != nil || string(content) != "sentinel" { + t.Fatalf("external destination changed: content=%q err=%v", content, err) + } +} diff --git a/internal/tools/browserproofverify/input_manifest.go b/internal/tools/browserproofverify/input_manifest.go new file mode 100644 index 0000000..b7679a7 --- /dev/null +++ b/internal/tools/browserproofverify/input_manifest.go @@ -0,0 +1,116 @@ +package main + +import ( + "bytes" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/research-engineering/agentic-proofkit/internal/kernel/admission" + "github.com/research-engineering/agentic-proofkit/internal/kernel/admit" +) + +const ( + proofInputManifestPath = "scripts/browser-runtime-proof-inputs.v1.json" + proofVerifierTarget = "./internal/tools/browserproofverify" +) + +type proofInputManifest struct { + ServerTarget string + TestRoot string + WriterPath string + Paths []string +} + +func loadProofInputManifest(root string) (proofInputManifest, error) { + raw, err := os.ReadFile(filepath.Join(root, proofInputManifestPath)) + if err != nil { + return proofInputManifest{}, err + } + value, err := admission.DecodeJSON(bytes.NewReader(raw), 1<<20) + if err != nil { + return proofInputManifest{}, err + } + record, ok := value.(map[string]any) + if !ok { + return proofInputManifest{}, fmt.Errorf("browser proof input manifest must be an object") + } + if err := admit.KnownKeys(record, []string{"paths", "proofKind", "schemaVersion", "serverTarget", "testRoot", "writerPath"}, "browser proof input manifest"); err != nil { + return proofInputManifest{}, err + } + if !admit.JSONNumberEquals(record["schemaVersion"], 1) || record["proofKind"] != "proofkit.browser-runtime-proof-inputs" { + return proofInputManifest{}, fmt.Errorf("browser proof input manifest identity is invalid") + } + serverTarget, err := admitManifestGoTarget(record["serverTarget"]) + if err != nil { + return proofInputManifest{}, err + } + writerPath, err := admitManifestPath(record["writerPath"], "browser proof input manifest writerPath") + if err != nil { + return proofInputManifest{}, err + } + testRoot, err := admitManifestPath(record["testRoot"], "browser proof input manifest testRoot") + if err != nil { + return proofInputManifest{}, err + } + paths, err := admitManifestPaths(record["paths"]) + if err != nil { + return proofInputManifest{}, err + } + for _, ownedPath := range []string{proofInputManifestPath, testRoot, writerPath} { + for _, path := range paths { + if path == ownedPath || strings.HasPrefix(ownedPath, path+"/") || strings.HasPrefix(path, ownedPath+"/") { + return proofInputManifest{}, fmt.Errorf("browser proof input manifest paths must not duplicate role-owned paths") + } + } + } + return proofInputManifest{ServerTarget: serverTarget, TestRoot: testRoot, WriterPath: writerPath, Paths: paths}, nil +} + +func admitManifestGoTarget(raw any) (string, error) { + value, err := admit.NonEmptyText(raw, "browser proof input manifest serverTarget") + if err != nil { + return "", err + } + if !strings.HasPrefix(value, "./") { + return "", fmt.Errorf("browser proof input manifest serverTarget must be an explicit relative package") + } + path, err := admit.SafeRepoRelativePath(strings.TrimPrefix(value, "./"), "browser proof input manifest serverTarget") + if err != nil { + return "", err + } + return "./" + path, nil +} + +func admitManifestPath(raw any, context string) (string, error) { + value, err := admit.NonEmptyText(raw, context) + if err != nil { + return "", err + } + return admit.SafeRepoRelativePath(value, context) +} + +func admitManifestPaths(raw any) ([]string, error) { + values, ok := raw.([]any) + if !ok || len(values) == 0 { + return nil, fmt.Errorf("browser proof input manifest paths must be a non-empty array") + } + result := make([]string, 0, len(values)) + for index, rawValue := range values { + value, err := admitManifestPath(rawValue, fmt.Sprintf("browser proof input manifest paths[%d]", index)) + if err != nil { + return nil, err + } + for _, previous := range result { + if value == previous || strings.HasPrefix(value, previous+"/") || strings.HasPrefix(previous, value+"/") { + return nil, fmt.Errorf("browser proof input manifest paths must be sorted, unique, and non-overlapping") + } + } + if len(result) > 0 && value <= result[len(result)-1] { + return nil, fmt.Errorf("browser proof input manifest paths must be sorted, unique, and non-overlapping") + } + result = append(result, value) + } + return result, nil +} diff --git a/internal/tools/browserproofverify/input_resolution.go b/internal/tools/browserproofverify/input_resolution.go new file mode 100644 index 0000000..1d496f4 --- /dev/null +++ b/internal/tools/browserproofverify/input_resolution.go @@ -0,0 +1,221 @@ +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "slices" + "sort" + "strings" + + "github.com/research-engineering/agentic-proofkit/internal/kernel/admission" + "github.com/research-engineering/agentic-proofkit/internal/kernel/admit" +) + +const proofInputResolutionEnvironment = "PROOFKIT_BROWSER_INPUT_RESOLUTION" + +type proofInputResolution struct { + InputPaths []string + ServerTarget string + WriterPath string + Selectors []string +} + +type goListPackage struct { + Dir string + Module *struct{ Main bool } + GoFiles []string + CgoFiles []string + CFiles []string + CXXFiles []string + MFiles []string + HFiles []string + FFiles []string + SFiles []string + SwigFiles []string + SwigCXXFiles []string + SysoFiles []string + EmbedFiles []string +} + +func resolveProofInputs(root string, manifest proofInputManifest) (proofInputResolution, error) { + goDirs, goFiles, err := repoLocalGoInputs(root, []string{proofVerifierTarget, manifest.ServerTarget}) + if err != nil { + return proofInputResolution{}, err + } + if err := validateRegularInputPaths(root, goFiles); err != nil { + return proofInputResolution{}, err + } + roleOwnedPaths := []string{proofInputManifestPath, manifest.TestRoot, manifest.WriterPath} + selectors := sortedUnique(append(roleOwnedPaths, append(manifest.Paths, goDirs...)...)) + if err := validateBrowserWitnessPolicy(root, selectors); err != nil { + return proofInputResolution{}, err + } + explicitPaths, err := collectInputPaths(root, append(roleOwnedPaths, manifest.Paths...)) + if err != nil { + return proofInputResolution{}, err + } + inputPaths := sortedUnique(append(explicitPaths, goFiles...)) + return proofInputResolution{ + InputPaths: inputPaths, + ServerTarget: manifest.ServerTarget, + WriterPath: manifest.WriterPath, + Selectors: selectors, + }, nil +} + +func repoLocalGoInputs(root string, targets []string) ([]string, []string, error) { + arguments := append([]string{"list", "-deps", "-json"}, targets...) + command := exec.Command("go", arguments...) + command.Dir = root + output, err := command.Output() + if err != nil { + return nil, nil, fmt.Errorf("resolve browser proof Go dependency closure: %w", err) + } + decoder := json.NewDecoder(bytes.NewReader(output)) + dirs := []string{} + files := []string{} + for { + var pkg goListPackage + if err := decoder.Decode(&pkg); err == io.EOF { + break + } else if err != nil { + return nil, nil, fmt.Errorf("decode browser proof Go dependency closure: %w", err) + } + if pkg.Module == nil || !pkg.Module.Main { + continue + } + relativeDir, err := repoRelativePath(root, pkg.Dir) + if err != nil { + return nil, nil, err + } + dirs = append(dirs, relativeDir) + for _, name := range goPackageSourceFiles(pkg) { + absolute := name + if !filepath.IsAbs(absolute) { + absolute = filepath.Join(pkg.Dir, name) + } + relative, err := repoRelativePath(root, absolute) + if err != nil { + return nil, nil, err + } + files = append(files, relative) + } + } + return sortedUnique(dirs), sortedUnique(files), nil +} + +func validateRegularInputPaths(root string, paths []string) error { + for _, path := range paths { + info, err := os.Lstat(filepath.Join(root, filepath.FromSlash(path))) + if err != nil { + return err + } + if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() { + return fmt.Errorf("browser proof resolved inputs must be regular non-symlink files") + } + } + return nil +} + +func goPackageSourceFiles(pkg goListPackage) []string { + groups := [][]string{ + pkg.GoFiles, pkg.CgoFiles, pkg.CFiles, pkg.CXXFiles, pkg.MFiles, pkg.HFiles, + pkg.FFiles, pkg.SFiles, pkg.SwigFiles, pkg.SwigCXXFiles, pkg.SysoFiles, pkg.EmbedFiles, + } + result := []string{} + for _, group := range groups { + result = append(result, group...) + } + return result +} + +func repoRelativePath(root, absolute string) (string, error) { + relative, err := filepath.Rel(root, absolute) + if err != nil { + return "", err + } + relative = filepath.ToSlash(relative) + if relative == "." || relative == ".." || strings.HasPrefix(relative, "../") { + return "", fmt.Errorf("browser proof input escaped the repository") + } + return admit.SafeRepoRelativePath(relative, "browser proof resolved input") +} + +func validateBrowserWitnessPolicy(root string, expected []string) error { + raw, err := os.ReadFile(filepath.Join(root, "proofkit/witness-plan.json")) + if err != nil { + return err + } + value, err := admission.DecodeJSON(bytes.NewReader(raw), int64(len(raw))) + if err != nil { + return err + } + record, ok := value.(map[string]any) + if !ok { + return fmt.Errorf("witness plan must be an object") + } + policies, ok := record["policies"].([]any) + if !ok { + return fmt.Errorf("witness plan policies must be an array") + } + matches := 0 + for index, rawPolicy := range policies { + policy, ok := rawPolicy.(map[string]any) + if !ok { + return fmt.Errorf("witness plan policies[%d] must be an object", index) + } + if policy["commandId"] != "proofkit.browser-check" { + continue + } + matches++ + selectors, err := admittedTextArray(policy["inputSelectors"], "browser witness inputSelectors") + if err != nil { + return err + } + if !slices.Equal(selectors, expected) { + return fmt.Errorf("browser witness inputSelectors drifted from the input manifest") + } + } + if matches != 1 { + return fmt.Errorf("witness plan must contain exactly one browser-check policy") + } + return nil +} + +func admittedTextArray(raw any, context string) ([]string, error) { + values, ok := raw.([]any) + if !ok { + return nil, fmt.Errorf("%s must be an array", context) + } + result := make([]string, 0, len(values)) + for index, rawValue := range values { + value, err := admit.NonEmptyText(rawValue, fmt.Sprintf("%s[%d]", context, index)) + if err != nil { + return nil, err + } + result = append(result, value) + } + if !slices.Equal(result, sortedUnique(result)) { + return nil, fmt.Errorf("%s must be sorted and unique", context) + } + return result, nil +} + +func sortedUnique(values []string) []string { + seen := make(map[string]struct{}, len(values)) + result := make([]string, 0, len(values)) + for _, value := range values { + if _, exists := seen[value]; exists { + continue + } + seen[value] = struct{}{} + result = append(result, value) + } + sort.Strings(result) + return result +} diff --git a/internal/tools/browserproofverify/main.go b/internal/tools/browserproofverify/main.go new file mode 100644 index 0000000..19e764a --- /dev/null +++ b/internal/tools/browserproofverify/main.go @@ -0,0 +1,433 @@ +package main + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "slices" + "sort" + "strconv" + "strings" + + "github.com/research-engineering/agentic-proofkit/internal/kernel/admit" + "github.com/research-engineering/agentic-proofkit/internal/kernel/digest" + "github.com/research-engineering/agentic-proofkit/internal/kernel/secretjson" + "github.com/research-engineering/agentic-proofkit/internal/kernel/stablejson" +) + +const proofPath = "artifacts/proofkit/browser-runtime-proof.json" + +var proofNonClaims = []string{ + "This record proves only that the listed content-bound Playwright tests passed in each recorded project before record creation.", + "Playwright WebKit is not branded Safari compatibility proof.", + "This record does not prove registry publication, rollout, or production readiness.", + "A dirty-tree record is content-bound only to its listed assets; sourceRevision is contextual.", + "This record does not independently authenticate or sandbox the same-user proof runner, external toolchain packages, or browser binaries.", +} + +func main() { + if len(os.Args) == 3 && os.Args[1] == "--admit-playwright-report" { + root, err := os.Getwd() + if err == nil { + err = writeAdmittedPlaywrightReport(root, os.Args[2]) + } + if err != nil { + exit(err) + } + return + } + root, err := repositoryRoot() + if err != nil { + exit(err) + } + switch { + case len(os.Args) == 1: + err = verifyCurrentProof(root) + case len(os.Args) == 2 && os.Args[1] == "--resolve-inputs": + err = writeCurrentInputResolution(root) + case len(os.Args) == 2 && os.Args[1] == "--run": + err = runBrowserProof(root) + default: + err = fmt.Errorf("usage: browserproofverify [--resolve-inputs | --run | --admit-playwright-report PATH]") + } + if err != nil { + exit(err) + } +} + +func verifyCurrentProof(root string) error { + manifest, err := loadProofInputManifest(root) + if err != nil { + return err + } + resolution, err := resolveProofInputs(root, manifest) + if err != nil { + return err + } + revision, state, err := currentSourceIdentity(root) + if err != nil { + return err + } + value, err := readRootedJSON(root, proofPath, 8<<20) + if err != nil { + return err + } + return verifyRecord(root, value, resolution, revision, state) +} + +func writeCurrentInputResolution(root string) error { + manifest, err := loadProofInputManifest(root) + if err != nil { + return err + } + resolution, err := resolveProofInputs(root, manifest) + if err != nil { + return err + } + encoded, err := encodeInputResolution(resolution) + if err != nil { + return err + } + _, err = os.Stdout.Write(encoded) + return err +} + +func runBrowserProof(root string) (resultErr error) { + manifest, err := loadProofInputManifest(root) + if err != nil { + return err + } + resolution, err := resolveProofInputs(root, manifest) + if err != nil { + return err + } + encoded, err := encodeInputResolution(resolution) + if err != nil { + return err + } + runPaths, err := prepareBrowserProofRun(root) + if err != nil { + return err + } + defer func() { + if cleanupErr := cleanupBrowserProofRun(root, runPaths.RunDirectory); cleanupErr != nil { + resultErr = errors.Join(resultErr, fmt.Errorf("clean browser proof run directory: %w", cleanupErr)) + } + }() + command := exec.Command("node", manifest.WriterPath) + command.Dir = root + environment := withEnvironmentValue(os.Environ(), proofInputResolutionEnvironment, strings.TrimSpace(string(encoded))) + environment = withEnvironmentValue(environment, browserRunDirectoryEnvironment, runPaths.RunDirectory) + environment = withEnvironmentValue(environment, browserProofCandidateEnvironment, runPaths.CandidatePath) + command.Env = environment + command.Stdout = os.Stdout + command.Stderr = os.Stderr + if err := command.Run(); err != nil { + return fmt.Errorf("run browser proof writer: %w", err) + } + value, err := readRootedJSON(root, runPaths.CandidatePath, 8<<20) + if err != nil { + return fmt.Errorf("read browser proof candidate: %w", err) + } + revision, state, err := currentSourceIdentity(root) + if err != nil { + return err + } + if err := verifyRecord(root, value, resolution, revision, state); err != nil { + return fmt.Errorf("admit browser proof candidate: %w", err) + } + if err := writeRootedJSON(root, proofPath, value); err != nil { + return fmt.Errorf("write browser runtime proof: %w", err) + } + return verifyCurrentProof(root) +} + +func currentSourceIdentity(root string) (string, string, error) { + revision, err := gitOutput(root, "rev-parse", "HEAD") + if err != nil { + return "", "", err + } + status, err := gitOutput(root, "status", "--porcelain=v1", "--untracked-files=all") + if err != nil { + return "", "", err + } + state := "clean" + if status != "" { + state = "dirty" + } + return revision, state, nil +} + +func encodeInputResolution(resolution proofInputResolution) ([]byte, error) { + paths := make([]any, len(resolution.InputPaths)) + for index, path := range resolution.InputPaths { + paths[index] = path + } + return stablejson.MarshalLayout(map[string]any{ + "inputPaths": paths, + "schemaVersion": 1, + "serverTarget": resolution.ServerTarget, + "writerPath": resolution.WriterPath, + }, stablejson.LayoutCompact) +} + +func withEnvironmentValue(environment []string, key, value string) []string { + prefix := key + "=" + result := make([]string, 0, len(environment)+1) + for _, entry := range environment { + if !strings.HasPrefix(entry, prefix) { + result = append(result, entry) + } + } + return append(result, prefix+value) +} + +func verifyRecord(root string, raw any, expectedResolution proofInputResolution, revision, treeState string) error { + record, ok := raw.(map[string]any) + if !ok { + return fmt.Errorf("browser runtime proof must be an object") + } + if err := admit.KnownKeys(record, []string{"assets", "command", "engines", "inputDigest", "inputResolution", "nonClaims", "projects", "proofKind", "schemaVersion", "sourceRevision", "sourceTreeState", "state"}, "browser runtime proof"); err != nil { + return err + } + if !admit.JSONNumberEquals(record["schemaVersion"], 2) || record["proofKind"] != "proofkit.browser-runtime-proof" || record["state"] != "passed" || record["sourceRevision"] != revision || record["sourceTreeState"] != treeState { + return fmt.Errorf("browser runtime proof identity or source state is invalid") + } + if err := exactTextArray(record["nonClaims"], proofNonClaims, "browser runtime proof nonClaims"); err != nil { + return err + } + command, ok := record["command"].(map[string]any) + if !ok || admit.KnownKeys(command, []string{"argv", "exitCode", "inputMode", "runner"}, "browser runtime proof command") != nil || command["inputMode"] != "materialized_snapshot" || command["runner"] != "node" || !admit.JSONNumberEquals(command["exitCode"], 0) { + return fmt.Errorf("browser runtime proof command is invalid") + } + if err := exactTextArray(command["argv"], []string{"node_modules/@playwright/test/cli.js", "test"}, "browser runtime proof command argv"); err != nil { + return err + } + inputResolution, ok := record["inputResolution"].(map[string]any) + if !ok || admit.KnownKeys(inputResolution, []string{"serverTarget", "writerPath"}, "browser runtime proof inputResolution") != nil || + inputResolution["serverTarget"] != expectedResolution.ServerTarget || inputResolution["writerPath"] != expectedResolution.WriterPath { + return fmt.Errorf("browser runtime proof input resolution is invalid") + } + assets, ok := record["assets"].([]any) + if !ok || len(assets) != len(expectedResolution.InputPaths) { + return fmt.Errorf("browser runtime proof assets do not match the input set") + } + identityAssets := make([]any, 0, len(assets)) + for index, rawAsset := range assets { + asset, ok := rawAsset.(map[string]any) + if !ok || admit.KnownKeys(asset, []string{"path", "sha256"}, "browser runtime proof asset") != nil || asset["path"] != expectedResolution.InputPaths[index] { + return fmt.Errorf("browser runtime proof asset order or shape is invalid") + } + path, err := admit.SafeRepoRelativePath(expectedResolution.InputPaths[index], "browser runtime proof asset path") + if err != nil { + return err + } + sha, err := admit.LowercaseSHA256(asset["sha256"], "browser runtime proof asset sha256") + if err != nil { + return err + } + content, err := os.ReadFile(filepath.Join(root, filepath.FromSlash(path))) + if err != nil { + return err + } + actual := sha256.Sum256(content) + if sha != hex.EncodeToString(actual[:]) { + return fmt.Errorf("browser runtime proof asset digest mismatch at %s", admit.RedactStructuralText(path)) + } + identityAssets = append(identityAssets, map[string]any{"path": path, "sha256": sha}) + } + encodedIdentity, err := json.Marshal(map[string]any{ + "assets": identityAssets, + "inputResolution": map[string]any{ + "serverTarget": expectedResolution.ServerTarget, + "writerPath": expectedResolution.WriterPath, + }, + }) + if err != nil { + return err + } + expectedInputDigest := digest.SHA256TextRef(string(encodedIdentity)) + if record["inputDigest"] != expectedInputDigest { + return fmt.Errorf("browser runtime proof inputDigest mismatch: got %s want %s", admit.RedactStructuralText(fmt.Sprint(record["inputDigest"])), expectedInputDigest) + } + engines, ok := record["engines"].([]any) + if !ok || len(engines) != 3 { + return fmt.Errorf("browser runtime proof must record three engines") + } + engineVersions := make([]string, len(engines)) + for index, name := range []string{"chromium", "firefox", "webkit"} { + engine, ok := engines[index].(map[string]any) + if !ok || admit.KnownKeys(engine, []string{"name", "version"}, "browser runtime proof engine") != nil || engine["name"] != name { + return fmt.Errorf("browser runtime proof engine identity is invalid") + } + version, err := admit.NonEmptyText(engine["version"], "browser runtime proof engine version") + if err != nil { + return err + } + engineVersions[index] = version + } + projectVersions, err := verifyProjectExecutions(record["projects"]) + if err != nil { + return err + } + if !slices.Equal(engineVersions, projectVersions) { + return fmt.Errorf("browser runtime proof engine versions must come from the recorded project executions") + } + findings, err := secretjson.Scan(record, "browser_runtime_proof") + if err != nil || len(findings) > 0 { + return fmt.Errorf("browser runtime proof contains secret-shaped data") + } + return nil +} + +func verifyProjectExecutions(raw any) ([]string, error) { + projects, ok := raw.([]any) + if !ok || len(projects) != 3 { + return nil, fmt.Errorf("browser runtime proof must record three project executions") + } + var expectedTestIDs []string + versions := make([]string, len(projects)) + for index, name := range []string{"chromium", "firefox", "webkit"} { + project, ok := projects[index].(map[string]any) + if !ok || admit.KnownKeys(project, []string{"browserName", "browserVersion", "executedTestCount", "name", "passedTestCount", "testIds"}, "browser runtime proof project") != nil || project["name"] != name || project["browserName"] != name { + return nil, fmt.Errorf("browser runtime proof project identity is invalid") + } + version, err := admit.NonEmptyText(project["browserVersion"], "browser runtime proof project browserVersion") + if err != nil { + return nil, err + } + versions[index] = version + executed, err := positiveJSONInteger(project["executedTestCount"], "browser runtime proof executedTestCount") + if err != nil { + return nil, err + } + passed, err := positiveJSONInteger(project["passedTestCount"], "browser runtime proof passedTestCount") + if err != nil { + return nil, err + } + testIDs, err := sortedUniqueTextArray(project["testIds"], "browser runtime proof testIds") + if err != nil { + return nil, err + } + if executed != passed || executed != len(testIDs) { + return nil, fmt.Errorf("browser runtime proof project counts do not prove all recorded tests passed") + } + if index == 0 { + expectedTestIDs = testIDs + } else if !slices.Equal(testIDs, expectedTestIDs) { + return nil, fmt.Errorf("browser runtime proof projects must record the same test identities") + } + } + return versions, nil +} + +func positiveJSONInteger(raw any, context string) (int, error) { + number, ok := raw.(json.Number) + if !ok { + return 0, fmt.Errorf("%s must be a positive integer", context) + } + value, err := strconv.Atoi(string(number)) + if err != nil || value <= 0 { + return 0, fmt.Errorf("%s must be a positive integer", context) + } + return value, nil +} + +func sortedUniqueTextArray(raw any, context string) ([]string, error) { + values, ok := raw.([]any) + if !ok || len(values) == 0 { + return nil, fmt.Errorf("%s must be a non-empty sorted unique array", context) + } + result := make([]string, len(values)) + for index, rawValue := range values { + value, err := admit.NonEmptyText(rawValue, context) + if err != nil { + return nil, err + } + if index > 0 && value <= result[index-1] { + return nil, fmt.Errorf("%s must be a non-empty sorted unique array", context) + } + result[index] = value + } + return result, nil +} + +func collectInputPaths(root string, inputs []string) ([]string, error) { + seen := map[string]struct{}{} + for _, input := range inputs { + absolute := filepath.Join(root, filepath.FromSlash(input)) + info, err := os.Lstat(absolute) + if err != nil { + return nil, err + } + if info.Mode()&os.ModeSymlink != 0 { + return nil, fmt.Errorf("browser proof input selector must not be a symlink") + } + if info.Mode().IsRegular() { + seen[input] = struct{}{} + continue + } + err = filepath.WalkDir(absolute, func(path string, entry os.DirEntry, err error) error { + if err != nil { + return err + } + if entry.Type()&os.ModeSymlink != 0 { + return fmt.Errorf("browser proof input tree must not contain symlinks") + } + if entry.Type().IsRegular() { + relative, err := filepath.Rel(root, path) + if err != nil { + return err + } + seen[filepath.ToSlash(relative)] = struct{}{} + } + return nil + }) + if err != nil { + return nil, err + } + } + paths := make([]string, 0, len(seen)) + for path := range seen { + paths = append(paths, path) + } + sort.Strings(paths) + return paths, nil +} + +func exactTextArray(raw any, expected []string, context string) error { + values, ok := raw.([]any) + if !ok || len(values) != len(expected) { + return fmt.Errorf("%s must equal the owner-defined values", context) + } + for index, value := range values { + if value != expected[index] { + return fmt.Errorf("%s must equal the owner-defined values", context) + } + } + return nil +} + +func repositoryRoot() (string, error) { + return gitOutput("", "rev-parse", "--show-toplevel") +} + +func gitOutput(root string, args ...string) (string, error) { + command := exec.Command("git", args...) + command.Dir = root + output, err := command.Output() + if err != nil { + return "", err + } + return strings.TrimSpace(string(output)), nil +} + +func exit(err error) { + fmt.Fprintln(os.Stderr, "browser runtime proof verification failed:", admit.RedactStructuralText(err.Error())) + os.Exit(1) +} diff --git a/internal/tools/browserproofverify/main_test.go b/internal/tools/browserproofverify/main_test.go new file mode 100644 index 0000000..1fe7449 --- /dev/null +++ b/internal/tools/browserproofverify/main_test.go @@ -0,0 +1,233 @@ +package main + +import ( + "bytes" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "os" + "path/filepath" + "slices" + "testing" + + "github.com/research-engineering/agentic-proofkit/internal/kernel/admission" + "github.com/research-engineering/agentic-proofkit/internal/kernel/digest" +) + +func TestInputManifestClosesGoDependenciesAndWitnessPolicy(t *testing.T) { + root, err := repositoryRoot() + if err != nil { + t.Fatal(err) + } + manifest, err := loadProofInputManifest(root) + if err != nil { + t.Fatal(err) + } + if manifest.TestRoot != "tests/browser" { + t.Fatalf("manifest testRoot = %q, want tests/browser", manifest.TestRoot) + } + for _, required := range []string{"go.mod", "go.sum", "proofkit/witness-plan.json"} { + if !slices.Contains(manifest.Paths, required) { + t.Fatalf("manifest omits required browser proof input selector %q", required) + } + } + resolution, err := resolveProofInputs(root, manifest) + if err != nil { + t.Fatal(err) + } + if !slices.Contains(resolution.Selectors, "internal/command/requirementcontext") { + t.Fatal("resolved witness selectors omit a transitive Go dependency") + } + if !slices.Contains(resolution.InputPaths, "internal/command/requirementcontext/slice.go") { + t.Fatal("resolved proof assets omit a transitive Go source file") + } + weakened := slices.DeleteFunc(append([]string{}, resolution.Selectors...), func(path string) bool { + return path == "internal/command/requirementcontext" + }) + if err := validateBrowserWitnessPolicy(root, weakened); err == nil { + t.Fatal("witness policy accepted a resolver projection with a missing Go dependency") + } +} + +func TestInputManifestRejectsNonCanonicalAuthority(t *testing.T) { + validPrefix := `{"schemaVersion":1,"proofKind":"proofkit.browser-runtime-proof-inputs","serverTarget":"./internal/tools/browsertestserver","testRoot":"tests/browser","writerPath":"scripts/write-browser-proof.mjs",` + tests := []struct { + name string + raw string + }{ + {name: "duplicate keys", raw: validPrefix + `"paths":["go.mod"],"paths":["go.sum"]}`}, + {name: "scheme path", raw: validPrefix + `"paths":["file:outside"]}`}, + {name: "role overlap", raw: validPrefix + `"paths":["scripts"]}`}, + {name: "test root overlap", raw: validPrefix + `"paths":["tests"]}`}, + } + for _, item := range tests { + t.Run(item.name, func(t *testing.T) { + root := t.TempDir() + if err := os.MkdirAll(filepath.Join(root, "scripts"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, filepath.FromSlash(proofInputManifestPath)), []byte(item.raw), 0o644); err != nil { + t.Fatal(err) + } + if _, err := loadProofInputManifest(root); err == nil { + t.Fatalf("loadProofInputManifest accepted %s", item.name) + } + }) + } +} + +func TestBrowserTestScriptUsesSingleGoOrchestrator(t *testing.T) { + root, err := repositoryRoot() + if err != nil { + t.Fatal(err) + } + rawPackage, err := os.ReadFile(filepath.Join(root, "package.json")) + if err != nil { + t.Fatal(err) + } + decodedPackage, err := admission.DecodeJSON(bytes.NewReader(rawPackage), int64(len(rawPackage))) + if err != nil { + t.Fatal(err) + } + script := decodedPackage.(map[string]any)["scripts"].(map[string]any)["browser:test"] + if script != "go run ./internal/tools/browserproofverify --run" { + t.Fatalf("browser:test must delegate execution to the single Go orchestrator, got %q", script) + } +} + +func TestVerifyRecordBindsExecutionAndSourceInputs(t *testing.T) { + root := t.TempDir() + paths := []string{"a.txt", "nested/b.txt"} + for index, path := range paths { + target := filepath.Join(root, filepath.FromSlash(path)) + if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(target, []byte{byte('a' + index)}, 0o644); err != nil { + t.Fatal(err) + } + } + resolution := fixtureResolution(paths) + record := fixtureRecord(t, root, resolution) + if err := verifyRecord(root, record, resolution, "abc123", "dirty"); err != nil { + t.Fatalf("verifyRecord() error = %v", err) + } + record["command"].(map[string]any)["exitCode"] = json.Number("1") + if err := verifyRecord(root, record, resolution, "abc123", "dirty"); err == nil { + t.Fatal("verifyRecord accepted failed browser command") + } + record = fixtureRecord(t, root, resolution) + if err := os.WriteFile(filepath.Join(root, "a.txt"), []byte("changed"), 0o644); err != nil { + t.Fatal(err) + } + if err := verifyRecord(root, record, resolution, "abc123", "dirty"); err == nil { + t.Fatal("verifyRecord accepted stale source input digest") + } +} + +func TestVerifyRecordRejectsAuthorityFieldMutations(t *testing.T) { + root := t.TempDir() + paths := []string{"a.txt", "b.txt"} + for _, path := range paths { + if err := os.WriteFile(filepath.Join(root, path), []byte(path), 0o644); err != nil { + t.Fatal(err) + } + } + resolution := fixtureResolution(paths) + tests := []struct { + name string + mutate func(map[string]any) + }{ + {name: "proof kind", mutate: func(record map[string]any) { record["proofKind"] = "proofkit.other" }}, + {name: "schema version", mutate: func(record map[string]any) { record["schemaVersion"] = json.Number("3") }}, + {name: "state", mutate: func(record map[string]any) { record["state"] = "failed" }}, + {name: "revision", mutate: func(record map[string]any) { record["sourceRevision"] = "other" }}, + {name: "tree state", mutate: func(record map[string]any) { record["sourceTreeState"] = "clean" }}, + {name: "command argv", mutate: func(record map[string]any) { record["command"].(map[string]any)["argv"] = []any{"playwright", "test"} }}, + {name: "command runner", mutate: func(record map[string]any) { record["command"].(map[string]any)["runner"] = "bun" }}, + {name: "command input mode", mutate: func(record map[string]any) { record["command"].(map[string]any)["inputMode"] = "mutable_worktree" }}, + {name: "command exit code", mutate: func(record map[string]any) { record["command"].(map[string]any)["exitCode"] = json.Number("1") }}, + {name: "input digest", mutate: func(record map[string]any) { + record["inputDigest"] = "sha256:0000000000000000000000000000000000000000000000000000000000000000" + }}, + {name: "server target", mutate: func(record map[string]any) { + record["inputResolution"].(map[string]any)["serverTarget"] = "./internal/tools/other" + }}, + {name: "writer path", mutate: func(record map[string]any) { + record["inputResolution"].(map[string]any)["writerPath"] = "scripts/other.mjs" + }}, + {name: "asset order", mutate: func(record map[string]any) { + assets := record["assets"].([]any) + assets[0], assets[1] = assets[1], assets[0] + }}, + {name: "extra asset", mutate: func(record map[string]any) { + record["assets"] = append(record["assets"].([]any), map[string]any{"path": "extra.txt", "sha256": "0000000000000000000000000000000000000000000000000000000000000000"}) + }}, + {name: "engine identity", mutate: func(record map[string]any) { record["engines"].([]any)[0].(map[string]any)["name"] = "chrome" }}, + {name: "engine version", mutate: func(record map[string]any) { record["engines"].([]any)[0].(map[string]any)["version"] = "" }}, + {name: "engine order", mutate: func(record map[string]any) { + engines := record["engines"].([]any) + engines[0], engines[1] = engines[1], engines[0] + }}, + {name: "project identity", mutate: func(record map[string]any) { record["projects"].([]any)[0].(map[string]any)["name"] = "chrome" }}, + {name: "project browser version", mutate: func(record map[string]any) { record["projects"].([]any)[0].(map[string]any)["browserVersion"] = "2" }}, + {name: "project count", mutate: func(record map[string]any) { + record["projects"].([]any)[0].(map[string]any)["passedTestCount"] = json.Number("2") + }}, + {name: "project test mismatch", mutate: func(record map[string]any) { + record["projects"].([]any)[1].(map[string]any)["testIds"] = []any{"tests/browser/other.spec.mjs::other"} + }}, + {name: "project test order", mutate: func(record map[string]any) { + record["projects"].([]any)[0].(map[string]any)["testIds"] = []any{"tests/browser/z.spec.mjs::z", "tests/browser/a.spec.mjs::a"} + record["projects"].([]any)[0].(map[string]any)["executedTestCount"] = json.Number("2") + record["projects"].([]any)[0].(map[string]any)["passedTestCount"] = json.Number("2") + }}, + {name: "non-claim", mutate: func(record map[string]any) { record["nonClaims"].([]any)[0] = "weaker" }}, + } + for _, item := range tests { + t.Run(item.name, func(t *testing.T) { + record := fixtureRecord(t, root, resolution) + item.mutate(record) + if err := verifyRecord(root, record, resolution, "abc123", "dirty"); err == nil { + t.Fatalf("verifyRecord accepted mutated %s", item.name) + } + }) + } +} + +func fixtureResolution(paths []string) proofInputResolution { + return proofInputResolution{InputPaths: paths, ServerTarget: "./internal/tools/browserfixture", WriterPath: "scripts/browser-fixture.mjs"} +} + +func fixtureRecord(t *testing.T, root string, resolution proofInputResolution) map[string]any { + t.Helper() + assets := make([]any, 0, len(resolution.InputPaths)) + for _, path := range resolution.InputPaths { + content, err := os.ReadFile(filepath.Join(root, filepath.FromSlash(path))) + if err != nil { + t.Fatal(err) + } + sum := sha256.Sum256(content) + assets = append(assets, map[string]any{"path": path, "sha256": hex.EncodeToString(sum[:])}) + } + inputResolution := map[string]any{"serverTarget": resolution.ServerTarget, "writerPath": resolution.WriterPath} + encoded, err := json.Marshal(map[string]any{"assets": assets, "inputResolution": inputResolution}) + if err != nil { + t.Fatal(err) + } + nonClaims := make([]any, len(proofNonClaims)) + for index, nonClaim := range proofNonClaims { + nonClaims[index] = nonClaim + } + return map[string]any{ + "assets": assets, "command": map[string]any{"argv": []any{"node_modules/@playwright/test/cli.js", "test"}, "exitCode": json.Number("0"), "inputMode": "materialized_snapshot", "runner": "node"}, + "engines": []any{map[string]any{"name": "chromium", "version": "1"}, map[string]any{"name": "firefox", "version": "1"}, map[string]any{"name": "webkit", "version": "1"}}, + "inputDigest": digest.SHA256TextRef(string(encoded)), "inputResolution": inputResolution, "nonClaims": nonClaims, + "projects": []any{ + map[string]any{"browserName": "chromium", "browserVersion": "1", "executedTestCount": json.Number("1"), "name": "chromium", "passedTestCount": json.Number("1"), "testIds": []any{"tests/browser/workspace.spec.mjs::runs"}}, + map[string]any{"browserName": "firefox", "browserVersion": "1", "executedTestCount": json.Number("1"), "name": "firefox", "passedTestCount": json.Number("1"), "testIds": []any{"tests/browser/workspace.spec.mjs::runs"}}, + map[string]any{"browserName": "webkit", "browserVersion": "1", "executedTestCount": json.Number("1"), "name": "webkit", "passedTestCount": json.Number("1"), "testIds": []any{"tests/browser/workspace.spec.mjs::runs"}}, + }, + "proofKind": "proofkit.browser-runtime-proof", "schemaVersion": json.Number("2"), "sourceRevision": "abc123", "sourceTreeState": "dirty", "state": "passed", + } +} diff --git a/internal/tools/browserproofverify/playwright_report.go b/internal/tools/browserproofverify/playwright_report.go new file mode 100644 index 0000000..edb170b --- /dev/null +++ b/internal/tools/browserproofverify/playwright_report.go @@ -0,0 +1,365 @@ +package main + +import ( + "bytes" + "fmt" + "io" + "os" + "path/filepath" + "slices" + "strings" + + "github.com/research-engineering/agentic-proofkit/internal/kernel/admission" + "github.com/research-engineering/agentic-proofkit/internal/kernel/admit" + "github.com/research-engineering/agentic-proofkit/internal/kernel/stablejson" +) + +const maxPlaywrightReportBytes = 8 << 20 + +var requiredBrowserProjects = []string{"chromium", "firefox", "webkit"} + +type browserProjectExecution struct { + BrowserName string + BrowserVersion string + Name string + TestIDs []string +} + +func writeAdmittedPlaywrightReport(root, reportPath string) error { + info, err := os.Lstat(reportPath) + if err != nil { + return err + } + if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() { + return fmt.Errorf("playwright report must be a regular non-symlink file") + } + if info.Size() > maxPlaywrightReportBytes { + return fmt.Errorf("playwright report exceeds the input size limit") + } + raw, err := os.ReadFile(reportPath) + if err != nil { + return err + } + manifest, err := loadProofInputManifest(root) + if err != nil { + return err + } + resolution, err := resolveProofInputs(root, manifest) + if err != nil { + return err + } + admittedPaths := make(map[string]struct{}, len(resolution.InputPaths)) + for _, path := range resolution.InputPaths { + admittedPaths[path] = struct{}{} + } + projects, err := admitPlaywrightReport(bytes.NewReader(raw), root, manifest.TestRoot, admittedPaths) + if err != nil { + return err + } + encoded, err := encodeBrowserProjectExecutions(projects) + if err != nil { + return err + } + _, err = os.Stdout.Write(encoded) + return err +} + +func admitPlaywrightReport(reader io.Reader, root, testRoot string, admittedPaths map[string]struct{}) ([]browserProjectExecution, error) { + raw, err := admission.DecodeJSON(reader, maxPlaywrightReportBytes) + if err != nil { + return nil, err + } + report, ok := raw.(map[string]any) + if !ok { + return nil, fmt.Errorf("playwright report must be an object") + } + if err := admit.KnownKeys(report, []string{"config", "errors", "stats", "suites"}, "playwright report"); err != nil { + return nil, err + } + projectIDs, err := admitPlaywrightConfig(report["config"], root, testRoot) + if err != nil { + return nil, err + } + errors, ok := report["errors"].([]any) + if !ok || len(errors) != 0 { + return nil, fmt.Errorf("playwright report must contain no aggregate errors") + } + suites, ok := report["suites"].([]any) + if !ok || len(suites) == 0 { + return nil, fmt.Errorf("playwright report must contain suites") + } + testsByProject := make(map[string]map[string]struct{}, len(requiredBrowserProjects)) + browserVersions := make(map[string]string, len(requiredBrowserProjects)) + for _, project := range requiredBrowserProjects { + testsByProject[project] = map[string]struct{}{} + } + if err := collectPlaywrightSuites(suites, nil, testRoot, admittedPaths, projectIDs, browserVersions, testsByProject); err != nil { + return nil, err + } + projects := make([]browserProjectExecution, len(requiredBrowserProjects)) + totalExecutions := 0 + for index, name := range requiredBrowserProjects { + testIDs := make([]string, 0, len(testsByProject[name])) + for testID := range testsByProject[name] { + testIDs = append(testIDs, testID) + } + slices.Sort(testIDs) + if len(testIDs) == 0 { + return nil, fmt.Errorf("required Playwright project executed no tests") + } + browserVersion := browserVersions[name] + if browserVersion == "" { + return nil, fmt.Errorf("required Playwright project recorded no runtime browser version") + } + projects[index] = browserProjectExecution{BrowserName: name, BrowserVersion: browserVersion, Name: name, TestIDs: testIDs} + totalExecutions += len(testIDs) + } + for _, project := range projects[1:] { + if !slices.Equal(project.TestIDs, projects[0].TestIDs) { + return nil, fmt.Errorf("playwright projects did not execute the same test identities") + } + } + if err := admitPlaywrightStats(report["stats"], totalExecutions); err != nil { + return nil, err + } + return projects, nil +} + +func admitPlaywrightConfig(raw any, root, testRoot string) (map[string]string, error) { + config, ok := raw.(map[string]any) + if !ok { + return nil, fmt.Errorf("playwright report config must be an object") + } + expectedRoot := filepath.ToSlash(filepath.Join(root, filepath.FromSlash(testRoot))) + if config["rootDir"] != expectedRoot { + return nil, fmt.Errorf("playwright report rootDir does not match the content-bound test root") + } + projects, ok := config["projects"].([]any) + if !ok || len(projects) != len(requiredBrowserProjects) { + return nil, fmt.Errorf("playwright report config must contain the required projects") + } + projectIDs := make(map[string]string, len(projects)) + for index, expectedName := range requiredBrowserProjects { + project, ok := projects[index].(map[string]any) + if !ok || project["name"] != expectedName || project["testDir"] != expectedRoot { + return nil, fmt.Errorf("playwright report project configuration is invalid") + } + projectID, err := admit.NonEmptyText(project["id"], "playwright report project id") + if err != nil { + return nil, err + } + if _, duplicate := projectIDs[expectedName]; duplicate { + return nil, fmt.Errorf("playwright report project configuration is duplicated") + } + projectIDs[expectedName] = projectID + } + return projectIDs, nil +} + +func collectPlaywrightSuites(suites []any, parentTitles []string, testRoot string, admittedPaths map[string]struct{}, projectIDs, browserVersions map[string]string, testsByProject map[string]map[string]struct{}) error { + for _, rawSuite := range suites { + suite, ok := rawSuite.(map[string]any) + if !ok { + return fmt.Errorf("playwright suite must be an object") + } + if err := admit.KnownKeys(suite, []string{"column", "file", "line", "specs", "suites", "title"}, "playwright suite"); err != nil { + return err + } + title, err := admit.NonEmptyText(suite["title"], "playwright suite title") + if err != nil { + return err + } + titles := append(slices.Clone(parentTitles), title) + if rawSpecs, exists := suite["specs"]; exists { + specs, ok := rawSpecs.([]any) + if !ok { + return fmt.Errorf("playwright suite specs must be an array") + } + for _, rawSpec := range specs { + if err := collectPlaywrightSpec(rawSpec, titles, testRoot, admittedPaths, projectIDs, browserVersions, testsByProject); err != nil { + return err + } + } + } + if rawSuites, exists := suite["suites"]; exists { + children, ok := rawSuites.([]any) + if !ok { + return fmt.Errorf("playwright nested suites must be an array") + } + if err := collectPlaywrightSuites(children, titles, testRoot, admittedPaths, projectIDs, browserVersions, testsByProject); err != nil { + return err + } + } + } + return nil +} + +func collectPlaywrightSpec(raw any, parentTitles []string, testRoot string, admittedPaths map[string]struct{}, projectIDs, browserVersions map[string]string, testsByProject map[string]map[string]struct{}) error { + spec, ok := raw.(map[string]any) + if !ok { + return fmt.Errorf("playwright spec must be an object") + } + if err := admit.KnownKeys(spec, []string{"column", "file", "id", "line", "ok", "tags", "tests", "title"}, "playwright spec"); err != nil { + return err + } + if spec["ok"] != true { + return fmt.Errorf("playwright spec status must be successful") + } + path, err := admittedPlaywrightSpecPath(spec["file"], testRoot, admittedPaths) + if err != nil { + return err + } + title, err := admit.NonEmptyText(spec["title"], "playwright spec title") + if err != nil { + return err + } + testID, err := admit.NonEmptyText(path+"::"+strings.Join(append(slices.Clone(parentTitles), title), " > "), "playwright test identity") + if err != nil { + return err + } + tests, ok := spec["tests"].([]any) + if !ok || len(tests) == 0 { + return fmt.Errorf("playwright spec must contain tests") + } + for _, rawTest := range tests { + if err := collectPlaywrightTest(rawTest, testID, projectIDs, browserVersions, testsByProject); err != nil { + return err + } + } + return nil +} + +func admittedPlaywrightSpecPath(raw any, testRoot string, admittedPaths map[string]struct{}) (string, error) { + reported, ok := raw.(string) + if !ok { + return "", fmt.Errorf("playwright spec file must be an admitted browser test path") + } + reported, err := admit.SafeRepoRelativePath(reported, "playwright spec file") + if err != nil { + return "", fmt.Errorf("playwright spec file must be an admitted browser test path") + } + candidate := testRoot + "/" + reported + if _, exists := admittedPaths[candidate]; exists { + return candidate, nil + } + return "", fmt.Errorf("playwright spec file is outside the content-bound input set") +} + +func collectPlaywrightTest(raw any, testID string, projectIDs, browserVersions map[string]string, testsByProject map[string]map[string]struct{}) error { + test, ok := raw.(map[string]any) + if !ok { + return fmt.Errorf("playwright test must be an object") + } + if err := admit.KnownKeys(test, []string{"annotations", "expectedStatus", "projectId", "projectName", "results", "status", "timeout"}, "playwright test"); err != nil { + return err + } + project, ok := test["projectName"].(string) + identities, exists := testsByProject[project] + if !ok || !exists { + return fmt.Errorf("playwright report contains an unexpected project") + } + if test["projectId"] != projectIDs[project] { + return fmt.Errorf("playwright report test is not bound to its configured project") + } + browserVersion, err := admitBrowserRuntimeAnnotations(test["annotations"], project) + if err != nil { + return err + } + if previous := browserVersions[project]; previous != "" && previous != browserVersion { + return fmt.Errorf("playwright project tests recorded inconsistent runtime browser versions") + } + browserVersions[project] = browserVersion + if test["expectedStatus"] != "passed" || test["status"] != "expected" { + return fmt.Errorf("playwright test status is not an expected pass") + } + results, ok := test["results"].([]any) + if !ok || len(results) != 1 { + return fmt.Errorf("playwright test must have exactly one non-retried result") + } + result, ok := results[0].(map[string]any) + if !ok { + return fmt.Errorf("playwright test result must be an object") + } + if err := admit.KnownKeys(result, []string{"annotations", "attachments", "duration", "errors", "parallelIndex", "retry", "startTime", "status", "stderr", "stdout", "workerIndex"}, "playwright test result"); err != nil { + return err + } + resultErrors, ok := result["errors"].([]any) + if result["status"] != "passed" || !admit.JSONNumberEquals(result["retry"], 0) || !ok || len(resultErrors) != 0 { + return fmt.Errorf("playwright test result is not a clean first-attempt pass") + } + if _, duplicate := identities[testID]; duplicate { + return fmt.Errorf("playwright report duplicates a project test identity") + } + identities[testID] = struct{}{} + return nil +} + +func admitBrowserRuntimeAnnotations(raw any, expectedEngine string) (string, error) { + annotations, ok := raw.([]any) + if !ok { + return "", fmt.Errorf("playwright test must record its runtime browser identity") + } + engineCount := 0 + versionCount := 0 + browserVersion := "" + for _, rawAnnotation := range annotations { + annotation, ok := rawAnnotation.(map[string]any) + if !ok { + return "", fmt.Errorf("playwright test annotation must be an object") + } + switch annotation["type"] { + case "proofkit.browser-engine": + if annotation["description"] != expectedEngine { + return "", fmt.Errorf("playwright test runtime browser engine does not match its project") + } + engineCount++ + case "proofkit.browser-version": + version, err := admit.NonEmptyText(annotation["description"], "playwright runtime browser version") + if err != nil { + return "", err + } + browserVersion = version + versionCount++ + } + } + if engineCount != 1 || versionCount != 1 { + return "", fmt.Errorf("playwright test must record exactly one runtime browser engine and version") + } + return browserVersion, nil +} + +func admitPlaywrightStats(raw any, expectedExecutions int) error { + stats, ok := raw.(map[string]any) + if !ok { + return fmt.Errorf("playwright report stats must be an object") + } + if err := admit.KnownKeys(stats, []string{"duration", "expected", "flaky", "skipped", "startTime", "unexpected"}, "playwright report stats"); err != nil { + return err + } + if !admit.JSONNumberEquals(stats["expected"], int64(expectedExecutions)) || + !admit.JSONNumberEquals(stats["flaky"], 0) || + !admit.JSONNumberEquals(stats["skipped"], 0) || + !admit.JSONNumberEquals(stats["unexpected"], 0) { + return fmt.Errorf("playwright report stats do not prove an all-passing execution") + } + return nil +} + +func encodeBrowserProjectExecutions(projects []browserProjectExecution) ([]byte, error) { + values := make([]any, len(projects)) + for index, project := range projects { + testIDs := make([]any, len(project.TestIDs)) + for testIndex, testID := range project.TestIDs { + testIDs[testIndex] = testID + } + values[index] = map[string]any{ + "browserName": project.BrowserName, + "browserVersion": project.BrowserVersion, + "executedTestCount": len(testIDs), + "name": project.Name, + "passedTestCount": len(testIDs), + "testIds": testIDs, + } + } + return stablejson.MarshalLayout(values, stablejson.LayoutCompact) +} diff --git a/internal/tools/browserproofverify/playwright_report_test.go b/internal/tools/browserproofverify/playwright_report_test.go new file mode 100644 index 0000000..31e8185 --- /dev/null +++ b/internal/tools/browserproofverify/playwright_report_test.go @@ -0,0 +1,193 @@ +package main + +import ( + "bytes" + "encoding/json" + "path/filepath" + "slices" + "strings" + "testing" +) + +func TestAdmitPlaywrightReportRequiresCleanEquivalentProjectExecutions(t *testing.T) { + root := t.TempDir() + report := validPlaywrightReport(root) + projects, err := admitPlaywrightReport(bytes.NewReader(encodePlaywrightReport(t, report)), root, "tests/browser", admittedBrowserTestPaths()) + if err != nil { + t.Fatal(err) + } + if len(projects) != 3 || projects[0].Name != "chromium" || projects[1].Name != "firefox" || projects[2].Name != "webkit" { + t.Fatalf("unexpected projects: %#v", projects) + } + if len(projects[0].TestIDs) != 2 || !slices.Equal(projects[0].TestIDs, projects[1].TestIDs) || !slices.Equal(projects[1].TestIDs, projects[2].TestIDs) { + t.Fatalf("project test identities are not equivalent: %#v", projects) + } + if projects[0].BrowserVersion != "123.0" || projects[1].BrowserVersion != "123.0" || projects[2].BrowserVersion != "123.0" { + t.Fatalf("project runtime versions were not admitted: %#v", projects) + } +} + +func TestAdmitPlaywrightReportResolvesTestDirRelativeSpecPaths(t *testing.T) { + root := t.TempDir() + report := validPlaywrightReport(root) + for _, rawSpec := range firstPlaywrightSuite(report)["specs"].([]any) { + rawSpec.(map[string]any)["file"] = "workspace.spec.mjs" + } + projects, err := admitPlaywrightReport(bytes.NewReader(encodePlaywrightReport(t, report)), root, "tests/browser", admittedBrowserTestPaths()) + if err != nil { + t.Fatal(err) + } + if !strings.HasPrefix(projects[0].TestIDs[0], "tests/browser/workspace.spec.mjs::") { + t.Fatalf("test identity is not repository-relative: %q", projects[0].TestIDs[0]) + } +} + +func TestAdmitPlaywrightReportRejectsInvalidEvidenceSemantics(t *testing.T) { + tests := []struct { + name string + mutate func(map[string]any) + }{ + {name: "aggregate errors", mutate: func(report map[string]any) { report["errors"] = []any{map[string]any{"message": "failure"}} }}, + {name: "unexpected test status", mutate: func(report map[string]any) { firstPlaywrightTest(report)["status"] = "unexpected" }}, + {name: "flaky test status", mutate: func(report map[string]any) { firstPlaywrightTest(report)["status"] = "flaky" }}, + {name: "retried result", mutate: func(report map[string]any) { firstPlaywrightResult(report)["retry"] = 1 }}, + {name: "result errors", mutate: func(report map[string]any) { + firstPlaywrightResult(report)["errors"] = []any{map[string]any{"message": "failure"}} + }}, + {name: "unsafe spec path", mutate: func(report map[string]any) { firstPlaywrightSpec(report)["file"] = "../outside.spec.mjs" }}, + {name: "unbound spec path", mutate: func(report map[string]any) { firstPlaywrightSpec(report)["file"] = "tests/browser/unbound.spec.mjs" }}, + {name: "unbound root directory", mutate: func(report map[string]any) { + report["config"].(map[string]any)["rootDir"] = "/tmp/unbound-browser-tests" + }}, + {name: "unbound project test directory", mutate: func(report map[string]any) { firstPlaywrightProject(report)["testDir"] = "/tmp/unbound-browser-tests" }}, + {name: "mismatched project id", mutate: func(report map[string]any) { firstPlaywrightTest(report)["projectId"] = "other" }}, + {name: "remapped runtime engine", mutate: func(report map[string]any) { firstPlaywrightEngineAnnotation(report)["description"] = "webkit" }}, + {name: "missing runtime version", mutate: func(report map[string]any) { + firstPlaywrightTest(report)["annotations"] = firstPlaywrightTest(report)["annotations"].([]any)[:1] + }}, + {name: "inconsistent runtime version", mutate: func(report map[string]any) { firstPlaywrightVersionAnnotation(report)["description"] = "999.0" }}, + {name: "non-text suite title", mutate: func(report map[string]any) { + firstPlaywrightSuite(report)["title"] = map[string]any{"value": "workspace"} + }}, + {name: "stats mismatch", mutate: func(report map[string]any) { report["stats"].(map[string]any)["expected"] = 5 }}, + {name: "missing project", mutate: func(report map[string]any) { + tests := firstPlaywrightSpec(report)["tests"].([]any) + firstPlaywrightSpec(report)["tests"] = tests[:2] + }}, + {name: "extra project", mutate: func(report map[string]any) { + firstPlaywrightSpec(report)["tests"] = append(firstPlaywrightSpec(report)["tests"].([]any), passingPlaywrightTest("mobile")) + report["stats"].(map[string]any)["expected"] = 7 + }}, + {name: "duplicate identity", mutate: func(report map[string]any) { + firstPlaywrightSpec(report)["tests"] = append(firstPlaywrightSpec(report)["tests"].([]any), passingPlaywrightTest("chromium")) + report["stats"].(map[string]any)["expected"] = 7 + }}, + {name: "different project test sets", mutate: func(report map[string]any) { + specs := firstPlaywrightSuite(report)["specs"].([]any) + second := specs[1].(map[string]any) + second["tests"] = second["tests"].([]any)[:2] + report["stats"].(map[string]any)["expected"] = 5 + }}, + } + for _, item := range tests { + t.Run(item.name, func(t *testing.T) { + root := t.TempDir() + report := validPlaywrightReport(root) + item.mutate(report) + if _, err := admitPlaywrightReport(bytes.NewReader(encodePlaywrightReport(t, report)), root, "tests/browser", admittedBrowserTestPaths()); err == nil { + t.Fatal("expected report admission failure") + } + }) + } +} + +func TestAdmitPlaywrightReportRejectsDuplicateJSONKeys(t *testing.T) { + root := t.TempDir() + encoded := string(encodePlaywrightReport(t, validPlaywrightReport(root))) + duplicated := strings.Replace(encoded, `"suites":`, `"suites":[],"suites":`, 1) + if _, err := admitPlaywrightReport(strings.NewReader(duplicated), root, "tests/browser", admittedBrowserTestPaths()); err == nil || !strings.Contains(err.Error(), "duplicate object key") { + t.Fatalf("expected duplicate-key rejection, got %v", err) + } +} + +func validPlaywrightReport(root string) map[string]any { + testRoot := filepath.ToSlash(filepath.Join(root, "tests/browser")) + return map[string]any{ + "config": map[string]any{ + "rootDir": testRoot, + "projects": []any{ + map[string]any{"id": "chromium", "name": "chromium", "testDir": testRoot}, + map[string]any{"id": "firefox", "name": "firefox", "testDir": testRoot}, + map[string]any{"id": "webkit", "name": "webkit", "testDir": testRoot}, + }, + }, + "errors": []any{}, + "stats": map[string]any{"duration": 1, "expected": 6, "flaky": 0, "skipped": 0, "startTime": "2026-07-13T00:00:00Z", "unexpected": 0}, + "suites": []any{map[string]any{ + "title": "workspace.spec.mjs", + "specs": []any{ + map[string]any{"file": "workspace.spec.mjs", "ok": true, "title": "renders authority", "tests": passingPlaywrightTests()}, + map[string]any{"file": "workspace.spec.mjs", "ok": true, "title": "creates handoff", "tests": passingPlaywrightTests()}, + }, + }}, + } +} + +func passingPlaywrightTests() []any { + return []any{passingPlaywrightTest("chromium"), passingPlaywrightTest("firefox"), passingPlaywrightTest("webkit")} +} + +func passingPlaywrightTest(project string) map[string]any { + return map[string]any{ + "annotations": []any{ + map[string]any{"description": project, "type": "proofkit.browser-engine"}, + map[string]any{"description": "123.0", "type": "proofkit.browser-version"}, + }, + "expectedStatus": "passed", + "projectId": project, + "projectName": project, + "results": []any{map[string]any{"errors": []any{}, "retry": 0, "status": "passed"}}, + "status": "expected", + } +} + +func admittedBrowserTestPaths() map[string]struct{} { + return map[string]struct{}{"tests/browser/workspace.spec.mjs": {}} +} + +func encodePlaywrightReport(t *testing.T, report map[string]any) []byte { + t.Helper() + encoded, err := json.Marshal(report) + if err != nil { + t.Fatal(err) + } + return encoded +} + +func firstPlaywrightSuite(report map[string]any) map[string]any { + return report["suites"].([]any)[0].(map[string]any) +} + +func firstPlaywrightProject(report map[string]any) map[string]any { + return report["config"].(map[string]any)["projects"].([]any)[0].(map[string]any) +} + +func firstPlaywrightSpec(report map[string]any) map[string]any { + return firstPlaywrightSuite(report)["specs"].([]any)[0].(map[string]any) +} + +func firstPlaywrightTest(report map[string]any) map[string]any { + return firstPlaywrightSpec(report)["tests"].([]any)[0].(map[string]any) +} + +func firstPlaywrightResult(report map[string]any) map[string]any { + return firstPlaywrightTest(report)["results"].([]any)[0].(map[string]any) +} + +func firstPlaywrightEngineAnnotation(report map[string]any) map[string]any { + return firstPlaywrightTest(report)["annotations"].([]any)[0].(map[string]any) +} + +func firstPlaywrightVersionAnnotation(report map[string]any) map[string]any { + return firstPlaywrightTest(report)["annotations"].([]any)[1].(map[string]any) +} diff --git a/internal/tools/browsertestserver/main.go b/internal/tools/browsertestserver/main.go new file mode 100644 index 0000000..024456b --- /dev/null +++ b/internal/tools/browsertestserver/main.go @@ -0,0 +1,29 @@ +package main + +import ( + "context" + "fmt" + "os" + "os/signal" + "syscall" + + "github.com/research-engineering/agentic-proofkit/internal/command/requirementbrowser" + "github.com/research-engineering/agentic-proofkit/internal/testsupport/browserfixture" +) + +func main() { + workspace, err := browserfixture.Workspace() + if err != nil { + fatal(err) + } + ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer cancel() + if err := requirementbrowser.Serve(ctx, workspace, requirementbrowser.Options{Host: "127.0.0.1", Port: 0, PortSet: true, SessionMode: "browse", View: "workspace"}, os.Stdout); err != nil && err != context.Canceled { + fatal(err) + } +} + +func fatal(err error) { + _, _ = fmt.Fprintln(os.Stderr, err) + os.Exit(1) +} diff --git a/internal/tools/commandfamilygen/main.go b/internal/tools/commandfamilygen/main.go index 9ed71bf..a543207 100644 --- a/internal/tools/commandfamilygen/main.go +++ b/internal/tools/commandfamilygen/main.go @@ -22,7 +22,7 @@ import ( const ( catalogPath = "proofkit/command-families.v1.json" - cliContractPath = "proofkit/cli-contract.v1.json" + cliContractPath = "proofkit/cli-contract.v2.json" generatedPath = "internal/app/command_family_catalog_generated.go" maxCatalogBytes = 1 << 20 ) diff --git a/internal/tools/coveragemetrics/main.go b/internal/tools/coveragemetrics/main.go index 3e3972f..a21e855 100644 --- a/internal/tools/coveragemetrics/main.go +++ b/internal/tools/coveragemetrics/main.go @@ -157,7 +157,7 @@ func run() error { if err != nil { return err } - contract, err := readJSON[cliContract]("proofkit/cli-contract.v1.json") + contract, err := readJSON[cliContract]("proofkit/cli-contract.v2.json") if err != nil { return err } diff --git a/internal/tools/coveragemetrics/main_test.go b/internal/tools/coveragemetrics/main_test.go index dbeebeb..47dd54a 100644 --- a/internal/tools/coveragemetrics/main_test.go +++ b/internal/tools/coveragemetrics/main_test.go @@ -481,7 +481,7 @@ func writeMinimalCoverageMetricsRepo(t *testing.T, root string) { } `) writeFile(t, filepath.Join(root, "proofkit/witness-plan.json"), `{"commands": [{"id": "proofkit.test.command"}]}`) - writeFile(t, filepath.Join(root, "proofkit/cli-contract.v1.json"), `{"commands": [{"command": "target"}]}`) + writeFile(t, filepath.Join(root, "proofkit/cli-contract.v2.json"), `{"commands": [{"command": "target"}]}`) } func writeFile(t *testing.T, path string, content string) { diff --git a/internal/tools/packageverify/main.go b/internal/tools/packageverify/main.go index 373c21c..ae9e57b 100644 --- a/internal/tools/packageverify/main.go +++ b/internal/tools/packageverify/main.go @@ -12,6 +12,7 @@ import ( "errors" "fmt" "io" + "maps" "os" "os/exec" "path/filepath" @@ -105,6 +106,7 @@ type packageManifest struct { Bin map[string]string `json:"bin"` CPU []string `json:"cpu"` Description string `json:"description"` + DevDependencies map[string]string `json:"devDependencies"` Exports map[string]string `json:"exports"` Files []string `json:"files"` License string `json:"license"` @@ -320,7 +322,7 @@ func requiredRootEntries() []string { "package/docs/proofkit-contract-map.md", "package/docs/release-process.md", "package/package.json", - "package/proofkit/cli-contract.v1.json", + "package/proofkit/cli-contract.v2.json", "package/proofkit/command-families.v1.json", "package/proofkit/receipt-producer-policy.json", "package/proofkit/requirement-bindings.json", @@ -453,22 +455,23 @@ func readManifestFromTar(artifact rootPackageArtifact) (packageManifest, error) func verifyManifestTopLevelKeys(record map[string]any) error { allowed := map[string]struct{}{ - "bin": {}, - "cpu": {}, - "description": {}, - "exports": {}, - "files": {}, - "license": {}, - "name": {}, - "os": {}, - "packageManager": {}, - "private": {}, - "publishConfig": {}, - "repository": {}, - "scripts": {}, - "sideEffects": {}, - "type": {}, - "version": {}, + "bin": {}, + "cpu": {}, + "description": {}, + "devDependencies": {}, + "exports": {}, + "files": {}, + "license": {}, + "name": {}, + "os": {}, + "packageManager": {}, + "private": {}, + "publishConfig": {}, + "repository": {}, + "scripts": {}, + "sideEffects": {}, + "type": {}, + "version": {}, } unknown := []string{} for key := range record { @@ -540,7 +543,7 @@ func allowedRootEntry(path string) bool { "package/docs/proofkit-contract-map.md": {}, "package/docs/release-process.md": {}, "package/package.json": {}, - "package/proofkit/cli-contract.v1.json": {}, + "package/proofkit/cli-contract.v2.json": {}, "package/proofkit/command-families.v1.json": {}, "package/proofkit/receipt-producer-policy.json": {}, "package/proofkit/requirement-bindings.json": {}, @@ -604,6 +607,10 @@ func verifyRootManifestBoundary(artifact rootPackageArtifact) error { if manifest.SideEffects { return fmt.Errorf("root package sideEffects must be false") } + expectedDevDependencies := map[string]string{"@axe-core/playwright": "4.12.1", "@playwright/test": "1.61.1", "typescript": "7.0.2"} + if !maps.Equal(manifest.DevDependencies, expectedDevDependencies) { + return fmt.Errorf("root package devDependencies must equal the source-only browser proof toolchain") + } if manifest.Repository.Type != "git" || manifest.Repository.URL != "git+https://github.com/research-engineering/agentic-proofkit.git" { return fmt.Errorf("root package repository must be git+https://github.com/research-engineering/agentic-proofkit.git") } @@ -928,6 +935,13 @@ func verifyInstalledJSONABI(consumer string, binPath string) error { }); err != nil { return fmt.Errorf("outside consumer JSON success smoke failed: %w", err) } + compactSuccess, err := runWithInput(consumer, binPath, packageSmokeSuccessInput(), "--json-layout", "compact", "text-policy", "--input", "-") + if err != nil { + return fmt.Errorf("outside consumer compact JSON success smoke failed to run: %w", err) + } + if err := verifyTextPolicySmokeReport(compactSuccess, "proofkit.package-smoke.success", "passed", 0, textPolicySmokeSummary{CheckedTextFileCount: 1, FailureCount: 0, InputFileCount: 1}); err != nil { + return fmt.Errorf("outside consumer compact JSON success smoke failed: %w", err) + } failed, err := runWithInput(consumer, binPath, packageSmokeFailureInput(), "text-policy", "--input", "-") if err != nil { return fmt.Errorf("outside consumer JSON failure smoke failed to run: %w", err) @@ -939,6 +953,13 @@ func verifyInstalledJSONABI(consumer string, binPath string) error { }); err != nil { return fmt.Errorf("outside consumer JSON failure smoke failed: %w", err) } + compactFailed, err := runWithInput(consumer, binPath, packageSmokeFailureInput(), "--json-layout", "compact", "text-policy", "--input", "-") + if err != nil { + return fmt.Errorf("outside consumer compact JSON failure smoke failed to run: %w", err) + } + if err := verifyTextPolicySmokeReport(compactFailed, "proofkit.package-smoke.failure", "failed", 1, textPolicySmokeSummary{CheckedTextFileCount: 1, FailureCount: 1, InputFileCount: 1}); err != nil { + return fmt.Errorf("outside consumer compact JSON failure smoke failed: %w", err) + } if err := verifyJSONAdapterSourceSmoke(consumer, binPath); err != nil { return err } @@ -1096,7 +1117,7 @@ func verifyOutsideConsumerImports(consumer string) error { rootPackageName + "/dist/agentic-proofkit", rootPackageName + "/dist/command-descriptors.json", rootPackageName + "/internal/app/command_descriptors", - rootPackageName + "/proofkit/cli-contract.v1.json", + rootPackageName + "/proofkit/cli-contract.v2.json", rootPackageName + "/proofkit/command-descriptors.v1.json", rootPackageName + "/internal/tools/packageverify", }, diff --git a/internal/tools/packageverify/main_test.go b/internal/tools/packageverify/main_test.go index ee52d91..c1e57ee 100644 --- a/internal/tools/packageverify/main_test.go +++ b/internal/tools/packageverify/main_test.go @@ -82,7 +82,7 @@ func TestVerifyPackedOwnerRecordsRejectsSourceArtifactContentDrift(t *testing.T) "package/LICENSE", "package/package.json", "package/docs/specs/example/requirements.v1.json", - "package/proofkit/cli-contract.v1.json", + "package/proofkit/cli-contract.v2.json", } for _, entry := range entries { sourcePath := strings.TrimPrefix(entry, "package/") @@ -333,8 +333,8 @@ func TestVerifyNoStalePackageDocsReadsTarballDocs(t *testing.T) { }, { name: "shipped spec json contract", - path: "package/proofkit/cli-contract.v1.json", - wantPath: "package/proofkit/cli-contract.v1.json", + path: "package/proofkit/cli-contract.v2.json", + wantPath: "package/proofkit/cli-contract.v2.json", staleText: "public/root API", }, } @@ -794,7 +794,7 @@ func packageDocEntries(content string) map[string]string { "package/docs/proofkit-contract-map.md": content, "package/docs/release-process.md": content, "package/docs/specs/proofkit-package-boundary/overview.md": content, - "package/proofkit/cli-contract.v1.json": content, + "package/proofkit/cli-contract.v2.json": content, } } @@ -887,6 +887,11 @@ func packageManifestFixture(repositoryURL string) string { "packageManager": "npm@11.18.0", "type": "module", "sideEffects": false, + "devDependencies": { + "@axe-core/playwright": "4.12.1", + "@playwright/test": "1.61.1", + "typescript": "7.0.2" + }, "repository": { "type": "git", "url": "` + repositoryURL + `" diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..3d639d0 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,490 @@ +{ + "name": "@research-engineering/agentic-proofkit", + "version": "0.1.159", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@research-engineering/agentic-proofkit", + "version": "0.1.159", + "cpu": [ + "arm64", + "x64" + ], + "license": "MIT", + "os": [ + "darwin", + "linux" + ], + "bin": { + "agentic-proofkit": "dist/agentic-proofkit" + }, + "devDependencies": { + "@axe-core/playwright": "4.12.1", + "@playwright/test": "1.61.1", + "typescript": "7.0.2" + } + }, + "node_modules/@axe-core/playwright": { + "version": "4.12.1", + "resolved": "https://registry.npmjs.org/@axe-core/playwright/-/playwright-4.12.1.tgz", + "integrity": "sha512-rMd7xriptqKpP+w5265i4Hdkv2X5kbu6uiBi/B2I7uf3hieRBM3qDCfaKPtxfiYb2mKXfF+yLODJwIx+Jv1GDw==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "axe-core": "~4.12.1" + }, + "peerDependencies": { + "playwright-core": ">= 1.0.0" + } + }, + "node_modules/@playwright/test": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.1.tgz", + "integrity": "sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.61.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@typescript/typescript-aix-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", + "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz", + "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz", + "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz", + "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz", + "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz", + "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz", + "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-loong64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz", + "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-mips64el": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz", + "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz", + "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-riscv64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz", + "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-s390x": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz", + "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz", + "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz", + "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz", + "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz", + "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz", + "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-sunos-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz", + "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz", + "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz", + "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/axe-core": { + "version": "4.12.1", + "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.12.1.tgz", + "integrity": "sha512-s7iGf5GaVMxEG0ENN9x+xTr7GFZCb1ZP/1uATUpCEK2X78nDB3RwbtFCo9pGAf9ru+VwoQ464DkaLEeRM08wJA==", + "dev": true, + "license": "MPL-2.0", + "engines": { + "node": ">=4" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/playwright": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz", + "integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.61.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz", + "integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/typescript": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", + "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc" + }, + "engines": { + "node": ">=16.20.0" + }, + "optionalDependencies": { + "@typescript/typescript-aix-ppc64": "7.0.2", + "@typescript/typescript-darwin-arm64": "7.0.2", + "@typescript/typescript-darwin-x64": "7.0.2", + "@typescript/typescript-freebsd-arm64": "7.0.2", + "@typescript/typescript-freebsd-x64": "7.0.2", + "@typescript/typescript-linux-arm": "7.0.2", + "@typescript/typescript-linux-arm64": "7.0.2", + "@typescript/typescript-linux-loong64": "7.0.2", + "@typescript/typescript-linux-mips64el": "7.0.2", + "@typescript/typescript-linux-ppc64": "7.0.2", + "@typescript/typescript-linux-riscv64": "7.0.2", + "@typescript/typescript-linux-s390x": "7.0.2", + "@typescript/typescript-linux-x64": "7.0.2", + "@typescript/typescript-netbsd-arm64": "7.0.2", + "@typescript/typescript-netbsd-x64": "7.0.2", + "@typescript/typescript-openbsd-arm64": "7.0.2", + "@typescript/typescript-openbsd-x64": "7.0.2", + "@typescript/typescript-sunos-x64": "7.0.2", + "@typescript/typescript-win32-arm64": "7.0.2", + "@typescript/typescript-win32-x64": "7.0.2" + } + } + } +} diff --git a/package.json b/package.json index f519623..cbebd42 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@research-engineering/agentic-proofkit", "description": "Reusable proof profile, report, graph, and witness-planning primitives.", - "version": "0.1.158", + "version": "0.1.159", "type": "module", "license": "MIT", "sideEffects": false, @@ -36,7 +36,10 @@ ], "scripts": { "build": "go run ./internal/tools/packagebuild", - "check": "npm run npm:version && npm run source-hygiene && npm run command-family:check && npm run text-policy && npm run mermaid:check && npm run go:check && npm run package:artifact && npm run self:receipt && npm run self:coverage && npm run release:closeout", + "check": "npm run npm:version && npm run source-hygiene && npm run command-family:check && npm run text-policy && npm run mermaid:check && npm run browser:check && npm run go:check && npm run package:artifact && npm run self:receipt && npm run self:coverage && npm run release:closeout", + "browser:check": "npm run browser:static-check && npm run browser:test", + "browser:static-check": "tsc -p tsconfig.browser.json && node --test scripts/browser-proof-execution.test.mjs scripts/browser-proof-inputs.test.mjs scripts/browser-selection-authority.test.mjs", + "browser:test": "go run ./internal/tools/browserproofverify --run", "command-family:check": "go run ./internal/tools/commandfamilygen --check", "go:actionlint": "go tool actionlint", "go:bench": "go test ./internal/kernel/admission ./internal/kernel/stablejson -run '^$' -bench . -benchmem -count=10", @@ -69,5 +72,10 @@ "cpu": [ "arm64", "x64" - ] + ], + "devDependencies": { + "@axe-core/playwright": "4.12.1", + "@playwright/test": "1.61.1", + "typescript": "7.0.2" + } } diff --git a/playwright.config.mjs b/playwright.config.mjs new file mode 100644 index 0000000..ff512af --- /dev/null +++ b/playwright.config.mjs @@ -0,0 +1,28 @@ +import {defineConfig, devices} from "@playwright/test"; + +const baseURL = process.env.PROOFKIT_BROWSER_TEST_URL; +if (!baseURL) throw new Error("PROOFKIT_BROWSER_TEST_URL is required"); +const outputDir = process.env.PROOFKIT_BROWSER_TEST_OUTPUT_DIR; +if (!outputDir) throw new Error("PROOFKIT_BROWSER_TEST_OUTPUT_DIR is required"); +const reportPath = process.env.PROOFKIT_BROWSER_TEST_REPORT_PATH; +if (!reportPath) throw new Error("PROOFKIT_BROWSER_TEST_REPORT_PATH is required"); + +export default defineConfig({ + outputDir, + testDir: "tests/browser", + fullyParallel: false, + forbidOnly: true, + retries: 0, + reporter: [["line"], ["json", {outputFile: reportPath}]], + timeout: 30_000, + workers: 1, + use: { + baseURL, + trace: "retain-on-failure", + }, + projects: [ + {name: "chromium", use: {...devices["Desktop Chrome"]}}, + {name: "firefox", use: {...devices["Desktop Firefox"]}}, + {name: "webkit", use: {...devices["Desktop Safari"]}}, + ], +}); diff --git a/proofkit/cli-contract.v1.json b/proofkit/cli-contract.v2.json similarity index 94% rename from proofkit/cli-contract.v1.json rename to proofkit/cli-contract.v2.json index 16f0b04..240bda2 100644 --- a/proofkit/cli-contract.v1.json +++ b/proofkit/cli-contract.v2.json @@ -1,15 +1,33 @@ { - "schemaVersion": 1, - "contractId": "proofkit.cli-contract.v1", + "schemaVersion": 2, + "contractId": "proofkit.cli-contract.v2", "packageName": "@research-engineering/agentic-proofkit", "processContract": { "successExitCode": 0, "failureExitCode": 1, "stdout": "Successful JSON commands write exactly one JSON value to stdout unless an admitted explicit output-file flag writes that JSON value to the selected path and leaves stdout empty. Successful text or help commands write text to stdout unless an admitted explicit output-file flag writes the selected representation to the selected path and leaves stdout empty. Commands do not write stderr on success.", "stderr": "Argument parsing, unsupported command, input admission, and output serialization failures write human diagnostics to stderr. Failed report commands may still write a machine-readable failed report to stdout when admission succeeds. Opt-in agent-envelope repair packets may write deterministic invalid-input JSON to stdout with exit code 1 and empty stderr.", + "globalOptions": { + "jsonLayout": { + "flag": "--json-layout", + "position": "before_command", + "values": [ + "compact", + "pretty" + ], + "default": "pretty", + "scope": "json_output_only" + } + }, "helpGrammar": { - "rootHelpFlags": ["--help", "-h"], - "commandHelpFlags": ["--help", "-h"], + "rootHelpFlags": [ + "--help", + "-h" + ], + "commandHelpFlags": [ + "--help", + "-h" + ], "helpCommandPositionalTarget": "optional_supported_command", "helpCatalogFormsSource": "proofkit/command-families.v1.json", "helpReadsCommandInput": false @@ -132,8 +150,14 @@ "readiness_closeout_input", "registry_consumer_input", "release_authority_input", + "requirement_context_catalog", + "requirement_context_repo_root", + "requirement_context_slice_input", + "requirement_semantic_diff_input", "requirement_source", "requirement_source_transition", + "requirement_traceability_graph_input", + "requirement_workspace_input", "scaffold_profile_plan", "scaffold_project_structure", "selective_evidence", @@ -170,11 +194,14 @@ "check_overview_claims", "decide_obligations", "inspect_coverage", + "inspect_requirement_context", + "inspect_requirement_traceability", "inventory_tests", "plan_selective_checks", "release_or_deploy_evidence", "render_human_view", "retire_local_infrastructure", + "review_requirement_change", "scaffold_first_module", "unknown", "validate_requirement_source", @@ -1017,10 +1044,82 @@ "--port", "--scope", "--serve", + "--session-mode", + "--session-timeout-seconds", "--view" ], "requiredFlags": [ "--view" + ], + "flagValueRequirements": [ + { + "flag": "--session-mode", + "requiredFlags": [ + "--open", + "--serve", + "--view" + ], + "value": "one-shot-question" + } + ], + "inputContract": { + "schemaVersion": 1, + "workspaceRequiredFields": [ + "context", + "schemaVersion", + "workspaceId" + ], + "workspaceOptionalFields": [ + "diffInput", + "graphInput" + ], + "sessionModeValues": [ + "browse", + "one-shot-question" + ], + "sessionTimeoutSeconds": { + "minimum": 1, + "maximum": 7200, + "requiresSessionMode": "one-shot-question" + }, + "oneShotRequirements": { + "open": true, + "serve": true, + "view": "workspace" + }, + "jsonLayoutRule": "allowed for non-serving JSON plans; serving and one-shot modes reject the global layout option because one-shot terminal packets are fixed compact JSON" + } + }, + { + "command": "requirement-context-compose", + "input": "required", + "stdin": true, + "inputPointer": true, + "scopeClass": "explicit_filesystem_scan", + "outputModes": [ + "json" + ], + "allowedFlags": [ + "--input", + "--input-pointer", + "--repo-root" + ], + "requiredFlags": [ + "--repo-root" + ] + }, + { + "command": "requirement-context-slice", + "input": "required", + "stdin": true, + "inputPointer": true, + "scopeClass": "explicit_caller_input", + "outputModes": [ + "json" + ], + "allowedFlags": [ + "--input", + "--input-pointer" ] }, { @@ -1321,6 +1420,20 @@ "--scope" ] }, + { + "command": "requirement-semantic-diff", + "input": "required", + "stdin": true, + "inputPointer": true, + "scopeClass": "explicit_caller_input", + "outputModes": [ + "json" + ], + "allowedFlags": [ + "--input", + "--input-pointer" + ] + }, { "command": "requirement-source-admission", "input": "required", @@ -1374,7 +1487,15 @@ "scopeClass": "explicit_caller_input", "inputContract": { "schemaVersion": 2, - "requiredFields": ["schemaVersion", "treeId", "rootNodeId", "callerAnnotations", "nodes", "edges", "overlays"], + "requiredFields": [ + "schemaVersion", + "treeId", + "rootNodeId", + "callerAnnotations", + "nodes", + "edges", + "overlays" + ], "callerTextAuthority": "callerAnnotations fields are untrusted presentation annotations and never command-owned nonClaims" }, "outputModes": [ @@ -1393,7 +1514,15 @@ "scopeClass": "explicit_caller_input", "inputContract": { "schemaVersion": 2, - "requiredFields": ["schemaVersion", "treeId", "rootNodeId", "callerAnnotations", "nodes", "edges", "overlays"], + "requiredFields": [ + "schemaVersion", + "treeId", + "rootNodeId", + "callerAnnotations", + "nodes", + "edges", + "overlays" + ], "callerTextAuthority": "callerAnnotations fields are untrusted presentation annotations and never command-owned nonClaims" }, "outputModes": [ @@ -1411,7 +1540,10 @@ "contractId": "proofkit.requirement-spec-tree-view.output.v2", "schemaVersion": 2, "authority": "requirement spec tree view output contract", - "nonJsonFormats": ["html", "markdown"], + "nonJsonFormats": [ + "html", + "markdown" + ], "requiredFields": [ "authority", "nodeCount", @@ -1425,6 +1557,20 @@ ] } }, + { + "command": "requirement-traceability-graph", + "input": "required", + "stdin": true, + "inputPointer": true, + "scopeClass": "explicit_caller_input", + "outputModes": [ + "json" + ], + "allowedFlags": [ + "--input", + "--input-pointer" + ] + }, { "command": "scaffold-profile-plan", "input": "required", diff --git a/proofkit/command-families.v1.json b/proofkit/command-families.v1.json index 9957065..f090cc6 100644 --- a/proofkit/command-families.v1.json +++ b/proofkit/command-families.v1.json @@ -110,6 +110,17 @@ "workspace-manifest-facts" ] }, + { + "familyId": "requirement-context-and-traceability", + "label": "Requirement context and traceability", + "purpose": "Compose bounded semantic context and derive diff and traceability projections.", + "commands": [ + "requirement-context-compose", + "requirement-context-slice", + "requirement-semantic-diff", + "requirement-traceability-graph" + ] + }, { "familyId": "requirement-navigation-and-rendering", "label": "Requirement navigation and rendering", diff --git a/proofkit/requirement-bindings.json b/proofkit/requirement-bindings.json index 5b0f711..6daa3d8 100644 --- a/proofkit/requirement-bindings.json +++ b/proofkit/requirement-bindings.json @@ -572,6 +572,22 @@ "This requirement does not claim producer authentication, hermetic or reproducible builds, registry publication, provider evidence freshness, release approval, rollout approval, production readiness, or exact artifact closure outside downstream package and release validators." ] }, + { + "requirementId": "REQ-PROOFKIT-QUALITY-021", + "ownerId": "proofkit.supply-chain-quality", + "specPath": "docs/specs/proofkit-supply-chain-quality/requirements.v1.json", + "claimLevel": "blocking", + "proofState": "witness_backed", + "nonClaims": ["This requirement does not change canonical source storage, semantic selection, stable identity, text output, help output, browser sessions, merge approval, release approval, or rollout readiness."] + }, + { + "requirementId": "REQ-PROOFKIT-QUALITY-022", + "ownerId": "proofkit.supply-chain-quality", + "specPath": "docs/specs/proofkit-supply-chain-quality/requirements.v1.json", + "claimLevel": "blocking", + "proofState": "witness_backed", + "nonClaims": ["This requirement does not claim full WCAG conformance, every branded browser, provider CI ingestion, registry publication, release approval, rollout approval, or production readiness."] + }, { "requirementId": "REQ-PROOFKIT-SPEC-017", "ownerId": "proofkit.spec-proof-core", @@ -591,6 +607,46 @@ "nonClaims": [ "This requirement does not claim byte-for-byte help text compatibility across owner-approved CLI contract corrections, command recommendation, command execution through family membership, consumer policy, native witness execution, merge approval, release approval, rollout approval, or production readiness." ] + }, + { + "requirementId": "REQ-PROOFKIT-SPEC-019", + "ownerId": "proofkit.spec-proof-core", + "specPath": "docs/specs/proofkit-spec-proof-core/requirements.v1.json", + "claimLevel": "blocking", + "proofState": "witness_backed", + "nonClaims": ["This requirement does not claim ambient repository discovery, later-checkout freshness, requirement meaning, proof adequacy, native witness execution, merge approval, release approval, rollout approval, or production readiness."] + }, + { + "requirementId": "REQ-PROOFKIT-SPEC-020", + "ownerId": "proofkit.spec-proof-core", + "specPath": "docs/specs/proofkit-spec-proof-core/requirements.v1.json", + "claimLevel": "blocking", + "proofState": "witness_backed", + "nonClaims": ["This requirement does not claim source completeness outside the admitted snapshot, product meaning, proof freshness, native execution, merge approval, release approval, rollout approval, or production readiness."] + }, + { + "requirementId": "REQ-PROOFKIT-SPEC-021", + "ownerId": "proofkit.spec-proof-core", + "specPath": "docs/specs/proofkit-spec-proof-core/requirements.v1.json", + "claimLevel": "blocking", + "proofState": "witness_backed", + "nonClaims": ["This requirement does not authenticate the surrounding browser profile or operating-system user and does not claim remote access, annotation persistence, agent execution, provider delivery, proof freshness, merge approval, release approval, rollout approval, branded Safari behavior, or production readiness."] + }, + { + "requirementId": "REQ-PROOFKIT-SPEC-022", + "ownerId": "proofkit.spec-proof-core", + "specPath": "docs/specs/proofkit-spec-proof-core/requirements.v1.json", + "claimLevel": "blocking", + "proofState": "witness_backed", + "nonClaims": ["This requirement does not claim textual equivalence, inferred moves, product meaning, proof freshness, merge approval, release approval, rollout approval, or production readiness."] + }, + { + "requirementId": "REQ-PROOFKIT-SPEC-023", + "ownerId": "proofkit.spec-proof-core", + "specPath": "docs/specs/proofkit-spec-proof-core/requirements.v1.json", + "claimLevel": "blocking", + "proofState": "witness_backed", + "nonClaims": ["This requirement does not infer code topology, line or branch coverage, native execution, proof freshness, merge approval, release approval, rollout approval, or production readiness."] } ], "bindings": [ @@ -2128,9 +2184,167 @@ "environmentClasses": [ "local-go" ] + }, + { + "requirementId": "REQ-PROOFKIT-QUALITY-021", + "scenarioId": "proofkit.supply-chain-quality.cli-json-layout-parity", + "witnessId": "proofkit.stablejson.layout-parity-falsifier", + "witnessKind": "contract", + "witnessPath": "internal/kernel/stablejson/stablejson_test.go", + "commandIds": ["proofkit.go-test"], + "environmentClasses": ["local-go"] + }, + { + "requirementId": "REQ-PROOFKIT-QUALITY-021", + "scenarioId": "proofkit.supply-chain-quality.cli-json-layout-process-boundary", + "witnessId": "proofkit.app.json-layout-process-boundary-falsifier", + "witnessKind": "contract", + "witnessPath": "internal/app/app_test.go", + "commandIds": ["proofkit.go-test"], + "environmentClasses": ["local-go"] + }, + { + "requirementId": "REQ-PROOFKIT-QUALITY-021", + "scenarioId": "proofkit.supply-chain-quality.cli-json-layout-file-boundary", + "witnessId": "proofkit.app.json-layout-file-boundary-falsifier", + "witnessKind": "contract", + "witnessPath": "internal/app/cli_abi_test.go", + "commandIds": ["proofkit.go-test"], + "environmentClasses": ["local-go"] + }, + { + "requirementId": "REQ-PROOFKIT-QUALITY-022", + "scenarioId": "proofkit.supply-chain-quality.browser-asset-and-security-boundary", + "witnessId": "proofkit.requirement-browser.workspace-security-falsifier", + "witnessKind": "contract", + "witnessPath": "internal/command/requirementbrowser/workspace_test.go", + "commandIds": ["proofkit.go-test"], + "environmentClasses": ["local-go"] + }, + { + "requirementId": "REQ-PROOFKIT-QUALITY-022", + "scenarioId": "proofkit.supply-chain-quality.browser-project-execution-evidence", + "witnessId": "proofkit.requirement-browser.project-execution-evidence-falsifier", + "witnessKind": "contract", + "witnessPath": "internal/tools/browserproofverify/playwright_report_test.go", + "commandIds": ["proofkit.go-test"], + "environmentClasses": ["local-go"] + }, + { + "requirementId": "REQ-PROOFKIT-QUALITY-022", + "scenarioId": "proofkit.supply-chain-quality.browser-proof-artifact-confinement", + "witnessId": "proofkit.requirement-browser.proof-artifact-confinement-falsifier", + "witnessKind": "contract", + "witnessPath": "internal/tools/browserproofverify/artifact_paths_test.go", + "commandIds": ["proofkit.go-test"], + "environmentClasses": ["local-go"] + }, + { + "requirementId": "REQ-PROOFKIT-QUALITY-022", + "scenarioId": "proofkit.supply-chain-quality.browser-terminal-concurrency", + "witnessId": "proofkit.requirement-browser.terminal-concurrency-falsifier", + "witnessKind": "contract", + "witnessPath": "internal/command/requirementbrowser/workspace_handoff_boundary_test.go", + "commandIds": ["proofkit.go-test"], + "environmentClasses": ["local-go"] + }, + { + "requirementId": "REQ-PROOFKIT-QUALITY-022", + "scenarioId": "proofkit.supply-chain-quality.browser-process-lifecycle", + "witnessId": "proofkit.requirement-browser.process-lifecycle-falsifier", + "witnessKind": "technical", + "witnessPath": "scripts/browser-proof-execution.test.mjs", + "commandIds": ["proofkit.browser-check"], + "environmentClasses": ["local-node-browser"] + }, + { + "requirementId": "REQ-PROOFKIT-SPEC-019", + "scenarioId": "proofkit.spec-proof-core.requirement-context-composition", + "witnessId": "proofkit.requirement-context.compose-round-trip-falsifier", + "witnessKind": "contract", + "witnessPath": "internal/command/requirementcontext/requirementcontext_test.go", + "commandIds": ["proofkit.go-test"], + "environmentClasses": ["local-go"] + }, + { + "requirementId": "REQ-PROOFKIT-SPEC-020", + "scenarioId": "proofkit.spec-proof-core.requirement-context-slicing", + "witnessId": "proofkit.requirement-context.slice-closure-falsifier", + "witnessKind": "contract", + "witnessPath": "internal/command/requirementcontext/slice_closure_test.go", + "commandIds": ["proofkit.go-test"], + "environmentClasses": ["local-go"] + }, + { + "requirementId": "REQ-PROOFKIT-SPEC-021", + "scenarioId": "proofkit.spec-proof-core.requirement-browser-workspace-handoff", + "witnessId": "proofkit.requirement-browser.workspace-handoff-falsifier", + "witnessKind": "contract", + "witnessPath": "internal/command/requirementbrowser/workspace_test.go", + "commandIds": ["proofkit.go-test"], + "environmentClasses": ["local-go"] + }, + { + "requirementId": "REQ-PROOFKIT-SPEC-021", + "scenarioId": "proofkit.spec-proof-core.requirement-browser-tree-domain-closure", + "witnessId": "proofkit.requirement-browser.tree-domain-closure-falsifier", + "witnessKind": "contract", + "witnessPath": "internal/command/requirementbrowser/workspace_handoff_boundary_test.go", + "commandIds": ["proofkit.go-test"], + "environmentClasses": ["local-go"] + }, + { + "requirementId": "REQ-PROOFKIT-SPEC-021", + "scenarioId": "proofkit.spec-proof-core.requirement-browser-packet-bounds", + "witnessId": "proofkit.requirement-browser.packet-bounds-falsifier", + "witnessKind": "contract", + "witnessPath": "internal/command/requirementbrowser/handoff_bounds_test.go", + "commandIds": ["proofkit.go-test"], + "environmentClasses": ["local-go"] + }, + { + "requirementId": "REQ-PROOFKIT-SPEC-022", + "scenarioId": "proofkit.spec-proof-core.requirement-semantic-diff", + "witnessId": "proofkit.requirement-semantic-diff.owner-field-falsifier", + "witnessKind": "contract", + "witnessPath": "internal/command/requirementdiff/requirementdiff_test.go", + "commandIds": ["proofkit.go-test"], + "environmentClasses": ["local-go"] + }, + { + "requirementId": "REQ-PROOFKIT-SPEC-023", + "scenarioId": "proofkit.spec-proof-core.requirement-traceability-evidence-planes", + "witnessId": "proofkit.requirement-traceability.evidence-plane-falsifier", + "witnessKind": "contract", + "witnessPath": "internal/command/requirementgraph/requirementgraph_test.go", + "commandIds": ["proofkit.go-test"], + "environmentClasses": ["local-go"] + }, + { + "requirementId": "REQ-PROOFKIT-SPEC-021", + "scenarioId": "proofkit.spec-proof-core.requirement-browser-rendered-runtime", + "witnessId": "proofkit.requirement-browser.rendered-runtime-falsifier", + "witnessKind": "technical", + "witnessPath": "tests/browser/workspace.spec.mjs", + "commandIds": ["proofkit.browser-check"], + "environmentClasses": ["local-node-browser"] + }, + { + "requirementId": "REQ-PROOFKIT-QUALITY-022", + "scenarioId": "proofkit.supply-chain-quality.browser-static-and-runtime-proof", + "witnessId": "proofkit.requirement-browser.static-runtime-proof-falsifier", + "witnessKind": "technical", + "witnessPath": "tests/browser/workspace.spec.mjs", + "commandIds": ["proofkit.browser-check"], + "environmentClasses": ["local-node-browser"] } ], "witnessCommands": [ + { + "commandId": "proofkit.browser-check", + "command": "npm run browser:check", + "environmentClass": "local-node-browser" + }, { "commandId": "proofkit.ci-receipt-anchor", "command": "npm run self:receipt", diff --git a/proofkit/witness-plan.json b/proofkit/witness-plan.json index 8e09033..7efdbc4 100644 --- a/proofkit/witness-plan.json +++ b/proofkit/witness-plan.json @@ -14,6 +14,7 @@ "environmentClasses": [ "local-go", "local-go-python", + "local-node-browser", "local-python" ], "environmentClassPolicies": [ @@ -47,6 +48,18 @@ "write-local" ] }, + { + "environmentClass": "local-node-browser", + "networkPolicies": [ + "none" + ], + "credentialClasses": [ + "none" + ], + "cachePolicies": [ + "disabled" + ] + }, { "environmentClass": "local-python", "networkPolicies": [ @@ -62,6 +75,7 @@ } ], "parallelGroups": [ + "browser-runtime", "coverage-metrics", "local-go-static", "package-artifact", @@ -628,6 +642,41 @@ 0 ] } + }, + { + "schemaVersion": 1, + "id": "proofkit.browser-check", + "cwd": ".", + "argv": [ + "npm", + "run", + "browser:check" + ], + "environment": { + "inherit": "none", + "allowlist": [], + "classes": [ + "local-node-browser" + ] + }, + "timeoutMs": 600000, + "networkPolicy": "none", + "credentialClass": "none", + "cachePolicy": "disabled", + "expectedArtifacts": [ + { + "kind": "report", + "path": "artifacts/proofkit/browser-runtime-proof.json", + "required": true + } + ], + "parallelGroup": "browser-runtime", + "exitCodePolicy": { + "kind": "zero", + "successCodes": [ + 0 + ] + } } ], "policies": [ @@ -669,6 +718,80 @@ "Self-hosting witness policy does not prove producer authentication or merge approval." ] }, + { + "commandId": "proofkit.browser-check", + "inputSelectors": [ + "go.mod", + "go.sum", + "internal/command/requirementbinding", + "internal/command/requirementbrowser", + "internal/command/requirementcontext", + "internal/command/requirementcoverageview", + "internal/command/requirementdiff", + "internal/command/requirementgraph", + "internal/command/requirementproofview", + "internal/command/requirementsourceadmission", + "internal/command/requirementsourceview", + "internal/command/requirementspectree", + "internal/command/testevidenceinventory", + "internal/kernel/admission", + "internal/kernel/admit", + "internal/kernel/agentenvelope", + "internal/kernel/browserdoc", + "internal/kernel/compactproofcontract", + "internal/kernel/digest", + "internal/kernel/markdownfmt", + "internal/kernel/report", + "internal/kernel/secretjson", + "internal/kernel/stablejson", + "internal/kernel/witnesscommand", + "internal/testsupport/browserfixture", + "internal/tools/browserproofverify", + "internal/tools/browsertestserver", + "package-lock.json", + "package.json", + "playwright.config.mjs", + "proofkit/witness-plan.json", + "scripts/browser-proof-execution.mjs", + "scripts/browser-proof-execution.test.mjs", + "scripts/browser-proof-inputs.mjs", + "scripts/browser-proof-inputs.test.mjs", + "scripts/browser-runtime-proof-inputs.v1.json", + "scripts/browser-selection-authority.test.mjs", + "scripts/write-browser-proof.mjs", + "tests/browser", + "tsconfig.browser.json" + ], + "outputSelectors": [ + "artifacts/proofkit/browser-runtime-proof.json" + ], + "resourceReads": [ + "resource.proofkit.source" + ], + "resourceWrites": [ + "resource.proofkit.local-artifacts" + ], + "exclusiveLocks": [], + "sideEffectClass": "local_write", + "deterministicOutput": false, + "cacheAdmissionRefs": [], + "retryPolicy": { + "kind": "none", + "maxAttempts": 1 + }, + "cancellationPolicy": { + "kind": "cooperative", + "graceMs": 5000 + }, + "timeoutPolicy": { + "kind": "bounded", + "timeoutMs": 600000 + }, + "nonClaims": [ + "Browser runtime proof does not prove registry publication, rollout, or production readiness.", + "Pinned Playwright engines are not branded browser compatibility proof." + ] + }, { "commandId": "proofkit.ci-receipt-anchor", "inputSelectors": [ @@ -731,7 +854,7 @@ "internal/command", "internal/testsupport/commandcoverage", "internal/tools/coveragemetrics", - "proofkit/cli-contract.v1.json", + "proofkit/cli-contract.v2.json", "proofkit/requirement-bindings.json", "proofkit/witness-plan.json" ], @@ -873,9 +996,11 @@ "go.mod", "go.sum", "internal", - "proofkit/cli-contract.v1.json", + "package.json", + "proofkit/cli-contract.v2.json", "proofkit/requirement-bindings.json", "proofkit/witness-plan.json", + "scripts/browser-runtime-proof-inputs.v1.json", "scripts/validate-self-hosting-receipts.go" ], "outputSelectors": [], diff --git a/scripts/browser-proof-execution.mjs b/scripts/browser-proof-execution.mjs new file mode 100644 index 0000000..a34b9f2 --- /dev/null +++ b/scripts/browser-proof-execution.mjs @@ -0,0 +1,203 @@ +import {spawn} from "node:child_process"; +import {symlinkSync} from "node:fs"; +import {join, resolve} from "node:path"; + +import {assertInputSnapshotUnchanged, materializeInputSnapshot, snapshotInputAssets} from "./browser-proof-inputs.mjs"; + +export async function executeBrowserProof({environment, execFile, inputPaths, nodeExecutable, resolution, runDirectory, spawnProcess, startServer = startBrowserServer, testCommand}) { + const sourceDirectory = resolve(runDirectory, "source"); + const assets = materializeInputSnapshot(inputPaths, ".", sourceDirectory); + symlinkSync(resolve("node_modules"), join(sourceDirectory, "node_modules"), process.platform === "win32" ? "junction" : "dir"); + const serverBinary = buildBrowserProofServer({execFile, runDirectory, serverTarget: resolution.serverTarget, sourceDirectory}); + const server = await startServer(serverBinary); + let projects; + let testResult; + let executionError; + try { + testResult = runBrowserProofTests({environment, nodeExecutable, runDirectory, serverURL: server.url, sourceDirectory, spawnProcess, testCommand}); + if (testResult.error) throw testResult.error; + if (testResult.status !== 0) throw new Error(`browser test command failed; diagnostics retained in ${runDirectory}`); + projects = admitBrowserProofReport({execFile, reportPath: resolve(runDirectory, "playwright-report.json"), sourceDirectory}); + } catch (error) { + executionError = error; + } + try { + await server.stop(); + } catch (cleanupError) { + if (executionError) { + throw new AggregateError([executionError, cleanupError], "browser proof execution and server cleanup both failed"); + } + throw cleanupError; + } + if (executionError) throw executionError; + assertInputSnapshotUnchanged(assets, snapshotInputAssets(inputPaths, sourceDirectory)); + return {assets, projects, sourceDirectory, testResult}; +} + +export function buildBrowserProofServer({execFile, runDirectory, serverTarget, sourceDirectory}) { + const serverBinary = resolve(runDirectory, "server"); + execFile("go", ["build", "-o", serverBinary, serverTarget], {cwd: sourceDirectory, stdio: "inherit"}); + return serverBinary; +} + +export function runBrowserProofTests({environment, nodeExecutable, runDirectory, serverURL, sourceDirectory, spawnProcess, testCommand}) { + const admittedEnvironment = Object.fromEntries( + Object.entries(environment).filter(([name]) => !name.startsWith("PW_TEST_CONNECT_")), + ); + return spawnProcess(nodeExecutable, testCommand, { + cwd: sourceDirectory, + env: { + ...admittedEnvironment, + PROOFKIT_BROWSER_TEST_REPORT_PATH: resolve(runDirectory, "playwright-report.json"), + PROOFKIT_BROWSER_TEST_OUTPUT_DIR: resolve(runDirectory, "test-results"), + PROOFKIT_BROWSER_TEST_URL: serverURL, + }, + stdio: "inherit", + }); +} + +function admitBrowserProofReport({execFile, reportPath, sourceDirectory}) { + const output = execFile( + "go", + ["run", "./internal/tools/browserproofverify", "--admit-playwright-report", reportPath], + {cwd: sourceDirectory, encoding: "utf8", maxBuffer: 8 << 20}, + ); + const projects = JSON.parse(String(output)); + if (!Array.isArray(projects)) throw new Error("browser proof report admission returned an invalid projection"); + return projects; +} + +export async function startBrowserServer(binary, options = {}) { + const spawnProcess = options.spawnProcess ?? spawn; + const readinessTimeoutMs = options.readinessTimeoutMs ?? 30_000; + const stopTimeoutMs = options.stopTimeoutMs ?? 10_000; + const child = spawnProcess(binary, [], {stdio: ["ignore", "pipe", "pipe"]}); + let stdout = ""; + let stderr = ""; + child.stdout.setEncoding("utf8"); + child.stderr.setEncoding("utf8"); + const collectStdout = (chunk) => { stdout = boundedDiagnostics(stdout, chunk); }; + const collectStderr = (chunk) => { stderr = boundedDiagnostics(stderr, chunk); }; + child.stdout.on("data", collectStdout); + child.stderr.on("data", collectStderr); + const lifecycle = observeChildProcess(child); + let ready = false; + let stopPromise; + const stop = () => { + const terminalBeforeStop = lifecycle.current(); + stopPromise ??= stopChildProcess(child, lifecycle, stopTimeoutMs, ready && terminalBeforeStop !== null).finally(() => { + child.stdout.off("data", collectStdout); + child.stderr.off("data", collectStderr); + lifecycle.dispose(); + }); + return stopPromise; + }; + try { + const url = await waitForBrowserServerReady(child, lifecycle, () => stdout, () => stderr, readinessTimeoutMs); + ready = true; + return {url, stop}; + } catch (error) { + try { + await stop(); + } catch (cleanupError) { + throw new AggregateError([error, cleanupError], "browser test server failed before readiness and cleanup did not complete"); + } + throw error; + } +} + +function waitForBrowserServerReady(child, lifecycle, stdout, stderr, timeoutMs) { + return new Promise((resolve, reject) => { + let settled = false; + const finish = (callback, value) => { + if (settled) return; + settled = true; + clearTimeout(timeout); + child.stdout.off("data", inspect); + callback(value); + }; + const inspect = () => { + const match = stdout().match(/Proofkit requirement browser: (http:\/\/127\.0\.0\.1:\d+\/)\n/); + if (match) finish(resolve, match[1]); + }; + const timeout = setTimeout(() => finish(reject, new Error(`browser test server readiness timed out: ${stderr()}`)), timeoutMs); + child.stdout.on("data", inspect); + lifecycle.promise.then((outcome) => finish(reject, terminalOutcomeError(outcome, "before readiness", stderr()))); + inspect(); + }); +} + +function observeChildProcess(child) { + let current = null; + let resolveTerminal; + const promise = new Promise((resolve) => { resolveTerminal = resolve; }); + const record = (outcome) => { + if (current !== null) return; + current = outcome; + resolveTerminal(outcome); + }; + const onExit = (code, signal) => record({code, kind: "exit", signal}); + const onError = () => record({kind: "error"}); + child.once("exit", onExit); + child.on("error", onError); + if (child.exitCode !== null || child.signalCode !== null) onExit(child.exitCode, child.signalCode); + return { + current: () => current, + dispose: () => { + child.off("exit", onExit); + child.off("error", onError); + }, + promise, + }; +} + +async function stopChildProcess(child, lifecycle, timeoutMs, rejectExistingTerminal) { + const existing = lifecycle.current(); + if (existing !== null) { + if (rejectExistingTerminal) throw terminalOutcomeError(existing, "before requested shutdown", ""); + return; + } + child.kill("SIGTERM"); + let outcome = await waitForTerminalOutcome(lifecycle, timeoutMs); + if (outcome !== null) { + admitRequestedShutdown(outcome); + return; + } + child.kill("SIGKILL"); + outcome = await waitForTerminalOutcome(lifecycle, timeoutMs); + if (outcome === null) throw new Error("browser test server did not stop after SIGKILL"); + admitRequestedShutdown(outcome); +} + +function waitForTerminalOutcome(lifecycle, timeoutMs) { + const existing = lifecycle.current(); + if (existing !== null) return Promise.resolve(existing); + return new Promise((resolve) => { + let settled = false; + const finish = (outcome) => { + if (settled) return; + settled = true; + clearTimeout(timeout); + resolve(outcome); + }; + const timeout = setTimeout(() => finish(null), timeoutMs); + lifecycle.promise.then(finish); + }); +} + +function admitRequestedShutdown(outcome) { + if (outcome.kind === "error") throw terminalOutcomeError(outcome, "during requested shutdown", ""); + if (outcome.code === 0 || outcome.signal === "SIGTERM" || outcome.signal === "SIGKILL") return; + throw terminalOutcomeError(outcome, "during requested shutdown", ""); +} + +function terminalOutcomeError(outcome, phase, diagnostics) { + if (outcome.kind === "error") return new Error(`browser test server process error ${phase}`); + const suffix = diagnostics === "" ? "" : `: ${diagnostics}`; + return new Error(`browser test server exited ${phase} with code ${outcome.code} and signal ${outcome.signal}${suffix}`); +} + +function boundedDiagnostics(current, chunk) { + const next = current + String(chunk); + return next.length <= 65_536 ? next : next.slice(-65_536); +} diff --git a/scripts/browser-proof-execution.test.mjs b/scripts/browser-proof-execution.test.mjs new file mode 100644 index 0000000..54e7f6a --- /dev/null +++ b/scripts/browser-proof-execution.test.mjs @@ -0,0 +1,149 @@ +import assert from "node:assert/strict"; +import {EventEmitter} from "node:events"; +import {mkdtempSync, rmSync} from "node:fs"; +import {tmpdir} from "node:os"; +import {join} from "node:path"; +import {PassThrough} from "node:stream"; +import test from "node:test"; + +import {executeBrowserProof, runBrowserProofTests, startBrowserServer} from "./browser-proof-execution.mjs"; + +test("browser proof test execution rejects ambient remote-connect authority", () => { + let observed; + const result = runBrowserProofTests({ + environment: { + KEEP_ME: "yes", + PW_TEST_CONNECT_EXPOSE_NETWORK: "*", + PW_TEST_CONNECT_FUTURE_OPTION: "unexpected", + PW_TEST_CONNECT_HEADERS: "{}", + PW_TEST_CONNECT_WS_ENDPOINT: "ws://remote.invalid/", + }, + nodeExecutable: "node", + runDirectory: "artifacts/run", + serverURL: "http://127.0.0.1:41001/", + sourceDirectory: "source", + spawnProcess: (_executable, _argv, options) => { + observed = options.env; + return {status: 0}; + }, + testCommand: ["playwright", "test"], + }); + assert.equal(result.status, 0); + assert.equal(observed.KEEP_ME, "yes"); + assert.equal(observed.PROOFKIT_BROWSER_TEST_URL, "http://127.0.0.1:41001/"); + assert.equal(Object.keys(observed).some((name) => name.startsWith("PW_TEST_CONNECT_")), false); +}); + +test("browser proof server readiness timeout terminates the child", async () => { + const child = new FakeChild((signal) => { + if (signal === "SIGTERM") child.exit(signal); + }); + await assert.rejects( + startBrowserServer("server", {spawnProcess: () => child, readinessTimeoutMs: 10, stopTimeoutMs: 50}), + /readiness timed out/, + ); + assert.deepEqual(child.signals, ["SIGTERM"]); + assert.equal(child.listenerCount("exit"), 0); +}); + +test("browser proof server cleanup escalates from SIGTERM to SIGKILL", async () => { + const child = new FakeChild((signal) => { + if (signal === "SIGKILL") child.exit(signal); + }); + queueMicrotask(() => child.stdout.write("Proofkit requirement browser: http://127.0.0.1:41001/\n")); + const server = await startBrowserServer("server", {spawnProcess: () => child, readinessTimeoutMs: 50, stopTimeoutMs: 10}); + assert.equal(server.url, "http://127.0.0.1:41001/"); + await server.stop(); + await server.stop(); + assert.deepEqual(child.signals, ["SIGTERM", "SIGKILL"]); + assert.equal(child.listenerCount("exit"), 0); +}); + +test("browser proof server observes a child that exited before readiness listeners", async () => { + const child = new FakeChild(() => {}); + child.exitCode = 23; + await assert.rejects( + startBrowserServer("server", {spawnProcess: () => child, readinessTimeoutMs: 50, stopTimeoutMs: 50}), + /exited before readiness with code 23/, + ); + assert.deepEqual(child.signals, []); + assert.equal(child.listenerCount("exit"), 0); +}); + +test("browser proof server rejects a terminal exit after readiness but before requested shutdown", async () => { + const child = new FakeChild(() => {}); + queueMicrotask(() => child.stdout.write("Proofkit requirement browser: http://127.0.0.1:41001/\n")); + const server = await startBrowserServer("server", {spawnProcess: () => child, readinessTimeoutMs: 50, stopTimeoutMs: 50}); + child.exitWithCode(1); + await Promise.resolve(); + await assert.rejects(server.stop(), /before requested shutdown/); + assert.deepEqual(child.signals, []); + assert.equal(child.listenerCount("error"), 0); +}); + +test("browser proof server captures a process error after readiness", async () => { + const child = new FakeChild(() => {}); + queueMicrotask(() => child.stdout.write("Proofkit requirement browser: http://127.0.0.1:41001/\n")); + const server = await startBrowserServer("server", {spawnProcess: () => child, readinessTimeoutMs: 50, stopTimeoutMs: 50}); + child.emit("error", new Error("post-readiness failure")); + await assert.rejects(server.stop(), /process error before requested shutdown/); + assert.deepEqual(child.signals, []); + assert.equal(child.listenerCount("error"), 0); +}); + +test("browser proof preserves execution and cleanup failures", async () => { + const root = mkdtempSync(join(tmpdir(), "proofkit-browser-dual-failure-")); + try { + await assert.rejects( + executeBrowserProof({ + environment: {}, + execFile: () => {}, + inputPaths: ["package.json"], + nodeExecutable: "node", + resolution: {serverTarget: "./internal/tools/browsertestserver"}, + runDirectory: join(root, "run"), + spawnProcess: () => ({status: 1}), + startServer: async () => ({ + stop: async () => { throw new Error("cleanup failed"); }, + url: "http://127.0.0.1:41001/", + }), + testCommand: ["playwright", "test"], + }), + (error) => error instanceof AggregateError && + error.errors.some((item) => /browser test command failed/.test(item.message)) && + error.errors.some((item) => /cleanup failed/.test(item.message)), + ); + } finally { + rmSync(root, {recursive: true, force: true}); + } +}); + +class FakeChild extends EventEmitter { + constructor(onKill) { + super(); + this.exitCode = null; + this.signalCode = null; + this.stderr = new PassThrough(); + this.stdout = new PassThrough(); + this.signals = []; + this.onKill = onKill; + } + + kill(signal) { + this.signals.push(signal); + this.onKill(signal); + return true; + } + + exit(signal) { + if (this.exitCode !== null || this.signalCode !== null) return; + this.signalCode = signal; + queueMicrotask(() => this.emit("exit", null, signal)); + } + + exitWithCode(code) { + if (this.exitCode !== null || this.signalCode !== null) return; + this.exitCode = code; + queueMicrotask(() => this.emit("exit", code, null)); + } +} diff --git a/scripts/browser-proof-inputs.mjs b/scripts/browser-proof-inputs.mjs new file mode 100644 index 0000000..405081b --- /dev/null +++ b/scripts/browser-proof-inputs.mjs @@ -0,0 +1,71 @@ +import {execFileSync} from "node:child_process"; +import {createHash} from "node:crypto"; +import {lstatSync, mkdirSync, readFileSync, writeFileSync} from "node:fs"; +import {dirname, isAbsolute, join} from "node:path"; + +export const browserProofInputManifestPath = "scripts/browser-runtime-proof-inputs.v1.json"; + +export function loadBrowserProofInputResolution() { + const encoded = process.env.PROOFKIT_BROWSER_INPUT_RESOLUTION ?? execFileSync( + "go", + ["run", "./internal/tools/browserproofverify", "--resolve-inputs"], + {encoding: "utf8"}, + ); + const resolution = JSON.parse(encoded); + if (!resolution || typeof resolution !== "object" || Array.isArray(resolution)) throw new Error("browser proof input resolution must be an object"); + const keys = Object.keys(resolution).sort(); + if (JSON.stringify(keys) !== JSON.stringify(["inputPaths", "schemaVersion", "serverTarget", "writerPath"])) throw new Error("browser proof input resolution keys are invalid"); + if (resolution.schemaVersion !== 1) throw new Error("browser proof input resolution schemaVersion is invalid"); + assertSortedUniqueStrings(resolution.inputPaths, "browser proof input resolution inputPaths"); + for (const path of resolution.inputPaths) { + if (!isSafeRepoPath(path)) throw new Error("browser proof input resolution contains an unsafe path"); + } + if (typeof resolution.serverTarget !== "string" || !resolution.serverTarget.startsWith("./") || !isSafeRepoPath(resolution.serverTarget.slice(2))) throw new Error("browser proof input resolution serverTarget is invalid"); + if (typeof resolution.writerPath !== "string" || !isSafeRepoPath(resolution.writerPath)) throw new Error("browser proof input resolution writerPath is invalid"); + return resolution; +} + +export function snapshotInputAssets(inputPaths, root = ".") { + return inputPaths.map((path) => inputAsset(path, root)); +} + +export function materializeInputSnapshot(inputPaths, sourceRoot, destinationRoot) { + return inputPaths.map((path) => { + if (!isSafeRepoPath(path)) throw new Error("browser proof snapshot input path must be repository-relative"); + const source = filePath(sourceRoot, path); + const metadata = lstatSync(source); + if (metadata.isSymbolicLink() || !metadata.isFile()) throw new Error("browser proof inputs must be regular non-symlink files"); + const content = readFileSync(source); + const destination = filePath(destinationRoot, path); + mkdirSync(dirname(destination), {recursive: true}); + writeFileSync(destination, content, {flag: "wx", mode: metadata.mode & 0o777}); + return {path, sha256: createHash("sha256").update(content).digest("hex")}; + }); +} + +export function assertInputSnapshotUnchanged(before, after) { + if (JSON.stringify(after) !== JSON.stringify(before)) { + throw new Error("browser proof inputs changed during execution"); + } +} + +function inputAsset(path, root) { + const source = filePath(root, path); + const metadata = lstatSync(source); + if (metadata.isSymbolicLink() || !metadata.isFile()) throw new Error("browser proof inputs must be regular non-symlink files"); + return {path, sha256: createHash("sha256").update(readFileSync(source)).digest("hex")}; +} + +function filePath(root, path) { + return isAbsolute(path) ? path : join(root, path); +} + +function assertSortedUniqueStrings(value, context) { + if (!Array.isArray(value) || value.length === 0 || value.some((item) => typeof item !== "string" || item.length === 0)) throw new Error(`${context} must be a non-empty string array`); + const sorted = [...new Set(value)].sort(); + if (JSON.stringify(value) !== JSON.stringify(sorted)) throw new Error(`${context} must be sorted and unique`); +} + +export function isSafeRepoPath(path) { + return path !== "." && !path.startsWith("/") && !path.includes("\\") && !path.includes(":") && path.split("/").every((part) => part !== "" && part !== "." && part !== ".."); +} diff --git a/scripts/browser-proof-inputs.test.mjs b/scripts/browser-proof-inputs.test.mjs new file mode 100644 index 0000000..c03ed3e --- /dev/null +++ b/scripts/browser-proof-inputs.test.mjs @@ -0,0 +1,188 @@ +import assert from "node:assert/strict"; +import {existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync} from "node:fs"; +import {tmpdir} from "node:os"; +import {dirname, join, relative, resolve} from "node:path"; +import test from "node:test"; + +import {createScanner, LanguageVariant, SyntaxKind} from "typescript/unstable/ast"; + +import {executeBrowserProof} from "./browser-proof-execution.mjs"; +import {assertInputSnapshotUnchanged, browserProofInputManifestPath, loadBrowserProofInputResolution, materializeInputSnapshot, snapshotInputAssets} from "./browser-proof-inputs.mjs"; + +test("owner resolution closes manifest, Go dependencies, and role-owned paths", () => { + const resolution = loadBrowserProofInputResolution(); + assert.deepEqual(resolution.inputPaths, [...resolution.inputPaths].sort()); + assert(resolution.inputPaths.includes(browserProofInputManifestPath)); + assert(resolution.inputPaths.includes(resolution.writerPath)); + assert(resolution.inputPaths.some((path) => path.startsWith("internal/command/requirementcontext/"))); + assert.equal(resolution.serverTarget, "./internal/tools/browsertestserver"); +}); + +test("every local JavaScript import is content-bound by the owner resolution", () => { + const {inputPaths} = loadBrowserProofInputResolution(); + const admitted = new Set(inputPaths); + for (const path of inputPaths.filter((candidate) => candidate.endsWith(".mjs") || candidate.endsWith(".js"))) { + for (const specifier of localModuleSpecifiers(path)) { + const importedPath = resolveLocalModule(path, specifier); + assert(admitted.has(importedPath), `${path} imports omitted browser proof input ${importedPath}`); + } + } +}); + +test("input snapshot rejects source mutation during browser proof execution", () => { + const root = mkdtempSync(join(tmpdir(), "proofkit-browser-inputs-")); + try { + const input = join(root, "input.txt"); + writeFileSync(input, "before"); + const before = snapshotInputAssets([input]); + writeFileSync(input, "after"); + const after = snapshotInputAssets([input]); + assert.throws(() => assertInputSnapshotUnchanged(before, after), /changed during execution/); + } finally { + rmSync(root, {recursive: true, force: true}); + } +}); + +test("materialized execution snapshot is isolated from mutate-restore source changes", () => { + const root = mkdtempSync(join(tmpdir(), "proofkit-browser-materialized-")); + try { + const sourceRoot = join(root, "source"); + const executionRoot = join(root, "execution"); + const input = join(sourceRoot, "input.txt"); + mkdirSync(sourceRoot, {recursive: true}); + writeFileSync(input, "before"); + const assets = materializeInputSnapshot(["input.txt"], sourceRoot, executionRoot); + writeFileSync(input, "transient-executed"); + assert.equal(readFileSync(join(executionRoot, "input.txt"), "utf8"), "before"); + writeFileSync(input, "before"); + assertInputSnapshotUnchanged(assets, snapshotInputAssets(["input.txt"], executionRoot)); + } finally { + rmSync(root, {recursive: true, force: true}); + } +}); + +test("materialized execution snapshot rejects escaping destination paths", () => { + const root = mkdtempSync(join(tmpdir(), "proofkit-browser-materialized-path-")); + try { + assert.throws(() => materializeInputSnapshot([join(root, "input.txt")], root, join(root, "execution")), /repository-relative/); + } finally { + rmSync(root, {recursive: true, force: true}); + } +}); + +test("browser proof composition binds materialization, build, and Playwright to one source root", async () => { + const root = mkdtempSync(join(tmpdir(), "proofkit-browser-process-boundary-")); + try { + const runDirectory = join(root, "run"); + const calls = []; + const result = await executeBrowserProof({ + environment: {PATH: "/test/bin"}, + execFile: (...args) => { + if (args[1]?.includes("--admit-playwright-report")) { + calls.push({kind: "admit-report", args}); + return JSON.stringify(passingProjectExecutions()); + } + calls.push({kind: "build", args}); + return undefined; + }, + inputPaths: ["package.json"], + nodeExecutable: "/test/node", + resolution: {serverTarget: "./internal/tools/browsertestserver"}, + runDirectory, + spawnProcess: (...args) => { + calls.push({kind: "test", args}); + writeFileSync(args[2].env.PROOFKIT_BROWSER_TEST_REPORT_PATH, JSON.stringify(passingPlaywrightReport())); + return {status: 0}; + }, + startServer: async (serverBinary) => { + calls.push({kind: "server", serverBinary}); + return {url: "http://127.0.0.1:41001/", stop: async () => { calls.push({kind: "stop"}); }}; + }, + testCommand: ["node_modules/@playwright/test/cli.js", "test"], + }); + const sourceDirectory = resolve(runDirectory, "source"); + assert.equal(result.sourceDirectory, sourceDirectory); + assert.equal(result.testResult.status, 0); + assert.deepEqual(result.projects.map((project) => project.name), ["chromium", "firefox", "webkit"]); + assert.equal(calls.length, 5); + assert.equal(calls[0].args[2].cwd, sourceDirectory); + assert.equal(calls[1].serverBinary, resolve(runDirectory, "server")); + assert.equal(calls[2].args[2].cwd, sourceDirectory); + assert.equal(calls[2].args[2].env.PROOFKIT_BROWSER_TEST_OUTPUT_DIR, resolve(runDirectory, "test-results")); + assert.equal(calls[2].args[2].env.PROOFKIT_BROWSER_TEST_REPORT_PATH, resolve(runDirectory, "playwright-report.json")); + assert.equal(calls[3].kind, "admit-report"); + assert.equal(calls[3].args[2].cwd, sourceDirectory); + assert.equal(calls[4].kind, "stop"); + } finally { + rmSync(root, {recursive: true, force: true}); + } +}); + +function passingPlaywrightReport() { + return { + config: {}, + errors: [], + stats: {duration: 1, expected: 3, flaky: 0, skipped: 0, startTime: "2026-07-13T00:00:00Z", unexpected: 0}, + suites: [{ + title: "workspace.spec.mjs", + specs: [{ + file: "tests/browser/workspace.spec.mjs", + ok: true, + title: "runs", + tests: ["chromium", "firefox", "webkit"].map((projectName) => ({expectedStatus: "passed", projectName, results: [{errors: [], retry: 0, status: "passed"}], status: "expected"})), + }], + }], + }; +} + +function passingProjectExecutions() { + const testIds = ["tests/browser/workspace.spec.mjs::workspace.spec.mjs > runs"]; + return ["chromium", "firefox", "webkit"].map((name) => ({executedTestCount: 1, name, passedTestCount: 1, testIds})); +} + +function localModuleSpecifiers(path) { + const scanner = createScanner(true, LanguageVariant.Standard, readFileSync(path, "utf8")); + const tokens = []; + for (let kind = scanner.scan(); kind !== SyntaxKind.EndOfFile; kind = scanner.scan()) { + tokens.push({kind, value: scanner.getTokenValue()}); + } + const result = []; + for (let index = 0; index < tokens.length; index += 1) { + const token = tokens[index]; + if (token.kind === SyntaxKind.ImportKeyword) { + const next = tokens[index + 1]; + if (next?.kind === SyntaxKind.DotToken) continue; + if (next?.kind === SyntaxKind.OpenParenToken) { + const specifier = tokens[index + 2]; + if (!isStaticModuleToken(specifier)) throw new Error(`${path} contains a non-static dynamic import`); + result.push(specifier.value); + continue; + } + if (isStaticModuleToken(next)) { + result.push(next.value); + continue; + } + const from = tokens.slice(index + 1).findIndex((candidate) => candidate.kind === SyntaxKind.FromKeyword || candidate.kind === SyntaxKind.SemicolonToken); + const absoluteFrom = from < 0 ? -1 : index + 1 + from; + if (absoluteFrom >= 0 && tokens[absoluteFrom].kind === SyntaxKind.FromKeyword && isStaticModuleToken(tokens[absoluteFrom + 1])) result.push(tokens[absoluteFrom + 1].value); + } + if (token.kind === SyntaxKind.ExportKeyword && [SyntaxKind.AsteriskToken, SyntaxKind.OpenBraceToken].includes(tokens[index + 1]?.kind)) { + const from = tokens.slice(index + 1).findIndex((candidate) => candidate.kind === SyntaxKind.FromKeyword || candidate.kind === SyntaxKind.SemicolonToken); + const absoluteFrom = from < 0 ? -1 : index + 1 + from; + if (absoluteFrom >= 0 && tokens[absoluteFrom].kind === SyntaxKind.FromKeyword && isStaticModuleToken(tokens[absoluteFrom + 1])) result.push(tokens[absoluteFrom + 1].value); + } + } + return result.filter((specifier) => specifier.startsWith(".")); +} + +function isStaticModuleToken(token) { + return token && [SyntaxKind.StringLiteral, SyntaxKind.NoSubstitutionTemplateLiteral].includes(token.kind); +} + +function resolveLocalModule(importer, specifier) { + const base = resolve(dirname(importer), specifier); + const candidates = [base, `${base}.mjs`, `${base}.js`, join(base, "index.mjs"), join(base, "index.js")]; + const match = candidates.find((candidate) => existsSync(candidate)); + if (!match) throw new Error(`${importer} imports missing local module ${specifier}`); + return relative(process.cwd(), match).replaceAll("\\", "/"); +} diff --git a/scripts/browser-runtime-proof-inputs.v1.json b/scripts/browser-runtime-proof-inputs.v1.json new file mode 100644 index 0000000..c58cc30 --- /dev/null +++ b/scripts/browser-runtime-proof-inputs.v1.json @@ -0,0 +1,21 @@ +{ + "schemaVersion": 1, + "proofKind": "proofkit.browser-runtime-proof-inputs", + "serverTarget": "./internal/tools/browsertestserver", + "testRoot": "tests/browser", + "writerPath": "scripts/write-browser-proof.mjs", + "paths": [ + "go.mod", + "go.sum", + "package-lock.json", + "package.json", + "playwright.config.mjs", + "proofkit/witness-plan.json", + "scripts/browser-proof-execution.mjs", + "scripts/browser-proof-execution.test.mjs", + "scripts/browser-proof-inputs.mjs", + "scripts/browser-proof-inputs.test.mjs", + "scripts/browser-selection-authority.test.mjs", + "tsconfig.browser.json" + ] +} diff --git a/scripts/browser-selection-authority.test.mjs b/scripts/browser-selection-authority.test.mjs new file mode 100644 index 0000000..58e195f --- /dev/null +++ b/scripts/browser-selection-authority.test.mjs @@ -0,0 +1,48 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import {emptySelectionState, transitionSelection} from "../internal/command/requirementbrowser/assets/selection-authority.js"; + +const target = { + anchorId: "requirement:REQ-PROOFKIT-SPEC-001:invariant", + endCodePoint: 9, + exactQuote: "Invariant", + startCodePoint: 0, +}; + +test("collapsed text selection cannot retain hidden handoff authority", () => { + const selected = transitionSelection(emptySelectionState(), {kind: "text", targets: [target]}); + assertEmptySelection(transitionSelection(selected, {kind: "collapse"})); +}); + +test("collapsed native selection does not revoke explicit button authority", () => { + const selected = transitionSelection(emptySelectionState(), {kind: "button", targets: [target]}); + assert.deepEqual(transitionSelection(selected, {kind: "collapse"}), selected); +}); + +test("empty text selection and explicit clear produce the empty state", () => { + const selected = transitionSelection(emptySelectionState(), {kind: "text", targets: [target]}); + assertEmptySelection(transitionSelection(selected, {kind: "text", targets: []})); + assertEmptySelection(transitionSelection(selected, {kind: "clear"})); +}); + +test("initial selection state has no handoff authority", () => { + assertEmptySelection(emptySelectionState()); +}); + +test("button authority requires exactly one target", () => { + assert.throws(() => transitionSelection(emptySelectionState(), {kind: "button", targets: []}), /exactly one target/); + assert.throws(() => transitionSelection(emptySelectionState(), {kind: "button", targets: [target, target]}), /exactly one target/); +}); + +test("selection state does not retain caller-owned target objects", () => { + const callerTarget = {...target}; + const selected = transitionSelection(emptySelectionState(), {kind: "text", targets: [callerTarget]}); + callerTarget.exactQuote = "mutated"; + assert.equal(selected.targets[0]?.exactQuote, "Invariant"); +}); + +function assertEmptySelection(state) { + assert.equal(state.mode, "none"); + assert.deepEqual(state.targets, []); +} diff --git a/scripts/workflow_package_gate_oracle_test.go b/scripts/workflow_package_gate_oracle_test.go index f11c6e9..26473da 100644 --- a/scripts/workflow_package_gate_oracle_test.go +++ b/scripts/workflow_package_gate_oracle_test.go @@ -629,7 +629,7 @@ func TestCIWorkflowDeclaresFailClosedRequiredAggregate(t *testing.T) { if !usesAlwaysStatusCheck(gate.If) { t.Fatalf("ci-required-gate if=%q, want always() so failed or skipped needs are inspected", gate.If) } - wantNeeds := []string{"platform-smoke", "source-quality"} + wantNeeds := []string{"browser-runtime", "platform-smoke", "source-quality"} if got := needsList(gate.Needs); !reflect.DeepEqual(got, wantNeeds) { t.Fatalf("ci-required-gate needs=%#v, want %#v", got, wantNeeds) } @@ -645,6 +645,55 @@ func TestCIWorkflowDeclaresFailClosedRequiredAggregate(t *testing.T) { } } +func TestCIBrowserRuntimeInstallsEnginesBeforeProofAndRetainsOnlySuccessfulEvidence(t *testing.T) { + workflow := readWorkflowForTest(t, filepath.Join("..", ".github", "workflows", "ci.yml")) + job, ok := workflow.Jobs["browser-runtime"] + if !ok { + t.Fatal("ci workflow missing browser-runtime job") + } + installIndex, err := uniqueStepIndex(job.Steps, "Install pinned browser engines") + if err != nil { + t.Fatal(err) + } + proofIndex, err := uniqueStepIndex(job.Steps, "Run browser proof") + if err != nil { + t.Fatal(err) + } + uploadIndex, err := uniqueStepIndex(job.Steps, "Upload browser proof") + if err != nil { + t.Fatal(err) + } + if !(installIndex < proofIndex && proofIndex < uploadIndex) { + t.Fatalf("browser runtime order install=%d proof=%d upload=%d", installIndex, proofIndex, uploadIndex) + } + if job.Steps[installIndex].Run != "npx playwright install --with-deps chromium firefox webkit" || job.Steps[proofIndex].Run != "npm run browser:check" { + t.Fatalf("browser runtime commands are not exact: install=%q proof=%q", job.Steps[installIndex].Run, job.Steps[proofIndex].Run) + } + upload := job.Steps[uploadIndex] + if usesAlwaysStatusCheck(upload.If) || upload.With["if-no-files-found"] != "error" || upload.With["path"] != "artifacts/proofkit/browser-runtime-proof.json" { + t.Fatalf("browser proof upload is not fail-closed success evidence: %#v", upload) + } +} + +func TestReleaseCandidateInstallsBrowserEnginesBeforePackageGate(t *testing.T) { + workflow := readWorkflowForTest(t, filepath.Join("..", ".github", "workflows", "release.yml")) + job, ok := workflow.Jobs["candidate"] + if !ok { + t.Fatal("release workflow missing candidate job") + } + installIndex, err := uniqueStepIndex(job.Steps, "Install pinned browser engines") + if err != nil { + t.Fatal(err) + } + gateIndex, err := uniqueStepIndex(job.Steps, "Run package gate") + if err != nil { + t.Fatal(err) + } + if installIndex >= gateIndex || job.Steps[installIndex].Run != "npx playwright install --with-deps chromium firefox webkit" || job.Steps[gateIndex].Run != "npm run check" { + t.Fatalf("release browser prerequisite is not fail-closed before package gate: install=%#v gate=%#v", job.Steps[installIndex], job.Steps[gateIndex]) + } +} + func TestCISourceQualityInstallsPythonBeforeLifecycleTests(t *testing.T) { workflow := readWorkflowForTest(t, filepath.Join("..", ".github", "workflows", "ci.yml")) job, ok := workflow.Jobs["source-quality"] diff --git a/scripts/write-browser-proof.mjs b/scripts/write-browser-proof.mjs new file mode 100644 index 0000000..4a15a82 --- /dev/null +++ b/scripts/write-browser-proof.mjs @@ -0,0 +1,58 @@ +import {execFileSync, spawnSync} from "node:child_process"; +import {createHash} from "node:crypto"; +import {writeFileSync} from "node:fs"; + +import {executeBrowserProof} from "./browser-proof-execution.mjs"; +import {isSafeRepoPath, loadBrowserProofInputResolution} from "./browser-proof-inputs.mjs"; + +const testCommand = ["node_modules/@playwright/test/cli.js", "test"]; +const resolution = loadBrowserProofInputResolution(); +const inputPaths = resolution.inputPaths; +const runDirectory = requiredSafePath("PROOFKIT_BROWSER_RUN_DIRECTORY"); +const proofCandidatePath = requiredSafePath("PROOFKIT_BROWSER_PROOF_CANDIDATE_PATH"); +if (proofCandidatePath !== `${runDirectory}/browser-runtime-proof.candidate.json`) { + throw new Error("browser proof candidate path must belong to the admitted run directory"); +} +const execution = await executeBrowserProof({ + environment: process.env, + execFile: execFileSync, + inputPaths, + nodeExecutable: process.execPath, + resolution, + runDirectory, + spawnProcess: spawnSync, + testCommand, +}); +const {assets, projects, testResult} = execution; +const inputResolution = {serverTarget: resolution.serverTarget, writerPath: resolution.writerPath}; +const inputDigest = createHash("sha256").update(JSON.stringify({assets, inputResolution})).digest("hex"); +const engines = projects.map(({browserName, browserVersion}) => ({name: browserName, version: browserVersion})); +const sourceRevision = execFileSync("git", ["rev-parse", "HEAD"], {encoding: "utf8"}).trim(); +const sourceTreeState = execFileSync("git", ["status", "--porcelain=v1", "--untracked-files=all"], {encoding: "utf8"}).trim() === "" ? "clean" : "dirty"; +const record = { + assets, + command: {argv: testCommand, exitCode: testResult.status, inputMode: "materialized_snapshot", runner: "node"}, + engines, + inputDigest: `sha256:${inputDigest}`, + inputResolution, + nonClaims: [ + "This record proves only that the listed content-bound Playwright tests passed in each recorded project before record creation.", + "Playwright WebKit is not branded Safari compatibility proof.", + "This record does not prove registry publication, rollout, or production readiness.", + "A dirty-tree record is content-bound only to its listed assets; sourceRevision is contextual.", + "This record does not independently authenticate or sandbox the same-user proof runner, external toolchain packages, or browser binaries.", + ], + projects, + proofKind: "proofkit.browser-runtime-proof", + schemaVersion: 2, + sourceRevision, + sourceTreeState, + state: "passed", +}; +writeFileSync(proofCandidatePath, `${JSON.stringify(record, null, 2)}\n`, {flag: "wx", mode: 0o600}); + +function requiredSafePath(name) { + const value = process.env[name]; + if (!value || !isSafeRepoPath(value)) throw new Error(`${name} must be a safe repository-relative path`); + return value; +} diff --git a/tests/browser/workspace.spec.mjs b/tests/browser/workspace.spec.mjs new file mode 100644 index 0000000..b08aa73 --- /dev/null +++ b/tests/browser/workspace.spec.mjs @@ -0,0 +1,217 @@ +import AxeBuilder from "@axe-core/playwright"; +import {expect, test} from "@playwright/test"; + +test.beforeEach(async ({browser, browserName, channel, connectOptions, launchOptions}, testInfo) => { + testInfo.annotations.push({type: "proofkit.browser-engine", description: browserName}); + testInfo.annotations.push({type: "proofkit.browser-version", description: browser.version()}); + expect(browserName).toBe(testInfo.project.name); + expect(channel).toBeUndefined(); + expect(connectOptions).toBeUndefined(); + expect(launchOptions.channel).toBeUndefined(); + expect(launchOptions.executablePath).toBeUndefined(); +}); + +test("workspace renders admitted views and creates a keyboard-authorized handoff", async ({page}) => { + const consoleErrors = []; + page.on("console", (message) => { + if (message.type() === "error") consoleErrors.push(message.text()); + }); + + await page.goto("/"); + await expect(page.getByRole("heading", {name: "browser.fixture.workspace"})).toBeVisible(); + await expect(page.locator('meta[name="proofkit-browser-capability"]')).toHaveCount(0); + const workspaceAuthority = page.getByLabel("Authority boundary"); + await expect(workspaceAuthority).toContainText("presentation_adapter"); + await expect(workspaceAuthority).toContainText("Baseline: unverified"); + await expect(workspaceAuthority).toContainText("do not prove receipt freshness"); + + const requirementBoundary = page.getByLabel("Boundary for REQ-CONSUMER-001"); + await expect(requirementBoundary).toContainText("Owner: browser.fixture.owner"); + await expect(requirementBoundary).toContainText("Claim level: blocking"); + await expect(requirementBoundary).toContainText("Fixture requirements do not approve merge"); + await expect(requirementBoundary).toContainText("This requirement does not approve merge"); + + const selectInvariant = page.getByRole("button", {name: "Select invariant"}); + await selectInvariant.focus(); + await page.keyboard.press("Enter"); + await expect(page.getByRole("status")).toContainText("1 source-bound target"); + await page.getByRole("textbox", {name: "Question"}).fill("Does retry preserve the same contract?"); + await page.getByRole("button", {name: "Create handoff packet"}).click(); + await expect(page.getByRole("status")).toContainText("Handoff packet created"); + await expect(page.getByLabel("Handoff packet")).toContainText('"state": "submitted"'); + await expect(page.getByLabel("Handoff packet")).toContainText("retry \u{1F680}"); + + await page.getByRole("button", {name: "Diff"}).click(); + await expect(page.getByRole("heading", {name: /scalar_changed/})).toBeVisible(); + await expect(page.getByText(/Source digests: sha256:/)).toBeVisible(); + const diffBoundary = page.locator(".projection-boundary"); + await expect(diffBoundary).toContainText("lookup_fragment_only"); + await expect(diffBoundary).toContainText(/Base snapshot: sha256:.*\(unverified\)/); + await expect(diffBoundary).toContainText(/Current snapshot: sha256:.*\(unverified\)/); + await expect(diffBoundary).toContainText("does not own requirement meaning"); + + await page.getByRole("button", {name: "Traceability"}).click(); + const graph = page.getByRole("img", {name: /traceability nodes and edges/}); + await expect(graph).toBeVisible(); + const nodeIDs = (await graph.getAttribute("data-node-ids")).split(" ").filter(Boolean); + const edgeIDs = (await graph.getAttribute("data-edge-ids")).split(" ").filter(Boolean); + const tableNodeIDs = await page.locator('table[data-identity-kind="node"] tbody tr').evaluateAll((rows) => rows.map((row) => row.dataset.identity)); + const tableEdgeIDs = await page.locator('table[data-identity-kind="edge"] tbody tr').evaluateAll((rows) => rows.map((row) => row.dataset.identity)); + expect(tableNodeIDs).toEqual(nodeIDs); + expect(tableEdgeIDs).toEqual(edgeIDs); + const repositoryRow = page.locator('table[data-identity-kind="node"] tbody tr[data-identity="code:code.repository"]'); + await expect(repositoryRow).toContainText("stale"); + const rangeRow = page.locator('table[data-identity-kind="node"] tbody tr[data-identity="code:code.retry"]'); + await expect(rangeRow).toContainText("source_range"); + await expect(rangeRow).toContainText("verified"); + const candidateRow = page.locator('table[data-identity-kind="node"] tbody tr').filter({hasText: "browser.fixture.candidate-runner"}); + await expect(candidateRow).toContainText("caller_reported"); + await expect(candidateRow).toContainText("unverified"); + await expect(candidateRow).toContainText("failed"); + const executionRow = page.locator('table[data-identity-kind="node"] tbody tr').filter({hasText: "browser.fixture.runner"}); + await expect(executionRow).toContainText("receipt_admitted"); + await expect(executionRow).toContainText("current"); + await expect(executionRow).toContainText("passed"); + const traceEdgeRow = page.locator('table[data-identity-kind="edge"] tbody tr').filter({hasText: "browser.fixture.trace"}); + await expect(traceEdgeRow).toContainText("owner_admitted"); + await expect(traceEdgeRow).toContainText("current"); + await expect(page.locator(".projection-boundary")).toContainText("does not infer code topology"); + await expect(graph.locator("title").filter({hasText: /deliberately long traceability label/})).toHaveCount(1); + const graphBox = await graph.boundingBox(); + expect(graphBox?.width).toBeGreaterThan(100); + expect(graphBox?.height).toBeGreaterThan(100); + expect(consoleErrors).toEqual([]); + const firstEdge = graph.locator("line").first(); + await expect(firstEdge).toHaveCount(1); + expect(await firstEdge.evaluate((line) => getComputedStyle(line).stroke)).not.toBe("none"); + expect((await graph.screenshot({animations: "allow", caret: "initial"})).byteLength).toBeGreaterThan(1000); + const accessibility = await new AxeBuilder({page}).analyze(); + expect(accessibility.violations).toEqual([]); +}); + +test("collapsed text selection cannot retain hidden handoff authority", async ({page}) => { + await page.goto("/"); + const invariant = page.locator("[data-anchor-id]").first(); + const selectionButton = page.locator("[data-select-anchor]").first(); + await expect(invariant).toBeVisible(); + await selectionButton.click(); + await expect(selectionButton).toHaveAttribute("aria-pressed", "true"); + await setSelection(page, invariant, 0, 0); + await expect(selectionButton).toHaveAttribute("aria-pressed", "true"); + await setSelection(page, invariant, 0, 3); + await expect(page.getByRole("status")).toContainText("1 source-bound target"); + await expect(selectionButton).toHaveAttribute("aria-pressed", "false"); + await page.evaluate(() => window.getSelection()?.collapseToStart()); + await expect(page.getByRole("status")).toContainText("No source-bound text selected"); + await expect(selectionButton).toHaveAttribute("aria-pressed", "false"); + await page.getByRole("textbox", {name: "Question"}).fill("Can a hidden selection be submitted?"); + await page.getByRole("button", {name: "Create handoff packet"}).click(); + await expect(page.getByRole("status")).toContainText("Select invariant text"); + await expect(page.getByLabel("Handoff packet")).toBeEmpty(); +}); + +/** @param {import("@playwright/test").Page} page @param {import("@playwright/test").Locator} locator @param {number} start @param {number} end */ +async function setSelection(page, locator, start, end) { + const anchorID = await locator.getAttribute("data-anchor-id"); + if (!anchorID) throw new Error("Invariant anchor identity is unavailable"); + await page.waitForFunction((expectedID) => [...document.querySelectorAll("[data-anchor-id]")].some((element) => element.getAttribute("data-anchor-id") === expectedID), anchorID); + await page.evaluate((bounds) => { + const element = [...document.querySelectorAll("[data-anchor-id]")].find((candidate) => candidate.getAttribute("data-anchor-id") === bounds.anchorID); + if (!element) throw new Error("Invariant anchor is unavailable"); + const text = element.firstChild; + const selection = window.getSelection(); + if (!text || !selection) throw new Error("Text selection is unavailable"); + const limit = text.textContent?.length ?? 0; + const range = document.createRange(); + range.setStart(text, Math.min(bounds.start, limit)); + range.setEnd(text, Math.min(bounds.end, limit)); + selection.removeAllRanges(); + selection.addRange(range); + document.dispatchEvent(new Event("selectionchange")); + }, {anchorID, end, start}); +} + +test("a view transition cooperatively aborts the superseded request", async ({page}) => { + await disableOptionalViews(page); + await page.addInitScript(() => { + const nativeFetch = globalThis.fetch.bind(globalThis); + globalThis.__proofkitAbortProbe = {aborted: false, started: false}; + globalThis.fetch = (input, init = {}) => { + const path = new URL(typeof input === "string" ? input : input.url, location.href).pathname; + if (path === "/api/v1/requirements") { + globalThis.__proofkitAbortProbe.started = true; + init.signal?.addEventListener("abort", () => { globalThis.__proofkitAbortProbe.aborted = true; }, {once: true}); + } + return nativeFetch(input, init); + }; + }); + let releaseRequest; + const requestReleased = new Promise((resolve) => { releaseRequest = resolve; }); + await page.route("**/api/v1/requirements", async (route) => { + await requestReleased; + try { + await route.continue(); + } catch { + // The asserted abort may close the intercepted request first. + } + }); + + await page.goto("/"); + await page.waitForFunction(() => globalThis.__proofkitAbortProbe?.started === true); + await page.getByRole("button", {name: "Diff"}).click(); + await page.waitForFunction(() => globalThis.__proofkitAbortProbe?.aborted === true); + releaseRequest(); + await expect(page.locator("#workspace-content [role=status]")).toHaveAttribute("data-state", "unavailable"); +}); + +for (const unavailableView of [ + {button: "Diff", heading: "Semantic diff"}, + {button: "Traceability", heading: "Traceability graph"}, +]) { + test(`request generation rejects a non-cooperative late response after opening ${unavailableView.button}`, async ({page}) => { + await disableOptionalViews(page); + await page.addInitScript(() => { + const nativeFetch = globalThis.fetch.bind(globalThis); + globalThis.AbortController = class { + signal = {aborted: false, addEventListener() {}}; + abort() {} + }; + globalThis.__proofkitLateResponse = {consumed: false, release: undefined, started: false}; + globalThis.fetch = async (input, init = {}) => { + const path = new URL(typeof input === "string" ? input : input.url, location.href).pathname; + if (path !== "/api/v1/requirements") return nativeFetch(input, init); + globalThis.__proofkitLateResponse.started = true; + const {signal: _ignored, ...nonCooperativeInit} = init; + const response = await nativeFetch(input, nonCooperativeInit); + await new Promise((resolve) => { globalThis.__proofkitLateResponse.release = resolve; }); + return { + ok: response.ok, + status: response.status, + async json() { + const value = await response.json(); + setTimeout(() => { globalThis.__proofkitLateResponse.consumed = true; }, 0); + return value; + }, + }; + }; + }); + + await page.goto("/"); + await page.waitForFunction(() => globalThis.__proofkitLateResponse?.started === true); + await page.getByRole("button", {name: unavailableView.button}).click(); + await expect(page.getByRole("heading", {name: unavailableView.heading})).toBeVisible(); + await expect(page.locator("#workspace-content [role=status]")).toHaveAttribute("data-state", "unavailable"); + await page.evaluate(() => globalThis.__proofkitLateResponse.release()); + await page.waitForFunction(() => globalThis.__proofkitLateResponse?.consumed === true); + await expect(page.getByRole("heading", {name: unavailableView.heading})).toBeVisible(); + await expect(page.locator('[role="tree"]')).toHaveCount(0); + }); +} + +async function disableOptionalViews(page) { + await page.route("**/api/v1/manifest", async (route) => { + const response = await route.fetch(); + const body = await response.json(); + await route.fulfill({response, json: {...body, diffAvailable: false, graphAvailable: false}}); + }); +} diff --git a/tsconfig.browser.json b/tsconfig.browser.json new file mode 100644 index 0000000..0ac8af3 --- /dev/null +++ b/tsconfig.browser.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "allowJs": true, + "checkJs": true, + "lib": ["DOM", "ES2024"], + "module": "ESNext", + "noEmit": true, + "strict": true, + "target": "ES2024" + }, + "include": ["internal/command/requirementbrowser/assets/selection-authority.js", "internal/command/requirementbrowser/assets/workspace.js"] +}