From 3389c2077e75924fa5695b2cecf0e6181b40657e Mon Sep 17 00:00:00 2001 From: iperev Date: Mon, 7 Sep 2026 07:24:59 +0200 Subject: [PATCH] feat: open the project workspace with view --- ADOPTION.md | 24 + docs/proofkit-contract-map.md | 1 + .../proofkit-spec-proof-core/overview.md | 4 + .../requirements.v1.json | 27 + internal/app/app.go | 6 + internal/app/cli_contract_test.go | 3 +- internal/app/command_contract_generated.go | 37 +- internal/app/command_coverage_routes.go | 1 + internal/app/command_coverage_test.go | 3 + internal/app/command_descriptors.go | 3 + .../app/command_family_catalog_generated.go | 4 +- internal/app/command_flag_constraints.go | 14 +- internal/app/integration_version_edge_test.go | 5 +- internal/app/json_layout.go | 4 +- internal/app/project_view_command.go | 98 ++++ internal/app/project_view_command_test.go | 357 +++++++++++++ .../app/project_view_version_edge_test.go | 109 ++++ internal/app/requirement_browser_command.go | 29 +- internal/app/requirement_context_cli_test.go | 54 +- .../compact-current-production-consumers.json | 4 + .../project_closure.go | 4 + .../project_closure_test.go | 6 +- .../project_projection.go | 81 +++ .../project_projection_test.go | 236 +++++++++ internal/command/projectstatus/inspect.go | 77 +-- .../command/projectstatus/inspect_test.go | 68 ++- internal/command/projectstatus/model.go | 9 + .../projectstatus/project_inspection_test.go | 213 ++++++++ .../requirementbrowser/http_handler.go | 9 +- .../command/requirementbrowser/project.go | 80 +++ .../requirementbrowser/project_test.go | 341 +++++++++++++ .../requirementbrowser/requirementbrowser.go | 26 +- internal/command/requirementbrowser/server.go | 34 +- .../command/requirementbrowser/server_test.go | 90 ++++ .../command/requirementbrowser/workspace.go | 25 +- .../context_wire_compatibility_test.go | 90 ++++ internal/command/requirementcontext/model.go | 43 +- .../requirementcontext/project_origin.go | 197 ++++++++ .../requirementcontext/project_origin_test.go | 299 +++++++++++ internal/command/requirementcontext/slice.go | 12 +- .../testdata/context-wire-v2/catalog.json | 6 + .../docs/specs/wire/requirements.v1.json | 20 + .../testdata/context-wire-v2/expected.json | 93 ++++ .../context-wire-v2/proofkit/tree.json | 16 + .../requirementgraph/output_admission.go | 13 +- .../reference_projection_test.go | 96 ++++ .../stackpreset/preset_ids_generated.go | 2 +- .../testsupport/projectfixture/fixture.go | 148 ++++++ .../projectfixture/fixture_test.go | 27 + .../projectfixture/testdata/source.json | 20 + internal/tools/releasechange/record_test.go | 15 +- .../workflowsmoke/project_navigation_smoke.go | 44 +- .../workflowsmoke/workflow_smoke_test.go | 3 + package-lock.json | 4 +- package.json | 2 +- proofkit/cli-contract.v2.json | 278 +++++++++-- proofkit/command-families.v1.json | 5 +- proofkit/requirement-bindings.json | 470 ++++++++++++++++++ proofkit/witness-plan.json | 10 + release/change-record.v2.json | 25 +- 60 files changed, 3820 insertions(+), 204 deletions(-) create mode 100644 internal/app/project_view_command.go create mode 100644 internal/app/project_view_command_test.go create mode 100644 internal/app/project_view_version_edge_test.go create mode 100644 internal/command/adoptionmaterialization/project_projection.go create mode 100644 internal/command/adoptionmaterialization/project_projection_test.go create mode 100644 internal/command/projectstatus/project_inspection_test.go create mode 100644 internal/command/requirementbrowser/project.go create mode 100644 internal/command/requirementbrowser/project_test.go create mode 100644 internal/command/requirementcontext/context_wire_compatibility_test.go create mode 100644 internal/command/requirementcontext/project_origin.go create mode 100644 internal/command/requirementcontext/project_origin_test.go create mode 100644 internal/command/requirementcontext/testdata/context-wire-v2/catalog.json create mode 100644 internal/command/requirementcontext/testdata/context-wire-v2/docs/specs/wire/requirements.v1.json create mode 100644 internal/command/requirementcontext/testdata/context-wire-v2/expected.json create mode 100644 internal/command/requirementcontext/testdata/context-wire-v2/proofkit/tree.json create mode 100644 internal/testsupport/projectfixture/fixture.go create mode 100644 internal/testsupport/projectfixture/fixture_test.go create mode 100644 internal/testsupport/projectfixture/testdata/source.json diff --git a/ADOPTION.md b/ADOPTION.md index 3a3f3df..d6a8f67 100644 --- a/ADOPTION.md +++ b/ADOPTION.md @@ -321,6 +321,30 @@ requirement records or be rejected by the consuming repository's policy. ## Rendering And Browser Views +After reviewing and applying the candidate project with `adopt materialize +plan` and `adopt materialize apply`, inspect it without composing browser JSON: + +```sh +agentic-proofkit status --repo-root . +agentic-proofkit view --repo-root . --serve +``` + +Only `--open` opens the local browser. Without `--serve`, `view` returns a +bounded JSON plan and opens no listener. For a single question use `--serve +--open --session-mode one-shot-question`; the terminal packet follows server +cleanup. `--session-timeout-seconds` is optional, bounded to 1..7200 and valid +only in that one-shot mode. Serving does not accept `--json-layout`. + +`view` requires a complete, current, structurally admitted materialized +project. Other states direct the caller to `next` with the same explicit root; +they do not trigger repair or materialization. The single captured project +provides specifications and declared proof relations, not native proof results. +Coverage and semantic diff are unavailable without their own evidence inputs. +The browser remains bound to that capture when live files change. Viewing does +not change `verification_required` into verified. Existing +`requirement-browser-server` routes remain available for explicitly composed +source, proof, coverage, tree or comparison workspaces. + Rendered HTML, Markdown, lookup graphs, and browser views are presentation products. They should be generated on demand from explicit caller-owned inputs unless a consumer explicitly admits a small tracked artifact with a freshness diff --git a/docs/proofkit-contract-map.md b/docs/proofkit-contract-map.md index 259bddf..421f999 100644 --- a/docs/proofkit-contract-map.md +++ b/docs/proofkit-contract-map.md @@ -43,6 +43,7 @@ owner boundaries. It is not a second command-family inventory. |---|---|---|---|---|---| | Agent integrations | `integration source`, `integration check`, `integration plan`, `integration apply`, `integration recover` | explicit tool for source/check/plan/apply; root for filesystem operations; operation and both reviewed identities for apply; transaction/action for recovery | one bounded renderer, read-only freshness, canonical cooperative baseline, and managed file lifecycle through the native transaction owner | launcher admission, instruction ownership, host discovery/activation, permissions and native verification | generated source, freshness classification, reviewed transaction plan or historical transaction receipt; none proves host activation | | Project state navigation | `status`, `next` | explicit repository root | bounded transaction-first materialized-project inspection, normalized observation identity, deterministic project-state classification, and one non-executable next action; admitted in-bound records bind exact content digests, while unread out-of-bound records intentionally identify only their invalid class | repository policy, byte identity for unread out-of-bound records, witness execution, receipt trust/currentness/scope, merge, release, deployment, rollout, and production readiness | project-status report, next-action packet, or bounded text projection | +| Project browser entry | `view --repo-root ` | one complete materialized project at an explicit root; optional bounded browser session flags | one closure-admitted capture, context schema 3 with composite source identities, existing workspace and declared-relation graph, source-bound Unicode handoff and owned server cleanup | source editing, native witness execution, proof coverage, baseline comparison, freshness after capture, owner approval, merge or release authority | bounded JSON plan, loopback browse session or compact one-shot terminal packet | | Agent workflow planning | `change plan`, `native-evidence-guidance` | explicit checkpoint, completed stage ids, bounded context refs, governing authority ref, and required context ref ids | optional built-in `proofkit.reviewed-change.v1` checkpoint relation, reference-closed next-stage context, deterministic agent prompts, bounded text/JSON/envelope projections, and repository-neutral native-evidence guidance with closed applicability classes | custom workflow topology, repository state discovery, stage execution, native witness semantics, evidence collection, review conclusions, merge, release, deployment, and rollout authority | next-action plan, terminal workflow report, bounded agent envelope, or guidance catalog | | Adoption and scaffolding | `adopt plan`, `adopt materialize plan`, `adopt materialize apply`, `adopt materialize recover`, `repository-inventory`, `adoption-contract-envelope`, `adoption-workflow-plan`, `adoption-checklist`, `adoption-doctor`, `gradual-adoption`, `gradual-adoption-bootstrap`, `gradual-adoption-guidance`, `capability-map-admission`, `pilot-admission`, `scaffold-profile-plan`, `scaffold-project-structure`, `stack-preset` | explicit repository root, explicit fresh/code-baseline/audit-from-code intent, optional stack hint, owner-reviewed candidate packet, expected transaction and desired-state identities, recovery action, aggregate adoption contract envelope, checklist facts, target paths, owner routes, caller-extracted stale authority vocabulary facts, explicit pre-spec capability observations, and pilot records | bounded fixed-catalog root inventory, candidate-only front-door tasks, owner-closed read-only materialization plans, confined transactional apply and recovery receipts, aggregate contract-envelope admission, deterministic starter plans, checklist/report admission, bounded guidance envelopes, dry-run manifests, pre-spec trust-mode admission, adoption gap and stale-authority classification, and pilot shape admission | stack selection, arbitrary source inspection, candidate review, final requirement meaning, proof adequacy, rollout policy, text extraction, code observation extraction, and pilot truth | inventory, candidate-only plan, transaction-bound materialization plan or receipt, selected child output, report, seed packet, or agent envelope | | Requirement source | `capability-map-admission`, `requirement-authoring-plan`, `requirement-source-admission`, `requirement-source-transition`, `spec-overview-claims`, `requirement-spec-tree`, `requirement-spec-tree-view`, `requirement-source-view`, `requirement-browser-server` | `requirements.v1.json`, caller-owned capability maps, caller-owned authoring facts, overview claim extraction, explicit spec hierarchy, view options | candidate seed admission, candidate-only authoring packets, source-shape admission, lifecycle checks, explicit tree topology/source-ref admission, shared safe renderer fragments, presentation-only views | requirement meaning, extraction completeness, Markdown extraction completeness, hierarchy ownership, proof adequacy, file materialization | capability map report, authoring packet, source report, spec-tree report, rendered view, or browser presentation | diff --git a/docs/specs/proofkit-spec-proof-core/overview.md b/docs/specs/proofkit-spec-proof-core/overview.md index f122bb7..ab7956a 100644 --- a/docs/specs/proofkit-spec-proof-core/overview.md +++ b/docs/specs/proofkit-spec-proof-core/overview.md @@ -223,6 +223,10 @@ execution receipts, and merge policy. server bytes, source identity, drafts and independent generation/lock state. - `REQ-PROOFKIT-SPEC-041`: diff summaries count only admitted page changes and retain distinct global counts, source identities and full disclosed values. +- `REQ-PROOFKIT-SPEC-042`: the explicit-root project browser retains one + closure-admitted capture, replays a versioned role-preserving context and + reuses the existing workspace and terminal lifecycle without manufacturing + proof coverage, losing source restrictions or rereading live files. ## 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 8ee03e5..5258569 100644 --- a/docs/specs/proofkit-spec-proof-core/requirements.v1.json +++ b/docs/specs/proofkit-spec-proof-core/requirements.v1.json @@ -789,6 +789,33 @@ "lifecycle": {"state": "active", "replacementRequirementIds": [], "evidenceRefs": []}, "deferral": null, "updatePolicy": {"reviewOwnerId": "proofkit.spec-proof-core", "requiresImpactDeclaration": true, "requiresProofBindingReview": true} + }, + { + "requirementId": "REQ-PROOFKIT-SPEC-042", + "ownerId": "proofkit.spec-proof-core", + "invariant": "The explicit-root view command consumes one completed read-only project inspection and retains an opaque project only after child admission, exact manifest route currentness and cross-record closure. Context schema 3 binds the captured manifest bytes, closed canonical project origin, exact role/source/path partition and derived collection in one snapshot; re-admission replays the project owner and rejects contradictory or re-signed projections. Existing context v1/v2 wire identities and source fragments remain unchanged. The existing workspace renders that captured context and declared-relation graph without rescanning, creating coverage or baseline evidence, writing project records or executing witnesses. Unicode handoff preserves original source digests, resolving pointers, requirement identity and distinct source and requirement non-claims even after live files change. Flags and mode constraints are admitted before project I/O; paths are not reinterpreted as flags. The default output is a bounded JSON plan, serving preserves the existing browse and one-shot process contracts, and every terminal route closes and awaits its owned server without double consumption or disclosing caller input.", + "claimLevel": "blocking", + "riskClass": "high", + "proofBindingRefs": [ + "proofkit/requirement-bindings.json" + ], + "nonClaimRefs": [ + "NC-PROOFKIT-SPEC-042" + ], + "nonClaims": [ + "Viewing a captured project does not establish native witness execution, semantic proof adequacy, live source freshness after capture, owner approval, merge, release, deployment or production readiness. Coverage and semantic diff require their own admitted evidence and are unavailable in the project front door." + ], + "lifecycle": { + "state": "active", + "replacementRequirementIds": [], + "evidenceRefs": [] + }, + "deferral": null, + "updatePolicy": { + "reviewOwnerId": "proofkit.spec-proof-core", + "requiresImpactDeclaration": true, + "requiresProofBindingReview": true + } } ], "nonClaims": [ diff --git a/internal/app/app.go b/internal/app/app.go index c083a52..df92c63 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -29,6 +29,10 @@ func RunWithRenderer(ctx context.Context, args []string, stdin io.Reader, stdout } func RunWithRendererAndCapabilities(ctx context.Context, args []string, stdin io.Reader, stdout io.Writer, stderr io.Writer, renderer cliexec.Renderer, capabilities PresentationCapabilities) int { + return runWithProjectView(ctx, args, stdin, stdout, stderr, renderer, capabilities, runProjectView) +} + +func runWithProjectView(ctx context.Context, args []string, stdin io.Reader, stdout io.Writer, stderr io.Writer, renderer cliexec.Renderer, capabilities PresentationCapabilities, projectView projectViewRunner) int { args, layout, layoutExplicit, err := parseProcessOptions(args) if err != nil { writeDiagnostic(stderr, err) @@ -164,6 +168,8 @@ func RunWithRendererAndCapabilities(ctx context.Context, args []string, stdin io return runPilotAdmission(args[1:], stdin, stdout, stderr) case commandRunnerProjectStatus: return runProjectStatus(ctx, args[0], args[1:], stdout, stderr, capabilities) + case commandRunnerProjectView: + return projectView(ctx, parsedArguments, stdout, stderr) case commandRunnerProjectStructure: return runProjectStructure(args[1:], stdin, stdout, stderr, renderer) case commandRunnerTypeScriptPublicAPISurfaces: diff --git a/internal/app/cli_contract_test.go b/internal/app/cli_contract_test.go index 16c7410..46d4ab8 100644 --- a/internal/app/cli_contract_test.go +++ b/internal/app/cli_contract_test.go @@ -24,7 +24,7 @@ import ( ) const ( - cliContractPublicABISHA256 = "3fea991fd7ef956c6e2252e909aa4a01ffaf453c8cf3ae1c6ba8df4fe9521cb1" + cliContractPublicABISHA256 = "ea558a436e8f4302da57a94695ccb4dbf8132e70a3a836e130a117d7d6a193c2" maxAggregateFileReadBytesForContractTest = 64 << 20 maxPackageManifestBytesForContractTest = 256 << 10 maxSourceFileBytesForContractTest = 8 << 20 @@ -1539,6 +1539,7 @@ func TestDescriptorFlagConstraintsAreRenderedTruthfully(t *testing.T) { "stack-preset": "agentic-proofkit stack-preset --preset ", "status": "agentic-proofkit status [--color ] [--format ] --repo-root ", "typescript-public-api-surfaces": "agentic-proofkit typescript-public-api-surfaces --input [--input-pointer ] --repo-root ", + "view": "agentic-proofkit view [--host <127.0.0.1|::1>] [--open] [--port ] --repo-root [--serve] [--session-mode ] [--session-timeout-seconds <1..7200>]", } constrainedCount := 0 for _, descriptor := range commandDescriptors { diff --git a/internal/app/command_contract_generated.go b/internal/app/command_contract_generated.go index 50ebb97..6115c67 100644 --- a/internal/app/command_contract_generated.go +++ b/internal/app/command_contract_generated.go @@ -1,7 +1,7 @@ // Code generated by internal/tools/commandcontractgen; DO NOT EDIT. package app -const commandContractSourceSHA256 = "e73b37ff7bbef58b81289c13a28b34bbe4fbe42b198e52fc897aa6e1847fb80c" +const commandContractSourceSHA256 = "4504d5e7a4e18ccdda5ef77dcaf8c510280afdc1d49f9cbd170bbf497b8e6ec3" type generatedCommandContractMetadata struct { InputContractSHA256 string @@ -12,15 +12,15 @@ type generatedCommandContractMetadata struct { } var generatedCommandContractMetadataByName = map[string]generatedCommandContractMetadata{ - "adopt-materialize-apply": {InputContractSHA256: "sha256:c973f13aea5b071bbf32fa31332fef73c6f1fda2821add4304f128e7285ccf77", InputSchemaSummary: []string{"schemaVersion=1", "owner-admitted adoption plan, requirement sources, proof bindings, and direct test inventory", "root-shape-only definition proofkit.adoption-materialization.apply-input.v1.root-shape; nested fields, types, cardinalities, and cross-record closure remain native-owner claims"}, OutputContractSHA256: "sha256:676fb85d7cb8669c9b636ba94aa4af90f48c6e5bbfd3070b96ca77604719ca74", FlagChoices: map[string][]string{"--color": []string{"auto", "never"}, "--format": []string{"json", "text"}}, RouteTokens: []string{"adopt", "materialize", "apply"}}, - "adopt-materialize-plan": {InputContractSHA256: "sha256:309f594e0642a52fe38137bb31d1c3af20c04cda648881be11473a5fc56896b6", InputSchemaSummary: []string{"schemaVersion=1", "owner-admitted adoption plan, requirement sources, proof bindings, and direct test inventory", "root-shape-only definition proofkit.adoption-materialization.plan-input.v1.root-shape; nested fields, types, cardinalities, and cross-record closure remain native-owner claims"}, OutputContractSHA256: "sha256:9bfa8f75d87e1c5b5a0c275b412e7dcc81c80d88dc58c2080d9c891cb49a6bfa", FlagChoices: map[string][]string{"--color": []string{"auto", "never"}, "--format": []string{"json", "text"}}, RouteTokens: []string{"adopt", "materialize", "plan"}}, - "adopt-materialize-recover": {InputContractSHA256: "", InputSchemaSummary: []string(nil), OutputContractSHA256: "sha256:d1f75b77ab996921ce2e94380a65319f7f3db6e489bf3de0da8fe6fd0976a0d4", FlagChoices: map[string][]string{"--action": []string{"resume", "rollback"}, "--color": []string{"auto", "never"}, "--format": []string{"json", "text"}}, RouteTokens: []string{"adopt", "materialize", "recover"}}, + "adopt-materialize-apply": {InputContractSHA256: "sha256:725f8033b3360e3bbe1aef952bfc1ef19a6d17aed86610a18f19cd4428c0ec3b", InputSchemaSummary: []string{"schemaVersion=1", "owner-admitted adoption plan, requirement sources, proof bindings, and direct test inventory", "root-shape-only definition proofkit.adoption-materialization.apply-input.v1.root-shape; nested fields, types, cardinalities, and cross-record closure remain native-owner claims"}, OutputContractSHA256: "sha256:f31db7672c65bdbf46ec2dc0436ac9d96f4a5fd95e2e8e010a319a5458c121ab", FlagChoices: map[string][]string{"--color": []string{"auto", "never"}, "--format": []string{"json", "text"}}, RouteTokens: []string{"adopt", "materialize", "apply"}}, + "adopt-materialize-plan": {InputContractSHA256: "sha256:4d0a8ac8913e7e9f3558624b800afd3df9ac6d85d320e1217016ed96f63623af", InputSchemaSummary: []string{"schemaVersion=1", "owner-admitted adoption plan, requirement sources, proof bindings, and direct test inventory", "root-shape-only definition proofkit.adoption-materialization.plan-input.v1.root-shape; nested fields, types, cardinalities, and cross-record closure remain native-owner claims"}, OutputContractSHA256: "sha256:e548faf4df03f9bb4bd68d0a3249836ef504832207058f9f51c2fbd0ace1d4cb", FlagChoices: map[string][]string{"--color": []string{"auto", "never"}, "--format": []string{"json", "text"}}, RouteTokens: []string{"adopt", "materialize", "plan"}}, + "adopt-materialize-recover": {InputContractSHA256: "", InputSchemaSummary: []string(nil), OutputContractSHA256: "sha256:a947bab7a21f911401570cf32b2e7c249172eb9f1a89f4533db5d0743d7cae47", FlagChoices: map[string][]string{"--action": []string{"resume", "rollback"}, "--color": []string{"auto", "never"}, "--format": []string{"json", "text"}}, RouteTokens: []string{"adopt", "materialize", "recover"}}, "adopt-plan": {InputContractSHA256: "", InputSchemaSummary: []string(nil), OutputContractSHA256: "sha256:13e3392d9005c27fed3003a1d036123fe12a341dbe16a97c0cd9f8272fb83320", FlagChoices: map[string][]string{"--color": []string{"auto", "never"}, "--format": []string{"json", "text"}, "--mode": []string{"audit-from-code", "code-baseline", "fresh"}, "--stack": []string{"agentic_runtime_repo", "generated_docs_contract_repo", "python_service", "python_typescript_service", "typescript_monorepo", "typescript_workspace"}}, RouteTokens: []string{"adopt", "plan"}}, "adoption-checklist": {InputContractSHA256: "sha256:4e6c4c9b369279837a5894c0b3f842a411dce529b91c91cb2d4ec63eb5ee4c2c", InputSchemaSummary: []string{"schemaVersion=1", "root-shape-only definition proofkit.adoption-checklist.input.v1.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:9d0d0e60f0935407fd31007d8502459663eb4c7228dc5e3c7727ae2c9907bdc9", FlagChoices: map[string][]string{}, RouteTokens: []string{"adoption-checklist"}}, "adoption-contract-envelope": {InputContractSHA256: "sha256:c310214676ff4b6f536a5bc9d687f681a7e71f73d7a03ac932707d8cd3905cdf", InputSchemaSummary: []string{"schemaVersion=2", "root-shape-only definition proofkit.adoption-contract-envelope.input.v2.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:3efb2c5161fee16fd8ac6a40dcb6d9c41fbc23e468f60621436ae9e8076e0950", FlagChoices: map[string][]string{}, RouteTokens: []string{"adoption-contract-envelope"}}, "adoption-doctor": {InputContractSHA256: "sha256:efa9acfe32bff07f56d9dc9902530df2979794289bc2f7f547f7a108a7dd0f35", InputSchemaSummary: []string{"schemaVersion=1", "root-shape-only definition proofkit.adoption-doctor.input.v1.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:8fdfc6608f197e633f042f20031ae1014872a90aa3daa66885ffcaddca994766", FlagChoices: map[string][]string{}, RouteTokens: []string{"adoption-doctor"}}, "adoption-workflow-plan": {InputContractSHA256: "sha256:b32ae67179d7b6dcf1ea66cb6b2b2691c8367ce2e2be367619b65973166da55c", InputSchemaSummary: []string{"schemaVersion=1", "root-shape-only definition proofkit.adoption-workflow-plan.input.v1.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:8d64cb53ebd0307e3cebc3435286a3d2a1ee8a0ad6f7514fc0fb3285db0f565b", FlagChoices: map[string][]string{}, RouteTokens: []string{"adoption-workflow-plan"}}, - "agent-route": {InputContractSHA256: "sha256:c00e832b4e9eac6b858eec46e810431c0a5c9f56c5c50f055f39ee024f50014c", InputSchemaSummary: []string{"availableInputs", "browserMode", "goal", "knownChangedPaths", "mode", "nonClaims", "observedReports", "openBrowser", "routeId", "schemaVersion", "root-shape-only definition proofkit.agent-route.input.v2.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:2911a7f4351acf9449580a86d4d91e7c363776675432d7e70fdf289500823cf0", FlagChoices: map[string][]string{}, RouteTokens: []string{"agent-route"}}, + "agent-route": {InputContractSHA256: "sha256:c00e832b4e9eac6b858eec46e810431c0a5c9f56c5c50f055f39ee024f50014c", InputSchemaSummary: []string{"availableInputs", "browserMode", "goal", "knownChangedPaths", "mode", "nonClaims", "observedReports", "openBrowser", "routeId", "schemaVersion", "root-shape-only definition proofkit.agent-route.input.v2.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:10a0ddb2bcf296135e14e975dcaa23386c3692bd1c9e802cc27264651c674e64", FlagChoices: map[string][]string{}, RouteTokens: []string{"agent-route"}}, "binding-partition": {InputContractSHA256: "sha256:366ad082045af52b2ac6604f18626d0f285b2db73b45d9a82687b8d3b0d2b3fd", InputSchemaSummary: []string{"schemaVersion=1", "root-shape-only definition proofkit.binding-partition.input.v1.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:52840879e13a00ef9a4abaad6cdb33000511674d5f9003fb56f387fdf58fadc8", FlagChoices: map[string][]string{}, RouteTokens: []string{"binding-partition"}}, "branch-authority": {InputContractSHA256: "sha256:8a3ed74978898593fbdbf1f7fa684dae450fbd9019edcd60d07f818d63363ed4", InputSchemaSummary: []string{"schemaVersion=1", "root-shape-only definition proofkit.branch-authority.input.v1.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:3c7dc74842299b92cd5baf57cc8666e9415963091359e5faf654e28da89561f1", FlagChoices: map[string][]string{}, RouteTokens: []string{"branch-authority"}}, "capability-map-admission": {InputContractSHA256: "sha256:e49433f295c43c34d5d660ac9d656b117ed87208406b57723d25165ffec5d486", InputSchemaSummary: []string{"schemaVersion=1", "root-shape-only definition proofkit.capability-map-admission.input.v1.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:bfa35fe1be210ab98f3620694ab63b52a9724f7b92cd9dcbfd7b01b2c6a3555e", FlagChoices: map[string][]string{}, RouteTokens: []string{"capability-map-admission"}}, @@ -38,19 +38,19 @@ var generatedCommandContractMetadataByName = map[string]generatedCommandContract "gradual-adoption-guidance": {InputContractSHA256: "sha256:4752cbac81c864cb3e18a39facfd666a9707314233d54798c7f71e67d7f2800c", InputSchemaSummary: []string{"schemaVersion=1", "root-shape-only definition proofkit.gradual-adoption-guidance.input.v1.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:171fed4bb8d32a47fc5ec49796f5b0b55ed666feaccc2fbbfeb12da31d80ecc9", FlagChoices: map[string][]string{}, RouteTokens: []string{"gradual-adoption-guidance"}}, "help": {InputContractSHA256: "", InputSchemaSummary: []string(nil), OutputContractSHA256: "", FlagChoices: map[string][]string{}, RouteTokens: []string{"help"}}, "impact": {InputContractSHA256: "sha256:41d3107414837955ee408d5ce94949a4c1a6b76f6949e6c1dc224bd06f6b09bc", InputSchemaSummary: []string{"schemaVersion=2", "root-shape-only definition proofkit.impact.input.v2.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:73066e9a5ca48f21936111ffb7223900fb629875997f4e7b16d7fef9c4177972", FlagChoices: map[string][]string{}, RouteTokens: []string{"impact"}}, - "integration-apply": {InputContractSHA256: "", InputSchemaSummary: []string(nil), OutputContractSHA256: "sha256:042023fbed99bb8b55661b2c5b25979202d8445a0fefc97568400754bbacd025", FlagChoices: map[string][]string{"--color": []string{"auto", "never"}, "--format": []string{"json", "text"}, "--operation": []string{"install", "remove", "update"}, "--tool": []string{"claude", "codex"}}, RouteTokens: []string{"integration", "apply"}}, - "integration-check": {InputContractSHA256: "", InputSchemaSummary: []string(nil), OutputContractSHA256: "sha256:98c693f6d4dfa644fcba0ab2ff7da6bf7791f5b2ad197516a6d3846cbf1cd301", FlagChoices: map[string][]string{"--format": []string{"json", "text"}, "--tool": []string{"claude", "codex"}}, RouteTokens: []string{"integration", "check"}}, - "integration-plan": {InputContractSHA256: "", InputSchemaSummary: []string(nil), OutputContractSHA256: "sha256:dd153473f15fa590c2c948b89f7c97c1a3b7d12efc851b0f2276a28f62b3d515", FlagChoices: map[string][]string{"--color": []string{"auto", "never"}, "--format": []string{"json", "text"}, "--operation": []string{"install", "remove", "update"}, "--tool": []string{"claude", "codex"}}, RouteTokens: []string{"integration", "plan"}}, - "integration-recover": {InputContractSHA256: "", InputSchemaSummary: []string(nil), OutputContractSHA256: "sha256:64dc335ebfe4e7ae368e6432042bec7ae40a3cc8c9261fbdaa8ef60401130d0d", FlagChoices: map[string][]string{"--action": []string{"resume", "rollback"}, "--color": []string{"auto", "never"}, "--format": []string{"json", "text"}}, RouteTokens: []string{"integration", "recover"}}, - "integration-source": {InputContractSHA256: "", InputSchemaSummary: []string(nil), OutputContractSHA256: "sha256:6b1308edeb5f4edf033e0d696d130ee5ba14232f156e2ae9a1f0f3b0d0d420a4", FlagChoices: map[string][]string{"--format": []string{"json", "text"}, "--tool": []string{"claude", "codex"}}, RouteTokens: []string{"integration", "source"}}, + "integration-apply": {InputContractSHA256: "", InputSchemaSummary: []string(nil), OutputContractSHA256: "sha256:012f400729c0c8b9c8c2d594c38331985c873ae6a989ee35059e9a6bacd4ea59", FlagChoices: map[string][]string{"--color": []string{"auto", "never"}, "--format": []string{"json", "text"}, "--operation": []string{"install", "remove", "update"}, "--tool": []string{"claude", "codex"}}, RouteTokens: []string{"integration", "apply"}}, + "integration-check": {InputContractSHA256: "", InputSchemaSummary: []string(nil), OutputContractSHA256: "sha256:b4017b8c7e9219c5e06f2835544b973fb35e04913b74b5cd3d523027906ad483", FlagChoices: map[string][]string{"--format": []string{"json", "text"}, "--tool": []string{"claude", "codex"}}, RouteTokens: []string{"integration", "check"}}, + "integration-plan": {InputContractSHA256: "", InputSchemaSummary: []string(nil), OutputContractSHA256: "sha256:abd56dd42ec06c0de72d50975c7dd3e3eb6d51da14a6bcb3419fdae3cc946a04", FlagChoices: map[string][]string{"--color": []string{"auto", "never"}, "--format": []string{"json", "text"}, "--operation": []string{"install", "remove", "update"}, "--tool": []string{"claude", "codex"}}, RouteTokens: []string{"integration", "plan"}}, + "integration-recover": {InputContractSHA256: "", InputSchemaSummary: []string(nil), OutputContractSHA256: "sha256:d1626e94ef9b4525e0c29e4d3368faaf03ac14e2e0bd9f915fade45c7323ad9b", FlagChoices: map[string][]string{"--action": []string{"resume", "rollback"}, "--color": []string{"auto", "never"}, "--format": []string{"json", "text"}}, RouteTokens: []string{"integration", "recover"}}, + "integration-source": {InputContractSHA256: "", InputSchemaSummary: []string(nil), OutputContractSHA256: "sha256:02bee51b6386f1a93fa1158fbb86ebfc77723995631b21dddaf415f174b95511", FlagChoices: map[string][]string{"--format": []string{"json", "text"}, "--tool": []string{"claude", "codex"}}, RouteTokens: []string{"integration", "source"}}, "json-report-cli-adapter-source": {InputContractSHA256: "", InputSchemaSummary: []string(nil), OutputContractSHA256: "sha256:6c3dd1c8507a90e055cf2c886089446d8560ff3e0d3ca9cc6360a3377d2d85da", FlagChoices: map[string][]string{}, RouteTokens: []string{"json-report-cli-adapter-source"}}, "migration-parity-admission": {InputContractSHA256: "sha256:0b36c0e68da3b857dac4b13e7b3bd523052459106133aa8c908a4352682e6c05", InputSchemaSummary: []string{"schemaVersion=1", "paritySetId", "sourceProofOwners[]", "targetProofkitRefs[]", "parityRecords[]", "nonClaims[]", "root-shape-only definition proofkit.migration-parity-admission.input.v1.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:8e0f8af2b205817f018b0fe133fe789661caa29007695e036bfcab63c1830f47", FlagChoices: map[string][]string{}, RouteTokens: []string{"migration-parity-admission"}}, "migration-plan": {InputContractSHA256: "sha256:58a62759a634101ce2ca9218184175134bbe5633328e1b23797b94c19fc9b11a", InputSchemaSummary: []string{"schemaVersion=1", "migrationId", "sourceProofOwners[]", "targetProofkitRefs[]", "parityEvidenceRefs[]", "retainedOwners[]", "retirementCandidates[]", "followUpCommands[]", "nonClaims[]", "root-shape-only definition proofkit.migration-plan.input.v1.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:f14f0381e9dc241357c346315b95b03ef5b23f1d1bbc3b00f111fbe1515ed3ff", FlagChoices: map[string][]string{}, RouteTokens: []string{"migration-plan"}}, "native-evidence-guidance": {InputContractSHA256: "", InputSchemaSummary: []string(nil), OutputContractSHA256: "sha256:c9306d800668ecea18aaced6a21334036a935570f267baed356e6a4888025c8d", FlagChoices: map[string][]string{"--color": []string{"auto", "never"}, "--format": []string{"json", "text"}}, RouteTokens: []string{"native-evidence-guidance"}}, - "next": {InputContractSHA256: "", InputSchemaSummary: []string(nil), OutputContractSHA256: "sha256:92f6d3dc427a795ec97112e6c3ce55ebd4ab670e4323b4faa8215310a8492747", FlagChoices: map[string][]string{"--color": []string{"auto", "never"}, "--format": []string{"json", "text"}}, RouteTokens: []string{"next"}}, + "next": {InputContractSHA256: "", InputSchemaSummary: []string(nil), OutputContractSHA256: "sha256:7394789ca6a1a275662109980d82d58f3586e6afb2689d0b3e54acb14d067c21", FlagChoices: map[string][]string{"--color": []string{"auto", "never"}, "--format": []string{"json", "text"}}, RouteTokens: []string{"next"}}, "obligation-decision": {InputContractSHA256: "sha256:1dea2ed5c5066451d6d49b815cea99df2cdae2ef05d42fed16c8aeb45eb7f445", InputSchemaSummary: []string{"schemaVersion=1", "root-shape-only definition proofkit.obligation-decision.input.v1.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:96dc074f611bcc12e511bc803c548e4df623e2de869d3add29a3ea6386e04330", FlagChoices: map[string][]string{}, RouteTokens: []string{"obligation-decision"}}, "package-runtime-dependency-admission": {InputContractSHA256: "sha256:fc85887af9b8fcd899d245f0db30b2f2f68609822fc268126bf999082bb4115f", InputSchemaSummary: []string{"schemaVersion=1", "reportId", "expectedDependencySpec", "expectedLockfileIntegrity", "expectedPackageName", "expectedPackageVersion", "admissibleLocations{}", "packageResolution{}", "nonClaims[]", "root-shape-only definition proofkit.package-runtime-dependency-admission.input.v1.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:c012032e8c8212fd50bc2e85669cc610609ca2124ebc992c9e88f44a1ad2d5fc", FlagChoices: map[string][]string{}, RouteTokens: []string{"package-runtime-dependency-admission"}}, - "pilot-admission": {InputContractSHA256: "sha256:a1d9116ce619f7d705349ff4ae44c0f4399a281ebaa9e7d62ea304ac57af59ba", InputSchemaSummary: []string{"schemaVersion=2", "root-shape-only definition proofkit.pilot-admission.input.v2.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:63563d18f12d10c1f0cea5766f74d0c76cc15f78579e0a504830514cdf703fb2", FlagChoices: map[string][]string{}, RouteTokens: []string{"pilot-admission"}}, + "pilot-admission": {InputContractSHA256: "sha256:a1d9116ce619f7d705349ff4ae44c0f4399a281ebaa9e7d62ea304ac57af59ba", InputSchemaSummary: []string{"schemaVersion=2", "root-shape-only definition proofkit.pilot-admission.input.v2.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:855bea811a49bb7a8c48d5b988ae1758b295c357e37d1037e01ebaea9ebc27fe", FlagChoices: map[string][]string{}, RouteTokens: []string{"pilot-admission"}}, "producer-policy-self-proof": {InputContractSHA256: "sha256:d48e18826000c8d415f3c44b6c686e1da6ed962ef7ca36c9f705de8c68d034f9", InputSchemaSummary: []string{"schemaVersion=1", "root-shape-only definition proofkit.producer-policy-self-proof.input.v1.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:e82a3989a743f8babc6069f7af82b1dd1ea62bad8dbb18d95e105b36f74e4276", FlagChoices: map[string][]string{}, RouteTokens: []string{"producer-policy-self-proof"}}, "proof-obligation-algebra": {InputContractSHA256: "sha256:4f176b6bc9bdbd0d96d65c071d66447d246665bda7a23269e7927f1d0b80b043", InputSchemaSummary: []string{"schemaVersion=1", "root-shape-only definition proofkit.proof-obligation-algebra.input.v1.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:f9ee9e56b349756c55856a2dab198e1ad85db70a468c38e3aeca73cfe2ed66f6", FlagChoices: map[string][]string{}, RouteTokens: []string{"proof-obligation-algebra"}}, "proof-receipt-admission": {InputContractSHA256: "sha256:7cb4c4fb60c8b5a37109bbd8c00d567749f7d181bbc905d8bc58155f139c44cb", InputSchemaSummary: []string{"schemaVersion=1", "root-shape-only definition proofkit.proof-receipt-admission.input.v1.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:3f802ac3fac6762ede51f0e0a151f16dc10b4a20344a3887b3ee8bae43ce94f2", FlagChoices: map[string][]string{}, RouteTokens: []string{"proof-receipt-admission"}}, @@ -67,9 +67,9 @@ var generatedCommandContractMetadataByName = map[string]generatedCommandContract "repository-inventory": {InputContractSHA256: "", InputSchemaSummary: []string(nil), OutputContractSHA256: "sha256:4a6fc5b5ef55090854e70927494d220afdee0ae234de4f61a720a6018865f02f", FlagChoices: map[string][]string{}, RouteTokens: []string{"repository-inventory"}}, "requirement-authoring-plan": {InputContractSHA256: "sha256:208d7d47109dee1ec355ae3970937690ae528a9cc0cb0eb885d7cc72d843f1e8", InputSchemaSummary: []string{"schemaVersion=1", "root-shape-only definition proofkit.requirement-authoring-plan.input.v1.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:e995d289c4a44310add784bbafa5a3a50ec305c3809a89506dd3b49914fbe28f", FlagChoices: map[string][]string{}, RouteTokens: []string{"requirement-authoring-plan"}}, "requirement-bindings": {InputContractSHA256: "sha256:4771b7ed1e23b20c983060deb8f8e65391052f0e5a61cf0f5c67c0e73b8fc5dd", InputSchemaSummary: []string{"schemaVersion=1", "root-shape-only definition proofkit.requirement-bindings.input.v1.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:7821c7b23ff2c0ca83c64039c22400d90660cad73a60b9afb46829c539c61168", FlagChoices: map[string][]string{}, RouteTokens: []string{"requirement-bindings"}}, - "requirement-browser-server": {InputContractSHA256: "sha256:c6a3ac09a55f21c7201ba828ac1d1d6140c292f54695a4422957540945f11c91", InputSchemaSummary: []string{"workspace mode: schemaVersion=2", "workspace mode: workspaceId", "workspace mode: context=proofkit.requirement-context schemaVersion=2 with strict v1 adapter", "workspace mode: diffInput=proofkit.requirement-semantic-diff-input schemaVersion=2 (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", "root-shape-only definition proofkit.requirement-browser-server.input.v3.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:a6b8b2886cd303fa306656d4be8a20c4f3046056989a49788c0f6e93ad09410b", FlagChoices: map[string][]string{}, RouteTokens: []string{"requirement-browser-server"}}, - "requirement-context-compose": {InputContractSHA256: "sha256:3b06ebca2a07d01b34005d915918b8a7743ef8175901ee8dd0a37ecfb6ee80fe", InputSchemaSummary: []string{"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", "root-shape-only definition proofkit.requirement-context-compose.input.v1.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:a2b8bf00d2308628e1835fe890dc6e9c4f402c531726f0bdaffad1d7100a7466", FlagChoices: map[string][]string{}, RouteTokens: []string{"requirement-context-compose"}}, - "requirement-context-slice": {InputContractSHA256: "sha256:883e864e44944270f7b85c013635835e5e1f1a49a7fd7ca3ebe860c7b56da601", InputSchemaSummary: []string{"schemaVersion=1", "sliceId", "context=proofkit.requirement-context schemaVersion=2 with strict v1 adapter", "query.profile=routing|specification|proof|coverage|review", "query.nodeIds[]|requirementIds[]|ownerIds[]|lifecycleStates[]", "query.maxDepth=0..512", "query.maxNodes=1..4096", "query.maxRequirements=1..16384", "root-shape-only definition proofkit.requirement-context-slice.input.v1.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:971f78ec94bb29a24057343ccfb3d1192134d2ac88968aab5193e3d64ac87506", FlagChoices: map[string][]string{}, RouteTokens: []string{"requirement-context-slice"}}, + "requirement-browser-server": {InputContractSHA256: "sha256:557d9f1e6919a6f40b831fb910340f85cdc0a0619d0c4895098308939a262e6e", InputSchemaSummary: []string{"workspace mode: schemaVersion=2", "workspace mode: workspaceId", "workspace mode: context=proofkit.requirement-context schemaVersion=2 with strict v1 adapter", "workspace mode: diffInput=proofkit.requirement-semantic-diff-input schemaVersion=2 (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", "root-shape-only definition proofkit.requirement-browser-server.input.v3.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:40a4ac0312d4fb921d572817331a73c629ed807caed789296f992f1482e0d932", FlagChoices: map[string][]string{}, RouteTokens: []string{"requirement-browser-server"}}, + "requirement-context-compose": {InputContractSHA256: "sha256:0b8d5eace6247fd8fa01ad7372f0395ea6fa10e7f0aa9fe5bab2ff4ed8a69384", InputSchemaSummary: []string{"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", "root-shape-only definition proofkit.requirement-context-compose.input.v1.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:dd08c6e3e66349019a349049345e64fca3d31459e42fe634e98e9a9ade8101f4", FlagChoices: map[string][]string{}, RouteTokens: []string{"requirement-context-compose"}}, + "requirement-context-slice": {InputContractSHA256: "sha256:a97c99d2c36bdf90aa6f3f55adf3afff7d3576093ae3ed79bbde5aaccaf1249c", InputSchemaSummary: []string{"schemaVersion=1", "sliceId", "context=proofkit.requirement-context schemaVersion=2 with strict v1 adapter, or schemaVersion=3 closed captured project origin", "Project-origin v3 replay validates the exact canonical project and role/source partition; it does not reread live files or reinterpret the existing v1/v2 identities.", "query.profile=routing|specification|proof|coverage|review", "query.nodeIds[]|requirementIds[]|ownerIds[]|lifecycleStates[]", "query.maxDepth=0..512", "query.maxNodes=1..4096", "query.maxRequirements=1..16384", "root-shape-only definition proofkit.requirement-context-slice.input.v1.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:f9dfb92adc6548f7e8171ad0e7261cf5d7ce3112f6ecd989acc8d1fb9ff5dd31", FlagChoices: map[string][]string{}, RouteTokens: []string{"requirement-context-slice"}}, "requirement-coverage-input-compose": {InputContractSHA256: "sha256:1980d1fc5c3c3cfe08f557e3c55f7128de5bae0d9b25bd2a46d7c6e47db73faa", InputSchemaSummary: []string{"schemaVersion=2", "root-shape-only definition proofkit.requirement-coverage-input-compose.input.v2.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:3a8686e91f3a229b273531d9c03cb6ce86ec76b66f8c35c6ca7f361b6e5eeaff", FlagChoices: map[string][]string{}, RouteTokens: []string{"requirement-coverage-input-compose"}}, "requirement-coverage-view": {InputContractSHA256: "sha256:202dfbf2b9929a9244ba067a6a57af361e8f7da245625d3d53f13d73e04f14e3", InputSchemaSummary: []string{"schemaVersion=2", "root-shape-only definition proofkit.requirement-coverage-view.input.v2.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:b5784b98b903ef9bf9ddbac047592c32baeda96a83c3e426dee51d64d3846269", FlagChoices: map[string][]string{}, RouteTokens: []string{"requirement-coverage-view"}}, "requirement-impact-input-compose": {InputContractSHA256: "sha256:c80c57489205004f92603fec541ce3d36dd0d9b65109dcfefb97bcfb07b90679", InputSchemaSummary: []string{"schemaVersion=2", "root-shape-only definition proofkit.requirement-impact-input-compose.input.v2.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:b0b689f4d0b5bafc52c6041a2aa3583c9c8628aa0f13a0f0d7c42a610ed1a0d6", FlagChoices: map[string][]string{}, RouteTokens: []string{"requirement-impact-input-compose"}}, @@ -82,21 +82,22 @@ var generatedCommandContractMetadataByName = map[string]generatedCommandContract "requirement-source-view": {InputContractSHA256: "sha256:0819889f9bfaddefe0555250612ef5f4d9172899b04d427d48b0420d765c00ad", InputSchemaSummary: []string{"schemaVersion=1", "root-shape-only definition proofkit.requirement-source-view.input.v1.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:2f29bd1edea7c3e930a143c2f96c229a0a199e52c89367302c414759bf660c44", FlagChoices: map[string][]string{}, RouteTokens: []string{"requirement-source-view"}}, "requirement-spec-tree": {InputContractSHA256: "sha256:96876589778a1cf1bc3f41fa33ad86de502db05886ba620ecbee59225060e315", InputSchemaSummary: []string{"schemaVersion", "treeId", "rootNodeId", "callerAnnotations", "nodes", "edges", "overlays", "root-shape-only definition proofkit.requirement-spec-tree.input.v1.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:a027461411b8614288186df2c014a9e5303f905f0fb4cfc9b3ed6439790ae547", FlagChoices: map[string][]string{}, RouteTokens: []string{"requirement-spec-tree"}}, "requirement-spec-tree-view": {InputContractSHA256: "sha256:9e725fc145c437e0f9cdea0deed86189da855cc7d0350d33e59c80d34d91c03a", InputSchemaSummary: []string{"schemaVersion", "treeId", "rootNodeId", "callerAnnotations", "nodes", "edges", "overlays", "root-shape-only definition proofkit.requirement-spec-tree-view.input.v1.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:ba9b258e45d485936ddbfd76f7a7c43a5ae5760a4fe6da56bce4a45afd4221e6", FlagChoices: map[string][]string{}, RouteTokens: []string{"requirement-spec-tree-view"}}, - "requirement-traceability-graph": {InputContractSHA256: "sha256:9a67442525f4831a6d24a189cb0040d3ab0478bf70c5d1e219fa41b6592a7818", InputSchemaSummary: []string{"schemaVersion=2", "graphId", "context=proofkit.requirement-context schemaVersion=2 with strict v1 adapter", "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", "root-shape-only definition proofkit.requirement-traceability-graph.input.v2.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:28035f0124497a1456e180490cddc05064af3fafaa617f079b417b82bbc623c3", FlagChoices: map[string][]string{}, RouteTokens: []string{"requirement-traceability-graph"}}, + "requirement-traceability-graph": {InputContractSHA256: "sha256:dec7e0e8bbb1e7249ef084031091bbbdf74b2e89a0292f903472efa73e35eb1b", InputSchemaSummary: []string{"schemaVersion=2", "graphId", "context=proofkit.requirement-context schemaVersion=2 with strict v1 adapter, or schemaVersion=3 closed captured project origin", "Project-origin v3 replay validates the exact canonical project and role/source partition; it does not reread live files or reinterpret the existing v1/v2 identities.", "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", "root-shape-only definition proofkit.requirement-traceability-graph.input.v2.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:13116ea9ec6980e61244f649e85805ad3fb474b4f44a3e48a65024c727f50608", FlagChoices: map[string][]string{}, RouteTokens: []string{"requirement-traceability-graph"}}, "scaffold-profile-plan": {InputContractSHA256: "sha256:bc2a9dc33664fc0555bb5c4b67c6c2caa451995f7bcb1b8add8ea8a8aabd88a6", InputSchemaSummary: []string{"schemaVersion=1", "root-shape-only definition proofkit.scaffold-profile-plan.input.v1.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:3d5d6584ef88c14534333e62b677ecac73d5bae659edf71893faa7ab1068659c", FlagChoices: map[string][]string{}, RouteTokens: []string{"scaffold-profile-plan"}}, "scaffold-project-structure": {InputContractSHA256: "sha256:0db5eca08d353a8d314908a34d8293c947a9d208e280353d9784b489576ec55a", InputSchemaSummary: []string{"schemaVersion=1", "root-shape-only definition proofkit.scaffold-project-structure.input.v1.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:79950f8b779b00616be24b2d7e28021a83e9414881676ae86ab467940d36e6cb", FlagChoices: map[string][]string{}, RouteTokens: []string{"scaffold-project-structure"}}, "secret-scan": {InputContractSHA256: "sha256:bf2f193e382bc1bf709031be6d9d9c913264e1c5865b926ae7d72ac14ea35324", InputSchemaSummary: []string{"files", "nonClaims", "reportId", "schemaVersion", "root-shape-only definition proofkit.secret-scan.input.v1.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:25ec4640a71e0ce30709b3215535439561d671de9b5be8228b710ab556e0fd8a", FlagChoices: map[string][]string{}, RouteTokens: []string{"secret-scan"}}, "selective-gate-evidence": {InputContractSHA256: "sha256:8aa178ab7ca7c475c23707bc4e15fd3f9f8d57acf6f6dcf279677e7769a45586", InputSchemaSummary: []string{"schemaVersion=1", "root-shape-only definition proofkit.selective-gate-evidence.input.v1.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:723569262bb85d9674b2a78d3bcb6e9f4cab229b71e8c784ff1b804a7fcade71", FlagChoices: map[string][]string{}, RouteTokens: []string{"selective-gate-evidence"}}, "selective-gate-obligation-decision-input": {InputContractSHA256: "sha256:85761fcbc0ea94239d55bf379d0592a6ca814e6612a2d609a651f6cdaf8ca10a", InputSchemaSummary: []string{"schemaVersion=1", "root-shape-only definition proofkit.selective-gate-obligation-decision-input.input.v1.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:ab9dddabe975238d7019266c43350afa2df1a61d4c2eb7bc23afd520b588a2da", FlagChoices: map[string][]string{}, RouteTokens: []string{"selective-gate-obligation-decision-input"}}, "selective-gate-plan": {InputContractSHA256: "sha256:5293a5a4c7d8426cf637e6f8d252095ca0eb1714365bb89bec83307b778c678a", InputSchemaSummary: []string{"schemaVersion=1", "root-shape-only definition proofkit.selective-gate-plan.input.v1.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:d7bffed853af5595af08b03859be01c283a3bdff1b3502d94ddc190889977647", FlagChoices: map[string][]string{}, RouteTokens: []string{"selective-gate-plan"}}, - "self-check": {InputContractSHA256: "sha256:537fcf6837cac6841e58cb7aeddec16dd5079c2ac756959d74cf64b7797981db", InputSchemaSummary: []string{"schemaVersion=1", "root-shape-only definition proofkit.self-check.input.v1.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:cb2ce0b159ed3923e65f1f8a92eaee9ef8def422b9b6ed5cb1814214ed7b9f68", FlagChoices: map[string][]string{}, RouteTokens: []string{"self-check"}}, + "self-check": {InputContractSHA256: "sha256:5f3a21328b11ac98c7661c6966a451fdc13a6a3f5031787cb59b0b03f1707159", InputSchemaSummary: []string{"schemaVersion=1", "root-shape-only definition proofkit.self-check.input.v1.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:e8cbde02bb90735163330d204d4eef7b2b93d2d0ff41316abb227a888a9ab306", FlagChoices: map[string][]string{}, RouteTokens: []string{"self-check"}}, "spec-overview-claims": {InputContractSHA256: "sha256:2490dcd34ba7485e13f8f33e8a288a0463c4c52cc6b0d82c57777466927e49a4", InputSchemaSummary: []string{"schemaVersion=1", "root-shape-only definition proofkit.spec-overview-claims.input.v1.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:554f3a7020e9820ccb90672629fd769c52b2f298f356040aa3b0a817666cbfbf", FlagChoices: map[string][]string{}, RouteTokens: []string{"spec-overview-claims"}}, "spec-proof-bundle-admission": {InputContractSHA256: "sha256:6b6c2875b6476e63a1911e7d6112d9999df2babbee969f84abc4c9e4b470c933", InputSchemaSummary: []string{"schemaVersion=1", "root-shape-only definition proofkit.spec-proof-bundle-admission.input.v1.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:e9e0eb66cebca3b99fe5036fb2e7327a9284934ed76f58818d18094d0546fc52", FlagChoices: map[string][]string{}, RouteTokens: []string{"spec-proof-bundle-admission"}}, "stack-preset": {InputContractSHA256: "", InputSchemaSummary: []string(nil), OutputContractSHA256: "sha256:ef5920f363a4a96dcac308ea8412260a06e64ba4876460a369aefb8983130a9d", FlagChoices: map[string][]string{"--preset": []string{"agentic_runtime_repo", "generated_docs_contract_repo", "python_service", "python_typescript_service", "typescript_monorepo", "typescript_workspace"}}, RouteTokens: []string{"stack-preset"}}, - "status": {InputContractSHA256: "", InputSchemaSummary: []string(nil), OutputContractSHA256: "sha256:3ac87fa98c4650fc3b758ab4a43dee1beeb12f9aee3631a35a2ddf5d0fc5f899", FlagChoices: map[string][]string{"--color": []string{"auto", "never"}, "--format": []string{"json", "text"}}, RouteTokens: []string{"status"}}, + "status": {InputContractSHA256: "", InputSchemaSummary: []string(nil), OutputContractSHA256: "sha256:41b27b34d8ed1eef3446b355a35774a540bfe63e0481a75cf90889739e38cf88", FlagChoices: map[string][]string{"--color": []string{"auto", "never"}, "--format": []string{"json", "text"}}, RouteTokens: []string{"status"}}, "test-evidence-inventory": {InputContractSHA256: "sha256:c2e26a02e127eec069275c9afa902a20cb9e062f4008db35e042d8fe0e3fdd60", InputSchemaSummary: []string{"schemaVersion=2", "root-shape-only definition proofkit.test-evidence-inventory.input.v2.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:06fd4be6a34e73708f8539fed04ec41b2a4c9c79cf0e0dcc311496c0e6aafcbf", FlagChoices: map[string][]string{}, RouteTokens: []string{"test-evidence-inventory"}}, "text-policy": {InputContractSHA256: "sha256:afc366b0bcdcb33d7b85d3347f832cb647de68cb737cfa81a52e55f5b4901038", InputSchemaSummary: []string{"schemaVersion=1", "root-shape-only definition proofkit.text-policy.input.v1.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:94031bd5ee75115e80ebe10cb8c3c7b77806dc349721ba550cacaa99ff8184e4", FlagChoices: map[string][]string{}, RouteTokens: []string{"text-policy"}}, "typescript-public-api-surfaces": {InputContractSHA256: "sha256:11ddef5ae86c8f5ff9fcb8cfac540c8ea680cd61233e54e9ba74563b28020e2a", InputSchemaSummary: []string{"schemaVersion=1", "machineContract=public_api_surfaces", "entries[].packageManifestPath", "entries[].packageName", "entries[].exportKey", "entries[].exportConditions[] (non-empty, sorted unique by condition)", "entries[].exportConditions[].condition", "entries[].exportConditions[].path", "entries[].exportConditions[].sourcePath (declared and canonical target .ts/.mts/.cts)", "entries[].runtimeExports[]", "entries[].typeExports[]", "entries[].deniedExportKeys[] (optional)", "sourceGrammar=fail_closed_restricted_typescript_exports_v1", "maxSourceFileBytes=8388608", "maxPackageManifestBytes=262144", "maxAggregateFileReadBytes=67108864", "root-shape-only definition proofkit.typescript-public-api-surfaces.input.v1.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:f26372435db16de436cd5483089d06d3723496574b28d80de97ee36ccddf1587", FlagChoices: map[string][]string{}, RouteTokens: []string{"typescript-public-api-surfaces"}}, + "view": {InputContractSHA256: "", InputSchemaSummary: []string(nil), OutputContractSHA256: "sha256:4374a9dce8f1f7046f9ce0a2b06bcd5ed9e768cdb1b18e0a2d97859cd8c474b1", FlagChoices: map[string][]string{}, RouteTokens: []string{"view"}}, "witness-plan": {InputContractSHA256: "sha256:7814c5d27487a361bac77045afe32c6449c24881f04d5496da7182b3f2c0c1ee", InputSchemaSummary: []string{"schemaVersion=1", "root-shape-only definition proofkit.witness-plan.input.v1.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:722c577bf5cf1dab8c9da8d18fc0634e1b9a6471aa5ca0b05f55b730cdd0d303", FlagChoices: map[string][]string{}, RouteTokens: []string{"witness-plan"}}, "witness-scheduler-plan": {InputContractSHA256: "sha256:972c782dc8c5f012380acba2f7e80030adccef3a93c63255e60b2ac75af9cc4c", InputSchemaSummary: []string{"schemaVersion=1", "root-shape-only definition proofkit.witness-scheduler-plan.input.v1.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:1c87ee7d359b28e66e5236ff8f8e779d9d8b452f8337b589f8b7bcfd17db1dc4", FlagChoices: map[string][]string{}, RouteTokens: []string{"witness-scheduler-plan"}}, "workspace-changed-package-plan": {InputContractSHA256: "sha256:77528c486d3b95c85be7c653d544ae9190662e5a3293cc70475efb28e8fe7485", InputSchemaSummary: []string{"schemaVersion=1", "root-shape-only definition proofkit.workspace-changed-package-plan.input.v1.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:02db1649b4d5aaa4b5abff0579b6edbbd89af43a58fcafe0b1258fa396a78570", FlagChoices: map[string][]string{}, RouteTokens: []string{"workspace-changed-package-plan"}}, diff --git a/internal/app/command_coverage_routes.go b/internal/app/command_coverage_routes.go index 885d227..592b988 100644 --- a/internal/app/command_coverage_routes.go +++ b/internal/app/command_coverage_routes.go @@ -172,6 +172,7 @@ var commandCoverageRoutes = map[string][]commandCoverageRoute{ }, "text-policy": {requiredInputAdmissionRoute, packageFalsifierRoute("internal/command/textpolicy/textpolicy_test.go", "TestEvaluatePreservesUTF8ASCIIWhitespaceAndBinaryFalsifiers", semanticRouteProof("textpolicy.evaluate_preserves_utf8_asciiwhitespace_and_binary_falsifiers"), "Text policy must preserve UTF-8, ASCII, final-newline, trailing-whitespace, binary-suffix, missing-file, and explicit-inventory falsifiers without scanning repository state.")}, "typescript-public-api-surfaces": {requiredInputAdmissionRoute, packageFalsifierRoute("internal/command/publicapi/public_api_test.go", "TestVerifyTypeScriptPackagePublicAPIRejectsExportStar", semanticRouteProof("public_api.verify_type_script_package_public_apirejects_export_star"), "TypeScript public API verifier must reject export-star surfaces that hide public contract drift."), packageFalsifierRoute("internal/command/publicapi/public_api_test.go", "TestVerifyTypeScriptPackagePublicAPIRejectsExportsFromDifferentDeclaredSource", semanticRouteProof("public_api.verify_type_script_package_public_apirejects_exports_from_different_declared_source"), "TypeScript public API verifier must compare declared public exports against each explicitly referenced source file.")}, + "view": {directCLIRoute("internal/app/project_view_command_test.go", "TestProjectViewCLI", semanticRouteProof("project_view.whole_cli"), "Project view must consume only an explicit complete project, emit a bounded workspace plan and reject stale input without mutating source or promoting verification.")}, "witness-plan": {requiredInputAdmissionRoute, packageFalsifierRoute("internal/command/witnessplan/witnessplan_test.go", "TestBuildAdmitsSafeCommandAndRejectsShellCommand", semanticRouteProof("witnessplan.build_admits_safe_command_and_rejects_shell_command"), "Witness plan must preserve witness command safety policy and reject shell command execution."), packageFalsifierRoute("internal/command/witnessplan/witnessplan_test.go", "TestBuildProjectsRequirementBindingsToWitnessPlan", semanticRouteProof("witnessplan.build_projects_requirement_bindings_to_witness_plan"), "Witness plan projection must derive witness commands from admitted requirement proof bindings without duplicating command identity.")}, "witness-scheduler-plan": {requiredInputAdmissionRoute, packageFalsifierRoute("internal/command/witnessschedulerplan/witnessschedulerplan_test.go", "TestBuildRejectsUnsafeParallelWriteCollision", semanticRouteProof("witnessschedulerplan.build_rejects_unsafe_parallel_write_collision"), "Witness scheduler planning must reject unsafe parallel write collisions.")}, "workspace-changed-package-plan": {requiredInputAdmissionRoute, packageFalsifierRoute("internal/command/workspaceplanning/workspaceplanning_test.go", "TestChangedPackagePlanAdmitsPackagesRootAndSchema", semanticRouteProof("workspaceplanning.changed_package_plan_admits_packages_root_and_schema"), "Workspace changed-package planning must admit packagesRoot only through explicit schema-versioned input.")}, diff --git a/internal/app/command_coverage_test.go b/internal/app/command_coverage_test.go index ea39538..74dfd74 100644 --- a/internal/app/command_coverage_test.go +++ b/internal/app/command_coverage_test.go @@ -10,6 +10,7 @@ import ( "github.com/research-engineering/agentic-proofkit/internal/command/testevidenceinventory" "github.com/research-engineering/agentic-proofkit/internal/testsupport/commandcoverage" + "github.com/research-engineering/agentic-proofkit/internal/testsupport/projectfixture" ) func TestSupportedCommandsHaveExplicitCoverageRoutes(t *testing.T) { @@ -557,6 +558,8 @@ func noInputRuntimeSmokeArgs(t *testing.T, descriptor commandDescriptor) ([]stri return append(cloneStrings(descriptor.routeTokens), "--repo-root", t.TempDir()), true case "stack-preset": return []string{"stack-preset", "--preset", "typescript_workspace"}, true + case "view": + return []string{"view", "--repo-root", projectfixture.New(t).Root}, true default: panic("missing no-input command smoke args for " + descriptor.name) } diff --git a/internal/app/command_descriptors.go b/internal/app/command_descriptors.go index c63b645..0dd62c7 100644 --- a/internal/app/command_descriptors.go +++ b/internal/app/command_descriptors.go @@ -38,6 +38,7 @@ const ( commandRunnerPilotAdmission commandRunner = "pilot_admission" commandRunnerPlanning commandRunner = "planning" commandRunnerProjectStatus commandRunner = "project_status" + commandRunnerProjectView commandRunner = "project_view" commandRunnerProjectStructure commandRunner = "project_structure" commandRunnerRequirementBrowserServer commandRunner = "requirement_browser_server" commandRunnerRequirementContextCompose commandRunner = "requirement_context_compose" @@ -183,6 +184,7 @@ var commandDescriptors = []commandDescriptor{ command("test-evidence-inventory", commandInputRequired, flags("--input", "--input-pointer", "--normalized-inventory", "--projection"), modes("json", "normalized-inventory"), ownerDirs("proofbindingtestinventory", "testevidenceinventory"), withRunner(commandRunnerTestEvidenceInventory)), command("text-policy", commandInputRequired, flags("--input", "--input-pointer"), modes("json"), ownerDirs("textpolicy")), command("typescript-public-api-surfaces", commandInputRequired, flags("--input", "--input-pointer", "--repo-root"), modes("json"), ownerDirs("publicapi"), withRunner(commandRunnerTypeScriptPublicAPISurfaces), withScopeClass(commandScopeExplicitFileSystemScan), withRequiredFlags("--repo-root")), + command("view", commandInputNone, flags("--host", "--open", "--port", "--repo-root", "--serve", "--session-mode", "--session-timeout-seconds"), modes("json", "server"), ownerDirs("requirementbrowser"), withRunner(commandRunnerProjectView), withSemanticAppTests("TestProjectViewCLI"), withScopeClass(commandScopeExplicitFileSystemScan), withRequiredFlags("--repo-root"), withFlagChoices("--host", requirementbrowser.HostChoices()...), withFlagChoices("--session-mode", requirementbrowser.SessionModeChoices()...), withFlagPresenceRequirement("--open", "--serve"), withFlagPresenceRequirement("--session-mode", "--serve"), withFlagPresenceAndRequiredValue("--session-timeout-seconds", "--session-mode", "one-shot-question"), withFlagValueRequirement("--session-mode", "one-shot-question", "--open", "--serve"), withSingleOccurrenceFlags("--host", "--open", "--port", "--repo-root", "--serve", "--session-mode", "--session-timeout-seconds")), command("witness-plan", commandInputRequired, flags("--input", "--input-pointer"), modes("json"), ownerDirs("witnessplan")), command("witness-scheduler-plan", commandInputRequired, flags("--input", "--input-pointer"), modes("json"), ownerDirs("witnessschedulerplan")), command("workspace-changed-package-plan", commandInputRequired, flags("--agent-envelope", "--input", "--input-pointer"), modes("json"), ownerDirs("workspaceplanning"), withRunner(commandRunnerPlanning), withAgentEnvelope()), @@ -211,6 +213,7 @@ var knownCommandRunners = map[commandRunner]struct{}{ commandRunnerPilotAdmission: {}, commandRunnerPlanning: {}, commandRunnerProjectStatus: {}, + commandRunnerProjectView: {}, commandRunnerProjectStructure: {}, commandRunnerRequirementBrowserServer: {}, commandRunnerRequirementContextCompose: {}, diff --git a/internal/app/command_family_catalog_generated.go b/internal/app/command_family_catalog_generated.go index 693e2fc..fe3dd52 100644 --- a/internal/app/command_family_catalog_generated.go +++ b/internal/app/command_family_catalog_generated.go @@ -1,7 +1,7 @@ // Code generated by internal/tools/commandfamilygen; DO NOT EDIT. package app -const commandFamilyCatalogSourceSHA256 = "328dab95301ee37df2cf4bb4aa45e49ec78713403bdf3f655853d69813fdbbd9" +const commandFamilyCatalogSourceSHA256 = "d91c315a14139f3ba70c764f8ec8c51f6b9b8db60dfb9492218239a7e6d2b426" func generatedCommandFamilyCatalog() commandFamilyCatalog { return commandFamilyCatalog{ @@ -14,7 +14,7 @@ func generatedCommandFamilyCatalog() commandFamilyCatalog { {ID: "cli-metadata-and-conformance", Label: "CLI metadata and conformance", Purpose: "Expose, route, generate, and self-check CLI contract surfaces.", Commands: []string{"agent-route", "conformance-profile", "help", "json-report-cli-adapter-source", "self-check"}}, {ID: "deployment-and-readiness", Label: "Deployment and readiness", Purpose: "Admit deployment evidence and bounded closeout decisions.", Commands: []string{"branch-authority", "completion-criteria", "deployment-evidence-admission", "readiness-closeout"}}, {ID: "migration-and-retirement", Label: "Migration and retirement", Purpose: "Prove parity and plan retirement of consumer-local infrastructure.", Commands: []string{"migration-parity-admission", "migration-plan"}}, - {ID: "project-state-navigation", Label: "Project state navigation", Purpose: "Classify a materialized project and expose one bounded next action.", Commands: []string{"next", "status"}}, + {ID: "project-state-navigation", Label: "Project state navigation", Purpose: "Classify a materialized project, expose one bounded next action and inspect its captured specifications in a local browser.", Commands: []string{"next", "status", "view"}}, {ID: "proof-artifact-governance", Label: "Proof artifact governance", Purpose: "Govern custom rules, document lifecycle, freshness, and witness plans.", Commands: []string{"custom-rule-boundary", "document-lifecycle-boundary", "rendered-artifact-freshness", "witness-plan", "witness-scheduler-plan"}}, {ID: "proof-binding-topology", Label: "Proof binding topology", Purpose: "Admit, resolve, partition, and inspect requirement proof topology.", Commands: []string{"binding-partition", "evidence-graph", "proof-slice", "requirement-bindings", "requirement-proof-resolver", "requirement-proof-source-set", "spec-proof-bundle-admission"}}, {ID: "receipt-authority", Label: "Receipt authority", Purpose: "Admit receipts, producer compatibility, currentness, and trust classes.", Commands: []string{"producer-policy-self-proof", "proof-receipt-admission", "receipt-currentness-scope", "receipt-producer-admission", "receipt-trust-class"}}, diff --git a/internal/app/command_flag_constraints.go b/internal/app/command_flag_constraints.go index e916889..3f6acb8 100644 --- a/internal/app/command_flag_constraints.go +++ b/internal/app/command_flag_constraints.go @@ -7,9 +7,10 @@ import ( ) type descriptorArguments struct { - counts map[string]int - present map[string]bool - values map[string][]string + counts map[string]int + present map[string]bool + values map[string][]string + unexpected bool } func classifyDescriptorArguments(descriptor commandDescriptor, args []string) descriptorArguments { @@ -22,6 +23,7 @@ func classifyDescriptorArguments(descriptor commandDescriptor, args []string) de continue } if !slices.Contains(descriptor.allowedFlags, argument) { + parsed.unexpected = true continue } parsed.present[argument] = true @@ -43,7 +45,11 @@ func validateFlagConstraints(descriptor commandDescriptor, parsed descriptorArgu return fmt.Errorf("%s may be specified only once", flag) } } - for flag, choices := range descriptor.flagValueChoices { + for _, flag := range descriptor.allowedFlags { + choices, constrained := descriptor.flagValueChoices[flag] + if !constrained { + continue + } for _, value := range parsed.values[flag] { if !slices.Contains(choices, value) { return flagChoiceError(flag, choices) diff --git a/internal/app/integration_version_edge_test.go b/internal/app/integration_version_edge_test.go index 721e23b..4555473 100644 --- a/internal/app/integration_version_edge_test.go +++ b/internal/app/integration_version_edge_test.go @@ -77,6 +77,9 @@ const managedReplayPolicy = "Replay requires a retained generation-2 terminal re func verifyManagedIntegrationPublicABIDiff(frozen frozenPublicABI, current map[string]any) error { current = clonePublicABIRecord(current) + if err := normalizeProjectContextPublicABIDelta(current); err != nil { + return err + } commands, _, err := indexPublicABIRecords(current["commands"], "command") if err != nil { return err @@ -104,7 +107,7 @@ func verifyManagedIntegrationPublicABIDiff(frozen frozenPublicABI, current map[s } } current["commands"] = values - return verifyAdditivePublicABIDiff(frozen, current, []string{"integration-apply", "integration-plan", "integration-recover"}, nil) + return verifyAdditivePublicABIDiff(frozen, current, []string{"integration-apply", "integration-plan", "integration-recover", "view"}, nil) } func TestManagedIntegrationVersionEdgeClosesDeclaredPublicABIDelta(t *testing.T) { diff --git a/internal/app/json_layout.go b/internal/app/json_layout.go index e830ca1..9c97e1a 100644 --- a/internal/app/json_layout.go +++ b/internal/app/json_layout.go @@ -47,8 +47,8 @@ func validateJSONLayoutUse(descriptor commandDescriptor, parsed descriptorArgume if !slices.Contains(descriptor.outputModes, "json") { return fmt.Errorf("--json-layout is valid only for JSON command output") } - if descriptor.name == "requirement-browser-server" && parsed.present["--serve"] { - return fmt.Errorf("--json-layout is invalid when requirement-browser-server serves a browser session") + if (descriptor.name == "requirement-browser-server" || descriptor.name == "view") && parsed.present["--serve"] { + return fmt.Errorf("--json-layout is invalid when %s serves a browser session", descriptor.name) } for _, format := range parsed.values["--format"] { if format != "json" { diff --git a/internal/app/project_view_command.go b/internal/app/project_view_command.go new file mode 100644 index 0000000..18ad304 --- /dev/null +++ b/internal/app/project_view_command.go @@ -0,0 +1,98 @@ +package app + +import ( + "context" + "errors" + "fmt" + "io" + "os" + "os/signal" + "syscall" + "time" + + "github.com/research-engineering/agentic-proofkit/internal/command/requirementbrowser" +) + +type projectViewArgs struct { + repositoryRoot string + serve bool + browser requirementbrowser.Options +} + +type projectViewRunner func(context.Context, descriptorArguments, io.Writer, io.Writer) int + +type projectViewOperations struct { + plan func(context.Context, string, requirementbrowser.Options) (map[string]any, int, error) + serve func(context.Context, string, requirementbrowser.Options, io.Writer) error +} + +func runProjectView(ctx context.Context, parsed descriptorArguments, stdout, stderr io.Writer) int { + return runProjectViewWithOperations(ctx, parsed, stdout, stderr, projectViewOperations{ + plan: requirementbrowser.BuildProjectPlan, serve: requirementbrowser.ServeProject, + }) +} + +func runProjectViewWithOperations(ctx context.Context, parsed descriptorArguments, stdout, stderr io.Writer, operations projectViewOperations) int { + options, err := parseProjectViewArgs(parsed) + if err != nil { + writeDiagnostic(stderr, err) + return 1 + } + if !options.serve { + output, code, err := operations.plan(ctx, options.repositoryRoot, options.browser) + return writeJSON(output, code, err, stdout, stderr) + } + signalContext, stop := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM) + defer stop() + if err := operations.serve(signalContext, options.repositoryRoot, options.browser, stdout); err != nil { + if !errors.Is(err, requirementbrowser.ErrOneShotTerminal) { + writeDiagnosticf(stderr, "view could not complete the browser session; use next with the same --repo-root") + } + return 1 + } + return 0 +} + +// The dispatcher owns token classification and descriptor constraints. Values +// are consumed from that result; a path such as --serve is not reparsed here. +func parseProjectViewArgs(parsed descriptorArguments) (projectViewArgs, error) { + if parsed.unexpected { + return projectViewArgs{}, fmt.Errorf("unsupported argument for view") + } + options := projectViewArgs{ + serve: parsed.present["--serve"], + browser: requirementbrowser.Options{ + Host: "127.0.0.1", View: "workspace", SessionMode: "browse", + Open: parsed.present["--open"], + }, + } + for _, flag := range []string{"--repo-root", "--host", "--port", "--session-mode", "--session-timeout-seconds"} { + if !parsed.present[flag] { + continue + } + values := parsed.values[flag] + if len(values) != 1 || values[0] == "" { + return projectViewArgs{}, fmt.Errorf("%s requires a value", flag) + } + value := values[0] + switch flag { + case "--repo-root": + options.repositoryRoot = value + case "--host": + options.browser.Host = value + case "--session-mode": + options.browser.SessionMode = value + case "--port", "--session-timeout-seconds": + number, err := parseBrowserInteger(flag, value) + if err != nil { + return projectViewArgs{}, err + } + if flag == "--port" { + options.browser.Port, options.browser.PortSet = number, true + } else { + options.browser.SessionTimeout = time.Duration(number) * time.Second + } + } + } + return options, nil +} diff --git a/internal/app/project_view_command_test.go b/internal/app/project_view_command_test.go new file mode 100644 index 0000000..78e8311 --- /dev/null +++ b/internal/app/project_view_command_test.go @@ -0,0 +1,357 @@ +package app + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "errors" + "io" + "net" + "net/http" + "net/url" + "os" + "os/exec" + "path/filepath" + "regexp" + "runtime" + "strings" + "syscall" + "testing" + "time" + + "github.com/research-engineering/agentic-proofkit/internal/command/requirementbrowser" + "github.com/research-engineering/agentic-proofkit/internal/kernel/cliexec" + "github.com/research-engineering/agentic-proofkit/internal/testsupport/commandcoverage" + "github.com/research-engineering/agentic-proofkit/internal/testsupport/projectfixture" +) + +func TestProjectViewCLI(t *testing.T) { + commandcoverage.SemanticRoute(t, "proofkit.command_coverage.source_oracle.v1.108643115507842968757287693696779878324657631379944770299252611290330528256825") + fixture := projectfixture.New(t) + code, output, diagnostic := executeAgentWorkflowCLI(t, []string{"view", "--repo-root", fixture.Root}, panicReader{}, PresentationCapabilities{}) + if code != 0 || diagnostic != "" { + t.Fatalf("project plan exit=%d diagnostic=%q", code, diagnostic) + } + plan := decodeCLIJSON(t, output).(map[string]any) + assertExactObjectKeys(t, plan, []string{"authority", "host", "htmlByteLength", "nonClaims", "planKind", "port", "portSelection", "renderedAuthority", "renderedViewKind", "schemaVersion", "url", "view"}, "view plan") + if plan["authority"] != "presentation_adapter_plan" || plan["planKind"] != "proofkit.requirement-browser-server-plan" || plan["view"] != "workspace" || plan["host"] != "127.0.0.1" || plan["portSelection"] != "ephemeral" || plan["url"] != nil || plan["renderedViewKind"] != "proofkit.requirement-workspace" || plan["renderedAuthority"] != "presentation_adapter" { + t.Fatal("project view did not produce the bounded workspace plan") + } + if strings.Contains(output, fixture.Root) || strings.Contains(output, "\x1b[") || len(plan["nonClaims"].([]any)) == 0 { + t.Fatal("project plan disclosed the root, styled JSON or lost limitations") + } + code, compact, diagnostic := executeAgentWorkflowCLI(t, []string{"--json-layout", "compact", "view", "--repo-root", fixture.Root}, panicReader{}, PresentationCapabilities{}) + if code != 0 || diagnostic != "" || strings.Count(compact, "\n") != 1 { + t.Fatal("project view compact plan failed") + } + var normalized bytes.Buffer + if err := json.Compact(&normalized, []byte(output)); err != nil || normalized.String()+"\n" != compact { + t.Fatal("project view JSON layouts describe different plans") + } + code, status, diagnostic := executeAgentWorkflowCLI(t, []string{"status", "--repo-root", fixture.Root}, panicReader{}, PresentationCapabilities{}) + if code != 0 || diagnostic != "" || decodeCLIJSON(t, status).(map[string]any)["projectState"] != "verification_required" { + t.Fatal("view promoted structural admission to repository verification") + } + for path, before := range fixture.Files { + after, err := os.ReadFile(filepath.Join(fixture.Root, filepath.FromSlash(path))) + if err != nil || !bytes.Equal(before, after) { + t.Fatal("view changed a captured project file") + } + } + if err := os.WriteFile(filepath.Join(fixture.Root, "docs/specs/a/requirements.v1.json"), []byte("{}\n"), 0o600); err != nil { + t.Fatal(err) + } + code, output, diagnostic = executeAgentWorkflowCLI(t, []string{"view", "--repo-root", fixture.Root}, panicReader{}, PresentationCapabilities{}) + if code != 1 || output != "" || !strings.Contains(diagnostic, "complete admitted project") || !strings.Contains(diagnostic, "next") || strings.Contains(diagnostic, fixture.Root) { + t.Fatal("view did not fail closed on a stale project") + } +} + +func TestProjectViewRejectsFlagsBeforeProjectIO(t *testing.T) { + missing := filepath.Join(t.TempDir(), "missing") + cases := [][]string{ + {"--input", "-"}, {"--input-pointer", "/"}, {"--output", "result.json"}, {"--format", "json"}, {"--scope", "graph"}, {"--local-environment-class", "local-go"}, {"--view", "source"}, + {"--host", "0.0.0.0"}, {"--host", "localhost"}, {"--host", ""}, {"--port", "-1"}, {"--port", "65536"}, {"--port", "1.2"}, {"--port"}, + {"--session-mode", "other"}, {"--session-mode", "browse"}, {"--open"}, {"--serve", "--session-mode", "one-shot-question"}, + {"--session-timeout-seconds", "1"}, {"--serve", "--open", "--session-mode", "one-shot-question", "--session-timeout-seconds", "0"}, + {"--serve", "--open", "--session-mode", "one-shot-question", "--session-timeout-seconds", "7201"}, + } + for _, flag := range commandDescriptorByName["view"].singleOccurrenceFlags { + args := []string{flag} + if flagRequiresValue(flag) { + value := map[string]string{"--host": "127.0.0.1", "--port": "0", "--repo-root": missing, "--session-mode": "browse", "--session-timeout-seconds": "1"}[flag] + args = append(args, value) + } + cases = append(cases, append(append([]string{}, args...), args...)) + } + for index, extra := range cases { + args := append([]string{"view", "--repo-root", missing}, extra...) + code, output, diagnostic := executeAgentWorkflowCLI(t, args, panicReader{}, PresentationCapabilities{}) + if code != 1 || output != "" || diagnostic == "" || strings.Contains(diagnostic, missing) || strings.Contains(diagnostic, "could not inspect") { + t.Fatalf("case %d did not reject before inspection: exit=%d diagnostic=%q", index, code, diagnostic) + } + assertProjectViewEffectCount(t, args, 1, 0) + } + for _, args := range [][]string{{"view"}, {"view", "--repo-root"}, {"view", "--repo-root", ""}} { + code, output, diagnostic := executeAgentWorkflowCLI(t, args, panicReader{}, PresentationCapabilities{}) + if code != 1 || output != "" || !strings.Contains(diagnostic, "--repo-root") { + t.Fatal("view accepted an absent explicit root") + } + assertProjectViewEffectCount(t, args, 1, 0) + } + fixture := projectfixture.New(t) + assertProjectViewEffectCount(t, []string{"view", "--repo-root", fixture.Root}, 0, 1) + assertProjectViewEffectCount(t, []string{"view", "--repo-root", fixture.Root, "--serve"}, 0, 1) +} + +func assertProjectViewEffectCount(t *testing.T, args []string, expectedExit, expectedCalls int) { + t.Helper() + calls := 0 + observe := func(ctx context.Context, root string, options requirementbrowser.Options) (map[string]any, int, error) { + calls++ + return requirementbrowser.BuildProjectPlan(ctx, root, options) + } + operations := projectViewOperations{ + plan: observe, + serve: func(ctx context.Context, root string, options requirementbrowser.Options, _ io.Writer) error { + _, _, err := observe(ctx, root, options) + return err + }, + } + runner := func(ctx context.Context, parsed descriptorArguments, stdout, stderr io.Writer) int { + return runProjectViewWithOperations(ctx, parsed, stdout, stderr, operations) + } + var stdout, stderr bytes.Buffer + code := runWithProjectView(t.Context(), args, panicReader{}, &stdout, &stderr, cliexec.PathRenderer(), PresentationCapabilities{}, runner) + if code != expectedExit || calls != expectedCalls { + t.Fatalf("project effect boundary: exit=%d calls=%d, want exit=%d calls=%d", code, calls, expectedExit, expectedCalls) + } +} + +func TestProjectViewChoiceDiagnosticsAreDeterministic(t *testing.T) { + args := []string{"view", "--repo-root", filepath.Join(t.TempDir(), "missing"), "--host", "localhost", "--session-mode", "invalid", "--serve"} + for range 128 { + code, output, diagnostic := executeAgentWorkflowCLI(t, args, panicReader{}, PresentationCapabilities{}) + if code != 1 || output != "" || diagnostic != "--host requires one of: 127.0.0.1, ::1\n" { + t.Fatalf("choice diagnostics changed: exit=%d diagnostic=%q", code, diagnostic) + } + } +} + +func TestProjectViewHelpLayoutAndFlagShapedPaths(t *testing.T) { + for _, help := range [][]string{{"help", "view"}, {"view", "--help"}, {"view", "-h"}} { + code, output, diagnostic := executeAgentWorkflowCLI(t, help, panicReader{}, PresentationCapabilities{}) + if code != 0 || diagnostic != "" || !strings.Contains(output, "--repo-root") || !strings.Contains(output, "--serve") { + t.Fatal("view contextual help is not discoverable") + } + code, output, diagnostic = executeAgentWorkflowCLI(t, append([]string{"--json-layout", "compact"}, help...), panicReader{}, PresentationCapabilities{}) + if code != 1 || output != "" || !strings.Contains(diagnostic, "--json-layout") { + t.Fatal("text help accepted JSON-only layout") + } + } + missing := filepath.Join(t.TempDir(), "missing") + code, output, diagnostic := executeAgentWorkflowCLI(t, []string{"--json-layout", "compact", "view", "--repo-root", missing, "--serve"}, panicReader{}, PresentationCapabilities{}) + if code != 1 || output != "" || !strings.Contains(diagnostic, "--json-layout") { + t.Fatal("serving accepted a JSON layout or inspected the root first") + } + fixture := projectfixture.New(t) + directory := t.TempDir() + if err := os.Rename(fixture.Root, filepath.Join(directory, "--serve")); err != nil { + t.Fatal(err) + } + t.Chdir(directory) + code, output, diagnostic = executeAgentWorkflowCLI(t, []string{"--json-layout", "compact", "view", "--repo-root", "--serve"}, panicReader{}, PresentationCapabilities{}) + if code != 0 || diagnostic != "" || decodeCLIJSON(t, output).(map[string]any)["planKind"] != "proofkit.requirement-browser-server-plan" { + t.Fatal("a flag-shaped path was reinterpreted as a server flag") + } +} + +func TestProjectViewSignalClosesNativeProcessServer(t *testing.T) { + fixture := projectfixture.New(t) + ctx, cancel := context.WithTimeout(t.Context(), 15*time.Second) + defer cancel() + command := exec.CommandContext(ctx, os.Args[0], "-test.run=^TestProjectViewProcessHelper$") + command.Env = append(os.Environ(), "PROOFKIT_VIEW_PROCESS_TEST_ROOT="+fixture.Root) + command.WaitDelay = time.Second + pipe, err := command.StdoutPipe() + if err != nil { + t.Fatal(err) + } + var diagnostic bytes.Buffer + command.Stderr = &diagnostic + if err := command.Start(); err != nil { + t.Fatal(err) + } + waited := false + defer func() { + if !waited { + _ = command.Process.Kill() + _ = command.Wait() + } + }() + line, err := bufio.NewReader(pipe).ReadString('\n') + if err != nil || !strings.HasPrefix(line, "Proofkit requirement browser: http://127.0.0.1:") { + t.Fatal("view process did not publish its actual loopback URL") + } + browserURL := strings.TrimSpace(strings.TrimPrefix(line, "Proofkit requirement browser: ")) + client := &http.Client{Timeout: 5 * time.Second} + response, err := client.Get(browserURL) + if err != nil { + t.Fatal(err) + } + body, err := io.ReadAll(response.Body) + _ = response.Body.Close() + capability := regexp.MustCompile(`name="proofkit-browser-capability" content="([A-Za-z0-9_-]{43})"`).FindSubmatch(body) + if err != nil || response.StatusCode != http.StatusOK || len(capability) != 2 { + t.Fatal("project CLI did not serve the existing capability-protected workspace") + } + request, err := http.NewRequest(http.MethodGet, browserURL+"api/v1/manifest", nil) + if err != nil { + t.Fatal(err) + } + request.Header.Set("X-Proofkit-Browser-Capability", string(capability[1])) + response, err = client.Do(request) + if err != nil { + t.Fatal(err) + } + body, err = io.ReadAll(response.Body) + _ = response.Body.Close() + if err != nil || response.StatusCode != http.StatusOK { + t.Fatal("project CLI manifest request failed") + } + manifest := decodeCLIJSON(t, string(body)).(map[string]any) + if manifest["requirementCount"] != json.Number("3") || manifest["workspaceId"] != "shared.identity" || manifest["coverageAvailable"] != false || manifest["diffAvailable"] != false || manifest["graphAvailable"] != true { + t.Fatal("project CLI manifest is not bound to its explicit root") + } + if err := command.Process.Signal(syscall.SIGTERM); err != nil { + t.Fatal(err) + } + err = command.Wait() + waited = true + if err != nil || diagnostic.Len() != 0 { + t.Fatalf("project CLI shutdown error=%v diagnostic=%q", err, diagnostic.String()) + } + parsed, err := url.Parse(browserURL) + if err != nil { + t.Fatal(err) + } + connection, err := net.DialTimeout("tcp", parsed.Host, time.Second) + if err == nil { + _ = connection.Close() + t.Fatal("project CLI retained its listener after termination") + } +} + +func TestProjectViewProcessHelper(t *testing.T) { + root := os.Getenv("PROOFKIT_VIEW_PROCESS_TEST_ROOT") + if root == "" { + return + } + os.Exit(Run(context.Background(), []string{"view", "--repo-root", root, "--serve"}, panicReader{}, os.Stdout, os.Stderr)) +} + +func TestProjectViewOneShotCLIOutputVariants(t *testing.T) { + fixture := projectfixture.New(t) + launcherDir := t.TempDir() + launcherName := "xdg-open" + if runtime.GOOS == "darwin" { + launcherName = "open" + } + launcher := "#!/bin/sh\nprintf '%s\\n' \"$1\" > \"$PROOFKIT_TEST_BROWSER_URL_FILE\"\n" + if err := os.WriteFile(filepath.Join(launcherDir, launcherName), []byte(launcher), 0o755); err != nil { + t.Fatal(err) + } + urlFile := filepath.Join(t.TempDir(), "browser-url") + t.Setenv("PROOFKIT_TEST_BROWSER_URL_FILE", urlFile) + t.Setenv("PATH", launcherDir+string(os.PathListSeparator)+os.Getenv("PATH")) + args := []string{"view", "--repo-root", fixture.Root, "--serve", "--open", "--session-mode", "one-shot-question", "--session-timeout-seconds", "10"} + ctx, cancel := context.WithCancel(t.Context()) + var stdout, stderr bytes.Buffer + result := make(chan int, 1) + finished := make(chan struct{}) + go func() { + defer close(finished) + result <- Run(ctx, args, panicReader{}, &stdout, &stderr) + }() + defer func() { + cancel() + select { + case <-finished: + case <-time.After(10 * time.Second): + t.Error("project one-shot did not finish cleanup") + } + }() + browserURL := waitForBrowserLauncherURL(t, urlFile, result, &stdout, &stderr) + client := &http.Client{Timeout: 5 * time.Second} + response, err := client.Get(browserURL) + if err != nil { + t.Fatal(err) + } + body, err := io.ReadAll(response.Body) + _ = response.Body.Close() + capability := regexp.MustCompile(`name="proofkit-browser-capability" content="([A-Za-z0-9_-]{43})"`).FindSubmatch(body) + if err != nil || response.StatusCode != http.StatusOK || len(capability) != 2 { + t.Fatal("one-shot project CLI workspace is unavailable") + } + handoff := `{"annotations":[{"anchorId":"requirement:REQ-WIRE-001:invariant","startCodePoint":11,"endCodePoint":12,"exactQuote":"\ud83e\udded","question":"Does this remain source-bound?"}]}` + request, err := http.NewRequest(http.MethodPost, browserURL+"api/v1/handoff", strings.NewReader(handoff)) + if err != nil { + t.Fatal(err) + } + request.Header.Set("Origin", strings.TrimSuffix(browserURL, "/")) + request.Header.Set("Content-Type", "application/json") + request.Header.Set("X-Proofkit-Browser-Capability", string(capability[1])) + response, err = client.Do(request) + if err != nil { + t.Fatal(err) + } + body, err = io.ReadAll(response.Body) + _ = response.Body.Close() + if err != nil || response.StatusCode != http.StatusOK { + t.Fatal("project CLI handoff was not admitted") + } + select { + case code := <-result: + if code != 0 || stderr.Len() != 0 { + t.Fatal("submitted project CLI handoff failed") + } + case <-time.After(10 * time.Second): + t.Fatal("project CLI handoff did not terminate") + } + packet := decodeCLIJSON(t, stdout.String()).(map[string]any) + if !equalCLIJSON(t, packet, decodeCLIJSON(t, string(body))) || packet["state"] != "submitted" || strings.Count(stdout.String(), "\n") != 1 { + t.Fatal("project CLI did not preserve the compact native handoff packet") + } + assertPublicCLIRootVariant(t, "view", "output", "03-one-shot-submitted", packet) + args[len(args)-1] = "1" + code, terminal, diagnostic := executeAgentWorkflowCLI(t, args, panicReader{}, PresentationCapabilities{}) + if code != 1 || diagnostic != "" || decodeCLIJSON(t, terminal).(map[string]any)["state"] != "expired" { + t.Fatal("one-shot expiry did not retain the existing terminal output contract") + } + assertPublicCLIRootVariant(t, "view", "output", "02-one-shot-terminal", decodeCLIJSON(t, terminal)) +} + +func TestProjectViewDiagnosticsDoNotDiscloseCallerText(t *testing.T) { + sentinel := "api_key=" + strings.Repeat("a", 40) + fixture := projectfixture.New(t) + for _, args := range [][]string{ + {"view", "--repo-root", filepath.Join(t.TempDir(), sentinel)}, + {"view", "--repo-root", fixture.Root, sentinel}, + {"view", "--repo-root", fixture.Root, "--port", sentinel}, + } { + code, output, diagnostic := executeAgentWorkflowCLI(t, args, panicReader{}, PresentationCapabilities{}) + if code != 1 || output != "" || diagnostic == "" || strings.Contains(diagnostic, sentinel) { + t.Fatal("project view leaked caller text or accepted malformed input") + } + } + var stderr bytes.Buffer + code := Run(t.Context(), []string{"view", "--repo-root", fixture.Root, "--serve"}, panicReader{}, projectViewFailureWriter{err: errors.New(sentinel)}, &stderr) + if code != 1 || stderr.Len() == 0 || strings.Contains(stderr.String(), sentinel) { + t.Fatal("project view leaked a terminal writer error") + } +} + +type projectViewFailureWriter struct{ err error } + +func (writer projectViewFailureWriter) Write([]byte) (int, error) { return 0, writer.err } diff --git a/internal/app/project_view_version_edge_test.go b/internal/app/project_view_version_edge_test.go new file mode 100644 index 0000000..24108c9 --- /dev/null +++ b/internal/app/project_view_version_edge_test.go @@ -0,0 +1,109 @@ +package app + +import ( + "bytes" + "encoding/json" + "fmt" + "slices" + "testing" +) + +// Normalize only the exact declared text delta. The cumulative ABI fingerprint +// oracle still checks every other predecessor field and definition unchanged. +func normalizeProjectContextPublicABIDelta(current map[string]any) error { + commands, _, err := indexPublicABIRecords(current["commands"], "command") + if err != nil { + return err + } + values := slices.Clone(current["commands"].([]any)) + for _, name := range []string{"requirement-context-slice", "requirement-traceability-graph"} { + command, ok := commands[name] + if !ok { + return fmt.Errorf("project-context consumer is missing") + } + command = clonePublicABIRecord(command) + input, ok := command["inputContract"].(map[string]any) + if !ok { + return fmt.Errorf("project-context consumer input contract is missing") + } + input = clonePublicABIRecord(input) + command["inputContract"] = input + summary, ok := input["compatibilitySummary"].([]any) + if !ok || len(summary) < 4 || summary[2] != "context=proofkit.requirement-context schemaVersion=2 with strict v1 adapter, or schemaVersion=3 closed captured project origin" || summary[3] != "Project-origin v3 replay validates the exact canonical project and role/source partition; it does not reread live files or reinterpret the existing v1/v2 identities." { + return fmt.Errorf("project-context compatibility differs from its declared delta") + } + references, ok := input["ownerRequirementRefs"].([]any) + if !ok || len(references) == 0 || references[len(references)-1] != "REQ-PROOFKIT-SPEC-042" { + return fmt.Errorf("project-context owner reference differs from its declared delta") + } + input["compatibilitySummary"] = slices.Concat(summary[:2], []any{"context=proofkit.requirement-context schemaVersion=2 with strict v1 adapter"}, summary[4:]) + input["ownerRequirementRefs"] = references[:len(references)-1] + if name == "requirement-context-slice" { + output, ok := command["outputContract"].(map[string]any) + if !ok { + return fmt.Errorf("project-context slice output contract is missing") + } + output = clonePublicABIRecord(output) + command["outputContract"] = output + summary, ok := output["compatibilitySummary"].([]any) + if !ok || len(summary) < 2 || summary[1] != "Source-level nonClaims are retained separately in fragments from project-origin v3; v1/v2 fragment fields remain unchanged." { + return fmt.Errorf("project-context slice output differs from its declared delta") + } + output["compatibilitySummary"] = slices.Concat(summary[:1], summary[2:]) + } + for index, raw := range values { + if raw.(map[string]any)["command"] == name { + values[index] = command + } + } + } + current["commands"] = values + return nil +} + +func TestProjectViewVersionEdgePreservesCallerRecord(t *testing.T) { + current := readCLIContractRaw(t) + before, err := json.Marshal(current) + if err != nil { + t.Fatal(err) + } + if err := verifyManagedIntegrationPublicABIDiff(readFrozenManagedIntegrationPredecessor(t), current); err != nil { + t.Fatal(err) + } + after, err := json.Marshal(current) + if err != nil || !bytes.Equal(before, after) { + t.Fatal("ABI delta oracle mutated its caller-owned contract") + } +} + +func TestProjectViewVersionEdgeRejectsContextContractDrift(t *testing.T) { + for _, name := range []string{"requirement-context-slice", "requirement-traceability-graph"} { + for _, field := range []string{"input-version", "input-replay", "input-owner", "output-restrictions"} { + if field == "output-restrictions" && name != "requirement-context-slice" { + continue + } + t.Run(name+"/"+field, func(t *testing.T) { + current := readCLIContractRaw(t) + mutatePublicABIRecord(t, current, "commands", "command", name, func(record map[string]any) { + input := record["inputContract"].(map[string]any) + switch field { + case "input-version", "input-replay": + index := 2 + if field == "input-replay" { + index = 3 + } + input["compatibilitySummary"].([]any)[index] = "undeclared drift" + case "input-owner": + references := input["ownerRequirementRefs"].([]any) + references[len(references)-1] = "REQ-UNKNOWN" + case "output-restrictions": + record["outputContract"].(map[string]any)["compatibilitySummary"].([]any)[1] = "undeclared drift" + } + }) + if verifyManagedIntegrationPublicABIDiff(readFrozenManagedIntegrationPredecessor(t), current) == nil { + t.Fatal("undeclared project context ABI delta was admitted") + } + }) + } + } +} diff --git a/internal/app/requirement_browser_command.go b/internal/app/requirement_browser_command.go index f787fbf..2ea2a98 100644 --- a/internal/app/requirement_browser_command.go +++ b/internal/app/requirement_browser_command.go @@ -110,9 +110,9 @@ func parseRequirementBrowserArgs(args []string) (requirementBrowserArgs, error) if index+1 >= len(args) { return requirementBrowserArgs{}, fmt.Errorf("--port requires an integer from 0 to 65535") } - port, err := strconv.Atoi(args[index+1]) - if err != nil || port < 0 || port > 65535 { - return requirementBrowserArgs{}, fmt.Errorf("--port requires an integer from 0 to 65535") + port, err := parseBrowserInteger("--port", args[index+1]) + if err != nil { + return requirementBrowserArgs{}, err } options.port = port options.portSet = true @@ -132,9 +132,9 @@ func parseRequirementBrowserArgs(args []string) (requirementBrowserArgs, error) if index+1 >= len(args) { return requirementBrowserArgs{}, fmt.Errorf("--session-timeout-seconds requires an integer from 1 to 7200") } - seconds, err := strconv.Atoi(args[index+1]) - if err != nil || seconds < 1 || seconds > 7200 { - return requirementBrowserArgs{}, fmt.Errorf("--session-timeout-seconds requires an integer from 1 to 7200") + seconds, err := parseBrowserInteger("--session-timeout-seconds", args[index+1]) + if err != nil { + return requirementBrowserArgs{}, err } options.sessionTimeoutSeconds = seconds index++ @@ -194,6 +194,23 @@ func requirementBrowserFlagChoices(flag string) []string { return commandDescriptorByName["requirement-browser-server"].flagValueChoices[flag] } +func parseBrowserInteger(flag, value string) (int, error) { + var minimum, maximum int + switch flag { + case "--port": + maximum = 65535 + case "--session-timeout-seconds": + minimum, maximum = 1, 7200 + default: + return 0, fmt.Errorf("unsupported browser numeric option") + } + number, err := strconv.Atoi(value) + if err != nil || number < minimum || number > maximum { + return 0, fmt.Errorf("%s requires an integer from %d to %d", flag, minimum, maximum) + } + return number, nil +} + func requirementBrowserFlagValueAllowed(flag string, value string) bool { return slices.Contains(requirementBrowserFlagChoices(flag), value) } diff --git a/internal/app/requirement_context_cli_test.go b/internal/app/requirement_context_cli_test.go index 0ecf709..39d3a8e 100644 --- a/internal/app/requirement_context_cli_test.go +++ b/internal/app/requirement_context_cli_test.go @@ -12,14 +12,51 @@ import ( "strings" "testing" + "github.com/research-engineering/agentic-proofkit/internal/command/projectstatus" "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" + "github.com/research-engineering/agentic-proofkit/internal/testsupport/projectfixture" ) +func TestProjectContextConsumersThroughWholeCLI(t *testing.T) { + fixture := projectfixture.New(t) + inspection, err := projectstatus.InspectProject(t.Context(), fixture.Root) + if err != nil { + t.Fatal(err) + } + snapshot, err := requirementcontext.FromProject(inspection.Project, inspection.ManifestContentDigest) + if err != nil { + t.Fatal(err) + } + context := requirementcontext.SnapshotValue(snapshot) + slice := runAppJSON(t, []string{"requirement-context-slice", "--input", "-"}, map[string]any{ + "schemaVersion": json.Number("1"), "sliceId": "project.slice", "context": context, + "query": map[string]any{"profile": "review", "requirementIds": []any{"REQ-WIRE-001"}}, + }) + if slice["state"] != "selected" || slice["snapshotId"] != context["snapshotId"] { + t.Fatal("CLI did not consume the captured project context") + } + sources := slice["projections"].(map[string]any)["requirementSources"].([]any) + if len(sources) != 1 { + t.Fatal("CLI selected sources outside the explicit project requirement") + } + source := sources[0].(map[string]any) + limitations := source["nonClaims"].([]any) + if source["sourceId"] != "zeta.source" || len(limitations) != 1 || limitations[0] != "Source a does not prove execution." { + t.Fatal("CLI lost the selected source identity or limitations") + } + graph := runAppJSON(t, []string{"requirement-traceability-graph", "--input", "-"}, map[string]any{ + "schemaVersion": json.Number("2"), "graphId": "project.graph", "context": context, + }) + if _, err := requirementgraph.AdmitOutput(graph, context["snapshotId"].(string)); err != nil || len(graph["nodes"].([]any)) != 7 || len(graph["edges"].([]any)) != 6 { + t.Fatalf("CLI project graph is not reference-closed: %v", err) + } +} + func TestRequirementContextCommandsComposeThroughWholeCLI(t *testing.T) { commandcoverage.SemanticRoute(t, "proofkit.command_coverage.source_oracle.v1.097109304805955804866101416335094065400345464281933061498534528351192063227949") commandcoverage.SemanticRoute(t, "proofkit.command_coverage.source_oracle.v1.057550300858348527262188220465911463572498394140028790265329600017071610078423") @@ -92,14 +129,15 @@ func TestRequirementContextCommandsComposeThroughWholeCLI(t *testing.T) { func TestLegacyDigestVocabularyConfinedToV1AdaptersAndFixtures(t *testing.T) { repoRoot := filepath.Clean(filepath.Join("..", "..")) allowed := map[string]struct{}{ - "internal/app/requirement_context_cli_test.go": {}, - "internal/command/requirementbrowser/v1_adapter.go": {}, - "internal/command/requirementbrowser/workspace_test.go": {}, - "internal/command/requirementcontext/requirementcontext_test.go": {}, - "internal/command/requirementcontext/v1_adapter.go": {}, - "internal/command/requirementdiff/requirementdiff_test.go": {}, - "internal/command/requirementdiff/v1_adapter.go": {}, - "internal/command/requirementgraph/requirementgraph_test.go": {}, + "internal/app/requirement_context_cli_test.go": {}, + "internal/command/requirementbrowser/v1_adapter.go": {}, + "internal/command/requirementbrowser/workspace_test.go": {}, + "internal/command/requirementcontext/context_wire_compatibility_test.go": {}, + "internal/command/requirementcontext/requirementcontext_test.go": {}, + "internal/command/requirementcontext/v1_adapter.go": {}, + "internal/command/requirementdiff/requirementdiff_test.go": {}, + "internal/command/requirementdiff/v1_adapter.go": {}, + "internal/command/requirementgraph/requirementgraph_test.go": {}, } legacy := []string{ "BaselineVerification", diff --git a/internal/app/testdata/compact-current-production-consumers.json b/internal/app/testdata/compact-current-production-consumers.json index ed44249..cc99cfe 100644 --- a/internal/app/testdata/compact-current-production-consumers.json +++ b/internal/app/testdata/compact-current-production-consumers.json @@ -10,6 +10,7 @@ "internal/app/command_registry.go", "internal/app/conformance_command.go", "internal/app/project_status_command.go", + "internal/app/project_view_command.go", "internal/app/requirement_browser_command.go", "internal/app/requirement_commands.go", "internal/app/requirement_context_command.go", @@ -19,17 +20,20 @@ "internal/command/adoptionmaterialization/admission.go", "internal/command/adoptionmaterialization/build.go", "internal/command/adoptionmaterialization/project_closure.go", + "internal/command/adoptionmaterialization/project_projection.go", "internal/command/conformanceprofile/conformanceprofile.go", "internal/command/impact/impact.go", "internal/command/pilotadmission/pilotadmission.go", "internal/command/projectstatus/inspect.go", "internal/command/proofbindingtestinventory/proofbindingtestinventory.go", "internal/command/requirementbinding/requirementbinding.go", + "internal/command/requirementbrowser/project.go", "internal/command/requirementbrowser/requirementbrowser.go", "internal/command/requirementbrowser/server.go", "internal/command/requirementbrowser/workspace.go", "internal/command/requirementcontext/compose.go", "internal/command/requirementcontext/model.go", + "internal/command/requirementcontext/project_origin.go", "internal/command/requirementcontext/slice.go", "internal/command/requirementcontext/v1_adapter.go", "internal/command/requirementcoverageinput/requirementcoverageinput.go", diff --git a/internal/command/adoptionmaterialization/project_closure.go b/internal/command/adoptionmaterialization/project_closure.go index 0dabec3..a9671db 100644 --- a/internal/command/adoptionmaterialization/project_closure.go +++ b/internal/command/adoptionmaterialization/project_closure.go @@ -34,6 +34,7 @@ type RoutedProjectRecordAdmission struct { type MaterializedProjectAdmission struct { ClosureAdmitted bool ClosureEvaluated bool + Project *Project Records []RoutedProjectRecordAdmission } @@ -111,6 +112,9 @@ func AdmitMaterializedProject(manifest Manifest, records []RoutedProjectRecord) Manifest: manifest, Sources: children.sources, } result.ClosureAdmitted = validateMaterializedProjectSnapshot(snapshot) == nil + if result.ClosureAdmitted { + result.Project = &Project{snapshot: &snapshot} + } return result, nil } diff --git a/internal/command/adoptionmaterialization/project_closure_test.go b/internal/command/adoptionmaterialization/project_closure_test.go index 32b364e..f963497 100644 --- a/internal/command/adoptionmaterialization/project_closure_test.go +++ b/internal/command/adoptionmaterialization/project_closure_test.go @@ -169,7 +169,7 @@ func TestAdmitMaterializedProjectRoutesEveryManifestArtifactKindThroughItsOwner( records = append(records, RoutedProjectRecord{Content: artifact.Content, Path: artifact.Path}) } result, err := AdmitMaterializedProject(manifest, records) - if err != nil || !result.ClosureEvaluated || !result.ClosureAdmitted || len(result.Records) != len(manifest.Routes) { + if err != nil || !result.ClosureEvaluated || !result.ClosureAdmitted || result.Project == nil || len(result.Records) != len(manifest.Routes) { t.Fatalf("AdmitMaterializedProject()=%#v, %v", result, err) } for _, item := range result.Records { @@ -195,7 +195,7 @@ func TestAdmitMaterializedProjectRoutesEveryManifestArtifactKindThroughItsOwner( routeAdmission = item } } - if err != nil || got.ClosureEvaluated || got.ClosureAdmitted || routeAdmission.DigestMatches || routeAdmission.Admitted { + if err != nil || got.ClosureEvaluated || got.ClosureAdmitted || got.Project != nil || routeAdmission.DigestMatches || routeAdmission.Admitted { t.Fatalf("mutated route admission=%#v, %v", got, err) } }) @@ -218,7 +218,7 @@ func TestAdmitMaterializedProjectRoutesEveryManifestArtifactKindThroughItsOwner( routeAdmission = item } } - if err != nil || got.ClosureEvaluated || got.ClosureAdmitted || !routeAdmission.DigestMatches || routeAdmission.Admitted { + if err != nil || got.ClosureEvaluated || got.ClosureAdmitted || got.Project != nil || !routeAdmission.DigestMatches || routeAdmission.Admitted { t.Fatalf("semantically invalid route admission=%#v, %v", got, err) } }) diff --git a/internal/command/adoptionmaterialization/project_projection.go b/internal/command/adoptionmaterialization/project_projection.go new file mode 100644 index 0000000..a471893 --- /dev/null +++ b/internal/command/adoptionmaterialization/project_projection.go @@ -0,0 +1,81 @@ +package adoptionmaterialization + +import ( + "fmt" + + "github.com/research-engineering/agentic-proofkit/internal/command/requirementbinding" + "github.com/research-engineering/agentic-proofkit/internal/command/requirementsourceadmission" + "github.com/research-engineering/agentic-proofkit/internal/command/testevidenceinventory" + "github.com/research-engineering/agentic-proofkit/internal/kernel/admit" +) + +// Project retains only a complete, child-admitted, cross-record-closed project. +// It does not establish repository freshness or native witness execution. +type Project struct { + snapshot *materializedProjectSnapshot +} + +// JSONValue delegates child serialization to each semantic owner and returns +// fresh nested values, so consumers cannot mutate the retained cohort. +func (project *Project) JSONValue() (map[string]any, error) { + if project == nil || project.snapshot == nil { + return nil, fmt.Errorf("materialized project is unavailable") + } + snapshot := project.snapshot + sources := make([]any, 0, len(snapshot.Sources)) + for _, source := range snapshot.Sources { + sources = append(sources, requirementsourceadmission.SourceValue(source)) + } + return map[string]any{ + "manifest": snapshot.Manifest.JSONValue(), + "proofBinding": requirementbinding.InputValue(snapshot.Binding), + "requirementSources": sources, + "testEvidenceInventory": testevidenceinventory.InventoryValue(snapshot.Inventory), + }, nil +} + +// AdmitProject replays a logical project projection through the materialization +// owner. Route digests bind canonical child bytes, not a new filesystem read. +func AdmitProject(raw any) (*Project, error) { + record, ok := raw.(map[string]any) + if !ok { + return nil, fmt.Errorf("materialized project must be an object") + } + if err := admit.KnownKeys(record, []string{"manifest", "proofBinding", "requirementSources", "testEvidenceInventory"}, "materialized project"); err != nil { + return nil, err + } + manifest, err := AdmitManifest(record["manifest"]) + if err != nil { + return nil, err + } + sources, err := admitSources(record["requirementSources"]) + if err != nil { + return nil, err + } + var bindingPath, inventoryPath string + for _, route := range manifest.Routes { + switch route.ArtifactKind { + case ArtifactRequirementBinding: + bindingPath = route.Path + case ArtifactTestInventory: + inventoryPath = route.Path + } + } + _, binding, err := admitBindingArtifact(map[string]any{"path": bindingPath, "record": record["proofBinding"]}) + if err != nil { + return nil, err + } + _, inventory, err := admitInventoryArtifact(map[string]any{"path": inventoryPath, "record": record["testEvidenceInventory"]}) + if err != nil { + return nil, err + } + snapshot := materializedProjectSnapshot{ + Binding: binding, BindingPath: bindingPath, + Inventory: inventory, InventoryPath: inventoryPath, + Manifest: manifest, Sources: sources, + } + if err := validateMaterializedProjectSnapshot(snapshot); err != nil { + return nil, err + } + return &Project{snapshot: &snapshot}, nil +} diff --git a/internal/command/adoptionmaterialization/project_projection_test.go b/internal/command/adoptionmaterialization/project_projection_test.go new file mode 100644 index 0000000..0cec294 --- /dev/null +++ b/internal/command/adoptionmaterialization/project_projection_test.go @@ -0,0 +1,236 @@ +package adoptionmaterialization + +import ( + "bytes" + "reflect" + "sort" + "testing" + + "github.com/research-engineering/agentic-proofkit/internal/kernel/admission" +) + +func TestProjectProjectionPreservesIndependentChildExpectations(t *testing.T) { + raw := projectionFixtureRequest(t) + manifest, records := projectFixtureRecords(t, raw) + admitted, err := AdmitMaterializedProject(manifest, records) + if err != nil || !admitted.ClosureAdmitted || admitted.Project == nil { + t.Fatalf("project admission failed: %#v, %v", admitted, err) + } + value, err := admitted.Project.JSONValue() + if err != nil { + t.Fatal(err) + } + expectedInventory := cloneValue(t, raw["testEvidenceInventory"].(map[string]any)["record"]).(map[string]any) + expectedInventory["nonClaims"] = []any{ + "Pilot inventory fixture does not execute native tests.", + "Test evidence inventory reports do not approve merge, release, rollout, or repository policy.", + "Test evidence inventory reports do not authenticate runner output or receipt producers.", + "Test evidence inventory reports do not execute native tests.", + "Test evidence inventory reports do not prove repository inventory completeness.", + "Test evidence inventory reports do not resolve selectors or review caller-authored oracle quality.", + "Test evidence inventory reports do not verify that a caller-declared falsifier supersession dominates the superseded falsifier.", + } + expected := map[string]any{ + "manifest": manifest.JSONValue(), + "proofBinding": raw["requirementProofBinding"].(map[string]any)["record"], + "requirementSources": raw["requirementSources"], + "testEvidenceInventory": expectedInventory, + } + if !reflect.DeepEqual(value, expected) { + t.Fatal("project projection differs from independently authored child fields") + } + replayed, err := AdmitProject(value) + if err != nil { + t.Fatal(err) + } + replayedValue, err := replayed.JSONValue() + if err != nil || !reflect.DeepEqual(replayedValue, expected) { + t.Fatalf("project replay lost canonical child fields: %v", err) + } + // Mutate both a projection and all original input carriers after admission. + value["requirementSources"].([]any)[0].(map[string]any)["nonClaims"].([]any)[0] = "Changed source restriction." + value["proofBinding"].(map[string]any)["bindings"].([]any)[0].(map[string]any)["witnessSelectors"].([]any)[0].(map[string]any)["selector"] = "ChangedSelector" + value["testEvidenceInventory"].(map[string]any)["entries"].([]any)[0].(map[string]any)["qualityFindings"].([]any)[0].(map[string]any)["nonClaims"].([]any)[0] = "Changed finding restriction." + value["manifest"].(map[string]any)["routes"].([]any)[0].(map[string]any)["path"] = "changed" + for index := range records { + clear(records[index].Content) + } + manifest.Routes[0].Path = "changed" + for _, project := range []*Project{admitted.Project, replayed} { + fresh, err := project.JSONValue() + if err != nil || !reflect.DeepEqual(fresh, expected) { + t.Fatalf("retained project aliases caller data: %v", err) + } + } +} + +func TestProjectProjectionRejectsZeroAndIncompleteProjects(t *testing.T) { + for _, project := range []*Project{nil, {}} { + if value, err := project.JSONValue(); err == nil || value != nil { + t.Fatal("zero project produced an authoritative projection") + } + } + manifest, records := projectFixtureRecords(t, projectionFixtureRequest(t)) + for _, count := range []int{0, 1, len(records) - 1} { + result, err := AdmitMaterializedProject(manifest, records[:count]) + if err != nil || result.Project != nil || result.ClosureEvaluated || result.ClosureAdmitted { + t.Fatalf("partial project retained semantic data: %#v, %v", result, err) + } + } + for index := range records { + mutant := snapshotRoutedProjectRecords(records) + mutant[index].Content[0] ^= 1 + result, err := AdmitMaterializedProject(manifest, mutant) + if err != nil || result.Project != nil || result.ClosureEvaluated || result.ClosureAdmitted { + t.Fatalf("digest-mismatched project retained semantic data: %#v, %v", result, err) + } + } +} + +func TestProjectReplayRejectsChangedProjectionOperands(t *testing.T) { + manifest, records := projectFixtureRecords(t, projectionFixtureRequest(t)) + admitted, err := AdmitMaterializedProject(manifest, records) + if err != nil || admitted.Project == nil { + t.Fatal("positive project prerequisite failed") + } + tests := []struct { + name string + mutate func(map[string]any) + }{ + {"source", func(value map[string]any) { + value["requirementSources"].([]any)[0].(map[string]any)["nonClaims"] = []any{"Different source restriction."} + }}, + {"binding", func(value map[string]any) { + value["proofBinding"].(map[string]any)["nonClaims"] = []any{"Different binding restriction."} + }}, + {"inventory", func(value map[string]any) { + value["testEvidenceInventory"].(map[string]any)["nonClaims"] = []any{"Different inventory restriction."} + }}, + {"project identity", func(value map[string]any) { value["manifest"].(map[string]any)["projectId"] = "different.project" }}, + {"duplicate source", func(value map[string]any) { + sources := value["requirementSources"].([]any) + value["requirementSources"] = append(sources, sources[0]) + }}, + {"unknown field", func(value map[string]any) { value["unowned"] = true }}, + } + for _, key := range []string{"manifest", "proofBinding", "requirementSources", "testEvidenceInventory"} { + tests = append(tests, struct { + name string + mutate func(map[string]any) + }{"missing " + key, func(value map[string]any) { delete(value, key) }}) + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + value, err := admitted.Project.JSONValue() + if err != nil { + t.Fatal(err) + } + test.mutate(value) + if project, err := AdmitProject(value); err == nil || project != nil { + t.Fatal("changed project operand survived owner replay") + } + }) + } + if project, err := AdmitProject(nil); err == nil || project != nil { + t.Fatal("non-object project survived replay") + } +} + +func TestProjectRetentionRejectsDigestMatchedCrossRecordContradiction(t *testing.T) { + request, err := admitRequest(projectionFixtureRequest(t)) + if err != nil { + t.Fatal(err) + } + // Change only the cross-owner relation, then recompute every byte identity. + request.Binding.Requirements[0].OwnerID = "different.owner" + artifacts, err := childArtifacts(request) + if err != nil { + t.Fatal(err) + } + manifest, err := buildManifest(request, artifacts) + if err != nil { + t.Fatal(err) + } + records := routedRecords(artifacts) + result, err := AdmitMaterializedProject(manifest, records) + if err != nil || !result.ClosureEvaluated || result.ClosureAdmitted || result.Project != nil { + t.Fatalf("cross-record contradiction was not isolated: %#v, %v", result, err) + } + for _, child := range result.Records { + if !child.Admitted || !child.DigestMatches { + t.Fatal("negative fixture failed before cross-record closure") + } + } + projection := map[string]any{"manifest": manifest.JSONValue()} + for _, artifact := range artifacts { + raw, err := admission.DecodeJSON(bytes.NewReader(artifact.Content), int64(len(artifact.Content))) + if err != nil { + t.Fatal(err) + } + switch artifact.Kind { + case ArtifactRequirementSource: + projection["requirementSources"] = []any{raw} + case ArtifactRequirementBinding: + projection["proofBinding"] = raw + case ArtifactTestInventory: + projection["testEvidenceInventory"] = raw + } + } + if project, err := AdmitProject(projection); err == nil || project != nil { + t.Fatal("projection replay admitted the same cross-record contradiction") + } +} + +func projectionFixtureRequest(t *testing.T) map[string]any { + t.Helper() + raw := validRequest(t, t.TempDir()) + source := raw["requirementSources"].([]any)[0].(map[string]any) + requirement := source["requirements"].([]any)[0].(map[string]any) + requirement["claimLevel"] = "deferred" + requirement["nonClaimRefs"] = []any{"pilot.nonclaim.execution"} + requirement["deferral"] = map[string]any{ + "evidenceRefs": []any{"docs/evidence/pilot.md"}, "expiryRef": "pilot.expiry.review", + "mergePolicy": "pilot.merge.policy", "ownerId": "pilot.owner", + "reviewCondition": "Revisit after independent consumer evidence.", "riskAcceptedBy": "pilot.reviewer", + } + binding := raw["requirementProofBinding"].(map[string]any)["record"].(map[string]any) + binding["requirements"].([]any)[0].(map[string]any)["claimLevel"] = "deferred" + binding["bindings"].([]any)[0].(map[string]any)["witnessSelectors"] = []any{map[string]any{ + "command": "go test ./internal/pilot -run TestMaterialization", "selector": "TestMaterialization", + }} + inventory := raw["testEvidenceInventory"].(map[string]any)["record"].(map[string]any) + inventory["ownerId"] = "pilot.inventory.owner" + inventory["sourceId"] = "pilot.inventory.source" + inventory["entries"].([]any)[0].(map[string]any)["qualityFindings"] = []any{map[string]any{ + "class": "missing_edge", "evidenceRefs": []any{"proof.pilot.quality"}, "findingId": "pilot.quality.candidate", + "nonClaims": []any{"Candidate finding is not an owner verdict."}, "ownerReviewState": "candidate", "severity": "warning", + }} + return raw +} + +func projectFixtureRecords(t *testing.T, raw map[string]any) (Manifest, []RoutedProjectRecord) { + t.Helper() + request, err := admitRequest(raw) + if err != nil { + t.Fatal(err) + } + artifacts, err := childArtifacts(request) + if err != nil { + t.Fatal(err) + } + manifest, err := buildManifest(request, artifacts) + if err != nil { + t.Fatal(err) + } + return manifest, routedRecords(artifacts) +} + +func routedRecords(artifacts []artifact) []RoutedProjectRecord { + records := make([]RoutedProjectRecord, 0, len(artifacts)) + for _, artifact := range artifacts { + records = append(records, RoutedProjectRecord{Content: artifact.Content, Path: artifact.Path}) + } + // Input arrival order is not a second canonical route order. + sort.Slice(records, func(left, right int) bool { return records[left].Path > records[right].Path }) + return records +} diff --git a/internal/command/projectstatus/inspect.go b/internal/command/projectstatus/inspect.go index 39b3b95..7cdd4f7 100644 --- a/internal/command/projectstatus/inspect.go +++ b/internal/command/projectstatus/inspect.go @@ -26,6 +26,13 @@ type cohortEntry struct { state fileState } +type childInspection struct { + children []childObservation + closure ClosureState + cohort []cohortEntry + project *adoptionmaterialization.Project +} + var defaultInspectionDependencies = inspectionDependencies{ inspectControl: func(ctx context.Context, lease *repositorytransaction.InspectionLease) (repositorytransaction.ControlInspection, error) { return lease.InspectControlState(ctx) @@ -40,26 +47,35 @@ func Inspect(ctx context.Context, repositoryRoot string) (Status, error) { return inspectWithDependencies(ctx, repositoryRoot, defaultInspectionDependencies) } +func InspectProject(ctx context.Context, repositoryRoot string) (Inspection, error) { + return inspectProjectWithDependencies(ctx, repositoryRoot, defaultInspectionDependencies) +} + func inspectWithDependencies(ctx context.Context, repositoryRoot string, dependencies inspectionDependencies) (Status, error) { + inspection, err := inspectProjectWithDependencies(ctx, repositoryRoot, dependencies) + return inspection.Status, err +} + +func inspectProjectWithDependencies(ctx context.Context, repositoryRoot string, dependencies inspectionDependencies) (Inspection, error) { if dependencies.inspectControl == nil || dependencies.readFile == nil { - return Status{}, fmt.Errorf("project status inspection dependencies are incomplete") + return Inspection{}, fmt.Errorf("project status inspection dependencies are incomplete") } for attempt := 0; attempt < 2; attempt++ { - status, err := inspectAttempt(ctx, repositoryRoot, dependencies) + inspection, err := inspectAttempt(ctx, repositoryRoot, dependencies) if err == nil { - return status, nil + return inspection, nil } if !errors.Is(err, errSnapshotChanged) && !errors.Is(err, repositorytransaction.ErrControlStateChanged) { - return Status{}, err + return Inspection{}, err } } - return Status{}, fmt.Errorf("project status repository changed during both bounded inspection attempts") + return Inspection{}, fmt.Errorf("project status repository changed during both bounded inspection attempts") } -func inspectAttempt(ctx context.Context, repositoryRoot string, dependencies inspectionDependencies) (status Status, returnErr error) { +func inspectAttempt(ctx context.Context, repositoryRoot string, dependencies inspectionDependencies) (inspection Inspection, returnErr error) { lease, err := repositorytransaction.OpenInspectionLease(ctx, repositoryRoot) if err != nil { - return Status{}, err + return Inspection{}, err } closeLease := dependencies.closeLease if closeLease == nil { @@ -67,20 +83,20 @@ func inspectAttempt(ctx context.Context, repositoryRoot string, dependencies ins } defer func() { if closeErr := closeLease(lease); closeErr != nil { - status = Status{} + inspection = Inspection{} returnErr = fmt.Errorf("close project status inspection: %w", closeErr) } }() before, err := dependencies.inspectControl(ctx, lease) if err != nil { - return Status{}, err + return Inspection{}, err } if err := lease.VerifyRootIdentity(); err != nil { - return Status{}, err + return Inspection{}, err } transaction, err := observeTransaction(before) if err != nil { - return Status{}, err + return Inspection{}, err } snapshot := inspectionSnapshot{ ClosureState: ClosureNotEvaluated, @@ -91,33 +107,33 @@ func inspectAttempt(ctx context.Context, repositoryRoot string, dependencies ins if transaction.State == TransactionClean { snapshot, cohort, err = inspectProjectFiles(ctx, lease, transaction, dependencies.readFile) if err != nil { - return Status{}, err + return Inspection{}, err } if err := verifyCohort(ctx, lease, cohort, dependencies.readFile); err != nil { - return Status{}, err + return Inspection{}, err } } after, err := dependencies.inspectControl(ctx, lease) if err != nil { - return Status{}, err + return Inspection{}, err } if before != after { - return Status{}, errSnapshotChanged + return Inspection{}, errSnapshotChanged } if err := lease.VerifyRootIdentity(); err != nil { - return Status{}, err + return Inspection{}, err } if err := ctx.Err(); err != nil { - return Status{}, fmt.Errorf("project status inspection cancelled before evaluation: %w", err) + return Inspection{}, fmt.Errorf("project status inspection cancelled before evaluation: %w", err) } - status, err = evaluate(snapshot) + status, err := evaluate(snapshot) if err != nil { - return Status{}, err + return Inspection{}, err } if err := ctx.Err(); err != nil { - return Status{}, fmt.Errorf("project status inspection cancelled before completion: %w", err) + return Inspection{}, fmt.Errorf("project status inspection cancelled before completion: %w", err) } - return status, nil + return Inspection{ManifestContentDigest: snapshot.Manifest.ContentDigest, Project: snapshot.project, Status: status}, nil } func observeTransaction(value repositorytransaction.ControlInspection) (transactionObservation, error) { @@ -170,24 +186,25 @@ func inspectProjectFiles(ctx context.Context, lease *repositorytransaction.Inspe } snapshot.Manifest = manifestObservation{ContentDigest: manifestFile.digest, ManifestID: manifest.ManifestID, State: ManifestAdmitted} snapshot.ProjectID = manifest.ProjectID - children, closure, childCohort, err := inspectChildren(ctx, lease, manifest, budget, readFile) + children, err := inspectChildren(ctx, lease, manifest, budget, readFile) if err != nil { return inspectionSnapshot{}, nil, err } - cohort = append(cohort, childCohort...) - snapshot.Children = children - snapshot.ClosureState = closure + cohort = append(cohort, children.cohort...) + snapshot.Children = children.children + snapshot.ClosureState = children.closure + snapshot.project = children.project return snapshot, cohort, nil } -func inspectChildren(ctx context.Context, lease *repositorytransaction.InspectionLease, manifest adoptionmaterialization.Manifest, budget *readBudget, readFile projectFileReader) ([]childObservation, ClosureState, []cohortEntry, error) { +func inspectChildren(ctx context.Context, lease *repositorytransaction.InspectionLease, manifest adoptionmaterialization.Manifest, budget *readBudget, readFile projectFileReader) (childInspection, error) { observations := make(map[string]fileObservation, len(manifest.Routes)) records := make([]adoptionmaterialization.RoutedProjectRecord, 0, len(manifest.Routes)) cohort := make([]cohortEntry, 0, len(manifest.Routes)) for _, route := range manifest.Routes { file, err := readFile(ctx, lease, route.Path, budget) if err != nil { - return nil, ClosureNotEvaluated, nil, err + return childInspection{}, err } observations[route.Path] = file cohort = append(cohort, cohortEntry{digest: file.digest, path: route.Path, state: file.state}) @@ -197,7 +214,7 @@ func inspectChildren(ctx context.Context, lease *repositorytransaction.Inspectio } admissionResult, err := adoptionmaterialization.AdmitMaterializedProject(manifest, records) if err != nil { - return nil, ClosureNotEvaluated, nil, err + return childInspection{}, err } admissions := make(map[string]adoptionmaterialization.RoutedProjectRecordAdmission, len(admissionResult.Records)) for _, item := range admissionResult.Records { @@ -223,7 +240,7 @@ func inspectChildren(ctx context.Context, lease *repositorytransaction.Inspectio child.State = ChildAdmitted } default: - return nil, ClosureNotEvaluated, nil, fmt.Errorf("project status file owner returned an unsupported state") + return childInspection{}, fmt.Errorf("project status file owner returned an unsupported state") } children = append(children, child) } @@ -234,7 +251,7 @@ func inspectChildren(ctx context.Context, lease *repositorytransaction.Inspectio closure = ClosureAdmitted } } - return children, closure, cohort, nil + return childInspection{children: children, closure: closure, cohort: cohort, project: admissionResult.Project}, nil } func verifyCohort(ctx context.Context, lease *repositorytransaction.InspectionLease, cohort []cohortEntry, readFile projectFileReader) error { diff --git a/internal/command/projectstatus/inspect_test.go b/internal/command/projectstatus/inspect_test.go index f57c6d0..dc19f56 100644 --- a/internal/command/projectstatus/inspect_test.go +++ b/internal/command/projectstatus/inspect_test.go @@ -27,12 +27,13 @@ func TestInspectClassifiesMaterializedProjectWithoutApplicationWrites(t *testing commandcoverage.SemanticRoute(t, "proofkit.command_coverage.source_oracle.v1.068153284639677751912209073851318961044240216422390589277786880896123148215480") root := t.TempDir() before := snapshotProjectTree(t, root) - status, err := Inspect(context.Background(), root) + inspection, err := InspectProject(context.Background(), root) if err != nil { t.Fatal(err) } + status := inspection.Status assertProjectTreeUnchanged(t, root, before) - if status.ProjectState != StateUninitialized || status.NextAction.ActionClass != ActionChooseAdoptionMode { + if inspection.Project != nil || status.ProjectState != StateUninitialized || status.NextAction.ActionClass != ActionChooseAdoptionMode { t.Fatalf("Inspect() = %#v", status) } if _, err := os.Stat(filepath.Join(root, repositorytransaction.ControlRoot)); !errors.Is(err, os.ErrNotExist) { @@ -41,12 +42,13 @@ func TestInspectClassifiesMaterializedProjectWithoutApplicationWrites(t *testing materializeTestProject(t, root) before = snapshotProjectTree(t, root) - status, err = Inspect(context.Background(), root) + inspection, err = InspectProject(context.Background(), root) if err != nil { t.Fatal(err) } + status = inspection.Status assertProjectTreeUnchanged(t, root, before) - if status.ProjectState != StateVerificationRequired || status.ProjectID != "pilot.project" || status.ManifestID == "" { + if inspection.Project == nil || status.ProjectState != StateVerificationRequired || status.ProjectID != "pilot.project" || status.ManifestID == "" { t.Fatalf("Inspect() = %#v", status) } @@ -55,12 +57,13 @@ func TestInspectClassifiesMaterializedProjectWithoutApplicationWrites(t *testing t.Fatal(err) } before = snapshotProjectTree(t, root) - status, err = Inspect(context.Background(), root) + inspection, err = InspectProject(context.Background(), root) if err != nil { t.Fatal(err) } + status = inspection.Status assertProjectTreeUnchanged(t, root, before) - if status.ProjectState != StateStale || !reflectIssue(status.IssueCodes, IssueChildDigestMismatch) { + if inspection.Project != nil || status.ProjectState != StateStale || !reflectIssue(status.IssueCodes, IssueChildDigestMismatch) { t.Fatalf("Inspect() after drift = %#v", status) } } @@ -118,11 +121,12 @@ func TestInspectRejectsAdmittedChildrenWithInvalidCrossRecordClosure(t *testing. root := t.TempDir() materializeTestProject(t, root) breakMaterializedProjectClosure(t, root) - status, err := Inspect(context.Background(), root) + inspection, err := InspectProject(context.Background(), root) if err != nil { t.Fatal(err) } - if status.ProjectState != StateBlocked || !reflectIssue(status.IssueCodes, IssueClosureInvalid) { + status := inspection.Status + if inspection.Project != nil || status.ProjectState != StateBlocked || !reflectIssue(status.IssueCodes, IssueClosureInvalid) { t.Fatalf("Inspect() = %#v, want blocked closure-invalid status", status) } } @@ -237,13 +241,14 @@ func TestInspectCohortValidationClosesCleanEpochABA(t *testing.T) { } return observation, err } - status, err := inspectWithDependencies(context.Background(), root, dependencies) + inspection, err := inspectProjectWithDependencies(context.Background(), root, dependencies) if changeDigest { + assertEmptyInspection(t, inspection) if err == nil || !strings.Contains(err.Error(), "both bounded inspection attempts") || pathReads != 4 { t.Fatalf("digest drift error=%v reads=%d, want rejection after two two-pass attempts", err, pathReads) } - } else if err != nil || status.ProjectState != StateVerificationRequired || pathReads != 2 { - t.Fatalf("stable cohort state=%s error=%v reads=%d", status.ProjectState, err, pathReads) + } else if err != nil || inspection.Project == nil || inspection.Status.ProjectState != StateVerificationRequired || pathReads != 2 { + t.Fatalf("stable cohort state=%s error=%v reads=%d", inspection.Status.ProjectState, err, pathReads) } }) } @@ -272,7 +277,9 @@ func TestInspectCleanupFailureDominatesRetryableSnapshotChange(t *testing.T) { return errors.New("injected inspection cleanup failure") }, } - if _, err := inspectWithDependencies(context.Background(), t.TempDir(), dependencies); err == nil || !strings.Contains(err.Error(), "cleanup failure") || errors.Is(err, errSnapshotChanged) { + inspection, err := inspectProjectWithDependencies(context.Background(), t.TempDir(), dependencies) + assertEmptyInspection(t, inspection) + if err == nil || !strings.Contains(err.Error(), "cleanup failure") || errors.Is(err, errSnapshotChanged) { t.Fatalf("inspectWithDependencies() error=%v, want terminal cleanup failure", err) } if controlReads != 2 || closeCalls != 1 { @@ -328,11 +335,12 @@ func TestInspectMapsRecoverableControlState(t *testing.T) { return fileObservation{}, nil }, } - status, err := inspectWithDependencies(context.Background(), t.TempDir(), dependencies) + inspection, err := inspectProjectWithDependencies(context.Background(), t.TempDir(), dependencies) if err != nil { t.Fatal(err) } - if status.ProjectState != StateRecoveryRequired || status.NextAction.ActionClass != ActionChooseRecovery || status.NextAction.ContextRef != transactionID { + status := inspection.Status + if inspection.Project != nil || inspection.ManifestContentDigest != "" || status.ProjectState != StateRecoveryRequired || status.NextAction.ActionClass != ActionChooseRecovery || status.NextAction.ContextRef != transactionID { t.Fatalf("inspectWithDependencies() = %#v", status) } } @@ -346,11 +354,12 @@ func TestInspectMapsInvalidControlState(t *testing.T) { if err := os.WriteFile(filepath.Join(controlDirectory, "unknown"), []byte("opaque"), 0o600); err != nil { t.Fatal(err) } - status, err := Inspect(context.Background(), root) + inspection, err := InspectProject(context.Background(), root) if err != nil { t.Fatal(err) } - if status.ProjectState != StateBlocked || status.NextAction.ActionClass != ActionRepairControlState || !reflectIssue(status.IssueCodes, IssueTransactionInvalid) { + status := inspection.Status + if inspection.Project != nil || inspection.ManifestContentDigest != "" || status.ProjectState != StateBlocked || status.NextAction.ActionClass != ActionRepairControlState || !reflectIssue(status.IssueCodes, IssueTransactionInvalid) { t.Fatalf("Inspect()=%#v, want invalid transaction classification", status) } var expectedStatus Status @@ -388,6 +397,7 @@ func TestInspectAttemptRejectsFinalRepositoryRootReplacement(t *testing.T) { if err := os.Mkdir(root, 0o755); err != nil { t.Fatal(err) } + materializeTestProject(t, root) control := repositorytransaction.ControlInspection{EpochID: digest.SHA256TextRef("stable epoch"), State: repositorytransaction.ControlStateClean} controlReads := 0 dependencies := inspectionDependencies{ @@ -403,11 +413,11 @@ func TestInspectAttemptRejectsFinalRepositoryRootReplacement(t *testing.T) { } return control, nil }, - readFile: func(context.Context, *repositorytransaction.InspectionLease, string, *readBudget) (fileObservation, error) { - return fileObservation{state: fileMissing}, nil - }, + readFile: readProjectFile, } - if _, err := inspectAttempt(context.Background(), root, dependencies); !errors.Is(err, repositorytransaction.ErrControlStateChanged) { + inspection, err := inspectAttempt(context.Background(), root, dependencies) + assertEmptyInspection(t, inspection) + if !errors.Is(err, repositorytransaction.ErrControlStateChanged) { t.Fatalf("inspectAttempt() error=%v, want repository-root change", err) } } @@ -426,7 +436,9 @@ func TestInspectRejectsChangingControlEpochAcrossBothAttempts(t *testing.T) { return fileObservation{state: fileMissing}, nil }, } - if _, err := inspectWithDependencies(context.Background(), t.TempDir(), dependencies); err == nil || !strings.Contains(err.Error(), "both bounded inspection attempts") { + inspection, err := inspectProjectWithDependencies(context.Background(), t.TempDir(), dependencies) + assertEmptyInspection(t, inspection) + if err == nil || !strings.Contains(err.Error(), "both bounded inspection attempts") { t.Fatalf("inspectWithDependencies() error = %v", err) } if controlReads != 4 { @@ -546,7 +558,9 @@ func TestOutOfBoundManifestIdentityIsAClassificationNotByteIdentity(t *testing.T func TestInspectHonorsCancellationBeforeReads(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) cancel() - if _, err := Inspect(ctx, t.TempDir()); !errors.Is(err, context.Canceled) { + inspection, err := InspectProject(ctx, t.TempDir()) + assertEmptyInspection(t, inspection) + if !errors.Is(err, context.Canceled) { t.Fatalf("Inspect() error = %v", err) } } @@ -567,7 +581,9 @@ func TestInspectHonorsCancellationBetweenBoundedReads(t *testing.T) { } return observation, err } - if _, err := inspectWithDependencies(ctx, root, dependencies); !errors.Is(err, context.Canceled) { + inspection, err := inspectProjectWithDependencies(ctx, root, dependencies) + assertEmptyInspection(t, inspection) + if !errors.Is(err, context.Canceled) { t.Fatalf("inspectWithDependencies() error = %v, want context cancellation", err) } if readCount != 1 { @@ -576,6 +592,8 @@ func TestInspectHonorsCancellationBetweenBoundedReads(t *testing.T) { } func TestInspectHonorsCancellationAfterFinalControlObservation(t *testing.T) { + root := t.TempDir() + materializeTestProject(t, root) ctx, cancel := context.WithCancel(context.Background()) controlReads := 0 dependencies := defaultInspectionDependencies @@ -589,7 +607,9 @@ func TestInspectHonorsCancellationAfterFinalControlObservation(t *testing.T) { State: repositorytransaction.ControlStateClean, }, nil } - if _, err := inspectWithDependencies(ctx, t.TempDir(), dependencies); !errors.Is(err, context.Canceled) { + inspection, err := inspectProjectWithDependencies(ctx, root, dependencies) + assertEmptyInspection(t, inspection) + if !errors.Is(err, context.Canceled) { t.Fatalf("inspectWithDependencies() error = %v, want context cancellation", err) } } diff --git a/internal/command/projectstatus/model.go b/internal/command/projectstatus/model.go index 09d07d5..d45a94f 100644 --- a/internal/command/projectstatus/model.go +++ b/internal/command/projectstatus/model.go @@ -6,6 +6,7 @@ import ( "encoding/json" "fmt" + "github.com/research-engineering/agentic-proofkit/internal/command/adoptionmaterialization" "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/repositorytransaction" @@ -118,6 +119,14 @@ type inspectionSnapshot struct { Manifest manifestObservation ProjectID string Transaction transactionObservation + project *adoptionmaterialization.Project +} + +// Inspection carries one completed cohort without expanding the public status. +type Inspection struct { + ManifestContentDigest string + Project *adoptionmaterialization.Project + Status Status } type NextAction struct { diff --git a/internal/command/projectstatus/project_inspection_test.go b/internal/command/projectstatus/project_inspection_test.go new file mode 100644 index 0000000..774df10 --- /dev/null +++ b/internal/command/projectstatus/project_inspection_test.go @@ -0,0 +1,213 @@ +package projectstatus + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + + "github.com/research-engineering/agentic-proofkit/internal/command/adoptionmaterialization" + "github.com/research-engineering/agentic-proofkit/internal/kernel/admission" + "github.com/research-engineering/agentic-proofkit/internal/kernel/repositorytransaction" +) + +func TestInspectProjectRetainsOriginalCohortAndStatusIdentity(t *testing.T) { + root := t.TempDir() + materializeTestProject(t, root) + unlistedSource, err := os.ReadFile(filepath.Join(root, "docs/specs/pilot/requirements.v1.json")) + if err != nil { + t.Fatal(err) + } + for name, content := range map[string][]byte{"unlisted-invalid.json": []byte("not JSON"), "unlisted-valid.json": unlistedSource} { + if err := os.WriteFile(filepath.Join(root, name), content, 0o600); err != nil { + t.Fatal(err) + } + } + wantProject, expectedPaths, manifestDigest := projectInspectionFixture(t, root) + before := snapshotProjectTree(t, root) + reads := map[string]int{} + var control repositorytransaction.ControlInspection + dependencies := defaultInspectionDependencies + dependencies.inspectControl = func(ctx context.Context, lease *repositorytransaction.InspectionLease) (repositorytransaction.ControlInspection, error) { + observed, err := lease.InspectControlState(ctx) + control = observed + return observed, err + } + dependencies.readFile = func(ctx context.Context, lease *repositorytransaction.InspectionLease, path string, budget *readBudget) (fileObservation, error) { + observed, err := readProjectFile(ctx, lease, path, budget) + if err != nil { + return fileObservation{}, err + } + reads[path]++ + if reads[path] == 2 { + // Deliberately inconsistent test seam: verification consumes only the + // state and digest, never a second semantic representation. + observed.content = []byte("not a JSON record") + } + return observed, nil + } + inspection, err := inspectProjectWithDependencies(context.Background(), root, dependencies) + if err != nil { + t.Fatal(err) + } + if inspection.Project == nil || inspection.ManifestContentDigest != manifestDigest || inspection.Status.ProjectState != StateVerificationRequired { + t.Fatalf("inspection did not retain the captured project: %#v", inspection) + } + actual, err := inspection.Project.JSONValue() + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(actual, wantProject) { + t.Fatal("retained project differs from the independently captured original records") + } + if len(reads) != len(expectedPaths) { + t.Fatalf("read %d paths, want exactly %d manifest-owned paths", len(reads), len(expectedPaths)) + } + for _, path := range expectedPaths { + if reads[path] != 2 { + t.Fatalf("path read count=%d, want one capture and one verification", reads[path]) + } + } + manifest := wantProject["manifest"].(map[string]any) + children := []any{} + for _, raw := range manifest["routes"].([]any) { + route := raw.(map[string]any) + children = append(children, map[string]any{ + "artifactKind": route["artifactKind"], "expectedDigest": route["artifactId"], + "observedDigest": route["artifactId"], "state": "admitted", + }) + } + // This is the predecessor identity preimage, not the production projection. + wantIdentity := map[string]any{ + "children": children, "closureState": "admitted", "schemaVersion": json.Number("1"), + "manifest": map[string]any{"contentDigest": manifestDigest, "manifestId": manifest["manifestId"], "state": "admitted"}, + "project": map[string]any{"projectId": "pilot.project", "state": "admitted"}, + "transaction": map[string]any{"epoch": control.EpochID, "state": "clean", "transactionId": nil}, + } + encoded, err := json.MarshalIndent(wantIdentity, "", " ") + if err != nil { + t.Fatal(err) + } + wantID := fmt.Sprintf("sha256:%x", sha256.Sum256(append(encoded, '\n'))) + if inspection.Status.SnapshotID != wantID { + t.Fatalf("snapshot identity=%s, want predecessor identity=%s", inspection.Status.SnapshotID, wantID) + } + status, err := Inspect(context.Background(), root) + if err != nil || !reflect.DeepEqual(status, inspection.Status) { + t.Fatalf("legacy status projection differs: %v", err) + } + assertProjectTreeUnchanged(t, root, before) +} + +func TestInspectProjectDoesNotRetainUnusableRecords(t *testing.T) { + for _, test := range []struct { + name string + path string + state ProjectState + }{ + {"missing manifest", adoptionmaterialization.ProjectManifestPath, StateUninitialized}, + {"invalid manifest", adoptionmaterialization.ProjectManifestPath, StateBlocked}, + {"missing source", "docs/specs/pilot/requirements.v1.json", StateStale}, + {"invalid source", "docs/specs/pilot/requirements.v1.json", StateStale}, + {"source symlink", "docs/specs/pilot/requirements.v1.json", StateBlocked}, + {"oversized source", "docs/specs/pilot/requirements.v1.json", StateBlocked}, + } { + t.Run(test.name, func(t *testing.T) { + root := t.TempDir() + materializeTestProject(t, root) + path := filepath.Join(root, filepath.FromSlash(test.path)) + var err error + switch test.name { + case "missing manifest", "missing source": + err = os.Remove(path) + case "invalid manifest", "invalid source": + err = os.WriteFile(path, []byte("{}\n"), 0o600) + case "source symlink": + if err = os.Remove(path); err == nil { + err = os.Symlink(filepath.Join(root, "README.md"), path) + } + case "oversized source": + err = os.WriteFile(path, make([]byte, MaximumFileBytes+1), 0o600) + } + if err != nil { + t.Fatal(err) + } + before := snapshotProjectTree(t, root) + inspection, err := InspectProject(context.Background(), root) + if err != nil || inspection.Project != nil || inspection.Status.ProjectState != test.state { + t.Fatalf("inspection=%#v error=%v, want %s without project", inspection, err, test.state) + } + assertProjectTreeUnchanged(t, root, before) + }) + } +} + +func TestInspectProjectCleanupFailureClearsWholeResult(t *testing.T) { + root := t.TempDir() + materializeTestProject(t, root) + before := snapshotProjectTree(t, root) + closeCalls := 0 + dependencies := defaultInspectionDependencies + dependencies.closeLease = func(lease *repositorytransaction.InspectionLease) error { + closeCalls++ + if err := lease.Close(); err != nil { + return err + } + return errors.New("injected cleanup failure") + } + inspection, err := inspectAttempt(context.Background(), root, dependencies) + assertEmptyInspection(t, inspection) + if err == nil || !strings.Contains(err.Error(), "cleanup failure") || closeCalls != 1 { + t.Fatalf("cleanup error=%v calls=%d, want one terminal failure", err, closeCalls) + } + assertProjectTreeUnchanged(t, root, before) +} + +func assertEmptyInspection(t *testing.T, inspection Inspection) { + t.Helper() + if !reflect.DeepEqual(inspection, Inspection{}) { + t.Fatal("failed inspection retained status, project, or observed digest") + } +} + +func projectInspectionFixture(t *testing.T, root string) (map[string]any, []string, string) { + t.Helper() + readRecord := func(path string) (map[string]any, []byte) { + content, err := os.ReadFile(filepath.Join(root, filepath.FromSlash(path))) + if err != nil { + t.Fatal(err) + } + value, err := admission.DecodeJSON(bytes.NewReader(content), MaximumFileBytes) + if err != nil { + t.Fatal(err) + } + return value.(map[string]any), content + } + manifest, content := readRecord(adoptionmaterialization.ProjectManifestPath) + paths := []string{adoptionmaterialization.ProjectManifestPath} + project := map[string]any{"manifest": manifest, "requirementSources": []any{}} + for _, raw := range manifest["routes"].([]any) { + route := raw.(map[string]any) + path := route["path"].(string) + paths = append(paths, path) + record, _ := readRecord(path) + switch route["artifactKind"] { + case "requirement_source": + project["requirementSources"] = append(project["requirementSources"].([]any), record) + case "requirement_proof_binding": + project["proofBinding"] = record + case "test_evidence_inventory": + project["testEvidenceInventory"] = record + default: + t.Fatal("unexpected artifact kind in independently authored project fixture") + } + } + return project, paths, fmt.Sprintf("sha256:%x", sha256.Sum256(content)) +} diff --git a/internal/command/requirementbrowser/http_handler.go b/internal/command/requirementbrowser/http_handler.go index cb3aa9c..2e04bae 100644 --- a/internal/command/requirementbrowser/http_handler.go +++ b/internal/command/requirementbrowser/http_handler.go @@ -724,12 +724,9 @@ func admitAnnotation(annotation map[string]any, session workspaceSession) (map[s if err := admit.KnownKeys(annotation, []string{"anchorId", "endCodePoint", "exactQuote", "question", "startCodePoint"}, "browser handoff annotation"); err != nil { return nil, err } - anchorID, err := admit.RuleID(annotation["anchorId"], "browser handoff anchorId") - if err != nil { - return nil, err - } - anchor, ok := session.Anchors[anchorID] - if !ok { + anchorID, isText := annotation["anchorId"].(string) + anchor, known := session.Anchors[anchorID] + if !isText || !known { return nil, fmt.Errorf("handoff references unknown anchor") } quote, ok := annotation["exactQuote"].(string) diff --git a/internal/command/requirementbrowser/project.go b/internal/command/requirementbrowser/project.go new file mode 100644 index 0000000..fc29a95 --- /dev/null +++ b/internal/command/requirementbrowser/project.go @@ -0,0 +1,80 @@ +package requirementbrowser + +import ( + "context" + "encoding/json" + "fmt" + "io" + + "github.com/research-engineering/agentic-proofkit/internal/command/projectstatus" + "github.com/research-engineering/agentic-proofkit/internal/command/requirementcontext" + "github.com/research-engineering/agentic-proofkit/internal/command/requirementgraph" +) + +func BuildProjectPlan(ctx context.Context, repositoryRoot string, options Options) (map[string]any, int, error) { + rendered, options, err := prepareProject(ctx, repositoryRoot, options) + if err != nil { + return nil, 1, err + } + return renderedPlan(rendered, options), 0, nil +} + +func StartProjectServer(ctx context.Context, repositoryRoot string, options Options) (ServerHandle, error) { + rendered, options, err := prepareProject(ctx, repositoryRoot, options) + if err != nil { + return ServerHandle{}, err + } + if err := ctx.Err(); err != nil { + return ServerHandle{}, err + } + return startRenderedServer(rendered, options) +} + +func ServeProject(ctx context.Context, repositoryRoot string, options Options, stdout io.Writer) error { + handle, err := StartProjectServer(ctx, repositoryRoot, options) + if err != nil { + return err + } + return serveHandle(ctx, handle, options, stdout) +} + +func prepareProject(ctx context.Context, repositoryRoot string, options Options) (renderedView, Options, error) { + return prepareProjectWithInspector(ctx, repositoryRoot, options, projectstatus.InspectProject) +} + +func prepareProjectWithInspector(ctx context.Context, repositoryRoot string, options Options, inspect func(context.Context, string) (projectstatus.Inspection, error)) (renderedView, Options, error) { + options, err := admitServerAddress(options) + if err != nil { + return renderedView{}, Options{}, err + } + if options.View != "" && options.View != "workspace" || options.ProofViewScope != "" || options.EmptyLocalEnvironmentPolicy || len(options.LocalEnvironmentClasses) != 0 { + return renderedView{}, Options{}, fmt.Errorf("project browser does not accept alternate views or proof policies") + } + options.View = "workspace" + if repositoryRoot == "" { + return renderedView{}, Options{}, fmt.Errorf("view requires an explicit --repo-root") + } + inspection, err := inspect(ctx, repositoryRoot) + if err != nil { + return renderedView{}, Options{}, fmt.Errorf("view could not inspect the project; use next with the same --repo-root") + } + if inspection.Project == nil { + return renderedView{}, Options{}, fmt.Errorf("view requires a complete admitted project; use next with the same --repo-root") + } + snapshot, err := requirementcontext.FromProject(inspection.Project, inspection.ManifestContentDigest) + if err != nil { + return renderedView{}, Options{}, fmt.Errorf("view could not prepare bounded project context") + } + graph, err := requirementgraph.Build(map[string]any{ + "schemaVersion": json.Number("2"), "graphId": snapshot.CatalogID, + "context": requirementcontext.SnapshotValue(snapshot), + }) + if err != nil { + return renderedView{}, Options{}, fmt.Errorf("view could not prepare the project relation graph") + } + session, document, err := prepareWorkspace(snapshot.CatalogID, snapshot, nil, graph, workspaceHTML) + if err != nil { + return renderedView{}, Options{}, fmt.Errorf("view could not prepare the project workspace") + } + return renderedView{authority: "presentation_adapter", html: document, viewKind: "proofkit.requirement-workspace", workspace: &session}, options, nil +} diff --git a/internal/command/requirementbrowser/project_test.go b/internal/command/requirementbrowser/project_test.go new file mode 100644 index 0000000..f648af2 --- /dev/null +++ b/internal/command/requirementbrowser/project_test.go @@ -0,0 +1,341 @@ +package requirementbrowser + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "net/http" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + "time" + + "github.com/research-engineering/agentic-proofkit/internal/command/adoptionmaterialization" + "github.com/research-engineering/agentic-proofkit/internal/command/projectstatus" + "github.com/research-engineering/agentic-proofkit/internal/command/requirementcontext" + "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/jsonpointer" + "github.com/research-engineering/agentic-proofkit/internal/testsupport/projectfixture" +) + +// Bound a stuck test after both cleanup phases, not the product's response latency. +const projectShutdownWatchdog = 2*serverShutdownTimeout + time.Second + +func TestProjectBrowserCapturesAndHandsOffExactSourceFacts(t *testing.T) { + for _, selected := range []int{1, 2} { + t.Run(fmt.Sprint(selected), func(t *testing.T) { + fixture := projectfixture.New(t) + if err := os.WriteFile(filepath.Join(fixture.Root, "initial-unlisted.json"), []byte("invalid ambient input"), 0o600); err != nil { + t.Fatal(err) + } + plan, exit, err := BuildProjectPlan(t.Context(), fixture.Root, Options{}) + if err != nil || exit != 0 || len(plan) != 12 || plan["view"] != "workspace" || plan["url"] != nil || plan["portSelection"] != "ephemeral" || plan["renderedAuthority"] != "presentation_adapter" || plan["planKind"] != "proofkit.requirement-browser-server-plan" { + t.Fatalf("project plan lost its bounded presentation contract: %v", err) + } + prepared, _, err := prepareProject(t.Context(), fixture.Root, Options{}) + if err != nil { + t.Fatal(err) + } + handle, err := StartProjectServer(t.Context(), fixture.Root, Options{SessionMode: "one-shot-question"}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = handle.Close(context.Background()) }) + var stdout bytes.Buffer + finished := make(chan error, 1) + go func() { + finished <- serveHandle(t.Context(), handle, Options{SessionMode: "one-shot-question"}, &stdout) + }() + response, err := (&http.Client{Timeout: 5 * time.Second}).Get(handle.URL) + if err != nil { + t.Fatal(err) + } + html, err := io.ReadAll(response.Body) + _ = response.Body.Close() + match := capabilityPattern.FindSubmatch(html) + if err != nil || response.StatusCode != http.StatusOK || len(match) != 2 || plan["htmlByteLength"] != len(html) || !strings.Contains(response.Header.Get("Content-Security-Policy"), "default-src 'none'") { + t.Fatal("project browser did not reuse the secured workspace document") + } + capability := string(match[1]) + for path := range fixture.Files { + if err := os.Remove(filepath.Join(fixture.Root, filepath.FromSlash(path))); err != nil { + t.Fatal(err) + } + } + if err := os.WriteFile(filepath.Join(fixture.Root, "unlisted.json"), []byte("invalid ambient input"), 0o600); err != nil { + t.Fatal(err) + } + manifest := projectHTTP(t, handle, capability, "manifest", nil, http.StatusOK) + if manifest["workspaceId"] != "shared.identity" || manifest["snapshotId"] != handle.SnapshotID || manifest["coverageAvailable"] != false || manifest["diffAvailable"] != false || manifest["graphAvailable"] != true || manifest["expectedDigestCoverage"] != "partial" || manifest["requirementCount"] != json.Number("3") { + t.Fatal("project manifest invented coverage, baseline or a different snapshot") + } + query := map[string]any{"requestId": "project.lookup", "snapshotId": handle.SnapshotID, "query": map[string]any{}} + rows := projectHTTP(t, handle, capability, "requirements", query, http.StatusOK)["projection"].(map[string]any)["requirements"].([]any) + assertWorkspaceRowIDs(t, rows, "requirementId", []string{"REQ-WIRE-001", "REQ-WIRE-002", "REQ-WIRE-003"}) + for _, endpoint := range []string{"coverage", "diff"} { + projectHTTP(t, handle, capability, endpoint, query, http.StatusNotFound) + } + graph := projectHTTP(t, handle, capability, "graph", query, http.StatusOK)["projection"].(map[string]any) + if len(graph["nodes"].([]any)) != 7 || len(graph["edges"].([]any)) != 6 { + t.Fatal("project graph did not preserve all three declarations and binding relations") + } + annotations := []any{} + for index, item := range []struct { + id, path, pointer, quote string + end int + }{ + {"REQ-WIRE-001", "docs/specs/a/requirements.v1.json", "/projections/requirementSources/1/requirements/0/invariant", "\U0001f9ed", 12}, + {"REQ-WIRE-002", "docs/specs/z/requirements.v1.json", "/projections/requirementSources/0/requirements/0/invariant", "E\u0301", 13}, + }[:selected] { + anchor := rows[index].(map[string]any)["anchor"].(map[string]any) + wantDigest := fmt.Sprintf("sha256:%x", sha256.Sum256(fixture.Files[item.path])) + if anchor["jsonPointer"] != item.pointer || anchor["sourceDigest"] != wantDigest || anchor["requirementId"] != item.id { + t.Fatal("lookup source order changed its original anchor identity") + } + original, err := jsonpointer.Select(requirementcontext.SnapshotValue(prepared.workspace.Snapshot), item.pointer) + if err != nil || original != rows[index].(map[string]any)["invariant"] { + t.Fatalf("original context pointer no longer resolves to lookup text: %v", err) + } + annotations = append(annotations, map[string]any{"anchorId": anchor["anchorId"], "startCodePoint": json.Number("11"), "endCodePoint": json.Number(fmt.Sprint(item.end)), "exactQuote": item.quote, "question": "Does this remain source-bound?"}) + } + packet := projectHTTP(t, handle, capability, "handoff", map[string]any{"annotations": annotations}, http.StatusOK) + select { + case err := <-finished: + if err != nil { + t.Fatal(err) + } + case <-time.After(projectShutdownWatchdog): + t.Fatal("project one-shot did not complete after handoff") + } + terminal, err := admission.DecodeJSON(bytes.NewReader(stdout.Bytes()), int64(stdout.Len())) + if err != nil || !reflect.DeepEqual(terminal, packet) || packet["state"] != "submitted" || packet["snapshotRefs"].([]any)[0].(map[string]any)["snapshotId"] != handle.SnapshotID { + t.Fatalf("terminal packet differs from the captured HTTP handoff: %v", err) + } + fragments := packet["context"].(map[string]any)["projections"].(map[string]any)["requirementSources"].([]any) + if len(fragments) != selected { + t.Fatal("terminal context included an omitted source") + } + for _, raw := range fragments { + fragment := raw.(map[string]any) + id, limitation, count, omitted := "REQ-WIRE-001", "Source a does not prove execution.", "1", "0" + if fragment["sourceId"] == "shared.identity" { + id, limitation, count, omitted = "REQ-WIRE-002", "Source z does not prove execution.", "2", "1" + } + if !reflect.DeepEqual(fragment["nonClaims"], []any{limitation}) || fragment["totalRequirementCount"] != json.Number(count) || fragment["omittedRequirementCount"] != json.Number(omitted) { + t.Fatal("terminal source-level restrictions or omissions were lost") + } + requirements := fragment["requirements"].([]any) + if len(requirements) != 1 || requirements[0].(map[string]any)["requirementId"] != id || !reflect.DeepEqual(requirements[0].(map[string]any)["nonClaims"], []any{"This fixture does not establish execution evidence."}) { + t.Fatal("terminal selected requirement or its independent restrictions were lost") + } + } + connection, err := net.DialTimeout("tcp", net.JoinHostPort(handle.Host, fmt.Sprint(handle.Port)), time.Second) + if err == nil { + _ = connection.Close() + t.Fatal("successful terminal output left the listener open") + } + }) + } +} + +func TestProjectWorkspacePreparationValidatesBeforeRendering(t *testing.T) { + fixture := projectfixture.New(t) + project, err := adoptionmaterialization.AdmitProject(fixture.Project) + if err != nil { + t.Fatal(err) + } + snapshot, err := requirementcontext.FromProject(project, fmt.Sprintf("sha256:%x", sha256.Sum256(fixture.Files[adoptionmaterialization.ProjectManifestPath]))) + if err != nil { + t.Fatal(err) + } + graph, err := requirementgraph.Build(map[string]any{"schemaVersion": json.Number("2"), "graphId": "project.graph", "context": requirementcontext.SnapshotValue(snapshot)}) + if err != nil { + t.Fatal(err) + } + calls := 0 + document := func(id string) string { calls++; return workspaceHTML(id) } + if _, _, err := prepareWorkspace("shared.identity", snapshot, nil, graph, document); err != nil || calls != 1 { + t.Fatalf("owner-valid project did not reach exactly one render: %v calls=%d", err, calls) + } + calls = 0 + graph["snapshotId"] = "different.snapshot" + if _, _, err := prepareWorkspace("shared.identity", snapshot, nil, graph, document); err == nil || calls != 0 { + t.Fatal("invalid graph reached rendering") + } + graph["snapshotId"] = snapshot.SnapshotID + graph["nodes"], graph["edges"] = []any{}, []any{} + graph["nodeCount"], graph["edgeCount"] = json.Number("0"), json.Number("0") + if _, err := requirementgraph.AdmitOutput(graph, snapshot.SnapshotID); err != nil { + t.Fatalf("closure falsifier failed before workspace guard: %v", err) + } + if _, _, err := prepareWorkspace("shared.identity", snapshot, nil, graph, document); err == nil || !strings.Contains(err.Error(), "does not close") || calls != 0 { + t.Fatal("individually admitted but incomplete graph reached rendering") + } +} + +func TestProjectHandoffPreservesLongRequirementIdentities(t *testing.T) { + for _, length := range []int{234, 235, 244, 245, 256} { + t.Run(fmt.Sprint(length), func(t *testing.T) { + id := "REQ-" + strings.Repeat("A", length-4) + fixture := projectfixture.WithRequirementIDs(t, [3]string{id, "REQ-WIRE-002", "REQ-WIRE-003"}) + handle, err := StartProjectServer(t.Context(), fixture.Root, Options{SessionMode: "one-shot-question"}) + if err != nil { + t.Fatalf("admitted requirement did not reach the workspace: %v", err) + } + t.Cleanup(func() { _ = handle.Close(context.Background()) }) + response, err := (&http.Client{Timeout: 5 * time.Second}).Get(handle.URL) + if err != nil { + t.Fatal(err) + } + body, err := io.ReadAll(response.Body) + _ = response.Body.Close() + match := capabilityPattern.FindSubmatch(body) + if err != nil || response.StatusCode != http.StatusOK || len(match) != 2 { + t.Fatal("workspace capability is unavailable") + } + capability := string(match[1]) + query := map[string]any{"requestId": "long.lookup", "snapshotId": handle.SnapshotID, "query": map[string]any{}} + rows := projectHTTP(t, handle, capability, "requirements", query, http.StatusOK)["projection"].(map[string]any)["requirements"].([]any) + anchor := rows[0].(map[string]any)["anchor"].(map[string]any) + anchorID := "requirement:" + id + ":invariant" + wantDigest := fmt.Sprintf("sha256:%x", sha256.Sum256(fixture.Files["docs/specs/a/requirements.v1.json"])) + if anchor["anchorId"] != anchorID || anchor["requirementId"] != id || anchor["sourceDigest"] != wantDigest || anchor["jsonPointer"] != "/projections/requirementSources/1/requirements/0/invariant" { + t.Fatal("workspace changed the original long requirement coordinate") + } + for _, invalid := range []any{json.Number("7"), "requirement:unknown:invariant", anchorID + "x", "requirement:" + strings.Repeat("A", 257) + ":invariant"} { + annotation := map[string]any{"anchorId": invalid, "startCodePoint": json.Number("11"), "endCodePoint": json.Number("12"), "exactQuote": "\U0001f9ed", "question": "Is this source-bound?"} + projectHTTP(t, handle, capability, "handoff", map[string]any{"annotations": []any{annotation}}, http.StatusBadRequest) + } + var stdout bytes.Buffer + finished := make(chan error, 1) + go func() { + finished <- serveHandle(t.Context(), handle, Options{SessionMode: "one-shot-question"}, &stdout) + }() + annotation := map[string]any{"anchorId": anchorID, "startCodePoint": json.Number("11"), "endCodePoint": json.Number("12"), "exactQuote": "\U0001f9ed", "question": "Is this source-bound?"} + packet := projectHTTP(t, handle, capability, "handoff", map[string]any{"annotations": []any{annotation}}, http.StatusOK) + select { + case err := <-finished: + if err != nil { + t.Fatal(err) + } + case <-time.After(projectShutdownWatchdog): + t.Fatal("long-ID handoff did not terminate") + } + terminal, err := admission.DecodeJSON(bytes.NewReader(stdout.Bytes()), int64(stdout.Len())) + if err != nil || !reflect.DeepEqual(terminal, packet) || !reflect.DeepEqual(packet["annotations"].([]any)[0].(map[string]any)["anchor"], anchor) { + t.Fatal("terminal handoff lost the browser-issued coordinate") + } + fragments := packet["context"].(map[string]any)["projections"].(map[string]any)["requirementSources"].([]any) + if len(fragments) != 1 || !reflect.DeepEqual(fragments[0].(map[string]any)["nonClaims"], []any{"Source a does not prove execution."}) || fragments[0].(map[string]any)["requirements"].([]any)[0].(map[string]any)["requirementId"] != id { + t.Fatal("long-ID handoff lost source restrictions or requirement identity") + } + connection, err := net.DialTimeout("tcp", net.JoinHostPort(handle.Host, fmt.Sprint(handle.Port)), time.Second) + if err == nil { + _ = connection.Close() + t.Fatal("long-ID handoff left the listener open") + } + }) + } +} + +func TestProjectBrowserRejectsIncompleteProjectBeforeListening(t *testing.T) { + occupied, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer occupied.Close() + options := Options{Port: occupied.Addr().(*net.TCPAddr).Port, PortSet: true} + root := t.TempDir() + handle, err := StartProjectServer(t.Context(), root, options) + var operation *net.OpError + if err == nil || errors.As(err, &operation) || !strings.Contains(err.Error(), "next with the same --repo-root") || handle.URL != "" || handle.Done() != nil { + t.Fatalf("incomplete project reached the occupied listener: %v", err) + } + fixture := projectfixture.New(t) + _, err = StartProjectServer(t.Context(), fixture.Root, options) + if !errors.As(err, &operation) || operation.Op != "listen" { + t.Fatalf("positive preparation sibling did not reach the actual listener: %v", err) + } + if err := os.WriteFile(filepath.Join(fixture.Root, "proofkit", "tests.json"), []byte("not JSON"), 0o600); err != nil { + t.Fatal(err) + } + _, err = StartProjectServer(t.Context(), fixture.Root, options) + if err == nil || errors.As(err, &operation) || !strings.Contains(err.Error(), "complete admitted project") { + t.Fatalf("invalid routed artifact reached listening or escaped static classification: %v", err) + } +} + +func TestProjectBrowserRejectsOptionsBeforeRepositoryRead(t *testing.T) { + for _, item := range []struct { + options Options + diagnostic string + }{ + {Options{Host: "localhost"}, "loopback literal"}, + {Options{Port: -1, PortSet: true}, "integer from 0 to 65535"}, + {Options{View: "proof"}, "does not accept"}, + {Options{ProofViewScope: "local"}, "does not accept"}, + {Options{EmptyLocalEnvironmentPolicy: true}, "does not accept"}, + {Options{LocalEnvironmentClasses: []string{"local-go"}}, "does not accept"}, + } { + plan, code, err := BuildProjectPlan(t.Context(), filepath.Join(t.TempDir(), "missing"), item.options) + if err == nil || !strings.Contains(err.Error(), item.diagnostic) || plan != nil || code != 1 { + t.Fatalf("invalid options reached repository inspection: %v", err) + } + calls := 0 + inspect := func(ctx context.Context, root string) (projectstatus.Inspection, error) { + calls++ + return projectstatus.InspectProject(ctx, root) + } + if _, _, err := prepareProjectWithInspector(t.Context(), t.TempDir(), item.options, inspect); err == nil || calls != 0 { + t.Fatalf("invalid options crossed the inspection boundary: calls=%d error=%v", calls, err) + } + } + fixture := projectfixture.New(t) + calls := 0 + inspect := func(ctx context.Context, root string) (projectstatus.Inspection, error) { + calls++ + return projectstatus.InspectProject(ctx, root) + } + rendered, _, err := prepareProjectWithInspector(t.Context(), fixture.Root, Options{}, inspect) + if err != nil || calls != 1 || rendered.workspace == nil { + t.Fatalf("valid sibling did not execute exactly one inspection: calls=%d error=%v", calls, err) + } +} + +func projectHTTP(t *testing.T, handle ServerHandle, capability, endpoint string, value any, expectedStatus int) map[string]any { + t.Helper() + method := http.MethodPost + var body io.Reader + if value == nil { + method = http.MethodGet + } else { + body = bytes.NewReader(stableWorkspaceBytes(t, value)) + } + request, err := http.NewRequest(method, handle.URL+"api/v1/"+endpoint, body) + if err != nil { + t.Fatal(err) + } + request.Header.Set("Origin", strings.TrimSuffix(handle.URL, "/")) + request.Header.Set("Content-Type", "application/json") + request.Header.Set("X-Proofkit-Browser-Capability", capability) + response, err := (&http.Client{Timeout: 5 * time.Second}).Do(request) + if err != nil { + t.Fatal(err) + } + defer response.Body.Close() + if response.StatusCode != expectedStatus { + t.Fatalf("project endpoint %s status=%d, expected %d", endpoint, response.StatusCode, expectedStatus) + } + if expectedStatus != http.StatusOK { + return nil + } + return decodeWorkspaceResponse(t, response) +} diff --git a/internal/command/requirementbrowser/requirementbrowser.go b/internal/command/requirementbrowser/requirementbrowser.go index deab08b..684b856 100644 --- a/internal/command/requirementbrowser/requirementbrowser.go +++ b/internal/command/requirementbrowser/requirementbrowser.go @@ -60,6 +60,18 @@ type Options struct { } func BuildPlan(raw any, options Options) (map[string]any, int, error) { + options, err := admitServerAddress(options) + if err != nil { + return nil, 1, err + } + rendered, err := render(raw, options) + if err != nil { + return nil, 1, err + } + return renderedPlan(rendered, options), 0, nil +} + +func admitServerAddress(options Options) (Options, error) { if options.Host == "" { options.Host = defaultHost } @@ -67,15 +79,15 @@ func BuildPlan(raw any, options Options) (map[string]any, int, error) { options.Port = defaultPort } if err := admitLoopbackHost(options.Host); err != nil { - return nil, 1, err + return Options{}, err } if err := admitPort(options.Port); err != nil { - return nil, 1, err - } - rendered, err := render(raw, options) - if err != nil { - return nil, 1, err + return Options{}, err } + return options, nil +} + +func renderedPlan(rendered renderedView, options Options) map[string]any { portSelection := "fixed" var plannedURL any = browserURL(options.Host, options.Port) if options.Port == 0 { @@ -95,7 +107,7 @@ func BuildPlan(raw any, options Options) (map[string]any, int, error) { "schemaVersion": 1, "url": plannedURL, "view": options.View, - }, 0, nil + } } type renderedView struct { diff --git a/internal/command/requirementbrowser/server.go b/internal/command/requirementbrowser/server.go index 2a13982..d238b37 100644 --- a/internal/command/requirementbrowser/server.go +++ b/internal/command/requirementbrowser/server.go @@ -75,22 +75,18 @@ func (handle ServerHandle) Done() <-chan error { } func StartServer(raw any, options Options) (ServerHandle, error) { - if options.Host == "" { - options.Host = defaultHost - } - if !options.PortSet { - options.Port = defaultPort - } - if err := admitLoopbackHost(options.Host); err != nil { - return ServerHandle{}, err - } - if err := admitPort(options.Port); err != nil { + options, err := admitServerAddress(options) + if err != nil { return ServerHandle{}, err } rendered, err := render(raw, options) if err != nil { return ServerHandle{}, err } + return startRenderedServer(rendered, options) +} + +func startRenderedServer(rendered renderedView, options Options) (ServerHandle, error) { listener, err := net.Listen("tcp", net.JoinHostPort(options.Host, strconv.Itoa(options.Port))) if err != nil { return ServerHandle{}, err @@ -161,26 +157,26 @@ func Serve(ctx context.Context, raw any, options Options, stdout io.Writer) erro } func serveHandle(ctx context.Context, handle ServerHandle, options Options, stdout io.Writer) error { + return serveHandleWithOpener(ctx, handle, options, stdout, openBrowser) +} + +func serveHandleWithOpener(ctx context.Context, handle ServerHandle, options Options, stdout io.Writer, open func(context.Context, string) error) 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 err := open(ctx, handle.URL); err != nil { + return errors.Join(err, closeAndWaitServer(handle)) } } if _, err := fmt.Fprintf(stdout, "Proofkit requirement browser: %s\n", handle.URL); err != nil { - return err + return errors.Join(err, closeAndWaitServer(handle)) } select { case <-ctx.Done(): - closeErr := closeHandle(handle) - waitCtx, cancel := context.WithTimeout(context.Background(), serverShutdownTimeout) - defer cancel() - return errors.Join(closeErr, waitServerDone(waitCtx, handle)) + return closeAndWaitServer(handle) case err := <-handle.Done(): - return err + return errors.Join(err, closeHandle(handle)) } } diff --git a/internal/command/requirementbrowser/server_test.go b/internal/command/requirementbrowser/server_test.go index a8efaa0..ee3ad57 100644 --- a/internal/command/requirementbrowser/server_test.go +++ b/internal/command/requirementbrowser/server_test.go @@ -591,6 +591,96 @@ func TestServeOneShotDoesNotReadCompletedDoneTwice(t *testing.T) { } } +func TestBrowseTerminalPathsJoinCleanupAndConsumeDoneOnce(t *testing.T) { + for _, mode := range []string{"output", "open", "cancel", "done"} { + t.Run(mode, func(t *testing.T) { + primary, closeFailure, doneFailure := errors.New("primary failure"), errors.New("close failure"), errors.New("done failure") + unread := errors.New("unread completion") + done := make(chan error, 2) + closeCalls := 0 + if mode == "done" { + done <- primary + done <- unread + } + handle := ServerHandle{URL: "http://127.0.0.1:43127/", done: done, close: func(context.Context) error { + closeCalls++ + if mode != "done" { + done <- doneFailure + done <- unread + } + return closeFailure + }} + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + if mode == "cancel" { + cancel() + } + writes, opens := 0, 0 + writer := browserWriterFunc(func(data []byte) (int, error) { + writes++ + if mode == "output" { + return 0, primary + } + return len(data), nil + }) + err := serveHandleWithOpener(ctx, handle, Options{Open: mode == "open"}, writer, func(context.Context, string) error { opens++; return primary }) + if !errors.Is(err, closeFailure) || closeCalls != 1 { + t.Fatal("browse terminal path dropped cleanup failure or closed more than once") + } + if mode != "cancel" && !errors.Is(err, primary) { + t.Fatal("browse path dropped its primary failure") + } + if mode != "done" && !errors.Is(err, doneFailure) { + t.Fatal("browse path did not await and report Serve completion") + } + if mode == "open" && (opens != 1 || writes != 0) || mode != "open" && (opens != 0 || writes != 1) { + t.Fatal("launch and output effects have changed order") + } + select { + case remaining := <-done: + if remaining != unread { + t.Fatal("browse path did not consume its exact first completion") + } + default: + t.Fatal("browse path consumed Done twice") + } + }) + } +} + +func TestBrowseEarlyFailuresCloseAndAwaitRealServer(t *testing.T) { + for _, mode := range []string{"output", "open"} { + t.Run(mode, func(t *testing.T) { + handle, err := StartServer(sourceInput(t), Options{View: "source"}) + if err != nil { + t.Fatal(err) + } + realClose, realDone := handle.close, handle.done + t.Cleanup(func() { _ = realClose(context.Background()) }) + primary, closeFailure, doneFailure := errors.New("primary failure"), errors.New("close failure"), errors.New("done failure") + closeCalls := 0 + handle.close = func(ctx context.Context) error { closeCalls++; return errors.Join(realClose(ctx), closeFailure) } + completion := make(chan error, 1) + go func() { completion <- errors.Join(<-realDone, doneFailure) }() + handle.done = completion + writer := browserWriterFunc(func([]byte) (int, error) { return 0, primary }) + err = serveHandleWithOpener(t.Context(), handle, Options{Open: mode == "open"}, writer, func(context.Context, string) error { return primary }) + if !errors.Is(err, primary) || !errors.Is(err, closeFailure) || !errors.Is(err, doneFailure) || closeCalls != 1 { + t.Fatal("real server early failure did not own primary, cleanup and completion results") + } + connection, err := net.DialTimeout("tcp", net.JoinHostPort(handle.Host, strconv.Itoa(handle.Port)), time.Second) + if err == nil { + _ = connection.Close() + t.Fatal("early failure returned with an open listener") + } + }) + } +} + +type browserWriterFunc func([]byte) (int, error) + +func (write browserWriterFunc) Write(data []byte) (int, error) { return write(data) } + type readyWriter struct { ready chan<- string } diff --git a/internal/command/requirementbrowser/workspace.go b/internal/command/requirementbrowser/workspace.go index 35a78cb..6e37b18 100644 --- a/internal/command/requirementbrowser/workspace.go +++ b/internal/command/requirementbrowser/workspace.go @@ -51,13 +51,26 @@ func buildWorkspace(raw any) (workspaceSession, string, error) { if err != nil { return workspaceSession{}, "", err } - lookup, anchors := buildWorkspaceLookupIndex(snapshot) var diff map[string]any if record["diffInput"] != nil { diff, err = requirementdiff.Build(record["diffInput"]) if err != nil { return workspaceSession{}, "", err } + } + var graph map[string]any + if record["graphInput"] != nil { + graph, err = requirementgraph.Build(record["graphInput"]) + if err != nil { + return workspaceSession{}, "", err + } + } + return prepareWorkspace(workspaceID, snapshot, diff, graph, workspaceHTML) +} + +func prepareWorkspace(workspaceID string, snapshot requirementcontext.Snapshot, diff, graph map[string]any, document func(string) string) (workspaceSession, string, error) { + var err error + if diff != nil { if diff["currentSnapshotId"] != snapshot.SnapshotID { return workspaceSession{}, "", fmt.Errorf("requirement browser diff input current context must equal workspace context") } @@ -66,12 +79,7 @@ func buildWorkspace(raw any) (workspaceSession, string, error) { return workspaceSession{}, "", err } } - var graph map[string]any - if record["graphInput"] != nil { - graph, err = requirementgraph.Build(record["graphInput"]) - if err != nil { - return workspaceSession{}, "", err - } + if graph != nil { if graph["snapshotId"] != snapshot.SnapshotID { return workspaceSession{}, "", fmt.Errorf("requirement browser graph input context must equal workspace context") } @@ -83,6 +91,7 @@ func buildWorkspace(raw any) (workspaceSession, string, error) { return workspaceSession{}, "", err } } + lookup, anchors := buildWorkspaceLookupIndex(snapshot) manifest := map[string]any{ "authority": "presentation_adapter", "availableViews": []any{"specifications", "coverage", "diff", "graph"}, @@ -97,7 +106,7 @@ func buildWorkspace(raw any) (workspaceSession, string, error) { "snapshotId": snapshot.SnapshotID, "workspaceId": workspaceID, } - return workspaceSession{Anchors: anchors, Diff: diff, Graph: graph, Manifest: manifest, Lookup: lookup, Snapshot: snapshot, SnapshotID: snapshot.SnapshotID}, workspaceHTML(workspaceID), nil + return workspaceSession{Anchors: anchors, Diff: diff, Graph: graph, Manifest: manifest, Lookup: lookup, Snapshot: snapshot, SnapshotID: snapshot.SnapshotID}, document(workspaceID), nil } func admitWorkspaceInputVersion(record map[string]any) error { diff --git a/internal/command/requirementcontext/context_wire_compatibility_test.go b/internal/command/requirementcontext/context_wire_compatibility_test.go new file mode 100644 index 0000000..820a8a3 --- /dev/null +++ b/internal/command/requirementcontext/context_wire_compatibility_test.go @@ -0,0 +1,90 @@ +package requirementcontext + +import ( + "bytes" + "crypto/sha256" + "encoding/json" + "fmt" + "os" + "reflect" + "testing" + + "github.com/research-engineering/agentic-proofkit/internal/kernel/admission" + "github.com/research-engineering/agentic-proofkit/internal/kernel/stablejson" +) + +// This independently authored ASCII fixture was captured and its explicit +// preimage checked at 3d6f81f6a85cb963f2bbb96073c75f1137afa153. Never regenerate +// expected.json from the candidate under test. +func TestContextPredecessorWireIdentity(t *testing.T) { + const root = "testdata/context-wire-v2" + const snapshotID = "sha256:6fb22780b923a77dd826e9eb3a2f55f59bec8bb526c684bc070e77ac60096467" + expectedBytes, err := os.ReadFile(root + "/expected.json") + if err != nil { + t.Fatal(err) + } + if got := fmt.Sprintf("%x", sha256.Sum256(expectedBytes)); got != "42bf8527d17e2fb34a4e9a388d850f8265d2cf6390c27f46e94d90f658bfee15" { + t.Fatal("independent predecessor packet bytes changed") + } + expected := predecessorRecord(t, expectedBytes) + identities := []any{ + map[string]any{"currentDigest": "sha256:b5ed1da77cad818a8a2909b686d5c51d4b132812ab6c04c017bec13965dda3a3", "expectedDigest": "", "kind": "spec_tree", "path": "proofkit/tree.json", "sourceRef": "spec_tree:wire.tree"}, + map[string]any{"currentDigest": "sha256:5bcd8dfd746e8bbf85ae220e893eb7928f159a3577a1f31f82b849546d88b86b", "expectedDigest": "", "kind": "requirement_source", "nodeId": "wire.root", "path": "docs/specs/wire/requirements.v1.json", "sourceRef": "wire.requirements", "sourceRole": "requirements"}, + } + preimage, err := json.MarshalIndent(map[string]any{"catalogId": "wire.catalog", "projections": expected["projections"], "sources": identities}, "", " ") + if err != nil { + t.Fatal(err) + } + if got := fmt.Sprintf("sha256:%x", sha256.Sum256(append(preimage, '\n'))); got != snapshotID || expected["snapshotId"] != snapshotID { + t.Fatal("independent predecessor preimage does not match its recorded identity") + } + catalogBytes, err := os.ReadFile(root + "/catalog.json") + if err != nil { + t.Fatal(err) + } + composed, err := Compose(root, predecessorRecord(t, catalogBytes)) + if err != nil || !reflect.DeepEqual(composed, expected) { + t.Fatalf("Compose changed predecessor semantics: %v", err) + } + for _, version := range []string{"1", "2"} { + t.Run("v"+version, func(t *testing.T) { + input := predecessorRecord(t, expectedBytes) + if version == "1" { + input["schemaVersion"] = json.Number("1") + delete(input, "expectedDigestCoverage") + input["baselineVerification"] = "unverified" + input["nonClaims"] = input["nonClaims"].([]any)[:2] + } + snapshot, err := AdmitSnapshot(input) + if err != nil { + t.Fatal(err) + } + encoded, err := stablejson.Marshal(SnapshotValue(snapshot)) + if err != nil || !bytes.Equal(encoded, expectedBytes) { + t.Fatalf("admission changed predecessor wire: %v", err) + } + slice, err := SliceSnapshot(snapshot, map[string]any{"profile": "review", "requirementIds": []any{"REQ-WIRE-001"}}, "wire.slice") + if err != nil { + t.Fatal(err) + } + wantFragment := []any{map[string]any{ + "authority": "lookup_fragment_only", "omittedRequirementCount": 0, + "projectionKind": "proofkit.requirement-source-fragment", + "requirements": expected["projections"].(map[string]any)["requirementSources"].([]any)[0].(map[string]any)["requirements"], + "selectedRequirementCount": 1, "sourceId": "wire.requirements", "totalRequirementCount": 1, + }} + if !reflect.DeepEqual(slice["projections"].(map[string]any)["requirementSources"], wantFragment) { + t.Fatal("predecessor fragment fields or values changed") + } + }) + } +} + +func predecessorRecord(t *testing.T, content []byte) map[string]any { + t.Helper() + value, err := admission.DecodeJSON(bytes.NewReader(content), int64(len(content))) + if err != nil { + t.Fatal(err) + } + return value.(map[string]any) +} diff --git a/internal/command/requirementcontext/model.go b/internal/command/requirementcontext/model.go index 4778710..2a45d6d 100644 --- a/internal/command/requirementcontext/model.go +++ b/internal/command/requirementcontext/model.go @@ -45,6 +45,7 @@ type Snapshot struct { SnapshotID string Sources []Source Tree requirementspectree.Tree + projectOrigin *projectOrigin } func SnapshotValue(snapshot Snapshot) map[string]any { @@ -67,7 +68,7 @@ func SnapshotValue(snapshot Snapshot) map[string]any { } sources = append(sources, record) } - return map[string]any{ + value := map[string]any{ "catalogId": snapshot.CatalogID, "contextKind": ContextKind, "expectedDigestCoverage": snapshot.ExpectedDigestCoverage, @@ -77,6 +78,11 @@ func SnapshotValue(snapshot Snapshot) map[string]any { "snapshotId": snapshot.SnapshotID, "sources": sources, } + if snapshot.projectOrigin != nil { + value["schemaVersion"] = json.Number("3") + value["projectOrigin"] = snapshot.projectOrigin.value() + } + return value } func AdmitSnapshot(raw any) (Snapshot, error) { @@ -89,8 +95,10 @@ func AdmitSnapshot(raw any) (Snapshot, error) { return admitV1Snapshot(record) case admit.JSONNumberEquals(record["schemaVersion"], 2): return admitV2Snapshot(record) + case admit.JSONNumberEquals(record["schemaVersion"], 3): + return admitProjectSnapshot(record) default: - return Snapshot{}, fmt.Errorf("requirement context schemaVersion must be 1 or 2") + return Snapshot{}, fmt.Errorf("requirement context schemaVersion must be 1, 2 or 3") } } @@ -345,13 +353,21 @@ func admitExactNonClaims(raw any, expectedNonClaims []string) error { } func admitSources(raw any) ([]Source, error) { + return admitSourceInventory(raw, false) +} + +func admitSourceInventory(raw any, fromProject bool) ([]Source, error) { values, ok := raw.([]any) if !ok || len(values) == 0 { return nil, fmt.Errorf("requirement context sources must be a non-empty array") } result := make([]Source, 0, len(values)) seenPaths := map[string]struct{}{} - seenRefs := map[string]struct{}{} + seenRefs := map[[2]string]struct{}{} + kinds := map[string]struct{}{"coverage": {}, "proof_binding": {}, "requirement_source": {}, "spec_tree": {}} + if fromProject { + kinds = map[string]struct{}{"project_manifest": {}, "test_inventory": {}, "proof_binding": {}, "requirement_source": {}} + } for index, value := range values { record, ok := value.(map[string]any) if !ok { @@ -383,7 +399,7 @@ func admitSources(raw any) ([]Source, error) { return nil, err } } - kind, err := admit.Enum(record["kind"], map[string]struct{}{"coverage": {}, "proof_binding": {}, "requirement_source": {}, "spec_tree": {}}, "requirement context source kind") + kind, err := admit.Enum(record["kind"], kinds, "requirement context source kind") if err != nil { return nil, err } @@ -391,10 +407,14 @@ func admitSources(raw any) ([]Source, error) { if err != nil { return nil, err } - if _, exists := seenRefs[sourceRef]; exists { + key := [2]string{"", sourceRef} + if fromProject { + key[0] = kind + } + if _, exists := seenRefs[key]; exists { return nil, fmt.Errorf("requirement context source refs must be unique") } - seenRefs[sourceRef] = struct{}{} + seenRefs[key] = struct{}{} if expectedDigest != "" && expectedDigest != currentDigest { return nil, fmt.Errorf("requirement context source expectedDigest must equal currentDigest") } @@ -414,10 +434,19 @@ func admitSources(raw any) ([]Source, error) { } result = append(result, Source{CurrentDigest: currentDigest, ExpectedDigest: expectedDigest, Kind: kind, NodeID: nodeID, Path: path, SourceRef: sourceRef, SourceRole: sourceRole}) } - sort.Slice(result, func(left, right int) bool { return result[left].SourceRef < result[right].SourceRef }) + sortSourceInventory(result, fromProject) return result, nil } +func sortSourceInventory(values []Source, fromProject bool) { + sort.Slice(values, func(left, right int) bool { + if fromProject && values[left].Kind != values[right].Kind { + return values[left].Kind < values[right].Kind + } + return values[left].SourceRef < values[right].SourceRef + }) +} + func admitDigestRef(raw any, context string) (string, error) { return admit.SHA256Ref(raw, context) } diff --git a/internal/command/requirementcontext/project_origin.go b/internal/command/requirementcontext/project_origin.go new file mode 100644 index 0000000..9878995 --- /dev/null +++ b/internal/command/requirementcontext/project_origin.go @@ -0,0 +1,197 @@ +package requirementcontext + +import ( + "encoding/json" + "fmt" + "reflect" + "sort" + + "github.com/research-engineering/agentic-proofkit/internal/command/adoptionmaterialization" + "github.com/research-engineering/agentic-proofkit/internal/command/requirementsourceadmission" + "github.com/research-engineering/agentic-proofkit/internal/command/requirementspectree" + "github.com/research-engineering/agentic-proofkit/internal/command/testevidenceinventory" + "github.com/research-engineering/agentic-proofkit/internal/kernel/admit" + "github.com/research-engineering/agentic-proofkit/internal/kernel/digest" + "github.com/research-engineering/agentic-proofkit/internal/kernel/stablejson" +) + +const projectRootNodeID = "project.root" + +type projectOrigin struct { + manifest adoptionmaterialization.Manifest + inventory testevidenceinventory.Inventory +} + +func (origin projectOrigin) value() map[string]any { + return map[string]any{ + "manifest": origin.manifest.JSONValue(), + "testEvidenceInventory": testevidenceinventory.InventoryValue(origin.inventory), + } +} + +// FromProject derives routing context from one admitted logical project. The +// observed manifest digest records captured bytes, not ongoing file freshness. +func FromProject(project *adoptionmaterialization.Project, manifestContentDigest string) (Snapshot, error) { + if _, err := admitDigestRef(manifestContentDigest, "project context manifest currentDigest"); err != nil { + return Snapshot{}, err + } + value, err := project.JSONValue() + if err != nil { + return Snapshot{}, err + } + manifest, err := adoptionmaterialization.AdmitManifest(value["manifest"]) + if err != nil { + return Snapshot{}, err + } + inventory, err := testevidenceinventory.EvaluateDirect(value["testEvidenceInventory"]) + if err != nil || inventory.ExitCode != 0 { + return Snapshot{}, fmt.Errorf("project context test inventory is invalid") + } + refs := make([]requirementspectree.SourceRef, 0, len(value["requirementSources"].([]any))) + for _, raw := range value["requirementSources"].([]any) { + id := raw.(map[string]any)["sourceId"].(string) + refs = append(refs, requirementspectree.SourceRef{SourceRefID: id, SourceRefKind: "source_id", SourceRole: "requirements", SourceID: id}) + } + sort.Slice(refs, func(left, right int) bool { return refs[left].SourceRefID < refs[right].SourceRefID }) + collection := requirementspectree.Tree{ + TreeID: "project.collection", RootNodeID: projectRootNodeID, + CallerAnnotations: []string{"This collection is routing-only and does not infer product hierarchy."}, + Nodes: []requirementspectree.Node{{NodeID: projectRootNodeID, NodeKind: "meta_spec", Label: manifest.ProjectID, DisplayOrder: 1, SourceRefs: refs}}, + } + projections := map[string]any{ + "requirementSources": value["requirementSources"], "proofBinding": value["proofBinding"], + "specTree": requirementspectree.TreeValue(collection), + } + tree, requirements, binding, _, canonical, err := admitSnapshotProjections(projections) + if err != nil { + return Snapshot{}, err + } + origin := &projectOrigin{manifest: manifest, inventory: inventory.Inventory} + snapshot := Snapshot{ + CatalogID: manifest.ProjectID, ExpectedDigestCoverage: "partial", ProofBinding: binding, + Projections: canonical, RequirementSources: requirements, Tree: tree, projectOrigin: origin, + Sources: projectSources(manifest, inventory.Inventory.InventoryID, binding.BindingID, requirements, manifestContentDigest), + } + snapshot.SnapshotID, err = digest.StableJSONSHA256Ref(projectSnapshotIdentity(snapshot)) + if err != nil { + return Snapshot{}, err + } + return validateSnapshotSize(snapshot) +} + +func projectSources(manifest adoptionmaterialization.Manifest, inventoryID, bindingID string, requirements []requirementsourceadmission.Source, manifestDigest string) []Source { + requirementIDs := make(map[string]string, len(requirements)) + for _, source := range requirements { + requirementIDs[source.RequirementsPath] = source.SourceID + } + sources := []Source{{Kind: "project_manifest", SourceRef: manifest.ProjectID, Path: adoptionmaterialization.ProjectManifestPath, CurrentDigest: manifestDigest}} + for _, route := range manifest.Routes { + source := Source{Path: route.Path, CurrentDigest: route.ArtifactID, ExpectedDigest: route.ArtifactID} + switch route.ArtifactKind { + case adoptionmaterialization.ArtifactRequirementSource: + source.Kind, source.SourceRef = "requirement_source", requirementIDs[route.Path] + source.NodeID, source.SourceRole = projectRootNodeID, "requirements" + case adoptionmaterialization.ArtifactRequirementBinding: + source.Kind, source.SourceRef = "proof_binding", bindingID + case adoptionmaterialization.ArtifactTestInventory: + source.Kind, source.SourceRef = "test_inventory", inventoryID + } + sources = append(sources, source) + } + sortSourceInventory(sources, true) + return sources +} + +func projectSnapshotIdentity(snapshot Snapshot) map[string]any { + return map[string]any{ + "schemaVersion": json.Number("3"), "catalogId": snapshot.CatalogID, + "projectOrigin": snapshot.projectOrigin.value(), "projections": snapshot.Projections, + "sources": sourceIdentityValues(snapshot.Sources), + } +} + +func admitProjectSnapshot(record map[string]any) (Snapshot, error) { + if err := admit.KnownKeys(record, []string{"catalogId", "contextKind", "expectedDigestCoverage", "nonClaims", "projectOrigin", "projections", "schemaVersion", "snapshotId", "sources"}, "project context"); err != nil { + return Snapshot{}, err + } + if record["contextKind"] != ContextKind || record["expectedDigestCoverage"] != "partial" { + return Snapshot{}, fmt.Errorf("project context identity or expectedDigestCoverage is invalid") + } + catalogID, err := admit.RuleID(record["catalogId"], "project context catalogId") + if err != nil { + return Snapshot{}, err + } + encoded, err := stablejson.Marshal(record) + if err != nil || len(encoded) > maxSnapshotBytes { + return Snapshot{}, fmt.Errorf("project context exceeds the JSON byte boundary") + } + if err := admitExactNonClaims(record["nonClaims"], boundaryNonClaims); err != nil { + return Snapshot{}, err + } + origin, ok := record["projectOrigin"].(map[string]any) + if !ok { + return Snapshot{}, fmt.Errorf("project context origin must be an object") + } + if err := admit.KnownKeys(origin, []string{"manifest", "testEvidenceInventory"}, "project context origin"); err != nil { + return Snapshot{}, err + } + projections, ok := record["projections"].(map[string]any) + if !ok { + return Snapshot{}, fmt.Errorf("project context projections must be an object") + } + if err := admit.KnownKeys(projections, []string{"proofBinding", "requirementSources", "specTree"}, "project context projections"); err != nil { + return Snapshot{}, err + } + project, err := adoptionmaterialization.AdmitProject(map[string]any{ + "manifest": origin["manifest"], "testEvidenceInventory": origin["testEvidenceInventory"], + "proofBinding": projections["proofBinding"], "requirementSources": projections["requirementSources"], + }) + if err != nil { + return Snapshot{}, err + } + sources, err := admitSourceInventory(record["sources"], true) + if err != nil { + return Snapshot{}, err + } + manifestDigest := "" + for _, source := range sources { + if source.Kind == "project_manifest" { + if manifestDigest != "" { + return Snapshot{}, fmt.Errorf("project context requires one physical manifest") + } + manifestDigest = source.CurrentDigest + } + } + expected, err := FromProject(project, manifestDigest) + if err != nil { + return Snapshot{}, err + } + tree, err := requirementspectree.Evaluate(projections["specTree"]) + if err != nil || tree.ExitCode != 0 { + return Snapshot{}, fmt.Errorf("project context spec tree is invalid") + } + // Identity and derivation are independent obligations: a caller can rehash + // a valid but unrelated tree, so integrity cannot replace owner replay. + actual := expected + actual.CatalogID = catalogID + actual.Sources = sources + actual.Projections = map[string]any{ + "specTree": requirementspectree.TreeValue(tree.Tree), + "proofBinding": expected.Projections["proofBinding"], + "requirementSources": expected.Projections["requirementSources"], + } + id, err := digest.StableJSONSHA256Ref(projectSnapshotIdentity(actual)) + if err != nil || record["snapshotId"] != id { + return Snapshot{}, fmt.Errorf("project context identity does not match admitted content") + } + if !reflect.DeepEqual(actual.Projections["specTree"], expected.Projections["specTree"]) { + return Snapshot{}, fmt.Errorf("project context spec tree must equal the derived collection") + } + if !reflect.DeepEqual(sources, expected.Sources) { + return Snapshot{}, fmt.Errorf("project context physical sources must equal the manifest route partition") + } + if catalogID != expected.CatalogID { + return Snapshot{}, fmt.Errorf("project context catalogId must equal the project identity") + } + return expected, nil +} diff --git a/internal/command/requirementcontext/project_origin_test.go b/internal/command/requirementcontext/project_origin_test.go new file mode 100644 index 0000000..609b1a5 --- /dev/null +++ b/internal/command/requirementcontext/project_origin_test.go @@ -0,0 +1,299 @@ +package requirementcontext + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/json" + "fmt" + "os" + "reflect" + "sort" + "strings" + "testing" + + "github.com/research-engineering/agentic-proofkit/internal/command/adoptionmaterialization" + "github.com/research-engineering/agentic-proofkit/internal/command/projectstatus" + "github.com/research-engineering/agentic-proofkit/internal/kernel/jsonpointer" + "github.com/research-engineering/agentic-proofkit/internal/testsupport/projectfixture" +) + +type projectContextFixture struct { + inspection projectstatus.Inspection + original map[string]any + files map[string][]byte +} + +func TestProjectContextCaptureWireAdmissionAndSlice(t *testing.T) { + fixture := newProjectContextFixture(t) + snapshot, err := FromProject(fixture.inspection.Project, fixture.inspection.ManifestContentDigest) + if err != nil { + t.Fatal(err) + } + actual := SnapshotValue(snapshot) + expected := expectedProjectContext(t, fixture) + if !bytes.Equal(projectTestJSON(t, actual), projectTestJSON(t, expected)) { + for key, want := range expected { + if !bytes.Equal(projectTestJSON(t, actual[key]), projectTestJSON(t, want)) { + t.Logf("different wire field: %s", key) + } + } + t.Fatal("project context differs from the independent complete projection") + } + admitted, err := AdmitSnapshot(predecessorRecord(t, projectTestJSON(t, actual))) + if err != nil || !bytes.Equal(projectTestJSON(t, SnapshotValue(admitted)), projectTestJSON(t, expected)) { + t.Fatalf("project context did not survive wire re-admission: %v", err) + } + if snapshot.Coverage != nil || snapshot.ExpectedDigestCoverage != "partial" { + t.Fatal("project inventory became coverage or fabricated full expected-digest coverage") + } + for _, test := range []struct { + id, text, pointer, path string + }{ + {"REQ-WIRE-001", "Collection \U0001f9ed A preserves its explicit invariant.", "/projections/requirementSources/1/requirements/0/invariant", "docs/specs/a/requirements.v1.json"}, + {"REQ-WIRE-002", "Collection E\u0301 Z preserves its explicit invariant.", "/projections/requirementSources/0/requirements/0/invariant", "docs/specs/z/requirements.v1.json"}, + } { + t.Run(test.id, func(t *testing.T) { + pointer, err := jsonpointer.Parse(test.pointer) + if err != nil { + t.Fatal(err) + } + text, err := jsonpointer.SelectParsed(actual, pointer) + if err != nil || text != test.text { + t.Fatalf("source-ordered pointer resolved to a different invariant: %v", err) + } + slice, err := SliceSnapshot(admitted, map[string]any{"profile": "review", "requirementIds": []any{test.id}, "maxRequirements": json.Number("1")}, "collection.slice") + if err != nil { + t.Fatal(err) + } + fragments := slice["projections"].(map[string]any)["requirementSources"].([]any) + if len(fragments) != 1 { + t.Fatal("slice included an unselected source") + } + fragment := fragments[0].(map[string]any) + original := predecessorRecord(t, fixture.files[test.path]) + if !reflect.DeepEqual(fragment["nonClaims"], original["nonClaims"]) { + t.Fatal("source-level restrictions were lost or taken from a different source") + } + requirements := fragment["requirements"].([]any) + if len(requirements) != 1 || !reflect.DeepEqual(requirements[0], original["requirements"].([]any)[0]) { + t.Fatal("selected invariant or its separate metadata was lost") + } + if fragment["omittedRequirementCount"] != len(original["requirements"].([]any))-1 { + t.Fatal("source omissions differ from the original source") + } + matched := 0 + for _, source := range admitted.Sources { + if source.Kind == "requirement_source" && source.SourceRef == original["sourceId"] { + matched++ + if source.Path != test.path || source.CurrentDigest != projectTestDigest(fixture.files[test.path]) { + t.Fatal("source ID/path ordering changed the physical digest association") + } + } + } + if matched != 1 { + t.Fatal("typed requirement source did not resolve exactly once") + } + }) + } +} + +func TestProjectContextNormalizesEquivalentSourceOrders(t *testing.T) { + fixture := newProjectContextFixture(t) + expected := expectedProjectContext(t, fixture) + reordered := deepClone(t, expected) + for _, values := range [][]any{reordered["sources"].([]any), reordered["projections"].(map[string]any)["requirementSources"].([]any)} { + for left, right := 0, len(values)-1; left < right; left, right = left+1, right-1 { + values[left], values[right] = values[right], values[left] + } + } + snapshot, err := AdmitSnapshot(reordered) + if err != nil || !bytes.Equal(projectTestJSON(t, SnapshotValue(snapshot)), projectTestJSON(t, expected)) { + t.Fatalf("equivalent project source order changed identity: %v", err) + } +} + +func TestProjectContextRejectsInvalidOriginAndSourcePartition(t *testing.T) { + base := expectedProjectContext(t, newProjectContextFixture(t)) + for name, mutate := range map[string]func(map[string]any){ + "old version": func(v map[string]any) { v["schemaVersion"] = json.Number("2") }, + "future version": func(v map[string]any) { v["schemaVersion"] = json.Number("4") }, + "wrong kind": func(v map[string]any) { v["contextKind"] = "different.context" }, + "missing origin": func(v map[string]any) { delete(v, "projectOrigin") }, + "missing inventory": func(v map[string]any) { delete(v["projectOrigin"].(map[string]any), "testEvidenceInventory") }, + "extra origin field": func(v map[string]any) { v["projectOrigin"].(map[string]any)["unknown"] = true }, + "wrong identity": func(v map[string]any) { v["snapshotId"] = "sha256:" + strings.Repeat("0", 64) }, + "fabricated coverage": func(v map[string]any) { v["projections"].(map[string]any)["coverage"] = map[string]any{} }, + "invented full digest coverage": func(v map[string]any) { v["expectedDigestCoverage"] = "all" }, + "missing physical inventory": func(v map[string]any) { v["sources"] = v["sources"].([]any)[:4] }, + "fabricated tree file": func(v map[string]any) { v["sources"].([]any)[0].(map[string]any)["kind"] = "spec_tree" }, + "wrong source role": func(v map[string]any) { v["sources"].([]any)[2].(map[string]any)["sourceRole"] = "overview" }, + "wrong source node": func(v map[string]any) { + v["sources"].([]any)[2].(map[string]any)["nodeId"] = "different.root" + resignProjectContext(t, v) + }, + "manifest expected digest": func(v map[string]any) { + row := v["sources"].([]any)[0].(map[string]any) + row["expectedDigest"] = row["currentDigest"] + resignProjectContext(t, v) + }, + "child digest not from route": func(v map[string]any) { + row := v["sources"].([]any)[1].(map[string]any) + row["currentDigest"], row["expectedDigest"] = projectTestDigest(nil), projectTestDigest(nil) + resignProjectContext(t, v) + }, + "duplicate composite key": func(v map[string]any) { + row := deepClone(t, v["sources"].([]any)[0].(map[string]any)) + row["path"] = "proofkit/another.json" + v["sources"] = append(v["sources"].([]any), row) + }, + "duplicate path": func(v map[string]any) { + row := deepClone(t, v["sources"].([]any)[0].(map[string]any)) + row["sourceRef"] = "another.binding" + v["sources"] = append(v["sources"].([]any), row) + }, + "lost source restriction": func(v map[string]any) { + v["projections"].(map[string]any)["requirementSources"].([]any)[0].(map[string]any)["nonClaims"] = []any{} + }, + "different catalog": func(v map[string]any) { v["catalogId"] = "another.project"; resignProjectContext(t, v) }, + } { + t.Run(name, func(t *testing.T) { + candidate := deepClone(t, base) + mutate(candidate) + if _, err := AdmitSnapshot(candidate); err == nil { + t.Fatal("invalid project context was admitted") + } + }) + } +} + +func TestProjectContextRejectsResignedUnrelatedTree(t *testing.T) { + base := expectedProjectContext(t, newProjectContextFixture(t)) + origin := base["projectOrigin"].(map[string]any) + manifest := origin["manifest"].(map[string]any) + manifest["projectId"] = "another.project" + delete(manifest, "manifestId") + manifest["manifestId"] = projectTestDigest(projectTestJSON(t, manifest)) + base["catalogId"] = "another.project" + physical := base["sources"].([]any)[0].(map[string]any) + physical["sourceRef"] = "another.project" + physical["currentDigest"] = projectTestDigest(projectTestJSON(t, manifest)) + resignProjectContext(t, base) + if _, err := AdmitSnapshot(base); err == nil || !strings.Contains(err.Error(), "derived collection") { + t.Fatalf("resigned project retaining the old tree label must fail owner replay: %v", err) + } + base["projections"].(map[string]any)["specTree"].(map[string]any)["nodes"].([]any)[0].(map[string]any)["label"] = "another.project" + resignProjectContext(t, base) + if _, err := AdmitSnapshot(base); err != nil { + t.Fatalf("the otherwise identical correct collection was rejected: %v", err) + } +} + +func TestProjectContextRejectsZeroProjectAndOversizeBeforeReplay(t *testing.T) { + for _, project := range []*adoptionmaterialization.Project{nil, {}} { + if _, err := FromProject(project, projectTestDigest(nil)); err == nil { + t.Fatal("zero project acquired context authority") + } + } + base := expectedProjectContext(t, newProjectContextFixture(t)) + base["projections"].(map[string]any)["requirementSources"].([]any)[0].(map[string]any)["requirements"].([]any)[0].(map[string]any)["invariant"] = strings.Repeat("a", maxSnapshotBytes) + if _, err := AdmitSnapshot(base); err == nil || !strings.Contains(err.Error(), "byte boundary") { + t.Fatalf("oversize input reached child replay: %v", err) + } +} + +func expectedProjectContext(t *testing.T, fixture projectContextFixture) map[string]any { + t.Helper() + sources := fixture.original["requirementSources"].([]any) + projections := map[string]any{ + "requirementSources": []any{sources[1], sources[0]}, + "proofBinding": fixture.original["proofBinding"], + "specTree": map[string]any{ + "schemaVersion": json.Number("2"), "treeId": "project.collection", "rootNodeId": "project.root", + "callerAnnotations": []any{"This collection is routing-only and does not infer product hierarchy."}, + "edges": []any{}, "overlays": []any{}, + "nodes": []any{map[string]any{ + "callerAnnotations": []any{}, "displayOrder": json.Number("1"), "label": "shared.identity", "nodeId": "project.root", "nodeKind": "meta_spec", + "sourceRefs": []any{ + map[string]any{"sourceRefId": "shared.identity", "sourceRefKind": "source_id", "sourceRole": "requirements", "sourceId": "shared.identity"}, + map[string]any{"sourceRefId": "zeta.source", "sourceRefKind": "source_id", "sourceRole": "requirements", "sourceId": "zeta.source"}, + }, + }}, + }, + } + physical := []any{} + for _, row := range []struct{ kind, id, path string }{ + {"project_manifest", "shared.identity", adoptionmaterialization.ProjectManifestPath}, + {"proof_binding", "shared.identity", "proofkit/bindings.json"}, + {"requirement_source", "shared.identity", "docs/specs/z/requirements.v1.json"}, + {"requirement_source", "zeta.source", "docs/specs/a/requirements.v1.json"}, + {"test_inventory", "shared.identity", "proofkit/tests.json"}, + } { + value := map[string]any{"kind": row.kind, "sourceRef": row.id, "path": row.path, "currentDigest": projectTestDigest(fixture.files[row.path])} + if row.kind != "project_manifest" { + value["expectedDigest"] = value["currentDigest"] + } + if row.kind == "requirement_source" { + value["nodeId"], value["sourceRole"] = "project.root", "requirements" + } + physical = append(physical, value) + } + old, err := os.ReadFile("testdata/context-wire-v2/expected.json") + if err != nil { + t.Fatal(err) + } + result := map[string]any{ + "schemaVersion": json.Number("3"), "contextKind": "proofkit.requirement-context", "catalogId": "shared.identity", + "expectedDigestCoverage": "partial", "nonClaims": predecessorRecord(t, old)["nonClaims"], + "projectOrigin": map[string]any{"manifest": fixture.original["manifest"], "testEvidenceInventory": fixture.original["testEvidenceInventory"]}, + "projections": projections, "sources": physical, + } + resignProjectContext(t, result) + return result +} + +func resignProjectContext(t *testing.T, record map[string]any) { + t.Helper() + sources := []any{} + for _, raw := range record["sources"].([]any) { + value := deepClone(t, raw.(map[string]any)) + if _, ok := value["expectedDigest"]; !ok { + value["expectedDigest"] = "" + } + sources = append(sources, value) + } + sort.Slice(sources, func(left, right int) bool { + a, b := sources[left].(map[string]any), sources[right].(map[string]any) + if a["kind"] != b["kind"] { + return a["kind"].(string) < b["kind"].(string) + } + return a["sourceRef"].(string) < b["sourceRef"].(string) + }) + record["snapshotId"] = projectTestDigest(projectTestJSON(t, map[string]any{ + "schemaVersion": json.Number("3"), "catalogId": record["catalogId"], "projectOrigin": record["projectOrigin"], + "projections": record["projections"], "sources": sources, + })) +} + +func newProjectContextFixture(t *testing.T) projectContextFixture { + t.Helper() + fixture := projectfixture.New(t) + inspection, err := projectstatus.InspectProject(context.Background(), fixture.Root) + if err != nil || inspection.Project == nil { + t.Fatalf("independent project capture: status=%s error=%v", inspection.Status.ProjectState, err) + } + return projectContextFixture{inspection: inspection, files: fixture.Files, original: fixture.Project} +} + +func projectTestJSON(t *testing.T, value any) []byte { + t.Helper() + encoded, err := json.MarshalIndent(value, "", " ") + if err != nil { + t.Fatal(err) + } + return append(encoded, '\n') +} + +func projectTestDigest(content []byte) string { + return fmt.Sprintf("sha256:%x", sha256.Sum256(content)) +} diff --git a/internal/command/requirementcontext/slice.go b/internal/command/requirementcontext/slice.go index 9324c8e..c274fe8 100644 --- a/internal/command/requirementcontext/slice.go +++ b/internal/command/requirementcontext/slice.go @@ -104,7 +104,7 @@ func buildSlice(sliceID string, snapshot Snapshot, query SliceQuery) (map[string selectedNodes = nodeResult.selected } projections := map[string]any{ - "requirementSources": requirementSourceFragmentValues(snapshot.RequirementSources, selectedSources), + "requirementSources": requirementSourceFragmentValues(snapshot.RequirementSources, selectedSources, snapshot.projectOrigin != nil), "specTree": treeSliceValue(snapshot.Tree, selectedNodes, selectedSourceIDs), } if query.Profile == "proof" || query.Profile == "review" { @@ -423,7 +423,7 @@ func treeSliceValue(tree requirementspectree.Tree, selected, selectedSourceIDs m return value } -func requirementSourceFragmentValues(all, selected []requirementsourceadmission.Source) []any { +func requirementSourceFragmentValues(all, selected []requirementsourceadmission.Source, includeSourceNonClaims bool) []any { totals := map[string]int{} for _, source := range all { totals[source.SourceID] = len(source.Requirements) @@ -434,12 +434,16 @@ func requirementSourceFragmentValues(all, selected []requirementsourceadmission. for _, requirement := range source.Requirements { requirements = append(requirements, requirementsourceadmission.RequirementValue(requirement)) } - values = append(values, map[string]any{ + fragment := map[string]any{ "authority": "lookup_fragment_only", "omittedRequirementCount": totals[source.SourceID] - len(source.Requirements), "projectionKind": "proofkit.requirement-source-fragment", "requirements": requirements, "selectedRequirementCount": len(source.Requirements), "sourceId": source.SourceID, "totalRequirementCount": totals[source.SourceID], - }) + } + if includeSourceNonClaims { + fragment["nonClaims"] = admit.StringSliceToAny(source.NonClaims) + } + values = append(values, fragment) } return values } diff --git a/internal/command/requirementcontext/testdata/context-wire-v2/catalog.json b/internal/command/requirementcontext/testdata/context-wire-v2/catalog.json new file mode 100644 index 0000000..6b52913 --- /dev/null +++ b/internal/command/requirementcontext/testdata/context-wire-v2/catalog.json @@ -0,0 +1,6 @@ +{ + "schemaVersion": 1, + "catalogId": "wire.catalog", + "specTree": {"path": "proofkit/tree.json"}, + "requirementSources": [{"nodeId": "wire.root", "path": "docs/specs/wire/requirements.v1.json"}] +} diff --git a/internal/command/requirementcontext/testdata/context-wire-v2/docs/specs/wire/requirements.v1.json b/internal/command/requirementcontext/testdata/context-wire-v2/docs/specs/wire/requirements.v1.json new file mode 100644 index 0000000..afb2d5d --- /dev/null +++ b/internal/command/requirementcontext/testdata/context-wire-v2/docs/specs/wire/requirements.v1.json @@ -0,0 +1,20 @@ +{ + "schemaVersion": 1, + "sourceId": "wire.requirements", + "specPackagePath": "docs/specs/wire", + "overviewPath": "docs/specs/wire/overview.md", + "requirementsPath": "docs/specs/wire/requirements.v1.json", + "requirements": [{ + "requirementId": "REQ-WIRE-001", + "ownerId": "wire.owner", + "invariant": "A bounded context preserves its admitted requirement identity.", + "claimLevel": "blocking", + "riskClass": "high", + "lifecycle": {"state": "active", "replacementRequirementIds": [], "evidenceRefs": []}, + "proofBindingRefs": ["proofkit/bindings.json"], + "nonClaimRefs": [], + "nonClaims": ["This fixture does not establish execution evidence."], + "updatePolicy": {"requiresImpactDeclaration": true, "requiresProofBindingReview": true, "reviewOwnerId": "wire.owner"} + }], + "nonClaims": ["Wire compatibility is not consumer readiness."] +} diff --git a/internal/command/requirementcontext/testdata/context-wire-v2/expected.json b/internal/command/requirementcontext/testdata/context-wire-v2/expected.json new file mode 100644 index 0000000..1f9cf0e --- /dev/null +++ b/internal/command/requirementcontext/testdata/context-wire-v2/expected.json @@ -0,0 +1,93 @@ +{ + "catalogId": "wire.catalog", + "contextKind": "proofkit.requirement-context", + "expectedDigestCoverage": "none", + "nonClaims": [ + "Requirement context is a derived projection and is not requirement, proof, coverage, merge, release, rollout, or readiness authority.", + "Requirement context does not execute native witnesses or prove source freshness after composition.", + "Expected-digest coverage records caller-supplied expected/current equality only and does not authenticate a producer, baseline, checkout, or freshness." + ], + "projections": { + "requirementSources": [ + { + "nonClaims": [ + "Wire compatibility is not consumer readiness." + ], + "overviewPath": "docs/specs/wire/overview.md", + "requirements": [ + { + "claimLevel": "blocking", + "invariant": "A bounded context preserves its admitted requirement identity.", + "lifecycle": { + "evidenceRefs": [], + "replacementRequirementIds": [], + "state": "active" + }, + "nonClaimRefs": [], + "nonClaims": [ + "This fixture does not establish execution evidence." + ], + "ownerId": "wire.owner", + "proofBindingRefs": [ + "proofkit/bindings.json" + ], + "requirementId": "REQ-WIRE-001", + "riskClass": "high", + "updatePolicy": { + "requiresImpactDeclaration": true, + "requiresProofBindingReview": true, + "reviewOwnerId": "wire.owner" + } + } + ], + "requirementsPath": "docs/specs/wire/requirements.v1.json", + "schemaVersion": 1, + "sourceId": "wire.requirements", + "specPackagePath": "docs/specs/wire" + } + ], + "specTree": { + "callerAnnotations": [], + "edges": [], + "nodes": [ + { + "callerAnnotations": [], + "displayOrder": 1, + "label": "Wire compatibility", + "nodeId": "wire.root", + "nodeKind": "meta_spec", + "sourceRefs": [ + { + "sourceId": "wire.requirements", + "sourceRefId": "wire.requirements.ref", + "sourceRefKind": "source_id", + "sourceRole": "requirements" + } + ] + } + ], + "overlays": [], + "rootNodeId": "wire.root", + "schemaVersion": 2, + "treeId": "wire.tree" + } + }, + "schemaVersion": 2, + "snapshotId": "sha256:6fb22780b923a77dd826e9eb3a2f55f59bec8bb526c684bc070e77ac60096467", + "sources": [ + { + "currentDigest": "sha256:b5ed1da77cad818a8a2909b686d5c51d4b132812ab6c04c017bec13965dda3a3", + "kind": "spec_tree", + "path": "proofkit/tree.json", + "sourceRef": "spec_tree:wire.tree" + }, + { + "currentDigest": "sha256:5bcd8dfd746e8bbf85ae220e893eb7928f159a3577a1f31f82b849546d88b86b", + "kind": "requirement_source", + "nodeId": "wire.root", + "path": "docs/specs/wire/requirements.v1.json", + "sourceRef": "wire.requirements", + "sourceRole": "requirements" + } + ] +} diff --git a/internal/command/requirementcontext/testdata/context-wire-v2/proofkit/tree.json b/internal/command/requirementcontext/testdata/context-wire-v2/proofkit/tree.json new file mode 100644 index 0000000..15b63e7 --- /dev/null +++ b/internal/command/requirementcontext/testdata/context-wire-v2/proofkit/tree.json @@ -0,0 +1,16 @@ +{ + "schemaVersion": 2, + "treeId": "wire.tree", + "rootNodeId": "wire.root", + "nodes": [{ + "nodeId": "wire.root", + "nodeKind": "meta_spec", + "label": "Wire compatibility", + "displayOrder": 1, + "callerAnnotations": [], + "sourceRefs": [{"sourceRefId": "wire.requirements.ref", "sourceRefKind": "source_id", "sourceRole": "requirements", "sourceId": "wire.requirements"}] + }], + "edges": [], + "overlays": [], + "callerAnnotations": [] +} diff --git a/internal/command/requirementgraph/output_admission.go b/internal/command/requirementgraph/output_admission.go index 84cf8c1..deae4cb 100644 --- a/internal/command/requirementgraph/output_admission.go +++ b/internal/command/requirementgraph/output_admission.go @@ -227,7 +227,7 @@ func admitGraphEdge(edge map[string]any) error { keys = append(keys, "codeNodeId") } if plane == "native_execution_coverage" && edge["edgeKind"] == "observed_by" { - if _, err := admit.RuleID(edge["codeNodeId"], "requirement traceability graph edge codeNodeId"); err != nil { + if _, err := admitGraphID(edge["codeNodeId"], "requirement traceability graph edge codeNodeId"); err != nil { return err } } @@ -241,7 +241,7 @@ func admitGraphEdge(edge map[string]any) error { return err } for _, key := range []string{"fromNodeId", "toNodeId"} { - if _, err := admit.RuleID(edge[key], "requirement traceability graph edge "+key); err != nil { + if _, err := admitGraphID(edge[key], "requirement traceability graph edge "+key); err != nil { return err } } @@ -347,6 +347,15 @@ func admitGraphID(raw any, context string) (string, error) { if ok && derivedGraphIDPattern.MatchString(value) { return value, nil } + if prefix, component, found := strings.Cut(value, ":"); ok && found { + switch prefix { + case "spec", "requirement", "code", "execution": + if _, err := admit.RuleID(component, context); err != nil { + return "", err + } + return value, nil + } + } return admit.RuleID(raw, context) } diff --git a/internal/command/requirementgraph/reference_projection_test.go b/internal/command/requirementgraph/reference_projection_test.go index 406785e..a9d0c7e 100644 --- a/internal/command/requirementgraph/reference_projection_test.go +++ b/internal/command/requirementgraph/reference_projection_test.go @@ -1,8 +1,14 @@ package requirementgraph import ( + "crypto/sha256" + "encoding/json" + "fmt" "reflect" + "strings" "testing" + + "github.com/research-engineering/agentic-proofkit/internal/kernel/admit" ) func TestNodeReferencesPreserveTypedRecordAndFieldIdentity(t *testing.T) { @@ -26,3 +32,93 @@ func TestNodeReferencesPreserveTypedRecordAndFieldIdentity(t *testing.T) { t.Fatal("empty selection invented a reference") } } + +func TestGraphDigestNodeAndEndpointAdmissionAgree(t *testing.T) { + const proofID = "proof:5d8cdbfd0739d25ae297840d1514765c4db00ac02cd521669535b5865cffc0fe" + const requirementID = "REQ-WIRE-001" + identity := map[string]any{"requirementId": requirementID, "scenarioId": "collection.scenario.001", "witnessId": "collection.witness.001", "witnessKind": "contract", "witnessPath": "tests/collection_test.go"} + hashID := func(prefix string, value any) string { + encoded, err := json.MarshalIndent(value, "", " ") + if err != nil { + t.Fatal(err) + } + return fmt.Sprintf("%s:%x", prefix, sha256.Sum256(append(encoded, '\n'))) + } + if proofID != hashID("proof", identity) { + t.Fatal("independent proof-node identity fixture changed") + } + if _, err := admit.RuleID(proofID, "caller id"); err == nil || !strings.Contains(err.Error(), "timestamp") { + t.Fatal("counterexample does not exercise digest/caller-identity separation") + } + proofNode := map[string]any{"nodeId": proofID, "kind": "scenario", "evidencePlane": "proof_coverage", "sourceId": "collection.witness.001", "label": "collection.scenario.001"} + for key, value := range identity { + proofNode[key] = value + } + edge := map[string]any{"edgeKind": "proved_by_candidate", "evidencePlane": "proof_coverage", "fromNodeId": "requirement:" + requirementID, "toNodeId": proofID} + edge["edgeId"] = hashID("proof-edge", map[string]any{"fromNodeId": edge["fromNodeId"], "toNodeId": edge["toNodeId"]}) + output := map[string]any{ + "schemaVersion": json.Number("1"), "graphKind": "proofkit.requirement-traceability-graph", "graphId": "fixture.graph", "snapshotId": "sha256:" + strings.Repeat("a", 64), + "nodeCount": json.Number("2"), "edgeCount": json.Number("1"), "edges": []any{edge}, + "nodes": []any{proofNode, map[string]any{"nodeId": "requirement:" + requirementID, "kind": "requirement", "evidencePlane": "specification_coverage", "sourceId": requirementID, "label": requirementID}}, + "nonClaims": []any{"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."}, + } + if err := admitGraphNode(proofNode); err != nil { + t.Fatalf("digest node itself is invalid: %v", err) + } + decoded := decodedGraphOutput(t, output) + if _, err := AdmitOutput(decoded, output["snapshotId"].(string)); err != nil { + t.Fatalf("resolved digest endpoint did not survive wire admission: %v", err) + } + edge["toNodeId"] = "proof:" + strings.Repeat("b", 64) + edge["edgeId"] = hashID("proof-edge", map[string]any{"fromNodeId": edge["fromNodeId"], "toNodeId": edge["toNodeId"]}) + if _, err := AdmitOutput(output, output["snapshotId"].(string)); err == nil || !strings.Contains(err.Error(), "target must resolve") { + t.Fatalf("unmatched but well-formed digest endpoint was accepted or masked: %v", err) + } + if _, err := admitGraphID("code:20260901", "caller node"); err == nil { + t.Fatal("digest support relaxed ordinary caller identity admission") + } +} + +func TestTransparentGraphIdentitiesPreserveComponentBounds(t *testing.T) { + for _, prefix := range []string{"spec", "requirement", "code", "execution"} { + value := prefix + ":" + strings.Repeat("A", 256) + if actual, err := admitGraphID(value, "derived identity"); err != nil || actual != value { + t.Errorf("%s rejected an admitted component: %v", prefix, err) + } + for _, component := range []string{"", strings.Repeat("A", 257), "20260901", "A/unsafe", "A\nunsafe"} { + if _, err := admitGraphID(prefix+":"+component, "derived identity"); err == nil { + t.Errorf("%s accepted an invalid component", prefix) + } + } + } + input := graphPermutationInput(t) + topology := input["codeTopology"].(map[string]any) + nodes := topology["nodes"].([]any) + parent, child := strings.Repeat("A", 256), strings.Repeat("B", 256) + nodes[0].(map[string]any)["nodeId"] = parent + nodes[1].(map[string]any)["nodeId"], nodes[1].(map[string]any)["parentNodeId"] = child, parent + for _, raw := range topology["edges"].([]any) { + raw.(map[string]any)["codeNodeId"] = child + } + for index, raw := range topology["nativeCoverage"].([]any) { + raw.(map[string]any)["codeNodeId"] = child + raw.(map[string]any)["evidenceRef"] = strings.Repeat(string(rune('C'+index)), 256) + } + output, err := Build(input) + if err != nil { + t.Fatal(err) + } + wire := decodedGraphOutput(t, output) + if _, err := AdmitOutput(wire, wire["snapshotId"].(string)); err != nil { + t.Fatalf("long code and native references failed wire admission: %v", err) + } + seen := map[string]bool{} + for _, raw := range wire["nodes"].([]any) { + seen[raw.(map[string]any)["nodeId"].(string)] = true + } + for _, expected := range []string{"code:" + parent, "code:" + child, "execution:" + strings.Repeat("C", 256), "execution:" + strings.Repeat("D", 256)} { + if !seen[expected] { + t.Fatal("graph replaced a caller-owned component identity") + } + } +} diff --git a/internal/command/stackpreset/preset_ids_generated.go b/internal/command/stackpreset/preset_ids_generated.go index 40d2e82..a663c22 100644 --- a/internal/command/stackpreset/preset_ids_generated.go +++ b/internal/command/stackpreset/preset_ids_generated.go @@ -1,6 +1,6 @@ // Code generated by internal/tools/commandcontractgen; DO NOT EDIT. package stackpreset -const presetContractSourceSHA256 = "e73b37ff7bbef58b81289c13a28b34bbe4fbe42b198e52fc897aa6e1847fb80c" +const presetContractSourceSHA256 = "4504d5e7a4e18ccdda5ef77dcaf8c510280afdc1d49f9cbd170bbf497b8e6ec3" var presetIDs = []string{"agentic_runtime_repo", "generated_docs_contract_repo", "python_service", "python_typescript_service", "typescript_monorepo", "typescript_workspace"} diff --git a/internal/testsupport/projectfixture/fixture.go b/internal/testsupport/projectfixture/fixture.go new file mode 100644 index 0000000..8426864 --- /dev/null +++ b/internal/testsupport/projectfixture/fixture.go @@ -0,0 +1,148 @@ +package projectfixture + +import ( + "bytes" + "crypto/sha256" + _ "embed" + "encoding/json" + "fmt" + "github.com/research-engineering/agentic-proofkit/internal/command/adoptionmaterialization" + "github.com/research-engineering/agentic-proofkit/internal/command/requirementbinding" + "github.com/research-engineering/agentic-proofkit/internal/command/requirementsourceadmission" + "github.com/research-engineering/agentic-proofkit/internal/command/testevidenceinventory" + "github.com/research-engineering/agentic-proofkit/internal/kernel/admission" + "os" + "path/filepath" + "testing" +) + +//go:embed testdata/source.json +var sourceSeed []byte + +type Fixture struct { + Root string + Files map[string][]byte + Project map[string]any +} + +// New arranges a disposable project, never an expected browser projection. +func New(t testing.TB) Fixture { + t.Helper() + return WithRequirementIDs(t, [3]string{"REQ-WIRE-001", "REQ-WIRE-002", "REQ-WIRE-003"}) +} + +// WithRequirementIDs varies only admitted source identities in the same fixture. +func WithRequirementIDs(t testing.TB, ids [3]string) Fixture { + t.Helper() + seed := sourceSeed + sources, requirements, bindings, entries := []any{}, []any{}, []any{}, []any{} + files := map[string][]byte{} + for index, item := range []struct{ id, directory, text string }{{"zeta.source", "a", "Collection \U0001f9ed A preserves its explicit invariant."}, {"shared.identity", "z", "Collection E\u0301 Z preserves its explicit invariant."}} { + source := record(t, seed) + path := "docs/specs/" + item.directory + "/requirements.v1.json" + source["sourceId"], source["specPackagePath"], source["overviewPath"], source["requirementsPath"] = item.id, "docs/specs/"+item.directory, "docs/specs/"+item.directory+"/overview.md", path + source["nonClaims"] = []any{"Source " + item.directory + " does not prove execution."} + items := source["requirements"].([]any) + if index == 1 { + items = append(items, clone(t, items[0].(map[string]any))) + } + for position, raw := range items { + requirement := raw.(map[string]any) + number := index + position + 1 + id := ids[number-1] + requirement["requirementId"], requirement["invariant"] = id, item.text + requirement["nonClaimRefs"] = []any{"collection.nonclaim.execution"} + suffix := fmt.Sprintf("%03d", number) + requirements = append(requirements, map[string]any{"requirementId": id, "ownerId": "wire.owner", "specPath": path, "claimLevel": "blocking", "proofState": "witness_backed", "nonClaims": requirement["nonClaims"]}) + bindings = append(bindings, map[string]any{"requirementId": id, "scenarioId": "collection.scenario." + suffix, "witnessId": "collection.witness." + suffix, "witnessKind": "contract", "witnessPath": "tests/collection_test.go", "witnessSelectors": []any{map[string]any{"selector": "TestCollection" + suffix, "command": "go test ./tests -run TestCollection" + suffix}}, "commandIds": []any{"collection.check"}, "environmentClasses": []any{"local-go"}}) + entries = append(entries, map[string]any{ + "testId": "collection.test." + suffix, "selector": "go test ./tests -run TestCollection" + suffix, "sourcePath": "tests/collection_test.go", "ownerId": "wire.owner", + "evidenceClass": "declared_semantic_falsifier_route", "requirementRefs": []any{id}, "ownerInvariantRefs": []any{}, "commandRefs": []any{"collection.check"}, "witnessRefs": []any{"collection.witness." + suffix}, + "falsifier": map[string]any{"falsifierId": "collection.falsifier." + suffix, "negativeCaseId": "collection.case." + suffix, "wrongImplementationClassId": "collection.wrong." + suffix, "dominanceGroup": "collection.group." + suffix, "supersedes": []any{}}, + "oracle": map[string]any{"oracleId": "collection.oracle." + suffix, "oracleKind": "negative_exit_and_diagnostic", "expectedPublicOutcome": "invalid collection fails", "assertionSummary": "A contradictory collection is rejected."}, "nonClaims": []any{}, + }) + } + source["requirements"] = items + admitted, err := requirementsourceadmission.Evaluate(source) + if err != nil || admitted.ExitCode != 0 { + t.Fatalf("independent source fixture: %v", err) + } + canonical := requirementsourceadmission.SourceValue(admitted.Source) + sources = append(sources, canonical) + files[path] = jsonBytes(t, canonical) + } + binding, err := requirementbinding.Build(map[string]any{ + "schemaVersion": json.Number("1"), "bindingId": "shared.identity", "requirements": requirements, "bindings": bindings, + "selection": map[string]any{"changedPaths": []any{}, "ownerIds": []any{}, "requirementIds": []any{}}, + "witnessCommands": []any{map[string]any{"commandId": "collection.check", "command": "go test ./tests", "environmentClasses": []any{"local-go"}}}, + "nonClaims": []any{"Collection bindings do not execute witnesses."}, + }) + if err != nil || binding.Record.State != "passed" { + t.Fatalf("independent binding fixture: %v", err) + } + inventory, err := testevidenceinventory.EvaluateDirect(map[string]any{ + "schemaVersion": json.Number("1"), "inventoryId": "shared.identity", "authority": "caller_owned_inventory", "entries": entries, + "sourceId": "collection.inventory", "ownerId": "wire.owner", + "nonClaims": []any{"Collection inventory does not establish execution."}, + }) + if err != nil || inventory.ExitCode != 0 { + t.Fatalf("independent inventory fixture: %v", err) + } + bindingValue, inventoryValue := requirementbinding.InputValue(binding.Input), testevidenceinventory.InventoryValue(inventory.Inventory) + files["proofkit/bindings.json"], files["proofkit/tests.json"] = jsonBytes(t, bindingValue), jsonBytes(t, inventoryValue) + routes := []any{} + for _, item := range []struct{ kind, path string }{ + {"requirement_source", "docs/specs/a/requirements.v1.json"}, {"requirement_source", "docs/specs/z/requirements.v1.json"}, + {"requirement_proof_binding", "proofkit/bindings.json"}, {"test_evidence_inventory", "proofkit/tests.json"}, + } { + routes = append(routes, map[string]any{"artifactId": contentDigest(files[item.path]), "artifactKind": item.kind, "path": item.path}) + } + manifest := map[string]any{ + "schemaVersion": json.Number("1"), "authority": "routing_only", "manifestKind": "proofkit.project-routing-manifest", + "materializationRequestId": "collection.request", "projectId": "shared.identity", "sourcePlanId": contentDigest([]byte("collection source plan")), "routes": routes, + "nonClaims": []any{"Project routing manifests do not duplicate child semantics or prove child admission, freshness, execution, merge, release, rollout, or production readiness."}, + } + manifest["manifestId"] = contentDigest(jsonBytes(t, manifest)) + // Captured manifest bytes intentionally differ from canonical JSON bytes. + files[adoptionmaterialization.ProjectManifestPath] = append([]byte(" \n"), jsonBytes(t, manifest)...) + root := t.TempDir() + for path, content := range files { + full := filepath.Join(root, filepath.FromSlash(path)) + if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(full, content, 0o600); err != nil { + t.Fatal(err) + } + } + return Fixture{Root: root, Files: files, Project: map[string]any{ + "manifest": manifest, "requirementSources": sources, "proofBinding": bindingValue, "testEvidenceInventory": inventoryValue, + }} +} + +func record(t testing.TB, content []byte) map[string]any { + t.Helper() + value, err := admission.DecodeJSON(bytes.NewReader(content), int64(len(content))) + if err != nil { + t.Fatal(err) + } + return value.(map[string]any) +} + +func clone(t testing.TB, value map[string]any) map[string]any { + t.Helper() + return record(t, jsonBytes(t, value)) +} + +func jsonBytes(t testing.TB, value any) []byte { + t.Helper() + encoded, err := json.MarshalIndent(value, "", " ") + if err != nil { + t.Fatal(err) + } + return append(encoded, '\n') +} + +func contentDigest(content []byte) string { + return fmt.Sprintf("sha256:%x", sha256.Sum256(content)) +} diff --git a/internal/testsupport/projectfixture/fixture_test.go b/internal/testsupport/projectfixture/fixture_test.go new file mode 100644 index 0000000..c195c45 --- /dev/null +++ b/internal/testsupport/projectfixture/fixture_test.go @@ -0,0 +1,27 @@ +package projectfixture + +import ( + "bytes" + "os" + "path/filepath" + "testing" +) + +func TestProjectFixtureHasIndependentRecordsFilesAndCalls(t *testing.T) { + first, second := New(t), New(t) + if first.Root == second.Root || !bytes.Equal(jsonBytes(t, first.Project), jsonBytes(t, second.Project)) { + t.Fatal("fresh fixture does not preserve equivalent independent project inputs") + } + want := jsonBytes(t, second.Project) + first.Project["requirementSources"].([]any)[0].(map[string]any)["requirements"].([]any)[0].(map[string]any)["invariant"] = "Mutated fixture input." + for path, content := range first.Files { + content[0] = '!' + onDisk, err := os.ReadFile(filepath.Join(first.Root, filepath.FromSlash(path))) + if err != nil || !bytes.Equal(onDisk, second.Files[path]) { + t.Fatal("returned fixture bytes alias persisted input or another fixture") + } + } + if !bytes.Equal(want, jsonBytes(t, second.Project)) || !bytes.Equal(want, jsonBytes(t, New(t).Project)) { + t.Fatal("changing one fixture changes a sibling or later fixture") + } +} diff --git a/internal/testsupport/projectfixture/testdata/source.json b/internal/testsupport/projectfixture/testdata/source.json new file mode 100644 index 0000000..afb2d5d --- /dev/null +++ b/internal/testsupport/projectfixture/testdata/source.json @@ -0,0 +1,20 @@ +{ + "schemaVersion": 1, + "sourceId": "wire.requirements", + "specPackagePath": "docs/specs/wire", + "overviewPath": "docs/specs/wire/overview.md", + "requirementsPath": "docs/specs/wire/requirements.v1.json", + "requirements": [{ + "requirementId": "REQ-WIRE-001", + "ownerId": "wire.owner", + "invariant": "A bounded context preserves its admitted requirement identity.", + "claimLevel": "blocking", + "riskClass": "high", + "lifecycle": {"state": "active", "replacementRequirementIds": [], "evidenceRefs": []}, + "proofBindingRefs": ["proofkit/bindings.json"], + "nonClaimRefs": [], + "nonClaims": ["This fixture does not establish execution evidence."], + "updatePolicy": {"requiresImpactDeclaration": true, "requiresProofBindingReview": true, "reviewOwnerId": "wire.owner"} + }], + "nonClaims": ["Wire compatibility is not consumer readiness."] +} diff --git a/internal/tools/releasechange/record_test.go b/internal/tools/releasechange/record_test.go index 23a0fd5..3f3f116 100644 --- a/internal/tools/releasechange/record_test.go +++ b/internal/tools/releasechange/record_test.go @@ -197,9 +197,11 @@ func TestCurrentChangeRecordNamesReviewedSemanticChanges(t *testing.T) { var currentBreakingChanges = []Change{} var currentAdditions = []Change{ - {ChangeID: "proofkit.browser.coverage-inspection", Summary: "Inspect compact or structured coverage beside original source-bound requirements, distinguish missing evidence from reported verdicts, preserve complete evidence and non-claims in disclosures, and ask an explicit evidence question without replacing an existing draft."}, - {ChangeID: "proofkit.browser.diff-and-graph-inspection", Summary: "Show exact diff-page class, entity, risk and lifecycle counts. Inspect a bounded traceability diagram and equivalent records with local evidence-plane and neighborhood filters, preserved directed relations, exact numeric source coordinates, explicit outside-page references and deliberate target-page navigation."}, - {ChangeID: "proofkit.browser.handoff-inspection", Summary: "Preview included source-bound handoff context by stable requirement identity and explicitly copy or download the exact compact server JSON. Obsolete view or clipboard results cannot replace current output; independent pending exclusion and denied or stale request locks survive settlement, with one workspace reload action for a stale session."}, + {ChangeID: "proofkit.browser.project-entry", Summary: "Use view --repo-root to prepare a read-only workspace from one complete materialized project, or add --serve to inspect it through the existing loopback browser. Explicit --open, one-shot questions, compact JSON plans and bounded diagnostics reuse the existing browser and CLI owners."}, + {ChangeID: "proofkit.browser.reference-closure", Summary: "Resolve question anchors against the exact browser-issued session inventory, preserving admitted long requirement identities without expanding base identifier limits or weakening quote, source and terminal checks."}, + {ChangeID: "proofkit.cli.deterministic-choice-diagnostics", Summary: "Validate flag choices in descriptor order so identical invalid invocations select the same diagnostic. Effect-sensitive tests cover rejection before project preparation and inspection, including errors deliberately discarded by a faulty caller."}, + {ChangeID: "proofkit.context.captured-project-origin", Summary: "Add closed project-origin context schema 3, with owner-replayed source and binding projections, role-preserving physical identities and source-level limitations retained in question handoffs. Existing context v1/v2 identities and low-level browser contracts remain unchanged."}, + {ChangeID: "proofkit.graph.derived-reference-admission", Summary: "Apply one typed identity owner to graph nodes and relation references. Transparent prefixes preserve the full admitted component length; digest-derived identities remain distinct from caller IDs. Invalid components, unresolved references and inconsistent topology remain rejected."}, } var currentMigrationSteps = []string{} @@ -222,7 +224,7 @@ func validateCurrentChangeRecord(record Record, notes string) error { func currentExpectedReleaseNotes() string { lines := []string{ - "# @research-engineering/agentic-proofkit 0.13.0", + "# @research-engineering/agentic-proofkit 0.14.0", "", "## Breaking Contract Changes", "", @@ -263,6 +265,7 @@ func currentExpectedReleaseNotes() string { "- Brief agent-route packets cap pretty JSON at 3072 bytes and may defer oversized argv to explicit full detail; the bound does not claim tokenizer-specific token counts.", "- Complete nested public structural contracts remain blocked under SCHEMA-01; current CLI contracts own exact root variants only.", "- Project status and next classify materialized repository structure only; they do not execute native verification, validate receipt currentness or trust, or declare workflow completion.", + "- View requires an explicitly selected complete materialized project. It neither scans source files nor writes project records or executes native witnesses. Its graph contains declared relations only; coverage and diff remain unavailable. The captured snapshot does not claim filesystem freshness after inspection.", "- The selected requirement-source v2 codec remains internal; current requirement sources are not migrated and no source cutover is claimed.", "- TSX source parsing remains unsupported.", "- Managed integration baselines are cooperative byte/mode bookkeeping, not authenticated origin or protection against coordinated same-user edits. File lifecycle does not prove native host discovery, instruction loading, or approved-launcher invocation.", @@ -274,7 +277,7 @@ func currentExpectedReleaseNotes() string { "Primary npm channel:", "", "```bash", - "npm install --save-dev --save-exact @research-engineering/agentic-proofkit@0.13.0", + "npm install --save-dev --save-exact @research-engineering/agentic-proofkit@0.14.0", "```", "", "Pre-1.0 npm consumers must keep this dependency exact-pinned.", @@ -286,7 +289,7 @@ func currentExpectedReleaseNotes() string { "## Rollback", "", "- First follow the migration and persistent-state compatibility restrictions above; changing a package pin does not roll back repository state.", - "- Pin npm consumers to the previous admitted version 0.12.0 with `npm install --save-dev --save-exact @research-engineering/agentic-proofkit@0.12.0`.", + "- Pin npm consumers to the previous admitted version 0.13.0 with `npm install --save-dev --save-exact @research-engineering/agentic-proofkit@0.13.0`.", "- Treat local package artifacts as candidates until registry identity is proven.", ) return strings.Join(lines, "\n") + "\n" diff --git a/internal/tools/workflowsmoke/project_navigation_smoke.go b/internal/tools/workflowsmoke/project_navigation_smoke.go index bff9c0e..4509ad4 100644 --- a/internal/tools/workflowsmoke/project_navigation_smoke.go +++ b/internal/tools/workflowsmoke/project_navigation_smoke.go @@ -3,6 +3,7 @@ package workflowsmoke import ( "bytes" "context" + "encoding/json" "errors" "fmt" "os" @@ -11,6 +12,7 @@ import ( "github.com/research-engineering/agentic-proofkit/internal/command/adoptionmaterialization" "github.com/research-engineering/agentic-proofkit/internal/command/projectstatus" "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/repositorytransaction" ) @@ -93,6 +95,9 @@ func verifyProjectNavigation(ctx context.Context, run Runner) (returnErr error) if err := verifyFailure(ctx, run, "project next JSON color denial", unreadInvocation("next", "--repo-root", repositoryRoot, "--color", "never"), "--color requires --format text"); err != nil { return err } + if err := verifyFailure(ctx, run, "uninitialized project view", unreadInvocation("view", "--repo-root", repositoryRoot), "requires a complete admitted project"); err != nil { + return err + } return verifyMaterializedProjectNavigation(ctx, run, repositoryRoot) } @@ -135,6 +140,9 @@ func verifyMaterializedProjectNavigation(ctx context.Context, run Runner, reposi if err := verifyInstalledProjectState(ctx, run, repositoryRoot, projectstatus.StateVerificationRequired, projectstatus.ActionRunRepositoryVerification, "materialized"); err != nil { return err } + if err := verifyInstalledProjectView(ctx, run, repositoryRoot); err != nil { + return err + } if len(expectedPlan.Manifest.Routes) == 0 { return fmt.Errorf("installed materialization plan has no routed child") } @@ -150,7 +158,41 @@ func verifyMaterializedProjectNavigation(ctx context.Context, run Runner, reposi if err := file.Close(); err != nil { return fmt.Errorf("close drifted installed materialization child: %w", err) } - return verifyInstalledProjectState(ctx, run, repositoryRoot, projectstatus.StateStale, projectstatus.ActionRematerializeProject, "drifted materialized") + if err := verifyInstalledProjectState(ctx, run, repositoryRoot, projectstatus.StateStale, projectstatus.ActionRematerializeProject, "drifted materialized"); err != nil { + return err + } + return verifyFailure(ctx, run, "stale project view", unreadInvocation("view", "--repo-root", repositoryRoot), "requires a complete admitted project") +} + +func verifyInstalledProjectView(ctx context.Context, run Runner, repositoryRoot string) error { + result, err := invoke(ctx, run, "materialized project view", unreadInvocation("view", "--repo-root", repositoryRoot)) + if err != nil { + return err + } + value, err := admission.DecodeJSON(bytes.NewReader(result.Stdout), 4096) + if err != nil { + return fmt.Errorf("installed project view is not a bounded JSON plan") + } + plan, ok := value.(map[string]any) + keys := []string{"authority", "host", "htmlByteLength", "nonClaims", "planKind", "port", "portSelection", "renderedAuthority", "renderedViewKind", "schemaVersion", "url", "view"} + if !ok || len(plan) != len(keys) || admit.KnownKeys(plan, keys, "installed project view") != nil { + return fmt.Errorf("installed project view has an invalid plan shape") + } + if plan["authority"] != "presentation_adapter_plan" || plan["host"] != "127.0.0.1" || plan["planKind"] != "proofkit.requirement-browser-server-plan" || plan["port"] != json.Number("0") || plan["portSelection"] != "ephemeral" || plan["renderedAuthority"] != "presentation_adapter" || plan["renderedViewKind"] != "proofkit.requirement-workspace" || plan["schemaVersion"] != json.Number("1") || plan["url"] != nil || plan["view"] != "workspace" { + return fmt.Errorf("installed project view changed its workspace plan semantics") + } + length, ok := plan["htmlByteLength"].(json.Number) + if !ok { + return fmt.Errorf("installed project view has no HTML byte count") + } + count, err := length.Int64() + if err != nil || count <= 0 { + return fmt.Errorf("installed project view has an invalid HTML byte count") + } + if _, err := admit.TextArray(plan["nonClaims"], "installed project view nonClaims", false); err != nil { + return fmt.Errorf("installed project view lost its limitations") + } + return nil } func verifyInstalledProjectState(ctx context.Context, run Runner, repositoryRoot string, wantState projectstatus.ProjectState, wantAction, label string) error { diff --git a/internal/tools/workflowsmoke/workflow_smoke_test.go b/internal/tools/workflowsmoke/workflow_smoke_test.go index a90c387..9353fc6 100644 --- a/internal/tools/workflowsmoke/workflow_smoke_test.go +++ b/internal/tools/workflowsmoke/workflow_smoke_test.go @@ -60,6 +60,9 @@ func TestVerifyRejectsCarrierContractMutations(t *testing.T) { {name: "guidance applicability", match: "native-evidence-guidance", apply: replaceStdoutFragment(`"applicabilityClass": "always"`, `"applicabilityClass": "external_process"`)}, {name: "guidance middle slot", match: "native-evidence-guidance", apply: replaceStdoutFragment(`"slotId": "output_bounds"`, `"slotId": "wrong"`)}, {name: "guidance text suffix", match: "native-evidence-guidance --format text --color never", apply: appendStdout("surplus\n")}, + {name: "project view identity", match: "view --repo-root ", matchPrefix: true, materializedOnly: true, apply: replaceStdoutFragment(`"planKind": "proofkit.requirement-browser-server-plan"`, `"planKind": "wrong"`)}, + {name: "project view wrong mode", match: "view --repo-root ", matchPrefix: true, materializedOnly: true, apply: replaceStdoutFragment(`"view": "workspace"`, `"view": "proof"`)}, + {name: "project view unbounded shape", match: "view --repo-root ", matchPrefix: true, materializedOnly: true, apply: replaceStdout(`{"ok":true}`)}, {name: "project status identity", match: "status --repo-root ", matchPrefix: true, apply: replaceStdoutFragment(`"reportKind": "proofkit.project-status"`, `"reportKind": "wrong"`)}, {name: "project next identity", match: "next --repo-root ", matchPrefix: true, apply: replaceStdoutFragment(`"packetKind": "proofkit.project-next-action"`, `"packetKind": "wrong"`)}, {name: "materialized project status failure", match: "status --repo-root ", matchPrefix: true, materializedOnly: true, apply: func(result workflowsmoke.Result) workflowsmoke.Result { diff --git a/package-lock.json b/package-lock.json index e660f78..cdef40a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@research-engineering/agentic-proofkit", - "version": "0.13.0", + "version": "0.14.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@research-engineering/agentic-proofkit", - "version": "0.13.0", + "version": "0.14.0", "cpu": [ "arm64", "x64" diff --git a/package.json b/package.json index c15e9a9..0a27267 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.13.0", + "version": "0.14.0", "type": "module", "license": "MIT", "sideEffects": false, diff --git a/proofkit/cli-contract.v2.json b/proofkit/cli-contract.v2.json index 59cd8e0..21b01a6 100644 --- a/proofkit/cli-contract.v2.json +++ b/proofkit/cli-contract.v2.json @@ -112,7 +112,7 @@ "rootDefinitionDigest": "sha256:3dbf760a8f8b0a4093c1dd4edc23367c5ef8027b31c18a01490afb657fcc03ad", "nativeSource": { "path": "internal/command/adoptionmaterialization", - "canonicalDigest": "sha256:a02e36ec07451d0923b79f102edf984b6b34eee255dc7d0fa1f7bc41440c306e", + "canonicalDigest": "sha256:f9bd5797a95e7d620a3f0dadf814d917ded15599dabb0fc6530a2f785bcc03b4", "evidenceClass": "source_checkout" }, "nativeAdmissionWitnessSelector": { @@ -143,12 +143,12 @@ "nativeSources": [ { "path": "internal/app", - "canonicalDigest": "sha256:2bea687b114cac5e816ae754ab7c27cdaf953f9b81134733373ba27ff86fc2e3", + "canonicalDigest": "sha256:da018bb511cc1dd050a9314b69b539cb96972733a1418c9fd1692ce561bac0e7", "evidenceClass": "source_checkout" }, { "path": "internal/command/adoptionmaterialization", - "canonicalDigest": "sha256:a02e36ec07451d0923b79f102edf984b6b34eee255dc7d0fa1f7bc41440c306e", + "canonicalDigest": "sha256:f9bd5797a95e7d620a3f0dadf814d917ded15599dabb0fc6530a2f785bcc03b4", "evidenceClass": "source_checkout" }, { @@ -251,7 +251,7 @@ "rootDefinitionDigest": "sha256:c18995fb310dbde102bea231d83ec3756ffdf863199acbc88237471d06278c9e", "nativeSource": { "path": "internal/command/adoptionmaterialization", - "canonicalDigest": "sha256:a02e36ec07451d0923b79f102edf984b6b34eee255dc7d0fa1f7bc41440c306e", + "canonicalDigest": "sha256:f9bd5797a95e7d620a3f0dadf814d917ded15599dabb0fc6530a2f785bcc03b4", "evidenceClass": "source_checkout" }, "nativeAdmissionWitnessSelector": { @@ -282,12 +282,12 @@ "nativeSources": [ { "path": "internal/app", - "canonicalDigest": "sha256:2bea687b114cac5e816ae754ab7c27cdaf953f9b81134733373ba27ff86fc2e3", + "canonicalDigest": "sha256:da018bb511cc1dd050a9314b69b539cb96972733a1418c9fd1692ce561bac0e7", "evidenceClass": "source_checkout" }, { "path": "internal/command/adoptionmaterialization", - "canonicalDigest": "sha256:a02e36ec07451d0923b79f102edf984b6b34eee255dc7d0fa1f7bc41440c306e", + "canonicalDigest": "sha256:f9bd5797a95e7d620a3f0dadf814d917ded15599dabb0fc6530a2f785bcc03b4", "evidenceClass": "source_checkout" }, { @@ -396,12 +396,12 @@ "nativeSources": [ { "path": "internal/app", - "canonicalDigest": "sha256:2bea687b114cac5e816ae754ab7c27cdaf953f9b81134733373ba27ff86fc2e3", + "canonicalDigest": "sha256:da018bb511cc1dd050a9314b69b539cb96972733a1418c9fd1692ce561bac0e7", "evidenceClass": "source_checkout" }, { "path": "internal/command/adoptionmaterialization", - "canonicalDigest": "sha256:a02e36ec07451d0923b79f102edf984b6b34eee255dc7d0fa1f7bc41440c306e", + "canonicalDigest": "sha256:f9bd5797a95e7d620a3f0dadf814d917ded15599dabb0fc6530a2f785bcc03b4", "evidenceClass": "source_checkout" }, { @@ -1187,7 +1187,7 @@ "nativeSources": [ { "path": "internal/app", - "canonicalDigest": "sha256:2bea687b114cac5e816ae754ab7c27cdaf953f9b81134733373ba27ff86fc2e3", + "canonicalDigest": "sha256:da018bb511cc1dd050a9314b69b539cb96972733a1418c9fd1692ce561bac0e7", "evidenceClass": "source_checkout" }, { @@ -2506,7 +2506,7 @@ "nativeSources": [ { "path": "internal/app", - "canonicalDigest": "sha256:2bea687b114cac5e816ae754ab7c27cdaf953f9b81134733373ba27ff86fc2e3", + "canonicalDigest": "sha256:da018bb511cc1dd050a9314b69b539cb96972733a1418c9fd1692ce561bac0e7", "evidenceClass": "source_checkout" }, { @@ -2612,7 +2612,7 @@ "nativeSources": [ { "path": "internal/app", - "canonicalDigest": "sha256:2bea687b114cac5e816ae754ab7c27cdaf953f9b81134733373ba27ff86fc2e3", + "canonicalDigest": "sha256:da018bb511cc1dd050a9314b69b539cb96972733a1418c9fd1692ce561bac0e7", "evidenceClass": "source_checkout" }, { @@ -2740,7 +2740,7 @@ "nativeSources": [ { "path": "internal/app", - "canonicalDigest": "sha256:2bea687b114cac5e816ae754ab7c27cdaf953f9b81134733373ba27ff86fc2e3", + "canonicalDigest": "sha256:da018bb511cc1dd050a9314b69b539cb96972733a1418c9fd1692ce561bac0e7", "evidenceClass": "source_checkout" }, { @@ -2867,7 +2867,7 @@ "nativeSources": [ { "path": "internal/app", - "canonicalDigest": "sha256:2bea687b114cac5e816ae754ab7c27cdaf953f9b81134733373ba27ff86fc2e3", + "canonicalDigest": "sha256:da018bb511cc1dd050a9314b69b539cb96972733a1418c9fd1692ce561bac0e7", "evidenceClass": "source_checkout" }, { @@ -2965,7 +2965,7 @@ "nativeSources": [ { "path": "internal/app", - "canonicalDigest": "sha256:2bea687b114cac5e816ae754ab7c27cdaf953f9b81134733373ba27ff86fc2e3", + "canonicalDigest": "sha256:da018bb511cc1dd050a9314b69b539cb96972733a1418c9fd1692ce561bac0e7", "evidenceClass": "source_checkout" }, { @@ -3370,7 +3370,7 @@ "rootDefinitionDigest": "sha256:b046def1ec1d608e3c76efd84127df7cd8b0a10a920e7ad9b8b850826432d3af", "nativeSource": { "path": "internal/command/projectstatus", - "canonicalDigest": "sha256:050ff57dee13ac2cc7c71ab4f2ed94c8d9a73f945544ecb57c18c07da4ed5c13", + "canonicalDigest": "sha256:b67a14ae9a1348fcbc0610bebc0148600d4fd3e4d54ed9efe1738af2d975dfe2", "evidenceClass": "source_checkout" }, "nativeOutputWitnessSelector": { @@ -3640,7 +3640,7 @@ "nativeSources": [ { "path": "internal/app", - "canonicalDigest": "sha256:2bea687b114cac5e816ae754ab7c27cdaf953f9b81134733373ba27ff86fc2e3", + "canonicalDigest": "sha256:da018bb511cc1dd050a9314b69b539cb96972733a1418c9fd1692ce561bac0e7", "evidenceClass": "source_checkout" }, { @@ -4939,7 +4939,7 @@ "rootDefinitionDigest": "sha256:e6af5b9edb1284ab2ff2f1a276a6b924127b99810cd9446bffbc971f638a0997", "nativeSource": { "path": "internal/command/requirementbrowser", - "canonicalDigest": "sha256:308099bac0caa0aa309a9f236182a6a09e0caac6fb5c7c07679fa471d1ee590c", + "canonicalDigest": "sha256:70f0fc4f0dfa82f4077c844f9b247a524591ec49d7c8093c789b48ff525a5ab1", "evidenceClass": "source_checkout" }, "nativeAdmissionWitnessSelector": { @@ -4976,7 +4976,7 @@ "rootDefinitionDigest": "sha256:c2e7d851c7928560d4267fe85ebc0ae61e33c4c0ef15f7f7b01deb73bb80eda7", "nativeSource": { "path": "internal/command/requirementbrowser", - "canonicalDigest": "sha256:308099bac0caa0aa309a9f236182a6a09e0caac6fb5c7c07679fa471d1ee590c", + "canonicalDigest": "sha256:70f0fc4f0dfa82f4077c844f9b247a524591ec49d7c8093c789b48ff525a5ab1", "evidenceClass": "source_checkout" }, "nativeOutputWitnessSelector": { @@ -5023,7 +5023,7 @@ "rootDefinitionDigest": "sha256:41bc233c96bd468bc96eeb0447e037cacdbcf92bb04161052303e8747e4282f2", "nativeSource": { "path": "internal/command/requirementcontext", - "canonicalDigest": "sha256:15b85d9a093c8ce0b30f6428a72d3d4e62baf8e9f51be2b1f12d47f2f3f34509", + "canonicalDigest": "sha256:2ee9c04ca2b3775a8e1d1f066a3eefcee71ea4f3e24c6b6690fd2e3c4c2aeb40", "evidenceClass": "source_checkout" }, "nativeAdmissionWitnessSelector": { @@ -5063,7 +5063,7 @@ "rootDefinitionDigest": "sha256:cdadd7b589d682a122cfe2b801f001a3b22a2269aa4977ebe073932d11e1f816", "nativeSource": { "path": "internal/command/requirementcontext", - "canonicalDigest": "sha256:15b85d9a093c8ce0b30f6428a72d3d4e62baf8e9f51be2b1f12d47f2f3f34509", + "canonicalDigest": "sha256:2ee9c04ca2b3775a8e1d1f066a3eefcee71ea4f3e24c6b6690fd2e3c4c2aeb40", "evidenceClass": "source_checkout" }, "nativeOutputWitnessSelector": { @@ -5110,7 +5110,7 @@ "rootDefinitionDigest": "sha256:43852cf1a52b3c1f000ec95e460fbad157a6b6a39958d20f81c9e1e4d31e3c6f", "nativeSource": { "path": "internal/command/requirementcontext", - "canonicalDigest": "sha256:15b85d9a093c8ce0b30f6428a72d3d4e62baf8e9f51be2b1f12d47f2f3f34509", + "canonicalDigest": "sha256:2ee9c04ca2b3775a8e1d1f066a3eefcee71ea4f3e24c6b6690fd2e3c4c2aeb40", "evidenceClass": "source_checkout" }, "nativeAdmissionWitnessSelector": { @@ -5122,7 +5122,8 @@ "compatibilitySummary": [ "schemaVersion=1", "sliceId", - "context=proofkit.requirement-context schemaVersion=2 with strict v1 adapter", + "context=proofkit.requirement-context schemaVersion=2 with strict v1 adapter, or schemaVersion=3 closed captured project origin", + "Project-origin v3 replay validates the exact canonical project and role/source partition; it does not reread live files or reinterpret the existing v1/v2 identities.", "query.profile=routing|specification|proof|coverage|review", "query.nodeIds[]|requirementIds[]|ownerIds[]|lifecycleStates[]", "query.maxDepth=0..512", @@ -5136,7 +5137,8 @@ "REQ-PROOFKIT-SPEC-019", "REQ-PROOFKIT-SPEC-020", "REQ-PROOFKIT-SPEC-022", - "REQ-PROOFKIT-SPEC-023" + "REQ-PROOFKIT-SPEC-023", + "REQ-PROOFKIT-SPEC-042" ] }, "outputContract": { @@ -5148,7 +5150,7 @@ "rootDefinitionDigest": "sha256:61f7fd43e2e9da5bde9b61bb86e02cbf337a43c6891ca888c1d42936e5b16456", "nativeSource": { "path": "internal/command/requirementcontext", - "canonicalDigest": "sha256:15b85d9a093c8ce0b30f6428a72d3d4e62baf8e9f51be2b1f12d47f2f3f34509", + "canonicalDigest": "sha256:2ee9c04ca2b3775a8e1d1f066a3eefcee71ea4f3e24c6b6690fd2e3c4c2aeb40", "evidenceClass": "source_checkout" }, "nativeOutputWitnessSelector": { @@ -5159,6 +5161,7 @@ }, "compatibilitySummary": [ "schemaVersion=1", + "Source-level nonClaims are retained separately in fragments from project-origin v3; v1/v2 fragment fields remain unchanged.", "root-shape-only definition proofkit.requirement-context-slice.output.v1.root-shape; nested fields, types, and cardinalities are non-claims" ], "ownerRequirementRefs": [ @@ -6344,7 +6347,7 @@ "rootDefinitionDigest": "sha256:6487ff537380d1cbffe5a72b9688e9ce6baffa218e76f8dbbfe8f50d14509219", "nativeSource": { "path": "internal/command/requirementgraph", - "canonicalDigest": "sha256:26836d1c497ccf3f8609dfc6e4559f24c1a5f767e15114af955f97ab26238926", + "canonicalDigest": "sha256:05b5121d8d16352bea9ee5c7279a3fc5f4495cd8314e333ee7b1fa966ae4c79e", "evidenceClass": "source_checkout" }, "nativeAdmissionWitnessSelector": { @@ -6356,7 +6359,8 @@ "compatibilitySummary": [ "schemaVersion=2", "graphId", - "context=proofkit.requirement-context schemaVersion=2 with strict v1 adapter", + "context=proofkit.requirement-context schemaVersion=2 with strict v1 adapter, or schemaVersion=3 closed captured project origin", + "Project-origin v3 replay validates the exact canonical project and role/source partition; it does not reread live files or reinterpret the existing v1/v2 identities.", "codeSources[].path+content (optional, bounded UTF-8)", "codeTopology.nodes[].abstractionLevel=repository|package|module|file|symbol|source_range", "codeTopology.nodes[].sourceDigest+currentnessState", @@ -6370,7 +6374,8 @@ "REQ-PROOFKIT-SPEC-019", "REQ-PROOFKIT-SPEC-020", "REQ-PROOFKIT-SPEC-022", - "REQ-PROOFKIT-SPEC-023" + "REQ-PROOFKIT-SPEC-023", + "REQ-PROOFKIT-SPEC-042" ] }, "outputContract": { @@ -6382,7 +6387,7 @@ "rootDefinitionDigest": "sha256:37d07abc4d9122176ffab9842b5d85fbfdbe068ee9e4097da8fc21206aae1d47", "nativeSource": { "path": "internal/command/requirementgraph", - "canonicalDigest": "sha256:26836d1c497ccf3f8609dfc6e4559f24c1a5f767e15114af955f97ab26238926", + "canonicalDigest": "sha256:05b5121d8d16352bea9ee5c7279a3fc5f4495cd8314e333ee7b1fa966ae4c79e", "evidenceClass": "source_checkout" }, "nativeOutputWitnessSelector": { @@ -6930,7 +6935,7 @@ "rootDefinitionDigest": "sha256:3c842174dff5361e7f83166469b832805e05aa314b073c16234b5b64e346281e", "nativeSource": { "path": "internal/app", - "canonicalDigest": "sha256:2bea687b114cac5e816ae754ab7c27cdaf953f9b81134733373ba27ff86fc2e3", + "canonicalDigest": "sha256:da018bb511cc1dd050a9314b69b539cb96972733a1418c9fd1692ce561bac0e7", "evidenceClass": "source_checkout" }, "nativeAdmissionWitnessSelector": { @@ -6959,7 +6964,7 @@ "rootDefinitionDigest": "sha256:0ea95e277ebe44cd2de42c29b47c38686ac0b6b390d8965367437b3fe138e209", "nativeSource": { "path": "internal/app", - "canonicalDigest": "sha256:2bea687b114cac5e816ae754ab7c27cdaf953f9b81134733373ba27ff86fc2e3", + "canonicalDigest": "sha256:da018bb511cc1dd050a9314b69b539cb96972733a1418c9fd1692ce561bac0e7", "evidenceClass": "source_checkout" }, "nativeOutputWitnessSelector": { @@ -7236,7 +7241,7 @@ "rootDefinitionDigest": "sha256:4d26ca7b7f6be8120fbac40519db02d8e5ae8ba7264208c714fbb9f1cb88ef15", "nativeSource": { "path": "internal/command/projectstatus", - "canonicalDigest": "sha256:050ff57dee13ac2cc7c71ab4f2ed94c8d9a73f945544ecb57c18c07da4ed5c13", + "canonicalDigest": "sha256:b67a14ae9a1348fcbc0610bebc0148600d4fd3e4d54ed9efe1738af2d975dfe2", "evidenceClass": "source_checkout" }, "nativeOutputWitnessSelector": { @@ -7733,6 +7738,115 @@ ] } }, + { + "command": "view", + "input": "none", + "stdin": false, + "inputPointer": false, + "scopeClass": "explicit_filesystem_scan", + "outputModes": [ + "json", + "server" + ], + "allowedFlags": [ + "--host", + "--open", + "--port", + "--repo-root", + "--serve", + "--session-mode", + "--session-timeout-seconds" + ], + "requiredFlags": [ + "--repo-root" + ], + "singleOccurrenceFlags": [ + "--host", + "--open", + "--port", + "--repo-root", + "--serve", + "--session-mode", + "--session-timeout-seconds" + ], + "flagChoices": { + "--host": [ + "127.0.0.1", + "::1" + ], + "--session-mode": [ + "browse", + "one-shot-question" + ] + }, + "flagPresenceRequirements": [ + { + "flag": "--open", + "requiredFlags": [ + "--serve" + ] + }, + { + "flag": "--session-mode", + "requiredFlags": [ + "--serve" + ] + }, + { + "flag": "--session-timeout-seconds", + "requiredFlagValues": [ + { + "flag": "--session-mode", + "value": "one-shot-question" + } + ], + "requiredFlags": [] + } + ], + "flagValueRequirements": [ + { + "flag": "--session-mode", + "requiredFlags": [ + "--open", + "--serve" + ], + "value": "one-shot-question" + } + ], + "outputContract": { + "contractId": "proofkit.view.output.v1", + "schemaVersion": 1, + "rootType": "object", + "closed": true, + "rootDefinitionRef": "proofkit.view.output.v1.root-shape", + "rootDefinitionDigest": "sha256:a99a87f8bcd3ba8fe9c0609896d93670f6acffffd8015b6c8732369c779b6c79", + "nativeSource": { + "path": "internal/command/requirementbrowser", + "canonicalDigest": "sha256:70f0fc4f0dfa82f4077c844f9b247a524591ec49d7c8093c789b48ff525a5ab1", + "evidenceClass": "source_checkout" + }, + "nativeOutputWitnessSelector": { + "path": "internal/app/project_view_command_test.go", + "test": "TestProjectViewCLI", + "command": "go test ./internal/app -run '^TestProjectViewCLI$'", + "evidenceClass": "source_checkout" + }, + "compatibilitySummary": [ + "No JSON input; --repo-root explicitly selects one complete materialized project and never reads stdin.", + "The non-serving JSON plan shares the existing presentation_adapter_plan root and supports pretty or compact layout.", + "Serving emits a loopback URL in browse mode or one fixed compact JSON terminal packet in one-shot-question mode; --json-layout is rejected for every serving mode.", + "--port is 0..65535; --session-timeout-seconds is 1..7200 and requires --session-mode one-shot-question; one-shot-question requires --serve --open.", + "One closed captured project supplies context schemaVersion=3, exact source-role references and source nonClaims; old workspace context versions remain unchanged.", + "Graph shows declared relations only; coverage and diff are unavailable. Viewing does not execute witnesses, write project files or establish current filesystem freshness after capture.", + "Root-shape-only variants; nested fields, semantic admission, host effects and complete execution remain native-owner and native-witness claims." + ], + "ownerRequirementRefs": [ + "REQ-PROOFKIT-PACKAGE-002", + "REQ-PROOFKIT-QUALITY-004", + "REQ-PROOFKIT-SPEC-042" + ] + } + }, { "command": "witness-plan", "input": "required", @@ -16981,6 +17095,108 @@ }, "canonicalDigest": "sha256:93da5f4d5efd4c482cb5da37bb60a80f8e47e4eab549428790ddd1a4e680009d" }, + { + "definitionId": "proofkit.view.output.v1.root-shape", + "schemaVersion": 1, + "rootType": "object", + "closed": true, + "definitionRefs": [], + "fieldTree": { + "kind": "root_shape_only", + "nonClaims": [ + "Root-shape definitions do not claim nested field shapes, leaf types, cardinalities, or semantic validity.", + "Root-shape definitions do not replace direct public-CLI runtime witnesses for variant selection." + ], + "variants": [ + { + "allowedFields": [ + "authority", + "host", + "htmlByteLength", + "nonClaims", + "planKind", + "port", + "portSelection", + "renderedAuthority", + "renderedViewKind", + "schemaVersion", + "url", + "view" + ], + "requiredFields": [ + "authority", + "host", + "htmlByteLength", + "nonClaims", + "planKind", + "port", + "portSelection", + "renderedAuthority", + "renderedViewKind", + "schemaVersion", + "url", + "view" + ], + "rootKind": "object", + "variantId": "01-plan", + "when": [ + "without --serve" + ] + }, + { + "allowedFields": [ + "handoffKind", + "nonClaims", + "schemaVersion", + "snapshotRefs", + "state" + ], + "requiredFields": [ + "handoffKind", + "nonClaims", + "schemaVersion", + "snapshotRefs", + "state" + ], + "rootKind": "object", + "variantId": "02-one-shot-terminal", + "when": [ + "--open; --serve; --session-mode one-shot-question; state=cancelled|expired" + ] + }, + { + "allowedFields": [ + "annotations", + "context", + "handoffKind", + "instructionAuthority", + "nonClaims", + "schemaVersion", + "snapshotRefs", + "sourceTextAuthority", + "state" + ], + "requiredFields": [ + "annotations", + "context", + "handoffKind", + "instructionAuthority", + "nonClaims", + "schemaVersion", + "snapshotRefs", + "sourceTextAuthority", + "state" + ], + "rootKind": "object", + "variantId": "03-one-shot-submitted", + "when": [ + "--open; --serve; --session-mode one-shot-question; state=submitted" + ] + } + ] + }, + "canonicalDigest": "sha256:a99a87f8bcd3ba8fe9c0609896d93670f6acffffd8015b6c8732369c779b6c79" + }, { "definitionId": "proofkit.witness-plan.input.v1.root-shape", "schemaVersion": 1, diff --git a/proofkit/command-families.v1.json b/proofkit/command-families.v1.json index 20bfb75..25ddd9a 100644 --- a/proofkit/command-families.v1.json +++ b/proofkit/command-families.v1.json @@ -83,10 +83,11 @@ { "familyId": "project-state-navigation", "label": "Project state navigation", - "purpose": "Classify a materialized project and expose one bounded next action.", + "purpose": "Classify a materialized project, expose one bounded next action and inspect its captured specifications in a local browser.", "commands": [ "next", - "status" + "status", + "view" ] }, { diff --git a/proofkit/requirement-bindings.json b/proofkit/requirement-bindings.json index eebec6f..9c7f2a2 100644 --- a/proofkit/requirement-bindings.json +++ b/proofkit/requirement-bindings.json @@ -1003,6 +1003,16 @@ "Recovery reports a historical transaction, not current installed/removed state or post-return stability; cancellation after the final effect cannot retract a committed operation.", "The baseline is cooperative bookkeeping, not authenticated origin, owner approval, or protection from coordinated same-user edits or rollback." ] + }, + { + "requirementId": "REQ-PROOFKIT-SPEC-042", + "ownerId": "proofkit.spec-proof-core", + "specPath": "docs/specs/proofkit-spec-proof-core/requirements.v1.json", + "claimLevel": "blocking", + "proofState": "witness_backed", + "nonClaims": [ + "Viewing a captured project does not establish native witness execution, semantic proof adequacy, live source freshness after capture, owner approval, merge, release, deployment or production readiness. Coverage and semantic diff require their own admitted evidence and are unavailable in the project front door." + ] } ], "bindings": [ @@ -7741,6 +7751,466 @@ "environmentClasses": [ "local-go" ] + }, + { + "requirementId": "REQ-PROOFKIT-SPEC-042", + "scenarioId": "proofkit.spec-proof-core.project-browser-cleanup", + "witnessId": "proofkit.spec-proof-core.project-browser-cleanup-witness", + "witnessKind": "contract", + "witnessPath": "internal/command/requirementbrowser/server_test.go", + "witnessSelectors": [ + { + "selector": "TestBrowseTerminalPathsJoinCleanupAndConsumeDoneOnce", + "command": "go test ./internal/command/requirementbrowser -run '^TestBrowseTerminalPathsJoinCleanupAndConsumeDoneOnce$'" + } + ], + "commandIds": [ + "proofkit.go-test" + ], + "environmentClasses": [ + "local-go" + ] + }, + { + "requirementId": "REQ-PROOFKIT-SPEC-042", + "scenarioId": "proofkit.spec-proof-core.project-browser-handoff", + "witnessId": "proofkit.spec-proof-core.project-browser-handoff-witness", + "witnessKind": "contract", + "witnessPath": "internal/command/requirementbrowser/project_test.go", + "witnessSelectors": [ + { + "selector": "TestProjectBrowserCapturesAndHandsOffExactSourceFacts", + "command": "go test ./internal/command/requirementbrowser -run '^TestProjectBrowserCapturesAndHandsOffExactSourceFacts$'" + } + ], + "commandIds": [ + "proofkit.go-test" + ], + "environmentClasses": [ + "local-go" + ] + }, + { + "requirementId": "REQ-PROOFKIT-SPEC-042", + "scenarioId": "proofkit.spec-proof-core.project-browser-handoff-identities", + "witnessId": "proofkit.spec-proof-core.project-browser-handoff-identities-witness", + "witnessKind": "contract", + "witnessPath": "internal/command/requirementbrowser/project_test.go", + "witnessSelectors": [ + { + "selector": "TestProjectHandoffPreservesLongRequirementIdentities", + "command": "go test ./internal/command/requirementbrowser -run '^TestProjectHandoffPreservesLongRequirementIdentities$'" + } + ], + "commandIds": [ + "proofkit.go-test" + ], + "environmentClasses": [ + "local-go" + ] + }, + { + "requirementId": "REQ-PROOFKIT-SPEC-042", + "scenarioId": "proofkit.spec-proof-core.project-browser-listener", + "witnessId": "proofkit.spec-proof-core.project-browser-listener-witness", + "witnessKind": "contract", + "witnessPath": "internal/command/requirementbrowser/project_test.go", + "witnessSelectors": [ + { + "selector": "TestProjectBrowserRejectsIncompleteProjectBeforeListening", + "command": "go test ./internal/command/requirementbrowser -run '^TestProjectBrowserRejectsIncompleteProjectBeforeListening$'" + } + ], + "commandIds": [ + "proofkit.go-test" + ], + "environmentClasses": [ + "local-go" + ] + }, + { + "requirementId": "REQ-PROOFKIT-SPEC-042", + "scenarioId": "proofkit.spec-proof-core.project-browser-preparation", + "witnessId": "proofkit.spec-proof-core.project-browser-preparation-witness", + "witnessKind": "contract", + "witnessPath": "internal/command/requirementbrowser/project_test.go", + "witnessSelectors": [ + { + "selector": "TestProjectBrowserRejectsOptionsBeforeRepositoryRead", + "command": "go test ./internal/command/requirementbrowser -run '^TestProjectBrowserRejectsOptionsBeforeRepositoryRead$'" + }, + { + "selector": "TestProjectWorkspacePreparationValidatesBeforeRendering", + "command": "go test ./internal/command/requirementbrowser -run '^TestProjectWorkspacePreparationValidatesBeforeRendering$'" + } + ], + "commandIds": [ + "proofkit.go-test" + ], + "environmentClasses": [ + "local-go" + ] + }, + { + "requirementId": "REQ-PROOFKIT-SPEC-042", + "scenarioId": "proofkit.spec-proof-core.project-context-partition", + "witnessId": "proofkit.spec-proof-core.project-context-partition-witness", + "witnessKind": "contract", + "witnessPath": "internal/command/requirementcontext/project_origin_test.go", + "witnessSelectors": [ + { + "selector": "TestProjectContextRejectsInvalidOriginAndSourcePartition", + "command": "go test ./internal/command/requirementcontext -run '^TestProjectContextRejectsInvalidOriginAndSourcePartition$'" + } + ], + "commandIds": [ + "proofkit.go-test" + ], + "environmentClasses": [ + "local-go" + ] + }, + { + "requirementId": "REQ-PROOFKIT-SPEC-042", + "scenarioId": "proofkit.spec-proof-core.project-context-predecessor", + "witnessId": "proofkit.spec-proof-core.project-context-predecessor-witness", + "witnessKind": "contract", + "witnessPath": "internal/command/requirementcontext/context_wire_compatibility_test.go", + "witnessSelectors": [ + { + "selector": "TestContextPredecessorWireIdentity", + "command": "go test ./internal/command/requirementcontext -run '^TestContextPredecessorWireIdentity$'" + } + ], + "commandIds": [ + "proofkit.go-test" + ], + "environmentClasses": [ + "local-go" + ] + }, + { + "requirementId": "REQ-PROOFKIT-SPEC-042", + "scenarioId": "proofkit.spec-proof-core.project-context-tree-replay", + "witnessId": "proofkit.spec-proof-core.project-context-tree-replay-witness", + "witnessKind": "contract", + "witnessPath": "internal/command/requirementcontext/project_origin_test.go", + "witnessSelectors": [ + { + "selector": "TestProjectContextRejectsResignedUnrelatedTree", + "command": "go test ./internal/command/requirementcontext -run '^TestProjectContextRejectsResignedUnrelatedTree$'" + } + ], + "commandIds": [ + "proofkit.go-test" + ], + "environmentClasses": [ + "local-go" + ] + }, + { + "requirementId": "REQ-PROOFKIT-SPEC-042", + "scenarioId": "proofkit.spec-proof-core.project-context-wire", + "witnessId": "proofkit.spec-proof-core.project-context-wire-witness", + "witnessKind": "contract", + "witnessPath": "internal/command/requirementcontext/project_origin_test.go", + "witnessSelectors": [ + { + "selector": "TestProjectContextCaptureWireAdmissionAndSlice", + "command": "go test ./internal/command/requirementcontext -run '^TestProjectContextCaptureWireAdmissionAndSlice$'" + } + ], + "commandIds": [ + "proofkit.go-test" + ], + "environmentClasses": [ + "local-go" + ] + }, + { + "requirementId": "REQ-PROOFKIT-SPEC-042", + "scenarioId": "proofkit.spec-proof-core.project-cross-record", + "witnessId": "proofkit.spec-proof-core.project-cross-record-witness", + "witnessKind": "contract", + "witnessPath": "internal/command/adoptionmaterialization/project_projection_test.go", + "witnessSelectors": [ + { + "selector": "TestProjectRetentionRejectsDigestMatchedCrossRecordContradiction", + "command": "go test ./internal/command/adoptionmaterialization -run '^TestProjectRetentionRejectsDigestMatchedCrossRecordContradiction$'" + } + ], + "commandIds": [ + "proofkit.go-test" + ], + "environmentClasses": [ + "local-go" + ] + }, + { + "requirementId": "REQ-PROOFKIT-SPEC-042", + "scenarioId": "proofkit.spec-proof-core.project-graph-digest-reference", + "witnessId": "proofkit.spec-proof-core.project-graph-digest-reference-witness", + "witnessKind": "contract", + "witnessPath": "internal/command/requirementgraph/reference_projection_test.go", + "witnessSelectors": [ + { + "selector": "TestGraphDigestNodeAndEndpointAdmissionAgree", + "command": "go test ./internal/command/requirementgraph -run '^TestGraphDigestNodeAndEndpointAdmissionAgree$'" + } + ], + "commandIds": [ + "proofkit.go-test" + ], + "environmentClasses": [ + "local-go" + ] + }, + { + "requirementId": "REQ-PROOFKIT-SPEC-042", + "scenarioId": "proofkit.spec-proof-core.project-graph-transparent-reference", + "witnessId": "proofkit.spec-proof-core.project-graph-transparent-reference-witness", + "witnessKind": "contract", + "witnessPath": "internal/command/requirementgraph/reference_projection_test.go", + "witnessSelectors": [ + { + "selector": "TestTransparentGraphIdentitiesPreserveComponentBounds", + "command": "go test ./internal/command/requirementgraph -run '^TestTransparentGraphIdentitiesPreserveComponentBounds$'" + } + ], + "commandIds": [ + "proofkit.go-test" + ], + "environmentClasses": [ + "local-go" + ] + }, + { + "requirementId": "REQ-PROOFKIT-SPEC-042", + "scenarioId": "proofkit.spec-proof-core.project-inspection", + "witnessId": "proofkit.spec-proof-core.project-inspection-witness", + "witnessKind": "contract", + "witnessPath": "internal/command/projectstatus/project_inspection_test.go", + "witnessSelectors": [ + { + "selector": "TestInspectProjectRetainsOriginalCohortAndStatusIdentity", + "command": "go test ./internal/command/projectstatus -run '^TestInspectProjectRetainsOriginalCohortAndStatusIdentity$'" + } + ], + "commandIds": [ + "proofkit.go-test" + ], + "environmentClasses": [ + "local-go" + ] + }, + { + "requirementId": "REQ-PROOFKIT-SPEC-042", + "scenarioId": "proofkit.spec-proof-core.project-inspection-cleanup", + "witnessId": "proofkit.spec-proof-core.project-inspection-cleanup-witness", + "witnessKind": "contract", + "witnessPath": "internal/command/projectstatus/project_inspection_test.go", + "witnessSelectors": [ + { + "selector": "TestInspectProjectCleanupFailureClearsWholeResult", + "command": "go test ./internal/command/projectstatus -run '^TestInspectProjectCleanupFailureClearsWholeResult$'" + } + ], + "commandIds": [ + "proofkit.go-test" + ], + "environmentClasses": [ + "local-go" + ] + }, + { + "requirementId": "REQ-PROOFKIT-SPEC-042", + "scenarioId": "proofkit.spec-proof-core.project-installed-protocol", + "witnessId": "proofkit.spec-proof-core.project-installed-protocol-witness", + "witnessKind": "contract", + "witnessPath": "internal/tools/workflowsmoke/workflow_smoke_test.go", + "witnessSelectors": [ + { + "selector": "TestVerifyAcceptsApplicationCLI", + "command": "go test ./internal/tools/workflowsmoke -run '^TestVerifyAcceptsApplicationCLI$'" + } + ], + "commandIds": [ + "proofkit.go-test" + ], + "environmentClasses": [ + "local-go" + ] + }, + { + "requirementId": "REQ-PROOFKIT-SPEC-042", + "scenarioId": "proofkit.spec-proof-core.project-installed-protocol-oracle", + "witnessId": "proofkit.spec-proof-core.project-installed-protocol-oracle-witness", + "witnessKind": "contract", + "witnessPath": "internal/tools/workflowsmoke/workflow_smoke_test.go", + "witnessSelectors": [ + { + "selector": "TestVerifyRejectsCarrierContractMutations", + "command": "go test ./internal/tools/workflowsmoke -run '^TestVerifyRejectsCarrierContractMutations$'" + } + ], + "commandIds": [ + "proofkit.go-test" + ], + "environmentClasses": [ + "local-go" + ] + }, + { + "requirementId": "REQ-PROOFKIT-SPEC-042", + "scenarioId": "proofkit.spec-proof-core.project-retention", + "witnessId": "proofkit.spec-proof-core.project-retention-witness", + "witnessKind": "contract", + "witnessPath": "internal/command/adoptionmaterialization/project_projection_test.go", + "witnessSelectors": [ + { + "selector": "TestProjectProjectionPreservesIndependentChildExpectations", + "command": "go test ./internal/command/adoptionmaterialization -run '^TestProjectProjectionPreservesIndependentChildExpectations$'" + } + ], + "commandIds": [ + "proofkit.go-test" + ], + "environmentClasses": [ + "local-go" + ] + }, + { + "requirementId": "REQ-PROOFKIT-SPEC-042", + "scenarioId": "proofkit.spec-proof-core.project-view-cli", + "witnessId": "proofkit.spec-proof-core.project-view-cli-witness", + "witnessKind": "contract", + "witnessPath": "internal/app/project_view_command_test.go", + "witnessSelectors": [ + { + "selector": "TestProjectViewCLI", + "command": "go test ./internal/app -run '^TestProjectViewCLI$'" + } + ], + "commandIds": [ + "proofkit.go-test" + ], + "environmentClasses": [ + "local-go" + ] + }, + { + "requirementId": "REQ-PROOFKIT-SPEC-042", + "scenarioId": "proofkit.spec-proof-core.project-view-diagnostic-order", + "witnessId": "proofkit.spec-proof-core.project-view-diagnostic-order-witness", + "witnessKind": "contract", + "witnessPath": "internal/app/project_view_command_test.go", + "witnessSelectors": [ + { + "selector": "TestProjectViewChoiceDiagnosticsAreDeterministic", + "command": "go test ./internal/app -run '^TestProjectViewChoiceDiagnosticsAreDeterministic$'" + } + ], + "commandIds": [ + "proofkit.go-test" + ], + "environmentClasses": [ + "local-go" + ] + }, + { + "requirementId": "REQ-PROOFKIT-SPEC-042", + "scenarioId": "proofkit.spec-proof-core.project-view-diagnostics", + "witnessId": "proofkit.spec-proof-core.project-view-diagnostics-witness", + "witnessKind": "contract", + "witnessPath": "internal/app/project_view_command_test.go", + "witnessSelectors": [ + { + "selector": "TestProjectViewDiagnosticsDoNotDiscloseCallerText", + "command": "go test ./internal/app -run '^TestProjectViewDiagnosticsDoNotDiscloseCallerText$'" + } + ], + "commandIds": [ + "proofkit.go-test" + ], + "environmentClasses": [ + "local-go" + ] + }, + { + "requirementId": "REQ-PROOFKIT-SPEC-042", + "scenarioId": "proofkit.spec-proof-core.project-view-flag-admission", + "witnessId": "proofkit.spec-proof-core.project-view-flag-admission-witness", + "witnessKind": "contract", + "witnessPath": "internal/app/project_view_command_test.go", + "witnessSelectors": [ + { + "selector": "TestProjectViewRejectsFlagsBeforeProjectIO", + "command": "go test ./internal/app -run '^TestProjectViewRejectsFlagsBeforeProjectIO$'" + } + ], + "commandIds": [ + "proofkit.go-test" + ], + "environmentClasses": [ + "local-go" + ] + }, + { + "requirementId": "REQ-PROOFKIT-SPEC-042", + "scenarioId": "proofkit.spec-proof-core.project-view-help-layout", + "witnessId": "proofkit.spec-proof-core.project-view-help-layout-witness", + "witnessKind": "contract", + "witnessPath": "internal/app/project_view_command_test.go", + "witnessSelectors": [ + { + "selector": "TestProjectViewHelpLayoutAndFlagShapedPaths", + "command": "go test ./internal/app -run '^TestProjectViewHelpLayoutAndFlagShapedPaths$'" + } + ], + "commandIds": [ + "proofkit.go-test" + ], + "environmentClasses": [ + "local-go" + ] + }, + { + "requirementId": "REQ-PROOFKIT-SPEC-042", + "scenarioId": "proofkit.spec-proof-core.project-view-one-shot", + "witnessId": "proofkit.spec-proof-core.project-view-one-shot-witness", + "witnessKind": "contract", + "witnessPath": "internal/app/project_view_command_test.go", + "witnessSelectors": [ + { + "selector": "TestProjectViewOneShotCLIOutputVariants", + "command": "go test ./internal/app -run '^TestProjectViewOneShotCLIOutputVariants$'" + } + ], + "commandIds": [ + "proofkit.go-test" + ], + "environmentClasses": [ + "local-go" + ] + }, + { + "requirementId": "REQ-PROOFKIT-SPEC-042", + "scenarioId": "proofkit.spec-proof-core.project-view-signal", + "witnessId": "proofkit.spec-proof-core.project-view-signal-witness", + "witnessKind": "contract", + "witnessPath": "internal/app/project_view_command_test.go", + "witnessSelectors": [ + { + "selector": "TestProjectViewSignalClosesNativeProcessServer", + "command": "go test ./internal/app -run '^TestProjectViewSignalClosesNativeProcessServer$'" + } + ], + "commandIds": [ + "proofkit.go-test" + ], + "environmentClasses": [ + "local-go" + ] } ], "witnessCommands": [ diff --git a/proofkit/witness-plan.json b/proofkit/witness-plan.json index 736949b..7be5091 100644 --- a/proofkit/witness-plan.json +++ b/proofkit/witness-plan.json @@ -757,6 +757,12 @@ "inputSelectors": [ "go.mod", "go.sum", + "internal/command/adoptionmaterialization", + "internal/command/adoptionplan", + "internal/command/capabilitymapadmission", + "internal/command/nativeevidenceguidance", + "internal/command/projectstatus", + "internal/command/repositoryinventory", "internal/command/requirementbinding", "internal/command/requirementbrowser", "internal/command/requirementcontext", @@ -767,6 +773,7 @@ "internal/command/requirementsourceadmission", "internal/command/requirementsourceview", "internal/command/requirementspectree", + "internal/command/stackpreset", "internal/command/testevidenceinventory", "internal/kernel/admission", "internal/kernel/admit", @@ -777,8 +784,11 @@ "internal/kernel/diagnostic", "internal/kernel/digest", "internal/kernel/markdownfmt", + "internal/kernel/pathidentity", "internal/kernel/proofvocab", "internal/kernel/report", + "internal/kernel/repositorytransaction", + "internal/kernel/rootpath", "internal/kernel/secretjson", "internal/kernel/stablejson", "internal/kernel/unicodepolicy", diff --git a/release/change-record.v2.json b/release/change-record.v2.json index b2d052b..32bbbac 100644 --- a/release/change-record.v2.json +++ b/release/change-record.v2.json @@ -1,21 +1,29 @@ { "schemaVersion": 2, - "previousVersion": "0.12.0", - "version": "0.13.0", + "previousVersion": "0.13.0", + "version": "0.14.0", "changeClass": "compatible", "breakingChanges": [], "additions": [ { - "changeId": "proofkit.browser.coverage-inspection", - "summary": "Inspect compact or structured coverage beside original source-bound requirements, distinguish missing evidence from reported verdicts, preserve complete evidence and non-claims in disclosures, and ask an explicit evidence question without replacing an existing draft." + "changeId": "proofkit.browser.project-entry", + "summary": "Use view --repo-root to prepare a read-only workspace from one complete materialized project, or add --serve to inspect it through the existing loopback browser. Explicit --open, one-shot questions, compact JSON plans and bounded diagnostics reuse the existing browser and CLI owners." }, { - "changeId": "proofkit.browser.diff-and-graph-inspection", - "summary": "Show exact diff-page class, entity, risk and lifecycle counts. Inspect a bounded traceability diagram and equivalent records with local evidence-plane and neighborhood filters, preserved directed relations, exact numeric source coordinates, explicit outside-page references and deliberate target-page navigation." + "changeId": "proofkit.browser.reference-closure", + "summary": "Resolve question anchors against the exact browser-issued session inventory, preserving admitted long requirement identities without expanding base identifier limits or weakening quote, source and terminal checks." }, { - "changeId": "proofkit.browser.handoff-inspection", - "summary": "Preview included source-bound handoff context by stable requirement identity and explicitly copy or download the exact compact server JSON. Obsolete view or clipboard results cannot replace current output; independent pending exclusion and denied or stale request locks survive settlement, with one workspace reload action for a stale session." + "changeId": "proofkit.cli.deterministic-choice-diagnostics", + "summary": "Validate flag choices in descriptor order so identical invalid invocations select the same diagnostic. Effect-sensitive tests cover rejection before project preparation and inspection, including errors deliberately discarded by a faulty caller." + }, + { + "changeId": "proofkit.context.captured-project-origin", + "summary": "Add closed project-origin context schema 3, with owner-replayed source and binding projections, role-preserving physical identities and source-level limitations retained in question handoffs. Existing context v1/v2 identities and low-level browser contracts remain unchanged." + }, + { + "changeId": "proofkit.graph.derived-reference-admission", + "summary": "Apply one typed identity owner to graph nodes and relation references. Transparent prefixes preserve the full admitted component length; digest-derived identities remain distinct from caller IDs. Invalid components, unresolved references and inconsistent topology remain rejected." } ], "migration": { @@ -32,6 +40,7 @@ "Brief agent-route packets cap pretty JSON at 3072 bytes and may defer oversized argv to explicit full detail; the bound does not claim tokenizer-specific token counts.", "Complete nested public structural contracts remain blocked under SCHEMA-01; current CLI contracts own exact root variants only.", "Project status and next classify materialized repository structure only; they do not execute native verification, validate receipt currentness or trust, or declare workflow completion.", + "View requires an explicitly selected complete materialized project. It neither scans source files nor writes project records or executes native witnesses. Its graph contains declared relations only; coverage and diff remain unavailable. The captured snapshot does not claim filesystem freshness after inspection.", "The selected requirement-source v2 codec remains internal; current requirement sources are not migrated and no source cutover is claimed.", "TSX source parsing remains unsupported.", "Managed integration baselines are cooperative byte/mode bookkeeping, not authenticated origin or protection against coordinated same-user edits. File lifecycle does not prove native host discovery, instruction loading, or approved-launcher invocation.",