diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 47ddbbb..f651533 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -45,6 +45,11 @@ jobs: go-version-file: go.mod cache: true + - name: Setup Python + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 + with: + python-version: '3.14.6' + - name: Verify diff hygiene run: | set -euo pipefail @@ -70,26 +75,11 @@ jobs: - name: Verify Go formatting run: npm run go:fmt - - name: Run Go tests for app - run: go test ./cmd/agentic-proofkit ./internal/app - - - name: Run Go tests for kernel - run: go test ./internal/kernel/... - - - name: Run Go tests for commands - run: go test ./internal/command/... - - - name: Run Go tests for coverage and package tools - run: go test ./internal/tools/coveragemetrics ./internal/tools/packagebuild ./internal/tools/packagepack ./internal/tools/packageverify - - - name: Run Go tests for registry and Python tools - run: go test ./internal/tools/pypiregistry ./internal/tools/pythonpackage - - - name: Run Go tests for release tools - run: go test ./internal/tools/releasecloseoutinput ./internal/tools/releasemanifest ./internal/tools/releasepreflight ./internal/tools/releasesbom ./internal/tools/textpolicyinput + - name: Verify generated command family catalog + run: npm run command-family:check - - name: Run Go tests for scripts - run: go test ./scripts + - name: Run all Go tests + run: npm run go:test - name: Run Go vet run: npm run go:vet diff --git a/docs/proofkit-contract-map.md b/docs/proofkit-contract-map.md index 8759f3e..36c9444 100644 --- a/docs/proofkit-contract-map.md +++ b/docs/proofkit-contract-map.md @@ -47,7 +47,7 @@ owner boundaries. It is not a second command-family inventory. | Receipts and producers | `proof-receipt-admission`, `receipt-producer-admission`, `receipt-currentness-scope`, `receipt-trust-class`, `producer-policy-self-proof` | receipt sets, producer policy, scope/currentness facts, trust classes | receipt shape, producer/receipt compatibility, self-proof diagnostics | producer authentication, freshness policy, CI trust roots | receipt/provenance report | | Release and deployment | `release-authority`, `external-consumer`, `registry-consumer-proof-input-compose`, `registry-consumer`, `deployment-evidence-admission`, `completion-criteria`, `branch-authority`, `readiness-closeout` | package facts, tarball/registry facts, explicit primitive registry/install/smoke facts, deployment evidence, criteria, branch facts | artifact/channel boundary checks, registry-consumer input composition, release diagnostics, falsifiable criteria shape | package publication, registry fetch, package-manager execution, deployment, rollback, approval | composed input, release/deployment/readiness report | | Supply-chain and quality | `self-check`, release workflow, `npm run release:sbom`, `npm run self:coverage`, `npm run go:actionlint`, `npm run go:bench` | release artifacts, source workflows, specs, bindings, witness plans, explicit benchmark invocation | deterministic self-check report shape, SBOM candidate evidence, coverage metrics, workflow lint routing, benchmark entrypoints | public-source provenance, vulnerability triage, license approval, CI run admission, release approval | self-check report, SBOM, metrics report, CI signal, or benchmark output | -| Repository structure | `repo-profile-admission`, `workspace-manifest-facts`, `workspace-registry`, `workspace-changed-package-plan`, `workspace-shard-partition`, `typescript-public-api-surfaces`, `text-policy`, `secret-scan`, `package-runtime-dependency-admission` | explicit repo/profile facts, caller-owned manifest records, caller-owned roots, caller-owned text file inventories, optional `environmentClassPolicies` tuples | structural admission, manifest-to-workspace fact projection, workspace graph projections, TypeScript package public API checks, text policy admission, explicit-inventory secret-like text detection, shard plans | repository freshness, git/file discovery, command policy, package manager truth, provider secret scanning | structural, fact, policy, or planning report | +| Repository structure | `repo-profile-admission`, `workspace-manifest-facts`, `workspace-registry`, `workspace-changed-package-plan`, `workspace-shard-partition`, `typescript-public-api-surfaces`, `text-policy`, `secret-scan`, `package-runtime-dependency-admission` | explicit repo/profile facts, caller-owned manifest records, caller-owned roots, caller-owned text file inventories, explicit TypeScript package-manifest and per-condition source paths, optional `environmentClassPolicies` tuples | structural admission, manifest-to-workspace fact projection, workspace graph projections, bounded TypeScript package public API checks over referenced files, text policy admission, explicit-inventory secret-like text detection, shard plans | repository freshness, git/file discovery, compiler output provenance, command policy, package manager truth, provider secret scanning | structural, fact, policy, or planning report | | Custom and generated artifacts | `custom-rule-boundary`, `document-lifecycle-boundary`, `rendered-artifact-freshness`, `conformance-profile`, `json-report-cli-adapter-source`, `witness-plan`, `witness-scheduler-plan` | custom rule metadata, document lifecycle records, artifact digests, profile manifests, command metadata, adapter language | boundary checks, generated-view freshness shape, deterministic adapter source generation, scheduler metadata checks | rule execution, document meaning, cache contents, CI scheduling, committed generated-source freshness | boundary report, generated source artifact, or scheduler report | | CLI metadata | `help` | optional command name or help flag | built-in command catalog and help text routing | command selection, semantic proof, freshness, merge policy | text help only | @@ -121,7 +121,7 @@ Decision tree: | Tests or proof evidence need inventory. | `test-evidence-inventory`; use `--projection discovery-draft` only for explicit discovered-test facts, then `requirement-coverage-input-compose` when an aggregate `coverage_compose_input` exists, then `requirement-coverage-view` when a `coverage_view_input` exists | Compose only from explicit caller-owned facts. Discovery drafts are candidate-only and cannot close coverage. Use `failureClassifications[]`, `warningClassifications[]`, and `agentActionPlan[]` for machine routing. Escalate when tests are route-only, weak-oracle, unbound, or outside the caller-owned coverage universe. | | A change set is known. | `changed-path-set`, optionally `requirement-impact-input-compose`, `impact`, then `selective-gate-plan --agent-envelope` | Raw `knownChangedPaths` in `agent-route` are diagnostic only. Materialize a caller-owned `changed_path_set`, compose a caller-owned `impact_input` before `impact`, and compose a caller-owned `selective_gate_plan_input` before `selective-gate-plan`. Use `scanObligation` to name whether a `text-policy`, `secret-scan`, or caller-owned external scanner is required. Fail closed on unknown scope, dynamic edges, missing owner routes, unbound proof-like paths, or full-gate escalation. | | Caller-owned file contents need secret-like text detection. | `secret-scan` | Provide explicit sorted file inventory with content. Stop before claiming repository-wide discovery, credential validity, provider ingestion, merge readiness, or replacement of GitHub secret scanning. | -| Does a TypeScript package public API match a caller-owned manifest? | `agent-route` with `goal: "verify_typescript_public_api"` and explicit `typescript_public_api_manifest` plus `typescript_public_api_repo_root`, then `typescript-public-api-surfaces --repo-root ` | This is an explicit filesystem scanner. Stop before guessing `repo-root`, claiming checkout freshness, package-manager truth, or merge readiness. | +| Does a TypeScript package public API match a caller-owned manifest? | `agent-route` with `goal: "verify_typescript_public_api"` and explicit `typescript_public_api_manifest` plus `typescript_public_api_repo_root`, then `typescript-public-api-surfaces --repo-root ` | The manifest must name each referenced `package.json`, sorted-unique export conditions, and a non-JSX `.ts`, `.mts`, or `.cts` `sourcePath` whose canonical target has the same admitted extension class. The bounded scanner accepts only the fail-closed export grammar in `proofkit/cli-contract.v1.json`; it does not parse unrestricted TypeScript or TSX, infer conventional layouts, or prove compiler output provenance, checkout freshness, package-manager truth, or merge readiness. | | Receipts are available for planned checks. | `selective-gate-evidence --agent-envelope`; then materialize a caller-owned `obligation_decision_input` from the evidence output plus command routes, currentness, and trust facts; then run `selective-gate-obligation-decision-input`; then materialize the resulting `obligation_decision` input and run `obligation-decision --agent-envelope` | Escalate on missing, stale, invalid, untrusted, blocked, unavailable, or unknown-scope evidence. | | Human inspection is needed. | `requirement-source-view`, `requirement-proof-view`, `requirement-coverage-view`, `requirement-spec-tree-view`, or `requirement-browser-server` | `agent-route` emits browser commands as plan-only by default. Use `browserMode: "serve_local_view"` for `--serve` and `openBrowser: true` for `--open`. Rendered HTML and Markdown are presentation only unless the consumer admits a tracked artifact freshness gate. | | Temporary external document lifecycle facts, generated views, or rendered views need authority classification. | `document-lifecycle-boundary` | Treat lifecycle records as caller-owned metadata. Temporary design docs and implementation plans are not retained repository authority unless rewritten into deterministic specs, proof bindings, tests, package-public docs, or backlog rows. | diff --git a/docs/specs/proofkit-package-boundary/overview.md b/docs/specs/proofkit-package-boundary/overview.md index 39aa0da..c911208 100644 --- a/docs/specs/proofkit-package-boundary/overview.md +++ b/docs/specs/proofkit-package-boundary/overview.md @@ -13,22 +13,27 @@ denial, and package artifact behavior only. - `REQ-PROOFKIT-PACKAGE-002`: the CLI builds deterministic reports, plans, generated source artifacts, and policy-admission results from explicit caller-owned JSON, declared no-input command parameters, or declared explicit - scanner scope classes without executing native witnesses, scanning implicit - repository state, deciding proof freshness, or accepting broad caller-supplied - phrase suppressors that can hide readiness overclaims. + scanner scope classes. Explicit scanners consume only caller-named bounded + canonical files under documented fail-closed grammars rather than guessing + layouts or silently accepting unsupported syntax; no command executes native + witnesses, scans implicit repository state, decides proof freshness, or accepts + broad caller-supplied phrase suppressors that can hide readiness overclaims. - `REQ-PROOFKIT-PACKAGE-003`: the root package remains installable and executable by an outside consumer on the current native platform without claiming registry publication. - `REQ-PROOFKIT-PACKAGE-004`: CI package-gate receipts used as merge evidence are admitted through a declared producer policy and proof-receipt shape validator instead of current-build output alone. -- `REQ-PROOFKIT-PACKAGE-005`: the Go source, static analysis, package gate, - and vulnerability gates remain the native merge-critical quality floor for - the current Proofkit source tree. +- `REQ-PROOFKIT-PACKAGE-005`: tracked source hygiene covers each admitted text + language in staged blobs and the current worktree; Go formatting, static + analysis, test, package, and vulnerability gates remain the native + merge-critical quality floor and explicitly provision every runtime required + by a merge-critical oracle. - `REQ-PROOFKIT-PACKAGE-006`: Python/uv distribution is a platform wheel wrapper over the same Go CLI, with wheel-safe package metadata, wheel tags, - embedded binary identity, local install smoke proof, and explicit non-claims - until PyPI publication. + embedded binary identity, local install smoke proof, POSIX exec + process-identity and signal preservation, and explicit non-claims until PyPI + publication. - `REQ-PROOFKIT-PACKAGE-007`: package-public Markdown records release-channel state only and must not embed exact per-version provider facts that are owned by immutable registry, release, and manifest artifacts. diff --git a/docs/specs/proofkit-package-boundary/requirements.v1.json b/docs/specs/proofkit-package-boundary/requirements.v1.json index 84b8f45..1079c7d 100644 --- a/docs/specs/proofkit-package-boundary/requirements.v1.json +++ b/docs/specs/proofkit-package-boundary/requirements.v1.json @@ -35,7 +35,7 @@ { "requirementId": "REQ-PROOFKIT-PACKAGE-002", "ownerId": "proofkit.package-boundary", - "invariant": "The CLI builds deterministic reports, plans, generated source artifacts, and policy-admission results from explicit caller-owned JSON, declared no-input command parameters, or declared explicit scanner scope classes without executing native witnesses, scanning implicit repository state, deciding proof freshness, or accepting broad caller-supplied phrase suppressors that can hide readiness overclaims.", + "invariant": "The CLI builds deterministic reports, plans, generated source artifacts, and policy-admission results from explicit caller-owned JSON, declared no-input command parameters, or declared explicit scanner scope classes; explicit scanners consume only caller-named bounded canonical files under documented fail-closed grammars rather than guessing layouts or silently accepting unsupported syntax, and no command executes native witnesses, scans implicit repository state, decides proof freshness, or accepts broad caller-supplied phrase suppressors that can hide readiness overclaims.", "claimLevel": "blocking", "riskClass": "high", "proofBindingRefs": [ @@ -116,7 +116,7 @@ { "requirementId": "REQ-PROOFKIT-PACKAGE-005", "ownerId": "proofkit.package-boundary", - "invariant": "The Go source, formatting, static analysis, vet, test, package artifact, and vulnerability gates remain the native merge-critical quality floor for the current Proofkit source tree.", + "invariant": "Tracked source hygiene covers every admitted project text language in both staged blobs and the current worktree, while Go formatting, static analysis, vet, test, package artifact, and vulnerability gates remain the native merge-critical quality floor and explicitly provision every runtime required by a merge-critical oracle.", "claimLevel": "blocking", "riskClass": "high", "proofBindingRefs": [ @@ -143,7 +143,7 @@ { "requirementId": "REQ-PROOFKIT-PACKAGE-006", "ownerId": "proofkit.package-boundary", - "invariant": "Python/uv distribution is a platform wheel wrapper over the same Go CLI, with wheel-safe package metadata, wheel tags, embedded binary identity, local install smoke proof, and explicit non-claims until PyPI publication.", + "invariant": "Python/uv distribution is a platform wheel wrapper over the same Go CLI, with wheel-safe package metadata, wheel tags, embedded binary identity, local install smoke proof, POSIX exec process-identity and signal preservation, and explicit non-claims until PyPI publication.", "claimLevel": "blocking", "riskClass": "medium", "proofBindingRefs": [ diff --git a/docs/specs/proofkit-spec-proof-core/overview.md b/docs/specs/proofkit-spec-proof-core/overview.md index 1b77346..ed848d4 100644 --- a/docs/specs/proofkit-spec-proof-core/overview.md +++ b/docs/specs/proofkit-spec-proof-core/overview.md @@ -25,16 +25,18 @@ execution receipts, and merge policy. - `REQ-PROOFKIT-SPEC-003`: witness planning accepts caller-owned structured command metadata, scheduler constraints, environment classes, and binding-derived command projections only through admitted witness vocabulary - and conservative safe argv policy, without executing commands or selecting - repository policy. + and conservative safe argv policy. Self-hosting bindings and witness plans + preserve command ids, argv, and environment classes without executing + commands or selecting repository policy. - `REQ-PROOFKIT-SPEC-004`: selective planning and selective evidence reports - keep changed-path facts, planned commands, receipts, evidence class, - producer-admission state, and obligation candidates explicit, keep merge - approval consumer-owned, and fail closed for unknown or unmatched proof - inputs. + keep canonical changed-path facts, resolvable agent references, planned + commands, receipts, evidence class, producer-admission state, and obligation + candidates explicit, keep merge approval consumer-owned, and fail closed for + unknown or unmatched proof inputs. - `REQ-PROOFKIT-SPEC-005`: rendered proof views and agent envelopes remain - bounded, derived presentations over structured source and never become - canonical proof or requirement authority. + bounded, reference-closed derived presentations over structured source, + count each semantic omission once, and never become canonical proof or + requirement authority. - `REQ-PROOFKIT-SPEC-006`: test evidence inventory, explicit test-discovery draft projections, proof-binding-derived inventory projections, normalized inventory projections, coverage-view input composition, and requirement diff --git a/docs/specs/proofkit-spec-proof-core/requirements.v1.json b/docs/specs/proofkit-spec-proof-core/requirements.v1.json index a69407f..3d1aaec 100644 --- a/docs/specs/proofkit-spec-proof-core/requirements.v1.json +++ b/docs/specs/proofkit-spec-proof-core/requirements.v1.json @@ -62,7 +62,7 @@ { "requirementId": "REQ-PROOFKIT-SPEC-003", "ownerId": "proofkit.spec-proof-core", - "invariant": "Witness planning accepts caller-owned structured command metadata, scheduler constraints, environment classes, and binding-derived command projections only through admitted witness vocabulary and conservative safe argv policy, without executing commands or selecting repository policy.", + "invariant": "Witness planning accepts caller-owned structured command metadata, scheduler constraints, environment classes, and binding-derived command projections only through admitted witness vocabulary and conservative safe argv policy; self-hosting requirement bindings and witness plans preserve the same command ids, argv, and environment classes without executing commands or selecting repository policy.", "claimLevel": "blocking", "riskClass": "high", "proofBindingRefs": [ @@ -89,7 +89,7 @@ { "requirementId": "REQ-PROOFKIT-SPEC-004", "ownerId": "proofkit.spec-proof-core", - "invariant": "Selective planning and selective evidence reports keep changed-path facts, planned commands, receipts, evidence class, producer-admission state, and obligation candidates explicit, keep merge approval consumer-owned, and fail closed for unknown or unmatched proof inputs.", + "invariant": "Selective planning and selective evidence reports keep canonical changed-path facts, resolvable agent references, planned commands, receipts, evidence class, producer-admission state, and obligation candidates explicit, keep merge approval consumer-owned, and fail closed for unknown or unmatched proof inputs.", "claimLevel": "blocking", "riskClass": "high", "proofBindingRefs": [ @@ -116,7 +116,7 @@ { "requirementId": "REQ-PROOFKIT-SPEC-005", "ownerId": "proofkit.spec-proof-core", - "invariant": "Rendered proof views and agent envelopes remain bounded, derived presentations over structured source and never become canonical proof or requirement authority.", + "invariant": "Rendered proof views and agent envelopes remain bounded, reference-closed derived presentations over structured source, count each semantic omission once, and never become canonical proof or requirement authority.", "claimLevel": "blocking", "riskClass": "medium", "proofBindingRefs": [ diff --git a/docs/specs/proofkit-supply-chain-quality/overview.md b/docs/specs/proofkit-supply-chain-quality/overview.md index df5d39f..24b53e6 100644 --- a/docs/specs/proofkit-supply-chain-quality/overview.md +++ b/docs/specs/proofkit-supply-chain-quality/overview.md @@ -22,7 +22,8 @@ vulnerability absence, or consumer rollout safety by itself. fuzzing side-effecting CLI or filesystem flows. - `REQ-PROOFKIT-QUALITY-004`: public CLI ABI has a small golden corpus and canonical ABI hash covering command topology, exit code, stdout/stderr - channel discipline, JSON parseability, stable public diagnostics, and + channel discipline, JSON parseability, stable public diagnostics, explicit + output schema evolution for breaking JSON field changes, and descriptor/contract/help parity without making private descriptors public API. - `REQ-PROOFKIT-QUALITY-005`: CodeQL workflow source is admitted as an independent semantic security analysis signal for Go source without replacing @@ -45,9 +46,10 @@ vulnerability absence, or consumer rollout safety by itself. unknown command refs, contract-only commands, or route-only commands. Static route metadata and source syntax never become semantic falsifier evidence. - `REQ-PROOFKIT-QUALITY-011`: CI separates the OS-independent full - source/package gate from macOS platform smoke, uses explicit hosted runner - labels instead of floating latest labels, and exposes one fail-closed - aggregate gate that requires every required leaf check to finish with success. + source/package gate from macOS platform smoke, executes the complete Go + package set through its owner command, uses explicit hosted runner labels + instead of floating latest labels, and exposes one fail-closed aggregate gate + that requires every required leaf check to finish with success. - `REQ-PROOFKIT-QUALITY-012`: release and adoption channel identifiers use one canonical authority vocabulary that separates durable authority channels from display labels, publisher environments, statuses, and candidate evidence. @@ -65,8 +67,9 @@ vulnerability absence, or consumer rollout safety by itself. release closeout. - `REQ-PROOFKIT-QUALITY-016`: release platform targets use one private owner that projects platform suffixes, Go build targets, npm OS/CPU metadata, - package tar entries, Python wheel tags, PyPI candidate completeness, and - SBOM binary subjects without becoming public API. + package tar entries, Python wheel tags, PyPI candidate completeness, + self-hosting native binary selection, and SBOM binary subjects without + becoming public API. - `REQ-PROOFKIT-QUALITY-017`: report-visible secret-shaped JSON traversal uses one private kernel owner for deterministic paths and finding kinds while command packages only map findings to their local report policy. diff --git a/docs/specs/proofkit-supply-chain-quality/requirements.v1.json b/docs/specs/proofkit-supply-chain-quality/requirements.v1.json index b5772f0..bda389b 100644 --- a/docs/specs/proofkit-supply-chain-quality/requirements.v1.json +++ b/docs/specs/proofkit-supply-chain-quality/requirements.v1.json @@ -51,7 +51,7 @@ { "requirementId": "REQ-PROOFKIT-QUALITY-004", "ownerId": "proofkit.supply-chain-quality", - "invariant": "Public CLI ABI has a small golden corpus and canonical ABI hash covering command topology, exit code, stdout and stderr channel discipline, JSON parseability, stable public diagnostics, and descriptor/contract/help parity without making private descriptors a public API.", + "invariant": "Public CLI ABI has a small golden corpus and canonical ABI hash covering command topology, exit code, stdout and stderr channel discipline, JSON parseability, stable public diagnostics, explicit output schema evolution for breaking JSON field changes, and descriptor/contract/help parity without making private descriptors a public API.", "claimLevel": "blocking", "riskClass": "high", "proofBindingRefs": ["proofkit/requirement-bindings.json"], @@ -142,7 +142,7 @@ { "requirementId": "REQ-PROOFKIT-QUALITY-011", "ownerId": "proofkit.supply-chain-quality", - "invariant": "CI separates the OS-independent full source/package gate from the platform-specific macOS execution smoke, uses explicit hosted runner labels instead of floating latest labels, and exposes one fail-closed aggregate quality gate that requires every required leaf check to finish with success so skipped, failed, or cancelled leaf jobs cannot satisfy branch-protection evidence.", + "invariant": "CI separates the OS-independent full source/package gate from the platform-specific macOS execution smoke, executes the complete Go package set through the owner command rather than a partial package list, uses explicit hosted runner labels instead of floating latest labels, and exposes one fail-closed aggregate quality gate that requires every required leaf check to finish with success so skipped, failed, or cancelled leaf jobs cannot satisfy branch-protection evidence.", "claimLevel": "blocking", "riskClass": "medium", "proofBindingRefs": ["proofkit/requirement-bindings.json"], @@ -207,7 +207,7 @@ { "requirementId": "REQ-PROOFKIT-QUALITY-016", "ownerId": "proofkit.supply-chain-quality", - "invariant": "Release platform targets have one private owner that projects exact platform suffixes, Go build targets, npm OS/CPU metadata, package tar binary entries, Python wheel tags, PyPI candidate completeness, and SBOM binary subjects without turning the platform matrix into public API.", + "invariant": "Release platform targets have one private owner that projects exact platform suffixes, Go build targets, npm OS/CPU metadata, package tar binary entries, Python wheel tags, PyPI candidate completeness, self-hosting native binary selection, and SBOM binary subjects without turning the platform matrix into public API.", "claimLevel": "blocking", "riskClass": "high", "proofBindingRefs": ["proofkit/requirement-bindings.json"], diff --git a/internal/app/app_test.go b/internal/app/app_test.go index 7e746a0..598f716 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -6,15 +6,18 @@ import ( "encoding/json" "errors" "fmt" - "github.com/research-engineering/agentic-proofkit/internal/testsupport/commandcoverage" "net/http" "os" "os/exec" "path/filepath" + "slices" "strings" "sync" "testing" "time" + + "github.com/research-engineering/agentic-proofkit/internal/command/agentroute" + "github.com/research-engineering/agentic-proofkit/internal/testsupport/commandcoverage" ) var processTestBinary = struct { @@ -104,6 +107,33 @@ func TestSelfCheckRejectsInputPointer(t *testing.T) { } } +func TestAgentRouteCLIOutputUsesVersionedRouteFamilyFields(t *testing.T) { + input := `{"schemaVersion":1,"routeId":"consumer.route.requirement_source","goal":"validate_requirement_source","mode":"observe","availableInputs":[{"kind":"requirement_source","ref":"docs/specs/module/requirements.v1.json"}]}` + var stdout bytes.Buffer + var stderr bytes.Buffer + status := Run(t.Context(), []string{"agent-route", "--input", "-"}, strings.NewReader(input), &stdout, &stderr) + if status != 0 || stderr.Len() != 0 { + t.Fatalf("agent-route failed status=%d stdout=%s stderr=%s", status, stdout.String(), stderr.String()) + } + var report map[string]any + if err := json.Unmarshal(stdout.Bytes(), &report); err != nil { + t.Fatalf("stdout must be a JSON route report: %v", err) + } + if report["schemaVersion"] != float64(2) || report["selectedRouteFamily"] != "requirement_source" { + t.Fatalf("agent-route output does not satisfy schema v2 route-family contract: %#v", report) + } + if _, legacy := report["selectedFamily"]; legacy { + t.Fatalf("agent-route schema v2 retained selectedFamily: %#v", report) + } + guidance := report["guidanceSlice"].(map[string]any) + if guidance["routeFamily"] != report["selectedRouteFamily"] { + t.Fatalf("guidance routeFamily=%v selectedRouteFamily=%v", guidance["routeFamily"], report["selectedRouteFamily"]) + } + if _, legacy := guidance["family"]; legacy { + t.Fatalf("agent-route schema v2 retained guidanceSlice.family: %#v", guidance) + } +} + func TestAgentRouteAgentEnvelopeCLIABI(t *testing.T) { input := `{"schemaVersion":1,"routeId":"consumer.route.requirement_source","goal":"validate_requirement_source","mode":"observe","availableInputs":[{"kind":"requirement_source","ref":"docs/specs/module/requirements.v1.json"}],"nonClaims":["Caller route fixture is not merge proof."]}` var stdout bytes.Buffer @@ -407,6 +437,95 @@ func TestCLIDiagnosticsRedactSecretLikeCallerLabels(t *testing.T) { } } +func TestLocalEnvironmentClassAdmissionRejectsSecretsAcrossResolverAndViews(t *testing.T) { + secret := "api_key=local-environment-secret-sentinel" + cases := [][]string{ + {"requirement-proof-resolver", "--input", "-", "--local-environment-class", secret}, + {"requirement-proof-view", "--input", "-", "--local-environment-class", secret}, + {"requirement-browser-server", "--input", "-", "--view", "proof", "--local-environment-class", secret}, + } + for _, args := range cases { + t.Run(args[0], func(t *testing.T) { + var stdout bytes.Buffer + var stderr bytes.Buffer + status := Run(t.Context(), args, strings.NewReader(`{}`), &stdout, &stderr) + combined := stdout.String() + stderr.String() + if status != 1 || !strings.Contains(stderr.String(), "must not contain secret-like values") { + t.Fatalf("Run(%s) status=%d stdout=%q stderr=%q", args[0], status, stdout.String(), stderr.String()) + } + if stdout.Len() != 0 || strings.Contains(combined, secret) || strings.Contains(combined, "local-environment-secret-sentinel") { + t.Fatalf("Run(%s) disclosed rejected local environment class: stdout=%q stderr=%q", args[0], stdout.String(), stderr.String()) + } + }) + } +} + +func TestEveryAgentRouteArgvSatisfiesCommandDescriptorAdmission(t *testing.T) { + contract := agentroute.InputContract() + fields := contract["fields"].(map[string]any) + goals := fields["goal"].(map[string]any)["enum"].([]any) + kinds := fields["availableInputs"].(map[string]any)["item"].(map[string]any)["kind"].(map[string]any)["enum"].([]any) + available := make([]any, 0, len(kinds)) + for _, value := range kinds { + kind := value.(string) + ref := "inputs/" + kind + ".json" + if kind == "typescript_public_api_repo_root" { + ref = "." + } + available = append(available, map[string]any{"kind": kind, "ref": ref}) + } + for _, value := range goals { + goal := value.(string) + t.Run(goal, func(t *testing.T) { + report, _, err := agentroute.Build(map[string]any{ + "schemaVersion": json.Number("1"), + "routeId": "proofkit.test.route." + strings.ReplaceAll(goal, "_", "-"), + "goal": goal, + "mode": "observe", + "availableInputs": available, + }) + if err != nil { + t.Fatalf("agentroute.Build() error = %v", err) + } + for _, rawCommand := range report["nextCommands"].([]any) { + command := rawCommand.(map[string]any) + rawArgv := command["argv"].([]any) + argv := make([]string, len(rawArgv)) + for index, raw := range rawArgv { + argv[index] = raw.(string) + } + assertDescriptorAdmitsAgentArgv(t, argv) + } + }) + } +} + +func assertDescriptorAdmitsAgentArgv(t *testing.T, argv []string) { + t.Helper() + if len(argv) < 2 || argv[0] != "agentic-proofkit" { + t.Fatalf("agent route emitted invalid process argv: %v", argv) + } + descriptor, ok := commandDescriptorFor(argv[1]) + if !ok { + t.Fatalf("agent route emitted unknown command argv: %v", argv) + } + for index := 2; index < len(argv); index++ { + flag := argv[index] + if !slices.Contains(descriptor.allowedFlags, flag) { + t.Fatalf("agent route emitted unsupported flag %q for %s: %v", flag, descriptor.name, argv) + } + if flagRequiresValue(flag) { + if index+1 >= len(argv) || slices.Contains(descriptor.allowedFlags, argv[index+1]) { + t.Fatalf("agent route emitted flag %q without value for %s: %v", flag, descriptor.name, argv) + } + index++ + } + } + if err := validateFlagConstraints(descriptor, argv[2:]); err != nil { + t.Fatalf("agent route argv is rejected by %s descriptor: %v; argv=%v", descriptor.name, err, argv) + } +} + func TestCLIDiagnosticsRedactControlAndOversizedCallerLabels(t *testing.T) { cases := []struct { name string diff --git a/internal/app/cli_contract_test.go b/internal/app/cli_contract_test.go index ce5806d..92c5a66 100644 --- a/internal/app/cli_contract_test.go +++ b/internal/app/cli_contract_test.go @@ -18,7 +18,12 @@ import ( "github.com/research-engineering/agentic-proofkit/internal/testsupport/commandcoverage" ) -const cliContractPublicABISHA256 = "9923feb596ae4334e24d728b6156df3dbc6ac3464ed0b593e924a86c4d934b84" +const ( + cliContractPublicABISHA256 = "9923feb596ae4334e24d728b6156df3dbc6ac3464ed0b593e924a86c4d934b84" + maxAggregateFileReadBytesForContractTest = 64 << 20 + maxPackageManifestBytesForContractTest = 256 << 10 + maxSourceFileBytesForContractTest = 8 << 20 +) func TestCLIContractMatchesDispatcherAndHelp(t *testing.T) { contract := readCLIContract(t) @@ -911,6 +916,75 @@ func TestRequirementProofSourceSetContractDescribesProjection(t *testing.T) { } } +func TestTypeScriptPublicAPIContractOwnsExplicitScanTopology(t *testing.T) { + contract := readCLIContract(t) + var publicAPI *cliContractCommand + for index := range contract.Commands { + if contract.Commands[index].Command == "typescript-public-api-surfaces" { + publicAPI = &contract.Commands[index] + break + } + } + if publicAPI == nil || publicAPI.InputContract == nil { + t.Fatal("typescript-public-api-surfaces must expose its input topology contract") + } + inputContract := canonicalJSONValue(t, publicAPI.InputContract).(map[string]any) + fields := inputContract["fields"].(map[string]any) + entries := fields["entries"].(map[string]any) + item := entries["item"].(map[string]any) + required := stringsFromAny(item["requiredFields"].([]any)) + assertStringSet(t, required, []string{ + "exportConditions", + "exportKey", + "packageManifestPath", + "packageName", + "runtimeExports", + "typeExports", + }, "TypeScript public API required fields") + pathAuthority := item["pathAuthority"].(string) + if !strings.Contains(pathAuthority, "packageManifestPath") || !strings.Contains(pathAuthority, "sourcePath") || !strings.Contains(pathAuthority, "canonical resolved source target") { + t.Fatalf("TypeScript public API path authority is incomplete: %q", pathAuthority) + } + if rule := item["exportConditionsRule"]; rule != "non-empty and sorted unique by condition" { + t.Fatalf("TypeScript public API export condition rule=%v", rule) + } + budgets := inputContract["resourceBudgets"].(map[string]any) + if budgets["maxSourceFileBytes"] != float64(maxSourceFileBytesForContractTest) || + budgets["maxPackageManifestBytes"] != float64(maxPackageManifestBytesForContractTest) || + budgets["maxAggregateFileReadBytes"] != float64(maxAggregateFileReadBytesForContractTest) { + t.Fatalf("TypeScript public API resource budgets drifted: %#v", budgets) + } + grammar := inputContract["sourceGrammar"].(map[string]any) + if grammar["grammarId"] != "proofkit.typescript-public-api.export-subset.v1" || grammar["mode"] != "fail_closed" { + t.Fatalf("TypeScript public API source grammar is not fail-closed: %#v", grammar) + } + rejected := strings.Join(stringsFromAny(grammar["rejectedLexicalForms"].([]any)), " ") + for _, required := range []string{"slash tokens outside comments", "template interpolation", "non-ASCII code identifiers", "unbalanced delimiters", "angle-bracket syntax"} { + if !strings.Contains(rejected, required) { + t.Fatalf("TypeScript public API source grammar omits %q: %s", required, rejected) + } + } + nonClaims := stringsFromAny(inputContract["nonClaims"].([]any)) + joinedNonClaims := strings.Join(nonClaims, " ") + if !strings.Contains(joinedNonClaims, "compiler output provenance") || !strings.Contains(joinedNonClaims, "does not parse JSX") || !strings.Contains(joinedNonClaims, "does not parse unrestricted TypeScript") { + t.Fatalf("TypeScript public API input contract omits scanner non-claims: %v", nonClaims) + } +} + +func TestGenericCommandBuilderRegistryExactlyMatchesGenericDescriptors(t *testing.T) { + want := commandNamesMatching(func(descriptor commandDescriptor) bool { + return descriptor.runner == commandRunnerGenericInput + }) + got := make([]string, 0, len(genericCommandBuilders)) + for command := range genericCommandBuilders { + got = append(got, command) + } + sort.Strings(got) + if !reflect.DeepEqual(got, want) { + t.Fatalf("generic command builder registry=%v, want descriptor-owned commands=%v", got, want) + } +} + func TestRequirementCoverageViewContractDescribesMachineClassifications(t *testing.T) { contract := readCLIContract(t) var coverage *cliContractCommand @@ -1221,11 +1295,19 @@ func TestAgentRouteInputContractMatchesAdmission(t *testing.T) { if route.InputContract == nil { t.Fatal("agent-route must expose inputContract") } + if route.OutputContract == nil { + t.Fatal("agent-route must expose its versioned output contract") + } got := canonicalJSONValue(t, route.InputContract) want := canonicalJSONValue(t, agentroute.InputContract()) if !reflect.DeepEqual(got, want) { t.Fatalf("agent-route input contract drift\ngot: %#v\nwant: %#v", got, want) } + gotOutput := canonicalJSONValue(t, route.OutputContract) + wantOutput := canonicalJSONValue(t, agentroute.OutputContract()) + if !reflect.DeepEqual(gotOutput, wantOutput) { + t.Fatalf("agent-route output contract drift\ngot: %#v\nwant: %#v", gotOutput, wantOutput) + } } func canonicalJSONValue(t *testing.T, value any) any { diff --git a/internal/app/command_coverage_routes.go b/internal/app/command_coverage_routes.go index 980c8d1..733a0ca 100644 --- a/internal/app/command_coverage_routes.go +++ b/internal/app/command_coverage_routes.go @@ -138,7 +138,7 @@ var commandCoverageRoutes = map[string][]commandCoverageRoute{ packageFalsifierRoute("internal/command/proofbindingtestinventory/proofbindingtestinventory_test.go", "TestBuildRejectsDerivedCommandRefCollision", semanticRouteProof("proofbindingtestinventory.build_rejects_derived_command_ref_collision", commandCoverageExpectedPublicOutcome), "Proof-binding-derived inventory projection must reject command-ref collisions before emitting normalized inventory."), }, "text-policy": {requiredInputAdmissionRoute, packageFalsifierRoute("internal/command/textpolicy/textpolicy_test.go", "TestEvaluatePreservesUTF8ASCIIWhitespaceAndBinaryFalsifiers", semanticRouteProof("textpolicy.evaluate_preserves_utf8_asciiwhitespace_and_binary_falsifiers", commandCoverageExpectedPublicOutcome), "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", commandCoverageExpectedPublicOutcome), "TypeScript public API verifier must reject export-star surfaces that hide public contract drift."), packageFalsifierRoute("internal/command/publicapi/public_api_test.go", "TestVerifyTypeScriptPackagePublicAPIRejectsExportTargetDifferentFromScannedSource", semanticRouteProof("public_api.verify_type_script_package_public_apirejects_export_target_different_from_scanned_source", commandCoverageExpectedPublicOutcome), "TypeScript public API verifier must reject export targets that cannot be tied to the scanned source or its compiled target.")}, + "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", commandCoverageExpectedPublicOutcome), "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", commandCoverageExpectedPublicOutcome), "TypeScript public API verifier must compare declared public exports against each explicitly referenced source file.")}, "witness-plan": {requiredInputAdmissionRoute, packageFalsifierRoute("internal/command/witnessplan/witnessplan_test.go", "TestBuildAdmitsSafeCommandAndRejectsShellCommand", semanticRouteProof("witnessplan.build_admits_safe_command_and_rejects_shell_command", commandCoverageExpectedPublicOutcome), "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", commandCoverageExpectedPublicOutcome), "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", commandCoverageExpectedPublicOutcome), "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", commandCoverageExpectedPublicOutcome), "Workspace changed-package planning must admit packagesRoot only through explicit schema-versioned input.")}, diff --git a/internal/app/command_descriptors.go b/internal/app/command_descriptors.go index 122ee5b..390fd10 100644 --- a/internal/app/command_descriptors.go +++ b/internal/app/command_descriptors.go @@ -137,7 +137,7 @@ var commandDescriptors = []commandDescriptor{ command("stack-preset", commandInputNone, flags("--preset"), modes("json"), ownerDirs("stackpreset"), withRunner(commandRunnerStackPreset), withSemanticAppTests("TestNoInputCommandsHaveCommandSpecificBehavior"), withRequiredFlags("--preset")), 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("typescript-public-api-surfaces", commandInputRequired, flags("--input", "--input-pointer", "--repo-root"), modes("json"), ownerDirs("publicapi"), withRunner(commandRunnerTypeScriptPublicAPISurfaces), withScopeClass(commandScopeExplicitFileSystemScan), withRequiredFlags("--repo-root"), withInputSchemaSummary("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")), 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()), diff --git a/internal/app/command_family_catalog_test.go b/internal/app/command_family_catalog_test.go index 107b949..04bd9c8 100644 --- a/internal/app/command_family_catalog_test.go +++ b/internal/app/command_family_catalog_test.go @@ -88,6 +88,15 @@ func TestExistingHelpEntrypointsRemainCompatible(t *testing.T) { } } +func TestHelpCommandUsageIncludesGeneratedFamilyGrammar(t *testing.T) { + output := runTextCommand(t, []string{"help", "help"}) + for _, form := range generatedCommandFamilyCatalog().HelpForms { + if !strings.Contains(output, "agentic-proofkit "+form) { + t.Fatalf("help help output is missing generated family form %q:\n%s", form, output) + } + } +} + func TestCommandFamilyHelpRejectsInvalidFormsBeforeReadingInput(t *testing.T) { cases := []struct { args []string diff --git a/internal/app/command_help.go b/internal/app/command_help.go index 7df734a..39f2975 100644 --- a/internal/app/command_help.go +++ b/internal/app/command_help.go @@ -11,24 +11,29 @@ func isCommandHelpRequest(args []string) bool { } func commandUsage(descriptor commandDescriptor) string { - lines := []string{ - "Usage:", - " " + commandUsageLine(descriptor), + lines := []string{"Usage:"} + lines = append(lines, " "+commandUsageLine(descriptor)) + if descriptor.name == "help" { + for _, form := range generatedCommandFamilyCatalog().HelpForms { + lines = append(lines, " agentic-proofkit "+form) + } + } + lines = append(lines, "", "Command:", - " " + descriptor.name, + " "+descriptor.name, "", "Input:", - " " + commandInputHelp(descriptor), + " "+commandInputHelp(descriptor), "", "Output modes:", - " " + strings.Join(descriptor.outputModes, ", "), + " "+strings.Join(descriptor.outputModes, ", "), "", "Scope class:", - " " + string(descriptor.scopeClass), + " "+string(descriptor.scopeClass), "", "Allowed flags:", - } + ) for _, flag := range descriptor.allowedFlags { lines = append(lines, " "+flag) } diff --git a/internal/app/command_registry.go b/internal/app/command_registry.go index c5d574b..de11cf1 100644 --- a/internal/app/command_registry.go +++ b/internal/app/command_registry.go @@ -4,7 +4,6 @@ import ( "fmt" "github.com/research-engineering/agentic-proofkit/internal/command/adoptionchecklist" - "github.com/research-engineering/agentic-proofkit/internal/command/agentroute" "github.com/research-engineering/agentic-proofkit/internal/command/bindingpartition" "github.com/research-engineering/agentic-proofkit/internal/command/branchauthority" "github.com/research-engineering/agentic-proofkit/internal/command/capabilitymapadmission" @@ -33,7 +32,6 @@ import ( "github.com/research-engineering/agentic-proofkit/internal/command/requirementauthoringplan" "github.com/research-engineering/agentic-proofkit/internal/command/requirementbinding" "github.com/research-engineering/agentic-proofkit/internal/command/requirementcoverageinput" - "github.com/research-engineering/agentic-proofkit/internal/command/requirementcoverageview" "github.com/research-engineering/agentic-proofkit/internal/command/requirementimpactinput" "github.com/research-engineering/agentic-proofkit/internal/command/requirementproofsourceset" "github.com/research-engineering/agentic-proofkit/internal/command/requirementsourceadmission" @@ -43,7 +41,6 @@ import ( "github.com/research-engineering/agentic-proofkit/internal/command/secretscan" "github.com/research-engineering/agentic-proofkit/internal/command/specoverviewclaims" "github.com/research-engineering/agentic-proofkit/internal/command/specproofbundleadmission" - "github.com/research-engineering/agentic-proofkit/internal/command/testevidenceinventory" "github.com/research-engineering/agentic-proofkit/internal/command/textpolicy" "github.com/research-engineering/agentic-proofkit/internal/command/witnessplan" "github.com/research-engineering/agentic-proofkit/internal/command/witnessschedulerplan" @@ -52,164 +49,120 @@ import ( "github.com/research-engineering/agentic-proofkit/internal/kernel/report" ) +type genericCommandBuilder func(any) (any, int, error) + +var genericCommandBuilders = mustGenericCommandBuilders(map[string]genericCommandBuilder{ + "adoption-checklist": reportOutput(adoptionchecklist.Build), + "binding-partition": reportOutput(bindingpartition.Build), + "branch-authority": reportOutputWithoutError(branchauthority.Build), + "capability-map-admission": reportOutput(capabilitymapadmission.Build), + "completion-criteria": reportOutput(completioncriteria.Build), + "custom-rule-boundary": reportOutput(customruleboundary.Build), + "deployment-evidence-admission": reportOutput(deploymentevidenceadmission.Build), + "document-lifecycle-boundary": reportOutput(documentlifecycle.Build), + "evidence-graph": requirementbinding.BuildEvidenceGraph, + "external-consumer": reportOutput(externalconsumer.Build), + "impact": outputWithExit(impact.Build), + "migration-parity-admission": reportOutput(migrationparityadmission.Build), + "migration-plan": outputWithExit(migrationplan.Build), + "package-runtime-dependency-admission": reportOutputWithoutError(packageruntimedependency.Build), + "producer-policy-self-proof": reportOutput(producerpolicyselfproof.Build), + "proof-obligation-algebra": reportOutput(proofobligationalgebra.Build), + "proof-receipt-admission": reportOutput(proofreceiptadmission.Build), + "proof-slice": requirementbinding.BuildProofSlice, + "readiness-closeout": reportOutput(readinesscloseout.Build), + "receipt-currentness-scope": reportOutput(receiptcurrentnessscope.Build), + "receipt-producer-admission": reportOutput(receiptproduceradmission.Build), + "receipt-trust-class": reportOutput(receipttrustclass.Build), + "registry-consumer": reportOutput(registryconsumer.Build), + "registry-consumer-proof-input-compose": outputWithExit( + registryconsumerinputcompose.Build, + ), + "release-authority": reportOutput(releaseauthority.Build), + "rendered-artifact-freshness": reportOutput(renderedartifactfreshness.Build), + "repo-profile-admission": reportOutput(repoprofileadmission.Build), + "requirement-authoring-plan": outputWithExit(requirementauthoringplan.Build), + "requirement-bindings": reportOutput(requirementbinding.BuildReport), + "requirement-coverage-input-compose": outputWithExit(requirementcoverageinput.Build), + "requirement-impact-input-compose": outputWithExit(requirementimpactinput.Build), + "requirement-proof-source-set": requirementproofsourceset.Build, + "requirement-source-admission": reportOutput(requirementsourceadmission.Build), + "requirement-source-transition": reportOutput(requirementsourcetransition.Build), + "requirement-spec-tree": reportOutput(requirementspectree.Build), + "scaffold-profile-plan": zeroExitOutput(scaffoldprofileplan.Build), + "secret-scan": reportOutput(secretscan.Build), + "self-check": selfCheckOutput, + "spec-overview-claims": reportOutput(specoverviewclaims.Build), + "spec-proof-bundle-admission": reportOutput(specproofbundleadmission.Build), + "text-policy": reportOutput(textpolicy.Build), + "witness-plan": zeroExitOutput(witnessplan.Build), + "witness-scheduler-plan": reportOutput(witnessschedulerplan.Build), + "workspace-manifest-facts": outputWithExit(workspacemanifestfacts.Build), + "workspace-registry": reportOutput(workspaceregistry.Build), +}) + func buildOutput(command string, input any) (any, int, error) { - if command == "impact" { - return impact.Build(input) - } - if command == "migration-plan" { - return migrationplan.Build(input) - } - if command == "witness-plan" { - output, err := witnessplan.Build(input) - return output, 0, err - } - if command == "evidence-graph" { - return requirementbinding.BuildEvidenceGraph(input) - } - if command == "proof-slice" { - return requirementbinding.BuildProofSlice(input) - } - if command == "requirement-proof-source-set" { - return requirementproofsourceset.Build(input) + builder, ok := genericCommandBuilders[command] + if !ok { + return nil, 1, fmt.Errorf("unsupported generic command: %s", command) } - if command == "requirement-coverage-view" { - return requirementcoverageview.BuildJSON(input, requirementcoverageview.Options{}) - } - if command == "requirement-coverage-input-compose" { - return requirementcoverageinput.Build(input) - } - if command == "requirement-impact-input-compose" { - return requirementimpactinput.Build(input) - } - if command == "requirement-authoring-plan" { - return requirementauthoringplan.Build(input) - } - if command == "registry-consumer-proof-input-compose" { - return registryconsumerinputcompose.Build(input) - } - if command == "workspace-manifest-facts" { - return workspacemanifestfacts.Build(input) + return builder(input) +} + +func outputWithExit[T any](builder func(any) (T, int, error)) genericCommandBuilder { + return func(input any) (any, int, error) { + return builder(input) } - if command == "scaffold-profile-plan" { - output, err := scaffoldprofileplan.Build(input) +} + +func zeroExitOutput[T any](builder func(any) (T, error)) genericCommandBuilder { + return func(input any) (any, int, error) { + output, err := builder(input) return output, 0, err } - if command == "agent-route" { - return agentroute.Build(input) - } - record, exitCode, err := buildReport(command, input) - if err != nil { - return nil, exitCode, err - } - return record.JSONValue(), exitCode, nil } -func buildReport(command string, input any) (report.Record, int, error) { - if command == "adoption-checklist" { - return adoptionchecklist.Build(input) - } - if command == "binding-partition" { - return bindingpartition.Build(input) - } - if command == "branch-authority" { - record, exitCode := branchauthority.Build(input) - return record, exitCode, nil - } - if command == "capability-map-admission" { - return capabilitymapadmission.Build(input) - } - if command == "completion-criteria" { - return completioncriteria.Build(input) - } - if command == "custom-rule-boundary" { - return customruleboundary.Build(input) - } - if command == "deployment-evidence-admission" { - return deploymentevidenceadmission.Build(input) - } - if command == "document-lifecycle-boundary" { - return documentlifecycle.Build(input) - } - if command == "external-consumer" { - return externalconsumer.Build(input) - } - if command == "migration-parity-admission" { - return migrationparityadmission.Build(input) - } - if command == "package-runtime-dependency-admission" { - record, exitCode := packageruntimedependency.Build(input) - return record, exitCode, nil +func reportOutput(builder func(any) (report.Record, int, error)) genericCommandBuilder { + return func(input any) (any, int, error) { + record, exitCode, err := builder(input) + if err != nil { + return nil, exitCode, err + } + return record.JSONValue(), exitCode, nil } - if command == "proof-obligation-algebra" { - return proofobligationalgebra.Build(input) - } - if command == "producer-policy-self-proof" { - return producerpolicyselfproof.Build(input) - } - if command == "proof-receipt-admission" { - return proofreceiptadmission.Build(input) - } - if command == "readiness-closeout" { - return readinesscloseout.Build(input) - } - if command == "receipt-currentness-scope" { - return receiptcurrentnessscope.Build(input) - } - if command == "receipt-producer-admission" { - return receiptproduceradmission.Build(input) - } - if command == "receipt-trust-class" { - return receipttrustclass.Build(input) - } - if command == "registry-consumer" { - return registryconsumer.Build(input) - } - if command == "rendered-artifact-freshness" { - return renderedartifactfreshness.Build(input) - } - if command == "requirement-bindings" { - return requirementbinding.BuildReport(input) - } - if command == "release-authority" { - return releaseauthority.Build(input) - } - if command == "repo-profile-admission" { - return repoprofileadmission.Build(input) - } - if command == "requirement-source-admission" { - return requirementsourceadmission.Build(input) - } - if command == "requirement-spec-tree" { - return requirementspectree.Build(input) - } - if command == "requirement-source-transition" { - return requirementsourcetransition.Build(input) - } - if command == "spec-overview-claims" { - return specoverviewclaims.Build(input) - } - if command == "spec-proof-bundle-admission" { - return specproofbundleadmission.Build(input) - } - if command == "test-evidence-inventory" { - return testevidenceinventory.Build(input) - } - if command == "text-policy" { - return textpolicy.Build(input) - } - if command == "witness-scheduler-plan" { - return witnessschedulerplan.Build(input) - } - if command == "workspace-registry" { - return workspaceregistry.Build(input) - } - if command == "self-check" { - return report.BuildSelfCheckReport(input), 0, nil - } - if command == "secret-scan" { - return secretscan.Build(input) +} + +func reportOutputWithoutError(builder func(any) (report.Record, int)) genericCommandBuilder { + return func(input any) (any, int, error) { + record, exitCode := builder(input) + return record.JSONValue(), exitCode, nil } - return report.Record{}, 1, fmt.Errorf("unsupported command: %s", command) +} + +func selfCheckOutput(input any) (any, int, error) { + return report.BuildSelfCheckReport(input).JSONValue(), 0, nil +} + +func mustGenericCommandBuilders(builders map[string]genericCommandBuilder) map[string]genericCommandBuilder { + for _, descriptor := range commandDescriptors { + _, registered := builders[descriptor.name] + if descriptor.runner == commandRunnerGenericInput && !registered { + panic("generic command has no builder: " + descriptor.name) + } + if descriptor.runner != commandRunnerGenericInput && registered { + panic("non-generic command has an unreachable generic builder: " + descriptor.name) + } + } + for command, builder := range builders { + descriptor, exists := commandDescriptorByName[command] + if !exists || descriptor.runner != commandRunnerGenericInput { + panic("generic builder has no generic command descriptor: " + command) + } + if builder == nil { + panic("generic command has a nil builder: " + command) + } + } + return builders } func buildInitReport(preset string) (report.Record, error) { diff --git a/internal/app/planning_changed_command.go b/internal/app/planning_changed_command.go index 81c8e37..1e64aa5 100644 --- a/internal/app/planning_changed_command.go +++ b/internal/app/planning_changed_command.go @@ -15,5 +15,5 @@ func runChangedPathSetPlanning(input any, options planningArgs, stdout io.Writer if options.agentEnvelope { return writeJSON(changedpathset.AgentEnvelope(result), result.ExitCode, nil, stdout, stderr) } - return writeJSON(result.Report.JSONValue(), result.ExitCode, nil, stdout, stderr) + return writeJSON(result.JSONValue(), result.ExitCode, nil, stdout, stderr) } diff --git a/internal/app/requirement_browser_command.go b/internal/app/requirement_browser_command.go index 435bcc6..3bd8c09 100644 --- a/internal/app/requirement_browser_command.go +++ b/internal/app/requirement_browser_command.go @@ -10,6 +10,7 @@ import ( "syscall" "github.com/research-engineering/agentic-proofkit/internal/command/requirementbrowser" + "github.com/research-engineering/agentic-proofkit/internal/kernel/compactproofcontract" "github.com/research-engineering/agentic-proofkit/internal/kernel/jsonpointer" ) @@ -109,7 +110,11 @@ func parseRequirementBrowserArgs(args []string) (requirementBrowserArgs, error) if index+1 >= len(args) || args[index+1] == "" { return requirementBrowserArgs{}, fmt.Errorf("--local-environment-class requires an id") } - options.localEnvironmentClasses = append(options.localEnvironmentClasses, args[index+1]) + class, err := compactproofcontract.AdmitLocalEnvironmentClass(args[index+1]) + if err != nil { + return requirementBrowserArgs{}, err + } + options.localEnvironmentClasses = append(options.localEnvironmentClasses, class) index++ case "--empty-local-environment-policy": options.emptyLocalEnvironmentPolicy = true diff --git a/internal/app/requirement_commands.go b/internal/app/requirement_commands.go index 1ed2f09..09ed178 100644 --- a/internal/app/requirement_commands.go +++ b/internal/app/requirement_commands.go @@ -12,6 +12,7 @@ import ( "github.com/research-engineering/agentic-proofkit/internal/command/requirementsourceview" "github.com/research-engineering/agentic-proofkit/internal/command/requirementspectree" "github.com/research-engineering/agentic-proofkit/internal/kernel/admit" + "github.com/research-engineering/agentic-proofkit/internal/kernel/compactproofcontract" "github.com/research-engineering/agentic-proofkit/internal/kernel/jsonpointer" "github.com/research-engineering/agentic-proofkit/internal/kernel/stablejson" ) @@ -115,7 +116,11 @@ func parseRequirementViewArgs(command string, args []string) (requirementViewArg if index+1 >= len(args) || args[index+1] == "" { return requirementViewArgs{}, fmt.Errorf("--local-environment-class requires an id") } - options.localEnvironmentClasses = append(options.localEnvironmentClasses, args[index+1]) + class, err := compactproofcontract.AdmitLocalEnvironmentClass(args[index+1]) + if err != nil { + return requirementViewArgs{}, err + } + options.localEnvironmentClasses = append(options.localEnvironmentClasses, class) index++ case "--empty-local-environment-policy": if command != "requirement-proof-view" { diff --git a/internal/app/requirement_proof_resolver_command.go b/internal/app/requirement_proof_resolver_command.go index 2239fb3..acc3a36 100644 --- a/internal/app/requirement_proof_resolver_command.go +++ b/internal/app/requirement_proof_resolver_command.go @@ -5,6 +5,7 @@ import ( "io" "github.com/research-engineering/agentic-proofkit/internal/command/requirementbinding" + "github.com/research-engineering/agentic-proofkit/internal/kernel/compactproofcontract" "github.com/research-engineering/agentic-proofkit/internal/kernel/jsonpointer" ) @@ -54,7 +55,11 @@ func parseRequirementProofResolverArgs(args []string) (requirementProofResolverA if index+1 >= len(args) || args[index+1] == "" { return requirementProofResolverArgs{}, fmt.Errorf("--local-environment-class requires an id") } - options.localEnvironmentClasses = append(options.localEnvironmentClasses, args[index+1]) + class, err := compactproofcontract.AdmitLocalEnvironmentClass(args[index+1]) + if err != nil { + return requirementProofResolverArgs{}, err + } + options.localEnvironmentClasses = append(options.localEnvironmentClasses, class) index++ case "--empty-local-environment-policy": options.emptyLocalEnvironmentPolicy = true diff --git a/internal/app/self_hosting_semantics_test.go b/internal/app/self_hosting_semantics_test.go index bef3842..ece1c44 100644 --- a/internal/app/self_hosting_semantics_test.go +++ b/internal/app/self_hosting_semantics_test.go @@ -91,6 +91,65 @@ func TestSelfHostingWitnessBackedBindingsReferenceExistingSurfaces(t *testing.T) } } +func TestSelfHostingRequirementBindingRecordsPreserveSourceAuthority(t *testing.T) { + type sourceRequirement struct { + claimLevel string + nonClaims map[string]struct{} + ownerID string + specPath string + } + sources := map[string]sourceRequirement{} + for _, path := range requirementSourcePaths(t) { + record := readJSONFile(t, path).(map[string]any) + for _, value := range record["requirements"].([]any) { + requirement := value.(map[string]any) + requirementID := requirement["requirementId"].(string) + if _, exists := sources[requirementID]; exists { + t.Fatalf("duplicate source requirementId %s", requirementID) + } + nonClaims := map[string]struct{}{} + for _, nonClaim := range requirement["nonClaims"].([]any) { + nonClaims[nonClaim.(string)] = struct{}{} + } + sources[requirementID] = sourceRequirement{ + claimLevel: requirement["claimLevel"].(string), + nonClaims: nonClaims, + ownerID: requirement["ownerId"].(string), + specPath: path, + } + } + } + + bindings := readJSONFile(t, "proofkit/requirement-bindings.json").(map[string]any) + bound := map[string]struct{}{} + for _, value := range bindings["requirements"].([]any) { + requirement := value.(map[string]any) + requirementID := requirement["requirementId"].(string) + source, ok := sources[requirementID] + if !ok { + t.Fatalf("binding requirement %s has no source owner record", requirementID) + } + bound[requirementID] = struct{}{} + if requirement["ownerId"] != source.ownerID || requirement["claimLevel"] != source.claimLevel || requirement["specPath"] != source.specPath { + t.Fatalf("binding requirement %s changed source identity: binding=%#v source=%#v", requirementID, requirement, source) + } + bindingNonClaims := map[string]struct{}{} + for _, nonClaim := range requirement["nonClaims"].([]any) { + bindingNonClaims[nonClaim.(string)] = struct{}{} + } + for nonClaim := range source.nonClaims { + if _, ok := bindingNonClaims[nonClaim]; !ok { + t.Fatalf("binding requirement %s dropped source nonClaim %q", requirementID, nonClaim) + } + } + } + for requirementID := range sources { + if _, ok := bound[requirementID]; !ok { + t.Fatalf("source requirement %s has no binding requirement record", requirementID) + } + } +} + func TestSelfHostingProofCoreCommandsRejectBrokenLinkage(t *testing.T) { bindings := readJSONFile(t, "proofkit/requirement-bindings.json") bindingRecord := bindings.(map[string]any) diff --git a/internal/command/agentroute/agentroute.go b/internal/command/agentroute/agentroute.go index 440bf7e..eb2d1e3 100644 --- a/internal/command/agentroute/agentroute.go +++ b/internal/command/agentroute/agentroute.go @@ -110,7 +110,7 @@ type observedReport struct { } type routeSpec struct { - Family string + RouteFamily routeFamily RequiredAny [][]string NextCommands []commandSpec StopConditions []string @@ -119,6 +119,22 @@ type routeSpec struct { SliceNonClaims []string } +type routeFamily string + +const ( + routeFamilyAdoption routeFamily = "adoption" + routeFamilyMigration routeFamily = "migration" + routeFamilyReleaseAndDeployment routeFamily = "release_and_deployment" + routeFamilyRenderedViews routeFamily = "rendered_views" + routeFamilyRepositoryStructure routeFamily = "repository_structure" + routeFamilyRequirementProofBinding routeFamily = "requirement_proof_binding" + routeFamilyRequirementSource routeFamily = "requirement_source" + routeFamilySelectiveEvidence routeFamily = "selective_evidence" + routeFamilySelectivePlanning routeFamily = "selective_planning" + routeFamilyTestInventoryAndCoverage routeFamily = "test_inventory_and_coverage" + routeFamilyUnknown routeFamily = "unknown" +) + type commandSpec struct { Command string InputKind string @@ -134,7 +150,7 @@ type commandArgInput struct { var routeSpecs = map[string]routeSpec{ "admit_receipts": { - Family: "selective_evidence", + RouteFamily: routeFamilySelectiveEvidence, RequiredAny: [][]string{{"selective_evidence", "obligation_decision_input", "obligation_decision"}}, NextCommands: []commandSpec{ {Command: "selective-gate-evidence", InputKind: "selective_evidence", Why: "Receipts must be admitted against the caller-owned selective plan before obligation decisions consume them."}, @@ -147,7 +163,7 @@ var routeSpecs = map[string]routeSpec{ SliceNonClaims: []string{"The selective evidence slice does not authenticate producers or approve merge."}, }, "adopt_repository": { - Family: "adoption", + RouteFamily: routeFamilyAdoption, RequiredAny: [][]string{{"adoption_workflow", "capability_map", "scaffold_project_structure"}}, NextCommands: []commandSpec{ {Command: "adoption-workflow-plan", InputKind: "adoption_workflow", Why: "Repository adoption should start from a caller-owned workflow plan instead of ambient repository scanning."}, @@ -160,7 +176,7 @@ var routeSpecs = map[string]routeSpec{ SliceNonClaims: []string{"The adoption slice does not select final module boundaries or write files."}, }, "author_requirements": { - Family: "requirement_source", + RouteFamily: routeFamilyRequirementSource, RequiredAny: [][]string{{"authoring_plan", "capability_map"}}, NextCommands: []commandSpec{ {Command: "capability-map-admission", InputKind: "capability_map", Why: "Capability maps convert caller-owned observations into candidate requirement and proof-binding seeds before any stable requirement source is materialized."}, @@ -172,7 +188,7 @@ var routeSpecs = map[string]routeSpec{ SliceNonClaims: []string{"The authoring slice does not infer requirement meaning or materialize stable requirement source files."}, }, "bind_requirement_proofs": { - Family: "requirement_proof_binding", + RouteFamily: routeFamilyRequirementProofBinding, RequiredAny: [][]string{{"proof_binding", "binding_witness_plan_input", "witness_command_catalog"}}, NextCommands: []commandSpec{ {Command: "requirement-bindings", InputKind: "proof_binding", Why: "Proof bindings own the route from requirements to scenarios, witnesses, commands, and environment classes."}, @@ -186,7 +202,7 @@ var routeSpecs = map[string]routeSpec{ SliceNonClaims: []string{"The proof-binding slice does not execute native witnesses or prove semantic adequacy."}, }, "check_overview_claims": { - Family: "requirement_source", + RouteFamily: routeFamilyRequirementSource, RequiredAny: [][]string{{"overview_claims"}}, NextCommands: []commandSpec{ {Command: "spec-overview-claims", InputKind: "overview_claims", Why: "Durable overview claims must cite REQ-* records or remain non-normative prose."}, @@ -197,7 +213,7 @@ var routeSpecs = map[string]routeSpec{ SliceNonClaims: []string{"The overview slice does not parse Markdown or judge product claim adequacy."}, }, "decide_obligations": { - Family: "selective_evidence", + RouteFamily: routeFamilySelectiveEvidence, RequiredAny: [][]string{{"obligation_decision"}}, NextCommands: []commandSpec{ {Command: "obligation-decision", InputKind: "obligation_decision", Why: "Merge-relevant proof obligations need an explicit decision input instead of raw command results."}, @@ -208,7 +224,7 @@ var routeSpecs = map[string]routeSpec{ SliceNonClaims: []string{"The obligation slice does not make consumer merge decisions."}, }, "inspect_coverage": { - Family: "test_inventory_and_coverage", + RouteFamily: routeFamilyTestInventoryAndCoverage, RequiredAny: [][]string{{"coverage_compose_input", "coverage_view_input"}}, NextCommands: []commandSpec{ {Command: "requirement-coverage-input-compose", InputKind: "coverage_compose_input", Why: "Coverage views should be composed from explicit caller-owned requirement source, proof binding, test inventory, universe, and local environment policy before inspection."}, @@ -221,7 +237,7 @@ var routeSpecs = map[string]routeSpec{ SliceNonClaims: []string{"The coverage slice does not claim inventory completeness outside the caller-owned universe."}, }, "inventory_tests": { - Family: "test_inventory_and_coverage", + RouteFamily: routeFamilyTestInventoryAndCoverage, RequiredAny: [][]string{{"test_discovery", "test_inventory", "coverage_compose_input", "coverage_view_input"}}, NextCommands: []commandSpec{ {Command: "test-evidence-inventory", InputKind: "test_discovery", ExtraArgs: []string{"--projection", "discovery-draft"}, Why: "Caller-owned discovered test facts must become candidate-only inventory guidance before any strict semantic inventory is materialized."}, @@ -235,7 +251,7 @@ var routeSpecs = map[string]routeSpec{ SliceNonClaims: []string{"The test inventory slice does not infer test intent from source code or execute tests."}, }, "plan_selective_checks": { - Family: "selective_planning", + RouteFamily: routeFamilySelectivePlanning, RequiredAny: [][]string{{"changed_path_set", "impact_input", "selective_gate_plan_input"}}, NextCommands: []commandSpec{ {Command: "changed-path-set", InputKind: "changed_path_set", Why: "Changed paths must be admitted from caller-owned git facts before impact analysis."}, @@ -248,7 +264,7 @@ var routeSpecs = map[string]routeSpec{ SliceNonClaims: []string{"The selective planning slice does not discover git state or run CI commands."}, }, "release_or_deploy_evidence": { - Family: "release_and_deployment", + RouteFamily: routeFamilyReleaseAndDeployment, RequiredAny: [][]string{{"release_authority_input", "registry_consumer_input", "deployment_evidence_input", "readiness_closeout_input"}}, NextCommands: []commandSpec{ {Command: "release-authority", InputKind: "release_authority_input", Why: "Release authority checks package and provenance facts without becoming the publisher."}, @@ -262,7 +278,7 @@ var routeSpecs = map[string]routeSpec{ SliceNonClaims: []string{"The release slice does not publish packages, deploy services, or approve readiness."}, }, "render_human_view": { - Family: "rendered_views", + RouteFamily: routeFamilyRenderedViews, RequiredAny: [][]string{{"requirement_source", "proof_binding", "compact_proof_binding", "coverage_view_input", "spec_tree_bundle"}}, NextCommands: []commandSpec{ {Command: "requirement-source-view", InputKind: "requirement_source", Why: "Human source views render structured requirement records without becoming authority."}, @@ -282,7 +298,7 @@ var routeSpecs = map[string]routeSpec{ SliceNonClaims: []string{"The rendered view slice does not make HTML or Markdown canonical authority."}, }, "retire_local_infrastructure": { - Family: "migration", + RouteFamily: routeFamilyMigration, RequiredAny: [][]string{{"migration_parity", "migration_plan"}}, NextCommands: []commandSpec{ {Command: "migration-parity-admission", InputKind: "migration_parity", Why: "Local proof infrastructure can be retired only after caller-owned parity evidence is admitted."}, @@ -294,7 +310,7 @@ var routeSpecs = map[string]routeSpec{ SliceNonClaims: []string{"The migration slice does not approve deletion of consumer-owned proof owners."}, }, "scaffold_first_module": { - Family: "adoption", + RouteFamily: routeFamilyAdoption, RequiredAny: [][]string{{"scaffold_profile_plan", "scaffold_project_structure"}}, NextCommands: []commandSpec{ {Command: "scaffold-profile-plan", InputKind: "scaffold_profile_plan", Why: "Scaffold profile planning turns caller-reviewed hints into deterministic profile draft data."}, @@ -306,7 +322,7 @@ var routeSpecs = map[string]routeSpec{ SliceNonClaims: []string{"The scaffold slice does not invent final requirement text or materialize files."}, }, "validate_requirement_source": { - Family: "requirement_source", + RouteFamily: routeFamilyRequirementSource, RequiredAny: [][]string{{"requirement_source", "requirement_source_transition"}}, NextCommands: []commandSpec{ {Command: "requirement-source-admission", InputKind: "requirement_source", Why: "Structured requirement records are the machine-admissible source for requirement state."}, @@ -318,7 +334,7 @@ var routeSpecs = map[string]routeSpec{ SliceNonClaims: []string{"The requirement source slice does not own product meaning or proof adequacy."}, }, "verify_typescript_public_api": { - Family: "repository_structure", + RouteFamily: routeFamilyRepositoryStructure, RequiredAny: [][]string{{"typescript_public_api_manifest"}, {"typescript_public_api_repo_root"}}, NextCommands: []commandSpec{ {Command: "typescript-public-api-surfaces", InputKind: "typescript_public_api_manifest", ArgInputs: []commandArgInput{{Flag: "--repo-root", InputKind: "typescript_public_api_repo_root"}}, Why: "This is an explicit filesystem-scan command: it verifies a caller-owned TypeScript public API manifest against a caller-selected checkout root."}, @@ -429,6 +445,44 @@ func InputContract() map[string]any { } } +func OutputContract() map[string]any { + return map[string]any{ + "contractId": "proofkit.agent-route.output.v2", + "schemaVersion": 2, + "authority": "deterministic route report derived from admitted agent-route input", + "requiredFields": []any{ + "guidanceSlice", + "reportId", + "reportKind", + "schemaVersion", + "selectedRouteFamily", + "state", + }, + "fields": map[string]any{ + "schemaVersion": map[string]any{"value": 2}, + "selectedRouteFamily": map[string]any{ + "enum": routeFamilyContractValues(), + }, + "guidanceSlice": map[string]any{ + "requiredFields": []any{"routeFamily"}, + "routeFamilyRule": "must equal selectedRouteFamily", + }, + }, + "changesFromV1": []any{ + "selectedFamily is replaced by selectedRouteFamily", + "guidanceSlice.family is replaced by guidanceSlice.routeFamily", + }, + } +} + +func routeFamilyContractValues() []any { + values := map[string]struct{}{string(routeFamilyUnknown): {}} + for _, spec := range routeSpecs { + values[string(spec.RouteFamily)] = struct{}{} + } + return sortedKeys(values) +} + func admitInput(raw any) (routeInput, error) { record, ok := raw.(map[string]any) if !ok { @@ -689,7 +743,7 @@ func inputGroupSatisfied(group []string, available map[string]string) bool { func buildUnknownGoal(input routeInput) map[string]any { spec := routeSpec{ - Family: "unknown", + RouteFamily: routeFamilyUnknown, SliceSummary: "Unknown goals are deliberately not routed.", SliceNonClaims: []string{ "The unknown-goal slice does not choose commands from ambiguous caller intent.", @@ -705,15 +759,15 @@ func buildUnknownGoal(input routeInput) map[string]any { "omitted": []any{ map[string]any{"reason": "Unknown goals are not routed because that would create an implicit policy owner inside Proofkit."}, }, - "reportId": input.RouteID, - "reportKind": "proofkit.agent-route", - "requiredInputs": []any{}, - "schemaVersion": 1, - "selectedFamily": "unknown", - "state": "blocked_unknown_goal", - "stopConditions": []any{"Stop before running a Proofkit command until the caller supplies a known goal."}, - "callerNonClaims": toAnySlice(input.CallerNonClaims), - "guidanceSlice": guidanceSliceReport(input.Goal, spec), + "reportId": input.RouteID, + "reportKind": "proofkit.agent-route", + "requiredInputs": []any{}, + "schemaVersion": 2, + "selectedRouteFamily": string(routeFamilyUnknown), + "state": "blocked_unknown_goal", + "stopConditions": []any{"Stop before running a Proofkit command until the caller supplies a known goal."}, + "callerNonClaims": toAnySlice(input.CallerNonClaims), + "guidanceSlice": guidanceSliceReport(input.Goal, spec), "summary": map[string]any{ "browserMode": input.BrowserMode, "availableInputCount": len(input.AvailableInputs), @@ -733,21 +787,21 @@ func buildReport(input routeInput, spec routeSpec, state string, missing []map[s nextCommands = []any{} } return map[string]any{ - "diagnostics": diagnostics(input, state, missing), - "escalations": toAnySlice(spec.Escalations), - "nextCommands": nextCommands, - "nonClaims": mergedNonClaims(input.CallerNonClaims), - "omitted": omittedReports(spec.NextCommands, input.AvailableInputs), - "observedReports": observedReportReports(input.ObservedReports), - "reportId": input.RouteID, - "reportKind": "proofkit.agent-route", - "requiredInputs": requiredInputReports(missing), - "schemaVersion": 1, - "selectedFamily": spec.Family, - "state": state, - "stopConditions": toAnySlice(spec.StopConditions), - "callerNonClaims": toAnySlice(input.CallerNonClaims), - "guidanceSlice": guidanceSliceReport(input.Goal, spec), + "diagnostics": diagnostics(input, state, missing), + "escalations": toAnySlice(spec.Escalations), + "nextCommands": nextCommands, + "nonClaims": mergedNonClaims(input.CallerNonClaims), + "omitted": omittedReports(spec.NextCommands, input.AvailableInputs), + "observedReports": observedReportReports(input.ObservedReports), + "reportId": input.RouteID, + "reportKind": "proofkit.agent-route", + "requiredInputs": requiredInputReports(missing), + "schemaVersion": 2, + "selectedRouteFamily": string(spec.RouteFamily), + "state": state, + "stopConditions": toAnySlice(spec.StopConditions), + "callerNonClaims": toAnySlice(input.CallerNonClaims), + "guidanceSlice": guidanceSliceReport(input.Goal, spec), "summary": map[string]any{ "browserMode": input.BrowserMode, "availableInputCount": len(input.AvailableInputs), @@ -940,7 +994,7 @@ func guidanceSliceReport(goal string, spec routeSpec) map[string]any { "selector": "agent guidance envelopes", }, }, - "family": spec.Family, + "routeFamily": string(spec.RouteFamily), "lookupOnly": true, "nonClaims": toAnySlice(spec.SliceNonClaims), "sliceId": "proofkit.agent-route.slice." + strings.ReplaceAll(goal, "_", "-"), diff --git a/internal/command/agentroute/agentroute_test.go b/internal/command/agentroute/agentroute_test.go index 327d06a..b485a37 100644 --- a/internal/command/agentroute/agentroute_test.go +++ b/internal/command/agentroute/agentroute_test.go @@ -34,8 +34,14 @@ func TestBuildRoutesRequirementSourceAndBlocksUnknownGoal(t *testing.T) { if state := report["state"]; state != "routed" { t.Fatalf("state = %v, want routed", state) } - if family := report["selectedFamily"]; family != "requirement_source" { - t.Fatalf("selectedFamily = %v, want requirement_source", family) + if schemaVersion := report["schemaVersion"]; schemaVersion != 2 { + t.Fatalf("schemaVersion = %v, want 2 for selectedRouteFamily output", schemaVersion) + } + if family := report["selectedRouteFamily"]; family != "requirement_source" { + t.Fatalf("selectedRouteFamily = %v, want requirement_source", family) + } + if _, legacy := report["selectedFamily"]; legacy { + t.Fatalf("schema v2 report retained legacy selectedFamily alias: %#v", report) } commands := report["nextCommands"].([]any) if len(commands) != 1 { @@ -69,6 +75,9 @@ func TestBuildRoutesRequirementSourceAndBlocksUnknownGoal(t *testing.T) { if state := blocked["state"]; state != "blocked_unknown_goal" { t.Fatalf("unknown state = %v, want blocked_unknown_goal", state) } + if schemaVersion := blocked["schemaVersion"]; schemaVersion != 2 { + t.Fatalf("unknown schemaVersion = %v, want 2", schemaVersion) + } if commands := blocked["nextCommands"].([]any); len(commands) != 0 { t.Fatalf("unknown nextCommands length = %d, want 0", len(commands)) } @@ -825,8 +834,8 @@ func TestBuildRoutesTypeScriptPublicAPIAsExplicitScanner(t *testing.T) { if exitCode != 0 { t.Fatalf("exitCode = %d, want 0", exitCode) } - if family := report["selectedFamily"]; family != "repository_structure" { - t.Fatalf("selectedFamily = %v, want repository_structure", family) + if family := report["selectedRouteFamily"]; family != "repository_structure" { + t.Fatalf("selectedRouteFamily = %v, want repository_structure", family) } argv := findCommandArgv(t, report["nextCommands"].([]any), "typescript-public-api-surfaces") assertArgvContainsPair(t, argv, "--input", "docs/contracts/public-api-surfaces.v1.json") diff --git a/internal/command/capabilitymapadmission/capability_map_admission.go b/internal/command/capabilitymapadmission/capability_map_admission.go index ee844c0..77c2e12 100644 --- a/internal/command/capabilitymapadmission/capability_map_admission.go +++ b/internal/command/capabilitymapadmission/capability_map_admission.go @@ -498,6 +498,11 @@ func admitScenarioShape(raw any, context string) (scenarioShape, error) { if err != nil { return scenarioShape{}, err } + for _, required := range requiredEvidence { + if _, ok := requiredEvidenceWitnessKind[required]; !ok { + return scenarioShape{}, fmt.Errorf("%s.requiredEvidence %s is unsupported", context, required) + } + } ownerQuestions, err := admitOptionalSortedText(record["ownerQuestions"], context+".ownerQuestions", true) if err != nil { return scenarioShape{}, err @@ -734,11 +739,7 @@ func anchorSatisfiesRequiredEvidence(anchor scenarioAnchor, requiredEvidence []s func requiredEvidenceFailures(shape scenarioShape, anchors []scenarioAnchor) []string { failures := []string{} for _, required := range shape.RequiredEvidence { - requiredKind, ok := requiredEvidenceWitnessKind[required] - if !ok { - failures = append(failures, fmt.Sprintf("scenario %s requiredEvidence %s is unsupported", shape.ScenarioID, required)) - continue - } + requiredKind := requiredEvidenceWitnessKind[required] satisfied := false for _, anchor := range anchors { if !isExecutableAnchor(anchor) { diff --git a/internal/command/capabilitymapadmission/capability_map_admission_test.go b/internal/command/capabilitymapadmission/capability_map_admission_test.go index 470d2c2..144fb9c 100644 --- a/internal/command/capabilitymapadmission/capability_map_admission_test.go +++ b/internal/command/capabilitymapadmission/capability_map_admission_test.go @@ -118,16 +118,21 @@ func TestBuildCodeBaselineRejectsUnsupportedRequiredEvidence(t *testing.T) { input := validCapabilityMapInput("code_baseline") firstScenarioShape(input)["requiredEvidence"] = []any{"mutation_test"} - record, exitCode, err := Build(input) - if err != nil { - t.Fatalf("Build() error = %v", err) + _, exitCode, err := Build(input) + if exitCode != 1 || err == nil || !strings.Contains(err.Error(), "requiredEvidence mutation_test is unsupported") { + t.Fatalf("Build() exit=%d error=%v, want unsupported requiredEvidence admission failure", exitCode, err) } - if exitCode == 0 || record.State != "failed" { - t.Fatalf("Build() exit=%d state=%s, want failed", exitCode, record.State) - } - encoded, _ := json.Marshal(record.JSONValue()) - if !strings.Contains(string(encoded), "requiredEvidence mutation_test is unsupported") { - t.Fatalf("failure did not mention unsupported requiredEvidence: %s", encoded) +} + +func TestBuildAuditModeRejectsUnsupportedRequiredEvidence(t *testing.T) { + t.Parallel() + + input := validCapabilityMapInput("audit_from_code") + firstScenarioShape(input)["requiredEvidence"] = []any{"mutation_test"} + + _, exitCode, err := Build(input) + if exitCode != 1 || err == nil || !strings.Contains(err.Error(), "requiredEvidence mutation_test is unsupported") { + t.Fatalf("Build() exit=%d error=%v, want audit-mode vocabulary admission failure", exitCode, err) } } diff --git a/internal/command/changedpathset/changedpathset.go b/internal/command/changedpathset/changedpathset.go index fdfc344..f581909 100644 --- a/internal/command/changedpathset/changedpathset.go +++ b/internal/command/changedpathset/changedpathset.go @@ -51,6 +51,17 @@ type Result struct { SourceSummaries []SourceSummary } +func (result Result) JSONValue() map[string]any { + value := result.Report.JSONValue() + value["changedPathSetHash"] = result.ChangedPathSetHash + value["changedPaths"] = admit.StringSliceToAny(result.ChangedPaths) + value["duplicatePaths"] = diagnosticsJSON(result.DuplicatePaths) + value["failures"] = admit.StringSliceToAny(result.Failures) + value["invalidPaths"] = diagnosticsJSON(result.InvalidPaths) + value["sourceSummaries"] = sourceSummariesJSON(result.SourceSummaries) + return value +} + var changedPathSetNonClaims = []string{ "Changed path set reports do not run git, inspect the filesystem, or discover changed paths.", "Changed path set reports do not infer proof routes, command ids, gate selection, or fallback policy.", diff --git a/internal/command/changedpathset/changedpathset_test.go b/internal/command/changedpathset/changedpathset_test.go index 0c87d34..fd20a0a 100644 --- a/internal/command/changedpathset/changedpathset_test.go +++ b/internal/command/changedpathset/changedpathset_test.go @@ -2,11 +2,69 @@ package changedpathset import ( "encoding/json" - "github.com/research-engineering/agentic-proofkit/internal/testsupport/commandcoverage" + "fmt" "strings" "testing" + + "github.com/research-engineering/agentic-proofkit/internal/kernel/jsonpointer" + "github.com/research-engineering/agentic-proofkit/internal/testsupport/commandcoverage" ) +func TestAgentEnvelopeContextPointersResolveAgainstCanonicalOutput(t *testing.T) { + result, err := Build(map[string]any{ + "schemaVersion": json.Number("1"), + "reportId": "proofkit.test.changed-path-set", + "preexistingFailures": []any{}, + "nonClaims": []any{"Changed-path test input does not prove git diff freshness."}, + "sources": []any{map[string]any{"sourceId": "git", "paths": []any{"a.ts"}}}, + }) + if err != nil { + t.Fatalf("Build() error = %v", err) + } + output := result.JSONValue() + envelope := AgentEnvelope(result) + contextRefs := envelope["contextRefs"].([]any) + checked, err := validateContextJSONPointers(output, contextRefs) + if err != nil { + t.Fatal(err) + } + if checked == 0 { + t.Fatal("agent envelope exposed no JSON-pointer context refs") + } + invalidRefs := append([]any{}, contextRefs...) + invalid := map[string]any{} + for key, value := range contextRefs[0].(map[string]any) { + invalid[key] = value + } + invalid["ref"] = "/missing" + invalidRefs[0] = invalid + if _, err := validateContextJSONPointers(output, invalidRefs); err == nil { + t.Fatal("context-ref oracle accepted a selected missing pointer") + } +} + +func validateContextJSONPointers(output any, contextRefs []any) (int, error) { + checked := 0 + for _, value := range contextRefs { + ref, ok := value.(map[string]any) + if !ok { + return checked, fmt.Errorf("context ref is not an object: %T", value) + } + if kind := ref["kind"]; kind != "json-pointer" { + return checked, fmt.Errorf("context ref %v kind=%v, want json-pointer", ref["refId"], kind) + } + selector, ok := ref["ref"].(string) + if !ok || !strings.HasPrefix(selector, "/") { + return checked, fmt.Errorf("context ref %v has malformed JSON pointer %v", ref["refId"], ref["ref"]) + } + checked++ + if _, err := jsonpointer.Select(output, selector); err != nil { + return checked, fmt.Errorf("context ref %v has dangling selector %s: %w", ref["refId"], selector, err) + } + } + return checked, nil +} + func TestBuildDeduplicatesAndFailsClosedOnInvalidPaths(t *testing.T) { commandcoverage.SemanticRoute(t, "proofkit.command_coverage.source_oracle.v1.020996495977209692976965486603091189537178619363476268911749132800454063351641") result, err := Build(map[string]any{ diff --git a/internal/command/pilotadmission/pilotadmission.go b/internal/command/pilotadmission/pilotadmission.go index f568086..8143502 100644 --- a/internal/command/pilotadmission/pilotadmission.go +++ b/internal/command/pilotadmission/pilotadmission.go @@ -159,7 +159,7 @@ func Build(raw any, options Options) (report.Record, int, error) { if err != nil { return report.Record{}, 1, err } - primaryLanguages, err := sortedTextArray(profile["primaryLanguages"], "proofkit pilot primaryLanguages", false) + primaryLanguages, err := admit.SortedTextArray(profile["primaryLanguages"], "proofkit pilot primaryLanguages", false) if err != nil { return report.Record{}, 1, err } @@ -223,7 +223,7 @@ func Build(raw any, options Options) (report.Record, int, error) { if err != nil { return report.Record{}, 1, err } - nonClaims, err := sortedTextArray(input["nonClaims"], "proofkit pilot nonClaims", false) + nonClaims, err := admit.SortedTextArray(input["nonClaims"], "proofkit pilot nonClaims", false) if err != nil { return report.Record{}, 1, err } @@ -481,7 +481,7 @@ func admitCacheScheduler(raw any, failures *[]string) (admittedCacheScheduler, e } cacheKeyInputs := safePathArray(record["cacheKeyInputs"], "cacheKeyInputs", failures) invalidationInputs := safePathArray(record["invalidationInputs"], "invalidationInputs", failures) - parallelGroups, err := sortedTextArray(record["parallelGroups"], "parallelGroups", false) + parallelGroups, err := admit.SortedTextArray(record["parallelGroups"], "parallelGroups", false) if err != nil { return admittedCacheScheduler{}, err } @@ -975,31 +975,6 @@ func nonEmptyText(raw any, context string) (string, error) { return admit.NonEmptyText(raw, context) } -func sortedTextArray(raw any, context string, allowEmpty bool) ([]string, error) { - values, ok := raw.([]any) - if !ok { - return nil, fmt.Errorf("%s must be an array", context) - } - result := make([]string, 0, len(values)) - for _, value := range values { - text, err := nonEmptyText(value, context) - if err != nil { - return nil, err - } - result = append(result, text) - } - sort.Strings(result) - if !allowEmpty && len(result) == 0 { - return nil, fmt.Errorf("%s must be non-empty", context) - } - for index := 1; index < len(result); index++ { - if result[index-1] == result[index] { - return nil, fmt.Errorf("%s must be sorted and unique", context) - } - } - return result, nil -} - func safePath(raw any, context string) (string, error) { value, err := nonEmptyText(raw, context) if err != nil { diff --git a/internal/command/publicapi/public_api.go b/internal/command/publicapi/public_api.go index 667f197..6104098 100644 --- a/internal/command/publicapi/public_api.go +++ b/internal/command/publicapi/public_api.go @@ -14,42 +14,37 @@ import ( const ( defaultMachineContract = "public_api_surfaces" - defaultPackagesRoot = "packages" - defaultSourceExtension = ".ts" - defaultSourcePrefix = "src/" maxSourceFileBytes = 8 << 20 maxPackageManifestBytes = 256 << 10 maxAggregateScanBytes = 64 << 20 maxManifestEntries = 1024 - maxPackageDirEntries = 1024 ) type Options struct { MachineContract string - PackagesRoot string RepoRoot string - SourceExtension string - SourcePrefix string } type entry struct { - DeniedExportKeys []string - ExportConditions []exportCondition - ExportKey string - PackageName string - RuntimeExports []string - Source string - TypeExports []string + DeniedExportKeys []string + ExportConditions []exportCondition + ExportKey string + PackageManifestPath string + PackageName string + RuntimeExports []string + TypeExports []string } type exportCondition struct { - Condition string - Path string + Condition string + Path string + SourcePath string } type packageSnapshot struct { dir string manifest map[string]any + name string } type sourceExportSnapshot struct { @@ -73,15 +68,6 @@ func verifyWithScanBudget(raw any, options Options, scanBudget int64) (map[strin if options.MachineContract == "" { options.MachineContract = defaultMachineContract } - if options.PackagesRoot == "" { - options.PackagesRoot = defaultPackagesRoot - } - if options.SourceExtension == "" { - options.SourceExtension = defaultSourceExtension - } - if options.SourcePrefix == "" { - options.SourcePrefix = defaultSourcePrefix - } repoRoot, err := filepath.EvalSymlinks(options.RepoRoot) if err != nil { return nil, 1, err @@ -91,7 +77,7 @@ func verifyWithScanBudget(raw any, options Options, scanBudget int64) (map[strin if err != nil { return nil, 1, err } - packages, err := packageDirs(scan, options.PackagesRoot) + packages, err := referencedPackages(scan, manifest) if err != nil { return nil, 1, err } @@ -105,27 +91,40 @@ func verifyWithScanBudget(raw any, options Options, scanBudget int64) (map[strin continue } seenKeys[manifestKey] = struct{}{} - pkg, ok := packages[item.PackageName] + pkg, ok := packages[item.PackageManifestPath] if !ok { - failures = append(failures, "TypeScript public API manifest references missing package "+item.PackageName) + failures = append(failures, "TypeScript public API manifest references missing package manifest "+item.PackageManifestPath) continue } - source, err := safePackageRelativePath(item.Source, manifestKey, options.SourcePrefix, options.SourceExtension) - if err != nil { - return nil, 1, err + if pkg.name != item.PackageName { + failures = append(failures, fmt.Sprintf("%s package manifest name is %s", manifestKey, pkg.name)) + continue } - sourcePath := filepath.Join(pkg.dir, filepath.FromSlash(source)) - actualRuntime, actualTypes, err := scan.collectSourceExports(sourcePath, manifestKey+" source") - if err != nil { - if os.IsNotExist(err) { - failures = append(failures, fmt.Sprintf("%s source does not exist: %s", manifestKey, source)) - continue + actualRuntimeSet := map[string]struct{}{} + actualTypeSet := map[string]struct{}{} + for _, sourcePath := range entrySourcePaths(item) { + resolvedSource, err := resolvePackageSource(scan.repoRoot, pkg.dir, sourcePath, manifestKey+" source") + if err != nil { + if os.IsNotExist(err) { + failures = append(failures, fmt.Sprintf("%s source does not exist: %s", manifestKey, sourcePath)) + continue + } + return nil, 1, err + } + actualRuntime, actualTypes, err := scan.collectSourceExports(resolvedSource, manifestKey+" source") + if err != nil { + return nil, 1, err + } + for _, value := range actualRuntime { + actualRuntimeSet[value] = struct{}{} + } + for _, value := range actualTypes { + actualTypeSet[value] = struct{}{} } - return nil, 1, err } verifyPackageExportMap(pkg, item, &failures) - compareExports(item.RuntimeExports, actualRuntime, manifestKey+" runtime exports", &failures) - compareExports(item.TypeExports, actualTypes, manifestKey+" type exports", &failures) + compareExports(item.RuntimeExports, sortedSet(actualRuntimeSet), manifestKey+" runtime exports", &failures) + compareExports(item.TypeExports, sortedSet(actualTypeSet), manifestKey+" type exports", &failures) } exitCode := 0 if len(failures) > 0 { @@ -138,6 +137,9 @@ func verifyWithScanBudget(raw any, options Options, scanBudget int64) (map[strin "inputAuthority": "caller_manifest_plus_filesystem_snapshot", "nonClaims": []any{ "TypeScript public API verification is a filesystem verifier for a caller-selected checkout.", + "TypeScript source-to-export-condition mappings are caller-owned manifest facts; this command does not prove compiler output provenance.", + "TypeScript public API verification does not parse JSX or admit TSX source files.", + "TypeScript public API verification admits a documented fail-closed export grammar subset; it does not parse unrestricted TypeScript.", "TypeScript public API verification does not claim pure JSON admission or repository freshness beyond the supplied repo root.", }, }, exitCode, nil @@ -180,7 +182,7 @@ func manifestEntry(raw any, context string) (entry, error) { if !ok { return entry{}, fmt.Errorf("%s must be an object", context) } - if err := admit.KnownKeys(record, []string{"deniedExportKeys", "exportConditions", "exportKey", "packageName", "runtimeExports", "source", "typeExports"}, context); err != nil { + if err := admit.KnownKeys(record, []string{"deniedExportKeys", "exportConditions", "exportKey", "packageManifestPath", "packageName", "runtimeExports", "typeExports"}, context); err != nil { return entry{}, err } conditions, err := exportConditions(record["exportConditions"], context+".exportConditions") @@ -203,17 +205,20 @@ func manifestEntry(raw any, context string) (entry, error) { if err != nil { return entry{}, err } - source, err := nonEmptyString(record["source"], context+".source") + packageManifestPath, err := safeRepoPath(record["packageManifestPath"], context+".packageManifestPath") if err != nil { return entry{}, err } + if filepath.Base(filepath.FromSlash(packageManifestPath)) != "package.json" { + return entry{}, fmt.Errorf("%s.packageManifestPath must identify package.json", context) + } exportKey, err := nonEmptyString(record["exportKey"], context+".exportKey") if err != nil { return entry{}, err } return entry{ DeniedExportKeys: denied, ExportConditions: conditions, ExportKey: exportKey, - PackageName: packageName, RuntimeExports: runtimeExports, Source: source, TypeExports: typeExports, + PackageManifestPath: packageManifestPath, PackageName: packageName, RuntimeExports: runtimeExports, TypeExports: typeExports, }, nil } @@ -231,7 +236,7 @@ func exportConditions(raw any, context string) ([]exportCondition, error) { if !ok { return nil, fmt.Errorf("%s[%d] must be an object", context, index) } - if err := admit.KnownKeys(record, []string{"condition", "path"}, fmt.Sprintf("%s[%d]", context, index)); err != nil { + if err := admit.KnownKeys(record, []string{"condition", "path", "sourcePath"}, fmt.Sprintf("%s[%d]", context, index)); err != nil { return nil, err } condition, err := nonEmptyString(record["condition"], fmt.Sprintf("%s[%d].condition", context, index)) @@ -242,7 +247,11 @@ func exportConditions(raw any, context string) ([]exportCondition, error) { if err != nil { return nil, err } - conditions = append(conditions, exportCondition{Condition: condition, Path: path}) + sourcePath, err := safeTypeScriptSourcePath(record["sourcePath"], fmt.Sprintf("%s[%d].sourcePath", context, index)) + if err != nil { + return nil, err + } + conditions = append(conditions, exportCondition{Condition: condition, Path: path, SourcePath: sourcePath}) } if err := assertSortedUnique(exportConditionNames(conditions), context+" conditions"); err != nil { return nil, err @@ -250,45 +259,33 @@ func exportConditions(raw any, context string) ([]exportCondition, error) { return conditions, nil } -func packageDirs(scan *scanCache, packagesRootPath string) (map[string]packageSnapshot, error) { - packagesRoot := filepath.Join(scan.repoRoot, filepath.FromSlash(packagesRootPath)) - if _, err := resolvedPathUnderRoot(scan.repoRoot, packagesRoot, "TypeScript public API packages root"); err != nil { - return nil, err - } - directory, err := os.Open(packagesRoot) - if err != nil { - return nil, err - } - defer directory.Close() - entries, err := directory.ReadDir(maxPackageDirEntries + 1) - if err != nil { - return nil, err - } - if len(entries) > maxPackageDirEntries { - return nil, fmt.Errorf("TypeScript public API packages root exceeds the %d-entry limit", maxPackageDirEntries) - } - byName := map[string]packageSnapshot{} - for _, dirent := range entries { - if !dirent.IsDir() { +func referencedPackages(scan *scanCache, entries []entry) (map[string]packageSnapshot, error) { + byManifest := map[string]packageSnapshot{} + manifestByName := map[string]string{} + for _, item := range entries { + if _, exists := byManifest[item.PackageManifestPath]; exists { continue } - packageDir := filepath.Join(packagesRoot, dirent.Name()) - manifestPath := filepath.Join(packageDir, "package.json") - if _, err := os.Lstat(manifestPath); err != nil { - continue + manifestPath := filepath.Join(scan.repoRoot, filepath.FromSlash(item.PackageManifestPath)) + resolvedManifest, err := resolvedPathUnderRoot(scan.repoRoot, manifestPath, "TypeScript public API package manifest") + if err != nil { + return nil, err } - manifest, err := readPackageManifest(scan, manifestPath) + manifest, err := readPackageManifest(scan, resolvedManifest) if err != nil { return nil, err } - if name, ok := manifest["name"].(string); ok { - if previous, exists := byName[name]; exists { - return nil, fmt.Errorf("duplicate package name %s in %s and %s", name, filepath.ToSlash(previous.dir), filepath.ToSlash(packageDir)) - } - byName[name] = packageSnapshot{dir: packageDir, manifest: manifest} + name, err := nonEmptyString(manifest["name"], item.PackageManifestPath+" name") + if err != nil { + return nil, err + } + if previous, exists := manifestByName[name]; exists && previous != item.PackageManifestPath { + return nil, fmt.Errorf("duplicate referenced package name %s in %s and %s", name, previous, item.PackageManifestPath) } + manifestByName[name] = item.PackageManifestPath + byManifest[item.PackageManifestPath] = packageSnapshot{dir: filepath.Dir(resolvedManifest), manifest: manifest, name: name} } - return byName, nil + return byManifest, nil } func readPackageManifest(scan *scanCache, path string) (map[string]any, error) { @@ -393,19 +390,47 @@ func resolvedPathUnderRoot(repoRoot string, filePath string, context string) (st return resolved, nil } -func safePackageRelativePath(path string, context string, sourcePrefix string, sourceExtension string) (string, error) { - if filepath.IsAbs(path) || - strings.Contains(path, `\`) || - strings.ContainsRune(path, '\x00') || - path == "." || - path == ".." || - strings.HasPrefix(path, "../") || - strings.Contains(path, "/../") || - !strings.HasPrefix(path, sourcePrefix) || - !strings.HasSuffix(path, sourceExtension) { - return "", fmt.Errorf("%s must be a package-relative %s*%s path without traversal", context, sourcePrefix, sourceExtension) +func safeRepoPath(raw any, context string) (string, error) { + value, err := nonEmptyString(raw, context) + if err != nil { + return "", err + } + return admit.SafeRepoRelativePath(value, context) +} + +func safeTypeScriptSourcePath(raw any, context string) (string, error) { + value, err := safeRepoPath(raw, context) + if err != nil { + return "", err + } + if err := requireTypeScriptSourceExtension(value, context); err != nil { + return "", err + } + return value, nil +} + +func requireTypeScriptSourceExtension(path string, context string) error { + switch strings.ToLower(filepath.Ext(filepath.FromSlash(path))) { + case ".ts", ".mts", ".cts": + return nil + default: + return fmt.Errorf("%s must identify a non-JSX TypeScript source (.ts, .mts, or .cts)", context) + } +} + +func resolvePackageSource(repoRoot string, packageDir string, sourcePath string, context string) (string, error) { + resolved, err := resolvedPathUnderRoot(repoRoot, filepath.Join(repoRoot, filepath.FromSlash(sourcePath)), context) + if err != nil { + return "", err + } + relative, err := filepath.Rel(packageDir, resolved) + if err != nil || relative == "." || relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) || filepath.IsAbs(relative) { + return "", fmt.Errorf("%s must resolve under its referenced package manifest directory", context) } - return path, nil + if err := requireTypeScriptSourceExtension(resolved, context+" canonical target"); err != nil { + return "", err + } + return resolved, nil } func verifyPackageExportMap(pkg packageSnapshot, item entry, failures *[]string) { @@ -427,9 +452,6 @@ func verifyPackageExportMap(pkg packageSnapshot, item entry, failures *[]string) if exportEntry[condition.Condition] != condition.Path { *failures = append(*failures, fmt.Sprintf("%s exports[%s].%s must be %s", item.PackageName, item.ExportKey, condition.Condition, condition.Path)) } - if !isAdmittedSourceExportTarget(item.Source, condition) { - *failures = append(*failures, fmt.Sprintf("%s exports[%s].%s target %s must match scanned source %s or its compiled target", item.PackageName, item.ExportKey, condition.Condition, condition.Path, "./"+item.Source)) - } } for _, deniedKey := range item.DeniedExportKeys { deniedValue, ok := exportsField[deniedKey] @@ -439,46 +461,27 @@ func verifyPackageExportMap(pkg packageSnapshot, item entry, failures *[]string) } } -func isAdmittedSourceExportTarget(source string, condition exportCondition) bool { - if condition.Path == "./"+source { - return true - } - compiledTarget, ok := compiledExportTargetForSource(source, condition.Condition) - return ok && condition.Path == compiledTarget -} - -func compiledExportTargetForSource(source string, condition string) (string, bool) { - if !strings.HasPrefix(source, "src/") || !strings.HasSuffix(source, ".ts") { - return "", false - } - stem := strings.TrimSuffix(strings.TrimPrefix(source, "src/"), ".ts") - if condition == "types" { - return "./dist/" + stem + ".d.ts", true - } - return "./dist/" + stem + ".js", true -} - func verifyCoveredPackageExportKeys(packages map[string]packageSnapshot, entries []entry, failures *[]string) { expectedByPackage := map[string]map[string]struct{}{} for _, item := range entries { - keys := expectedByPackage[item.PackageName] + keys := expectedByPackage[item.PackageManifestPath] if keys == nil { keys = map[string]struct{}{} - expectedByPackage[item.PackageName] = keys + expectedByPackage[item.PackageManifestPath] = keys } keys[item.ExportKey] = struct{}{} for _, denied := range item.DeniedExportKeys { keys[denied] = struct{}{} } } - packageNames := make([]string, 0, len(expectedByPackage)) - for packageName := range expectedByPackage { - packageNames = append(packageNames, packageName) + packagePaths := make([]string, 0, len(expectedByPackage)) + for packagePath := range expectedByPackage { + packagePaths = append(packagePaths, packagePath) } - sort.Strings(packageNames) - for _, packageName := range packageNames { - expectedSet := expectedByPackage[packageName] - pkg, ok := packages[packageName] + sort.Strings(packagePaths) + for _, packagePath := range packagePaths { + expectedSet := expectedByPackage[packagePath] + pkg, ok := packages[packagePath] if !ok { continue } @@ -486,8 +489,16 @@ func verifyCoveredPackageExportKeys(packages map[string]packageSnapshot, entries if !ok { continue } - compareExports(sortedSet(expectedSet), sortedKeys(exportsField), packageName+" package.json export keys", failures) + compareExports(sortedSet(expectedSet), sortedKeys(exportsField), pkg.name+" package.json export keys", failures) + } +} + +func entrySourcePaths(item entry) []string { + set := map[string]struct{}{} + for _, condition := range item.ExportConditions { + set[condition.SourcePath] = struct{}{} } + return sortedSet(set) } func compareExports(expected []string, actual []string, label string, failures *[]string) { diff --git a/internal/command/publicapi/public_api_test.go b/internal/command/publicapi/public_api_test.go index f682ab0..ebd5755 100644 --- a/internal/command/publicapi/public_api_test.go +++ b/internal/command/publicapi/public_api_test.go @@ -26,7 +26,7 @@ func TestVerifyTypeScriptPackagePublicAPISurfaces(t *testing.T) { } } -func TestVerifyTypeScriptPackagePublicAPIAcceptsCompiledTargetsForScannedSource(t *testing.T) { +func TestVerifyTypeScriptPackagePublicAPIAcceptsExplicitSourceMappingsForCompiledTargets(t *testing.T) { repoRoot := writeTypeScriptPackageFixture(t) packageRoot := filepath.Join(repoRoot, "packages", "alpha") writeJSON(t, filepath.Join(packageRoot, "package.json"), map[string]any{ @@ -42,8 +42,8 @@ func TestVerifyTypeScriptPackagePublicAPIAcceptsCompiledTargetsForScannedSource( input := publicAPIManifest() entry := input["entries"].([]any)[0].(map[string]any) entry["exportConditions"] = []any{ - map[string]any{"condition": "default", "path": "./dist/index.js"}, - map[string]any{"condition": "types", "path": "./dist/index.d.ts"}, + map[string]any{"condition": "default", "path": "./dist/index.js", "sourcePath": "packages/alpha/src/index.ts"}, + map[string]any{"condition": "types", "path": "./dist/index.d.ts", "sourcePath": "packages/alpha/src/index.ts"}, } output, exitCode, err := Verify(input, Options{RepoRoot: repoRoot}) @@ -71,20 +71,24 @@ func TestVerifyTypeScriptPackagePublicAPIRejectsSecretLikeManifestText(t *testin } } -func TestVerifyTypeScriptPackagePublicAPIRejectsNonTypeScriptSource(t *testing.T) { - repoRoot := writeTypeScriptPackageFixture(t) - input := publicAPIManifest() - entry := input["entries"].([]any)[0].(map[string]any) - entry["source"] = "src/index.go" +func TestVerifyTypeScriptPackagePublicAPIRejectsUnsupportedSourceSyntaxExtensions(t *testing.T) { + for _, sourcePath := range []string{"packages/alpha/src/index.go", "packages/alpha/src/index.tsx"} { + t.Run(filepath.Ext(sourcePath), func(t *testing.T) { + repoRoot := writeTypeScriptPackageFixture(t) + input := publicAPIManifest() + entry := input["entries"].([]any)[0].(map[string]any) + entry["exportConditions"].([]any)[0].(map[string]any)["sourcePath"] = sourcePath - _, exitCode, err := Verify(input, Options{RepoRoot: repoRoot}) - if exitCode != 1 || err == nil || !strings.Contains(err.Error(), "src/*.ts") { - t.Fatalf("expected TypeScript source boundary failure, exitCode=%d err=%v", exitCode, err) + _, exitCode, err := Verify(input, Options{RepoRoot: repoRoot}) + if exitCode != 1 || err == nil || !strings.Contains(err.Error(), "non-JSX TypeScript source") { + t.Fatalf("expected source syntax boundary failure, exitCode=%d err=%v", exitCode, err) + } + }) } } -func TestVerifyTypeScriptPackagePublicAPIRejectsExportTargetDifferentFromScannedSource(t *testing.T) { - commandcoverage.SemanticRoute(t, "proofkit.command_coverage.source_oracle.v1.006943269310710743628834650487878767615578781676283703895362243693196266764140") +func TestVerifyTypeScriptPackagePublicAPIRejectsExportsFromDifferentDeclaredSource(t *testing.T) { + commandcoverage.SemanticRoute(t, "proofkit.command_coverage.source_oracle.v1.045293983925342815526031937349730030851244261191887869042173792304502947213298") repoRoot := writeTypeScriptPackageFixture(t) packageRoot := filepath.Join(repoRoot, "packages", "alpha") if err := os.WriteFile(filepath.Join(packageRoot, "src", "other.ts"), []byte(`export const OTHER = 1;`), 0o600); err != nil { @@ -103,8 +107,8 @@ func TestVerifyTypeScriptPackagePublicAPIRejectsExportTargetDifferentFromScanned input := publicAPIManifest() entry := input["entries"].([]any)[0].(map[string]any) entry["exportConditions"] = []any{ - map[string]any{"condition": "import", "path": "./src/other.ts"}, - map[string]any{"condition": "types", "path": "./src/other.ts"}, + map[string]any{"condition": "import", "path": "./src/other.ts", "sourcePath": "packages/alpha/src/other.ts"}, + map[string]any{"condition": "types", "path": "./src/other.ts", "sourcePath": "packages/alpha/src/other.ts"}, } output, exitCode, err := Verify(input, Options{RepoRoot: repoRoot}) @@ -114,8 +118,8 @@ func TestVerifyTypeScriptPackagePublicAPIRejectsExportTargetDifferentFromScanned if exitCode != 1 { t.Fatalf("Verify() exitCode=%d output=%#v, want target/source failure", exitCode, output) } - if !strings.Contains(fmt.Sprint(output["failures"]), "must match scanned source") { - t.Fatalf("failures=%#v, want scanned source mismatch", output["failures"]) + if !strings.Contains(fmt.Sprint(output["failures"]), "runtime exports drift") { + t.Fatalf("failures=%#v, want declared source export mismatch", output["failures"]) } } @@ -154,6 +158,30 @@ func TestVerifyTypeScriptPackagePublicAPIRejectsSymlinkEscapedSource(t *testing. } } +func TestVerifyTypeScriptPackagePublicAPIRejectsSymlinkToTSX(t *testing.T) { + repoRoot := writeTypeScriptPackageFixture(t) + sourcePath := filepath.Join(repoRoot, "packages", "alpha", "src", "index.ts") + tsxPath := filepath.Join(repoRoot, "packages", "alpha", "src", "index.tsx") + content, err := os.ReadFile(sourcePath) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(tsxPath, content, 0o600); err != nil { + t.Fatal(err) + } + if err := os.Remove(sourcePath); err != nil { + t.Fatal(err) + } + if err := os.Symlink(tsxPath, sourcePath); err != nil { + t.Skipf("symlink unavailable: %v", err) + } + + _, exitCode, err := Verify(publicAPIManifest(), Options{RepoRoot: repoRoot}) + if exitCode != 1 || err == nil || !strings.Contains(err.Error(), "non-JSX TypeScript source") { + t.Fatalf("Verify() exitCode=%d error=%v, want canonical TSX target rejection", exitCode, err) + } +} + func TestVerifyTypeScriptPackagePublicAPIRejectsAmbiguousPackageManifest(t *testing.T) { cases := []struct { name string @@ -191,8 +219,15 @@ func TestVerifyTypeScriptPackagePublicAPIRejectsDuplicatePackageIdentity(t *test "exports": map[string]any{}, }) - _, exitCode, err := Verify(publicAPIManifest(), Options{RepoRoot: repoRoot}) - if exitCode != 1 || err == nil || !strings.Contains(err.Error(), "duplicate package name @example/alpha") { + input := publicAPIManifest() + second := map[string]any{} + for key, value := range input["entries"].([]any)[0].(map[string]any) { + second[key] = value + } + second["packageManifestPath"] = "packages/beta/package.json" + input["entries"] = append(input["entries"].([]any), second) + _, exitCode, err := Verify(input, Options{RepoRoot: repoRoot}) + if exitCode != 1 || err == nil || !strings.Contains(err.Error(), "duplicate referenced package name @example/alpha") { t.Fatalf("Verify() exitCode=%d error=%v, want duplicate package identity", exitCode, err) } } @@ -237,30 +272,107 @@ func TestCollectExportsClassifiesInlineTypeReexports(t *testing.T) { func TestCollectExportsDoesNotInventExportsFromCommaBearingInitializers(t *testing.T) { source := strings.Join([]string{ - "export const a = {x: 1, b: 2}, c: Array = [\"x\", \"y\"];", - "export let d = makeValue<{left: string, right: string}>();", - "export const genericCall = make();", - "export const directMatcher = /,/, matcherWithClass = /[a,b]/;", - "export const isSmall = limit < 10, fallback = 1;", + "export const a = {x: 1, b: 2}, c = [\"x\", \"y\"];", + "export let d = makeValue({left: \"x\", right: \"y\"});", + "export const ordinaryCall = make(\"x\", \"y\");", + "export const isSmall = (limit < 10), fallback = 1;", }, "\n") runtimeExports, typeExports, err := CollectExports(source) if err != nil { t.Fatalf("collect exports: %v", err) } - assertStringSlice(t, runtimeExports, []string{"a", "c", "d", "directMatcher", "fallback", "genericCall", "isSmall", "matcherWithClass"}) + assertStringSlice(t, runtimeExports, []string{"a", "c", "d", "fallback", "isSmall", "ordinaryCall"}) assertStringSlice(t, typeExports, []string{}) } -func TestCollectExportsAcceptsRegexLiteralReturnedByArrowInitializer(t *testing.T) { - runtimeExports, typeExports, err := CollectExports("export const matcher = () => /,/, next = 1;") +func TestCollectExportsRejectsRegexLiteralReturnedByArrowInitializer(t *testing.T) { + _, _, err := CollectExports("export const matcher = () => /,/, next = 1;") + if err == nil || !strings.Contains(err.Error(), "slash tokens outside comments") { + t.Fatalf("CollectExports() error=%v, want fail-closed regex rejection", err) + } +} + +func TestCollectExportsFindsMultipleTopLevelExportsOnOneLine(t *testing.T) { + runtimeExports, typeExports, err := CollectExports("export const A = 1; export const B = 2;") if err != nil { - t.Fatalf("collect arrow regex initializer exports: %v", err) + t.Fatalf("CollectExports() error = %v", err) } - assertStringSlice(t, runtimeExports, []string{"matcher", "next"}) + assertStringSlice(t, runtimeExports, []string{"A", "B"}) assertStringSlice(t, typeExports, []string{}) } +func TestCollectExportsRecognizesAllLineCommentTerminators(t *testing.T) { + for _, source := range []string{ + "// comment terminated by CR\rexport const Public = 3;", + "// comment terminated by LS\u2028export const Public = 3;", + "// comment terminated by PS\u2029export const Public = 3;", + } { + runtimeExports, typeExports, err := CollectExports(source) + if err != nil { + t.Fatalf("CollectExports() error = %v", err) + } + assertStringSlice(t, runtimeExports, []string{"Public"}) + assertStringSlice(t, typeExports, []string{}) + } +} + +func TestCollectExportsPreservesStatementOffsetsAcrossMaskedLiterals(t *testing.T) { + source := "export const FIRST = \"\u00e9; } export\"; export const SECOND = `; } export`; export type Third = { value: string };" + runtimeExports, typeExports, err := CollectExports(source) + if err != nil { + t.Fatalf("CollectExports() error = %v", err) + } + assertStringSlice(t, runtimeExports, []string{"FIRST", "SECOND"}) + assertStringSlice(t, typeExports, []string{"Third"}) +} + +func TestVerifyTypeScriptPackagePublicAPIFailsClosedOnAmbiguousSourceGrammar(t *testing.T) { + repoRoot := writeTypeScriptPackageFixture(t) + sourcePath := filepath.Join(repoRoot, "packages", "alpha", "src", "index.ts") + if err := os.WriteFile(sourcePath, []byte("const ratio = 1 / /\\{/.test(\"{\") ? 1 : 2;\nexport const Public = 3;\n"), 0o600); err != nil { + t.Fatal(err) + } + input := publicAPIManifest() + entry := input["entries"].([]any)[0].(map[string]any) + entry["runtimeExports"] = []any{} + entry["typeExports"] = []any{} + + _, exitCode, err := Verify(input, Options{RepoRoot: repoRoot}) + if exitCode != 1 || err == nil || !strings.Contains(err.Error(), "unsupported TypeScript public API source grammar") { + t.Fatalf("Verify() exitCode=%d error=%v, want fail-closed grammar rejection", exitCode, err) + } +} + +func TestCollectExportsRejectsLexicallyAmbiguousOrOutOfGrammarSources(t *testing.T) { + tests := []struct { + name string + source string + }{ + {name: "division and regex", source: "const ratio = 1 / /\\{/.test(\"{\") ? 1 : 2; export const Public = 3;"}, + {name: "unicode code identifier", source: "const \u03c0 = 1; export const Public = 3;"}, + {name: "template interpolation", source: "const value = `prefix ${1}`; export const Public = 3;"}, + {name: "escaped code identifier", source: "const \\u0061 = 1; export const Public = 3;"}, + {name: "top-level angle syntax", source: "export const values: Array = [];"}, + {name: "unterminated block comment", source: "/* hidden export const Ghost = 1;"}, + {name: "unbalanced delimiter", source: "if (true) { export const Nested = 1;"}, + } + for _, item := range tests { + t.Run(item.name, func(t *testing.T) { + if _, _, err := CollectExports(item.source); err == nil || !strings.Contains(err.Error(), "unsupported TypeScript public API source grammar") { + t.Fatalf("CollectExports() error=%v, want fail-closed grammar rejection", err) + } + }) + } +} + +func TestCollectExportsRejectsNamedReexportAliasedAsDefault(t *testing.T) { + _, _, err := CollectExports(`export { PublicThing as default } from "./thing.js";`) + if err == nil || !strings.Contains(err.Error(), "default alias") { + t.Fatalf("CollectExports() error=%v, want default alias rejection", err) + } +} + func TestCollectExportsIgnoresCommentsStringsAndTemplates(t *testing.T) { source := strings.Join([]string{ "/* export const BLOCK_GHOST = 1; */", @@ -325,15 +437,15 @@ func TestVerifyTypeScriptPackagePublicAPICachesRepeatedCanonicalSources(t *testi }) input := publicAPIManifest() input["entries"] = append(input["entries"].([]any), map[string]any{ - "packageName": "@example/alpha", - "exportKey": "./secondary", - "source": "src/index.ts", - "runtimeExports": []any{"VALUE", "makeThing"}, - "typeExports": []any{"Mode", "Thing"}, - "deniedExportKeys": []any{"./internal"}, + "packageName": "@example/alpha", + "packageManifestPath": "packages/alpha/package.json", + "exportKey": "./secondary", + "runtimeExports": []any{"VALUE", "makeThing"}, + "typeExports": []any{"Mode", "Thing"}, + "deniedExportKeys": []any{"./internal"}, "exportConditions": []any{ - map[string]any{"condition": "import", "path": "./src/index.ts"}, - map[string]any{"condition": "types", "path": "./src/index.ts"}, + map[string]any{"condition": "import", "path": "./src/index.ts", "sourcePath": "packages/alpha/src/index.ts"}, + map[string]any{"condition": "types", "path": "./src/index.ts", "sourcePath": "packages/alpha/src/index.ts"}, }, }) manifestInfo, err := os.Stat(manifestPath) @@ -405,23 +517,34 @@ func TestPublicAPIAdmissionRejectsAggregateResourceBudgets(t *testing.T) { if _, err := admitManifest(manifest, defaultMachineContract); err == nil || !strings.Contains(err.Error(), "entry limit") { t.Fatalf("admitManifest() error=%v, want aggregate entry limit", err) } +} +func TestVerifyEmptyManifestDoesNotRequireAConventionalPackagesDirectory(t *testing.T) { repoRoot := t.TempDir() - packagesRoot := filepath.Join(repoRoot, "packages") - if err := os.MkdirAll(packagesRoot, 0o755); err != nil { - t.Fatal(err) + input := map[string]any{ + "schemaVersion": json.Number("1"), + "machineContract": defaultMachineContract, + "entries": []any{}, } - for index := 0; index <= maxPackageDirEntries; index++ { - if err := os.WriteFile(filepath.Join(packagesRoot, fmt.Sprintf("entry-%04d", index)), nil, 0o600); err != nil { - t.Fatal(err) - } + output, exitCode, err := Verify(input, Options{RepoRoot: repoRoot}) + if err != nil || exitCode != 0 || output["entryCount"] != 0 { + t.Fatalf("Verify(empty) output=%#v exitCode=%d error=%v", output, exitCode, err) } - resolvedRoot, err := filepath.EvalSymlinks(repoRoot) - if err != nil { +} + +func TestVerifyCollectsMultipleSameLineExportsThroughFilesystemBoundary(t *testing.T) { + repoRoot := writeTypeScriptPackageFixture(t) + sourcePath := filepath.Join(repoRoot, "packages", "alpha", "src", "index.ts") + if err := os.WriteFile(sourcePath, []byte("export const A = 1; export const B = 2;"), 0o600); err != nil { t.Fatal(err) } - if _, err := packageDirs(newScanCache(resolvedRoot, maxAggregateScanBytes), "packages"); err == nil || !strings.Contains(err.Error(), "entry limit") { - t.Fatalf("packageDirs() error=%v, want bounded directory admission", err) + input := publicAPIManifest() + entry := input["entries"].([]any)[0].(map[string]any) + entry["runtimeExports"] = []any{"A", "B"} + entry["typeExports"] = []any{} + output, exitCode, err := Verify(input, Options{RepoRoot: repoRoot}) + if err != nil || exitCode != 0 { + t.Fatalf("Verify() output=%#v exitCode=%d error=%v", output, exitCode, err) } } @@ -459,15 +582,15 @@ func publicAPIManifest() map[string]any { "machineContract": "public_api_surfaces", "entries": []any{ map[string]any{ - "packageName": "@example/alpha", - "exportKey": ".", - "source": "src/index.ts", - "runtimeExports": []any{"VALUE", "makeThing"}, - "typeExports": []any{"Mode", "Thing"}, - "deniedExportKeys": []any{"./internal"}, + "packageName": "@example/alpha", + "packageManifestPath": "packages/alpha/package.json", + "exportKey": ".", + "runtimeExports": []any{"VALUE", "makeThing"}, + "typeExports": []any{"Mode", "Thing"}, + "deniedExportKeys": []any{"./internal"}, "exportConditions": []any{ - map[string]any{"condition": "import", "path": "./src/index.ts"}, - map[string]any{"condition": "types", "path": "./src/index.ts"}, + map[string]any{"condition": "import", "path": "./src/index.ts", "sourcePath": "packages/alpha/src/index.ts"}, + map[string]any{"condition": "types", "path": "./src/index.ts", "sourcePath": "packages/alpha/src/index.ts"}, }, }, }, diff --git a/internal/command/publicapi/typescript_exports.go b/internal/command/publicapi/typescript_exports.go index 3900344..36f76e0 100644 --- a/internal/command/publicapi/typescript_exports.go +++ b/internal/command/publicapi/typescript_exports.go @@ -19,7 +19,11 @@ var ( func CollectExports(source string) ([]string, []string, error) { runtimeExports := map[string]struct{}{} typeExports := map[string]struct{}{} - for _, statement := range exportStatements(maskTypeScriptNonCode(source)) { + statements, err := exportStatements(source) + if err != nil { + return nil, nil, err + } + for _, statement := range statements { if strings.HasPrefix(statement, "export *") { return nil, nil, fmt.Errorf("TypeScript public API entrypoints must not use export *") } @@ -30,11 +34,15 @@ func CollectExports(source string) ([]string, []string, error) { return nil, nil, fmt.Errorf("TypeScript public API entrypoints must not use ambient declare exports") } if match := typeExportPattern.FindStringSubmatch(statement); match != nil { - addTypeClauseExports(match[1], typeExports) + if err := addTypeClauseExports(match[1], typeExports); err != nil { + return nil, nil, err + } continue } if match := namedExportPattern.FindStringSubmatch(statement); match != nil { - addNamedClauseExports(match[1], runtimeExports, typeExports) + if err := addNamedClauseExports(match[1], runtimeExports, typeExports); err != nil { + return nil, nil, err + } continue } if match := runtimeDeclPattern.FindStringSubmatch(statement); match != nil { @@ -60,133 +68,262 @@ func CollectExports(source string) ([]string, []string, error) { return sortedSet(runtimeExports), sortedSet(typeExports), nil } -func maskTypeScriptNonCode(source string) string { - const ( - code = iota - lineComment - blockComment - singleQuoted - doubleQuoted - templateQuoted - ) - runes := []rune(source) - state := code +type typeScriptLexicalState uint8 + +const ( + typeScriptCode typeScriptLexicalState = iota + typeScriptLineComment + typeScriptBlockComment + typeScriptSingleQuoted + typeScriptDoubleQuoted + typeScriptTemplateQuoted +) + +type typeScriptLexicalScan struct { + masked string + topLevelExportOffsets []int +} + +func exportStatements(source string) ([]string, error) { + scan, err := scanTypeScriptSource(source) + if err != nil { + return nil, err + } + statements := make([]string, 0, len(scan.topLevelExportOffsets)) + for index, start := range scan.topLevelExportOffsets { + limit := len(source) + if index+1 < len(scan.topLevelExportOffsets) { + limit = scan.topLevelExportOffsets[index+1] + } + end := exportStatementEnd(scan.masked, start, limit) + statement := strings.Join(strings.Fields(scan.masked[start:end]), " ") + if statement != "" { + statements = append(statements, statement) + } + } + return statements, nil +} + +func scanTypeScriptSource(source string) (typeScriptLexicalScan, error) { + masked := []byte(source) + starts := make([]int, 0) + state := typeScriptCode escaped := false - maskQuotedContent := false - for index := 0; index < len(runes); index++ { - current := runes[index] - next := rune(0) - if index+1 < len(runes) { - next = runes[index+1] + braceDepth := 0 + bracketDepth := 0 + parenDepth := 0 + for index := 0; index < len(source); index++ { + current := source[index] + next := byte(0) + if index+1 < len(source) { + next = source[index+1] } switch state { - case code: - switch { - case current == '/' && next == '/': - runes[index], runes[index+1] = ' ', ' ' - index++ - state = lineComment - case current == '/' && next == '*': - runes[index], runes[index+1] = ' ', ' ' - index++ - state = blockComment - case current == '\'': - maskQuotedContent = false - state = singleQuoted - case current == '"': - maskQuotedContent = false - state = doubleQuoted - case current == '`': - runes[index] = ' ' - state = templateQuoted + case typeScriptLineComment: + if width := unicodeLineTerminatorWidth(source, index); width > 0 { + state = typeScriptCode + index += width - 1 + continue } - case lineComment: - if current == '\n' { - state = code + if current == '\n' || current == '\r' { + state = typeScriptCode } else { - runes[index] = ' ' + masked[index] = ' ' } - case blockComment: + continue + case typeScriptBlockComment: if current == '*' && next == '/' { - runes[index], runes[index+1] = ' ', ' ' + masked[index], masked[index+1] = ' ', ' ' + state = typeScriptCode index++ - state = code } else if current != '\n' { - runes[index] = ' ' + masked[index] = ' ' } - case singleQuoted, doubleQuoted, templateQuoted: - if current == '\n' && state != templateQuoted { - if escaped { + continue + case typeScriptSingleQuoted, typeScriptDoubleQuoted: + closing := byte('\'') + if state == typeScriptDoubleQuoted { + closing = '"' + } + unicodeLineWidth := unicodeLineTerminatorWidth(source, index) + if (current == '\n' || current == '\r' || unicodeLineWidth > 0) && !escaped { + return typeScriptLexicalScan{}, unsupportedTypeScriptSourceGrammar("quoted strings must terminate before an unescaped newline") + } + if escaped { + if unicodeLineWidth > 0 { escaped = false - maskQuotedContent = true + index += unicodeLineWidth - 1 continue } - state = code - continue - } - closing := rune('\'') - if state == doubleQuoted { - closing = '"' - } else if state == templateQuoted { - closing = '`' + if current == '\r' && next == '\n' { + escaped = false + index++ + continue + } + if current != '\n' && current != '\r' { + masked[index] = ' ' + } + escaped = false + } else if current == '\\' { + masked[index] = ' ' + escaped = true + } else if current == closing { + state = typeScriptCode + } else if current != '\n' { + masked[index] = ' ' } - if current != '\n' && (state == templateQuoted || maskQuotedContent) { - runes[index] = ' ' + continue + case typeScriptTemplateQuoted: + if current != '\n' { + masked[index] = ' ' } if escaped { escaped = false } else if current == '\\' { escaped = true - } else if current == closing { - state = code - maskQuotedContent = false + } else if current == '$' && next == '{' { + return typeScriptLexicalScan{}, unsupportedTypeScriptSourceGrammar("template interpolation is not admitted") + } else if current == '`' { + state = typeScriptCode } + continue + } + if current >= 0x80 { + return typeScriptLexicalScan{}, unsupportedTypeScriptSourceGrammar("code tokens must use direct ASCII identifiers") + } + switch { + case current == '/' && next == '/': + masked[index], masked[index+1] = ' ', ' ' + state = typeScriptLineComment + index++ + continue + case current == '/' && next == '*': + masked[index], masked[index+1] = ' ', ' ' + state = typeScriptBlockComment + index++ + continue + case current == '/': + return typeScriptLexicalScan{}, unsupportedTypeScriptSourceGrammar("slash tokens outside comments are not admitted") + case current == '\'': + state = typeScriptSingleQuoted + continue + case current == '"': + state = typeScriptDoubleQuoted + continue + case current == '`': + masked[index] = ' ' + state = typeScriptTemplateQuoted + continue + case current == '\\': + return typeScriptLexicalScan{}, unsupportedTypeScriptSourceGrammar("escaped code identifiers are not admitted") + } + if braceDepth == 0 && bracketDepth == 0 && parenDepth == 0 && strings.HasPrefix(source[index:], "export") { + beforeOK := index == 0 || !isASCIITypeScriptIdentifierByte(source[index-1]) && source[index-1] != '.' + after := index + len("export") + afterOK := after == len(source) || !isASCIITypeScriptIdentifierByte(source[after]) + if beforeOK && afterOK { + starts = append(starts, index) + } + } + switch current { + case '{': + braceDepth++ + case '}': + if braceDepth == 0 { + return typeScriptLexicalScan{}, unsupportedTypeScriptSourceGrammar("closing brace has no matching opener") + } + braceDepth-- + case '[': + bracketDepth++ + case ']': + if bracketDepth == 0 { + return typeScriptLexicalScan{}, unsupportedTypeScriptSourceGrammar("closing bracket has no matching opener") + } + bracketDepth-- + case '(': + parenDepth++ + case ')': + if parenDepth == 0 { + return typeScriptLexicalScan{}, unsupportedTypeScriptSourceGrammar("closing parenthesis has no matching opener") + } + parenDepth-- } } - return string(runes) + switch state { + case typeScriptCode, typeScriptLineComment: + case typeScriptBlockComment: + return typeScriptLexicalScan{}, unsupportedTypeScriptSourceGrammar("block comments must terminate") + case typeScriptSingleQuoted, typeScriptDoubleQuoted: + return typeScriptLexicalScan{}, unsupportedTypeScriptSourceGrammar("quoted strings must terminate") + case typeScriptTemplateQuoted: + return typeScriptLexicalScan{}, unsupportedTypeScriptSourceGrammar("template literals must terminate") + } + if braceDepth != 0 || bracketDepth != 0 || parenDepth != 0 { + return typeScriptLexicalScan{}, unsupportedTypeScriptSourceGrammar("delimiters must be balanced") + } + return typeScriptLexicalScan{masked: string(masked), topLevelExportOffsets: starts}, nil } -func exportStatements(source string) []string { - statements := []string{} - pending := []string{} - for _, rawLine := range strings.Split(source, "\n") { - line := strings.TrimSpace(rawLine) - if len(pending) > 0 { - pending = append(pending, line) - if strings.Contains(line, ";") { - statements = append(statements, strings.Join(pending, " ")) - pending = nil +func exportStatementEnd(masked string, start int, limit int) int { + parenDepth := 0 + bracketDepth := 0 + braceDepth := 0 + for index := start; index < limit; index++ { + switch masked[index] { + case '(': + parenDepth++ + case ')': + if parenDepth > 0 { + parenDepth-- + } + case '[': + bracketDepth++ + case ']': + if bracketDepth > 0 { + bracketDepth-- + } + case '{': + braceDepth++ + case '}': + if braceDepth > 0 { + braceDepth-- + } + case ';': + if parenDepth == 0 && bracketDepth == 0 && braceDepth == 0 { + return index + 1 } - continue - } - if !strings.HasPrefix(line, "export ") && line != "export" { - continue - } - if startsMultilineReexport(line) && !strings.Contains(line, ";") { - pending = append(pending, line) - continue } - statements = append(statements, line) } - if len(pending) > 0 { - statements = append(statements, strings.Join(pending, " ")) + return limit +} + +func isASCIITypeScriptIdentifierByte(value byte) bool { + return value == '_' || value == '$' || value >= 'a' && value <= 'z' || value >= 'A' && value <= 'Z' || value >= '0' && value <= '9' +} + +func unicodeLineTerminatorWidth(source string, index int) int { + if index+2 >= len(source) || source[index] != 0xe2 || source[index+1] != 0x80 { + return 0 + } + if source[index+2] == 0xa8 || source[index+2] == 0xa9 { + return 3 } - return statements + return 0 } -func startsMultilineReexport(line string) bool { - return strings.HasPrefix(line, "export {") || strings.HasPrefix(line, "export type {") +func unsupportedTypeScriptSourceGrammar(reason string) error { + return fmt.Errorf("unsupported TypeScript public API source grammar: %s", reason) } -func addTypeClauseExports(clause string, target map[string]struct{}) { - addClauseExports(clause, nil, target, true) +func addTypeClauseExports(clause string, target map[string]struct{}) error { + return addClauseExports(clause, nil, target, true) } -func addNamedClauseExports(clause string, runtimeTarget map[string]struct{}, typeTarget map[string]struct{}) { - addClauseExports(clause, runtimeTarget, typeTarget, false) +func addNamedClauseExports(clause string, runtimeTarget map[string]struct{}, typeTarget map[string]struct{}) error { + return addClauseExports(clause, runtimeTarget, typeTarget, false) } -func addClauseExports(clause string, runtimeTarget map[string]struct{}, typeTarget map[string]struct{}, typeClause bool) { +func addClauseExports(clause string, runtimeTarget map[string]struct{}, typeTarget map[string]struct{}, typeClause bool) error { body := strings.TrimSuffix(strings.TrimPrefix(strings.TrimSpace(clause), "{"), "}") for _, rawPart := range strings.Split(body, ",") { part := strings.TrimSpace(rawPart) @@ -202,12 +339,19 @@ func addClauseExports(clause string, runtimeTarget map[string]struct{}, typeTarg if match := exportClauseNameRegex.FindStringSubmatch(part); match != nil { name = match[1] } + if name == "default" { + return fmt.Errorf("TypeScript public API entrypoints must not export a default alias") + } + if !identifierRegex.MatchString(name) { + return fmt.Errorf("TypeScript public API re-exports must use identifier names") + } if typeOnly { typeTarget[name] = struct{}{} continue } runtimeTarget[name] = struct{}{} } + return nil } func isInlineTypeOnlyReexport(part string) bool { @@ -246,52 +390,8 @@ func splitTopLevelComma(value string) ([]string, error) { parenDepth := 0 bracketDepth := 0 braceDepth := 0 - angleDepth := 0 - var quote rune - escaped := false - inRegex := false - inRegexClass := false - inInitializer := false for index, char := range value { - if inRegex { - if escaped { - escaped = false - continue - } - if char == '\\' { - escaped = true - continue - } - if char == '[' { - inRegexClass = true - continue - } - if char == ']' && inRegexClass { - inRegexClass = false - continue - } - if char == '/' && !inRegexClass { - inRegex = false - } - continue - } - if quote != 0 { - if escaped { - escaped = false - continue - } - if char == '\\' { - escaped = true - continue - } - if char == quote { - quote = 0 - } - continue - } switch char { - case '\'', '"', '`': - quote = char case '(': parenDepth++ case ')': @@ -310,102 +410,19 @@ func splitTopLevelComma(value string) ([]string, error) { if braceDepth > 0 { braceDepth-- } - case '=': - if parenDepth == 0 && bracketDepth == 0 && braceDepth == 0 && angleDepth == 0 { - inInitializer = true - } - case '<': - if parenDepth == 0 && bracketDepth == 0 && braceDepth == 0 && (!inInitializer || looksLikeTypeArgumentStart(value, index)) { - angleDepth++ - } - case '>': - if angleDepth > 0 { - angleDepth-- - } - case '/': - if inInitializer && looksLikeRegexLiteralStart(value, index) { - inRegex = true - inRegexClass = false + case '<', '>': + if parenDepth == 0 && bracketDepth == 0 && braceDepth == 0 { + if char != '>' || index == 0 || value[index-1] != '=' { + return nil, unsupportedTypeScriptSourceGrammar("top-level angle-bracket syntax in variable exports is not admitted") + } } case ',': - if parenDepth == 0 && bracketDepth == 0 && braceDepth == 0 && angleDepth == 0 { + if parenDepth == 0 && bracketDepth == 0 && braceDepth == 0 { parts = append(parts, value[start:index]) start = index + len(string(char)) - inInitializer = false } } } - if quote != 0 || inRegex || inRegexClass { - return nil, fmt.Errorf("TypeScript public API variable exports must not contain unterminated literals") - } parts = append(parts, value[start:]) return parts, nil } - -func looksLikeRegexLiteralStart(value string, start int) bool { - for index := start - 1; index >= 0; index-- { - switch value[index] { - case ' ', '\t', '\n', '\r': - continue - case '=', '(', '[', '{', ',', ':', ';', '!', '&', '|', '?': - return true - case '>': - return index > 0 && value[index-1] == '=' - default: - return false - } - } - return true -} - -func looksLikeTypeArgumentStart(value string, start int) bool { - depth := 0 - var quote rune - escaped := false - for index, char := range value[start:] { - absolute := start + index - if quote != 0 { - if escaped { - escaped = false - continue - } - if char == '\\' { - escaped = true - continue - } - if char == quote { - quote = 0 - } - continue - } - switch char { - case '\'', '"', '`': - quote = char - case '<': - depth++ - case '>': - if depth == 0 { - return false - } - depth-- - if depth == 0 { - next := nextNonSpaceRune(value[absolute+len(string(char)):]) - return next == '(' || next == '[' || next == '.' || next == ',' || next == ';' - } - case ';': - if depth == 0 { - return false - } - } - } - return false -} - -func nextNonSpaceRune(value string) rune { - for _, char := range value { - if char != ' ' && char != '\t' && char != '\n' && char != '\r' { - return char - } - } - return 0 -} diff --git a/internal/command/requirementbinding/projections_test.go b/internal/command/requirementbinding/projections_test.go index 6130755..d73d628 100644 --- a/internal/command/requirementbinding/projections_test.go +++ b/internal/command/requirementbinding/projections_test.go @@ -2,13 +2,75 @@ package requirementbinding import ( "encoding/json" + "os" + "path/filepath" + "reflect" "strings" "testing" + "github.com/research-engineering/agentic-proofkit/internal/kernel/admission" "github.com/research-engineering/agentic-proofkit/internal/kernel/report" "github.com/research-engineering/agentic-proofkit/internal/testsupport/commandcoverage" ) +func TestSelfHostingWitnessCommandIdentityMatchesWitnessPlan(t *testing.T) { + repositoryRoot := filepath.Join("..", "..", "..") + bindingRaw := readOwnerJSON(t, filepath.Join(repositoryRoot, "proofkit", "requirement-bindings.json")) + result, err := Build(bindingRaw) + if err != nil { + t.Fatalf("Build(requirement-bindings.json) error=%v", err) + } + if result.Record.State != "passed" { + t.Fatalf("requirement-bindings.json state=%s, want passed", result.Record.State) + } + + witnessPlan := readOwnerJSON(t, filepath.Join(repositoryRoot, "proofkit", "witness-plan.json")).(map[string]any) + planCommands := map[string]map[string]any{} + for _, rawCommand := range witnessPlan["commands"].([]any) { + command := rawCommand.(map[string]any) + commandID := command["id"].(string) + if _, exists := planCommands[commandID]; exists { + t.Fatalf("witness-plan.json repeats command id %s", commandID) + } + planCommands[commandID] = command + } + if len(planCommands) != len(result.Input.WitnessCommands) { + t.Fatalf("witness command count binding=%d plan=%d", len(result.Input.WitnessCommands), len(planCommands)) + } + for _, bindingCommand := range result.Input.WitnessCommands { + planCommand, ok := planCommands[bindingCommand.CommandID] + if !ok { + t.Errorf("binding witness command %s is absent from witness-plan.json", bindingCommand.CommandID) + continue + } + argv, err := displayCommandArgv(bindingCommand.Command) + if err != nil { + t.Fatalf("parse binding command %s: %v", bindingCommand.CommandID, err) + } + if got := stringValues(planCommand["argv"].([]any)); !reflect.DeepEqual(got, argv) { + t.Errorf("command %s argv=%q, want %q", bindingCommand.CommandID, got, argv) + } + environment := planCommand["environment"].(map[string]any) + if got := stringValues(environment["classes"].([]any)); !reflect.DeepEqual(got, bindingCommand.EnvironmentClasses) { + t.Errorf("command %s environment classes=%q, want %q", bindingCommand.CommandID, got, bindingCommand.EnvironmentClasses) + } + } +} + +func readOwnerJSON(t *testing.T, path string) any { + t.Helper() + file, err := os.Open(path) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = file.Close() }) + value, err := admission.DecodeJSON(file, 8<<20) + if err != nil { + t.Fatal(err) + } + return value +} + func TestBuildReportFailsUnknownRequirementBinding(t *testing.T) { commandcoverage.SemanticRoute(t, "proofkit.command_coverage.source_oracle.v1.000376648692213538787264617164821843651034132903736657977168045531003514534050") input := validRequirementBindingInput() diff --git a/internal/command/requirementbrowser/server.go b/internal/command/requirementbrowser/server.go index bd4f804..0633767 100644 --- a/internal/command/requirementbrowser/server.go +++ b/internal/command/requirementbrowser/server.go @@ -16,7 +16,12 @@ import ( "github.com/research-engineering/agentic-proofkit/internal/kernel/stablejson" ) -const serverShutdownTimeout = 5 * time.Second +const ( + serverIdleTimeout = 30 * time.Second + serverReadHeaderTimeout = 5 * time.Second + serverShutdownTimeout = 5 * time.Second + serverWriteTimeout = 15 * time.Second +) type ServerHandle struct { Host string @@ -64,8 +69,12 @@ func StartServer(raw any, options Options) (ServerHandle, error) { _ = listener.Close() return ServerHandle{}, err } + expectedAuthority := net.JoinHostPort(options.Host, strconv.Itoa(actualPort)) server := &http.Server{ - Handler: browserHandler(options.View, rendered), + Handler: browserHandler(options.View, rendered, expectedAuthority), + IdleTimeout: serverIdleTimeout, + ReadHeaderTimeout: serverReadHeaderTimeout, + WriteTimeout: serverWriteTimeout, } done := make(chan error, 1) go func() { @@ -81,7 +90,11 @@ func StartServer(raw any, options Options) (ServerHandle, error) { Port: actualPort, URL: browserURL(options.Host, actualPort), close: func(ctx context.Context) error { - return server.Shutdown(ctx) + shutdownErr := server.Shutdown(ctx) + if shutdownErr == nil { + return nil + } + return errors.Join(shutdownErr, server.Close()) }, done: done, }, nil @@ -92,12 +105,7 @@ func Serve(ctx context.Context, raw any, options Options, stdout io.Writer) erro if err != nil { return err } - closed := false - defer func() { - if !closed { - _ = closeHandle(handle) - } - }() + defer func() { _ = closeHandle(handle) }() if options.Open { if err := openBrowser(ctx, handle.URL); err != nil { return err @@ -108,21 +116,30 @@ func Serve(ctx context.Context, raw any, options Options, stdout io.Writer) erro } select { case <-ctx.Done(): - closed = true - shutdownCtx, cancel := context.WithTimeout(context.Background(), serverShutdownTimeout) + closeErr := closeHandle(handle) + waitCtx, cancel := context.WithTimeout(context.Background(), serverShutdownTimeout) defer cancel() - if err := handle.Close(shutdownCtx); err != nil { - return err - } - return waitServerDone(shutdownCtx, handle) + return errors.Join(closeErr, waitServerDone(waitCtx, handle)) case err := <-handle.Done(): - closed = true return err } } -func browserHandler(view string, rendered renderedView) http.Handler { +func browserHandler(view string, rendered renderedView, expectedAuthority string) http.Handler { return http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { + if request.Host != expectedAuthority { + response.Header().Set("content-type", "text/plain; charset=utf-8") + response.WriteHeader(http.StatusForbidden) + writeBody(response, request.Method, []byte("forbidden host\n")) + return + } + expectedOrigin := "http://" + expectedAuthority + if origin := request.Header.Get("origin"); origin != "" && origin != expectedOrigin { + response.Header().Set("content-type", "text/plain; charset=utf-8") + response.WriteHeader(http.StatusForbidden) + writeBody(response, request.Method, []byte("forbidden origin\n")) + return + } method := request.Method if method != http.MethodGet && method != http.MethodHead { response.Header().Set("allow", "GET, HEAD") diff --git a/internal/command/requirementbrowser/server_test.go b/internal/command/requirementbrowser/server_test.go index 1533b30..4a30c44 100644 --- a/internal/command/requirementbrowser/server_test.go +++ b/internal/command/requirementbrowser/server_test.go @@ -4,8 +4,10 @@ import ( "context" "encoding/json" "io" + "net" "net/http" "path/filepath" + "strconv" "strings" "testing" "time" @@ -94,6 +96,36 @@ func TestStartServerServesExplicitSourceViews(t *testing.T) { if missing.StatusCode != http.StatusNotFound { t.Fatalf("unexpected missing status: %d", missing.StatusCode) } + + for _, item := range []struct { + name string + host string + origin string + }{ + {name: "foreign host", host: "attacker.invalid"}, + {name: "foreign origin", origin: "https://attacker.invalid"}, + } { + t.Run(item.name, func(t *testing.T) { + request, err := http.NewRequest(http.MethodGet, handle.URL, nil) + if err != nil { + t.Fatal(err) + } + if item.host != "" { + request.Host = item.host + } + if item.origin != "" { + request.Header.Set("origin", item.origin) + } + response, err := client.Do(request) + if err != nil { + t.Fatal(err) + } + defer response.Body.Close() + if response.StatusCode != http.StatusForbidden { + t.Fatalf("status=%d, want forbidden", response.StatusCode) + } + }) + } } func TestStartServerServesExplicitProofViews(t *testing.T) { @@ -293,6 +325,30 @@ func TestServeStopsOnContextCancellation(t *testing.T) { } } +func TestServerCloseForcesTerminationAfterGracefulDeadline(t *testing.T) { + handle, err := StartServer(sourceInput(t), Options{Host: "127.0.0.1", Port: 0, PortSet: true, View: "source"}) + if err != nil { + t.Fatal(err) + } + connection, err := net.Dial("tcp", net.JoinHostPort(handle.Host, strconv.Itoa(handle.Port))) + if err != nil { + closeTestServer(t, handle) + t.Fatal(err) + } + defer connection.Close() + if _, err := io.WriteString(connection, "GET / HTTP/1.1\r\nHost: "+net.JoinHostPort(handle.Host, strconv.Itoa(handle.Port))+"\r\n"); err != nil { + t.Fatal(err) + } + shutdownCtx, cancel := context.WithTimeout(t.Context(), 20*time.Millisecond) + defer cancel() + _ = handle.Close(shutdownCtx) + select { + case <-handle.Done(): + case <-time.After(time.Second): + t.Fatal("server did not reach terminal state after forced close") + } +} + type readyWriter struct { ready chan<- string } diff --git a/internal/command/selectivegateplan/admission.go b/internal/command/selectivegateplan/admission.go index 748fa57..457a7cc 100644 --- a/internal/command/selectivegateplan/admission.go +++ b/internal/command/selectivegateplan/admission.go @@ -6,6 +6,7 @@ import ( "strings" "github.com/research-engineering/agentic-proofkit/internal/kernel/admit" + "github.com/research-engineering/agentic-proofkit/internal/kernel/pathpattern" ) var selectiveGatePlanNonClaims = []string{ @@ -126,7 +127,7 @@ func admitInput(raw any) (input, error) { if err != nil { return input{}, err } - archivePatterns, err := sortedPaths(record["archiveOrBinaryPathPatterns"], "selective gate archiveOrBinaryPathPatterns", true) + archivePatterns, err := compiledPatterns(record["archiveOrBinaryPathPatterns"], "selective gate archiveOrBinaryPathPatterns", true) if err != nil { return input{}, err } @@ -134,7 +135,7 @@ func admitInput(raw any) (input, error) { if err != nil { return input{}, err } - proofLikePatterns, err := sortedPaths(record["proofLikePathPatterns"], "selective gate proofLikePathPatterns", true) + proofLikePatterns, err := compiledPatterns(record["proofLikePathPatterns"], "selective gate proofLikePathPatterns", true) if err != nil { return input{}, err } @@ -325,11 +326,11 @@ func generatedArtifactRules(raw any) ([]generatedArtifactRule, error) { if err != nil { return nil, err } - sourcePatterns, err := sortedPaths(record["sourceOfTruthPatterns"], "generated artifact "+pathValue+" sourceOfTruthPatterns", true) + sourcePatterns, err := compiledPatterns(record["sourceOfTruthPatterns"], "generated artifact "+pathValue+" sourceOfTruthPatterns", true) if err != nil { return nil, err } - result = append(result, generatedArtifactRule{Command: commandText, Generator: generator, Path: pathValue, SourceOfTruthPattern: sourcePatterns}) + result = append(result, generatedArtifactRule{Command: commandText, Generator: generator, Path: pathValue, SourceOfTruthPatterns: sourcePatterns}) } sort.Slice(result, func(left int, right int) bool { return result[left].Path < result[right].Path @@ -355,7 +356,11 @@ func artifactPolicies(raw any) ([]artifactIntegrityPolicy, error) { if err != nil { return nil, err } - pathPattern, err := safePath(record["pathPattern"], "artifact integrity pathPattern") + pathPatternValue, err := safePath(record["pathPattern"], "artifact integrity pathPattern") + if err != nil { + return nil, err + } + pathPattern, err := pathpattern.Compile(pathPatternValue, "artifact integrity pathPattern") if err != nil { return nil, err } @@ -423,7 +428,7 @@ func pathTriggeredCommandArray(raw any) ([]pathTriggeredCommand, error) { if err != nil { return nil, err } - patterns, err := sortedPaths(record["pathPatterns"], commandValue.ID+" pathTriggeredCommands pathPatterns", false) + patterns, err := compiledPatterns(record["pathPatterns"], commandValue.ID+" pathTriggeredCommands pathPatterns", false) if err != nil { return nil, err } @@ -540,6 +545,14 @@ func sortedPaths(raw any, context string, allowEmpty bool) ([]string, error) { return unique(result, context, allowEmpty) } +func compiledPatterns(raw any, context string, allowEmpty bool) ([]pathpattern.Pattern, error) { + values, err := sortedPaths(raw, context, allowEmpty) + if err != nil { + return nil, err + } + return pathpattern.CompileAll(values, context) +} + func sortedPrefixes(raw any) ([]string, error) { values, err := textArray(raw, "private path prefix", true) if err != nil { diff --git a/internal/command/selectivegateplan/model.go b/internal/command/selectivegateplan/model.go index 6885483..f53f1eb 100644 --- a/internal/command/selectivegateplan/model.go +++ b/internal/command/selectivegateplan/model.go @@ -1,6 +1,9 @@ package selectivegateplan -import "github.com/research-engineering/agentic-proofkit/internal/kernel/proofvocab" +import ( + "github.com/research-engineering/agentic-proofkit/internal/kernel/pathpattern" + "github.com/research-engineering/agentic-proofkit/internal/kernel/proofvocab" +) var edgeClassSet = proofvocab.SelectiveEdgeClassSet() @@ -22,10 +25,10 @@ type scanObligation struct { } type generatedArtifactRule struct { - Command string - Generator string - Path string - SourceOfTruthPattern []string + Command string + Generator string + Path string + SourceOfTruthPatterns []pathpattern.Pattern } type generatedArtifactObligation struct { @@ -37,7 +40,7 @@ type generatedArtifactObligation struct { type artifactIntegrityPolicy struct { Command string - PathPattern string + PathPattern pathpattern.Pattern Policy string } @@ -79,7 +82,7 @@ type skippedGate struct { type pathTriggeredCommand struct { Command command - PathPatterns []string + PathPatterns []pathpattern.Pattern } type commandAccumulator struct { @@ -88,7 +91,7 @@ type commandAccumulator struct { } type input struct { - ArchiveOrBinaryPathPatterns []string + ArchiveOrBinaryPathPatterns []pathpattern.Pattern ArtifactIntegrityPolicies []artifactIntegrityPolicy BaseCommands []command ChangedPaths []string @@ -103,7 +106,7 @@ type input struct { PathTriggeredCommands []pathTriggeredCommand PreexistingFailures []string PrivatePathPrefixes []string - ProofLikePathPatterns []string + ProofLikePathPatterns []pathpattern.Pattern PublicAPICommand string PublicAPITouched bool RequirementImpactCommand string diff --git a/internal/command/selectivegateplan/planner.go b/internal/command/selectivegateplan/planner.go index 752f1c4..8f1e863 100644 --- a/internal/command/selectivegateplan/planner.go +++ b/internal/command/selectivegateplan/planner.go @@ -167,12 +167,16 @@ func generatedArtifactObligations(rules []generatedArtifactRule, changedPaths [] result := []generatedArtifactObligation{} for _, rule := range rules { sourceChanged := false - for _, pattern := range rule.SourceOfTruthPattern { + for _, pattern := range rule.SourceOfTruthPatterns { for _, path := range changedPaths { - if pathpattern.Match(pattern, path) { + if pattern.MatchAdmitted(path) { sourceChanged = true + break } } + if sourceChanged { + break + } } _, generatedChanged := changed[rule.Path] if generatedChanged && !sourceChanged { @@ -183,7 +187,7 @@ func generatedArtifactObligations(rules []generatedArtifactRule, changedPaths [] if sourceChanged { reason = "source_changed" } - result = append(result, generatedArtifactObligation{Generator: rule.Generator, Path: rule.Path, Reason: reason, SourceOfTruth: rule.SourceOfTruthPattern}) + result = append(result, generatedArtifactObligation{Generator: rule.Generator, Path: rule.Path, Reason: reason, SourceOfTruth: patternStrings(rule.SourceOfTruthPatterns)}) } } return result @@ -194,7 +198,7 @@ func artifactIntegrityObligations(policies []artifactIntegrityPolicy, paths []st for _, path := range paths { var matched *artifactIntegrityPolicy for _, policy := range policies { - if pathpattern.Match(policy.PathPattern, path) { + if policy.PathPattern.MatchAdmitted(path) { policyCopy := policy matched = &policyCopy break @@ -214,7 +218,7 @@ func pathTriggeredCommands(rules []pathTriggeredCommand, changedPaths []string) for _, rule := range rules { for _, pattern := range rule.PathPatterns { for _, path := range changedPaths { - if pathpattern.Match(pattern, path) { + if pattern.MatchAdmitted(path) { result = append(result, rule.Command) goto nextRule } @@ -270,8 +274,21 @@ func uniqueSorted(values []string) []string { return result } -func matchesAny(patterns []string, path string) bool { - return pathpattern.MatchAny(patterns, path) +func matchesAny(patterns []pathpattern.Pattern, path string) bool { + for _, pattern := range patterns { + if pattern.MatchAdmitted(path) { + return true + } + } + return false +} + +func patternStrings(patterns []pathpattern.Pattern) []string { + values := make([]string, 0, len(patterns)) + for _, pattern := range patterns { + values = append(values, pattern.String()) + } + return values } func isPrivatePath(path string, prefixes []string) bool { diff --git a/internal/kernel/agentenvelope/agentenvelope.go b/internal/kernel/agentenvelope/agentenvelope.go index 6b91385..c97512a 100644 --- a/internal/kernel/agentenvelope/agentenvelope.go +++ b/internal/kernel/agentenvelope/agentenvelope.go @@ -75,14 +75,32 @@ func Build(input Input) map[string]any { } } truncated := false + originalTargets := collectLocalReferenceTargets(commands, contextRefs, receiptRefs) actionPlan, omitted, truncated = limitActionPlan(actionPlan, omitted, bounds) blockedPreconditions, omitted, truncated = limitList(blockedPreconditions, "blockedPrecondition", "preconditionId", "maxBlockedPreconditions", hardMaxBlockedPreconditions, omitted, bounds, truncated) clarificationQuestions, omitted, truncated = limitList(clarificationQuestions, "clarificationQuestion", "questionId", "maxClarificationQuestions", hardMaxClarificationQuestions, omitted, bounds, truncated) - commands, omitted, truncated = limitList(commands, "commandRef", "commandId", "maxCommandRefs", hardMaxCommandRefs, omitted, bounds, truncated) - contextRefs, omitted, truncated = limitList(contextRefs, "contextRef", "refId", "maxContextRefs", hardMaxContextRefs, omitted, bounds, truncated) - receiptRefs, omitted, truncated = limitList(receiptRefs, "receiptRef", "receiptRefId", "maxReceiptRefs", hardMaxReceiptRefs, omitted, bounds, truncated) routeQuestions, omitted, truncated = limitList(routeQuestions, "routeQuestion", "questionId", "maxRouteQuestions", hardMaxRouteQuestions, omitted, bounds, truncated) + priorities := collectLocalReferencePriorities(originalTargets, actionPlan, blockedPreconditions, clarificationQuestions, routeQuestions, omitted, commands, contextRefs, receiptRefs) + commands, omitted, truncated = limitListPrioritized(commands, "commandRef", "commandId", "maxCommandRefs", hardMaxCommandRefs, priorities.commands, omitted, bounds, truncated) + contextRefs, omitted, truncated = limitListPrioritized(contextRefs, "contextRef", "refId", "maxContextRefs", hardMaxContextRefs, priorities.contexts, omitted, bounds, truncated) + receiptRefs, omitted, truncated = limitListPrioritized(receiptRefs, "receiptRef", "receiptRefId", "maxReceiptRefs", hardMaxReceiptRefs, priorities.receipts, omitted, bounds, truncated) omitted, truncated = limitOmitted(omitted, bounds, truncated) + retainedTargets := collectLocalReferenceTargets(commands, contextRefs, receiptRefs) + removedReferenceCount := 0 + for _, records := range []*[]map[string]any{&actionPlan, &blockedPreconditions, &clarificationQuestions, &routeQuestions, &omitted, &commands, &contextRefs, &receiptRefs} { + var removed int + *records, removed = pruneDanglingLocalReferences(*records, originalTargets, retainedTargets) + removedReferenceCount += removed + } + referenceClosurePreserved := localReferenceClosurePreserved(originalTargets, retainedTargets, actionPlan, blockedPreconditions, clarificationQuestions, routeQuestions, omitted, commands, contextRefs, receiptRefs) + if removedReferenceCount > 0 { + bounds["prunedLocalReferenceCount"] = removedReferenceCount + truncated = true + } + if !referenceClosurePreserved { + bounds["referenceClosurePreserved"] = false + truncated = true + } sourceReport := projectSourceReport(sanitizeMap(input.SourceReport)) omittedCount = 0 for _, item := range omitted { @@ -126,6 +144,8 @@ func Build(input Input) map[string]any { "nonClaim": "Compatibility cost contracts do not prove tokenizer-specific cost, proof completeness, native witness execution, receipt freshness, or merge satisfaction.", "omittedEdgeCount": omittedCount, "omittedEdgesCounted": true, + "referenceClosurePreserved": referenceClosurePreserved, + "prunedLocalReferenceCount": removedReferenceCount, "receiptRecordCount": len(receiptRefs), "routeQuestionCount": len(routeQuestions), "boundsViolationCount": len(boundsViolations), @@ -325,6 +345,166 @@ func limitList(values []map[string]any, kind string, idKey string, limitKey stri return values[:limit], omitted, true } +func limitListPrioritized(values []map[string]any, kind string, idKey string, limitKey string, hardMax int, priorities map[string]struct{}, omitted []map[string]any, bounds map[string]any, truncated bool) ([]map[string]any, []map[string]any, bool) { + limit := itemLimit(bounds, limitKey, hardMax) + if len(values) <= limit { + return values, omitted, truncated + } + selected := make([]map[string]any, 0, limit) + for _, prioritized := range []bool{true, false} { + for _, value := range values { + _, isPriority := priorities[stringValue(value[idKey])] + if isPriority != prioritized || len(selected) >= limit { + continue + } + selected = append(selected, value) + } + } + omitted = append(omitted, omissionRecord(kind, idKey, len(values)-len(selected))) + return sortMaps(selected, idKey), omitted, true +} + +type localReferenceTargets struct { + all map[string]struct{} + commands map[string]struct{} + contexts map[string]struct{} + receipts map[string]struct{} +} + +func collectLocalReferenceTargets(commands []map[string]any, contexts []map[string]any, receipts []map[string]any) localReferenceTargets { + targets := localReferenceTargets{ + all: map[string]struct{}{}, + commands: idSet(commands, "commandId"), + contexts: idSet(contexts, "refId"), + receipts: idSet(receipts, "receiptRefId"), + } + for _, values := range []map[string]struct{}{targets.commands, targets.contexts, targets.receipts} { + for value := range values { + targets.all[value] = struct{}{} + } + } + return targets +} + +func collectLocalReferencePriorities(targets localReferenceTargets, groups ...[]map[string]any) localReferenceTargets { + priorities := localReferenceTargets{all: map[string]struct{}{}, commands: map[string]struct{}{}, contexts: map[string]struct{}{}, receipts: map[string]struct{}{}} + for _, values := range groups { + for _, value := range values { + for _, ref := range stringReferences(value["evidenceRefs"]) { + if _, ok := targets.commands[ref]; ok { + priorities.commands[ref] = struct{}{} + } + if _, ok := targets.contexts[ref]; ok { + priorities.contexts[ref] = struct{}{} + } + if _, ok := targets.receipts[ref]; ok { + priorities.receipts[ref] = struct{}{} + } + } + for _, ref := range stringReferences(value["commandIds"]) { + if _, ok := targets.commands[ref]; ok { + priorities.commands[ref] = struct{}{} + } + } + } + } + return priorities +} + +func pruneDanglingLocalReferences(values []map[string]any, original localReferenceTargets, retained localReferenceTargets) ([]map[string]any, int) { + removed := 0 + result := make([]map[string]any, len(values)) + for index, value := range values { + cloned := copyMap(value) + for _, field := range []struct { + key string + original map[string]struct{} + retained map[string]struct{} + requireRetained bool + }{ + {key: "evidenceRefs", original: original.all, retained: retained.all}, + {key: "commandIds", original: original.commands, retained: retained.commands, requireRetained: true}, + } { + refs, ok := cloned[field.key] + if !ok { + continue + } + kept := []any{} + for _, ref := range stringReferences(refs) { + if field.requireRetained { + if _, present := field.retained[ref]; !present { + removed++ + continue + } + kept = append(kept, ref) + continue + } + if _, local := field.original[ref]; local { + if _, present := field.retained[ref]; !present { + removed++ + continue + } + } + kept = append(kept, ref) + } + cloned[field.key] = kept + } + result[index] = cloned + } + return result, removed +} + +func localReferenceClosurePreserved(original localReferenceTargets, retained localReferenceTargets, groups ...[]map[string]any) bool { + for _, values := range groups { + for _, value := range values { + for _, ref := range stringReferences(value["commandIds"]) { + if _, ok := retained.commands[ref]; !ok { + return false + } + } + for _, ref := range stringReferences(value["evidenceRefs"]) { + if _, wasLocal := original.all[ref]; !wasLocal { + continue + } + if _, ok := retained.all[ref]; !ok { + return false + } + } + } + } + return true +} + +func idSet(values []map[string]any, key string) map[string]struct{} { + result := map[string]struct{}{} + for _, value := range values { + if id := stringValue(value[key]); id != "" { + result[id] = struct{}{} + } + } + return result +} + +func stringReferences(raw any) []string { + result := []string{} + switch values := raw.(type) { + case []any: + for _, value := range values { + if text, ok := value.(string); ok { + result = append(result, text) + } + } + case []string: + result = append(result, values...) + } + return result +} + +func stringValue(raw any) string { + value, _ := raw.(string) + return value +} + func limitOmitted(values []map[string]any, bounds map[string]any, truncated bool) ([]map[string]any, bool) { limit := itemLimit(bounds, "maxOmittedItems", hardMaxOmittedItems) if limit < 1 && len(values) > 0 { diff --git a/internal/kernel/agentenvelope/agentenvelope_test.go b/internal/kernel/agentenvelope/agentenvelope_test.go index 95c85bd..32fefca 100644 --- a/internal/kernel/agentenvelope/agentenvelope_test.go +++ b/internal/kernel/agentenvelope/agentenvelope_test.go @@ -104,6 +104,72 @@ func TestBuildMarksOversizedBoundedEnvelopeAsWide(t *testing.T) { } } +func TestBuildPrunesReferencesToTruncatedLocalTargets(t *testing.T) { + commands := make([]map[string]any, hardMaxCommandRefs+1) + evidenceRefs := make([]any, len(commands)) + for index := range commands { + id := "proofkit.command." + string(rune('a'+index)) + commands[index] = map[string]any{"commandId": id} + evidenceRefs[index] = id + } + envelope := Build(Input{ + EnvelopeID: "proofkit.test.envelope", + SourceReport: map[string]any{ + "reportId": "proofkit.test.report", + "reportKind": "proofkit.test", + "state": "passed", + }, + ActionPlan: []map[string]any{{"stepId": "proofkit.step", "evidenceRefs": evidenceRefs}}, + Commands: commands, + }) + retained := map[string]struct{}{} + for _, value := range envelope["commands"].([]any) { + retained[value.(map[string]any)["commandId"].(string)] = struct{}{} + } + refs := envelope["actionPlan"].([]any)[0].(map[string]any)["evidenceRefs"].([]any) + for _, value := range refs { + if _, ok := retained[value.(string)]; !ok { + t.Fatalf("action plan retained dangling local command ref %q", value) + } + } + if len(refs) != hardMaxCommandRefs { + t.Fatalf("retained action evidence refs=%d, want %d closure-preserved refs", len(refs), hardMaxCommandRefs) + } + cost := envelope["costContract"].(map[string]any) + if cost["referenceClosurePreserved"] != true { + t.Fatalf("cost contract does not attest local reference closure: %#v", cost) + } + if cost["prunedLocalReferenceCount"] != 1 { + t.Fatalf("cost contract prunedLocalReferenceCount=%v, want 1", cost["prunedLocalReferenceCount"]) + } + if cost["omittedEdgeCount"] != 1 { + t.Fatalf("pruned derived references must not double-count the omitted target: %#v", cost) + } +} + +func TestBuildPrunesCommandIDsWithoutRetainedCommands(t *testing.T) { + envelope := Build(Input{ + EnvelopeID: "proofkit.test.envelope", + SourceReport: map[string]any{ + "reportId": "proofkit.test.report", + "reportKind": "proofkit.test", + "state": "passed", + }, + ActionPlan: []map[string]any{{ + "stepId": "proofkit.step", + "commandIds": []any{"proofkit.command.missing"}, + }}, + }) + action := envelope["actionPlan"].([]any)[0].(map[string]any) + if refs := action["commandIds"].([]any); len(refs) != 0 { + t.Fatalf("action plan retained command IDs without command records: %#v", refs) + } + cost := envelope["costContract"].(map[string]any) + if cost["referenceClosurePreserved"] != true || cost["prunedLocalReferenceCount"] != 1 { + t.Fatalf("cost contract does not prove repaired command closure: %#v", cost) + } +} + func TestBuildPreservesSemanticOmittedCountWhenOmissionRecordsAreCapped(t *testing.T) { envelope := Build(Input{ EnvelopeID: "proofkit.test.envelope", diff --git a/internal/kernel/compactproofcontract/compactproofcontract.go b/internal/kernel/compactproofcontract/compactproofcontract.go index ae69ee0..baf57a9 100644 --- a/internal/kernel/compactproofcontract/compactproofcontract.go +++ b/internal/kernel/compactproofcontract/compactproofcontract.go @@ -146,9 +146,8 @@ func Admit(raw any) (Contract, error) { } func (contract Contract) ResolverProjection(options ResolverOptions) (map[string]any, error) { - localClasses := append([]string{}, options.LocalEnvironmentClasses...) - sort.Strings(localClasses) - if err := assertSortedUnique(localClasses, "compact requirement proof resolver localEnvironmentClasses"); err != nil { + localClasses, err := AdmitLocalEnvironmentClasses(options.LocalEnvironmentClasses) + if err != nil { return nil, err } requirements := contract.resolverRequirements(localClasses) @@ -168,6 +167,29 @@ func (contract Contract) ResolverProjection(options ResolverOptions) (map[string }, nil } +func AdmitLocalEnvironmentClass(value string) (string, error) { + if admit.ContainsSecretLikeValue(value) { + return "", fmt.Errorf("compact requirement proof local environment class must not contain secret-like values") + } + return admit.RuleID(value, "compact requirement proof local environment class") +} + +func AdmitLocalEnvironmentClasses(values []string) ([]string, error) { + admitted := make([]string, 0, len(values)) + for _, value := range values { + class, err := AdmitLocalEnvironmentClass(value) + if err != nil { + return nil, err + } + admitted = append(admitted, class) + } + sort.Strings(admitted) + if err := assertSortedUnique(admitted, "compact requirement proof resolver localEnvironmentClasses"); err != nil { + return nil, err + } + return admitted, nil +} + func (contract Contract) FalsificationRoutes() []FalsificationRoute { routes := make([]FalsificationRoute, 0, len(contract.Bindings)) for _, binding := range contract.Bindings { diff --git a/internal/kernel/compactproofcontract/compactproofcontract_test.go b/internal/kernel/compactproofcontract/compactproofcontract_test.go index 94661c8..2eedec8 100644 --- a/internal/kernel/compactproofcontract/compactproofcontract_test.go +++ b/internal/kernel/compactproofcontract/compactproofcontract_test.go @@ -29,6 +29,21 @@ func TestResolverProjectionIsColumnOrderIndependent(t *testing.T) { } } +func TestResolverProjectionRejectsSecretLikeLocalEnvironmentClass(t *testing.T) { + contract, err := Admit(validCompactContract()) + if err != nil { + t.Fatalf("Admit() error = %v", err) + } + secret := "api_key=environment-secret-sentinel" + _, err = contract.ResolverProjection(ResolverOptions{LocalEnvironmentClasses: []string{secret}}) + if err == nil || !strings.Contains(err.Error(), "must not contain secret-like values") { + t.Fatalf("ResolverProjection() error=%v, want secret-like environment rejection", err) + } + if strings.Contains(err.Error(), secret) || strings.Contains(err.Error(), "environment-secret-sentinel") { + t.Fatalf("ResolverProjection() leaked local environment value: %v", err) + } +} + func TestAdmitRejectsUnknownCompactColumn(t *testing.T) { input := validCompactContract() input["binding_columns"] = append(input["binding_columns"].([]any), "unexpected_column") diff --git a/internal/kernel/pathpattern/pathpattern.go b/internal/kernel/pathpattern/pathpattern.go index 51987da..c8d5da7 100644 --- a/internal/kernel/pathpattern/pathpattern.go +++ b/internal/kernel/pathpattern/pathpattern.go @@ -1,25 +1,67 @@ package pathpattern import ( + "fmt" "regexp" "strings" "github.com/research-engineering/agentic-proofkit/internal/kernel/admit" ) -func Match(pattern string, target string) bool { - safePattern, err := admit.SafeRepoRelativePath(pattern, "path pattern") +type Pattern struct { + exactPrefix string + regexp *regexp.Regexp + source string +} + +func Compile(pattern string, context string) (Pattern, error) { + safePattern, err := admit.SafeRepoRelativePath(pattern, context) if err != nil { - return false + return Pattern{}, err + } + compiled := Pattern{source: safePattern} + if strings.Contains(safePattern, "*") { + compiled.regexp = globToRegexp(safePattern) + } else { + compiled.exactPrefix = strings.TrimSuffix(safePattern, "/") + } + return compiled, nil +} + +func CompileAll(patterns []string, context string) ([]Pattern, error) { + compiled := make([]Pattern, 0, len(patterns)) + for index, pattern := range patterns { + value, err := Compile(pattern, fmt.Sprintf("%s[%d]", context, index)) + if err != nil { + return nil, err + } + compiled = append(compiled, value) + } + return compiled, nil +} + +func (pattern Pattern) String() string { + return pattern.source +} + +func (pattern Pattern) MatchAdmitted(target string) bool { + if pattern.regexp != nil { + return pattern.regexp.MatchString(target) } + return target == pattern.exactPrefix || strings.HasPrefix(target, pattern.exactPrefix+"/") +} + +func (pattern Pattern) Match(target string) bool { safeTarget, err := admit.SafeRepoRelativePath(target, "path") + return err == nil && pattern.MatchAdmitted(safeTarget) +} + +func Match(pattern string, target string) bool { + compiled, err := Compile(pattern, "path pattern") if err != nil { return false } - if strings.Contains(safePattern, "*") { - return globToRegexp(safePattern).MatchString(safeTarget) - } - return safeTarget == safePattern || strings.HasPrefix(safeTarget, strings.TrimSuffix(safePattern, "/")+"/") + return compiled.Match(target) } func MatchAny(patterns []string, target string) bool { @@ -32,7 +74,7 @@ func MatchAny(patterns []string, target string) bool { } func Validate(pattern string, context string) error { - _, err := admit.SafeRepoRelativePath(pattern, context) + _, err := Compile(pattern, context) return err } diff --git a/internal/kernel/pathpattern/pathpattern_test.go b/internal/kernel/pathpattern/pathpattern_test.go index 6fc3eec..b660e72 100644 --- a/internal/kernel/pathpattern/pathpattern_test.go +++ b/internal/kernel/pathpattern/pathpattern_test.go @@ -40,3 +40,25 @@ func TestMatchAnyAndValidateShareOwnerSemantics(t *testing.T) { t.Fatalf("MatchAny() matched unrelated path") } } + +func TestCompiledPatternsPreserveMatcherSemanticsAcrossRepeatedTargets(t *testing.T) { + compiled, err := Compile("docs/**/*.md", "test pattern") + if err != nil { + t.Fatalf("Compile() error = %v", err) + } + for _, item := range []struct { + target string + want bool + }{ + {target: "docs/a.md", want: true}, + {target: "docs/nested/a.md", want: true}, + {target: "docs/a.go", want: false}, + } { + if got := compiled.MatchAdmitted(item.target); got != item.want { + t.Fatalf("compiled.MatchAdmitted(%q)=%v, want %v", item.target, got, item.want) + } + if got := Match(compiled.String(), item.target); got != item.want { + t.Fatalf("Match(%q, %q)=%v, want %v", compiled.String(), item.target, got, item.want) + } + } +} diff --git a/internal/tools/packageartifact/main_test.go b/internal/tools/packageartifact/main_test.go index 0068387..f3d55c8 100644 --- a/internal/tools/packageartifact/main_test.go +++ b/internal/tools/packageartifact/main_test.go @@ -112,6 +112,36 @@ func TestRunWithDependenciesInvalidatesPriorRecordButRetainsCandidateOutputsOnPr } } +func TestRunWithDependenciesRejectsExecutionRecordSymlinkBeforeRunner(t *testing.T) { + root := packageArtifactFixture(t) + external := t.TempDir() + if err := os.MkdirAll(filepath.Join(root, "artifacts"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.Symlink(external, filepath.Join(root, "artifacts", "proofkit")); err != nil { + t.Skipf("symlink unavailable: %v", err) + } + externalRecord := filepath.Join(external, filepath.Base(packageartifactrecord.RecordPath)) + if err := os.WriteFile(externalRecord, []byte("external-sentinel"), 0o600); err != nil { + t.Fatal(err) + } + runnerCalled := false + + err := runWithDependencies(root, runnerFunc(func(string, []string) (int, error) { + runnerCalled = true + return 0, nil + }), stableDependencies()) + if err == nil { + t.Fatal("runWithDependencies() accepted an execution-record symlink escape") + } + if runnerCalled { + t.Fatal("runner executed after execution-record root rejection") + } + if content, readErr := os.ReadFile(externalRecord); readErr != nil || string(content) != "external-sentinel" { + t.Fatalf("external record changed: content=%q err=%v", content, readErr) + } +} + func TestRunWithDependenciesAcceptsCleanRegenerationWithIdenticalBytes(t *testing.T) { root := packageArtifactFixture(t) writeArtifactFixture(t, root, "artifact-v1") diff --git a/internal/tools/packageartifactrecord/record.go b/internal/tools/packageartifactrecord/record.go index 7acdb78..e90a867 100644 --- a/internal/tools/packageartifactrecord/record.go +++ b/internal/tools/packageartifactrecord/record.go @@ -1,6 +1,7 @@ package packageartifactrecord import ( + "crypto/rand" "crypto/sha256" "encoding/binary" "encoding/hex" @@ -112,7 +113,12 @@ type ArtifactEvidence struct { } func Read(root string) (Record, error) { - file, err := os.Open(filepath.Join(root, filepath.FromSlash(RecordPath))) + rootFS, err := os.OpenRoot(root) + if err != nil { + return Record{}, fmt.Errorf("open package artifact execution root: %w", err) + } + defer rootFS.Close() + file, err := rootFS.Open(filepath.FromSlash(RecordPath)) if err != nil { return Record{}, err } @@ -159,38 +165,67 @@ func Write(root string, record Record) error { if err != nil { return err } - path := filepath.Join(root, filepath.FromSlash(RecordPath)) - if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + rootFS, err := os.OpenRoot(root) + if err != nil { + return fmt.Errorf("open package artifact execution root: %w", err) + } + defer rootFS.Close() + path := filepath.FromSlash(RecordPath) + directory := filepath.Dir(path) + if err := rootFS.MkdirAll(directory, 0o755); err != nil { return err } - temporary, err := os.CreateTemp(filepath.Dir(path), ".package-artifact-execution-*") + temporary, temporaryPath, err := createRootedTemporary(rootFS, directory) if err != nil { return err } - temporaryPath := temporary.Name() - defer os.Remove(temporaryPath) - if err := temporary.Chmod(0o600); err != nil { + defer rootFS.Remove(temporaryPath) + if _, err := temporary.Write(content); err != nil { temporary.Close() return err } - if _, err := temporary.Write(content); err != nil { + if err := temporary.Sync(); err != nil { temporary.Close() return err } if err := temporary.Close(); err != nil { return err } - return os.Rename(temporaryPath, path) + return rootFS.Rename(temporaryPath, path) } func Invalidate(root string) error { - err := os.Remove(filepath.Join(root, filepath.FromSlash(RecordPath))) + rootFS, err := os.OpenRoot(root) + if err != nil { + return fmt.Errorf("open package artifact execution root: %w", err) + } + defer rootFS.Close() + err = rootFS.Remove(filepath.FromSlash(RecordPath)) if err != nil && !os.IsNotExist(err) { return fmt.Errorf("invalidate package artifact execution record: %w", err) } return nil } +func createRootedTemporary(rootFS *os.Root, directory string) (*os.File, string, error) { + for attempt := 0; attempt < 16; attempt++ { + var nonce [16]byte + if _, err := rand.Read(nonce[:]); err != nil { + return nil, "", fmt.Errorf("create package artifact execution nonce: %w", err) + } + path := filepath.Join(directory, ".package-artifact-execution-"+hex.EncodeToString(nonce[:])+".tmp") + file, err := rootFS.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) + if errors.Is(err, fs.ErrExist) { + continue + } + if err != nil { + return nil, "", err + } + return file, path, nil + } + return nil, "", fmt.Errorf("create package artifact execution temporary file: exhausted collision budget") +} + func PrepareCandidateArtifactOutputs(root string) error { rootFS, err := os.OpenRoot(root) if err != nil { @@ -266,6 +301,9 @@ func admitCandidateReleaseManifest(rootFS *os.Root) error { if !ok { return fmt.Errorf("candidate package artifact execution cannot classify existing release manifest") } + if err := admit.KnownKeys(object, []string{"artifactKind", "channels", "localPackEvidence", "nonClaims", "package", "registryInstallEvidence", "schemaVersion", "source", "toolchain", "workflow"}, "candidate release manifest"); err != nil { + return fmt.Errorf("candidate package artifact execution cannot classify existing release manifest: %w", err) + } artifactKind, kindOK := object["artifactKind"].(string) schemaVersion, versionOK := object["schemaVersion"].(json.Number) if !kindOK || artifactKind != "proofkit.release-manifest.v1" || !versionOK || schemaVersion.String() != "1" { @@ -275,11 +313,17 @@ func admitCandidateReleaseManifest(rootFS *os.Root) error { if !ok || len(channels) == 0 { return fmt.Errorf("candidate package artifact execution cannot classify existing release manifest") } + if _, present := object["registryInstallEvidence"]; present { + return fmt.Errorf("candidate package artifact execution rejects provider-derived release manifest at %s", relativePath) + } for _, value := range channels { channel, ok := value.(map[string]any) if !ok { return fmt.Errorf("candidate package artifact execution cannot classify existing release manifest") } + if err := admit.KnownKeys(channel, []string{"assets", "authorityChannel", "authorityValidator", "kind", "nonClaims", "packages", "publicationMode", "publisherEnvironment", "registry", "role", "status", "trustedPublisher"}, "candidate release manifest channel"); err != nil { + return fmt.Errorf("candidate package artifact execution cannot classify existing release manifest: %w", err) + } status, ok := channel["status"].(string) if !ok || (status != "candidate" && status != "planned" && status != "not_applicable") { return fmt.Errorf("candidate package artifact execution rejects provider-derived release manifest at %s", relativePath) diff --git a/internal/tools/packageartifactrecord/record_test.go b/internal/tools/packageartifactrecord/record_test.go index dac6ebb..6b296a4 100644 --- a/internal/tools/packageartifactrecord/record_test.go +++ b/internal/tools/packageartifactrecord/record_test.go @@ -209,6 +209,55 @@ func TestPrepareCandidateArtifactOutputsRejectsProviderDerivedReleaseManifestBef } } +func TestPrepareCandidateArtifactOutputsRejectsTopLevelRegistryInstallEvidenceBeforeMutation(t *testing.T) { + root := t.TempDir() + writeFixture(t, root, "artifacts/package/package.tgz", "candidate") + writeFixture(t, root, "artifacts/release/release-manifest.json", `{"artifactKind":"proofkit.release-manifest.v1","channels":[{"status":"candidate"}],"registryInstallEvidence":{"kind":"proofkit.registry-install-evidence.v1"},"schemaVersion":1}`) + + err := PrepareCandidateArtifactOutputs(root) + if err == nil || !strings.Contains(err.Error(), "rejects provider-derived release manifest") { + t.Fatalf("PrepareCandidateArtifactOutputs() error = %v", err) + } + if content, readErr := os.ReadFile(filepath.Join(root, "artifacts/package/package.tgz")); readErr != nil || string(content) != "candidate" { + t.Fatalf("candidate output changed before registry-install-evidence rejection: content=%q err=%v", content, readErr) + } +} + +func TestExecutionRecordOperationsRejectSymlinkEscape(t *testing.T) { + operations := []struct { + name string + run func(string) error + }{ + {name: "read", run: func(root string) error { _, err := Read(root); return err }}, + {name: "write", run: func(root string) error { return Write(root, Record{SchemaVersion: SchemaVersion}) }}, + {name: "invalidate", run: Invalidate}, + } + for _, operation := range operations { + t.Run(operation.name, func(t *testing.T) { + root := t.TempDir() + external := t.TempDir() + if err := os.MkdirAll(filepath.Join(root, "artifacts"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.Symlink(external, filepath.Join(root, "artifacts", "proofkit")); err != nil { + t.Skipf("symlink unavailable: %v", err) + } + externalRecord := filepath.Join(external, filepath.Base(RecordPath)) + if err := os.WriteFile(externalRecord, []byte("external-sentinel"), 0o600); err != nil { + t.Fatal(err) + } + + if err := operation.run(root); err == nil { + t.Fatalf("%s accepted an execution-record symlink escape", operation.name) + } + content, err := os.ReadFile(externalRecord) + if err != nil || string(content) != "external-sentinel" { + t.Fatalf("%s changed external record: content=%q err=%v", operation.name, content, err) + } + }) + } +} + func TestPrepareCandidateArtifactOutputsDoesNotFollowArtifactSymlink(t *testing.T) { root := t.TempDir() external := t.TempDir() diff --git a/internal/tools/packageverify/main.go b/internal/tools/packageverify/main.go index eca93c5..373c21c 100644 --- a/internal/tools/packageverify/main.go +++ b/internal/tools/packageverify/main.go @@ -209,6 +209,9 @@ func runVerifier() error { if err := verifyRootManifestBoundary(rootPackage); err != nil { return err } + if err := verifyPackedOwnerRecordsMatchSource(rootPackage); err != nil { + return err + } if err := verifyNoStalePackageDocs(rootPackage); err != nil { return err } @@ -218,6 +221,36 @@ func runVerifier() error { return verifyOutsideConsumer(rootPackage) } +func verifyPackedOwnerRecordsMatchSource(artifact rootPackageArtifact) error { + for _, entry := range artifact.Entries { + if !sourceOwnedPackageEntry(entry) { + continue + } + packed, err := readTarFileFromBytes(artifact.Content, entry) + if err != nil { + return err + } + sourcePath := strings.TrimPrefix(entry, "package/") + source, err := os.ReadFile(filepath.FromSlash(sourcePath)) + if err != nil { + return fmt.Errorf("read source owner for packed entry %s: %w", entry, err) + } + if !bytes.Equal(packed, source) { + return fmt.Errorf("packed owner record %s does not match source owner %s", entry, sourcePath) + } + } + return nil +} + +func sourceOwnedPackageEntry(entry string) bool { + switch entry { + case "package/LICENSE", "package/package.json": + return true + default: + return packageTextEntry(entry) + } +} + func verifyRootPackage(record packRecord) (rootPackageArtifact, error) { if record.Name != rootPackageName { return rootPackageArtifact{}, fmt.Errorf("unexpected root package name: %s", record.Name) diff --git a/internal/tools/packageverify/main_test.go b/internal/tools/packageverify/main_test.go index ef74095..ee52d91 100644 --- a/internal/tools/packageverify/main_test.go +++ b/internal/tools/packageverify/main_test.go @@ -75,6 +75,29 @@ func TestVerifySpecReferenceClosureReadsTarballBindings(t *testing.T) { } } +func TestVerifyPackedOwnerRecordsRejectsSourceArtifactContentDrift(t *testing.T) { + root := t.TempDir() + withWorkingDirectory(t, root) + entries := []string{ + "package/LICENSE", + "package/package.json", + "package/docs/specs/example/requirements.v1.json", + "package/proofkit/cli-contract.v1.json", + } + for _, entry := range entries { + sourcePath := strings.TrimPrefix(entry, "package/") + writeFileBytes(t, filepath.Join(root, filepath.FromSlash(sourcePath)), []byte("source-owner\n")) + tarball := writePackageTarball(t, map[string]string{entry: "corrupted-packed-owner\n"}) + artifact := tarballArtifact(t, tarball) + artifact.Entries = []string{entry} + + err := verifyPackedOwnerRecordsMatchSource(artifact) + if err == nil || !strings.Contains(err.Error(), "does not match source owner") { + t.Fatalf("verifyPackedOwnerRecordsMatchSource(%s) error=%v, want content-identity failure", entry, err) + } + } +} + func TestVerifyPackRecordBytesRejectsStaleMetadata(t *testing.T) { root := t.TempDir() withWorkingDirectory(t, root) diff --git a/internal/tools/pythonpackage/exec_lifecycle_test.go b/internal/tools/pythonpackage/exec_lifecycle_test.go index abe1aed..cf0a2ed 100644 --- a/internal/tools/pythonpackage/exec_lifecycle_test.go +++ b/internal/tools/pythonpackage/exec_lifecycle_test.go @@ -19,7 +19,7 @@ func TestPOSIXWrapperExecPreservesProcessIdentityAndSignals(t *testing.T) { } python, err := exec.LookPath("python3") if err != nil { - t.Skipf("python3 unavailable: %v", err) + t.Fatalf("python3 is required for the POSIX wrapper lifecycle oracle: %v", err) } root := t.TempDir() packageRoot := filepath.Join(root, "agentic_proofkit") @@ -42,8 +42,13 @@ def raise_exit(): signal.signal(signal.SIGTERM, lambda _signum, _frame: raise_exit()) -with open(os.environ["PROOFKIT_TEST_PID_FILE"], "w", encoding="utf-8") as handle: +pid_path = os.environ["PROOFKIT_TEST_PID_FILE"] +temporary_pid_path = pid_path + ".tmp" +with open(temporary_pid_path, "w", encoding="utf-8") as handle: handle.write(str(os.getpid())) + handle.flush() + os.fsync(handle.fileno()) +os.replace(temporary_pid_path, pid_path) while True: time.sleep(0.1) @@ -63,7 +68,7 @@ while True: _, _ = command.Process.Wait() } }) - pid := waitForPID(t, pidFile) + pid := waitForPID(t, pidFile, command.Process.Pid) if pid != command.Process.Pid { t.Fatalf("wrapper pid=%d embedded CLI pid=%d, want exec-preserved identity", command.Process.Pid, pid) } @@ -88,20 +93,36 @@ func copyFixtureFile(t *testing.T, source string, target string) { } } -func waitForPID(t *testing.T, path string) int { +func waitForPID(t *testing.T, path string, expected int) int { t.Helper() deadline := time.Now().Add(5 * time.Second) for time.Now().Before(deadline) { content, err := os.ReadFile(path) if err == nil { pid, err := strconv.Atoi(strings.TrimSpace(string(content))) - if err != nil { - t.Fatal(err) + if err == nil && pid == expected { + return pid } - return pid } time.Sleep(10 * time.Millisecond) } t.Fatal(fmt.Errorf("timed out waiting for embedded CLI pid")) return 0 } + +func TestWaitForPIDIgnoresPartialMalformedAndStaleValues(t *testing.T) { + path := filepath.Join(t.TempDir(), "child.pid") + expected := 424242 + done := make(chan struct{}) + go func() { + defer close(done) + for _, content := range []string{"", "not-a-pid", "123", strconv.Itoa(expected)} { + _ = os.WriteFile(path, []byte(content), 0o600) + time.Sleep(15 * time.Millisecond) + } + }() + if got := waitForPID(t, path, expected); got != expected { + t.Fatalf("waitForPID()=%d, want %d", got, expected) + } + <-done +} diff --git a/internal/tools/pythonpackage/metadata_test.go b/internal/tools/pythonpackage/metadata_test.go index f1ec0b0..e18c3a0 100644 --- a/internal/tools/pythonpackage/metadata_test.go +++ b/internal/tools/pythonpackage/metadata_test.go @@ -2,6 +2,8 @@ package main import ( "archive/zip" + "crypto/sha256" + "fmt" "os" "path/filepath" "strings" @@ -95,7 +97,7 @@ func TestVerifyWheelContentsRequiresExactWheelMetadata(t *testing.T) { path := filepath.Join(t.TempDir(), "wheel.whl") writeMinimalWheel(t, path, version, wheelMetadata(target)+"Tag: py3-none-conflicting\n") - err := verifyWheelContents(path, version, target) + err := verifyWheelContents(path, version, target, binarySHA256([]byte("binary"))) if err == nil || !strings.Contains(err.Error(), "WHEEL metadata must match release platform target") { t.Fatalf("verifyWheelContents() error=%v, want exact WHEEL metadata rejection", err) } @@ -125,20 +127,36 @@ func TestVerifyWheelContentsRejectsDataDescriptors(t *testing.T) { path := filepath.Join(t.TempDir(), "wheel.whl") writeDataDescriptorWheel(t, path, version, wheelMetadata(target)) - err := verifyWheelContents(path, version, target) + err := verifyWheelContents(path, version, target, binarySHA256([]byte("binary"))) if err == nil || !strings.Contains(err.Error(), "ZIP data descriptor") { t.Fatalf("verifyWheelContents() error=%v, want data descriptor rejection", err) } } +func TestVerifyWheelContentsRejectsEmbeddedBinaryDifferentFromSourceRecord(t *testing.T) { + target := releaseTargets()[0] + version := "1.2.3" + path := filepath.Join(t.TempDir(), "wheel.whl") + writeMinimalWheelWithBinary(t, path, version, wheelMetadata(target), []byte("corrupted-binary")) + + err := verifyWheelContents(path, version, target, binarySHA256([]byte("source-binary"))) + if err == nil || !strings.Contains(err.Error(), "embedded binary sha256 mismatch") { + t.Fatalf("verifyWheelContents() error=%v, want embedded/source binary identity rejection", err) + } +} + func writeMinimalWheel(t *testing.T, path string, version string, wheel string) { + writeMinimalWheelWithBinary(t, path, version, wheel, []byte("binary")) +} + +func writeMinimalWheelWithBinary(t *testing.T, path string, version string, wheel string, binary []byte) { t.Helper() distInfo := distInfoDir(version) entries := []wheelEntry{ {Path: "agentic_proofkit/__init__.py", Mode: 0o644}, {Path: "agentic_proofkit/__main__.py", Mode: 0o644}, {Path: "agentic_proofkit/cli.py", Mode: 0o644}, - {Path: "agentic_proofkit/bin/agentic-proofkit", Content: []byte("binary"), Mode: 0o755}, + {Path: "agentic_proofkit/bin/agentic-proofkit", Content: binary, Mode: 0o755}, {Path: distInfo + "/METADATA", Content: []byte("Metadata-Version: 2.1\n"), Mode: 0o644}, {Path: distInfo + "/WHEEL", Content: []byte(wheel), Mode: 0o644}, {Path: distInfo + "/entry_points.txt", Content: []byte(entryPoints()), Mode: 0o644}, @@ -149,6 +167,11 @@ func writeMinimalWheel(t *testing.T, path string, version string, wheel string) } } +func binarySHA256(content []byte) string { + sum := sha256.Sum256(content) + return fmt.Sprintf("%x", sum[:]) +} + func writeDataDescriptorWheel(t *testing.T, path string, version string, wheel string) { t.Helper() file, err := os.Create(path) diff --git a/internal/tools/pythonpackage/verify.go b/internal/tools/pythonpackage/verify.go index fb0c973..cc5f451 100644 --- a/internal/tools/pythonpackage/verify.go +++ b/internal/tools/pythonpackage/verify.go @@ -98,10 +98,10 @@ func verifyWheelRecord(manifest packageJSON, target target, record wheelRecord) if record.BinarySha256 != fmt.Sprintf("%x", binarySHA[:]) { return fmt.Errorf("python wheel binary sha256 mismatch for %s", record.Filename) } - return verifyWheelContents(wheelPath, manifest.Version, target) + return verifyWheelContents(wheelPath, manifest.Version, target, record.BinarySha256) } -func verifyWheelContents(path string, version string, target target) error { +func verifyWheelContents(path string, version string, target target, expectedBinarySHA256 string) error { reader, err := zip.OpenReader(path) if err != nil { return err @@ -152,6 +152,14 @@ func verifyWheelContents(path string, version string, target target) error { if string(entryPointsContent) != entryPoints() { return fmt.Errorf("%s entry_points.txt mismatch", path) } + embeddedBinary, err := readZipFile(entries["agentic_proofkit/bin/agentic-proofkit"]) + if err != nil { + return err + } + embeddedBinarySHA256 := sha256.Sum256(embeddedBinary) + if fmt.Sprintf("%x", embeddedBinarySHA256[:]) != expectedBinarySHA256 { + return fmt.Errorf("%s embedded binary sha256 mismatch", path) + } return verifyRecord(entries, distInfo+"/RECORD") } diff --git a/internal/tools/releasecloseoutinput/main.go b/internal/tools/releasecloseoutinput/main.go index 6e1e16b..192a9b3 100644 --- a/internal/tools/releasecloseoutinput/main.go +++ b/internal/tools/releasecloseoutinput/main.go @@ -197,6 +197,7 @@ type proofReceiptEvidence struct { EvidenceRefs []string `json:"evidenceRefs"` ExitCode int `json:"exitCode"` FinishedAt string `json:"finishedAt"` + LockfileDigest *string `json:"lockfileDigest"` NonClaims []string `json:"nonClaims"` ProducerAdmissionClass string `json:"producerAdmissionClass"` ProducerID string `json:"producerId"` @@ -207,6 +208,7 @@ type proofReceiptEvidence struct { ReceiptID string `json:"receiptId"` ReceiptKind string `json:"receiptKind"` RunnerClass string `json:"runnerClass"` + RunnerIdentity string `json:"runnerIdentity"` SourceRevision string `json:"sourceRevision"` StartedAt string `json:"startedAt"` Status string `json:"status"` @@ -662,6 +664,14 @@ func (snapshot SelfEvidenceSnapshot) identityConsistent(execution packageartifac len(bundle.ReceiptProducerAdmission.Receipts) != 1 { return false } + receiptRaw, receiptRawOK := firstNestedRecord(snapshot.proofReceipts.raw, "receipts") + bundleReceiptRaw, bundleReceiptRawOK := firstNestedRecord(snapshot.specProofBundle.raw, "receiptAdmission", "receipts") + producerRaw, producerRawOK := firstNestedRecord(snapshot.producerPolicy.raw, "receipts") + bundleProducerRaw, bundleProducerRawOK := firstNestedRecord(snapshot.specProofBundle.raw, "receiptProducerAdmission", "receipts") + if !receiptRawOK || !bundleReceiptRawOK || !producerRawOK || !bundleProducerRawOK || + !canonicalRecordsEqual(receiptRaw, bundleReceiptRaw) || !canonicalRecordsEqual(producerRaw, bundleProducerRaw) { + return false + } bundleProofIdentity, ok := proofReceiptIdentity(bundle.ReceiptAdmission.Receipts[0]) if !ok || bundleProofIdentity != proofIdentity || !reflect.DeepEqual(bundle.ReceiptAdmission.Receipts[0], receiptSet.Receipts[0]) || @@ -673,6 +683,34 @@ func (snapshot SelfEvidenceSnapshot) identityConsistent(execution packageartifac reflect.DeepEqual(bundle.ReceiptProducerAdmission.Receipts[0], producerPolicy.Receipts[0]) } +func firstNestedRecord(raw any, objectPath ...string) (any, bool) { + current := raw + for _, key := range objectPath { + record, ok := current.(map[string]any) + if !ok { + return nil, false + } + current, ok = record[key] + if !ok { + return nil, false + } + } + values, ok := current.([]any) + if !ok || len(values) != 1 { + return nil, false + } + return values[0], true +} + +func canonicalRecordsEqual(left any, right any) bool { + leftDigest, err := digest.StableJSONSHA256Ref(left) + if err != nil { + return false + } + rightDigest, err := digest.StableJSONSHA256Ref(right) + return err == nil && leftDigest == rightDigest +} + func proofReceiptIdentity(record proofReceiptEvidence) (selfHostingReceiptIdentity, bool) { if !selfHostingReceiptIdentityMatches(record.ReceiptID, record.ProducerID, record.RunnerClass) { return selfHostingReceiptIdentity{}, false diff --git a/internal/tools/releasecloseoutinput/main_test.go b/internal/tools/releasecloseoutinput/main_test.go index 2de86af..2d27407 100644 --- a/internal/tools/releasecloseoutinput/main_test.go +++ b/internal/tools/releasecloseoutinput/main_test.go @@ -14,6 +14,7 @@ import ( "path/filepath" "sort" "strings" + "sync" "testing" "time" @@ -33,6 +34,20 @@ const ( testPythonWheelName = "agentic_proofkit-1.2.3-py3-none-any.whl" ) +var ( + completeFixtureBaseErr error + completeFixtureBaseOnce sync.Once + completeFixtureBaseRoot string +) + +func TestMain(m *testing.M) { + exitCode := m.Run() + if completeFixtureBaseRoot != "" { + _ = os.RemoveAll(completeFixtureBaseRoot) + } + os.Exit(exitCode) +} + func TestBuildInputProducesAdmittedSatisfiedCloseout(t *testing.T) { root := completeFixture(t) @@ -46,6 +61,21 @@ func TestBuildInputProducesAdmittedSatisfiedCloseout(t *testing.T) { assertCriterionInventory(t, input) } +func TestCompleteFixtureCopiesAreIsolated(t *testing.T) { + first := completeFixture(t) + second := completeFixture(t) + firstSource := filepath.Join(first, "source.txt") + secondSource := filepath.Join(second, "source.txt") + writeFile(t, firstSource, "mutated\n") + content, err := os.ReadFile(secondSource) + if err != nil { + t.Fatal(err) + } + if string(content) != "source-v1\n" { + t.Fatalf("fixture copy mutation leaked across tests: %q", content) + } +} + func TestBuildInputAdmitsGitHubActionsAdvisorySelfEvidence(t *testing.T) { root := completeFixture(t) writeGitHubActionsSelfEvidence(t, root) @@ -703,6 +733,32 @@ func TestSelfEvidenceBindsReceiptFieldsToPackageArtifactExecution(t *testing.T) } } +func TestSelfEvidenceRejectsLossyReceiptProjectionCollisions(t *testing.T) { + cases := []struct { + field string + value any + }{ + {field: "runnerIdentity", value: "local.other-runner"}, + {field: "lockfileDigest", value: testDigest("different-lockfile")}, + } + for _, item := range cases { + t.Run(item.field, func(t *testing.T) { + root := completeFixture(t) + receiptsPath := filepath.Join(root, filepath.FromSlash(proofReceiptsPath)) + receiptSet := readJSONMap(t, receiptsPath) + receiptSet["receipts"].([]any)[0].(map[string]any)[item.field] = item.value + writeJSON(t, receiptsPath, receiptSet) + writeOwnerSelfEvidenceReports(t, root) + + input, err := buildInput(root) + if err != nil { + t.Fatalf("buildInput() error = %v", err) + } + assertCriterionStatus(t, input, "proofkit.release_closeout.self_evidence", "missing_evidence") + }) + } +} + func TestSelfEvidenceRejectsTagOnlyJSONStubs(t *testing.T) { root := completeFixture(t) writeJSON(t, filepath.Join(root, "artifacts/proofkit/self-hosting-proof-receipt-admission-report.json"), map[string]any{ @@ -788,7 +844,25 @@ func mutateSelfEvidenceReceiptField(t *testing.T, root string, field string, val func completeFixture(t *testing.T) string { t.Helper() + completeFixtureBaseOnce.Do(func() { + completeFixtureBaseRoot, completeFixtureBaseErr = os.MkdirTemp("", "proofkit-release-closeout-fixture-") + if completeFixtureBaseErr != nil { + return + } + populateCompleteFixture(t, completeFixtureBaseRoot) + }) + if completeFixtureBaseErr != nil { + t.Fatalf("create complete fixture base: %v", completeFixtureBaseErr) + } root := t.TempDir() + if err := os.CopyFS(root, os.DirFS(completeFixtureBaseRoot)); err != nil { + t.Fatalf("copy complete fixture base: %v", err) + } + return root +} + +func populateCompleteFixture(t *testing.T, root string) { + t.Helper() runFixtureGit(t, root, "init") runFixtureGit(t, root, "config", "user.email", "proofkit@example.invalid") runFixtureGit(t, root, "config", "user.name", "Proofkit Test") @@ -828,7 +902,6 @@ func completeFixture(t *testing.T) string { }) execution := writePackageArtifactExecutionFixture(t, root) writeLocalSelfEvidence(t, root, execution) - return root } func writeLocalSelfEvidence(t *testing.T, root string, execution packageartifactrecord.Record) { diff --git a/proofkit/cli-contract.v1.json b/proofkit/cli-contract.v1.json index 8b373c5..16f0b04 100644 --- a/proofkit/cli-contract.v1.json +++ b/proofkit/cli-contract.v1.json @@ -249,6 +249,49 @@ "This input contract describes agent-route admission only.", "It does not validate referenced file contents, execute commands, read repositories, or approve merge." ] + }, + "outputContract": { + "contractId": "proofkit.agent-route.output.v2", + "schemaVersion": 2, + "authority": "deterministic route report derived from admitted agent-route input", + "requiredFields": [ + "guidanceSlice", + "reportId", + "reportKind", + "schemaVersion", + "selectedRouteFamily", + "state" + ], + "fields": { + "schemaVersion": { + "value": 2 + }, + "selectedRouteFamily": { + "enum": [ + "adoption", + "migration", + "release_and_deployment", + "rendered_views", + "repository_structure", + "requirement_proof_binding", + "requirement_source", + "selective_evidence", + "selective_planning", + "test_inventory_and_coverage", + "unknown" + ] + }, + "guidanceSlice": { + "requiredFields": [ + "routeFamily" + ], + "routeFamilyRule": "must equal selectedRouteFamily" + } + }, + "changesFromV1": [ + "selectedFamily is replaced by selectedRouteFamily", + "guidanceSlice.family is replaced by guidanceSlice.routeFamily" + ] } }, { @@ -1836,7 +1879,71 @@ ], "requiredFlags": [ "--repo-root" - ] + ], + "inputContract": { + "contractId": "proofkit.typescript-public-api-surfaces.input.v1", + "schemaVersion": 1, + "authority": "caller-owned manifest plus explicitly selected filesystem snapshot", + "fields": { + "schemaVersion": { + "required": true, + "value": 1 + }, + "machineContract": { + "required": true, + "value": "public_api_surfaces" + }, + "entries": { + "required": true, + "maxItems": 1024, + "item": { + "requiredFields": [ + "exportConditions", + "exportKey", + "packageManifestPath", + "packageName", + "runtimeExports", + "typeExports" + ], + "optionalFields": [ + "deniedExportKeys" + ], + "pathAuthority": "packageManifestPath names an explicit repo-relative package.json; every export condition names its explicit repo-relative non-JSX TypeScript sourcePath, and the canonical resolved source target must also have a .ts, .mts, or .cts extension", + "setFields": "deniedExportKeys, runtimeExports, and typeExports are sorted unique arrays", + "exportConditionsRule": "non-empty and sorted unique by condition" + } + } + }, + "resourceBudgets": { + "maxSourceFileBytes": 8388608, + "maxPackageManifestBytes": 262144, + "maxAggregateFileReadBytes": 67108864 + }, + "sourceGrammar": { + "grammarId": "proofkit.typescript-public-api.export-subset.v1", + "mode": "fail_closed", + "identifierDomain": "direct ASCII identifiers in code; Unicode remains permitted inside comments and literals", + "admittedExportForms": [ + "named runtime re-exports from a string module specifier", + "named type re-exports from a string module specifier", + "ASCII-named function, class, enum, interface, type, const, let, and var exports" + ], + "rejectedLexicalForms": [ + "slash tokens outside comments, including division and regular-expression literals", + "template interpolation", + "escaped or direct non-ASCII code identifiers", + "unterminated comments or literals", + "unbalanced delimiters", + "top-level angle-bracket syntax in variable export declarations" + ] + }, + "nonClaims": [ + "The command does not infer conventional package or source layouts.", + "The command does not parse JSX or admit TSX source files.", + "The command does not parse unrestricted TypeScript; sources outside the documented export grammar fail closed.", + "Caller-owned source-to-condition mappings do not prove compiler output provenance, checkout freshness, package-manager truth, merge readiness, or rollout." + ] + } }, { "command": "witness-plan", diff --git a/proofkit/requirement-bindings.json b/proofkit/requirement-bindings.json index 0e9f524..5b0f711 100644 --- a/proofkit/requirement-bindings.json +++ b/proofkit/requirement-bindings.json @@ -349,7 +349,7 @@ "claimLevel": "blocking", "proofState": "witness_backed", "nonClaims": [ - "This requirement does not claim repository scanning, semantic boundary correctness, automatic requirement extraction, native witness execution, receipt authenticity, proof freshness, owner approval, merge approval, release approval, rollout approval, or old proof-owner retirement approval." + "This requirement does not claim repository scanning, text extraction from files, semantic boundary correctness, automatic requirement extraction, native witness execution, receipt authenticity, proof freshness, owner approval, merge approval, release approval, rollout approval, or old proof-owner retirement approval." ] }, { @@ -479,7 +479,7 @@ "claimLevel": "blocking", "proofState": "witness_backed", "nonClaims": [ - "This requirement does not claim provider-run success, Windows support, performance budget enforcement, or release approval." + "This requirement does not claim provider-run success, branch-protection settings, Windows support, performance budget enforcement, or release approval." ] }, { @@ -702,6 +702,32 @@ "local-go" ] }, + { + "requirementId": "REQ-PROOFKIT-PACKAGE-002", + "scenarioId": "proofkit.package-boundary.typescript-explicit-scan-topology", + "witnessId": "proofkit.typescript-public-api.explicit-scan-topology", + "witnessKind": "contract", + "witnessPath": "internal/command/publicapi/public_api_test.go", + "commandIds": [ + "proofkit.go-test" + ], + "environmentClasses": [ + "local-go" + ] + }, + { + "requirementId": "REQ-PROOFKIT-PACKAGE-002", + "scenarioId": "proofkit.package-boundary.typescript-fail-closed-source-grammar", + "witnessId": "proofkit.typescript-public-api.fail-closed-source-grammar", + "witnessKind": "contract", + "witnessPath": "internal/command/publicapi/public_api_test.go", + "commandIds": [ + "proofkit.go-test" + ], + "environmentClasses": [ + "local-go" + ] + }, { "requirementId": "REQ-PROOFKIT-PACKAGE-003", "scenarioId": "proofkit.package-boundary.outside-consumer-artifact", @@ -745,6 +771,32 @@ "local-go" ] }, + { + "requirementId": "REQ-PROOFKIT-PACKAGE-005", + "scenarioId": "proofkit.package-boundary.staged-and-worktree-source-hygiene", + "witnessId": "proofkit.source-hygiene.tracked-language-closure", + "witnessKind": "contract", + "witnessPath": "scripts/source_hygiene_test.go", + "commandIds": [ + "proofkit.go-test" + ], + "environmentClasses": [ + "local-go" + ] + }, + { + "requirementId": "REQ-PROOFKIT-PACKAGE-005", + "scenarioId": "proofkit.package-boundary.merge-critical-runtime-preconditions", + "witnessId": "proofkit.ci.merge-critical-runtime-preconditions", + "witnessKind": "contract", + "witnessPath": "scripts/workflow_package_gate_oracle_test.go", + "commandIds": [ + "proofkit.go-test" + ], + "environmentClasses": [ + "local-go" + ] + }, { "requirementId": "REQ-PROOFKIT-PACKAGE-006", "scenarioId": "proofkit.package-boundary.python-wheel-candidate", @@ -759,6 +811,19 @@ "local-python" ] }, + { + "requirementId": "REQ-PROOFKIT-PACKAGE-006", + "scenarioId": "proofkit.package-boundary.python-wrapper-posix-lifecycle", + "witnessId": "proofkit.python-package.posix-exec-lifecycle", + "witnessKind": "contract", + "witnessPath": "internal/tools/pythonpackage/exec_lifecycle_test.go", + "commandIds": [ + "proofkit.go-test" + ], + "environmentClasses": [ + "local-go" + ] + }, { "requirementId": "REQ-PROOFKIT-SPEC-001", "scenarioId": "proofkit.spec-proof-core.requirement-source-admission", @@ -837,6 +902,19 @@ "local-go" ] }, + { + "requirementId": "REQ-PROOFKIT-SPEC-003", + "scenarioId": "proofkit.spec-proof-core.self-hosting-witness-command-identity", + "witnessId": "proofkit.witness-plan.self-hosting-command-identity", + "witnessKind": "contract", + "witnessPath": "internal/command/requirementbinding/projections_test.go", + "commandIds": [ + "proofkit.go-test" + ], + "environmentClasses": [ + "local-go" + ] + }, { "requirementId": "REQ-PROOFKIT-SPEC-004", "scenarioId": "proofkit.spec-proof-core.selective-planning-and-evidence", @@ -850,6 +928,19 @@ "local-go" ] }, + { + "requirementId": "REQ-PROOFKIT-SPEC-004", + "scenarioId": "proofkit.spec-proof-core.changed-path-agent-reference-closure", + "witnessId": "proofkit.changed-path-set.agent-reference-closure", + "witnessKind": "contract", + "witnessPath": "internal/command/changedpathset/changedpathset_test.go", + "commandIds": [ + "proofkit.go-test" + ], + "environmentClasses": [ + "local-go" + ] + }, { "requirementId": "REQ-PROOFKIT-SPEC-005", "scenarioId": "proofkit.spec-proof-core.rendered-view-and-agent-envelope-non-authority", @@ -876,6 +967,19 @@ "local-go" ] }, + { + "requirementId": "REQ-PROOFKIT-SPEC-005", + "scenarioId": "proofkit.spec-proof-core.agent-envelope-reference-closure", + "witnessId": "proofkit.agent-envelope.reference-closure", + "witnessKind": "contract", + "witnessPath": "internal/kernel/agentenvelope/agentenvelope_test.go", + "commandIds": [ + "proofkit.go-test" + ], + "environmentClasses": [ + "local-go" + ] + }, { "requirementId": "REQ-PROOFKIT-SPEC-006", "scenarioId": "proofkit.spec-proof-core.test-inventory-normalized-output", @@ -1475,6 +1579,19 @@ "local-go" ] }, + { + "requirementId": "REQ-PROOFKIT-QUALITY-004", + "scenarioId": "proofkit.supply-chain-quality.cli-output-schema-evolution", + "witnessId": "proofkit.cli-contract.output-schema-evolution", + "witnessKind": "contract", + "witnessPath": "internal/app/app_test.go", + "commandIds": [ + "proofkit.go-test" + ], + "environmentClasses": [ + "local-go" + ] + }, { "requirementId": "REQ-PROOFKIT-QUALITY-005", "scenarioId": "proofkit.supply-chain-quality.codeql-workflow", @@ -1533,6 +1650,21 @@ "local-python" ] }, + { + "requirementId": "REQ-PROOFKIT-QUALITY-011", + "scenarioId": "proofkit.supply-chain-quality.ci-complete-go-package-set", + "witnessId": "proofkit.ci.complete-go-package-set-oracle", + "witnessKind": "contract", + "witnessPath": "scripts/validate-self-hosting-receipts_test.go", + "commandIds": [ + "proofkit.go-test", + "proofkit.package-gate" + ], + "environmentClasses": [ + "local-go", + "local-go-python" + ] + }, { "requirementId": "REQ-PROOFKIT-QUALITY-012", "scenarioId": "proofkit.supply-chain-quality.release-channel-authority-vocabulary", @@ -1643,6 +1775,19 @@ "local-go" ] }, + { + "requirementId": "REQ-PROOFKIT-QUALITY-016", + "scenarioId": "proofkit.supply-chain-quality.release-platform-self-hosting-binary", + "witnessId": "proofkit.self-hosting.release-platform-binary-projection", + "witnessKind": "contract", + "witnessPath": "scripts/validate-self-hosting-receipts_test.go", + "commandIds": [ + "proofkit.go-test" + ], + "environmentClasses": [ + "local-go" + ] + }, { "requirementId": "REQ-PROOFKIT-QUALITY-016", "scenarioId": "proofkit.supply-chain-quality.release-platform-package-artifact", diff --git a/scripts/source-hygiene.mjs b/scripts/source-hygiene.mjs index 0344e11..02476ee 100644 --- a/scripts/source-hygiene.mjs +++ b/scripts/source-hygiene.mjs @@ -8,6 +8,7 @@ const textExtensions = new Set([ ".json", ".md", ".mjs", + ".py", ".ts", ".yml", ".yaml", diff --git a/scripts/source_hygiene_test.go b/scripts/source_hygiene_test.go index f9dd284..79a1232 100644 --- a/scripts/source_hygiene_test.go +++ b/scripts/source_hygiene_test.go @@ -11,56 +11,66 @@ import ( func TestSourceHygieneReadsStagedBlob(t *testing.T) { t.Parallel() - scriptPath := sourceHygieneScriptPath(t) - tempDir := t.TempDir() - runCommand(t, tempDir, "git", "init") + for _, file := range []string{"README.md", "proof.py"} { + file := file + t.Run(file, func(t *testing.T) { + t.Parallel() + scriptPath := sourceHygieneScriptPath(t) + tempDir := t.TempDir() + runCommand(t, tempDir, "git", "init") - bannedToken := strings.Join([]string{"a", "fc"}, "") - readmePath := filepath.Join(tempDir, "README.md") - if err := os.WriteFile(readmePath, []byte("leaked "+bannedToken+"\n"), 0o644); err != nil { - t.Fatalf("write staged README: %v", err) - } - runCommand(t, tempDir, "git", "add", "README.md") - if err := os.WriteFile(readmePath, []byte("clean\n"), 0o644); err != nil { - t.Fatalf("clean worktree README: %v", err) - } + bannedToken := strings.Join([]string{"a", "fc"}, "") + path := filepath.Join(tempDir, file) + if err := os.WriteFile(path, []byte("leaked "+bannedToken+"\n"), 0o644); err != nil { + t.Fatalf("write staged file: %v", err) + } + runCommand(t, tempDir, "git", "add", file) + if err := os.WriteFile(path, []byte("clean\n"), 0o644); err != nil { + t.Fatalf("clean worktree file: %v", err) + } - command := exec.Command("node", scriptPath) - command.Dir = tempDir - output, err := command.CombinedOutput() - if err == nil { - t.Fatalf("source hygiene passed despite staged banned token:\n%s", output) - } - if !strings.Contains(string(output), "organization-specific text leaked into Proofkit: README.md") { - t.Fatalf("source hygiene output=%s, want staged README failure", output) + assertSourceHygieneRejects(t, scriptPath, tempDir, file, "staged") + }) } } func TestSourceHygieneReadsTrackedWorktree(t *testing.T) { t.Parallel() - scriptPath := sourceHygieneScriptPath(t) - tempDir := t.TempDir() - runCommand(t, tempDir, "git", "init") + for _, file := range []string{"README.md", "proof.py"} { + file := file + t.Run(file, func(t *testing.T) { + t.Parallel() + scriptPath := sourceHygieneScriptPath(t) + tempDir := t.TempDir() + runCommand(t, tempDir, "git", "init") - readmePath := filepath.Join(tempDir, "README.md") - if err := os.WriteFile(readmePath, []byte("clean\n"), 0o644); err != nil { - t.Fatalf("write clean README: %v", err) - } - runCommand(t, tempDir, "git", "add", "README.md") - bannedToken := strings.Join([]string{"a", "fc"}, "") - if err := os.WriteFile(readmePath, []byte("leaked "+bannedToken+"\n"), 0o644); err != nil { - t.Fatalf("dirty worktree README: %v", err) + path := filepath.Join(tempDir, file) + if err := os.WriteFile(path, []byte("clean\n"), 0o644); err != nil { + t.Fatalf("write clean file: %v", err) + } + runCommand(t, tempDir, "git", "add", file) + bannedToken := strings.Join([]string{"a", "fc"}, "") + if err := os.WriteFile(path, []byte("leaked "+bannedToken+"\n"), 0o644); err != nil { + t.Fatalf("dirty worktree file: %v", err) + } + + assertSourceHygieneRejects(t, scriptPath, tempDir, file, "tracked worktree") + }) } +} +func assertSourceHygieneRejects(t *testing.T, scriptPath string, repoRoot string, file string, evidenceClass string) { + t.Helper() command := exec.Command("node", scriptPath) - command.Dir = tempDir + command.Dir = repoRoot output, err := command.CombinedOutput() if err == nil { - t.Fatalf("source hygiene passed despite tracked worktree banned token:\n%s", output) + t.Fatalf("source hygiene passed despite %s banned token:\n%s", evidenceClass, output) } - if !strings.Contains(string(output), "organization-specific text leaked into Proofkit: README.md") { - t.Fatalf("source hygiene output=%s, want tracked worktree README failure", output) + want := "organization-specific text leaked into Proofkit: " + file + if !strings.Contains(string(output), want) { + t.Fatalf("source hygiene output=%s, want %s failure", output, file) } } diff --git a/scripts/validate-self-hosting-receipts.go b/scripts/validate-self-hosting-receipts.go index 332fc82..188de19 100644 --- a/scripts/validate-self-hosting-receipts.go +++ b/scripts/validate-self-hosting-receipts.go @@ -8,7 +8,6 @@ import ( "os" "os/exec" "path/filepath" - "runtime" "sort" "strings" "time" @@ -16,6 +15,7 @@ import ( "github.com/research-engineering/agentic-proofkit/internal/command/proofreceiptadmission" "github.com/research-engineering/agentic-proofkit/internal/command/receiptproduceradmission" "github.com/research-engineering/agentic-proofkit/internal/kernel/admission" + "github.com/research-engineering/agentic-proofkit/internal/kernel/releaseplatform" "github.com/research-engineering/agentic-proofkit/internal/kernel/report" "github.com/research-engineering/agentic-proofkit/internal/kernel/stablejson" "github.com/research-engineering/agentic-proofkit/internal/tools/packageartifactrecord" @@ -333,12 +333,11 @@ func runProofkit(command string, inputPath string, outputPath string) error { } func currentPlatformBinary() (string, error) { - osName := runtime.GOOS - cpuName := runtime.GOARCH - if cpuName == "amd64" { - cpuName = "x64" + target, err := releaseplatform.CurrentTarget() + if err != nil { + return "", err } - path := filepath.Join("dist", "platform", osName+"-"+cpuName, "agentic-proofkit") + path := filepath.FromSlash(target.BinaryPath) if stat, err := os.Stat(path); err != nil { return "", fmt.Errorf("current platform binary is unavailable at %s: %w", path, err) } else if stat.IsDir() { diff --git a/scripts/validate-self-hosting-receipts_test.go b/scripts/validate-self-hosting-receipts_test.go index 536a9d9..65d3581 100644 --- a/scripts/validate-self-hosting-receipts_test.go +++ b/scripts/validate-self-hosting-receipts_test.go @@ -9,10 +9,34 @@ import ( "github.com/research-engineering/agentic-proofkit/internal/command/receiptproduceradmission" "github.com/research-engineering/agentic-proofkit/internal/kernel/admission" + "github.com/research-engineering/agentic-proofkit/internal/kernel/releaseplatform" "github.com/research-engineering/agentic-proofkit/internal/kernel/report" "go.yaml.in/yaml/v3" ) +func TestCurrentPlatformBinaryUsesReleasePlatformOwner(t *testing.T) { + target, err := releaseplatform.CurrentTarget() + if err != nil { + t.Skipf("current platform is outside the release matrix: %v", err) + } + root := t.TempDir() + t.Chdir(root) + path := filepath.FromSlash(target.BinaryPath) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte("fixture"), 0o755); err != nil { + t.Fatal(err) + } + got, err := currentPlatformBinary() + if err != nil { + t.Fatal(err) + } + if got != path { + t.Fatalf("currentPlatformBinary()=%q, release platform owner=%q", got, path) + } +} + func TestProducerAdmissionFromEnvironmentDoesNotMintMergeSatisfyingReceipts(t *testing.T) { cases := []struct { name string @@ -352,14 +376,10 @@ func ciSourceQualityProofSteps() []workflowStepExpectation { {name: "Verify npm version", runCommand: "npm run npm:version"}, {name: "Verify source hygiene", runCommand: "npm run source-hygiene"}, {name: "Verify text policy", runCommand: "npm run text-policy"}, + {name: "Verify Mermaid diagrams", runCommand: "npm run mermaid:check"}, {name: "Verify Go formatting", runCommand: "npm run go:fmt"}, - {name: "Run Go tests for app", runCommand: "go test ./cmd/agentic-proofkit ./internal/app"}, - {name: "Run Go tests for kernel", runCommand: "go test ./internal/kernel/..."}, - {name: "Run Go tests for commands", runCommand: "go test ./internal/command/..."}, - {name: "Run Go tests for coverage and package tools", runCommand: "go test ./internal/tools/coveragemetrics ./internal/tools/packagebuild ./internal/tools/packagepack ./internal/tools/packageverify"}, - {name: "Run Go tests for registry and Python tools", runCommand: "go test ./internal/tools/pypiregistry ./internal/tools/pythonpackage"}, - {name: "Run Go tests for release tools", runCommand: "go test ./internal/tools/releasecloseoutinput ./internal/tools/releasemanifest ./internal/tools/releasepreflight ./internal/tools/releasesbom ./internal/tools/textpolicyinput"}, - {name: "Run Go tests for scripts", runCommand: "go test ./scripts"}, + {name: "Verify generated command family catalog", runCommand: "npm run command-family:check"}, + {name: "Run all Go tests", runCommand: "npm run go:test"}, {name: "Run Go vet", runCommand: "npm run go:vet"}, {name: "Run staticcheck", runCommand: "npm run go:staticcheck"}, {name: "Run actionlint", runCommand: "npm run go:actionlint"}, diff --git a/scripts/workflow_package_gate_oracle_test.go b/scripts/workflow_package_gate_oracle_test.go index 27dc0c2..f11c6e9 100644 --- a/scripts/workflow_package_gate_oracle_test.go +++ b/scripts/workflow_package_gate_oracle_test.go @@ -645,6 +645,38 @@ func TestCIWorkflowDeclaresFailClosedRequiredAggregate(t *testing.T) { } } +func TestCISourceQualityInstallsPythonBeforeLifecycleTests(t *testing.T) { + workflow := readWorkflowForTest(t, filepath.Join("..", ".github", "workflows", "ci.yml")) + job, ok := workflow.Jobs["source-quality"] + if !ok { + t.Fatal("ci workflow missing source-quality job") + } + setupIndex, err := uniqueStepIndex(job.Steps, "Setup Python") + if err != nil { + t.Fatal(err) + } + if setupIndex < 0 { + t.Fatal("source-quality job missing Setup Python") + } + testIndex, err := uniqueStepIndex(job.Steps, "Run all Go tests") + if err != nil { + t.Fatal(err) + } + if testIndex < 0 { + t.Fatal("source-quality job missing Run all Go tests") + } + if setupIndex >= testIndex { + t.Fatalf("Setup Python index=%d must precede Go tests index=%d", setupIndex, testIndex) + } + setup := job.Steps[setupIndex] + if setup.Uses != "actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1" { + t.Fatalf("Setup Python uses=%q, want pinned actions/setup-python v6.3.0", setup.Uses) + } + if got := withString(setup.With, "python-version"); got != "3.14.6" { + t.Fatalf("Setup Python python-version=%q, want 3.14.6", got) + } +} + func withString(values map[string]any, key string) string { if values == nil { return ""