From 0033a0e8c36e4844c2940db90ea3a5a14c46fab0 Mon Sep 17 00:00:00 2001 From: iperev Date: Tue, 14 Jul 2026 01:34:09 +0200 Subject: [PATCH] fix: close release and traceability proof boundaries --- .github/workflows/release.yml | 28 +- BACKLOG.md | 1 + README.md | 2 + docs/release-process.md | 24 +- .../proofkit-spec-proof-core/overview.md | 11 +- .../requirements.v1.json | 4 +- .../proofkit-supply-chain-quality/overview.md | 8 + .../requirements.v1.json | 26 ++ internal/app/command_coverage_routes.go | 8 +- internal/app/command_descriptors.go | 10 +- internal/app/requirement_context_cli_test.go | 138 ++++++++ .../assets/selection-authority.js | 8 +- .../requirementbrowser/assets/workspace.css | 2 + .../requirementbrowser/assets/workspace.js | 49 ++- internal/command/requirementbrowser/server.go | 71 ++-- .../command/requirementbrowser/server_test.go | 132 ++++++++ .../command/requirementbrowser/workspace.go | 2 +- .../requirementbrowser/workspace_test.go | 2 +- .../requirementdiff/requirementdiff_test.go | 90 ++++++ .../requirementgraph/output_admission.go | 100 +++--- .../requirementgraph/requirementgraph.go | 241 ++++++++------ .../requirementgraph/requirementgraph_test.go | 302 +++++++++++++++++- .../requirementgraph/topology_validation.go | 197 ++++++++++++ .../kernel/releaseplatform/releaseplatform.go | 4 +- .../releaseplatform/releaseplatform_test.go | 8 +- .../testsupport/browserfixture/fixture.go | 3 +- .../tools/packageartifactrecord/record.go | 2 +- internal/tools/pythonpackage/build.go | 16 +- internal/tools/pythonpackage/metadata.go | 28 +- internal/tools/pythonpackage/metadata_test.go | 220 ++++++++++++- internal/tools/pythonpackage/verify.go | 136 +++++++- internal/tools/releasechange/record.go | 282 ++++++++++++++++ internal/tools/releasechange/record_test.go | 113 +++++++ internal/tools/releasecloseoutinput/main.go | 72 +++-- .../tools/releasecloseoutinput/main_test.go | 81 ++++- internal/tools/releasemanifest/main.go | 46 +-- internal/tools/releasemanifest/main_test.go | 13 - internal/tools/releasepreflight/main.go | 9 +- internal/tools/releasepreflight/main_test.go | 28 ++ internal/tools/retainedevidence/manifest.go | 143 +++++++++ .../tools/retainedevidence/manifest_test.go | 117 +++++++ package-lock.json | 4 +- package.json | 2 +- proofkit/requirement-bindings.json | 133 ++++++++ release/change-record.v1.json | 49 +++ scripts/browser-selection-authority.test.mjs | 8 + .../validate-self-hosting-receipts_test.go | 9 +- tests/browser/workspace.spec.mjs | 34 ++ 48 files changed, 2660 insertions(+), 356 deletions(-) create mode 100644 internal/app/requirement_context_cli_test.go create mode 100644 internal/command/requirementgraph/topology_validation.go create mode 100644 internal/tools/releasechange/record.go create mode 100644 internal/tools/releasechange/record_test.go create mode 100644 internal/tools/retainedevidence/manifest.go create mode 100644 internal/tools/retainedevidence/manifest_test.go create mode 100644 release/change-record.v1.json diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index bdb1128..fe19cf2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1011,18 +1011,7 @@ jobs: --notes-file artifacts/release/release-notes.md \ --asset-names-file /tmp/proofkit-expected-release-assets.txt cp /tmp/proofkit-release-view.json artifacts/release/github-release.json - retained_evidence=(artifacts/release/github-release.json) - if compgen -G "artifacts/attestations/*.json" >/dev/null; then - while IFS= read -r evidence; do - retained_evidence+=("$evidence") - done < <(find artifacts/attestations -type f -name '*.json' | sort) - fi - mapfile -t retained_evidence < <(printf '%s\n' "${retained_evidence[@]}" | sort) - : > artifacts/release/retained-evidence-checksums.sha256 - for evidence in "${retained_evidence[@]}"; do - sum="$(sha256sum "$evidence" | awk '{print $1}')" - printf '%s %s\n' "$sum" "$(basename "$evidence")" >> artifacts/release/retained-evidence-checksums.sha256 - done + go run ./internal/tools/releasepreflight retained-evidence --artifact-root artifacts final_dir="$(mktemp -d)" for asset in "${expected_assets[@]}"; do asset_name="$(basename "$asset")" @@ -1048,18 +1037,7 @@ jobs: --tag "$GITHUB_REF_NAME" \ --notes-file artifacts/release/release-notes.md \ --asset-names-file /tmp/proofkit-expected-release-assets.txt - retained_evidence=(artifacts/release/github-release.json) - if compgen -G "artifacts/attestations/*.json" >/dev/null; then - while IFS= read -r evidence; do - retained_evidence+=("$evidence") - done < <(find artifacts/attestations -type f -name '*.json' | sort) - fi - mapfile -t retained_evidence < <(printf '%s\n' "${retained_evidence[@]}" | sort) - : > artifacts/release/retained-evidence-checksums.sha256 - for evidence in "${retained_evidence[@]}"; do - sum="$(sha256sum "$evidence" | awk '{print $1}')" - printf '%s %s\n' "$sum" "$(basename "$evidence")" >> artifacts/release/retained-evidence-checksums.sha256 - done + go run ./internal/tools/releasepreflight retained-evidence --artifact-root artifacts final_dir="$(mktemp -d)" for asset in "${expected_assets[@]}"; do asset_name="$(basename "$asset")" @@ -1095,7 +1073,7 @@ jobs: artifacts/release/checksums.sha256 artifacts/release/github-release.json artifacts/release/metadata-checksums.sha256 - artifacts/release/retained-evidence-checksums.sha256 + artifacts/retained-evidence-checksums.sha256 artifacts/release/release-manifest.json artifacts/release/release-notes.md artifacts/release/sbom.cdx.json diff --git a/BACKLOG.md b/BACKLOG.md index 9e18b1d..18e81be 100644 --- a/BACKLOG.md +++ b/BACKLOG.md @@ -53,3 +53,4 @@ records, generated release manifests, or the owning docs named above. | DEFERRED | VALUE-01 | Admit exact value-evidence comparisons only after a real producer and downstream consumer establish the public record boundary; detailed candidate contract is retained in [issue #65](https://github.com/research-engineering/agentic-proofkit/issues/65). | A real execution-receipt projection, baseline producer, and downstream consumer prove an exact producer-output-to-admission round trip plus compact/full-graph inclusion or an intentional omission non-claim; otherwise no public command is added. | | BLOCKED | RELOCATION-01 | Add provenance-bounded witness relocation candidates without introducing a second binding path or trusting a caller-authored prior digest; detailed candidate contract is retained in [issue #66](https://github.com/research-engineering/agentic-proofkit/issues/66). | An owner-admitted content-addressed baseline binds witness id, prior path and digest, source revision, evidence class, authentication non-claims, and freshness non-claims; the scanner then proves the zero/one/many match partition while remaining non-current until fresh execution evidence exists. | | BLOCKED | RELEASE-01 | Prove signed protected-tag release policy as provider-side release governance, not source-only intent. | Repository tag protection/ruleset and release workflow variables require signed annotated release tags; the next public release records provider-side evidence or the row is explicitly retired as an accepted non-claim. | +| BLOCKED | RELEASE-02 | Retire the inaccurate PyPI `0.1.159` wheel compatibility and license projection without mutating immutable release history. | After a public replacement release proves macOS 12.0 wheel tags, embedded MIT license identity, npm/PyPI/GitHub byte closure, and installed-package smoke, yank PyPI `0.1.159` with an exact compatibility-and-license reason and retain provider evidence of the yank. | diff --git a/README.md b/README.md index cdfa1e3..0b78e0c 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,8 @@ Python consumers use the Python package as a runner wrapper over the same Go CLI, not as a Python SDK. Python projects should still treat CLI/JSON records, exit codes, and package metadata as the public contract. +Published Darwin binary distributions require macOS 12.0 or later. + ## Project Boundary `agentic-proofkit` is intended to provide reusable proof-workflow mechanics for diff --git a/docs/release-process.md b/docs/release-process.md index 8dac9b5..8445868 100644 --- a/docs/release-process.md +++ b/docs/release-process.md @@ -42,7 +42,11 @@ Go source -> GitHub Release assets with checksums and SBOM for provenance lookup ``` -The repository-owned `release:manifest` tool creates `release-manifest.json`, +The committed `release/change-record.v1.json` owns the reviewed, version-bound +declaration of the public-contract delta, migration decision, platform +requirements, known limitations, and rollback strategy. It does not infer +change completeness from the source diff. The repository-owned +`release:manifest` tool admits that record and creates `release-manifest.json`, `checksums.sha256`, `metadata-checksums.sha256`, `sbom-subjects.sha256`, release notes, and deterministic SBOM candidate evidence from explicit package, registry, and release evidence. `checksums.sha256` covers distributable archive @@ -77,18 +81,21 @@ Before publishing a version: 1. The source tree is clean. 2. `package.json` contains the exact new version. -3. `package.json` repository, license, bin, exports, files, and publishConfig +3. `release/change-record.v1.json` contains the same version and explicitly + classifies breaking changes, additions, migration, platform requirements, + known limitations, and rollback. +4. `package.json` repository, license, bin, exports, files, and publishConfig match the intended public package contract. -4. The npm account has verified email and write-protective 2FA, or the package +5. The npm account has verified email and write-protective 2FA, or the package uses an admitted Trusted Publisher configuration. -5. When `PROOFKIT_ENABLE_PYPI_PUBLISH=true`, the PyPI account has a normal or +6. When `PROOFKIT_ENABLE_PYPI_PUBLISH=true`, the PyPI account has a normal or pending Trusted Publisher configured for project `agentic-proofkit`, repository `research-engineering/agentic-proofkit`, workflow `release.yml`, and environment `pypi`. -6. The release workflow is configured for the package, public npm registry, and +7. The release workflow is configured for the package, public npm registry, and release environments. PyPI release configuration is required only when the PyPI channel is explicitly enabled. -7. `npm run check` passes locally or the release candidate workflow provides +8. `npm run check` passes locally or the release candidate workflow provides equivalent current-head evidence. ## Candidate Proof @@ -156,7 +163,10 @@ The `release` workflow must: 14. retain normalized GitHub Release metadata as workflow release evidence at `artifacts/release/github-release.json` after byte-for-byte asset verification, and bind it plus any attestation record with - `artifacts/release/retained-evidence-checksums.sha256`. The release + `artifacts/retained-evidence-checksums.sha256`. The checksum manifest lives + at the retained artifact root and uses exact `release/...` and + `attestations/...` paths, so standard checksum verification executes against + the downloaded artifact layout without path rewriting. The release manifest records GitHub Release channel data as candidate/archive inventory; `github-release.json` owns post-create GitHub Release facts only inside retained workflow evidence, not as a public release asset. diff --git a/docs/specs/proofkit-spec-proof-core/overview.md b/docs/specs/proofkit-spec-proof-core/overview.md index 2733891..e9cf8ce 100644 --- a/docs/specs/proofkit-spec-proof-core/overview.md +++ b/docs/specs/proofkit-spec-proof-core/overview.md @@ -108,10 +108,13 @@ execution receipts, and merge policy. 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. + by owner-declared scalar, set, map, and lifecycle semantics, covers entity + additions and removals, and remains closed under output re-admission. +- `REQ-PROOFKIT-SPEC-023`: traceability graph input schema v2 preserves + specification, proof, code traceability, and native execution as distinct + evidence planes and accepts code topology only as explicit caller-owned input + with source-digest, parent-edge, abstraction-order, and pre-materialization + budget closure. ## 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 7181b73..2f96344 100644 --- a/docs/specs/proofkit-spec-proof-core/requirements.v1.json +++ b/docs/specs/proofkit-spec-proof-core/requirements.v1.json @@ -533,7 +533,7 @@ { "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.", + "invariant": "Requirement semantic diff compares two admitted context snapshots by stable requirement identity and owner-declared scalar, set, map, and lifecycle field classes, producing canonically ordered added, removed, and modified facts that remain closed under output re-admission without textual diff, inferred move semantics, or authority over requirement meaning.", "claimLevel": "blocking", "riskClass": "medium", "proofBindingRefs": ["proofkit/requirement-bindings.json"], @@ -546,7 +546,7 @@ { "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.", + "invariant": "Requirement traceability graph input schema v2 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; one typed topology validator rejects dangling endpoints, enforces the bijection between non-root parentNodeId facts and contains edges with broader parent abstraction, verifies every supplied code-source digest, and applies node, edge, and source-byte budgets before per-item semantic materialization.", "claimLevel": "blocking", "riskClass": "high", "proofBindingRefs": ["proofkit/requirement-bindings.json"], diff --git a/docs/specs/proofkit-supply-chain-quality/overview.md b/docs/specs/proofkit-supply-chain-quality/overview.md index 6cc05e2..18cb715 100644 --- a/docs/specs/proofkit-supply-chain-quality/overview.md +++ b/docs/specs/proofkit-supply-chain-quality/overview.md @@ -98,6 +98,14 @@ vulnerability absence, or consumer rollout safety by itself. 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. +- `REQ-PROOFKIT-QUALITY-023`: Python wheels independently bind advertised + platform compatibility to decoded executable bytes and carry Core Metadata + 2.4 plus an exact, RECORD-closed repository license payload. +- `REQ-PROOFKIT-QUALITY-024`: a version-bound machine record declares the + reviewed public-contract change set and owns release-note content, while one + retained-evidence owner builds and verifies checksums against exact + downloadable artifact-relative paths without inferring change completeness + from the source diff. ## 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 4685aae..c821471 100644 --- a/docs/specs/proofkit-supply-chain-quality/requirements.v1.json +++ b/docs/specs/proofkit-supply-chain-quality/requirements.v1.json @@ -294,6 +294,32 @@ "lifecycle": {"state": "active", "replacementRequirementIds": [], "evidenceRefs": []}, "deferral": null, "updatePolicy": {"reviewOwnerId": "proofkit.supply-chain-quality", "requiresImpactDeclaration": true, "requiresProofBindingReview": true} + }, + { + "requirementId": "REQ-PROOFKIT-QUALITY-023", + "ownerId": "proofkit.supply-chain-quality", + "invariant": "Each Python wheel binds its embedded executable to the release binary digest, independently decodes Darwin Mach-O bytes so an advertised macOS platform minimum is never lower than the executable minimum, uses Core Metadata 2.4 License-Expression and License-File fields, carries an exact repository LICENSE copy under the wheel licenses directory, and closes every payload entry through RECORD.", + "claimLevel": "blocking", + "riskClass": "critical", + "proofBindingRefs": ["proofkit/requirement-bindings.json"], + "nonClaimRefs": ["NC-PROOFKIT-QUALITY-023"], + "nonClaims": ["This requirement does not claim execution on every accepted operating-system version, legal approval, 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} + }, + { + "requirementId": "REQ-PROOFKIT-QUALITY-024", + "ownerId": "proofkit.supply-chain-quality", + "invariant": "Release metadata generation admits one version-bound machine-readable declaration of the reviewed public-contract change set and renders release notes as its exact projection, while one repository-owned retained-evidence builder and verifier checksum the exact final downloadable artifact topology with artifact-relative paths, reject unbound evidence files and symlink substitution, and fail release closeout on record, note, path, or digest drift.", + "claimLevel": "blocking", + "riskClass": "high", + "proofBindingRefs": ["proofkit/requirement-bindings.json"], + "nonClaimRefs": ["NC-PROOFKIT-QUALITY-024"], + "nonClaims": ["This requirement does not infer change-record completeness from source changes, make release notes approval authority, or prove provider publication, attestation authenticity, consumer adoption, 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/command_coverage_routes.go b/internal/app/command_coverage_routes.go index ab84c6b..df755bd 100644 --- a/internal/app/command_coverage_routes.go +++ b/internal/app/command_coverage_routes.go @@ -104,8 +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-context-compose": {requiredInputAdmissionRoute, directCLIRoute("internal/app/requirement_context_cli_test.go", "TestRequirementContextCommandsComposeThroughWholeCLI", semanticRouteProof("requirement_context_cli.compose_through_whole_cli", commandCoverageExpectedPublicOutcome), "Requirement context composition must honor its explicit repository root and emit an owner-admitted snapshot through the public CLI."), 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, directCLIRoute("internal/app/requirement_context_cli_test.go", "TestRequirementContextCommandsComposeThroughWholeCLI", semanticRouteProof("requirement_context_cli.slice_through_whole_cli", commandCoverageExpectedPublicOutcome), "Requirement context slicing must consume the composed snapshot and emit the selected semantic fragment through the public CLI."), 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."), @@ -116,7 +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-semantic-diff": {requiredInputAdmissionRoute, directCLIRoute("internal/app/requirement_context_cli_test.go", "TestRequirementContextCommandsComposeThroughWholeCLI", semanticRouteProof("requirement_context_cli.diff_through_whole_cli", commandCoverageExpectedPublicOutcome), "Requirement semantic diff must emit an owner-admitted change set through the public CLI."), packageFalsifierRoute("internal/command/requirementdiff/requirementdiff_test.go", "TestBuildCoversCompleteRequirementChangeAlgebra", semanticRouteProof("requirementdiff.build_covers_complete_requirement_change_algebra", commandCoverageExpectedPublicOutcome), "Requirement semantic diff must cover entity, scalar, set, map, and lifecycle changes and remain closed under output admission.")}, "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.")}, @@ -124,7 +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.")}, + "requirement-traceability-graph": {requiredInputAdmissionRoute, directCLIRoute("internal/app/requirement_context_cli_test.go", "TestRequirementContextCommandsComposeThroughWholeCLI", semanticRouteProof("requirement_context_cli.graph_through_whole_cli", commandCoverageExpectedPublicOutcome), "Requirement traceability graph must emit an owner-admitted graph through the public CLI."), 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."), packageFalsifierRoute("internal/command/requirementgraph/requirementgraph_test.go", "TestAdmitOutputRejectsDanglingAndIncoherentCodeParents", semanticRouteProof("requirementgraph.admit_output_rejects_dangling_and_incoherent_code_parents", commandCoverageExpectedPublicOutcome), "Requirement traceability graph output admission must reject dangling and incoherent code parent relations.")}, "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_descriptors.go b/internal/app/command_descriptors.go index c1d7a0f..415e01b 100644 --- a/internal/app/command_descriptors.go +++ b/internal/app/command_descriptors.go @@ -114,22 +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", "--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-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 schemaVersion=2 (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), withSemanticAppTests("TestRequirementContextCommandsComposeThroughWholeCLI"), 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"), withSemanticAppTests("TestRequirementContextCommandsComposeThroughWholeCLI"), 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-semantic-diff", commandInputRequired, flags("--input", "--input-pointer"), modes("json"), ownerDirs("requirementdiff"), withSemanticAppTests("TestRequirementContextCommandsComposeThroughWholeCLI"), 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("requirement-traceability-graph", commandInputRequired, flags("--input", "--input-pointer"), modes("json"), ownerDirs("requirementgraph"), withSemanticAppTests("TestRequirementContextCommandsComposeThroughWholeCLI"), withInputSchemaSummary("schemaVersion=2", "graphId", "context=proofkit.requirement-context", "codeSources[].path+content (optional, bounded UTF-8)", "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()), diff --git a/internal/app/requirement_context_cli_test.go b/internal/app/requirement_context_cli_test.go new file mode 100644 index 0000000..5cd6049 --- /dev/null +++ b/internal/app/requirement_context_cli_test.go @@ -0,0 +1,138 @@ +package app + +import ( + "bytes" + "context" + "encoding/json" + "os" + "path/filepath" + "testing" + + "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/admission" + "github.com/research-engineering/agentic-proofkit/internal/kernel/stablejson" + "github.com/research-engineering/agentic-proofkit/internal/testsupport/commandcoverage" +) + +func TestRequirementContextCommandsComposeThroughWholeCLI(t *testing.T) { + commandcoverage.SemanticRoute(t, "proofkit.command_coverage.source_oracle.v1.078417255856588541640488533337296521071324425566921898314006295059346651375053") + commandcoverage.SemanticRoute(t, "proofkit.command_coverage.source_oracle.v1.026918779924949500817883735459777435749569395463462098851750112168717349371591") + commandcoverage.SemanticRoute(t, "proofkit.command_coverage.source_oracle.v1.037106612382996619981621102911104931496378404893868406197919247815645123874441") + commandcoverage.SemanticRoute(t, "proofkit.command_coverage.source_oracle.v1.023947615033231006584057272018250251823714544315759467896512469485975523531624") + root := t.TempDir() + tree := map[string]any{ + "schemaVersion": json.Number("2"), "treeId": "consumer.spec-tree", "rootNodeId": "consumer.root", + "callerAnnotations": []any{}, "edges": []any{}, "overlays": []any{}, + "nodes": []any{map[string]any{ + "nodeId": "consumer.root", "nodeKind": "meta_spec", "label": "Consumer specification", "displayOrder": json.Number("1"), + "callerAnnotations": []any{}, + "sourceRefs": []any{map[string]any{ + "sourceRefId": "consumer.root.requirements", "sourceRefKind": "source_id", + "sourceRole": "requirements", "sourceId": "consumer.requirements", + }}, + }}, + } + requirementSource := cliRequirementSource("The CLI composes the baseline requirement context.") + writeCLIJSONFixture(t, root, "proofkit/spec-tree.json", tree) + writeCLIJSONFixture(t, root, "docs/specs/consumer/requirements.v1.json", requirementSource) + catalog := map[string]any{ + "schemaVersion": json.Number("1"), "catalogId": "consumer.context", + "specTree": map[string]any{"path": "proofkit/spec-tree.json"}, + "requirementSources": []any{map[string]any{ + "nodeId": "consumer.root", "path": "docs/specs/consumer/requirements.v1.json", + }}, + } + + base := runAppJSON(t, []string{"requirement-context-compose", "--input", "-", "--repo-root", root}, catalog) + if _, err := requirementcontext.AdmitSnapshot(base); err != nil { + t.Fatalf("whole-CLI context output failed owner admission: %v", err) + } + slice := runAppJSON(t, []string{"requirement-context-slice", "--input", "-"}, map[string]any{ + "schemaVersion": json.Number("1"), "sliceId": "consumer.context.slice", "context": base, + "query": map[string]any{"profile": "specification", "requirementIds": []any{"REQ-CONSUMER-001"}}, + }) + if slice["contextKind"] != "proofkit.requirement-context-slice" || slice["state"] != "selected" || slice["snapshotId"] != base["snapshotId"] { + t.Fatalf("unexpected whole-CLI context slice: %#v", slice) + } + + requirementSource["requirements"].([]any)[0].(map[string]any)["invariant"] = "The CLI composes the current requirement context." + writeCLIJSONFixture(t, root, "docs/specs/consumer/requirements.v1.json", requirementSource) + current := runAppJSON(t, []string{"requirement-context-compose", "--input", "-", "--repo-root", root}, catalog) + diff := runAppJSON(t, []string{"requirement-semantic-diff", "--input", "-"}, map[string]any{ + "schemaVersion": json.Number("1"), "diffId": "consumer.requirement.diff", + "baseContext": base, "currentContext": current, + }) + if diff["changeCount"] != json.Number("1") { + t.Fatalf("whole-CLI semantic diff changeCount=%v, want 1", diff["changeCount"]) + } + if _, err := requirementdiff.AdmitOutput(diff, current["snapshotId"].(string)); err != nil { + t.Fatalf("whole-CLI semantic diff failed owner admission: %v", err) + } + + graph := runAppJSON(t, []string{"requirement-traceability-graph", "--input", "-"}, map[string]any{ + "schemaVersion": json.Number("2"), "graphId": "consumer.requirement.graph", "context": current, + }) + if _, err := requirementgraph.AdmitOutput(graph, current["snapshotId"].(string)); err != nil { + t.Fatalf("whole-CLI traceability graph failed owner admission: %v", err) + } +} + +func cliRequirementSource(invariant string) map[string]any { + return 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", + "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{}}, + "deferral": nil, + "updatePolicy": map[string]any{"reviewOwnerId": "consumer.owner", "requiresImpactDeclaration": true, "requiresProofBindingReview": true}, + }}, + "nonClaims": []any{"This source does not execute proof witnesses."}, + } +} + +func runAppJSON(t *testing.T, args []string, input any) map[string]any { + t.Helper() + encoded, err := stablejson.Marshal(input) + if err != nil { + t.Fatal(err) + } + var stdout bytes.Buffer + var stderr bytes.Buffer + if exit := Run(context.Background(), args, bytes.NewReader(encoded), &stdout, &stderr); exit != 0 { + t.Fatalf("Run(%v) exit=%d stderr=%s stdout=%s", args, exit, stderr.String(), stdout.String()) + } + if stderr.Len() != 0 { + t.Fatalf("Run(%v) wrote stderr on success: %s", args, stderr.String()) + } + decoded, err := admission.DecodeJSON(bytes.NewReader(stdout.Bytes()), int64(stdout.Len())) + if err != nil { + t.Fatalf("Run(%v) emitted invalid JSON: %v", args, err) + } + record, ok := decoded.(map[string]any) + if !ok { + t.Fatalf("Run(%v) output is not an object", args) + } + return record +} + +func writeCLIJSONFixture(t *testing.T, root, path string, value any) { + t.Helper() + encoded, err := stablejson.Marshal(value) + if err != nil { + t.Fatal(err) + } + 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, encoded, 0o644); err != nil { + t.Fatal(err) + } +} diff --git a/internal/command/requirementbrowser/assets/selection-authority.js b/internal/command/requirementbrowser/assets/selection-authority.js index 2d1d905..d9e6daa 100644 --- a/internal/command/requirementbrowser/assets/selection-authority.js +++ b/internal/command/requirementbrowser/assets/selection-authority.js @@ -1,8 +1,8 @@ // @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 */ +/** @typedef {{mode: "none" | "button" | "text" | "committed_text", targets: SelectionTarget[]}} SelectionState */ +/** @typedef {{kind: "button" | "text", targets: SelectionTarget[]} | {kind: "collapse" | "commit" | "clear"}} SelectionEvent */ /** @returns {SelectionState} */ export function emptySelectionState() { @@ -17,6 +17,8 @@ export function transitionSelection(state, event) { return selectionState("button", event.targets); case "text": return event.targets.length === 0 ? emptySelectionState() : selectionState("text", event.targets); + case "commit": + return state.mode === "text" ? selectionState("committed_text", state.targets) : state; case "collapse": return state.mode === "text" ? emptySelectionState() : state; case "clear": @@ -24,7 +26,7 @@ export function transitionSelection(state, event) { } } -/** @param {"button" | "text"} mode @param {SelectionTarget[]} targets @returns {SelectionState} */ +/** @param {"button" | "text" | "committed_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 index c53b0ae..c410b74 100644 --- a/internal/command/requirementbrowser/assets/workspace.css +++ b/internal/command/requirementbrowser/assets/workspace.css @@ -9,6 +9,8 @@ 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%; } +#selected-context { padding-left: 1.25rem; } +#selected-context li { overflow-wrap: anywhere; } 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; } diff --git a/internal/command/requirementbrowser/assets/workspace.js b/internal/command/requirementbrowser/assets/workspace.js index ebbb6c8..5148840 100644 --- a/internal/command/requirementbrowser/assets/workspace.js +++ b/internal/command/requirementbrowser/assets/workspace.js @@ -395,10 +395,25 @@ document.querySelectorAll("[data-view]").forEach((button) => button.addEventList if (button.dataset.view === "graph") void renderGraph(); })); +const questionInputElement = document.querySelector("#annotation-question"); +const statusElement = document.querySelector("#handoff-status"); +const packetElement = document.querySelector("#handoff-packet"); +const submitElement = document.querySelector("#submit-question"); +const selectedContextElement = document.querySelector("#selected-context"); +const clearSelectionElement = document.querySelector("#clear-selection"); +if (!(questionInputElement instanceof HTMLTextAreaElement) || !(statusElement instanceof HTMLElement) || !(packetElement instanceof HTMLElement) || !(submitElement instanceof HTMLButtonElement) || !(selectedContextElement instanceof HTMLUListElement) || !(clearSelectionElement 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); +const selectedContext = /** @type {HTMLUListElement} */ (selectedContextElement); +const clearSelectionButton = /** @type {HTMLButtonElement} */ (clearSelectionElement); + document.addEventListener("selectionchange", () => { const selection = window.getSelection(); if (!selection || selection.isCollapsed || selection.rangeCount !== 1) { - const nextState = transitionSelection(selectionState, {kind: "collapse"}); + const focusedHandoffControl = document.activeElement === questionInput || document.activeElement === submit || document.activeElement === clearSelectionButton; + const nextState = transitionSelection(selectionState, {kind: focusedHandoffControl ? "commit" : "collapse"}); if (nextState !== selectionState) { selectionState = nextState; resetPressedSelectionControls(); @@ -430,17 +445,10 @@ document.addEventListener("selectionchange", () => { 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() { + selectedContext.replaceChildren(); + appendTextItems(selectedContext, selectionState.targets.map((target) => target.exactQuote)); + clearSelectionButton.disabled = selectionState.targets.length === 0; status.textContent = selectionState.targets.length === 0 ? "No source-bound text selected." : `${selectionState.targets.length} source-bound target(s) selected.`; } @@ -452,9 +460,23 @@ 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."; + selection?.removeAllRanges(); + announceSelection(); +} + +function commitSelection() { + const nextState = transitionSelection(selectionState, {kind: "commit"}); + if (nextState !== selectionState) { + selectionState = nextState; + announceSelection(); + } +} + +for (const control of [questionInput, submit]) { + control.addEventListener("pointerdown", commitSelection); + control.addEventListener("focus", commitSelection); } +clearSelectionButton.addEventListener("click", clearSelection); submit.addEventListener("click", async () => { if (submit.disabled) return; const question = questionInput.value.trim(); @@ -475,4 +497,5 @@ submit.addEventListener("click", async () => { } }); +announceSelection(); void renderSpecifications(); diff --git a/internal/command/requirementbrowser/server.go b/internal/command/requirementbrowser/server.go index 69f9b58..14d3651 100644 --- a/internal/command/requirementbrowser/server.go +++ b/internal/command/requirementbrowser/server.go @@ -153,30 +153,19 @@ func Serve(ctx context.Context, raw any, options Options, stdout io.Writer) erro if err != nil { return err } + return serveHandle(ctx, handle, options, stdout) +} + +func serveHandle(ctx context.Context, handle ServerHandle, options Options, stdout io.Writer) error { + if options.SessionMode == "one-shot-question" { + return serveOneShot(ctx, handle, options, stdout) + } defer func() { _ = closeHandle(handle) }() if options.Open { if err := openBrowser(ctx, handle.URL); err != nil { 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 } @@ -191,12 +180,43 @@ func Serve(ctx context.Context, raw any, options Options, stdout io.Writer) erro } } -func commitOrReadTerminal(handle ServerHandle, candidate map[string]any, stdout io.Writer) error { +func serveOneShot(ctx context.Context, handle ServerHandle, options Options, stdout io.Writer) error { + if options.Open { + if err := openBrowser(ctx, handle.URL); err != nil { + return errors.Join(err, closeAndWaitServer(handle)) + } + } + timeout := options.SessionTimeout + if timeout == 0 { + timeout = 30 * time.Minute + } + timer := time.NewTimer(timeout) + defer timer.Stop() + var packet map[string]any + select { + case packet = <-handle.Handoff: + case <-timer.C: + packet = commitOrReadTerminal(handle, terminalPacket("expired", handle)) + case <-ctx.Done(): + packet = commitOrReadTerminal(handle, terminalPacket("cancelled", handle)) + case err := <-handle.Done(): + return errors.Join(err, closeHandle(handle)) + } + unsuccessful := packet["state"] != "submitted" + if cleanupErr := closeAndWaitServer(handle); cleanupErr != nil { + if unsuccessful { + return errors.Join(ErrOneShotTerminal, cleanupErr) + } + return cleanupErr + } + return writeOneShotPacket(stdout, packet, unsuccessful) +} + +func commitOrReadTerminal(handle ServerHandle, candidate map[string]any) map[string]any { if handle.terminal.TryCommit(candidate) { - return writeOneShotPacket(stdout, candidate, true) + return candidate } - winner := <-handle.Handoff - return writeOneShotPacket(stdout, winner, winner["state"] != "submitted") + return <-handle.Handoff } func terminalPacket(state string, handle ServerHandle) map[string]any { @@ -254,6 +274,13 @@ func closeHandle(handle ServerHandle) error { return handle.Close(ctx) } +func closeAndWaitServer(handle ServerHandle) error { + closeErr := closeHandle(handle) + waitCtx, cancel := context.WithTimeout(context.Background(), serverShutdownTimeout) + defer cancel() + return errors.Join(closeErr, waitServerDone(waitCtx, handle)) +} + func waitServerDone(ctx context.Context, handle ServerHandle) error { select { case err := <-handle.Done(): diff --git a/internal/command/requirementbrowser/server_test.go b/internal/command/requirementbrowser/server_test.go index 4a30c44..2090d38 100644 --- a/internal/command/requirementbrowser/server_test.go +++ b/internal/command/requirementbrowser/server_test.go @@ -1,8 +1,10 @@ package requirementbrowser import ( + "bytes" "context" "encoding/json" + "errors" "io" "net" "net/http" @@ -349,6 +351,136 @@ func TestServerCloseForcesTerminationAfterGracefulDeadline(t *testing.T) { } } +func TestServeOneShotWaitsForDoneBeforeWritingTerminalPacket(t *testing.T) { + handoff := make(chan map[string]any, 1) + handoff <- map[string]any{"state": "submitted"} + done := make(chan error, 1) + closeStarted := make(chan struct{}) + closeCalls := 0 + handle := ServerHandle{ + Handoff: handoff, + close: func(context.Context) error { + closeCalls++ + if closeCalls == 1 { + close(closeStarted) + } + return nil + }, + done: done, + } + var stdout bytes.Buffer + result := make(chan error, 1) + go func() { + result <- serveHandle(t.Context(), handle, Options{SessionMode: "one-shot-question"}, &stdout) + }() + select { + case <-closeStarted: + case err := <-result: + t.Fatalf("Serve returned before cleanup started: %v", err) + case <-time.After(time.Second): + t.Fatal("Serve did not start cleanup") + } + if stdout.Len() != 0 { + t.Fatalf("stdout emitted before Done completed: %q", stdout.String()) + } + select { + case err := <-result: + t.Fatalf("Serve returned before Done completed: %v", err) + default: + } + done <- nil + select { + case err := <-result: + if err != nil { + t.Fatalf("Serve() error = %v", err) + } + case <-time.After(time.Second): + t.Fatal("Serve did not return after Done completed") + } + if closeCalls != 1 { + t.Fatalf("Close calls = %d, want 1", closeCalls) + } + if got := stdout.String(); got != "{\"state\":\"submitted\"}\n" { + t.Fatalf("stdout = %q, want submitted packet", got) + } +} + +func TestServeOneShotReturnsCleanupFailuresWithoutWritingTerminalPacket(t *testing.T) { + closeFailure := errors.New("close failure") + doneFailure := errors.New("done failure") + unreadDone := errors.New("unexpected second Done read") + handoff := make(chan map[string]any, 1) + handoff <- map[string]any{"state": "submitted"} + done := make(chan error, 2) + closeCalls := 0 + handle := ServerHandle{ + Handoff: handoff, + close: func(context.Context) error { + closeCalls++ + done <- doneFailure + done <- unreadDone + return closeFailure + }, + done: done, + } + var stdout bytes.Buffer + err := serveHandle(t.Context(), handle, Options{SessionMode: "one-shot-question"}, &stdout) + if !errors.Is(err, closeFailure) || !errors.Is(err, doneFailure) { + t.Fatalf("Serve() error = %v, want close and Done failures", err) + } + if stdout.Len() != 0 { + t.Fatalf("stdout emitted despite cleanup failure: %q", stdout.String()) + } + if closeCalls != 1 { + t.Fatalf("Close calls = %d, want 1", closeCalls) + } + select { + case got := <-done: + if !errors.Is(got, unreadDone) { + t.Fatalf("Done first unread value = %v, want second-read sentinel", got) + } + default: + t.Fatal("Done was read more than once") + } +} + +func TestServeOneShotDoesNotReadCompletedDoneTwice(t *testing.T) { + serveFailure := errors.New("serve failure") + closeFailure := errors.New("close failure") + unreadDone := errors.New("unexpected second Done read") + done := make(chan error, 2) + done <- serveFailure + done <- unreadDone + closeCalls := 0 + handle := ServerHandle{ + Handoff: make(chan map[string]any), + close: func(context.Context) error { + closeCalls++ + return closeFailure + }, + done: done, + } + var stdout bytes.Buffer + err := serveHandle(t.Context(), handle, Options{SessionMode: "one-shot-question"}, &stdout) + if !errors.Is(err, serveFailure) || !errors.Is(err, closeFailure) { + t.Fatalf("Serve() error = %v, want Serve and close failures", err) + } + if stdout.Len() != 0 { + t.Fatalf("stdout emitted after premature server completion: %q", stdout.String()) + } + if closeCalls != 1 { + t.Fatalf("Close calls = %d, want 1", closeCalls) + } + select { + case got := <-done: + if !errors.Is(got, unreadDone) { + t.Fatalf("Done first unread value = %v, want second-read sentinel", got) + } + default: + t.Fatal("Done was read more than once") + } +} + type readyWriter struct { ready chan<- string } diff --git a/internal/command/requirementbrowser/workspace.go b/internal/command/requirementbrowser/workspace.go index 4bb4fd0..a30ea77 100644 --- a/internal/command/requirementbrowser/workspace.go +++ b/internal/command/requirementbrowser/workspace.go @@ -222,7 +222,7 @@ func workspaceHTML(workspaceID string) string { "

Proofkit semantic workspace

" + html.EscapeString(workspaceID) + "

Authority boundary

    ", "
    ", "
    ", - "", + "", "\n", }, "") } diff --git a/internal/command/requirementbrowser/workspace_test.go b/internal/command/requirementbrowser/workspace_test.go index c4c6569..f6b88c7 100644 --- a/internal/command/requirementbrowser/workspace_test.go +++ b/internal/command/requirementbrowser/workspace_test.go @@ -214,7 +214,7 @@ func TestWorkspaceRejectsGraphInputForAnotherContext(t *testing.T) { 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")} + fixture["graphInput"] = map[string]any{"context": otherContext, "graphId": "consumer.workspace.graph", "schemaVersion": json.Number("2")} if _, _, err := buildWorkspace(fixture); err == nil { t.Fatal("buildWorkspace accepted graph input for a different context") } diff --git a/internal/command/requirementdiff/requirementdiff_test.go b/internal/command/requirementdiff/requirementdiff_test.go index db7c588..cb6b645 100644 --- a/internal/command/requirementdiff/requirementdiff_test.go +++ b/internal/command/requirementdiff/requirementdiff_test.go @@ -124,6 +124,79 @@ func TestOwnerFilterPreservesStableIdentityAcrossOwnershipChange(t *testing.T) { } } +func TestBuildCoversCompleteRequirementChangeAlgebra(t *testing.T) { + commandcoverage.SemanticRoute(t, "proofkit.command_coverage.source_oracle.v1.091504423064274626695623049569025044424830049352504253663226902245461570004740") + baseline := contextFixture(t, "The shared requirement remains stable.") + current := contextFixture(t, "The shared requirement changes its invariant.") + baseRequirements := baseline["projections"].(map[string]any)["requirementSources"].([]any)[0].(map[string]any)["requirements"].([]any) + currentRequirements := current["projections"].(map[string]any)["requirementSources"].([]any)[0].(map[string]any)["requirements"].([]any) + + removed := cloneRequirementRecord(t, baseRequirements[0].(map[string]any)) + removed["requirementId"] = "REQ-CONSUMER-REMOVED" + baseline["projections"].(map[string]any)["requirementSources"].([]any)[0].(map[string]any)["requirements"] = append(baseRequirements, removed) + + added := cloneRequirementRecord(t, currentRequirements[0].(map[string]any)) + added["requirementId"] = "REQ-CONSUMER-ADDED" + added["invariant"] = "The added requirement remains independently identifiable." + current["projections"].(map[string]any)["requirementSources"].([]any)[0].(map[string]any)["requirements"] = append(currentRequirements, added) + + shared := currentRequirements[0].(map[string]any) + shared["claimLevel"] = "advisory" + shared["nonClaimRefs"] = []any{"NC-CONSUMER-001", "NC-CONSUMER-002"} + shared["updatePolicy"].(map[string]any)["requiresImpactDeclaration"] = false + shared["lifecycle"] = map[string]any{ + "state": "superseded", + "replacementRequirementIds": []any{"REQ-CONSUMER-ADDED"}, + "evidenceRefs": []any{"consumer.lifecycle.transition"}, + } + resignContextFixture(t, baseline) + resignContextFixture(t, current) + + output, err := Build(map[string]any{ + "baseContext": baseline, "currentContext": current, + "diffId": "consumer.complete-algebra.diff", "schemaVersion": json.Number("1"), + }) + if err != nil { + t.Fatal(err) + } + classes := map[string]bool{} + pointers := map[string]string{} + for _, raw := range output["changes"].([]any) { + change := raw.(map[string]any) + class := change["changeClass"].(string) + classes[class] = true + pointers[change["jsonPointer"].(string)] = class + } + for _, class := range []string{"entity_added", "entity_removed", "lifecycle_transition", "opaque_value_changed", "scalar_changed", "set_membership_changed"} { + if !classes[class] { + t.Fatalf("semantic diff omitted %s: %#v", class, output["changes"]) + } + } + for pointer, class := range map[string]string{ + "/requirements/REQ-CONSUMER-ADDED": "entity_added", + "/requirements/REQ-CONSUMER-REMOVED": "entity_removed", + "/requirements/REQ-CONSUMER-001/invariant": "scalar_changed", + "/requirements/REQ-CONSUMER-001/nonClaimRefs": "set_membership_changed", + "/requirements/REQ-CONSUMER-001/updatePolicy": "opaque_value_changed", + "/requirements/REQ-CONSUMER-001/lifecycle": "lifecycle_transition", + } { + if pointers[pointer] != class { + t.Fatalf("change %s class=%q, want %q", pointer, pointers[pointer], class) + } + } + 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("complete semantic diff algebra is not closed under output admission: %v", err) + } +} + func TestBuildIncludesDeferralAndAdmissionBindsChangeIdentity(t *testing.T) { base := deferredContextFixture(t, "Review after the migration window.") current := deferredContextFixture(t, "Review after the compatibility window.") @@ -206,6 +279,23 @@ func resignContextFixture(t *testing.T, value map[string]any) { value["snapshotId"] = digest.SHA256TextRef(string(encoded)) } +func cloneRequirementRecord(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) + } + record, ok := decoded.(map[string]any) + if !ok { + t.Fatal("cloned requirement is not an object") + } + return record +} + 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 index 6f7cb58..84cf8c1 100644 --- a/internal/command/requirementgraph/output_admission.go +++ b/internal/command/requirementgraph/output_admission.go @@ -41,8 +41,18 @@ func AdmitOutput(raw any, snapshotID string) (map[string]any, error) { if !graphCountEquals(record["nodeCount"], len(rawNodes)) || !graphCountEquals(record["edgeCount"], len(rawEdges)) { return nil, fmt.Errorf("requirement traceability graph counts must match records") } + budget := graphBudget{} + if err := budget.reserveOutput(len(rawNodes), len(rawEdges)); err != nil { + return nil, err + } + encoded, err := stablejson.Marshal(record) + if err != nil { + return nil, err + } + if len(encoded) > maxGraphOutputBytes { + return nil, fmt.Errorf("requirement traceability graph exceeds output byte limit") + } 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 { @@ -52,7 +62,6 @@ func AdmitOutput(raw any, snapshotID string) (map[string]any, error) { 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 { @@ -63,12 +72,13 @@ func AdmitOutput(raw any, snapshotID string) (map[string]any, error) { 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 { + topology, err := outputTopologyFromRecords(nodes, edges) + if err != nil { + return nil, err + } + if err := validateOutputTopology(topology); err != nil { return nil, err } findings, err := secretjson.Scan(record, "traceability_graph") @@ -84,10 +94,6 @@ func AdmitOutput(raw any, snapshotID string) (map[string]any, error) { 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 @@ -160,6 +166,11 @@ func admitGraphNode(node map[string]any) error { if _, err := digestRef(node["sourceDigest"], "requirement traceability graph code node sourceDigest"); err != nil { return err } + if rawParentID, exists := node["parentNodeId"]; exists { + if _, err := admitGraphID(rawParentID, "requirement traceability graph code node parentNodeId"); 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") @@ -175,9 +186,18 @@ func admitGraphNode(node map[string]any) error { if err != nil || end <= start { return fmt.Errorf("requirement traceability graph range must be non-empty and half-open") } + } else { + for _, key := range []string{"byteEnd", "byteStart", "coordinateUnit", "rangeVerification"} { + if _, exists := node[key]; exists { + return fmt.Errorf("requirement traceability graph range fields are allowed only on source_range nodes") + } + } } } if plane == "native_execution_coverage" { + if node["kind"] != "execution_evidence" { + return fmt.Errorf("requirement traceability graph execution node kind must be execution_evidence") + } if _, err := admit.Enum(node["authorityClass"], map[string]struct{}{"caller_reported": {}, "receipt_admitted": {}}, "requirement traceability graph execution authorityClass"); err != nil { return err } @@ -265,6 +285,13 @@ func admitGraphNodeIdentity(node map[string]any) error { if nodeID != "execution:"+sourceID || node["label"] != sourceID { return fmt.Errorf("requirement traceability graph execution node identity is invalid") } + case "code_traceability": + if !strings.HasPrefix(nodeID, "code:") { + return fmt.Errorf("requirement traceability graph code node identity is invalid") + } + if _, err := admit.RuleID(strings.TrimPrefix(nodeID, "code:"), "requirement traceability graph code node identity"); err != nil { + return fmt.Errorf("requirement traceability graph code node identity is invalid") + } } return nil } @@ -273,7 +300,10 @@ 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] + prefix, err := graphEdgeIdentityPrefix(edge) + if err != nil { + return err + } var identity map[string]any switch prefix { case "spec-edge", "declaration-edge", "code-parent-edge": @@ -284,8 +314,6 @@ func admitGraphEdgeIdentity(edge map[string]any) error { 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 { @@ -294,42 +322,32 @@ func admitGraphEdgeIdentity(edge map[string]any) error { 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 { +func graphEdgeIdentityPrefix(edge map[string]any) (string, error) { + key := edge["evidencePlane"].(string) + ":" + edge["edgeKind"].(string) + switch key { case "specification_coverage:contains": - valid = from["evidencePlane"] == plane && to["evidencePlane"] == plane && from["kind"] != "requirement" && to["kind"] != "requirement" + return "spec-edge", nil case "specification_coverage:declares": - valid = from["evidencePlane"] == plane && from["kind"] != "requirement" && to["evidencePlane"] == plane && to["kind"] == "requirement" + return "declaration-edge", nil case "proof_coverage:proved_by_candidate": - valid = from["evidencePlane"] == "specification_coverage" && from["kind"] == "requirement" && to["evidencePlane"] == plane && to["kind"] == "scenario" + return "proof-edge", nil case "code_traceability:contains": - valid = from["evidencePlane"] == plane && to["evidencePlane"] == plane + return "code-parent-edge", nil case "code_traceability:traced_to": - valid = from["evidencePlane"] == "specification_coverage" && from["kind"] == "requirement" && to["evidencePlane"] == plane + return "code-edge", nil 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" + return "execution-edge", nil + default: + return "", fmt.Errorf("requirement traceability graph edge identity class is invalid") } - if !valid { - return fmt.Errorf("requirement traceability graph relation is incompatible with its evidence plane and endpoint kinds") +} + +func admitGraphID(raw any, context string) (string, error) { + value, ok := raw.(string) + if ok && derivedGraphIDPattern.MatchString(value) { + return value, nil } - return nil + return admit.RuleID(raw, context) } func exactGraphNonClaims(raw any) error { diff --git a/internal/command/requirementgraph/requirementgraph.go b/internal/command/requirementgraph/requirementgraph.go index 4ff2c77..fbbd417 100644 --- a/internal/command/requirementgraph/requirementgraph.go +++ b/internal/command/requirementgraph/requirementgraph.go @@ -29,6 +29,33 @@ type codeSource struct { digest string } +type graphBudget struct { + codeSourceBytes int + edges int + nodes int +} + +func (budget *graphBudget) reserveOutput(nodes, edges int) error { + if nodes < 0 || edges < 0 || nodes > maxGraphNodes-budget.nodes || edges > maxGraphEdges-budget.edges { + return fmt.Errorf("requirement traceability graph exceeds node or edge limit") + } + budget.nodes += nodes + budget.edges += edges + return nil +} + +func (budget *graphBudget) reserveCodeSourceBytes(size int) error { + if size < 0 || size > maxCodeSourceBytes-budget.codeSourceBytes { + return fmt.Errorf("requirement traceability graph code sources exceed byte limit") + } + budget.codeSourceBytes += size + return nil +} + +func (budget graphBudget) matches(nodes, edges int) bool { + return budget.nodes == nodes && budget.edges == edges +} + 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.", @@ -42,8 +69,8 @@ func Build(raw any) (map[string]any, error) { 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") + if !admit.JSONNumberEquals(record["schemaVersion"], 2) { + return nil, fmt.Errorf("requirement traceability graph input schemaVersion must be 2") } graphID, err := admit.RuleID(record["graphId"], "requirement traceability graph graphId") if err != nil { @@ -53,6 +80,10 @@ func Build(raw any) (map[string]any, error) { if err != nil { return nil, err } + budget := graphBudget{} + if err := budget.reserveOutput(len(snapshot.Tree.Nodes), len(snapshot.Tree.Edges)); err != nil { + return nil, err + } nodes := []map[string]any{} edges := []map[string]any{} for _, node := range snapshot.Tree.Nodes { @@ -67,29 +98,33 @@ func Build(raw any) (map[string]any, error) { } edges = append(edges, map[string]any{"edgeId": edgeID, "edgeKind": "contains", "evidencePlane": "specification_coverage", "fromNodeId": fromNodeID, "toNodeId": toNodeID}) } - requirementNodeIDs, err := appendRequirementNodes(snapshot, &nodes) + requirementNodeIDs, err := appendRequirementNodes(snapshot, &budget, &nodes) if err != nil { return nil, err } - if err := appendSpecificationRequirementEdges(snapshot, snapshot.Tree, &edges); err != nil { + if err := appendSpecificationRequirementEdges(snapshot, snapshot.Tree, &budget, &edges); err != nil { return nil, err } - if err := appendProofEdges(snapshot, requirementNodeIDs, &nodes, &edges); err != nil { + if err := appendProofEdges(snapshot, requirementNodeIDs, &budget, &nodes, &edges); err != nil { return nil, err } - codeSources, err := admitCodeSources(record["codeSources"]) + codeSources, err := admitCodeSources(record["codeSources"], &budget) if err != nil { return nil, err } if record["codeTopology"] != nil { - if err := appendCodeTopology(record["codeTopology"], codeSources, requirementNodeIDs, &nodes, &edges); err != nil { + if err := appendCodeTopology(record["codeTopology"], codeSources, &budget, &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 !budget.matches(len(nodes), len(edges)) { + return nil, fmt.Errorf("requirement traceability graph budget does not match projected topology") + } + topology, err := outputTopologyFromRecords(nodes, edges) + if err != nil { + return nil, err } - if err := uniqueGraphIdentities(nodes, edges); err != nil { + if err := validateOutputTopology(topology); err != nil { return nil, err } sort.Slice(nodes, func(left, right int) bool { return nodes[left]["nodeId"].(string) < nodes[right]["nodeId"].(string) }) @@ -102,22 +137,30 @@ func Build(raw any) (map[string]any, error) { return output, nil } -func appendSpecificationRequirementEdges(snapshot requirementcontext.Snapshot, tree requirementspectree.Tree, edges *[]map[string]any) error { +func appendSpecificationRequirementEdges(snapshot requirementcontext.Snapshot, tree requirementspectree.Tree, budget *graphBudget, 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) } } + relationCount := 0 + for _, node := range tree.Nodes { + for _, ref := range node.SourceRefs { + if ref.SourceRole == "requirements" { + relationCount += len(requirementsBySource[ref.SourceID]) + } + } + } + if err := budget.reserveOutput(0, relationCount); err != nil { + return err + } 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}) @@ -131,33 +174,14 @@ func appendSpecificationRequirementEdges(snapshot requirementcontext.Snapshot, t 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{}{} +func appendRequirementNodes(snapshot requirementcontext.Snapshot, budget *graphBudget, nodes *[]map[string]any) (map[string]struct{}, error) { + requirementCount := 0 + for _, source := range snapshot.RequirementSources { + requirementCount += len(source.Requirements) } - 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") - } + if err := budget.reserveOutput(requirementCount, 0); err != nil { + return nil, err } - 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 { @@ -172,10 +196,19 @@ func appendRequirementNodes(snapshot requirementcontext.Snapshot, nodes *[]map[s return ids, nil } -func appendProofEdges(snapshot requirementcontext.Snapshot, requirementIDs map[string]struct{}, nodes *[]map[string]any, edges *[]map[string]any) error { +func appendProofEdges(snapshot requirementcontext.Snapshot, requirementIDs map[string]struct{}, budget *graphBudget, nodes *[]map[string]any, edges *[]map[string]any) error { if snapshot.ProofBinding == nil { return nil } + bindingCount := 0 + for _, binding := range snapshot.ProofBinding.Bindings { + if _, ok := requirementIDs[binding.RequirementID]; ok { + bindingCount++ + } + } + if err := budget.reserveOutput(bindingCount, bindingCount); err != nil { + return err + } seenBindings := map[string]struct{}{} for _, binding := range snapshot.ProofBinding.Bindings { requirementID := binding.RequirementID @@ -191,9 +224,6 @@ func appendProofEdges(snapshot requirementcontext.Snapshot, requirementIDs map[s 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 { @@ -204,27 +234,34 @@ func appendProofEdges(snapshot requirementcontext.Snapshot, requirementIDs map[s return nil } -func appendCodeTopology(raw any, codeSources map[string]codeSource, requirementIDs map[string]struct{}, nodes *[]map[string]any, edges *[]map[string]any) error { +func appendCodeTopology(raw any, codeSources map[string]codeSource, budget *graphBudget, 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 { + if err := admit.KnownKeys(record, []string{"edges", "nativeCoverage", "nodes"}, "codeTopology"); 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 + rawEdges, ok := record["edges"].([]any) + if !ok { + return fmt.Errorf("codeTopology edges must be an array") } - levels := map[string]int{"repository": 0, "package": 1, "module": 2, "file": 3, "symbol": 4, "source_range": 5} - known := map[string]admittedCodeNode{} + coverage := []any{} + if rawCoverage := record["nativeCoverage"]; rawCoverage != nil { + coverage, ok = rawCoverage.([]any) + if !ok { + return fmt.Errorf("codeTopology nativeCoverage must be an array") + } + } + reservedNodes, reservedEdges := codeTopologyReservation(rawNodes, rawEdges, coverage) + if err := budget.reserveOutput(reservedNodes, reservedEdges); err != nil { + return err + } + known := map[string]string{} for _, rawNode := range rawNodes { node, ok := rawNode.(map[string]any) if !ok { @@ -255,9 +292,6 @@ func appendCodeTopology(raw any, codeSources map[string]codeSource, requirementI 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 @@ -274,6 +308,10 @@ func appendCodeTopology(raw any, codeSources map[string]codeSource, requirementI if err != nil { return err } + source, hasSource := codeSources[path] + if hasSource && source.digest != digestRef { + return fmt.Errorf("codeTopology node sourceDigest does not match codeSources content") + } 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 @@ -303,31 +341,21 @@ func appendCodeTopology(raw any, codeSources map[string]codeSource, requirementI 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 hasSource { 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} + known[id] = parentID *nodes = append(*nodes, projected) } - for id, node := range known { - if node.parent == "" { + for id, parentID := range known { + if parentID == "" { 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 + fromNodeID := "code:" + parentID toNodeID := "code:" + id edgeID, err := semanticGraphID("code-parent-edge", map[string]any{"edgeKind": "contains", "fromNodeId": fromNodeID, "toNodeId": toNodeID}) if err != nil { @@ -335,10 +363,6 @@ func appendCodeTopology(raw any, codeSources map[string]codeSource, requirementI } *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) @@ -356,12 +380,6 @@ func appendCodeTopology(raw any, codeSources map[string]codeSource, requirementI 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") @@ -383,16 +401,9 @@ func appendCodeTopology(raw any, codeSources map[string]codeSource, requirementI 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") - } + if len(coverage) > 0 { seenEvidence := map[string]map[string]any{} seenObservations := map[string]struct{}{} for _, raw := range coverage { @@ -407,16 +418,10 @@ func appendCodeTopology(raw any, codeSources map[string]codeSource, requirementI 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 @@ -455,15 +460,38 @@ func appendCodeTopology(raw any, codeSources map[string]codeSource, requirementI 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 codeTopologyReservation(rawNodes, rawEdges, coverage []any) (int, int) { + parentEdges := 0 + for _, rawNode := range rawNodes { + node, ok := rawNode.(map[string]any) + if !ok || node["parentNodeId"] != nil { + parentEdges++ + } + } + evidenceRefs := map[string]struct{}{} + malformedEvidenceRefs := 0 + for _, raw := range coverage { + item, ok := raw.(map[string]any) + if !ok { + malformedEvidenceRefs++ + continue + } + evidenceRef, ok := item["evidenceRef"].(string) + if !ok { + malformedEvidenceRefs++ + continue + } + evidenceRefs[evidenceRef] = struct{}{} + } + return len(rawNodes) + len(evidenceRefs) + malformedEvidenceRefs, parentEdges + len(rawEdges) + len(coverage) +} + func semanticGraphID(prefix string, identity map[string]any) (string, error) { encoded, err := stablejson.Marshal(identity) if err != nil { @@ -472,7 +500,7 @@ func semanticGraphID(prefix string, identity map[string]any) (string, error) { return prefix + ":" + strings.TrimPrefix(digest.SHA256TextRef(string(encoded)), "sha256:"), nil } -func admitCodeSources(raw any) (map[string]codeSource, error) { +func admitCodeSources(raw any, budget *graphBudget) (map[string]codeSource, error) { if raw == nil { return map[string]codeSource{}, nil } @@ -481,7 +509,6 @@ func admitCodeSources(raw any) (map[string]codeSource, error) { 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 { @@ -490,6 +517,13 @@ func admitCodeSources(raw any) (map[string]codeSource, error) { if err := admit.KnownKeys(record, []string{"content", "path"}, "requirement traceability graph code source"); err != nil { return nil, err } + content, ok := record["content"].(string) + if !ok { + return nil, fmt.Errorf("requirement traceability graph code source content must be UTF-8 text") + } + if err := budget.reserveCodeSourceBytes(len(content)); err != nil { + return nil, err + } pathText, err := admit.NonEmptyText(record["path"], "requirement traceability graph code source path") if err != nil { return nil, err @@ -501,17 +535,12 @@ func admitCodeSources(raw any) (map[string]codeSource, error) { 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) { + if !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 diff --git a/internal/command/requirementgraph/requirementgraph_test.go b/internal/command/requirementgraph/requirementgraph_test.go index d1429bb..bb5b9bb 100644 --- a/internal/command/requirementgraph/requirementgraph_test.go +++ b/internal/command/requirementgraph/requirementgraph_test.go @@ -3,11 +3,15 @@ package requirementgraph import ( "bytes" "encoding/json" + "sort" + "strconv" + "strings" "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/admit" "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" @@ -19,10 +23,9 @@ func TestBuildKeepsTraceabilityEvidencePlanesDistinct(t *testing.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, + "schemaVersion": json.Number("2"), "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")}, @@ -66,6 +69,222 @@ func TestBuildKeepsTraceabilityEvidencePlanesDistinct(t *testing.T) { } } +func TestAdmitOutputRejectsDanglingAndIncoherentCodeParents(t *testing.T) { + commandcoverage.SemanticRoute(t, "proofkit.command_coverage.source_oracle.v1.093487220897786293104017571940100080664662437591187487146507089441802769113895") + output, err := Build(graphPermutationInput(t)) + if err != nil { + t.Fatal(err) + } + + t.Run("dangling parent", func(t *testing.T) { + record := decodedGraphOutput(t, output) + for _, raw := range record["nodes"].([]any) { + node := raw.(map[string]any) + if node["kind"] == "source_range" { + node["parentNodeId"] = "code:missing" + break + } + } + if _, err := AdmitOutput(record, record["snapshotId"].(string)); err == nil || !strings.Contains(err.Error(), "parent") { + t.Fatalf("AdmitOutput() error = %v, want unresolved parent rejection", err) + } + }) + + t.Run("missing parent edge", func(t *testing.T) { + record := decodedGraphOutput(t, output) + edges := record["edges"].([]any) + filtered := make([]any, 0, len(edges)-1) + for _, raw := range edges { + edge := raw.(map[string]any) + if edge["evidencePlane"] == "code_traceability" && edge["edgeKind"] == "contains" { + continue + } + filtered = append(filtered, edge) + } + record["edges"] = filtered + record["edgeCount"] = json.Number(strconv.Itoa(len(filtered))) + if _, err := AdmitOutput(record, record["snapshotId"].(string)); err == nil || !strings.Contains(err.Error(), "parent") { + t.Fatalf("AdmitOutput() error = %v, want missing parent edge rejection", err) + } + }) + + t.Run("second parent edge", func(t *testing.T) { + record := decodedGraphOutput(t, output) + child := graphCodeNodeByKind(t, record, "source_range") + root := graphCodeNodeByKind(t, record, "repository") + secondRootID := "code:code.second-repository" + appendGraphNode(record, map[string]any{ + "currentnessState": root["currentnessState"], "evidencePlane": "code_traceability", "kind": "repository", + "label": "Second repository", "nodeId": secondRootID, "sourceDigest": root["sourceDigest"], "sourceId": root["sourceId"], + }) + appendCodeParentEdge(t, record, secondRootID, child["nodeId"].(string)) + if _, err := AdmitOutput(record, record["snapshotId"].(string)); err == nil || !strings.Contains(err.Error(), "exactly one parent edge") { + t.Fatalf("AdmitOutput() error = %v, want second parent edge rejection", err) + } + }) + + t.Run("parent field and edge mismatch", func(t *testing.T) { + record := decodedGraphOutput(t, output) + root := graphCodeNodeByKind(t, record, "repository") + secondRootID := "code:code.second-repository" + appendGraphNode(record, map[string]any{ + "currentnessState": root["currentnessState"], "evidencePlane": "code_traceability", "kind": "repository", + "label": "Second repository", "nodeId": secondRootID, "sourceDigest": root["sourceDigest"], "sourceId": root["sourceId"], + }) + graphCodeNodeByKind(t, record, "source_range")["parentNodeId"] = secondRootID + if _, err := AdmitOutput(record, record["snapshotId"].(string)); err == nil || !strings.Contains(err.Error(), "parentNodeId must match") { + t.Fatalf("AdmitOutput() error = %v, want parent field and edge mismatch rejection", err) + } + }) + + t.Run("parent abstraction is not broader", func(t *testing.T) { + record := decodedGraphOutput(t, output) + parent := graphCodeNodeByKind(t, record, "source_range") + for _, key := range []string{"byteEnd", "byteStart", "coordinateUnit", "rangeVerification"} { + delete(parent, key) + } + parent["kind"] = "package" + childID := "code:code.same-level" + appendGraphNode(record, map[string]any{ + "currentnessState": parent["currentnessState"], "evidencePlane": "code_traceability", "kind": "package", + "label": "Same-level child", "nodeId": childID, "parentNodeId": parent["nodeId"], + "sourceDigest": parent["sourceDigest"], "sourceId": parent["sourceId"], + }) + appendCodeParentEdge(t, record, parent["nodeId"].(string), childID) + if _, err := AdmitOutput(record, record["snapshotId"].(string)); err == nil || !strings.Contains(err.Error(), "abstraction level") { + t.Fatalf("AdmitOutput() error = %v, want parent abstraction rejection", err) + } + }) +} + +func TestAdmitOutputRejectsFieldsAndKindsOutsideTheirEvidencePlane(t *testing.T) { + output, err := Build(graphPermutationInput(t)) + if err != nil { + t.Fatal(err) + } + for _, kind := range []string{"file", "module", "package", "repository", "symbol"} { + t.Run("range fields on "+kind, func(t *testing.T) { + record := decodedGraphOutput(t, output) + node := graphCodeNodeByKind(t, record, "repository") + node["kind"] = kind + node["byteStart"] = json.Number("0") + node["byteEnd"] = json.Number("1") + node["coordinateUnit"] = "utf8_byte" + node["rangeVerification"] = "unverified" + if _, err := AdmitOutput(record, record["snapshotId"].(string)); err == nil || !strings.Contains(err.Error(), "range fields") { + t.Fatalf("AdmitOutput() error = %v, want non-range field rejection", err) + } + }) + } + + t.Run("native execution kind", func(t *testing.T) { + record := decodedGraphOutput(t, output) + for _, raw := range record["nodes"].([]any) { + node := raw.(map[string]any) + if node["evidencePlane"] == "native_execution_coverage" { + node["kind"] = "repository" + break + } + } + if _, err := AdmitOutput(record, record["snapshotId"].(string)); err == nil || !strings.Contains(err.Error(), "execution node kind") { + t.Fatalf("AdmitOutput() error = %v, want native execution kind rejection", err) + } + }) +} + +func TestBuildRejectsCodeSourceDigestMismatchAtEveryAbstractionLevel(t *testing.T) { + for _, level := range []string{"repository", "package", "module", "file", "symbol", "source_range"} { + t.Run(level, func(t *testing.T) { + input := graphPermutationInput(t) + nodes := input["codeTopology"].(map[string]any)["nodes"].([]any) + target := nodes[0].(map[string]any) + if level != "repository" { + target = nodes[1].(map[string]any) + target["abstractionLevel"] = level + if level != "source_range" { + delete(target, "byteStart") + delete(target, "byteEnd") + } + } + target["sourceDigest"] = "sha256:" + strings.Repeat("0", 64) + if _, err := Build(input); err == nil || !strings.Contains(err.Error(), "does not match codeSources content") { + t.Fatalf("Build() error = %v, want source digest mismatch rejection", err) + } + }) + } +} + +func TestBuildRejectsBudgetsBeforePerItemSemantics(t *testing.T) { + t.Run("code nodes", func(t *testing.T) { + input := map[string]any{ + "schemaVersion": json.Number("2"), "graphId": "consumer.traceability.node-budget", "context": graphContextFixture(t), + "codeTopology": map[string]any{"nodes": make([]any, maxGraphNodes+1), "edges": []any{}}, + } + if _, err := Build(input); err == nil || !strings.Contains(err.Error(), "node or edge limit") { + t.Fatalf("Build() error = %v, want node budget rejection before node admission", err) + } + }) + + t.Run("code edges", func(t *testing.T) { + input := map[string]any{ + "schemaVersion": json.Number("2"), "graphId": "consumer.traceability.edge-budget", "context": graphContextFixture(t), + "codeTopology": map[string]any{"nodes": []any{}, "edges": make([]any, maxGraphEdges+1)}, + } + if _, err := Build(input); err == nil || !strings.Contains(err.Error(), "node or edge limit") { + t.Fatalf("Build() error = %v, want edge budget rejection before edge admission", err) + } + }) + + t.Run("code source bytes", func(t *testing.T) { + oversizedInvalidUTF8 := strings.Repeat("x", maxCodeSourceBytes+1) + string([]byte{0xff}) + input := map[string]any{ + "schemaVersion": json.Number("2"), "graphId": "consumer.traceability.source-budget", "context": graphContextFixture(t), + "codeSources": []any{map[string]any{"path": "../outside.go", "content": oversizedInvalidUTF8}}, + } + if _, err := Build(input); err == nil || !strings.Contains(err.Error(), "code sources exceed byte limit") { + t.Fatalf("Build() error = %v, want byte budget rejection before path and UTF-8 admission", err) + } + }) +} + +func TestAdmitOutputRejectsBudgetsBeforePerItemSemantics(t *testing.T) { + snapshotID := graphContextFixture(t)["snapshotId"].(string) + for _, test := range []struct { + name string + nodes []any + edges []any + }{ + {name: "nodes", nodes: make([]any, maxGraphNodes+1), edges: []any{}}, + {name: "edges", nodes: []any{}, edges: make([]any, maxGraphEdges+1)}, + } { + t.Run(test.name, func(t *testing.T) { + record := map[string]any{ + "schemaVersion": json.Number("1"), "graphId": "consumer.traceability.output-budget", "graphKind": "proofkit.requirement-traceability-graph", "snapshotId": snapshotID, + "nodes": test.nodes, "nodeCount": json.Number(strconv.Itoa(len(test.nodes))), "edges": test.edges, "edgeCount": json.Number(strconv.Itoa(len(test.edges))), "nonClaims": admit.StringSliceToAny(nonClaims), + } + if _, err := AdmitOutput(record, snapshotID); err == nil || !strings.Contains(err.Error(), "node or edge limit") { + t.Fatalf("AdmitOutput() error = %v, want budget rejection before record admission", err) + } + }) + } +} + +func TestBuildRejectsSemanticallyInertTopologyID(t *testing.T) { + input := graphPermutationInput(t) + input["codeTopology"].(map[string]any)["topologyId"] = "consumer.code-topology" + if _, err := Build(input); err == nil || !strings.Contains(err.Error(), "unsupported field(s): topologyId") { + t.Fatalf("Build() error = %v, want topologyId rejection", err) + } +} + +func TestBuildRequiresInputSchemaVersion2(t *testing.T) { + input := graphPermutationInput(t) + input["schemaVersion"] = json.Number("1") + if _, err := Build(input); err == nil || !strings.Contains(err.Error(), "input schemaVersion must be 2") { + t.Fatalf("Build() error = %v, want old input schema rejection", err) + } +} + func TestGraphIdentityIsPermutationStableAndRelationsAreTyped(t *testing.T) { input := graphPermutationInput(t) first, err := Build(input) @@ -102,6 +321,34 @@ func TestGraphIdentityIsPermutationStableAndRelationsAreTyped(t *testing.T) { if _, err := AdmitOutput(tampered, input["context"].(map[string]any)["snapshotId"].(string)); err == nil { t.Fatal("AdmitOutput accepted an edge laundered into another evidence plane") } + + t.Run("edge identity class", func(t *testing.T) { + record := decodedGraphOutput(t, first) + for _, raw := range record["edges"].([]any) { + edge := raw.(map[string]any) + if edge["edgeKind"] == "contains" && edge["evidencePlane"] == "code_traceability" { + edge["edgeId"] = strings.Replace(edge["edgeId"].(string), "code-parent-edge:", "spec-edge:", 1) + break + } + } + if _, err := AdmitOutput(record, record["snapshotId"].(string)); err == nil || !strings.Contains(err.Error(), "identity") { + t.Fatalf("AdmitOutput() error = %v, want edge identity-class rejection", err) + } + }) + + t.Run("code node namespace", func(t *testing.T) { + record := decodedGraphOutput(t, first) + for _, raw := range record["nodes"].([]any) { + node := raw.(map[string]any) + if node["evidencePlane"] == "code_traceability" { + node["nodeId"] = "forged.code-node" + break + } + } + if _, err := AdmitOutput(record, record["snapshotId"].(string)); err == nil || !strings.Contains(err.Error(), "identity") { + t.Fatalf("AdmitOutput() error = %v, want code namespace rejection", err) + } + }) } func graphPermutationInput(t *testing.T) map[string]any { @@ -110,10 +357,9 @@ func graphPermutationInput(t *testing.T) map[string]any { 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, + "schemaVersion": json.Number("2"), "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")}, @@ -130,6 +376,54 @@ func graphPermutationInput(t *testing.T) map[string]any { } } +func decodedGraphOutput(t *testing.T, output map[string]any) map[string]any { + t.Helper() + 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) + } + return decoded.(map[string]any) +} + +func graphCodeNodeByKind(t *testing.T, record map[string]any, kind string) map[string]any { + t.Helper() + for _, raw := range record["nodes"].([]any) { + node := raw.(map[string]any) + if node["evidencePlane"] == "code_traceability" && node["kind"] == kind { + return node + } + } + t.Fatalf("code node kind %q is unavailable", kind) + return nil +} + +func appendGraphNode(record map[string]any, node map[string]any) { + nodes := append(record["nodes"].([]any), node) + sort.Slice(nodes, func(left, right int) bool { + return nodes[left].(map[string]any)["nodeId"].(string) < nodes[right].(map[string]any)["nodeId"].(string) + }) + record["nodes"] = nodes + record["nodeCount"] = json.Number(strconv.Itoa(len(nodes))) +} + +func appendCodeParentEdge(t *testing.T, record map[string]any, parentID, childID string) { + t.Helper() + edgeID, err := semanticGraphID("code-parent-edge", map[string]any{"edgeKind": "contains", "fromNodeId": parentID, "toNodeId": childID}) + if err != nil { + t.Fatal(err) + } + edges := append(record["edges"].([]any), map[string]any{"edgeId": edgeID, "edgeKind": "contains", "evidencePlane": "code_traceability", "fromNodeId": parentID, "toNodeId": childID}) + sort.Slice(edges, func(left, right int) bool { + return edges[left].(map[string]any)["edgeId"].(string) < edges[right].(map[string]any)["edgeId"].(string) + }) + record["edges"] = edges + record["edgeCount"] = json.Number(strconv.Itoa(len(edges))) +} + func graphContextFixture(t *testing.T) map[string]any { t.Helper() projections := map[string]any{ diff --git a/internal/command/requirementgraph/topology_validation.go b/internal/command/requirementgraph/topology_validation.go new file mode 100644 index 0000000..c9cd89e --- /dev/null +++ b/internal/command/requirementgraph/topology_validation.go @@ -0,0 +1,197 @@ +package requirementgraph + +import "fmt" + +type outputTopologyNode struct { + id string + kind string + parentID string + plane string +} + +type outputTopologyEdge struct { + codeNodeID string + fromID string + id string + kind string + plane string + toID string +} + +type outputTopology struct { + edges []outputTopologyEdge + nodes []outputTopologyNode +} + +func outputTopologyFromRecords(nodes, edges []map[string]any) (outputTopology, error) { + topology := outputTopology{ + edges: make([]outputTopologyEdge, 0, len(edges)), + nodes: make([]outputTopologyNode, 0, len(nodes)), + } + for index, record := range nodes { + id, err := outputTopologyString(record["nodeId"], fmt.Sprintf("requirement traceability graph nodes[%d] nodeId", index)) + if err != nil { + return outputTopology{}, err + } + kind, err := outputTopologyString(record["kind"], fmt.Sprintf("requirement traceability graph nodes[%d] kind", index)) + if err != nil { + return outputTopology{}, err + } + plane, err := outputTopologyString(record["evidencePlane"], fmt.Sprintf("requirement traceability graph nodes[%d] evidencePlane", index)) + if err != nil { + return outputTopology{}, err + } + parentID := "" + if rawParentID, exists := record["parentNodeId"]; exists { + parentID, err = outputTopologyString(rawParentID, fmt.Sprintf("requirement traceability graph nodes[%d] parentNodeId", index)) + if err != nil { + return outputTopology{}, err + } + } + topology.nodes = append(topology.nodes, outputTopologyNode{id: id, kind: kind, parentID: parentID, plane: plane}) + } + for index, record := range edges { + id, err := outputTopologyString(record["edgeId"], fmt.Sprintf("requirement traceability graph edges[%d] edgeId", index)) + if err != nil { + return outputTopology{}, err + } + fromID, err := outputTopologyString(record["fromNodeId"], fmt.Sprintf("requirement traceability graph edges[%d] fromNodeId", index)) + if err != nil { + return outputTopology{}, err + } + toID, err := outputTopologyString(record["toNodeId"], fmt.Sprintf("requirement traceability graph edges[%d] toNodeId", index)) + if err != nil { + return outputTopology{}, err + } + kind, err := outputTopologyString(record["edgeKind"], fmt.Sprintf("requirement traceability graph edges[%d] edgeKind", index)) + if err != nil { + return outputTopology{}, err + } + plane, err := outputTopologyString(record["evidencePlane"], fmt.Sprintf("requirement traceability graph edges[%d] evidencePlane", index)) + if err != nil { + return outputTopology{}, err + } + codeNodeID := "" + if rawCodeNodeID, exists := record["codeNodeId"]; exists { + codeNodeID, err = outputTopologyString(rawCodeNodeID, fmt.Sprintf("requirement traceability graph edges[%d] codeNodeId", index)) + if err != nil { + return outputTopology{}, err + } + } + topology.edges = append(topology.edges, outputTopologyEdge{codeNodeID: codeNodeID, fromID: fromID, id: id, kind: kind, plane: plane, toID: toID}) + } + return topology, nil +} + +func validateOutputTopology(topology outputTopology) error { + nodesByID := make(map[string]outputTopologyNode, len(topology.nodes)) + for _, node := range topology.nodes { + if _, exists := nodesByID[node.id]; exists { + return fmt.Errorf("requirement traceability graph node ids must be unique") + } + nodesByID[node.id] = node + } + seenEdges := make(map[string]struct{}, len(topology.edges)) + codeParentsByChild := map[string]string{} + for _, edge := range topology.edges { + if _, exists := seenEdges[edge.id]; exists { + return fmt.Errorf("requirement traceability graph edge ids must be unique") + } + seenEdges[edge.id] = struct{}{} + from, fromExists := nodesByID[edge.fromID] + if !fromExists { + return fmt.Errorf("requirement traceability graph edge source must resolve") + } + to, toExists := nodesByID[edge.toID] + if !toExists { + return fmt.Errorf("requirement traceability graph edge target must resolve") + } + if !validOutputTopologyRelation(edge, from, to, nodesByID) { + return fmt.Errorf("requirement traceability graph relation is incompatible with its evidence plane and endpoint kinds") + } + if edge.plane == "code_traceability" && edge.kind == "contains" { + if _, exists := codeParentsByChild[edge.toID]; exists { + return fmt.Errorf("requirement traceability graph code node must have exactly one parent edge") + } + codeParentsByChild[edge.toID] = edge.fromID + } + } + for _, node := range topology.nodes { + if node.plane != "code_traceability" { + continue + } + parentFromEdge, hasParentEdge := codeParentsByChild[node.id] + if node.kind == "repository" { + if node.parentID != "" || hasParentEdge { + return fmt.Errorf("requirement traceability graph repository code nodes must be parentless roots") + } + continue + } + if node.parentID == "" { + return fmt.Errorf("requirement traceability graph non-root code node must declare parentNodeId") + } + parent, exists := nodesByID[node.parentID] + if !exists { + return fmt.Errorf("requirement traceability graph code node parent must resolve") + } + if parent.plane != "code_traceability" { + return fmt.Errorf("requirement traceability graph code node parent must remain in the code traceability plane") + } + parentLevel, parentLevelKnown := codeAbstractionLevel(parent.kind) + childLevel, childLevelKnown := codeAbstractionLevel(node.kind) + if !parentLevelKnown || !childLevelKnown || parentLevel >= childLevel { + return fmt.Errorf("requirement traceability graph code node parent abstraction level must be broader than child level") + } + if !hasParentEdge || parentFromEdge != node.parentID { + return fmt.Errorf("requirement traceability graph code node parentNodeId must match exactly one parent edge") + } + } + return nil +} + +func validOutputTopologyRelation(edge outputTopologyEdge, from, to outputTopologyNode, nodesByID map[string]outputTopologyNode) bool { + switch edge.plane + ":" + edge.kind { + case "specification_coverage:contains": + return from.plane == edge.plane && to.plane == edge.plane && from.kind != "requirement" && to.kind != "requirement" + case "specification_coverage:declares": + return from.plane == edge.plane && from.kind != "requirement" && to.plane == edge.plane && to.kind == "requirement" + case "proof_coverage:proved_by_candidate": + return from.plane == "specification_coverage" && from.kind == "requirement" && to.plane == edge.plane && to.kind == "scenario" + case "code_traceability:contains": + return from.plane == edge.plane && to.plane == edge.plane + case "code_traceability:traced_to": + return from.plane == "specification_coverage" && from.kind == "requirement" && to.plane == edge.plane + case "native_execution_coverage:observed_by": + codeNode, exists := nodesByID[edge.codeNodeID] + return from.plane == "specification_coverage" && from.kind == "requirement" && to.plane == edge.plane && to.kind == "execution_evidence" && exists && codeNode.plane == "code_traceability" + default: + return false + } +} + +func codeAbstractionLevel(kind string) (int, bool) { + switch kind { + case "repository": + return 0, true + case "package": + return 1, true + case "module": + return 2, true + case "file": + return 3, true + case "symbol": + return 4, true + case "source_range": + return 5, true + default: + return 0, false + } +} + +func outputTopologyString(raw any, context string) (string, error) { + value, ok := raw.(string) + if !ok || value == "" { + return "", fmt.Errorf("%s must be non-empty text", context) + } + return value, nil +} diff --git a/internal/kernel/releaseplatform/releaseplatform.go b/internal/kernel/releaseplatform/releaseplatform.go index a0b839f..60fcd02 100644 --- a/internal/kernel/releaseplatform/releaseplatform.go +++ b/internal/kernel/releaseplatform/releaseplatform.go @@ -21,8 +21,8 @@ type Target struct { } var targets = []Target{ - target("darwin", "arm64", "arm64", "darwin-arm64", "macosx_11_0_arm64"), - target("darwin", "amd64", "x64", "darwin-x64", "macosx_10_12_x86_64"), + target("darwin", "arm64", "arm64", "darwin-arm64", "macosx_12_0_arm64"), + target("darwin", "amd64", "x64", "darwin-x64", "macosx_12_0_x86_64"), target("linux", "arm64", "arm64", "linux-arm64", "manylinux_2_17_aarch64"), target("linux", "amd64", "x64", "linux-x64", "manylinux_2_17_x86_64"), } diff --git a/internal/kernel/releaseplatform/releaseplatform_test.go b/internal/kernel/releaseplatform/releaseplatform_test.go index 0aec65d..c778f78 100644 --- a/internal/kernel/releaseplatform/releaseplatform_test.go +++ b/internal/kernel/releaseplatform/releaseplatform_test.go @@ -17,8 +17,8 @@ func TestTargetsOwnExactReleaseMatrix(t *testing.T) { NPMOS: "darwin", PackageTarEntry: "package/dist/platform/darwin-arm64/agentic-proofkit", PlatformSuffix: "darwin-arm64", - PlatformTag: "macosx_11_0_arm64", - WheelTag: "py3-none-macosx_11_0_arm64", + PlatformTag: "macosx_12_0_arm64", + WheelTag: "py3-none-macosx_12_0_arm64", }, { BinaryPath: "dist/platform/darwin-x64/agentic-proofkit", @@ -28,8 +28,8 @@ func TestTargetsOwnExactReleaseMatrix(t *testing.T) { NPMOS: "darwin", PackageTarEntry: "package/dist/platform/darwin-x64/agentic-proofkit", PlatformSuffix: "darwin-x64", - PlatformTag: "macosx_10_12_x86_64", - WheelTag: "py3-none-macosx_10_12_x86_64", + PlatformTag: "macosx_12_0_x86_64", + WheelTag: "py3-none-macosx_12_0_x86_64", }, { BinaryPath: "dist/platform/linux-arm64/agentic-proofkit", diff --git a/internal/testsupport/browserfixture/fixture.go b/internal/testsupport/browserfixture/fixture.go index 5fd0224..ceaa252 100644 --- a/internal/testsupport/browserfixture/fixture.go +++ b/internal/testsupport/browserfixture/fixture.go @@ -39,9 +39,8 @@ func Workspace() (map[string]any, error) { 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"), + "context": current, "graphId": "browser.fixture.graph", "schemaVersion": json.Number("2"), } return map[string]any{"context": current, "diffInput": diffInput, "graphInput": graphInput, "schemaVersion": json.Number("1"), "workspaceId": "browser.fixture.workspace"}, nil } diff --git a/internal/tools/packageartifactrecord/record.go b/internal/tools/packageartifactrecord/record.go index e90a867..141b198 100644 --- a/internal/tools/packageartifactrecord/record.go +++ b/internal/tools/packageartifactrecord/record.go @@ -79,8 +79,8 @@ var providerEvidencePaths = []string{ "artifacts/attestations", "artifacts/pypi-registry", "artifacts/registry", + "artifacts/retained-evidence-checksums.sha256", "artifacts/release/github-release.json", - "artifacts/release/retained-evidence-checksums.sha256", } func CanonicalCommandArgv() []string { diff --git a/internal/tools/pythonpackage/build.go b/internal/tools/pythonpackage/build.go index dc41f67..33e14cb 100644 --- a/internal/tools/pythonpackage/build.go +++ b/internal/tools/pythonpackage/build.go @@ -122,7 +122,7 @@ func wheelEntries(manifest packageJSON, target target, binary []byte) ([]wheelEn "python/agentic_proofkit/__main__.py", "python/agentic_proofkit/cli.py", } - entries := make([]wheelEntry, 0, len(sourceFiles)+5) + entries := make([]wheelEntry, 0, len(sourceFiles)+6) for _, source := range sourceFiles { content, err := os.ReadFile(source) if err != nil { @@ -134,6 +134,10 @@ func wheelEntries(manifest packageJSON, target target, binary []byte) ([]wheelEn Path: strings.TrimPrefix(source, "python/"), }) } + license, err := readLicenseFile() + if err != nil { + return nil, err + } entries = append(entries, wheelEntry{ Content: binary, @@ -155,6 +159,11 @@ func wheelEntries(manifest packageJSON, target target, binary []byte) ([]wheelEn Mode: 0o644, Path: distInfo + "/entry_points.txt", }, + wheelEntry{ + Content: license, + Mode: 0o644, + Path: distInfo + "/licenses/" + licenseFilename, + }, ) sort.Slice(entries, func(left int, right int) bool { return entries[left].Path < entries[right].Path @@ -169,11 +178,12 @@ func wheelEntries(manifest packageJSON, target target, binary []byte) ([]wheelEn func metadata(manifest packageJSON) string { return strings.Join([]string{ - "Metadata-Version: 2.1", + "Metadata-Version: 2.4", "Name: " + packageName, "Version: " + manifest.Version, "Summary: " + manifest.Description, - "License: " + manifest.License, + "License-Expression: " + licenseExpression, + "License-File: " + licenseFilename, "Requires-Python: >=3.9", "Project-URL: Source, " + strings.TrimPrefix(manifest.Repository.URL, "git+"), "", diff --git a/internal/tools/pythonpackage/metadata.go b/internal/tools/pythonpackage/metadata.go index 7972791..f607171 100644 --- a/internal/tools/pythonpackage/metadata.go +++ b/internal/tools/pythonpackage/metadata.go @@ -13,12 +13,14 @@ import ( var pep440VersionPattern = regexp.MustCompile(`^[0-9]+(?:\.[0-9]+)+(?:a[0-9]+|b[0-9]+|rc[0-9]+|\.post[0-9]+|\.dev[0-9]+)?$`) const ( - artifactKind = "proofkit.python-package-set.v1" - npmPackageName = "@research-engineering/agentic-proofkit" - packageName = "agentic-proofkit" - pythonTag = "py3" - abiTag = "none" - schemaVersion = 1 + artifactKind = "proofkit.python-package-set.v1" + npmPackageName = "@research-engineering/agentic-proofkit" + packageName = "agentic-proofkit" + licenseExpression = "MIT" + pythonTag = "py3" + abiTag = "none" + licenseFilename = "LICENSE" + schemaVersion = 1 ) type target = releaseplatform.Target @@ -81,6 +83,9 @@ func readPackageJSON() (packageJSON, error) { if err := requireMetadataField("license", manifest.License); err != nil { return packageJSON{}, err } + if manifest.License != licenseExpression { + return packageJSON{}, fmt.Errorf("package.json license must be %s", licenseExpression) + } if err := requireMetadataField("repository.url", manifest.Repository.URL); err != nil { return packageJSON{}, err } @@ -97,6 +102,17 @@ func requireMetadataField(label string, value string) error { return nil } +func readLicenseFile() ([]byte, error) { + content, err := os.ReadFile(licenseFilename) + if err != nil { + return nil, fmt.Errorf("read %s: %w", licenseFilename, err) + } + if len(content) == 0 { + return nil, fmt.Errorf("%s must not be empty", licenseFilename) + } + return content, nil +} + func readAdmittedJSON[T any](path string) (T, error) { var out T file, err := os.Open(path) diff --git a/internal/tools/pythonpackage/metadata_test.go b/internal/tools/pythonpackage/metadata_test.go index e18c3a0..fa5a143 100644 --- a/internal/tools/pythonpackage/metadata_test.go +++ b/internal/tools/pythonpackage/metadata_test.go @@ -3,6 +3,7 @@ package main import ( "archive/zip" "crypto/sha256" + "encoding/binary" "fmt" "os" "path/filepath" @@ -10,6 +11,8 @@ import ( "testing" ) +const testLicenseContent = "MIT License\n" + func TestPythonPackageReadersRejectAmbiguousJSON(t *testing.T) { cases := []struct { name string @@ -73,6 +76,11 @@ func TestReadPackageJSONRejectsUnsafeWheelMetadata(t *testing.T) { content: `{"name":"@research-engineering/agentic-proofkit","version":"1.2.3","license":"MIT","repository":{"url":"https://example.test/repo"}}`, want: "must include description", }, + { + name: "license expression disagrees with repository license", + content: `{"name":"@research-engineering/agentic-proofkit","version":"1.2.3","description":"Proofkit CLI","license":"Apache-2.0","repository":{"url":"https://example.test/repo"}}`, + want: "license must be MIT", + }, } for _, item := range cases { t.Run(item.name, func(t *testing.T) { @@ -91,13 +99,68 @@ func TestReadPackageJSONRejectsUnsafeWheelMetadata(t *testing.T) { } } +func TestMetadataUsesCoreMetadata24LicenseFields(t *testing.T) { + content := metadata(testPackageManifest("1.2.3")) + for _, required := range []string{ + "Metadata-Version: 2.4\n", + "License-Expression: MIT\n", + "License-File: LICENSE\n", + } { + if !strings.Contains(content, required) { + t.Fatalf("metadata() missing %q:\n%s", required, content) + } + } + if strings.Contains(content, "\nLicense: ") { + t.Fatalf("metadata() retained deprecated License field:\n%s", content) + } +} + +func TestWheelEntriesIncludeCanonicalLicense(t *testing.T) { + root := t.TempDir() + for _, source := range []string{ + "python/agentic_proofkit/__init__.py", + "python/agentic_proofkit/__main__.py", + "python/agentic_proofkit/cli.py", + } { + path := filepath.Join(root, source) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(source), 0o600); err != nil { + t.Fatal(err) + } + } + if err := os.WriteFile(filepath.Join(root, licenseFilename), []byte(testLicenseContent), 0o600); err != nil { + t.Fatal(err) + } + + var entries []wheelEntry + withWorkingDirectory(t, root, func() { + var err error + entries, err = wheelEntries(testPackageManifest("1.2.3"), releaseTargets()[2], []byte("binary")) + if err != nil { + t.Fatalf("wheelEntries() error: %v", err) + } + }) + wantPath := distInfoDir("1.2.3") + "/licenses/" + licenseFilename + for _, entry := range entries { + if entry.Path == wantPath { + if string(entry.Content) != testLicenseContent { + t.Fatalf("wheel license = %q, want canonical source license", entry.Content) + } + return + } + } + t.Fatalf("wheelEntries() missing %s", wantPath) +} + func TestVerifyWheelContentsRequiresExactWheelMetadata(t *testing.T) { target := releaseTargets()[0] version := "1.2.3" path := filepath.Join(t.TempDir(), "wheel.whl") writeMinimalWheel(t, path, version, wheelMetadata(target)+"Tag: py3-none-conflicting\n") - err := verifyWheelContents(path, version, target, binarySHA256([]byte("binary"))) + err := verifyWheelContents(path, testPackageManifest(version), target, binarySHA256([]byte("binary")), []byte(testLicenseContent)) if err == nil || !strings.Contains(err.Error(), "WHEEL metadata must match release platform target") { t.Fatalf("verifyWheelContents() error=%v, want exact WHEEL metadata rejection", err) } @@ -127,7 +190,7 @@ func TestVerifyWheelContentsRejectsDataDescriptors(t *testing.T) { path := filepath.Join(t.TempDir(), "wheel.whl") writeDataDescriptorWheel(t, path, version, wheelMetadata(target)) - err := verifyWheelContents(path, version, target, binarySHA256([]byte("binary"))) + err := verifyWheelContents(path, testPackageManifest(version), target, binarySHA256([]byte("binary")), []byte(testLicenseContent)) if err == nil || !strings.Contains(err.Error(), "ZIP data descriptor") { t.Fatalf("verifyWheelContents() error=%v, want data descriptor rejection", err) } @@ -139,17 +202,117 @@ func TestVerifyWheelContentsRejectsEmbeddedBinaryDifferentFromSourceRecord(t *te path := filepath.Join(t.TempDir(), "wheel.whl") writeMinimalWheelWithBinary(t, path, version, wheelMetadata(target), []byte("corrupted-binary")) - err := verifyWheelContents(path, version, target, binarySHA256([]byte("source-binary"))) + err := verifyWheelContents(path, testPackageManifest(version), target, binarySHA256([]byte("source-binary")), []byte(testLicenseContent)) if err == nil || !strings.Contains(err.Error(), "embedded binary sha256 mismatch") { t.Fatalf("verifyWheelContents() error=%v, want embedded/source binary identity rejection", err) } } +func TestVerifyWheelContentsRejectsDarwinTagBelowMachOMinimum(t *testing.T) { + target := releaseTargets()[0] + target.PlatformTag = "macosx_11_0_arm64" + target.WheelTag = "py3-none-" + target.PlatformTag + version := "1.2.3" + binaryContent := macho64WithMinimumMacOS(12, 0, 0) + path := filepath.Join(t.TempDir(), "wheel.whl") + writeMinimalWheelWithBinary(t, path, version, wheelMetadata(target), binaryContent) + + err := verifyWheelContents(path, testPackageManifest(version), target, binarySHA256(binaryContent), []byte(testLicenseContent)) + if err == nil || !strings.Contains(err.Error(), "embedded Mach-O requires macOS 12.0") { + t.Fatalf("verifyWheelContents() error=%v, want stale Darwin tag rejection", err) + } +} + +func TestVerifyWheelContentsAcceptsDarwinTagAtOrAboveMachOMinimum(t *testing.T) { + for _, platformTag := range []string{"macosx_12_0_arm64", "macosx_13_0_arm64"} { + t.Run(platformTag, func(t *testing.T) { + target := releaseTargets()[0] + target.PlatformTag = platformTag + target.WheelTag = "py3-none-" + platformTag + version := "1.2.3" + binaryContent := macho64WithMinimumMacOS(12, 0, 0) + path := filepath.Join(t.TempDir(), "wheel.whl") + writeMinimalWheelWithBinary(t, path, version, wheelMetadata(target), binaryContent) + + if err := verifyWheelContents(path, testPackageManifest(version), target, binarySHA256(binaryContent), []byte(testLicenseContent)); err != nil { + t.Fatalf("verifyWheelContents() error=%v, want truthful Darwin tag accepted", err) + } + }) + } +} + +func TestMachOMinimumMacOSRejectsTruncatedBuildVersion(t *testing.T) { + content := macho64WithMinimumMacOS(12, 0, 0) + binary.LittleEndian.PutUint32(content[36:40], 8) + + _, err := machoMinimumMacOS(content) + if err == nil { + t.Fatalf("machoMinimumMacOS() error=%v, want truncated command rejection", err) + } +} + +func TestMachOMinimumMacOSAcceptsLegacyVersionCommand(t *testing.T) { + content := macho64WithMinimumMacOS(12, 0, 0) + binary.LittleEndian.PutUint32(content[32:36], machoMinimumVersionCommand) + binary.LittleEndian.PutUint32(content[36:40], machoMinimumVersionCommandSize) + binary.LittleEndian.PutUint32(content[20:24], machoMinimumVersionCommandSize) + binary.LittleEndian.PutUint32(content[40:44], 12<<16) + content = content[:32+machoMinimumVersionCommandSize] + + minimum, err := machoMinimumMacOS(content) + if err != nil { + t.Fatalf("machoMinimumMacOS() error=%v", err) + } + if minimum != 12<<16 { + t.Fatalf("machoMinimumMacOS()=%#x, want macOS 12.0", minimum) + } +} + +func TestVerifyWheelContentsRejectsLegacyLicenseMetadata(t *testing.T) { + target := releaseTargets()[2] + version := "1.2.3" + path := filepath.Join(t.TempDir(), "wheel.whl") + writeMinimalWheelFixture(t, path, version, wheelMetadata(target), []byte("binary"), "Metadata-Version: 2.1\nLicense: MIT\n", true, []byte(testLicenseContent)) + + err := verifyWheelContents(path, testPackageManifest(version), target, binarySHA256([]byte("binary")), []byte(testLicenseContent)) + if err == nil || !strings.Contains(err.Error(), "METADATA mismatch") { + t.Fatalf("verifyWheelContents() error=%v, want legacy METADATA rejection", err) + } +} + +func TestVerifyWheelContentsRequiresLicenseFile(t *testing.T) { + target := releaseTargets()[2] + version := "1.2.3" + path := filepath.Join(t.TempDir(), "wheel.whl") + writeMinimalWheelFixture(t, path, version, wheelMetadata(target), []byte("binary"), metadata(testPackageManifest(version)), false, nil) + + err := verifyWheelContents(path, testPackageManifest(version), target, binarySHA256([]byte("binary")), []byte(testLicenseContent)) + if err == nil || !strings.Contains(err.Error(), "licenses/LICENSE") { + t.Fatalf("verifyWheelContents() error=%v, want missing license rejection", err) + } +} + +func TestVerifyWheelContentsRejectsDifferentLicenseFile(t *testing.T) { + target := releaseTargets()[2] + version := "1.2.3" + path := filepath.Join(t.TempDir(), "wheel.whl") + writeMinimalWheelFixture(t, path, version, wheelMetadata(target), []byte("binary"), metadata(testPackageManifest(version)), true, []byte("different license\n")) + + err := verifyWheelContents(path, testPackageManifest(version), target, binarySHA256([]byte("binary")), []byte(testLicenseContent)) + if err == nil || !strings.Contains(err.Error(), "embedded LICENSE mismatch") { + t.Fatalf("verifyWheelContents() error=%v, want source/artifact license mismatch rejection", err) + } +} + func writeMinimalWheel(t *testing.T, path string, version string, wheel string) { writeMinimalWheelWithBinary(t, path, version, wheel, []byte("binary")) } func writeMinimalWheelWithBinary(t *testing.T, path string, version string, wheel string, binary []byte) { + writeMinimalWheelFixture(t, path, version, wheel, binary, metadata(testPackageManifest(version)), true, []byte(testLicenseContent)) +} + +func writeMinimalWheelFixture(t *testing.T, path string, version string, wheel string, binary []byte, metadataContent string, includeLicense bool, license []byte) { t.Helper() distInfo := distInfoDir(version) entries := []wheelEntry{ @@ -157,16 +320,52 @@ func writeMinimalWheelWithBinary(t *testing.T, path string, version string, whee {Path: "agentic_proofkit/__main__.py", Mode: 0o644}, {Path: "agentic_proofkit/cli.py", Mode: 0o644}, {Path: "agentic_proofkit/bin/agentic-proofkit", Content: binary, Mode: 0o755}, - {Path: distInfo + "/METADATA", Content: []byte("Metadata-Version: 2.1\n"), Mode: 0o644}, + {Path: distInfo + "/METADATA", Content: []byte(metadataContent), Mode: 0o644}, {Path: distInfo + "/WHEEL", Content: []byte(wheel), Mode: 0o644}, {Path: distInfo + "/entry_points.txt", Content: []byte(entryPoints()), Mode: 0o644}, - {Path: distInfo + "/RECORD", Mode: 0o644}, } + if includeLicense { + entries = append(entries, wheelEntry{Path: distInfo + "/licenses/LICENSE", Content: license, Mode: 0o644}) + } + recordPath := distInfo + "/RECORD" + entries = append(entries, wheelEntry{Path: recordPath, Content: recordContent(entries, recordPath), Mode: 0o644}) if err := writeWheel(path, entries); err != nil { t.Fatalf("write wheel: %v", err) } } +func testPackageManifest(version string) packageJSON { + return packageJSON{ + Description: "Proofkit CLI", + License: "MIT", + Name: npmPackageName, + Repository: repositoryJSON{ + URL: "git+https://example.test/proofkit.git", + }, + Version: version, + } +} + +func macho64WithMinimumMacOS(major uint32, minor uint32, patch uint32) []byte { + const ( + machoHeader64Size = 32 + buildVersionSize = 24 + ) + content := make([]byte, machoHeader64Size+buildVersionSize) + binary.LittleEndian.PutUint32(content[0:4], 0xfeedfacf) + binary.LittleEndian.PutUint32(content[4:8], 0x0100000c) + binary.LittleEndian.PutUint32(content[12:16], 2) + binary.LittleEndian.PutUint32(content[16:20], 1) + binary.LittleEndian.PutUint32(content[20:24], buildVersionSize) + binary.LittleEndian.PutUint32(content[32:36], 0x32) + binary.LittleEndian.PutUint32(content[36:40], buildVersionSize) + binary.LittleEndian.PutUint32(content[40:44], 1) + minimum := major<<16 | minor<<8 | patch + binary.LittleEndian.PutUint32(content[44:48], minimum) + binary.LittleEndian.PutUint32(content[48:52], minimum) + return content +} + func binarySHA256(content []byte) string { sum := sha256.Sum256(content) return fmt.Sprintf("%x", sum[:]) @@ -213,6 +412,15 @@ func withPackageJSON(t *testing.T, source string, fn func() error) error { if err := os.WriteFile(filepath.Join(root, "package.json"), content, 0o600); err != nil { t.Fatalf("write package.json: %v", err) } + var result error + withWorkingDirectory(t, root, func() { + result = fn() + }) + return result +} + +func withWorkingDirectory(t *testing.T, root string, fn func()) { + t.Helper() previous, err := os.Getwd() if err != nil { t.Fatalf("getwd: %v", err) @@ -225,5 +433,5 @@ func withPackageJSON(t *testing.T, source string, fn func() error) error { t.Fatalf("restore cwd: %v", err) } }() - return fn() + fn() } diff --git a/internal/tools/pythonpackage/verify.go b/internal/tools/pythonpackage/verify.go index cc5f451..d9e0316 100644 --- a/internal/tools/pythonpackage/verify.go +++ b/internal/tools/pythonpackage/verify.go @@ -4,6 +4,7 @@ import ( "archive/zip" "bytes" "crypto/sha256" + "debug/macho" "encoding/base64" "encoding/csv" "fmt" @@ -17,7 +18,14 @@ import ( "strings" ) -const zipDataDescriptorFlag = 0x8 +const ( + zipDataDescriptorFlag = 0x8 + machoBuildVersionCommand = 0x32 + machoBuildVersionCommandSize = 24 + machoMinimumVersionCommand = 0x24 + machoMinimumVersionCommandSize = 16 + machoPlatformMacOS = 1 +) func verifyPythonPackages() error { return verifyPythonPackagesForTargets(releaseTargets()) @@ -98,10 +106,14 @@ func verifyWheelRecord(manifest packageJSON, target target, record wheelRecord) if record.BinarySha256 != fmt.Sprintf("%x", binarySHA[:]) { return fmt.Errorf("python wheel binary sha256 mismatch for %s", record.Filename) } - return verifyWheelContents(wheelPath, manifest.Version, target, record.BinarySha256) + license, err := readLicenseFile() + if err != nil { + return err + } + return verifyWheelContents(wheelPath, manifest, target, record.BinarySha256, license) } -func verifyWheelContents(path string, version string, target target, expectedBinarySHA256 string) error { +func verifyWheelContents(path string, manifest packageJSON, target target, expectedBinarySHA256 string, expectedLicense []byte) error { reader, err := zip.OpenReader(path) if err != nil { return err @@ -117,7 +129,8 @@ func verifyWheelContents(path string, version string, target target, expectedBin } entries[file.Name] = file } - distInfo := distInfoDir(version) + distInfo := distInfoDir(manifest.Version) + licensePath := distInfo + "/licenses/" + licenseFilename required := []string{ "agentic_proofkit/__init__.py", "agentic_proofkit/__main__.py", @@ -126,6 +139,7 @@ func verifyWheelContents(path string, version string, target target, expectedBin distInfo + "/METADATA", distInfo + "/WHEEL", distInfo + "/entry_points.txt", + licensePath, distInfo + "/RECORD", } for _, entry := range required { @@ -138,6 +152,20 @@ func verifyWheelContents(path string, version string, target target, expectedBin return fmt.Errorf("%s contains unexpected entry %s", path, entry) } } + metadataContent, err := readZipFile(entries[distInfo+"/METADATA"]) + if err != nil { + return err + } + if string(metadataContent) != metadata(manifest) { + return fmt.Errorf("%s METADATA mismatch", path) + } + licenseContent, err := readZipFile(entries[licensePath]) + if err != nil { + return err + } + if !bytes.Equal(licenseContent, expectedLicense) { + return fmt.Errorf("%s embedded %s mismatch", path, licenseFilename) + } wheel, err := readZipFile(entries[distInfo+"/WHEEL"]) if err != nil { return err @@ -160,9 +188,109 @@ func verifyWheelContents(path string, version string, target target, expectedBin if fmt.Sprintf("%x", embeddedBinarySHA256[:]) != expectedBinarySHA256 { return fmt.Errorf("%s embedded binary sha256 mismatch", path) } + if err := verifyDarwinWheelMinimum(target, embeddedBinary); err != nil { + return fmt.Errorf("%s: %w", path, err) + } return verifyRecord(entries, distInfo+"/RECORD") } +func verifyDarwinWheelMinimum(target target, content []byte) error { + if target.GOOS != "darwin" { + return nil + } + wheelMinimum, err := macOSPlatformTagMinimum(target.PlatformTag) + if err != nil { + return err + } + binaryMinimum, err := machoMinimumMacOS(content) + if err != nil { + return fmt.Errorf("decode embedded Mach-O minimum macOS: %w", err) + } + if wheelMinimum < binaryMinimum { + return fmt.Errorf( + "wheel tag %s advertises macOS %s but embedded Mach-O requires macOS %s", + target.PlatformTag, + formatMachOVersion(wheelMinimum), + formatMachOVersion(binaryMinimum), + ) + } + return nil +} + +func macOSPlatformTagMinimum(platformTag string) (uint32, error) { + const prefix = "macosx_" + if !strings.HasPrefix(platformTag, prefix) { + return 0, fmt.Errorf("darwin platform tag must start with %s: %s", prefix, platformTag) + } + parts := strings.SplitN(strings.TrimPrefix(platformTag, prefix), "_", 3) + if len(parts) != 3 || parts[2] == "" { + return 0, fmt.Errorf("invalid Darwin platform tag %s", platformTag) + } + major, err := strconv.ParseUint(parts[0], 10, 16) + if err != nil || major < 10 { + return 0, fmt.Errorf("invalid Darwin platform tag major version in %s", platformTag) + } + minor, err := strconv.ParseUint(parts[1], 10, 8) + if err != nil { + return 0, fmt.Errorf("invalid Darwin platform tag minor version in %s", platformTag) + } + return uint32(major)<<16 | uint32(minor)<<8, nil +} + +func machoMinimumMacOS(content []byte) (uint32, error) { + file, err := macho.NewFile(bytes.NewReader(content)) + if err != nil { + return 0, fmt.Errorf("parse Mach-O: %w", err) + } + var minimum uint32 + found := false + for _, load := range file.Loads { + raw := load.Raw() + if len(raw) < 8 { + return 0, fmt.Errorf("truncated Mach-O load command") + } + command := file.ByteOrder.Uint32(raw[:4]) + switch command { + case machoBuildVersionCommand: + if found { + return 0, fmt.Errorf("multiple Mach-O minimum macOS commands") + } + if len(raw) < machoBuildVersionCommandSize { + return 0, fmt.Errorf("truncated Mach-O LC_BUILD_VERSION command") + } + platform := file.ByteOrder.Uint32(raw[8:12]) + if platform != machoPlatformMacOS { + return 0, fmt.Errorf("Mach-O LC_BUILD_VERSION platform is %d, want macOS", platform) + } + minimum = file.ByteOrder.Uint32(raw[12:16]) + found = true + case machoMinimumVersionCommand: + if found { + return 0, fmt.Errorf("multiple Mach-O minimum macOS commands") + } + if len(raw) < machoMinimumVersionCommandSize { + return 0, fmt.Errorf("truncated Mach-O LC_VERSION_MIN_MACOSX command") + } + minimum = file.ByteOrder.Uint32(raw[8:12]) + found = true + } + } + if !found { + return 0, fmt.Errorf("missing Mach-O minimum macOS command") + } + return minimum, nil +} + +func formatMachOVersion(version uint32) string { + major := version >> 16 + minor := version >> 8 & 0xff + patch := version & 0xff + if patch == 0 { + return fmt.Sprintf("%d.%d", major, minor) + } + return fmt.Sprintf("%d.%d.%d", major, minor, patch) +} + func verifyRecord(entries map[string]*zip.File, recordPath string) error { recordFile, ok := entries[recordPath] if !ok { diff --git a/internal/tools/releasechange/record.go b/internal/tools/releasechange/record.go new file mode 100644 index 0000000..0b3c302 --- /dev/null +++ b/internal/tools/releasechange/record.go @@ -0,0 +1,282 @@ +package releasechange + +import ( + "bytes" + "fmt" + "os" + "strings" + + "github.com/research-engineering/agentic-proofkit/internal/kernel/admission" + "github.com/research-engineering/agentic-proofkit/internal/kernel/admit" +) + +const ( + RecordPath = "release/change-record.v1.json" + maxRecordBytes = 1 << 20 + maxListItems = 128 + maxEntryTextByte = 4096 +) + +type Record struct { + Additions []Change + BreakingChanges []Change + KnownLimitations []string + Migration Migration + PlatformRequirements []string + RollbackStrategy string + SchemaVersion int + Version string +} + +type Change struct { + ChangeID string + Summary string +} + +type Migration struct { + Required bool + Steps []string +} + +func Read(path string) (Record, error) { + content, err := os.ReadFile(path) + if err != nil { + return Record{}, fmt.Errorf("read release change record: %w", err) + } + value, err := admission.DecodeJSON(bytes.NewReader(content), maxRecordBytes) + if err != nil { + return Record{}, fmt.Errorf("decode release change record: %w", err) + } + return Admit(value) +} + +func Admit(raw any) (Record, error) { + record, ok := raw.(map[string]any) + if !ok { + return Record{}, fmt.Errorf("release change record must be an object") + } + if err := admit.KnownKeys(record, []string{"additions", "breakingChanges", "knownLimitations", "migration", "platformRequirements", "rollback", "schemaVersion", "version"}, "release change record"); err != nil { + return Record{}, err + } + if !admit.JSONNumberEquals(record["schemaVersion"], 1) { + return Record{}, fmt.Errorf("release change record schemaVersion must be 1") + } + version, err := boundedText(record["version"], "release change record version") + if err != nil { + return Record{}, err + } + breaking, err := admitChanges(record["breakingChanges"], "release change record breakingChanges") + if err != nil { + return Record{}, err + } + additions, err := admitChanges(record["additions"], "release change record additions") + if err != nil { + return Record{}, err + } + if err := uniqueChangeIDs(breaking, additions); err != nil { + return Record{}, err + } + migration, err := admitMigration(record["migration"]) + if err != nil { + return Record{}, err + } + platforms, err := orderedUniqueText(record["platformRequirements"], "release change record platformRequirements", true) + if err != nil { + return Record{}, err + } + limitations, err := orderedUniqueText(record["knownLimitations"], "release change record knownLimitations", true) + if err != nil { + return Record{}, err + } + rollback, ok := record["rollback"].(map[string]any) + if !ok { + return Record{}, fmt.Errorf("release change record rollback must be an object") + } + if err := admit.KnownKeys(rollback, []string{"strategy"}, "release change record rollback"); err != nil { + return Record{}, err + } + strategy, err := admit.NonEmptyText(rollback["strategy"], "release change record rollback strategy") + if err != nil { + return Record{}, err + } + if strategy != "previous_admitted_version" { + return Record{}, fmt.Errorf("release change record rollback strategy must be previous_admitted_version") + } + return Record{ + Additions: additions, BreakingChanges: breaking, KnownLimitations: limitations, + Migration: migration, PlatformRequirements: platforms, RollbackStrategy: strategy, + SchemaVersion: 1, Version: version, + }, nil +} + +func RequireVersion(record Record, version string) error { + if record.Version != version { + return fmt.Errorf("release change record version %s does not match package version %s", record.Version, version) + } + return nil +} + +func RenderMarkdown(record Record, npmPackage, pythonPackage string, pypiPublished bool) string { + lines := []string{ + fmt.Sprintf("# %s %s", npmPackage, record.Version), + "", + "## Breaking Contract Changes", + "", + } + lines = appendChanges(lines, record.BreakingChanges) + lines = append(lines, "", "## Additions", "") + lines = appendChanges(lines, record.Additions) + lines = append(lines, "", "## Migration", "") + if record.Migration.Required { + lines = append(lines, "Migration is required:", "") + lines = appendTextList(lines, record.Migration.Steps) + } else { + lines = append(lines, "No consumer migration is required.") + } + lines = append(lines, "", "## Platform Requirements", "") + lines = appendTextList(lines, record.PlatformRequirements) + lines = append(lines, "", "## Known Limitations", "") + lines = appendTextList(lines, record.KnownLimitations) + lines = append(lines, + "", "## Install", "", "Primary npm channel:", "", "```bash", + fmt.Sprintf("npm install -D %s@%s", npmPackage, record.Version), "```", + ) + if pypiPublished { + lines = append(lines, + "", "Python/uv channel:", "", "```bash", + fmt.Sprintf("uv tool install %s==%s", pythonPackage, record.Version), "```", + ) + } else { + lines = append(lines, "", "Python wheels are candidate artifacts until a PyPI release workflow publishes them.") + } + lines = append(lines, + "", "GitHub Release assets and checksums are archive and provenance evidence, not package-manager dependency authority.", + "", "## Rollback", "", + fmt.Sprintf("- Pin npm consumers to the previous admitted version with `npm install -D %s@`.", npmPackage), + ) + if pypiPublished { + lines = append(lines, fmt.Sprintf("- Pin Python/uv consumers with `uv tool install %s==`.", pythonPackage)) + } + lines = append(lines, "- Treat local package artifacts as candidates until registry identity is proven.") + return strings.Join(lines, "\n") + "\n" +} + +func admitChanges(raw any, context string) ([]Change, error) { + values, ok := raw.([]any) + if !ok || len(values) > maxListItems { + return nil, fmt.Errorf("%s must be an array with at most %d records", context, maxListItems) + } + changes := make([]Change, 0, len(values)) + for index, value := range values { + record, ok := value.(map[string]any) + if !ok { + return nil, fmt.Errorf("%s[%d] must be an object", context, index) + } + if err := admit.KnownKeys(record, []string{"changeId", "summary"}, fmt.Sprintf("%s[%d]", context, index)); err != nil { + return nil, err + } + changeID, err := admit.RuleID(record["changeId"], fmt.Sprintf("%s[%d] changeId", context, index)) + if err != nil { + return nil, err + } + summary, err := boundedText(record["summary"], fmt.Sprintf("%s[%d] summary", context, index)) + if err != nil { + return nil, err + } + changes = append(changes, Change{ChangeID: changeID, Summary: summary}) + } + return changes, nil +} + +func admitMigration(raw any) (Migration, error) { + record, ok := raw.(map[string]any) + if !ok { + return Migration{}, fmt.Errorf("release change record migration must be an object") + } + if err := admit.KnownKeys(record, []string{"required", "steps"}, "release change record migration"); err != nil { + return Migration{}, err + } + required, ok := record["required"].(bool) + if !ok { + return Migration{}, fmt.Errorf("release change record migration required must be boolean") + } + steps, err := orderedUniqueText(record["steps"], "release change record migration steps", !required) + if err != nil { + return Migration{}, err + } + if !required && len(steps) != 0 { + return Migration{}, fmt.Errorf("release change record migration steps must be empty when migration is not required") + } + return Migration{Required: required, Steps: steps}, nil +} + +func orderedUniqueText(raw any, context string, allowEmpty bool) ([]string, error) { + values, ok := raw.([]any) + if !ok || len(values) > maxListItems { + return nil, fmt.Errorf("%s must be an array with at most %d values", context, maxListItems) + } + if !allowEmpty && len(values) == 0 { + return nil, fmt.Errorf("%s must be non-empty", context) + } + result := make([]string, 0, len(values)) + seen := map[string]struct{}{} + for index, value := range values { + text, err := boundedText(value, fmt.Sprintf("%s[%d]", context, index)) + if err != nil { + return nil, err + } + if _, exists := seen[text]; exists { + return nil, fmt.Errorf("%s must be unique", context) + } + seen[text] = struct{}{} + result = append(result, text) + } + return result, nil +} + +func boundedText(raw any, context string) (string, error) { + value, err := admit.NonEmptyText(raw, context) + if err != nil { + return "", err + } + if strings.ContainsAny(value, "\r\n") { + return "", fmt.Errorf("%s must be single-line text", context) + } + if len(value) > maxEntryTextByte { + return "", fmt.Errorf("%s exceeds %d bytes", context, maxEntryTextByte) + } + return value, nil +} + +func uniqueChangeIDs(groups ...[]Change) error { + seen := map[string]struct{}{} + for _, changes := range groups { + for _, change := range changes { + if _, exists := seen[change.ChangeID]; exists { + return fmt.Errorf("release change record changeId must be unique: %s", change.ChangeID) + } + seen[change.ChangeID] = struct{}{} + } + } + return nil +} + +func appendChanges(lines []string, changes []Change) []string { + if len(changes) == 0 { + return append(lines, "- None.") + } + for _, change := range changes { + lines = append(lines, fmt.Sprintf("- `%s`: %s", change.ChangeID, change.Summary)) + } + return lines +} + +func appendTextList(lines []string, values []string) []string { + if len(values) == 0 { + return append(lines, "- None.") + } + for _, value := range values { + lines = append(lines, "- "+value) + } + return lines +} diff --git a/internal/tools/releasechange/record_test.go b/internal/tools/releasechange/record_test.go new file mode 100644 index 0000000..82a4053 --- /dev/null +++ b/internal/tools/releasechange/record_test.go @@ -0,0 +1,113 @@ +package releasechange + +import ( + "encoding/json" + "os" + "strings" + "testing" +) + +func TestAdmitAndRenderVersionBoundChangeRecord(t *testing.T) { + record, err := Admit(validRecord()) + if err != nil { + t.Fatal(err) + } + if err := RequireVersion(record, "1.2.3"); err != nil { + t.Fatal(err) + } + notes := RenderMarkdown(record, "@research-engineering/agentic-proofkit", "agentic-proofkit", true) + for _, expected := range []string{ + "## Breaking Contract Changes", "## Additions", "## Migration", "Migration is required:", + "## Platform Requirements", "## Known Limitations", "## Install", "## Rollback", + "npm install -D @research-engineering/agentic-proofkit@1.2.3", + "uv tool install agentic-proofkit==1.2.3", + "`proofkit.contract.breaking`: Remove the inert field.", + } { + if !strings.Contains(notes, expected) { + t.Fatalf("rendered notes missing %q:\n%s", expected, notes) + } + } +} + +func TestAdmitRejectsAmbiguousOrIncompleteChangeRecords(t *testing.T) { + t.Run("duplicate identity across sections", func(t *testing.T) { + record := validRecord() + record["additions"] = []any{map[string]any{"changeId": "proofkit.contract.breaking", "summary": "Duplicate identity."}} + if _, err := Admit(record); err == nil || !strings.Contains(err.Error(), "must be unique") { + t.Fatalf("Admit() error = %v, want duplicate identity rejection", err) + } + }) + t.Run("required migration without steps", func(t *testing.T) { + record := validRecord() + record["migration"] = map[string]any{"required": true, "steps": []any{}} + if _, err := Admit(record); err == nil || !strings.Contains(err.Error(), "must be non-empty") { + t.Fatalf("Admit() error = %v, want migration closure rejection", err) + } + }) + t.Run("no migration with steps", func(t *testing.T) { + record := validRecord() + record["migration"] = map[string]any{"required": false, "steps": []any{"Do something."}} + if _, err := Admit(record); err == nil || !strings.Contains(err.Error(), "must be empty") { + t.Fatalf("Admit() error = %v, want no-migration contradiction rejection", err) + } + }) + t.Run("version mismatch", func(t *testing.T) { + record, err := Admit(validRecord()) + if err != nil { + t.Fatal(err) + } + if err := RequireVersion(record, "1.2.4"); err == nil || !strings.Contains(err.Error(), "does not match") { + t.Fatalf("RequireVersion() error = %v, want mismatch", err) + } + }) + t.Run("multiline release-note field", func(t *testing.T) { + record := validRecord() + record["additions"] = []any{map[string]any{ + "changeId": "proofkit.release.license", + "summary": "Embed license evidence.\n## Forged Section", + }} + if _, err := Admit(record); err == nil || !strings.Contains(err.Error(), "single-line") { + t.Fatalf("Admit() error = %v, want multiline rejection", err) + } + }) +} + +func TestReadRejectsDuplicateJSONKeys(t *testing.T) { + content := `{"schemaVersion":1,"version":"1.2.3","version":"1.2.4"}` + path := t.TempDir() + "/record.json" + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + if _, err := Read(path); err == nil || !strings.Contains(err.Error(), "duplicate object key") { + t.Fatalf("Read() error = %v, want duplicate-key rejection", err) + } +} + +func TestRenderRepresentsEmptyOptionalSectionsExplicitly(t *testing.T) { + record := validRecord() + record["knownLimitations"] = []any{} + record["platformRequirements"] = []any{} + admitted, err := Admit(record) + if err != nil { + t.Fatal(err) + } + notes := RenderMarkdown(admitted, "@research-engineering/agentic-proofkit", "agentic-proofkit", false) + if strings.Count(notes, "- None.") < 2 { + t.Fatalf("rendered notes must represent empty sections explicitly:\n%s", notes) + } +} + +func validRecord() map[string]any { + return map[string]any{ + "schemaVersion": json.Number("1"), + "version": "1.2.3", + "breakingChanges": []any{ + map[string]any{"changeId": "proofkit.contract.breaking", "summary": "Remove the inert field."}, + }, + "additions": []any{map[string]any{"changeId": "proofkit.release.license", "summary": "Embed license evidence."}}, + "migration": map[string]any{"required": true, "steps": []any{"Delete the obsolete input field."}}, + "platformRequirements": []any{"macOS 12.0 or later is required by Darwin wheels."}, + "knownLimitations": []any{"TSX parsing remains unsupported."}, + "rollback": map[string]any{"strategy": "previous_admitted_version"}, + } +} diff --git a/internal/tools/releasecloseoutinput/main.go b/internal/tools/releasecloseoutinput/main.go index 192a9b3..6b6f0dd 100644 --- a/internal/tools/releasecloseoutinput/main.go +++ b/internal/tools/releasecloseoutinput/main.go @@ -25,6 +25,8 @@ import ( "github.com/research-engineering/agentic-proofkit/internal/kernel/releasepublisher" "github.com/research-engineering/agentic-proofkit/internal/kernel/trustedpublisher" "github.com/research-engineering/agentic-proofkit/internal/tools/packageartifactrecord" + "github.com/research-engineering/agentic-proofkit/internal/tools/releasechange" + "github.com/research-engineering/agentic-proofkit/internal/tools/retainedevidence" ) const ( @@ -446,6 +448,7 @@ func pythonArtifactCriterion(root string, manifest packageJSON) criterion { func releaseManifestCriterion(root string, manifest packageJSON) criterion { evidence := []string{ ciProvenancePath, + releasechange.RecordPath, "artifacts/release/checksums.sha256", "artifacts/release/metadata-checksums.sha256", "artifacts/release/release-manifest.json", @@ -456,17 +459,17 @@ func releaseManifestCriterion(root string, manifest packageJSON) criterion { evidence = append(evidence, retainedReleaseEvidenceRefs(root)...) ok := releaseManifestMatches(root, manifest) && validSBOM(root, "artifacts/release/sbom.cdx.json") && - releaseNotesIncludeRollback(root, "artifacts/release/release-notes.md", manifest.Name) && + releaseNotesMatchChangeRecord(root, "artifacts/release/release-notes.md", manifest) && releaseChecksumInventoriesMatch(root, manifest) && retainedReleaseEvidenceMatches(root) && allFilesExist(root, evidence) return blockingCriterion( "proofkit.release_closeout.manifest_and_sbom", - "Release manifest, rollback-capable release notes, package checksums, metadata checksums, SBOM, and SBOM subject digests must exist for the current package version.", + "Release manifest, version-bound change-record notes, package checksums, metadata checksums, SBOM, and SBOM subject digests must exist for the current package version.", ok, evidence, []string{"npm:release:manifest", "npm:release:sbom"}, - []string{"Release manifest, rollback-capable notes, checksum inventory, metadata checksum inventory, SBOM, or SBOM subject digest is missing or invalid."}, + []string{"Release manifest, change record, release notes projection, checksum inventory, metadata checksum inventory, SBOM, or SBOM subject digest is missing or invalid."}, []string{"This criterion does not claim vulnerability absence, license approval, GitHub Release publication, or registry publication."}, ) } @@ -1430,38 +1433,42 @@ func retainedReleaseEvidenceRefs(root string) []string { if len(targets) == 0 { return []string{} } - return append([]string{"artifacts/release/retained-evidence-checksums.sha256"}, targets...) + return append([]string{"artifacts/" + retainedevidence.ManifestPath}, targets...) } func retainedReleaseEvidenceMatches(root string) bool { - targets := retainedReleaseEvidenceTargets(root) - if len(targets) == 0 { - return true + paths := []string{ + "artifacts/" + retainedevidence.ManifestPath, + "artifacts/attestations", + } + paths = append(paths, retainedReleaseEvidenceRepositoryPaths()...) + for _, path := range paths { + if pathEntryExists(root, path) { + return retainedevidence.Verify(filepath.Join(root, "artifacts")) == nil + } } - return checksumFileMatches(root, "artifacts/release/retained-evidence-checksums.sha256", targets) + return true } func retainedReleaseEvidenceTargets(root string) []string { targets := []string{} - if fileExists(root, "artifacts/release/github-release.json") { - targets = append(targets, "artifacts/release/github-release.json") - } - attestationRoot := filepath.Join(root, "artifacts", "attestations") - _ = filepath.WalkDir(attestationRoot, func(path string, entry os.DirEntry, err error) error { - if err != nil || entry == nil || entry.IsDir() || filepath.Ext(path) != ".json" { - return nil + for _, repositoryPath := range retainedReleaseEvidenceRepositoryPaths() { + if fileExists(root, repositoryPath) { + targets = append(targets, repositoryPath) } - relative, relErr := filepath.Rel(root, path) - if relErr != nil { - return nil - } - targets = append(targets, filepath.ToSlash(relative)) - return nil - }) + } sort.Strings(targets) return targets } +func retainedReleaseEvidenceRepositoryPaths() []string { + paths := make([]string, 0, len(retainedevidence.EvidencePaths())) + for _, path := range retainedevidence.EvidencePaths() { + paths = append(paths, filepath.ToSlash(filepath.Join("artifacts", filepath.FromSlash(path)))) + } + return paths +} + func checksumFileMatches(root string, checksumPath string, targetPaths []string) bool { expected, err := checksumLines(root, targetPaths) if err != nil { @@ -1513,16 +1520,18 @@ func validSBOM(root string, path string) bool { return err == nil && sbom.BOMFormat == "CycloneDX" && sbom.SpecVersion != "" } -func releaseNotesIncludeRollback(root string, path string, packageName string) bool { +func releaseNotesMatchChangeRecord(root string, path string, manifest packageJSON) bool { content, err := os.ReadFile(filepath.Join(root, filepath.FromSlash(path))) - if err != nil || len(content) == 0 { + if err != nil { + return false + } + record, err := releasechange.Read(filepath.Join(root, filepath.FromSlash(releasechange.RecordPath))) + if err != nil || releasechange.RequireVersion(record, manifest.Version) != nil { return false } - normalized := strings.ToLower(string(content)) - packageRef := strings.ToLower(packageName) + "@" - return strings.Contains(normalized, "rollback") && - strings.Contains(normalized, "npm install") && - strings.Contains(normalized, packageRef) + pypiPublished := fileExists(root, "artifacts/pypi-registry/pypi-release.json") + expected := releasechange.RenderMarkdown(record, manifest.Name, pythonPackageName, pypiPublished) + return string(content) == expected } func hasChannelStatus(manifest releaseManifest, authority string, allowed ...string) bool { @@ -1649,6 +1658,11 @@ func fileExists(root string, path string) bool { return err == nil && !info.IsDir() && info.Size() > 0 } +func pathEntryExists(root string, path string) bool { + _, err := os.Lstat(filepath.Join(root, filepath.FromSlash(path))) + return err == nil +} + func readTypedJSON[T any](root string, path string) (T, error) { file, err := os.Open(filepath.Join(root, filepath.FromSlash(path))) if err != nil { diff --git a/internal/tools/releasecloseoutinput/main_test.go b/internal/tools/releasecloseoutinput/main_test.go index 2d27407..8f12e34 100644 --- a/internal/tools/releasecloseoutinput/main_test.go +++ b/internal/tools/releasecloseoutinput/main_test.go @@ -25,6 +25,7 @@ import ( "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/tools/packageartifactrecord" + "github.com/research-engineering/agentic-proofkit/internal/tools/releasechange" ) const ( @@ -270,6 +271,27 @@ func TestBuildInputFailsClosedForEachBlockingEvidenceClass(t *testing.T) { writeFile(t, filepath.Join(root, "artifacts", "release", "release-notes.md"), "# notes\n\nrollback with npm install\n") }, }, + { + name: "change record summary drift from release notes", + criterionID: "proofkit.release_closeout.manifest_and_sbom", + mutate: func(root string) { + path := filepath.Join(root, "release", "change-record.v1.json") + record := readJSONMap(t, path) + additions := record["additions"].([]any) + additions[0].(map[string]any)["summary"] = "Changed without regenerating release notes." + writeJSON(t, path, record) + }, + }, + { + name: "change record version drift from package and release notes", + criterionID: "proofkit.release_closeout.manifest_and_sbom", + mutate: func(root string) { + path := filepath.Join(root, "release", "change-record.v1.json") + record := readJSONMap(t, path) + record["version"] = "1.2.4" + writeJSON(t, path, record) + }, + }, { name: "missing metadata checksums", criterionID: "proofkit.release_closeout.manifest_and_sbom", @@ -294,6 +316,40 @@ func TestBuildInputFailsClosedForEachBlockingEvidenceClass(t *testing.T) { }) }, }, + { + name: "retained evidence manifest without targets", + criterionID: "proofkit.release_closeout.manifest_and_sbom", + mutate: func(root string) { + writeFile(t, filepath.Join(root, "artifacts", "retained-evidence-checksums.sha256"), strings.Repeat("a", 64)+" release/github-release.json\n") + }, + }, + { + name: "unbound retained attestation without manifest", + criterionID: "proofkit.release_closeout.manifest_and_sbom", + mutate: func(root string) { + writeJSON(t, filepath.Join(root, "artifacts", "attestations", "unbound.json"), map[string]any{"state": "unbound"}) + }, + }, + { + name: "empty retained attestation namespace", + criterionID: "proofkit.release_closeout.manifest_and_sbom", + mutate: func(root string) { + if err := os.MkdirAll(filepath.Join(root, "artifacts", "attestations"), 0o755); err != nil { + t.Fatal(err) + } + }, + }, + { + name: "symlinked retained attestation namespace", + criterionID: "proofkit.release_closeout.manifest_and_sbom", + mutate: func(root string) { + target := t.TempDir() + writeJSON(t, filepath.Join(target, "github-artifact-attestations.json"), map[string]any{"state": "redirected"}) + if err := os.Symlink(target, filepath.Join(root, "artifacts", "attestations")); err != nil { + t.Fatal(err) + } + }, + }, { name: "stale retained evidence checksums", criterionID: "proofkit.release_closeout.manifest_and_sbom", @@ -302,7 +358,8 @@ func TestBuildInputFailsClosedForEachBlockingEvidenceClass(t *testing.T) { "tagName": "v1.2.3", "assets": []any{}, }) - writeFile(t, filepath.Join(root, "artifacts", "release", "retained-evidence-checksums.sha256"), strings.Repeat("a", 64)+" github-release.json\n") + writeJSON(t, filepath.Join(root, "artifacts", "attestations", "github-artifact-attestations.json"), map[string]any{"state": "not_published"}) + writeFile(t, filepath.Join(root, "artifacts", "retained-evidence-checksums.sha256"), strings.Repeat("a", 64)+" release/github-release.json\n") }, }, { @@ -877,7 +934,8 @@ func populateCompleteFixture(t *testing.T, root string) { "version": "1.2.3", "repository": map[string]any{"url": "git+https://github.com/research-engineering/agentic-proofkit.git"}, }) - runFixtureGit(t, root, "add", ".gitignore", "go.mod", "go.sum", "package.json", "proofkit", "source.txt") + writeJSON(t, filepath.Join(root, filepath.FromSlash(releasechange.RecordPath)), releaseChangeRecordFixture()) + runFixtureGit(t, root, "add", ".gitignore", "go.mod", "go.sum", "package.json", "proofkit", "release", "source.txt") runFixtureGit(t, root, "commit", "-m", "fixture") writeNPMArtifact(t, root, testNPMTarballName, []byte("package")) writeJSON(t, filepath.Join(root, "artifacts", "pypi", "python-packages.json"), map[string]any{ @@ -888,7 +946,11 @@ func populateCompleteFixture(t *testing.T, root string) { }, }) writeFile(t, filepath.Join(root, "artifacts", "pypi", testPythonWheelName), "wheel") - writeFile(t, filepath.Join(root, "artifacts", "release", "release-notes.md"), "# notes\n\nRollback: pin consumers with `npm install -D @research-engineering/agentic-proofkit@`.\n") + changeRecord, err := releasechange.Read(filepath.Join(root, filepath.FromSlash(releasechange.RecordPath))) + if err != nil { + t.Fatal(err) + } + writeFile(t, filepath.Join(root, "artifacts", "release", "release-notes.md"), releasechange.RenderMarkdown(changeRecord, testNPMPackageName, testPythonPackageName, false)) writeJSON(t, filepath.Join(root, "artifacts", "release", "sbom.cdx.json"), map[string]any{"bomFormat": "CycloneDX", "specVersion": "1.6"}) writeReleaseManifest(t, root, releaseManifestFixture(true)) writeChecksumFile(t, root, "artifacts/release/checksums.sha256", []string{ @@ -904,6 +966,19 @@ func populateCompleteFixture(t *testing.T, root string) { writeLocalSelfEvidence(t, root, execution) } +func releaseChangeRecordFixture() map[string]any { + return map[string]any{ + "additions": []any{map[string]any{"changeId": "proofkit.release.fixture", "summary": "Exercise release change projection."}}, + "breakingChanges": []any{}, + "knownLimitations": []any{"The fixture does not prove registry publication."}, + "migration": map[string]any{"required": false, "steps": []any{}}, + "platformRequirements": []any{"Use a supported fixture platform."}, + "rollback": map[string]any{"strategy": "previous_admitted_version"}, + "schemaVersion": 1, + "version": "1.2.3", + } +} + func writeLocalSelfEvidence(t *testing.T, root string, execution packageartifactrecord.Record) { t.Helper() writeJSON(t, filepath.Join(root, filepath.FromSlash(ciProvenancePath)), map[string]any{ diff --git a/internal/tools/releasemanifest/main.go b/internal/tools/releasemanifest/main.go index 5fd0534..8f2203d 100644 --- a/internal/tools/releasemanifest/main.go +++ b/internal/tools/releasemanifest/main.go @@ -16,6 +16,7 @@ import ( "github.com/research-engineering/agentic-proofkit/internal/kernel/releasechannel" "github.com/research-engineering/agentic-proofkit/internal/kernel/releasepublisher" "github.com/research-engineering/agentic-proofkit/internal/kernel/trustedpublisher" + "github.com/research-engineering/agentic-proofkit/internal/tools/releasechange" ) const ( @@ -191,6 +192,13 @@ func run() error { if err != nil { return err } + changeRecord, err := releasechange.Read(releasechange.RecordPath) + if err != nil { + return err + } + if err := releasechange.RequireVersion(changeRecord, manifest.Version); err != nil { + return err + } localRecords, err := readPackRecords(filepath.Join("artifacts", "package", "npm-pack.json")) if err != nil { return err @@ -307,7 +315,7 @@ func run() error { if err := writeJSON(filepath.Join(releaseDir, "release-manifest.json"), release); err != nil { return err } - if err := os.WriteFile(filepath.Join(releaseDir, "release-notes.md"), []byte(releaseNotes(manifest, pypiRegistry != nil)), 0o644); err != nil { + if err := os.WriteFile(filepath.Join(releaseDir, "release-notes.md"), []byte(releasechange.RenderMarkdown(changeRecord, manifest.Name, pythonPackageName, pypiRegistry != nil)), 0o644); err != nil { return err } metadataChecksums, err := checksumLines([]string{ @@ -996,39 +1004,3 @@ func optionalCommandOutput(name string, args ...string) string { } return strings.TrimSpace(string(output)) } - -func releaseNotes(manifest packageJSON, pypiPublished bool) string { - lines := []string{ - fmt.Sprintf("# %s %s", manifest.Name, manifest.Version), - "", - "Primary install channel:", - "", - "```bash", - fmt.Sprintf("npm install -D %s@%s", manifest.Name, manifest.Version), - "```", - "", - "This release publishes one root CLI package with embedded platform binaries.", - "GitHub Release assets and checksums are archive/provenance evidence.", - "", - "Rollback:", - "", - fmt.Sprintf("- Pin consumers to the previous admitted version with `npm install -D %s@`.", manifest.Name), - "- Treat local package artifacts as candidates until registry identity is proven.", - } - if pypiPublished { - lines = append(lines, - "", - "Python/uv install channel:", - "", - "```bash", - fmt.Sprintf("uv tool install %s==%s", pythonPackageName, manifest.Version), - "```", - "", - "PyPI registry identity is recorded in the release manifest.", - ) - } else { - lines = append(lines, "Python wheels are candidate artifacts until a PyPI release workflow publishes them.") - } - lines = append(lines, "") - return strings.Join(lines, "\n") -} diff --git a/internal/tools/releasemanifest/main_test.go b/internal/tools/releasemanifest/main_test.go index 83b8414..89bc832 100644 --- a/internal/tools/releasemanifest/main_test.go +++ b/internal/tools/releasemanifest/main_test.go @@ -230,19 +230,6 @@ func TestTrustedPublisherSetFromEnvRequiresIdentityOnlyForWorkflowPublication(t } } -func TestReleaseNotesIncludeRollbackInstruction(t *testing.T) { - content := releaseNotes(packageJSON{Name: "agentic-proofkit", Version: "1.2.3"}, false) - for _, want := range []string{ - "Rollback:", - "npm install -D agentic-proofkit@", - "Treat local package artifacts as candidates until registry identity is proven.", - } { - if !strings.Contains(content, want) { - t.Fatalf("releaseNotes() missing %q:\n%s", want, content) - } - } -} - func TestRequirePublicationModeFailsClosedWhenRegistryEvidenceExists(t *testing.T) { cases := []struct { name string diff --git a/internal/tools/releasepreflight/main.go b/internal/tools/releasepreflight/main.go index eec081b..3655d2f 100644 --- a/internal/tools/releasepreflight/main.go +++ b/internal/tools/releasepreflight/main.go @@ -15,6 +15,7 @@ import ( "strings" "github.com/research-engineering/agentic-proofkit/internal/kernel/admission" + "github.com/research-engineering/agentic-proofkit/internal/tools/retainedevidence" ) const maxReleaseJSONBytes = 8 << 20 @@ -105,7 +106,7 @@ func main() { func run(args []string) error { if len(args) == 0 { - return fmt.Errorf("usage: releasepreflight ") + return fmt.Errorf("usage: releasepreflight ") } switch args[0] { case "npm-existing": @@ -198,6 +199,12 @@ func run(args []string) error { return err } return validateGitHubSignedTag(ref, tag, options["tag"], options["commit"]) + case "retained-evidence": + options, err := parseFlags(args[1:], "artifact-root") + if err != nil { + return err + } + return retainedevidence.Write(options["artifact-root"]) default: return fmt.Errorf("unknown releasepreflight command %s", args[0]) } diff --git a/internal/tools/releasepreflight/main_test.go b/internal/tools/releasepreflight/main_test.go index c019810..2e85c57 100644 --- a/internal/tools/releasepreflight/main_test.go +++ b/internal/tools/releasepreflight/main_test.go @@ -10,6 +10,24 @@ import ( "testing" ) +func TestRetainedEvidenceCommandWritesArtifactRootManifest(t *testing.T) { + root := t.TempDir() + writeFile(t, filepath.Join(root, "release", "github-release.json"), "release\n") + writeFile(t, filepath.Join(root, "attestations", "github-artifact-attestations.json"), "attestation\n") + if err := run([]string{"retained-evidence", "--artifact-root", root}); err != nil { + t.Fatal(err) + } + content, err := os.ReadFile(filepath.Join(root, "retained-evidence-checksums.sha256")) + if err != nil { + t.Fatal(err) + } + for _, path := range []string{"attestations/github-artifact-attestations.json", "release/github-release.json"} { + if !strings.Contains(string(content), " "+path+"\n") { + t.Fatalf("retained evidence manifest missing %s:\n%s", path, content) + } + } +} + func TestCompareNPMExisting(t *testing.T) { expected := npmCandidate{Name: "agentic-proofkit", Version: "1.2.3", Shasum: "sha", Integrity: "integrity"} actual := npmView{Name: "agentic-proofkit", Version: "1.2.3"} @@ -379,3 +397,13 @@ func TestParseFlagsRejectsUnsupportedAndDuplicateFlags(t *testing.T) { func boolPtr(value bool) *bool { return &value } + +func writeFile(t *testing.T, path, content string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatal(err) + } +} diff --git a/internal/tools/retainedevidence/manifest.go b/internal/tools/retainedevidence/manifest.go new file mode 100644 index 0000000..fc65b6c --- /dev/null +++ b/internal/tools/retainedevidence/manifest.go @@ -0,0 +1,143 @@ +package retainedevidence + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "os" + "path/filepath" + "sort" + "strings" +) + +const ManifestPath = "retained-evidence-checksums.sha256" + +var evidencePaths = []string{ + "attestations/github-artifact-attestations.json", + "release/github-release.json", +} + +func EvidencePaths() []string { + return append([]string(nil), evidencePaths...) +} + +func Build(artifactRoot string) ([]byte, error) { + root, err := os.OpenRoot(artifactRoot) + if err != nil { + return nil, fmt.Errorf("open retained evidence root: %w", err) + } + defer root.Close() + + if err := requireExactAttestationSet(root); err != nil { + return nil, err + } + paths := EvidencePaths() + sort.Strings(paths) + lines := make([]string, 0, len(paths)) + for _, path := range paths { + content, err := readRegularFile(root, path) + if err != nil { + return nil, err + } + sum := sha256.Sum256(content) + lines = append(lines, hex.EncodeToString(sum[:])+" "+path) + } + return []byte(strings.Join(lines, "\n") + "\n"), nil +} + +func Write(artifactRoot string) error { + content, err := Build(artifactRoot) + if err != nil { + return err + } + root, err := os.OpenRoot(artifactRoot) + if err != nil { + return fmt.Errorf("open retained evidence root: %w", err) + } + defer root.Close() + if info, err := root.Lstat(ManifestPath); err == nil && info.Mode()&os.ModeSymlink != 0 { + return fmt.Errorf("retained evidence manifest must not be a symlink") + } else if err != nil && !os.IsNotExist(err) { + return fmt.Errorf("inspect retained evidence manifest: %w", err) + } + if err := root.WriteFile(ManifestPath, content, 0o644); err != nil { + return fmt.Errorf("write retained evidence manifest: %w", err) + } + return nil +} + +func Verify(artifactRoot string) error { + expected, err := Build(artifactRoot) + if err != nil { + return err + } + root, err := os.OpenRoot(artifactRoot) + if err != nil { + return fmt.Errorf("open retained evidence root: %w", err) + } + defer root.Close() + actual, err := readRegularFile(root, ManifestPath) + if err != nil { + return err + } + if string(actual) != string(expected) { + return fmt.Errorf("retained evidence manifest does not match artifact topology") + } + return nil +} + +func requireExactAttestationSet(root *os.Root) error { + directory, err := root.Open("attestations") + if err != nil { + return fmt.Errorf("open retained attestation directory: %w", err) + } + defer directory.Close() + entries, err := directory.ReadDir(-1) + if err != nil { + return fmt.Errorf("read retained attestation directory: %w", err) + } + expectedName := filepath.Base(evidencePaths[0]) + actual := make([]string, 0, len(entries)) + for _, entry := range entries { + actual = append(actual, entry.Name()) + if entry.Name() != expectedName || !entry.Type().IsRegular() { + sort.Strings(actual) + return fmt.Errorf("retained attestation file set must contain only regular file %s, got %v", expectedName, actual) + } + } + sort.Strings(actual) + expected := []string{expectedName} + if len(actual) != len(expected) || actual[0] != expected[0] { + return fmt.Errorf("retained attestation file set must be exactly %v, got %v", expected, actual) + } + return nil +} + +func readRegularFile(root *os.Root, path string) ([]byte, error) { + current := "" + parts := strings.Split(filepath.ToSlash(path), "/") + for index, part := range parts { + if part == "" || part == "." || part == ".." { + return nil, fmt.Errorf("retained evidence path is not canonical: %s", path) + } + current = filepath.Join(current, part) + info, err := root.Lstat(current) + if err != nil { + return nil, fmt.Errorf("inspect retained evidence path %s: %w", path, err) + } + if info.Mode()&os.ModeSymlink != 0 { + return nil, fmt.Errorf("retained evidence path must not contain symlinks: %s", path) + } + if index < len(parts)-1 && !info.IsDir() { + return nil, fmt.Errorf("retained evidence parent must be a directory: %s", path) + } + if index == len(parts)-1 && !info.Mode().IsRegular() { + return nil, fmt.Errorf("retained evidence must be a regular file: %s", path) + } + } + content, err := root.ReadFile(filepath.FromSlash(path)) + if err != nil { + return nil, fmt.Errorf("read retained evidence %s: %w", path, err) + } + return content, nil +} diff --git a/internal/tools/retainedevidence/manifest_test.go b/internal/tools/retainedevidence/manifest_test.go new file mode 100644 index 0000000..65641e0 --- /dev/null +++ b/internal/tools/retainedevidence/manifest_test.go @@ -0,0 +1,117 @@ +package retainedevidence + +import ( + "crypto/sha256" + "encoding/hex" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestManifestUsesDownloadableArtifactPaths(t *testing.T) { + root := retainedEvidenceFixture(t) + if err := Write(root); err != nil { + t.Fatal(err) + } + if err := Verify(root); err != nil { + t.Fatal(err) + } + content, err := os.ReadFile(filepath.Join(root, ManifestPath)) + if err != nil { + t.Fatal(err) + } + for path, value := range map[string]string{ + "attestations/github-artifact-attestations.json": "attestation\n", + "release/github-release.json": "release\n", + } { + sum := sha256.Sum256([]byte(value)) + line := hex.EncodeToString(sum[:]) + " " + path + if !strings.Contains(string(content), line) { + t.Fatalf("manifest missing executable path line %q:\n%s", line, content) + } + } + if strings.Contains(string(content), " github-release.json") { + t.Fatalf("manifest collapsed artifact topology to basenames:\n%s", content) + } +} + +func TestManifestRejectsUnboundAttestationAndSymlink(t *testing.T) { + t.Run("extra attestation", func(t *testing.T) { + root := retainedEvidenceFixture(t) + writeRetainedFixture(t, root, "attestations/extra.json", "extra\n") + if _, err := Build(root); err == nil || !strings.Contains(err.Error(), "file set") { + t.Fatalf("Build() error = %v, want exact-set rejection", err) + } + }) + t.Run("extra non-JSON file", func(t *testing.T) { + root := retainedEvidenceFixture(t) + writeRetainedFixture(t, root, "attestations/unbound.txt", "extra\n") + if _, err := Build(root); err == nil || !strings.Contains(err.Error(), "file set") { + t.Fatalf("Build() error = %v, want exact-set rejection", err) + } + }) + t.Run("extra symlink", func(t *testing.T) { + root := retainedEvidenceFixture(t) + if err := os.Symlink( + filepath.Join(root, "release", "github-release.json"), + filepath.Join(root, "attestations", "unbound.json"), + ); err != nil { + t.Fatal(err) + } + if _, err := Build(root); err == nil || !strings.Contains(err.Error(), "file set") { + t.Fatalf("Build() error = %v, want symlink-set rejection", err) + } + }) + t.Run("symlink", func(t *testing.T) { + root := retainedEvidenceFixture(t) + target := filepath.Join(root, "release", "target.json") + writeRetainedFixture(t, root, "release/target.json", "release\n") + if err := os.Remove(filepath.Join(root, "release", "github-release.json")); err != nil { + t.Fatal(err) + } + if err := os.Symlink(target, filepath.Join(root, "release", "github-release.json")); err != nil { + t.Fatal(err) + } + if _, err := Build(root); err == nil || !strings.Contains(err.Error(), "symlinks") { + t.Fatalf("Build() error = %v, want symlink rejection", err) + } + }) +} + +func TestVerifyRejectsManifestAddressDrift(t *testing.T) { + root := retainedEvidenceFixture(t) + if err := Write(root); err != nil { + t.Fatal(err) + } + content, err := os.ReadFile(filepath.Join(root, ManifestPath)) + if err != nil { + t.Fatal(err) + } + drifted := strings.ReplaceAll(string(content), "attestations/github-artifact-attestations.json", "github-artifact-attestations.json") + if err := os.WriteFile(filepath.Join(root, ManifestPath), []byte(drifted), 0o644); err != nil { + t.Fatal(err) + } + if err := Verify(root); err == nil || !strings.Contains(err.Error(), "artifact topology") { + t.Fatalf("Verify() error = %v, want topology rejection", err) + } +} + +func retainedEvidenceFixture(t *testing.T) string { + t.Helper() + root := t.TempDir() + writeRetainedFixture(t, root, "attestations/github-artifact-attestations.json", "attestation\n") + writeRetainedFixture(t, root, "release/github-release.json", "release\n") + return root +} + +func writeRetainedFixture(t *testing.T, root, path, content string) { + t.Helper() + 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(content), 0o644); err != nil { + t.Fatal(err) + } +} diff --git a/package-lock.json b/package-lock.json index 3d639d0..86f965f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@research-engineering/agentic-proofkit", - "version": "0.1.159", + "version": "0.1.160", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@research-engineering/agentic-proofkit", - "version": "0.1.159", + "version": "0.1.160", "cpu": [ "arm64", "x64" diff --git a/package.json b/package.json index cbebd42..12f2b77 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.159", + "version": "0.1.160", "type": "module", "license": "MIT", "sideEffects": false, diff --git a/proofkit/requirement-bindings.json b/proofkit/requirement-bindings.json index 6daa3d8..b798b95 100644 --- a/proofkit/requirement-bindings.json +++ b/proofkit/requirement-bindings.json @@ -588,6 +588,22 @@ "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-QUALITY-023", + "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 execution on every accepted operating-system version, legal approval, registry publication, release approval, rollout approval, or production readiness."] + }, + { + "requirementId": "REQ-PROOFKIT-QUALITY-024", + "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 infer change-record completeness from source changes, make release notes approval authority, or prove provider publication, attestation authenticity, consumer adoption, rollout approval, or production readiness."] + }, { "requirementId": "REQ-PROOFKIT-SPEC-017", "ownerId": "proofkit.spec-proof-core", @@ -2257,6 +2273,51 @@ "commandIds": ["proofkit.browser-check"], "environmentClasses": ["local-node-browser"] }, + { + "requirementId": "REQ-PROOFKIT-QUALITY-023", + "scenarioId": "proofkit.supply-chain-quality.python-wheel-platform-byte-compatibility", + "witnessId": "proofkit.python-package.macho-platform-falsifier", + "witnessKind": "contract", + "witnessPath": "internal/tools/pythonpackage/metadata_test.go", + "commandIds": ["proofkit.go-test"], + "environmentClasses": ["local-go"] + }, + { + "requirementId": "REQ-PROOFKIT-QUALITY-023", + "scenarioId": "proofkit.supply-chain-quality.python-wheel-license-closure", + "witnessId": "proofkit.python-package.license-payload-falsifier", + "witnessKind": "contract", + "witnessPath": "internal/tools/pythonpackage/metadata_test.go", + "commandIds": ["proofkit.go-test"], + "environmentClasses": ["local-go"] + }, + { + "requirementId": "REQ-PROOFKIT-QUALITY-024", + "scenarioId": "proofkit.supply-chain-quality.release-change-record-projection", + "witnessId": "proofkit.release-change.versioned-projection-falsifier", + "witnessKind": "contract", + "witnessPath": "internal/tools/releasechange/record_test.go", + "commandIds": ["proofkit.go-test"], + "environmentClasses": ["local-go"] + }, + { + "requirementId": "REQ-PROOFKIT-QUALITY-024", + "scenarioId": "proofkit.supply-chain-quality.retained-evidence-artifact-topology", + "witnessId": "proofkit.retained-evidence.download-layout-falsifier", + "witnessKind": "contract", + "witnessPath": "internal/tools/retainedevidence/manifest_test.go", + "commandIds": ["proofkit.go-test"], + "environmentClasses": ["local-go"] + }, + { + "requirementId": "REQ-PROOFKIT-QUALITY-024", + "scenarioId": "proofkit.supply-chain-quality.release-closeout-change-record", + "witnessId": "proofkit.release-closeout.change-record-falsifier", + "witnessKind": "contract", + "witnessPath": "internal/tools/releasecloseoutinput/main_test.go", + "commandIds": ["proofkit.go-test"], + "environmentClasses": ["local-go"] + }, { "requirementId": "REQ-PROOFKIT-SPEC-019", "scenarioId": "proofkit.spec-proof-core.requirement-context-composition", @@ -2266,6 +2327,15 @@ "commandIds": ["proofkit.go-test"], "environmentClasses": ["local-go"] }, + { + "requirementId": "REQ-PROOFKIT-SPEC-019", + "scenarioId": "proofkit.spec-proof-core.requirement-context-composition-whole-cli", + "witnessId": "proofkit.app.requirement-context-compose-cli-falsifier", + "witnessKind": "contract", + "witnessPath": "internal/app/requirement_context_cli_test.go", + "commandIds": ["proofkit.go-test"], + "environmentClasses": ["local-go"] + }, { "requirementId": "REQ-PROOFKIT-SPEC-020", "scenarioId": "proofkit.spec-proof-core.requirement-context-slicing", @@ -2275,6 +2345,15 @@ "commandIds": ["proofkit.go-test"], "environmentClasses": ["local-go"] }, + { + "requirementId": "REQ-PROOFKIT-SPEC-020", + "scenarioId": "proofkit.spec-proof-core.requirement-context-slicing-whole-cli", + "witnessId": "proofkit.app.requirement-context-slice-cli-falsifier", + "witnessKind": "contract", + "witnessPath": "internal/app/requirement_context_cli_test.go", + "commandIds": ["proofkit.go-test"], + "environmentClasses": ["local-go"] + }, { "requirementId": "REQ-PROOFKIT-SPEC-021", "scenarioId": "proofkit.spec-proof-core.requirement-browser-workspace-handoff", @@ -2302,6 +2381,15 @@ "commandIds": ["proofkit.go-test"], "environmentClasses": ["local-go"] }, + { + "requirementId": "REQ-PROOFKIT-SPEC-021", + "scenarioId": "proofkit.spec-proof-core.requirement-browser-one-shot-cleanup", + "witnessId": "proofkit.requirement-browser.one-shot-cleanup-falsifier", + "witnessKind": "contract", + "witnessPath": "internal/command/requirementbrowser/server_test.go", + "commandIds": ["proofkit.go-test"], + "environmentClasses": ["local-go"] + }, { "requirementId": "REQ-PROOFKIT-SPEC-022", "scenarioId": "proofkit.spec-proof-core.requirement-semantic-diff", @@ -2311,6 +2399,24 @@ "commandIds": ["proofkit.go-test"], "environmentClasses": ["local-go"] }, + { + "requirementId": "REQ-PROOFKIT-SPEC-022", + "scenarioId": "proofkit.spec-proof-core.requirement-semantic-diff-complete-algebra", + "witnessId": "proofkit.requirement-semantic-diff.complete-algebra-falsifier", + "witnessKind": "contract", + "witnessPath": "internal/command/requirementdiff/requirementdiff_test.go", + "commandIds": ["proofkit.go-test"], + "environmentClasses": ["local-go"] + }, + { + "requirementId": "REQ-PROOFKIT-SPEC-022", + "scenarioId": "proofkit.spec-proof-core.requirement-semantic-diff-whole-cli", + "witnessId": "proofkit.app.requirement-semantic-diff-cli-falsifier", + "witnessKind": "contract", + "witnessPath": "internal/app/requirement_context_cli_test.go", + "commandIds": ["proofkit.go-test"], + "environmentClasses": ["local-go"] + }, { "requirementId": "REQ-PROOFKIT-SPEC-023", "scenarioId": "proofkit.spec-proof-core.requirement-traceability-evidence-planes", @@ -2320,6 +2426,24 @@ "commandIds": ["proofkit.go-test"], "environmentClasses": ["local-go"] }, + { + "requirementId": "REQ-PROOFKIT-SPEC-023", + "scenarioId": "proofkit.spec-proof-core.requirement-traceability-topology-closure", + "witnessId": "proofkit.requirement-traceability.topology-closure-falsifier", + "witnessKind": "contract", + "witnessPath": "internal/command/requirementgraph/requirementgraph_test.go", + "commandIds": ["proofkit.go-test"], + "environmentClasses": ["local-go"] + }, + { + "requirementId": "REQ-PROOFKIT-SPEC-023", + "scenarioId": "proofkit.spec-proof-core.requirement-traceability-whole-cli", + "witnessId": "proofkit.app.requirement-traceability-cli-falsifier", + "witnessKind": "contract", + "witnessPath": "internal/app/requirement_context_cli_test.go", + "commandIds": ["proofkit.go-test"], + "environmentClasses": ["local-go"] + }, { "requirementId": "REQ-PROOFKIT-SPEC-021", "scenarioId": "proofkit.spec-proof-core.requirement-browser-rendered-runtime", @@ -2329,6 +2453,15 @@ "commandIds": ["proofkit.browser-check"], "environmentClasses": ["local-node-browser"] }, + { + "requirementId": "REQ-PROOFKIT-SPEC-021", + "scenarioId": "proofkit.spec-proof-core.requirement-browser-unicode-code-point-selection", + "witnessId": "proofkit.requirement-browser.unicode-selection-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", diff --git a/release/change-record.v1.json b/release/change-record.v1.json new file mode 100644 index 0000000..4385dd8 --- /dev/null +++ b/release/change-record.v1.json @@ -0,0 +1,49 @@ +{ + "schemaVersion": 1, + "version": "0.1.160", + "breakingChanges": [ + { + "changeId": "proofkit.python-wheel.macos-minimum", + "summary": "Darwin distributions now declare macOS 12.0 as their minimum supported operating-system version, matching the embedded executable." + }, + { + "changeId": "proofkit.requirement-graph.topology-id-removal", + "summary": "Requirement traceability graph inputs advance to schemaVersion 2 and reject the semantically inert codeTopology.topologyId field." + } + ], + "additions": [ + { + "changeId": "proofkit.python-wheel.license-closure", + "summary": "Python wheels carry Core Metadata 2.4 license fields and an exact copy of the repository MIT license." + }, + { + "changeId": "proofkit.release.change-record", + "summary": "Release notes are generated from this version-bound machine-readable public-contract change record." + }, + { + "changeId": "proofkit.release.retained-evidence-topology", + "summary": "Retained evidence checksums use executable artifact-relative paths owned by one builder and verifier." + }, + { + "changeId": "proofkit.requirement-graph.topology-closure", + "summary": "Traceability graph build and output admission enforce source-digest coherence, bounded materialization, and parent-to-edge topology closure." + } + ], + "migration": { + "required": true, + "steps": [ + "Set requirement-traceability-graph input schemaVersion to 2 and remove codeTopology.topologyId.", + "Run Darwin package binaries only on macOS 12.0 or later." + ] + }, + "platformRequirements": [ + "Published Darwin package binaries require macOS 12.0 or later on arm64 and x86_64." + ], + "knownLimitations": [ + "TSX source parsing remains unsupported.", + "The immutable 0.1.159 release retains its inaccurate macOS wheel tags and incomplete wheel license payload." + ], + "rollback": { + "strategy": "previous_admitted_version" + } +} diff --git a/scripts/browser-selection-authority.test.mjs b/scripts/browser-selection-authority.test.mjs index 58e195f..c874249 100644 --- a/scripts/browser-selection-authority.test.mjs +++ b/scripts/browser-selection-authority.test.mjs @@ -20,6 +20,14 @@ test("collapsed native selection does not revoke explicit button authority", () assert.deepEqual(transitionSelection(selected, {kind: "collapse"}), selected); }); +test("committed text remains visible authority until explicitly cleared", () => { + const selected = transitionSelection(emptySelectionState(), {kind: "text", targets: [target]}); + const committed = transitionSelection(selected, {kind: "commit"}); + assert.equal(committed.mode, "committed_text"); + assert.deepEqual(transitionSelection(committed, {kind: "collapse"}), committed); + assertEmptySelection(transitionSelection(committed, {kind: "clear"})); +}); + 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: []})); diff --git a/scripts/validate-self-hosting-receipts_test.go b/scripts/validate-self-hosting-receipts_test.go index 65d3581..30a5bf3 100644 --- a/scripts/validate-self-hosting-receipts_test.go +++ b/scripts/validate-self-hosting-receipts_test.go @@ -472,14 +472,15 @@ func TestReleaseWorkflowRetainsReleaseAssetAndPostCreateEvidenceClosure(t *testi for _, item := range []string{ "artifacts/release/release-notes.md", "artifacts/release/github-release.json", - "artifacts/release/retained-evidence-checksums.sha256", - "artifacts/attestations/github-artifact-attestations.json", - "printf '%s %s\\n' \"$sum\" \"$(basename \"$evidence\")\"", + "go run ./internal/tools/releasepreflight retained-evidence --artifact-root artifacts", } { if !strings.Contains(createRun, item) { t.Fatalf("Create GitHub Release step missing retained evidence token %q", item) } } + if strings.Contains(createRun, "$(basename \"$evidence\")") || strings.Contains(createRun, "sha256sum \"$evidence\"") { + t.Fatal("Create GitHub Release step must delegate retained evidence topology to its repository owner") + } uploadIndex, err := uniqueStepIndex(assetJob.Steps, "Upload release evidence") if err != nil { t.Fatalf("find release evidence upload step: %v", err) @@ -491,7 +492,7 @@ func TestReleaseWorkflowRetainsReleaseAssetAndPostCreateEvidenceClosure(t *testi for _, item := range []string{ "artifacts/attestations/*.json", "artifacts/release/github-release.json", - "artifacts/release/retained-evidence-checksums.sha256", + "artifacts/retained-evidence-checksums.sha256", "artifacts/release/release-notes.md", } { if !strings.Contains(uploadPath, item) { diff --git a/tests/browser/workspace.spec.mjs b/tests/browser/workspace.spec.mjs index b08aa73..99a2404 100644 --- a/tests/browser/workspace.spec.mjs +++ b/tests/browser/workspace.spec.mjs @@ -110,6 +110,40 @@ test("collapsed text selection cannot retain hidden handoff authority", async ({ await expect(page.getByLabel("Handoff packet")).toBeEmpty(); }); +test("text selection projects Unicode code-point coordinates", async ({page}) => { + await page.goto("/"); + const invariant = page.locator("[data-anchor-id]").filter({hasText: "retry \u{1F680}"}).first(); + await expect(invariant).toBeVisible(); + const offsets = await invariant.evaluate((element) => { + const text = element.firstChild?.textContent ?? ""; + const domStart = text.indexOf("\u{1F680}"); + if (domStart < 0) throw new Error("Unicode fixture is unavailable"); + return { + codePointStart: Array.from(text.slice(0, domStart)).length, + domEnd: domStart + "\u{1F680}".length, + domStart, + }; + }); + await setSelection(page, invariant, offsets.domStart, offsets.domEnd); + await expect(page.getByRole("status")).toContainText("1 source-bound target"); + await expect(page.getByLabel("Selected source text").getByRole("listitem")).toHaveText("\u{1F680}"); + await page.getByRole("textbox", {name: "Question"}).fill("Does the Unicode coordinate remain source-bound?"); + await expect(page.getByLabel("Selected source text").getByRole("listitem")).toHaveText("\u{1F680}"); + await page.getByRole("button", {name: "Create handoff packet"}).click(); + await expect(page.getByRole("status")).toContainText("Handoff packet created"); + + const packet = JSON.parse(await page.getByLabel("Handoff packet").textContent()); + const annotation = packet.annotations[0]; + expect(annotation.exactQuote).toBe("\u{1F680}"); + expect(annotation.startCodePoint).toBe(offsets.codePointStart); + expect(annotation.endCodePoint).toBe(offsets.codePointStart + 1); + await page.getByRole("button", {name: "Clear selection"}).click(); + await expect(page.getByLabel("Selected source text").getByRole("listitem")).toHaveCount(0); + await expect(page.getByRole("status")).toContainText("No source-bound text selected"); + await page.getByRole("button", {name: "Create handoff packet"}).click(); + await expect(page.getByRole("status")).toContainText("Select invariant text"); +}); + /** @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");