diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c5a0e53..e453b8e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -69,6 +69,17 @@ jobs: with: targets: x86_64-unknown-linux-musl + - name: Provision pinned cargo-udeps proof artifact + run: | + rustup toolchain install nightly-2026-07-27 --profile minimal + cargo +nightly-2026-07-27 install cargo-udeps --version 0.1.61 --locked + test "$(cargo +nightly-2026-07-27 udeps --version)" = "cargo-udeps 0.1.61" + + - name: Provision pinned Rust function-metrics proof artifact + run: | + cargo install rust-code-analysis-cli --version 0.0.25 --locked + test "$(rust-code-analysis-cli --version)" = "rust-code-analysis-cli 0.0.25" + - name: Setup Linux musl toolchain run: sudo apt-get update && sudo apt-get install -y musl-tools @@ -83,6 +94,8 @@ jobs: - name: Check run: npm run ci + env: + OPCORE_RUST_NIGHTLY_TOOLCHAIN: nightly-2026-07-27 - name: Prove authoritative mypy after-state execution run: npm run python:mypy-authority-proof diff --git a/.gitignore b/.gitignore index f405b62..afcd905 100644 --- a/.gitignore +++ b/.gitignore @@ -2,18 +2,15 @@ node_modules/ dist/ *.tsbuildinfo target/ -.ace/ .agents/ .claude/ .codex/ .gemini/ .opencode/ -.code-review-graph/ -.rox-cache/ -.robustness-engine-cache/ .zeroshot/* !.zeroshot/settings.json -.opcore/ +.opcore/* +!.opcore/config .asp/ coverage/ *.tgz diff --git a/.opcore/config b/.opcore/config new file mode 100644 index 0000000..a78f43d --- /dev/null +++ b/.opcore/config @@ -0,0 +1,85 @@ +{ + "validation": { + "adapters": [ + "typescript", + "rust", + "python", + "docs", + "clone" + ], + "pathPolicy": { + "exclude": [ + "packages/fixtures/", + "tests/fixtures/", + "packages/opcore-graph-core-darwin-arm64/", + "packages/opcore-graph-core-darwin-x64/", + "packages/opcore-graph-core-linux-x64/" + ] + }, + "checks": { + "packs": [], + "disabled": [], + "defaults": [ + "typescript.syntax", + "typescript.types", + "typescript.lint", + "typescript.import-graph", + "typescript.dead-code", + "typescript.function-metrics", + "typescript.relevant-tests", + "typescript.file-length", + "rust.source-hygiene", + "rust.fmt", + "rust.cargo-check", + "rust.clippy", + "rust.rustdoc", + "rust.import-graph", + "rust.dead-code", + "rust.graph-signals", + "rust.unused-deps", + "rust.file-length", + "rust.function-metrics", + "python.syntax", + "python.source-hygiene", + "python.ruff-lint", + "python.ruff-format", + "python.types", + "python.import-graph", + "python.dead-code", + "python.relevant-tests", + "python.pytest", + "docs.existence", + "docs.staleness", + "docs.freshness", + "docs.length", + "docs.dry", + "docs.content-quality", + "docs.code-blocks", + "docs.rules-why", + "docs.hub-coverage", + "docs.subtree-coverage", + "clone.duplication" + ], + "typescript": { + "fileLength": { + "maxFileLines": 300 + }, + "functionMetrics": { + "maxFunctionLines": 80, + "maxComplexity": 10, + "maxParams": 4 + } + }, + "rust": { + "fileLength": { + "maxFileLines": 500 + }, + "functionMetrics": { + "maxFunctionLines": 80, + "maxComplexity": 10, + "maxParams": 4 + } + } + } + } +} diff --git a/.zeroshot/settings.json b/.zeroshot/settings.json index 5eee803..d6c3208 100644 --- a/.zeroshot/settings.json +++ b/.zeroshot/settings.json @@ -5,7 +5,7 @@ "worktree": { "baseRef": "origin/dev", "setup": [ - "npm run setup" + "npm ci" ] }, "ship": { diff --git a/AGENTS.md b/AGENTS.md index 9bedb52..4227771 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,7 +2,7 @@ UPDATE THIS FILE when making architectural changes, adding patterns, or changing # Opcore -Opcore is the code-intelligence and robustness monorepo for graph context, edit planning, pre-write validation, repo robustness scanning/measurement, and the standalone ASP Core check provider for coding agents. Remaining old-name env vars, cache dirs, fixtures, receipts, and repo-path names are transitional implementation debt; do not introduce new public/product-facing old-name branding. The accepted runtime/CLI boundary is hybrid: Rust graph core with TypeScript contracts, CLI router, edit, validation, validation-typescript, validation-clone, ASP provider facade, npm/Opcore facade, and ACE descriptors. See @docs/architecture/runtime-cli-ard.md and @docs/planning/opcore-alpha-roadmap.md before changing language, package, provider, product, or CLI ownership. +Opcore is the code-intelligence and robustness monorepo for graph context, edit planning, pre-write validation, repo robustness scanning/measurement, and the standalone ASP Core check provider for coding agents. The accepted runtime/CLI boundary is hybrid: Rust graph core with TypeScript contracts, CLI router, edit, validation, validation-typescript, validation-clone, ASP provider facade, npm/Opcore facade, and managed descriptor artifacts. See @docs/architecture/runtime-cli-ard.md and @docs/planning/opcore-alpha-roadmap.md before changing language, package, provider, product, or CLI ownership. ## Key Concepts @@ -11,19 +11,21 @@ Opcore is the code-intelligence and robustness monorepo for graph context, edit | Graph provider | Owns source extraction, persistent graph facts, freshness metadata, graph query contracts, and FTS search index artifacts. | | Edit planner | Owns symbol-aware rename, move, signature, patch, and tree edits; it must validate full edit plans, not isolated files. | | Validation engine | Owns mechanical checks, hypothetical validation, check manifests, and failure policy. | -| Opcore product facade | Thin user-facing robustness loop over graph, validation, edit, and ASP-provider packages: read-only scan/status/check/measure by default, approval-gated init, and no ASP-standard or old-tool replacement claims. | +| Opcore product facade | Thin user-facing robustness loop over graph, validation, edit, and ASP-provider packages: read-only scan/status/check/measure by default and approval-gated init. | | Command adapters | Package-owned graph, edit, check, and validate dispatch surfaces used by canonical `opcore` advanced routes. | -| Current-tool wrappers | Local agent tooling that invokes the already-installed ACE-managed tools; these wrappers are not Opcore implementation artifacts. | +| Repository self-validation | Opcore validates its own changed implementation surface through `npm run opcore:self-check` and `.opcore/config`. | ## Where To Look | Concept | Primary File | |---------|--------------| | Runtime/CLI ARD | @docs/architecture/runtime-cli-ard.md | +| Graph hub inventory | @docs/architecture/graph-hub-inventory.md | | Opcore alpha roadmap | @docs/planning/opcore-alpha-roadmap.md | | Opcore metrics/report/history | `packages/opcore/src/reporting.ts` | | Latency budgets and trend gate | `docs/performance/latency-budgets.json`, `scripts/check-latency-budgets.mjs` | | Public contracts | @packages/contracts/ | +| Public contracts barrel | `packages/contracts/src/index.ts` (API-only exports; domain modules own implementations) | | Contract JSON schema | `packages/contracts/schemas/opcore-contracts.schema.json` | | Command router package | @packages/opcore/src/advanced/ | | Graph provider package track | @packages/graph/ | @@ -34,6 +36,7 @@ Opcore is the code-intelligence and robustness monorepo for graph context, edit | Validation policy composition | @packages/validation-policy/ | | Validation file view | `packages/validation/src/overlays.ts` | | Validation graph client | `packages/validation/src/graph-client.ts` | +| Documentation validation composition | `packages/validation-docs/src/checks.ts`, `packages/validation-docs/src/document-check.ts` | | Rust validation adapter | @packages/validation-rust/ | | TypeScript validation adapter | @packages/validation-typescript/ | | Clone validation adapter | @packages/validation-clone/ | @@ -43,7 +46,6 @@ Opcore is the code-intelligence and robustness monorepo for graph context, edit | ASP warm inspect/edit session | `packages/opcore/src/advanced/asp-warm/`, @docs/architecture/asp-warm-session-ard.md | | ASP provider manifest generator | `scripts/write-asp-provider-manifest.mjs` writes canonical `asp-server.json` plus retained provisional install metadata. | | Golden fixtures and reference evidence | @packages/fixtures/ | -| Graph reference evidence manifest | `packages/fixtures/graph-reference-evidence/manifest.json` | | Graph release fixture | `packages/fixtures/graph-release/release-readiness-fixture.json` | | Graph release receipt | `docs/release/graph-release-receipt.json` | | Graph release payload checksum target | `docs/release/graph-release-receipt.payload.json` | @@ -54,7 +56,6 @@ Opcore is the code-intelligence and robustness monorepo for graph context, edit | Cutover receipt summary | @docs/release/cutover-receipt.summary.md | | ASP dogfood receipt | `docs/release/asp-dogfood-receipt.json` | | ASP dogfood receipt summary | @docs/release/asp-dogfood-receipt.summary.md | -| Retained guardrail matrix | @docs/release/retained-guardrail-matrix.md | | Secret scan allowlist | @docs/release/secret-scan-allowlist.json | | Release receipt generator | `scripts/generate-release-receipt.mjs` | | Cutover receipt generator | `scripts/generate-cutover-receipt.mjs` | @@ -62,30 +63,25 @@ Opcore is the code-intelligence and robustness monorepo for graph context, edit | Workspace checks | `scripts/check-workspace.mjs` | | Package dry-run checks | `scripts/check-packages.mjs` | | Provenance checks | `scripts/check-provenance.mjs` | -| Current ACE tool setup | `scripts/setup-current-tools.sh` | +| Opcore self-check | `scripts/run-opcore-self-check.mjs`, `.opcore/config` | | Local CI-equivalent gate | @scripts/ci/run-local-ci-equivalent.sh | | Zeroshot setup | @.zeroshot/settings.json | | GitHub Actions | @.github/workflows/ | | Tests | @tests/ | -## Current Tooling - -- Run `npm run setup:tools` after cloning or entering a fresh worktree. It writes `.ace/runtime/bin/{rox,crg,cix}` wrappers that exec the current external ACE-managed tools from `LATTICE_CURRENT_TOOLS_DIR`, sibling Covibes repos, or `PATH`. -- Run `npm run ace:install` when ACE provider state or bundled generic skills are missing. It writes ignored generated provider/runtime files under `.claude/`, `.agents/`, `.codex/`, `.gemini/`, `.opencode/`, and `.ace/`; do not hand-edit or commit those trees. Use `npm run ace:sync` after changing `CLAUDE.md`, `AGENTS.md`, or `ace.json`, and `npm run ace:validate` to verify generated ACE state. Repo-specific guidance belongs in this file, not in checked-in provider skills. -- The generated `rox` wrapper also prepends the current ACE-managed `rust-code-analysis-cli` native tool when one is discoverable - WHY: all-mode Rust function metrics must match scoped Rust Rox findings. -- NEVER point `.ace/runtime/bin/{rox,crg,cix}` at `packages/graph`, `packages/edit`, or `packages/validation` before the release/cutover issues say those packages are production-ready - WHY: agents must validate Opcore work with the stable current tools, not with the toolchain being rewritten. -- Source `scripts/dev-env.sh` when interactive shells should prefer the generated wrappers. It fails non-zero and leaves PATH/env untouched when `.ace/runtime/bin/{rox,crg,cix}` is incomplete - WHY: ACE, MCP, Zeroshot, and humans should resolve the same current tool surface. -- `ace.json` routes the code-review graph MCP through `.ace/runtime/bin/crg serve --repo "$repo_root"`; update `ace.json`, `scripts/setup-current-tools.sh`, and this file together when tool acquisition changes. -- Use current external `crg` for discovery before broad text scans, current external `cix` for cohesive symbol/edit deltas, and current external `rox` for staged/changed/repo validation. These are dev validation helpers, not Opcore release surfaces. -- `npm run current-tools:validate-changed` runs `scripts/ci/run-rox-clean-changed-gate.mjs`: it stops Rox, clears `.rox-cache` and `.robustness-engine-cache`, runs daemon-free changed-file Rox, and fails non-baseline findings while retaining legacy code-quality findings already present on the base tree. -- `scripts/ci/run-local-ci-equivalent.sh` uses a docs/agent-guidance fast path when the only changed files are launch docs or agent guidance: setup tools, shell syntax, release hygiene, workspace, provenance, and changed-file Rox. CI-wrapper, source, package, native artifact, release evidence, and other implementation changes still run `npm run ci`, `npm run current-tools:validate-all`, and `npm run current-tools:validate-rust-graph`. `cutover:check` may set `OPCORE_CUTOVER_REUSE_CURRENT_TOOL_GUARDRAILS=1` in root CI and release aggregate proof to reuse validated guardrail hashes from the checked-in cutover receipt while regenerating installed-artifact proof; `cutover:receipt` without the flag must run the retained current-tool commands - WHY: doc-only handoff proofs must fit the cmdproof timeout, aggregate installed-artifact CI must not depend on workstation-only current-tool wrappers, and maintainer receipts must still co-record real retained guardrail proof. +## Development Tooling + +- Run `npm run setup` after cloning or entering a fresh worktree. It installs repository dependencies only and must leave the worktree clean. +- Run `npm run opcore:self-check` after building. It requires `.opcore/config` to select every registered check explicitly with no disabled checks, validates changed-compatible checks in introduced mode against the configured base ref, prepares fresh graph evidence for incompatible repo-wide checks, and requires zero diagnostics across the complete manifest - WHY: self-validation must fail when a new check is not explicitly governed or any supported language loses complexity, tool, graph, docs, or clone coverage. +- `scripts/ci/run-local-ci-equivalent.sh` runs normal CI plus the Opcore self-check. Its docs/agent-guidance fast path uses only repository-native workspace, provenance, build, and self-validation commands. +- Zeroshot worktrees run `npm ci` and the same local CI-equivalent command proof - WHY: humans, agents, CI, and ship clusters must exercise one repository-owned validation surface. - Root `.npmrc` sets `loglevel=silent` - WHY: JSON-emitting npm scripts such as `npm run asp-dogfood:check -- --json` must write parseable JSON to redirected stdout without npm lifecycle preambles. - `npm run test:ci` routes through `scripts/run-test-ci.mjs`: it runs the parallel-safe Node test files first, runs `tests/validation-python.test.mjs` separately because its real subprocess fixtures can exhaust CI process slots under the parallel suite, then runs `tests/native-packaging-policy.test.mjs` separately with receipt gates skipped - WHY: Python compiler-truth tests need deterministic process availability, and the native packaging policy test intentionally mutates native package artifacts while exercising aggregate dry-run failures. +- CI provisions `cargo-udeps` 0.1.61 with `nightly-2026-07-27`, selects that exact toolchain for `rust.unused-deps` through `OPCORE_RUST_NIGHTLY_TOOLCHAIN`, and provisions `rust-code-analysis-cli` 0.0.25 for `rust.function-metrics`; the unused-deps adapter defaults to the conventional `nightly` selector elsewhere - WHY: strict self-validation must execute both retained Rust tool authorities without replacing stable as the repository's default Rust toolchain or allowing either CI authority to float. - Graph-owned transitional `opcore graph serve --repo ` starts the graph package stdio/MCP bridge over graph-core JSONL; `--repo` defaults to cwd, supports ping/status/query/search/shutdown, injects missing nested query repos, and returns typed startup/frame/provider failures. -- #126 ships graph-core through bundled internal Opcore native packages `@the-open-engine/opcore-graph-core-darwin-arm64`, `@the-open-engine/opcore-graph-core-darwin-x64`, and `@the-open-engine/opcore-graph-core-linux-x64`; `packages/graph` resolves only matching package metadata and never probes `packages/graph/dist/native`, sibling checkouts, `.ace/runtime`, or PATH. +- #126 ships graph-core through bundled internal Opcore native packages `@the-open-engine/opcore-graph-core-darwin-arm64`, `@the-open-engine/opcore-graph-core-darwin-x64`, and `@the-open-engine/opcore-graph-core-linux-x64`; `packages/graph` resolves only matching package metadata and never probes `packages/graph/dist/native`, sibling checkouts, or PATH. - Release flow is `dev -> main`: CI runs on `dev` and `main`, PRs to `main` must come from `dev`, and `.github/workflows/release.yml` auto-publishes npm package version `0.2.1` with dist-tag `latest` after the `CI` workflow succeeds on `main`. Each release must provide readable notes at `docs/release/v.md`; the workflow uses that file verbatim for the GitHub release. The CI native jobs upload tarred native package directories so `opcore-graph-core` execute bits survive artifact transfer; aggregate CI and release publish must set `OPCORE_REQUIRE_ALL_NATIVE_PACKAGES=1` after extracting all three native artifacts, and `scripts/release-dry-run.mjs` then validates package-local executable binaries/checksums without rebuilding graph-core - WHY: aggregate and publish proof must consume runnable per-target artifacts produced by native jobs, not a local Linux rebuild or non-executable download. -- #19 keeps `OPCORE_GRAPH_WATCH_PATHS` as the only watch env default and ignores `CRG_WATCH_PATHS` - WHY: Opcore watch roots must not inherit old-tool scoping accidentally. -- #19 graph discovery excludes generated/private/dependency roots even without repo ignore files: `.git`, `node_modules`, `.pnpm`, `vendor`, `dist`, `target`, `.ace`, `.agents`, `.claude`, `.codex`, `.gemini`, `.lattice`, `.opencode`, `.rox-cache`, and `.robustness-engine-cache` - WHY: cache/vendor/provider mirror changes must not create graph facts, freshness changes, validation input, or FTS rows. +- #19 graph discovery excludes generated/private/dependency roots even without repo ignore files: `.git`, `node_modules`, `.pnpm`, `vendor`, `dist`, `target`, `.agents`, `.claude`, `.codex`, `.gemini`, `.lattice`, and `.opencode` - WHY: cache/vendor/provider mirror changes must not create graph facts, freshness changes, validation input, or FTS rows. - #17/#19/#21 source/coverage policy is reconciled across graph-core, validation, and Opcore status/metrics: graph-extractable TypeScript, JavaScript, Python `.py`/`.pyi`, and Rust `.rs`; validation-supported TS/JS variants, Python source/stubs, Rust source/includes, and `Cargo.toml`; retained `Cargo.lock`; unsupported/degraded stacks and missing Python tools are counted honestly. - #16 Python generated/private/dependency roots are excluded from discovery and status census: `.venv`, `venv`, `env`, `__pycache__`, `.eggs`, `build`, `.tox`, `.mypy_cache`, `.pytest_cache`, `.ruff_cache`, `site-packages`, `*.egg-info`, and `*.dist-info` - WHY: dependency/cache artifacts must not create graph freshness or coverage evidence. - #17 Python export metadata is best-effort: `__all__` wins when present, otherwise the leading-underscore convention marks module-level public names; file `exports[]` entries must include policy and supportedSymbol - WHY: Python has no enforced export boundary. @@ -93,16 +89,15 @@ Opcore is the code-intelligence and robustness monorepo for graph context, edit - #246 makes `@the-open-engine/opcore-validation-python` the sole dynamic owner of `opcore.python.project-context.v1`: every Python target resolves against its nearest project boundary through an injected read/list/exists/realpath workspace view, smol-toml AST config/build metadata, exact interpreter/tool/build probes, and after-state content. Missing realpath evidence is ambiguous, deleted overlays cannot remain discovery markers, and declared constraints never become invented exact versions. Validation, status, scan, init/install preview, metrics, ASP, and installed execution must reuse the resulting project key, context fingerprint, outcome, and provenance; ASP workspace/config reads must remain host-callback-only. Static descriptors advertise only the schema, outcome vocabulary, read-only behavior, and no-install guarantee - WHY: root-scoped and duplicate project/environment discovery validates nested monorepo files with the wrong interpreter and makes surfaces disagree. #256/#257 make `python.types` select and execute exactly one mypy or Pyright authority per canonical project. Mypy uses first-match config precedence and strict NDJSON. Pyright preserves `pyrightconfig.json` precedence over `[tool.pyright]`, recursive repo-confined extends, and config-driven source/stub semantics while consuming only complete `--outputjson`. Both authorities use the same isolated exact after-state, portable receipt, selected interpreter, bounded process-tree runner, and sanitized HOME/XDG/cache/temp environment. Availability never selects an authority or permits fallback. Malformed, partial, contradictory, version/count-mismatched, out-of-repo, fatal, or stderr protocol evidence fails closed, and every project attempt emits `opcore.python.validation-capability-run` evidence - WHY: host config/imports, source mutation, orphaned checker descendants, availability, human-output parsing, or check-level summaries cannot prove which project/config/after-state produced type evidence. - #258 keeps `python.ruff-lint` and `python.ruff-format` separate from `python.source-hygiene` and opt-in through explicit selection or `.opcore/config` defaults. They execute the #246-selected Ruff over a temporary #245 after-state workspace with fixes, writes, and caches disabled; lint consumes JSON and format uses bounded exit-code refinement. Closest target-applicable `.ruff.toml`, `ruff.toml`, or `[tool.ruff]` configuration searches through the repository root across nested Python project boundaries, partitions project execution, requires non-symlink realpath evidence for every selected or recursively extended config, and materializes only that config closure. Python types and Ruff share the `packages/validation-python/src/python-execution-workspace.ts` primitive and sanitized HOME/XDG/TMP/PATH runtime for after-state fingerprinting, materialization, execution isolation, and cleanup while capability code selects its own support files. Ruff receipts use the shared `afterStateManifestFingerprint` field and portable executable/argv locators. Missing Ruff degrades status only while a Ruff check is active, and metrics require executed capability receipts - WHY: optional source tooling must never be probed, invoked, counted, or reported as enforced when policy did not select it, target-local configuration must not leak across files, host state must not affect results, and parallel materializers can make tool inputs diverge from receipts. - #209 makes Rust graph-core the sole parser/resolver for Python repo imports. `@the-open-engine/opcore-graph` materializes supplied `.py`/`.pyi` after-state files only in an isolated temporary repo and returns canonical directed `IMPORTS_FROM` file edges; validation-python owns only the structural analyzer contract, visible-file enumeration, cached target/transitive closure, and edge consumption. Opcore, advanced validation, validation-policy, and ASP inject the graph adapter; missing/failed/malformed analysis is an infrastructure failure, never empty success - WHY: a second TypeScript import grammar/resolver diverges on multiline syntax, overlays, packages, stubs, namespaces, and src layouts. -- #197 makes hypothetical graph evaluation exact-state: validation creates one `ValidationFileView` per before/after state and owns one disposable graph session for that view; graph materializes the complete visible TS/TSX/JS/JSX, Python `.py`/`.pyi`, and Rust `.rs` universe into a bounded isolated root, builds graph-core once, shares the immutable session across checks, and removes it on every exit. Introduced mode must use distinct before/after snapshots, ASP listings must preserve host truncation, and exact-state construction/query failure is non-pass even when persistent graph mode is optional - WHY: a persistent target-repo graph or incomplete listing cannot describe hypothetical file contents and must never produce a false clean pre-write result. +- #197 makes hypothetical graph evaluation exact-state: validation creates one `ValidationFileView` per before/after state and owns one disposable graph session for that view; graph materializes the complete visible TS/TSX/JS/JSX, Python `.py`/`.pyi`, and Rust `.rs` universe plus root `tsconfig.json` into a bounded isolated root, builds graph-core once, shares the immutable session across checks, and removes it on every exit. Introduced mode must use distinct before/after snapshots, ASP listings must preserve host truncation, and exact-state construction/query failure is non-pass even when persistent graph mode is optional - WHY: a persistent target-repo graph, missing alias configuration, or incomplete listing cannot describe hypothetical file contents and must never produce a false clean pre-write result. - #19 requires graph status to preserve real WAL checkpoint evidence from the latest pipeline summary and release gates to fail missing/fabricated WAL evidence - WHY: freshness and checkpoint pressure must remain host-visible provider facts. - #19 treats `opcore graph serve` as the stdio/MCP hot-query replacement, not a Unix socket, with parallel independent serve sessions as the supported concurrency evidence. -- #19 keeps current external CRG receipts as non-implementation compatibility evidence only - WHY: old CRG remains a guardrail until downstream cutover issues consume the Opcore proof. -- `opcore-asp-provider --stdio` is the transitional provider binary for the standalone ASP Core check provider; it uses host workspace callbacks and Opcore validation only, never ACE descriptors or current-tool wrappers. +- `opcore-asp-provider --stdio` is the transitional provider binary for the standalone ASP Core check provider; it uses host workspace callbacks and Opcore validation only. - `opcore asp serve --stdio` is a hidden host-launched warm ASP session for inspect/edit/check under `packages/opcore/src/advanced/asp-warm/`; it is intercepted before the public router, omitted from `opcore --help`, keeps lifecycle state under `.opcore/asp/`, never auto-spawns, never stays always-on beyond its idle timeout, and never mutates source files - WHY: agents may need warm inspect/edit latency without making ASP a public human command group or changing the cold check provider. -- #120 ASP dogfood uses `npm run asp-dogfood:check` with a temporary `ASP_HOME`, standalone sibling/private ASP manager bootstrap evidence, installed provider evidence, and retained current-tool guardrail receipts. `OPCORE_ASP_DOGFOOD_REUSE_CURRENT_TOOL_GUARDRAILS=1 npm run asp-dogfood:receipt` may refresh installed-artifact evidence from already-recorded retained guardrails, but the default dogfood path must still run those guardrails live - WHY: dogfood proves advisory/shadow host integration without making Opcore the host, manager, authority, or old-tool replacement. +- #120 ASP dogfood uses `npm run asp-dogfood:check` with a temporary `ASP_HOME`, a built adjacent `agent-server-protocol` checkout or explicit `ASP_DOGFOOD_ASP_REPO`, installed provider evidence, and provider/host authority receipts. Receipts redact the resolved manager root as `` - WHY: dogfood proves advisory/shadow host integration without making Opcore the host, manager, or authority or embedding workstation paths. - `opcore [--repo ] [--json]` is the public first-run scan. It emits `repoState` plus `validationResult`, prints Coverage before Findings, and writes only `.opcore/report.json`, `.opcore/history.jsonl`, and bounded `.opcore/telemetry.jsonl` capped at 500 records or 1 MiB. - `opcore --version`, `opcore -v`, and `opcore version` are read-only runtime provenance surfaces. JSON output carries `runtimeInfo` with package name/version, bin, artifact source (`source_checkout`, `installed_package`, or `unknown`), package root, and entrypoint - WHY: agents and humans must know which Opcore binary/artifact is actually running. -- `opcore status [--repo ] [--json]` is the runtime-owned activation/readiness entrypoint. It emits `repoState` and must stay read-only: no graph build/update/watch, validation checks, package installs, ASP setup, ACE setup, current-tool wrapper execution, or source writes. +- `opcore status [--repo ] [--json]` is the runtime-owned activation/readiness entrypoint. It emits `repoState` and must stay read-only: no graph build/update/watch, validation checks, package installs, ASP setup, or source writes. - `opcore doctor [--repo ] [--json]` is the runtime-owned diagnostic entrypoint. It emits `runtimeInfo`, `opcoreDoctor`, and transitional `validationStatus`, reporting version/provenance, config found/missing/unreadable state for `.opcore/config`, loaded check ids, graph freshness, generated-state ignore guidance, and next actions without building graphs, running checks, installing packages, setup, wrappers, or source writes. - `opcore check --changed --json` is the agent gate and defaults to `--base HEAD`. `opcore check --staged`, `opcore check --all`, and explicit file operands are native check scopes; explicit missing files and blank check ids must return structured `invalid_payload` JSON instead of passing zero checks or throwing plain text. - #31 latency telemetry contracts live in `packages/contracts`: `CommandTiming`, `RepoShapeFingerprint`, `CommandLatencyRecord`, `LatencyBudget`, and `LatencyBudgetResult` must stay source-safe, schema/validator/test aligned, and `.opcore/telemetry.jsonl` must remain ring-buffer bounded to 500 records or 1 MiB. Telemetry `bin` is the normalized public bin name and `canonicalCommand` is sanitized command identity, not raw argv or path operands. @@ -113,7 +108,7 @@ Opcore is the code-intelligence and robustness monorepo for graph context, edit - #36 `opcore measure` reads bounded `.opcore/telemetry.jsonl` plus latency budgets and emits `opcoreMeasure.latency.findings[]` for slower or over-budget command/phase observations only; it must not write artifacts, run checks, or emit `ok` latency rows - WHY: slow actions need drillable evidence without turning measure into a runner or score. - #137 `opcore graph serve` writes one bounded `CommandLatencyRecord` per forwarded child frame through a product-CLI-injected telemetry writer, with canonical commands shaped as `opcore graph serve ` and per-op phase ids such as `serve_query`; `npm run latency:check` includes serve and inspect budgets in non-blocking trend mode - WHY: long-lived serve/inspect performance claims need before/after evidence without reintroducing a daemon or bypassing the existing telemetry contract. - `opcore install [--repo ] [--local|--global] [--yes] [--json]` is the recommended scan-first repo/agent setup path and interactive wizard. It runs read-only scan output before setup, prompts in an interactive Git repo to choose repo or global write-gate scope when neither scope nor `--yes`/`--json` is supplied, keeps JSON/non-TTY preview runs plan-only, and approved repo install writes additive `.opcore/config`, delimited guidance, Opcore agent skills, `.opcore/hooks/opcore-agent-gate.mjs`, Claude Code `.claude/settings.json` and Codex `.codex/hooks.json` PreToolUse wiring, a safe active `.git/hooks/pre-commit` when no existing hook is present, managed `.opcore/` `.gitignore` coverage, and `.opcore/init-undo.json`; approved global install writes `~/.opcore/hooks/opcore-agent-gate.mjs`, user-level skill files, merges `~/.claude/settings.json` and `~/.codex/hooks.json`, and records undo in `~/.opcore/init-undo.json`. `opcore uninstall [--repo ] [--local|--global] [--yes] [--json]` removes/restores only recorded Opcore-owned entries. `opcore init` remains the conservative compatibility setup path with explicit `--approve` semantics and the separate opt-in `--fail-closed-hook` script. -- `opcore try [--json]` is the launch demo loop. It creates local TS, Rust, mixed, and unsupported-file sample repos, runs scan/init/check/measure, returns `opcoreTry.published:false`, and must not publish anything or mention old-tool/current-tool names in human output. +- `opcore try [--json]` is the launch demo loop. It creates local TS, Rust, mixed, and unsupported-file sample repos, runs scan/init/check/measure, returns `opcoreTry.published:false`, and must not publish anything. - `opcore` is the only public npm package and intentionally exposes only Opcore-owned public bins: `opcore` plus the bundled ASP provider bin `opcore-asp-provider`. The opcoreGraph/opcoreValidation and cliGraph/validateCommand/editValidation trees remain parallel internal product/advanced-router surfaces, not duplicate drift. `opcore-graph` remains internal allowlisted naming. - Launch-facing naming gates must keep README, quickstart, concepts, examples, demo, agent integration, and `packages/opcore` copy branded as Opcore. Any remaining old-name hit in those surfaces must be explicitly allowlisted as internal/transitional implementation naming. @@ -121,6 +116,7 @@ Opcore is the code-intelligence and robustness monorepo for graph context, edit - ALWAYS keep graph, edit, and validation as separate ownership boundaries - WHY: graph facts, code mutation, and policy checks evolve at different correctness boundaries - Consequence: a single mixed engine makes parity and cutover unverifiable. - ALWAYS put shared wire/types/contracts in `packages/contracts` before another package consumes them - WHY: package-private shape copying creates incompatible command and API surfaces. +- ALWAYS keep `packages/contracts/src/index.ts` as an API-only export barrel; place implementations in domain modules, preserve the root export surface during internal splits, and keep contracts source modules below 300 lines - WHY: the public package needs one stable import surface without returning to a monolithic implementation file. - ALWAYS update `packages/contracts/schemas/opcore-contracts.schema.json`, contract tests, fixture metadata, package exports, and packlists together when changing shared contracts - WHY: Rust/native graph-core consumers and TypeScript packages must consume the same wire artifacts. - ALWAYS dispatch implemented canonical `opcore graph`, `opcore edit`, `opcore check`, and `opcore validate` routes through public package-owned adapters - WHY: package entrypoints must be able to run without importing the aggregate CLI. - NEVER import package implementation internals across package tracks - WHY: graph, edit, and validation must be releasable and testable independently - Consequence: router composition hides runtime coupling until package publishing. @@ -135,30 +131,29 @@ Opcore is the code-intelligence and robustness monorepo for graph context, edit - Repo-owned validation extensions live in `.opcore/config` `validation.checks.packs`, resolve from the target repo root, and must export current `ValidationCheckDefinition` objects; Opcore owns loading/registry validation, repos own policy content. - ALWAYS keep `packages/asp-provider` as a provider-process facade over ASP Core check/evaluate only - WHY: ASP hosts own decisions, authority, gate semantics, workspace grants, and apply behavior. - ALWAYS keep warm ASP inspect/edit composition inside `packages/opcore/src/advanced/asp-warm/` and out of `packages/asp-provider` - WHY: only the advanced Opcore router may combine ASP JSON-RPC with ts-morph inspect/edit state, while the standalone provider must remain cold and check-only. -- ALWAYS keep the Opcore product facade thin over public package adapters - WHY: `opcore` is first-run UX, not a second implementation of graph, validation, edit, ASP host authority, or old-tool behavior. +- ALWAYS keep the Opcore product facade thin over public package adapters - WHY: `opcore` is first-run UX, not a second implementation of graph, validation, edit, or ASP host authority. - ALWAYS make Opcore scan/status/check/measure read-only with respect to source files and require explicit approval before `opcore install` or compatibility `opcore init` writes guidance, hooks, or config - WHY: first-run trust depends on showing value before mutating a repo. - ALWAYS put coverage honesty before Opcore metrics - WHY: the graph engine currently supports TypeScript/JavaScript, Python source/stub files, and syn-backed Rust `.rs` extraction, while unsupported stacks must be counted instead of silently ignored. -- NEVER ship a blended quality score, security/SAST claim, all-stack claim, AI-authorship claim, automatic-fix claim, ASP-standard claim, or old-tool replacement claim from Opcore alpha - WHY: the alpha must survive skeptical drill-down and current receipts keep `oldToolReplacementClaimed: false`. +- NEVER ship a blended quality score, security/SAST claim, all-stack claim, AI-authorship claim, automatic-fix claim, or ASP-standard claim from Opcore alpha - WHY: every claim must survive skeptical drill-down through current receipts. - NEVER add new launch-facing old-name branding - WHY: the public/product name is Opcore. Existing old-name package/bin/repo references are transitional implementation debt to remove or hide before alpha. -- ALWAYS keep Rust validation in `packages/validation-rust` as provider assessment checks composed by the CLI, not host decisions or old-tool wrappers - WHY: Cargo, rustfmt, clippy, rustdoc, import/dead-code, unused dependency, and function-metric evidence must remain package-owned and overlay-safe. +- ALWAYS keep Rust validation in `packages/validation-rust` as provider assessment checks composed by the CLI, not host decisions - WHY: Cargo, rustfmt, clippy, rustdoc, import/dead-code, unused dependency, and function-metric evidence must remain package-owned and overlay-safe. - ALWAYS treat Cargo.lock-only changes as retained compatibility unless a later decision expands Rust adapter ownership - WHY: current parity covers `.rs`, `.inc`, and `Cargo.toml`; lockfile-only policy needs an explicit cutover decision before old guardrails move. - NEVER add public CLI behavior outside @docs/architecture/runtime-cli-ard.md canonical routing - WHY: early command shapes become accidental API promises. - ALWAYS keep `GraphProviderStatus.state` aligned with `failure.category` in TypeScript validators and JSON schema - WHY: consumers branch on both fields for required graph failure policy; contradictory pairs make provider handling ambiguous. - ALWAYS reject blank validation check names before normalization deduplicates or trims them - WHY: blank checks can otherwise become an empty no-check validation request and hide caller mistakes. -- ALWAYS update `rox.json`, CI, and this file in the same change when adding a new implementation language or Rust gate surface - WHY: language support without repo-wide and scoped validation lets agents ship unverified code paths. +- ALWAYS update `.opcore/config`, CI, and this file in the same change when adding a new implementation language or validation gate surface - WHY: language support without repo-wide and scoped self-validation lets agents ship unverified code paths. - NEVER add backward-compatibility shims for removed internal paths - WHY: this is a clean release line; migrate the caller or delete the old path. - ALWAYS keep generated provider/runtime trees out of Git - WHY: descriptors and scripts are source of truth; generated trees drift by machine. -- ALWAYS keep old-tool reference evidence under @docs/graph-reference-evidence/ and @packages/fixtures/graph-reference-evidence/ - WHY: reference data may mention old tools only under allowlisted evidence docs/fixtures, never as implementation package naming or source provenance. - ALWAYS keep staged graph optional-analysis classifications sourced from `graphReleaseOptionalAnalysisSurfaces` - WHY: #13 coverage, #14 flows, #15 communities, and #16 read-only suggestions are non-release-blocking #17 deferred/staged surfaces and must not drift across contracts, fixtures, receipts, or docs. -- ALWAYS update `packages/opcore/src/advanced/descriptor.ts`, `scripts/write-cli-descriptor.mjs`, descriptor fixtures, package packlists, and descriptor validation together when changing ACE acquisition metadata - WHY: ACE must consume installed Opcore release artifacts, not workspace-local paths. +- ALWAYS update `packages/opcore/src/advanced/descriptor.ts`, `scripts/write-cli-descriptor.mjs`, descriptor fixtures, package packlists, and descriptor validation together when changing managed artifact metadata - WHY: installed consumers must resolve package artifacts, not workspace-local paths. - ALWAYS update release receipt contracts, `scripts/generate-release-receipt.mjs`, docs/release receipts, CI, and package/provenance/secret negative tests together when changing release evidence ownership - WHY: #29 is the maintainer release proof gate for the alpha line. - ALWAYS update cutover receipt contracts, `scripts/generate-cutover-receipt.mjs`, docs/release cutover receipts, CI, and cutover negative tests together when changing installed-artifact release behavior - WHY: #30 proves canonical Opcore artifacts replace current external dev tools without fallback. - ALWAYS record installed package file paths and checksums in cutover receipts, including ASP provider manifests - WHY: cutover proof must show packaged artifacts survived installation, not only tarball and package.json evidence. -- ALWAYS keep ASP dogfood advisory/shadow and isolated to temp `ASP_HOME`; co-record `current-tools:validate-changed` and `current-tools:validate-rust-graph`, keep `oldToolReplacementClaimed: false`, and represent inspect/edit gaps as degraded or retained blockers - WHY: #120 proves standalone ASP manager integration without authorizing rollout or retiring Rox/CRG/CIX. +- ALWAYS keep ASP dogfood advisory/shadow and isolated to temp `ASP_HOME`, and represent inspect/edit gaps as degraded or parity blockers - WHY: #120 proves standalone ASP manager integration without authorizing rollout. - ALWAYS update `packages/asp-provider/src/manifest.ts`, `scripts/write-asp-provider-manifest.mjs`, package exports/packlists, release receipts, installed-bin tests, and claim scrub together when changing ASP provider manifest/install metadata - WHY: canonical `asp-server.json` and retained provisional metadata must not imply trust, authority, gate permission, or host apply permission. - ALWAYS treat `darwin-arm64`, `darwin-x64`, and `linux-x64` as the only supported Opcore alpha graph-core native targets until CI aggregate evidence expands the set - WHY: local single-platform builds cannot prove clean public installs for other targets. - ALWAYS require CI aggregate and release publish workflows to download all three Opcore native package artifacts before release receipts, cutover receipts, or npm publish claim cross-platform graph-core readiness - WHY: the public `opcore` package must bundle real per-target checksums, not local fallback or fabricated binaries. -- ALWAYS keep `opcore` launch-facing help, README snippets, smoke output, and JSON named Opcore while preserving transitional `opcore status` compatibility - WHY: first-run activation must not leak internal or old-tool names into the public readiness flow. +- ALWAYS keep `opcore` launch-facing help, README snippets, smoke output, and JSON named Opcore while preserving transitional `opcore status` compatibility - WHY: first-run activation must not leak internal implementation names into the public readiness flow. - ALWAYS keep Opcore metrics finding-only and evidence-backed, with no opaque score or blended quality number - WHY: `opcore measure` is a trend report over concrete counts, not a scoring system. - ALWAYS keep clone detection split between the existing graph-core native subcommand and the injected TypeScript validation-clone adapter - WHY: duplicate-code detection needs native indexing without a daemon, graph-provider dependency, security claim, or extra native package. - ALWAYS keep latency budget gates finding-only and non-blocking by default, with `--fail-on-over` reserved for explicit promotion - WHY: latency evidence should guide regression decisions without becoming an opaque score or live measurement runner. @@ -167,8 +162,8 @@ Opcore is the code-intelligence and robustness monorepo for graph context, edit ## Language And Runtime - Node >=22 and TypeScript are the current scaffold baseline. -- The accepted boundary is hybrid: Rust graph core owns extraction, persistence, watch refresh, hot graph queries, and clone index analysis; TypeScript owns contracts, router-core helpers, CLI composition, package command adapters, edit orchestration, validation policy, validation-typescript and validation-clone adapters, npm facade, and ACE descriptors. -- #21 adds the Cargo workspace, `crates/graph-core`, Rust sidecar protocol, installed native artifacts, checksums, schema-compatible `rox.json` Rust gate metadata, npm Rust gate scripts, workspace Rust/clippy lint policy, `rox.json` code-quality scope for `packages/`, `scripts/`, `tests/`, and `crates/`, repo-wide Rox extension coverage for scoped Rust graph function metrics under `crates/`, scoped `current-tools:validate-rust-graph`, and GitHub Actions Rust setup. +- The accepted boundary is hybrid: Rust graph core owns extraction, persistence, watch refresh, hot graph queries, and clone index analysis; TypeScript owns contracts, router-core helpers, CLI composition, package command adapters, edit orchestration, validation policy, validation-typescript and validation-clone adapters, npm facade, and managed descriptors. +- #21 adds the Cargo workspace, `crates/graph-core`, Rust sidecar protocol, installed native artifacts, checksums, npm Rust gate scripts, workspace Rust/clippy lint policy, and GitHub Actions Rust setup. - #8 adds Wave 1 Rust graph-core source extraction for `.ts`, `.tsx`, `.js`, and `.jsx` through OXC parser crates. #25 adds syn-backed `.rs` extraction through graph-core facts for File, Module, Struct, Enum, Trait, Impl, Function/Method, TypeAlias, Const/Static, Test, Macro, CONTAINS, IMPORTS_FROM, CALLS, IMPLEMENTS, and DEPENDS_ON with Rust language/signature/span/export metadata. #9 adds the SQLite GraphProvider store at `.opcore/graph/graph.db`, freshness metadata, and #19 direct-reader reference evidence. #10 implements `opcore graph build/update/watch/status`, incremental cached FileFacts updates, phase timings, daemon `ping`/`health`, and watch artifacts at `.opcore/graph/daemon/{pid,state.json,daemon.log}`. #11 implements read-only store-backed `impact`, named `query`, `review-context`, and `detect-changes` envelopes. #12 implements Rust graph-core FTS5 search with `nodes_fts`, signature projection, full and incremental index maintenance, typed failures, and canonical `opcore graph search` through the TypeScript graph adapter. Full build/update/status are unscoped unless `--paths` is passed; `OPCORE_GRAPH_WATCH_PATHS` scopes watch only. Pipeline failures return router status `error`/exit 1 without fabricated summaries; status, health, query, and search paths are read-only when the store is missing or stale. Long-tail parser coverage remains follow-up graph work. - #17 adds Python graph-core extraction for `.py` and `.pyi` through `tree-sitter-python`, emitting File/Module/Class/Function/Variable nodes plus CONTAINS/IMPORTS_FROM/CALLS/INHERITS/TESTED_BY edges. Python imports are repo-local best-effort over absolute, relative, package initializer, and stub candidates; unresolved relative or repo-local imports emit warning category `unresolved_import`; validation support remains absent. - The Rust graph fact-model foundation makes Rust node/edge kinds canonical and confirms SQLite store schema v1 already persists Rust fact columns used by #25 Rust extraction. @@ -180,25 +175,26 @@ Opcore is the code-intelligence and robustness monorepo for graph context, edit - #56 adds the validation file view: `@the-open-engine/opcore-validation` composes normalized `ValidationRequest` overlays, resolved scope files, and injected workspace `readFile` access so `opcore validate` checks can read hypothetical after-state writes/deletes, before-state comparisons for introduced report mode, and before-state checksums without mutating the worktree. `ValidationFileView.defaultReadState` is check-visible so adapters with out-of-band readers can bind their before/after source mode to the runner pass. `checksumBefore` conflicts return refused/conflict before checks run. - #26 adds the validation-owned GraphProvider consumer boundary: `ValidationGraphProviderClient`, cached `ValidationGraphQuerySession`, graph requirement preloading, status/query failure mapping, and helper access for metadata, file checksums, IMPORTS_FROM, CALLS, and TESTED_BY facts. - #57 adds the TypeScript validation adapter: `@the-open-engine/opcore-validation-typescript` exports package-owned syntax, type, import-graph, dead-code, and relevant-tests check definitions. Syntax/type checks use the validation file view and an overlay-aware compiler host, including tsconfig path aliases for repo files and deterministic node_modules/package declaration resolution for external package imports; graph checks require #26 graph sessions and batched IMPORTS_FROM, CALLS, and TESTED_BY fact requirements. -- #20 adds the Rust validation adapter: `@the-open-engine/opcore-validation-rust` exports package-owned source hygiene, fmt, cargo-check, clippy, rustdoc, import-graph, dead-code, graph-signals, unused-deps, file-length, and function-metrics checks. `rust.graph-signals` is graph-provider-backed evidence from `ValidationGraphProviderClient` only (untested public Rust surface, dead public exports, module orphans/cycles); it does not replace mechanical cargo/rustdoc/clippy/Rox guardrails. #61 makes the five retained Rust rows native when supporting tools are available: rustdoc diagnostics block through `cargo doc`, import-graph reports unresolved modules/use paths/orphans/cycles from fileView, dead-code combines Cargo `dead_code` with orphan-source evidence, unused-deps parses cargo-udeps with workspace/package scoping, and function-metrics parses rust-code-analysis JSON object/array output. Missing `rustdoc`, `cargo-depgraph`, `cargo-udeps`, or `rust-code-analysis-cli` stays degraded or unsupported with `requiredTool` and retained `currentUsage`; no generic retained row remains for available tools. Rust checks materialize temporary workspaces from `ValidationCheckContext.fileView` after-state content for Cargo tools, add no validation daemon or hidden cache, never shell out to Rox, and keep current external Rust guardrails active until #29/#30 accept replacement receipts. #138 shares cargo-check JSON with dead-code, forbids a second dead-code cargo compile or `-Ddead_code` cache-busting flags, and injects a persistent `CARGO_TARGET_DIR` outside the worktree through `runTool`; cache keys must derive from generic repo, scope, overlay, platform, and toolchain inputs and cleanup must remain TTL/size bounded - WHY: Rust validation must be fast without leaking hypothetical after-state into real worktrees or tuning behavior to one repository. +- Opcore self-validation records file-level `TESTED_BY` evidence when a conventional TS/JS test imports a source file, while retaining symbol-level call evidence. The TypeScript relevant-test adapter emits diagnostics only for missing evidence; positive evidence is a clean check result. The real `tests/source-package-contracts.test.ts` suite imports workspace facades through root tsconfig source aliases and runs in normal local/CI tests - WHY: unsupported `.mjs` runtime suites and built `dist` imports cannot prove source-level relevant-test coverage, and successful evidence is not a finding. +- #20 adds the Rust validation adapter: `@the-open-engine/opcore-validation-rust` exports package-owned source hygiene, fmt, cargo-check, clippy, rustdoc, import-graph, dead-code, graph-signals, unused-deps, file-length, and function-metrics checks. `rust.graph-signals` is graph-provider-backed evidence from `ValidationGraphProviderClient` only (untested public Rust surface, dead public exports, module orphans/cycles), while mechanical evidence remains owned by Cargo and the configured native tools. Missing `rustdoc`, `cargo-depgraph`, `cargo-udeps`, or `rust-code-analysis-cli` stays degraded or unsupported with `requiredTool`. Rust checks materialize temporary workspaces from `ValidationCheckContext.fileView` after-state content for Cargo tools and add no validation daemon or hidden cache. #138 shares cargo-check JSON with dead-code, forbids a second dead-code cargo compile or `-Ddead_code` cache-busting flags, and injects a persistent `CARGO_TARGET_DIR` outside the worktree through `runTool`; cache keys must derive from generic repo, scope, overlay, platform, and toolchain inputs and cleanup must remain TTL/size bounded - WHY: Rust validation must be fast without leaking hypothetical after-state into real worktrees or tuning behavior to one repository. - Rust Cargo/native checks share one environment-keyed materialized workspace per validation file-view state through runner-owned disposable resources; the runner removes it on every exit - WHY: exact staged/tree/overlay semantics require state isolation, while per-check whole-repo copies create unbounded filesystem-event churn. - #151/#214 adds clone detection through the existing `opcore-graph-core` binary as a `clone` subcommand plus `@the-open-engine/opcore-validation-clone`. `CloneAnalysisRequest`/`CloneAnalysisResult` use `opcore.clone.v1`; committed analysis may refresh `.opcore/clone/clone.db`, while scoped or hypothetical analysis is ephemeral. The sparse request shape sends committed candidates as `paths`/`sourcePaths` plus `sourceReadMode`/`sourceTreeRef`, with full content only in write overlays. `clone.duplication` is a validation adapter check with injected native invocation, no graph requirement, no line-level identity, no daemon, and no SAST/security or score claim. - #27 adds canonical validation CLI surfaces: `opcore check` implements `files`, `staged`, `changed`, `tree`, `all`, and `manifest`; `tree` reads committed Git tree content from `--tree ` and scopes files from `--changed-from ` without consuming dirty worktree files. `opcore validate` implements `--request-file`, `hypothetical --request-file`, `pre-write --request-file --timeout-ms --json`, and `manifest`; runtime-owned `opcore status --json` includes `repoState`, while `opcore doctor --json` includes typed `runtimeInfo`, `opcoreDoctor`, and transitional `validationStatus` payloads with adapter routes, check ids, manifest entries, graph status, and daemon readiness metadata. #58 defines `opcore validate pre-write --request-file --timeout-ms 30000 --json` as the hook-safe, fail-closed pre-write contract with typed `PreWriteValidationReceipt` output. - #69 removes public runtime lifecycle command groups: top-level start and stop are unsupported unless a later architecture decision adopts them. Runtime readiness remains `opcore status` and `opcore doctor`; graph daemon lifecycle/status remains graph-owned under `opcore graph`. -- #118 adds bundled `@the-open-engine/opcore-asp-provider` internals and the public `opcore-asp-provider --stdio` bin from the `opcore` package as an independently launchable ASP Core check provider facade. It handles `initialize`, `initialized`, and `check/evaluate`, maps ASP create/modify/delete/rename changesets into validation overlays through host `workspace/listTree` and `workspace/readBlob` callbacks, runs the same TypeScript and Rust validation checks as Opcore validation composition, reports degraded/unsupported coverage for missing graph/toolchain/provider surfaces, emits provider-owned diagnostics, binds `validAsOf` to baseline/changeset/read blobs, and strips host-owned decision/authority/apply fields. It does not add a `opcore asp` router group and must not use ACE descriptors, `.ace/runtime`, `rox`, `crg`, or `cix` as implementation paths. +- #118 adds bundled `@the-open-engine/opcore-asp-provider` internals and the public `opcore-asp-provider --stdio` bin from the `opcore` package as an independently launchable ASP Core check provider facade. It handles `initialize`, `initialized`, and `check/evaluate`, maps ASP create/modify/delete/rename changesets into validation overlays through host `workspace/listTree` and `workspace/readBlob` callbacks, runs the same TypeScript and Rust validation checks as Opcore validation composition, reports degraded/unsupported coverage for missing graph/toolchain/provider surfaces, emits provider-owned diagnostics, binds `validAsOf` to baseline/changeset/read blobs, and strips host-owned decision/authority/apply fields. It does not add a public `opcore asp` router group. - #128 adds `opcore status` and `opcore status --json` as the read-only repo-aware activation command. It resolves repo/Git state, coverage, graph status/action, validation adapter/check availability, degraded Rust tools, cheap ASP enrollment hints, warnings, blockers, and next actions without running builds, checks, installs, setup, wrappers, or writes. Its JSON payload is `repoState`; `opcore status` keeps the transitional `validationStatus` payload. - #129 adds `opcore` and the standalone `opcore` bin. Zero-command scan uses `repoState` from #128, runs validation without source mutation, prints Coverage before Findings, writes only `.opcore/report.json`, `.opcore/history.jsonl`, and bounded `.opcore/telemetry.jsonl` capped at 500 records or 1 MiB, exposes `opcore check --changed|--staged|--all| --json` with stable agent exit codes, and exposes read-only `opcore --version`/`opcore version` runtime provenance. - #130 adds `packages/opcore/src/reporting.ts`, `OpcoreMetricReport`, `OpcoreMetricHistoryEntry`, and `OpcoreMeasureDelta`. Reports aggregate TS/JS syntax/type/test/dead-export diagnostics, graph structure/fan-in evidence when supplied, Rust source hygiene/file length/module/toolchain diagnostics, unsupported stack census, and honest degradations for unavailable checks/tools/facts. `writeOpcoreMetricArtifacts` writes only under `.opcore/`; `opcore status` excludes those generated artifacts from coverage; `opcore measure` reads them and returns deltas without validationResult, validationStatus, scans, graph builds, setup, or source writes. -- #131 adds `packages/opcore/src/init.ts` and the `opcoreInit` router payload. `opcore init` detects existing `AGENTS.md`, `CLAUDE.md`, `GEMINI.md`, `.github/copilot-instructions.md`, `.codex/AGENTS.md`, and `.opencode/AGENTS.md`, runs a read-only scan without `.opcore/report.json` or history writes, emits scan/settings/interaction/timing payloads, presents a plan before writing, prompts on TTY only, and now chooses repo/global write-gate scope by flag or interactive Git prompt. Approved repo init upserts a single `` block, writes additive `.opcore/config`, appends one managed `.opcore/` `.gitignore` line only in Git repos that do not already ignore it, installs `.opcore/hooks/opcore-agent-gate.mjs`, and merges Claude Code plus Codex PreToolUse hook settings without clobbering existing hooks. Approved global init installs the same adapter under `~/.opcore/hooks/`, merges `~/.claude/settings.json` and `~/.codex/hooks.json`, and records undo under `~/.opcore/init-undo.json`. The adapter maps Write/Edit/MultiEdit and Codex apply_patch payloads to hypothetical validation overlays, calls `opcore validate pre-write --request-file --timeout-ms 30000 --json`, exits 2 on non-ok receipts or validation command failure, and fail-opens only when the adapter cannot parse/map the harness payload. Approved writes refuse symlink targets or symlink ancestors for repo and global paths. Undo refuses metadata whose recorded root does not match or whose entries are outside Opcore-owned config/hooks/agent guidance paths plus the managed `.gitignore` line; `.gitignore` undo removes only that managed line and deletes an init-created `.gitignore` only when empty. The managed `.opcore/` ignore covers `.opcore/telemetry.jsonl`. The guidance must tell agents to run `opcore check --changed`, preserve existing guardrails, report unsupported/degraded coverage honestly, and not rely on ACE, Rox, CRG, CIX, or ASP host authority for direct Opcore. +- #131 adds `packages/opcore/src/init.ts` and the `opcoreInit` router payload. `opcore init` detects existing `AGENTS.md`, `CLAUDE.md`, `GEMINI.md`, `.github/copilot-instructions.md`, `.codex/AGENTS.md`, and `.opencode/AGENTS.md`, runs a read-only scan without `.opcore/report.json` or history writes, emits scan/settings/interaction/timing payloads, presents a plan before writing, prompts on TTY only, and now chooses repo/global write-gate scope by flag or interactive Git prompt. Approved repo init upserts a single `` block, writes additive `.opcore/config`, appends one managed `.opcore/` `.gitignore` line only in Git repos that do not already ignore it, installs `.opcore/hooks/opcore-agent-gate.mjs`, and merges Claude Code plus Codex PreToolUse hook settings without clobbering existing hooks. Approved global init installs the same adapter under `~/.opcore/hooks/`, merges `~/.claude/settings.json` and `~/.codex/hooks.json`, and records undo under `~/.opcore/init-undo.json`. The adapter maps Write/Edit/MultiEdit and Codex apply_patch payloads to hypothetical validation overlays, calls `opcore validate pre-write --request-file --timeout-ms 30000 --json`, exits 2 on non-ok receipts or validation command failure, and fail-opens only when the adapter cannot parse/map the harness payload. Approved writes refuse symlink targets or symlink ancestors for repo and global paths. Undo refuses metadata whose recorded root does not match or whose entries are outside Opcore-owned config/hooks/agent guidance paths plus the managed `.gitignore` line; `.gitignore` undo removes only that managed line and deletes an init-created `.gitignore` only when empty. The managed `.opcore/` ignore covers `.opcore/telemetry.jsonl`. The guidance must tell agents to run `opcore check --changed`, preserve existing safeguards, report unsupported/degraded coverage honestly, and not rely on ASP host authority for direct Opcore. - #133 adds `packages/opcore/src/try.ts` and the `opcoreTry` router payload. `opcore try` uses generated local sample repos only, keeps output coverage-first with named findings/deltas, includes unsupported-file census evidence, and records clean-room launch proof without public announcement or package publishing. -- #22 adds the edit-core library foundation: `@the-open-engine/opcore-edit` owns deterministic exact, multi-edit, and literal search-replace planners, edit checksums, plan hashes, validation overlay construction, preview mode, repo path policy, Node workspaces, and all-or-nothing atomic apply/rollback. #59 adds canonical `opcore edit exact`, `multi`, `search-replace`, `check`, and `apply` parsing inside the edit package, typed `editPlan`/`editResult` router payloads, no old `cix` aliases, and search-replace uniqueness unless `replaceAll` is true. #60 adds canonical `opcore edit patch` and `tree`: raw unified diff patch input through `--stdin`/`--request-file` or `{patch}` JSON, tree payloads `{repo?,validation?,fileContains?,files:[{path,content,checksumBefore?}|{path,delete:true,checksumBefore?}]}`, patch/tree-only forbidden target policy for absolute paths, parent traversal, UNC paths, symlink escapes, `.gitignore` targets, generated/private roots, and binary content, plus `editResult.rollback` state for atomic apply failures. #24 routes non-empty `opcore edit` apply/check plans through an injected validation runner before writes, rejects validation bypass plans, preserves full `ValidationResult` envelopes in edit results, and keeps `--dry-run` as a non-validating preview. #23 implements canonical `opcore edit rename`, `move`, and `signature` routes as graph-backed, validation-required edit plans: GraphProvider contract status/query/search evidence is required for targeting/freshness, TypeScript/JavaScript language-service materialization stays edit-owned, and apply/check refuses graph freshness changes before validation or writes. -- #149 adds the shared inspect/edit ts-morph Project construction seam: cold inspect signatures and graph-backed implementations default to target import-closure loading; references and symbol edits load the target import closure plus reverse importers instead of whole-repo Projects when that preserves correctness; graphless implementation paths keep whole-repo fallback where reverse dependents cannot be bounded safely; and both inspect/edit accept injected Project plus snapshot/revert hooks for future warm ASP reuse without introducing a daemon. +- #22 adds the edit-core library foundation: `@the-open-engine/opcore-edit` owns deterministic exact, multi-edit, and literal search-replace planners, edit checksums, plan hashes, validation overlay construction, preview mode, repo path policy, Node workspaces, and all-or-nothing atomic apply/rollback. #59 adds canonical `opcore edit exact`, `multi`, `search-replace`, `check`, and `apply` parsing inside the edit package, typed `editPlan`/`editResult` router payloads, and search-replace uniqueness unless `replaceAll` is true. #60 adds canonical `opcore edit patch` and `tree`: raw unified diff patch input through `--stdin`/`--request-file` or `{patch}` JSON, tree payloads `{repo?,validation?,fileContains?,files:[{path,content,checksumBefore?}|{path,delete:true,checksumBefore?}]}`, patch/tree-only forbidden target policy for absolute paths, parent traversal, UNC paths, symlink escapes, `.gitignore` targets, generated/private roots, and binary content, plus `editResult.rollback` state for atomic apply failures. #24 routes non-empty `opcore edit` apply/check plans through an injected validation runner before writes, rejects validation bypass plans, preserves full `ValidationResult` envelopes in edit results, and keeps `--dry-run` as a non-validating preview. #23 implements canonical `opcore edit rename`, `move`, and `signature` routes as graph-backed, validation-required edit plans: GraphProvider contract status/query/search evidence is required for targeting/freshness, TypeScript/JavaScript language-service materialization stays edit-owned, and apply/check refuses graph freshness changes before validation or writes. +- #149 makes `packages/edit/src/typescript-project/` the shared owner of ts-morph project discovery, tsconfig selection, import resolution, source listing, scope materialization, and injected-project snapshot composition consumed by edit, CLI-owned inspect, and warm ASP sessions. Cold inspect signatures and graph-backed implementations default to target import-closure loading; references and symbol edits load the target import closure plus reverse importers instead of whole-repo Projects when that preserves correctness; graphless implementation paths keep whole-repo fallback where reverse dependents cannot be bounded safely; and both inspect/edit accept injected Project plus snapshot/revert hooks without introducing a daemon - WHY: inspect and warm sessions must not maintain a second TypeScript project scanner or import resolver that diverges from symbol edits. - #153 adds the hidden `opcore asp serve --stdio` warm ASP session: host-launched only, process-local singleton/idle lifecycle under `.opcore/asp`, warm injected whole-repo ts-morph Project for `inspect/references` and `edit/rename` preview, unchanged delegated `check/evaluate`, `session/shutdown`, no source writes, no public help/manifest command group, no auto-spawned daemon, and no change to the cold `opcore-asp-provider --stdio` check-only capability. - #17 adds the graph release readiness receipt gate: `npm run graph-release:check` proves canonical graph commands, direct SQLite queries, serve transport, package inspection, provenance/license receipts, benchmark metrics, and handoff data for #7/#28/#29. -- #28 adds the aggregate Opcore ACE descriptor contract and artifact: `opcore` packages `dist/descriptors/opcore.managed-tool.json`, generated from `packages/opcore/src/advanced/descriptor.ts` after build. Descriptors must list only the canonical `opcore` bin, command groups graph/inspect/edit/check/validate/status/doctor, package-relative artifact/checksum paths, GraphProvider daemon/query/search/native capabilities, edit validation dependency, validation graph optional/required modes, and deferred #13-#16 optional surfaces as metadata. Descriptor validators must reject private runtime roots such as `.ace` on either slash style. +- #28 adds the aggregate Opcore managed descriptor contract and artifact: `opcore` packages `dist/descriptors/opcore.managed-tool.json`, generated from `packages/opcore/src/advanced/descriptor.ts` after build. Descriptors list only the canonical `opcore` bin, command groups graph/inspect/edit/check/validate/status/doctor, package-relative artifact/checksum paths, GraphProvider query/search/native capabilities, edit validation dependency, validation graph optional/required modes, and deferred optional surfaces as metadata. - #29 adds the repo-wide release receipt gate: `npm run release-receipt:check` proves the single public `opcore` tarball, bundled internal implementation/native/runtime dependencies, exact packlists, sha256 checksums, descriptor/provider manifest artifact resolution, canonical Opcore command groups, native graph artifact checksum evidence, production and bundled dependency licenses, provenance scans, release hygiene, graph #17 input evidence, and current-tree plus git-history secret scans. `npm run release-receipt:receipt` refreshes `docs/release/release-receipt.json`, `docs/release/release-receipt.summary.md`, license/provenance reports, and artifact attestation docs. Secret allowlist entries live only in `docs/release/secret-scan-allowlist.json` and must include reviewed path or commit scope, reviewer, reason, expiry, and optional fingerprint/kind narrowing; remove real findings instead of allowlisting them. -- #30 adds the installed-artifact cutover gate: `npm run cutover:check` packs and installs only the public `opcore` package into a clean temp project, clears current-tool env resolution, excludes local wrapper and sibling paths, verifies installed canonical bins (`opcore` and `opcore-asp-provider`), validates `ReleaseCutoverReceipt`, proves graph/inspect/edit/check/validate/status/doctor/pre-write plus scan/measure flows through `opcore`, and rejects old-tool/private-path markers or advertised `not_implemented` release commands. Each command receipt id is contract-bound to its expected canonical command/status/exit. Top-level `opcore inspect symbols|definition|references|signature|implementations|search` is read-only CLI behavior; signature and implementations are implemented read-only language-service parity. `opcore graph inspect` is not an advertised release route. -- #72 adds typed inspect reference results and `opcore inspect references --line [--column ]` for read-only CIX refs parity over fresh graph facts plus an inspect-owned TypeScript/JavaScript language-service seam. #100 adds shared read-only `InspectSignatureResult` and `InspectImplementationResult` contracts, fixture foundations, target parsing, graph freshness enforcement, and typed `unsupported_route` scaffolds. #101 implements `opcore inspect signature --line [--column ]` and node-id targeting for read-only CIX `sig` parity over fresh graph facts. #102 implements `opcore inspect implementations --line [--column ]` and class/type node-id targets for CIX `impls` parity over graph `IMPLEMENTS`/`INHERITS` facts plus TypeScript/TSX language-service materialization. +- #30 adds the installed-artifact cutover gate: `npm run cutover:check` packs and installs only the public `opcore` package into a clean temp project, sanitizes execution paths, verifies installed canonical bins (`opcore` and `opcore-asp-provider`), validates `ReleaseCutoverReceipt`, and proves graph/inspect/edit/check/validate/status/doctor/pre-write plus scan/measure flows through `opcore`. Each command receipt id is contract-bound to its expected canonical command/status/exit. Top-level `opcore inspect symbols|definition|references|signature|implementations|search` is read-only CLI behavior; signature and implementations are implemented read-only language-service parity. `opcore graph inspect` is not an advertised release route. +- #72 adds typed inspect reference results and `opcore inspect references --line [--column ]` over fresh graph facts plus an inspect-owned TypeScript/JavaScript language-service seam. #100 adds shared read-only `InspectSignatureResult` and `InspectImplementationResult` contracts, fixture foundations, target parsing, graph freshness enforcement, and typed `unsupported_route` scaffolds. #101 implements `opcore inspect signature --line [--column ]` and node-id targeting over fresh graph facts. #102 implements `opcore inspect implementations --line [--column ]` and class/type node-id targets over graph `IMPLEMENTS`/`INHERITS` facts plus TypeScript/TSX language-service materialization. - #141 keeps inspect file-symbol routes useful when graph facts are missing or stale: supported TS/JS `references` and `signature`, plus existing TS/TSX `implementations`, return read-only language-service payloads with `inspectResult.status: "degraded"` and `failure.category: "graph_unavailable"` instead of hard failure; unsupported paths and graph-only node-id targets remain hard failures or unsupported as before. - The provenance GitHub workflow must install stable Rust and run `npm run build` before `npm run release-receipt:check` - WHY: release receipts import ignored `dist/` contracts/descriptors and require native graph artifacts from a clean checkout. @@ -206,15 +202,17 @@ Opcore is the code-intelligence and robustness monorepo for graph context, edit - ALL tests live in @tests/ until a package-specific test harness is explicitly introduced by an issue. - Add contract tests before implementation tests for GraphProvider, EditPlan, ValidationRequest, and canonical CLI behavior. -- Add golden/reference fixtures before replacing current external tool behavior. +- Add golden/reference fixtures before changing established behavior. - Keep release hygiene, conformance metadata, and package packlist gates executable when changing package or release surfaces - WHY: maintainer release receipts must fail before public alpha assumptions drift. -- Add #29 negative fixtures for release evidence regressions: Python code-review-graph provenance, high-confidence secrets, unexpected package files, old public bins, descriptor artifact drift, and missing native checksum evidence. -- Add #30 negative fixtures for cutover regressions: current-tool descriptor markers, advertised placeholder command receipts, missing cutover command receipts, and old bin fallback in installed projects. -- Local proof for agent work is `npm run ci:local`; for source/package/native/release changes it regenerates current-tool wrappers, runs `npm run ci`, runs repo-wide Rox, then runs the Rust graph function-metrics check. For docs/agent-guidance-only changes it uses the local CI fast path described above. GitHub Actions run Node and Rust gates while current external ACE tools remain local-worktree dependencies. +- Add #29 negative fixtures for release evidence regressions: high-confidence secrets, unexpected package files, + descriptor artifact drift, and missing native checksum evidence. +- Add #30 negative fixtures for cutover regressions: advertised unimplemented command receipts, missing cutover command + receipts, and invalid installed package/bin surfaces. +- Local proof for agent work is `npm run ci:local`; for source/package/native/release changes it runs `npm run ci` and then `npm run opcore:self-check`. Docs/agent-guidance-only changes use the repository-native fast path described above. ## Commands -- Setup and wrappers: `npm run setup`, `npm run setup:tools`, `source scripts/dev-env.sh`, `npm run ace:install`, `npm run ace:sync`, `npm run ace:validate`. -- Proof: core `npm run ci`, targeted `node --test tests/...`, `npm run rust:check`, `npm run ci:local` or `npm run verify`; release `npm run graph:artifact`, `npm run descriptor:artifact`, `npm run asp-provider:manifest`, `npm run graph-release:check`, `npm run release-receipt:check`, `npm run cutover:check`, `npm run asp-dogfood:check`, `npm run pack:check`, `npm run release:hygiene`; retained guardrails `npm run current-tools:validate-changed`, `npm run current-tools:validate-all`, `npm run current-tools:validate-rust-graph`. +- Setup: `npm run setup`. +- Proof: core `npm run ci`, `npm run opcore:self-check`, targeted `node --test tests/...`, `npm run rust:check`, `npm run ci:local` or `npm run verify`; release `npm run graph:artifact`, `npm run descriptor:artifact`, `npm run asp-provider:manifest`, `npm run graph-release:check`, `npm run release-receipt:check`, `npm run cutover:check`, `npm run asp-dogfood:check`, `npm run pack:check`, `npm run release:hygiene`. - Public scan/readiness: `opcore --repo . --json`, `opcore status --repo . --json`, `opcore --version --json`, `opcore doctor --repo . --json`, `opcore measure --repo . --json`, `opcore try --json`. - Public setup/check/provider: `opcore install --repo . --json`, `opcore install --repo . --yes --json`, `opcore uninstall --repo . --yes --json`, `opcore check --changed --json`, `opcore-asp-provider --stdio`. diff --git a/CLAUDE.md b/CLAUDE.md index 9bedb52..4227771 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,7 +2,7 @@ UPDATE THIS FILE when making architectural changes, adding patterns, or changing # Opcore -Opcore is the code-intelligence and robustness monorepo for graph context, edit planning, pre-write validation, repo robustness scanning/measurement, and the standalone ASP Core check provider for coding agents. Remaining old-name env vars, cache dirs, fixtures, receipts, and repo-path names are transitional implementation debt; do not introduce new public/product-facing old-name branding. The accepted runtime/CLI boundary is hybrid: Rust graph core with TypeScript contracts, CLI router, edit, validation, validation-typescript, validation-clone, ASP provider facade, npm/Opcore facade, and ACE descriptors. See @docs/architecture/runtime-cli-ard.md and @docs/planning/opcore-alpha-roadmap.md before changing language, package, provider, product, or CLI ownership. +Opcore is the code-intelligence and robustness monorepo for graph context, edit planning, pre-write validation, repo robustness scanning/measurement, and the standalone ASP Core check provider for coding agents. The accepted runtime/CLI boundary is hybrid: Rust graph core with TypeScript contracts, CLI router, edit, validation, validation-typescript, validation-clone, ASP provider facade, npm/Opcore facade, and managed descriptor artifacts. See @docs/architecture/runtime-cli-ard.md and @docs/planning/opcore-alpha-roadmap.md before changing language, package, provider, product, or CLI ownership. ## Key Concepts @@ -11,19 +11,21 @@ Opcore is the code-intelligence and robustness monorepo for graph context, edit | Graph provider | Owns source extraction, persistent graph facts, freshness metadata, graph query contracts, and FTS search index artifacts. | | Edit planner | Owns symbol-aware rename, move, signature, patch, and tree edits; it must validate full edit plans, not isolated files. | | Validation engine | Owns mechanical checks, hypothetical validation, check manifests, and failure policy. | -| Opcore product facade | Thin user-facing robustness loop over graph, validation, edit, and ASP-provider packages: read-only scan/status/check/measure by default, approval-gated init, and no ASP-standard or old-tool replacement claims. | +| Opcore product facade | Thin user-facing robustness loop over graph, validation, edit, and ASP-provider packages: read-only scan/status/check/measure by default and approval-gated init. | | Command adapters | Package-owned graph, edit, check, and validate dispatch surfaces used by canonical `opcore` advanced routes. | -| Current-tool wrappers | Local agent tooling that invokes the already-installed ACE-managed tools; these wrappers are not Opcore implementation artifacts. | +| Repository self-validation | Opcore validates its own changed implementation surface through `npm run opcore:self-check` and `.opcore/config`. | ## Where To Look | Concept | Primary File | |---------|--------------| | Runtime/CLI ARD | @docs/architecture/runtime-cli-ard.md | +| Graph hub inventory | @docs/architecture/graph-hub-inventory.md | | Opcore alpha roadmap | @docs/planning/opcore-alpha-roadmap.md | | Opcore metrics/report/history | `packages/opcore/src/reporting.ts` | | Latency budgets and trend gate | `docs/performance/latency-budgets.json`, `scripts/check-latency-budgets.mjs` | | Public contracts | @packages/contracts/ | +| Public contracts barrel | `packages/contracts/src/index.ts` (API-only exports; domain modules own implementations) | | Contract JSON schema | `packages/contracts/schemas/opcore-contracts.schema.json` | | Command router package | @packages/opcore/src/advanced/ | | Graph provider package track | @packages/graph/ | @@ -34,6 +36,7 @@ Opcore is the code-intelligence and robustness monorepo for graph context, edit | Validation policy composition | @packages/validation-policy/ | | Validation file view | `packages/validation/src/overlays.ts` | | Validation graph client | `packages/validation/src/graph-client.ts` | +| Documentation validation composition | `packages/validation-docs/src/checks.ts`, `packages/validation-docs/src/document-check.ts` | | Rust validation adapter | @packages/validation-rust/ | | TypeScript validation adapter | @packages/validation-typescript/ | | Clone validation adapter | @packages/validation-clone/ | @@ -43,7 +46,6 @@ Opcore is the code-intelligence and robustness monorepo for graph context, edit | ASP warm inspect/edit session | `packages/opcore/src/advanced/asp-warm/`, @docs/architecture/asp-warm-session-ard.md | | ASP provider manifest generator | `scripts/write-asp-provider-manifest.mjs` writes canonical `asp-server.json` plus retained provisional install metadata. | | Golden fixtures and reference evidence | @packages/fixtures/ | -| Graph reference evidence manifest | `packages/fixtures/graph-reference-evidence/manifest.json` | | Graph release fixture | `packages/fixtures/graph-release/release-readiness-fixture.json` | | Graph release receipt | `docs/release/graph-release-receipt.json` | | Graph release payload checksum target | `docs/release/graph-release-receipt.payload.json` | @@ -54,7 +56,6 @@ Opcore is the code-intelligence and robustness monorepo for graph context, edit | Cutover receipt summary | @docs/release/cutover-receipt.summary.md | | ASP dogfood receipt | `docs/release/asp-dogfood-receipt.json` | | ASP dogfood receipt summary | @docs/release/asp-dogfood-receipt.summary.md | -| Retained guardrail matrix | @docs/release/retained-guardrail-matrix.md | | Secret scan allowlist | @docs/release/secret-scan-allowlist.json | | Release receipt generator | `scripts/generate-release-receipt.mjs` | | Cutover receipt generator | `scripts/generate-cutover-receipt.mjs` | @@ -62,30 +63,25 @@ Opcore is the code-intelligence and robustness monorepo for graph context, edit | Workspace checks | `scripts/check-workspace.mjs` | | Package dry-run checks | `scripts/check-packages.mjs` | | Provenance checks | `scripts/check-provenance.mjs` | -| Current ACE tool setup | `scripts/setup-current-tools.sh` | +| Opcore self-check | `scripts/run-opcore-self-check.mjs`, `.opcore/config` | | Local CI-equivalent gate | @scripts/ci/run-local-ci-equivalent.sh | | Zeroshot setup | @.zeroshot/settings.json | | GitHub Actions | @.github/workflows/ | | Tests | @tests/ | -## Current Tooling - -- Run `npm run setup:tools` after cloning or entering a fresh worktree. It writes `.ace/runtime/bin/{rox,crg,cix}` wrappers that exec the current external ACE-managed tools from `LATTICE_CURRENT_TOOLS_DIR`, sibling Covibes repos, or `PATH`. -- Run `npm run ace:install` when ACE provider state or bundled generic skills are missing. It writes ignored generated provider/runtime files under `.claude/`, `.agents/`, `.codex/`, `.gemini/`, `.opencode/`, and `.ace/`; do not hand-edit or commit those trees. Use `npm run ace:sync` after changing `CLAUDE.md`, `AGENTS.md`, or `ace.json`, and `npm run ace:validate` to verify generated ACE state. Repo-specific guidance belongs in this file, not in checked-in provider skills. -- The generated `rox` wrapper also prepends the current ACE-managed `rust-code-analysis-cli` native tool when one is discoverable - WHY: all-mode Rust function metrics must match scoped Rust Rox findings. -- NEVER point `.ace/runtime/bin/{rox,crg,cix}` at `packages/graph`, `packages/edit`, or `packages/validation` before the release/cutover issues say those packages are production-ready - WHY: agents must validate Opcore work with the stable current tools, not with the toolchain being rewritten. -- Source `scripts/dev-env.sh` when interactive shells should prefer the generated wrappers. It fails non-zero and leaves PATH/env untouched when `.ace/runtime/bin/{rox,crg,cix}` is incomplete - WHY: ACE, MCP, Zeroshot, and humans should resolve the same current tool surface. -- `ace.json` routes the code-review graph MCP through `.ace/runtime/bin/crg serve --repo "$repo_root"`; update `ace.json`, `scripts/setup-current-tools.sh`, and this file together when tool acquisition changes. -- Use current external `crg` for discovery before broad text scans, current external `cix` for cohesive symbol/edit deltas, and current external `rox` for staged/changed/repo validation. These are dev validation helpers, not Opcore release surfaces. -- `npm run current-tools:validate-changed` runs `scripts/ci/run-rox-clean-changed-gate.mjs`: it stops Rox, clears `.rox-cache` and `.robustness-engine-cache`, runs daemon-free changed-file Rox, and fails non-baseline findings while retaining legacy code-quality findings already present on the base tree. -- `scripts/ci/run-local-ci-equivalent.sh` uses a docs/agent-guidance fast path when the only changed files are launch docs or agent guidance: setup tools, shell syntax, release hygiene, workspace, provenance, and changed-file Rox. CI-wrapper, source, package, native artifact, release evidence, and other implementation changes still run `npm run ci`, `npm run current-tools:validate-all`, and `npm run current-tools:validate-rust-graph`. `cutover:check` may set `OPCORE_CUTOVER_REUSE_CURRENT_TOOL_GUARDRAILS=1` in root CI and release aggregate proof to reuse validated guardrail hashes from the checked-in cutover receipt while regenerating installed-artifact proof; `cutover:receipt` without the flag must run the retained current-tool commands - WHY: doc-only handoff proofs must fit the cmdproof timeout, aggregate installed-artifact CI must not depend on workstation-only current-tool wrappers, and maintainer receipts must still co-record real retained guardrail proof. +## Development Tooling + +- Run `npm run setup` after cloning or entering a fresh worktree. It installs repository dependencies only and must leave the worktree clean. +- Run `npm run opcore:self-check` after building. It requires `.opcore/config` to select every registered check explicitly with no disabled checks, validates changed-compatible checks in introduced mode against the configured base ref, prepares fresh graph evidence for incompatible repo-wide checks, and requires zero diagnostics across the complete manifest - WHY: self-validation must fail when a new check is not explicitly governed or any supported language loses complexity, tool, graph, docs, or clone coverage. +- `scripts/ci/run-local-ci-equivalent.sh` runs normal CI plus the Opcore self-check. Its docs/agent-guidance fast path uses only repository-native workspace, provenance, build, and self-validation commands. +- Zeroshot worktrees run `npm ci` and the same local CI-equivalent command proof - WHY: humans, agents, CI, and ship clusters must exercise one repository-owned validation surface. - Root `.npmrc` sets `loglevel=silent` - WHY: JSON-emitting npm scripts such as `npm run asp-dogfood:check -- --json` must write parseable JSON to redirected stdout without npm lifecycle preambles. - `npm run test:ci` routes through `scripts/run-test-ci.mjs`: it runs the parallel-safe Node test files first, runs `tests/validation-python.test.mjs` separately because its real subprocess fixtures can exhaust CI process slots under the parallel suite, then runs `tests/native-packaging-policy.test.mjs` separately with receipt gates skipped - WHY: Python compiler-truth tests need deterministic process availability, and the native packaging policy test intentionally mutates native package artifacts while exercising aggregate dry-run failures. +- CI provisions `cargo-udeps` 0.1.61 with `nightly-2026-07-27`, selects that exact toolchain for `rust.unused-deps` through `OPCORE_RUST_NIGHTLY_TOOLCHAIN`, and provisions `rust-code-analysis-cli` 0.0.25 for `rust.function-metrics`; the unused-deps adapter defaults to the conventional `nightly` selector elsewhere - WHY: strict self-validation must execute both retained Rust tool authorities without replacing stable as the repository's default Rust toolchain or allowing either CI authority to float. - Graph-owned transitional `opcore graph serve --repo ` starts the graph package stdio/MCP bridge over graph-core JSONL; `--repo` defaults to cwd, supports ping/status/query/search/shutdown, injects missing nested query repos, and returns typed startup/frame/provider failures. -- #126 ships graph-core through bundled internal Opcore native packages `@the-open-engine/opcore-graph-core-darwin-arm64`, `@the-open-engine/opcore-graph-core-darwin-x64`, and `@the-open-engine/opcore-graph-core-linux-x64`; `packages/graph` resolves only matching package metadata and never probes `packages/graph/dist/native`, sibling checkouts, `.ace/runtime`, or PATH. +- #126 ships graph-core through bundled internal Opcore native packages `@the-open-engine/opcore-graph-core-darwin-arm64`, `@the-open-engine/opcore-graph-core-darwin-x64`, and `@the-open-engine/opcore-graph-core-linux-x64`; `packages/graph` resolves only matching package metadata and never probes `packages/graph/dist/native`, sibling checkouts, or PATH. - Release flow is `dev -> main`: CI runs on `dev` and `main`, PRs to `main` must come from `dev`, and `.github/workflows/release.yml` auto-publishes npm package version `0.2.1` with dist-tag `latest` after the `CI` workflow succeeds on `main`. Each release must provide readable notes at `docs/release/v.md`; the workflow uses that file verbatim for the GitHub release. The CI native jobs upload tarred native package directories so `opcore-graph-core` execute bits survive artifact transfer; aggregate CI and release publish must set `OPCORE_REQUIRE_ALL_NATIVE_PACKAGES=1` after extracting all three native artifacts, and `scripts/release-dry-run.mjs` then validates package-local executable binaries/checksums without rebuilding graph-core - WHY: aggregate and publish proof must consume runnable per-target artifacts produced by native jobs, not a local Linux rebuild or non-executable download. -- #19 keeps `OPCORE_GRAPH_WATCH_PATHS` as the only watch env default and ignores `CRG_WATCH_PATHS` - WHY: Opcore watch roots must not inherit old-tool scoping accidentally. -- #19 graph discovery excludes generated/private/dependency roots even without repo ignore files: `.git`, `node_modules`, `.pnpm`, `vendor`, `dist`, `target`, `.ace`, `.agents`, `.claude`, `.codex`, `.gemini`, `.lattice`, `.opencode`, `.rox-cache`, and `.robustness-engine-cache` - WHY: cache/vendor/provider mirror changes must not create graph facts, freshness changes, validation input, or FTS rows. +- #19 graph discovery excludes generated/private/dependency roots even without repo ignore files: `.git`, `node_modules`, `.pnpm`, `vendor`, `dist`, `target`, `.agents`, `.claude`, `.codex`, `.gemini`, `.lattice`, and `.opencode` - WHY: cache/vendor/provider mirror changes must not create graph facts, freshness changes, validation input, or FTS rows. - #17/#19/#21 source/coverage policy is reconciled across graph-core, validation, and Opcore status/metrics: graph-extractable TypeScript, JavaScript, Python `.py`/`.pyi`, and Rust `.rs`; validation-supported TS/JS variants, Python source/stubs, Rust source/includes, and `Cargo.toml`; retained `Cargo.lock`; unsupported/degraded stacks and missing Python tools are counted honestly. - #16 Python generated/private/dependency roots are excluded from discovery and status census: `.venv`, `venv`, `env`, `__pycache__`, `.eggs`, `build`, `.tox`, `.mypy_cache`, `.pytest_cache`, `.ruff_cache`, `site-packages`, `*.egg-info`, and `*.dist-info` - WHY: dependency/cache artifacts must not create graph freshness or coverage evidence. - #17 Python export metadata is best-effort: `__all__` wins when present, otherwise the leading-underscore convention marks module-level public names; file `exports[]` entries must include policy and supportedSymbol - WHY: Python has no enforced export boundary. @@ -93,16 +89,15 @@ Opcore is the code-intelligence and robustness monorepo for graph context, edit - #246 makes `@the-open-engine/opcore-validation-python` the sole dynamic owner of `opcore.python.project-context.v1`: every Python target resolves against its nearest project boundary through an injected read/list/exists/realpath workspace view, smol-toml AST config/build metadata, exact interpreter/tool/build probes, and after-state content. Missing realpath evidence is ambiguous, deleted overlays cannot remain discovery markers, and declared constraints never become invented exact versions. Validation, status, scan, init/install preview, metrics, ASP, and installed execution must reuse the resulting project key, context fingerprint, outcome, and provenance; ASP workspace/config reads must remain host-callback-only. Static descriptors advertise only the schema, outcome vocabulary, read-only behavior, and no-install guarantee - WHY: root-scoped and duplicate project/environment discovery validates nested monorepo files with the wrong interpreter and makes surfaces disagree. #256/#257 make `python.types` select and execute exactly one mypy or Pyright authority per canonical project. Mypy uses first-match config precedence and strict NDJSON. Pyright preserves `pyrightconfig.json` precedence over `[tool.pyright]`, recursive repo-confined extends, and config-driven source/stub semantics while consuming only complete `--outputjson`. Both authorities use the same isolated exact after-state, portable receipt, selected interpreter, bounded process-tree runner, and sanitized HOME/XDG/cache/temp environment. Availability never selects an authority or permits fallback. Malformed, partial, contradictory, version/count-mismatched, out-of-repo, fatal, or stderr protocol evidence fails closed, and every project attempt emits `opcore.python.validation-capability-run` evidence - WHY: host config/imports, source mutation, orphaned checker descendants, availability, human-output parsing, or check-level summaries cannot prove which project/config/after-state produced type evidence. - #258 keeps `python.ruff-lint` and `python.ruff-format` separate from `python.source-hygiene` and opt-in through explicit selection or `.opcore/config` defaults. They execute the #246-selected Ruff over a temporary #245 after-state workspace with fixes, writes, and caches disabled; lint consumes JSON and format uses bounded exit-code refinement. Closest target-applicable `.ruff.toml`, `ruff.toml`, or `[tool.ruff]` configuration searches through the repository root across nested Python project boundaries, partitions project execution, requires non-symlink realpath evidence for every selected or recursively extended config, and materializes only that config closure. Python types and Ruff share the `packages/validation-python/src/python-execution-workspace.ts` primitive and sanitized HOME/XDG/TMP/PATH runtime for after-state fingerprinting, materialization, execution isolation, and cleanup while capability code selects its own support files. Ruff receipts use the shared `afterStateManifestFingerprint` field and portable executable/argv locators. Missing Ruff degrades status only while a Ruff check is active, and metrics require executed capability receipts - WHY: optional source tooling must never be probed, invoked, counted, or reported as enforced when policy did not select it, target-local configuration must not leak across files, host state must not affect results, and parallel materializers can make tool inputs diverge from receipts. - #209 makes Rust graph-core the sole parser/resolver for Python repo imports. `@the-open-engine/opcore-graph` materializes supplied `.py`/`.pyi` after-state files only in an isolated temporary repo and returns canonical directed `IMPORTS_FROM` file edges; validation-python owns only the structural analyzer contract, visible-file enumeration, cached target/transitive closure, and edge consumption. Opcore, advanced validation, validation-policy, and ASP inject the graph adapter; missing/failed/malformed analysis is an infrastructure failure, never empty success - WHY: a second TypeScript import grammar/resolver diverges on multiline syntax, overlays, packages, stubs, namespaces, and src layouts. -- #197 makes hypothetical graph evaluation exact-state: validation creates one `ValidationFileView` per before/after state and owns one disposable graph session for that view; graph materializes the complete visible TS/TSX/JS/JSX, Python `.py`/`.pyi`, and Rust `.rs` universe into a bounded isolated root, builds graph-core once, shares the immutable session across checks, and removes it on every exit. Introduced mode must use distinct before/after snapshots, ASP listings must preserve host truncation, and exact-state construction/query failure is non-pass even when persistent graph mode is optional - WHY: a persistent target-repo graph or incomplete listing cannot describe hypothetical file contents and must never produce a false clean pre-write result. +- #197 makes hypothetical graph evaluation exact-state: validation creates one `ValidationFileView` per before/after state and owns one disposable graph session for that view; graph materializes the complete visible TS/TSX/JS/JSX, Python `.py`/`.pyi`, and Rust `.rs` universe plus root `tsconfig.json` into a bounded isolated root, builds graph-core once, shares the immutable session across checks, and removes it on every exit. Introduced mode must use distinct before/after snapshots, ASP listings must preserve host truncation, and exact-state construction/query failure is non-pass even when persistent graph mode is optional - WHY: a persistent target-repo graph, missing alias configuration, or incomplete listing cannot describe hypothetical file contents and must never produce a false clean pre-write result. - #19 requires graph status to preserve real WAL checkpoint evidence from the latest pipeline summary and release gates to fail missing/fabricated WAL evidence - WHY: freshness and checkpoint pressure must remain host-visible provider facts. - #19 treats `opcore graph serve` as the stdio/MCP hot-query replacement, not a Unix socket, with parallel independent serve sessions as the supported concurrency evidence. -- #19 keeps current external CRG receipts as non-implementation compatibility evidence only - WHY: old CRG remains a guardrail until downstream cutover issues consume the Opcore proof. -- `opcore-asp-provider --stdio` is the transitional provider binary for the standalone ASP Core check provider; it uses host workspace callbacks and Opcore validation only, never ACE descriptors or current-tool wrappers. +- `opcore-asp-provider --stdio` is the transitional provider binary for the standalone ASP Core check provider; it uses host workspace callbacks and Opcore validation only. - `opcore asp serve --stdio` is a hidden host-launched warm ASP session for inspect/edit/check under `packages/opcore/src/advanced/asp-warm/`; it is intercepted before the public router, omitted from `opcore --help`, keeps lifecycle state under `.opcore/asp/`, never auto-spawns, never stays always-on beyond its idle timeout, and never mutates source files - WHY: agents may need warm inspect/edit latency without making ASP a public human command group or changing the cold check provider. -- #120 ASP dogfood uses `npm run asp-dogfood:check` with a temporary `ASP_HOME`, standalone sibling/private ASP manager bootstrap evidence, installed provider evidence, and retained current-tool guardrail receipts. `OPCORE_ASP_DOGFOOD_REUSE_CURRENT_TOOL_GUARDRAILS=1 npm run asp-dogfood:receipt` may refresh installed-artifact evidence from already-recorded retained guardrails, but the default dogfood path must still run those guardrails live - WHY: dogfood proves advisory/shadow host integration without making Opcore the host, manager, authority, or old-tool replacement. +- #120 ASP dogfood uses `npm run asp-dogfood:check` with a temporary `ASP_HOME`, a built adjacent `agent-server-protocol` checkout or explicit `ASP_DOGFOOD_ASP_REPO`, installed provider evidence, and provider/host authority receipts. Receipts redact the resolved manager root as `` - WHY: dogfood proves advisory/shadow host integration without making Opcore the host, manager, or authority or embedding workstation paths. - `opcore [--repo ] [--json]` is the public first-run scan. It emits `repoState` plus `validationResult`, prints Coverage before Findings, and writes only `.opcore/report.json`, `.opcore/history.jsonl`, and bounded `.opcore/telemetry.jsonl` capped at 500 records or 1 MiB. - `opcore --version`, `opcore -v`, and `opcore version` are read-only runtime provenance surfaces. JSON output carries `runtimeInfo` with package name/version, bin, artifact source (`source_checkout`, `installed_package`, or `unknown`), package root, and entrypoint - WHY: agents and humans must know which Opcore binary/artifact is actually running. -- `opcore status [--repo ] [--json]` is the runtime-owned activation/readiness entrypoint. It emits `repoState` and must stay read-only: no graph build/update/watch, validation checks, package installs, ASP setup, ACE setup, current-tool wrapper execution, or source writes. +- `opcore status [--repo ] [--json]` is the runtime-owned activation/readiness entrypoint. It emits `repoState` and must stay read-only: no graph build/update/watch, validation checks, package installs, ASP setup, or source writes. - `opcore doctor [--repo ] [--json]` is the runtime-owned diagnostic entrypoint. It emits `runtimeInfo`, `opcoreDoctor`, and transitional `validationStatus`, reporting version/provenance, config found/missing/unreadable state for `.opcore/config`, loaded check ids, graph freshness, generated-state ignore guidance, and next actions without building graphs, running checks, installing packages, setup, wrappers, or source writes. - `opcore check --changed --json` is the agent gate and defaults to `--base HEAD`. `opcore check --staged`, `opcore check --all`, and explicit file operands are native check scopes; explicit missing files and blank check ids must return structured `invalid_payload` JSON instead of passing zero checks or throwing plain text. - #31 latency telemetry contracts live in `packages/contracts`: `CommandTiming`, `RepoShapeFingerprint`, `CommandLatencyRecord`, `LatencyBudget`, and `LatencyBudgetResult` must stay source-safe, schema/validator/test aligned, and `.opcore/telemetry.jsonl` must remain ring-buffer bounded to 500 records or 1 MiB. Telemetry `bin` is the normalized public bin name and `canonicalCommand` is sanitized command identity, not raw argv or path operands. @@ -113,7 +108,7 @@ Opcore is the code-intelligence and robustness monorepo for graph context, edit - #36 `opcore measure` reads bounded `.opcore/telemetry.jsonl` plus latency budgets and emits `opcoreMeasure.latency.findings[]` for slower or over-budget command/phase observations only; it must not write artifacts, run checks, or emit `ok` latency rows - WHY: slow actions need drillable evidence without turning measure into a runner or score. - #137 `opcore graph serve` writes one bounded `CommandLatencyRecord` per forwarded child frame through a product-CLI-injected telemetry writer, with canonical commands shaped as `opcore graph serve ` and per-op phase ids such as `serve_query`; `npm run latency:check` includes serve and inspect budgets in non-blocking trend mode - WHY: long-lived serve/inspect performance claims need before/after evidence without reintroducing a daemon or bypassing the existing telemetry contract. - `opcore install [--repo ] [--local|--global] [--yes] [--json]` is the recommended scan-first repo/agent setup path and interactive wizard. It runs read-only scan output before setup, prompts in an interactive Git repo to choose repo or global write-gate scope when neither scope nor `--yes`/`--json` is supplied, keeps JSON/non-TTY preview runs plan-only, and approved repo install writes additive `.opcore/config`, delimited guidance, Opcore agent skills, `.opcore/hooks/opcore-agent-gate.mjs`, Claude Code `.claude/settings.json` and Codex `.codex/hooks.json` PreToolUse wiring, a safe active `.git/hooks/pre-commit` when no existing hook is present, managed `.opcore/` `.gitignore` coverage, and `.opcore/init-undo.json`; approved global install writes `~/.opcore/hooks/opcore-agent-gate.mjs`, user-level skill files, merges `~/.claude/settings.json` and `~/.codex/hooks.json`, and records undo in `~/.opcore/init-undo.json`. `opcore uninstall [--repo ] [--local|--global] [--yes] [--json]` removes/restores only recorded Opcore-owned entries. `opcore init` remains the conservative compatibility setup path with explicit `--approve` semantics and the separate opt-in `--fail-closed-hook` script. -- `opcore try [--json]` is the launch demo loop. It creates local TS, Rust, mixed, and unsupported-file sample repos, runs scan/init/check/measure, returns `opcoreTry.published:false`, and must not publish anything or mention old-tool/current-tool names in human output. +- `opcore try [--json]` is the launch demo loop. It creates local TS, Rust, mixed, and unsupported-file sample repos, runs scan/init/check/measure, returns `opcoreTry.published:false`, and must not publish anything. - `opcore` is the only public npm package and intentionally exposes only Opcore-owned public bins: `opcore` plus the bundled ASP provider bin `opcore-asp-provider`. The opcoreGraph/opcoreValidation and cliGraph/validateCommand/editValidation trees remain parallel internal product/advanced-router surfaces, not duplicate drift. `opcore-graph` remains internal allowlisted naming. - Launch-facing naming gates must keep README, quickstart, concepts, examples, demo, agent integration, and `packages/opcore` copy branded as Opcore. Any remaining old-name hit in those surfaces must be explicitly allowlisted as internal/transitional implementation naming. @@ -121,6 +116,7 @@ Opcore is the code-intelligence and robustness monorepo for graph context, edit - ALWAYS keep graph, edit, and validation as separate ownership boundaries - WHY: graph facts, code mutation, and policy checks evolve at different correctness boundaries - Consequence: a single mixed engine makes parity and cutover unverifiable. - ALWAYS put shared wire/types/contracts in `packages/contracts` before another package consumes them - WHY: package-private shape copying creates incompatible command and API surfaces. +- ALWAYS keep `packages/contracts/src/index.ts` as an API-only export barrel; place implementations in domain modules, preserve the root export surface during internal splits, and keep contracts source modules below 300 lines - WHY: the public package needs one stable import surface without returning to a monolithic implementation file. - ALWAYS update `packages/contracts/schemas/opcore-contracts.schema.json`, contract tests, fixture metadata, package exports, and packlists together when changing shared contracts - WHY: Rust/native graph-core consumers and TypeScript packages must consume the same wire artifacts. - ALWAYS dispatch implemented canonical `opcore graph`, `opcore edit`, `opcore check`, and `opcore validate` routes through public package-owned adapters - WHY: package entrypoints must be able to run without importing the aggregate CLI. - NEVER import package implementation internals across package tracks - WHY: graph, edit, and validation must be releasable and testable independently - Consequence: router composition hides runtime coupling until package publishing. @@ -135,30 +131,29 @@ Opcore is the code-intelligence and robustness monorepo for graph context, edit - Repo-owned validation extensions live in `.opcore/config` `validation.checks.packs`, resolve from the target repo root, and must export current `ValidationCheckDefinition` objects; Opcore owns loading/registry validation, repos own policy content. - ALWAYS keep `packages/asp-provider` as a provider-process facade over ASP Core check/evaluate only - WHY: ASP hosts own decisions, authority, gate semantics, workspace grants, and apply behavior. - ALWAYS keep warm ASP inspect/edit composition inside `packages/opcore/src/advanced/asp-warm/` and out of `packages/asp-provider` - WHY: only the advanced Opcore router may combine ASP JSON-RPC with ts-morph inspect/edit state, while the standalone provider must remain cold and check-only. -- ALWAYS keep the Opcore product facade thin over public package adapters - WHY: `opcore` is first-run UX, not a second implementation of graph, validation, edit, ASP host authority, or old-tool behavior. +- ALWAYS keep the Opcore product facade thin over public package adapters - WHY: `opcore` is first-run UX, not a second implementation of graph, validation, edit, or ASP host authority. - ALWAYS make Opcore scan/status/check/measure read-only with respect to source files and require explicit approval before `opcore install` or compatibility `opcore init` writes guidance, hooks, or config - WHY: first-run trust depends on showing value before mutating a repo. - ALWAYS put coverage honesty before Opcore metrics - WHY: the graph engine currently supports TypeScript/JavaScript, Python source/stub files, and syn-backed Rust `.rs` extraction, while unsupported stacks must be counted instead of silently ignored. -- NEVER ship a blended quality score, security/SAST claim, all-stack claim, AI-authorship claim, automatic-fix claim, ASP-standard claim, or old-tool replacement claim from Opcore alpha - WHY: the alpha must survive skeptical drill-down and current receipts keep `oldToolReplacementClaimed: false`. +- NEVER ship a blended quality score, security/SAST claim, all-stack claim, AI-authorship claim, automatic-fix claim, or ASP-standard claim from Opcore alpha - WHY: every claim must survive skeptical drill-down through current receipts. - NEVER add new launch-facing old-name branding - WHY: the public/product name is Opcore. Existing old-name package/bin/repo references are transitional implementation debt to remove or hide before alpha. -- ALWAYS keep Rust validation in `packages/validation-rust` as provider assessment checks composed by the CLI, not host decisions or old-tool wrappers - WHY: Cargo, rustfmt, clippy, rustdoc, import/dead-code, unused dependency, and function-metric evidence must remain package-owned and overlay-safe. +- ALWAYS keep Rust validation in `packages/validation-rust` as provider assessment checks composed by the CLI, not host decisions - WHY: Cargo, rustfmt, clippy, rustdoc, import/dead-code, unused dependency, and function-metric evidence must remain package-owned and overlay-safe. - ALWAYS treat Cargo.lock-only changes as retained compatibility unless a later decision expands Rust adapter ownership - WHY: current parity covers `.rs`, `.inc`, and `Cargo.toml`; lockfile-only policy needs an explicit cutover decision before old guardrails move. - NEVER add public CLI behavior outside @docs/architecture/runtime-cli-ard.md canonical routing - WHY: early command shapes become accidental API promises. - ALWAYS keep `GraphProviderStatus.state` aligned with `failure.category` in TypeScript validators and JSON schema - WHY: consumers branch on both fields for required graph failure policy; contradictory pairs make provider handling ambiguous. - ALWAYS reject blank validation check names before normalization deduplicates or trims them - WHY: blank checks can otherwise become an empty no-check validation request and hide caller mistakes. -- ALWAYS update `rox.json`, CI, and this file in the same change when adding a new implementation language or Rust gate surface - WHY: language support without repo-wide and scoped validation lets agents ship unverified code paths. +- ALWAYS update `.opcore/config`, CI, and this file in the same change when adding a new implementation language or validation gate surface - WHY: language support without repo-wide and scoped self-validation lets agents ship unverified code paths. - NEVER add backward-compatibility shims for removed internal paths - WHY: this is a clean release line; migrate the caller or delete the old path. - ALWAYS keep generated provider/runtime trees out of Git - WHY: descriptors and scripts are source of truth; generated trees drift by machine. -- ALWAYS keep old-tool reference evidence under @docs/graph-reference-evidence/ and @packages/fixtures/graph-reference-evidence/ - WHY: reference data may mention old tools only under allowlisted evidence docs/fixtures, never as implementation package naming or source provenance. - ALWAYS keep staged graph optional-analysis classifications sourced from `graphReleaseOptionalAnalysisSurfaces` - WHY: #13 coverage, #14 flows, #15 communities, and #16 read-only suggestions are non-release-blocking #17 deferred/staged surfaces and must not drift across contracts, fixtures, receipts, or docs. -- ALWAYS update `packages/opcore/src/advanced/descriptor.ts`, `scripts/write-cli-descriptor.mjs`, descriptor fixtures, package packlists, and descriptor validation together when changing ACE acquisition metadata - WHY: ACE must consume installed Opcore release artifacts, not workspace-local paths. +- ALWAYS update `packages/opcore/src/advanced/descriptor.ts`, `scripts/write-cli-descriptor.mjs`, descriptor fixtures, package packlists, and descriptor validation together when changing managed artifact metadata - WHY: installed consumers must resolve package artifacts, not workspace-local paths. - ALWAYS update release receipt contracts, `scripts/generate-release-receipt.mjs`, docs/release receipts, CI, and package/provenance/secret negative tests together when changing release evidence ownership - WHY: #29 is the maintainer release proof gate for the alpha line. - ALWAYS update cutover receipt contracts, `scripts/generate-cutover-receipt.mjs`, docs/release cutover receipts, CI, and cutover negative tests together when changing installed-artifact release behavior - WHY: #30 proves canonical Opcore artifacts replace current external dev tools without fallback. - ALWAYS record installed package file paths and checksums in cutover receipts, including ASP provider manifests - WHY: cutover proof must show packaged artifacts survived installation, not only tarball and package.json evidence. -- ALWAYS keep ASP dogfood advisory/shadow and isolated to temp `ASP_HOME`; co-record `current-tools:validate-changed` and `current-tools:validate-rust-graph`, keep `oldToolReplacementClaimed: false`, and represent inspect/edit gaps as degraded or retained blockers - WHY: #120 proves standalone ASP manager integration without authorizing rollout or retiring Rox/CRG/CIX. +- ALWAYS keep ASP dogfood advisory/shadow and isolated to temp `ASP_HOME`, and represent inspect/edit gaps as degraded or parity blockers - WHY: #120 proves standalone ASP manager integration without authorizing rollout. - ALWAYS update `packages/asp-provider/src/manifest.ts`, `scripts/write-asp-provider-manifest.mjs`, package exports/packlists, release receipts, installed-bin tests, and claim scrub together when changing ASP provider manifest/install metadata - WHY: canonical `asp-server.json` and retained provisional metadata must not imply trust, authority, gate permission, or host apply permission. - ALWAYS treat `darwin-arm64`, `darwin-x64`, and `linux-x64` as the only supported Opcore alpha graph-core native targets until CI aggregate evidence expands the set - WHY: local single-platform builds cannot prove clean public installs for other targets. - ALWAYS require CI aggregate and release publish workflows to download all three Opcore native package artifacts before release receipts, cutover receipts, or npm publish claim cross-platform graph-core readiness - WHY: the public `opcore` package must bundle real per-target checksums, not local fallback or fabricated binaries. -- ALWAYS keep `opcore` launch-facing help, README snippets, smoke output, and JSON named Opcore while preserving transitional `opcore status` compatibility - WHY: first-run activation must not leak internal or old-tool names into the public readiness flow. +- ALWAYS keep `opcore` launch-facing help, README snippets, smoke output, and JSON named Opcore while preserving transitional `opcore status` compatibility - WHY: first-run activation must not leak internal implementation names into the public readiness flow. - ALWAYS keep Opcore metrics finding-only and evidence-backed, with no opaque score or blended quality number - WHY: `opcore measure` is a trend report over concrete counts, not a scoring system. - ALWAYS keep clone detection split between the existing graph-core native subcommand and the injected TypeScript validation-clone adapter - WHY: duplicate-code detection needs native indexing without a daemon, graph-provider dependency, security claim, or extra native package. - ALWAYS keep latency budget gates finding-only and non-blocking by default, with `--fail-on-over` reserved for explicit promotion - WHY: latency evidence should guide regression decisions without becoming an opaque score or live measurement runner. @@ -167,8 +162,8 @@ Opcore is the code-intelligence and robustness monorepo for graph context, edit ## Language And Runtime - Node >=22 and TypeScript are the current scaffold baseline. -- The accepted boundary is hybrid: Rust graph core owns extraction, persistence, watch refresh, hot graph queries, and clone index analysis; TypeScript owns contracts, router-core helpers, CLI composition, package command adapters, edit orchestration, validation policy, validation-typescript and validation-clone adapters, npm facade, and ACE descriptors. -- #21 adds the Cargo workspace, `crates/graph-core`, Rust sidecar protocol, installed native artifacts, checksums, schema-compatible `rox.json` Rust gate metadata, npm Rust gate scripts, workspace Rust/clippy lint policy, `rox.json` code-quality scope for `packages/`, `scripts/`, `tests/`, and `crates/`, repo-wide Rox extension coverage for scoped Rust graph function metrics under `crates/`, scoped `current-tools:validate-rust-graph`, and GitHub Actions Rust setup. +- The accepted boundary is hybrid: Rust graph core owns extraction, persistence, watch refresh, hot graph queries, and clone index analysis; TypeScript owns contracts, router-core helpers, CLI composition, package command adapters, edit orchestration, validation policy, validation-typescript and validation-clone adapters, npm facade, and managed descriptors. +- #21 adds the Cargo workspace, `crates/graph-core`, Rust sidecar protocol, installed native artifacts, checksums, npm Rust gate scripts, workspace Rust/clippy lint policy, and GitHub Actions Rust setup. - #8 adds Wave 1 Rust graph-core source extraction for `.ts`, `.tsx`, `.js`, and `.jsx` through OXC parser crates. #25 adds syn-backed `.rs` extraction through graph-core facts for File, Module, Struct, Enum, Trait, Impl, Function/Method, TypeAlias, Const/Static, Test, Macro, CONTAINS, IMPORTS_FROM, CALLS, IMPLEMENTS, and DEPENDS_ON with Rust language/signature/span/export metadata. #9 adds the SQLite GraphProvider store at `.opcore/graph/graph.db`, freshness metadata, and #19 direct-reader reference evidence. #10 implements `opcore graph build/update/watch/status`, incremental cached FileFacts updates, phase timings, daemon `ping`/`health`, and watch artifacts at `.opcore/graph/daemon/{pid,state.json,daemon.log}`. #11 implements read-only store-backed `impact`, named `query`, `review-context`, and `detect-changes` envelopes. #12 implements Rust graph-core FTS5 search with `nodes_fts`, signature projection, full and incremental index maintenance, typed failures, and canonical `opcore graph search` through the TypeScript graph adapter. Full build/update/status are unscoped unless `--paths` is passed; `OPCORE_GRAPH_WATCH_PATHS` scopes watch only. Pipeline failures return router status `error`/exit 1 without fabricated summaries; status, health, query, and search paths are read-only when the store is missing or stale. Long-tail parser coverage remains follow-up graph work. - #17 adds Python graph-core extraction for `.py` and `.pyi` through `tree-sitter-python`, emitting File/Module/Class/Function/Variable nodes plus CONTAINS/IMPORTS_FROM/CALLS/INHERITS/TESTED_BY edges. Python imports are repo-local best-effort over absolute, relative, package initializer, and stub candidates; unresolved relative or repo-local imports emit warning category `unresolved_import`; validation support remains absent. - The Rust graph fact-model foundation makes Rust node/edge kinds canonical and confirms SQLite store schema v1 already persists Rust fact columns used by #25 Rust extraction. @@ -180,25 +175,26 @@ Opcore is the code-intelligence and robustness monorepo for graph context, edit - #56 adds the validation file view: `@the-open-engine/opcore-validation` composes normalized `ValidationRequest` overlays, resolved scope files, and injected workspace `readFile` access so `opcore validate` checks can read hypothetical after-state writes/deletes, before-state comparisons for introduced report mode, and before-state checksums without mutating the worktree. `ValidationFileView.defaultReadState` is check-visible so adapters with out-of-band readers can bind their before/after source mode to the runner pass. `checksumBefore` conflicts return refused/conflict before checks run. - #26 adds the validation-owned GraphProvider consumer boundary: `ValidationGraphProviderClient`, cached `ValidationGraphQuerySession`, graph requirement preloading, status/query failure mapping, and helper access for metadata, file checksums, IMPORTS_FROM, CALLS, and TESTED_BY facts. - #57 adds the TypeScript validation adapter: `@the-open-engine/opcore-validation-typescript` exports package-owned syntax, type, import-graph, dead-code, and relevant-tests check definitions. Syntax/type checks use the validation file view and an overlay-aware compiler host, including tsconfig path aliases for repo files and deterministic node_modules/package declaration resolution for external package imports; graph checks require #26 graph sessions and batched IMPORTS_FROM, CALLS, and TESTED_BY fact requirements. -- #20 adds the Rust validation adapter: `@the-open-engine/opcore-validation-rust` exports package-owned source hygiene, fmt, cargo-check, clippy, rustdoc, import-graph, dead-code, graph-signals, unused-deps, file-length, and function-metrics checks. `rust.graph-signals` is graph-provider-backed evidence from `ValidationGraphProviderClient` only (untested public Rust surface, dead public exports, module orphans/cycles); it does not replace mechanical cargo/rustdoc/clippy/Rox guardrails. #61 makes the five retained Rust rows native when supporting tools are available: rustdoc diagnostics block through `cargo doc`, import-graph reports unresolved modules/use paths/orphans/cycles from fileView, dead-code combines Cargo `dead_code` with orphan-source evidence, unused-deps parses cargo-udeps with workspace/package scoping, and function-metrics parses rust-code-analysis JSON object/array output. Missing `rustdoc`, `cargo-depgraph`, `cargo-udeps`, or `rust-code-analysis-cli` stays degraded or unsupported with `requiredTool` and retained `currentUsage`; no generic retained row remains for available tools. Rust checks materialize temporary workspaces from `ValidationCheckContext.fileView` after-state content for Cargo tools, add no validation daemon or hidden cache, never shell out to Rox, and keep current external Rust guardrails active until #29/#30 accept replacement receipts. #138 shares cargo-check JSON with dead-code, forbids a second dead-code cargo compile or `-Ddead_code` cache-busting flags, and injects a persistent `CARGO_TARGET_DIR` outside the worktree through `runTool`; cache keys must derive from generic repo, scope, overlay, platform, and toolchain inputs and cleanup must remain TTL/size bounded - WHY: Rust validation must be fast without leaking hypothetical after-state into real worktrees or tuning behavior to one repository. +- Opcore self-validation records file-level `TESTED_BY` evidence when a conventional TS/JS test imports a source file, while retaining symbol-level call evidence. The TypeScript relevant-test adapter emits diagnostics only for missing evidence; positive evidence is a clean check result. The real `tests/source-package-contracts.test.ts` suite imports workspace facades through root tsconfig source aliases and runs in normal local/CI tests - WHY: unsupported `.mjs` runtime suites and built `dist` imports cannot prove source-level relevant-test coverage, and successful evidence is not a finding. +- #20 adds the Rust validation adapter: `@the-open-engine/opcore-validation-rust` exports package-owned source hygiene, fmt, cargo-check, clippy, rustdoc, import-graph, dead-code, graph-signals, unused-deps, file-length, and function-metrics checks. `rust.graph-signals` is graph-provider-backed evidence from `ValidationGraphProviderClient` only (untested public Rust surface, dead public exports, module orphans/cycles), while mechanical evidence remains owned by Cargo and the configured native tools. Missing `rustdoc`, `cargo-depgraph`, `cargo-udeps`, or `rust-code-analysis-cli` stays degraded or unsupported with `requiredTool`. Rust checks materialize temporary workspaces from `ValidationCheckContext.fileView` after-state content for Cargo tools and add no validation daemon or hidden cache. #138 shares cargo-check JSON with dead-code, forbids a second dead-code cargo compile or `-Ddead_code` cache-busting flags, and injects a persistent `CARGO_TARGET_DIR` outside the worktree through `runTool`; cache keys must derive from generic repo, scope, overlay, platform, and toolchain inputs and cleanup must remain TTL/size bounded - WHY: Rust validation must be fast without leaking hypothetical after-state into real worktrees or tuning behavior to one repository. - Rust Cargo/native checks share one environment-keyed materialized workspace per validation file-view state through runner-owned disposable resources; the runner removes it on every exit - WHY: exact staged/tree/overlay semantics require state isolation, while per-check whole-repo copies create unbounded filesystem-event churn. - #151/#214 adds clone detection through the existing `opcore-graph-core` binary as a `clone` subcommand plus `@the-open-engine/opcore-validation-clone`. `CloneAnalysisRequest`/`CloneAnalysisResult` use `opcore.clone.v1`; committed analysis may refresh `.opcore/clone/clone.db`, while scoped or hypothetical analysis is ephemeral. The sparse request shape sends committed candidates as `paths`/`sourcePaths` plus `sourceReadMode`/`sourceTreeRef`, with full content only in write overlays. `clone.duplication` is a validation adapter check with injected native invocation, no graph requirement, no line-level identity, no daemon, and no SAST/security or score claim. - #27 adds canonical validation CLI surfaces: `opcore check` implements `files`, `staged`, `changed`, `tree`, `all`, and `manifest`; `tree` reads committed Git tree content from `--tree ` and scopes files from `--changed-from ` without consuming dirty worktree files. `opcore validate` implements `--request-file`, `hypothetical --request-file`, `pre-write --request-file --timeout-ms --json`, and `manifest`; runtime-owned `opcore status --json` includes `repoState`, while `opcore doctor --json` includes typed `runtimeInfo`, `opcoreDoctor`, and transitional `validationStatus` payloads with adapter routes, check ids, manifest entries, graph status, and daemon readiness metadata. #58 defines `opcore validate pre-write --request-file --timeout-ms 30000 --json` as the hook-safe, fail-closed pre-write contract with typed `PreWriteValidationReceipt` output. - #69 removes public runtime lifecycle command groups: top-level start and stop are unsupported unless a later architecture decision adopts them. Runtime readiness remains `opcore status` and `opcore doctor`; graph daemon lifecycle/status remains graph-owned under `opcore graph`. -- #118 adds bundled `@the-open-engine/opcore-asp-provider` internals and the public `opcore-asp-provider --stdio` bin from the `opcore` package as an independently launchable ASP Core check provider facade. It handles `initialize`, `initialized`, and `check/evaluate`, maps ASP create/modify/delete/rename changesets into validation overlays through host `workspace/listTree` and `workspace/readBlob` callbacks, runs the same TypeScript and Rust validation checks as Opcore validation composition, reports degraded/unsupported coverage for missing graph/toolchain/provider surfaces, emits provider-owned diagnostics, binds `validAsOf` to baseline/changeset/read blobs, and strips host-owned decision/authority/apply fields. It does not add a `opcore asp` router group and must not use ACE descriptors, `.ace/runtime`, `rox`, `crg`, or `cix` as implementation paths. +- #118 adds bundled `@the-open-engine/opcore-asp-provider` internals and the public `opcore-asp-provider --stdio` bin from the `opcore` package as an independently launchable ASP Core check provider facade. It handles `initialize`, `initialized`, and `check/evaluate`, maps ASP create/modify/delete/rename changesets into validation overlays through host `workspace/listTree` and `workspace/readBlob` callbacks, runs the same TypeScript and Rust validation checks as Opcore validation composition, reports degraded/unsupported coverage for missing graph/toolchain/provider surfaces, emits provider-owned diagnostics, binds `validAsOf` to baseline/changeset/read blobs, and strips host-owned decision/authority/apply fields. It does not add a public `opcore asp` router group. - #128 adds `opcore status` and `opcore status --json` as the read-only repo-aware activation command. It resolves repo/Git state, coverage, graph status/action, validation adapter/check availability, degraded Rust tools, cheap ASP enrollment hints, warnings, blockers, and next actions without running builds, checks, installs, setup, wrappers, or writes. Its JSON payload is `repoState`; `opcore status` keeps the transitional `validationStatus` payload. - #129 adds `opcore` and the standalone `opcore` bin. Zero-command scan uses `repoState` from #128, runs validation without source mutation, prints Coverage before Findings, writes only `.opcore/report.json`, `.opcore/history.jsonl`, and bounded `.opcore/telemetry.jsonl` capped at 500 records or 1 MiB, exposes `opcore check --changed|--staged|--all| --json` with stable agent exit codes, and exposes read-only `opcore --version`/`opcore version` runtime provenance. - #130 adds `packages/opcore/src/reporting.ts`, `OpcoreMetricReport`, `OpcoreMetricHistoryEntry`, and `OpcoreMeasureDelta`. Reports aggregate TS/JS syntax/type/test/dead-export diagnostics, graph structure/fan-in evidence when supplied, Rust source hygiene/file length/module/toolchain diagnostics, unsupported stack census, and honest degradations for unavailable checks/tools/facts. `writeOpcoreMetricArtifacts` writes only under `.opcore/`; `opcore status` excludes those generated artifacts from coverage; `opcore measure` reads them and returns deltas without validationResult, validationStatus, scans, graph builds, setup, or source writes. -- #131 adds `packages/opcore/src/init.ts` and the `opcoreInit` router payload. `opcore init` detects existing `AGENTS.md`, `CLAUDE.md`, `GEMINI.md`, `.github/copilot-instructions.md`, `.codex/AGENTS.md`, and `.opencode/AGENTS.md`, runs a read-only scan without `.opcore/report.json` or history writes, emits scan/settings/interaction/timing payloads, presents a plan before writing, prompts on TTY only, and now chooses repo/global write-gate scope by flag or interactive Git prompt. Approved repo init upserts a single `` block, writes additive `.opcore/config`, appends one managed `.opcore/` `.gitignore` line only in Git repos that do not already ignore it, installs `.opcore/hooks/opcore-agent-gate.mjs`, and merges Claude Code plus Codex PreToolUse hook settings without clobbering existing hooks. Approved global init installs the same adapter under `~/.opcore/hooks/`, merges `~/.claude/settings.json` and `~/.codex/hooks.json`, and records undo under `~/.opcore/init-undo.json`. The adapter maps Write/Edit/MultiEdit and Codex apply_patch payloads to hypothetical validation overlays, calls `opcore validate pre-write --request-file --timeout-ms 30000 --json`, exits 2 on non-ok receipts or validation command failure, and fail-opens only when the adapter cannot parse/map the harness payload. Approved writes refuse symlink targets or symlink ancestors for repo and global paths. Undo refuses metadata whose recorded root does not match or whose entries are outside Opcore-owned config/hooks/agent guidance paths plus the managed `.gitignore` line; `.gitignore` undo removes only that managed line and deletes an init-created `.gitignore` only when empty. The managed `.opcore/` ignore covers `.opcore/telemetry.jsonl`. The guidance must tell agents to run `opcore check --changed`, preserve existing guardrails, report unsupported/degraded coverage honestly, and not rely on ACE, Rox, CRG, CIX, or ASP host authority for direct Opcore. +- #131 adds `packages/opcore/src/init.ts` and the `opcoreInit` router payload. `opcore init` detects existing `AGENTS.md`, `CLAUDE.md`, `GEMINI.md`, `.github/copilot-instructions.md`, `.codex/AGENTS.md`, and `.opencode/AGENTS.md`, runs a read-only scan without `.opcore/report.json` or history writes, emits scan/settings/interaction/timing payloads, presents a plan before writing, prompts on TTY only, and now chooses repo/global write-gate scope by flag or interactive Git prompt. Approved repo init upserts a single `` block, writes additive `.opcore/config`, appends one managed `.opcore/` `.gitignore` line only in Git repos that do not already ignore it, installs `.opcore/hooks/opcore-agent-gate.mjs`, and merges Claude Code plus Codex PreToolUse hook settings without clobbering existing hooks. Approved global init installs the same adapter under `~/.opcore/hooks/`, merges `~/.claude/settings.json` and `~/.codex/hooks.json`, and records undo under `~/.opcore/init-undo.json`. The adapter maps Write/Edit/MultiEdit and Codex apply_patch payloads to hypothetical validation overlays, calls `opcore validate pre-write --request-file --timeout-ms 30000 --json`, exits 2 on non-ok receipts or validation command failure, and fail-opens only when the adapter cannot parse/map the harness payload. Approved writes refuse symlink targets or symlink ancestors for repo and global paths. Undo refuses metadata whose recorded root does not match or whose entries are outside Opcore-owned config/hooks/agent guidance paths plus the managed `.gitignore` line; `.gitignore` undo removes only that managed line and deletes an init-created `.gitignore` only when empty. The managed `.opcore/` ignore covers `.opcore/telemetry.jsonl`. The guidance must tell agents to run `opcore check --changed`, preserve existing safeguards, report unsupported/degraded coverage honestly, and not rely on ASP host authority for direct Opcore. - #133 adds `packages/opcore/src/try.ts` and the `opcoreTry` router payload. `opcore try` uses generated local sample repos only, keeps output coverage-first with named findings/deltas, includes unsupported-file census evidence, and records clean-room launch proof without public announcement or package publishing. -- #22 adds the edit-core library foundation: `@the-open-engine/opcore-edit` owns deterministic exact, multi-edit, and literal search-replace planners, edit checksums, plan hashes, validation overlay construction, preview mode, repo path policy, Node workspaces, and all-or-nothing atomic apply/rollback. #59 adds canonical `opcore edit exact`, `multi`, `search-replace`, `check`, and `apply` parsing inside the edit package, typed `editPlan`/`editResult` router payloads, no old `cix` aliases, and search-replace uniqueness unless `replaceAll` is true. #60 adds canonical `opcore edit patch` and `tree`: raw unified diff patch input through `--stdin`/`--request-file` or `{patch}` JSON, tree payloads `{repo?,validation?,fileContains?,files:[{path,content,checksumBefore?}|{path,delete:true,checksumBefore?}]}`, patch/tree-only forbidden target policy for absolute paths, parent traversal, UNC paths, symlink escapes, `.gitignore` targets, generated/private roots, and binary content, plus `editResult.rollback` state for atomic apply failures. #24 routes non-empty `opcore edit` apply/check plans through an injected validation runner before writes, rejects validation bypass plans, preserves full `ValidationResult` envelopes in edit results, and keeps `--dry-run` as a non-validating preview. #23 implements canonical `opcore edit rename`, `move`, and `signature` routes as graph-backed, validation-required edit plans: GraphProvider contract status/query/search evidence is required for targeting/freshness, TypeScript/JavaScript language-service materialization stays edit-owned, and apply/check refuses graph freshness changes before validation or writes. -- #149 adds the shared inspect/edit ts-morph Project construction seam: cold inspect signatures and graph-backed implementations default to target import-closure loading; references and symbol edits load the target import closure plus reverse importers instead of whole-repo Projects when that preserves correctness; graphless implementation paths keep whole-repo fallback where reverse dependents cannot be bounded safely; and both inspect/edit accept injected Project plus snapshot/revert hooks for future warm ASP reuse without introducing a daemon. +- #22 adds the edit-core library foundation: `@the-open-engine/opcore-edit` owns deterministic exact, multi-edit, and literal search-replace planners, edit checksums, plan hashes, validation overlay construction, preview mode, repo path policy, Node workspaces, and all-or-nothing atomic apply/rollback. #59 adds canonical `opcore edit exact`, `multi`, `search-replace`, `check`, and `apply` parsing inside the edit package, typed `editPlan`/`editResult` router payloads, and search-replace uniqueness unless `replaceAll` is true. #60 adds canonical `opcore edit patch` and `tree`: raw unified diff patch input through `--stdin`/`--request-file` or `{patch}` JSON, tree payloads `{repo?,validation?,fileContains?,files:[{path,content,checksumBefore?}|{path,delete:true,checksumBefore?}]}`, patch/tree-only forbidden target policy for absolute paths, parent traversal, UNC paths, symlink escapes, `.gitignore` targets, generated/private roots, and binary content, plus `editResult.rollback` state for atomic apply failures. #24 routes non-empty `opcore edit` apply/check plans through an injected validation runner before writes, rejects validation bypass plans, preserves full `ValidationResult` envelopes in edit results, and keeps `--dry-run` as a non-validating preview. #23 implements canonical `opcore edit rename`, `move`, and `signature` routes as graph-backed, validation-required edit plans: GraphProvider contract status/query/search evidence is required for targeting/freshness, TypeScript/JavaScript language-service materialization stays edit-owned, and apply/check refuses graph freshness changes before validation or writes. +- #149 makes `packages/edit/src/typescript-project/` the shared owner of ts-morph project discovery, tsconfig selection, import resolution, source listing, scope materialization, and injected-project snapshot composition consumed by edit, CLI-owned inspect, and warm ASP sessions. Cold inspect signatures and graph-backed implementations default to target import-closure loading; references and symbol edits load the target import closure plus reverse importers instead of whole-repo Projects when that preserves correctness; graphless implementation paths keep whole-repo fallback where reverse dependents cannot be bounded safely; and both inspect/edit accept injected Project plus snapshot/revert hooks without introducing a daemon - WHY: inspect and warm sessions must not maintain a second TypeScript project scanner or import resolver that diverges from symbol edits. - #153 adds the hidden `opcore asp serve --stdio` warm ASP session: host-launched only, process-local singleton/idle lifecycle under `.opcore/asp`, warm injected whole-repo ts-morph Project for `inspect/references` and `edit/rename` preview, unchanged delegated `check/evaluate`, `session/shutdown`, no source writes, no public help/manifest command group, no auto-spawned daemon, and no change to the cold `opcore-asp-provider --stdio` check-only capability. - #17 adds the graph release readiness receipt gate: `npm run graph-release:check` proves canonical graph commands, direct SQLite queries, serve transport, package inspection, provenance/license receipts, benchmark metrics, and handoff data for #7/#28/#29. -- #28 adds the aggregate Opcore ACE descriptor contract and artifact: `opcore` packages `dist/descriptors/opcore.managed-tool.json`, generated from `packages/opcore/src/advanced/descriptor.ts` after build. Descriptors must list only the canonical `opcore` bin, command groups graph/inspect/edit/check/validate/status/doctor, package-relative artifact/checksum paths, GraphProvider daemon/query/search/native capabilities, edit validation dependency, validation graph optional/required modes, and deferred #13-#16 optional surfaces as metadata. Descriptor validators must reject private runtime roots such as `.ace` on either slash style. +- #28 adds the aggregate Opcore managed descriptor contract and artifact: `opcore` packages `dist/descriptors/opcore.managed-tool.json`, generated from `packages/opcore/src/advanced/descriptor.ts` after build. Descriptors list only the canonical `opcore` bin, command groups graph/inspect/edit/check/validate/status/doctor, package-relative artifact/checksum paths, GraphProvider query/search/native capabilities, edit validation dependency, validation graph optional/required modes, and deferred optional surfaces as metadata. - #29 adds the repo-wide release receipt gate: `npm run release-receipt:check` proves the single public `opcore` tarball, bundled internal implementation/native/runtime dependencies, exact packlists, sha256 checksums, descriptor/provider manifest artifact resolution, canonical Opcore command groups, native graph artifact checksum evidence, production and bundled dependency licenses, provenance scans, release hygiene, graph #17 input evidence, and current-tree plus git-history secret scans. `npm run release-receipt:receipt` refreshes `docs/release/release-receipt.json`, `docs/release/release-receipt.summary.md`, license/provenance reports, and artifact attestation docs. Secret allowlist entries live only in `docs/release/secret-scan-allowlist.json` and must include reviewed path or commit scope, reviewer, reason, expiry, and optional fingerprint/kind narrowing; remove real findings instead of allowlisting them. -- #30 adds the installed-artifact cutover gate: `npm run cutover:check` packs and installs only the public `opcore` package into a clean temp project, clears current-tool env resolution, excludes local wrapper and sibling paths, verifies installed canonical bins (`opcore` and `opcore-asp-provider`), validates `ReleaseCutoverReceipt`, proves graph/inspect/edit/check/validate/status/doctor/pre-write plus scan/measure flows through `opcore`, and rejects old-tool/private-path markers or advertised `not_implemented` release commands. Each command receipt id is contract-bound to its expected canonical command/status/exit. Top-level `opcore inspect symbols|definition|references|signature|implementations|search` is read-only CLI behavior; signature and implementations are implemented read-only language-service parity. `opcore graph inspect` is not an advertised release route. -- #72 adds typed inspect reference results and `opcore inspect references --line [--column ]` for read-only CIX refs parity over fresh graph facts plus an inspect-owned TypeScript/JavaScript language-service seam. #100 adds shared read-only `InspectSignatureResult` and `InspectImplementationResult` contracts, fixture foundations, target parsing, graph freshness enforcement, and typed `unsupported_route` scaffolds. #101 implements `opcore inspect signature --line [--column ]` and node-id targeting for read-only CIX `sig` parity over fresh graph facts. #102 implements `opcore inspect implementations --line [--column ]` and class/type node-id targets for CIX `impls` parity over graph `IMPLEMENTS`/`INHERITS` facts plus TypeScript/TSX language-service materialization. +- #30 adds the installed-artifact cutover gate: `npm run cutover:check` packs and installs only the public `opcore` package into a clean temp project, sanitizes execution paths, verifies installed canonical bins (`opcore` and `opcore-asp-provider`), validates `ReleaseCutoverReceipt`, and proves graph/inspect/edit/check/validate/status/doctor/pre-write plus scan/measure flows through `opcore`. Each command receipt id is contract-bound to its expected canonical command/status/exit. Top-level `opcore inspect symbols|definition|references|signature|implementations|search` is read-only CLI behavior; signature and implementations are implemented read-only language-service parity. `opcore graph inspect` is not an advertised release route. +- #72 adds typed inspect reference results and `opcore inspect references --line [--column ]` over fresh graph facts plus an inspect-owned TypeScript/JavaScript language-service seam. #100 adds shared read-only `InspectSignatureResult` and `InspectImplementationResult` contracts, fixture foundations, target parsing, graph freshness enforcement, and typed `unsupported_route` scaffolds. #101 implements `opcore inspect signature --line [--column ]` and node-id targeting over fresh graph facts. #102 implements `opcore inspect implementations --line [--column ]` and class/type node-id targets over graph `IMPLEMENTS`/`INHERITS` facts plus TypeScript/TSX language-service materialization. - #141 keeps inspect file-symbol routes useful when graph facts are missing or stale: supported TS/JS `references` and `signature`, plus existing TS/TSX `implementations`, return read-only language-service payloads with `inspectResult.status: "degraded"` and `failure.category: "graph_unavailable"` instead of hard failure; unsupported paths and graph-only node-id targets remain hard failures or unsupported as before. - The provenance GitHub workflow must install stable Rust and run `npm run build` before `npm run release-receipt:check` - WHY: release receipts import ignored `dist/` contracts/descriptors and require native graph artifacts from a clean checkout. @@ -206,15 +202,17 @@ Opcore is the code-intelligence and robustness monorepo for graph context, edit - ALL tests live in @tests/ until a package-specific test harness is explicitly introduced by an issue. - Add contract tests before implementation tests for GraphProvider, EditPlan, ValidationRequest, and canonical CLI behavior. -- Add golden/reference fixtures before replacing current external tool behavior. +- Add golden/reference fixtures before changing established behavior. - Keep release hygiene, conformance metadata, and package packlist gates executable when changing package or release surfaces - WHY: maintainer release receipts must fail before public alpha assumptions drift. -- Add #29 negative fixtures for release evidence regressions: Python code-review-graph provenance, high-confidence secrets, unexpected package files, old public bins, descriptor artifact drift, and missing native checksum evidence. -- Add #30 negative fixtures for cutover regressions: current-tool descriptor markers, advertised placeholder command receipts, missing cutover command receipts, and old bin fallback in installed projects. -- Local proof for agent work is `npm run ci:local`; for source/package/native/release changes it regenerates current-tool wrappers, runs `npm run ci`, runs repo-wide Rox, then runs the Rust graph function-metrics check. For docs/agent-guidance-only changes it uses the local CI fast path described above. GitHub Actions run Node and Rust gates while current external ACE tools remain local-worktree dependencies. +- Add #29 negative fixtures for release evidence regressions: high-confidence secrets, unexpected package files, + descriptor artifact drift, and missing native checksum evidence. +- Add #30 negative fixtures for cutover regressions: advertised unimplemented command receipts, missing cutover command + receipts, and invalid installed package/bin surfaces. +- Local proof for agent work is `npm run ci:local`; for source/package/native/release changes it runs `npm run ci` and then `npm run opcore:self-check`. Docs/agent-guidance-only changes use the repository-native fast path described above. ## Commands -- Setup and wrappers: `npm run setup`, `npm run setup:tools`, `source scripts/dev-env.sh`, `npm run ace:install`, `npm run ace:sync`, `npm run ace:validate`. -- Proof: core `npm run ci`, targeted `node --test tests/...`, `npm run rust:check`, `npm run ci:local` or `npm run verify`; release `npm run graph:artifact`, `npm run descriptor:artifact`, `npm run asp-provider:manifest`, `npm run graph-release:check`, `npm run release-receipt:check`, `npm run cutover:check`, `npm run asp-dogfood:check`, `npm run pack:check`, `npm run release:hygiene`; retained guardrails `npm run current-tools:validate-changed`, `npm run current-tools:validate-all`, `npm run current-tools:validate-rust-graph`. +- Setup: `npm run setup`. +- Proof: core `npm run ci`, `npm run opcore:self-check`, targeted `node --test tests/...`, `npm run rust:check`, `npm run ci:local` or `npm run verify`; release `npm run graph:artifact`, `npm run descriptor:artifact`, `npm run asp-provider:manifest`, `npm run graph-release:check`, `npm run release-receipt:check`, `npm run cutover:check`, `npm run asp-dogfood:check`, `npm run pack:check`, `npm run release:hygiene`. - Public scan/readiness: `opcore --repo . --json`, `opcore status --repo . --json`, `opcore --version --json`, `opcore doctor --repo . --json`, `opcore measure --repo . --json`, `opcore try --json`. - Public setup/check/provider: `opcore install --repo . --json`, `opcore install --repo . --yes --json`, `opcore uninstall --repo . --yes --json`, `opcore check --changed --json`, `opcore-asp-provider --stdio`. diff --git a/ace.json b/ace.json deleted file mode 100644 index 5137aa2..0000000 --- a/ace.json +++ /dev/null @@ -1,50 +0,0 @@ -{ - "canonical": ".claude", - "providers": [ - ".agents", - ".codex", - ".gemini", - ".opencode" - ], - "policy": "standard", - "sync": { - "skills": true, - "commands": false, - "hooks": true, - "contextDocs": true - }, - "mcpServers": { - "code-review-graph": { - "command": "bash", - "args": [ - "-lc", - "set -euo pipefail; repo_root=$(git rev-parse --show-toplevel 2>/dev/null || pwd); exec \"$repo_root/.ace/runtime/bin/crg\" serve --repo \"$repo_root\"" - ] - } - }, - "contextDocs": { - "source": "CLAUDE.md", - "excludedPaths": [ - "dist", - "node_modules", - "coverage", - ".changeset", - ".ace", - ".agents", - ".claude", - ".codex", - ".gemini", - ".opencode", - ".zeroshot/bin", - ".zeroshot/logs", - ".zeroshot/run", - ".code-review-graph", - ".rox-cache", - ".robustness-engine-cache" - ] - }, - "gitSafe": false, - "preWriteQuality": { - "bash": false - } -} diff --git a/crates/graph-core/src/clone/mod.rs b/crates/graph-core/src/clone/mod.rs index 02b4e98..565e085 100644 --- a/crates/graph-core/src/clone/mod.rs +++ b/crates/graph-core/src/clone/mod.rs @@ -257,17 +257,25 @@ fn validate_positive_option(label: &str, value: Option) -> Result<(), Clo } fn validate_request_paths(request: &CloneAnalysisRequest) -> Result<(), CloneError> { - for path in &request.paths { - normalize_repo_relative_path(path, "clone request path") - .map_err(|message| CloneError::InvalidRequest(message.to_string()))?; - } + validate_normalized_paths(&request.paths, "clone request path")?; if let Some(paths) = &request.source_paths { - for path in paths { - normalize_repo_relative_path(path, "clone request source path") - .map_err(|message| CloneError::InvalidRequest(message.to_string()))?; - } + validate_normalized_paths(paths, "clone request source path")?; + } + validate_partitions(&request.partitions)?; + validate_patterns(&request.exclude, "clone exclude pattern")?; + validate_overlay_paths(&request.overlays) +} + +fn validate_normalized_paths(paths: &[String], label: &str) -> Result<(), CloneError> { + for path in paths { + normalize_repo_relative_path(path, label) + .map_err(|message| CloneError::InvalidRequest(message.to_string()))?; } - for (index, partition) in request.partitions.iter().enumerate() { + Ok(()) +} + +fn validate_partitions(partitions: &[Vec]) -> Result<(), CloneError> { + for (index, partition) in partitions.iter().enumerate() { if partition.is_empty() { return Err(CloneError::InvalidRequest(format!( "partitions[{index}] must not be empty" @@ -277,10 +285,18 @@ fn validate_request_paths(request: &CloneAnalysisRequest) -> Result<(), CloneErr validate_clone_pattern(pattern, "clone partition pattern")?; } } - for pattern in &request.exclude { - validate_clone_pattern(pattern, "clone exclude pattern")?; + Ok(()) +} + +fn validate_patterns(patterns: &[String], label: &str) -> Result<(), CloneError> { + for pattern in patterns { + validate_clone_pattern(pattern, label)?; } - for overlay in &request.overlays { + Ok(()) +} + +fn validate_overlay_paths(overlays: &[CloneOverlay]) -> Result<(), CloneError> { + for overlay in overlays { normalize_repo_relative_path(overlay.path(), "clone overlay path") .map_err(|message| CloneError::InvalidRequest(message.to_string()))?; } diff --git a/crates/graph-core/src/clone/store.rs b/crates/graph-core/src/clone/store.rs index 8bb7689..443060c 100644 --- a/crates/graph-core/src/clone/store.rs +++ b/crates/graph-core/src/clone/store.rs @@ -1,6 +1,6 @@ use super::analysis::{CloneClass, CloneSource}; use super::{CloneError, CLONE_PROTOCOL, CLONE_STORE_SCHEMA_VERSION}; -use rusqlite::{params, Connection}; +use rusqlite::{params, Connection, Transaction}; use std::path::{Path, PathBuf}; pub(super) fn persist_clone_index( @@ -14,9 +14,22 @@ pub(super) fn persist_clone_index( let mut connection = Connection::open(&db_path)?; initialize_clone_schema(&connection)?; let transaction = connection.transaction()?; + reset_clone_index(&transaction)?; + write_clone_metadata(&transaction)?; + write_clone_sources(&transaction, sources)?; + write_clone_classes(&transaction, classes)?; + transaction.commit()?; + Ok(db_path) +} + +fn reset_clone_index(transaction: &Transaction<'_>) -> Result<(), CloneError> { transaction.execute("delete from clone_occurrences", [])?; transaction.execute("delete from clone_files", [])?; transaction.execute("delete from clone_metadata", [])?; + Ok(()) +} + +fn write_clone_metadata(transaction: &Transaction<'_>) -> Result<(), CloneError> { transaction.execute( "insert into clone_metadata(key, value) values ('protocol', ?1)", params![CLONE_PROTOCOL], @@ -25,12 +38,26 @@ pub(super) fn persist_clone_index( "insert into clone_metadata(key, value) values ('schema_version', ?1)", params![CLONE_STORE_SCHEMA_VERSION.to_string()], )?; + Ok(()) +} + +fn write_clone_sources( + transaction: &Transaction<'_>, + sources: &[CloneSource], +) -> Result<(), CloneError> { for source in sources { transaction.execute( "insert into clone_files(path, language, sha256) values (?1, ?2, ?3)", params![source.path, source.language, source.sha256], )?; } + Ok(()) +} + +fn write_clone_classes( + transaction: &Transaction<'_>, + classes: &[CloneClass], +) -> Result<(), CloneError> { for class in classes { for occurrence in &class.occurrences { transaction.execute( @@ -45,8 +72,7 @@ pub(super) fn persist_clone_index( )?; } } - transaction.commit()?; - Ok(db_path) + Ok(()) } fn initialize_clone_schema(connection: &Connection) -> Result<(), CloneError> { diff --git a/crates/graph-core/src/daemon/tests.rs b/crates/graph-core/src/daemon/tests.rs index c05782d..2865389 100644 --- a/crates/graph-core/src/daemon/tests.rs +++ b/crates/graph-core/src/daemon/tests.rs @@ -3,9 +3,10 @@ use crate::protocol::{ GraphFactQueryKind, GraphFactQueryRequest, GraphFactQuerySelector, GraphImpactRequest, GraphProviderMode, GraphSearchRequest, RepoIdentity, }; +use crate::test_support::wave1_fixture_root; use serde_json::{json, Value}; use std::fs; -use std::path::{Path, PathBuf}; +use std::path::Path; use std::time::Duration; use tempfile::TempDir; @@ -383,12 +384,6 @@ fn copied_wave1_fixture() -> Result { Ok(destination) } -fn wave1_fixture_root() -> Result { - PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .join("../../packages/fixtures/source-extraction/wave1") - .canonicalize() -} - fn copy_dir(source: &Path, destination: &Path) -> Result<(), std::io::Error> { let mut pending = vec![(source.to_path_buf(), destination.to_path_buf())]; while let Some((from_dir, to_dir)) = pending.pop() { diff --git a/crates/graph-core/src/extraction/facts.rs b/crates/graph-core/src/extraction/facts.rs index a82770c..fca4006 100644 --- a/crates/graph-core/src/extraction/facts.rs +++ b/crates/graph-core/src/extraction/facts.rs @@ -11,8 +11,10 @@ use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; mod collector; mod python; +mod resolution; mod rust; +use resolution::{FactResolution, Resolution}; use std::collections::{BTreeMap, BTreeSet}; #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] @@ -100,7 +102,7 @@ pub fn file_node(source: &DiscoveredSource) -> GraphFactNode { | SourceLanguage::JavaScriptJsx => "oxc_parser", }; GraphFactNode { - id: file_id(&source.relative_path), + id: Resolution::file_id(&source.relative_path), kind: "File".to_string(), path: Some(source.relative_path.clone()), name: None, @@ -157,7 +159,7 @@ pub fn finalize_facts( tsconfig: Option<&TsConfig>, diagnostics: &mut Vec, ) -> (Vec, Vec) { - let known_files = known_files(file_facts); + let known_files = Resolution::known_files(file_facts); let mut imports = ImportResolutionContext { known_files: &known_files, tsconfig, @@ -167,7 +169,7 @@ pub fn finalize_facts( finalizer.resolve_imports(file_facts, &mut imports); finalizer.resolve_re_exports(file_facts); finalizer.resolve_links(file_facts); - append_path_traversal_blocker(imports.diagnostics); + Resolution::append_path_traversal_blocker(imports.diagnostics); finalizer.into_parts() } @@ -179,9 +181,9 @@ struct ImportResolutionContext<'a> { impl ImportResolutionContext<'_> { fn resolve(&mut self, specifier: &str, path: &str) -> Option { - let resolution = if is_python_source_path(path) { + let resolution = if Resolution::is_python_source_path(path) { python_imports::resolve_import(specifier, path, self.known_files) - } else if is_rust_source_path(path) { + } else if Resolution::is_rust_source_path(path) { rust::resolve_import(specifier, path, self.known_files) } else { resolve_import(specifier, path, self.known_files, self.tsconfig) @@ -209,6 +211,36 @@ fn python_submodule_specifier(specifier: &str, imported: &str) -> Option Some(format!("{specifier}{separator}{imported}")) } +fn is_conventional_script_test_path(path: &str) -> bool { + let Some((stem, extension)) = path.rsplit_once('.') else { + return false; + }; + let is_script = matches!( + extension, + "ts" | "tsx" | "mts" | "cts" | "js" | "jsx" | "mjs" | "cjs" + ); + is_script + && (path.split('/').any(|segment| segment == "__tests__") + || stem.ends_with(".test") + || stem.ends_with(".spec")) +} + +fn set_node_attribute(node: &mut GraphFactNode, key: &str, value: Value) { + node_attributes_object(node).insert(key.to_string(), value); +} + +fn node_attributes_object(node: &mut GraphFactNode) -> &mut serde_json::Map { + let attributes = node + .attributes + .get_or_insert_with(|| Value::Object(serde_json::Map::new())); + loop { + if let Value::Object(object) = attributes { + return object; + } + *attributes = Value::Object(serde_json::Map::new()); + } +} + struct FactFinalizer { nodes: BTreeMap, edges: BTreeMap, @@ -246,7 +278,7 @@ impl FactFinalizer { fn resolve_imports(&mut self, file_facts: &[FileFacts], imports: &mut ImportResolutionContext) { for facts in file_facts { for import in &facts.imports { - if is_python_source_path(&facts.path) + if Resolution::is_python_source_path(&facts.path) && self.register_python_import(facts, import, imports) { continue; @@ -264,8 +296,10 @@ impl FactFinalizer { self.register_import_binding( &facts.path, binding, - target_path.clone(), - binding.imported.clone(), + ImportTarget { + path: target_path.clone(), + imported: binding.imported.clone(), + }, ); } } @@ -284,7 +318,14 @@ impl FactFinalizer { &binding.imported, &facts.path, ) { - self.register_import_binding(&facts.path, binding, target_path, "*".to_string()); + self.register_import_binding( + &facts.path, + binding, + ImportTarget { + path: target_path, + imported: "*".to_string(), + }, + ); handled_submodule = true; } else { fallback_bindings.push(binding); @@ -299,8 +340,10 @@ impl FactFinalizer { self.register_import_binding( &facts.path, binding, - target_path.clone(), - binding.imported.clone(), + ImportTarget { + path: target_path.clone(), + imported: binding.imported.clone(), + }, ); } } @@ -312,27 +355,25 @@ impl FactFinalizer { &mut self, facts_path: &str, binding: &ImportBinding, - target_path: String, - imported: String, + target: ImportTarget, ) { - self.register_import_edge(facts_path, &target_path); + self.register_import_edge(facts_path, &target.path); self.imports_by_file .entry(facts_path.to_string()) .or_default() - .insert( - binding.local.clone(), - ImportTarget { - path: target_path, - imported, - }, - ); + .insert(binding.local.clone(), target); } fn register_import_edge(&mut self, facts_path: &str, target_path: &str) { - let from = file_id(facts_path); - let to = file_id(target_path); - insert_edge(&mut self.edges, EdgeDraft::new("IMPORTS_FROM", &from, &to)); - insert_edge(&mut self.edges, EdgeDraft::new("DEPENDS_ON", &from, &to)); + let from = Resolution::file_id(facts_path); + let to = Resolution::file_id(target_path); + Resolution::insert_edge(&mut self.edges, EdgeDraft::new("IMPORTS_FROM", &from, &to)); + Resolution::insert_edge(&mut self.edges, EdgeDraft::new("DEPENDS_ON", &from, &to)); + if is_conventional_script_test_path(facts_path) + && !is_conventional_script_test_path(target_path) + { + Resolution::insert_edge(&mut self.edges, EdgeDraft::new("TESTED_BY", &to, &from)); + } } fn resolve_re_exports(&mut self, file_facts: &[FileFacts]) { @@ -350,7 +391,7 @@ impl FactFinalizer { }; let resolved = { let context = self.name_resolution_context(); - resolve_imported_name(&target.path, &target.imported, &context) + Resolution::resolve_imported_name(&target.path, &target.imported, &context) }; let Some(resolved) = resolved else { continue; @@ -373,7 +414,7 @@ impl FactFinalizer { } fn mark_file_export_supported(&mut self, path: &str, re_export: &ReExportFact) { - let Some(file_node) = self.nodes.get_mut(&file_id(path)) else { + let Some(file_node) = self.nodes.get_mut(&Resolution::file_id(path)) else { return; }; let Some(attributes) = file_node.attributes.as_mut().and_then(Value::as_object_mut) else { @@ -407,10 +448,10 @@ impl FactFinalizer { for heritage in &facts.heritage { let target = { let context = self.name_resolution_context(); - resolve_name(&facts.path, &heritage.name, &context) + Resolution::resolve_name(&facts.path, &heritage.name, &context) }; if let Some(target) = target { - insert_edge( + Resolution::insert_edge( &mut self.edges, EdgeDraft::new(&heritage.kind, &heritage.from, &target), ); @@ -422,15 +463,15 @@ impl FactFinalizer { for reference in &facts.references { let target = { let context = self.name_resolution_context(); - resolve_name(&facts.path, &reference.name, &context) + Resolution::resolve_name(&facts.path, &reference.name, &context) }; if let Some(target) = target { - insert_edge( + Resolution::insert_edge( &mut self.edges, EdgeDraft::new("CALLS", &reference.from, &target), ); if reference.is_test || reference.from.starts_with("test:") { - insert_edge( + Resolution::insert_edge( &mut self.edges, EdgeDraft::new("TESTED_BY", &target, &reference.from), ); @@ -454,182 +495,3 @@ impl FactFinalizer { ) } } - -fn known_files(file_facts: &[FileFacts]) -> BTreeSet { - file_facts - .iter() - .map(|facts| facts.path.clone()) - .collect::>() -} - -fn append_path_traversal_blocker(diagnostics: &mut Vec) { - let has_path_traversal = diagnostics - .iter() - .any(|diagnostic| diagnostic.category == GraphExtractionDiagnosticCategory::PathTraversal); - if has_path_traversal { - diagnostics.push(error( - GraphExtractionDiagnosticCategory::PathTraversal, - "path traversal diagnostics blocked graph availability", - None, - None, - )); - } -} - -pub fn file_id(path: &str) -> String { - format!("file:{path}") -} - -fn insert_edge(edges: &mut BTreeMap, edge: EdgeDraft<'_>) { - if edge.from == edge.to { - return; - } - let id = format!("edge:{}:{}->{}", edge.kind, edge.from, edge.to); - edges.entry(id.clone()).or_insert_with(|| GraphFactEdge { - id: Some(id), - kind: edge.kind.to_string(), - from: edge.from.to_string(), - to: edge.to.to_string(), - attributes: None, - }); -} - -fn resolve_name( - file_path: &str, - name: &str, - context: &NameResolutionContext<'_>, -) -> Option { - if let Some(local) = context - .declarations_by_file - .get(file_path) - .and_then(|declarations| declarations.get(name)) - { - return Some(local.clone()); - } - if name.contains('.') { - return resolve_dotted_name(file_path, name, context); - } - if let Some(target) = context - .imports_by_file - .get(file_path) - .and_then(|imports| imports.get(name)) - { - if target.imported != "*" { - if let Some(target) = resolve_imported_name(&target.path, &target.imported, context) { - return Some(target); - } - } - } - None -} - -fn resolve_dotted_name( - file_path: &str, - name: &str, - context: &NameResolutionContext<'_>, -) -> Option { - let parts = name.split('.').collect::>(); - let head = parts.first()?; - let target = context - .imports_by_file - .get(file_path) - .and_then(|imports| imports.get(*head))?; - if target.imported == "*" { - for candidate in namespace_import_member_candidates(&parts, &target.path) { - if let Some(target) = resolve_imported_name(&target.path, &candidate, context) { - return Some(target); - } - } - return None; - } - resolve_imported_name(&target.path, &target.imported, context) -} - -fn namespace_import_member_candidates(parts: &[&str], target_path: &str) -> Vec { - let mut candidates = Vec::new(); - let module_parts = module_parts_for_path(target_path); - if let Some(consumed) = longest_module_prefix_match(parts, &module_parts) { - if let Some(candidate) = parts.get(consumed..) { - candidates.push(candidate.join(".")); - } - } - if let Some(candidate) = parts.get(1..) { - candidates.push(candidate.join(".")); - } - if let Some(last) = parts.last() { - candidates.push((*last).to_string()); - } - deduplicate(candidates) -} - -fn longest_module_prefix_match(parts: &[&str], module_parts: &[String]) -> Option { - let max_len = parts.len().min(module_parts.len()); - (1..=max_len).rev().find(|len| { - let len = *len; - let Some(start) = module_parts.len().checked_sub(len) else { - return false; - }; - let Some(module_suffix) = module_parts.get(start..) else { - return false; - }; - let Some(parts_prefix) = parts.get(..len) else { - return false; - }; - module_suffix - .iter() - .map(String::as_str) - .eq(parts_prefix.iter().copied()) - }) -} - -fn module_parts_for_path(path: &str) -> Vec { - let without_extension = path - .strip_suffix(".py") - .or_else(|| path.strip_suffix(".pyi")) - .or_else(|| path.strip_suffix(".mts")) - .or_else(|| path.strip_suffix(".cts")) - .or_else(|| path.strip_suffix(".ts")) - .or_else(|| path.strip_suffix(".tsx")) - .or_else(|| path.strip_suffix(".js")) - .or_else(|| path.strip_suffix(".jsx")) - .or_else(|| path.strip_suffix(".rs")) - .unwrap_or(path); - let mut parts = without_extension - .split('/') - .filter(|part| !part.is_empty()) - .map(ToString::to_string) - .collect::>(); - if parts.last().is_some_and(|part| part == "__init__") { - parts.pop(); - } - parts -} - -fn deduplicate(values: Vec) -> Vec { - values.into_iter().fold(Vec::new(), |mut unique, value| { - if !value.is_empty() && !unique.contains(&value) { - unique.push(value); - } - unique - }) -} - -fn resolve_imported_name( - target_path: &str, - imported: &str, - context: &NameResolutionContext<'_>, -) -> Option { - context - .export_aliases_by_file - .get(target_path) - .and_then(|aliases| aliases.get(imported)) - .cloned() -} - -fn is_python_source_path(path: &str) -> bool { - path.ends_with(".py") || path.ends_with(".pyi") -} - -fn is_rust_source_path(path: &str) -> bool { - path.ends_with(".rs") -} diff --git a/crates/graph-core/src/extraction/facts/collector.rs b/crates/graph-core/src/extraction/facts/collector.rs index 2c89de8..dd3c4f8 100644 --- a/crates/graph-core/src/extraction/facts/collector.rs +++ b/crates/graph-core/src/extraction/facts/collector.rs @@ -1,6 +1,6 @@ use super::{ - file_id, insert_edge, EdgeDraft, FileFacts, HeritageFact, ImportBinding, ImportFact, - ReExportFact, ReferenceFact, + node_attributes_object, set_node_attribute, EdgeDraft, FactResolution, FileFacts, HeritageFact, + ImportBinding, ImportFact, ReExportFact, ReferenceFact, Resolution, }; use crate::protocol::{GraphFactEdge, GraphFactNode}; use oxc_ast::ast::{ @@ -15,13 +15,19 @@ use oxc_syntax::scope::ScopeFlags; use serde_json::{json, Value}; use std::collections::{btree_map::Entry, BTreeMap}; +mod helpers; +mod visit; + +use helpers::{CollectorHelpers, Helpers}; +use visit::CollectProgram; + pub(super) fn collect_file_facts( path: String, file_node: GraphFactNode, program: &oxc_ast::ast::Program<'_>, ) -> FileFacts { let mut collector = FileFactCollector::new(path, file_node); - collector.visit_program(program); + collector.collect_program(program); collector.finish() } @@ -95,7 +101,7 @@ impl FileFactCollector { fn finish(mut self) -> FileFacts { self.reconcile_file_exports(); if !self.file_exports.is_empty() { - set_attribute( + set_node_attribute( &mut self.file_node, "exports", Value::Array(self.file_exports.clone()), @@ -147,13 +153,13 @@ impl FileFactCollector { }; let register_default_alias = export .as_ref() - .is_some_and(|export| registers_default_export_alias(export, name)); + .is_some_and(|export| Helpers::registers_default_export_alias(export, name)); match self.nodes.entry(id.clone()) { Entry::Occupied(mut entry) => { if let Some(export) = &export { - apply_export_attributes(entry.get_mut(), export, name); + Helpers::apply_export_attributes(entry.get_mut(), export, name); } else { - ensure_export_attribute(entry.get_mut()); + Helpers::ensure_export_attribute(entry.get_mut()); } } Entry::Vacant(entry) => { @@ -167,7 +173,7 @@ impl FileFactCollector { })), }; if let Some(export) = &export { - apply_export_attributes(&mut node, export, name); + Helpers::apply_export_attributes(&mut node, export, name); } entry.insert(node); } @@ -187,8 +193,8 @@ impl FileFactCollector { .insert("default".to_string(), id.clone()); } } - let file = file_id(&self.path); - insert_edge(&mut self.edges, EdgeDraft::new("CONTAINS", &file, &id)); + let file = Resolution::file_id(&self.path); + Resolution::insert_edge(&mut self.edges, EdgeDraft::new("CONTAINS", &file, &id)); id } @@ -197,7 +203,7 @@ impl FileFactCollector { let id = format!("{prefix}:{}#{name}", self.path); match self.nodes.entry(id.clone()) { Entry::Occupied(mut entry) => { - apply_export_attributes(entry.get_mut(), &ExportContext::default(), name); + Helpers::apply_export_attributes(entry.get_mut(), &ExportContext::default(), name); } Entry::Vacant(entry) => { let mut node = GraphFactNode { @@ -209,7 +215,7 @@ impl FileFactCollector { "exported": false })), }; - apply_export_attributes(&mut node, &ExportContext::default(), name); + Helpers::apply_export_attributes(&mut node, &ExportContext::default(), name); entry.insert(node); } } @@ -217,8 +223,8 @@ impl FileFactCollector { self.top_level_declarations .insert(name.to_string(), id.clone()); self.register_export_alias(&ExportContext::default(), name, &id); - let file = file_id(&self.path); - insert_edge(&mut self.edges, EdgeDraft::new("CONTAINS", &file, &id)); + let file = Resolution::file_id(&self.path); + Resolution::insert_edge(&mut self.edges, EdgeDraft::new("CONTAINS", &file, &id)); id } @@ -233,8 +239,8 @@ impl FileFactCollector { name: Some(name.to_string()), attributes: None, }); - let file = file_id(&self.path); - insert_edge(&mut self.edges, EdgeDraft::new("CONTAINS", &file, &id)); + let file = Resolution::file_id(&self.path); + Resolution::insert_edge(&mut self.edges, EdgeDraft::new("CONTAINS", &file, &id)); id } @@ -265,9 +271,9 @@ impl FileFactCollector { fn mark_exported(&mut self, local: &str, export: ExportContext) { if let Some(id) = self.top_level_declarations.get(local).cloned() { if let Some(node) = self.nodes.get_mut(&id) { - apply_export_attributes(node, &export, local); + Helpers::apply_export_attributes(node, &export, local); } - if registers_default_export_alias(&export, local) { + if Helpers::registers_default_export_alias(&export, local) { self.declarations.insert("default".to_string(), id.clone()); self.top_level_declarations .insert("default".to_string(), id); @@ -309,459 +315,3 @@ struct ImportBackedLocal { source: String, imported: String, } - -impl<'a> Visit<'a> for FileFactCollector { - fn visit_import_declaration(&mut self, import: &ImportDeclaration<'a>) { - self.imports.push(ImportFact { - specifier: import.source.value.to_string(), - bindings: import_bindings(import.specifiers.as_ref()), - }); - } - - fn visit_import_expression(&mut self, import: &ImportExpression<'a>) { - if let Expression::StringLiteral(source) = &import.source { - self.imports.push(ImportFact { - specifier: source.value.to_string(), - bindings: Vec::new(), - }); - } - walk::walk_import_expression(self, import); - } - - fn visit_ts_import_type(&mut self, import: &TSImportType<'a>) { - self.imports.push(ImportFact { - specifier: import.source.value.to_string(), - bindings: Vec::new(), - }); - walk::walk_ts_import_type(self, import); - } - - fn visit_export_named_declaration(&mut self, export: &ExportNamedDeclaration<'a>) { - let source = export - .source - .as_ref() - .map(|source| source.value.to_string()); - if let Some(source) = &source { - self.imports.push(ImportFact { - specifier: source.clone(), - bindings: export - .specifiers - .iter() - .map(|specifier| ImportBinding { - local: module_export_name(&specifier.exported), - imported: module_export_name(&specifier.local), - }) - .collect(), - }); - } - if let Some(declaration) = &export.declaration { - self.with_export(ExportContext::named(None), |collector| { - collector.visit_declaration(declaration) - }); - } - for specifier in &export.specifiers { - let local = module_export_name(&specifier.local); - let exported = module_export_name(&specifier.exported); - if let Some(source) = &source { - self.re_exports.push(ReExportFact { - specifier: source.clone(), - imported: local.clone(), - exported: exported.clone(), - }); - self.record_file_export(json!({ - "kind": "named", - "local": local, - "exported": exported, - "source": source, - "imported": module_export_name(&specifier.local), - "supportedSymbol": false - })); - } else { - if !self.top_level_declarations.contains_key(&local) { - if let Some(imported) = self.import_backed_local(&local) { - self.record_file_export(json!({ - "kind": "named", - "local": local, - "exported": exported, - "source": imported.source, - "imported": imported.imported, - "supportedSymbol": false - })); - continue; - } - } - self.mark_exported(&local, ExportContext::named(Some(exported.clone()))); - self.record_file_export(json!({ - "kind": "named", - "local": local, - "exported": exported, - "source": null, - "supportedSymbol": true - })); - } - } - } - - fn visit_export_all_declaration(&mut self, export: &ExportAllDeclaration<'a>) { - self.imports.push(ImportFact { - specifier: export.source.value.to_string(), - bindings: Vec::new(), - }); - let source = export.source.value.to_string(); - if let Some(exported) = &export.exported { - self.record_file_export(json!({ - "kind": "namespace", - "exported": module_export_name(exported), - "source": source, - "supportedSymbol": false - })); - } else { - self.record_file_export(json!({ - "kind": "all", - "exported": "*", - "source": source, - "supportedSymbol": false - })); - } - } - - fn visit_export_default_declaration(&mut self, export: &ExportDefaultDeclaration<'a>) { - match &export.declaration { - ExportDefaultDeclarationKind::FunctionDeclaration(function) => { - self.record_file_export(json!({ - "kind": "default", - "local": function_name(function).unwrap_or_else(|| "default".to_string()), - "exported": "default", - "source": null, - "supportedSymbol": true - })); - self.with_export(ExportContext::default(), |collector| { - collector.visit_function(function, ScopeFlags::Function) - }); - } - ExportDefaultDeclarationKind::ClassDeclaration(class) => { - self.record_file_export(json!({ - "kind": "default", - "local": class_name(class).unwrap_or_else(|| "default".to_string()), - "exported": "default", - "source": null, - "supportedSymbol": true - })); - self.with_export(ExportContext::default(), |collector| { - collector.visit_class(class) - }); - } - ExportDefaultDeclarationKind::TSInterfaceDeclaration(declaration) => { - self.record_file_export(json!({ - "kind": "default", - "local": declaration.id.name.as_ref(), - "exported": "default", - "source": null, - "supportedSymbol": true - })); - self.with_export(ExportContext::default(), |collector| { - collector.visit_ts_interface_declaration(declaration) - }); - } - _ => { - let expression = export.declaration.to_expression(); - let local = default_export_local(expression); - if let Some(local) = &local { - if !self.top_level_declarations.contains_key(local) { - if let Some(imported) = self.import_backed_local(local) { - self.record_file_export(json!({ - "kind": "default", - "local": local, - "exported": "default", - "source": imported.source, - "imported": imported.imported, - "supportedSymbol": false - })); - self.visit_expression(expression); - return; - } - } - self.mark_exported(local, ExportContext::default()); - } - self.record_file_export(json!({ - "kind": "default", - "local": local, - "exported": "default", - "source": null, - "supportedSymbol": local.is_some() - })); - self.visit_expression(expression); - } - } - } - - fn visit_function(&mut self, function: &Function<'a>, flags: ScopeFlags) { - if let Some(name) = function_name(function) { - let id = self.add_declaration("function", "Function", &name); - self.with_context(id, |collector| { - walk::walk_function(collector, function, flags) - }); - } else if self - .current_export - .as_ref() - .is_some_and(|export| export.export_kind == "default") - { - let id = self.add_default_declaration("function", "Function"); - self.with_context(id, |collector| { - walk::walk_function(collector, function, flags) - }); - } else { - walk::walk_function(self, function, flags); - } - } - - fn visit_class(&mut self, class: &Class<'a>) { - if let Some(name) = class_name(class) { - let id = self.add_declaration("class", "Class", &name); - if let Some(super_class) = class.super_class.as_ref().and_then(expression_name) { - self.heritage.push(HeritageFact { - from: id.clone(), - name: super_class, - kind: "INHERITS".to_string(), - }); - } - for implemented in &class.implements { - if let Some(name) = ts_type_name(&implemented.expression) { - self.heritage.push(HeritageFact { - from: id.clone(), - name, - kind: "IMPLEMENTS".to_string(), - }); - } - } - self.with_context(id, |collector| walk::walk_class(collector, class)); - } else if self - .current_export - .as_ref() - .is_some_and(|export| export.export_kind == "default") - { - let id = self.add_default_declaration("class", "Class"); - self.with_context(id, |collector| walk::walk_class(collector, class)); - } else { - walk::walk_class(self, class); - } - } - - fn visit_ts_type_alias_declaration(&mut self, declaration: &TSTypeAliasDeclaration<'a>) { - self.add_declaration("type", "Type", declaration.id.name.as_ref()); - walk::walk_ts_type_alias_declaration(self, declaration); - } - - fn visit_ts_interface_declaration(&mut self, declaration: &TSInterfaceDeclaration<'a>) { - let id = self.add_declaration("type", "Type", declaration.id.name.as_ref()); - for extended in &declaration.extends { - if let Some(name) = expression_name(&extended.expression) { - self.heritage.push(HeritageFact { - from: id.clone(), - name, - kind: "INHERITS".to_string(), - }); - } - } - walk::walk_ts_interface_declaration(self, declaration); - } - - fn visit_variable_declaration(&mut self, declaration: &VariableDeclaration<'a>) { - for declarator in &declaration.declarations { - if let Some(type_annotation) = &declarator.type_annotation { - self.visit_ts_type_annotation(type_annotation); - } - if let Some(name) = binding_name(&declarator.id) { - if self.current_context.is_none() { - let Some(init) = declarator.init.as_ref() else { - self.add_declaration("variable", "Variable", &name); - continue; - }; - if is_function_like(init) { - let id = self.add_declaration("function", "Function", &name); - self.with_context(id, |collector| collector.visit_expression(init)); - } else { - let id = self.add_declaration("variable", "Variable", &name); - self.with_context(id, |collector| collector.visit_expression(init)); - } - continue; - } - } - if let Some(init) = declarator.init.as_ref() { - self.visit_expression(init); - } - } - } - - fn visit_call_expression(&mut self, call: &CallExpression<'a>) { - if let Some(callee) = expression_name(&call.callee) { - if callee == "test" || callee == "it" { - let Some(test_name) = first_string_argument(&call.arguments) else { - walk::walk_call_expression(self, call); - return; - }; - let test_id = self.add_test(&test_name); - self.with_context(test_id, |collector| { - walk::walk_call_expression(collector, call) - }); - return; - } - if callee != "describe" && callee != "test" && callee != "it" { - self.add_reference(callee); - } - } - walk::walk_call_expression(self, call); - } - - fn visit_new_expression(&mut self, expression: &NewExpression<'a>) { - if let Some(callee) = expression_name(&expression.callee) { - self.add_reference(callee); - } - walk::walk_new_expression(self, expression); - } -} - -fn import_bindings( - specifiers: Option<&oxc_allocator::Vec<'_, ImportDeclarationSpecifier<'_>>>, -) -> Vec { - specifiers - .into_iter() - .flat_map(|items| items.iter()) - .map(|specifier| match specifier { - ImportDeclarationSpecifier::ImportSpecifier(specifier) => ImportBinding { - local: specifier.local.name.to_string(), - imported: module_export_name(&specifier.imported), - }, - ImportDeclarationSpecifier::ImportDefaultSpecifier(specifier) => ImportBinding { - local: specifier.local.name.to_string(), - imported: "default".to_string(), - }, - ImportDeclarationSpecifier::ImportNamespaceSpecifier(specifier) => ImportBinding { - local: specifier.local.name.to_string(), - imported: "*".to_string(), - }, - }) - .collect() -} - -fn module_export_name(name: &ModuleExportName<'_>) -> String { - match name { - ModuleExportName::IdentifierName(name) => name.name.to_string(), - ModuleExportName::IdentifierReference(name) => name.name.to_string(), - ModuleExportName::StringLiteral(literal) => literal.value.to_string(), - } -} - -fn binding_name(pattern: &BindingPattern<'_>) -> Option { - match pattern { - BindingPattern::BindingIdentifier(identifier) => Some(identifier.name.to_string()), - _ => None, - } -} - -fn is_function_like(expression: &Expression<'_>) -> bool { - matches!( - expression, - Expression::ArrowFunctionExpression(_) | Expression::FunctionExpression(_) - ) -} - -fn first_string_argument(arguments: &oxc_allocator::Vec<'_, Argument<'_>>) -> Option { - arguments.first().and_then(|argument| match argument { - Argument::StringLiteral(literal) => Some(literal.value.to_string()), - _ => None, - }) -} - -fn expression_name(expression: &Expression<'_>) -> Option { - let expression = unwrapped_expression(expression); - match expression { - Expression::Identifier(identifier) => Some(identifier.name.to_string()), - Expression::StaticMemberExpression(member) => static_member_name(member), - _ => None, - } -} - -fn unwrapped_expression<'a>(expression: &'a Expression<'a>) -> &'a Expression<'a> { - let mut current = expression; - loop { - current = match current { - Expression::ParenthesizedExpression(expression) => &expression.expression, - Expression::TSAsExpression(expression) => &expression.expression, - Expression::TSSatisfiesExpression(expression) => &expression.expression, - Expression::TSNonNullExpression(expression) => &expression.expression, - Expression::TSInstantiationExpression(expression) => &expression.expression, - _ => return current, - }; - } -} - -fn static_member_name(member: &oxc_ast::ast::StaticMemberExpression<'_>) -> Option { - let property = member.property.name.to_string(); - match &member.object { - Expression::Identifier(object) => Some(format!("{}.{}", object.name, property)), - _ => Some(property), - } -} - -fn ts_type_name(name: &TSTypeName<'_>) -> Option { - match name { - TSTypeName::IdentifierReference(identifier) => Some(identifier.name.to_string()), - TSTypeName::QualifiedName(_) | TSTypeName::ThisExpression(_) => None, - } -} - -fn function_name(function: &Function<'_>) -> Option { - function.id.as_ref().map(|id| id.name.to_string()) -} - -fn class_name(class: &Class<'_>) -> Option { - class.id.as_ref().map(|id| id.name.to_string()) -} - -fn default_export_local(expression: &Expression<'_>) -> Option { - match unwrapped_expression(expression) { - Expression::Identifier(identifier) => Some(identifier.name.to_string()), - _ => None, - } -} - -fn ensure_export_attribute(node: &mut GraphFactNode) { - let attributes = attributes_object(node); - attributes - .entry("exported".to_string()) - .or_insert_with(|| Value::Bool(false)); -} - -fn apply_export_attributes(node: &mut GraphFactNode, export: &ExportContext, local_name: &str) { - let attributes = attributes_object(node); - attributes.insert("exported".to_string(), Value::Bool(true)); - attributes.insert( - "exportKind".to_string(), - Value::String(export.export_kind.clone()), - ); - attributes.insert( - "exportName".to_string(), - Value::String(export.export_name_for(local_name)), - ); -} - -fn registers_default_export_alias(export: &ExportContext, local_name: &str) -> bool { - export.export_kind == "default" || export.export_name_for(local_name) == "default" -} - -fn set_attribute(node: &mut GraphFactNode, key: &str, value: Value) { - attributes_object(node).insert(key.to_string(), value); -} - -fn attributes_object(node: &mut GraphFactNode) -> &mut serde_json::Map { - let attributes = node - .attributes - .get_or_insert_with(|| Value::Object(serde_json::Map::new())); - loop { - if let Value::Object(object) = attributes { - return object; - } - *attributes = Value::Object(serde_json::Map::new()); - } -} diff --git a/crates/graph-core/src/extraction/facts/collector/helpers.rs b/crates/graph-core/src/extraction/facts/collector/helpers.rs new file mode 100644 index 0000000..d433aa3 --- /dev/null +++ b/crates/graph-core/src/extraction/facts/collector/helpers.rs @@ -0,0 +1,154 @@ +use super::*; + +pub(super) struct Helpers; + +pub(super) trait CollectorHelpers { + fn import_bindings( + specifiers: Option<&oxc_allocator::Vec<'_, ImportDeclarationSpecifier<'_>>>, + ) -> Vec; + fn module_export_name(name: &ModuleExportName<'_>) -> String; + fn binding_name(pattern: &BindingPattern<'_>) -> Option; + fn is_function_like(expression: &Expression<'_>) -> bool; + fn first_string_argument(arguments: &oxc_allocator::Vec<'_, Argument<'_>>) -> Option; + fn expression_name(expression: &Expression<'_>) -> Option; + fn unwrapped_expression<'a>(expression: &'a Expression<'a>) -> &'a Expression<'a>; + fn static_member_name(member: &oxc_ast::ast::StaticMemberExpression<'_>) -> Option; + fn ts_type_name(name: &TSTypeName<'_>) -> Option; + fn function_name(function: &Function<'_>) -> Option; + fn class_name(class: &Class<'_>) -> Option; + fn default_export_local(expression: &Expression<'_>) -> Option; + fn ensure_export_attribute(node: &mut GraphFactNode); + fn apply_export_attributes(node: &mut GraphFactNode, export: &ExportContext, local_name: &str); + fn registers_default_export_alias(export: &ExportContext, local_name: &str) -> bool; +} + +impl CollectorHelpers for Helpers { + fn import_bindings( + specifiers: Option<&oxc_allocator::Vec<'_, ImportDeclarationSpecifier<'_>>>, + ) -> Vec { + specifiers + .into_iter() + .flat_map(|items| items.iter()) + .map(|specifier| match specifier { + ImportDeclarationSpecifier::ImportSpecifier(specifier) => ImportBinding { + local: specifier.local.name.to_string(), + imported: Self::module_export_name(&specifier.imported), + }, + ImportDeclarationSpecifier::ImportDefaultSpecifier(specifier) => ImportBinding { + local: specifier.local.name.to_string(), + imported: "default".to_string(), + }, + ImportDeclarationSpecifier::ImportNamespaceSpecifier(specifier) => ImportBinding { + local: specifier.local.name.to_string(), + imported: "*".to_string(), + }, + }) + .collect() + } + + fn module_export_name(name: &ModuleExportName<'_>) -> String { + match name { + ModuleExportName::IdentifierName(name) => name.name.to_string(), + ModuleExportName::IdentifierReference(name) => name.name.to_string(), + ModuleExportName::StringLiteral(literal) => literal.value.to_string(), + } + } + + fn binding_name(pattern: &BindingPattern<'_>) -> Option { + match pattern { + BindingPattern::BindingIdentifier(identifier) => Some(identifier.name.to_string()), + _ => None, + } + } + + fn is_function_like(expression: &Expression<'_>) -> bool { + matches!( + expression, + Expression::ArrowFunctionExpression(_) | Expression::FunctionExpression(_) + ) + } + + fn first_string_argument(arguments: &oxc_allocator::Vec<'_, Argument<'_>>) -> Option { + arguments.first().and_then(|argument| match argument { + Argument::StringLiteral(literal) => Some(literal.value.to_string()), + _ => None, + }) + } + + fn expression_name(expression: &Expression<'_>) -> Option { + let expression = Self::unwrapped_expression(expression); + match expression { + Expression::Identifier(identifier) => Some(identifier.name.to_string()), + Expression::StaticMemberExpression(member) => Self::static_member_name(member), + _ => None, + } + } + + fn unwrapped_expression<'a>(expression: &'a Expression<'a>) -> &'a Expression<'a> { + let mut current = expression; + loop { + current = match current { + Expression::ParenthesizedExpression(expression) => &expression.expression, + Expression::TSAsExpression(expression) => &expression.expression, + Expression::TSSatisfiesExpression(expression) => &expression.expression, + Expression::TSNonNullExpression(expression) => &expression.expression, + Expression::TSInstantiationExpression(expression) => &expression.expression, + _ => return current, + }; + } + } + + fn static_member_name(member: &oxc_ast::ast::StaticMemberExpression<'_>) -> Option { + let property = member.property.name.to_string(); + match &member.object { + Expression::Identifier(object) => Some(format!("{}.{}", object.name, property)), + _ => Some(property), + } + } + + fn ts_type_name(name: &TSTypeName<'_>) -> Option { + match name { + TSTypeName::IdentifierReference(identifier) => Some(identifier.name.to_string()), + TSTypeName::QualifiedName(_) | TSTypeName::ThisExpression(_) => None, + } + } + + fn function_name(function: &Function<'_>) -> Option { + function.id.as_ref().map(|id| id.name.to_string()) + } + + fn class_name(class: &Class<'_>) -> Option { + class.id.as_ref().map(|id| id.name.to_string()) + } + + fn default_export_local(expression: &Expression<'_>) -> Option { + match Self::unwrapped_expression(expression) { + Expression::Identifier(identifier) => Some(identifier.name.to_string()), + _ => None, + } + } + + fn ensure_export_attribute(node: &mut GraphFactNode) { + let attributes = node_attributes_object(node); + attributes + .entry("exported".to_string()) + .or_insert_with(|| Value::Bool(false)); + } + + fn apply_export_attributes(node: &mut GraphFactNode, export: &ExportContext, local_name: &str) { + let attributes = node_attributes_object(node); + attributes.insert("exported".to_string(), Value::Bool(true)); + attributes.insert( + "exportKind".to_string(), + Value::String(export.export_kind.clone()), + ); + attributes.insert( + "exportName".to_string(), + Value::String(export.export_name_for(local_name)), + ); + } + + fn registers_default_export_alias(export: &ExportContext, local_name: &str) -> bool { + export.export_kind == "default" || export.export_name_for(local_name) == "default" + } +} diff --git a/crates/graph-core/src/extraction/facts/collector/visit.rs b/crates/graph-core/src/extraction/facts/collector/visit.rs new file mode 100644 index 0000000..062a552 --- /dev/null +++ b/crates/graph-core/src/extraction/facts/collector/visit.rs @@ -0,0 +1,326 @@ +use super::*; + +pub(super) trait CollectProgram<'a> { + fn collect_program(&mut self, program: &oxc_ast::ast::Program<'a>); +} + +impl<'a> CollectProgram<'a> for FileFactCollector { + fn collect_program(&mut self, program: &oxc_ast::ast::Program<'a>) { + self.visit_program(program); + } +} + +impl<'a> Visit<'a> for FileFactCollector { + fn visit_import_declaration(&mut self, import: &ImportDeclaration<'a>) { + self.imports.push(ImportFact { + specifier: import.source.value.to_string(), + bindings: Helpers::import_bindings(import.specifiers.as_ref()), + }); + } + + fn visit_import_expression(&mut self, import: &ImportExpression<'a>) { + if let Expression::StringLiteral(source) = &import.source { + self.imports.push(ImportFact { + specifier: source.value.to_string(), + bindings: Vec::new(), + }); + } + walk::walk_import_expression(self, import); + } + + fn visit_ts_import_type(&mut self, import: &TSImportType<'a>) { + self.imports.push(ImportFact { + specifier: import.source.value.to_string(), + bindings: Vec::new(), + }); + walk::walk_ts_import_type(self, import); + } + + fn visit_export_named_declaration(&mut self, export: &ExportNamedDeclaration<'a>) { + let source = export + .source + .as_ref() + .map(|source| source.value.to_string()); + if let Some(source) = &source { + self.imports.push(ImportFact { + specifier: source.clone(), + bindings: export + .specifiers + .iter() + .map(|specifier| ImportBinding { + local: Helpers::module_export_name(&specifier.exported), + imported: Helpers::module_export_name(&specifier.local), + }) + .collect(), + }); + } + if let Some(declaration) = &export.declaration { + self.with_export(ExportContext::named(None), |collector| { + collector.visit_declaration(declaration) + }); + } + for specifier in &export.specifiers { + let local = Helpers::module_export_name(&specifier.local); + let exported = Helpers::module_export_name(&specifier.exported); + if let Some(source) = &source { + self.re_exports.push(ReExportFact { + specifier: source.clone(), + imported: local.clone(), + exported: exported.clone(), + }); + self.record_file_export(json!({ + "kind": "named", + "local": local, + "exported": exported, + "source": source, + "imported": Helpers::module_export_name(&specifier.local), + "supportedSymbol": false + })); + } else { + if !self.top_level_declarations.contains_key(&local) { + if let Some(imported) = self.import_backed_local(&local) { + self.record_file_export(json!({ + "kind": "named", + "local": local, + "exported": exported, + "source": imported.source, + "imported": imported.imported, + "supportedSymbol": false + })); + continue; + } + } + self.mark_exported(&local, ExportContext::named(Some(exported.clone()))); + self.record_file_export(json!({ + "kind": "named", + "local": local, + "exported": exported, + "source": null, + "supportedSymbol": true + })); + } + } + } + + fn visit_export_all_declaration(&mut self, export: &ExportAllDeclaration<'a>) { + self.imports.push(ImportFact { + specifier: export.source.value.to_string(), + bindings: Vec::new(), + }); + let source = export.source.value.to_string(); + if let Some(exported) = &export.exported { + self.record_file_export(json!({ + "kind": "namespace", + "exported": Helpers::module_export_name(exported), + "source": source, + "supportedSymbol": false + })); + } else { + self.record_file_export(json!({ + "kind": "all", + "exported": "*", + "source": source, + "supportedSymbol": false + })); + } + } + + fn visit_export_default_declaration(&mut self, export: &ExportDefaultDeclaration<'a>) { + match &export.declaration { + ExportDefaultDeclarationKind::FunctionDeclaration(function) => { + self.record_file_export(json!({ + "kind": "default", + "local": Helpers::function_name(function).unwrap_or_else(|| "default".to_string()), + "exported": "default", + "source": null, + "supportedSymbol": true + })); + self.with_export(ExportContext::default(), |collector| { + collector.visit_function(function, ScopeFlags::Function) + }); + } + ExportDefaultDeclarationKind::ClassDeclaration(class) => { + self.record_file_export(json!({ + "kind": "default", + "local": Helpers::class_name(class).unwrap_or_else(|| "default".to_string()), + "exported": "default", + "source": null, + "supportedSymbol": true + })); + self.with_export(ExportContext::default(), |collector| { + collector.visit_class(class) + }); + } + ExportDefaultDeclarationKind::TSInterfaceDeclaration(declaration) => { + self.record_file_export(json!({ + "kind": "default", + "local": declaration.id.name.as_ref(), + "exported": "default", + "source": null, + "supportedSymbol": true + })); + self.with_export(ExportContext::default(), |collector| { + collector.visit_ts_interface_declaration(declaration) + }); + } + _ => { + let expression = export.declaration.to_expression(); + let local = Helpers::default_export_local(expression); + if let Some(local) = &local { + if !self.top_level_declarations.contains_key(local) { + if let Some(imported) = self.import_backed_local(local) { + self.record_file_export(json!({ + "kind": "default", + "local": local, + "exported": "default", + "source": imported.source, + "imported": imported.imported, + "supportedSymbol": false + })); + self.visit_expression(expression); + return; + } + } + self.mark_exported(local, ExportContext::default()); + } + self.record_file_export(json!({ + "kind": "default", + "local": local, + "exported": "default", + "source": null, + "supportedSymbol": local.is_some() + })); + self.visit_expression(expression); + } + } + } + + fn visit_function(&mut self, function: &Function<'a>, flags: ScopeFlags) { + if let Some(name) = Helpers::function_name(function) { + let id = self.add_declaration("function", "Function", &name); + self.with_context(id, |collector| { + walk::walk_function(collector, function, flags) + }); + } else if self + .current_export + .as_ref() + .is_some_and(|export| export.export_kind == "default") + { + let id = self.add_default_declaration("function", "Function"); + self.with_context(id, |collector| { + walk::walk_function(collector, function, flags) + }); + } else { + walk::walk_function(self, function, flags); + } + } + + fn visit_class(&mut self, class: &Class<'a>) { + if let Some(name) = Helpers::class_name(class) { + let id = self.add_declaration("class", "Class", &name); + if let Some(super_class) = class + .super_class + .as_ref() + .and_then(Helpers::expression_name) + { + self.heritage.push(HeritageFact { + from: id.clone(), + name: super_class, + kind: "INHERITS".to_string(), + }); + } + for implemented in &class.implements { + if let Some(name) = Helpers::ts_type_name(&implemented.expression) { + self.heritage.push(HeritageFact { + from: id.clone(), + name, + kind: "IMPLEMENTS".to_string(), + }); + } + } + self.with_context(id, |collector| walk::walk_class(collector, class)); + } else if self + .current_export + .as_ref() + .is_some_and(|export| export.export_kind == "default") + { + let id = self.add_default_declaration("class", "Class"); + self.with_context(id, |collector| walk::walk_class(collector, class)); + } else { + walk::walk_class(self, class); + } + } + + fn visit_ts_type_alias_declaration(&mut self, declaration: &TSTypeAliasDeclaration<'a>) { + self.add_declaration("type", "Type", declaration.id.name.as_ref()); + walk::walk_ts_type_alias_declaration(self, declaration); + } + + fn visit_ts_interface_declaration(&mut self, declaration: &TSInterfaceDeclaration<'a>) { + let id = self.add_declaration("type", "Type", declaration.id.name.as_ref()); + for extended in &declaration.extends { + if let Some(name) = Helpers::expression_name(&extended.expression) { + self.heritage.push(HeritageFact { + from: id.clone(), + name, + kind: "INHERITS".to_string(), + }); + } + } + walk::walk_ts_interface_declaration(self, declaration); + } + + fn visit_variable_declaration(&mut self, declaration: &VariableDeclaration<'a>) { + for declarator in &declaration.declarations { + if let Some(type_annotation) = &declarator.type_annotation { + self.visit_ts_type_annotation(type_annotation); + } + if let Some(name) = Helpers::binding_name(&declarator.id) { + if self.current_context.is_none() { + let Some(init) = declarator.init.as_ref() else { + self.add_declaration("variable", "Variable", &name); + continue; + }; + if Helpers::is_function_like(init) { + let id = self.add_declaration("function", "Function", &name); + self.with_context(id, |collector| collector.visit_expression(init)); + } else { + let id = self.add_declaration("variable", "Variable", &name); + self.with_context(id, |collector| collector.visit_expression(init)); + } + continue; + } + } + if let Some(init) = declarator.init.as_ref() { + self.visit_expression(init); + } + } + } + + fn visit_call_expression(&mut self, call: &CallExpression<'a>) { + if let Some(callee) = Helpers::expression_name(&call.callee) { + if callee == "test" || callee == "it" { + let Some(test_name) = Helpers::first_string_argument(&call.arguments) else { + walk::walk_call_expression(self, call); + return; + }; + let test_id = self.add_test(&test_name); + self.with_context(test_id, |collector| { + walk::walk_call_expression(collector, call) + }); + return; + } + if callee != "describe" && callee != "test" && callee != "it" { + self.add_reference(callee); + } + } + walk::walk_call_expression(self, call); + } + + fn visit_new_expression(&mut self, expression: &NewExpression<'a>) { + if let Some(callee) = Helpers::expression_name(&expression.callee) { + self.add_reference(callee); + } + walk::walk_new_expression(self, expression); + } +} diff --git a/crates/graph-core/src/extraction/facts/python.rs b/crates/graph-core/src/extraction/facts/python.rs index fa899a6..465ed8b 100644 --- a/crates/graph-core/src/extraction/facts/python.rs +++ b/crates/graph-core/src/extraction/facts/python.rs @@ -1,6 +1,6 @@ use super::{ - file_id, insert_edge, EdgeDraft, FileFacts, HeritageFact, ImportBinding, ImportFact, - ReExportFact, ReferenceFact, + set_node_attribute, EdgeDraft, FactResolution, FileFacts, HeritageFact, ImportBinding, + ImportFact, ReExportFact, ReferenceFact, Resolution, }; use crate::protocol::{GraphFactEdge, GraphFactNode}; use serde_json::{json, Value}; @@ -8,6 +8,10 @@ use std::collections::{btree_map::Entry, BTreeMap, BTreeSet}; use std::path::Path; use tree_sitter::{Node, Tree}; +mod syntax; + +use syntax::{PythonSyntax, Syntax}; + pub(super) fn collect_file_facts( path: String, file_node: GraphFactNode, @@ -15,7 +19,7 @@ pub(super) fn collect_file_facts( tree: &Tree, ) -> FileFacts { let root = tree.root_node(); - let explicit_exports = collect_explicit_exports(root, source_text); + let explicit_exports = Syntax::collect_explicit_exports(root, source_text); let mut collector = PythonFileFactCollector::new(path, file_node, source_text, explicit_exports); collector.visit_module(root); @@ -49,7 +53,7 @@ impl<'a> PythonFileFactCollector<'a> { source_text: &'a str, explicit_exports: Option>, ) -> Self { - let module_name = module_name_for_path(&path); + let module_name = Syntax::module_name_for_path(&path); let module_id = format!("module:{path}#{module_name}"); let mut nodes = BTreeMap::new(); nodes.insert( @@ -63,9 +67,9 @@ impl<'a> PythonFileFactCollector<'a> { }, ); let mut edges = BTreeMap::new(); - insert_edge( + Resolution::insert_edge( &mut edges, - EdgeDraft::new("CONTAINS", &file_id(&path), &module_id), + EdgeDraft::new("CONTAINS", &Resolution::file_id(&path), &module_id), ); Self { path, @@ -106,7 +110,7 @@ impl<'a> PythonFileFactCollector<'a> { } } if self.explicit_exports.is_some() || !self.file_exports.is_empty() { - set_attribute( + set_node_attribute( &mut self.file_node, "exports", Value::Array(self.file_exports.clone()), @@ -127,22 +131,22 @@ impl<'a> PythonFileFactCollector<'a> { } fn visit_module(&mut self, node: Node<'_>) { - for child in named_children(node) { + for child in Syntax::named_children(node) { self.visit_statement(child, Vec::new()); } } fn visit_statement(&mut self, node: Node<'_>, decorators: Vec) { + if self.visit_definition(node, decorators) { + return; + } match node.kind() { - "decorated_definition" => self.visit_decorated_definition(node), - "class_definition" => self.visit_class(node, decorators), - "function_definition" => self.visit_function(node, decorators), "import_statement" => self.visit_import_statement(node), "import_from_statement" => self.visit_import_from_statement(node), "assignment" => self.visit_assignment(node), "expression_statement" => self.visit_expression_statement(node), "block" | "module" => { - for child in named_children(node) { + for child in Syntax::named_children(node) { self.visit_statement(child, Vec::new()); } } @@ -151,11 +155,21 @@ impl<'a> PythonFileFactCollector<'a> { } } + fn visit_definition(&mut self, node: Node<'_>, decorators: Vec) -> bool { + match node.kind() { + "decorated_definition" => self.visit_decorated_definition(node), + "class_definition" => self.visit_class(node, decorators), + "function_definition" => self.visit_function(node, decorators), + _ => return false, + } + true + } + fn visit_decorated_definition(&mut self, node: Node<'_>) { - let decorators = named_children(node) + let decorators = Syntax::named_children(node) .into_iter() .filter(|child| child.kind() == "decorator") - .filter_map(|child| decorator_name(child, self.source_text)) + .filter_map(|child| Syntax::decorator_name(child, self.source_text)) .collect::>(); if let Some(definition) = node.child_by_field_name("definition") { self.visit_statement(definition, decorators); @@ -163,21 +177,21 @@ impl<'a> PythonFileFactCollector<'a> { } fn visit_class(&mut self, node: Node<'_>, decorators: Vec) { - let Some(name) = field_text(node, "name", self.source_text) else { + let Some(name) = Syntax::field_text(node, "name", self.source_text) else { self.visit_children_for_references(node); return; }; - let bases = class_bases(node, self.source_text); - let is_test = is_test_class(&name, &bases); - let id = self.add_declaration( - "class", - "Class", - &name, - json!({ + let bases = Syntax::class_bases(node, self.source_text); + let is_test = Syntax::is_test_class(&name, &bases); + let id = self.add_declaration(PythonDeclarationDraft { + prefix: "class", + kind: "Class", + name: &name, + extra_attributes: json!({ "decorators": decorators, "isTest": is_test }), - ); + }); for base in bases { self.heritage.push(HeritageFact { from: id.clone(), @@ -186,7 +200,7 @@ impl<'a> PythonFileFactCollector<'a> { }); } let body = node.child_by_field_name("body"); - self.with_parent(id, name, is_test, |collector| { + self.with_parent(ParentScope::new(id, name, is_test), |collector| { if let Some(body) = body { collector.visit_statement(body, Vec::new()); } @@ -194,26 +208,26 @@ impl<'a> PythonFileFactCollector<'a> { } fn visit_function(&mut self, node: Node<'_>, decorators: Vec) { - let Some(name) = field_text(node, "name", self.source_text) else { + let Some(name) = Syntax::field_text(node, "name", self.source_text) else { self.visit_children_for_references(node); return; }; - let is_async = node_text(node, self.source_text) + let is_async = Syntax::node_text(node, self.source_text) .trim_start() .starts_with("async def"); - let is_test = is_test_function(&self.path, &name, self.test_class_depth > 0); - let id = self.add_declaration( - "function", - "Function", - &name, - json!({ + let is_test = Syntax::is_test_function(&self.path, &name, self.test_class_depth > 0); + let id = self.add_declaration(PythonDeclarationDraft { + prefix: "function", + kind: "Function", + name: &name, + extra_attributes: json!({ "async": is_async, "decorators": decorators, "isTest": is_test }), - ); + }); let body = node.child_by_field_name("body"); - self.with_parent(id, name, false, |collector| { + self.with_parent(ParentScope::new(id, name, false), |collector| { if let Some(body) = body { collector.visit_statement(body, Vec::new()); } @@ -222,18 +236,22 @@ impl<'a> PythonFileFactCollector<'a> { fn visit_import_statement(&mut self, node: Node<'_>) { self.imports - .extend(parse_import_statement(&node_text(node, self.source_text))); + .extend(Syntax::parse_import_statement(&Syntax::node_text( + node, + self.source_text, + ))); } fn visit_import_from_statement(&mut self, node: Node<'_>) { - self.imports.extend(parse_from_import_statement(&node_text( - node, - self.source_text, - ))); + self.imports + .extend(Syntax::parse_from_import_statement(&Syntax::node_text( + node, + self.source_text, + ))); } fn visit_expression_statement(&mut self, node: Node<'_>) { - if let Some(assignment) = named_children(node) + if let Some(assignment) = Syntax::named_children(node) .into_iter() .find(|child| child.kind() == "assignment") { @@ -247,9 +265,16 @@ impl<'a> PythonFileFactCollector<'a> { let left = node.child_by_field_name("left"); let right = node.child_by_field_name("right"); if self.current_parent == self.module_id { - if let Some(name) = left.and_then(|left| assignment_name(left, self.source_text)) { + if let Some(name) = + left.and_then(|left| Syntax::assignment_name(left, self.source_text)) + { if name != "__all__" { - let id = self.add_declaration("variable", "Variable", &name, json!({})); + let id = self.add_declaration(PythonDeclarationDraft { + prefix: "variable", + kind: "Variable", + name: &name, + extra_attributes: json!({}), + }); if let Some(right) = right { self.with_existing_parent(id, |collector| { collector.visit_children_for_references(right) @@ -266,8 +291,8 @@ impl<'a> PythonFileFactCollector<'a> { fn visit_call(&mut self, node: Node<'_>) { if let Some(function) = node.child_by_field_name("function") { - if let Some(name) = expression_name(function, self.source_text) { - if !is_builtin_reference(&name) { + if let Some(name) = Syntax::expression_name(function, self.source_text) { + if !Syntax::is_builtin_reference(&name) { self.references.push(ReferenceFact { from: self.current_parent.clone(), name, @@ -286,22 +311,17 @@ impl<'a> PythonFileFactCollector<'a> { self.visit_call(node); return; } - for child in named_children(node) { + for child in Syntax::named_children(node) { self.visit_statement(child, Vec::new()); } } - fn add_declaration( - &mut self, - prefix: &str, - kind: &str, - name: &str, - extra_attributes: Value, - ) -> String { - let qualifier = self.qualified_name(name); - let id = format!("{prefix}:{}#{qualifier}", self.path); + fn add_declaration(&mut self, draft: PythonDeclarationDraft<'_>) -> String { + let qualifier = self.qualified_name(draft.name); + let id = format!("{}:{}#{qualifier}", draft.prefix, self.path); let is_top_level = self.current_parent == self.module_id; - let export = export_policy(name, is_top_level, self.explicit_exports.as_ref()); + let export = + Syntax::export_policy(draft.name, is_top_level, self.explicit_exports.as_ref()); let mut attributes = serde_json::Map::new(); attributes.insert("exported".to_string(), Value::Bool(export.exported)); attributes.insert( @@ -310,9 +330,12 @@ impl<'a> PythonFileFactCollector<'a> { ); if export.exported { attributes.insert("exportKind".to_string(), Value::String("named".to_string())); - attributes.insert("exportName".to_string(), Value::String(name.to_string())); + attributes.insert( + "exportName".to_string(), + Value::String(draft.name.to_string()), + ); } - if let Value::Object(extra) = extra_attributes { + if let Value::Object(extra) = draft.extra_attributes { for (key, value) in extra { attributes.insert(key, value); } @@ -325,31 +348,32 @@ impl<'a> PythonFileFactCollector<'a> { Entry::Vacant(entry) => { entry.insert(GraphFactNode { id: id.clone(), - kind: kind.to_string(), + kind: draft.kind.to_string(), path: Some(self.path.clone()), - name: Some(name.to_string()), + name: Some(draft.name.to_string()), attributes: Some(Value::Object(attributes)), }); } } - self.declarations.insert(name.to_string(), id.clone()); + self.declarations.insert(draft.name.to_string(), id.clone()); self.declarations.insert(qualifier, id.clone()); if is_top_level { self.top_level_declarations - .insert(name.to_string(), id.clone()); + .insert(draft.name.to_string(), id.clone()); if export.exported { - self.export_aliases.insert(name.to_string(), id.clone()); + self.export_aliases + .insert(draft.name.to_string(), id.clone()); self.file_exports.push(json!({ "kind": "named", - "local": name, - "exported": name, + "local": draft.name, + "exported": draft.name, "source": null, "supportedSymbol": true, "policy": export.policy })); } } - insert_edge( + Resolution::insert_edge( &mut self.edges, EdgeDraft::new("CONTAINS", &self.current_parent, &id), ); @@ -363,20 +387,14 @@ impl<'a> PythonFileFactCollector<'a> { format!("{}.{}", self.qualifier.join("."), name) } - fn with_parent( - &mut self, - parent: String, - name: String, - is_test_class: bool, - visit: impl FnOnce(&mut Self), - ) { - let previous_parent = std::mem::replace(&mut self.current_parent, parent); - self.qualifier.push(name); - if is_test_class { + fn with_parent(&mut self, scope: ParentScope, visit: impl FnOnce(&mut Self)) { + let previous_parent = std::mem::replace(&mut self.current_parent, scope.parent); + self.qualifier.push(scope.name); + if scope.is_test_class { self.test_class_depth += 1; } visit(self); - if is_test_class { + if scope.is_test_class { self.test_class_depth = self.test_class_depth.saturating_sub(1); } self.qualifier.pop(); @@ -399,314 +417,25 @@ impl<'a> PythonFileFactCollector<'a> { } } -struct ExportPolicy<'a> { - exported: bool, - policy: &'a str, -} - -fn export_policy( - name: &str, - is_top_level: bool, - explicit_exports: Option<&BTreeSet>, -) -> ExportPolicy<'static> { - if !is_top_level { - return ExportPolicy { - exported: false, - policy: "not_module_level", - }; - } - if let Some(exports) = explicit_exports { - return ExportPolicy { - exported: exports.contains(name), - policy: "__all__", - }; - } - ExportPolicy { - exported: !name.starts_with('_'), - policy: "underscore_convention", - } -} - -fn collect_explicit_exports(root: Node<'_>, source_text: &str) -> Option> { - let mut exports = BTreeSet::new(); - let mut found = false; - for statement in named_children(root) { - let Some(node) = module_level_assignment(statement) else { - continue; - }; - let left = node.child_by_field_name("left"); - if left - .and_then(|left| assignment_name(left, source_text)) - .as_deref() - != Some("__all__") - { - continue; - } - found = true; - if let Some(right) = node.child_by_field_name("right") { - for string_node in descendant_nodes(right) { - if string_node.kind() == "string" { - if let Some(value) = parse_string_literal(&node_text(string_node, source_text)) - { - exports.insert(value); - } - } - } - } - } - found.then_some(exports) -} - -fn module_level_assignment(node: Node<'_>) -> Option> { - if node.kind() == "assignment" { - return Some(node); - } - if node.kind() != "expression_statement" { - return None; - } - named_children(node) - .into_iter() - .find(|child| child.kind() == "assignment") -} - -fn class_bases(node: Node<'_>, source_text: &str) -> Vec { - let Some(superclasses) = node.child_by_field_name("superclasses") else { - return Vec::new(); - }; - named_children(superclasses) - .into_iter() - .filter_map(|child| expression_name(child, source_text)) - .collect() -} - -fn is_test_class(name: &str, bases: &[String]) -> bool { - name.starts_with("Test") || bases.iter().any(|base| base == "unittest.TestCase") -} - -fn is_test_function(path: &str, name: &str, in_test_class: bool) -> bool { - (is_test_file(path) && name.starts_with("test_")) || in_test_class && name.starts_with("test_") -} - -fn is_test_file(path: &str) -> bool { - let file_name = Path::new(path) - .file_name() - .and_then(|name| name.to_str()) - .unwrap_or(path); - file_name.starts_with("test_") || file_name.ends_with("_test.py") -} - -fn parse_import_statement(text: &str) -> Vec { - let Some(imports) = text.trim().strip_prefix("import ") else { - return Vec::new(); - }; - imports - .split(',') - .filter_map(|entry| parse_import_entry(entry.trim())) - .map(|(specifier, local)| ImportFact { - specifier, - bindings: vec![ImportBinding { - local, - imported: "*".to_string(), - }], - }) - .collect() -} - -fn parse_import_entry(entry: &str) -> Option<(String, String)> { - let (module, alias) = split_alias(entry); - if module.is_empty() { - return None; - } - let local = alias - .map(ToString::to_string) - .or_else(|| module.split('.').next().map(ToString::to_string))?; - Some((module.to_string(), local)) -} - -fn parse_from_import_statement(text: &str) -> Vec { - let text = text.trim(); - let Some(rest) = text.strip_prefix("from ") else { - return Vec::new(); - }; - let Some((module, imports)) = rest.split_once(" import ") else { - return Vec::new(); - }; - let module = module.trim(); - let imports = imports.trim().trim_start_matches('(').trim_end_matches(')'); - if imports == "*" { - return vec![ImportFact { - specifier: module.to_string(), - bindings: vec![ImportBinding { - local: "*".to_string(), - imported: "*".to_string(), - }], - }]; - } - imports - .split(',') - .filter_map(|entry| parse_from_import_entry(module, entry.trim())) - .collect() -} - -fn parse_from_import_entry(module: &str, entry: &str) -> Option { - let (imported, alias) = split_alias(entry); - if imported.is_empty() { - return None; - } - let local = alias.unwrap_or(imported).to_string(); - let (specifier, imported_name) = if module.chars().all(|character| character == '.') { - (format!("{module}{imported}"), "*".to_string()) - } else { - (module.to_string(), imported.to_string()) - }; - Some(ImportFact { - specifier, - bindings: vec![ImportBinding { - local, - imported: imported_name, - }], - }) -} - -fn split_alias(entry: &str) -> (&str, Option<&str>) { - if let Some((left, right)) = entry.split_once(" as ") { - (left.trim(), Some(right.trim())) - } else { - (entry.trim(), None) - } -} - -fn decorator_name(node: Node<'_>, source_text: &str) -> Option { - let text = node_text(node, source_text); - text.trim() - .strip_prefix('@') - .map(str::trim) - .filter(|name| !name.is_empty()) - .map(ToString::to_string) -} - -fn assignment_name(node: Node<'_>, source_text: &str) -> Option { - match node.kind() { - "identifier" => Some(node_text(node, source_text)), - _ => None, - } -} - -fn expression_name(node: Node<'_>, source_text: &str) -> Option { - match node.kind() { - "identifier" => Some(node_text(node, source_text)), - "attribute" => { - let object = node - .child_by_field_name("object") - .and_then(|object| expression_name(object, source_text))?; - let attribute = field_text(node, "attribute", source_text)?; - Some(format!("{object}.{attribute}")) - } - "call" => node - .child_by_field_name("function") - .and_then(|function| expression_name(function, source_text)), - "dotted_name" => Some(node_text(node, source_text)), - _ => named_children(node) - .into_iter() - .find_map(|child| expression_name(child, source_text)), - } -} - -fn is_builtin_reference(name: &str) -> bool { - matches!( - name, - "super" - | "len" - | "str" - | "int" - | "float" - | "bool" - | "list" - | "dict" - | "set" - | "tuple" - | "print" - | "range" - ) || name.starts_with("self.") - || name.starts_with("cls.") -} - -fn field_text(node: Node<'_>, field: &str, source_text: &str) -> Option { - node.child_by_field_name(field) - .map(|child| node_text(child, source_text)) -} - -fn node_text(node: Node<'_>, source_text: &str) -> String { - node.utf8_text(source_text.as_bytes()) - .map(ToString::to_string) - .unwrap_or_default() -} - -fn named_children(node: Node<'_>) -> Vec> { - let mut cursor = node.walk(); - node.named_children(&mut cursor).collect() -} - -fn descendant_nodes(node: Node<'_>) -> Vec> { - let mut nodes = Vec::new(); - let mut stack = vec![node]; - while let Some(current) = stack.pop() { - nodes.push(current); - for child in named_children(current) { - stack.push(child); - } - } - nodes -} - -fn parse_string_literal(text: &str) -> Option { - let trimmed = text.trim(); - let quote_index = trimmed.find(['"', '\''])?; - let quoted = trimmed.get(quote_index..)?; - let quote = quoted.chars().next()?; - let triple = format!("{quote}{quote}{quote}"); - if let Some(body) = quoted - .strip_prefix(&triple) - .and_then(|body| body.strip_suffix(&triple)) - { - return Some(body.to_string()); - } - quoted - .strip_prefix(quote) - .and_then(|body| body.strip_suffix(quote)) - .map(ToString::to_string) -} - -fn module_name_for_path(path: &str) -> String { - let without_extension = path - .strip_suffix(".py") - .or_else(|| path.strip_suffix(".pyi")) - .unwrap_or(path); - let mut parts = without_extension - .split('/') - .filter(|part| !part.is_empty()) - .collect::>(); - if parts.last().is_some_and(|part| *part == "__init__") { - parts.pop(); - } - if parts.is_empty() { - return "__init__".to_string(); - } - parts.join(".") +struct PythonDeclarationDraft<'a> { + prefix: &'a str, + kind: &'a str, + name: &'a str, + extra_attributes: Value, } -fn set_attribute(node: &mut GraphFactNode, key: &str, value: Value) { - attributes_object(node).insert(key.to_string(), value); +struct ParentScope { + parent: String, + name: String, + is_test_class: bool, } -fn attributes_object(node: &mut GraphFactNode) -> &mut serde_json::Map { - let attributes = node - .attributes - .get_or_insert_with(|| Value::Object(serde_json::Map::new())); - loop { - if let Value::Object(object) = attributes { - return object; +impl ParentScope { + fn new(parent: String, name: String, is_test_class: bool) -> Self { + Self { + parent, + name, + is_test_class, } - *attributes = Value::Object(serde_json::Map::new()); } } diff --git a/crates/graph-core/src/extraction/facts/python/syntax.rs b/crates/graph-core/src/extraction/facts/python/syntax.rs new file mode 100644 index 0000000..dd73fcd --- /dev/null +++ b/crates/graph-core/src/extraction/facts/python/syntax.rs @@ -0,0 +1,335 @@ +use super::*; + +pub(super) struct ExportPolicy<'a> { + pub(super) exported: bool, + pub(super) policy: &'a str, +} + +pub(super) struct Syntax; + +pub(super) trait PythonSyntax { + fn export_policy( + name: &str, + is_top_level: bool, + explicit_exports: Option<&BTreeSet>, + ) -> ExportPolicy<'static>; + fn collect_explicit_exports(root: Node<'_>, source_text: &str) -> Option>; + fn module_level_assignment(node: Node<'_>) -> Option>; + fn class_bases(node: Node<'_>, source_text: &str) -> Vec; + fn is_test_class(name: &str, bases: &[String]) -> bool; + fn is_test_function(path: &str, name: &str, in_test_class: bool) -> bool; + fn is_test_file(path: &str) -> bool; + fn parse_import_statement(text: &str) -> Vec; + fn parse_import_entry(entry: &str) -> Option<(String, String)>; + fn parse_from_import_statement(text: &str) -> Vec; + fn parse_from_import_entry(module: &str, entry: &str) -> Option; + fn split_alias(entry: &str) -> (&str, Option<&str>); + fn decorator_name(node: Node<'_>, source_text: &str) -> Option; + fn assignment_name(node: Node<'_>, source_text: &str) -> Option; + fn expression_name(node: Node<'_>, source_text: &str) -> Option; + fn is_builtin_reference(name: &str) -> bool; + fn field_text(node: Node<'_>, field: &str, source_text: &str) -> Option; + fn node_text(node: Node<'_>, source_text: &str) -> String; + fn named_children(node: Node<'_>) -> Vec>; + fn descendant_nodes(node: Node<'_>) -> Vec>; + fn parse_string_literal(text: &str) -> Option; + fn module_name_for_path(path: &str) -> String; +} + +impl PythonSyntax for Syntax { + fn export_policy( + name: &str, + is_top_level: bool, + explicit_exports: Option<&BTreeSet>, + ) -> ExportPolicy<'static> { + if !is_top_level { + return ExportPolicy { + exported: false, + policy: "not_module_level", + }; + } + if let Some(exports) = explicit_exports { + return ExportPolicy { + exported: exports.contains(name), + policy: "__all__", + }; + } + ExportPolicy { + exported: !name.starts_with('_'), + policy: "underscore_convention", + } + } + + fn collect_explicit_exports(root: Node<'_>, source_text: &str) -> Option> { + let mut exports = BTreeSet::new(); + let mut found = false; + for statement in Self::named_children(root) { + let Some(node) = Self::module_level_assignment(statement) else { + continue; + }; + let left = node.child_by_field_name("left"); + if left + .and_then(|left| Self::assignment_name(left, source_text)) + .as_deref() + != Some("__all__") + { + continue; + } + found = true; + let Some(right) = node.child_by_field_name("right") else { + continue; + }; + for string_node in Self::descendant_nodes(right) { + if string_node.kind() != "string" { + continue; + } + let Some(value) = + Self::parse_string_literal(&Self::node_text(string_node, source_text)) + else { + continue; + }; + exports.insert(value); + } + } + found.then_some(exports) + } + + fn module_level_assignment(node: Node<'_>) -> Option> { + if node.kind() == "assignment" { + return Some(node); + } + if node.kind() != "expression_statement" { + return None; + } + Self::named_children(node) + .into_iter() + .find(|child| child.kind() == "assignment") + } + + fn class_bases(node: Node<'_>, source_text: &str) -> Vec { + let Some(superclasses) = node.child_by_field_name("superclasses") else { + return Vec::new(); + }; + Self::named_children(superclasses) + .into_iter() + .filter_map(|child| Self::expression_name(child, source_text)) + .collect() + } + + fn is_test_class(name: &str, bases: &[String]) -> bool { + name.starts_with("Test") || bases.iter().any(|base| base == "unittest.TestCase") + } + + fn is_test_function(path: &str, name: &str, in_test_class: bool) -> bool { + (Self::is_test_file(path) && name.starts_with("test_")) + || in_test_class && name.starts_with("test_") + } + + fn is_test_file(path: &str) -> bool { + let file_name = Path::new(path) + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or(path); + file_name.starts_with("test_") || file_name.ends_with("_test.py") + } + + fn parse_import_statement(text: &str) -> Vec { + let Some(imports) = text.trim().strip_prefix("import ") else { + return Vec::new(); + }; + imports + .split(',') + .filter_map(|entry| Self::parse_import_entry(entry.trim())) + .map(|(specifier, local)| ImportFact { + specifier, + bindings: vec![ImportBinding { + local, + imported: "*".to_string(), + }], + }) + .collect() + } + + fn parse_import_entry(entry: &str) -> Option<(String, String)> { + let (module, alias) = Self::split_alias(entry); + if module.is_empty() { + return None; + } + let local = alias + .map(ToString::to_string) + .or_else(|| module.split('.').next().map(ToString::to_string))?; + Some((module.to_string(), local)) + } + + fn parse_from_import_statement(text: &str) -> Vec { + let text = text.trim(); + let Some(rest) = text.strip_prefix("from ") else { + return Vec::new(); + }; + let Some((module, imports)) = rest.split_once(" import ") else { + return Vec::new(); + }; + let module = module.trim(); + let imports = imports.trim().trim_start_matches('(').trim_end_matches(')'); + if imports == "*" { + return vec![ImportFact { + specifier: module.to_string(), + bindings: vec![ImportBinding { + local: "*".to_string(), + imported: "*".to_string(), + }], + }]; + } + imports + .split(',') + .filter_map(|entry| Self::parse_from_import_entry(module, entry.trim())) + .collect() + } + + fn parse_from_import_entry(module: &str, entry: &str) -> Option { + let (imported, alias) = Self::split_alias(entry); + if imported.is_empty() { + return None; + } + let local = alias.unwrap_or(imported).to_string(); + let (specifier, imported_name) = if module.chars().all(|character| character == '.') { + (format!("{module}{imported}"), "*".to_string()) + } else { + (module.to_string(), imported.to_string()) + }; + Some(ImportFact { + specifier, + bindings: vec![ImportBinding { + local, + imported: imported_name, + }], + }) + } + + fn split_alias(entry: &str) -> (&str, Option<&str>) { + if let Some((left, right)) = entry.split_once(" as ") { + (left.trim(), Some(right.trim())) + } else { + (entry.trim(), None) + } + } + + fn decorator_name(node: Node<'_>, source_text: &str) -> Option { + let text = Self::node_text(node, source_text); + text.trim() + .strip_prefix('@') + .map(str::trim) + .filter(|name| !name.is_empty()) + .map(ToString::to_string) + } + + fn assignment_name(node: Node<'_>, source_text: &str) -> Option { + match node.kind() { + "identifier" => Some(Self::node_text(node, source_text)), + _ => None, + } + } + + fn expression_name(node: Node<'_>, source_text: &str) -> Option { + match node.kind() { + "identifier" => Some(Self::node_text(node, source_text)), + "attribute" => { + let object = node + .child_by_field_name("object") + .and_then(|object| Self::expression_name(object, source_text))?; + let attribute = Self::field_text(node, "attribute", source_text)?; + Some(format!("{object}.{attribute}")) + } + "call" => node + .child_by_field_name("function") + .and_then(|function| Self::expression_name(function, source_text)), + "dotted_name" => Some(Self::node_text(node, source_text)), + _ => Self::named_children(node) + .into_iter() + .find_map(|child| Self::expression_name(child, source_text)), + } + } + + fn is_builtin_reference(name: &str) -> bool { + matches!( + name, + "super" + | "len" + | "str" + | "int" + | "float" + | "bool" + | "list" + | "dict" + | "set" + | "tuple" + | "print" + | "range" + ) || name.starts_with("self.") + || name.starts_with("cls.") + } + + fn field_text(node: Node<'_>, field: &str, source_text: &str) -> Option { + node.child_by_field_name(field) + .map(|child| Self::node_text(child, source_text)) + } + + fn node_text(node: Node<'_>, source_text: &str) -> String { + node.utf8_text(source_text.as_bytes()) + .map(ToString::to_string) + .unwrap_or_default() + } + + fn named_children(node: Node<'_>) -> Vec> { + let mut cursor = node.walk(); + node.named_children(&mut cursor).collect() + } + + fn descendant_nodes(node: Node<'_>) -> Vec> { + let mut nodes = Vec::new(); + let mut stack = vec![node]; + while let Some(current) = stack.pop() { + nodes.push(current); + for child in Self::named_children(current) { + stack.push(child); + } + } + nodes + } + + fn parse_string_literal(text: &str) -> Option { + let trimmed = text.trim(); + let quote_index = trimmed.find(['"', '\''])?; + let quoted = trimmed.get(quote_index..)?; + let quote = quoted.chars().next()?; + let triple = format!("{quote}{quote}{quote}"); + if let Some(body) = quoted + .strip_prefix(&triple) + .and_then(|body| body.strip_suffix(&triple)) + { + return Some(body.to_string()); + } + quoted + .strip_prefix(quote) + .and_then(|body| body.strip_suffix(quote)) + .map(ToString::to_string) + } + + fn module_name_for_path(path: &str) -> String { + let without_extension = path + .strip_suffix(".py") + .or_else(|| path.strip_suffix(".pyi")) + .unwrap_or(path); + let mut parts = without_extension + .split('/') + .filter(|part| !part.is_empty()) + .collect::>(); + if parts.last().is_some_and(|part| *part == "__init__") { + parts.pop(); + } + if parts.is_empty() { + return "__init__".to_string(); + } + parts.join(".") + } +} diff --git a/crates/graph-core/src/extraction/facts/resolution.rs b/crates/graph-core/src/extraction/facts/resolution.rs new file mode 100644 index 0000000..19b2dee --- /dev/null +++ b/crates/graph-core/src/extraction/facts/resolution.rs @@ -0,0 +1,207 @@ +use super::*; + +pub(super) struct Resolution; + +pub(super) trait FactResolution { + fn file_id(path: &str) -> String; + fn insert_edge(edges: &mut BTreeMap, edge: EdgeDraft<'_>); + fn resolve_name( + file_path: &str, + name: &str, + context: &NameResolutionContext<'_>, + ) -> Option; + fn resolve_imported_name( + target_path: &str, + imported: &str, + context: &NameResolutionContext<'_>, + ) -> Option; + fn is_python_source_path(path: &str) -> bool; + fn is_rust_source_path(path: &str) -> bool; + fn known_files(file_facts: &[FileFacts]) -> BTreeSet; + fn append_path_traversal_blocker(diagnostics: &mut Vec); +} + +impl FactResolution for Resolution { + fn file_id(path: &str) -> String { + format!("file:{path}") + } + + fn insert_edge(edges: &mut BTreeMap, edge: EdgeDraft<'_>) { + if edge.from == edge.to { + return; + } + let id = format!("edge:{}:{}->{}", edge.kind, edge.from, edge.to); + edges.entry(id.clone()).or_insert_with(|| GraphFactEdge { + id: Some(id), + kind: edge.kind.to_string(), + from: edge.from.to_string(), + to: edge.to.to_string(), + attributes: None, + }); + } + + fn resolve_name( + file_path: &str, + name: &str, + context: &NameResolutionContext<'_>, + ) -> Option { + if let Some(local) = context + .declarations_by_file + .get(file_path) + .and_then(|declarations| declarations.get(name)) + { + return Some(local.clone()); + } + if name.contains('.') { + return resolve_dotted_name(file_path, name, context); + } + if let Some(target) = context + .imports_by_file + .get(file_path) + .and_then(|imports| imports.get(name)) + { + if target.imported != "*" { + if let Some(target) = + Self::resolve_imported_name(&target.path, &target.imported, context) + { + return Some(target); + } + } + } + None + } + + fn resolve_imported_name( + target_path: &str, + imported: &str, + context: &NameResolutionContext<'_>, + ) -> Option { + context + .export_aliases_by_file + .get(target_path) + .and_then(|aliases| aliases.get(imported)) + .cloned() + } + + fn is_python_source_path(path: &str) -> bool { + path.ends_with(".py") || path.ends_with(".pyi") + } + + fn is_rust_source_path(path: &str) -> bool { + path.ends_with(".rs") + } + + fn known_files(file_facts: &[FileFacts]) -> BTreeSet { + file_facts + .iter() + .map(|facts| facts.path.clone()) + .collect::>() + } + + fn append_path_traversal_blocker(diagnostics: &mut Vec) { + let has_path_traversal = diagnostics.iter().any(|diagnostic| { + diagnostic.category == GraphExtractionDiagnosticCategory::PathTraversal + }); + if has_path_traversal { + diagnostics.push(error( + GraphExtractionDiagnosticCategory::PathTraversal, + "path traversal diagnostics blocked graph availability", + None, + None, + )); + } + } +} + +fn resolve_dotted_name( + file_path: &str, + name: &str, + context: &NameResolutionContext<'_>, +) -> Option { + let parts = name.split('.').collect::>(); + let head = parts.first()?; + let target = context + .imports_by_file + .get(file_path) + .and_then(|imports| imports.get(*head))?; + if target.imported == "*" { + for candidate in namespace_import_member_candidates(&parts, &target.path) { + if let Some(target) = + Resolution::resolve_imported_name(&target.path, &candidate, context) + { + return Some(target); + } + } + return None; + } + Resolution::resolve_imported_name(&target.path, &target.imported, context) +} + +fn namespace_import_member_candidates(parts: &[&str], target_path: &str) -> Vec { + let mut candidates = Vec::new(); + let module_parts = module_parts_for_path(target_path); + if let Some(consumed) = longest_module_prefix_match(parts, &module_parts) { + if let Some(candidate) = parts.get(consumed..) { + candidates.push(candidate.join(".")); + } + } + if let Some(candidate) = parts.get(1..) { + candidates.push(candidate.join(".")); + } + if let Some(last) = parts.last() { + candidates.push((*last).to_string()); + } + deduplicate(candidates) +} + +fn longest_module_prefix_match(parts: &[&str], module_parts: &[String]) -> Option { + let max_len = parts.len().min(module_parts.len()); + (1..=max_len).rev().find(|len| { + let len = *len; + let Some(start) = module_parts.len().checked_sub(len) else { + return false; + }; + let Some(module_suffix) = module_parts.get(start..) else { + return false; + }; + let Some(parts_prefix) = parts.get(..len) else { + return false; + }; + module_suffix + .iter() + .map(String::as_str) + .eq(parts_prefix.iter().copied()) + }) +} + +fn module_parts_for_path(path: &str) -> Vec { + let without_extension = path + .strip_suffix(".py") + .or_else(|| path.strip_suffix(".pyi")) + .or_else(|| path.strip_suffix(".mts")) + .or_else(|| path.strip_suffix(".cts")) + .or_else(|| path.strip_suffix(".ts")) + .or_else(|| path.strip_suffix(".tsx")) + .or_else(|| path.strip_suffix(".js")) + .or_else(|| path.strip_suffix(".jsx")) + .or_else(|| path.strip_suffix(".rs")) + .unwrap_or(path); + let mut parts = without_extension + .split('/') + .filter(|part| !part.is_empty()) + .map(ToString::to_string) + .collect::>(); + if parts.last().is_some_and(|part| part == "__init__") { + parts.pop(); + } + parts +} + +fn deduplicate(values: Vec) -> Vec { + values.into_iter().fold(Vec::new(), |mut unique, value| { + if !value.is_empty() && !unique.contains(&value) { + unique.push(value); + } + unique + }) +} diff --git a/crates/graph-core/src/extraction/facts/rust.rs b/crates/graph-core/src/extraction/facts/rust.rs index f844d74..49df02f 100644 --- a/crates/graph-core/src/extraction/facts/rust.rs +++ b/crates/graph-core/src/extraction/facts/rust.rs @@ -1,6 +1,6 @@ use super::{ - file_id, insert_edge, EdgeDraft, FileFacts, HeritageFact, ImportBinding, ImportFact, - ReExportFact, ReferenceFact, + set_node_attribute, EdgeDraft, FactResolution, FileFacts, HeritageFact, ImportBinding, + ImportFact, ReExportFact, ReferenceFact, Resolution, }; use crate::protocol::{GraphFactEdge, GraphFactNode}; use quote::ToTokens; @@ -15,6 +15,14 @@ use syn::{ Path as SynPath, Type, UseTree, Visibility, }; +mod imports; +mod references; +mod syntax; + +use imports::{Imports, RustImports}; +use references::{References, RustReferences}; +use syntax::{ModuleScope, RustSyntax, Syntax}; + type ImportResolution = super::super::tsconfig::ImportResolution; pub(super) fn collect_file_facts( @@ -33,7 +41,7 @@ pub(super) fn resolve_import( known_files: &BTreeSet, ) -> ImportResolution { ImportResolution { - resolved_path: resolve_rust_import_path(specifier, from_path, known_files), + resolved_path: Imports::resolve_rust_import_path(specifier, from_path, known_files), diagnostics: Vec::new(), } } @@ -82,7 +90,7 @@ struct GraphNodeDraft<'a> { impl RustFileFactCollector { fn new(path: String, file_node: GraphFactNode) -> Self { - let module_name = file_module_name(&path); + let module_name = Imports::file_module_name(&path); let module_id = format!("module:{path}#{module_name}"); let mut nodes = BTreeMap::new(); nodes.insert( @@ -100,9 +108,9 @@ impl RustFileFactCollector { }, ); let mut edges = BTreeMap::new(); - insert_edge( + Resolution::insert_edge( &mut edges, - EdgeDraft::new("CONTAINS", &file_id(&path), &module_id), + EdgeDraft::new("CONTAINS", &Resolution::file_id(&path), &module_id), ); Self { path, @@ -123,7 +131,7 @@ impl RustFileFactCollector { fn finish(mut self) -> FileFacts { if !self.file_exports.is_empty() { - set_attribute( + set_node_attribute( &mut self.file_node, "exports", Value::Array(self.file_exports.clone()), @@ -151,11 +159,19 @@ impl RustFileFactCollector { fn visit_item(&mut self, item: &Item) { match item { - Item::Use(item) => self.imports.extend(imports_from_use_tree(&item.tree)), + Item::Use(item) => self + .imports + .extend(Imports::imports_from_use_tree(&item.tree)), Item::Mod(item) => self.visit_module(item), Item::Struct(item) => self.visit_struct(item), Item::Enum(item) => self.visit_enum(item), Item::Trait(item) => self.visit_trait(item), + _ => self.visit_remaining_item(item), + } + } + + fn visit_remaining_item(&mut self, item: &Item) { + match item { Item::Impl(item) => self.visit_impl(item), Item::Fn(item) => self.visit_function(item), Item::Type(item) => self.visit_type_alias(item), @@ -168,17 +184,18 @@ impl RustFileFactCollector { fn visit_module(&mut self, item: &ItemMod) { let name = item.ident.to_string(); - let exported = is_exported(&item.vis); - let is_test = has_cfg_test(&item.attrs) || self.test_module_depth > 0; + let exported = Syntax::is_exported(&item.vis); + let is_test = Syntax::has_cfg_test(&item.attrs) || self.test_module_depth > 0; let qualified_name = self.module_child_name(&name); let id = format!("module:{}#{qualified_name}", self.path); - let mut attributes = base_attributes( + let mut attributes = Syntax::base_attributes( exported, &qualified_name, - Some(signature_for_module(item)), + Some(Syntax::signature_for_module(item)), Some(item.span()), ); - attributes_object(&mut attributes).insert("isTest".to_string(), Value::Bool(is_test)); + Syntax::attributes_object(&mut attributes) + .insert("isTest".to_string(), Value::Bool(is_test)); self.insert_node(GraphNodeDraft { id: id.clone(), kind: "Module", @@ -186,11 +203,18 @@ impl RustFileFactCollector { attributes, exported, }); - self.with_module(id, name, is_test, |collector| { - if let Some((_, items)) = &item.content { - collector.visit_items(items); - } - }); + self.with_module( + ModuleScope { + parent: id, + name, + is_test, + }, + |collector| { + if let Some((_, items)) = &item.content { + collector.visit_items(items); + } + }, + ); } fn visit_struct(&mut self, item: &ItemStruct) { @@ -199,7 +223,7 @@ impl RustFileFactCollector { kind: "Struct", name: &item.ident.to_string(), visibility: &item.vis, - signature: signature_for_item(item), + signature: Syntax::signature_for_item(item), span: item.span(), }); } @@ -210,7 +234,7 @@ impl RustFileFactCollector { kind: "Enum", name: &item.ident.to_string(), visibility: &item.vis, - signature: signature_for_item(item), + signature: Syntax::signature_for_item(item), span: item.span(), }); } @@ -221,20 +245,21 @@ impl RustFileFactCollector { kind: "Trait", name: &item.ident.to_string(), visibility: &item.vis, - signature: signature_for_item(item), + signature: Syntax::signature_for_item(item), span: item.span(), }); } fn visit_impl(&mut self, item: &ItemImpl) { - let self_type = type_name(&item.self_ty).unwrap_or_else(|| "Self".to_string()); + let self_type = Syntax::type_name(&item.self_ty).unwrap_or_else(|| "Self".to_string()); let trait_name = item .trait_ .as_ref() - .and_then(|(_, path, _)| path_last_segment(path)); - let name = impl_name(trait_name.as_deref(), &self_type); + .and_then(|(_, path, _)| Syntax::path_last_segment(path)); + let name = Syntax::impl_name(trait_name.as_deref(), &self_type); let id = format!("impl:{}#{name}", self.path); - let attributes = base_attributes(false, &name, Some(name.clone()), Some(item.span())); + let attributes = + Syntax::base_attributes(false, &name, Some(name.clone()), Some(item.span())); self.insert_node(GraphNodeDraft { id: id.clone(), kind: "Impl", @@ -255,11 +280,11 @@ impl RustFileFactCollector { let name = method.sig.ident.to_string(); let qualified_name = format!("{self_type}::{name}"); let id = format!("method:{}#{qualified_name}", self.path); - let exported = is_exported(&method.vis); - let attributes = base_attributes( + let exported = Syntax::is_exported(&method.vis); + let attributes = Syntax::base_attributes( exported, &qualified_name, - Some(signature_for_method(method)), + Some(Syntax::signature_for_method(method)), Some(method.span()), ); self.insert_node(GraphNodeDraft { @@ -277,19 +302,20 @@ impl RustFileFactCollector { fn visit_function(&mut self, item: &ItemFn) { let name = item.sig.ident.to_string(); - let is_test = has_test_attr(&item.attrs) || has_cfg_test(&item.attrs); + let is_test = Syntax::has_test_attr(&item.attrs) || Syntax::has_cfg_test(&item.attrs); let qualified_name = self.item_qualified_name(&name); let prefix = if is_test { "test" } else { "function" }; let kind = if is_test { "Test" } else { "Function" }; let id = format!("{prefix}:{}#{qualified_name}", self.path); - let exported = is_exported(&item.vis); - let mut attributes = base_attributes( + let exported = Syntax::is_exported(&item.vis); + let mut attributes = Syntax::base_attributes( exported, &qualified_name, - Some(signature_for_function(item)), + Some(Syntax::signature_for_function(item)), Some(item.span()), ); - attributes_object(&mut attributes).insert("isTest".to_string(), Value::Bool(is_test)); + Syntax::attributes_object(&mut attributes) + .insert("isTest".to_string(), Value::Bool(is_test)); self.insert_node(GraphNodeDraft { id: id.clone(), kind, @@ -306,7 +332,7 @@ impl RustFileFactCollector { kind: "TypeAlias", name: &item.ident.to_string(), visibility: &item.vis, - signature: signature_for_item(item), + signature: Syntax::signature_for_item(item), span: item.span(), }); } @@ -317,7 +343,7 @@ impl RustFileFactCollector { kind: "Const", name: &item.ident.to_string(), visibility: &item.vis, - signature: signature_for_item(item), + signature: Syntax::signature_for_item(item), span: item.span(), }); } @@ -328,7 +354,7 @@ impl RustFileFactCollector { kind: "Static", name: &item.ident.to_string(), visibility: &item.vis, - signature: signature_for_item(item), + signature: Syntax::signature_for_item(item), span: item.span(), }); } @@ -355,7 +381,7 @@ impl RustFileFactCollector { kind: "Macro", name: ¯o_name, exported: false, - signature: signature_for_item(item), + signature: Syntax::signature_for_item(item), span: item.span(), }); } @@ -365,7 +391,7 @@ impl RustFileFactCollector { prefix: draft.prefix, kind: draft.kind, name: draft.name, - exported: is_exported(draft.visibility), + exported: Syntax::is_exported(draft.visibility), signature: draft.signature, span: draft.span, }) @@ -374,7 +400,7 @@ impl RustFileFactCollector { fn add_named_node(&mut self, draft: NamedNodeDraft<'_>) -> String { let qualified_name = self.item_qualified_name(draft.name); let id = format!("{}:{}#{qualified_name}", draft.prefix, self.path); - let attributes = base_attributes( + let attributes = Syntax::base_attributes( draft.exported, &qualified_name, Some(draft.signature), @@ -424,32 +450,25 @@ impl RustFileFactCollector { })); } } - insert_edge( + Resolution::insert_edge( &mut self.edges, EdgeDraft::new("CONTAINS", &self.current_parent, &draft.id), ); } fn collect_references_from_block(&mut self, from: &str, block: &syn::Block, is_test: bool) { - let mut visitor = RustReferenceVisitor::new(from.to_string(), is_test); - visitor.visit_block(block); - self.references.extend(visitor.references); + self.references + .extend(References::collect(from.to_string(), block, is_test)); } - fn with_module( - &mut self, - parent: String, - name: String, - is_test: bool, - visit: impl FnOnce(&mut Self), - ) { - let previous_parent = std::mem::replace(&mut self.current_parent, parent); - self.module_stack.push(name); - if is_test { + fn with_module(&mut self, scope: ModuleScope, visit: impl FnOnce(&mut Self)) { + let previous_parent = std::mem::replace(&mut self.current_parent, scope.parent); + self.module_stack.push(scope.name); + if scope.is_test { self.test_module_depth += 1; } visit(self); - if is_test { + if scope.is_test { self.test_module_depth = self.test_module_depth.saturating_sub(1); } self.module_stack.pop(); @@ -476,447 +495,3 @@ impl RustFileFactCollector { format!("{}::{name}", parts.join(".")) } } - -struct RustReferenceVisitor { - from: String, - is_test: bool, - references: Vec, -} - -impl RustReferenceVisitor { - fn new(from: String, is_test: bool) -> Self { - Self { - from, - is_test, - references: Vec::new(), - } - } - - fn push_path_reference(&mut self, path: &SynPath) { - if let Some(name) = reference_name_for_path(path) { - self.references.push(ReferenceFact { - from: self.from.clone(), - name, - is_test: self.is_test, - }); - } - } -} - -impl<'ast> Visit<'ast> for RustReferenceVisitor { - fn visit_expr_call(&mut self, node: &'ast ExprCall) { - if let syn::Expr::Path(path) = node.func.as_ref() { - self.push_path_reference(&path.path); - } - visit::visit_expr_call(self, node); - } - - fn visit_expr_method_call(&mut self, node: &'ast ExprMethodCall) { - self.references.push(ReferenceFact { - from: self.from.clone(), - name: node.method.to_string(), - is_test: self.is_test, - }); - visit::visit_expr_method_call(self, node); - } - - fn visit_expr_macro(&mut self, node: &'ast ExprMacro) { - self.push_path_reference(&node.mac.path); - visit::visit_expr_macro(self, node); - } -} - -fn imports_from_use_tree(tree: &UseTree) -> Vec { - let mut imports = Vec::new(); - collect_use_tree(tree, Vec::new(), &mut imports); - imports -} - -fn collect_use_tree(tree: &UseTree, prefix: Vec, imports: &mut Vec) { - match tree { - UseTree::Path(path) => { - let mut next_prefix = prefix; - next_prefix.push(path.ident.to_string()); - collect_use_tree(&path.tree, next_prefix, imports); - } - UseTree::Name(name) => push_use_binding(imports, &prefix, &name.ident.to_string(), None), - UseTree::Rename(rename) => push_use_binding( - imports, - &prefix, - &rename.ident.to_string(), - Some(rename.rename.to_string()), - ), - UseTree::Glob(_) => { - imports.push(ImportFact { - specifier: prefix.join("::"), - bindings: vec![ImportBinding { - local: "*".to_string(), - imported: "*".to_string(), - }], - }); - } - UseTree::Group(group) => { - for item in &group.items { - collect_use_tree(item, prefix.clone(), imports); - } - } - } -} - -fn push_use_binding( - imports: &mut Vec, - prefix: &[String], - imported_name: &str, - renamed: Option, -) { - let local = renamed.unwrap_or_else(|| imported_name.to_string()); - let module_import = is_probable_module_import(prefix, imported_name); - let specifier = if module_import { - path_with_tail(prefix, imported_name) - } else { - prefix.join("::") - }; - let imported = if module_import { - "*".to_string() - } else { - imported_name.to_string() - }; - imports.push(ImportFact { - specifier, - bindings: vec![ImportBinding { local, imported }], - }); -} - -fn is_probable_module_import(prefix: &[String], imported_name: &str) -> bool { - prefix - .last() - .is_some_and(|part| part == "crate" || part == "self" || part == "super") - && imported_name.chars().next().is_some_and(char::is_lowercase) -} - -fn path_with_tail(prefix: &[String], tail: &str) -> String { - let mut parts = prefix.to_vec(); - parts.push(tail.to_string()); - parts.join("::") -} - -fn resolve_rust_import_path( - specifier: &str, - from_path: &str, - known_files: &BTreeSet, -) -> Option { - let parts = specifier - .split("::") - .filter(|part| !part.is_empty()) - .map(ToString::to_string) - .collect::>(); - match parts.first().map(String::as_str) { - Some("crate") => resolve_crate_path(from_path, parts.iter().skip(1), known_files), - Some("self") => resolve_relative_path(from_path, parts.iter().skip(1), known_files), - Some("super") => resolve_super_path(from_path, parts.iter().skip(1), known_files), - Some(_) => resolve_relative_path(from_path, parts.iter(), known_files), - None => crate_root_file_for(from_path, known_files), - } -} - -fn resolve_crate_path<'a>( - from_path: &str, - module_parts: impl Iterator, - known_files: &BTreeSet, -) -> Option { - let source_dir = crate_source_dir_for(from_path, known_files)?; - let module_parts = module_parts.cloned().collect::>(); - if module_parts.is_empty() { - return crate_root_file_in_source_dir(&source_dir, known_files); - } - resolve_module_candidates(&source_dir, &module_parts, known_files) -} - -fn resolve_relative_path<'a>( - from_path: &str, - module_parts: impl Iterator, - known_files: &BTreeSet, -) -> Option { - let module_parts = module_parts.cloned().collect::>(); - if module_parts.is_empty() { - return Some(from_path.to_string()); - } - let base_dir = module_dir_for_path(from_path); - resolve_module_candidates(&base_dir, &module_parts, known_files) -} - -fn resolve_super_path<'a>( - from_path: &str, - module_parts: impl Iterator, - known_files: &BTreeSet, -) -> Option { - let parent = Path::new(from_path) - .parent() - .and_then(Path::parent) - .map(path_to_string) - .unwrap_or_default(); - let module_parts = module_parts.cloned().collect::>(); - if module_parts.is_empty() { - return None; - } - let base_dir = parent_module_dir_for_path(from_path).unwrap_or(parent); - resolve_module_candidates(&base_dir, &module_parts, known_files) -} - -fn resolve_module_candidates( - base_dir: &str, - module_parts: &[String], - known_files: &BTreeSet, -) -> Option { - module_file_candidates(base_dir, module_parts) - .into_iter() - .find(|candidate| known_files.contains(candidate)) -} - -fn module_file_candidates(base_dir: &str, module_parts: &[String]) -> Vec { - let mut module_path = PathBuf::from(base_dir); - for part in module_parts { - module_path.push(part); - } - let file_candidate = format!("{}.rs", path_to_string(&module_path)); - let mut mod_candidate = module_path; - mod_candidate.push("mod.rs"); - vec![file_candidate, path_to_string(&mod_candidate)] -} - -fn crate_root_file_for(from_path: &str, known_files: &BTreeSet) -> Option { - let source_dir = crate_source_dir_for(from_path, known_files)?; - crate_root_file_in_source_dir(&source_dir, known_files) -} - -fn crate_source_dir_for(from_path: &str, known_files: &BTreeSet) -> Option { - let mut current = Path::new(from_path).parent(); - while let Some(directory) = current { - let source_dir = path_to_string(directory); - if crate_root_file_in_source_dir(&source_dir, known_files).is_some() { - return Some(source_dir); - } - current = directory.parent(); - } - None -} - -fn crate_root_file_in_source_dir( - source_dir: &str, - known_files: &BTreeSet, -) -> Option { - let lib = join_path(source_dir, "lib.rs"); - if known_files.contains(&lib) { - return Some(lib); - } - let main = join_path(source_dir, "main.rs"); - if known_files.contains(&main) { - return Some(main); - } - None -} - -fn module_dir_for_path(path: &str) -> String { - let path = Path::new(path); - let parent = path.parent().map(path_to_string).unwrap_or_default(); - let Some(stem) = path - .file_stem() - .map(|stem| stem.to_string_lossy().to_string()) - else { - return parent; - }; - if stem == "lib" || stem == "main" || stem == "mod" { - return parent; - } - join_path(&parent, &stem) -} - -fn parent_module_dir_for_path(path: &str) -> Option { - Path::new(&module_dir_for_path(path)) - .parent() - .map(path_to_string) -} - -fn join_path(parent: &str, child: &str) -> String { - if parent.is_empty() { - child.to_string() - } else { - format!("{parent}/{child}") - } -} - -fn file_module_name(path: &str) -> String { - let file_name = Path::new(path) - .file_stem() - .map(|name| name.to_string_lossy().to_string()) - .unwrap_or_else(|| "crate".to_string()); - if file_name == "lib" || file_name == "main" { - return "crate".to_string(); - } - if file_name == "mod" { - return Path::new(path) - .parent() - .and_then(Path::file_name) - .map(|name| name.to_string_lossy().to_string()) - .unwrap_or(file_name); - } - file_name -} - -fn is_exported(visibility: &Visibility) -> bool { - !matches!(visibility, Visibility::Inherited) -} - -fn has_test_attr(attributes: &[Attribute]) -> bool { - attributes - .iter() - .any(|attribute| attribute.path().is_ident("test")) -} - -fn has_cfg_test(attributes: &[Attribute]) -> bool { - attributes.iter().any(|attribute| { - attribute.path().is_ident("cfg") - && attribute - .meta - .to_token_stream() - .to_string() - .contains("test") - }) -} - -fn signature_for_module(item: &ItemMod) -> String { - format!("{}mod {}", visibility_tokens(&item.vis), item.ident) - .trim() - .to_string() -} - -fn signature_for_function(item: &ItemFn) -> String { - format!( - "{}{}", - visibility_tokens(&item.vis), - item.sig.to_token_stream() - ) - .trim() - .to_string() -} - -fn signature_for_method(item: &syn::ImplItemFn) -> String { - format!( - "{}{}", - visibility_tokens(&item.vis), - item.sig.to_token_stream() - ) - .trim() - .to_string() -} - -fn signature_for_item(item: &impl ToTokens) -> String { - item.to_token_stream().to_string() -} - -fn visibility_tokens(visibility: &Visibility) -> String { - let tokens = visibility.to_token_stream().to_string(); - if tokens.is_empty() { - tokens - } else { - format!("{tokens} ") - } -} - -fn type_name(ty: &Type) -> Option { - match ty { - Type::Path(path) => path_last_segment(&path.path), - _ => None, - } -} - -fn path_last_segment(path: &SynPath) -> Option { - path.segments - .last() - .map(|segment| segment.ident.to_string()) -} - -fn impl_name(trait_name: Option<&str>, self_type: &str) -> String { - match trait_name { - Some(trait_name) => format!("impl {trait_name} for {self_type}"), - None => format!("impl {self_type}"), - } -} - -fn reference_name_for_path(path: &SynPath) -> Option { - let parts = path - .segments - .iter() - .map(|segment| segment.ident.to_string()) - .collect::>(); - if parts.is_empty() { - return None; - } - if parts - .iter() - .any(|part| part == "self" || part == "Self" || part == "super" || part == "crate") - { - return None; - } - let separator = if parts.len() > 1 { "." } else { "" }; - if separator.is_empty() { - parts.first().cloned() - } else { - Some(parts.join(separator)) - } -} - -fn base_attributes( - exported: bool, - qualified_name: &str, - signature: Option, - span: Option, -) -> Value { - let mut attributes = serde_json::Map::new(); - attributes.insert("language".to_string(), Value::String("rust".to_string())); - attributes.insert("exported".to_string(), Value::Bool(exported)); - attributes.insert( - "qualifiedName".to_string(), - Value::String(qualified_name.to_string()), - ); - if exported { - attributes.insert("exportKind".to_string(), Value::String("named".to_string())); - attributes.insert( - "exportName".to_string(), - Value::String(qualified_name.to_string()), - ); - } - if let Some(signature) = signature { - attributes.insert("signature".to_string(), Value::String(signature)); - } - if let Some(span) = span { - let start = span.start(); - let end = span.end(); - attributes.insert("lineStart".to_string(), json!(start.line)); - attributes.insert("lineEnd".to_string(), json!(end.line)); - attributes.insert("columnStart".to_string(), json!(start.column)); - attributes.insert("columnEnd".to_string(), json!(end.column)); - } - Value::Object(attributes) -} - -fn set_attribute(node: &mut GraphFactNode, key: &str, value: Value) { - attributes_object( - node.attributes - .get_or_insert_with(|| Value::Object(serde_json::Map::new())), - ) - .insert(key.to_string(), value); -} - -fn attributes_object(value: &mut Value) -> &mut serde_json::Map { - loop { - if let Value::Object(object) = value { - return object; - } - *value = Value::Object(serde_json::Map::new()); - } -} - -fn path_to_string(path: impl AsRef) -> String { - path.as_ref().to_string_lossy().replace('\\', "/") -} diff --git a/crates/graph-core/src/extraction/facts/rust/imports.rs b/crates/graph-core/src/extraction/facts/rust/imports.rs new file mode 100644 index 0000000..10505ea --- /dev/null +++ b/crates/graph-core/src/extraction/facts/rust/imports.rs @@ -0,0 +1,255 @@ +use super::*; + +pub(super) struct Imports; + +pub(super) trait RustImports { + fn imports_from_use_tree(tree: &UseTree) -> Vec; + fn resolve_rust_import_path( + specifier: &str, + from_path: &str, + known_files: &BTreeSet, + ) -> Option; + fn file_module_name(path: &str) -> String; +} + +impl RustImports for Imports { + fn imports_from_use_tree(tree: &UseTree) -> Vec { + let mut imports = Vec::new(); + collect_use_tree(tree, Vec::new(), &mut imports); + imports + } + + fn resolve_rust_import_path( + specifier: &str, + from_path: &str, + known_files: &BTreeSet, + ) -> Option { + let parts = specifier + .split("::") + .filter(|part| !part.is_empty()) + .map(ToString::to_string) + .collect::>(); + match parts.first().map(String::as_str) { + Some("crate") => resolve_crate_path(from_path, parts.iter().skip(1), known_files), + Some("self") => resolve_relative_path(from_path, parts.iter().skip(1), known_files), + Some("super") => resolve_super_path(from_path, parts.iter().skip(1), known_files), + Some(_) => resolve_relative_path(from_path, parts.iter(), known_files), + None => crate_root_file_for(from_path, known_files), + } + } + + fn file_module_name(path: &str) -> String { + let file_name = Path::new(path) + .file_stem() + .map(|name| name.to_string_lossy().to_string()) + .unwrap_or_else(|| "crate".to_string()); + if file_name == "lib" || file_name == "main" { + return "crate".to_string(); + } + if file_name == "mod" { + return Path::new(path) + .parent() + .and_then(Path::file_name) + .map(|name| name.to_string_lossy().to_string()) + .unwrap_or(file_name); + } + file_name + } +} + +fn collect_use_tree(tree: &UseTree, prefix: Vec, imports: &mut Vec) { + match tree { + UseTree::Path(path) => { + let mut next_prefix = prefix; + next_prefix.push(path.ident.to_string()); + collect_use_tree(&path.tree, next_prefix, imports); + } + UseTree::Name(name) => push_use_binding(imports, &prefix, &name.ident.to_string(), None), + UseTree::Rename(rename) => push_use_binding( + imports, + &prefix, + &rename.ident.to_string(), + Some(rename.rename.to_string()), + ), + UseTree::Glob(_) => { + imports.push(ImportFact { + specifier: prefix.join("::"), + bindings: vec![ImportBinding { + local: "*".to_string(), + imported: "*".to_string(), + }], + }); + } + UseTree::Group(group) => { + for item in &group.items { + collect_use_tree(item, prefix.clone(), imports); + } + } + } +} + +fn push_use_binding( + imports: &mut Vec, + prefix: &[String], + imported_name: &str, + renamed: Option, +) { + let local = renamed.unwrap_or_else(|| imported_name.to_string()); + let module_import = is_probable_module_import(prefix, imported_name); + let specifier = if module_import { + path_with_tail(prefix, imported_name) + } else { + prefix.join("::") + }; + let imported = if module_import { + "*".to_string() + } else { + imported_name.to_string() + }; + imports.push(ImportFact { + specifier, + bindings: vec![ImportBinding { local, imported }], + }); +} + +fn is_probable_module_import(prefix: &[String], imported_name: &str) -> bool { + prefix + .last() + .is_some_and(|part| part == "crate" || part == "self" || part == "super") + && imported_name.chars().next().is_some_and(char::is_lowercase) +} + +fn path_with_tail(prefix: &[String], tail: &str) -> String { + let mut parts = prefix.to_vec(); + parts.push(tail.to_string()); + parts.join("::") +} + +fn resolve_crate_path<'a>( + from_path: &str, + module_parts: impl Iterator, + known_files: &BTreeSet, +) -> Option { + let source_dir = crate_source_dir_for(from_path, known_files)?; + let module_parts = module_parts.cloned().collect::>(); + if module_parts.is_empty() { + return crate_root_file_in_source_dir(&source_dir, known_files); + } + resolve_module_candidates(&source_dir, &module_parts, known_files) +} + +fn resolve_relative_path<'a>( + from_path: &str, + module_parts: impl Iterator, + known_files: &BTreeSet, +) -> Option { + let module_parts = module_parts.cloned().collect::>(); + if module_parts.is_empty() { + return Some(from_path.to_string()); + } + let base_dir = module_dir_for_path(from_path); + resolve_module_candidates(&base_dir, &module_parts, known_files) +} + +fn resolve_super_path<'a>( + from_path: &str, + module_parts: impl Iterator, + known_files: &BTreeSet, +) -> Option { + let parent = Path::new(from_path) + .parent() + .and_then(Path::parent) + .map(Syntax::path_to_string) + .unwrap_or_default(); + let module_parts = module_parts.cloned().collect::>(); + if module_parts.is_empty() { + return None; + } + let base_dir = parent_module_dir_for_path(from_path).unwrap_or(parent); + resolve_module_candidates(&base_dir, &module_parts, known_files) +} + +fn resolve_module_candidates( + base_dir: &str, + module_parts: &[String], + known_files: &BTreeSet, +) -> Option { + module_file_candidates(base_dir, module_parts) + .into_iter() + .find(|candidate| known_files.contains(candidate)) +} + +fn module_file_candidates(base_dir: &str, module_parts: &[String]) -> Vec { + let mut module_path = PathBuf::from(base_dir); + for part in module_parts { + module_path.push(part); + } + let file_candidate = format!("{}.rs", Syntax::path_to_string(&module_path)); + let mut mod_candidate = module_path; + mod_candidate.push("mod.rs"); + vec![file_candidate, Syntax::path_to_string(&mod_candidate)] +} + +fn crate_root_file_for(from_path: &str, known_files: &BTreeSet) -> Option { + let source_dir = crate_source_dir_for(from_path, known_files)?; + crate_root_file_in_source_dir(&source_dir, known_files) +} + +fn crate_source_dir_for(from_path: &str, known_files: &BTreeSet) -> Option { + let mut current = Path::new(from_path).parent(); + while let Some(directory) = current { + let source_dir = Syntax::path_to_string(directory); + if crate_root_file_in_source_dir(&source_dir, known_files).is_some() { + return Some(source_dir); + } + current = directory.parent(); + } + None +} + +fn crate_root_file_in_source_dir( + source_dir: &str, + known_files: &BTreeSet, +) -> Option { + let lib = join_path(source_dir, "lib.rs"); + if known_files.contains(&lib) { + return Some(lib); + } + let main = join_path(source_dir, "main.rs"); + if known_files.contains(&main) { + return Some(main); + } + None +} + +fn module_dir_for_path(path: &str) -> String { + let path = Path::new(path); + let parent = path + .parent() + .map(Syntax::path_to_string) + .unwrap_or_default(); + let Some(stem) = path + .file_stem() + .map(|stem| stem.to_string_lossy().to_string()) + else { + return parent; + }; + if stem == "lib" || stem == "main" || stem == "mod" { + return parent; + } + join_path(&parent, &stem) +} + +fn parent_module_dir_for_path(path: &str) -> Option { + Path::new(&module_dir_for_path(path)) + .parent() + .map(Syntax::path_to_string) +} + +fn join_path(parent: &str, child: &str) -> String { + if parent.is_empty() { + child.to_string() + } else { + format!("{parent}/{child}") + } +} diff --git a/crates/graph-core/src/extraction/facts/rust/references.rs b/crates/graph-core/src/extraction/facts/rust/references.rs new file mode 100644 index 0000000..01a61e9 --- /dev/null +++ b/crates/graph-core/src/extraction/facts/rust/references.rs @@ -0,0 +1,60 @@ +use super::*; + +pub(super) struct References; + +pub(super) trait RustReferences { + fn collect(from: String, block: &syn::Block, is_test: bool) -> Vec; +} + +impl RustReferences for References { + fn collect(from: String, block: &syn::Block, is_test: bool) -> Vec { + let mut visitor = RustReferenceVisitor { + from, + is_test, + references: Vec::new(), + }; + visitor.visit_block(block); + visitor.references + } +} + +struct RustReferenceVisitor { + from: String, + is_test: bool, + references: Vec, +} + +impl RustReferenceVisitor { + fn push_path_reference(&mut self, path: &SynPath) { + if let Some(name) = Syntax::reference_name_for_path(path) { + self.references.push(ReferenceFact { + from: self.from.clone(), + name, + is_test: self.is_test, + }); + } + } +} + +impl<'ast> Visit<'ast> for RustReferenceVisitor { + fn visit_expr_call(&mut self, node: &'ast ExprCall) { + if let syn::Expr::Path(path) = node.func.as_ref() { + self.push_path_reference(&path.path); + } + visit::visit_expr_call(self, node); + } + + fn visit_expr_method_call(&mut self, node: &'ast ExprMethodCall) { + self.references.push(ReferenceFact { + from: self.from.clone(), + name: node.method.to_string(), + is_test: self.is_test, + }); + visit::visit_expr_method_call(self, node); + } + + fn visit_expr_macro(&mut self, node: &'ast ExprMacro) { + self.push_path_reference(&node.mac.path); + visit::visit_expr_macro(self, node); + } +} diff --git a/crates/graph-core/src/extraction/facts/rust/syntax.rs b/crates/graph-core/src/extraction/facts/rust/syntax.rs new file mode 100644 index 0000000..86868a1 --- /dev/null +++ b/crates/graph-core/src/extraction/facts/rust/syntax.rs @@ -0,0 +1,184 @@ +use super::*; + +pub(super) struct ModuleScope { + pub(super) parent: String, + pub(super) name: String, + pub(super) is_test: bool, +} + +pub(super) struct Syntax; + +pub(super) trait RustSyntax { + fn is_exported(visibility: &Visibility) -> bool; + fn has_test_attr(attributes: &[Attribute]) -> bool; + fn has_cfg_test(attributes: &[Attribute]) -> bool; + fn signature_for_module(item: &ItemMod) -> String; + fn signature_for_function(item: &ItemFn) -> String; + fn signature_for_method(item: &syn::ImplItemFn) -> String; + fn signature_for_item(item: &impl ToTokens) -> String; + fn visibility_tokens(visibility: &Visibility) -> String; + fn type_name(ty: &Type) -> Option; + fn path_last_segment(path: &SynPath) -> Option; + fn impl_name(trait_name: Option<&str>, self_type: &str) -> String; + fn reference_name_for_path(path: &SynPath) -> Option; + fn base_attributes( + exported: bool, + qualified_name: &str, + signature: Option, + span: Option, + ) -> Value; + fn attributes_object(value: &mut Value) -> &mut serde_json::Map; + fn path_to_string(path: impl AsRef) -> String; +} + +impl RustSyntax for Syntax { + fn is_exported(visibility: &Visibility) -> bool { + !matches!(visibility, Visibility::Inherited) + } + + fn has_test_attr(attributes: &[Attribute]) -> bool { + attributes + .iter() + .any(|attribute| attribute.path().is_ident("test")) + } + + fn has_cfg_test(attributes: &[Attribute]) -> bool { + attributes.iter().any(|attribute| { + attribute.path().is_ident("cfg") + && attribute + .meta + .to_token_stream() + .to_string() + .contains("test") + }) + } + + fn signature_for_module(item: &ItemMod) -> String { + format!("{}mod {}", Self::visibility_tokens(&item.vis), item.ident) + .trim() + .to_string() + } + + fn signature_for_function(item: &ItemFn) -> String { + format!( + "{}{}", + Self::visibility_tokens(&item.vis), + item.sig.to_token_stream() + ) + .trim() + .to_string() + } + + fn signature_for_method(item: &syn::ImplItemFn) -> String { + format!( + "{}{}", + Self::visibility_tokens(&item.vis), + item.sig.to_token_stream() + ) + .trim() + .to_string() + } + + fn signature_for_item(item: &impl ToTokens) -> String { + item.to_token_stream().to_string() + } + + fn visibility_tokens(visibility: &Visibility) -> String { + let tokens = visibility.to_token_stream().to_string(); + if tokens.is_empty() { + tokens + } else { + format!("{tokens} ") + } + } + + fn type_name(ty: &Type) -> Option { + match ty { + Type::Path(path) => Self::path_last_segment(&path.path), + _ => None, + } + } + + fn path_last_segment(path: &SynPath) -> Option { + path.segments + .last() + .map(|segment| segment.ident.to_string()) + } + + fn impl_name(trait_name: Option<&str>, self_type: &str) -> String { + match trait_name { + Some(trait_name) => format!("impl {trait_name} for {self_type}"), + None => format!("impl {self_type}"), + } + } + + fn reference_name_for_path(path: &SynPath) -> Option { + let parts = path + .segments + .iter() + .map(|segment| segment.ident.to_string()) + .collect::>(); + if parts.is_empty() { + return None; + } + if parts + .iter() + .any(|part| part == "self" || part == "Self" || part == "super" || part == "crate") + { + return None; + } + let separator = if parts.len() > 1 { "." } else { "" }; + if separator.is_empty() { + parts.first().cloned() + } else { + Some(parts.join(separator)) + } + } + + fn base_attributes( + exported: bool, + qualified_name: &str, + signature: Option, + span: Option, + ) -> Value { + let mut attributes = serde_json::Map::new(); + attributes.insert("language".to_string(), Value::String("rust".to_string())); + attributes.insert("exported".to_string(), Value::Bool(exported)); + attributes.insert( + "qualifiedName".to_string(), + Value::String(qualified_name.to_string()), + ); + if exported { + attributes.insert("exportKind".to_string(), Value::String("named".to_string())); + attributes.insert( + "exportName".to_string(), + Value::String(qualified_name.to_string()), + ); + } + if let Some(signature) = signature { + attributes.insert("signature".to_string(), Value::String(signature)); + } + if let Some(span) = span { + let start = span.start(); + let end = span.end(); + attributes.insert("lineStart".to_string(), json!(start.line)); + attributes.insert("lineEnd".to_string(), json!(end.line)); + attributes.insert("columnStart".to_string(), json!(start.column)); + attributes.insert("columnEnd".to_string(), json!(end.column)); + } + Value::Object(attributes) + } + + fn attributes_object(value: &mut Value) -> &mut serde_json::Map { + loop { + if let Value::Object(object) = value { + return object; + } + *value = Value::Object(serde_json::Map::new()); + } + } + + fn path_to_string(path: impl AsRef) -> String { + path.as_ref().to_string_lossy().replace('\\', "/") + } +} diff --git a/crates/graph-core/src/extraction/ignore.rs b/crates/graph-core/src/extraction/ignore.rs index 1763bc9..e01c10a 100644 --- a/crates/graph-core/src/extraction/ignore.rs +++ b/crates/graph-core/src/extraction/ignore.rs @@ -45,7 +45,6 @@ const BUILT_IN_IGNORE_GLOBS: &[&str] = &[ "**/vendor/**", "**/dist/**", "**/target/**", - "**/.ace/**", "**/.agents/**", "**/.claude/**", "**/.codex/**", @@ -53,9 +52,6 @@ const BUILT_IN_IGNORE_GLOBS: &[&str] = &[ "**/.lattice/**", "**/.opencode/**", "**/.opcore/**", - "**/.rox-cache/**", - ".robustness-engine-cache/**", - "**/.robustness-engine-cache/**", ".venv/**", "**/.venv/**", "venv/**", @@ -105,7 +101,6 @@ pub(super) fn ignore_matcher(repo_root: &Path) -> Result Vec { .collect() } +pub(crate) struct SourcePaths; + +pub(crate) trait SourcePathOps { + fn source_hashes_by_path(hashes: &[SourceFileHash]) -> BTreeMap<&str, &str>; + fn normalize_relative_path(path: &Path) -> Result; +} + +impl SourcePathOps for SourcePaths { + fn source_hashes_by_path(hashes: &[SourceFileHash]) -> BTreeMap<&str, &str> { + hashes + .iter() + .map(|hash| (hash.relative_path.as_str(), hash.sha256.as_str())) + .collect() + } + + fn normalize_relative_path(path: &Path) -> Result { + let mut parts = Vec::new(); + for component in path.components() { + match component { + Component::CurDir => {} + Component::Normal(part) => parts.push(part.to_string_lossy().to_string()), + Component::ParentDir => { + if parts.pop().is_none() { + return Err(()); + } + } + Component::RootDir | Component::Prefix(_) => return Err(()), + } + } + if parts.is_empty() { + return Err(()); + } + Ok(parts.join("/")) + } +} + fn load_tsconfig_if_ready( repo_root: &std::path::Path, diagnostics: &mut Vec, diff --git a/crates/graph-core/src/extraction/python_imports.rs b/crates/graph-core/src/extraction/python_imports.rs index 59499fb..59c76db 100644 --- a/crates/graph-core/src/extraction/python_imports.rs +++ b/crates/graph-core/src/extraction/python_imports.rs @@ -1,8 +1,9 @@ use super::diagnostics::{error, warning}; use super::tsconfig::ImportResolution; +use super::{SourcePathOps, SourcePaths}; use crate::protocol::GraphExtractionDiagnosticCategory; use std::collections::BTreeSet; -use std::path::{Component, Path, PathBuf}; +use std::path::{Path, PathBuf}; pub fn resolve_import( specifier: &str, @@ -93,7 +94,7 @@ fn resolve_path_candidate( candidate: PathBuf, known_files: &BTreeSet, ) -> Result, ()> { - let normalized = normalize_relative(&candidate)?; + let normalized = SourcePaths::normalize_relative_path(&candidate)?; Ok(resolve_module_suffix(&normalized, known_files)) } @@ -186,26 +187,6 @@ fn unresolved_import(specifier: &str, from_path: &str) -> ImportResolution { } } -fn normalize_relative(path: &Path) -> Result { - let mut parts = Vec::new(); - for component in path.components() { - match component { - Component::CurDir => {} - Component::Normal(part) => parts.push(part.to_string_lossy().to_string()), - Component::ParentDir => { - if parts.pop().is_none() { - return Err(()); - } - } - Component::RootDir | Component::Prefix(_) => return Err(()), - } - } - if parts.is_empty() { - return Err(()); - } - Ok(parts.join("/")) -} - fn is_python_source_path(path: &str) -> bool { path.ends_with(".py") || path.ends_with(".pyi") } diff --git a/crates/graph-core/src/extraction/tests.rs b/crates/graph-core/src/extraction/tests.rs index 8b1abbd..3725ca0 100644 --- a/crates/graph-core/src/extraction/tests.rs +++ b/crates/graph-core/src/extraction/tests.rs @@ -1,1760 +1,22 @@ -use super::{ - discover_sources_for_options, extract_sources, DiscoveryResult, ExtractionOptions, - ExtractionResult, -}; -use crate::protocol::{ - GraphExtractionDiagnosticCategory as Category, GraphExtractionDiagnosticSeverity as Severity, - GraphFactNode, -}; -use serde_json::{json, Value}; -use std::fs; -use std::path::PathBuf; -use tempfile::TempDir; - -type TestResult = Result<(), Box>; - -#[test] -fn wave1_fixture_extracts_contract_facts() -> TestResult { - let fixture_root = wave1_fixture_root()?; - let expected: Value = serde_json::from_str(&fs::read_to_string( - fixture_root.join("wave1.expected.json"), - )?)?; - - let result = extract_sources(ExtractionOptions::new(&fixture_root)); - - assert!( - !result - .diagnostics - .iter() - .any(|diagnostic| diagnostic.severity == Severity::Error), - "{:?}", - result.diagnostics - ); - assert_eq!( - sorted(result.nodes.iter().map(|node| node.id.clone()).collect()), - value_strings(&expected, "nodeIds")? - ); - assert_eq!( - sorted(result.metadata.node_kinds), - value_strings(&expected, "nodeKinds")? - ); - assert_eq!( - sorted(result.metadata.edge_kinds), - value_strings(&expected, "edgeKinds")? - ); - assert_eq!( - edge_triples(&result.edges), - value_triples(&expected, "edgeTriples")? - ); - assert_eq!( - node_attributes(&result.nodes), - value_object(&expected, "nodeAttributes")? - ); - assert_eq!( - file_exports(&result.nodes), - value_object(&expected, "fileExports")? - ); - Ok(()) -} - -#[test] -fn export_metadata_marks_supported_ts_js_declarations() -> TestResult { - let repo = repo_with_tsconfig()?; - write_export_metadata_fixture(&repo)?; - - let result = extract_sources(ExtractionOptions::new(repo.path())); - - assert!( - !result - .diagnostics - .iter() - .any(|diagnostic| diagnostic.severity == Severity::Error), - "{:?}", - result.diagnostics - ); - assert_exported_symbol_attributes(&result.nodes)?; - assert_non_exported_symbol_attributes(&result.nodes)?; - assert_index_file_export_metadata(&result.nodes)?; - Ok(()) -} - -#[test] -fn import_backed_barrel_exports_are_unsupported_reexport_metadata() -> TestResult { - let repo = repo_with_tsconfig()?; - write( - &repo, - "src/source.ts", - "export default function inner() { return 1; }\nexport const named = 1;", - )?; - write( - &repo, - "src/barrel.ts", - "import inner, { named } from './source';\nexport { named };\nexport default inner;", - )?; - - let result = extract_sources(ExtractionOptions::new(repo.path())); - let exports = required_exports(&result.nodes, "file:src/barrel.ts")?; - - for expected in [ - json!({"kind": "named", "local": "named", "exported": "named", "source": "./source", "imported": "named", "supportedSymbol": false}), - json!({"kind": "default", "local": "inner", "exported": "default", "source": "./source", "imported": "default", "supportedSymbol": false}), - ] { - assert!(exports.contains(&expected), "{expected}"); - } - assert_missing_node(&result.nodes, "variable:src/barrel.ts#named")?; - assert_missing_node(&result.nodes, "function:src/barrel.ts#inner")?; - Ok(()) -} - -#[test] -fn unresolved_local_exports_are_unsupported_file_metadata() -> TestResult { - let repo = repo_with_tsconfig()?; - write( - &repo, - "src/index.ts", - "export { missing as renamed };\nfunction internal(){return 1;}\n", - )?; - - let result = extract_sources(ExtractionOptions::new(repo.path())); - let exports = required_exports(&result.nodes, "file:src/index.ts")?; - - assert!(exports.contains(&json!({ - "kind": "named", - "local": "missing", - "exported": "renamed", - "source": null, - "supportedSymbol": false - }))); - assert_eq!( - required_attributes(&result.nodes, "function:src/index.ts#internal")?, - json!({"exported": false}) - ); - assert_missing_node(&result.nodes, "function:src/index.ts#missing")?; - Ok(()) -} - -#[test] -fn nested_local_exports_are_unsupported_file_metadata() -> TestResult { - let repo = repo_with_tsconfig()?; - write( - &repo, - "src/index.ts", - "export { laterNested as exportedLaterNested };\nfunction container(){ function laterNested(){ return 1; } return laterNested(); }\n", - )?; - - let result = extract_sources(ExtractionOptions::new(repo.path())); - let exports = required_exports(&result.nodes, "file:src/index.ts")?; - - assert!(exports.contains(&json!({ - "kind": "named", - "local": "laterNested", - "exported": "exportedLaterNested", - "source": null, - "supportedSymbol": false - }))); - assert_eq!( - required_attributes(&result.nodes, "function:src/index.ts#laterNested")?, - json!({"exported": false}) - ); - Ok(()) -} - -fn write_export_metadata_fixture(repo: &TempDir) -> TestResult { - write_export_metadata_index_fixture(repo)?; - write_export_metadata_supporting_modules(repo)?; - write_export_metadata_default_modules(repo)?; - write_export_metadata_jsx_modules(repo)?; - Ok(()) -} - -fn write_export_metadata_index_fixture(repo: &TempDir) -> TestResult { - write( - repo, - "src/index.ts", - r#" - export interface Renderable { render(): string; } - export type Payload = { label: string }; - export class ExportedClass implements Renderable { render() { return "ok"; } } - class InternalClass {} - export function exportedFunction() { return new ExportedClass(); } - function internalFunction() { return new InternalClass(); } - export const exportedValue = 1; - const internalValue = 2; - export const exportedArrow = () => internalFunction(); - const internalArrow = () => exportedFunction(); - export function exportedWithNested() { - function nestedLocal() { return 1; } - return nestedLocal(); - } - export { laterNested as exportedLaterNested }; - function container() { - function laterNested() { return 1; } - return laterNested(); - } - const aliasTarget = 3; - export { aliasTarget as renamedAlias }; - export { externalThing as renamedExternal } from "./external"; - export { default as externalDefault } from "./defaulted"; - export * from "./barrel"; - export * as namespaceExport from "./namespace"; - const defaultValue = exportedValue; - export default defaultValue; - "#, - )?; - Ok(()) -} - -fn write_export_metadata_supporting_modules(repo: &TempDir) -> TestResult { - write(repo, "src/external.ts", "export const externalThing = 1;")?; - write( - repo, - "src/defaulted.ts", - "export default function defaulted() { return 1; }", - )?; - write(repo, "src/barrel.ts", "export const barrelValue = 1;")?; - write(repo, "src/namespace.ts", "export const namespaced = 1;")?; - Ok(()) -} - -fn write_export_metadata_default_modules(repo: &TempDir) -> TestResult { - write( - repo, - "src/default-function.ts", - "export default function defaultFunction() { return 1; }", - )?; - write( - repo, - "src/default-class.ts", - "export default class DefaultClass {}", - )?; - write( - repo, - "src/default-class-with-method.ts", - r#" - export default class DefaultClassWithMethod { - render() { return "ok"; } - } - "#, - )?; - write( - repo, - "src/default-interface.ts", - "export default interface DefaultInterface {}", - )?; - Ok(()) -} - -fn write_export_metadata_jsx_modules(repo: &TempDir) -> TestResult { - write( - repo, - "src/js-cases.js", - r#" - export function jsFunction() { return jsValue; } - export const jsValue = 1; - const jsInternal = 2; - "#, - )?; - write( - repo, - "src/view.tsx", - r#" - export function View() { return
; } - export const TsxArrow = () => ; - "#, - )?; - write( - repo, - "src/widget.jsx", - r#" - export default function Widget() { return
; } - export const WidgetHelper = () => ; - "#, - )?; - Ok(()) -} - -fn assert_exported_symbol_attributes(nodes: &[GraphFactNode]) -> TestResult { - for (id, expected) in [ - ( - "function:src/index.ts#exportedFunction", - json!({"exported": true, "exportKind": "named", "exportName": "exportedFunction"}), - ), - ( - "class:src/index.ts#ExportedClass", - json!({"exported": true, "exportKind": "named", "exportName": "ExportedClass"}), - ), - ( - "type:src/index.ts#Renderable", - json!({"exported": true, "exportKind": "named", "exportName": "Renderable"}), - ), - ( - "type:src/index.ts#Payload", - json!({"exported": true, "exportKind": "named", "exportName": "Payload"}), - ), - ( - "variable:src/index.ts#exportedValue", - json!({"exported": true, "exportKind": "named", "exportName": "exportedValue"}), - ), - ( - "function:src/index.ts#exportedArrow", - json!({"exported": true, "exportKind": "named", "exportName": "exportedArrow"}), - ), - ( - "function:src/index.ts#exportedWithNested", - json!({"exported": true, "exportKind": "named", "exportName": "exportedWithNested"}), - ), - ( - "variable:src/index.ts#aliasTarget", - json!({"exported": true, "exportKind": "named", "exportName": "renamedAlias"}), - ), - ( - "function:src/default-function.ts#defaultFunction", - json!({"exported": true, "exportKind": "default", "exportName": "default"}), - ), - ( - "class:src/default-class.ts#DefaultClass", - json!({"exported": true, "exportKind": "default", "exportName": "default"}), - ), - ( - "class:src/default-class-with-method.ts#DefaultClassWithMethod", - json!({"exported": true, "exportKind": "default", "exportName": "default"}), - ), - ( - "type:src/default-interface.ts#DefaultInterface", - json!({"exported": true, "exportKind": "default", "exportName": "default"}), - ), - ( - "function:src/js-cases.js#jsFunction", - json!({"exported": true, "exportKind": "named", "exportName": "jsFunction"}), - ), - ( - "variable:src/js-cases.js#jsValue", - json!({"exported": true, "exportKind": "named", "exportName": "jsValue"}), - ), - ( - "function:src/view.tsx#View", - json!({"exported": true, "exportKind": "named", "exportName": "View"}), - ), - ( - "function:src/view.tsx#TsxArrow", - json!({"exported": true, "exportKind": "named", "exportName": "TsxArrow"}), - ), - ( - "function:src/widget.jsx#Widget", - json!({"exported": true, "exportKind": "default", "exportName": "default"}), - ), - ( - "function:src/widget.jsx#WidgetHelper", - json!({"exported": true, "exportKind": "named", "exportName": "WidgetHelper"}), - ), - ] { - assert_eq!(required_attributes(nodes, id)?, expected, "{id}"); - } - Ok(()) -} - -fn assert_non_exported_symbol_attributes(nodes: &[GraphFactNode]) -> TestResult { - for id in [ - "class:src/index.ts#InternalClass", - "function:src/index.ts#internalFunction", - "variable:src/index.ts#internalValue", - "function:src/index.ts#internalArrow", - "function:src/index.ts#container", - "function:src/index.ts#nestedLocal", - "function:src/index.ts#laterNested", - "variable:src/js-cases.js#jsInternal", - ] { - assert_eq!( - required_attributes(nodes, id)?, - json!({"exported": false}), - "{id}" - ); - } - assert_missing_node(nodes, "function:src/default-class-with-method.ts#default")?; - Ok(()) -} - -fn assert_index_file_export_metadata(nodes: &[GraphFactNode]) -> TestResult { - let index_exports = required_exports(nodes, "file:src/index.ts")?; - for expected in [ - json!({"kind": "named", "local": "externalThing", "exported": "renamedExternal", "source": "./external", "imported": "externalThing", "supportedSymbol": true}), - json!({"kind": "named", "local": "default", "exported": "externalDefault", "source": "./defaulted", "imported": "default", "supportedSymbol": true}), - json!({"kind": "all", "exported": "*", "source": "./barrel", "supportedSymbol": false}), - json!({"kind": "namespace", "exported": "namespaceExport", "source": "./namespace", "supportedSymbol": false}), - json!({"kind": "default", "local": "defaultValue", "exported": "default", "source": null, "supportedSymbol": true}), - json!({"kind": "named", "local": "laterNested", "exported": "exportedLaterNested", "source": null, "supportedSymbol": false}), - ] { - assert!(index_exports.contains(&expected), "{expected}"); - } - Ok(()) -} - -#[test] -fn tsconfig_path_aliases_resolve_to_repo_relative_files() -> TestResult { - let result = extract_sources(ExtractionOptions::new(wave1_fixture_root()?)); - let triples = edge_triples(&result.edges); - - assert!(triples.contains(&vec![ - "IMPORTS_FROM".to_string(), - "file:src/__tests__/greeting.test.ts".to_string(), - "file:src/math.js".to_string() - ])); - assert!(triples.contains(&vec![ - "IMPORTS_FROM".to_string(), - "file:src/legacy-widget.jsx".to_string(), - "file:src/components/GreetingCard.tsx".to_string() - ])); - Ok(()) -} - -#[test] -fn unimported_cross_file_symbols_do_not_create_edges() -> TestResult { - let repo = temp_repo()?; - write( - &repo, - "tsconfig.json", - r#"{"compilerOptions":{"baseUrl":"."}}"#, - )?; - write( - &repo, - "src/a.ts", - r#" - export function caller() { return target(); } - export class Child extends Base implements Shape {} - "#, - )?; - write( - &repo, - "src/b.ts", - r#" - export function target() { return 1; } - export class Base {} - export interface Shape {} - "#, - )?; - write( - &repo, - "src/c.ts", - r#" - export function localCaller() { return sameName(); } - export function sameName() { return 1; } - "#, - )?; - write( - &repo, - "src/d.ts", - "export function sameName() { return 2; }", - )?; - - let result = extract_sources(ExtractionOptions::new(repo.path())); - let triples = edge_triples(&result.edges); - - assert!(!result - .diagnostics - .iter() - .any(|diagnostic| diagnostic.severity == Severity::Error)); - assert!(!triples.contains(&vec![ - "CALLS".to_string(), - "function:src/a.ts#caller".to_string(), - "function:src/b.ts#target".to_string() - ])); - assert!(!triples.contains(&vec![ - "INHERITS".to_string(), - "class:src/a.ts#Child".to_string(), - "class:src/b.ts#Base".to_string() - ])); - assert!(!triples.contains(&vec![ - "IMPLEMENTS".to_string(), - "class:src/a.ts#Child".to_string(), - "type:src/b.ts#Shape".to_string() - ])); - assert!(triples.contains(&vec![ - "CALLS".to_string(), - "function:src/c.ts#localCaller".to_string(), - "function:src/c.ts#sameName".to_string() - ])); - Ok(()) -} - -#[test] -fn default_imports_resolve_to_default_exported_symbols() -> TestResult { - let repo = repo_with_tsconfig()?; - write( - &repo, - "src/default-function.ts", - "export default function usedDefault() { return 1; }", - )?; - write( - &repo, - "src/default-value.ts", - "const usedValue = () => 1; export default usedValue;", - )?; - write( - &repo, - "src/index.ts", - r#" - import usedDefault from "./default-function"; - import usedValue from "./default-value"; - export function run() { - return usedDefault() + usedValue(); - } - "#, - )?; - - let result = extract_sources(ExtractionOptions::new(repo.path())); - let triples = edge_triples(&result.edges); - - assert!(!result - .diagnostics - .iter() - .any(|diagnostic| diagnostic.severity == Severity::Error)); - assert!(triples.contains(&vec![ - "CALLS".to_string(), - "function:src/index.ts#run".to_string(), - "function:src/default-function.ts#usedDefault".to_string() - ])); - assert!(triples.contains(&vec![ - "CALLS".to_string(), - "function:src/index.ts#run".to_string(), - "function:src/default-value.ts#usedValue".to_string() - ])); - Ok(()) -} - -#[test] -fn named_export_alias_imports_resolve_to_local_exported_symbols() -> TestResult { - let repo = repo_with_tsconfig()?; - write( - &repo, - "src/dep.ts", - "function localName() { return 1; }\nexport { localName as publicName };", - )?; - write( - &repo, - "src/index.ts", - r#" - import { publicName } from "./dep"; - export function run() { - return publicName(); - } - "#, - )?; - - let result = extract_sources(ExtractionOptions::new(repo.path())); - let triples = edge_triples(&result.edges); - - assert!(!result - .diagnostics - .iter() - .any(|diagnostic| diagnostic.severity == Severity::Error)); - assert!(triples.contains(&vec![ - "CALLS".to_string(), - "function:src/index.ts#run".to_string(), - "function:src/dep.ts#localName".to_string() - ])); - Ok(()) -} - -#[test] -fn source_re_export_alias_imports_resolve_to_source_exported_symbols() -> TestResult { - let repo = repo_with_tsconfig()?; - write( - &repo, - "src/source.ts", - "export function add() { return 1; }", - )?; - write( - &repo, - "src/barrel.ts", - "export { add as addFromBarrel } from './source';", - )?; - write( - &repo, - "src/index.ts", - r#" - import { addFromBarrel } from "./barrel"; - export function run() { - return addFromBarrel(); - } - "#, - )?; - - let result = extract_sources(ExtractionOptions::new(repo.path())); - let triples = edge_triples(&result.edges); - let exports = required_exports(&result.nodes, "file:src/barrel.ts")?; - - assert!( - !result - .diagnostics - .iter() - .any(|diagnostic| diagnostic.severity == Severity::Error), - "{:?}", - result.diagnostics - ); - assert!(triples.contains(&vec![ - "CALLS".to_string(), - "function:src/index.ts#run".to_string(), - "function:src/source.ts#add".to_string() - ])); - assert!(exports.contains(&json!({ - "kind": "named", - "local": "add", - "exported": "addFromBarrel", - "source": "./source", - "imported": "add", - "supportedSymbol": true - }))); - Ok(()) -} - -#[test] -fn oxc_parse_errors_are_typed_warnings_and_non_fatal() -> TestResult { - let repo = repo_with_tsconfig()?; - write(&repo, "src/broken.ts", "export function broken(")?; - write( - &repo, - "src/valid.ts", - "export function valid() { return 1; }", - )?; - - let result = extract_sources(ExtractionOptions::new(repo.path())); - - assert!( - result - .diagnostics - .iter() - .any(|diagnostic| diagnostic.category == Category::ParseError - && diagnostic.severity == Severity::Warning), - "{:?}", - result.diagnostics - ); - assert!( - !result - .diagnostics - .iter() - .any(|diagnostic| diagnostic.category == Category::ParseError - && diagnostic.severity == Severity::Error), - "{:?}", - result.diagnostics - ); - required_attributes(&result.nodes, "file:src/broken.ts")?; - required_attributes(&result.nodes, "function:src/valid.ts#valid")?; - Ok(()) -} - -#[test] -fn missing_parser_errors_are_typed_and_block_empty_success() -> TestResult { - let repo = repo_with_tsconfig()?; - write(&repo, "src/a.ts", "export function a() { return 1; }")?; - let mut options = ExtractionOptions::new(repo.path()); - options.force_missing_parser = true; - assert_error_category(extract_sources(options), Category::MissingParser); - Ok(()) -} - -#[test] -fn malformed_tsconfig_errors_are_typed_and_block_empty_success() -> TestResult { - let repo = temp_repo()?; - let malformed_json = char::from(123).to_string(); - write(&repo, "tsconfig.json", &malformed_json)?; - write(&repo, "src/a.ts", "export function a() { return 1; }")?; - assert_error_category( - extract_sources(ExtractionOptions::new(repo.path())), - Category::MalformedTsconfig, - ); - Ok(()) -} - -#[test] -fn malformed_tsconfig_paths_are_typed_and_block_empty_success() -> TestResult { - let repo = temp_repo()?; - write( - &repo, - "tsconfig.json", - r#"{"compilerOptions":{"baseUrl":".","paths":{"@bad/*":"src/*"}}}"#, - )?; - write( - &repo, - "src/a.ts", - "import { b } from '@bad/b'; export function a() { return b(); }", - )?; - write(&repo, "src/b.ts", "export function b() { return 1; }")?; - assert_error_category( - extract_sources(ExtractionOptions::new(repo.path())), - Category::MalformedTsconfig, - ); - Ok(()) -} - -#[test] -fn max_file_errors_are_typed_and_block_empty_success() -> TestResult { - let repo = repo_with_tsconfig()?; - write(&repo, "src/a.ts", "export function a() { return 1; }")?; - let mut options = ExtractionOptions::new(repo.path()); - options.max_files = 0; - assert_error_category(extract_sources(options), Category::MaxFilesExceeded); - Ok(()) -} - -#[test] -fn default_discovery_has_no_legacy_four_thousand_file_ceiling() -> TestResult { - let repo = repo_with_tsconfig()?; - for index in 0..4_001 { - write( - &repo, - &format!("src/generated/file_{index:04}.ts"), - "export const value = 1;\n", - )?; - } - - let discovery = discover_sources_for_options(&ExtractionOptions::new(repo.path())); - - assert_eq!(discovery.sources.len(), 4_001); - assert!( - !discovery - .diagnostics - .iter() - .any(|diagnostic| diagnostic.category == Category::MaxFilesExceeded), - "{:?}", - discovery.diagnostics - ); - Ok(()) -} - -#[test] -fn max_depth_errors_are_typed_and_block_empty_success() -> TestResult { - let repo = repo_with_tsconfig()?; - write(&repo, "src/deep/a.ts", "export function a() { return 1; }")?; - let mut options = ExtractionOptions::new(repo.path()); - options.max_depth = 1; - assert_error_category(extract_sources(options), Category::MaxDepthExceeded); - Ok(()) -} - -#[test] -fn path_traversal_errors_are_typed_and_block_empty_success() -> TestResult { - let repo = temp_repo()?; - write( - &repo, - "tsconfig.json", - r#"{"compilerOptions":{"baseUrl":".","paths":{"@outside/*":["../outside/*"]}}}"#, - )?; - write( - &repo, - "src/a.ts", - "import { out } from '@outside/out'; export function a() { return out(); }", - )?; - assert_error_category( - extract_sources(ExtractionOptions::new(repo.path())), - Category::PathTraversal, - ); - Ok(()) -} - -#[test] -fn unsupported_and_missing_tsconfig_are_typed_warnings() -> TestResult { - let repo = temp_repo()?; - write(&repo, "src/a.ts", "export function a() { return 1; }")?; - write(&repo, "src/view.vue", "")?; - - let result = extract_sources(ExtractionOptions::new(repo.path())); - - assert!(result - .diagnostics - .iter() - .any( - |diagnostic| diagnostic.category == Category::MissingTsconfig - && diagnostic.severity == Severity::Warning - )); - assert!(result - .diagnostics - .iter() - .any( - |diagnostic| diagnostic.category == Category::UnsupportedLanguage - && diagnostic.severity == Severity::Warning - )); - assert!(!result - .diagnostics - .iter() - .any(|diagnostic| diagnostic.severity == Severity::Error)); - assert!(!result.nodes.is_empty()); - Ok(()) -} - -#[test] -fn rust_sources_are_discovered_and_extracted() -> TestResult { - let repo = repo_with_tsconfig()?; - write_rust_graph_fixture(&repo)?; - - let discovery = discover_sources_for_options(&ExtractionOptions::new(repo.path())); - let result = extract_sources(ExtractionOptions::new(repo.path())); - - assert_rust_discovery_sources(&discovery); - assert_rust_extraction_nodes(&result); - assert_rust_extraction_edges(&result); - assert_rust_extraction_attributes(&result)?; - Ok(()) -} - -fn assert_rust_discovery_sources(discovery: &DiscoveryResult) { - let sources = discovery - .sources - .iter() - .map(|source| (source.relative_path.as_str(), source.language.as_str())) - .collect::>(); - assert_eq!( - sources, - vec![ - ("src/helpers.rs", "rust"), - ("src/lib.rs", "rust"), - ("src/user.rs", "rust") - ] - ); - assert!(!discovery - .diagnostics - .iter() - .any(|diagnostic| diagnostic.category == Category::UnsupportedLanguage)); -} - -fn assert_rust_extraction_nodes(result: &ExtractionResult) { - assert!( - !result - .diagnostics - .iter() - .any(|diagnostic| diagnostic.severity == Severity::Error), - "{:?}", - result.diagnostics - ); - let node_ids = sorted(result.nodes.iter().map(|node| node.id.clone()).collect()); - for id in [ - "file:src/lib.rs", - "module:src/lib.rs#crate", - "module:src/user.rs#user", - "module:src/user.rs#user.tests", - "struct:src/lib.rs#Widget", - "enum:src/lib.rs#Mode", - "trait:src/lib.rs#Service", - "impl:src/lib.rs#impl Service for Widget", - "method:src/lib.rs#Widget::handle", - "type:src/lib.rs#Alias", - "const:src/lib.rs#LIMIT", - "static:src/lib.rs#NAME", - "macro:src/lib.rs#trace", - "function:src/helpers.rs#helpers::assist", - "function:src/user.rs#user::run", - "test:src/user.rs#user.tests::test_run", - ] { - assert!(node_ids.contains(&id.to_string()), "{id}"); - } -} - -fn assert_rust_extraction_edges(result: &ExtractionResult) { - let triples = edge_triples(&result.edges); - for triple in rust_expected_edge_triples() { - assert!(triples.contains(&triple), "{triple:?}"); - } -} - -fn assert_rust_extraction_attributes(result: &ExtractionResult) -> TestResult { - assert_eq!( - required_attributes(&result.nodes, "struct:src/lib.rs#Widget")? - .get("exported") - .and_then(Value::as_bool), - Some(true) - ); - assert_eq!( - required_attributes(&result.nodes, "struct:src/lib.rs#Widget")? - .get("language") - .and_then(Value::as_str), - Some("rust") - ); - assert_rust_run_attributes(result)?; - assert!(result.metadata.node_kinds.contains(&"Struct".to_string())); - assert!(result - .metadata - .edge_kinds - .contains(&"IMPLEMENTS".to_string())); - Ok(()) -} - -fn assert_rust_run_attributes(result: &ExtractionResult) -> TestResult { - let attributes = required_attributes(&result.nodes, "function:src/user.rs#user::run")?; - assert_eq!( - attributes.get("qualifiedName").and_then(Value::as_str), - Some("user::run") - ); - assert!(attributes - .get("signature") - .and_then(Value::as_str) - .is_some_and(|signature| signature.starts_with("pub fn run"))); - assert!(attributes - .get("lineStart") - .and_then(Value::as_u64) - .is_some()); - Ok(()) -} - -fn rust_expected_edge_triples() -> Vec> { - [ - ("CONTAINS", "file:src/user.rs", "module:src/user.rs#user"), - ( - "CONTAINS", - "module:src/user.rs#user", - "function:src/user.rs#user::run", - ), - ( - "CONTAINS", - "module:src/user.rs#user.tests", - "test:src/user.rs#user.tests::test_run", - ), - ("IMPORTS_FROM", "file:src/user.rs", "file:src/helpers.rs"), - ( - "CALLS", - "function:src/user.rs#user::run", - "function:src/helpers.rs#helpers::assist", - ), - ( - "CALLS", - "test:src/user.rs#user.tests::test_run", - "function:src/user.rs#user::run", - ), - ( - "TESTED_BY", - "function:src/user.rs#user::run", - "test:src/user.rs#user.tests::test_run", - ), - ( - "IMPLEMENTS", - "impl:src/lib.rs#impl Service for Widget", - "trait:src/lib.rs#Service", - ), - ] - .into_iter() - .map(|(kind, from, to)| vec![kind.to_string(), from.to_string(), to.to_string()]) - .collect() -} - -#[test] -fn rust_crate_use_prefers_module_file_over_lib_declaration_stub() -> TestResult { - let repo = repo_with_tsconfig()?; - write(&repo, "src/lib.rs", "pub mod helpers;\nmod user;\n")?; - write(&repo, "src/helpers.rs", "pub fn assist() -> usize { 1 }\n")?; - write( - &repo, - "src/user.rs", - "use crate::helpers;\npub fn run() { helpers::assist(); }\n", - )?; - - let result = extract_sources(ExtractionOptions::new(repo.path())); - let triples = edge_triples(&result.edges); - - assert!(triples.contains(&vec![ - "IMPORTS_FROM".to_string(), - "file:src/user.rs".to_string(), - "file:src/helpers.rs".to_string() - ])); - assert!(!triples.contains(&vec![ - "IMPORTS_FROM".to_string(), - "file:src/user.rs".to_string(), - "file:src/lib.rs".to_string() - ])); - Ok(()) -} - -#[test] -fn rust_crate_use_resolves_within_nearest_workspace_crate() -> TestResult { - let repo = repo_with_tsconfig()?; - write( - &repo, - "crates/app/src/lib.rs", - "pub mod helpers;\nmod user;\n", - )?; - write( - &repo, - "crates/app/src/helpers.rs", - "pub fn assist() -> usize { 1 }\n", - )?; - write( - &repo, - "crates/app/src/user.rs", - "use crate::helpers;\npub fn run() { helpers::assist(); }\n", - )?; - write( - &repo, - "crates/other/src/lib.rs", - "pub mod helpers;\npub fn unrelated() {}\n", - )?; - write( - &repo, - "crates/other/src/helpers.rs", - "pub fn assist() -> usize { 2 }\n", - )?; - - let result = extract_sources(ExtractionOptions::new(repo.path())); - let triples = edge_triples(&result.edges); - - assert!(triples.contains(&vec![ - "IMPORTS_FROM".to_string(), - "file:crates/app/src/user.rs".to_string(), - "file:crates/app/src/helpers.rs".to_string() - ])); - assert!(!triples.contains(&vec![ - "IMPORTS_FROM".to_string(), - "file:crates/app/src/user.rs".to_string(), - "file:crates/other/src/helpers.rs".to_string() - ])); - Ok(()) -} - -#[test] -fn rust_parse_errors_are_typed_and_block_empty_success() -> TestResult { - let repo = repo_with_tsconfig()?; - write(&repo, "src/broken.rs", "pub fn broken(")?; - - let result = extract_sources(ExtractionOptions::new(repo.path())); - - assert_error_category(result.clone(), Category::ParseError); - assert!(result.nodes.is_empty(), "{:?}", result.nodes); - assert!(result.edges.is_empty(), "{:?}", result.edges); - Ok(()) -} - -#[test] -fn python_ast_extracts_contract_facts() -> TestResult { - let repo = repo_with_tsconfig()?; - write_python_graph_fixture(&repo)?; - - let result = extract_sources(ExtractionOptions::new(repo.path())); - let node_ids = sorted(result.nodes.iter().map(|node| node.id.clone()).collect()); - let triples = edge_triples(&result.edges); - - assert!( - !result - .diagnostics - .iter() - .any(|diagnostic| diagnostic.severity == Severity::Error), - "{:?}", - result.diagnostics - ); - for id in [ - "file:src/pkg/models.py", - "module:src/pkg/models.py#src.pkg.models", - "class:src/pkg/models.py#PublicModel", - "function:src/pkg/models.py#PublicModel.from_value", - "function:src/pkg/models.py#make_model", - "function:src/pkg/models.py#_hidden", - "variable:src/pkg/models.py#_private", - "function:tests/test_models.py#test_make_model", - "function:tests/test_models.py#TestPublicModel.test_render", - ] { - assert!(node_ids.contains(&id.to_string()), "{id}"); - } - for triple in [ - vec![ - "CONTAINS".to_string(), - "file:src/pkg/models.py".to_string(), - "module:src/pkg/models.py#src.pkg.models".to_string(), - ], - vec![ - "CONTAINS".to_string(), - "module:src/pkg/models.py#src.pkg.models".to_string(), - "class:src/pkg/models.py#PublicModel".to_string(), - ], - vec![ - "CONTAINS".to_string(), - "class:src/pkg/models.py#PublicModel".to_string(), - "function:src/pkg/models.py#PublicModel.from_value".to_string(), - ], - vec![ - "CALLS".to_string(), - "function:src/pkg/models.py#make_model".to_string(), - "class:src/pkg/models.py#PublicModel".to_string(), - ], - vec![ - "INHERITS".to_string(), - "class:src/pkg/models.py#PublicModel".to_string(), - "class:src/pkg/base.py#BaseModel".to_string(), - ], - vec![ - "TESTED_BY".to_string(), - "class:src/pkg/models.py#PublicModel".to_string(), - "function:tests/test_models.py#test_make_model".to_string(), - ], - ] { - assert!(triples.contains(&triple), "{triple:?}"); - } - assert!(result.metadata.node_kinds.contains(&"Module".to_string())); - Ok(()) -} - -#[test] -fn python_import_resolution_handles_absolute_relative_package_and_unresolved() -> TestResult { - let repo = repo_with_tsconfig()?; - write_python_graph_fixture(&repo)?; - - let result = extract_sources(ExtractionOptions::new(repo.path())); - let triples = edge_triples(&result.edges); - - for triple in [ - vec![ - "IMPORTS_FROM".to_string(), - "file:src/pkg/models.py".to_string(), - "file:src/pkg/base.py".to_string(), - ], - vec![ - "IMPORTS_FROM".to_string(), - "file:src/pkg/models.py".to_string(), - "file:src/pkg/helpers.py".to_string(), - ], - vec![ - "IMPORTS_FROM".to_string(), - "file:tests/test_models.py".to_string(), - "file:src/pkg/models.py".to_string(), - ], - vec![ - "IMPORTS_FROM".to_string(), - "file:tests/test_models.py".to_string(), - "file:src/pkg/__init__.py".to_string(), - ], - vec![ - "IMPORTS_FROM".to_string(), - "file:src/pkg/uses_stub.py".to_string(), - "file:src/pkg/stubs.pyi".to_string(), - ], - ] { - assert!(triples.contains(&triple), "{triple:?}"); - } - assert!(result.diagnostics.iter().any(|diagnostic| { - diagnostic.category == Category::UnresolvedImport - && diagnostic.severity == Severity::Warning - && diagnostic.path.as_deref() == Some("src/pkg/models.py") - })); - assert!(!result - .diagnostics - .iter() - .any(|diagnostic| diagnostic.severity == Severity::Error)); - Ok(()) -} - -#[test] -fn python_exports_are_best_effort_and_documented() -> TestResult { - let repo = repo_with_tsconfig()?; - write_python_graph_fixture(&repo)?; - - let result = extract_sources(ExtractionOptions::new(repo.path())); - - assert_eq!( - required_attributes(&result.nodes, "class:src/pkg/models.py#PublicModel")?, - json!({"decorators":[],"exportKind":"named","exportName":"PublicModel","exportPolicy":"__all__","exported":true,"isTest":false}) - ); - assert_eq!( - required_attributes(&result.nodes, "function:src/pkg/models.py#_hidden")?, - json!({"async":false,"decorators":[],"exportPolicy":"__all__","exported":false,"isTest":false}) - ); - assert_eq!( - required_attributes(&result.nodes, "function:src/pkg/helpers.py#build_name")?, - json!({"async":false,"decorators":[],"exportKind":"named","exportName":"build_name","exportPolicy":"underscore_convention","exported":true,"isTest":false}) - ); - let exports = required_exports(&result.nodes, "file:src/pkg/models.py")?; - for expected in [ - json!({"kind":"named","local":"PublicModel","exported":"PublicModel","source":null,"supportedSymbol":true,"policy":"__all__"}), - json!({"kind":"named","local":"make_model","exported":"make_model","source":null,"supportedSymbol":true,"policy":"__all__"}), - ] { - assert!(exports.contains(&expected), "{expected}"); - } - Ok(()) -} - -#[test] -fn python_module_level_all_wins_even_when_empty() -> TestResult { - let repo = repo_with_tsconfig()?; - write( - &repo, - "pkg/api.py", - r#" -__all__ = [] - -def exposed(): - return True - -class Public: - pass -"#, - )?; - - let result = extract_sources(ExtractionOptions::new(repo.path())); - - assert_eq!( - required_attributes(&result.nodes, "function:pkg/api.py#exposed")?, - json!({"async":false,"decorators":[],"exportPolicy":"__all__","exported":false,"isTest":false}) - ); - assert_eq!( - required_attributes(&result.nodes, "class:pkg/api.py#Public")?, - json!({"decorators":[],"exportPolicy":"__all__","exported":false,"isTest":false}) - ); - assert_eq!( - required_exports(&result.nodes, "file:pkg/api.py")?, - Vec::::new() - ); - Ok(()) -} - -#[test] -fn python_nested_all_does_not_control_module_exports() -> TestResult { - let repo = repo_with_tsconfig()?; - write( - &repo, - "pkg/api.py", - r#" -def leaked(): - __all__ = ["_hidden"] - return True - -def _hidden(): - return True -"#, - )?; - - let result = extract_sources(ExtractionOptions::new(repo.path())); - - assert_eq!( - required_attributes(&result.nodes, "function:pkg/api.py#leaked")?, - json!({"async":false,"decorators":[],"exportKind":"named","exportName":"leaked","exportPolicy":"underscore_convention","exported":true,"isTest":false}) - ); - assert_eq!( - required_attributes(&result.nodes, "function:pkg/api.py#_hidden")?, - json!({"async":false,"decorators":[],"exportPolicy":"underscore_convention","exported":false,"isTest":false}) - ); - assert_eq!( - required_exports(&result.nodes, "file:pkg/api.py")?, - vec![ - json!({"kind":"named","local":"leaked","exported":"leaked","source":null,"supportedSymbol":true,"policy":"underscore_convention"}) - ] - ); - Ok(()) -} - -#[test] -fn python_dotted_import_module_calls_resolve_to_exported_member() -> TestResult { - let repo = repo_with_tsconfig()?; - write( - &repo, - "pkg/sub.py", - r#" -def target(): - return True -"#, - )?; - write( - &repo, - "app.py", - r#" -import pkg.sub - -def run(): - return pkg.sub.target() -"#, - )?; - - let result = extract_sources(ExtractionOptions::new(repo.path())); - let triples = edge_triples(&result.edges); - - assert!(triples.contains(&vec![ - "IMPORTS_FROM".to_string(), - "file:app.py".to_string(), - "file:pkg/sub.py".to_string() - ])); - assert!(triples.contains(&vec![ - "CALLS".to_string(), - "function:app.py#run".to_string(), - "function:pkg/sub.py#target".to_string() - ])); - Ok(()) -} - -#[test] -fn python_package_from_import_submodule_calls_resolve_to_submodule_member() -> TestResult { - let repo = repo_with_tsconfig()?; - write(&repo, "pkg/__init__.py", "")?; - write( - &repo, - "pkg/mod.py", - r#" -def f(): - return True -"#, - )?; - write( - &repo, - "app.py", - r#" -from pkg import mod - -def g(): - return mod.f() -"#, - )?; - - let result = extract_sources(ExtractionOptions::new(repo.path())); - let triples = edge_triples(&result.edges); - - assert!(triples.contains(&vec![ - "IMPORTS_FROM".to_string(), - "file:app.py".to_string(), - "file:pkg/mod.py".to_string() - ])); - assert!(triples.contains(&vec![ - "CALLS".to_string(), - "function:app.py#g".to_string(), - "function:pkg/mod.py#f".to_string() - ])); - Ok(()) -} - -#[test] -fn python_parse_errors_are_typed_warnings_and_non_fatal() -> TestResult { - let repo = repo_with_tsconfig()?; - write(&repo, "src/broken.py", "def broken(:\n return True\n")?; - - let result = extract_sources(ExtractionOptions::new(repo.path())); - - assert!(result.diagnostics.iter().any(|diagnostic| { - diagnostic.category == Category::ParseError && diagnostic.severity == Severity::Warning - })); - assert!(!result - .diagnostics - .iter() - .any(|diagnostic| diagnostic.severity == Severity::Error)); - assert!(result - .nodes - .iter() - .any(|node| node.id == "file:src/broken.py")); - Ok(()) -} - -#[test] -fn python_sources_are_discovered_and_extracted() -> TestResult { - let repo = repo_with_tsconfig()?; - write(&repo, "src/app.ts", "export const app = true;\n")?; - write(&repo, "src/tool.py", "def run():\n return True\n")?; - write(&repo, "src/typings.pyi", "def run() -> bool: ...\n")?; - - let discovery = discover_sources_for_options(&ExtractionOptions::new(repo.path())); - let sources = discovery - .sources - .iter() - .map(|source| (source.relative_path.as_str(), source.language.as_str())) - .collect::>(); - assert_eq!( - sources, - vec![ - ("src/app.ts", "typescript"), - ("src/tool.py", "python"), - ("src/typings.pyi", "python") - ] - ); - assert!(!discovery - .diagnostics - .iter() - .any(|diagnostic| diagnostic.category == Category::UnsupportedLanguage)); - - let result = extract_sources(ExtractionOptions::new(repo.path())); - assert!(!result - .diagnostics - .iter() - .any(|diagnostic| diagnostic.severity == Severity::Error)); - assert_eq!( - sorted( - result - .file_hashes - .iter() - .map(|hash| format!("{}:{}", hash.relative_path, hash.language)) - .collect() - ), - vec![ - "src/app.ts:typescript".to_string(), - "src/tool.py:python".to_string(), - "src/typings.pyi:python".to_string() - ] - ); - assert!(result - .nodes - .iter() - .any(|node| node.id == "file:src/tool.py")); - assert!(result - .nodes - .iter() - .any(|node| node.id == "file:src/typings.pyi")); - Ok(()) -} - -#[test] -fn python_generated_private_and_dependency_paths_are_ignored() -> TestResult { - let repo = repo_with_tsconfig()?; - write(&repo, "src/app.ts", "export const app = true;\n")?; - write(&repo, "src/tool.py", "def run():\n return True\n")?; - for path in [ - ".venv/lib/python3.12/site-packages/pkg/ignored.py", - "venv/lib/python3.12/site-packages/pkg/ignored.py", - "env/lib/python3.12/site-packages/pkg/ignored.py", - ".agents/runtime/ignored.ts", - ".claude/runtime/ignored.ts", - ".codex/runtime/ignored.ts", - ".gemini/runtime/ignored.ts", - ".opencode/runtime/ignored.ts", - "src/__pycache__/ignored.py", - ".eggs/pkg/ignored.py", - "build/lib/ignored.py", - ".tox/py/ignored.py", - ".mypy_cache/ignored.py", - ".pytest_cache/ignored.py", - ".ruff_cache/ignored.py", - "pkg.egg-info/ignored.py", - "pkg.dist-info/ignored.py", - "lib/site-packages/pkg/ignored.py", - ] { - write(&repo, path, "def ignored():\n return True\n")?; - } - - let discovery = discover_sources_for_options(&ExtractionOptions::new(repo.path())); - let source_paths = sorted( - discovery - .sources - .iter() - .map(|source| source.relative_path.clone()) - .collect(), - ); - - assert_eq!(source_paths, vec!["src/app.ts", "src/tool.py"]); - assert!(!discovery - .diagnostics - .iter() - .any(|diagnostic| diagnostic.category == Category::UnsupportedLanguage)); - Ok(()) -} - -fn assert_error_category(result: super::ExtractionResult, category: Category) { - assert!( - result - .diagnostics - .iter() - .any(|diagnostic| diagnostic.category == category - && diagnostic.severity == Severity::Error), - "{:?}", - result.diagnostics - ); - assert!(result.nodes.is_empty()); - assert!(result.edges.is_empty()); -} - -fn wave1_fixture_root() -> Result { - PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .join("../../packages/fixtures/source-extraction/wave1") - .canonicalize() -} - -fn temp_repo() -> Result { - tempfile::tempdir() -} - -fn repo_with_tsconfig() -> Result { - let repo = temp_repo()?; - write( - &repo, - "tsconfig.json", - r#"{"compilerOptions":{"baseUrl":"."}}"#, - )?; - Ok(repo) -} - -fn write_python_graph_fixture(repo: &TempDir) -> TestResult { - write_python_package_files(repo)?; - write_python_model_files(repo)?; - write_python_stub_and_tests(repo)?; - Ok(()) -} - -fn write_rust_graph_fixture(repo: &TempDir) -> TestResult { - write( - repo, - "src/lib.rs", - r#" -pub mod helpers; -mod user; - -pub trait Service { - fn handle(&self); -} - -pub struct Widget; - -pub enum Mode { - Fast, -} - -impl Service for Widget { - fn handle(&self) { - helpers::assist(); - } -} - -pub type Alias = Widget; -pub const LIMIT: usize = 1; -pub static NAME: &str = "widget"; - -macro_rules! trace { - () => {}; -} -"#, - )?; - write(repo, "src/helpers.rs", "pub fn assist() -> usize { 1 }\n")?; - write( - repo, - "src/user.rs", - r#" -use crate::helpers; -use crate::{Service, Widget}; - -pub fn run() { - helpers::assist(); - let widget = Widget; - widget.handle(); -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_run() { - run(); - } -} -"#, - )?; - Ok(()) -} - -fn write_python_package_files(repo: &TempDir) -> TestResult { - write( - repo, - "src/pkg/__init__.py", - r#" -from .models import PublicModel - -PACKAGE_VALUE = PublicModel() -__all__ = ["PublicModel", "PACKAGE_VALUE"] -"#, - )?; - write( - repo, - "src/pkg/base.py", - r#" -class BaseModel: - pass -"#, - )?; - write( - repo, - "src/pkg/helpers.py", - r#" -def build_name(): - return "public" - "#, - )?; - Ok(()) -} - -fn write_python_model_files(repo: &TempDir) -> TestResult { - write( - repo, - "src/pkg/models.py", - r#" -from .base import BaseModel -from .helpers import build_name -from .missing import MissingLocal - -_private = 1 -__all__ = ["PublicModel", "make_model"] - -class PublicModel(BaseModel): - @classmethod - def from_value(cls): - return build_name() - - def render(self): - return build_name() - -def make_model(): - return PublicModel() - -def _hidden(): - return PublicModel() - "#, - )?; - Ok(()) -} - -fn write_python_stub_and_tests(repo: &TempDir) -> TestResult { - write(repo, "src/pkg/stubs.pyi", "def stubbed() -> str: ...\n")?; - write( - repo, - "src/pkg/uses_stub.py", - r#" -from .stubs import stubbed - -def call_stub(): - return stubbed() -"#, - )?; - write( - repo, - "tests/test_models.py", - r#" -from src.pkg import PACKAGE_VALUE -from src.pkg.models import PublicModel, make_model - -def test_make_model(): - make_model() - PublicModel.from_value() - return PACKAGE_VALUE - -class TestPublicModel: - def test_render(self): - return PublicModel().render() -"#, - )?; - Ok(()) -} - -fn write(repo: &TempDir, path: &str, contents: &str) -> Result<(), std::io::Error> { - let path = repo.path().join(path); - if let Some(parent) = path.parent() { - fs::create_dir_all(parent)?; - } - fs::write(path, contents) -} - -fn edge_triples(edges: &[crate::protocol::GraphFactEdge]) -> Vec> { - sorted( - edges - .iter() - .map(|edge| vec![edge.kind.clone(), edge.from.clone(), edge.to.clone()]) - .collect(), - ) -} - -fn value_strings(value: &Value, key: &str) -> Result, std::io::Error> { - let entries = value - .get(key) - .and_then(Value::as_array) - .ok_or_else(|| std::io::Error::other(format!("missing string array {key}")))?; - entries - .iter() - .map(|entry| { - entry - .as_str() - .map(ToString::to_string) - .ok_or_else(|| std::io::Error::other(format!("non-string entry in {key}"))) - }) - .collect::, _>>() - .map(sorted) -} - -fn value_triples(value: &Value, key: &str) -> Result>, std::io::Error> { - let entries = value - .get(key) - .and_then(Value::as_array) - .ok_or_else(|| std::io::Error::other(format!("missing triple array {key}")))?; - entries - .iter() - .map(|entry| { - let parts = entry - .as_array() - .ok_or_else(|| std::io::Error::other(format!("non-array triple in {key}")))?; - parts - .iter() - .map(|part| { - part.as_str().map(ToString::to_string).ok_or_else(|| { - std::io::Error::other(format!("non-string triple part in {key}")) - }) - }) - .collect::, _>>() - }) - .collect::, _>>() - .map(sorted) -} - -fn value_object(value: &Value, key: &str) -> Result { - value - .get(key) - .cloned() - .ok_or_else(|| std::io::Error::other(format!("missing object {key}"))) -} - -fn node_attributes(nodes: &[GraphFactNode]) -> Value { - let mut attributes = serde_json::Map::new(); - for node in nodes { - if node.kind == "File" { - continue; - } - attributes.insert( - node.id.clone(), - node.attributes.clone().unwrap_or_else(|| json!({})), - ); - } - Value::Object(attributes) -} - -fn file_exports(nodes: &[GraphFactNode]) -> Value { - let mut exports_by_file = serde_json::Map::new(); - for node in nodes { - if node.kind != "File" { - continue; - } - if let Some(exports) = node - .attributes - .as_ref() - .and_then(|attributes| attributes.get("exports")) - { - exports_by_file.insert(node.id.clone(), exports.clone()); - } - } - Value::Object(exports_by_file) -} - -fn required_attributes(nodes: &[GraphFactNode], id: &str) -> Result { - let node = nodes - .iter() - .find(|node| node.id == id) - .ok_or_else(|| std::io::Error::other(format!("missing node {id}")))?; - node.attributes - .clone() - .ok_or_else(|| std::io::Error::other(format!("missing attributes for {id}"))) -} - -fn required_exports(nodes: &[GraphFactNode], id: &str) -> Result, std::io::Error> { - let node = nodes - .iter() - .find(|node| node.id == id) - .ok_or_else(|| std::io::Error::other(format!("missing node {id}")))?; - node.attributes - .as_ref() - .and_then(|attributes| attributes.get("exports")) - .and_then(Value::as_array) - .cloned() - .ok_or_else(|| std::io::Error::other(format!("missing exports for {id}"))) -} - -fn assert_missing_node(nodes: &[GraphFactNode], id: &str) -> Result<(), std::io::Error> { - if nodes.iter().any(|node| node.id == id) { - Err(std::io::Error::other(format!("unexpected node {id}"))) - } else { - Ok(()) - } -} - -fn sorted(mut values: Vec) -> Vec { - values.sort(); - values -} +mod diagnostics; +mod python; +mod rust; +mod support; +mod typescript_exports; +mod typescript_resolution; + +use diagnostics::DiagnosticsTests; +use python::PythonTests; +use rust::RustTests; +use typescript_exports::TypeScriptExportTests; +use typescript_resolution::TypeScriptResolutionTests; + +type SplitTestModules = ( + DiagnosticsTests, + PythonTests, + RustTests, + TypeScriptExportTests, + TypeScriptResolutionTests, +); + +const _: usize = std::mem::size_of::(); diff --git a/crates/graph-core/src/extraction/tests/diagnostics.rs b/crates/graph-core/src/extraction/tests/diagnostics.rs new file mode 100644 index 0000000..5321be7 --- /dev/null +++ b/crates/graph-core/src/extraction/tests/diagnostics.rs @@ -0,0 +1,184 @@ +use super::support::*; +use crate::extraction::{discover_sources_for_options, extract_sources, ExtractionOptions}; +use crate::protocol::{ + GraphExtractionDiagnosticCategory as Category, GraphExtractionDiagnosticSeverity as Severity, +}; + +type TestResult = Result<(), Box>; + +#[cfg(test)] +pub(super) struct DiagnosticsTests; + +#[test] +fn oxc_parse_errors_are_typed_warnings_and_non_fatal() -> TestResult { + let repo = repo_with_tsconfig()?; + write(&repo, "src/broken.ts", "export function broken(")?; + write( + &repo, + "src/valid.ts", + "export function valid() { return 1; }", + )?; + + let result = extract_sources(ExtractionOptions::new(repo.path())); + + assert!( + result + .diagnostics + .iter() + .any(|diagnostic| diagnostic.category == Category::ParseError + && diagnostic.severity == Severity::Warning), + "{:?}", + result.diagnostics + ); + assert!( + !result + .diagnostics + .iter() + .any(|diagnostic| diagnostic.category == Category::ParseError + && diagnostic.severity == Severity::Error), + "{:?}", + result.diagnostics + ); + required_attributes(&result.nodes, "file:src/broken.ts")?; + required_attributes(&result.nodes, "function:src/valid.ts#valid")?; + Ok(()) +} + +#[test] +fn missing_parser_errors_are_typed_and_block_empty_success() -> TestResult { + let repo = repo_with_tsconfig()?; + write(&repo, "src/a.ts", "export function a() { return 1; }")?; + let mut options = ExtractionOptions::new(repo.path()); + options.force_missing_parser = true; + assert_error_category(extract_sources(options), Category::MissingParser); + Ok(()) +} + +#[test] +fn malformed_tsconfig_errors_are_typed_and_block_empty_success() -> TestResult { + let repo = temp_repo()?; + let malformed_json = char::from(123).to_string(); + write(&repo, "tsconfig.json", &malformed_json)?; + write(&repo, "src/a.ts", "export function a() { return 1; }")?; + assert_error_category( + extract_sources(ExtractionOptions::new(repo.path())), + Category::MalformedTsconfig, + ); + Ok(()) +} + +#[test] +fn malformed_tsconfig_paths_are_typed_and_block_empty_success() -> TestResult { + let repo = temp_repo()?; + write( + &repo, + "tsconfig.json", + r#"{"compilerOptions":{"baseUrl":".","paths":{"@bad/*":"src/*"}}}"#, + )?; + write( + &repo, + "src/a.ts", + "import { b } from '@bad/b'; export function a() { return b(); }", + )?; + write(&repo, "src/b.ts", "export function b() { return 1; }")?; + assert_error_category( + extract_sources(ExtractionOptions::new(repo.path())), + Category::MalformedTsconfig, + ); + Ok(()) +} + +#[test] +fn max_file_errors_are_typed_and_block_empty_success() -> TestResult { + let repo = repo_with_tsconfig()?; + write(&repo, "src/a.ts", "export function a() { return 1; }")?; + let mut options = ExtractionOptions::new(repo.path()); + options.max_files = 0; + assert_error_category(extract_sources(options), Category::MaxFilesExceeded); + Ok(()) +} + +#[test] +fn default_discovery_has_no_legacy_four_thousand_file_ceiling() -> TestResult { + let repo = repo_with_tsconfig()?; + for index in 0..4_001 { + write( + &repo, + &format!("src/generated/file_{index:04}.ts"), + "export const value = 1;\n", + )?; + } + + let discovery = discover_sources_for_options(&ExtractionOptions::new(repo.path())); + + assert_eq!(discovery.sources.len(), 4_001); + assert!( + !discovery + .diagnostics + .iter() + .any(|diagnostic| diagnostic.category == Category::MaxFilesExceeded), + "{:?}", + discovery.diagnostics + ); + Ok(()) +} + +#[test] +fn max_depth_errors_are_typed_and_block_empty_success() -> TestResult { + let repo = repo_with_tsconfig()?; + write(&repo, "src/deep/a.ts", "export function a() { return 1; }")?; + let mut options = ExtractionOptions::new(repo.path()); + options.max_depth = 1; + assert_error_category(extract_sources(options), Category::MaxDepthExceeded); + Ok(()) +} + +#[test] +fn path_traversal_errors_are_typed_and_block_empty_success() -> TestResult { + let repo = temp_repo()?; + write( + &repo, + "tsconfig.json", + r#"{"compilerOptions":{"baseUrl":".","paths":{"@outside/*":["../outside/*"]}}}"#, + )?; + write( + &repo, + "src/a.ts", + "import { out } from '@outside/out'; export function a() { return out(); }", + )?; + assert_error_category( + extract_sources(ExtractionOptions::new(repo.path())), + Category::PathTraversal, + ); + Ok(()) +} + +#[test] +fn unsupported_and_missing_tsconfig_are_typed_warnings() -> TestResult { + let repo = temp_repo()?; + write(&repo, "src/a.ts", "export function a() { return 1; }")?; + write(&repo, "src/view.vue", "")?; + + let result = extract_sources(ExtractionOptions::new(repo.path())); + + assert!(result + .diagnostics + .iter() + .any( + |diagnostic| diagnostic.category == Category::MissingTsconfig + && diagnostic.severity == Severity::Warning + )); + assert!(result + .diagnostics + .iter() + .any( + |diagnostic| diagnostic.category == Category::UnsupportedLanguage + && diagnostic.severity == Severity::Warning + )); + assert!(!result + .diagnostics + .iter() + .any(|diagnostic| diagnostic.severity == Severity::Error)); + assert!(!result.nodes.is_empty()); + Ok(()) +} diff --git a/crates/graph-core/src/extraction/tests/python.rs b/crates/graph-core/src/extraction/tests/python.rs new file mode 100644 index 0000000..f192885 --- /dev/null +++ b/crates/graph-core/src/extraction/tests/python.rs @@ -0,0 +1,479 @@ +use super::support::*; +use crate::extraction::{discover_sources_for_options, extract_sources, ExtractionOptions}; +use crate::protocol::{ + GraphExtractionDiagnosticCategory as Category, GraphExtractionDiagnosticSeverity as Severity, +}; +use serde_json::{json, Value}; + +type TestResult = Result<(), Box>; + +#[cfg(test)] +pub(super) struct PythonTests; + +#[test] +fn python_ast_extracts_contract_facts() -> TestResult { + let repo = repo_with_tsconfig()?; + write_python_graph_fixture(&repo)?; + + let result = extract_sources(ExtractionOptions::new(repo.path())); + let node_ids = sorted(result.nodes.iter().map(|node| node.id.clone()).collect()); + let triples = edge_triples(&result.edges); + + assert!( + !result + .diagnostics + .iter() + .any(|diagnostic| diagnostic.severity == Severity::Error), + "{:?}", + result.diagnostics + ); + for id in [ + "file:src/pkg/models.py", + "module:src/pkg/models.py#src.pkg.models", + "class:src/pkg/models.py#PublicModel", + "function:src/pkg/models.py#PublicModel.from_value", + "function:src/pkg/models.py#make_model", + "function:src/pkg/models.py#_hidden", + "variable:src/pkg/models.py#_private", + "function:tests/test_models.py#test_make_model", + "function:tests/test_models.py#TestPublicModel.test_render", + ] { + assert!(node_ids.contains(&id.to_string()), "{id}"); + } + for triple in [ + vec![ + "CONTAINS".to_string(), + "file:src/pkg/models.py".to_string(), + "module:src/pkg/models.py#src.pkg.models".to_string(), + ], + vec![ + "CONTAINS".to_string(), + "module:src/pkg/models.py#src.pkg.models".to_string(), + "class:src/pkg/models.py#PublicModel".to_string(), + ], + vec![ + "CONTAINS".to_string(), + "class:src/pkg/models.py#PublicModel".to_string(), + "function:src/pkg/models.py#PublicModel.from_value".to_string(), + ], + vec![ + "CALLS".to_string(), + "function:src/pkg/models.py#make_model".to_string(), + "class:src/pkg/models.py#PublicModel".to_string(), + ], + vec![ + "INHERITS".to_string(), + "class:src/pkg/models.py#PublicModel".to_string(), + "class:src/pkg/base.py#BaseModel".to_string(), + ], + vec![ + "TESTED_BY".to_string(), + "class:src/pkg/models.py#PublicModel".to_string(), + "function:tests/test_models.py#test_make_model".to_string(), + ], + ] { + assert!(triples.contains(&triple), "{triple:?}"); + } + assert!(result.metadata.node_kinds.contains(&"Module".to_string())); + Ok(()) +} + +#[test] +fn python_import_resolution_handles_absolute_relative_package_and_unresolved() -> TestResult { + let repo = repo_with_tsconfig()?; + write_python_graph_fixture(&repo)?; + + let result = extract_sources(ExtractionOptions::new(repo.path())); + let triples = edge_triples(&result.edges); + + for triple in [ + vec![ + "IMPORTS_FROM".to_string(), + "file:src/pkg/models.py".to_string(), + "file:src/pkg/base.py".to_string(), + ], + vec![ + "IMPORTS_FROM".to_string(), + "file:src/pkg/models.py".to_string(), + "file:src/pkg/helpers.py".to_string(), + ], + vec![ + "IMPORTS_FROM".to_string(), + "file:tests/test_models.py".to_string(), + "file:src/pkg/models.py".to_string(), + ], + vec![ + "IMPORTS_FROM".to_string(), + "file:tests/test_models.py".to_string(), + "file:src/pkg/__init__.py".to_string(), + ], + vec![ + "IMPORTS_FROM".to_string(), + "file:src/pkg/uses_stub.py".to_string(), + "file:src/pkg/stubs.pyi".to_string(), + ], + ] { + assert!(triples.contains(&triple), "{triple:?}"); + } + assert!(result.diagnostics.iter().any(|diagnostic| { + diagnostic.category == Category::UnresolvedImport + && diagnostic.severity == Severity::Warning + && diagnostic.path.as_deref() == Some("src/pkg/models.py") + })); + assert!(!result + .diagnostics + .iter() + .any(|diagnostic| diagnostic.severity == Severity::Error)); + Ok(()) +} + +#[test] +fn python_exports_are_best_effort_and_documented() -> TestResult { + let repo = repo_with_tsconfig()?; + write_python_graph_fixture(&repo)?; + + let result = extract_sources(ExtractionOptions::new(repo.path())); + + assert_eq!( + required_attributes(&result.nodes, "class:src/pkg/models.py#PublicModel")?, + json!({ + "decorators": [], + "exportKind": "named", + "exportName": "PublicModel", + "exportPolicy": "__all__", + "exported": true, + "isTest": false + }) + ); + assert_eq!( + required_attributes(&result.nodes, "function:src/pkg/models.py#_hidden")?, + json!({ + "async": false, + "decorators": [], + "exportPolicy": "__all__", + "exported": false, + "isTest": false + }) + ); + assert_eq!( + required_attributes(&result.nodes, "function:src/pkg/helpers.py#build_name")?, + json!({ + "async": false, + "decorators": [], + "exportKind": "named", + "exportName": "build_name", + "exportPolicy": "underscore_convention", + "exported": true, + "isTest": false + }) + ); + let exports = required_exports(&result.nodes, "file:src/pkg/models.py")?; + for expected in [ + json!({ + "kind": "named", + "local": "PublicModel", + "exported": "PublicModel", + "source": null, + "supportedSymbol": true, + "policy": "__all__" + }), + json!({ + "kind": "named", + "local": "make_model", + "exported": "make_model", + "source": null, + "supportedSymbol": true, + "policy": "__all__" + }), + ] { + assert!(exports.contains(&expected), "{expected}"); + } + Ok(()) +} + +#[test] +fn python_module_level_all_wins_even_when_empty() -> TestResult { + let repo = repo_with_tsconfig()?; + write( + &repo, + "pkg/api.py", + r#" +__all__ = [] + +def exposed(): + return True + +class Public: + pass +"#, + )?; + + let result = extract_sources(ExtractionOptions::new(repo.path())); + + assert_eq!( + required_attributes(&result.nodes, "function:pkg/api.py#exposed")?, + json!({"async":false,"decorators":[],"exportPolicy":"__all__","exported":false,"isTest":false}) + ); + assert_eq!( + required_attributes(&result.nodes, "class:pkg/api.py#Public")?, + json!({"decorators":[],"exportPolicy":"__all__","exported":false,"isTest":false}) + ); + assert_eq!( + required_exports(&result.nodes, "file:pkg/api.py")?, + Vec::::new() + ); + Ok(()) +} + +#[test] +fn python_nested_all_does_not_control_module_exports() -> TestResult { + let repo = repo_with_tsconfig()?; + write( + &repo, + "pkg/api.py", + r#" +def leaked(): + __all__ = ["_hidden"] + return True + +def _hidden(): + return True +"#, + )?; + + let result = extract_sources(ExtractionOptions::new(repo.path())); + + assert_eq!( + required_attributes(&result.nodes, "function:pkg/api.py#leaked")?, + json!({ + "async": false, + "decorators": [], + "exportKind": "named", + "exportName": "leaked", + "exportPolicy": "underscore_convention", + "exported": true, + "isTest": false + }) + ); + assert_eq!( + required_attributes(&result.nodes, "function:pkg/api.py#_hidden")?, + json!({ + "async": false, + "decorators": [], + "exportPolicy": "underscore_convention", + "exported": false, + "isTest": false + }) + ); + assert_eq!( + required_exports(&result.nodes, "file:pkg/api.py")?, + vec![json!({ + "kind": "named", + "local": "leaked", + "exported": "leaked", + "source": null, + "supportedSymbol": true, + "policy": "underscore_convention" + })] + ); + Ok(()) +} + +#[test] +fn python_dotted_import_module_calls_resolve_to_exported_member() -> TestResult { + let repo = repo_with_tsconfig()?; + write( + &repo, + "pkg/sub.py", + r#" +def target(): + return True +"#, + )?; + write( + &repo, + "app.py", + r#" +import pkg.sub + +def run(): + return pkg.sub.target() +"#, + )?; + + let result = extract_sources(ExtractionOptions::new(repo.path())); + let triples = edge_triples(&result.edges); + + assert!(triples.contains(&vec![ + "IMPORTS_FROM".to_string(), + "file:app.py".to_string(), + "file:pkg/sub.py".to_string() + ])); + assert!(triples.contains(&vec![ + "CALLS".to_string(), + "function:app.py#run".to_string(), + "function:pkg/sub.py#target".to_string() + ])); + Ok(()) +} + +#[test] +fn python_package_from_import_submodule_calls_resolve_to_submodule_member() -> TestResult { + let repo = repo_with_tsconfig()?; + write(&repo, "pkg/__init__.py", "")?; + write( + &repo, + "pkg/mod.py", + r#" +def f(): + return True +"#, + )?; + write( + &repo, + "app.py", + r#" +from pkg import mod + +def g(): + return mod.f() +"#, + )?; + + let result = extract_sources(ExtractionOptions::new(repo.path())); + let triples = edge_triples(&result.edges); + + assert!(triples.contains(&vec![ + "IMPORTS_FROM".to_string(), + "file:app.py".to_string(), + "file:pkg/mod.py".to_string() + ])); + assert!(triples.contains(&vec![ + "CALLS".to_string(), + "function:app.py#g".to_string(), + "function:pkg/mod.py#f".to_string() + ])); + Ok(()) +} + +#[test] +fn python_parse_errors_are_typed_warnings_and_non_fatal() -> TestResult { + let repo = repo_with_tsconfig()?; + write(&repo, "src/broken.py", "def broken(:\n return True\n")?; + + let result = extract_sources(ExtractionOptions::new(repo.path())); + + assert!(result.diagnostics.iter().any(|diagnostic| { + diagnostic.category == Category::ParseError && diagnostic.severity == Severity::Warning + })); + assert!(!result + .diagnostics + .iter() + .any(|diagnostic| diagnostic.severity == Severity::Error)); + assert!(result + .nodes + .iter() + .any(|node| node.id == "file:src/broken.py")); + Ok(()) +} + +#[test] +fn python_sources_are_discovered_and_extracted() -> TestResult { + let repo = repo_with_tsconfig()?; + write(&repo, "src/app.ts", "export const app = true;\n")?; + write(&repo, "src/tool.py", "def run():\n return True\n")?; + write(&repo, "src/typings.pyi", "def run() -> bool: ...\n")?; + + let discovery = discover_sources_for_options(&ExtractionOptions::new(repo.path())); + let sources = discovery + .sources + .iter() + .map(|source| (source.relative_path.as_str(), source.language.as_str())) + .collect::>(); + assert_eq!( + sources, + vec![ + ("src/app.ts", "typescript"), + ("src/tool.py", "python"), + ("src/typings.pyi", "python") + ] + ); + assert!(!discovery + .diagnostics + .iter() + .any(|diagnostic| diagnostic.category == Category::UnsupportedLanguage)); + + let result = extract_sources(ExtractionOptions::new(repo.path())); + assert!(!result + .diagnostics + .iter() + .any(|diagnostic| diagnostic.severity == Severity::Error)); + assert_eq!( + sorted( + result + .file_hashes + .iter() + .map(|hash| format!("{}:{}", hash.relative_path, hash.language)) + .collect() + ), + vec![ + "src/app.ts:typescript".to_string(), + "src/tool.py:python".to_string(), + "src/typings.pyi:python".to_string() + ] + ); + assert!(result + .nodes + .iter() + .any(|node| node.id == "file:src/tool.py")); + assert!(result + .nodes + .iter() + .any(|node| node.id == "file:src/typings.pyi")); + Ok(()) +} + +#[test] +fn python_generated_private_and_dependency_paths_are_ignored() -> TestResult { + let repo = repo_with_tsconfig()?; + write(&repo, "src/app.ts", "export const app = true;\n")?; + write(&repo, "src/tool.py", "def run():\n return True\n")?; + for path in [ + ".venv/lib/python3.12/site-packages/pkg/ignored.py", + "venv/lib/python3.12/site-packages/pkg/ignored.py", + "env/lib/python3.12/site-packages/pkg/ignored.py", + ".agents/runtime/ignored.ts", + ".claude/runtime/ignored.ts", + ".codex/runtime/ignored.ts", + ".gemini/runtime/ignored.ts", + ".opencode/runtime/ignored.ts", + "src/__pycache__/ignored.py", + ".eggs/pkg/ignored.py", + "build/lib/ignored.py", + ".tox/py/ignored.py", + ".mypy_cache/ignored.py", + ".pytest_cache/ignored.py", + ".ruff_cache/ignored.py", + "pkg.egg-info/ignored.py", + "pkg.dist-info/ignored.py", + "lib/site-packages/pkg/ignored.py", + ] { + write(&repo, path, "def ignored():\n return True\n")?; + } + + let discovery = discover_sources_for_options(&ExtractionOptions::new(repo.path())); + let source_paths = sorted( + discovery + .sources + .iter() + .map(|source| source.relative_path.clone()) + .collect(), + ); + + assert_eq!(source_paths, vec!["src/app.ts", "src/tool.py"]); + assert!(!discovery + .diagnostics + .iter() + .any(|diagnostic| diagnostic.category == Category::UnsupportedLanguage)); + Ok(()) +} diff --git a/crates/graph-core/src/extraction/tests/rust.rs b/crates/graph-core/src/extraction/tests/rust.rs new file mode 100644 index 0000000..2b53b4d --- /dev/null +++ b/crates/graph-core/src/extraction/tests/rust.rs @@ -0,0 +1,252 @@ +use super::support::*; +use crate::extraction::{ + discover_sources_for_options, extract_sources, DiscoveryResult, ExtractionOptions, + ExtractionResult, +}; +use crate::protocol::{ + GraphExtractionDiagnosticCategory as Category, GraphExtractionDiagnosticSeverity as Severity, +}; + +#[cfg(test)] +pub(super) struct RustTests; +use serde_json::Value; + +type TestResult = Result<(), Box>; + +#[test] +fn rust_sources_are_discovered_and_extracted() -> TestResult { + let repo = repo_with_tsconfig()?; + write_rust_graph_fixture(&repo)?; + + let discovery = discover_sources_for_options(&ExtractionOptions::new(repo.path())); + let result = extract_sources(ExtractionOptions::new(repo.path())); + + assert_rust_discovery_sources(&discovery); + assert_rust_extraction_nodes(&result); + assert_rust_extraction_edges(&result); + assert_rust_extraction_attributes(&result)?; + Ok(()) +} + +fn assert_rust_discovery_sources(discovery: &DiscoveryResult) { + let sources = discovery + .sources + .iter() + .map(|source| (source.relative_path.as_str(), source.language.as_str())) + .collect::>(); + assert_eq!( + sources, + vec![ + ("src/helpers.rs", "rust"), + ("src/lib.rs", "rust"), + ("src/user.rs", "rust") + ] + ); + assert!(!discovery + .diagnostics + .iter() + .any(|diagnostic| diagnostic.category == Category::UnsupportedLanguage)); +} + +fn assert_rust_extraction_nodes(result: &ExtractionResult) { + assert!( + !result + .diagnostics + .iter() + .any(|diagnostic| diagnostic.severity == Severity::Error), + "{:?}", + result.diagnostics + ); + let node_ids = sorted(result.nodes.iter().map(|node| node.id.clone()).collect()); + for id in [ + "file:src/lib.rs", + "module:src/lib.rs#crate", + "module:src/user.rs#user", + "module:src/user.rs#user.tests", + "struct:src/lib.rs#Widget", + "enum:src/lib.rs#Mode", + "trait:src/lib.rs#Service", + "impl:src/lib.rs#impl Service for Widget", + "method:src/lib.rs#Widget::handle", + "type:src/lib.rs#Alias", + "const:src/lib.rs#LIMIT", + "static:src/lib.rs#NAME", + "macro:src/lib.rs#trace", + "function:src/helpers.rs#helpers::assist", + "function:src/user.rs#user::run", + "test:src/user.rs#user.tests::test_run", + ] { + assert!(node_ids.contains(&id.to_string()), "{id}"); + } +} + +fn assert_rust_extraction_edges(result: &ExtractionResult) { + let triples = edge_triples(&result.edges); + for triple in rust_expected_edge_triples() { + assert!(triples.contains(&triple), "{triple:?}"); + } +} + +fn assert_rust_extraction_attributes(result: &ExtractionResult) -> TestResult { + assert_eq!( + required_attributes(&result.nodes, "struct:src/lib.rs#Widget")? + .get("exported") + .and_then(Value::as_bool), + Some(true) + ); + assert_eq!( + required_attributes(&result.nodes, "struct:src/lib.rs#Widget")? + .get("language") + .and_then(Value::as_str), + Some("rust") + ); + assert_rust_run_attributes(result)?; + assert!(result.metadata.node_kinds.contains(&"Struct".to_string())); + assert!(result + .metadata + .edge_kinds + .contains(&"IMPLEMENTS".to_string())); + Ok(()) +} + +fn assert_rust_run_attributes(result: &ExtractionResult) -> TestResult { + let attributes = required_attributes(&result.nodes, "function:src/user.rs#user::run")?; + assert_eq!( + attributes.get("qualifiedName").and_then(Value::as_str), + Some("user::run") + ); + assert!(attributes + .get("signature") + .and_then(Value::as_str) + .is_some_and(|signature| signature.starts_with("pub fn run"))); + assert!(attributes + .get("lineStart") + .and_then(Value::as_u64) + .is_some()); + Ok(()) +} + +fn rust_expected_edge_triples() -> Vec> { + [ + ("CONTAINS", "file:src/user.rs", "module:src/user.rs#user"), + ( + "CONTAINS", + "module:src/user.rs#user", + "function:src/user.rs#user::run", + ), + ( + "CONTAINS", + "module:src/user.rs#user.tests", + "test:src/user.rs#user.tests::test_run", + ), + ("IMPORTS_FROM", "file:src/user.rs", "file:src/helpers.rs"), + ( + "CALLS", + "function:src/user.rs#user::run", + "function:src/helpers.rs#helpers::assist", + ), + ( + "CALLS", + "test:src/user.rs#user.tests::test_run", + "function:src/user.rs#user::run", + ), + ( + "TESTED_BY", + "function:src/user.rs#user::run", + "test:src/user.rs#user.tests::test_run", + ), + ( + "IMPLEMENTS", + "impl:src/lib.rs#impl Service for Widget", + "trait:src/lib.rs#Service", + ), + ] + .into_iter() + .map(|(kind, from, to)| vec![kind.to_string(), from.to_string(), to.to_string()]) + .collect() +} + +#[test] +fn rust_crate_use_prefers_module_file_over_lib_declaration_stub() -> TestResult { + let repo = repo_with_tsconfig()?; + write(&repo, "src/lib.rs", "pub mod helpers;\nmod user;\n")?; + write(&repo, "src/helpers.rs", "pub fn assist() -> usize { 1 }\n")?; + write( + &repo, + "src/user.rs", + "use crate::helpers;\npub fn run() { helpers::assist(); }\n", + )?; + + let result = extract_sources(ExtractionOptions::new(repo.path())); + let triples = edge_triples(&result.edges); + + assert!(triples.contains(&vec![ + "IMPORTS_FROM".to_string(), + "file:src/user.rs".to_string(), + "file:src/helpers.rs".to_string() + ])); + assert!(!triples.contains(&vec![ + "IMPORTS_FROM".to_string(), + "file:src/user.rs".to_string(), + "file:src/lib.rs".to_string() + ])); + Ok(()) +} + +#[test] +fn rust_crate_use_resolves_within_nearest_workspace_crate() -> TestResult { + let repo = repo_with_tsconfig()?; + write( + &repo, + "crates/app/src/lib.rs", + "pub mod helpers;\nmod user;\n", + )?; + write( + &repo, + "crates/app/src/helpers.rs", + "pub fn assist() -> usize { 1 }\n", + )?; + write( + &repo, + "crates/app/src/user.rs", + "use crate::helpers;\npub fn run() { helpers::assist(); }\n", + )?; + write( + &repo, + "crates/other/src/lib.rs", + "pub mod helpers;\npub fn unrelated() {}\n", + )?; + write( + &repo, + "crates/other/src/helpers.rs", + "pub fn assist() -> usize { 2 }\n", + )?; + + let result = extract_sources(ExtractionOptions::new(repo.path())); + let triples = edge_triples(&result.edges); + + assert!(triples.contains(&vec![ + "IMPORTS_FROM".to_string(), + "file:crates/app/src/user.rs".to_string(), + "file:crates/app/src/helpers.rs".to_string() + ])); + assert!(!triples.contains(&vec![ + "IMPORTS_FROM".to_string(), + "file:crates/app/src/user.rs".to_string(), + "file:crates/other/src/helpers.rs".to_string() + ])); + Ok(()) +} + +#[test] +fn rust_parse_errors_are_typed_and_block_empty_success() -> TestResult { + let repo = repo_with_tsconfig()?; + write(&repo, "src/broken.rs", "pub fn broken(")?; + + let result = extract_sources(ExtractionOptions::new(repo.path())); + + assert_error_category(result.clone(), Category::ParseError); + assert!(result.nodes.is_empty(), "{:?}", result.nodes); + assert!(result.edges.is_empty(), "{:?}", result.edges); + Ok(()) +} diff --git a/crates/graph-core/src/extraction/tests/support.rs b/crates/graph-core/src/extraction/tests/support.rs new file mode 100644 index 0000000..62eb394 --- /dev/null +++ b/crates/graph-core/src/extraction/tests/support.rs @@ -0,0 +1,360 @@ +use crate::extraction::ExtractionResult; +use crate::protocol::{ + GraphExtractionDiagnosticCategory as Category, GraphExtractionDiagnosticSeverity as Severity, + GraphFactNode, +}; +use serde_json::{json, Value}; +use std::fs; +use std::path::PathBuf; +use tempfile::TempDir; + +type TestResult = Result<(), Box>; + +#[cfg(test)] +pub(super) fn assert_error_category(result: ExtractionResult, category: Category) { + assert!( + result + .diagnostics + .iter() + .any(|diagnostic| diagnostic.category == category + && diagnostic.severity == Severity::Error), + "{:?}", + result.diagnostics + ); + assert!(result.nodes.is_empty()); + assert!(result.edges.is_empty()); +} + +#[cfg(test)] +pub(super) fn wave1_fixture_root() -> Result { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../packages/fixtures/source-extraction/wave1") + .canonicalize() +} + +#[cfg(test)] +pub(super) fn temp_repo() -> Result { + tempfile::tempdir() +} + +#[cfg(test)] +pub(super) fn repo_with_tsconfig() -> Result { + let repo = temp_repo()?; + write( + &repo, + "tsconfig.json", + r#"{"compilerOptions":{"baseUrl":"."}}"#, + )?; + Ok(repo) +} + +#[cfg(test)] +pub(super) fn write_python_graph_fixture(repo: &TempDir) -> TestResult { + write_python_package_files(repo)?; + write_python_model_files(repo)?; + write_python_stub_and_tests(repo)?; + Ok(()) +} + +#[cfg(test)] +pub(super) fn write_rust_graph_fixture(repo: &TempDir) -> TestResult { + write( + repo, + "src/lib.rs", + r#" +pub mod helpers; +mod user; + +pub trait Service { + fn handle(&self); +} + +pub struct Widget; + +pub enum Mode { + Fast, +} + +impl Service for Widget { + fn handle(&self) { + helpers::assist(); + } +} + +pub type Alias = Widget; +pub const LIMIT: usize = 1; +pub static NAME: &str = "widget"; + +macro_rules! trace { + () => {}; +} +"#, + )?; + write(repo, "src/helpers.rs", "pub fn assist() -> usize { 1 }\n")?; + write( + repo, + "src/user.rs", + r#" +use crate::helpers; +use crate::{Service, Widget}; + +pub fn run() { + helpers::assist(); + let widget = Widget; + widget.handle(); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_run() { + run(); + } +} +"#, + )?; + Ok(()) +} + +fn write_python_package_files(repo: &TempDir) -> TestResult { + write( + repo, + "src/pkg/__init__.py", + r#" +from .models import PublicModel + +PACKAGE_VALUE = PublicModel() +__all__ = ["PublicModel", "PACKAGE_VALUE"] +"#, + )?; + write( + repo, + "src/pkg/base.py", + r#" +class BaseModel: + pass +"#, + )?; + write( + repo, + "src/pkg/helpers.py", + r#" +def build_name(): + return "public" + "#, + )?; + Ok(()) +} + +fn write_python_model_files(repo: &TempDir) -> TestResult { + write( + repo, + "src/pkg/models.py", + r#" +from .base import BaseModel +from .helpers import build_name +from .missing import MissingLocal + +_private = 1 +__all__ = ["PublicModel", "make_model"] + +class PublicModel(BaseModel): + @classmethod + def from_value(cls): + return build_name() + + def render(self): + return build_name() + +def make_model(): + return PublicModel() + +def _hidden(): + return PublicModel() + "#, + )?; + Ok(()) +} + +fn write_python_stub_and_tests(repo: &TempDir) -> TestResult { + write(repo, "src/pkg/stubs.pyi", "def stubbed() -> str: ...\n")?; + write( + repo, + "src/pkg/uses_stub.py", + r#" +from .stubs import stubbed + +def call_stub(): + return stubbed() +"#, + )?; + write( + repo, + "tests/test_models.py", + r#" +from src.pkg import PACKAGE_VALUE +from src.pkg.models import PublicModel, make_model + +def test_make_model(): + make_model() + PublicModel.from_value() + return PACKAGE_VALUE + +class TestPublicModel: + def test_render(self): + return PublicModel().render() +"#, + )?; + Ok(()) +} + +#[cfg(test)] +pub(super) fn write(repo: &TempDir, path: &str, contents: &str) -> Result<(), std::io::Error> { + let path = repo.path().join(path); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + fs::write(path, contents) +} + +#[cfg(test)] +pub(super) fn edge_triples(edges: &[crate::protocol::GraphFactEdge]) -> Vec> { + sorted( + edges + .iter() + .map(|edge| vec![edge.kind.clone(), edge.from.clone(), edge.to.clone()]) + .collect(), + ) +} + +#[cfg(test)] +pub(super) fn value_strings(value: &Value, key: &str) -> Result, std::io::Error> { + let entries = value + .get(key) + .and_then(Value::as_array) + .ok_or_else(|| std::io::Error::other(format!("missing string array {key}")))?; + entries + .iter() + .map(|entry| { + entry + .as_str() + .map(ToString::to_string) + .ok_or_else(|| std::io::Error::other(format!("non-string entry in {key}"))) + }) + .collect::, _>>() + .map(sorted) +} + +#[cfg(test)] +pub(super) fn value_triples(value: &Value, key: &str) -> Result>, std::io::Error> { + let entries = value + .get(key) + .and_then(Value::as_array) + .ok_or_else(|| std::io::Error::other(format!("missing triple array {key}")))?; + entries + .iter() + .map(|entry| { + let parts = entry + .as_array() + .ok_or_else(|| std::io::Error::other(format!("non-array triple in {key}")))?; + parts + .iter() + .map(|part| { + part.as_str().map(ToString::to_string).ok_or_else(|| { + std::io::Error::other(format!("non-string triple part in {key}")) + }) + }) + .collect::, _>>() + }) + .collect::, _>>() + .map(sorted) +} + +#[cfg(test)] +pub(super) fn value_object(value: &Value, key: &str) -> Result { + value + .get(key) + .cloned() + .ok_or_else(|| std::io::Error::other(format!("missing object {key}"))) +} + +#[cfg(test)] +pub(super) fn node_attributes(nodes: &[GraphFactNode]) -> Value { + let mut attributes = serde_json::Map::new(); + for node in nodes { + if node.kind == "File" { + continue; + } + attributes.insert( + node.id.clone(), + node.attributes.clone().unwrap_or_else(|| json!({})), + ); + } + Value::Object(attributes) +} + +#[cfg(test)] +pub(super) fn file_exports(nodes: &[GraphFactNode]) -> Value { + let mut exports_by_file = serde_json::Map::new(); + for node in nodes { + if node.kind != "File" { + continue; + } + if let Some(exports) = node + .attributes + .as_ref() + .and_then(|attributes| attributes.get("exports")) + { + exports_by_file.insert(node.id.clone(), exports.clone()); + } + } + Value::Object(exports_by_file) +} + +#[cfg(test)] +pub(super) fn required_attributes( + nodes: &[GraphFactNode], + id: &str, +) -> Result { + let node = nodes + .iter() + .find(|node| node.id == id) + .ok_or_else(|| std::io::Error::other(format!("missing node {id}")))?; + node.attributes + .clone() + .ok_or_else(|| std::io::Error::other(format!("missing attributes for {id}"))) +} + +#[cfg(test)] +pub(super) fn required_exports( + nodes: &[GraphFactNode], + id: &str, +) -> Result, std::io::Error> { + let node = nodes + .iter() + .find(|node| node.id == id) + .ok_or_else(|| std::io::Error::other(format!("missing node {id}")))?; + node.attributes + .as_ref() + .and_then(|attributes| attributes.get("exports")) + .and_then(Value::as_array) + .cloned() + .ok_or_else(|| std::io::Error::other(format!("missing exports for {id}"))) +} + +#[cfg(test)] +pub(super) fn assert_missing_node(nodes: &[GraphFactNode], id: &str) -> Result<(), std::io::Error> { + if nodes.iter().any(|node| node.id == id) { + Err(std::io::Error::other(format!("unexpected node {id}"))) + } else { + Ok(()) + } +} + +#[cfg(test)] +pub(super) fn sorted(mut values: Vec) -> Vec { + values.sort(); + values +} diff --git a/crates/graph-core/src/extraction/tests/typescript_exports.rs b/crates/graph-core/src/extraction/tests/typescript_exports.rs new file mode 100644 index 0000000..4d21d95 --- /dev/null +++ b/crates/graph-core/src/extraction/tests/typescript_exports.rs @@ -0,0 +1,430 @@ +use super::support::*; +use crate::extraction::{extract_sources, ExtractionOptions}; +use crate::protocol::{GraphExtractionDiagnosticSeverity as Severity, GraphFactNode}; +use serde_json::{json, Value}; +use std::fs; +use tempfile::TempDir; + +type TestResult = Result<(), Box>; + +#[cfg(test)] +pub(super) struct TypeScriptExportTests; + +#[test] +fn wave1_fixture_extracts_contract_facts() -> TestResult { + let fixture_root = wave1_fixture_root()?; + let expected: Value = serde_json::from_str(&fs::read_to_string( + fixture_root.join("wave1.expected.json"), + )?)?; + + let result = extract_sources(ExtractionOptions::new(&fixture_root)); + + assert!( + !result + .diagnostics + .iter() + .any(|diagnostic| diagnostic.severity == Severity::Error), + "{:?}", + result.diagnostics + ); + assert_eq!( + sorted(result.nodes.iter().map(|node| node.id.clone()).collect()), + value_strings(&expected, "nodeIds")? + ); + assert_eq!( + sorted(result.metadata.node_kinds), + value_strings(&expected, "nodeKinds")? + ); + assert_eq!( + sorted(result.metadata.edge_kinds), + value_strings(&expected, "edgeKinds")? + ); + assert_eq!( + edge_triples(&result.edges), + value_triples(&expected, "edgeTriples")? + ); + assert_eq!( + node_attributes(&result.nodes), + value_object(&expected, "nodeAttributes")? + ); + assert_eq!( + file_exports(&result.nodes), + value_object(&expected, "fileExports")? + ); + Ok(()) +} + +#[test] +fn export_metadata_marks_supported_ts_js_declarations() -> TestResult { + let repo = repo_with_tsconfig()?; + write_export_metadata_fixture(&repo)?; + + let result = extract_sources(ExtractionOptions::new(repo.path())); + + assert!( + !result + .diagnostics + .iter() + .any(|diagnostic| diagnostic.severity == Severity::Error), + "{:?}", + result.diagnostics + ); + assert_exported_symbol_attributes(&result.nodes)?; + assert_non_exported_symbol_attributes(&result.nodes)?; + assert_index_file_export_metadata(&result.nodes)?; + Ok(()) +} + +#[test] +fn import_backed_barrel_exports_are_unsupported_reexport_metadata() -> TestResult { + let repo = repo_with_tsconfig()?; + write( + &repo, + "src/source.ts", + "export default function inner() { return 1; }\nexport const named = 1;", + )?; + write( + &repo, + "src/barrel.ts", + "import inner, { named } from './source';\nexport { named };\nexport default inner;", + )?; + + let result = extract_sources(ExtractionOptions::new(repo.path())); + let exports = required_exports(&result.nodes, "file:src/barrel.ts")?; + + for expected in [ + json!({ + "kind": "named", + "local": "named", + "exported": "named", + "source": "./source", + "imported": "named", + "supportedSymbol": false + }), + json!({ + "kind": "default", + "local": "inner", + "exported": "default", + "source": "./source", + "imported": "default", + "supportedSymbol": false + }), + ] { + assert!(exports.contains(&expected), "{expected}"); + } + assert_missing_node(&result.nodes, "variable:src/barrel.ts#named")?; + assert_missing_node(&result.nodes, "function:src/barrel.ts#inner")?; + Ok(()) +} + +#[test] +fn unresolved_local_exports_are_unsupported_file_metadata() -> TestResult { + let repo = repo_with_tsconfig()?; + write( + &repo, + "src/index.ts", + "export { missing as renamed };\nfunction internal(){return 1;}\n", + )?; + + let result = extract_sources(ExtractionOptions::new(repo.path())); + let exports = required_exports(&result.nodes, "file:src/index.ts")?; + + assert!(exports.contains(&json!({ + "kind": "named", + "local": "missing", + "exported": "renamed", + "source": null, + "supportedSymbol": false + }))); + assert_eq!( + required_attributes(&result.nodes, "function:src/index.ts#internal")?, + json!({"exported": false}) + ); + assert_missing_node(&result.nodes, "function:src/index.ts#missing")?; + Ok(()) +} + +#[test] +fn nested_local_exports_are_unsupported_file_metadata() -> TestResult { + let repo = repo_with_tsconfig()?; + write( + &repo, + "src/index.ts", + concat!( + "export { laterNested as exportedLaterNested };\n", + "function container(){ function laterNested(){ return 1; } return laterNested(); }\n", + ), + )?; + + let result = extract_sources(ExtractionOptions::new(repo.path())); + let exports = required_exports(&result.nodes, "file:src/index.ts")?; + + assert!(exports.contains(&json!({ + "kind": "named", + "local": "laterNested", + "exported": "exportedLaterNested", + "source": null, + "supportedSymbol": false + }))); + assert_eq!( + required_attributes(&result.nodes, "function:src/index.ts#laterNested")?, + json!({"exported": false}) + ); + Ok(()) +} + +fn write_export_metadata_fixture(repo: &TempDir) -> TestResult { + write_export_metadata_index_fixture(repo)?; + write_export_metadata_supporting_modules(repo)?; + write_export_metadata_default_modules(repo)?; + write_export_metadata_jsx_modules(repo)?; + Ok(()) +} + +fn write_export_metadata_index_fixture(repo: &TempDir) -> TestResult { + write( + repo, + "src/index.ts", + r#" + export interface Renderable { render(): string; } + export type Payload = { label: string }; + export class ExportedClass implements Renderable { render() { return "ok"; } } + class InternalClass {} + export function exportedFunction() { return new ExportedClass(); } + function internalFunction() { return new InternalClass(); } + export const exportedValue = 1; + const internalValue = 2; + export const exportedArrow = () => internalFunction(); + const internalArrow = () => exportedFunction(); + export function exportedWithNested() { + function nestedLocal() { return 1; } + return nestedLocal(); + } + export { laterNested as exportedLaterNested }; + function container() { + function laterNested() { return 1; } + return laterNested(); + } + const aliasTarget = 3; + export { aliasTarget as renamedAlias }; + export { externalThing as renamedExternal } from "./external"; + export { default as externalDefault } from "./defaulted"; + export * from "./barrel"; + export * as namespaceExport from "./namespace"; + const defaultValue = exportedValue; + export default defaultValue; + "#, + )?; + Ok(()) +} + +fn write_export_metadata_supporting_modules(repo: &TempDir) -> TestResult { + write(repo, "src/external.ts", "export const externalThing = 1;")?; + write( + repo, + "src/defaulted.ts", + "export default function defaulted() { return 1; }", + )?; + write(repo, "src/barrel.ts", "export const barrelValue = 1;")?; + write(repo, "src/namespace.ts", "export const namespaced = 1;")?; + Ok(()) +} + +fn write_export_metadata_default_modules(repo: &TempDir) -> TestResult { + write( + repo, + "src/default-function.ts", + "export default function defaultFunction() { return 1; }", + )?; + write( + repo, + "src/default-class.ts", + "export default class DefaultClass {}", + )?; + write( + repo, + "src/default-class-with-method.ts", + r#" + export default class DefaultClassWithMethod { + render() { return "ok"; } + } + "#, + )?; + write( + repo, + "src/default-interface.ts", + "export default interface DefaultInterface {}", + )?; + Ok(()) +} + +fn write_export_metadata_jsx_modules(repo: &TempDir) -> TestResult { + write( + repo, + "src/js-cases.js", + r#" + export function jsFunction() { return jsValue; } + export const jsValue = 1; + const jsInternal = 2; + "#, + )?; + write( + repo, + "src/view.tsx", + r#" + export function View() { return
; } + export const TsxArrow = () => ; + "#, + )?; + write( + repo, + "src/widget.jsx", + r#" + export default function Widget() { return
; } + export const WidgetHelper = () => ; + "#, + )?; + Ok(()) +} + +fn assert_exported_symbol_attributes(nodes: &[GraphFactNode]) -> TestResult { + for (id, expected) in [ + ( + "function:src/index.ts#exportedFunction", + json!({"exported": true, "exportKind": "named", "exportName": "exportedFunction"}), + ), + ( + "class:src/index.ts#ExportedClass", + json!({"exported": true, "exportKind": "named", "exportName": "ExportedClass"}), + ), + ( + "type:src/index.ts#Renderable", + json!({"exported": true, "exportKind": "named", "exportName": "Renderable"}), + ), + ( + "type:src/index.ts#Payload", + json!({"exported": true, "exportKind": "named", "exportName": "Payload"}), + ), + ( + "variable:src/index.ts#exportedValue", + json!({"exported": true, "exportKind": "named", "exportName": "exportedValue"}), + ), + ( + "function:src/index.ts#exportedArrow", + json!({"exported": true, "exportKind": "named", "exportName": "exportedArrow"}), + ), + ( + "function:src/index.ts#exportedWithNested", + json!({"exported": true, "exportKind": "named", "exportName": "exportedWithNested"}), + ), + ( + "variable:src/index.ts#aliasTarget", + json!({"exported": true, "exportKind": "named", "exportName": "renamedAlias"}), + ), + ( + "function:src/default-function.ts#defaultFunction", + json!({"exported": true, "exportKind": "default", "exportName": "default"}), + ), + ( + "class:src/default-class.ts#DefaultClass", + json!({"exported": true, "exportKind": "default", "exportName": "default"}), + ), + ( + "class:src/default-class-with-method.ts#DefaultClassWithMethod", + json!({"exported": true, "exportKind": "default", "exportName": "default"}), + ), + ( + "type:src/default-interface.ts#DefaultInterface", + json!({"exported": true, "exportKind": "default", "exportName": "default"}), + ), + ( + "function:src/js-cases.js#jsFunction", + json!({"exported": true, "exportKind": "named", "exportName": "jsFunction"}), + ), + ( + "variable:src/js-cases.js#jsValue", + json!({"exported": true, "exportKind": "named", "exportName": "jsValue"}), + ), + ( + "function:src/view.tsx#View", + json!({"exported": true, "exportKind": "named", "exportName": "View"}), + ), + ( + "function:src/view.tsx#TsxArrow", + json!({"exported": true, "exportKind": "named", "exportName": "TsxArrow"}), + ), + ( + "function:src/widget.jsx#Widget", + json!({"exported": true, "exportKind": "default", "exportName": "default"}), + ), + ( + "function:src/widget.jsx#WidgetHelper", + json!({"exported": true, "exportKind": "named", "exportName": "WidgetHelper"}), + ), + ] { + assert_eq!(required_attributes(nodes, id)?, expected, "{id}"); + } + Ok(()) +} + +fn assert_non_exported_symbol_attributes(nodes: &[GraphFactNode]) -> TestResult { + for id in [ + "class:src/index.ts#InternalClass", + "function:src/index.ts#internalFunction", + "variable:src/index.ts#internalValue", + "function:src/index.ts#internalArrow", + "function:src/index.ts#container", + "function:src/index.ts#nestedLocal", + "function:src/index.ts#laterNested", + "variable:src/js-cases.js#jsInternal", + ] { + assert_eq!( + required_attributes(nodes, id)?, + json!({"exported": false}), + "{id}" + ); + } + assert_missing_node(nodes, "function:src/default-class-with-method.ts#default")?; + Ok(()) +} + +fn assert_index_file_export_metadata(nodes: &[GraphFactNode]) -> TestResult { + let index_exports = required_exports(nodes, "file:src/index.ts")?; + for expected in [ + json!({ + "kind": "named", + "local": "externalThing", + "exported": "renamedExternal", + "source": "./external", + "imported": "externalThing", + "supportedSymbol": true + }), + json!({ + "kind": "named", + "local": "default", + "exported": "externalDefault", + "source": "./defaulted", + "imported": "default", + "supportedSymbol": true + }), + json!({"kind": "all", "exported": "*", "source": "./barrel", "supportedSymbol": false}), + json!({"kind": "namespace", "exported": "namespaceExport", "source": "./namespace", "supportedSymbol": false}), + json!({ + "kind": "default", + "local": "defaultValue", + "exported": "default", + "source": null, + "supportedSymbol": true + }), + json!({ + "kind": "named", + "local": "laterNested", + "exported": "exportedLaterNested", + "source": null, + "supportedSymbol": false + }), + ] { + assert!(index_exports.contains(&expected), "{expected}"); + } + Ok(()) +} diff --git a/crates/graph-core/src/extraction/tests/typescript_resolution.rs b/crates/graph-core/src/extraction/tests/typescript_resolution.rs new file mode 100644 index 0000000..1f5f3cd --- /dev/null +++ b/crates/graph-core/src/extraction/tests/typescript_resolution.rs @@ -0,0 +1,268 @@ +use super::support::*; +use crate::extraction::{extract_sources, ExtractionOptions}; +use crate::protocol::GraphExtractionDiagnosticSeverity as Severity; +use serde_json::json; + +type TestResult = Result<(), Box>; + +#[cfg(test)] +pub(super) struct TypeScriptResolutionTests; + +#[test] +fn tsconfig_path_aliases_resolve_to_repo_relative_files() -> TestResult { + let result = extract_sources(ExtractionOptions::new(wave1_fixture_root()?)); + let triples = edge_triples(&result.edges); + + assert!(triples.contains(&vec![ + "IMPORTS_FROM".to_string(), + "file:src/__tests__/greeting.test.ts".to_string(), + "file:src/math.js".to_string() + ])); + assert!(triples.contains(&vec![ + "IMPORTS_FROM".to_string(), + "file:src/legacy-widget.jsx".to_string(), + "file:src/components/GreetingCard.tsx".to_string() + ])); + Ok(()) +} + +#[test] +fn script_test_imports_emit_file_level_tested_by_evidence() -> TestResult { + let repo = temp_repo()?; + write( + &repo, + "tsconfig.json", + r#"{"compilerOptions":{"baseUrl":".","paths":{"@example/pkg":["src/index.ts"]}}}"#, + )?; + write( + &repo, + "src/index.ts", + "export { publicValue } from './value.js';", + )?; + write(&repo, "src/value.ts", "export const publicValue = 1;")?; + write( + &repo, + "tests/package-contract.test.ts", + "import { publicValue } from '@example/pkg'; test('public value', () => publicValue);", + )?; + write( + &repo, + "src/consumer.ts", + "import { publicValue } from '@example/pkg'; export const consumed = publicValue;", + )?; + + let result = extract_sources(ExtractionOptions::new(repo.path())); + let triples = edge_triples(&result.edges); + + assert!(triples.contains(&vec![ + "TESTED_BY".to_string(), + "file:src/index.ts".to_string(), + "file:tests/package-contract.test.ts".to_string() + ])); + assert!(!triples.contains(&vec![ + "TESTED_BY".to_string(), + "file:src/index.ts".to_string(), + "file:src/consumer.ts".to_string() + ])); + Ok(()) +} + +#[test] +fn unimported_cross_file_symbols_do_not_create_edges() -> TestResult { + let repo = temp_repo()?; + write( + &repo, + "tsconfig.json", + r#"{"compilerOptions":{"baseUrl":"."}}"#, + )?; + write( + &repo, + "src/a.ts", + r#" + export function caller() { return target(); } + export class Child extends Base implements Shape {} + "#, + )?; + write( + &repo, + "src/b.ts", + r#" + export function target() { return 1; } + export class Base {} + export interface Shape {} + "#, + )?; + write( + &repo, + "src/c.ts", + r#" + export function localCaller() { return sameName(); } + export function sameName() { return 1; } + "#, + )?; + write( + &repo, + "src/d.ts", + "export function sameName() { return 2; }", + )?; + + let result = extract_sources(ExtractionOptions::new(repo.path())); + let triples = edge_triples(&result.edges); + + assert!(!result + .diagnostics + .iter() + .any(|diagnostic| diagnostic.severity == Severity::Error)); + assert!(!triples.contains(&vec![ + "CALLS".to_string(), + "function:src/a.ts#caller".to_string(), + "function:src/b.ts#target".to_string() + ])); + assert!(!triples.contains(&vec![ + "INHERITS".to_string(), + "class:src/a.ts#Child".to_string(), + "class:src/b.ts#Base".to_string() + ])); + assert!(!triples.contains(&vec![ + "IMPLEMENTS".to_string(), + "class:src/a.ts#Child".to_string(), + "type:src/b.ts#Shape".to_string() + ])); + assert!(triples.contains(&vec![ + "CALLS".to_string(), + "function:src/c.ts#localCaller".to_string(), + "function:src/c.ts#sameName".to_string() + ])); + Ok(()) +} + +#[test] +fn default_imports_resolve_to_default_exported_symbols() -> TestResult { + let repo = repo_with_tsconfig()?; + write( + &repo, + "src/default-function.ts", + "export default function usedDefault() { return 1; }", + )?; + write( + &repo, + "src/default-value.ts", + "const usedValue = () => 1; export default usedValue;", + )?; + write( + &repo, + "src/index.ts", + r#" + import usedDefault from "./default-function"; + import usedValue from "./default-value"; + export function run() { + return usedDefault() + usedValue(); + } + "#, + )?; + + let result = extract_sources(ExtractionOptions::new(repo.path())); + let triples = edge_triples(&result.edges); + + assert!(!result + .diagnostics + .iter() + .any(|diagnostic| diagnostic.severity == Severity::Error)); + assert!(triples.contains(&vec![ + "CALLS".to_string(), + "function:src/index.ts#run".to_string(), + "function:src/default-function.ts#usedDefault".to_string() + ])); + assert!(triples.contains(&vec![ + "CALLS".to_string(), + "function:src/index.ts#run".to_string(), + "function:src/default-value.ts#usedValue".to_string() + ])); + Ok(()) +} + +#[test] +fn named_export_alias_imports_resolve_to_local_exported_symbols() -> TestResult { + let repo = repo_with_tsconfig()?; + write( + &repo, + "src/dep.ts", + "function localName() { return 1; }\nexport { localName as publicName };", + )?; + write( + &repo, + "src/index.ts", + r#" + import { publicName } from "./dep"; + export function run() { + return publicName(); + } + "#, + )?; + + let result = extract_sources(ExtractionOptions::new(repo.path())); + let triples = edge_triples(&result.edges); + + assert!(!result + .diagnostics + .iter() + .any(|diagnostic| diagnostic.severity == Severity::Error)); + assert!(triples.contains(&vec![ + "CALLS".to_string(), + "function:src/index.ts#run".to_string(), + "function:src/dep.ts#localName".to_string() + ])); + Ok(()) +} + +#[test] +fn source_re_export_alias_imports_resolve_to_source_exported_symbols() -> TestResult { + let repo = repo_with_tsconfig()?; + write( + &repo, + "src/source.ts", + "export function add() { return 1; }", + )?; + write( + &repo, + "src/barrel.ts", + "export { add as addFromBarrel } from './source';", + )?; + write( + &repo, + "src/index.ts", + r#" + import { addFromBarrel } from "./barrel"; + export function run() { + return addFromBarrel(); + } + "#, + )?; + + let result = extract_sources(ExtractionOptions::new(repo.path())); + let triples = edge_triples(&result.edges); + let exports = required_exports(&result.nodes, "file:src/barrel.ts")?; + + assert!( + !result + .diagnostics + .iter() + .any(|diagnostic| diagnostic.severity == Severity::Error), + "{:?}", + result.diagnostics + ); + assert!(triples.contains(&vec![ + "CALLS".to_string(), + "function:src/index.ts#run".to_string(), + "function:src/source.ts#add".to_string() + ])); + assert!(exports.contains(&json!({ + "kind": "named", + "local": "add", + "exported": "addFromBarrel", + "source": "./source", + "imported": "add", + "supportedSymbol": true + }))); + Ok(()) +} diff --git a/crates/graph-core/src/extraction/tsconfig.rs b/crates/graph-core/src/extraction/tsconfig.rs index b17a1e0..49a915f 100644 --- a/crates/graph-core/src/extraction/tsconfig.rs +++ b/crates/graph-core/src/extraction/tsconfig.rs @@ -1,8 +1,9 @@ use super::diagnostics::{error, warning}; +use super::{SourcePathOps, SourcePaths}; use crate::protocol::{GraphExtractionDiagnostic, GraphExtractionDiagnosticCategory}; use serde_json::{Map, Value}; use std::collections::BTreeSet; -use std::path::{Component, Path, PathBuf}; +use std::path::{Path, PathBuf}; #[derive(Debug, Clone)] pub struct TsConfig { @@ -324,7 +325,7 @@ fn resolve_candidate( specifier: &str, from_path: &str, ) -> ImportResolution { - let normalized = match normalize_relative(&candidate) { + let normalized = match SourcePaths::normalize_relative_path(&candidate) { Ok(path) => path, Err(()) => { return ImportResolution { @@ -401,23 +402,3 @@ fn replace_extension(path: &str, extension: &str) -> String { .to_string_lossy() .replace('\\', "/") } - -fn normalize_relative(path: &Path) -> Result { - let mut parts = Vec::new(); - for component in path.components() { - match component { - Component::CurDir => {} - Component::Normal(part) => parts.push(part.to_string_lossy().to_string()), - Component::ParentDir => { - if parts.pop().is_none() { - return Err(()); - } - } - Component::RootDir | Component::Prefix(_) => return Err(()), - } - } - if parts.is_empty() { - return Err(()); - } - Ok(parts.join("/")) -} diff --git a/crates/graph-core/src/lib.rs b/crates/graph-core/src/lib.rs index d32067d..3d5dd86 100644 --- a/crates/graph-core/src/lib.rs +++ b/crates/graph-core/src/lib.rs @@ -9,5 +9,8 @@ pub mod search; pub mod store; pub mod watch; +#[cfg(test)] +mod test_support; + pub const GRAPH_PROVIDER_NAME: &str = "opcore-graph"; pub const GRAPH_SCHEMA_VERSION: u32 = 1; diff --git a/crates/graph-core/src/pipeline.rs b/crates/graph-core/src/pipeline.rs index e3b0e41..de85879 100644 --- a/crates/graph-core/src/pipeline.rs +++ b/crates/graph-core/src/pipeline.rs @@ -17,6 +17,10 @@ use std::time::Instant; use time::format_description::well_known::Rfc3339; use time::OffsetDateTime; +mod delta; + +use delta::{Delta, SourceDeltaOps}; + #[derive(Debug, Clone)] pub struct GraphPipelineOptions { pub repo_root: PathBuf, @@ -221,7 +225,7 @@ fn incremental_file_facts( diagnostics: &mut Vec, ) -> Result { let store = GraphStore::open(StorePaths::for_repo_root(&context.options.repo_root))?; - let delta = source_delta(&store.file_hashes()?, context.current_hashes); + let delta = Delta::source_delta(&store.file_hashes()?, context.current_hashes); let changed_sources = sources_for_paths(&context.discovery.sources, &delta.changed_files); let changed_extractable_sources = graph_extractable_sources(&changed_sources); let parsed = timed("extraction", || { @@ -384,44 +388,6 @@ fn with_file_count( timing } -#[derive(Debug)] -struct SourceDelta { - changed_files: Vec, - deleted_files: Vec, -} - -fn source_delta( - stored: &[crate::extraction::SourceFileHash], - current: &[crate::extraction::SourceFileHash], -) -> SourceDelta { - let stored_by_path = stored - .iter() - .map(|hash| (hash.relative_path.as_str(), hash.sha256.as_str())) - .collect::>(); - let current_by_path = current - .iter() - .map(|hash| (hash.relative_path.as_str(), hash.sha256.as_str())) - .collect::>(); - let mut changed_files = current_by_path - .iter() - .filter_map(|(path, sha)| match stored_by_path.get(path) { - Some(stored_sha) if stored_sha == sha => None, - _ => Some((*path).to_string()), - }) - .collect::>(); - let mut deleted_files = stored_by_path - .keys() - .filter(|path| !current_by_path.contains_key(**path)) - .map(|path| (*path).to_string()) - .collect::>(); - changed_files.sort(); - deleted_files.sort(); - SourceDelta { - changed_files, - deleted_files, - } -} - fn sources_for_paths(sources: &[DiscoveredSource], paths: &[String]) -> Vec { let wanted = paths.iter().map(String::as_str).collect::>(); sources diff --git a/crates/graph-core/src/pipeline/delta.rs b/crates/graph-core/src/pipeline/delta.rs new file mode 100644 index 0000000..b32615c --- /dev/null +++ b/crates/graph-core/src/pipeline/delta.rs @@ -0,0 +1,37 @@ +use crate::extraction::{SourceFileHash, SourcePathOps, SourcePaths}; + +pub(super) struct SourceDelta { + pub(super) changed_files: Vec, + pub(super) deleted_files: Vec, +} + +pub(super) struct Delta; + +pub(super) trait SourceDeltaOps { + fn source_delta(stored: &[SourceFileHash], current: &[SourceFileHash]) -> SourceDelta; +} + +impl SourceDeltaOps for Delta { + fn source_delta(stored: &[SourceFileHash], current: &[SourceFileHash]) -> SourceDelta { + let stored_by_path = SourcePaths::source_hashes_by_path(stored); + let current_by_path = SourcePaths::source_hashes_by_path(current); + let mut changed_files = current_by_path + .iter() + .filter_map(|(path, sha)| match stored_by_path.get(path) { + Some(stored_sha) if stored_sha == sha => None, + _ => Some((*path).to_string()), + }) + .collect::>(); + let mut deleted_files = stored_by_path + .keys() + .filter(|path| !current_by_path.contains_key(**path)) + .map(|path| (*path).to_string()) + .collect::>(); + changed_files.sort(); + deleted_files.sort(); + SourceDelta { + changed_files, + deleted_files, + } + } +} diff --git a/crates/graph-core/src/query/tests.rs b/crates/graph-core/src/query/tests.rs index 18160a4..ce95a20 100644 --- a/crates/graph-core/src/query/tests.rs +++ b/crates/graph-core/src/query/tests.rs @@ -3,6 +3,10 @@ use crate::protocol::{GraphFactQueryKind, GraphFreshness, GraphProviderMode, Rep use serde_json::json; use std::collections::BTreeSet; +mod fixtures; + +use fixtures::*; + #[test] fn impact_traverses_reverse_file_dependencies_and_tests() { let snapshot = fixture_snapshot(); @@ -444,339 +448,3 @@ fn review_context_impacts_renamed_source_paths() { .tests .contains(&"src/__tests__/greeting.test.ts".to_string())); } - -fn fixture_snapshot() -> StoreQueryOutput { - StoreQueryOutput { - metadata: GraphSnapshotMetadata { - schema_version: 1, - provider: "opcore-graph".to_string(), - repo: repo(), - generated_at: "2026-06-04T00:00:00.000Z".to_string(), - freshness: GraphFreshness { - generated_at: "2026-06-04T00:00:00.000Z".to_string(), - age_ms: 0, - max_age_ms: None, - stale: false, - reason: None, - }, - node_kinds: vec![ - "File".to_string(), - "Function".to_string(), - "Test".to_string(), - ], - edge_kinds: vec![ - "CONTAINS".to_string(), - "DEPENDS_ON".to_string(), - "TESTED_BY".to_string(), - ], - }, - nodes: vec![ - node("file:src/models.ts", "File", Some("src/models.ts")), - node( - "file:src/components/GreetingCard.tsx", - "File", - Some("src/components/GreetingCard.tsx"), - ), - node( - "file:src/__tests__/greeting.test.ts", - "File", - Some("src/__tests__/greeting.test.ts"), - ), - node("function:src/models.ts#formatGreeting", "Function", None), - node( - "test:src/__tests__/greeting.test.ts#renders greeting cards", - "Test", - None, - ), - ], - edges: vec![ - edge( - "CONTAINS", - "file:src/models.ts", - "function:src/models.ts#formatGreeting", - ), - edge( - "DEPENDS_ON", - "file:src/components/GreetingCard.tsx", - "file:src/models.ts", - ), - edge( - "DEPENDS_ON", - "file:src/__tests__/greeting.test.ts", - "file:src/components/GreetingCard.tsx", - ), - edge( - "TESTED_BY", - "function:src/models.ts#formatGreeting", - "test:src/__tests__/greeting.test.ts#renders greeting cards", - ), - ], - diagnostics: Vec::new(), - } -} - -fn inheritance_snapshot() -> StoreQueryOutput { - StoreQueryOutput { - metadata: GraphSnapshotMetadata { - schema_version: 1, - provider: "opcore-graph".to_string(), - repo: repo(), - generated_at: "2026-06-04T00:00:00.000Z".to_string(), - freshness: GraphFreshness { - generated_at: "2026-06-04T00:00:00.000Z".to_string(), - age_ms: 0, - max_age_ms: None, - stale: false, - reason: None, - }, - node_kinds: vec!["Class".to_string()], - edge_kinds: vec!["INHERITS".to_string()], - }, - nodes: vec![ - node( - "class:src/models.ts#BaseModel", - "Class", - Some("src/models.ts"), - ), - node( - "class:src/models.ts#DirectModel", - "Class", - Some("src/models.ts"), - ), - node( - "class:src/models.ts#IndirectModel", - "Class", - Some("src/models.ts"), - ), - node( - "class:src/other.ts#UnrelatedModel", - "Class", - Some("src/other.ts"), - ), - ], - edges: vec![ - edge( - "INHERITS", - "class:src/models.ts#DirectModel", - "class:src/models.ts#BaseModel", - ), - edge( - "INHERITS", - "class:src/models.ts#IndirectModel", - "class:src/models.ts#DirectModel", - ), - ], - diagnostics: Vec::new(), - } -} - -fn cycle_snapshot() -> StoreQueryOutput { - StoreQueryOutput { - metadata: GraphSnapshotMetadata { - schema_version: 1, - provider: "opcore-graph".to_string(), - repo: repo(), - generated_at: "2026-06-04T00:00:00.000Z".to_string(), - freshness: GraphFreshness { - generated_at: "2026-06-04T00:00:00.000Z".to_string(), - age_ms: 0, - max_age_ms: None, - stale: false, - reason: None, - }, - node_kinds: vec!["File".to_string()], - edge_kinds: vec!["DEPENDS_ON".to_string()], - }, - nodes: vec![ - node("file:src/c.ts", "File", Some("src/c.ts")), - node("file:src/a.ts", "File", Some("src/a.ts")), - node("file:src/b.ts", "File", Some("src/b.ts")), - ], - edges: vec![ - edge("DEPENDS_ON", "file:src/c.ts", "file:src/a.ts"), - edge("DEPENDS_ON", "file:src/b.ts", "file:src/a.ts"), - edge("DEPENDS_ON", "file:src/a.ts", "file:src/b.ts"), - ], - diagnostics: Vec::new(), - } -} - -fn python_test_snapshot() -> StoreQueryOutput { - StoreQueryOutput { - metadata: python_test_metadata(), - nodes: python_test_nodes(), - edges: python_test_edges(), - diagnostics: Vec::new(), - } -} - -fn python_test_metadata() -> GraphSnapshotMetadata { - GraphSnapshotMetadata { - schema_version: 1, - provider: "opcore-graph".to_string(), - repo: repo(), - generated_at: "2026-06-04T00:00:00.000Z".to_string(), - freshness: GraphFreshness { - generated_at: "2026-06-04T00:00:00.000Z".to_string(), - age_ms: 0, - max_age_ms: None, - stale: false, - reason: None, - }, - node_kinds: vec![ - "File".to_string(), - "Module".to_string(), - "Class".to_string(), - "Function".to_string(), - ], - edge_kinds: vec![ - "CONTAINS".to_string(), - "IMPORTS_FROM".to_string(), - "TESTED_BY".to_string(), - ], - } -} - -fn python_test_nodes() -> Vec { - vec![ - node("file:src/pkg/models.py", "File", Some("src/pkg/models.py")), - node( - "module:src/pkg/models.py#src.pkg.models", - "Module", - Some("src/pkg/models.py"), - ), - node( - "class:src/pkg/models.py#PublicModel", - "Class", - Some("src/pkg/models.py"), - ), - node( - "function:src/pkg/models.py#make_model", - "Function", - Some("src/pkg/models.py"), - ), - node( - "file:tests/test_models.py", - "File", - Some("tests/test_models.py"), - ), - node( - "module:tests/test_models.py#tests.test_models", - "Module", - Some("tests/test_models.py"), - ), - node_with_attributes( - "function:tests/test_models.py#test_make_model", - "Function", - Some("tests/test_models.py"), - json!({"isTest": true}), - ), - ] -} - -fn python_test_edges() -> Vec { - vec![ - edge( - "CONTAINS", - "file:src/pkg/models.py", - "module:src/pkg/models.py#src.pkg.models", - ), - edge( - "CONTAINS", - "module:src/pkg/models.py#src.pkg.models", - "class:src/pkg/models.py#PublicModel", - ), - edge( - "CONTAINS", - "module:src/pkg/models.py#src.pkg.models", - "function:src/pkg/models.py#make_model", - ), - edge( - "CONTAINS", - "file:tests/test_models.py", - "module:tests/test_models.py#tests.test_models", - ), - edge( - "CONTAINS", - "module:tests/test_models.py#tests.test_models", - "function:tests/test_models.py#test_make_model", - ), - edge( - "IMPORTS_FROM", - "file:tests/test_models.py", - "file:src/pkg/models.py", - ), - edge( - "TESTED_BY", - "class:src/pkg/models.py#PublicModel", - "function:tests/test_models.py#test_make_model", - ), - edge( - "TESTED_BY", - "function:src/pkg/models.py#make_model", - "function:tests/test_models.py#test_make_model", - ), - ] -} - -fn repo() -> RepoIdentity { - RepoIdentity { - repo_id: Some("fixture".to_string()), - repo_root: None, - remote_url: None, - commit_sha: None, - } -} - -fn node(id: &str, kind: &str, path: Option<&str>) -> GraphFactNode { - GraphFactNode { - id: id.to_string(), - kind: kind.to_string(), - path: path.map(str::to_string), - name: None, - attributes: None, - } -} - -fn node_with_attributes( - id: &str, - kind: &str, - path: Option<&str>, - attributes: serde_json::Value, -) -> GraphFactNode { - GraphFactNode { - attributes: Some(attributes), - ..node(id, kind, path) - } -} - -fn edge(kind: &str, from: &str, to: &str) -> GraphFactEdge { - GraphFactEdge { - id: Some(format!("{kind}:{from}->{to}")), - kind: kind.to_string(), - from: from.to_string(), - to: to.to_string(), - attributes: None, - } -} - -fn hash(path: &str, sha: &str) -> SourceFileHash { - SourceFileHash { - relative_path: path.to_string(), - absolute_path: format!("/repo/{path}"), - language: "typescript".to_string(), - sha256: sha.to_string(), - } -} - -fn assert_limited_without_dangling(nodes: &[GraphFactNode], edges: &[GraphFactEdge], limit: usize) { - assert!(nodes.len() <= limit); - let node_ids = nodes - .iter() - .map(|node| node.id.as_str()) - .collect::>(); - for edge in edges { - assert!(node_ids.contains(edge.from.as_str())); - assert!(node_ids.contains(edge.to.as_str())); - } -} diff --git a/crates/graph-core/src/query/tests/fixtures.rs b/crates/graph-core/src/query/tests/fixtures.rs new file mode 100644 index 0000000..c62db6d --- /dev/null +++ b/crates/graph-core/src/query/tests/fixtures.rs @@ -0,0 +1,349 @@ +use super::*; + +#[cfg(test)] +pub(super) fn fixture_snapshot() -> StoreQueryOutput { + StoreQueryOutput { + metadata: GraphSnapshotMetadata { + schema_version: 1, + provider: "opcore-graph".to_string(), + repo: repo(), + generated_at: "2026-06-04T00:00:00.000Z".to_string(), + freshness: GraphFreshness { + generated_at: "2026-06-04T00:00:00.000Z".to_string(), + age_ms: 0, + max_age_ms: None, + stale: false, + reason: None, + }, + node_kinds: vec![ + "File".to_string(), + "Function".to_string(), + "Test".to_string(), + ], + edge_kinds: vec![ + "CONTAINS".to_string(), + "DEPENDS_ON".to_string(), + "TESTED_BY".to_string(), + ], + }, + nodes: vec![ + node("file:src/models.ts", "File", Some("src/models.ts")), + node( + "file:src/components/GreetingCard.tsx", + "File", + Some("src/components/GreetingCard.tsx"), + ), + node( + "file:src/__tests__/greeting.test.ts", + "File", + Some("src/__tests__/greeting.test.ts"), + ), + node("function:src/models.ts#formatGreeting", "Function", None), + node( + "test:src/__tests__/greeting.test.ts#renders greeting cards", + "Test", + None, + ), + ], + edges: vec![ + edge( + "CONTAINS", + "file:src/models.ts", + "function:src/models.ts#formatGreeting", + ), + edge( + "DEPENDS_ON", + "file:src/components/GreetingCard.tsx", + "file:src/models.ts", + ), + edge( + "DEPENDS_ON", + "file:src/__tests__/greeting.test.ts", + "file:src/components/GreetingCard.tsx", + ), + edge( + "TESTED_BY", + "function:src/models.ts#formatGreeting", + "test:src/__tests__/greeting.test.ts#renders greeting cards", + ), + ], + diagnostics: Vec::new(), + } +} + +#[cfg(test)] +pub(super) fn inheritance_snapshot() -> StoreQueryOutput { + StoreQueryOutput { + metadata: GraphSnapshotMetadata { + schema_version: 1, + provider: "opcore-graph".to_string(), + repo: repo(), + generated_at: "2026-06-04T00:00:00.000Z".to_string(), + freshness: GraphFreshness { + generated_at: "2026-06-04T00:00:00.000Z".to_string(), + age_ms: 0, + max_age_ms: None, + stale: false, + reason: None, + }, + node_kinds: vec!["Class".to_string()], + edge_kinds: vec!["INHERITS".to_string()], + }, + nodes: vec![ + node( + "class:src/models.ts#BaseModel", + "Class", + Some("src/models.ts"), + ), + node( + "class:src/models.ts#DirectModel", + "Class", + Some("src/models.ts"), + ), + node( + "class:src/models.ts#IndirectModel", + "Class", + Some("src/models.ts"), + ), + node( + "class:src/other.ts#UnrelatedModel", + "Class", + Some("src/other.ts"), + ), + ], + edges: vec![ + edge( + "INHERITS", + "class:src/models.ts#DirectModel", + "class:src/models.ts#BaseModel", + ), + edge( + "INHERITS", + "class:src/models.ts#IndirectModel", + "class:src/models.ts#DirectModel", + ), + ], + diagnostics: Vec::new(), + } +} + +#[cfg(test)] +pub(super) fn cycle_snapshot() -> StoreQueryOutput { + StoreQueryOutput { + metadata: GraphSnapshotMetadata { + schema_version: 1, + provider: "opcore-graph".to_string(), + repo: repo(), + generated_at: "2026-06-04T00:00:00.000Z".to_string(), + freshness: GraphFreshness { + generated_at: "2026-06-04T00:00:00.000Z".to_string(), + age_ms: 0, + max_age_ms: None, + stale: false, + reason: None, + }, + node_kinds: vec!["File".to_string()], + edge_kinds: vec!["DEPENDS_ON".to_string()], + }, + nodes: vec![ + node("file:src/c.ts", "File", Some("src/c.ts")), + node("file:src/a.ts", "File", Some("src/a.ts")), + node("file:src/b.ts", "File", Some("src/b.ts")), + ], + edges: vec![ + edge("DEPENDS_ON", "file:src/c.ts", "file:src/a.ts"), + edge("DEPENDS_ON", "file:src/b.ts", "file:src/a.ts"), + edge("DEPENDS_ON", "file:src/a.ts", "file:src/b.ts"), + ], + diagnostics: Vec::new(), + } +} + +#[cfg(test)] +pub(super) fn python_test_snapshot() -> StoreQueryOutput { + StoreQueryOutput { + metadata: python_test_metadata(), + nodes: python_test_nodes(), + edges: python_test_edges(), + diagnostics: Vec::new(), + } +} + +fn python_test_metadata() -> GraphSnapshotMetadata { + GraphSnapshotMetadata { + schema_version: 1, + provider: "opcore-graph".to_string(), + repo: repo(), + generated_at: "2026-06-04T00:00:00.000Z".to_string(), + freshness: GraphFreshness { + generated_at: "2026-06-04T00:00:00.000Z".to_string(), + age_ms: 0, + max_age_ms: None, + stale: false, + reason: None, + }, + node_kinds: vec![ + "File".to_string(), + "Module".to_string(), + "Class".to_string(), + "Function".to_string(), + ], + edge_kinds: vec![ + "CONTAINS".to_string(), + "IMPORTS_FROM".to_string(), + "TESTED_BY".to_string(), + ], + } +} + +fn python_test_nodes() -> Vec { + vec![ + node("file:src/pkg/models.py", "File", Some("src/pkg/models.py")), + node( + "module:src/pkg/models.py#src.pkg.models", + "Module", + Some("src/pkg/models.py"), + ), + node( + "class:src/pkg/models.py#PublicModel", + "Class", + Some("src/pkg/models.py"), + ), + node( + "function:src/pkg/models.py#make_model", + "Function", + Some("src/pkg/models.py"), + ), + node( + "file:tests/test_models.py", + "File", + Some("tests/test_models.py"), + ), + node( + "module:tests/test_models.py#tests.test_models", + "Module", + Some("tests/test_models.py"), + ), + node_with_attributes( + "function:tests/test_models.py#test_make_model", + "Function", + Some("tests/test_models.py"), + json!({"isTest": true}), + ), + ] +} + +fn python_test_edges() -> Vec { + vec![ + edge( + "CONTAINS", + "file:src/pkg/models.py", + "module:src/pkg/models.py#src.pkg.models", + ), + edge( + "CONTAINS", + "module:src/pkg/models.py#src.pkg.models", + "class:src/pkg/models.py#PublicModel", + ), + edge( + "CONTAINS", + "module:src/pkg/models.py#src.pkg.models", + "function:src/pkg/models.py#make_model", + ), + edge( + "CONTAINS", + "file:tests/test_models.py", + "module:tests/test_models.py#tests.test_models", + ), + edge( + "CONTAINS", + "module:tests/test_models.py#tests.test_models", + "function:tests/test_models.py#test_make_model", + ), + edge( + "IMPORTS_FROM", + "file:tests/test_models.py", + "file:src/pkg/models.py", + ), + edge( + "TESTED_BY", + "class:src/pkg/models.py#PublicModel", + "function:tests/test_models.py#test_make_model", + ), + edge( + "TESTED_BY", + "function:src/pkg/models.py#make_model", + "function:tests/test_models.py#test_make_model", + ), + ] +} + +#[cfg(test)] +pub(super) fn repo() -> RepoIdentity { + RepoIdentity { + repo_id: Some("fixture".to_string()), + repo_root: None, + remote_url: None, + commit_sha: None, + } +} + +fn node(id: &str, kind: &str, path: Option<&str>) -> GraphFactNode { + GraphFactNode { + id: id.to_string(), + kind: kind.to_string(), + path: path.map(str::to_string), + name: None, + attributes: None, + } +} + +fn node_with_attributes( + id: &str, + kind: &str, + path: Option<&str>, + attributes: serde_json::Value, +) -> GraphFactNode { + GraphFactNode { + attributes: Some(attributes), + ..node(id, kind, path) + } +} + +#[cfg(test)] +pub(super) fn edge(kind: &str, from: &str, to: &str) -> GraphFactEdge { + GraphFactEdge { + id: Some(format!("{kind}:{from}->{to}")), + kind: kind.to_string(), + from: from.to_string(), + to: to.to_string(), + attributes: None, + } +} + +#[cfg(test)] +pub(super) fn hash(path: &str, sha: &str) -> SourceFileHash { + SourceFileHash { + relative_path: path.to_string(), + absolute_path: format!("/repo/{path}"), + language: "typescript".to_string(), + sha256: sha.to_string(), + } +} + +#[cfg(test)] +pub(super) fn assert_limited_without_dangling( + nodes: &[GraphFactNode], + edges: &[GraphFactEdge], + limit: usize, +) { + assert!(nodes.len() <= limit); + let node_ids = nodes + .iter() + .map(|node| node.id.as_str()) + .collect::>(); + for edge in edges { + assert!(node_ids.contains(edge.from.as_str())); + assert!(node_ids.contains(edge.to.as_str())); + } +} diff --git a/crates/graph-core/src/search.rs b/crates/graph-core/src/search.rs index c9a7f2f..2d01ea9 100644 --- a/crates/graph-core/src/search.rs +++ b/crates/graph-core/src/search.rs @@ -2,7 +2,10 @@ use crate::protocol::{ GraphFactEdge, GraphFactNode, GraphSearchMode, GraphSearchRequest, GraphSearchResultEntry, GraphSearchSummary, }; -use crate::store::{StoreError, StoreResult}; +use crate::store::{ + read::{CollectRows, Rows}, + StoreError, StoreResult, +}; use rusqlite::{params, Connection, OptionalExtension}; mod dependents; mod scoring; @@ -348,7 +351,7 @@ fn limit_to_usize(limit: u32) -> usize { fn indexed_node_kinds(connection: &Connection) -> StoreResult> { let mut statement = connection.prepare("select distinct kind from nodes_fts order by kind")?; let rows = statement.query_map([], |row| row.get::<_, String>(0))?; - collect_rows(rows) + Rows::collect(rows) } fn project_node(node: &GraphFactNode) -> SearchIndexRow { @@ -426,17 +429,6 @@ fn sorted_strings(values: Vec) -> Vec { .collect() } -fn collect_rows(rows: rusqlite::MappedRows<'_, F>) -> StoreResult> -where - F: FnMut(&rusqlite::Row<'_>) -> rusqlite::Result, -{ - let mut values = Vec::new(); - for row in rows { - values.push(row?); - } - Ok(values) -} - pub fn boundary_name() -> &'static str { "GraphProvider FTS5 search boundary" } diff --git a/crates/graph-core/src/search/scoring.rs b/crates/graph-core/src/search/scoring.rs index 5c7a518..6bc2512 100644 --- a/crates/graph-core/src/search/scoring.rs +++ b/crates/graph-core/src/search/scoring.rs @@ -1,4 +1,4 @@ -use super::{collect_rows, limit_to_usize, searchable_text}; +use super::{limit_to_usize, searchable_text, CollectRows, Rows}; use crate::store::StoreResult; use rusqlite::{params, Connection}; use std::collections::BTreeSet; @@ -30,7 +30,7 @@ pub(super) fn search_rows( "#, )?; let rows = statement.query_map(params![spec.fts_query], |row| map_search_row(row, &spec))?; - let mut rows = collect_rows(rows)?; + let mut rows = Rows::collect(rows)?; rows.sort_by(|left, right| { right .score diff --git a/crates/graph-core/src/store.rs b/crates/graph-core/src/store.rs index 0e9f0e7..390c98c 100644 --- a/crates/graph-core/src/store.rs +++ b/crates/graph-core/src/store.rs @@ -16,7 +16,7 @@ use time::format_description::well_known::Rfc3339; use time::OffsetDateTime; mod metadata; -mod read; +pub(crate) mod read; mod schema; mod types; mod write; @@ -29,12 +29,12 @@ pub use types::{ }; use metadata::{ - collect_rows, current_source_hashes, freshness_age_ms, hash_mismatch_reason, - missing_metadata_freshness, missing_watch_root_reason, read_optional_json, - scoped_source_hashes, source_hash_discovery_failed, stale_freshness, stale_metadata_freshness, + current_source_hashes, freshness_age_ms, hash_mismatch_reason, missing_metadata_freshness, + missing_watch_root_reason, read_optional_json, scoped_source_hashes, + source_hash_discovery_failed, stale_freshness, stale_metadata_freshness, stale_metadata_freshness_with_age, }; -use read::{read_edge_row, read_node_row}; +use read::{read_edge_row, read_node_row, CollectRows, Rows}; use schema::{configure_sqlite, migrate_or_validate, validate_schema}; #[cfg(test)] use schema::{require_index, require_table, STORE_INDEX_NAMES}; @@ -410,7 +410,7 @@ impl GraphStore { sha256: row.get(3)?, }) })?; - collect_rows(rows) + Rows::collect(rows) } fn read_nodes(&self) -> StoreResult> { @@ -418,7 +418,7 @@ impl GraphStore { .connection .prepare("select id, kind, path, name, extra from nodes order by id")?; let rows = statement.query_map([], read_node_row)?; - collect_rows(rows) + Rows::collect(rows) } fn read_edges(&self) -> StoreResult> { @@ -426,7 +426,7 @@ impl GraphStore { "select id, kind, source_qualified, target_qualified, extra from edges order by kind, source_qualified, target_qualified", )?; let rows = statement.query_map([], read_edge_row)?; - collect_rows(rows) + Rows::collect(rows) } fn read_kind_counts(&self, table: &str) -> StoreResult> { @@ -441,7 +441,7 @@ impl GraphStore { }; let mut statement = self.connection.prepare(sql)?; let rows = statement.query_map([], |row| Ok((row.get(0)?, row.get(1)?)))?; - Ok(collect_rows(rows)?.into_iter().collect()) + Ok(Rows::collect(rows)?.into_iter().collect()) } } diff --git a/crates/graph-core/src/store/metadata.rs b/crates/graph-core/src/store/metadata.rs index b064239..2902525 100644 --- a/crates/graph-core/src/store/metadata.rs +++ b/crates/graph-core/src/store/metadata.rs @@ -1,14 +1,17 @@ use super::schema::user_version; use super::types::FreshnessState; -use super::{StoreError, StoreResult}; +use super::{ + read::{CollectRows, Rows}, + StoreError, StoreResult, +}; use crate::extraction::{ - collect_source_file_hashes, ExtractionOptions, SourceFileHash, EXTRACTION_GENERATED_AT, + collect_source_file_hashes, ExtractionOptions, SourceFileHash, SourcePathOps, SourcePaths, + EXTRACTION_GENERATED_AT, }; use crate::protocol::{GraphExtractionDiagnostic, GraphFreshness, GraphSnapshotMetadata}; use crate::{GRAPH_PROVIDER_NAME, GRAPH_SCHEMA_VERSION}; use rusqlite::{params, Connection, OptionalExtension}; use serde::de::DeserializeOwned; -use std::collections::BTreeMap; use std::path::Path; use time::format_description::well_known::Rfc3339; use time::OffsetDateTime; @@ -46,7 +49,7 @@ fn validate_metadata_table_json( metadata_key, diagnostics_key, }; - for (key, value) in collect_rows(rows)? { + for (key, value) in Rows::collect(rows)? { validate_metadata_row(&check, &key, &value)?; } Ok(()) @@ -152,17 +155,6 @@ fn parse_json(value: &str) -> Result serde_json::from_str(value) } -pub(super) fn collect_rows(rows: rusqlite::MappedRows<'_, F>) -> StoreResult> -where - F: FnMut(&rusqlite::Row<'_>) -> rusqlite::Result, -{ - let mut values = Vec::new(); - for row in rows { - values.push(row?); - } - Ok(values) -} - pub(super) fn optional_json(value: &Option) -> StoreResult> { value .as_ref() @@ -227,14 +219,8 @@ pub(super) fn hash_mismatch_reason( stored: &[SourceFileHash], current: &[SourceFileHash], ) -> Option { - let stored_by_path = stored - .iter() - .map(|hash| (hash.relative_path.as_str(), hash.sha256.as_str())) - .collect::>(); - let current_by_path = current - .iter() - .map(|hash| (hash.relative_path.as_str(), hash.sha256.as_str())) - .collect::>(); + let stored_by_path = SourcePaths::source_hashes_by_path(stored); + let current_by_path = SourcePaths::source_hashes_by_path(current); for path in stored_by_path.keys() { if !current_by_path.contains_key(path) { return Some(format!("source file {path} was removed")); diff --git a/crates/graph-core/src/store/read.rs b/crates/graph-core/src/store/read.rs index 3582b03..d5818bc 100644 --- a/crates/graph-core/src/store/read.rs +++ b/crates/graph-core/src/store/read.rs @@ -1,5 +1,26 @@ use crate::protocol::{GraphFactEdge, GraphFactNode}; +pub(crate) struct Rows; + +pub(crate) trait CollectRows { + fn collect(rows: rusqlite::MappedRows<'_, F>) -> super::StoreResult> + where + F: FnMut(&rusqlite::Row<'_>) -> rusqlite::Result; +} + +impl CollectRows for Rows { + fn collect(rows: rusqlite::MappedRows<'_, F>) -> super::StoreResult> + where + F: FnMut(&rusqlite::Row<'_>) -> rusqlite::Result, + { + let mut values = Vec::new(); + for row in rows { + values.push(row?); + } + Ok(values) + } +} + pub(super) fn read_node_row(row: &rusqlite::Row<'_>) -> rusqlite::Result { if let Some(extra) = row.get::<_, Option>(4)? { return parse_canonical_row(&extra, 4); diff --git a/crates/graph-core/src/store/schema.rs b/crates/graph-core/src/store/schema.rs index c4daef8..0c22482 100644 --- a/crates/graph-core/src/store/schema.rs +++ b/crates/graph-core/src/store/schema.rs @@ -1,11 +1,11 @@ -use super::metadata::{collect_rows, validate_metadata_json}; +use super::metadata::validate_metadata_json; +use super::read::{CollectRows, Rows}; use super::{ now_rfc3339, search, StoreError, StoreResult, STORE_SCHEMA_VERSION, WAL_AUTOCHECKPOINT_PAGES, }; use rusqlite::{params, Connection, OptionalExtension}; use std::collections::BTreeSet; -#[cfg_attr(not(test), allow(dead_code))] pub(super) const STORE_INDEX_NAMES: [&str; 8] = [ "idx_nodes_file", "idx_nodes_kind", @@ -318,7 +318,7 @@ pub(super) fn require_table(connection: &Connection, table: &str) -> StoreResult fn require_columns(connection: &Connection, table: &str, required: &[&str]) -> StoreResult<()> { let mut statement = connection.prepare(&format!("pragma table_info({table})"))?; let rows = statement.query_map([], |row| row.get::<_, String>(1))?; - let columns = collect_rows(rows)?.into_iter().collect::>(); + let columns = Rows::collect(rows)?.into_iter().collect::>(); for column in required { if !columns.contains(*column) { return Err(StoreError::SchemaMismatch { diff --git a/crates/graph-core/src/store/tests.rs b/crates/graph-core/src/store/tests.rs index be50de8..d7a23c4 100644 --- a/crates/graph-core/src/store/tests.rs +++ b/crates/graph-core/src/store/tests.rs @@ -1,6 +1,7 @@ use super::*; use crate::extraction::{extract_sources, ExtractionOptions}; use crate::protocol::{GraphFactQueryKind, GraphFactQuerySelector, GraphPipelineSummary}; +use crate::test_support::wave1_fixture_root; use crate::GRAPH_SCHEMA_VERSION; use std::fs; use std::path::PathBuf; @@ -430,12 +431,6 @@ fn copied_wave1_fixture() -> Result { Ok(destination) } -fn wave1_fixture_root() -> Result { - PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .join("../../packages/fixtures/source-extraction/wave1") - .canonicalize() -} - fn copy_dir(source: &Path, destination: &Path) -> Result<(), std::io::Error> { fs::create_dir_all(destination)?; for entry in fs::read_dir(source)? { diff --git a/crates/graph-core/src/test_support.rs b/crates/graph-core/src/test_support.rs new file mode 100644 index 0000000..23ef610 --- /dev/null +++ b/crates/graph-core/src/test_support.rs @@ -0,0 +1,8 @@ +use std::path::PathBuf; + +#[cfg(test)] +pub(crate) fn wave1_fixture_root() -> Result { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../packages/fixtures/source-extraction/wave1") + .canonicalize() +} diff --git a/crates/graph-core/src/watch/args.rs b/crates/graph-core/src/watch/args.rs index a135ef4..13459b2 100644 --- a/crates/graph-core/src/watch/args.rs +++ b/crates/graph-core/src/watch/args.rs @@ -233,7 +233,7 @@ mod tests { #[test] fn watch_paths_are_normalized_from_cli_and_env() -> TestResult { - with_watch_env(Some("./src:tests\\unit"), None, || { + with_watch_env(Some("./src:tests\\unit"), || { let env_options = parse(&["watch", "--repo", "."])?; assert_eq!(env_options.watch_paths, vec!["src", "tests/unit"]); let cli_options = parse(&["watch", "--repo", ".", "--paths", "./src//,tests\\unit"])?; @@ -242,18 +242,9 @@ mod tests { }) } - #[test] - fn crg_watch_paths_do_not_scope_lattice_watch() -> TestResult { - with_watch_env(None, Some("src"), || { - let options = parse(&["watch", "--repo", "."])?; - assert!(options.watch_paths.is_empty()); - Ok(()) - }) - } - #[test] fn unsafe_watch_paths_are_rejected() -> TestResult { - with_watch_env(None, None, || { + with_watch_env(None, || { for path in [ "/tmp/src", "C:\\tmp\\src", @@ -300,29 +291,19 @@ mod tests { fn with_watch_env( opcore_value: Option<&str>, - crg_value: Option<&str>, run: impl FnOnce() -> Result, ) -> Result { let _guard = ENV_LOCK.lock().map_err(|error| error.to_string())?; let old_opcore = std::env::var("OPCORE_GRAPH_WATCH_PATHS").ok(); - let old_crg = std::env::var("CRG_WATCH_PATHS").ok(); match opcore_value { Some(value) => std::env::set_var("OPCORE_GRAPH_WATCH_PATHS", value), None => std::env::remove_var("OPCORE_GRAPH_WATCH_PATHS"), } - match crg_value { - Some(value) => std::env::set_var("CRG_WATCH_PATHS", value), - None => std::env::remove_var("CRG_WATCH_PATHS"), - } let result = run(); match old_opcore { Some(value) => std::env::set_var("OPCORE_GRAPH_WATCH_PATHS", value), None => std::env::remove_var("OPCORE_GRAPH_WATCH_PATHS"), } - match old_crg { - Some(value) => std::env::set_var("CRG_WATCH_PATHS", value), - None => std::env::remove_var("CRG_WATCH_PATHS"), - } result } } diff --git a/docs/architecture/graph-hub-inventory.md b/docs/architecture/graph-hub-inventory.md new file mode 100644 index 0000000..a47e7e6 --- /dev/null +++ b/docs/architecture/graph-hub-inventory.md @@ -0,0 +1,356 @@ +# Graph Hub Inventory + +This inventory records source files currently classified as graph hubs by +`docs.hub-coverage`. It is grouped by ownership track so architectural context +remains discoverable after module splits. Update the inventory when graph +topology adds or removes a hub; inclusion documents location, not public API +status or cross-package ownership. + +## crates/graph-core + +```text +crates/graph-core/src/clone/analysis.rs +crates/graph-core/src/daemon/lifecycle.rs +crates/graph-core/src/daemon/session.rs +crates/graph-core/src/extraction/diagnostics.rs +crates/graph-core/src/extraction/discovery.rs +crates/graph-core/src/extraction/language.rs +crates/graph-core/src/extraction/tests/support.rs +crates/graph-core/src/extraction/tsconfig.rs +crates/graph-core/src/lib.rs +crates/graph-core/src/pipeline.rs +crates/graph-core/src/protocol.rs +crates/graph-core/src/protocol/daemon.rs +crates/graph-core/src/protocol/facts.rs +crates/graph-core/src/protocol/provider.rs +crates/graph-core/src/query.rs +crates/graph-core/src/query/common.rs +crates/graph-core/src/query/index.rs +crates/graph-core/src/search.rs +crates/graph-core/src/store/metadata.rs +crates/graph-core/src/store/read.rs +crates/graph-core/src/store/schema.rs +crates/graph-core/src/store/types.rs +crates/graph-core/src/test_support.rs +crates/graph-core/src/watch.rs +``` + +## packages/asp-provider + +```text +packages/asp-provider/src/json-rpc.ts +packages/asp-provider/src/protocol.ts +``` + +## packages/contracts + +```text +packages/contracts/src/command/adapter-validator.ts +packages/contracts/src/command/contracts.ts +packages/contracts/src/command/helper-validators.ts +packages/contracts/src/command/router-02.ts +packages/contracts/src/command/router-contracts.ts +packages/contracts/src/command/validators.ts +packages/contracts/src/command/vocabulary.ts +packages/contracts/src/edit/contracts.ts +packages/contracts/src/edit/refusal-validator.ts +packages/contracts/src/edit/validators.ts +packages/contracts/src/edit/vocabulary.ts +packages/contracts/src/graph/daemon-validators-01.ts +packages/contracts/src/graph/daemon-validators-02.ts +packages/contracts/src/graph/helper-validators.ts +packages/contracts/src/graph/payload-validators.ts +packages/contracts/src/graph/pipeline-contracts.ts +packages/contracts/src/graph/protocol-validators.ts +packages/contracts/src/graph/provider-contracts-01.ts +packages/contracts/src/graph/provider-contracts-02.ts +packages/contracts/src/graph/provider-validators.ts +packages/contracts/src/graph/query-contracts-01.ts +packages/contracts/src/graph/query-contracts-02.ts +packages/contracts/src/graph/query-validators.ts +packages/contracts/src/graph/search-contracts.ts +packages/contracts/src/graph/search-validators.ts +packages/contracts/src/graph/vocabulary-01.ts +packages/contracts/src/graph/vocabulary-02.ts +packages/contracts/src/inspect/contracts-01.ts +packages/contracts/src/inspect/contracts-02.ts +packages/contracts/src/inspect/helper-validators-01.ts +packages/contracts/src/inspect/validators.ts +packages/contracts/src/managed/contracts.ts +packages/contracts/src/managed/helper-validators.ts +packages/contracts/src/managed/validators-01.ts +packages/contracts/src/managed/validators-02.ts +packages/contracts/src/managed/validators-03.ts +packages/contracts/src/product/init-contracts.ts +packages/contracts/src/product/init-validators-01.ts +packages/contracts/src/product/latency-contracts.ts +packages/contracts/src/product/metrics-contracts-01.ts +packages/contracts/src/product/metrics-contracts-02.ts +packages/contracts/src/product/metrics-coverage-validators.ts +packages/contracts/src/product/metrics-validators-01.ts +packages/contracts/src/product/metrics-validators-02.ts +packages/contracts/src/product/metrics-validators-03.ts +packages/contracts/src/product/metrics-validators-04.ts +packages/contracts/src/product/metrics-validators-05.ts +packages/contracts/src/product/status-contracts.ts +packages/contracts/src/product/status-validators.ts +packages/contracts/src/release/asp-contracts-01.ts +packages/contracts/src/release/asp-validators-02.ts +packages/contracts/src/release/cutover-contracts.ts +packages/contracts/src/release/cutover-validators-02.ts +packages/contracts/src/release/graph-contracts.ts +packages/contracts/src/release/graph-optional-validators.ts +packages/contracts/src/release/graph-validators-02.ts +packages/contracts/src/release/graph-validators-03.ts +packages/contracts/src/release/graph-vocabulary-01.ts +packages/contracts/src/release/graph-vocabulary-02.ts +packages/contracts/src/release/receipt-contracts-01.ts +packages/contracts/src/release/receipt-contracts-02.ts +packages/contracts/src/release/receipt-validators-01.ts +packages/contracts/src/release/receipt-validators-02.ts +packages/contracts/src/release/receipt-validators-03.ts +packages/contracts/src/release/vocabulary-01.ts +packages/contracts/src/release/vocabulary-02.ts +packages/contracts/src/shared/json.ts +packages/contracts/src/shared/path-validators.ts +packages/contracts/src/shared/primitives.ts +packages/contracts/src/shared/validators-01.ts +packages/contracts/src/shared/validators-02.ts +packages/contracts/src/validation/capability-contracts.ts +packages/contracts/src/validation/diagnostic-contracts.ts +packages/contracts/src/validation/prewrite-status-validators-01.ts +packages/contracts/src/validation/python-project-contracts-01.ts +packages/contracts/src/validation/python-project-contracts-02.ts +packages/contracts/src/validation/python-project-validators-01.ts +packages/contracts/src/validation/python-project-validators-02.ts +packages/contracts/src/validation/python-pytest-validators-02.ts +packages/contracts/src/validation/python-ruff-validators-03.ts +packages/contracts/src/validation/python-types-validators.ts +packages/contracts/src/validation/python-validator-primitives.ts +packages/contracts/src/validation/request-contracts.ts +packages/contracts/src/validation/request-validators-01.ts +packages/contracts/src/validation/request-validators-02.ts +packages/contracts/src/validation/result-validator.ts +packages/contracts/src/validation/status-contracts.ts +packages/contracts/src/validation/vocabulary-01.ts +packages/contracts/src/validation/vocabulary-02.ts +``` + +## packages/edit + +```text +packages/edit/src/atomic-writer.ts +packages/edit/src/codex-patch-parser.ts +packages/edit/src/command-parser.ts +packages/edit/src/content-policy.ts +packages/edit/src/hash.ts +packages/edit/src/language-service.ts +packages/edit/src/operations.ts +packages/edit/src/patch-parser.ts +packages/edit/src/patch-tree-command.ts +packages/edit/src/path-policy.ts +packages/edit/src/planner.ts +packages/edit/src/symbol-command.ts +packages/edit/src/symbol-graph.ts +packages/edit/src/symbol-preview.ts +packages/edit/src/symbol-requests.ts +packages/edit/src/tree-planner.ts +packages/edit/src/typescript-project/filesystem-discovery.ts +packages/edit/src/typescript-project/path-policy.ts +packages/edit/src/typescript-project/source-discovery.ts +packages/edit/src/typescript-project/tsconfig.ts +packages/edit/src/typescript-project/types.ts +packages/edit/src/validated-apply.ts +packages/edit/src/validation-request.ts +packages/edit/src/validation.ts +``` + +## packages/fixtures + +Fixture roots are excluded from repository self-validation, so their graph hubs +are recorded by basename without claiming file-view freshness evidence. + +```text +helpers.py +models.py +relative_target.py +stubs.pyi +math.js +``` + +## packages/graph + +```text +packages/graph/src/artifact.ts +packages/graph/src/ephemeral-snapshot.ts +packages/graph/src/native-targets.ts +packages/graph/src/sidecar.ts +``` + +## packages/opcore + +```text +packages/opcore/src/advanced/asp-warm/asp-warm-lifecycle.ts +packages/opcore/src/advanced/asp-warm/warm-project-registry.ts +packages/opcore/src/advanced/inspect-language-service.ts +packages/opcore/src/advanced/inspect-typescript-project.ts +packages/opcore/src/advanced/router.ts +packages/opcore/src/agent-gate.ts +packages/opcore/src/doctor.ts +packages/opcore/src/init-action-helpers.ts +packages/opcore/src/init-actions.ts +packages/opcore/src/init-apply.ts +packages/opcore/src/init-constants.ts +packages/opcore/src/init-context-payload.ts +packages/opcore/src/init-data.ts +packages/opcore/src/init-files.ts +packages/opcore/src/init-format.ts +packages/opcore/src/init-gitignore.ts +packages/opcore/src/init-guidance.ts +packages/opcore/src/init-hooks.ts +packages/opcore/src/init-messages.ts +packages/opcore/src/init-paths.ts +packages/opcore/src/init-payloads.ts +packages/opcore/src/init-plan.ts +packages/opcore/src/init-prompts.ts +packages/opcore/src/init-result.ts +packages/opcore/src/init-timing.ts +packages/opcore/src/init-types.ts +packages/opcore/src/init-undo-metadata.ts +packages/opcore/src/init-undo-plan.ts +packages/opcore/src/init-wizard-render.ts +packages/opcore/src/install-wizard.ts +packages/opcore/src/json-output.ts +packages/opcore/src/plate.ts +packages/opcore/src/repo-paths.ts +packages/opcore/src/repo-validation-policy.ts +packages/opcore/src/runtime-info.ts +packages/opcore/src/scan-presentation.ts +packages/opcore/src/scan-validation-preview.ts +packages/opcore/src/scan.ts +packages/opcore/src/serve-telemetry.ts +packages/opcore/src/source-policy.ts +packages/opcore/src/status-errors.ts +packages/opcore/src/status-git.ts +packages/opcore/src/status-repo.ts +packages/opcore/src/status-state.ts +packages/opcore/src/status-validation.ts +packages/opcore/src/status.ts +packages/opcore/src/stream-output.ts +packages/opcore/src/timing.ts +packages/opcore/src/validation-graph-session.ts +``` + +## packages/validation-clone + +```text +packages/validation-clone/src/check-constants.ts +packages/validation-clone/src/check-ids.ts +packages/validation-clone/src/source-files.ts +``` + +## packages/validation-docs + +```text +packages/validation-docs/src/check-constants.ts +packages/validation-docs/src/check-definition.ts +packages/validation-docs/src/check-ids.ts +packages/validation-docs/src/check-results.ts +packages/validation-docs/src/diagnostics.ts +packages/validation-docs/src/history.ts +packages/validation-docs/src/options.ts +packages/validation-docs/src/snapshot.ts +``` + +## packages/validation-policy + +```text +packages/validation-policy/src/check-packs.ts +packages/validation-policy/src/config.ts +packages/validation-policy/src/path-policy.ts +packages/validation-policy/src/types.ts +``` + +## packages/validation-python + +```text +packages/validation-python/src/check-constants.ts +packages/validation-python/src/check-ids.ts +packages/validation-python/src/diagnostics.ts +packages/validation-python/src/environment-resolution.ts +packages/validation-python/src/graph-requirements.ts +packages/validation-python/src/import-analysis.ts +packages/validation-python/src/ini-config.ts +packages/validation-python/src/mypy-config-values.ts +packages/validation-python/src/mypy-runner-types.ts +packages/validation-python/src/process.ts +packages/validation-python/src/project-config-files.ts +packages/validation-python/src/project-context.ts +packages/validation-python/src/project-fingerprint.ts +packages/validation-python/src/project-groups.ts +packages/validation-python/src/project-workspace.ts +packages/validation-python/src/pyright-config-values.ts +packages/validation-python/src/pytest-result.ts +packages/validation-python/src/pytest-types.ts +packages/validation-python/src/pytest-workspace.ts +packages/validation-python/src/python-context-result.ts +packages/validation-python/src/ruff-capability-run.ts +packages/validation-python/src/ruff-check-definition.ts +packages/validation-python/src/ruff-check-shared.ts +packages/validation-python/src/ruff-execution.ts +packages/validation-python/src/ruff-invocation-failure.ts +packages/validation-python/src/source-files.ts +packages/validation-python/src/source-types.ts +packages/validation-python/src/strict-json.ts +packages/validation-python/src/toml-config.ts +packages/validation-python/src/toolchain.ts +packages/validation-python/src/type-authority.ts +packages/validation-python/src/type-capability-run.ts +packages/validation-python/src/type-result.ts +packages/validation-python/src/type-runner-runtime.ts +packages/validation-python/src/type-runner-types.ts +packages/validation-python/src/version-constraint.ts +``` + +## packages/validation-rust + +```text +packages/validation-rust/src/cargo-metadata.ts +packages/validation-rust/src/cargo-target-cache.ts +packages/validation-rust/src/check-constants.ts +packages/validation-rust/src/check-ids.ts +packages/validation-rust/src/diagnostics.ts +packages/validation-rust/src/import-graph-check.ts +packages/validation-rust/src/materialize.ts +packages/validation-rust/src/process.ts +packages/validation-rust/src/retained-compatibility.ts +packages/validation-rust/src/source-files.ts +packages/validation-rust/src/toolchain.ts +``` + +## packages/validation-typescript + +```text +packages/validation-typescript/src/check-constants.ts +packages/validation-typescript/src/check-ids.ts +packages/validation-typescript/src/compiler-host.ts +packages/validation-typescript/src/dead-code-entrypoints.ts +packages/validation-typescript/src/diagnostics.ts +packages/validation-typescript/src/graph-requirements.ts +packages/validation-typescript/src/lint-helpers.ts +packages/validation-typescript/src/script-kind.ts +packages/validation-typescript/src/source-files.ts +packages/validation-typescript/src/test-paths.ts +``` + +## packages/validation + +```text +packages/validation/src/aggregation.ts +packages/validation/src/command-options.ts +packages/validation/src/registry.ts +packages/validation/src/request.ts +packages/validation/src/resources.ts +packages/validation/src/runner.ts +packages/validation/src/scope.ts +``` diff --git a/docs/architecture/runtime-cli-ard.md b/docs/architecture/runtime-cli-ard.md index 55a8cfa..2532834 100644 --- a/docs/architecture/runtime-cli-ard.md +++ b/docs/architecture/runtime-cli-ard.md @@ -6,7 +6,7 @@ Decision: hybrid ## Context -The internal Opcore implementation line is the release line for graph, edit, validation, and the standalone ASP Core check provider facade behind Opcore. The bootstrap repository is TypeScript/npm, but the final runtime must not be chosen by scaffold inertia. The graph provider needs fast source extraction, persistent graph facts, SQLite/WAL indexing, watch daemons, and hot query paths. The edit and validation tracks need TypeScript compiler APIs, ts-morph-compatible orchestration, ESLint-style checks, JSON CLI output, npm packaging, and ACE/Zeroshot integration. The ASP provider facade needs a small stdio JSON-RPC process over host-owned workspace callbacks and Opcore validation checks. +The internal Opcore implementation line is the release line for graph, edit, validation, and the standalone ASP Core check provider facade behind Opcore. The bootstrap repository is TypeScript/npm, but the final runtime must not be chosen by scaffold inertia. The graph provider needs fast source extraction, persistent graph facts, SQLite/WAL indexing, watch daemons, and hot query paths. The edit and validation tracks need TypeScript compiler APIs, ts-morph-compatible orchestration, ESLint-style checks, JSON CLI output, npm packaging, and Zeroshot and CI integration. The ASP provider facade needs a small stdio JSON-RPC process over host-owned workspace callbacks and Opcore validation checks. ## Decision @@ -21,7 +21,7 @@ Opcore's internal Opcore implementation line uses a hybrid runtime: - TypeScript validation-typescript owns TypeScript-specific rules and compiler-backed adapters. - TypeScript validation-clone owns duplicate-code validation adapter wiring over the injected graph-core clone subcommand, without a graph-provider requirement or SAST/security claim. - TypeScript asp-provider owns the standalone ASP Core `check/evaluate` provider-process facade and provisional install manifest. -- TypeScript npm facade, ACE descriptors, and ASP provider install manifest own install metadata, runtime discovery inputs, wrapper generation, and release integration. +- TypeScript npm facade, managed descriptors, and ASP provider install manifest own install metadata, runtime discovery inputs, wrapper generation, and release integration. The ownership invariant is: do not collapse graph, edit, and policy ownership into one muddled abstraction. @@ -30,8 +30,8 @@ The ownership invariant is: do not collapse graph, edit, and policy ownership in | Option | Outcome | Reason | |---|---|---| | TS-only | Rejected | Simpler npm distribution, but graph performance, indexing, watch behavior, SQLite/WAL control, and hot query latency would depend on the wrong runtime boundary. | -| Rust-first | Rejected | Strong graph/runtime performance, but it would fight TypeScript compiler APIs, ESLint-style rule authoring, npm-first install, JSON CLI ergonomics, and ACE hook integration. | -| Hybrid | Accepted | Keeps the graph core in Rust where performance and persistence matter, while TypeScript owns contracts, CLI routing, edit, validation, npm facades, and ACE descriptors. | +| Rust-first | Rejected | Strong graph/runtime performance, but it would fight TypeScript compiler APIs, ESLint-style rule authoring, npm-first install, JSON CLI ergonomics, and agent hook integration. | +| Hybrid | Accepted | Keeps the graph core in Rust where performance and persistence matter, while TypeScript owns contracts, CLI routing, edit, validation, npm facades, and managed descriptors. | ## CLI Router @@ -45,7 +45,7 @@ The ownership invariant is: do not collapse graph, edit, and policy ownership in | `opcore check` | validation | Run mechanical checks and graph-aware checks. | | `opcore validate` | validation | Validate proposed or hypothetical edits against policy and manifests. | | `opcore status` | runtime | Report runtime, wrapper, graph, and validation readiness. | -| `opcore doctor` | runtime | Diagnose install, native artifact, wrapper, and ACE/Zeroshot integration problems. | +| `opcore doctor` | runtime | Diagnose install, native artifact, wrapper, and Zeroshot and CI integration problems. | | `opcore` | runtime | Run zero-command source-read-only scan, print coverage before findings, and write `.opcore/report.json`, `.opcore/history.jsonl`, plus bounded `.opcore/telemetry.jsonl` capped at 500 records or 1 MiB. | | `opcore status` | runtime | Report read-only repo activation, graph readiness, coverage, degraded toolchains, ASP enrollment hints, and next commands. | | `opcore check` | validation | Run the universal agent validation gate over changed, staged, or explicit files. | @@ -54,19 +54,19 @@ The ownership invariant is: do not collapse graph, edit, and policy ownership in | `opcore measure` | runtime | Read `.opcore/report.json` and `.opcore/history.jsonl` and return read-only metric deltas. | | `opcore try` | runtime | Generate local TS, Rust, mixed, and unsupported-file sample repos, run the launch loop, and publish nothing. | -Canonical `opcore graph`, `opcore edit`, `opcore check`, and `opcore validate` commands dispatch directly to public adapters owned by `packages/graph`, `packages/edit`, and `packages/validation`. Top-level `opcore inspect symbols|definition|references|signature|implementations|search` is CLI-owned read-only routing backed by public GraphProvider query/search APIs; `opcore inspect references --line [--column ]` adds #72 inspect-owned TypeScript/JavaScript language-service resolution for CIX refs parity. `opcore inspect signature --line [--column ]` and `opcore inspect signature ` add #101 inspect-owned TypeScript/JavaScript language-service signature parity for functions, methods, constructors, classes, interfaces, type aliases, overloads, imported/aliased symbols, path aliases, TS/TSX, JS, and JSX after fresh GraphProvider evidence is available. `opcore inspect implementations --line [--column ]` and node-id targets add #102 read-only implementation evidence for TypeScript/TSX class implements, class extends, and interface extends relationships over fresh graph facts plus language-service materialization. Unimplemented graph inspect routes are not advertised as release behavior. `opcore edit` implements `exact`, `multi`, `search-replace`, `patch`, `tree`, `rename`, `move`, `signature`, `check`, and `apply` over edit-core, edit-owned patch/tree APIs, and graph-backed symbol planning. `opcore edit patch` accepts unified diffs and Codex `apply_patch` documents; unified `--3way` is explicitly de-scoped for this release and returns a typed refusal instead of attempting dirty-file merges. Symbol routes consume GraphProvider contract status/query/search evidence plus edit-owned TypeScript/JavaScript language-service materialization, then emit normal validation-required edit plans; apply/check refuses graph freshness changes before validation or writes. Search-replace must reject duplicate matches unless `replaceAll` is true. `opcore check` implements `files`, `staged`, `changed`, `tree`, `all`, and `manifest`; tree checks read committed Git tree content from `--tree ` and scope files from `--changed-from `. `opcore validate` implements request-file validation, `hypothetical`, `pre-write`, and `manifest`. Runtime-owned `opcore status` and `opcore doctor` include typed validation status payloads but do not own validation checks. `opcore status` emits a stable `repoState` payload and must remain read-only: no graph build/update/watch, validation check execution, package install, ASP setup, ACE setup, current-tool wrapper execution, or source writes. `opcore` scan and `opcore check` must avoid source edits, hooks, setup, ASP setup, ACE setup, old-tool wrapper execution, sibling checkouts, and package installs; scan may write only `.opcore/report.json`, `.opcore/history.jsonl`, and bounded `.opcore/telemetry.jsonl` capped at 500 records or 1 MiB. `opcore install` is the recommended setup writer in the public product facade; it runs the same read-only scan without report/history writes, emits scan/settings/interaction/timing in `opcoreInit`, prompts in an interactive Git repo to choose repo or global write-gate scope when neither scope nor `--json`/`--yes` is supplied, keeps JSON and non-TTY no-flag runs plan-only, and approved mode is additive/idempotent. Repo scope appends `.opcore/` to `.gitignore` only in Git repos that do not already ignore it, skips `.gitignore` in non-Git repos, writes `.opcore/config`, agent guidance, Opcore agent skill files, `.opcore/hooks/opcore-agent-gate.mjs`, active `.git/hooks/pre-commit` when safe, and merges Claude Code `.claude/settings.json` plus Codex `.codex/hooks.json`; undo uses repo `.opcore/init-undo.json` through `opcore uninstall`. Global scope writes `~/.opcore/hooks/opcore-agent-gate.mjs`, user-level skill files, merges `~/.claude/settings.json` and `~/.codex/hooks.json`, and records undo in `~/.opcore/init-undo.json`. `opcore init` remains a compatibility setup route with explicit `--approve` semantics. The adapter maps harness write/edit payloads to validation overlays, calls `opcore validate pre-write --request-file --timeout-ms 30000 --json`, exits 2 for non-ok receipts and validation command failures, and fail-opens only for adapter parse/mapping errors. Claude Code PreToolUse blocks matching Edit/MultiEdit/Write calls through exit 2. Codex PreToolUse is wired as the strongest current guardrail for supported edit aliases, but Codex hook trust and interception scope remain harness behavior, so public copy must not claim broader enforcement. The separate fail-closed pre-commit hook script remains opt-in through `--fail-closed-hook`. The managed `.opcore/` ignore covers `.opcore/telemetry.jsonl`. No package may expose old-tool public bins, old `cix` aliases, or old-tool package identities as Opcore release surface. +Canonical `opcore graph`, `opcore edit`, `opcore check`, and `opcore validate` commands dispatch directly to public adapters owned by `packages/graph`, `packages/edit`, and `packages/validation`. Top-level `opcore inspect symbols|definition|references|signature|implementations|search` is CLI-owned read-only routing backed by public GraphProvider query/search APIs; `opcore inspect references --line [--column ]` adds #72 inspect-owned TypeScript/JavaScript language-service resolution for semantic reference evidence. `opcore inspect signature --line [--column ]` and `opcore inspect signature ` add #101 inspect-owned TypeScript/JavaScript language-service signature parity for functions, methods, constructors, classes, interfaces, type aliases, overloads, imported/aliased symbols, path aliases, TS/TSX, JS, and JSX after fresh GraphProvider evidence is available. `opcore inspect implementations --line [--column ]` and node-id targets add #102 read-only implementation evidence for TypeScript/TSX class implements, class extends, and interface extends relationships over fresh graph facts plus language-service materialization. Inspect routing and result ownership remain CLI-owned, while `packages/edit/src/typescript-project/` owns the shared ts-morph project discovery, tsconfig selection, import resolution, source listing, scope materialization, and injected-project snapshot seam consumed by cold inspect and warm ASP sessions. Unimplemented graph inspect routes are not advertised as release behavior. `opcore edit` implements `exact`, `multi`, `search-replace`, `patch`, `tree`, `rename`, `move`, `signature`, `check`, and `apply` over edit-core, edit-owned patch/tree APIs, and graph-backed symbol planning. `opcore edit patch` accepts unified diffs and Codex `apply_patch` documents; unified `--3way` is explicitly de-scoped for this release and returns a typed refusal instead of attempting dirty-file merges. Symbol routes consume GraphProvider contract status/query/search evidence plus edit-owned TypeScript/JavaScript language-service materialization, then emit normal validation-required edit plans; apply/check refuses graph freshness changes before validation or writes. Search-replace must reject duplicate matches unless `replaceAll` is true. `opcore check` implements `files`, `staged`, `changed`, `tree`, `all`, and `manifest`; tree checks read committed Git tree content from `--tree ` and scope files from `--changed-from `. `opcore validate` implements request-file validation, `hypothetical`, `pre-write`, and `manifest`. Runtime-owned `opcore status` and `opcore doctor` include typed validation status payloads but do not own validation checks. `opcore status` emits a stable `repoState` payload and must remain read-only: no graph build/update/watch, validation check execution, package install, ASP setup, external development-tool execution, or source writes. `opcore` scan and `opcore check` must avoid source edits, hooks, setup, ASP setup, external development-tool execution, sibling checkouts, and package installs; scan may write only `.opcore/report.json`, `.opcore/history.jsonl`, and bounded `.opcore/telemetry.jsonl` capped at 500 records or 1 MiB. `opcore install` is the recommended setup writer in the public product facade; it runs the same read-only scan without report/history writes, emits scan/settings/interaction/timing in `opcoreInit`, prompts in an interactive Git repo to choose repo or global write-gate scope when neither scope nor `--json`/`--yes` is supplied, keeps JSON and non-TTY no-flag runs plan-only, and approved mode is additive/idempotent. Repo scope appends `.opcore/` to `.gitignore` only in Git repos that do not already ignore it, skips `.gitignore` in non-Git repos, writes `.opcore/config`, agent guidance, Opcore agent skill files, `.opcore/hooks/opcore-agent-gate.mjs`, active `.git/hooks/pre-commit` when safe, and merges Claude Code `.claude/settings.json` plus Codex `.codex/hooks.json`; undo uses repo `.opcore/init-undo.json` through `opcore uninstall`. Global scope writes `~/.opcore/hooks/opcore-agent-gate.mjs`, user-level skill files, merges `~/.claude/settings.json` and `~/.codex/hooks.json`, and records undo in `~/.opcore/init-undo.json`. `opcore init` remains a compatibility setup route with explicit `--approve` semantics. The adapter maps harness write/edit payloads to validation overlays, calls `opcore validate pre-write --request-file --timeout-ms 30000 --json`, exits 2 for non-ok receipts and validation command failures, and fail-opens only for adapter parse/mapping errors. Claude Code PreToolUse blocks matching Edit/MultiEdit/Write calls through exit 2. Codex PreToolUse is wired as the strongest current guardrail for supported edit aliases, but Codex hook trust and interception scope remain harness behavior, so public copy must not claim broader enforcement. The separate fail-closed pre-commit hook script remains opt-in through `--fail-closed-hook`. The managed `.opcore/` ignore covers `.opcore/telemetry.jsonl`. The public package exposes exactly the `opcore` and `opcore-asp-provider` bins. #58 adds `opcore validate pre-write --request-file --timeout-ms 30000 --json` for hook integration. The route is validation-owned, file-based, fail-closed, overlay-only, and emits a typed `PreWriteValidationReceipt` with timing, repo, scope, checks, graph, overlay, status, and failure summary data. -#197 makes hypothetical graph-backed validation state-exact. `packages/validation` creates the before/after `ValidationFileView`, acquires one disposable exact graph session for each state, shares that immutable session across every selected check and streaming/fail-fast path, and disposes it on every exit. `reportMode:"introduced"` uses distinct before and after snapshots. `packages/graph` owns bounded temporary source materialization, one graph-core build, bound queries, and recursive cleanup; product, advanced, and ASP composition inject that implementation through validation-owned types. Exact materialization, build, listing, or query failures are non-pass even under optional persistent-graph policy. ASP continues to obtain all source content through host callbacks and preserves `workspace/listTree.truncated`. The target source tree, persistent `.opcore/graph`, configuration, lockfiles, environments, and caches remain untouched. +#197 makes hypothetical graph-backed validation state-exact. `packages/validation` creates the before/after `ValidationFileView`, acquires one disposable exact graph session for each state, shares that immutable session across every selected check and streaming/fail-fast path, and disposes it on every exit. `reportMode:"introduced"` uses distinct before and after snapshots. `packages/graph` owns bounded temporary source materialization including root `tsconfig.json` alias configuration, one graph-core build, bound queries, and recursive cleanup; product, advanced, and ASP composition inject that implementation through validation-owned types. Exact materialization, build, listing, or query failures are non-pass even under optional persistent-graph policy. ASP continues to obtain all source content through host callbacks and preserves `workspace/listTree.truncated`. The target source tree, persistent `.opcore/graph`, configuration, lockfiles, environments, and caches remain untouched. #30 adds `ReleaseCutoverReceipt` and `npm run cutover:check` as the installed-artifact cutover proof. The gate packs the public release packages, installs them into a clean temporary project, clears current-tool environment resolution, excludes local wrapper/sibling paths, verifies installed canonical bins (`opcore` and `opcore-asp-provider`), binds every command receipt id to its expected canonical command/status/exit, and fails on old-tool/private-path markers or advertised `not_implemented` release commands. -#120 adds `AspDogfoodReceipt` and `npm run asp-dogfood:check` as advisory/shadow evidence that the independent ASP manager can install/enroll Opcore as a Core check provider and record host-owned decisions separately from provider assessments. The receipt uses temporary `ASP_HOME` state, records `opcore-asp-provider --stdio` manifest/bin evidence, co-records retained current-tool guardrails, and keeps `oldToolReplacementClaimed: false`; it is not a cutover, host authority claim, public standard-readiness claim, or `opcore asp` route. +#120 adds `AspDogfoodReceipt` and `npm run asp-dogfood:check` as advisory/shadow evidence that the independent ASP manager can install/enroll Opcore as a Core check provider and record host-owned decisions separately from provider assessments. The receipt uses temporary `ASP_HOME` state, records `opcore-asp-provider --stdio` manifest/bin evidence, co-records `opcore:self-check`; it is not a cutover, host authority claim, public standard-readiness claim, or `opcore asp` route. Top-level runtime lifecycle helpers are not public command groups. If shared lifecycle commands are adopted later, they need a new architecture decision and release acceptance criteria; graph daemon lifecycle remains graph-owned under `opcore graph`. -There is no public `opcore asp` router group in this release. ASP Core check integration is launched as the package-owned `opcore-asp-provider --stdio` provider process from `@the-open-engine/opcore-asp-provider`; it is not an ACE descriptor route and it must not execute current-tool wrappers or old `rox`, `crg`, or `cix` binaries. Issue #153 adds a hidden host-launched warm stdio route under the Opcore advanced entrypoint for inspect/edit/check sessions; it stays out of public help and manifests, keeps bounded singleton/idle state under `.opcore/asp/`, delegates check/evaluate to the cold provider mapping, never auto-spawns, and never mutates source files. +There is no public `opcore asp` router group in this release. ASP Core check integration is launched as the package-owned `opcore-asp-provider --stdio` provider process from `@the-open-engine/opcore-asp-provider`; it does not invoke external development toolchains. Issue #153 adds a hidden host-launched warm stdio route under the Opcore advanced entrypoint for inspect/edit/check sessions; it stays out of public help and manifests, keeps bounded singleton/idle state under `.opcore/asp/`, delegates check/evaluate to the cold provider mapping, never auto-spawns, and never mutates source files. ## Ownership Boundaries @@ -75,15 +75,14 @@ There is no public `opcore asp` router group in this release. ASP Core check int | `packages/contracts` | Public wire contracts, schemas, command payloads, adapter request/result envelopes, validation shapes, graph query contracts, and generated TypeScript types. | | `crates/graph-core` | Planned Rust graph core for source extraction, parser integration, SQLite/WAL persistence, freshness metadata, watch daemon state, hot query execution, and clone index analysis through the existing native binary. | | `packages/graph` | npm facade/package track for graph commands, public graph command adapter, native graph-core loading, graph JSON output, and bounded disposable exact-state source snapshots. It must not duplicate graph-core internals or depend on the aggregate CLI. | -| `packages/edit` | Edit planner, public edit command adapter, patch/tree edits, symbol-aware orchestration, graph-backed discovery integration, and whole-plan validation. It consumes contracts and graph queries, not graph internals or the aggregate CLI. | +| `packages/edit` | Edit planner, public edit command adapter, patch/tree edits, symbol-aware orchestration, graph-backed discovery integration, whole-plan validation, and shared ts-morph project discovery/materialization consumed by edit plus CLI-owned inspect and warm ASP sessions. It consumes contracts and graph queries, not graph internals or the aggregate CLI. | | `packages/validation` | Mechanical checks, public check/validate command adapters, validation manifests, failure policy, hypothetical validation, file-view-state graph-session lifecycle, graph-aware rule orchestration, and `check`/`validate` reporting. It consumes contracts and injected graph adapters, not graph internals, edit internals, or the aggregate CLI. | | `packages/validation-rust` | Rust validation adapter rules, temporary workspace materialization from validation file views, and Cargo/native-tool-backed checks exposed to validation through contracts. It does not own host decisions or current external guardrail replacement policy. | | `packages/validation-typescript` | TypeScript adapter rules, compiler-backed checks, and TypeScript graph-aware validations exposed to validation through contracts. | | `packages/validation-clone` | Duplicate-code validation adapter rules over `ValidationCheckContext.fileView`, with an injected native clone invoker supplied by CLI composition. It does not own graph-provider status, daemon lifecycle, host decisions, SAST/security policy, or scoring. | -| `packages/asp-provider` | Standalone ASP Core check provider facade, stdio JSON-RPC lifecycle, ASP changeset-to-validation-overlay mapping, provider-owned diagnostics/coverage, read-set freshness binding, and provisional manifest metadata. It does not own host decisions, authority, gates, apply behavior, ACE launch, or current-tool execution. | +| `packages/asp-provider` | Standalone ASP Core check provider facade, stdio JSON-RPC lifecycle, ASP changeset-to-validation-overlay mapping, provider-owned diagnostics/coverage, read-set freshness binding, and provisional manifest metadata. It does not own host decisions, authority, gates, apply behavior, or external development tooling. | | `packages/fixtures` | Golden repos, graph snapshots, reference evidence, canonical command conformance cases, and release/cutover fixtures. | -| Release descriptors and manifests | npm package metadata, ACE descriptor metadata, ASP provider install metadata, native platform package declarations, provenance, and checksums. | -| `scripts/setup-current-tools.sh` | Current-tool bootstrap only. It must keep wrappers pointed at external ACE-managed tools until release/cutover issues say Opcore packages are production-ready. | +| Release descriptors and manifests | npm package metadata, managed descriptor metadata, ASP provider install metadata, native platform package declarations, provenance, and checksums. | No package may import implementation internals across graph, edit, or policy tracks. Shared shapes move to `packages/contracts` before use across boundaries. @@ -91,7 +90,7 @@ No package may import implementation internals across graph, edit, or policy tra #21 adds the Cargo workspace, `crates/graph-core`, JS/npm wrapper package work, platform artifacts, checksums, Rust validation adapter coverage, and Rust CI gates. -The Rust graph-core sidecar is packaged through optional Opcore native packages, not through local graph-package native output. The supported Opcore alpha targets are exactly `darwin-arm64`, `darwin-x64`, and `linux-x64`, provided by `@the-open-engine/opcore-graph-core-darwin-arm64`, `@the-open-engine/opcore-graph-core-darwin-x64`, and `@the-open-engine/opcore-graph-core-linux-x64`. The graph package facade resolves only the matching optional package metadata, validates `metadata.json` and `opcore-graph-core.sha256`, invokes the sidecar with schema-versioned JSON/NDJSON `GraphDaemonRequest` envelopes, and maps native/process/protocol failures to typed GraphProvider statuses instead of empty graph data. It must not fall back to workspace-local builds, sibling checkouts, `.ace/runtime`, PATH tools, or PATH-discovered tools; unsupported platforms, including Windows, fail with a clear unsupported-platform status. +The Rust graph-core sidecar is packaged through optional Opcore native packages, not through local graph-package native output. The supported Opcore alpha targets are exactly `darwin-arm64`, `darwin-x64`, and `linux-x64`, provided by `@the-open-engine/opcore-graph-core-darwin-arm64`, `@the-open-engine/opcore-graph-core-darwin-x64`, and `@the-open-engine/opcore-graph-core-linux-x64`. The graph package facade resolves only the matching optional package metadata, validates `metadata.json` and `opcore-graph-core.sha256`, invokes the sidecar with schema-versioned JSON/NDJSON `GraphDaemonRequest` envelopes, and maps native/process/protocol failures to typed GraphProvider statuses instead of empty graph data. It must not fall back to workspace-local builds, sibling checkouts, or PATH-discovered tools; unsupported platforms, including Windows, fail with a clear unsupported-platform status. Release artifacts must include: @@ -99,11 +98,13 @@ Release artifacts must include: - platform-specific native graph-core package artifacts for `darwin-arm64`, `darwin-x64`, and `linux-x64`. - checksum/provenance data for each native artifact. - CI gates for Node, TypeScript contracts, Rust build/test, native wrapper loading, fixture conformance, and a release-dry-run aggregate job that downloads all supported native package artifacts before release or cutover receipts claim cross-platform readiness. -- ACE/Zeroshot consumption through generated wrappers and descriptors, not direct source-tree paths. +- Zeroshot consumption through clean npm setup and repository-owned command proofs. - ASP provider provisional manifest metadata as install metadata only; it must not grant authority, trust, or gate permission. #8 adds Wave 1 staged source extraction in Rust graph-core for TS, TSX, JS, and JSX using OXC parser crates. Query responses may now return GraphProvider-compatible File, Class, Function, Type, and Test facts with typed extraction diagnostics. #9 adds the GraphProvider SQLite/WAL store at `.opcore/graph/graph.db`, freshness metadata, deterministic full-snapshot replacement, store-backed nodes/edges/neighbors/symbols selectors, and the #19 direct-reader reference evidence projection. +For TS/JS, graph-core emits file-level `TESTED_BY` from an imported source File to a conventional `.test.*`, `.spec.*`, or `__tests__` File, in addition to symbol-level test-call evidence. Ordinary importers and imports of another test file do not count as test evidence. The TypeScript relevant-test adapter emits diagnostics only when evidence is absent; positive evidence produces a clean passed check. Opcore self-validation exercises this through a real source-facade contract test resolved by root tsconfig aliases and executed by the normal Node test runner. + #10 adds `opcore graph build`, `opcore graph update`, `opcore graph watch`, and `opcore graph status --json` over the Rust graph-core pipeline. Build performs deterministic full-repo discovery, extraction, atomic store replacement, cached FileFacts persistence, phase timing, and WAL budget checks. Update compares stored and current file hashes, reparses changed files, removes deleted-file facts, reuses cached unchanged facts, records changed/deleted file summaries, and marks full rebuilds explicitly when cache state is insufficient. Build/update/status are scoped only by explicit `--paths`; `OPCORE_GRAPH_WATCH_PATHS` is watch-only default. Watch runs a polling Rust daemon with `.opcore/graph/daemon/{pid,state.json,daemon.log}` lifecycle artifacts, ignore-file and watch-path filtering, dirty/deleted reconciliation, graceful shutdown, and WAL checkpoint enforcement. #11 adds read-only store-backed `opcore graph impact`, named `opcore graph query`, `opcore graph review-context`, and `opcore graph detect-changes` envelopes; stale stores, schema mismatches, invalid daemon state, and unsupported query shapes return typed failures without graph payload arrays. #12 adds Rust graph-core FTS5 search with signature projection, `nodes_fts` schema ownership, full and incremental index maintenance, typed search failures, and canonical `opcore graph search` routing through the TypeScript graph adapter. The JSONL sidecar supports `ping` and `health` for lifecycle/status only; long-tail parser coverage remains follow-up graph-core work and must stay behind GraphProvider contracts. Graph pipeline command failures use router status `error` with exit code 1 and the typed GraphProvider failure status; the TypeScript facade must not fabricate pipeline summaries when graph-core returns no pipeline. `status` and `health` are read-only over existing store/lifecycle artifacts and must report missing stores without creating `.opcore/graph/graph.db`. @@ -118,7 +119,7 @@ Before replacing current external tool behavior, add golden/reference fixtures a ## Rust Validation Adapter -#20 adds `@the-open-engine/opcore-validation-rust` as a validation adapter package composed by the CLI beside TypeScript checks. Stable check ids are `rust.source-hygiene`, `rust.fmt`, `rust.cargo-check`, `rust.clippy`, `rust.rustdoc`, `rust.import-graph`, `rust.dead-code`, `rust.graph-signals`, `rust.unused-deps`, and `rust.function-metrics`. These are provider assessment checks; ASP hosts decide allow, deny, or degraded coverage later. `rust.graph-signals` is graph-provider-backed evidence over public contracts only; it does not replace the retained mechanical cargo/rustdoc/clippy/Rox guardrails. +#20 adds `@the-open-engine/opcore-validation-rust` as a validation adapter package composed by the CLI beside TypeScript checks. Stable check ids are `rust.source-hygiene`, `rust.fmt`, `rust.cargo-check`, `rust.clippy`, `rust.rustdoc`, `rust.import-graph`, `rust.dead-code`, `rust.graph-signals`, `rust.unused-deps`, and `rust.function-metrics`. These are provider assessment checks; ASP hosts decide allow, deny, or degraded coverage later. `rust.graph-signals` is graph-provider-backed evidence over public contracts only; mechanical evidence remains owned by Cargo and configured native tools. Rust checks read candidate files through `ValidationCheckContext.fileView`. Checks that need Cargo or native tools materialize one temporary workspace per validation file-view state from after-state content, apply write/delete overlays there, share that immutable workspace across selected checks with the same materialization environment, and dispose it when the state exits. Distinct environments remain distinct resources because Git index/tree environment can change staged or tree snapshots. The real worktree is never mutated. `.rs`, `.inc`, and `Cargo.toml` are adapter-owned inputs. Cargo.lock-only changes remain retained compatibility until a later decision expands Rust adapter ownership. diff --git a/docs/integration/pre-write-validation.md b/docs/integration/pre-write-validation.md index 4967f81..9ee7f7b 100644 --- a/docs/integration/pre-write-validation.md +++ b/docs/integration/pre-write-validation.md @@ -1,6 +1,6 @@ # Pre-Write Validation Integration -Issue #58 defines the hook-safe validation route for ACE and Codex cutover: +Issue #58 defines the hook-safe validation route for coding-agent integrations: ```bash opcore validate pre-write --request-file --timeout-ms 30000 --json diff --git a/docs/planning/opcore-alpha-roadmap.md b/docs/planning/opcore-alpha-roadmap.md index f689762..8308c77 100644 --- a/docs/planning/opcore-alpha-roadmap.md +++ b/docs/planning/opcore-alpha-roadmap.md @@ -55,7 +55,7 @@ Opcore alpha must provide value quickly and honestly: - Reports must state coverage before findings: deep TypeScript/JavaScript graph support, Rust validation/toolchain support, experimental Python validation (degraded-honest), and unsupported-language counts. - Init JSON includes scan, per-language onboarding settings, interaction state, and timing fields for time-to-first-output checks. - Metrics are named, drillable counts and deltas, not a blended quality score. -- Current Rox/CRG/CIX guardrails remain retained until explicit replacement evidence says otherwise. +- Opcore validates its own changes through repository-owned checks and `npm run opcore:self-check`. ## Honest Day-One Signals @@ -71,7 +71,8 @@ Ship only signals the engine can defend: - Python `.py`/`.pyi` graph-backed structure, untested modules, dead exports, syntax, source-hygiene, import graph, relevant-test signals, and configured-authority `python.types`; exactly one mypy or Pyright authority runs per project, while absent/conflicting/unavailable authority remains degraded. Opt-in `python.ruff-lint`/`python.ruff-format` add receipt-backed findings and activation-aware degraded status. Python readiness and parity remain gated on later release evidence. - Unsupported language census with no fake findings. -Do not ship headline claims for generic complexity, TS complexity, Python code analysis, Go/Java analysis, security, cross-repo percentiles, automatic fixes, or old-tool replacement. +Do not ship headline claims for generic complexity, TS complexity, Python code analysis, Go/Java analysis, security, +cross-repo percentiles, or automatic fixes. ## Release Gate @@ -80,18 +81,19 @@ Do not call the alpha ready until current evidence proves: - `npm run build` succeeds from a clean checkout. - `npm run release:hygiene` passes with launch-facing docs branded as Opcore. - `npm run provenance:check` passes and finds no forbidden public-surface claims. -- `npm run cutover:check` proves installed `opcore` scan/status/check/measure flows and keeps `oldToolReplacementClaimed: false`. +- `npm run cutover:check` proves installed `opcore` scan/status/check/measure flows and repository self-validation. - `opcore try --json` returns `opcoreTry.published:false`. - `opcore --repo . --json` emits the scan artifact allowlist: `.opcore/report.json`; `.opcore/history.jsonl`; bounded `.opcore/telemetry.jsonl` capped at 500 records or 1 MiB. -- `opcore status --repo . --json` does not build graphs, run checks, run setup, install packages, use ACE/current-tool wrappers, or write files. +- `opcore status --repo . --json` does not build graphs, run checks, run setup, install packages, or write files. - `opcore init --repo . --json` previews setup without writing. - TTY `opcore init --repo .` prompts after the scan and setup plan; declining writes nothing. - `opcore init --repo . --approve --json` applies additive setup without prompting and still avoids scan artifact writes. - `opcore check --changed --json` has stable agent exit codes. -- Public docs and package output contain no ASP-standard, old-tool replacement, security/SAST, all-stack, AI-authorship, automatic-fix, or blended-score overclaims. +- Public docs and package output contain no ASP-standard, generic replacement, security/SAST, all-stack, + AI-authorship, automatic-fix, or blended-score overclaims. ## Public Wording Rules @@ -105,7 +107,7 @@ Avoid: - "ASP is the standard." - "Opcore proves the standard." -- "Opcore replaces Rox/CRG/CIX." +- "Opcore replaces your existing engineering policy." - "Works with every stack." - "Python code analysis" as a headline. - "Detects AI authorship." @@ -116,8 +118,7 @@ Avoid: ## Non-Goals - Public ASP standard launch. -- Old-tool retirement. -- ACE-managed distribution. +- External development-tool integration. - Source-editing or automatic-fix product claims. - A SaaS dashboard. - Windows support in `0.2.0`. diff --git a/docs/release/artifact-attestation.md b/docs/release/artifact-attestation.md index 54b479d..c2649ae 100644 --- a/docs/release/artifact-attestation.md +++ b/docs/release/artifact-attestation.md @@ -21,9 +21,8 @@ No package publishing happens in this gate. ## Cutover Gate Issue #30 receipt: docs/release/cutover-receipt.json -Cutover receipt SHA-256: c72be0d79199505770c631b08201be09f8602e239e80c1ffa974e7f24d1cf32b +Cutover receipt SHA-256: 1bdb6d984ad52243be972abb5b061631f4eca65e7d5c5a9eac525569898ef97f Installed command receipts: 28 Rust command receipts: 7 Python command receipts: 8 -Current-tool guardrails retained: 2 -Old-tool replacement claimed: false +Self-validation: passed diff --git a/docs/release/asp-dogfood-receipt.json b/docs/release/asp-dogfood-receipt.json index a2d2d6d..d9543ae 100644 --- a/docs/release/asp-dogfood-receipt.json +++ b/docs/release/asp-dogfood-receipt.json @@ -4261,8 +4261,7 @@ "temp": true, "isolated": true, "sharedStateMutated": false, - "pathSanitized": true, - "aceRuntimeBinExcluded": true + "pathSanitized": true }, "hostFixture": { "repo": "/private/var/folders/cd/q981l5pn1l3gylf5_gddbkbw0000gn/T/opcore-asp-dogfood-nKYzZH/asp-host-fixture", @@ -6889,143 +6888,21 @@ "diagnosticsCount": 1, "hostOwnedFieldLeak": false }, - "currentToolGuardrails": [ - { - "id": "current-tools-validate-changed", - "command": [ - "npm", - "run", - "current-tools:validate-changed" - ], - "status": "passed", - "exitCode": 0, - "stdoutSha256": "c841ab97cd599893cd51807789d41d37c67133d4abd1315aba222fca4c3beae8", - "stderrSha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", - "assertion": "current-tools:validate-changed remains active", - "retained": true - }, - { - "id": "current-tools-validate-rust-graph", - "command": [ - "npm", - "run", - "current-tools:validate-rust-graph" - ], - "status": "passed", - "exitCode": 0, - "stdoutSha256": "37517e5f3dc66819f61f5a7bb8ace1921282415f10551d2defa5c3eb0985b570", - "stderrSha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", - "output": [], - "assertion": "current-tools:validate-rust-graph remains active", - "retained": true - }, - { - "id": "current-tools-validate-all", - "command": [ - "npm", - "run", - "current-tools:validate-all" - ], - "status": "retained-not-run", - "exitCode": null, - "stdoutSha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", - "stderrSha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", - "retained": true, - "assertion": "Retained old-tool guardrail; omitted unless --include-current-tools-all is passed" - } - ], "unsupportedSurfaces": [ { "surface": "inspect", "status": "parity-blocker", "cleanCoverage": false, - "blocker": "ASP dogfood covers Core check/evaluate only; inspect request/response mapping remains outside #120." + "blocker": "ASP dogfood covers Core check/evaluate only; inspect mapping remains outside this receipt." }, { "surface": "edit", - "status": "retained-old-tool-gate", + "status": "parity-blocker", "cleanCoverage": false, - "blocker": "ASP dogfood does not authorize edits or apply behavior; edit parity remains covered by current old-tool and cutover gates." - } - ], - "parityBlockers": [ - { - "source": "docs/validation/rust-adapter-parity.md:26", - "detail": "command receipts, but it does not include maintainer approval to retire Rust retained-tool rows." - }, - { - "source": "docs/validation/rust-adapter-parity.md:28", - "detail": "| Surface | Decision | Evidence | Why retirement is not accepted | Current guardrail action |" - }, - { - "source": "docs/validation/rust-adapter-parity.md:29", - "detail": "|---|---|---|---|---|" - }, - { - "source": "docs/validation/rust-adapter-parity.md:30", - "detail": "| `rust.rustdoc` | retained | #29 receipt records no graph replacement evidence. | Rustdoc diagnostics, broken intra-doc links, and documentation-policy failures remain unique current-tool evidence. No installed-artifact receipt plus maintainer approval proves exact replacement. | Keep current external Rust guardrails active for rustdoc coverage. |" - }, - { - "source": "docs/validation/rust-adapter-parity.md:31", - "detail": "| `rust.import-graph` | deferred | #29 records Rust graph `IMPORTS_FROM`/`DEPENDS_ON` facts; #30 records installed Rust graph build/query/impact/review-context/detect-changes/search receipts. | Graph facts are useful parity evidence, but rustdoc and cargo-depgraph-enriched import checks remain retained where graph facts are not sufficient. No maintainer approval flips this row. | Keep current external import-graph guardrails active while native graph evidence complements them. |" - }, - { - "source": "docs/validation/rust-adapter-parity.md:32", - "detail": "| `rust.dead-code` | retained | #29 records exported symbol metadata and graph-backed dead-public-export signals. | Cargo `dead_code` diagnostics and compiler reachability remain uniquely provided by current tools. Graph dead-public-export evidence is not exact replacement evidence. | Keep current external dead-code guardrails active. |" - }, - { - "source": "docs/validation/rust-adapter-parity.md:33", - "detail": "| `rust.unused-deps` | retained | #29 records no graph replacement evidence. | Cargo-udeps unused dependency analysis remains the unique evidence source. | Keep current external unused-dependency guardrails active. |" - }, - { - "source": "docs/validation/rust-adapter-parity.md:34", - "detail": "| `rust.function-metrics` | retained | #29 records Rust function/method spans and signatures; #30 records installed Rust graph receipts. | Rust-code-analysis complexity, line-count, and parameter-threshold metrics remain unique current-tool evidence. Spans/signatures are not exact metric replacement evidence. | Keep current external function-metric guardrails active. |" - }, - { - "source": "docs/validation/rust-adapter-parity.md:35", - "detail": "| `current-tools:validate-rust-graph` | retained | #29 records the aggregate Rust graph guardrail as retained; #30 Rust receipts are graph-owned installed command receipts. | No receipt proves an exact aggregate replacement for the current-tools Rust graph gate, and no maintainer approval retires it. | Continue running `npm run current-tools:validate-rust-graph`. |" - }, - { - "source": "docs/validation/rust-adapter-parity.md:36", - "detail": "| Rust portion of `current-tools:validate-changed` | retained | #30 installed `opcore check changed` receipt uses `--checks typescript.syntax`; #29 carries only Rust comparison evidence with `oldToolReplacementClaimed: false`. | The installed changed-check receipt does not exercise Rust retained-tool coverage, and no Tom approval flips Rust changed-file guardrails. | Continue running `npm run current-tools:validate-changed` for changed Rust-owned inputs and mixed changes. |" - }, - { - "source": "docs/validation/rust-adapter-parity.md:38", - "detail": "## Native Rust Checks" - }, - { - "source": "docs/validation/rust-adapter-parity.md:40", - "detail": "`@the-open-engine/opcore-validation-rust` exports these provider assessment checks:" - }, - { - "source": "docs/validation/rust-adapter-parity.md:42", - "detail": "| Check | Native behavior | Retained compatibility |" - }, - { - "source": "docs/validation/rust-retained-tools-receipts-2026-06-23.md:59", - "detail": "## Guardrails" - }, - { - "source": "docs/validation/rust-retained-tools-receipts-2026-06-23.md:61", - "detail": "- No validation daemon." - }, - { - "source": "docs/validation/rust-retained-tools-receipts-2026-06-23.md:62", - "detail": "- No hidden validation cache." - }, - { - "source": "docs/validation/rust-retained-tools-receipts-2026-06-23.md:63", - "detail": "- No Rox imports, Rox cache reads, or Rox shellouts from native Opcore checks." - }, - { - "source": "docs/validation/rust-retained-tools-receipts-2026-06-23.md:64", - "detail": "- Current external Rox gates stay active until downstream #27/#28/#29 accept replacement evidence." - }, - { - "source": "docs/validation/rust-retained-tools-receipts-2026-06-23.md:65", - "detail": "- Results are provider assessments only, not ASP host decisions or release authority." + "blocker": "ASP dogfood does not authorize edits or apply behavior; installed Opcore evidence covers edit behavior." } ], + "parityBlockers": [], "authority": { "hostOwnsDecisions": true, "providerOutputIsHostDecision": false, @@ -7035,15 +6912,25 @@ } }, "publicReleaseActions": [], - "oldToolReplacementClaimed": false, "forbiddenMarkerScan": { "scannedTextCount": 2, "findingCount": 0, "markersBlocked": [ "opcore asp serve", - "opcore asp", - "dist/bin/lattice", - ".ace/runtime" + "opcore asp" ] + }, + "selfValidation": { + "id": "opcore-self-check", + "command": [ + "npm", + "run", + "opcore:self-check" + ], + "status": "passed", + "exitCode": 0, + "stdoutSha256": "98006e4c11c39bfe79e15f629ed3e2f063b9c16f0c7ad5708d7d6e855614c01d", + "stderrSha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "assertion": "Opcore validated its own changed implementation surface" } } diff --git a/docs/release/asp-dogfood-receipt.summary.md b/docs/release/asp-dogfood-receipt.summary.md index 53ab95b..d57b46e 100644 --- a/docs/release/asp-dogfood-receipt.summary.md +++ b/docs/release/asp-dogfood-receipt.summary.md @@ -1,26 +1,7 @@ # ASP Dogfood Receipt Summary -Issue #120 receipt for advisory standalone ASP manager dogfood. +Issue #120 records advisory standalone ASP manager dogfood over the installed `opcore-asp-provider --stdio` process. -Machine receipt: docs/release/asp-dogfood-receipt.json -Machine receipt SHA-256: cf3808f44372b2ba4a3180717ea50f107a1c20c7d32772818ea25e524b68d514 -Bootstrap source: local-sibling -Repo enrollment mode: advisory -Host fixture repo: temporary -Host fixture changed paths: src/dogfood.ts -Source repo mutated: false -Provider command: opcore-asp-provider --stdio -Host assurance: gated -Transaction guarantee: none -Old-tool replacement claimed: false - -| Guardrail | Status | Exit | Evidence | -|-----------|--------|------|----------| -| current-tools-validate-changed | passed | 0 | current-tools:validate-changed remains active | -| current-tools-validate-rust-graph | passed | 0 | current-tools:validate-rust-graph remains active | -| current-tools-validate-all | retained-not-run | not-run | Retained old-tool guardrail; omitted unless --include-current-tools-all is passed | - -## Deferred Coverage - -- inspect: parity-blocker; ASP dogfood covers Core check/evaluate only; inspect request/response mapping remains outside #120. -- edit: retained-old-tool-gate; ASP dogfood does not authorize edits or apply behavior; edit parity remains covered by current old-tool and cutover gates. +The receipt proves temporary isolated host state, installed provider identity, host-owned decisions, provider +assessment provenance, and `opcore:self-check`. Inspect and edit remain explicit parity blockers for this check-only +dogfood receipt. diff --git a/docs/release/crg-graph-parity-ledger.md b/docs/release/crg-graph-parity-ledger.md deleted file mode 100644 index 984aeb9..0000000 --- a/docs/release/crg-graph-parity-ledger.md +++ /dev/null @@ -1,71 +0,0 @@ -# CRG Graph Parity Ledger - -Issue: #53 - -Status: CRG graph parity is demonstrated for `opcore graph`; replacement remains deferred. - -## Scope - -This ledger enumerates CRG-to-`opcore graph` parity from retained receipts only. It is release evidence, not a public retirement claim, npm publish action, ASP authority claim, or ACE wrapper cutover. - -Receipt sources: - -- `docs/release/graph-release-receipt.json` issue `#17`, regenerated with in-repo fixture `packages/fixtures/source-extraction/wave1`. -- `docs/release/cutover-receipt.json` issue `#30`, generated `2026-06-27T10:22:13.799Z`, installed `node_modules/.bin/opcore` proof with `environmentIsolation.opcoreBinOnly: true` meaning only Opcore-owned bins are exposed and `environmentIsolation.oldBinsAbsent.{crg,cix,rox}: true`. -- `docs/release/asp-dogfood-receipt.json` issue `#120`, generated `2026-06-26T19:04:19.583Z`, with `oldToolReplacementClaimed: false` and retained current-tool guardrails. - -## Issue Namespace - -The graph fixtures and receipts use covibes-tree issue numbers for older graph-release work. In that namespace, `#13`, `#14`, `#15`, and `#16` are graph optional-surface children of graph parent `#4` and graph-release gate `#17`: coverage, flows, communities, and read-only suggestions. - -Those numbers collide with the Opcore GitHub issue namespace. This document is Opcore issue `#53`, under Opcore epic `#13`; do not map covibes-tree `#13`-`#16` to Opcore-tree epics. - -## Implemented Surface Ledger - -| Surface | Status | Receipt evidence | -|---|---|---| -| `opcore graph build` | implemented | `graph-release-receipt.json` `commandCoverage.id=opcore-graph-build` on `wave1`; `cutover-receipt.json` `commandReceipts.id=graph-build`, status `ok`, exit 0 from `node_modules/.bin/opcore`, assertion `graph build completed with native artifact`. | -| `opcore graph update` | implemented | `graph-release-receipt.json` `commandCoverage.id=opcore-graph-update`, passed exit 0 in 664ms on `wave1`. The cutover receipt does not duplicate update. | -| `opcore graph watch` | implemented | `graph-release-receipt.json` `commandCoverage.id=opcore-graph-watch`, passed exit 0 in 609ms on `wave1`. The cutover receipt does not duplicate watch. | -| `opcore graph status` | implemented | `graph-release-receipt.json` `commandCoverage.id=opcore-graph-status`, passed exit 0 in 619ms on `wave1`; `cutover-receipt.json` `commandReceipts.id=graph-status`, status `ok`, exit 0, assertion `graph status available after build`. | -| `opcore graph query` | implemented | `graph-release-receipt.json` `commandCoverage.id=opcore-graph-query`, passed exit 0 in 603ms on `wave1`; `cutover-receipt.json` `commandReceipts.id=graph-query`, status `ok`, exit 0, assertion `graph query returned facts`. | -| `opcore graph impact` | implemented | `graph-release-receipt.json` `commandCoverage.id=opcore-graph-impact`, passed exit 0 in 617ms on `wave1`; `cutover-receipt.json` `commandReceipts.id=graph-impact`, status `ok`, exit 0, command includes `--files src/components/GreetingCard.tsx`, assertion `graph impact returned file impact`. | -| `opcore graph review-context` | implemented | `cutover-receipt.json` `commandReceipts.id=graph-review-context`, status `ok`, exit 0 from `node_modules/.bin/opcore`, command includes `--files src/components/GreetingCard.tsx`, assertion `graph review-context returned related facts`. The graph-release receipt does not include review-context. | -| `opcore graph detect-changes` | implemented | `cutover-receipt.json` `commandReceipts.id=graph-detect-changes`, status `ok`, exit 0 from `node_modules/.bin/opcore`, command includes `--files src/components/GreetingCard.tsx`, assertion `graph detect-changes returned typed change data`. The graph-release receipt does not include detect-changes. | -| `opcore graph search` | implemented | `graph-release-receipt.json` `commandCoverage.id=opcore-graph-search`, passed exit 0 in 644ms on `wave1`; `cutover-receipt.json` `commandReceipts.id=graph-search`, status `ok`, exit 0, command `graph search Greeting --limit 5`, assertion `graph search returned ranked results`. | -| `opcore graph serve` | implemented | `graph-release-receipt.json` `commandCoverage.id=opcore-graph-serve`, passed exit 0 in 555ms on `wave1`; `graph-release-receipt.json` `serveTransport` passed ping/status/query/search/shutdown over `opcore.graph.daemon`; `cutover-receipt.json` `commandReceipts.id=graph-serve`, status `ok`, exit 0, assertion `graph serve status route is ready`. | - -Combined receipts cover all 10 required surfaces at least once. `graph-release-receipt.json` covers build, update, watch, status, query, impact, search, and serve. `cutover-receipt.json` covers build, status, query, impact, review-context, detect-changes, search, and serve from installed artifacts with old bins absent. - -## Deferred Optional Analyses - -Optional graph analyses remain non-blocking and deferred in `packages/fixtures/graph-reference-evidence/manifest.json` and `docs/release/graph-release-receipt.json`: - -| Covibes-tree issue | Surface | Classification | Status | -|---|---|---|---| -| `#13` | coverage | deferred | deferred | -| `#14` | flows | optional | deferred | -| `#15` | communities | optional | deferred | -| `#16` | read_only_suggestions | supporting | deferred | - -These rows are not required for CRG graph parity and must not be read as Opcore epic dependencies. - -## Fact Model Evidence - -`packages/fixtures/graph-reference-evidence/golden-corpus.json` exercises the TypeScript graph corpus with 7 nodes and 6 edges: node kinds `File`, `Function`, and `Test`; edge kinds `CALLS`, `CONTAINS`, and `TESTED_BY`. `IMPORTS_FROM` is declared by the SQLite fixture but not exercised by the golden corpus. The current SQLite fixture also declares Rust-ready graph kinds and edges, but does not declare `Class` or `Type` as node kinds. - -Export metadata is represented by the SQLite `nodes.is_exported` column and `idx_nodes_exported_name` index in `packages/fixtures/graph-reference-evidence/sqlite-fixtures.json`. The reference fixtures do not require `attributes.exported` as fixture evidence. - -The graph-release receipt records direct SQLite reader evidence for status counts, edge counts, impact edges from file, search-by-name, and freshness metadata against `packages/fixtures/source-extraction/wave1/.opcore/graph/graph.db`. - -## Replacement Claim - -CRG graph parity is installed-receipt-backed: `cutover-receipt.json` runs `opcore graph` through `node_modules/.bin/opcore` while `environmentIsolation.opcoreBinOnly` confirms only Opcore-owned bins are exposed and `environmentIsolation.oldBinsAbsent.{crg,cix,rox}` is `true`. - -The formal old-tool replacement claim remains withheld. `asp-dogfood-receipt.json` pins `oldToolReplacementClaimed: false`, records retained `current-tools:validate-changed` and `current-tools:validate-rust-graph` guardrails, and keeps inspect/edit gaps outside ASP dogfood authority. ACE wrappers remain on current tools until explicit downstream cutover work changes that. - -Ledger state: CRG graph is `parity-demonstrated`; replacement claim is `replacement-claim-deferred`. - -## Cross Dependencies - -Rust graph parity rows are tracked by Opcore epic `#10` children: `#26` build/update/watch/status, `#27` query/search/impact/review-context/detect-changes, `#28` graph-backed validation, `#29` old-Rox comparison receipts, and `#30` retained-Rox retirement. This ledger references those rows and does not duplicate their acceptance evidence. diff --git a/docs/release/cutover-receipt.json b/docs/release/cutover-receipt.json index 06eea20..d8048d5 100644 --- a/docs/release/cutover-receipt.json +++ b/docs/release/cutover-receipt.json @@ -2,8 +2,8 @@ "schemaVersion": 1, "issue": "#30", "origin": "covibes-authored-cutover-proof", - "generatedAt": "2026-07-18T06:36:28.550Z", - "commitSha": "2d4e920e0b08a989953486b261327ed7496c328b", + "generatedAt": "2026-08-07T17:45:44.810Z", + "commitSha": "6ed9e0e5b48acdc298af1a2e1af1a59aae32ac78", "privateRepo": true, "packageNames": [ "opcore" @@ -14,7 +14,7 @@ "version": "0.2.1", "tarball": { "filename": "opcore-0.2.1.tgz", - "sha256": "f3c4478eca2a9a1118b10bdabdfa009dfed0bdd8903312f06c42a8b60515b128" + "sha256": "4c8962af2364d101587162ca56b4797eab5bea8a87738b8721a2734fa26cb483" }, "installedManifest": { "path": "node_modules/opcore/package.json", @@ -95,7 +95,7 @@ }, { "path": "node_modules/opcore/dist/advanced/asp-warm/warm-project-registry.js", - "sha256": "9a77612096d3e22a7ac8bebd52224bf5924409998bc0a264c9da8df02684bbee" + "sha256": "9cb81d931a7bd2052b7f99c22bec2fbfa988a8c29793dd75ac6094894040bc3f" }, { "path": "node_modules/opcore/dist/advanced/descriptor.d.ts", @@ -163,11 +163,11 @@ }, { "path": "node_modules/opcore/dist/advanced/inspect-language-service.d.ts.map", - "sha256": "9474ad4e5880a163ab7b6bbdffe51a0905b417e3575debc47199b20231ec5fe5" + "sha256": "4063a317bf3c82e1af2b74178de7d2129bf2f181c78b7f4542a6129163672684" }, { "path": "node_modules/opcore/dist/advanced/inspect-language-service.js", - "sha256": "8fe5e18238e0f0dfc005953dca723dc88cda350bb0d4cfb4b1283644ec5788f2" + "sha256": "5bb5d0d5db70859d0d236448045ea32cbc3f5eb719c7f256b48cd9010e0f42ba" }, { "path": "node_modules/opcore/dist/advanced/manifest.d.ts", @@ -195,15 +195,15 @@ }, { "path": "node_modules/opcore/dist/advanced/validation-composition.d.ts", - "sha256": "b7c28a6e21cbd3be1460bfed8f7df125eca47bbc4c1b4ddbefc311ab108ac370" + "sha256": "19b789ea43d428cf956da86979f61fbd34eca814122f698e21df8c3a5b807d0d" }, { "path": "node_modules/opcore/dist/advanced/validation-composition.d.ts.map", - "sha256": "ad06345d267c837f10334addb62d30389670129b49c9dcfbdd07b1ecb6398f87" + "sha256": "58c641c54f25fa5851c858466edea4bf4453e3307176a4d3d01e36ba07891572" }, { "path": "node_modules/opcore/dist/advanced/validation-composition.js", - "sha256": "c054f94d89a88cb0bb4ae8ef0440f766cb1f17c81a72d03ae156e4a7c907696b" + "sha256": "c32698ecac5863bbe7350dce480da7b7304b445fd8e815b208fe0ed064db2e7a" }, { "path": "node_modules/opcore/dist/agent-gate.d.ts", @@ -263,11 +263,11 @@ }, { "path": "node_modules/opcore/dist/doctor.d.ts.map", - "sha256": "110d344818ad031b1de8ccd69380671881cf80ba310df8b9f2ae85110eabaf8c" + "sha256": "e6d68cd0338091a9a8fb5c049265b2517efa618bbd8e90d10e5240514b9552c8" }, { "path": "node_modules/opcore/dist/doctor.js", - "sha256": "a5f8c2f24d4ac799a96b73bb0c43d52d1f287d969ee0081f25682b7b5a23c825" + "sha256": "8b3803eb6457f005e480ea5d7d4cd4dcf4a97c742ad1baa8714c570f8964a2ed" }, { "path": "node_modules/opcore/dist/graph-provider-client.d.ts", @@ -303,7 +303,7 @@ }, { "path": "node_modules/opcore/dist/init.js", - "sha256": "b0d1a394fd7f0cfd142a9cf5f0245becc6fb3baa687e6dbacc83046ff05dc7e7" + "sha256": "3274aa8778ab425d5fb84421bc9746d79a6ba393a0a3194446db7b124a0b93e0" }, { "path": "node_modules/opcore/dist/install-wizard-screen.d.ts", @@ -339,7 +339,7 @@ }, { "path": "node_modules/opcore/dist/json-output.js", - "sha256": "87f9c35eaafe9c3f917f18c3fb083acf5ac3a7a2cef42b78e8904bd4cfef9852" + "sha256": "003a14cede6abaa521ad271ca7c78c526778cf09c4975ce1f3f6cb15369ec5d9" }, { "path": "node_modules/opcore/dist/path-policy.d.ts", @@ -419,11 +419,11 @@ }, { "path": "node_modules/opcore/dist/reporting.d.ts.map", - "sha256": "f9ff1b0f1cab4481adaf18822d3fe23c949d00fc209f65e333da1967a381e227" + "sha256": "450ee9b50c4201bc6183152f916b3b2464961f11f5075c2936d9d149c813d4f3" }, { "path": "node_modules/opcore/dist/reporting.js", - "sha256": "2d879ead468ed0c95cd6d7c7a62d8fe0e11290af300c137d5ff3459659abaec6" + "sha256": "7f624cb51afeb9b2e25627ccbe7be898167e42502dacba5b2ee893cc3b025157" }, { "path": "node_modules/opcore/dist/router.d.ts", @@ -499,15 +499,15 @@ }, { "path": "node_modules/opcore/dist/source-policy.d.ts", - "sha256": "2a2c097a2df95010ad184898a1dc78b52821e155943573d4aabf0e5f43e81ed2" + "sha256": "8c3d16c4d6a851a964be5e40186449f6d6a11a76bb71503cce3e7e75f63a7694" }, { "path": "node_modules/opcore/dist/source-policy.d.ts.map", - "sha256": "3aa5b007c23983b575d02871ec4fef71bc87c57ef0e30c1ce44d07593f4223ac" + "sha256": "1089259f39d3c5e2d6404a4bfc5ce51a35136acefc1b81a48069d82ecbb9ce8d" }, { "path": "node_modules/opcore/dist/source-policy.js", - "sha256": "f66c2416b961cdc546a6813f521fe98876f195b21efb9053a8206962a7c4873e" + "sha256": "9fc5220cb1660071c2c199eeb336a98e7a3e6cdc85816eb807dbfda9253cc2df" }, { "path": "node_modules/opcore/dist/status-args.d.ts", @@ -599,11 +599,11 @@ }, { "path": "node_modules/opcore/dist/status-state.d.ts.map", - "sha256": "f82dec0779e45cc9b95d605dfbaf3ca353dcf0f5a663dfb9a3127b386f6eaaf0" + "sha256": "1ac635de74d0be13bf50c300312ad6baa45aee73feffd88d7f2f75cdd7729bdb" }, { "path": "node_modules/opcore/dist/status-state.js", - "sha256": "b4532b946efb07e53a00da79a0747d2db7907141b12b2a485a5faa47b82c08c2" + "sha256": "d797acfb868c457c702e65372c135616d30c9fdf161b28a9c766ca2d3f9fd744" }, { "path": "node_modules/opcore/dist/status-validation.d.ts", @@ -611,11 +611,11 @@ }, { "path": "node_modules/opcore/dist/status-validation.d.ts.map", - "sha256": "da0652f715ccd0963f66ac5e3fcb097ad014c6583c638b76f9e2c15dc0e32fd8" + "sha256": "4be041522e1b64e0e269811392232788cc24918b86adb7a9441f82551ec1e213" }, { "path": "node_modules/opcore/dist/status-validation.js", - "sha256": "c067d6433a5e1ea96df0ffcbfdb4f1aa82264505c8aed395239cabe63e4ec5ac" + "sha256": "e90b2bbe6bdf1855c55ebb5f73a0b4e61912edb89c091a910dba7c574679df47" }, { "path": "node_modules/opcore/dist/status.d.ts", @@ -667,15 +667,15 @@ }, { "path": "node_modules/opcore/dist/validation-composition.d.ts", - "sha256": "64becc3c5f557aba3672e006b6e060dac62f6f88fe2371ac62fc2947cf1cd14e" + "sha256": "5989aa61c266566b7ef4957e47f52419fdbc49c9c17f0a97c325b17ce1446f92" }, { "path": "node_modules/opcore/dist/validation-composition.d.ts.map", - "sha256": "8e5cd3d5b27a4d3c0b2ecfd730f29fd8ead5e23b3d89dde15960ece728281a95" + "sha256": "c0982d3aabf7329e861e86eef7920727a166b2d5be17af0f06134710e35b8901" }, { "path": "node_modules/opcore/dist/validation-composition.js", - "sha256": "6dbde57aa4eb9db2a859279648a1785428b27329d8e222a70428172a62eaa912" + "sha256": "ec437daecd199278e75f3263aac171a6b4b7cbd8fbef0895a669fa48ca959cf8" }, { "path": "node_modules/opcore/dist/validation-graph-session.d.ts", @@ -751,11 +751,11 @@ }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-asp-provider/dist/mapping.d.ts.map", - "sha256": "81081b573cd9498756c5048399ce6cc60189d6a4ba7aac8c58d7a87559f0b077" + "sha256": "0550ca9636befc772eb002e03597bc303ff23e173778edceb9db5707b66ef09d" }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-asp-provider/dist/mapping.js", - "sha256": "bbc7e9b39d9dfd0b309442584fbd7895ee1f5942878e1fd160ae7dc507c6daed" + "sha256": "d742e133c01bc029da401f30bf2a4edca2d6f04335e7248b751f735148285a60" }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-asp-provider/dist/protocol.d.ts", @@ -771,15 +771,15 @@ }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-asp-provider/dist/validation-composition.d.ts", - "sha256": "adab11e92e36d1f34d9b37757181b1527fbe9a571ba683c1aae62d8faf78b9d0" + "sha256": "6346cc8fd2ad7cb5e0cccd103d04d5c5fbe84a7e78eaefbd2127d5315d241fdf" }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-asp-provider/dist/validation-composition.d.ts.map", - "sha256": "3124e89e18f9c5370e5f1fe2e81b8a45a11fa1433c8ae4019e265100fe9bad01" + "sha256": "91976c441366599b275ec2798a4dde56e1e3356b6cb08c87702130d056fd2696" }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-asp-provider/dist/validation-composition.js", - "sha256": "197af4d677cee31ead9c7803788bf39f82f9e3e46db382dc927f4701ec62003b" + "sha256": "5ba81fb375317201181446063914a48902578333fb5980008cbc092ff4b5a6a8" }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-asp-provider/dist/workspace.d.ts", @@ -799,19 +799,19 @@ }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-asp-provider/README.md", - "sha256": "22f9bc36b92f6640267ae5ac7354831dbbe8f9771feed2e05ac9399e7357cc03" + "sha256": "71f4c594bb69c53369b75d8424de283ab9f26e55ba4f0bdeab857a8cb0055071" }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-contracts/dist/index.d.ts", - "sha256": "04c3529642808722491a610d95594d0a9d0f5c7bd480c86aec30c10da7ec4e60" + "sha256": "f1bea3ef560016e90fa0f7d6341faa07242c4ea7e900a9a2a9bf5fb00a3947ac" }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-contracts/dist/index.d.ts.map", - "sha256": "19ef90f6f5171dbfd0fa0131642a15bdf0b2b8b9060d2876d1c7312edb036790" + "sha256": "9f3f3814e6c596f1b9ddb3a0c4967ca70b8607274f81ebca30924443dbd2b96e" }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-contracts/dist/index.js", - "sha256": "c9d59ede565c92e4488c185223c96575ff8fa3adf9e34b99959e4d41a4949a25" + "sha256": "3a0a59407189ef2a1edfb6c2c697d493eaadee83e9e33ae0652d934dc94b24a2" }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-contracts/package.json", @@ -823,7 +823,7 @@ }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-contracts/schemas/opcore-contracts.schema.json", - "sha256": "0db053e2dec21f3143dcff59c7aa26ad5a8c4f9eadb5e34f110dcfffcb32eba7" + "sha256": "6358fe4ec5a4c37b13679460fa6d91ff582f75ac36eadd30b22d8261cc925000" }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-edit/dist/atomic-writer.d.ts", @@ -975,11 +975,11 @@ }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-edit/dist/path-policy.d.ts.map", - "sha256": "5fb6eefd43d8ae9e92549242f465e6d959075e9fa8751719bd9b9affa9c906a2" + "sha256": "1b4813762b5969ca45d761fd9881c2d25e1272f1af5f0f039b0e8a6687c737a4" }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-edit/dist/path-policy.js", - "sha256": "230639081d9b2a4acf54f5e883f1f2dc8ec9fc330a57ba9189080959c075cf5b" + "sha256": "244b2fbc4d2ec4af40c4cdd10e301c8b2eddee5e5916472daa769a0b8c221191" }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-edit/dist/planner.d.ts", @@ -1459,15 +1459,15 @@ }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-policy/dist/factory.d.ts", - "sha256": "5233652fd7c9ac59a9ffdada309e76d451dd3a73d6dadf7dc53977596808120a" + "sha256": "d18c4014f16b748582921a21b668ad74249cf6386545928bfe81e32cdfc510a3" }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-policy/dist/factory.d.ts.map", - "sha256": "03192673e6cba4665c2b6beaa4613bc81996273f044bfef56cd1760f9f3f75d6" + "sha256": "9f92bca9342f3f540d97700aec679ccc7196bfdcc016ddfd63ab107b1604bad6" }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-policy/dist/factory.js", - "sha256": "070be0a2f4a0c50271424eff9fff29768440d2f151f04de582fe57a083895493" + "sha256": "4dcbe0e426d01fd1cd1b3922b2832e1366b46ecf32992fff95f5447de451c2a3" }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-policy/dist/index.d.ts", @@ -1527,15 +1527,15 @@ }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/check-ids.d.ts", - "sha256": "ae2f204719f2bd127a0ef22b2c0f9581fea6cc033a26ee360c994ee42bcbaf50" + "sha256": "22a13912355e6b958b67873c813b28c6b06f9c1c0eb30792f6b49495eb8227e0" }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/check-ids.d.ts.map", - "sha256": "2413c3099dcb5f56aef778c45a1f07ffe65acd79eeabbdafb0726f2092eae16b" + "sha256": "dce4c35f5460ce9ee6495a45f0bf67fdd5f63df98927431ffc4835552b14b03f" }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/check-ids.js", - "sha256": "5e1aef29466da1e49a13fa0f04c63eede39d6da3f686f946ad5c11afd90c8b35" + "sha256": "21bc0dfabc1211abb3c7d1842497105b0070f0d0ff4d757ef18f5392a9b9364a" }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/compiler-protocol.d.ts", @@ -1575,15 +1575,15 @@ }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/environment-resolution.d.ts", - "sha256": "d93493b07da4e4a2325a0838e19077a9ae534487821573b78540b03780858a84" + "sha256": "79aa9267d234f0bf71633700f9788158dc9b7c73d53b17d65a9cc1062a408a7b" }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/environment-resolution.d.ts.map", - "sha256": "7667de8584b4fb91bbcba8722e793f13ce8f95be2c02f8565703bbeba702701f" + "sha256": "336880a2159aa62bfec303ac24e8fe403ed50d902611277e4ae28a094fb2de37" }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/environment-resolution.js", - "sha256": "46f548c9c8ab37ef21f734b8016dd95491bd749b5ea2e5ba7e4ee62768898cc7" + "sha256": "bfb558cb41b0eb796b44e1eae62914894a0dbdda536cbb10d6d92352eaced97b" }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/graph-requirements.d.ts", @@ -1623,15 +1623,15 @@ }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/index.d.ts", - "sha256": "2297dce38a1d39b7bffd466a31060066ddb79c4ccfc5752d4ab191a29770575f" + "sha256": "96fcd62da36d9128a802e5e7e784964211a91ba2d370e889f6bd6e0952aefe7a" }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/index.d.ts.map", - "sha256": "b688ca7b040abb33d556b247d611a127b4e75207665abf4408f63bd2c33caefd" + "sha256": "03af26deadbad7582237cfda4e925d66afe4852d15bd85242fd1fbb2c9cc5f79" }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/index.js", - "sha256": "fffab3bfa9eb10a5ca8675a896a85c8c7a7f946d771be5caa306ffc5205e4a08" + "sha256": "84124a91b8ed7f1e6b6d104436e4aa50e6b2dc461ce9a4ebc2f33a49fbfd86e6" }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/ini-config.d.ts", @@ -1645,6 +1645,18 @@ "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/ini-config.js", "sha256": "c05d3fcbe7f85594f3aeaa6436ba8b120afea6f6ab1f7e3d76c161a668a36c75" }, + { + "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/materialized-workspace.d.ts", + "sha256": "85c2e34ca8f5f7776def9eb9332299b0b619ae5fa979c8d861a395c0a1b210bb" + }, + { + "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/materialized-workspace.d.ts.map", + "sha256": "d48ef9f5abdb7514be07ce9d688d6624f8c2d2a3a6796bb958c888a040f6a48c" + }, + { + "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/materialized-workspace.js", + "sha256": "243d370f16625cf3473f906b098d91059e7d8b2f1467cf9c1b62d8c368b817e0" + }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/mypy-config-values.d.ts", "sha256": "67d28edcf2f8e696a577c9afd417d3fd41b41de46388f70cda8498315e92d8bc" @@ -1719,39 +1731,39 @@ }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/process.d.ts", - "sha256": "e4f9b2956b4dd6c50206eefa8440ed0e2f1e5d9cab455439478da4753f99287f" + "sha256": "74aa56b2cbb2b1f430c6fd81d998537396c943f7ee025a2f3c0c286f92a7b579" }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/process.d.ts.map", - "sha256": "69efd4c6155f1dd23c0d04aeaf60d4e28affe4ba12db983285685cca7b17cee5" + "sha256": "c549ddef9eec50fa3c1baaf1d0cd0291322a2efe8943ca49b78b8fa805210458" }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/process.js", - "sha256": "7cb40898ec8dae8c7beab0f4170278b10edcd159095eef964941e03597cd5976" + "sha256": "5d632b5ae123a1da4f41b25651cb493dabffe2d229349a6aecc5cac967ba4229" }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/project-config-files.d.ts", - "sha256": "497823250ecde82b9e744adf7689819179e161f1f1bbdcd831b925c4236e3f55" + "sha256": "5e7f925e3b1634b171afd2e2822a37996a13f95d9f5ca2d576bea302118eee2b" }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/project-config-files.d.ts.map", - "sha256": "754de277453cf78a68991966b34b4e63f6b298376bee40281e04f86e4a17e880" + "sha256": "7d0fff39d043626edcd76d2d21c8ab3a3f89c76fef1fadff27df94adb10aaeab" }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/project-config-files.js", - "sha256": "f9712fcf234ac93a6490d2b97281608c2333b68b45f9c5c27d93aade61105b51" + "sha256": "af05beeb33f98ad54aa979a7067bd2b34309fa3f371e9023201a5c2647b6161f" }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/project-context.d.ts", - "sha256": "773241c9fd9b80e352d7ce53ac908d3ccd021dbae17bd0f3da5346aed08b7575" + "sha256": "809be7d86d5edc022c663aff9f7e0ffbdb7807ef5e338fb7f7b62fa515f389e9" }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/project-context.d.ts.map", - "sha256": "c8a313303530b707f6c47e77ab091d0a459ef7f3c17c4e83229871710319c6d1" + "sha256": "41a77e90bd7717ab83e50a97e5d48220ba52c6b66b7d8ba50643e0a484cc8b93" }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/project-context.js", - "sha256": "766dc99fd6999254f0970f27c5e6f553c67eed34f911adc9e849def397606162" + "sha256": "c2c59c8850f296a0928ba7176523d36ad5804e5465c7cfa72a720977cd2343ba" }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/project-discovery.d.ts", @@ -1777,17 +1789,29 @@ "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/project-fingerprint.js", "sha256": "0faa887e07a73134622ab29989322637e85af760cf1877ccb90fcd024b93e59a" }, + { + "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/project-groups.d.ts", + "sha256": "924a96f6038d9699c5b8b50957c31140fc10d67a21a5762e87ea7ef6eab679d6" + }, + { + "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/project-groups.d.ts.map", + "sha256": "4c494cdd76b38ae628fab3e04106c426b2f52e6327edb344b5e7e6fe155b772e" + }, + { + "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/project-groups.js", + "sha256": "da86dd25edf4033e9b7cf47c9c99eca17b700253e1b3896b326ec3e9a800ac0b" + }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/project-workspace.d.ts", - "sha256": "84c0b4e365d65451ed5300b2164c92b2116f60eef38521a542a20a0587d024ee" + "sha256": "80b3f817945d2b28c16c766aebd13d7d1ec7a4525c476e6ae1d6f49d8835034d" }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/project-workspace.d.ts.map", - "sha256": "811647d3ceb411ca07805a0d306d2117d37744a69d80d17036db3e51f7d2f7cb" + "sha256": "9d756997e9aeb77816ec2b1d0a144e88130ea737c2f8500e7ed0fbac2134d224" }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/project-workspace.js", - "sha256": "e15bdae3b1ac852273ed31bbc4bf4436d8c9c309e8b7ff1743563a4dfd7e3308" + "sha256": "5a00fab587da5ce725361bd6261f5ac93d21e7bddb7567d9acde861d8fd130aa" }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/protocol-validation.d.ts", @@ -1849,6 +1873,138 @@ "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/pyright-runner.js", "sha256": "0943383eefacbd48d8b31328947f71d321a59811ea4e506ed916f7801f78690c" }, + { + "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/pytest-check.d.ts", + "sha256": "cb50d05c72f1e7d8d13ab79ce93be1b17e189626f9a510b3de14e2fb8313a392" + }, + { + "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/pytest-check.d.ts.map", + "sha256": "98e3cce78f4aca1e2426e9a215e46dc70918ea5199fca4273c8ba03077eea146" + }, + { + "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/pytest-check.js", + "sha256": "f06288de3d8c3c86474b7655bf31772aa1ba4e2aed2942281524a5cdb3b674f2" + }, + { + "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/pytest-hook-source.d.ts", + "sha256": "36a13f0754952b01eef01ab3c238a5da3d2a82fbdf566390fbad76ebabe354da" + }, + { + "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/pytest-hook-source.d.ts.map", + "sha256": "79210dc5f5f2a805edbfeecd1fb07faa53f5a105445eafab32e2f96e7204ddf7" + }, + { + "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/pytest-hook-source.js", + "sha256": "d043590b479d0c93edfd241f475231eda76158a91063d7d4c9c2c4112b1e400e" + }, + { + "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/pytest-process.d.ts", + "sha256": "37599fdde8e0a9b4b2264cc325a2ff36dd17c9a91cec50f2add49313ebdf5dca" + }, + { + "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/pytest-process.d.ts.map", + "sha256": "0b7ce2111a6d44e1fd3918212c468517876bbd72610c80dd53b2fd66794a33c9" + }, + { + "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/pytest-process.js", + "sha256": "cdba85f809bd0e5cf915b3a7cd34afbd7c1b40d669f7d7539774e4be6977d231" + }, + { + "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/pytest-project-runner.d.ts", + "sha256": "0b1c537d63c99175a700b083f158c0a76baf1099b9056c879a7c130c32375587" + }, + { + "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/pytest-project-runner.d.ts.map", + "sha256": "64f319b4be6018223991c69c71d38484cf622786054d4f717c29a083b20a062f" + }, + { + "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/pytest-project-runner.js", + "sha256": "11e9472c9fec2a74a2657ebd3c0ea045c94f669d7bd74e5a47a2b79382765c4b" + }, + { + "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/pytest-protocol.d.ts", + "sha256": "96e3afd4f1b82b38351d1aa9693bccc1c354fb9a1d1ee9edf2ac557641fd9eba" + }, + { + "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/pytest-protocol.d.ts.map", + "sha256": "23bd626fedb1a3d282670306751fe7ecac1a8cc87032816c6cb24db6ef8ac3eb" + }, + { + "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/pytest-protocol.js", + "sha256": "106366b2de8af92390272e96b4b32cda9c69fcae080ed6b93adcf265e240a9a4" + }, + { + "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/pytest-result.d.ts", + "sha256": "c730374bd414f2abdd598fb393076bcef8a1c86f702531a962f6f2a54bde4ec3" + }, + { + "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/pytest-result.d.ts.map", + "sha256": "b8db95aaf743a7a2eb68f2de98fe66add4dbe353322447415f9fb1fad883c422" + }, + { + "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/pytest-result.js", + "sha256": "6c2f149cba17fe48170e2717c5a55017c841f08b36f596b4ada9c7571a0163f3" + }, + { + "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/pytest-types.d.ts", + "sha256": "a2352c0db570885b2535bde03ae56db23a7db53f06e7e603405964e2856d9885" + }, + { + "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/pytest-types.d.ts.map", + "sha256": "20414cc649b7340c593a4efe247f439cc00a3cc8684b7d9ea17c631b5198c006" + }, + { + "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/pytest-types.js", + "sha256": "8e609bb71c20b858c77f0e9f90bb1319db8477b13f9f965f1a1e18524bf50881" + }, + { + "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/pytest-workspace.d.ts", + "sha256": "85e30d92ca1d044d6643b695e76464afdbf8e6de70fdb3b069e8ab659877ebce" + }, + { + "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/pytest-workspace.d.ts.map", + "sha256": "3fa43a0016c84b3b3c19a9e67d6d622c3fd949b3c4ade1144f79abb1484907f2" + }, + { + "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/pytest-workspace.js", + "sha256": "cdb1574e39892a087c0f99a078e6103fea29cfcd5e4d0f74ddae741d4638c957" + }, + { + "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/python-check-result.d.ts", + "sha256": "2993ae8fa2864fb5d055c02f17ac8abf44a040f01437b9e4fd1ebad5102888fe" + }, + { + "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/python-check-result.d.ts.map", + "sha256": "e352e216b30a7fc6e62bc02c8527c2aa6e5a5c2c8949bee607b6b088ebb541fa" + }, + { + "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/python-check-result.js", + "sha256": "ddc7360c4aa632a4b3f21309b94b8fce98f1cebd27546546c620df323c6fd336" + }, + { + "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/python-context-result.d.ts", + "sha256": "066b73b7d936f9d818fc46c75fc557af975c7afa34e9f50e20049b1915c81a71" + }, + { + "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/python-context-result.d.ts.map", + "sha256": "1cf3098f3643481bea362045ec15a57a0a5eaf864231dfa6ec4533df0a47e362" + }, + { + "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/python-context-result.js", + "sha256": "ab9d98df5aa0eaf7c24d26c2f2defc87848a4048d53a156e8b4af39d977f2ffb" + }, + { + "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/python-execution-workspace.d.ts", + "sha256": "5f056d705d63215f19c8660959552851255a8af2c06566abcc8a3bbf882d0783" + }, + { + "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/python-execution-workspace.d.ts.map", + "sha256": "0c4c421516eb6e176be28a3b584d1b0e4eac85c21091acaa5ae7c32b8180bdfe" + }, + { + "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/python-execution-workspace.js", + "sha256": "005eddfbc422fd62d92c646b598b2e9b317e40300e76e5a8d862ec77393c3a9f" + }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/relevant-tests-check.d.ts", "sha256": "997ccad55a27d3b8db02837b0cb3ab87aae977490a5a6010fb972c8767f1447c" @@ -1859,7 +2015,151 @@ }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/relevant-tests-check.js", - "sha256": "79568d4598b33b78ed388499b11038e3004518454d675fd4e95adc72462640a0" + "sha256": "4cb5a5aa64e8fc3a55e762140b5685c50aad9af53d48e5a7353dd42f3b825828" + }, + { + "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/ruff-capability-run.d.ts", + "sha256": "ea50389d268b8d4a978cf7dfb033141046b77a49248ea9e02d3fd6ab4614f963" + }, + { + "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/ruff-capability-run.d.ts.map", + "sha256": "43ca61bddb14b1278a70e7e1b6252193314feaf51622c9fbf53c9a603c8573a0" + }, + { + "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/ruff-capability-run.js", + "sha256": "ccb62c91c0ebabd692a8c380aea9918054f9e29ffc46a3af8c643948afb6b11c" + }, + { + "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/ruff-check-definition.d.ts", + "sha256": "720d616966a15934743ce37183fb84539d232e8b9add2eb274afc2ba73b7c88e" + }, + { + "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/ruff-check-definition.d.ts.map", + "sha256": "05f9d3444cd30e74b96c42e02b4ce49efea0175241ccea11828bf6f32054b3b9" + }, + { + "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/ruff-check-definition.js", + "sha256": "676cd30bea4395a40c691365f69423988b63d53cbcc81b830a817b2d4b9c69f0" + }, + { + "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/ruff-check-shared.d.ts", + "sha256": "648c987049c01116a35fc34b08105915bcdb62134953c32fdc942989d1ddba7c" + }, + { + "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/ruff-check-shared.d.ts.map", + "sha256": "e613c8a622a926cb7de90b1ea0805423b14e62ee7455b98e7cd3bc23b8af0ab3" + }, + { + "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/ruff-check-shared.js", + "sha256": "d08c30bc4c6c5da0dc8a3983ac6c39c5aadc543ca050243c8467e458ad193368" + }, + { + "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/ruff-config-paths.d.ts", + "sha256": "a63d4b4cf7afff1c8e361a83ea4c6bca163d0cc94cd5c911aab4ecf7a0401801" + }, + { + "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/ruff-config-paths.d.ts.map", + "sha256": "d4442d66ebc93fd13d003125f4c0a1a4c2eb360e54717c8368ce912d935967b6" + }, + { + "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/ruff-config-paths.js", + "sha256": "ea3cd5484c0e25e620d87649d49383766ab2773665d90bdc29708202f3614fe7" + }, + { + "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/ruff-config-proof.d.ts", + "sha256": "2b2a9804325752d770ab920abf0fd237ebcc6cebcba384a586ed9f0c7e52aa2a" + }, + { + "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/ruff-config-proof.d.ts.map", + "sha256": "49396dc15490606433180582e125efc588b5b7c956b349fdef027677af24c68f" + }, + { + "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/ruff-config-proof.js", + "sha256": "cbd6d00ebab311ddc997c738b02e98770f5be65d30a73f74d923ce6113b1c95c" + }, + { + "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/ruff-execution-workspace.d.ts", + "sha256": "f619280169af7e475e96a5630ef4b39c9e61a0fc6fecf57bec9cb8e18dd4bc51" + }, + { + "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/ruff-execution-workspace.d.ts.map", + "sha256": "62f839b1a18fbf4208c9d3175fe837216dea027b2bd77e5506fc025ac1e16869" + }, + { + "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/ruff-execution-workspace.js", + "sha256": "874818997de5db6f0627b3e268ca267ecfa14a5b2404f53f415df6b0736c9a7e" + }, + { + "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/ruff-execution.d.ts", + "sha256": "79217274ef1c2affe31112c4a4eebb9464f0c16d81e099c4cd0e622dc33da8f6" + }, + { + "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/ruff-execution.d.ts.map", + "sha256": "01d7302400c81743f2a03edcb710cb5be96a80a33da5382f440a9c56049f1dc4" + }, + { + "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/ruff-execution.js", + "sha256": "2c4eb300bda685f0d7390d1a786875d744fe4e404c2e55eb30f3a94ddec73695" + }, + { + "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/ruff-format-check.d.ts", + "sha256": "78df5b2778ef29ab4675446d72dd48e8e7d9aa777487d99fb4d00ce21ba95bbd" + }, + { + "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/ruff-format-check.d.ts.map", + "sha256": "174d46b3e4a40005206ee19bafb886da4eb49e74827ad1c186df916c635df72f" + }, + { + "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/ruff-format-check.js", + "sha256": "8a12d6c5312c9cb923cbaf5ae7d50bf638eab61d479e39ded75d7b3f099b5058" + }, + { + "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/ruff-format-refinement.d.ts", + "sha256": "cc0b58f6caec8010a536c17353ef8c4b2c1665c156f2e6a01fafc658d04980ce" + }, + { + "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/ruff-format-refinement.d.ts.map", + "sha256": "607e99dd7d8d7bfc53001101bbb2bb4bcd21cbf401e539ca8e0da0c59768b074" + }, + { + "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/ruff-format-refinement.js", + "sha256": "0f3a8da1669162685a594bc385f3a3e9c2cc563085a5182cb8749dbe08a6b130" + }, + { + "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/ruff-invocation-failure.d.ts", + "sha256": "46913a33c505d73987a225bb48c45c5ba4c191788b80d80919cdff5b89e75c61" + }, + { + "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/ruff-invocation-failure.d.ts.map", + "sha256": "5dbb2fbe6eafdcb3a58cfa6e95df15f261e91b2cc73935e77be5df0c798a7d4c" + }, + { + "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/ruff-invocation-failure.js", + "sha256": "45d9f26231260f37e030207ada0b7c6ed96e4a241e3a3108d32f9d9f62ade6d8" + }, + { + "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/ruff-lint-check.d.ts", + "sha256": "45658d88cdcab83eddc27a9cc524f5a90cd83f301242294c2c76b252e24e7adb" + }, + { + "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/ruff-lint-check.d.ts.map", + "sha256": "843f8ab8b12175b1e7ceffd753ff8984f43bffb9f9657a32a002c50fc3fea2ea" + }, + { + "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/ruff-lint-check.js", + "sha256": "0bf9b1e0b192315b211f739faecacef58b233109b33dd305e11f63fb7c482f9a" + }, + { + "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/ruff-lint-output.d.ts", + "sha256": "ad027daf8c03f0bef0dfd3d680df5e56236fbe9dacfd1cafcea9652a79f90832" + }, + { + "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/ruff-lint-output.d.ts.map", + "sha256": "f1f63de27c00955e8e50f03cabed4a3952aa8f2a283b31c606d2857e3735ee67" + }, + { + "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/ruff-lint-output.js", + "sha256": "cf8c290f1283a5ad30d4762b202e1e0e90d4b6527a6895359570198eb48d6ca7" }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/source-closure.d.ts", @@ -1867,23 +2167,23 @@ }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/source-closure.d.ts.map", - "sha256": "71ed3366c6f58aec9fae8bbd6723bb5f908ca2ffb2e284accee7f35ab26418f8" + "sha256": "3709393485a427a04c655070dac4d8b4c3111974a320f733af0c37359029348d" }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/source-closure.js", - "sha256": "eae99dc9a3c633b4f42194cad429b6d32a23efc99b7eff1fd23ffca019b18171" + "sha256": "22398188ced614dd1207efdb0a1d2863b52dea87d179babaadd166ff6550b09d" }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/source-files.d.ts", - "sha256": "dfce5a4343e78ec47df186161a9dab524f58e694cb4cfe57dfca42037da78d60" + "sha256": "fb156543f935d685116686f0ae0283066e54d396225677d3dbd0f4df783a447e" }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/source-files.d.ts.map", - "sha256": "ce06e10a525f1505d57fd2bee542a2c4981a0b838da66f3dc97644935d41d310" + "sha256": "e65d921a4a223d4d6761f283813d5213ceef779d0cba1a847ec1fefeccb464e1" }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/source-files.js", - "sha256": "0eebb0d5e1dccc7fef2b361c8bf7e9bc8e33ba951c04ef2996ea861c62c20350" + "sha256": "0fd5d0784276f059fdc830e430b6faece8710f82910391c309b882c58312d755" }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/source-hygiene-check.d.ts", @@ -1899,11 +2199,11 @@ }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/source-types.d.ts", - "sha256": "936f7ac1e09f675979270813162658eafe32e83cb7027df8c2bf33b69cff0f09" + "sha256": "c4e30d37532a2583473f7c05a6c3aed1191e2626477390e6de4095abf5059805" }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/source-types.d.ts.map", - "sha256": "0fa14033fe5b2eda30e761a9b2c2f56765e1d1c0d5fa176fd9b0362f50964090" + "sha256": "3511af97c05c27ec011dfa285cc009a510da24bb6711121d52a2c19088b53167" }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/source-types.js", @@ -1911,15 +2211,15 @@ }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/static-config.d.ts", - "sha256": "ae01347dc436be0d276a667c405c25e524edbd683857ce56305abc62fd86a490" + "sha256": "a357dc733ddb5a188e0acc6a798d38fa89023ff66a3f52d874b9c5d8268918d3" }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/static-config.d.ts.map", - "sha256": "5a073d331beda8a776bd23f5b34373d1aae6b5faed2916348bd2804257c9a432" + "sha256": "3bb91038153c08ab669b5606845df4fd4db8996ded8789d8cf3ccddde774f486" }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/static-config.js", - "sha256": "97460315c77b2fb5dcc1e01a123ff57db2a939cd7aefa82acfccf1ce9f486e7b" + "sha256": "1463934785c9fe6695ad8e60321fbe57a2b770411451a91e94af3b1be4db13d3" }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/strict-json.d.ts", @@ -1939,11 +2239,11 @@ }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/syntax-check.d.ts.map", - "sha256": "dc9907f32137fd34f8d60a5f236307e4b905a2d58d3adbb42bc28fdabc515402" + "sha256": "a329d2e19829af92b0d4bed0abe528ab719e31b2b22b56271e9af4a04525ea94" }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/syntax-check.js", - "sha256": "f5f3822c3b62e8be40c8dd142aedee02ccf5222863daac785dae9d743e47acaa" + "sha256": "21d54ebffb8563c134db1ea9762ff883ca51b1bed2d4da7cd761900ecfaeceb1" }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/toml-config.d.ts", @@ -1959,15 +2259,15 @@ }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/toolchain.d.ts", - "sha256": "632e02df935c1f62fc0fa93eafa0707352173be6f759b46d62c37915912e7a6c" + "sha256": "d0f6b22cdd8c53964be3ec208a5ae15f59581e7c45ec436a83dca46d4962c92b" }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/toolchain.d.ts.map", - "sha256": "b1f1418bdf728706d1a0699f14803288290c8b1c097038a3e0cbb666aac52a1f" + "sha256": "6c2ea813702b040408cd8e1ded956129b98c9f3dd839580e63d15c62f193d1ce" }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/toolchain.js", - "sha256": "2a6f80bda3f4013d958b448306503713ac8db21ce506d3d46910a723cd254ceb" + "sha256": "96050dbae7525f1a499a4cd9964de76f6f75785991e533bd6615a1c5dbe24f79" }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/type-authority.d.ts", @@ -1983,15 +2283,15 @@ }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/type-capability-run.d.ts", - "sha256": "c82b36087b1339e130b5da9a37a6fdbec38dada50da513d745750617488fbfbb" + "sha256": "e7bbb8377c19243856976c45b8bf953b670e563e319a86b9a3027b09da3fd77c" }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/type-capability-run.d.ts.map", - "sha256": "2ebe915fa8fdc02113bab3616b18b802ea623e0fdd1ab9267fec57e7551fa556" + "sha256": "57777ee8154679dc6a839f2794a5d0b71f451730769b29676a52f18e8c298666" }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/type-capability-run.js", - "sha256": "23088d94769aca2dba534aa2e17b0ee105c45c1971274e7f3d69db81c7cf7014" + "sha256": "3c7d1e6a382a17c9a5c4c22e0b5e091d631df457cda4a2d45d469abe49f3e4ab" }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/type-check.d.ts", @@ -1999,11 +2299,11 @@ }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/type-check.d.ts.map", - "sha256": "871a330554c156f9055b550c356041e5b4cf64e9ad054fc99c520fe22aea0fa7" + "sha256": "e4683237ef37244ff8d7d020c1d1f3c178265d76b4702c423a0d7f565ce4c5bf" }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/type-check.js", - "sha256": "ff167e49fe942cb9addfc83a421533d69d0d7667a9684bc3e7a276115307f544" + "sha256": "646f73a824443c60b13c9647394ec8855dec09d8713b36080a64b8cf66e59534" }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/type-result.d.ts", @@ -2011,19 +2311,19 @@ }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/type-result.d.ts.map", - "sha256": "f03c987bae4fa9cc6a0207ee0a9be3493ba8170f7a69fc6b05e3cd2c7f34faa7" + "sha256": "aab3c680e99504a5022211c5bd8eb8cf3ac1470d00045c9efa35f0c02be5f1a8" }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/type-result.js", - "sha256": "dd0f6587ad116b71589aee2f16a69be7aea4590a935c60d9d2c5f4666885692d" + "sha256": "38b186ff2703d1a06cf8f1b395bc478c72c878e79ca8f5e16259beed79c1d962" }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/type-runner-runtime.d.ts", - "sha256": "29e046656843be57a082d0375d491bea58f2de2e31bb8bc46912820c4e7aaa3c" + "sha256": "d1a41eb965b73f5e8a587bbc35aae79e4a07b6b3eed532f605a827bb20cfd107" }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/type-runner-runtime.d.ts.map", - "sha256": "346a94cc921488a0eaab7d7f4f3d9ea9c28e0998b2a6f3fe16d3b15479cce8a7" + "sha256": "5bea6bcb87fcd3f2d14b155a3f04d27dd58243e20c6d982e43f4398bac0d0f43" }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/type-runner-runtime.js", @@ -2031,11 +2331,11 @@ }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/type-runner-types.d.ts", - "sha256": "1e64ae862d330656cffa2fe63062f270b9ba2608e963fd7220840d65f2bf8f2d" + "sha256": "b4aeda3c500f3fa9966720886651a85ba4469b931bcbe1e838c859ae21e06d10" }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/type-runner-types.d.ts.map", - "sha256": "f316ed6daad0c7598e1975863c1c6e1e4768325922b46da54912d50a4424d25c" + "sha256": "30432e3ede165f83d89603d934dc364bf1eb3d139f7b22bcdb966d63dda0330a" }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/type-runner-types.js", @@ -2223,11 +2523,11 @@ }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-rust/dist/materialize.d.ts.map", - "sha256": "a4a3f8ba61e38e2895d598060aab9152e569f22aa2201471cfc43ba9dd9097ec" + "sha256": "5c8c703e382b6063cdc34ffade5f5d13a9bef42f1af27fa92f428c4e338562ce" }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-rust/dist/materialize.js", - "sha256": "b941f8d84b7638fd3c2081013349e4dfafb4d1dc5510b057be6d837c9820ad64" + "sha256": "66055ea2ec7123d5938d1df1982c35b64a61734694d2e28562d4cbde12bebb78" }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-rust/dist/process.d.ts", @@ -2543,11 +2843,11 @@ }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-typescript/dist/lint-plugin-cache.d.ts.map", - "sha256": "5bc9a67d30a9fcd10ac533ccf6722c7b292211df702879938ae37def8492b37a" + "sha256": "8da4015ba99ba5ac218b0fcbf936fee18dd24ffc9be50c03374ac92bb45b1a31" }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-typescript/dist/lint-plugin-cache.js", - "sha256": "c407b0c0ce2e74430af733c11282039686bc0fa7ecd5f748e4d374a4d81eaa8d" + "sha256": "65bb191dffff0ccc328bd78cee0b358656c37b62a0d3fbd6b726258d36fdfc33" }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-typescript/dist/lint-plugin-check.d.ts", @@ -2639,7 +2939,7 @@ }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation/dist/aggregation.js", - "sha256": "eccddc1c56476df4006b8caf30057c27117851f9c0cd170847678bfc51245204" + "sha256": "c8f1b43339f75d112737074d7f9435396d0a3a7a43389bb1ee819dac4b6c81e5" }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation/dist/command-adapter.d.ts", @@ -2647,11 +2947,11 @@ }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation/dist/command-adapter.d.ts.map", - "sha256": "886e5ff3a6b1c23477e7cb5de2d81e6eebfdbdf7c094537b91f7f75d72dd8559" + "sha256": "73ceb8c78d63b30289bd3eb78518ca23587e85aaf0f6a5a5ada42bd4a19c9dcd" }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation/dist/command-adapter.js", - "sha256": "912810eba24593a0f5439347b62dc67a1ed1aec62f3b7cba960e273523f9e87d" + "sha256": "73120520edd732597a647a8503799044d22405ec1cc96efb4abd912f051b28e9" }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation/dist/command-options.d.ts", @@ -2703,15 +3003,15 @@ }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation/dist/registry.d.ts", - "sha256": "0480cdb8df7b95f68f14f2eba16d273e9fb517cf7e0de0eaa01fedecdadf4563" + "sha256": "8a16a2d6b7e89c0bf3f0bb848e6500ee91c9f5961a83f1d8e2f6a2f9210e6a6f" }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation/dist/registry.d.ts.map", - "sha256": "1e95cbafb5c73ffef6860fd36bd1f0e1784742c0ecbaf5fa8d4904f25429fa84" + "sha256": "c987a7aa035a7f181a679bcc95c6911c8396a75581cbcef4f36c0624c9fcdc72" }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation/dist/registry.js", - "sha256": "7d5b44429cdc512d8dbcdd5fc49169acd855175656ad915864c31b6173c3b5a4" + "sha256": "738d9efd141ca9a42db7346d904d6317d780bd18935bee253e5e690381b784dc" }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation/dist/request.d.ts", @@ -2743,11 +3043,11 @@ }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation/dist/runner.d.ts.map", - "sha256": "ffdf7e8ee511ef92e1162c871e3c4d0b8463b976accda851193539afa4d186b3" + "sha256": "01f599c040069cb7a125f8259ad838d1956fc9a9e795ef23a8c288b5f79b58e9" }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation/dist/runner.js", - "sha256": "76395cb451d9989cfa710fbc9ca7f577e17dd6a754fb4fb61c7d097859ecb928" + "sha256": "289989a6a0604de76a394f4dc33d68388f9dd0702d8f6e87054ed27925e50c13" }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation/dist/scope.d.ts", @@ -5739,18 +6039,9 @@ ] }, "environmentIsolation": { - "currentToolEnvCleared": true, - "clearedEnvVarCount": 5, "pathSanitized": true, - "aceRuntimeBinExcluded": true, - "siblingCovibesExcluded": true, - "opcoreBinOnly": true, - "oldBinsAbsent": { - "lattice": true, - "crg": true, - "cix": true, - "rox": true - } + "siblingRepositoriesExcluded": true, + "opcoreBinsVerified": true }, "commandReceipts": [ { @@ -5767,7 +6058,7 @@ "status": "ok", "exitCode": 0, "binPath": "node_modules/.bin/opcore", - "stdoutSha256": "36113707a91309f5c999cd90122e3da19faa21c67ca2112acce48e2f6be0c46c", + "stdoutSha256": "3c2af8a6115b3d451db3cd1556865c83506181130f80397cdff0c7331ca6d984", "stderrSha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "assertion": "opcore scan wrote read-only report artifacts" }, @@ -5785,7 +6076,7 @@ "status": "ok", "exitCode": 0, "binPath": "node_modules/.bin/opcore", - "stdoutSha256": "423b6b401ac37b261a29e4d87ab163b372e2b4b52b3a4edb4944d931e3a694e8", + "stdoutSha256": "36b59af1489bd5d1adc8c502434e5eef143cb7a738193e6c6643097600c2b336", "stderrSha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "assertion": "opcore status returned repoState" }, @@ -5817,7 +6108,7 @@ "status": "ok", "exitCode": 0, "binPath": "node_modules/.bin/opcore", - "stdoutSha256": "f5131c4730d2d9b7d81fe551589c6d13c21d761467f5a8ff155d762f6901a96c", + "stdoutSha256": "11325fc37e8097b59ef83226f60cdf833f4009a626a11dcdfd890e684f24a28c", "stderrSha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "assertion": "opcore check changed defaulted base to HEAD" }, @@ -5835,7 +6126,7 @@ "status": "ok", "exitCode": 0, "binPath": "node_modules/.bin/opcore", - "stdoutSha256": "c4a21817782a216e4e03ca17b8209e9b154edb1af4a0f181ea18fbe5d5eaf5f2", + "stdoutSha256": "64b81e6c577f416f52d34417fcf228bcb412c9995365ec562c7dae0c5ecdccca", "stderrSha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "assertion": "opcore measure returned read-only report deltas" }, @@ -5853,7 +6144,7 @@ "status": "ok", "exitCode": 0, "binPath": "node_modules/.bin/opcore", - "stdoutSha256": "bfa8573238f3fb5eca7b4bca7cf7ed0c0de2f7f451f7e2d6e43e4021b071c06e", + "stdoutSha256": "62259730c4fe2aeac952350b45906cf3c0e38931c0a177415be54137abdfc198", "stderrSha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "assertion": "opcore try generated local sample repos without publishing" }, @@ -5871,7 +6162,7 @@ "status": "ok", "exitCode": 0, "binPath": "node_modules/.bin/opcore", - "stdoutSha256": "6e3f62a9c661ea71fa4da1491ca21a38fe00b342a420f0b04e651990afe2878a", + "stdoutSha256": "f5338a2ee4bc1bf6732a66974dab391dbe836933da81ad698f784342d3529962", "stderrSha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "assertion": "runtime status reports validation readiness" }, @@ -5889,7 +6180,7 @@ "status": "ok", "exitCode": 0, "binPath": "node_modules/.bin/opcore", - "stdoutSha256": "1145413cee682702c85758a9c6b9f6066c49065e9e67b49b68240b06e6170197", + "stdoutSha256": "85f13337e842a6e14184d3466d66ee34dbe941df11a49d35da85cf60b0158f24", "stderrSha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "assertion": "runtime doctor reports validation readiness" }, @@ -5909,7 +6200,7 @@ "status": "ok", "exitCode": 0, "binPath": "node_modules/.bin/opcore", - "stdoutSha256": "b22a7a73954479eaaf805bbb8d340f95dfda68553e613f73f6d510333885896a", + "stdoutSha256": "5f5717082535d188bc301379ac01bdaf332f835fa72348009025990b7d3356db", "stderrSha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "assertion": "graph build completed with native artifact" }, @@ -5929,7 +6220,7 @@ "status": "ok", "exitCode": 0, "binPath": "node_modules/.bin/opcore", - "stdoutSha256": "324e6d40a039c2aeeac7908ba5abdc46ddd31c849d8aba6a651b885b9f976368", + "stdoutSha256": "76aeb3daa3e632620f2142164b45f4c630e1d5c310fc8350af58f8d27bfaa90f", "stderrSha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "assertion": "graph status available after build" }, @@ -5949,7 +6240,7 @@ "status": "ok", "exitCode": 0, "binPath": "node_modules/.bin/opcore", - "stdoutSha256": "f1ce978b3c92240ac499db40a704a6a57db662a0dc8e577696c265fa9a1848cb", + "stdoutSha256": "57d34113c2c7e2f9c28862b63d90dd014bf433f36071cd0b8b5a7fb8640e0028", "stderrSha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "assertion": "graph query returned facts" }, @@ -5973,7 +6264,7 @@ "status": "ok", "exitCode": 0, "binPath": "node_modules/.bin/opcore", - "stdoutSha256": "70f01ffe2aa7ecfcfe2f23e92c9c0b662c2b6e4dbac1f81b579b3852c8950171", + "stdoutSha256": "5794383bd46e14ab5a6af4892c51ee0c9bb8a0c440fe7f439c4d1731166b1d29", "stderrSha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "assertion": "graph impact returned file impact" }, @@ -5997,7 +6288,7 @@ "status": "ok", "exitCode": 0, "binPath": "node_modules/.bin/opcore", - "stdoutSha256": "1448277aafc98d14b7e01279b841143ba0f12fbfd60eb81766e7f0479e01e619", + "stdoutSha256": "681210e9195fa824c4df7ca8bd554dd5671e6406f13dd05884adb814c01bb035", "stderrSha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "assertion": "graph review-context returned related facts" }, @@ -6021,7 +6312,7 @@ "status": "ok", "exitCode": 0, "binPath": "node_modules/.bin/opcore", - "stdoutSha256": "d41bb844d1cd4569c7dcd96dbdac4608f468cc58e82a12b1114c6f482b4856c0", + "stdoutSha256": "55f37b72791b9941acaf20d30dfdbe7a38c1ed0800e193ba5fba2146b26d9b5a", "stderrSha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "assertion": "graph detect-changes returned typed change data" }, @@ -6047,7 +6338,7 @@ "status": "ok", "exitCode": 0, "binPath": "node_modules/.bin/opcore", - "stdoutSha256": "2762b69f03689f5b98c1534dd7365bcac5e0f0057299da6d1d71fd2f0f2e7681", + "stdoutSha256": "f4e071af4aa3b5d55c171a66d744084690034ed1f7481b1d25e03c40ae178f19", "stderrSha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "assertion": "graph search returned ranked results" }, @@ -6067,7 +6358,7 @@ "status": "ok", "exitCode": 0, "binPath": "node_modules/.bin/opcore", - "stdoutSha256": "8e1b81680031ec996c1911c294f1717201865971eb19aa2f13015f2567a0a6a2", + "stdoutSha256": "e4960bdf835dbce256bf724c00a06d840ca87e74e2412257a9c7dbdd347fa73f", "stderrSha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "assertion": "graph serve status route is ready" }, @@ -6093,7 +6384,7 @@ "status": "ok", "exitCode": 0, "binPath": "node_modules/.bin/opcore", - "stdoutSha256": "57a75201b8e0c6e880d685f4acff2bc4c30e1eecb383d7078baf7b2856e555a9", + "stdoutSha256": "22cb73667744ba1af71f1ebb69ba18d64987db25dca2b9d616d19a8e0527f1bd", "stderrSha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "assertion": "inspect symbols returned graph symbols" }, @@ -6115,7 +6406,7 @@ "status": "ok", "exitCode": 0, "binPath": "node_modules/.bin/opcore", - "stdoutSha256": "07b9447821670d4cda06f1f9b3e035c51ce4a3a21f60ce97eaf2e851f7b9f478", + "stdoutSha256": "5cace6ae4f1e76dd43ea706b43b0d6514f443b6537851ad8631b1f0275dbda50", "stderrSha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "assertion": "inspect definition returned a symbol" }, @@ -6141,7 +6432,7 @@ "status": "ok", "exitCode": 0, "binPath": "node_modules/.bin/opcore", - "stdoutSha256": "9920c09424d083fd9811fb017a4f76df18ec1af1e039f87bc5d82b66d3f3cca8", + "stdoutSha256": "be58acf38764b0ffd8364551b94693117a3ec2b821834fb97c52a7bc6b6f12af", "stderrSha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "assertion": "inspect references returned callers" }, @@ -6163,7 +6454,7 @@ "status": "ok", "exitCode": 0, "binPath": "node_modules/.bin/opcore", - "stdoutSha256": "04f90b01005ea95d988cfa410408eb0374213a38539132c5345352625fa7c0cc", + "stdoutSha256": "386c4983ccb718fff3ad690cbde663598294e774ff63d3481675c763c9d1c55f", "stderrSha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "assertion": "inspect signature returned read-only language-service signatures" }, @@ -6185,7 +6476,7 @@ "status": "ok", "exitCode": 0, "binPath": "node_modules/.bin/opcore", - "stdoutSha256": "fbe1a9195487b304c47693aba906639989ed2a642c0d9dcb0a2339c590ce287f", + "stdoutSha256": "58416b6b1a529b93acced616153c8e0e30c4ce169ebad2eca4da2c07af5ca724", "stderrSha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "assertion": "inspect implementations returned implementation evidence" }, @@ -6211,7 +6502,7 @@ "status": "ok", "exitCode": 0, "binPath": "node_modules/.bin/opcore", - "stdoutSha256": "9ff50bf0cc2eb9c9ddaa20e3018a4c2b87ae09583b87db535738dbb16971bcee", + "stdoutSha256": "1c1eea32c5eca91ae788359df4b60f69d29598790c7c02bdc5a18b1215d66f13", "stderrSha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "assertion": "inspect search returned graph search results" }, @@ -6243,7 +6534,7 @@ "status": "ok", "exitCode": 0, "binPath": "node_modules/.bin/opcore", - "stdoutSha256": "6dd2f83ee9d36f94410e1260b05bf0064657d75e46711d44834c9a598cd24e8f", + "stdoutSha256": "6aba96b0a6ce9c1727aa855e0657e86513ba7e68640b212062c6ab9f4d96e518", "stderrSha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "assertion": "safe edit preview produced a plan without writing" }, @@ -6277,7 +6568,7 @@ "status": "ok", "exitCode": 0, "binPath": "node_modules/.bin/opcore", - "stdoutSha256": "c408d7cb1c30e5fb770edc0e3731e5effd723f84fd147c85e9f4d8f2c7c97255", + "stdoutSha256": "e04566959f046e57cba27f557cf80b9303b4df1b77b06f41cfb9660258fddf8b", "stderrSha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "assertion": "safe edit apply wrote after validation" }, @@ -6311,7 +6602,7 @@ "status": "error", "exitCode": 1, "binPath": "node_modules/.bin/opcore", - "stdoutSha256": "74011e1a54fc7a299458e645697258a3ab77f175fe1bbacd0b053287eec246f4", + "stdoutSha256": "c339beb8ba8827d85ede805ba7d490b8fb69095987b2a8970c4178a1fd6ae952", "stderrSha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "assertion": "validation-refused edit left file unchanged" }, @@ -6337,7 +6628,7 @@ "status": "ok", "exitCode": 0, "binPath": "node_modules/.bin/opcore", - "stdoutSha256": "9ac0f91133a7bf56a2c999ddf84a7e2c69ac18694e7388f4cef7fafd97113062", + "stdoutSha256": "e371b7481bcb6a8f96d528c87ac7c1a519e1970fd707e3efb0fa5c52cecf4e2d", "stderrSha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "assertion": "check files passed syntax and type checks" }, @@ -6361,7 +6652,7 @@ "status": "ok", "exitCode": 0, "binPath": "node_modules/.bin/opcore", - "stdoutSha256": "41f83a81b6fd39cd5e9ed539e232df040508fe8f8595d4cd3b42863711111620", + "stdoutSha256": "e46ac5c60df2a0ebb34482b8d1b12417345e9127c755532a25d75cff7d700175", "stderrSha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "assertion": "validate request passed" }, @@ -6389,7 +6680,7 @@ "status": "ok", "exitCode": 0, "binPath": "node_modules/.bin/opcore", - "stdoutSha256": "d7a5f71ea1b84d12bd34a88586b2b3565b88cdcfa4f3574d6f3ff890182ca1b1", + "stdoutSha256": "d2bc0e05c496e77d6a602ce34057ff42a33bd7637c9fcfe4847dab9e42a16e34", "stderrSha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "assertion": "pre-write pass receipt was ok" }, @@ -6417,7 +6708,7 @@ "status": "error", "exitCode": 1, "binPath": "node_modules/.bin/opcore", - "stdoutSha256": "0a8bb14c58e704b6145597c78a67a5d0a1a85e160b19866051fb0f1f33b65e82", + "stdoutSha256": "de9d07436b36f5f8450470017b20ab468837ed4d8a0f1d6067089c9a47c6e50d", "stderrSha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "assertion": "pre-write failure receipt failed closed" } @@ -6439,7 +6730,7 @@ "status": "ok", "exitCode": 0, "binPath": "node_modules/.bin/opcore", - "stdoutSha256": "35c4f156114926183039c3486e2aea9d62c6dd22ff30103dd88bd78dd91fa292", + "stdoutSha256": "c0501725c2ae853be3f6c9889c0c0ac7a7223d69c9753a9713a2f92dc3ce09ae", "stderrSha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "assertion": "Rust graph build completed with installed native artifact" }, @@ -6459,7 +6750,7 @@ "status": "ok", "exitCode": 0, "binPath": "node_modules/.bin/opcore", - "stdoutSha256": "870318ab1d0a49d120b45f2d061b52b75a21761b8eb1c91408c66b769f085296", + "stdoutSha256": "5e0e86dd93fcdae6d9598193dc6a4a130ad119ea874f9b3c466888f6e26362a2", "stderrSha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "assertion": "Rust graph status available after build" }, @@ -6479,7 +6770,7 @@ "status": "ok", "exitCode": 0, "binPath": "node_modules/.bin/opcore", - "stdoutSha256": "b427e613f1c30404883435b27864b1f370a319ff9ee836a92ef7414e1842bbc6", + "stdoutSha256": "93f1744c2bd7c2d575f0fe50ef04dc7515101087a19d5eee1f056c7815d29301", "stderrSha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "assertion": "Rust graph query returned Rust facts" }, @@ -6503,7 +6794,7 @@ "status": "ok", "exitCode": 0, "binPath": "node_modules/.bin/opcore", - "stdoutSha256": "cdd0f55fb1fee6a2fbba25a66712af47e17b3f984d20651c5c52e876e04cf76f", + "stdoutSha256": "913f9c2ec7248ede8d4ded155a056ae117b12efc97114a5847d35e4860959b53", "stderrSha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "assertion": "Rust graph impact returned related Rust facts" }, @@ -6527,7 +6818,7 @@ "status": "ok", "exitCode": 0, "binPath": "node_modules/.bin/opcore", - "stdoutSha256": "671aca39436cb322d14cdcd7c332e2bf4e4aa8d206b282b8e17a23116a3e4fd7", + "stdoutSha256": "1958ab70b6104e1b8f38ea46ed33e61f364970aba9f42afbba4387ea224c5dac", "stderrSha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "assertion": "Rust graph review-context returned related Rust facts" }, @@ -6551,7 +6842,7 @@ "status": "ok", "exitCode": 0, "binPath": "node_modules/.bin/opcore", - "stdoutSha256": "0b14409a5abd2020928b38ec3c70f4b287a1bd5dea2856605c5e604973c41deb", + "stdoutSha256": "2bd48ed0d16f222d1502a0863b7344726cf5e4715eabdde4717b1af1981b3677", "stderrSha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "assertion": "Rust graph detect-changes returned typed Rust change data" }, @@ -6577,7 +6868,7 @@ "status": "ok", "exitCode": 0, "binPath": "node_modules/.bin/opcore", - "stdoutSha256": "9c1145f77611b954bc8e0cfc0d68551e2a211486e78e90d3cb301ddb60860b48", + "stdoutSha256": "c061e8ad9a9896509cd3e92116111aa2253342758d927807b2ca78697c92af5b", "stderrSha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "assertion": "Rust graph search returned ranked Rust symbols" } @@ -6602,7 +6893,7 @@ "status": "ok", "exitCode": 0, "binPath": "node_modules/.bin/opcore", - "stdoutSha256": "1cbeccedf0104e8677d2d48708d488f422ef4e9d82407659ce81bc0b578cd860", + "stdoutSha256": "3cc5669beadd60a5851dcf0162eec726decb43817e1c0b552738af14805bffed", "stderrSha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "assertion": "opcore scan returned Python repoState and validation evidence from installed artifacts" }, @@ -6624,7 +6915,7 @@ "status": "ok", "exitCode": 0, "binPath": "node_modules/.bin/opcore", - "stdoutSha256": "60d6826a0650af05780b4f1b7e55317b8090cc441ba3417186b101fa20f63085", + "stdoutSha256": "9ea18e96672848c7394072b99465b91033093382e1c5b63b302a32e1b26a223b", "stderrSha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "assertion": "opcore status returned Python repoState from installed artifacts" }, @@ -6660,7 +6951,7 @@ "status": "ok", "exitCode": 0, "binPath": "node_modules/.bin/opcore", - "stdoutSha256": "7c1b37f8d613cc3bef3911fcdd881fb29b177de77516890953d68788b56c580b", + "stdoutSha256": "b0a4266f4752dc7982fe5f05b5a9f096931a218967fd1b425ede199e912c9697", "stderrSha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "assertion": "opcore check changed validated Python syntax and hygiene from installed artifacts" }, @@ -6681,7 +6972,7 @@ "status": "ok", "exitCode": 0, "binPath": "node_modules/.bin/opcore", - "stdoutSha256": "b8e91f03cbe6fe815fbb6246dec11ed7738630ef91d83caae60fba3e6a92e63a", + "stdoutSha256": "40e3413eadd66071b72d9e96d931a4a0ea0585c5ade330e9c5c494b8314c5f4d", "stderrSha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "assertion": "opcore measure returned Python metric deltas from installed artifacts" }, @@ -6704,7 +6995,7 @@ "status": "ok", "exitCode": 0, "binPath": "node_modules/.bin/opcore", - "stdoutSha256": "a55bd3df8241031cd60a4728aa37d5acb0a65f0b1a88f8edeb823c85dbc6d0e1", + "stdoutSha256": "40d27cfd4050e38b42edf9623340ce0655035fbb63296357210636076aa8a240", "stderrSha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "assertion": "Python graph build completed with installed native artifact" }, @@ -6727,7 +7018,7 @@ "status": "ok", "exitCode": 0, "binPath": "node_modules/.bin/opcore", - "stdoutSha256": "3a249ceca524bf44091ce805fd452861139d0b70048fb20e27c422f73c48801f", + "stdoutSha256": "27757fa1c644f97f7cb17f6c01d1bb5ccdc2e288b56ea561e0f4f997b702bf80", "stderrSha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "assertion": "Python graph status available after installed-artifact build" }, @@ -6752,7 +7043,7 @@ "status": "ok", "exitCode": 0, "binPath": "node_modules/.bin/opcore", - "stdoutSha256": "9a79b95d07b8f6e931e04c27ce154e2aac787044ef49d53fa28bc0e1ed75974a", + "stdoutSha256": "d0a132ac76558051eae345b7231d7d139e95e95eacd01b263570d62e17272767", "stderrSha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "assertion": "Python graph query returned installed-artifact Python facts" }, @@ -6782,7 +7073,7 @@ "status": "ok", "exitCode": 0, "binPath": "node_modules/.bin/opcore", - "stdoutSha256": "bd422996a6afe5fa4eb1da11a715457e950d272caf83bc2829ba9211ecd23bef", + "stdoutSha256": "2dcf525a2fe366332595332f310ab06a32d49e146be41668ee2cc949e0daa254", "stderrSha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "assertion": "Python graph search returned ranked installed-artifact Python symbols" } @@ -6845,7 +7136,7 @@ ], "status": "passed", "exitCode": 0, - "assertion": "source-hygiene check ran built-in policy while status reported ruff absent" + "assertion": "source hygiene stayed honest without ruff" }, { "id": "python-relevant-tests-no-pytest", @@ -6869,59 +7160,35 @@ ], "status": "passed", "exitCode": 0, - "assertion": "read-only status reported absent mypy, pyright, ruff, and pytest as degraded" + "assertion": "missing Python toolchain stayed degraded" } ], - "currentToolGuardrails": [ - { - "id": "current-tools-validate-changed", - "command": [ - "npm", - "run", - "current-tools:validate-changed" - ], - "status": "passed", - "exitCode": 0, - "stdoutSha256": "c841ab97cd599893cd51807789d41d37c67133d4abd1315aba222fca4c3beae8", - "stderrSha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", - "retained": true, - "assertion": "retained external changed-file guardrail passed during installed-artifact cutover proof", - "oldToolReplacementClaimed": false - }, - { - "id": "current-tools-validate-rust-graph", - "command": [ - "npm", - "run", - "current-tools:validate-rust-graph" - ], - "status": "passed", - "exitCode": 0, - "stdoutSha256": "37517e5f3dc66819f61f5a7bb8ace1921282415f10551d2defa5c3eb0985b570", - "stderrSha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", - "retained": true, - "assertion": "retained external Rust graph guardrail passed during installed-artifact cutover proof", - "oldToolReplacementClaimed": false - } - ], - "oldToolReplacementClaimed": false, + "selfValidation": { + "id": "opcore-self-check", + "command": [ + "npm", + "run", + "opcore:self-check" + ], + "status": "passed", + "exitCode": 0, + "stdoutSha256": "98006e4c11c39bfe79e15f629ed3e2f063b9c16f0c7ad5708d7d6e855614c01d", + "stderrSha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "assertion": "Opcore validated its own changed implementation surface" + }, "forbiddenMarkerScan": { - "scannedTextCount": 2005, + "scannedTextCount": 2105, "findingCount": 0, "markersBlocked": [ - "private-runtime", - "current-tool-env", "private-home", - "old-tool-bins", - "old-product-name", - "doubled-token" + "launch-claim" ] }, "inputEvidence": [ { "issue": "#17", "path": "docs/release/graph-release-receipt.json", - "checksumSha256": "fee81c57608f2c63ddbfc23d53490760d03a5df49fe69e3849860db0599e852f" + "checksumSha256": "dd92c74c58a5f4e039b36de397352e05fecd0a19a127f6a770a299e05bd1832f" }, { "issue": "#29", @@ -6931,7 +7198,7 @@ { "issue": "#58", "path": "docs/integration/pre-write-validation.md", - "checksumSha256": "ec111c8a4c8b3c41aec245082f2e700173b8b54c50c471ded0003862abe9e2ac" + "checksumSha256": "71d326f6c3b0eac3b1dde1b08142b8d6751f91469bc477f952f5ee858f8b165c" } ] } diff --git a/docs/release/cutover-receipt.summary.md b/docs/release/cutover-receipt.summary.md index 400f54c..7b6b333 100644 --- a/docs/release/cutover-receipt.summary.md +++ b/docs/release/cutover-receipt.summary.md @@ -3,14 +3,13 @@ Maintainer cutover gate proves installed Opcore artifacts handle canonical release commands without dev-tool fallback. Machine receipt: docs/release/cutover-receipt.json -Machine receipt SHA-256: c72be0d79199505770c631b08201be09f8602e239e80c1ffa974e7f24d1cf32b +Machine receipt SHA-256: 1bdb6d984ad52243be972abb5b061631f4eca65e7d5c5a9eac525569898ef97f Installed packages: 1 Command receipts: 28 Rust command receipts: 7 Python command receipts: 8 -Current-tool guardrails retained: 2 -Old-tool replacement claimed: false +Self-validation: passed Forbidden marker findings: 0 Input evidence: #17, #29, #58 diff --git a/docs/release/graph-release-handoff.md b/docs/release/graph-release-handoff.md index 1e16cde..0b0009b 100644 --- a/docs/release/graph-release-handoff.md +++ b/docs/release/graph-release-handoff.md @@ -3,13 +3,13 @@ Issue #17 graph-release gate receipt for #7, #28, and #29. Full receipt: docs/release/graph-release-receipt.json -Full receipt SHA-256: fee81c57608f2c63ddbfc23d53490760d03a5df49fe69e3849860db0599e852f +Full receipt SHA-256: dd92c74c58a5f4e039b36de397352e05fecd0a19a127f6a770a299e05bd1832f | Issue | Checksummed Receipt Path | SHA-256 | |-------|--------------------------|---------| -| #7 | docs/release/graph-release-receipt.payload.json | 2ae48d67a1a908c5418f274c8d6d07843c131c9fc7f11ee1d0bd1f89fbcc628b | -| #28 | docs/release/graph-release-receipt.payload.json | 2ae48d67a1a908c5418f274c8d6d07843c131c9fc7f11ee1d0bd1f89fbcc628b | -| #29 | docs/release/graph-release-receipt.payload.json | 2ae48d67a1a908c5418f274c8d6d07843c131c9fc7f11ee1d0bd1f89fbcc628b | +| #7 | docs/release/graph-release-receipt.payload.json | f6d68bdecc65a83e4e34e2d208f9158c5c83d06957e1da6ef90ab2444dd2891d | +| #28 | docs/release/graph-release-receipt.payload.json | f6d68bdecc65a83e4e34e2d208f9158c5c83d06957e1da6ef90ab2444dd2891d | +| #29 | docs/release/graph-release-receipt.payload.json | f6d68bdecc65a83e4e34e2d208f9158c5c83d06957e1da6ef90ab2444dd2891d | ## Parent #4 Graph Scope @@ -30,5 +30,5 @@ Full receipt SHA-256: fee81c57608f2c63ddbfc23d53490760d03a5df49fe69e3849860db059 License report: docs/release/license-report.md Provenance receipt: docs/release/provenance-receipts.md -Rollback: keep ACE wrappers on current external tools if receipt regresses. +Rollback: block release and repair Opcore self-validation if this receipt regresses. Maintainer note: these graph release checks must pass before publishing alpha artifacts. diff --git a/docs/release/graph-release-receipt.json b/docs/release/graph-release-receipt.json index efc66de..18d8184 100644 --- a/docs/release/graph-release-receipt.json +++ b/docs/release/graph-release-receipt.json @@ -2,8 +2,8 @@ "schemaVersion": 1, "issue": "#17", "origin": "covibes-authored-synthetic", - "generatedAt": "2026-07-18T06:29:58.350Z", - "commitSha": "2d4e920e0b08a989953486b261327ed7496c328b", + "generatedAt": "2026-08-07T17:35:07.549Z", + "commitSha": "6ed9e0e5b48acdc298af1a2e1af1a59aae32ac78", "graphPackageVersions": [ { "packageName": "@the-open-engine/opcore-graph", @@ -59,7 +59,7 @@ "status": "passed", "exitCode": 0, "fixture": "packages/fixtures/source-extraction/wave1", - "durationMs": 997 + "durationMs": 541 }, { "id": "opcore-graph-update", @@ -76,7 +76,7 @@ "status": "passed", "exitCode": 0, "fixture": "packages/fixtures/source-extraction/wave1", - "durationMs": 681 + "durationMs": 543 }, { "id": "opcore-graph-watch", @@ -93,7 +93,7 @@ "status": "passed", "exitCode": 0, "fixture": "packages/fixtures/source-extraction/wave1", - "durationMs": 662 + "durationMs": 533 }, { "id": "opcore-graph-status", @@ -110,7 +110,7 @@ "status": "passed", "exitCode": 0, "fixture": "packages/fixtures/source-extraction/wave1", - "durationMs": 743 + "durationMs": 550 }, { "id": "opcore-graph-query", @@ -127,7 +127,7 @@ "status": "passed", "exitCode": 0, "fixture": "packages/fixtures/source-extraction/wave1", - "durationMs": 691 + "durationMs": 531 }, { "id": "opcore-graph-impact", @@ -144,7 +144,7 @@ "status": "passed", "exitCode": 0, "fixture": "packages/fixtures/source-extraction/wave1", - "durationMs": 734 + "durationMs": 529 }, { "id": "opcore-graph-search", @@ -161,7 +161,7 @@ "status": "passed", "exitCode": 0, "fixture": "packages/fixtures/source-extraction/wave1", - "durationMs": 734 + "durationMs": 535 }, { "id": "opcore-graph-serve", @@ -178,7 +178,7 @@ "status": "passed", "exitCode": 0, "fixture": "packages/fixtures/source-extraction/wave1", - "durationMs": 745 + "durationMs": 531 } ], "rustCommandCoverage": [ @@ -197,7 +197,7 @@ "status": "passed", "exitCode": 0, "fixture": "packages/fixtures/source-extraction/rust-only", - "durationMs": 743 + "durationMs": 535 }, { "id": "opcore-graph-rust-update", @@ -214,7 +214,7 @@ "status": "passed", "exitCode": 0, "fixture": "packages/fixtures/source-extraction/rust-only", - "durationMs": 723 + "durationMs": 538 }, { "id": "opcore-graph-rust-watch", @@ -231,7 +231,7 @@ "status": "passed", "exitCode": 0, "fixture": "packages/fixtures/source-extraction/rust-only", - "durationMs": 733 + "durationMs": 539 }, { "id": "opcore-graph-rust-status", @@ -248,7 +248,7 @@ "status": "passed", "exitCode": 0, "fixture": "packages/fixtures/source-extraction/rust-only", - "durationMs": 735 + "durationMs": 541 }, { "id": "opcore-graph-rust-query", @@ -265,7 +265,7 @@ "status": "passed", "exitCode": 0, "fixture": "packages/fixtures/source-extraction/rust-only", - "durationMs": 784 + "durationMs": 532 }, { "id": "opcore-graph-rust-impact", @@ -282,7 +282,7 @@ "status": "passed", "exitCode": 0, "fixture": "packages/fixtures/source-extraction/rust-only", - "durationMs": 691 + "durationMs": 541 }, { "id": "opcore-graph-rust-search", @@ -299,7 +299,7 @@ "status": "passed", "exitCode": 0, "fixture": "packages/fixtures/source-extraction/rust-only", - "durationMs": 724 + "durationMs": 530 }, { "id": "opcore-graph-rust-serve", @@ -316,7 +316,7 @@ "status": "passed", "exitCode": 0, "fixture": "packages/fixtures/source-extraction/rust-only", - "durationMs": 719 + "durationMs": 527 } ], "directSqliteQueries": [ @@ -396,82 +396,82 @@ "benchmarks": [ { "metric": "install_setup_ms", - "value": 6, + "value": 1, "unit": "ms", "baselineIssue": "#19", - "baselineReceipt": "packages/fixtures/graph-reference-evidence/baseline-receipts.json", + "baselineReceipt": "docs/release/graph-release-receipt.json", "comparison": "recorded" }, { "metric": "cold_build_ms", - "value": 997, + "value": 541, "unit": "ms", "baselineIssue": "#19", - "baselineReceipt": "packages/fixtures/graph-reference-evidence/baseline-receipts.json", + "baselineReceipt": "docs/release/graph-release-receipt.json", "comparison": "recorded" }, { "metric": "incremental_update_ms", - "value": 681, + "value": 543, "unit": "ms", "baselineIssue": "#19", - "baselineReceipt": "packages/fixtures/graph-reference-evidence/baseline-receipts.json", + "baselineReceipt": "docs/release/graph-release-receipt.json", "comparison": "recorded" }, { "metric": "impact_cold_ms", - "value": 734, + "value": 529, "unit": "ms", "baselineIssue": "#19", - "baselineReceipt": "packages/fixtures/graph-reference-evidence/baseline-receipts.json", + "baselineReceipt": "docs/release/graph-release-receipt.json", "comparison": "recorded" }, { "metric": "impact_hot_ms", - "value": 756, + "value": 538, "unit": "ms", "baselineIssue": "#19", - "baselineReceipt": "packages/fixtures/graph-reference-evidence/baseline-receipts.json", + "baselineReceipt": "docs/release/graph-release-receipt.json", "comparison": "recorded" }, { "metric": "search_ms", - "value": 734, + "value": 535, "unit": "ms", "baselineIssue": "#19", - "baselineReceipt": "packages/fixtures/graph-reference-evidence/baseline-receipts.json", + "baselineReceipt": "docs/release/graph-release-receipt.json", "comparison": "recorded" }, { "metric": "daemon_startup_ms", - "value": 1130, + "value": 759, "unit": "ms", "baselineIssue": "#19", - "baselineReceipt": "packages/fixtures/graph-reference-evidence/baseline-receipts.json", + "baselineReceipt": "docs/release/graph-release-receipt.json", "comparison": "recorded" }, { "metric": "daemon_query_ms", - "value": 1130, + "value": 758, "unit": "ms", "baselineIssue": "#19", - "baselineReceipt": "packages/fixtures/graph-reference-evidence/baseline-receipts.json", + "baselineReceipt": "docs/release/graph-release-receipt.json", "comparison": "recorded" }, { "metric": "db_size_bytes", - "value": 245760, + "value": 241664, "unit": "bytes", "baselineIssue": "#19", - "baselineReceipt": "packages/fixtures/graph-reference-evidence/baseline-receipts.json", + "baselineReceipt": "docs/release/graph-release-receipt.json", "comparison": "recorded" }, { "metric": "wal_size_bytes", - "value": 403792, + "value": 399672, "unit": "bytes", "baselineIssue": "#19", - "baselineReceipt": "packages/fixtures/graph-reference-evidence/baseline-receipts.json", + "baselineReceipt": "docs/release/graph-release-receipt.json", "comparison": "recorded" } ], @@ -507,10 +507,10 @@ "forbiddenMarkersAbsent": true, "generatedBuildMetadataAbsent": true, "privatePathsAbsent": true, - "pythonCrgSourceAbsent": true, - "pythonGraphPackageMetadataAbsent": true, - "pythonCrgGitHistoryAbsent": true, - "forbiddenImplementationPackageNamesAbsent": true, + "sourceProvenanceAbsent": true, + "packageMetadataAbsent": true, + "gitHistoryAbsent": true, + "foreignImplementationNamesAbsent": true, "inspections": [ "npm-pack-dry-run", "package-file-scan", @@ -652,10 +652,10 @@ "graphArtifact": { "artifactName": "opcore-graph-core", "artifactVersion": "0.2.1", - "targetPlatform": "darwin-arm64", + "targetPlatform": "linux-x64", "binaryPath": "opcore-graph-core", "checksumPath": "opcore-graph-core.sha256", - "checksumSha256": "72acf10de0ece619c3896943addfb0cc1b09dab22000b48a653b033224217bc8", + "checksumSha256": "c3a771435a7a8172a9e5f0bb20b0d111453efc70901e95308094664f77e7bdf1", "buildProfile": "release" }, "optionalSurfaces": [ @@ -688,20 +688,20 @@ { "issue": "#7", "receiptPath": "docs/release/graph-release-receipt.payload.json", - "checksumSha256": "2ae48d67a1a908c5418f274c8d6d07843c131c9fc7f11ee1d0bd1f89fbcc628b", - "rollbackNote": "Keep ACE wrappers on current external tools if receipt regresses." + "checksumSha256": "f6d68bdecc65a83e4e34e2d208f9158c5c83d06957e1da6ef90ab2444dd2891d", + "rollbackNote": "Block release and repair Opcore self-validation if this receipt regresses." }, { "issue": "#28", "receiptPath": "docs/release/graph-release-receipt.payload.json", - "checksumSha256": "2ae48d67a1a908c5418f274c8d6d07843c131c9fc7f11ee1d0bd1f89fbcc628b", - "rollbackNote": "Keep ACE wrappers on current external tools if receipt regresses." + "checksumSha256": "f6d68bdecc65a83e4e34e2d208f9158c5c83d06957e1da6ef90ab2444dd2891d", + "rollbackNote": "Block release and repair Opcore self-validation if this receipt regresses." }, { "issue": "#29", "receiptPath": "docs/release/graph-release-receipt.payload.json", - "checksumSha256": "2ae48d67a1a908c5418f274c8d6d07843c131c9fc7f11ee1d0bd1f89fbcc628b", - "rollbackNote": "Keep ACE wrappers on current external tools if receipt regresses." + "checksumSha256": "f6d68bdecc65a83e4e34e2d208f9158c5c83d06957e1da6ef90ab2444dd2891d", + "rollbackNote": "Block release and repair Opcore self-validation if this receipt regresses." } ] } diff --git a/docs/release/graph-release-receipt.payload.json b/docs/release/graph-release-receipt.payload.json index 92f69ab..48c1f98 100644 --- a/docs/release/graph-release-receipt.payload.json +++ b/docs/release/graph-release-receipt.payload.json @@ -2,8 +2,8 @@ "schemaVersion": 1, "issue": "#17", "origin": "covibes-authored-synthetic", - "generatedAt": "2026-07-18T06:29:58.350Z", - "commitSha": "2d4e920e0b08a989953486b261327ed7496c328b", + "generatedAt": "2026-08-07T17:35:07.549Z", + "commitSha": "6ed9e0e5b48acdc298af1a2e1af1a59aae32ac78", "graphPackageVersions": [ { "packageName": "@the-open-engine/opcore-graph", @@ -59,7 +59,7 @@ "status": "passed", "exitCode": 0, "fixture": "packages/fixtures/source-extraction/wave1", - "durationMs": 997 + "durationMs": 541 }, { "id": "opcore-graph-update", @@ -76,7 +76,7 @@ "status": "passed", "exitCode": 0, "fixture": "packages/fixtures/source-extraction/wave1", - "durationMs": 681 + "durationMs": 543 }, { "id": "opcore-graph-watch", @@ -93,7 +93,7 @@ "status": "passed", "exitCode": 0, "fixture": "packages/fixtures/source-extraction/wave1", - "durationMs": 662 + "durationMs": 533 }, { "id": "opcore-graph-status", @@ -110,7 +110,7 @@ "status": "passed", "exitCode": 0, "fixture": "packages/fixtures/source-extraction/wave1", - "durationMs": 743 + "durationMs": 550 }, { "id": "opcore-graph-query", @@ -127,7 +127,7 @@ "status": "passed", "exitCode": 0, "fixture": "packages/fixtures/source-extraction/wave1", - "durationMs": 691 + "durationMs": 531 }, { "id": "opcore-graph-impact", @@ -144,7 +144,7 @@ "status": "passed", "exitCode": 0, "fixture": "packages/fixtures/source-extraction/wave1", - "durationMs": 734 + "durationMs": 529 }, { "id": "opcore-graph-search", @@ -161,7 +161,7 @@ "status": "passed", "exitCode": 0, "fixture": "packages/fixtures/source-extraction/wave1", - "durationMs": 734 + "durationMs": 535 }, { "id": "opcore-graph-serve", @@ -178,7 +178,7 @@ "status": "passed", "exitCode": 0, "fixture": "packages/fixtures/source-extraction/wave1", - "durationMs": 745 + "durationMs": 531 } ], "rustCommandCoverage": [ @@ -197,7 +197,7 @@ "status": "passed", "exitCode": 0, "fixture": "packages/fixtures/source-extraction/rust-only", - "durationMs": 743 + "durationMs": 535 }, { "id": "opcore-graph-rust-update", @@ -214,7 +214,7 @@ "status": "passed", "exitCode": 0, "fixture": "packages/fixtures/source-extraction/rust-only", - "durationMs": 723 + "durationMs": 538 }, { "id": "opcore-graph-rust-watch", @@ -231,7 +231,7 @@ "status": "passed", "exitCode": 0, "fixture": "packages/fixtures/source-extraction/rust-only", - "durationMs": 733 + "durationMs": 539 }, { "id": "opcore-graph-rust-status", @@ -248,7 +248,7 @@ "status": "passed", "exitCode": 0, "fixture": "packages/fixtures/source-extraction/rust-only", - "durationMs": 735 + "durationMs": 541 }, { "id": "opcore-graph-rust-query", @@ -265,7 +265,7 @@ "status": "passed", "exitCode": 0, "fixture": "packages/fixtures/source-extraction/rust-only", - "durationMs": 784 + "durationMs": 532 }, { "id": "opcore-graph-rust-impact", @@ -282,7 +282,7 @@ "status": "passed", "exitCode": 0, "fixture": "packages/fixtures/source-extraction/rust-only", - "durationMs": 691 + "durationMs": 541 }, { "id": "opcore-graph-rust-search", @@ -299,7 +299,7 @@ "status": "passed", "exitCode": 0, "fixture": "packages/fixtures/source-extraction/rust-only", - "durationMs": 724 + "durationMs": 530 }, { "id": "opcore-graph-rust-serve", @@ -316,7 +316,7 @@ "status": "passed", "exitCode": 0, "fixture": "packages/fixtures/source-extraction/rust-only", - "durationMs": 719 + "durationMs": 527 } ], "directSqliteQueries": [ @@ -396,82 +396,82 @@ "benchmarks": [ { "metric": "install_setup_ms", - "value": 6, + "value": 1, "unit": "ms", "baselineIssue": "#19", - "baselineReceipt": "packages/fixtures/graph-reference-evidence/baseline-receipts.json", + "baselineReceipt": "docs/release/graph-release-receipt.json", "comparison": "recorded" }, { "metric": "cold_build_ms", - "value": 997, + "value": 541, "unit": "ms", "baselineIssue": "#19", - "baselineReceipt": "packages/fixtures/graph-reference-evidence/baseline-receipts.json", + "baselineReceipt": "docs/release/graph-release-receipt.json", "comparison": "recorded" }, { "metric": "incremental_update_ms", - "value": 681, + "value": 543, "unit": "ms", "baselineIssue": "#19", - "baselineReceipt": "packages/fixtures/graph-reference-evidence/baseline-receipts.json", + "baselineReceipt": "docs/release/graph-release-receipt.json", "comparison": "recorded" }, { "metric": "impact_cold_ms", - "value": 734, + "value": 529, "unit": "ms", "baselineIssue": "#19", - "baselineReceipt": "packages/fixtures/graph-reference-evidence/baseline-receipts.json", + "baselineReceipt": "docs/release/graph-release-receipt.json", "comparison": "recorded" }, { "metric": "impact_hot_ms", - "value": 756, + "value": 538, "unit": "ms", "baselineIssue": "#19", - "baselineReceipt": "packages/fixtures/graph-reference-evidence/baseline-receipts.json", + "baselineReceipt": "docs/release/graph-release-receipt.json", "comparison": "recorded" }, { "metric": "search_ms", - "value": 734, + "value": 535, "unit": "ms", "baselineIssue": "#19", - "baselineReceipt": "packages/fixtures/graph-reference-evidence/baseline-receipts.json", + "baselineReceipt": "docs/release/graph-release-receipt.json", "comparison": "recorded" }, { "metric": "daemon_startup_ms", - "value": 1130, + "value": 759, "unit": "ms", "baselineIssue": "#19", - "baselineReceipt": "packages/fixtures/graph-reference-evidence/baseline-receipts.json", + "baselineReceipt": "docs/release/graph-release-receipt.json", "comparison": "recorded" }, { "metric": "daemon_query_ms", - "value": 1130, + "value": 758, "unit": "ms", "baselineIssue": "#19", - "baselineReceipt": "packages/fixtures/graph-reference-evidence/baseline-receipts.json", + "baselineReceipt": "docs/release/graph-release-receipt.json", "comparison": "recorded" }, { "metric": "db_size_bytes", - "value": 245760, + "value": 241664, "unit": "bytes", "baselineIssue": "#19", - "baselineReceipt": "packages/fixtures/graph-reference-evidence/baseline-receipts.json", + "baselineReceipt": "docs/release/graph-release-receipt.json", "comparison": "recorded" }, { "metric": "wal_size_bytes", - "value": 403792, + "value": 399672, "unit": "bytes", "baselineIssue": "#19", - "baselineReceipt": "packages/fixtures/graph-reference-evidence/baseline-receipts.json", + "baselineReceipt": "docs/release/graph-release-receipt.json", "comparison": "recorded" } ], @@ -507,10 +507,10 @@ "forbiddenMarkersAbsent": true, "generatedBuildMetadataAbsent": true, "privatePathsAbsent": true, - "pythonCrgSourceAbsent": true, - "pythonGraphPackageMetadataAbsent": true, - "pythonCrgGitHistoryAbsent": true, - "forbiddenImplementationPackageNamesAbsent": true, + "sourceProvenanceAbsent": true, + "packageMetadataAbsent": true, + "gitHistoryAbsent": true, + "foreignImplementationNamesAbsent": true, "inspections": [ "npm-pack-dry-run", "package-file-scan", @@ -652,10 +652,10 @@ "graphArtifact": { "artifactName": "opcore-graph-core", "artifactVersion": "0.2.1", - "targetPlatform": "darwin-arm64", + "targetPlatform": "linux-x64", "binaryPath": "opcore-graph-core", "checksumPath": "opcore-graph-core.sha256", - "checksumSha256": "72acf10de0ece619c3896943addfb0cc1b09dab22000b48a653b033224217bc8", + "checksumSha256": "c3a771435a7a8172a9e5f0bb20b0d111453efc70901e95308094664f77e7bdf1", "buildProfile": "release" }, "optionalSurfaces": [ diff --git a/docs/release/inspect-implementations-parity.md b/docs/release/inspect-implementations-parity.md index d6d7d27..119cc34 100644 --- a/docs/release/inspect-implementations-parity.md +++ b/docs/release/inspect-implementations-parity.md @@ -1,33 +1,12 @@ -# Inspect Implementations Parity +# Inspect Implementations Evidence Issue: #102 -`opcore inspect implementations` is read-only inspect evidence. It requires fresh graph provider status, uses graph `IMPLEMENTS` and `INHERITS` facts as mandatory input evidence, and materializes TypeScript/TSX locations through the inspect language-service path. It does not return edit plans, apply receipts, validation results, ASP host decisions, or gate authority. +`opcore inspect implementations` is read-only evidence over fresh graph `IMPLEMENTS` and `INHERITS` facts plus +TypeScript/TSX language-service locations. Results include implementation file, line, column, span, symbol, relation +kind, target identity, graph node ids, and resolver provenance. -## CIX Field Mapping +Covered behavior includes class implementation and inheritance, interface inheritance, TSX declarations, path aliases, +same-name disambiguation, node-id targets, and typed unavailable/stale/missing/ambiguous/unsupported failures. -| Old `cix impls` field | Opcore field | -|-----------------------|---------------| -| implementation file | `inspectResult.implementations[].file` | -| line and column | `line`, `column`, `span` | -| implementation name | `symbol.name` | -| implementation kind | `kind`: `implements`, `inherited_implements`, `extends`, `interface_extends` | -| queried target | `target.id`, `target.name`, `target.kind` | -| source proof | `evidence.graphNodeIds`, `evidence.resolver` | - -## Covered Evidence - -- Classes implementing interfaces, including inherited interface satisfaction. -- Classes extending classes, including imported and aliased base names. -- Interface inheritance. -- TSX component/model declarations. -- `tsconfig` path aliases through TypeScript project materialization. -- Same-name symbol disambiguation with `--line` and optional `--column`. -- Node-id class/type targets. -- Typed failures for unavailable/stale graph, missing symbols, malformed targets, ambiguous targets, and unsupported JS/JSX/other files. - -## Retained Gap - -Constructor parameter usage from old `cix impls` output is retained as a compatibility gap. Opcore classifies constructor usage as reference evidence, not implementation evidence, so it is covered by inspect references rather than implementation results. - -This evidence does not claim old-tool retirement, public certification, ASP host authority, or ACE-managed Opcore replacement. +Constructor parameter usage remains reference evidence rather than implementation evidence. diff --git a/docs/release/inspect-signature-parity.md b/docs/release/inspect-signature-parity.md index 9f6ba21..9cdc776 100644 --- a/docs/release/inspect-signature-parity.md +++ b/docs/release/inspect-signature-parity.md @@ -1,159 +1,10 @@ -# Inspect Signature Parity Evidence +# Inspect Signature Evidence Issue: #101 -Status: `opcore inspect signature` is implemented as read-only inspect-owned language-service evidence after mandatory fresh GraphProvider status. This does not retire CIX, change ACE guidance, or make ASP host decisions. +`opcore inspect signature` is read-only inspect-owned language-service evidence after graph freshness evaluation. +It returns symbol identity, source location/span, rendered signature, kind, parameters, type parameters, return type, +export/async state, overload index, graph node ids, and resolver provenance. -## Field Mapping - -| CIX `sig` field | Opcore field | -|-----------------|---------------| -| `name` | `inspectResult.signatures[].symbol.name` and `signature` text | -| `kind` | `inspectResult.signatures[].kind` | -| `file` | `inspectResult.signatures[].file` | -| `line` | `inspectResult.signatures[].line` | -| `isExported` | `inspectResult.signatures[].exported` | -| `isAsync` | `inspectResult.signatures[].async` | -| `typeParameters` | `inspectResult.signatures[].typeParameters` | -| `parameters` | `inspectResult.signatures[].parameters` | -| `returnType` | `inspectResult.signatures[].returnType` | -| not present | `providerStatus`, `target`, `span`, `symbol.id`, `evidence.graphNodeIds`, `evidence.resolver`, `overloadIndex` | - -## Commands Run - -```sh -npm run setup:tools -``` - -Output: - -```text -current tool wrappers ready at /Users/tom/.zeroshot/worktrees/cobalt-falcon-81/.ace/runtime/bin -``` - -```sh -./.ace/runtime/bin/cix sig packages/fixtures/inspect-symbol-parity/src/models.ts formatGreeting --line 20 --json -``` - -Output: - -```json -{ - "success": true, - "signatures": [ - { - "name": "formatGreeting", - "kind": "function", - "file": "src/models.ts", - "line": 20, - "isExported": true, - "isAsync": false, - "typeParameters": [], - "parameters": [ - { - "name": "message", - "type": "GreetingMessage", - "optional": false - } - ], - "returnType": "string" - } - ], - "timing": "141ms" -} -``` - -```sh -node packages/cli/dist/index.js graph build --repo packages/fixtures/inspect-symbol-parity --json -node packages/cli/dist/index.js inspect signature src/models.ts formatGreeting --line 20 --repo packages/fixtures/inspect-symbol-parity --json -``` - -Opcore output excerpt: - -```json -{ - "owner": "inspect", - "status": "ok", - "providerStatus": { - "state": "available" - }, - "inspectResult": { - "route": "signature", - "status": "ok", - "target": { - "kind": "file_symbol", - "path": "src/models.ts", - "symbolName": "formatGreeting", - "line": 20, - "nodeId": "function:src/models.ts#formatGreeting" - }, - "signatures": [ - { - "file": "src/models.ts", - "line": 20, - "column": 1, - "signature": "formatGreeting(message: GreetingMessage): string", - "kind": "function", - "parameters": [ - { - "name": "message", - "type": "GreetingMessage", - "optional": false - } - ], - "typeParameters": [], - "exported": true, - "async": false, - "returnType": "string", - "symbol": { - "id": "function:src/models.ts#formatGreeting", - "name": "formatGreeting", - "kind": "Function" - }, - "evidence": { - "graphNodeIds": [ - "function:src/models.ts#formatGreeting" - ], - "resolver": "language_service" - } - } - ] - } -} -``` - -Overload comparison: - -```sh -./.ace/runtime/bin/cix sig packages/fixtures/inspect-symbol-parity/src/overloads.ts describeGreeting --line 3 --json -``` - -Output: - -```json -{ - "success": false, - "message": "No signatures found for \"describeGreeting\"" -} -``` - -```sh -node packages/cli/dist/index.js inspect signature src/overloads.ts describeGreeting --line 3 --repo packages/fixtures/inspect-symbol-parity --json -``` - -Opcore returns two signatures with `overloadIndex` `0` and `1`: - -```json -[ - { - "signature": "describeGreeting(model: GreetingModel): string", - "overloadIndex": 0 - }, - { - "signature": "describeGreeting(message: GreetingMessage): string", - "overloadIndex": 1 - } -] -``` - -Retained gap: `opcore inspect implementations` remains typed unsupported. CIX stays as the retained guardrail until the implementations parity issue lands and #17/#4 evidence is updated. +The fixture suite covers functions, methods, constructors, classes, interfaces, type aliases, overloads, +imported/aliased symbols, path aliases, TS/TSX, JS, JSX, file-symbol targets, and node-id targets. diff --git a/docs/release/provenance-receipts.md b/docs/release/provenance-receipts.md index 09a6af0..201c67c 100644 --- a/docs/release/provenance-receipts.md +++ b/docs/release/provenance-receipts.md @@ -1,11 +1,11 @@ # Provenance Receipts -Maintainer provenance evidence for the Opcore alpha release gate. +Maintainer provenance evidence for the Opcore release gate. -- Current-tree files scanned: 695 -- Git-history commits scanned: 305 -- Python code-review-graph source findings: 0 -- Python package metadata findings: 0 -- Copied git-history marker findings: 0 +- Current repository and package outputs are scanned. +- Generated provider/build state is forbidden from tracked source. +- Package dependencies and TypeScript outputs must remain repository-confined. +- Native artifact metadata must use package-relative paths. +- Copied Git-history markers are rejected. -Allowed old-tool mentions are limited to dev current-tool setup, ACE routing, and graph reference evidence fixtures. +Run `npm run provenance:check` to refresh the executable proof. diff --git a/docs/release/retained-guardrail-matrix.md b/docs/release/retained-guardrail-matrix.md deleted file mode 100644 index 4636d39..0000000 --- a/docs/release/retained-guardrail-matrix.md +++ /dev/null @@ -1,64 +0,0 @@ -# Retained Guardrail Matrix - -Issue: #54 - -Status: master retained-guardrail matrix for Opcore epic #13. - -This matrix records current Rox, CRG, and CIX guardrail status after the #13 parity-ledger work. It is private release-readiness evidence only. It is not a public release, npm publish, registry or certification claim, ASP authority claim, ACE wrapper cutover, or old-tool retirement announcement. - -## Invariant - -No surface is marked `replaced` unless an installed Opcore receipt proves exact replacement for that surface and the release/cutover issue explicitly accepts the replacement. Until then, current external Rox, CRG, and CIX guardrails remain retained or replacement-deferred. - -`docs/release/asp-dogfood-receipt.json` pins `oldToolReplacementClaimed: false`. That value must stay false until a later installed-artifact receipt authorizes a surface-specific replacement claim. - -## Receipt Sources - -- `docs/release/crg-graph-parity-ledger.md` (#53): CRG graph parity ledger and issue-number collision note. -- `docs/release/graph-release-receipt.json` (#17): graph release command coverage and serve transport evidence. -- `docs/release/cutover-receipt.json` (#30): installed `node_modules/.bin/opcore` command receipts, with `opcoreBinOnly:true` meaning only Opcore-owned bins are exposed and old bins are absent from the cutover environment. -- `docs/release/asp-dogfood-receipt.json` (#120): advisory ASP dogfood, retained current-tool guardrails, inspect/edit deferred coverage, and `oldToolReplacementClaimed: false`. -- `docs/validation/rust-adapter-parity.md`, `docs/validation/rust-retained-tools-receipts-2026-06-23.md`, and `docs/validation/rust-old-rox-comparison-receipt-2026-06-27.json` (#29, PR #104 merge `ab0362d339ec2c41b0cc71ae5bb400c4b8254e36`): Rust adapter parity, retained Rox compatibility evidence, and the #29 comparison evidence consumed by #30 per-surface Rust decisions with `oldToolReplacementClaimed: false`. -- `docs/release/inspect-signature-parity.md` and `docs/release/inspect-implementations-parity.md`: CIX inspect parity evidence and retained gaps. - -## Matrix - -| Current guardrail surface | Opcore/lattice evidence | Matrix state | Guardrail decision | -|---|---|---|---| -| CRG graph: build/update/watch/status/query/impact/review-context/detect-changes/search/serve | `crg-graph-parity-ledger.md`; `graph-release-receipt.json` command coverage; `cutover-receipt.json` graph command receipts from installed artifacts with `environmentIsolation.oldBinsAbsent.{crg,cix,rox}: true`. | `deferred` | Parity is demonstrated, but replacement claim is deferred. CRG remains the retained graph guardrail until downstream cutover work accepts retirement. | -| Rox validation - TS/JS: `typescript.syntax`, `typescript.types`, `typescript.import-graph`, `typescript.dead-code`, `typescript.relevant-tests` | `release-receipt.json` validation check ids; `cutover-receipt.json` validation receipts: `opcore-check-changed`, `check-files`, `validate-request`, `validate-pre-write-pass`, and fail-closed `validate-pre-write-fail`. | `deferred` | Installed validation receipts exist, but Rox changed/repo guardrails remain active through `current-tools:validate-changed` and `current-tools:validate-all`. No old-tool replacement claim. | -| Rox validation - Rust foundation: `rust.source-hygiene`, `rust.fmt`, `rust.cargo-check`, `rust.clippy`, `rust.file-length` | `rust-adapter-parity.md` native Rust checks; `rust-retained-tools-receipts-2026-06-23.md`; `asp-dogfood-receipt.json` retained current-tool guardrails. | `retained` | Keep Rox Rust gates active. Runtime, cache, and retirement decisions feed #10/#26-#30 rather than #13. | -| Rox validation - Rust retained tool: `rust.rustdoc` | `rust-adapter-parity.md` records rustdoc diagnostics as blocking policy evidence and keeps retained guardrails active when `rustdoc` is missing; `rust-old-rox-comparison-receipt-2026-06-27.json` records no graph fact replacing rustdoc diagnostics and `replacementStatus: "retained"`. | `retained` | #30 decision: retain. No installed-artifact receipt or maintainer approval flips this surface. Rustdoc diagnostics, broken intra-doc links, and documentation-policy failures remain current-tool evidence. | -| Rox validation - Rust retained tool: `rust.import-graph` | `rust-adapter-parity.md` records native unresolved module/use path, orphan source, and module-cycle checks with optional `cargo-depgraph` enrichment; `rust-old-rox-comparison-receipt-2026-06-27.json` records Rust graph evidence exists but `replacementStatus: "deferred"`. | `deferred` | #30 decision: defer replacement. Rust graph facts are useful evidence, but cargo-depgraph-enriched import checks and retained current-tool behavior are not exactly replaced, and no maintainer approval authorizes retirement. | -| Rox validation - Rust retained tool: `rust.dead-code` | `rust-adapter-parity.md` records native `cargo check` `dead_code` denial plus orphan-source evidence; `rust-old-rox-comparison-receipt-2026-06-27.json` records graph-backed dead-public-export evidence but `replacementStatus: "retained"`. | `retained` | #30 decision: retain. Graph signals do not replace Cargo compiler reachability or current-tool gate behavior, and `oldToolReplacementClaimed` stays false. | -| Rox validation - Rust retained tool: `rust.unused-deps` | `rust-adapter-parity.md` records `cargo-udeps` parsing and degraded/unsupported behavior when the tool is missing; `rust-old-rox-comparison-receipt-2026-06-27.json` records no graph fact replacing cargo-udeps unused dependency analysis and `replacementStatus: "retained"`. | `retained` | #30 decision: retain. Unused dependency evidence remains uniquely provided by cargo-udeps/current tools until an installed-artifact receipt and approval accept replacement. | -| Rox validation - Rust retained tool: `rust.function-metrics` | `rust-adapter-parity.md` records `rust-code-analysis-cli` metrics for line count, complexity, and parameter thresholds; `rust-old-rox-comparison-receipt-2026-06-27.json` records graph spans/signatures exist but `replacementStatus: "retained"`. | `retained` | #30 decision: retain. Rust graph symbol spans do not exactly replace rust-code-analysis metrics or threshold behavior. | -| Rox validation - Rust aggregate gate: `current-tools:validate-rust-graph` | `rust-adapter-parity.md` lists `npm run current-tools:validate-rust-graph` as a retained guardrail; `rust-old-rox-comparison-receipt-2026-06-27.json` records this aggregate gate with `replacementStatus: "retained"` and `oldToolReplacementClaimed: false`. | `retained` | #30 decision: retain the aggregate Rust graph current-tool gate. The #29 receipt is comparison evidence only and does not retire the command. | -| Rox validation - Rust changed-file current-tool gate: `current-tools:validate-changed` | `rust-adapter-parity.md` lists `npm run current-tools:validate-changed` as a retained guardrail for Rust parity work; `asp-dogfood-receipt.json` records the changed-file current-tool guardrail as passed retained evidence. | `retained` | #30 decision: retain the Rust-relevant changed-file current-tool gate. No #29/#30 installed-artifact receipt or approval authorizes replacing this command. | -| CIX inspect: symbols/definition/references/signature/implementations/search | `cutover-receipt.json` inspect receipts: `inspect-symbols`, `inspect-definition`, `inspect-references`, `inspect-signature`, `inspect-implementations`, and `inspect-search`; `inspect-signature-parity.md`; `inspect-implementations-parity.md`; #49 node-id references fix. | `deferred` | Installed inspect receipts exist, but ASP dogfood still records inspect as `parity-blocker` because ASP inspect request/response mapping is outside #120. CIX remains retained until an inspect-specific cutover accepts replacement. | -| CIX edit: exact/multi/search-replace/patch/tree/rename/move/signature | `cutover-receipt.json` edit receipts: `edit-preview`, `edit-apply`, and fail-closed `edit-refused`; edit behavior remains under edit-owned validation plans. | `retained` | `asp-dogfood-receipt.json` marks edit `retained-old-tool-gate`: ASP dogfood does not authorize edits or apply behavior. CIX edit remains retained until edit-specific installed receipts and ASP/host authority decisions accept replacement. | - -No row is currently `replaced`. - -## Current Retained Gates - -These commands remain active retained guardrails: - -```sh -npm run current-tools:validate-changed -npm run current-tools:validate-rust-graph -npm run current-tools:validate-all -``` - -`rust-old-rox-comparison-receipt-2026-06-27.json` records `current-tools:validate-rust-graph` as retained for #30 comparison purposes. `rust-adapter-parity.md` keeps both `current-tools:validate-rust-graph` and `current-tools:validate-changed` active for Rust parity work. `asp-dogfood-receipt.json` records `current-tools-validate-changed` and `current-tools-validate-rust-graph` as passed retained guardrails, while `current-tools-validate-all` remains `retained-not-run` unless explicitly requested. - -## Issue Truth-Up Queue - -After this matrix lands, update these coordination issues with the matrix link and the same current truth: - -- Opcore #13: #54 closes the retained old-tool guardrail matrix; no row is replaced and `oldToolReplacementClaimed:false` remains pinned. -- Opcore #30: old-tool retirement remains deferred; CRG graph has installed parity receipts, but the #29 Rust comparison receipt keeps `rust.rustdoc`, `rust.dead-code`, `rust.unused-deps`, `rust.function-metrics`, `current-tools:validate-rust-graph`, and Rust `current-tools:validate-changed` retained, keeps `rust.import-graph` deferred, and keeps `oldToolReplacementClaimed:false`. -- Opcore #10 / #26-#30: Rust parity rows remain owned by the Rust graph/Rox-retirement lane; #54 does not retire Rox. -- `the-open-engine/agent-server-protocol#26`: ASP #18 has closed with Option A accepted: no edit/inspect daemon and installed cold-start is acceptable. ASP coordination should now treat Opcore as one enrolled provider/server, not the host or authority. ACE remains an optional downstream host client. Inspect remains a parity blocker and edit remains a retained old-tool gate until ASP request/response and host-authority work accepts replacement. - -Do not update public docs, publish packages, announce retirement, or change repository visibility from this matrix. diff --git a/docs/superpowers/plans/2026-07-09-opcore-legacy-policy-parity.md b/docs/superpowers/plans/2026-07-09-opcore-legacy-policy-parity.md deleted file mode 100644 index 76f554e..0000000 --- a/docs/superpowers/plans/2026-07-09-opcore-legacy-policy-parity.md +++ /dev/null @@ -1,319 +0,0 @@ -# Opcore Native Policy Parity Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Make Opcore natively represent and enforce the same repo-local robustness policy capabilities the team depends on today, without importing, translating, loading, or depending on external policy config or runtime. - -**Non-negotiable boundary:** Active behavior must come only from Opcore-owned `.opcore/config` policy, Opcore packages, and Opcore check packs. Do not add config importers, translation shims, public fields that describe imported policy, or active runtime branches that inspect external policy config. - -**Architecture:** Add a first-class repo validation policy model under `.opcore/config`, then route every Opcore validation entrypoint through one policy-aware check factory. Existing validation packages already implement many primitives; this work wires configurable thresholds, selection, docs, clone, path policy, TypeScript architecture rules, dead-code entrypoints, repo lint plugins, Rust command gates, and custom check packs through supported Opcore-owned APIs. - -**Tech Stack:** TypeScript, Node >=22, existing `@the-open-engine/opcore-validation*` packages, Rust graph-core clone subcommand, Node test runner. - ---- - -## Clean Target - -Opcore must support this native config shape: - -```json -{ - "schemaVersion": 1, - "kind": "opcore_init_config", - "validation": { - "adapters": ["typescript", "rust", "docs", "clone"], - "timeoutMs": 120000, - "pathPolicy": { - "include": ["packages/", "scripts/"], - "exclude": ["node_modules", "dist", ".ace", ".agents", ".claude", ".codex", ".opcore"] - }, - "checks": { - "packs": ["./.opcore/checks/covibes-policy.cjs"], - "disabled": ["typescript.types"], - "defaults": ["docs.existence", "docs.freshness"], - "typescript": { - "fileLength": { "maxFileLines": 600 }, - "functionMetrics": { "maxFunctionLines": 120, "maxComplexity": 10, "maxParams": 4 }, - "lint": { "repoPlugin": "./eslint-local-rules/index.cjs", "cacheDependencyGlobs": ["AGENTS.md", "eslint-local-rules/**/*.cjs"] }, - "importGraph": { - "ignoreTypeOnlyImports": true, - "layerRules": [ - { "name": "no-client-to-server", "from": "%/client/src/%", "to": "%/server/%" } - ] - }, - "deadCode": { "entrypoints": ["server/shared/types/preview-surface.ts"] } - }, - "rust": { - "fileLength": { "maxFileLines": 500 }, - "functionMetrics": { "maxFunctionLines": 80, "maxComplexity": 10, "maxParams": 4 }, - "commandGates": [ - { "id": "rust-gate.cargo-test", "command": "cargo", "args": ["test"], "cwd": ".", "timeoutMs": 120000 } - ] - }, - "docs": { - "enabled": { "existence": true, "freshness": true, "staleness": false, "length": true, "hubCoverage": true, "subtreeCoverage": true }, - "policy": { - "filenames": ["AGENTS.md", "CLAUDE.md"], - "requiredPaths": ["."], - "requireRoot": true, - "minimumContentLength": 1, - "maxLines": 220, - "maxSectionLines": 80 - }, - "history": { "maxStaleDays": 90 }, - "hubCoverage": { "minFanIn": 5, "minFanOut": 5, "requireExplicitMention": true }, - "subtreeCoverage": { "minLoc": 20000 } - }, - "clone": { - "windowSize": 16, - "minLines": 16, - "threshold": 5, - "partitions": [["server", "shared"], ["client"], ["platform-cli"]], - "exclude": ["docs/**", "generated/**"], - "modes": ["staged", "changed", "files"] - } - } - } -} -``` - -Root-level fields outside `validation` may remain as install metadata, but they are not active validation behavior. In particular, do not read or translate a root-level `checks` object. - -## Current Gaps - -- Repo config parsing needs a normalized native `validation` policy model. -- All validation entrypoints need to construct checks through the same policy-aware factory. -- Built-in check enable/disable and default-scope controls need to apply consistently. -- Thresholds need to flow into TypeScript and Rust file/function checks. -- Path policy needs to filter file views before checks run. -- Clone detection needs native policy knobs for window size, threshold, partitions, excludes, and scope modes. -- Docs checks need native existence/freshness/staleness/length/hub/subtree configuration. -- TypeScript checks need native import-layer rules, type-only handling, dead-code entrypoints, and repo lint plugin rules. -- Rust checks need native command gates for repo-specific commands. -- Status, doctor, reports, package wiring, and packlists need to expose the native policy without implying replacement claims. - -## Files - -Create or keep: - -- `packages/validation-policy/` - - Shared native policy parser, path-policy helper, check-pack loader, and policy-aware check factory. -- `packages/opcore/src/repo-validation-config.ts` - - Thin public re-export for Opcore facade consumers. -- `packages/opcore/src/repo-validation-policy.ts` - - Thin public re-export plus clone invoker injection for the Opcore facade. -- `packages/opcore/src/path-policy.ts` - - Thin public re-export for tests and downstream use. -- `packages/opcore/src/repo-check-packs.ts` - - Thin public re-export for check-pack helpers. -- `packages/validation-typescript/src/import-layer-rules-check.ts` -- `packages/validation-typescript/src/dead-code-entrypoints.ts` -- `packages/validation-typescript/src/lint-plugin-check.ts` -- `packages/validation-rust/src/command-gate-check.ts` - -Modify: - -- `packages/opcore/src/validation-composition.ts` -- `packages/opcore/src/advanced/validation-composition.ts` -- `packages/asp-provider/src/validation-composition.ts` -- `packages/opcore/src/scan.ts` -- `packages/opcore/src/status.ts` -- `packages/opcore/src/doctor.ts` -- `packages/opcore/src/reporting.ts` -- `packages/validation-typescript/src/index.ts` -- `packages/validation-rust/src/index.ts` -- `packages/validation-docs/src/index.ts` -- `packages/validation-clone/src/clone-check.ts` -- `packages/contracts` -- package metadata, workspace checks, release package dirs, packlists, and AGENTS.md where architecture changes require it. - -## Task 1: Native Repo Validation Config - -- [x] Add normalized `.opcore/config.validation` parser. -- [x] Validate adapters, arrays, positive integers, thresholds, path policy, docs policy, clone policy, and TypeScript policy. -- [x] Keep unknown top-level config fields inert. -- [x] Keep check-pack loading repo-relative and package-resolvable through native `validation.checks.packs`. -- [x] Remove root-level `checks` translation and tests. - -Verification: - -```bash -node --test --test-name-pattern "repo validation config|native check pack config|native disabled|native docs" tests/validation-cli.test.mjs -``` - -## Task 2: Native Path Policy - -- [x] Add repo-relative include/exclude matching. -- [x] Filter validation file-view scope files, visible files, overlays, reads, existence checks, and overlay lookup. -- [x] Confirm graph-backed checks do not bypass the filtered file view through unfiltered graph requirements. - -Verification: - -```bash -node --test --test-name-pattern "path policy" tests/validation-cli.test.mjs -``` - -## Task 3: Policy-Aware Check Construction - -- [x] Add shared `@the-open-engine/opcore-validation-policy` package. -- [x] Route public `opcore check`, advanced check/validate, scan, and ASP provider validation through the same policy-aware factory. -- [x] Validate unknown check ids after built-ins and packs are assembled. -- [x] Apply adapter selection, disabled checks, default-scope promotion, and path policy wrappers. -- [x] Add policy evidence to status, doctor, and reports. - -Verification: - -```bash -node --test tests/validation-cli.test.mjs tests/asp-provider.test.mjs -``` - -## Task 4: Built-In Thresholds And Check Selection - -- [x] Wire TypeScript file/function thresholds. -- [x] Wire Rust file/function thresholds. -- [x] Wire docs enabled flags to docs default/disabled selection. -- [x] Reject unknown native check ids. - -Verification: - -```bash -node --test --test-name-pattern "configured TypeScript thresholds|configured Rust thresholds|unknown check id" tests/validation-cli.test.mjs -``` - -## Task 5: Clone Policy - -- [x] Extend clone contract request fields for `windowSize`, `threshold`, `partitions`, `exclude`, and `modes`. -- [x] Pass clone policy through TypeScript validation adapter to graph-core clone analysis. -- [x] Implement native graph-core clone filtering for window, threshold, partitions, excludes, and scope modes. - -Verification: - -```bash -node --test tests/validation-clone.test.mjs -cargo test -p opcore-graph-core clone:: -``` - -## Task 6: Docs Policy - -- [x] Add docs length max-line and max-section checks. -- [x] Add hub fan-out and explicit mention policy. -- [x] Add subtree coverage policy. -- [x] Wire docs config through the shared policy factory. - -Verification: - -```bash -node --test tests/validation-docs.test.mjs -``` - -## Task 7: TypeScript Import-Layer Rules - -- [x] Add native `typescript.import-layer-rules` check. -- [x] Support `ignoreTypeOnlyImports`, `layerRules`, and `fromNot`. -- [x] Wire TypeScript import graph policy into check construction. - -Verification: - -```bash -node --test --test-name-pattern "import layer|type-only imports" tests/validation-typescript.test.mjs -``` - -## Task 8: TypeScript Dead-Code Entrypoints - -- [x] Add native dead-code `entrypoints` option. -- [x] Treat configured entrypoint files as graph reachability roots. -- [x] Keep unsupported graph coverage visible instead of claiming clean coverage when evidence is missing. -- [x] Wire entrypoints through `.opcore/config.validation.checks.typescript.deadCode`. - -Verification: - -```bash -node --test --test-name-pattern "configured TypeScript dead-code entrypoints" tests/validation-typescript.test.mjs -node --test --test-name-pattern "configured TypeScript dead-code entrypoints" tests/validation-cli.test.mjs -``` - -## Task 9: TypeScript Repo Lint Plugin - -- [x] Add native `typescript.lint-plugin` check only when `validation.checks.typescript.lint.repoPlugin` is configured. -- [x] Use repo-relative plugin paths only; reject absolute paths, parent traversal, and resolved paths outside the repo. -- [x] Load plugin rules with `createRequire(join(repoRoot, "package.json"))`. -- [x] Cache plugin loading by plugin path and configured dependency mtimes. -- [x] Preserve the default `typescript.lint` check. - -Verification: - -```bash -node --test --test-name-pattern "repo lint plugin" tests/validation-typescript.test.mjs -``` - -## Task 10: Rust Command Gates - -- [x] Add native Rust command-gate check definitions from `validation.checks.rust.commandGates`. -- [x] Restrict commands to repo-contained cwd and explicit command/args arrays. -- [x] Honor per-gate timeout and repo-level timeout. -- [x] Return command stdout/stderr/status evidence without mutating source files. -- [x] Wire command gates through the shared policy factory. - -Verification: - -```bash -node --test --test-name-pattern "Rust command gate" tests/validation-rust.test.mjs tests/validation-cli.test.mjs -``` - -## Task 11: Status, Doctor, Reports - -- [x] `opcore status --json` reports whether native validation policy is loaded, check count, disabled ids, default ids, packs, and degraded policy fields without running checks. -- [x] `opcore doctor --json` reports config parse errors, loaded packs, native policy readiness, and next actions. -- [x] Scan/reporting includes configured/disabled check evidence and policy degradations. -- [x] No status, doctor, report, or install output suggests config import or translation mode. - -Verification: - -```bash -node --test --test-name-pattern "policy readiness|doctor|report" tests/opcore-facade.test.mjs tests/validation-cli.test.mjs -``` - -## Task 12: Packaging, Docs, And Guardrails - -- [x] Update package exports, workspace checks, release package dirs, lockfile, and packlists for new package/files. -- [x] Update AGENTS.md for the new `packages/validation-policy` ownership boundary and native policy rules. -- [x] Ensure launch-facing docs and package metadata say Opcore and do not claim replacement of external tools. -- [x] Ensure no new active code path imports, translates, or loads external policy config. - -Verification: - -```bash -npm run build -npm run workspace:check -npm run pack:check -``` - -## Task 13: Final Verification - -- [x] Run targeted test suites for changed packages. -- [x] Run local CI-equivalent or the strongest feasible repo gate. -- [x] Run current external retained guardrail comparison only as evidence that no guardrail coverage was lost; do not make Opcore depend on those tools. - - Attempted `npm run current-tools:validate-changed`; it fails before running retained analysis with `Cannot read properties of undefined (reading 'trim')`. -- [x] Record exact commands and outcomes in the final handoff. - -Minimum verification: - -```bash -npm run build -node --test tests/validation-cli.test.mjs -node --test tests/validation-typescript.test.mjs -node --test tests/validation-docs.test.mjs -node --test tests/validation-clone.test.mjs -node --test tests/validation-rust.test.mjs -node --test tests/asp-provider.test.mjs -cargo test -p opcore-graph-core clone:: -``` - -## Completion Criteria - -- Native `.opcore/config.validation` can express the depended-on policy surface. -- Every validation entrypoint uses the native policy-aware check factory. -- TypeScript, Rust, docs, clone, path policy, check packs, and ASP provider paths honor native policy. -- Unsupported or degraded evidence is explicit. -- No config importer, external-policy loader, or active translation layer exists. -- No public/product surface claims replacement of external tools. diff --git a/docs/validation/rust-adapter-parity.md b/docs/validation/rust-adapter-parity.md index 21b2dbf..5767394 100644 --- a/docs/validation/rust-adapter-parity.md +++ b/docs/validation/rust-adapter-parity.md @@ -1,135 +1,12 @@ -# Rust Adapter Parity Evidence +# Rust Validation Evidence -Status: private dogfood and release-readiness evidence for #20, #21, #28, and the #30 validation-doc slice. +`@the-open-engine/opcore-validation-rust` owns Rust provider assessment checks for source hygiene, formatting, +Cargo compilation, clippy, rustdoc, import structure, dead code, graph signals, unused dependencies, file length, +and function metrics. -Source rows: `agent-server-protocol/docs/planning/old-tool-compatibility-matrix.{md,json}` Rust validation rows -`lattice-rox-rust-adapter-and-function-metrics`, `orchestra-rox-rust-gate`, `orchestra-rox-native-dependencies`, -`covibes-gateway-rox-rust-gate`, `robustness-engine-rust-adapter-source`, and -`robustness-engine-cargo-manifest-handling`. +Cargo-backed checks materialize one isolated after-state workspace per validation file view. Missing optional tools +produce typed degraded or unsupported outcomes with `requiredTool`; they never become silent passes. Graph-backed +checks consume `ValidationGraphProviderClient` facts through public contracts only. -## #30 Validation Retirement Decisions - -This is private release-readiness documentation only. It is not a publish action, visibility change, public -announcement, public replacement claim, or old-tool retirement approval. - -Decision policy for #30 validation docs: no surface is `replaced` unless installed-artifact receipt evidence proves the -exact replacement and maintainer approval accepts retirement for that surface. The landed #29 comparison receipt does -not make a retirement decision, pins `oldToolReplacementClaimed: false`, and records no public release actions. It -landed through PR #104 at merge `ab0362d339ec2c41b0cc71ae5bb400c4b8254e36`. - -Evidence anchors: - -- [#29 Rust old-Rox comparison receipt](rust-old-rox-comparison-receipt-2026-06-27.json) and - [summary](rust-old-rox-comparison-receipt-2026-06-27.md). -- [#30 cutover receipt](../release/cutover-receipt.json) and - [summary](../release/cutover-receipt.summary.md). The receipt proves installed command coverage, including Rust graph - command receipts, but it does not include maintainer approval to retire Rust retained-tool rows. - -| Surface | Decision | Evidence | Why retirement is not accepted | Current guardrail action | -|---|---|---|---|---| -| `rust.rustdoc` | retained | #29 receipt records no graph replacement evidence. | Rustdoc diagnostics, broken intra-doc links, and documentation-policy failures remain unique current-tool evidence. No installed-artifact receipt plus maintainer approval proves exact replacement. | Keep current external Rust guardrails active for rustdoc coverage. | -| `rust.import-graph` | deferred | #29 records Rust graph `IMPORTS_FROM`/`DEPENDS_ON` facts; #30 records installed Rust graph build/query/impact/review-context/detect-changes/search receipts. | Graph facts are useful parity evidence, but rustdoc and cargo-depgraph-enriched import checks remain retained where graph facts are not sufficient. No maintainer approval flips this row. | Keep current external import-graph guardrails active while native graph evidence complements them. | -| `rust.dead-code` | retained | #29 records exported symbol metadata and graph-backed dead-public-export signals. | Cargo `dead_code` diagnostics and compiler reachability remain uniquely provided by current tools. Graph dead-public-export evidence is not exact replacement evidence. | Keep current external dead-code guardrails active. | -| `rust.unused-deps` | retained | #29 records no graph replacement evidence. | Cargo-udeps unused dependency analysis remains the unique evidence source. | Keep current external unused-dependency guardrails active. | -| `rust.function-metrics` | retained | #29 records Rust function/method spans and signatures; #30 records installed Rust graph receipts. | Rust-code-analysis complexity, line-count, and parameter-threshold metrics remain unique current-tool evidence. Spans/signatures are not exact metric replacement evidence. | Keep current external function-metric guardrails active. | -| `current-tools:validate-rust-graph` | retained | #29 records the aggregate Rust graph guardrail as retained; #30 Rust receipts are graph-owned installed command receipts. | No receipt proves an exact aggregate replacement for the current-tools Rust graph gate, and no maintainer approval retires it. | Continue running `npm run current-tools:validate-rust-graph`. | -| Rust portion of `current-tools:validate-changed` | retained | #30 installed `opcore check changed` receipt uses `--checks typescript.syntax`; #29 carries only Rust comparison evidence with `oldToolReplacementClaimed: false`. | The installed changed-check receipt does not exercise Rust retained-tool coverage, and no Tom approval flips Rust changed-file guardrails. | Continue running `npm run current-tools:validate-changed` for changed Rust-owned inputs and mixed changes. | - -## Native Rust Checks - -`@the-open-engine/opcore-validation-rust` exports these provider assessment checks: - -| Check | Native behavior | Retained compatibility | -|---|---|---| -| `rust.source-hygiene` | Rejects `.inc`, `include!(...)`, `rustfmt::skip`, `allow(dead_code)`, broad `allow`/`expect`, and owned lint suppressions. | none for covered inputs | -| `rust.fmt` | Runs rustfmt or cargo fmt in a temporary workspace. | current external gate stays until #27 self-dogfood proof | -| `rust.cargo-check` | Runs structured Cargo metadata and `cargo check --message-format=json`. | current external gate stays until #21 runtime/cache decision | -| `rust.clippy` | Runs `cargo clippy` with Opcore-owned lint set. | current external gate stays until #21 runtime/cache decision | -| `rust.rustdoc` | Runs `cargo doc --no-deps --all-features --message-format=json`; rustdoc diagnostics are blocking policy evidence. | unsupported when rustdoc is missing; retain old gate | -| `rust.import-graph` | Reports unresolved `mod`, unresolved `crate`/`self`/`super` use paths, orphan source files, and module cycles from fileView after-state content. | cargo-depgraph enrichment remains degraded when unavailable | -| `rust.dead-code` | Reuses structured `cargo check --message-format=json` dead-code diagnostics from `rust.cargo-check` and adds native orphan-source dead-code evidence, without a second `-Ddead_code` compile. | core Cargo absence makes adapter unavailable; old gates stay active | -| `rust.graph-signals` | Reports graph-backed untested public Rust surface, dead public exports, module orphans, and module cycles through `ValidationGraphProviderClient`. | requires available graph facts; does not replace cargo/rustdoc/clippy/Rox guardrails | -| `rust.unused-deps` | Runs cargo-udeps with workspace or package scoping and parses unused dependency names into deterministic diagnostics. | unsupported when cargo-udeps is missing; retain old gate | -| `rust.function-metrics` | Runs rust-code-analysis-cli JSON object/array output and enforces 80 lines, complexity 10, params 4. | unsupported when tool is missing; retain old gate | - -## Retained Compatibility Ledger - -`opcore status --json` and `opcore doctor --json` must report retained blockers in `degradedChecks` only when a -supporting retained tool is missing. With `rustdoc`, `cargo-depgraph`, `cargo-udeps`, and `rust-code-analysis-cli` -available, the Rust adapter is `available` with `degradedChecks: []`. Missing-tool entries are not passing no-op checks; -they are machine-readable cutover blockers for #27/#28/#29. - -| Check | Opcore | Orchestra | CoVibes | Gateway | Required tool when degraded | Follow-up | -|---|---:|---:|---:|---:|---|---| -| `rust.rustdoc` | no | yes | no | yes | `rustdoc` | #27/#28/#29 | -| `rust.import-graph` | no | yes | no | yes | `cargo-depgraph` | #27/#28/#29 | -| `rust.dead-code` | no | yes | no | yes | core `cargo` only | #27/#28/#29 | -| `rust.graph-signals` | yes | no | yes | yes | graph provider | #28/#29 | -| `rust.unused-deps` | no | yes | no | yes | `cargo-udeps` | #27/#28/#29 | -| `rust.function-metrics` | yes | yes | no | yes | `rust-code-analysis-cli` | #27/#28/#29 | - -Cargo.lock-only changes are retained compatibility too. Current native ownership covers `.rs`, `.inc`, and -`Cargo.toml`; lockfile-only policy remains under #21 until an explicit cutover decision expands ownership. - -## Scope And Overlay Evidence - -Rust-owned inputs are `.rs`, `.inc`, and `Cargo.toml`. Cargo.lock-only changes are explicitly skipped as retained compatibility. Tree scope uses committed Git tree content through the validation workspace. Pre-write and hypothetical requests use fileView after-state overlays and one run-scoped temporary materialization for Cargo tools. Selected checks with the same materialization environment share the workspace; the runner removes it on normal, fail-fast, streaming, or failed exits. - -Representative Opcore diffs: - -```diff -diff --git a/crates/graph-core/src/lib.rs b/crates/graph-core/src/lib.rs -+#[allow(dead_code)] -+pub fn hidden_regression() {} -``` - -Expected native result: `rust.source-hygiene` returns `policy_failure` with `RUST_SOURCE_ALLOW_DEAD_CODE`. - -```diff -diff --git a/Cargo.toml b/Cargo.toml -@@ --members = ["crates/graph-core"] -+members = ["crates/graph-core", "crates/new-member"] -``` - -Expected native result: Rust checks run because Cargo.toml is adapter-owned. Missing package/toolchain failures are typed as `policy_failure`, `unsupported_request`, or `infrastructure_failure`, not silent skips. - -Representative Orchestra comparison diffs: - -```diff -diff --git a/crates/orchestra-core/src/lib.rs b/crates/orchestra-core/src/lib.rs -+pub fn unchecked(values: Vec) -> i32 { values[3] } -``` - -Expected native result: `rust.clippy` reports owned lint diagnostics when run beside Orchestra current gates. Orchestra must keep `npm run rox:ci`, `npm run rox:repo`, and `npm run rox:check` until #28 records replacement evidence from the same diff. - -## #21 Runtime Facts - -- Temporary workspace materialization is required for Cargo-backed checks, but it is bounded to one workspace per - validation file-view state and materialization environment. -- Missing `cargo`, `rustfmt`, or `clippy` makes the Rust adapter unavailable. Missing `rustdoc`, `cargo-udeps`, - `cargo-depgraph`, or `rust-code-analysis-cli` keeps the Rust adapter degraded and annotates the retained blocker - entry with `requiredTool`; no generic retained entries remain when those tools are available. -- Retained blocker entries include `currentUsage` booleans for Opcore, Orchestra, CoVibes, and gateway consumers. -- cargo-depgraph is optional enrichment for `rust.import-graph`; missing state is degraded, not a policy failure. -- cargo-udeps and rust-code-analysis-cli are required for their selected checks; missing state returns `unsupported_request`. -- #61 adds no validation daemon, hidden validation cache, Rox import, Rox cache read, or Rox shellout from native checks. - -## Retained Guardrails - -Keep Opcore current external Rust guardrails until #21/#27 accept parity and runtime behavior: - -```sh -npm run current-tools:validate-rust-graph -npm run current-tools:validate-changed -npm run current-tools:validate-all -``` - -Keep Orchestra current gates until #28 records safe comparison evidence: - -```sh -cd ../orchestra -npm run rox:ci -npm run rox:repo -npm run rox:check -``` +Opcore validates Rust changes through `npm run opcore:self-check`, targeted `opcore check` scopes, and the normal +Rust/CI gates. No external development toolchain is part of this repository's validation path. diff --git a/docs/validation/rust-old-rox-comparison-receipt-2026-06-27.json b/docs/validation/rust-old-rox-comparison-receipt-2026-06-27.json deleted file mode 100644 index 328503a..0000000 --- a/docs/validation/rust-old-rox-comparison-receipt-2026-06-27.json +++ /dev/null @@ -1,90 +0,0 @@ -{ - "schemaVersion": 1, - "issue": "#29", - "origin": "covibes-authored-old-rox-comparison", - "generatedAt": "2026-06-27T00:00:00.000Z", - "privateRepo": true, - "oldToolReplacementClaimed": false, - "publicReleaseActions": [], - "surfaces": [ - { - "id": "rust.rustdoc", - "graphEvidenceExists": false, - "graphEvidence": [ - "No graph fact replaces rustdoc diagnostics." - ], - "stillUniquelyProvidedByCurrentTools": [ - "rustdoc diagnostics, broken intra-doc links, and documentation-policy failures remain current-tool evidence." - ], - "replacementStatus": "retained" - }, - { - "id": "rust.import-graph", - "graphEvidenceExists": true, - "graphEvidence": [ - "Rust graph extraction emits IMPORTS_FROM and DEPENDS_ON edges for crate module files.", - "Rust graph query, impact, review-context, detect-changes, and search receipts exercise those facts on a Rust fixture." - ], - "stillUniquelyProvidedByCurrentTools": [ - "rustdoc and cargo-depgraph-enriched import checks remain retained current-tool evidence where graph facts are not sufficient." - ], - "replacementStatus": "deferred" - }, - { - "id": "rust.dead-code", - "graphEvidenceExists": true, - "graphEvidence": [ - "Rust graph extraction emits exported symbol metadata and graph-backed dead-public-export signals." - ], - "stillUniquelyProvidedByCurrentTools": [ - "Cargo dead_code diagnostics and retained current-tool gate behavior still uniquely cover compiler reachability." - ], - "replacementStatus": "retained" - }, - { - "id": "rust.unused-deps", - "graphEvidenceExists": false, - "graphEvidence": [ - "No graph fact replaces cargo-udeps unused dependency analysis." - ], - "stillUniquelyProvidedByCurrentTools": [ - "cargo-udeps and retained current-tool unused dependency detection remain the unique evidence source." - ], - "replacementStatus": "retained" - }, - { - "id": "rust.function-metrics", - "graphEvidenceExists": true, - "graphEvidence": [ - "Rust graph extraction emits function and method symbols with source spans and signatures." - ], - "stillUniquelyProvidedByCurrentTools": [ - "rust-code-analysis complexity, line-count, and parameter-threshold metrics remain retained current-tool evidence." - ], - "replacementStatus": "retained" - }, - { - "id": "current-tools:validate-rust-graph", - "graphEvidenceExists": false, - "graphEvidence": [ - "Graph receipts do not replace the aggregate current-tools Rust graph gate." - ], - "stillUniquelyProvidedByCurrentTools": [ - "npm run current-tools:validate-rust-graph remains the retained aggregate guardrail until #30 makes a retirement decision." - ], - "replacementStatus": "retained" - } - ], - "guardrails": [ - { - "id": "current-tools:validate-rust-graph", - "command": [ - "npm", - "run", - "current-tools:validate-rust-graph" - ], - "replacementStatus": "retained", - "oldToolReplacementClaimed": false - } - ] -} diff --git a/docs/validation/rust-old-rox-comparison-receipt-2026-06-27.md b/docs/validation/rust-old-rox-comparison-receipt-2026-06-27.md deleted file mode 100644 index b5b346b..0000000 --- a/docs/validation/rust-old-rox-comparison-receipt-2026-06-27.md +++ /dev/null @@ -1,18 +0,0 @@ -# Rust Old-Rox Comparison Receipt - 2026-06-27 - -Issue: #29. - -Machine receipt: `docs/validation/rust-old-rox-comparison-receipt-2026-06-27.json` - -This receipt records Rust graph evidence without making a retirement decision. `oldToolReplacementClaimed` is pinned to `false`, and no public release or publish action is recorded. - -| Surface | Graph Evidence | Still Unique To Current Tools | Status | -|---|---|---|---| -| `rust.rustdoc` | none | rustdoc diagnostics and documentation-policy failures | retained | -| `rust.import-graph` | Rust module/import graph facts plus query and impact receipts | rustdoc/cargo-depgraph-enriched import checks | deferred | -| `rust.dead-code` | exported symbol metadata and graph-backed dead-public-export signals | Cargo `dead_code` compiler reachability | retained | -| `rust.unused-deps` | none | cargo-udeps unused dependency analysis | retained | -| `rust.function-metrics` | Rust function/method spans and signatures | rust-code-analysis complexity and threshold metrics | retained | -| `current-tools:validate-rust-graph` | none | aggregate retained Rust graph guardrail | retained | - -`npm run current-tools:validate-rust-graph` remains active until #30 explicitly changes retained-tool policy. diff --git a/docs/validation/rust-retained-tools-receipts-2026-06-23.json b/docs/validation/rust-retained-tools-receipts-2026-06-23.json deleted file mode 100644 index 55dfdd2..0000000 --- a/docs/validation/rust-retained-tools-receipts-2026-06-23.json +++ /dev/null @@ -1,235 +0,0 @@ -{ - "schemaVersion": 1, - "issue": "#61", - "origin": "covibes-authored-retained-rust-parity-proof", - "generatedAt": "2026-06-23T22:18:30.000Z", - "runtimeDecision": { - "issue": "#21", - "decision": "no validation daemon and no hidden validation cache for initial Opcore cutover", - "providerOnly": true - }, - "nativeRetainedChecks": [ - "rust.rustdoc", - "rust.import-graph", - "rust.dead-code", - "rust.unused-deps", - "rust.function-metrics" - ], - "opcore": { - "launchGate": { - "fileLengthBaselinePresent": true, - "evidence": [ - "packages/validation-rust/src/file-length-check.ts exists", - "packages/validation-rust/src/check-ids.ts exports rust.file-length", - "check manifest, validate manifest, status, and doctor include rust.file-length" - ] - }, - "statusReceipts": [ - { - "command": "node packages/cli/dist/index.js status --json", - "exitCode": 0, - "rustAdapterStatus": "degraded", - "degradedChecks": [ - { - "checkId": "rust.import-graph", - "requiredTool": "cargo-depgraph", - "reason": "optional_tool_unavailable" - } - ] - }, - { - "command": "node packages/cli/dist/index.js doctor --json", - "exitCode": 0, - "rustAdapterStatus": "degraded", - "degradedChecks": [ - { - "checkId": "rust.import-graph", - "requiredTool": "cargo-depgraph", - "reason": "optional_tool_unavailable" - } - ] - } - ], - "nativeToolAvailableReceipt": { - "command": "node --test tests/validation-rust.test.mjs tests/validation-cli.test.mjs", - "assertion": "fake full Rust toolchain reports Rust adapter available with degradedChecks []", - "exitCode": 0 - }, - "nativeCheckReceipts": [ - { - "checkId": "rust.rustdoc", - "coverage": [ - "cargo doc JSON diagnostics become blocking policy failures", - "missing rustdoc returns unsupported_request" - ] - }, - { - "checkId": "rust.import-graph", - "coverage": [ - "unresolved mod declarations", - "unresolved crate/self/super use paths", - "orphan Rust source files", - "module cycles", - "overlay write/delete after-state reads" - ] - }, - { - "checkId": "rust.dead-code", - "coverage": [ - "cargo check dead_code JSON diagnostics", - "native orphan-source dead-code evidence" - ] - }, - { - "checkId": "rust.unused-deps", - "coverage": [ - "cargo-udeps workspace/package scoping", - "deterministic RUST_UNUSED_DEPENDENCY diagnostics", - "missing cargo-udeps unsupported_request" - ] - }, - { - "checkId": "rust.function-metrics", - "coverage": [ - "rust-code-analysis object and array JSON", - "function line, complexity, and parameter thresholds", - "repo and package scopes", - "missing rust-code-analysis-cli unsupported_request" - ] - } - ], - "verification": [ - { - "command": "npm run build", - "exitCode": 0 - }, - { - "command": "node --test tests/validation-rust.test.mjs tests/validation-rust-package-scope.test.mjs tests/validation-rust-regressions.test.mjs tests/validation-cli.test.mjs tests/validation-command-adapter.test.mjs tests/contracts.test.mjs tests/schema-contracts.test.mjs tests/gate-negative-fixtures.test.mjs", - "exitCode": 0, - "tests": 127 - }, - { - "command": "npm run rust:check", - "exitCode": 0 - }, - { - "command": "npm run ci", - "exitCode": 0, - "tests": 423 - }, - { - "command": "npm run setup:tools", - "exitCode": 0 - }, - { - "command": "npm run current-tools:validate-rust-graph", - "exitCode": 0, - "summary": "[]" - }, - { - "command": "npm run current-tools:validate-changed", - "exitCode": 0, - "summary": "Rox changed gate passed with 6 baseline-equivalent legacy code-quality findings retained." - }, - { - "command": "npm run current-tools:validate-all", - "exitCode": 0, - "summary": "No issues found" - } - ] - }, - "orchestra": { - "repo": "covibes/orchestra", - "oldToolReceipts": [ - { - "command": "npm run rox:ci", - "exitCode": 0, - "summary": "No issues found" - }, - { - "command": "npm run rox:repo", - "exitCode": 0, - "summary": "No issues found" - }, - { - "command": "npm run rox:check", - "exitCode": 0, - "summary": "No issues found" - } - ] - }, - "gateway": { - "repo": "covibes/covibes/gateway", - "oldToolReceipt": { - "command": "NODE_NO_WARNINGS=1 ../.ace/runtime/bin/rox check --all --no-daemon", - "exitCode": 2, - "retainedFindingCount": 11, - "retainedFindings": [ - { - "path": "crates/preview-router/src/ext_proc.rs", - "code": "code-quality/file-length", - "message": "File too long (1591 lines, max 600)" - }, - { - "path": "crates/preview-router/src/proto.rs", - "code": "rust-adapter/no-broad-lint-suppression", - "message": "Broad Rust lint suppression is forbidden" - }, - { - "path": "crates/preview-router/tests/golden_vectors.rs", - "code": "rust-adapter/no-rox-owned-lint-suppression", - "message": "Suppressing Rust lints owned by rox is forbidden" - }, - { - "path": null, - "code": "rust-adapter/import-graph-error", - "message": "rustdoc import graph failed for preview-router test cache_load" - }, - { - "path": "crates/preview-router/src/config.rs:136", - "code": "rust-adapter/cyclomatic-complexity", - "message": "Function from_lookup complexity 11 exceeds max 10" - }, - { - "path": "crates/preview-router/src/identity.rs:97", - "code": "rust-adapter/cyclomatic-complexity", - "message": "Function parse_preview_host_identity complexity 16 exceeds max 10" - }, - { - "path": "crates/preview-router/src/metadata.rs:58", - "code": "rust-adapter/cyclomatic-complexity", - "message": "Function parse_deployment_metadata complexity 11 exceeds max 10" - }, - { - "path": "crates/preview-router/src/postgres_provider.rs:167", - "code": "rust-adapter/cyclomatic-complexity", - "message": "Function decode_row complexity 15 exceeds max 10" - }, - { - "path": "crates/preview-router/src/router.rs:86", - "code": "rust-adapter/cyclomatic-complexity", - "message": "Function route complexity 11 exceeds max 10" - }, - { - "path": "crates/preview-router/tests/golden_vectors.rs:170", - "code": "rust-adapter/cyclomatic-complexity", - "message": "Function all_golden_vectors_pass complexity 13 exceeds max 10" - }, - { - "path": null, - "code": "rust-adapter/dead-code-error", - "message": "rustdoc orphan-source reachability failed for preview-router test cache_load" - } - ] - } - }, - "guardrails": { - "noValidationDaemon": true, - "noHiddenValidationCache": true, - "noRoxImports": true, - "noRoxCacheReads": true, - "noRoxShelloutsFromNativeChecks": true, - "oldRoxGatesRemainActive": true, - "providerAssessmentsOnly": true - } -} diff --git a/docs/validation/rust-retained-tools-receipts-2026-06-23.md b/docs/validation/rust-retained-tools-receipts-2026-06-23.md deleted file mode 100644 index 032ec96..0000000 --- a/docs/validation/rust-retained-tools-receipts-2026-06-23.md +++ /dev/null @@ -1,65 +0,0 @@ -# Rust Retained Tools Receipts - 2026-06-23 - -Issue: #61. - -Runtime decision: #21 remains Option A: no validation daemon and no hidden validation cache. Opcore emits provider -assessment evidence only; ASP hosts keep decision authority. - -## Native Parity Closed - -The five retained Rust rows now have native Opcore behavior when supporting tools are available: - -| Check | Native receipt | -|---|---| -| `rust.rustdoc` | `cargo doc --no-deps --all-features --message-format=json`; rustdoc diagnostics block, missing rustdoc is `unsupported_request`. | -| `rust.import-graph` | fileView-based unresolved `mod`, unresolved `crate`/`self`/`super` use paths, orphan sources, and cycles. | -| `rust.dead-code` | Cargo `dead_code` diagnostics plus native orphan-source dead-code evidence. | -| `rust.unused-deps` | cargo-udeps workspace/package scoping and sorted `RUST_UNUSED_DEPENDENCY` diagnostics. | -| `rust.function-metrics` | rust-code-analysis object/array JSON parsing with lines, complexity, and parameter thresholds. | - -With a full fake Rust toolchain, status and doctor tests prove the Rust adapter is `available` with -`degradedChecks: []`. In this local environment `cargo-depgraph` is absent, so live status/doctor correctly show only: - -```json -{"checkId":"rust.import-graph","requiredTool":"cargo-depgraph","reason":"optional_tool_unavailable"} -``` - -`rust.dead-code` has no generic retained row when core Cargo is available. - -## Opcore Receipts - -| Command | Result | -|---|---| -| `npm run build` | pass | -| targeted validation Rust/CLI/contracts/schema/gate tests | pass, 127 tests | -| `node packages/cli/dist/index.js check manifest --json` | pass, includes `rust.file-length` | -| `node packages/cli/dist/index.js validate manifest --json` | pass, includes `rust.file-length` | -| `node packages/cli/dist/index.js status --json` | pass, Rust adapter degraded only for missing `cargo-depgraph` | -| `node packages/cli/dist/index.js doctor --json` | pass, Rust adapter degraded only for missing `cargo-depgraph` | -| `npm run rust:check` | pass | -| `npm run ci` | pass, 423 tests | -| `npm run setup:tools` | pass | -| `npm run current-tools:validate-rust-graph` | pass, `[]` | -| `npm run current-tools:validate-changed` | pass, 6 baseline-equivalent legacy findings retained | -| `npm run current-tools:validate-all` | pass, no issues found | - -## External Old-Tool Receipts - -Orchestra remains strict and green: - -| Command | Result | -|---|---| -| `npm run rox:ci` | pass, no issues found | -| `npm run rox:repo` | pass, no issues found | -| `npm run rox:check` | pass, no issues found | - -Gateway remains retained evidence with exit 2 and 11 existing findings. The finding list is preserved in -`docs/validation/rust-retained-tools-receipts-2026-06-23.json`; no thresholds were relaxed. - -## Guardrails - -- No validation daemon. -- No hidden validation cache. -- No Rox imports, Rox cache reads, or Rox shellouts from native Opcore checks. -- Current external Rox gates stay active until downstream #27/#28/#29 accept replacement evidence. -- Results are provider assessments only, not ASP host decisions or release authority. diff --git a/eslint.config.mjs b/eslint.config.mjs index 0b9b881..be31dd3 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -4,9 +4,9 @@ import tseslint from "typescript-eslint"; // Advisory ESLint config for opcore (TypeScript + JS). // // Run with `npm run lint:eslint`. This is intentionally NOT wired into `npm run ci` -// yet: the existing Rox, tsc, clippy `-D warnings`, and `cargo fmt --check` gates remain -// the blocking guardrails. ESLint is added advisory-first so violations can be measured -// and cleaned up before any enforcement decision (matches the covibes/orchestra baseline). +// yet: TypeScript, Rust, workspace, provenance, and Opcore self-validation remain +// the blocking guardrails. ESLint is advisory-first so violations can be measured +// and cleaned up before any enforcement decision. export default tseslint.config( { ignores: [ @@ -15,7 +15,6 @@ export default tseslint.config( "**/target/**", "**/*.d.ts", ".claude/**", - ".ace/**", ], }, js.configs.recommended, diff --git a/opcore-zero.docs.json b/opcore-zero.docs.json new file mode 100644 index 0000000..6b5ec16 --- /dev/null +++ b/opcore-zero.docs.json @@ -0,0 +1,9 @@ +{ + "schemaVersion": 1, + "bindings": [ + { + "source": "packages/contracts/src/index.ts", + "document": "AGENTS.md" + } + ] +} diff --git a/package.json b/package.json index 184e75e..55c63f3 100644 --- a/package.json +++ b/package.json @@ -24,8 +24,7 @@ "asp-dogfood:check": "node scripts/generate-asp-dogfood-receipt.mjs --json", "asp-dogfood:receipt": "node scripts/generate-asp-dogfood-receipt.mjs --write --json", "asp-provider:manifest": "node scripts/write-asp-provider-manifest.mjs", - "baseline:graph-reference": "node scripts/measure-graph-reference-baselines.mjs", - "ci": "npm run lint && npm run rust:check && npm run build && npm run test:ci && npm run graph-release:check && npm run release-receipt:check && OPCORE_CUTOVER_REUSE_RELEASE_PACKAGES=1 OPCORE_CUTOVER_REUSE_CURRENT_TOOL_GUARDRAILS=1 npm run cutover:check", + "ci": "npm run lint && npm run rust:check && npm run build && npm run test:ci && npm run graph-release:check && npm run release-receipt:check && OPCORE_CUTOVER_REUSE_RELEASE_PACKAGES=1 npm run cutover:check", "ci:local": "bash ./scripts/ci/run-local-ci-equivalent.sh", "conformance:check": "node --test tests/conformance.test.mjs", "descriptor:artifact": "node scripts/write-cli-descriptor.mjs", @@ -41,6 +40,7 @@ "license:report": "node scripts/license-report.mjs", "latency:check": "node scripts/check-latency-budgets.mjs --records tests/fixtures/latency/telemetry-pass.jsonl", "lint": "node scripts/check-workspace.mjs", + "opcore:self-check": "node scripts/run-opcore-self-check.mjs", "pack:check": "node scripts/check-packages.mjs", "provenance:check": "node scripts/check-provenance.mjs", "python:resolver-matrix": "node scripts/check-python-resolver-matrix.mjs", @@ -51,21 +51,11 @@ "rust:clippy": "cargo clippy --all-targets --all-features -- -D warnings", "rust:fmt": "cargo fmt --check", "rust:test": "cargo test", - "ace:check": "bash ./scripts/run-ace.sh check", - "ace:install": "bash ./scripts/run-ace.sh install", - "ace:status": "bash ./scripts/run-ace.sh status", - "ace:sync": "bash ./scripts/run-ace.sh sync", - "ace:validate": "bash ./scripts/run-ace.sh validate", - "setup": "npm ci && npm run setup:tools", + "setup": "npm ci", "setup:check-clean": "bash ./scripts/ci/check-setup-clean-worktree.sh", - "setup:tools": "bash ./scripts/setup-current-tools.sh", - "test": "node --test tests/*.test.mjs", + "test": "node --test tests/*.test.mjs tests/*.test.ts", "test:ci": "node scripts/run-test-ci.mjs", "verify": "bash ./scripts/ci/run-local-ci-equivalent.sh", - "current-tools:validate-all": "./.ace/runtime/bin/rox check --all --no-daemon", - "current-tools:validate-rust-graph": "node scripts/check-rust-graph-function-metrics.mjs", - "current-tools:validate-changed": "node scripts/ci/run-rox-clean-changed-gate.mjs", - "current-tools:graph-status": "./.ace/runtime/bin/crg status --repo .", "lint:eslint": "eslint ." }, "repository": { diff --git a/packages/asp-provider/README.md b/packages/asp-provider/README.md index 8910263..f6a2db9 100644 --- a/packages/asp-provider/README.md +++ b/packages/asp-provider/README.md @@ -18,8 +18,7 @@ and honest coverage; the ASP host owns the allow/deny/transaction outcome. - It is **not** an ASP host, manager, catalog, or authority. - It is **not** exposed through aggregate CLI ASP subcommands; there is no ASP router command. The provider is launched as its own process. -- It does **not** use ACE as a carrier or provisioner, does **not** read or write - `.ace/runtime`, and does **not** execute `rox`, `crg`, or `cix`. +- It does not invoke external development toolchains. - It makes no ASP-standard, old-tool-replacement, security-scanner, all-stack, AI-authorship, automatic-fix, or opaque score-style claim. diff --git a/packages/contracts/schemas/opcore-contracts.schema.json b/packages/contracts/schemas/opcore-contracts.schema.json index 4aefbac..908bfaf 100644 --- a/packages/contracts/schemas/opcore-contracts.schema.json +++ b/packages/contracts/schemas/opcore-contracts.schema.json @@ -5374,15 +5374,7 @@ "minItems": 1, "items": { "type": "string", - "minLength": 1, - "pattern": "^(?!.*(?:^|[\\\\/])\\.ace(?:[\\\\/]|$))(?!.*LATTICE_CURRENT_TOOLS_DIR).+", - "not": { - "enum": [ - "crg", - "cix", - "rox" - ] - } + "minLength": 1 } }, "expectedExitCode": { @@ -6533,15 +6525,7 @@ "minItems": 1, "items": { "type": "string", - "minLength": 1, - "pattern": "^(?!.*(?:^|[\\\\/])\\.ace(?:[\\\\/]|$))(?!.*LATTICE_CURRENT_TOOLS_DIR).+", - "not": { - "enum": [ - "crg", - "cix", - "rox" - ] - } + "minLength": 1 } }, "expectedExitCode": { @@ -6679,7 +6663,7 @@ "ManagedToolPackagePath": { "type": "string", "minLength": 1, - "pattern": "^(?![\\\\/])(?![A-Za-z]:[\\\\/])(?!~(?:[\\\\/]|$))(?!\\.\\.)(?!.*(?:^|[\\\\/])\\.\\.(?:[\\\\/]|$))(?!.*(?:^|[\\\\/])(?:\\.ace|\\.agents|\\.claude|\\.codex|\\.gemini|\\.opencode)(?:[\\\\/]|$))(?!.*LATTICE_CURRENT_TOOLS_DIR).+" + "pattern": "^(?![\\\\/])(?![A-Za-z]:[\\\\/])(?!~(?:[\\\\/]|$))(?!\\.\\.)(?!.*(?:^|[\\\\/])\\.\\.(?:[\\\\/]|$))(?!.*(?:^|[\\\\/])(?:\\.agents|\\.claude|\\.codex|\\.gemini|\\.opencode)(?:[\\\\/]|$)).+" }, "InspectReferenceTarget": { "$ref": "#/$defs/InspectSymbolTarget" @@ -11239,10 +11223,10 @@ "forbiddenMarkersAbsent", "generatedBuildMetadataAbsent", "privatePathsAbsent", - "pythonCrgSourceAbsent", - "pythonGraphPackageMetadataAbsent", - "pythonCrgGitHistoryAbsent", - "forbiddenImplementationPackageNamesAbsent", + "sourceProvenanceAbsent", + "packageMetadataAbsent", + "gitHistoryAbsent", + "foreignImplementationNamesAbsent", "inspections" ], "properties": { @@ -11268,20 +11252,20 @@ "privatePathsAbsent": { "const": true }, - "pythonCrgSourceAbsent": { - "const": true + "inspections": { + "$ref": "#/$defs/GraphReleaseStringArray" }, - "pythonGraphPackageMetadataAbsent": { + "sourceProvenanceAbsent": { "const": true }, - "pythonCrgGitHistoryAbsent": { + "packageMetadataAbsent": { "const": true }, - "forbiddenImplementationPackageNamesAbsent": { + "gitHistoryAbsent": { "const": true }, - "inspections": { - "$ref": "#/$defs/GraphReleaseStringArray" + "foreignImplementationNamesAbsent": { + "const": true } } }, @@ -11347,7 +11331,8 @@ "supporting", "optional", "deferred" - ] + ], + "$ref": "#/$defs/GraphReleaseSurfaceClassification" }, "status": { "const": "deferred" @@ -13558,85 +13543,96 @@ } ] }, - "GraphReferenceEvidenceBaselineReceipt": { + "Sha256": { + "type": "string", + "pattern": "^[a-f0-9]{64}$" + }, + "ReleaseReceiptPackageName": { + "enum": [ + "opcore" + ] + }, + "ReleaseReceiptCommandGroupName": { + "enum": [ + "graph", + "inspect", + "edit", + "check", + "validate", + "status", + "doctor" + ] + }, + "ReleaseReceiptReportId": { + "enum": [ + "package-inspection", + "license", + "provenance", + "release-hygiene", + "graph-release", + "secret-history" + ] + }, + "ReleaseReceiptTarballEvidence": { "type": "object", "additionalProperties": false, - "allOf": [ - { - "$ref": "#/$defs/GraphReferenceEvidenceRequiredFixtureCoverage" - } - ], "required": [ - "id", - "metric", - "classification", - "receipt", - "label", - "sourceAvailability", - "nonImplementationInput", - "fixtures" + "filename", + "path", + "sha256" ], "properties": { - "id": { + "filename": { "type": "string", "minLength": 1 }, - "metric": { - "type": "string", - "minLength": 1 + "path": { + "$ref": "#/$defs/RepoRelativePath" }, - "classification": { - "$ref": "#/$defs/GraphReferenceEvidenceClassification" + "sha256": { + "$ref": "#/$defs/Sha256" }, - "receipt": { + "integrity": { "type": "string", "minLength": 1 }, - "label": { - "const": "reference_evidence_non_implementation_input" - }, - "sourceAvailability": { - "enum": [ - "available", - "unavailable" - ] - }, - "nonImplementationInput": { - "const": true - }, - "fixtures": { - "$ref": "#/$defs/GraphReferenceEvidenceCoverageFixtures" + "shasum": { + "type": "string", + "minLength": 1 } } }, - "GraphReferenceEvidenceCommandSurface": { + "ReleaseReceiptPackageManifestMetadata": { "type": "object", "additionalProperties": false, - "allOf": [ - { - "$ref": "#/$defs/GraphReferenceEvidenceRequiredFixtureCoverage" - } - ], "required": [ - "id", - "classification", - "referenceTool", - "referenceCommand", - "canonicalCommand", - "flags", - "positionals", - "fixtures", - "exitSemantics" + "name", + "version", + "license", + "files", + "bins", + "dependencies", + "bundledDependencies" ], "properties": { - "id": { + "name": { + "$ref": "#/$defs/ReleaseReceiptPackageName" + }, + "version": { "type": "string", "minLength": 1 }, - "classification": { - "$ref": "#/$defs/GraphReferenceEvidenceClassification" + "license": { + "type": "string", + "minLength": 1 }, - "canonicalCommand": { + "main": { + "$ref": "#/$defs/RepoRelativePath" + }, + "types": { + "$ref": "#/$defs/RepoRelativePath" + }, + "files": { "type": "array", "minItems": 1, "items": { @@ -13644,794 +13640,140 @@ "minLength": 1 } }, - "flags": { - "type": "array", - "items": { + "bins": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/RepoRelativePath" + } + }, + "dependencies": { + "type": "object", + "additionalProperties": { "type": "string", "minLength": 1 } }, - "positionals": { + "bundledDependencies": { "type": "array", "items": { "type": "string", "minLength": 1 } }, - "fixtures": { - "$ref": "#/$defs/GraphReferenceEvidenceCoverageFixtures" - }, - "exitSemantics": { - "$ref": "#/$defs/GraphReferenceEvidenceExitSemantics" + "optionalDependencies": { + "type": "object", + "additionalProperties": { + "type": "string", + "minLength": 1 + } }, - "referenceTool": { - "type": "string", - "minLength": 1 + "os": { + "type": "array", + "minItems": 1, + "items": { + "type": "string", + "minLength": 1 + } }, - "referenceCommand": { + "cpu": { "type": "array", + "minItems": 1, "items": { "type": "string", "minLength": 1 } } - } - }, - "GraphReferenceEvidenceDaemonFixture": { - "type": "object", - "additionalProperties": false, + }, "allOf": [ { - "$ref": "#/$defs/GraphReferenceEvidenceRequiredFixtureCoverage" + "if": { + "properties": { + "name": { + "const": "opcore" + } + }, + "required": [ + "name" + ] + }, + "then": { + "required": [ + "main", + "types" + ], + "properties": { + "bins": { + "required": [ + "opcore", + "opcore-asp-provider" + ] + }, + "bundledDependencies": { + "contains": { + "const": "@the-open-engine/opcore-asp-provider" + }, + "minContains": 1 + } + } + } } - ], + ] + }, + "ReleaseReceiptNativeArtifactEvidence": { + "type": "object", + "additionalProperties": false, "required": [ - "id", - "classification", - "fixture", - "protocol", - "envelopes", - "fixtures" + "packageName", + "bundledPackageName", + "targetPlatform", + "metadata", + "binaryPath", + "checksumPath", + "metadataPath", + "binarySha256", + "checksumFileSha256", + "metadataSha256", + "descriptorArtifactId", + "descriptorChecksumId" ], "properties": { - "id": { - "type": "string", - "minLength": 1 + "packageName": { + "const": "opcore" }, - "classification": { - "$ref": "#/$defs/GraphReferenceEvidenceClassification" + "targetPlatform": { + "$ref": "#/$defs/GraphCoreNativeSupportedTarget" }, - "fixture": { - "type": "string", - "minLength": 1 + "metadata": { + "$ref": "#/$defs/GraphProviderArtifactMetadata" }, - "protocol": { - "type": "string", - "minLength": 1 + "binaryPath": { + "$ref": "#/$defs/RepoRelativePath" }, - "envelopes": { - "$ref": "#/$defs/GraphReferenceEvidenceStringArray" + "checksumPath": { + "$ref": "#/$defs/RepoRelativePath" }, - "fixtures": { - "$ref": "#/$defs/GraphReferenceEvidenceCoverageFixtures" - } - } - }, - "GraphReferenceEvidenceExitSemantics": { - "type": "object", - "additionalProperties": false, - "required": [ - "success", - "failure" - ], - "properties": { - "success": { - "const": 0 + "metadataPath": { + "$ref": "#/$defs/RepoRelativePath" }, - "failure": { + "binarySha256": { + "$ref": "#/$defs/Sha256" + }, + "checksumFileSha256": { + "$ref": "#/$defs/Sha256" + }, + "metadataSha256": { + "$ref": "#/$defs/Sha256" + }, + "descriptorArtifactId": { + "type": "string", + "minLength": 1 + }, + "descriptorChecksumId": { "type": "string", "minLength": 1 - } - } - }, - "GraphReferenceEvidenceJsonOutputSurface": { - "type": "object", - "additionalProperties": false, - "allOf": [ - { - "$ref": "#/$defs/GraphReferenceEvidenceRequiredFixtureCoverage" - } - ], - "required": [ - "id", - "command", - "classification", - "requiredFields", - "fixtures", - "exitSemantics" - ], - "properties": { - "id": { - "type": "string", - "minLength": 1 - }, - "command": { - "type": "string", - "minLength": 1 - }, - "classification": { - "$ref": "#/$defs/GraphReferenceEvidenceClassification" - }, - "requiredFields": { - "type": "array", - "minItems": 1, - "items": { - "type": "string", - "minLength": 1 - } - }, - "fixtures": { - "$ref": "#/$defs/GraphReferenceEvidenceCoverageFixtures" - }, - "exitSemantics": { - "$ref": "#/$defs/GraphReferenceEvidenceExitSemantics" - } - } - }, - "GraphReferenceEvidenceManifest": { - "type": "object", - "additionalProperties": false, - "required": [ - "schemaVersion", - "issue", - "origin", - "fixtureRefs", - "commandSurfaces", - "jsonOutputSurfaces", - "sqliteFixtures", - "daemonFixtures", - "baselineReceipts", - "optionalAnalysisSurfaces", - "goldenCorpus", - "provenance" - ], - "properties": { - "schemaVersion": { - "const": 1 - }, - "issue": { - "const": "#19" - }, - "origin": { - "const": "covibes-authored-synthetic" - }, - "fixtureRefs": { - "$ref": "#/$defs/GraphReferenceEvidenceStringArray" - }, - "commandSurfaces": { - "type": "array", - "minItems": 1, - "items": { - "$ref": "#/$defs/GraphReferenceEvidenceCommandSurface" - } - }, - "jsonOutputSurfaces": { - "type": "array", - "minItems": 1, - "items": { - "$ref": "#/$defs/GraphReferenceEvidenceJsonOutputSurface" - } - }, - "sqliteFixtures": { - "type": "array", - "minItems": 1, - "items": { - "$ref": "#/$defs/GraphReferenceEvidenceSqliteFixture" - } - }, - "daemonFixtures": { - "type": "array", - "minItems": 1, - "items": { - "$ref": "#/$defs/GraphReferenceEvidenceDaemonFixture" - } - }, - "baselineReceipts": { - "type": "array", - "minItems": 1, - "items": { - "$ref": "#/$defs/GraphReferenceEvidenceBaselineReceipt" - } - }, - "optionalAnalysisSurfaces": { - "type": "array", - "minItems": 4, - "maxItems": 4, - "allOf": [ - { - "contains": { - "type": "object", - "required": [ - "issue", - "id", - "classification", - "status" - ], - "properties": { - "issue": { - "const": "#13" - }, - "id": { - "const": "coverage" - }, - "classification": { - "const": "deferred" - }, - "status": { - "const": "deferred" - } - } - }, - "minContains": 1, - "maxContains": 1 - }, - { - "contains": { - "type": "object", - "required": [ - "issue", - "id", - "classification", - "status" - ], - "properties": { - "issue": { - "const": "#14" - }, - "id": { - "const": "flows" - }, - "classification": { - "const": "optional" - }, - "status": { - "const": "deferred" - } - } - }, - "minContains": 1, - "maxContains": 1 - }, - { - "contains": { - "type": "object", - "required": [ - "issue", - "id", - "classification", - "status" - ], - "properties": { - "issue": { - "const": "#15" - }, - "id": { - "const": "communities" - }, - "classification": { - "const": "optional" - }, - "status": { - "const": "deferred" - } - } - }, - "minContains": 1, - "maxContains": 1 - }, - { - "contains": { - "type": "object", - "required": [ - "issue", - "id", - "classification", - "status" - ], - "properties": { - "issue": { - "const": "#16" - }, - "id": { - "const": "read_only_suggestions" - }, - "classification": { - "const": "supporting" - }, - "status": { - "const": "deferred" - } - } - }, - "minContains": 1, - "maxContains": 1 - } - ], - "items": { - "type": "object", - "additionalProperties": false, - "allOf": [ - { - "$ref": "#/$defs/GraphReferenceEvidenceRequiredFixtureCoverage" - } - ], - "required": [ - "issue", - "id", - "classification", - "status", - "fixtures" - ], - "properties": { - "issue": { - "enum": [ - "#13", - "#14", - "#15", - "#16" - ] - }, - "id": { - "type": "string", - "minLength": 1 - }, - "classification": { - "enum": [ - "supporting", - "optional", - "deferred" - ] - }, - "status": { - "const": "deferred" - }, - "fixtures": { - "$ref": "#/$defs/GraphReferenceEvidenceCoverageFixtures" - } - } - } - }, - "goldenCorpus": { - "type": "object", - "additionalProperties": false, - "allOf": [ - { - "$ref": "#/$defs/GraphReferenceEvidenceRequiredFixtureCoverage" - } - ], - "required": [ - "id", - "classification", - "fixture", - "covers", - "fixtures" - ], - "properties": { - "id": { - "type": "string", - "minLength": 1 - }, - "classification": { - "$ref": "#/$defs/GraphReferenceEvidenceClassification" - }, - "fixture": { - "type": "string", - "minLength": 1 - }, - "covers": { - "$ref": "#/$defs/GraphReferenceEvidenceStringArray" - }, - "fixtures": { - "$ref": "#/$defs/GraphReferenceEvidenceCoverageFixtures" - } - } - }, - "provenance": { - "type": "object", - "additionalProperties": false, - "required": [ - "containsPythonCrgSource", - "containsPackageMetadata", - "containsGitHistory", - "referenceReceiptsAreImplementationInput", - "implementationPackageNames", - "allowedMentionPaths" - ], - "properties": { - "containsPythonCrgSource": { - "const": false - }, - "containsPackageMetadata": { - "const": false - }, - "containsGitHistory": { - "const": false - }, - "implementationPackageNames": { - "$ref": "#/$defs/GraphReferenceEvidenceStringArray" - }, - "allowedMentionPaths": { - "$ref": "#/$defs/GraphReferenceEvidenceStringArray" - }, - "referenceReceiptsAreImplementationInput": { - "const": false - } - } - } - } - }, - "GraphReferenceEvidenceRequiredFixtureCoverage": { - "if": { - "required": [ - "classification" - ], - "properties": { - "classification": { - "const": "required" - } - } - }, - "then": { - "properties": { - "fixtures": { - "allOf": [ - { - "$ref": "#/$defs/GraphReferenceEvidenceCoverageFixtures" - }, - { - "minItems": 1 - } - ] - } - } - } - }, - "GraphReferenceEvidenceSqliteFixture": { - "type": "object", - "additionalProperties": false, - "allOf": [ - { - "$ref": "#/$defs/GraphReferenceEvidenceRequiredFixtureCoverage" - } - ], - "required": [ - "id", - "classification", - "fixture", - "tables", - "indexes", - "metadataKeys", - "nodeKinds", - "edgeKinds", - "directReaderQueries", - "fixtures" - ], - "properties": { - "id": { - "type": "string", - "minLength": 1 - }, - "classification": { - "$ref": "#/$defs/GraphReferenceEvidenceClassification" - }, - "fixture": { - "type": "string", - "minLength": 1 - }, - "tables": { - "$ref": "#/$defs/GraphReferenceEvidenceStringArray" - }, - "indexes": { - "$ref": "#/$defs/GraphReferenceEvidenceStringArray" - }, - "metadataKeys": { - "$ref": "#/$defs/GraphReferenceEvidenceStringArray" - }, - "nodeKinds": { - "type": "array", - "minItems": 1, - "items": { - "$ref": "#/$defs/GraphNodeKind" - } - }, - "edgeKinds": { - "type": "array", - "minItems": 1, - "items": { - "$ref": "#/$defs/GraphEdgeKind" - } - }, - "directReaderQueries": { - "$ref": "#/$defs/GraphReferenceEvidenceStringArray" - }, - "fixtures": { - "$ref": "#/$defs/GraphReferenceEvidenceCoverageFixtures" - } - } - }, - "GraphReferenceEvidenceClassification": { - "enum": [ - "required", - "supporting", - "optional", - "deferred" - ] - }, - "GraphReferenceEvidenceCoverageFixtures": { - "type": "array", - "items": { - "type": "string", - "minLength": 1 - } - }, - "GraphReferenceEvidenceStringArray": { - "type": "array", - "minItems": 1, - "items": { - "type": "string", - "minLength": 1 - } - }, - "Sha256": { - "type": "string", - "pattern": "^[a-f0-9]{64}$" - }, - "ReleaseReceiptPackageName": { - "enum": [ - "opcore" - ] - }, - "ReleaseReceiptCommandGroupName": { - "enum": [ - "graph", - "inspect", - "edit", - "check", - "validate", - "status", - "doctor" - ] - }, - "ReleaseReceiptReportId": { - "enum": [ - "package-inspection", - "license", - "provenance", - "release-hygiene", - "graph-release", - "secret-history" - ] - }, - "ReleaseReceiptTarballEvidence": { - "type": "object", - "additionalProperties": false, - "required": [ - "filename", - "path", - "sha256" - ], - "properties": { - "filename": { - "type": "string", - "minLength": 1 - }, - "path": { - "$ref": "#/$defs/RepoRelativePath" - }, - "sha256": { - "$ref": "#/$defs/Sha256" - }, - "integrity": { - "type": "string", - "minLength": 1 - }, - "shasum": { - "type": "string", - "minLength": 1 - } - } - }, - "ReleaseReceiptPackageManifestMetadata": { - "type": "object", - "additionalProperties": false, - "required": [ - "name", - "version", - "license", - "files", - "bins", - "dependencies", - "bundledDependencies" - ], - "properties": { - "name": { - "$ref": "#/$defs/ReleaseReceiptPackageName" - }, - "version": { - "type": "string", - "minLength": 1 - }, - "license": { - "type": "string", - "minLength": 1 - }, - "main": { - "$ref": "#/$defs/RepoRelativePath" - }, - "types": { - "$ref": "#/$defs/RepoRelativePath" - }, - "files": { - "type": "array", - "minItems": 1, - "items": { - "type": "string", - "minLength": 1 - } - }, - "bins": { - "type": "object", - "additionalProperties": { - "$ref": "#/$defs/RepoRelativePath" - }, - "not": { - "anyOf": [ - { - "required": [ - "crg" - ] - }, - { - "required": [ - "cix" - ] - }, - { - "required": [ - "rox" - ] - } - ] - } - }, - "dependencies": { - "type": "object", - "additionalProperties": { - "type": "string", - "minLength": 1 - } - }, - "bundledDependencies": { - "type": "array", - "items": { - "type": "string", - "minLength": 1 - } - }, - "optionalDependencies": { - "type": "object", - "additionalProperties": { - "type": "string", - "minLength": 1 - } - }, - "os": { - "type": "array", - "minItems": 1, - "items": { - "type": "string", - "minLength": 1 - } - }, - "cpu": { - "type": "array", - "minItems": 1, - "items": { - "type": "string", - "minLength": 1 - } - } - }, - "allOf": [ - { - "if": { - "properties": { - "name": { - "const": "opcore" - } - }, - "required": [ - "name" - ] - }, - "then": { - "required": [ - "main", - "types" - ], - "properties": { - "bins": { - "required": [ - "opcore", - "opcore-asp-provider" - ] - }, - "bundledDependencies": { - "contains": { - "const": "@the-open-engine/opcore-asp-provider" - }, - "minContains": 1 - } - } - } - } - ] - }, - "ReleaseReceiptNativeArtifactEvidence": { - "type": "object", - "additionalProperties": false, - "required": [ - "packageName", - "bundledPackageName", - "targetPlatform", - "metadata", - "binaryPath", - "checksumPath", - "metadataPath", - "binarySha256", - "checksumFileSha256", - "metadataSha256", - "descriptorArtifactId", - "descriptorChecksumId" - ], - "properties": { - "packageName": { - "const": "opcore" - }, - "targetPlatform": { - "$ref": "#/$defs/GraphCoreNativeSupportedTarget" - }, - "metadata": { - "$ref": "#/$defs/GraphProviderArtifactMetadata" - }, - "binaryPath": { - "$ref": "#/$defs/RepoRelativePath" - }, - "checksumPath": { - "$ref": "#/$defs/RepoRelativePath" - }, - "metadataPath": { - "$ref": "#/$defs/RepoRelativePath" - }, - "binarySha256": { - "$ref": "#/$defs/Sha256" - }, - "checksumFileSha256": { - "$ref": "#/$defs/Sha256" - }, - "metadataSha256": { - "$ref": "#/$defs/Sha256" - }, - "descriptorArtifactId": { - "type": "string", - "minLength": 1 - }, - "descriptorChecksumId": { - "type": "string", - "minLength": 1 - }, - "bundledPackageName": { - "$ref": "#/$defs/GraphCoreNativePackageName" + }, + "bundledPackageName": { + "$ref": "#/$defs/GraphCoreNativePackageName" } }, "allOf": [ @@ -14579,29 +13921,10 @@ "type": "integer", "minimum": 1 }, - "bins": { - "type": "object", - "additionalProperties": { - "$ref": "#/$defs/RepoRelativePath" - }, - "not": { - "anyOf": [ - { - "required": [ - "crg" - ] - }, - { - "required": [ - "cix" - ] - }, - { - "required": [ - "rox" - ] - } - ] + "bins": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/RepoRelativePath" } }, "descriptorReferences": { @@ -15526,25 +14849,6 @@ "type": "object", "additionalProperties": { "$ref": "#/$defs/RepoRelativePath" - }, - "not": { - "anyOf": [ - { - "required": [ - "crg" - ] - }, - { - "required": [ - "cix" - ] - }, - { - "required": [ - "rox" - ] - } - ] } } } @@ -15719,57 +15023,19 @@ "type": "object", "additionalProperties": false, "required": [ - "currentToolEnvCleared", - "clearedEnvVarCount", "pathSanitized", - "aceRuntimeBinExcluded", - "siblingCovibesExcluded", - "opcoreBinOnly", - "oldBinsAbsent" + "siblingRepositoriesExcluded", + "opcoreBinsVerified" ], "properties": { - "currentToolEnvCleared": { - "const": true - }, - "clearedEnvVarCount": { - "type": "integer", - "minimum": 5 - }, "pathSanitized": { "const": true }, - "aceRuntimeBinExcluded": { - "const": true - }, - "siblingCovibesExcluded": { + "siblingRepositoriesExcluded": { "const": true }, - "opcoreBinOnly": { + "opcoreBinsVerified": { "const": true - }, - "oldBinsAbsent": { - "type": "object", - "additionalProperties": false, - "required": [ - "lattice", - "crg", - "cix", - "rox" - ], - "properties": { - "lattice": { - "const": true - }, - "crg": { - "const": true - }, - "cix": { - "const": true - }, - "rox": { - "const": true - } - } } } }, @@ -18351,240 +17617,6 @@ } ] }, - "RustOldRoxComparisonSurfaceReceipt": { - "type": "object", - "additionalProperties": false, - "required": [ - "id", - "graphEvidenceExists", - "graphEvidence", - "stillUniquelyProvidedByCurrentTools", - "replacementStatus" - ], - "properties": { - "id": { - "enum": [ - "rust.rustdoc", - "rust.import-graph", - "rust.dead-code", - "rust.unused-deps", - "rust.function-metrics", - "current-tools:validate-rust-graph" - ] - }, - "graphEvidenceExists": { - "type": "boolean" - }, - "graphEvidence": { - "type": "array", - "minItems": 1, - "items": { - "type": "string", - "minLength": 1 - } - }, - "stillUniquelyProvidedByCurrentTools": { - "type": "array", - "minItems": 1, - "items": { - "type": "string", - "minLength": 1 - } - }, - "replacementStatus": { - "enum": [ - "retained", - "deferred" - ] - } - } - }, - "RustOldRoxComparisonGuardrailReceipt": { - "type": "object", - "additionalProperties": false, - "required": [ - "id", - "command", - "replacementStatus", - "oldToolReplacementClaimed" - ], - "properties": { - "id": { - "const": "current-tools:validate-rust-graph" - }, - "command": { - "type": "array", - "minItems": 3, - "maxItems": 3, - "prefixItems": [ - { - "const": "npm" - }, - { - "const": "run" - }, - { - "const": "current-tools:validate-rust-graph" - } - ], - "items": false - }, - "replacementStatus": { - "const": "retained" - }, - "oldToolReplacementClaimed": { - "const": false - } - } - }, - "RustOldRoxComparisonReceipt": { - "type": "object", - "additionalProperties": false, - "required": [ - "schemaVersion", - "issue", - "origin", - "generatedAt", - "privateRepo", - "oldToolReplacementClaimed", - "publicReleaseActions", - "surfaces", - "guardrails" - ], - "properties": { - "schemaVersion": { - "const": 1 - }, - "issue": { - "const": "#29" - }, - "origin": { - "const": "covibes-authored-old-rox-comparison" - }, - "generatedAt": { - "type": "string", - "minLength": 1 - }, - "privateRepo": { - "const": true - }, - "oldToolReplacementClaimed": { - "const": false - }, - "publicReleaseActions": { - "type": "array", - "maxItems": 0 - }, - "surfaces": { - "type": "array", - "minItems": 6, - "maxItems": 6, - "allOf": [ - { - "contains": { - "type": "object", - "required": [ - "id" - ], - "properties": { - "id": { - "const": "rust.rustdoc" - } - } - }, - "minContains": 1, - "maxContains": 1 - }, - { - "contains": { - "type": "object", - "required": [ - "id" - ], - "properties": { - "id": { - "const": "rust.import-graph" - } - } - }, - "minContains": 1, - "maxContains": 1 - }, - { - "contains": { - "type": "object", - "required": [ - "id" - ], - "properties": { - "id": { - "const": "rust.dead-code" - } - } - }, - "minContains": 1, - "maxContains": 1 - }, - { - "contains": { - "type": "object", - "required": [ - "id" - ], - "properties": { - "id": { - "const": "rust.unused-deps" - } - } - }, - "minContains": 1, - "maxContains": 1 - }, - { - "contains": { - "type": "object", - "required": [ - "id" - ], - "properties": { - "id": { - "const": "rust.function-metrics" - } - } - }, - "minContains": 1, - "maxContains": 1 - }, - { - "contains": { - "type": "object", - "required": [ - "id" - ], - "properties": { - "id": { - "const": "current-tools:validate-rust-graph" - } - } - }, - "minContains": 1, - "maxContains": 1 - } - ], - "items": { - "$ref": "#/$defs/RustOldRoxComparisonSurfaceReceipt" - } - }, - "guardrails": { - "type": "array", - "minItems": 1, - "maxItems": 1, - "items": { - "$ref": "#/$defs/RustOldRoxComparisonGuardrailReceipt" - } - } - } - }, "AspDogfoodCommandRunReceipt": { "type": "object", "allOf": [ @@ -18698,31 +17730,9 @@ "type": "string", "minLength": 1 } - } - }, - "AspDogfoodPassedCommandRunReceipt": { - "allOf": [ - { - "$ref": "#/$defs/AspDogfoodCommandRunReceipt" - }, - { - "type": "object", - "required": [ - "status", - "exitCode" - ], - "properties": { - "status": { - "const": "passed" - }, - "exitCode": { - "const": 0 - } - } - } - ] + } }, - "AspDogfoodGuardrailReceipt": { + "AspDogfoodPassedCommandRunReceipt": { "allOf": [ { "$ref": "#/$defs/AspDogfoodCommandRunReceipt" @@ -18730,81 +17740,16 @@ { "type": "object", "required": [ - "id", - "retained" + "status", + "exitCode" ], "properties": { - "id": { - "enum": [ - "current-tools-validate-changed", - "current-tools-validate-rust-graph", - "current-tools-validate-all" - ] + "status": { + "const": "passed" }, - "retained": { - "const": true - } - } - }, - { - "if": { - "required": [ - "id" - ], - "properties": { - "id": { - "enum": [ - "current-tools-validate-changed", - "current-tools-validate-rust-graph" - ] - } - } - }, - "then": { - "properties": { - "status": { - "const": "passed" - }, - "exitCode": { - "const": 0 - } - } - } - }, - { - "if": { - "required": [ - "id" - ], - "properties": { - "id": { - "const": "current-tools-validate-all" - } + "exitCode": { + "const": 0 } - }, - "then": { - "anyOf": [ - { - "properties": { - "status": { - "const": "passed" - }, - "exitCode": { - "const": 0 - } - } - }, - { - "properties": { - "status": { - "const": "retained-not-run" - }, - "exitCode": { - "const": null - } - } - } - ] } } ] @@ -18828,7 +17773,6 @@ "status": { "enum": [ "degraded", - "retained-old-tool-gate", "parity-blocker" ] }, @@ -18880,13 +17824,12 @@ "repoEnrollment", "hostEvaluation", "providerProbe", - "currentToolGuardrails", "unsupportedSurfaces", "parityBlockers", "authority", "publicReleaseActions", - "oldToolReplacementClaimed", - "forbiddenMarkerScan" + "forbiddenMarkerScan", + "selfValidation" ], "properties": { "schemaVersion": { @@ -18994,8 +17937,7 @@ "temp", "isolated", "sharedStateMutated", - "pathSanitized", - "aceRuntimeBinExcluded" + "pathSanitized" ], "properties": { "path": { @@ -19013,9 +17955,6 @@ }, "pathSanitized": { "const": true - }, - "aceRuntimeBinExcluded": { - "const": true } } }, @@ -19326,58 +18265,6 @@ } ] }, - "currentToolGuardrails": { - "type": "array", - "minItems": 3, - "maxItems": 3, - "items": { - "$ref": "#/$defs/AspDogfoodGuardrailReceipt" - }, - "allOf": [ - { - "contains": { - "type": "object", - "properties": { - "id": { - "const": "current-tools-validate-changed" - }, - "status": { - "const": "passed" - } - } - }, - "minContains": 1, - "maxContains": 1 - }, - { - "contains": { - "type": "object", - "properties": { - "id": { - "const": "current-tools-validate-rust-graph" - }, - "status": { - "const": "passed" - } - } - }, - "minContains": 1, - "maxContains": 1 - }, - { - "contains": { - "type": "object", - "properties": { - "id": { - "const": "current-tools-validate-all" - } - } - }, - "minContains": 1, - "maxContains": 1 - } - ] - }, "unsupportedSurfaces": { "type": "array", "minItems": 2, @@ -19414,7 +18301,7 @@ }, "parityBlockers": { "type": "array", - "minItems": 1, + "minItems": 0, "items": { "$ref": "#/$defs/AspDogfoodParityBlocker" } @@ -19456,9 +18343,6 @@ "type": "array", "maxItems": 0 }, - "oldToolReplacementClaimed": { - "const": false - }, "forbiddenMarkerScan": { "type": "object", "additionalProperties": false, @@ -19477,14 +18361,12 @@ }, "markersBlocked": { "type": "array", - "minItems": 4, - "maxItems": 4, + "minItems": 2, + "maxItems": 2, "items": { "enum": [ "opcore asp serve", - "opcore asp", - "dist/bin/lattice", - ".ace/runtime" + "opcore asp" ] }, "allOf": [ @@ -19501,24 +18383,13 @@ }, "minContains": 1, "maxContains": 1 - }, - { - "contains": { - "const": "dist/bin/lattice" - }, - "minContains": 1, - "maxContains": 1 - }, - { - "contains": { - "const": ".ace/runtime" - }, - "minContains": 1, - "maxContains": 1 } ] } } + }, + "selfValidation": { + "$ref": "#/$defs/OpcoreSelfValidationReceipt" } } }, @@ -19540,10 +18411,9 @@ "rustCommandReceipts", "pythonCommandReceipts", "negativeChecks", - "currentToolGuardrails", - "oldToolReplacementClaimed", "forbiddenMarkerScan", - "inputEvidence" + "inputEvidence", + "selfValidation" ], "properties": { "schemaVersion": { @@ -20453,42 +19323,8 @@ } ] }, - "currentToolGuardrails": { - "type": "array", - "minItems": 2, - "maxItems": 2, - "items": { - "$ref": "#/$defs/ReleaseCutoverCurrentToolGuardrailReceipt" - }, - "allOf": [ - { - "contains": { - "properties": { - "id": { - "const": "current-tools-validate-changed" - } - }, - "required": [ - "id" - ] - } - }, - { - "contains": { - "properties": { - "id": { - "const": "current-tools-validate-rust-graph" - } - }, - "required": [ - "id" - ] - } - } - ] - }, - "oldToolReplacementClaimed": { - "const": false + "selfValidation": { + "$ref": "#/$defs/OpcoreSelfValidationReceipt" } } }, @@ -22336,7 +21172,7 @@ } ] }, - "ReleaseCutoverCurrentToolGuardrailReceipt": { + "OpcoreSelfValidationReceipt": { "type": "object", "additionalProperties": false, "required": [ @@ -22346,16 +21182,11 @@ "exitCode", "stdoutSha256", "stderrSha256", - "retained", - "assertion", - "oldToolReplacementClaimed" + "assertion" ], "properties": { "id": { - "enum": [ - "current-tools-validate-changed", - "current-tools-validate-rust-graph" - ] + "const": "opcore-self-check" }, "command": { "type": "array", @@ -22369,10 +21200,7 @@ "const": "run" }, { - "enum": [ - "current-tools:validate-changed", - "current-tools:validate-rust-graph" - ] + "const": "opcore:self-check" } ], "items": false @@ -22389,103 +21217,18 @@ "stderrSha256": { "$ref": "#/$defs/Sha256" }, - "retained": { - "const": true - }, "assertion": { "type": "string", "minLength": 1 - }, - "oldToolReplacementClaimed": { - "const": false - } - }, - "allOf": [ - { - "if": { - "required": [ - "id" - ], - "properties": { - "id": { - "const": "current-tools-validate-changed" - } - } - }, - "then": { - "properties": { - "command": { - "type": "array", - "minItems": 3, - "maxItems": 3, - "prefixItems": [ - { - "const": "npm" - }, - { - "const": "run" - }, - { - "const": "current-tools:validate-changed" - } - ], - "items": false - } - } - } - }, - { - "if": { - "required": [ - "id" - ], - "properties": { - "id": { - "const": "current-tools-validate-rust-graph" - } - } - }, - "then": { - "properties": { - "command": { - "type": "array", - "minItems": 3, - "maxItems": 3, - "prefixItems": [ - { - "const": "npm" - }, - { - "const": "run" - }, - { - "const": "current-tools:validate-rust-graph" - } - ], - "items": false - } - } - } - }, - { - "if": { - "required": [ - "status" - ], - "properties": { - "status": { - "const": "passed" - } - } - }, - "then": { - "properties": { - "exitCode": { - "const": 0 - } - } - } } + } + }, + "GraphReleaseSurfaceClassification": { + "enum": [ + "required", + "supporting", + "optional", + "deferred" ] } } diff --git a/packages/contracts/src/clone/validators.ts b/packages/contracts/src/clone/validators.ts new file mode 100644 index 0000000..3e07e0d --- /dev/null +++ b/packages/contracts/src/clone/validators.ts @@ -0,0 +1,191 @@ +import { validateExactValue, validateOptional, validateRequiredObject } from "../shared/validators-02.js"; +import { includesString } from "../shared/primitives.js"; +import { validateRepoRelativePaths } from "../shared/path-validators.js"; +import { CLONE_PROTOCOL } from "../graph/vocabulary-01.js"; +import { validateRepoIdentity, validateRepoRelativePath } from "../shared/path-validators.js"; +import { + validateNonEmptyString, + validateNonNegativeInteger, + validatePositiveInteger, + validateStringArray, +} from "../shared/validators-01.js"; +import type { + CloneAnalysisRequest, + CloneAnalysisResult, + CloneAnalysisSummary, + CloneFinding, + CloneReportMode, + CloneSourceReadMode} from "../validation/request-contracts.js"; +import { + cloneReportModes, + cloneSourceReadModes, +} from "../validation/request-contracts.js"; +import { validateHypotheticalOverlays } from "../validation/request-validators-01.js"; + +function validateCloneAnalysisRequest(request: CloneAnalysisRequest): CloneAnalysisRequest { + validateRequiredObject(request, "Clone analysis request is required"); + validateExactValue( + request.protocol, + CLONE_PROTOCOL, + `Clone analysis request protocol must be ${CLONE_PROTOCOL}`, + ); + validateOptional(request.requestId, (value) => + validateNonEmptyString(value, "Clone analysis request requestId"), + ); + validateExactValue(request.schemaVersion, 1, "Clone analysis request schemaVersion must be 1"); + validateRepoIdentity(request.repo); + validateCloneReportMode(request.reportMode, "Clone analysis request reportMode"); + validateOptional(request.paths, (value) => validateRepoRelativePaths(value, "Clone analysis request paths")); + validateOptional(request.sourcePaths, (value) => + validateRepoRelativePaths(value, "Clone analysis request sourcePaths"), + ); + validateOptional(request.sourceReadMode, (value) => + validateCloneSourceReadMode(value, "Clone analysis request sourceReadMode"), + ); + validateOptional(request.sourceTreeRef, (value) => + validateNonEmptyString(value, "Clone analysis request sourceTreeRef"), + ); + validateHypotheticalOverlays(request.overlays); + validateOptional(request.windowSize, (value) => + validatePositiveInteger(value, "Clone analysis request windowSize"), + ); + validateOptional(request.minLines, (value) => + validatePositiveInteger(value, "Clone analysis request minLines"), + ); + validateOptional(request.minTokens, (value) => + validatePositiveInteger(value, "Clone analysis request minTokens"), + ); + validateOptional(request.threshold, (value) => + validatePositiveInteger(value, "Clone analysis request threshold"), + ); + validateOptional(request.partitions, validateCloneAnalysisPartitions); + validateOptional(request.exclude, (value) => + validateStringArray(value, "Clone analysis request exclude", { + allowEmpty: true, + }), + ); + validateOptional(request.modes, (value) => + validateStringArray(value, "Clone analysis request modes", { + allowEmpty: true, + }), + ); + return request; +} + +export { validateCloneAnalysisRequest }; + +function validateCloneSourceReadMode(value: unknown, label: string): asserts value is CloneSourceReadMode { + if (!cloneSourceReadModes.includes(value as CloneSourceReadMode)) { + throw new Error(`${label} must be one of ${cloneSourceReadModes.join(", ")}`); + } +} + +export { validateCloneSourceReadMode }; + +function validateCloneAnalysisPartitions(partitions: readonly (readonly string[])[]): void { + if (!Array.isArray(partitions)) throw new Error("Clone analysis request partitions must be an array"); + for (const [index, partition] of partitions.entries()) { + validateStringArray(partition, `Clone analysis request partitions[${index}]`, { allowEmpty: false }); + } +} + +export { validateCloneAnalysisPartitions }; + +function validateCloneAnalysisResult(result: CloneAnalysisResult): CloneAnalysisResult { + validateRequiredObject(result, "Clone analysis result is required"); + if (result.protocol !== CLONE_PROTOCOL) { + throw new Error(`Clone analysis result protocol must be ${CLONE_PROTOCOL}`); + } + if (result.requestId !== undefined) validateNonEmptyString(result.requestId, "Clone analysis result requestId"); + if (result.schemaVersion !== 1) { + throw new Error("Clone analysis result schemaVersion must be 1"); + } + validateRepoIdentity(result.repo); + validateCloneReportMode(result.reportMode, "Clone analysis result reportMode"); + if (result.status !== "passed") { + throw new Error("Clone analysis result status must be passed"); + } + if (typeof result.persisted !== "boolean") { + throw new Error("Clone analysis result persisted must be boolean"); + } + if (result.dbPath !== undefined) validateRepoRelativePath(result.dbPath); + if (!Array.isArray(result.findings)) { + throw new Error("Clone analysis result findings must be an array"); + } + for (const finding of result.findings) validateCloneFinding(finding); + validateCloneAnalysisSummary(result.summary, result.findings.length); + return result; +} + +export { validateCloneAnalysisResult }; + +function validateCloneReportMode(mode: unknown, label: string): CloneReportMode { + if (!includesString(cloneReportModes, mode)) { + throw new Error(`Unknown ${label}: ${String(mode)}`); + } + return mode; +} + +export { validateCloneReportMode }; + +function validateCloneFinding(finding: CloneFinding): CloneFinding { + validateRequiredObject(finding, "Clone finding is required"); + for (const forbidden of ["line", "column", "startLine", "endLine", "startColumn", "endColumn"]) { + if (Object.hasOwn(finding, forbidden)) { + throw new Error("Clone finding identity must stay line-free"); + } + } + validateCloneClassId(finding.cloneClassId, "Clone finding cloneClassId"); + validateSha256Hex(finding.contentHash, "Clone finding contentHash"); + const path = validateRepoRelativePath(finding.path); + const peerPath = validateRepoRelativePath(finding.peerPath); + if (path === peerPath) { + throw new Error("Clone finding path and peerPath must be distinct"); + } + validateRepoRelativePaths(finding.paths, "Clone finding paths"); + if (!finding.paths.includes(path) || !finding.paths.includes(peerPath)) { + throw new Error("Clone finding paths must include path and peerPath"); + } + validatePositiveInteger(finding.lineCount, "Clone finding lineCount"); + validatePositiveInteger(finding.tokenCount, "Clone finding tokenCount"); + if (typeof finding.introduced !== "boolean") { + throw new Error("Clone finding introduced must be boolean"); + } + return finding; +} + +export { validateCloneFinding }; + +function validateCloneAnalysisSummary(summary: CloneAnalysisSummary, findingsLength: number): CloneAnalysisSummary { + validateRequiredObject(summary, "Clone analysis summary is required"); + validateNonNegativeInteger(summary.analyzedFiles, "Clone analysis summary analyzedFiles"); + validateNonNegativeInteger(summary.cloneClassCount, "Clone analysis summary cloneClassCount"); + validateNonNegativeInteger(summary.findingCount, "Clone analysis summary findingCount"); + validateNonNegativeInteger(summary.overlayCount, "Clone analysis summary overlayCount"); + if (summary.findingCount !== findingsLength) { + throw new Error("Clone analysis summary findingCount must equal findings length"); + } + return summary; +} + +export { validateCloneAnalysisSummary }; + +function validateCloneClassId(value: unknown, label: string): string { + const id = validateNonEmptyString(value, label); + if (!/^clone-[a-f0-9]{16}$/u.test(id)) { + throw new Error(`${label} must be a stable clone id`); + } + return id; +} + +export { validateCloneClassId }; + +function validateSha256Hex(value: unknown, label: string): string { + const sha = validateNonEmptyString(value, label); + if (!/^[a-f0-9]{64}$/u.test(sha)) { + throw new Error(`${label} must be a SHA-256 hex digest`); + } + return sha; +} + +export { validateSha256Hex }; diff --git a/packages/contracts/src/command/adapter-validator.ts b/packages/contracts/src/command/adapter-validator.ts new file mode 100644 index 0000000..b7ef2a9 --- /dev/null +++ b/packages/contracts/src/command/adapter-validator.ts @@ -0,0 +1,32 @@ +import { validateRequiredObject } from "../shared/validators-02.js"; +import { validateNonEmptyString, validateStringArray } from "../shared/validators-01.js"; +import type { CommandAdapterRequest } from "./router-contracts.js"; +import { validateManifestGroups } from "./validators.js"; + +function validateCommandAdapterRequest(request: CommandAdapterRequest): CommandAdapterRequest { + validateRequiredObject(request, "Command adapter request is required"); + if (request.schemaVersion !== 1) { + throw new Error("Command adapter request schemaVersion must be 1"); + } + validateNonEmptyString(request.bin, "Command adapter request bin"); + validateStringArray(request.argv, "Command adapter request argv", { + allowEmpty: true, + allowEmptyValues: true, + }); + validateStringArray(request.args, "Command adapter request args", { + allowEmpty: true, + allowEmptyValues: true, + }); + if (typeof request.json !== "boolean") { + throw new Error("Command adapter request json must be boolean"); + } + validateRequiredObject(request.group, "Command adapter request group is required"); + validateManifestGroups([request.group]); + validateStringArray(request.canonicalCommand, "Command adapter request canonicalCommand", { allowEmpty: false }); + if (!request.group.canonicalCommand.every((part, index) => request.canonicalCommand[index] === part)) { + throw new Error("Command adapter request canonicalCommand must start with the group canonicalCommand"); + } + return request; +} + +export { validateCommandAdapterRequest }; diff --git a/packages/contracts/src/command/contracts.ts b/packages/contracts/src/command/contracts.ts new file mode 100644 index 0000000..6cc51a5 --- /dev/null +++ b/packages/contracts/src/command/contracts.ts @@ -0,0 +1,35 @@ +import type { CommandOwner } from "./vocabulary.js"; + +interface CommandExitSemantics { + ok: 0; + error: 1; + notImplemented: 2; + unsupported: 64; + jsonStable: boolean; +} + +export type { CommandExitSemantics }; + +interface CommandGroupContract { + name: string; + owner: CommandOwner; + canonicalCommand: readonly string[]; + commands: readonly string[]; + summary: string; +} + +export type { CommandGroupContract }; + +interface CommandRouterManifest { + schemaVersion: 1; + packageName: "opcore" | (string & {}); + bins: readonly string[]; + exitSemantics: CommandExitSemantics; + ownershipBoundaries: readonly { + owner: CommandOwner; + summary: string; + }[]; + commandGroups: readonly CommandGroupContract[]; +} + +export type { CommandRouterManifest }; diff --git a/packages/contracts/src/command/helper-validators.ts b/packages/contracts/src/command/helper-validators.ts new file mode 100644 index 0000000..7898176 --- /dev/null +++ b/packages/contracts/src/command/helper-validators.ts @@ -0,0 +1,35 @@ +import { includesString } from "../shared/primitives.js"; +import type { + GraphReleaseSurfaceClassification} from "../release/graph-vocabulary-01.js"; +import { + graphReleaseSurfaceClassifications, +} from "../release/graph-vocabulary-01.js"; +import type { CommandOwner, CommandRouteStatus} from "./vocabulary.js"; +import { commandOwners, commandRouteStatuses } from "./vocabulary.js"; + +function validateCommandOwner(owner: unknown): CommandOwner { + if (!includesString(commandOwners, owner)) { + throw new Error(`Unknown command owner: ${String(owner)}`); + } + return owner; +} + +export { validateCommandOwner }; + +function validateCommandRouteStatus(status: unknown): CommandRouteStatus { + if (!includesString(commandRouteStatuses, status)) { + throw new Error(`Unknown command route status: ${String(status)}`); + } + return status; +} + +export { validateCommandRouteStatus }; + +function validateGraphReleaseSurfaceClassification(classification: unknown): GraphReleaseSurfaceClassification { + if (!includesString(graphReleaseSurfaceClassifications, classification)) { + throw new Error(`Unknown graph release surface classification: ${String(classification)}`); + } + return classification; +} + +export { validateGraphReleaseSurfaceClassification }; diff --git a/packages/contracts/src/command/manifest.ts b/packages/contracts/src/command/manifest.ts new file mode 100644 index 0000000..abbff93 --- /dev/null +++ b/packages/contracts/src/command/manifest.ts @@ -0,0 +1,108 @@ +import type { CommandExitSemantics, CommandRouterManifest } from "./contracts.js"; + +const commandExitSemantics: CommandExitSemantics = { + ok: 0, + error: 1, + notImplemented: 2, + unsupported: 64, + jsonStable: true, +}; + +export { commandExitSemantics }; + +const commandRouterManifest: CommandRouterManifest = { + schemaVersion: 1, + packageName: "opcore", + bins: ["opcore"], + exitSemantics: commandExitSemantics, + ownershipBoundaries: [ + { + owner: "graph", + summary: "Graph provider owns extraction, persistent facts, freshness, query, search, and impact contracts.", + }, + { + owner: "inspect", + summary: "Inspect owns read-only code intelligence over graph facts and language-service surfaces.", + }, + { + owner: "edit", + summary: "Edit planner owns symbol-aware rename, move, signature, patch, and tree edit orchestration.", + }, + { + owner: "validation", + summary: "Validation owns checks, hypothetical validation, manifests, failure policy, and check status.", + }, + { + owner: "runtime", + summary: "Runtime owns shared router health, help, and doctor surfaces.", + }, + ], + commandGroups: [ + { + name: "graph", + owner: "graph", + canonicalCommand: ["opcore", "graph"], + commands: [ + "build", + "update", + "watch", + "status", + "query", + "serve", + "impact", + "review-context", + "detect-changes", + "search", + ], + summary: + "GraphProvider build, update, watch, status, query, impact, review context, change detection, " + + "daemon lifecycle, and freshness behavior.", + }, + { + name: "inspect", + owner: "inspect", + canonicalCommand: ["opcore", "inspect"], + commands: ["symbols", "definition", "references", "signature", "implementations", "search"], + summary: "Read-only code intelligence over graph and inspect-owned language services.", + }, + { + name: "edit", + owner: "edit", + canonicalCommand: ["opcore", "edit"], + commands: ["exact", "multi", "search-replace", "check", "apply", "patch", "tree", "rename", "move", "signature"], + summary: + "Exact edit, multi-edit, search-replace, patch/tree, graph-backed symbol rename/move/signature, " + + "preview/check, and apply routes.", + }, + { + name: "check", + owner: "validation", + canonicalCommand: ["opcore", "check"], + commands: ["files", "staged", "changed", "tree", "all", "manifest"], + summary: "Mechanical check execution and check manifest behavior.", + }, + { + name: "validate", + owner: "validation", + canonicalCommand: ["opcore", "validate"], + commands: ["request", "hypothetical", "pre-write", "manifest"], + summary: "Hypothetical, pre-write, and validation request behavior.", + }, + { + name: "status", + owner: "runtime", + canonicalCommand: ["opcore", "status"], + commands: ["status"], + summary: "Shared router and runtime health status.", + }, + { + name: "doctor", + owner: "runtime", + canonicalCommand: ["opcore", "doctor"], + commands: ["doctor"], + summary: "Shared runtime diagnostic summary.", + }, + ], +}; + +export { commandRouterManifest }; diff --git a/packages/contracts/src/command/router-01.ts b/packages/contracts/src/command/router-01.ts new file mode 100644 index 0000000..1132ffc --- /dev/null +++ b/packages/contracts/src/command/router-01.ts @@ -0,0 +1,265 @@ +import { withoutUndefinedProperties } from "../shared/primitives.js"; +import { validateCommandAdapterRequest } from "./adapter-validator.js"; +import type { CommandGroupContract } from "./contracts.js"; +import { commandRouterManifest } from "./manifest.js"; +import { commandGroupByName, commandHelpMessage } from "./router-02.js"; +import type { + CommandAdapterRequest, + CommandRouterResult, + CommandRouterResultInput, + ParsedCommandArgv, + RouteCommandAdapterOptions, + RunCommandAdapterCliOptions, +} from "./router-contracts.js"; +import { validateCommandRouterResult } from "./validators.js"; +import type { CommandRouteStatus } from "./vocabulary.js"; + +declare const process: { + argv: string[]; + stdout: { write(text: string): void }; + stderr: { write(text: string): void }; +}; + +const commandHelpArgs = new Set(["--help", "-h", "help"]); + +export { commandHelpArgs }; + +function parseCommandArgv(argv: readonly string[]): ParsedCommandArgv { + return { + args: argv.filter((arg) => arg !== "--json"), + json: argv.includes("--json"), + }; +} + +export { parseCommandArgv }; + +function normalizeCommandBin(bin: string): string { + const normalized = bin.replaceAll("\\", "/").split("/").at(-1) ?? bin; + return normalized.endsWith(".js") ? "opcore" : normalized; +} + +export { normalizeCommandBin }; + +function commandExitCodeForStatus(status: CommandRouteStatus): number { + if (status === "ok") return commandRouterManifest.exitSemantics.ok; + if (status === "error") return commandRouterManifest.exitSemantics.error; + if (status === "not_implemented") return commandRouterManifest.exitSemantics.notImplemented; + return commandRouterManifest.exitSemantics.unsupported; +} + +export { commandExitCodeForStatus }; + +function createCommandRouterResult(input: CommandRouterResultInput): CommandRouterResult { + return validateCommandRouterResult( + withoutUndefinedProperties({ + schemaVersion: 1, + bin: input.bin, + argv: input.argv, + canonicalCommand: input.canonicalCommand, + owner: input.owner, + status: input.status, + exitCode: commandExitCodeForStatus(input.status), + message: input.message, + json: input.json, + providerStatus: input.providerStatus, + graphPipeline: input.graphPipeline, + graphQuery: input.graphQuery, + graphSearch: input.graphSearch, + inspectResult: input.inspectResult, + graphImpact: input.graphImpact, + graphReviewContext: input.graphReviewContext, + graphChanges: input.graphChanges, + graphServe: input.graphServe, + validationResult: input.validationResult, + validationStatus: input.validationStatus, + receipt: input.receipt, + editPlan: input.editPlan, + editResult: input.editResult, + repoState: input.repoState, + runtimeInfo: input.runtimeInfo, + opcoreDoctor: input.opcoreDoctor, + opcoreInit: input.opcoreInit, + opcoreMeasure: input.opcoreMeasure, + opcoreTry: input.opcoreTry, + timing: input.timing, + }) as CommandRouterResult, + ); +} + +export { createCommandRouterResult }; + +async function routeCommandAdapter(options: RouteCommandAdapterOptions): Promise { + const parsedArgv = parseCommandArgv(options.argv); + const parsed = resolveParsedCommandArgv(options, parsedArgv); + const bin = normalizeCommandBin(options.bin); + const group = commandGroupByName(options.groupName); + if (!group) { + return unsupportedCommandGroupResult(options, bin, parsed.json); + } + + if (shouldShowCommandHelp(options, group, parsed.args)) { + const routeName = parsed.args.find((arg) => !commandHelpArgs.has(arg) && !arg.startsWith("-")); + return commandHelpResult(bin, options.argv, parsed.json, group.name, routeName); + } + + const canonicalCommand = [...group.canonicalCommand, ...parsed.args.map(canonicalCommandArg)]; + const firstRouteArg = parsed.args.find((arg) => !arg.startsWith("-")); + if (isUnsupportedCommandRoute(options, group, firstRouteArg)) { + return unsupportedCommandRouteResult({ options, bin, json: parsed.json, group, canonicalCommand }); + } + + const adapterRequest = validateCommandAdapterRequest({ + schemaVersion: 1, + bin, + argv: options.argv, + args: parsed.args, + json: parsed.json, + group, + canonicalCommand, + }); + return invokeCommandAdapter({ options, adapterRequest, bin, json: parsed.json, group, canonicalCommand }); +} + +export { routeCommandAdapter }; + +function resolveParsedCommandArgv( + options: RouteCommandAdapterOptions, + parsed: ParsedCommandArgv, +): ParsedCommandArgv { + return { + args: options.args ?? parsed.args, + json: options.json ?? parsed.json, + }; +} + +function shouldShowCommandHelp( + options: RouteCommandAdapterOptions, + group: CommandGroupContract, + args: readonly string[], +): boolean { + const showHelpOnEmpty = options.showHelpOnEmpty ?? group.name !== "check"; + return args.some((arg) => commandHelpArgs.has(arg)) || (args.length === 0 && showHelpOnEmpty); +} + +function isUnsupportedCommandRoute( + options: RouteCommandAdapterOptions, + group: CommandGroupContract, + firstRouteArg: string | undefined, +): boolean { + const validateFirstRouteArg = options.validateFirstRouteArg !== false; + return Boolean(validateFirstRouteArg && firstRouteArg && !group.commands.includes(firstRouteArg)); +} + +function unsupportedCommandGroupResult( + options: RouteCommandAdapterOptions, + bin: string, + json: boolean, +): CommandRouterResult { + return createCommandRouterResult({ + bin, + argv: options.argv, + canonicalCommand: ["opcore", options.groupName], + owner: "runtime", + status: "unsupported", + json, + message: `Unsupported opcore command group: ${options.groupName}`, + }); +} + +export { unsupportedCommandGroupResult }; + +interface CommandRouteResultContext { + options: RouteCommandAdapterOptions; + bin: string; + json: boolean; + group: CommandGroupContract; + canonicalCommand: readonly string[]; +} + +function unsupportedCommandRouteResult(context: CommandRouteResultContext): CommandRouterResult { + const { options, bin, json, group, canonicalCommand } = context; + return createCommandRouterResult({ + bin, + argv: options.argv, + canonicalCommand, + owner: group.owner, + status: "unsupported", + json, + message: `${canonicalCommand.join(" ")} is not a supported ${group.name} route.`, + }); +} + +export { unsupportedCommandRouteResult }; + +async function invokeCommandAdapter( + context: CommandRouteResultContext & { adapterRequest: CommandAdapterRequest }, +): Promise { + const { options, adapterRequest, bin, json, group, canonicalCommand } = context; + try { + return validateCommandRouterResult(await options.adapter(adapterRequest)); + } catch (error) { + return createCommandRouterResult({ + bin, + argv: options.argv, + canonicalCommand, + owner: group.owner, + status: "error", + json, + message: `${canonicalCommand.join(" ")} failed: ${errorMessage(error)}`, + }); + } +} + +export { invokeCommandAdapter }; + +async function runCommandAdapterCli(options: RunCommandAdapterCliOptions): Promise { + const stdout = options.stdout ?? ((text: string) => process.stdout.write(text)); + const stderr = options.stderr ?? ((text: string) => process.stderr.write(text)); + const argv = options.argv ?? process.argv.slice(2); + const routed = await routeCommandAdapter({ + ...options, + argv, + }); + const text = routed.json ? JSON.stringify(routed) : routed.message; + const write = routed.json || routed.status === "ok" ? stdout : stderr; + write(`${text}\n`); + return routed.exitCode; +} + +export { runCommandAdapterCli }; + +function commandHelpResult( + ...args: readonly [bin: string, argv: readonly string[], json: boolean, groupName?: string, routeName?: string] +): CommandRouterResult { + const [bin, argv, json, groupName, routeName] = args; + const group = groupName ? commandGroupByName(groupName) : undefined; + const canonicalCommand = + group && routeName + ? [...group.canonicalCommand, routeName, "help"] + : group + ? [...group.canonicalCommand, "help"] + : ["opcore", "help"]; + return createCommandRouterResult({ + bin, + argv, + canonicalCommand, + owner: group?.owner ?? "runtime", + status: "ok", + json, + message: commandHelpMessage(groupName, routeName), + }); +} + +export { commandHelpResult }; + +function canonicalCommandArg(arg: string): string { + return arg.length === 0 ? "" : arg; +} + +export { canonicalCommandArg }; + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +export { errorMessage }; diff --git a/packages/contracts/src/command/router-02.ts b/packages/contracts/src/command/router-02.ts new file mode 100644 index 0000000..065f8ac --- /dev/null +++ b/packages/contracts/src/command/router-02.ts @@ -0,0 +1,112 @@ +import type { CommandGroupContract } from "./contracts.js"; +import { commandRouterManifest } from "./manifest.js"; + +function commandGroupByName(groupName: string): CommandGroupContract | undefined { + return commandRouterManifest.commandGroups.find((group) => group.name === groupName); +} + +export { commandGroupByName }; + +function commandHelpMessage(groupName?: string, routeName?: string): string { + if (!groupName) { + return [ + "Opcore - local code intelligence and edit safety for coding agents.", + "Groups: graph, inspect, edit, check, validate, status, doctor", + ].join("\n"); + } + const group = commandGroupByName(groupName); + if (!group) return `Unknown opcore command group: ${groupName}`; + const routeHelp = routeName ? commandRouteHelpMessage(groupName, routeName) : undefined; + if (routeHelp !== undefined) return routeHelp; + return [ + `${group.canonicalCommand.join(" ")} - ${group.summary}`, + `Commands: ${group.commands.join(", ")}`, + ...(groupName === "graph" ? [`Syntax: ${contractHelpSyntax(group)}`] : []), + `Example: ${contractHelpExample(groupName)}`, + ].join("\n"); +} + +export { commandHelpMessage }; + +function commandRouteHelpMessage(groupName: string, routeName: string): string | undefined { + if (groupName === "graph" && routeName === "update") { + return [ + "Usage: opcore graph update [--repo ] [--base ] [--paths ] [--json]", + "Flags:", + " --repo Repository root to update.", + " --base Optional base ref for changed-file metadata.", + " --paths Optional repo-relative paths to refresh.", + " --json Emit structured JSON.", + "Defaults:", + " --repo defaults to the current working directory; JSON output is summary-oriented.", + "Examples:", + " opcore graph update --repo . --base HEAD --json", + " opcore graph update --repo . --paths src tests --json", + "Exit codes: 0 updated, 1 update failed, 64 unsupported.", + ].join("\n"); + } + if (groupName === "graph" && routeName === "build") { + return [ + "Usage: opcore graph build [--repo ] [--paths ] [--json]", + "Flags:", + " --repo Repository root to build.", + " --paths Optional repo-relative paths to index.", + " --json Emit structured JSON.", + "Defaults:", + " --repo defaults to the current working directory; JSON output is summary-oriented.", + "Examples:", + " opcore graph build --repo . --json", + "Exit codes: 0 built, 1 build failed, 64 unsupported.", + ].join("\n"); + } + if (groupName === "graph" && routeName === "status") { + return [ + "Usage: opcore graph status [--repo ] [--json]", + "Flags:", + " --repo Repository root to inspect.", + " --json Emit structured JSON.", + "Defaults:", + " --repo defaults to the current working directory.", + "Examples:", + " opcore graph status --repo . --json", + "Exit codes: 0 available or stale status read, 1 status failed, 64 unsupported.", + ].join("\n"); + } + if (groupName === "validate" && routeName === "pre-write") { + return [ + "Usage: opcore validate pre-write --request-file [--timeout-ms ] [--json]", + "Flags:", + " --request-file ValidationRequest JSON payload.", + " --timeout-ms Pre-write timeout in milliseconds.", + " --json Emit structured JSON.", + "Defaults:", + " --timeout-ms defaults to 30000.", + "Examples:", + " opcore validate pre-write --request-file ./validation-request.json --timeout-ms 30000 --json", + "Exit codes: 0 passed, 1 findings or errors, 64 unsupported.", + ].join("\n"); + } + return undefined; +} + +export { commandRouteHelpMessage }; + +function contractHelpSyntax(group: CommandGroupContract): string { + return `${group.canonicalCommand.join(" ")} <${group.commands.join("|")}> --repo . [--json]`; +} + +export { contractHelpSyntax }; + +function contractHelpExample(groupName: string): string { + if (groupName === "graph") return 'opcore graph search "GreetingCard" --repo . --limit 5'; + if (groupName === "inspect") return "opcore inspect definition GreetingCard --repo ."; + if (groupName === "edit") return 'opcore edit exact --path src/a.ts --expected "old" --replacement "new" --json'; + if (groupName === "check") return "opcore check files --files src/index.ts --json"; + if (groupName === "validate") + return "opcore validate pre-write --request-file ./validation-request.json --timeout-ms 30000 --json"; + if (groupName === "status") return "opcore status"; + if (groupName === "doctor") return "opcore doctor --json"; + return `opcore ${groupName} --help`; +} + +export { contractHelpExample }; diff --git a/packages/contracts/src/command/router-contracts.ts b/packages/contracts/src/command/router-contracts.ts new file mode 100644 index 0000000..a6880e9 --- /dev/null +++ b/packages/contracts/src/command/router-contracts.ts @@ -0,0 +1,139 @@ +import type { EditCommandResult, EditPlan } from "../edit/contracts.js"; +import type { GraphPipelineResult, GraphServeTransportStatus } from "../graph/pipeline-contracts.js"; +import type { GraphProviderStatus } from "../graph/provider-contracts-02.js"; +import type { GraphFactQueryResult, GraphNamedQueryResult } from "../graph/query-contracts-01.js"; +import type { + GraphDetectChangesResult, + GraphImpactResult, + GraphReviewContextResult, +} from "../graph/query-contracts-02.js"; +import type { GraphSearchResult } from "../graph/search-contracts.js"; +import type { InspectRouteResult } from "../inspect/contracts-02.js"; +import type { OpcoreInitPlanPayload } from "../product/init-contracts.js"; +import type { CommandTiming } from "../product/latency-contracts.js"; +import type { OpcoreMeasureDelta } from "../product/metrics-contracts-01.js"; +import type { OpcoreTryPayload } from "../product/metrics-contracts-02.js"; +import type { + OpcoreDoctorPayload, + OpcoreRepoStatePayload, + OpcoreRuntimeInfoPayload, +} from "../product/status-contracts.js"; +import type { ValidationResult } from "../validation/capability-contracts.js"; +import type { PreWriteValidationReceipt, ValidationStatusPayload } from "../validation/status-contracts.js"; +import type { CommandGroupContract } from "./contracts.js"; +import type { CommandOwner, CommandRouteStatus } from "./vocabulary.js"; + +interface CommandRouterContext { + bin: string; + argv: readonly string[]; + canonicalCommand: readonly string[]; + owner: CommandOwner; + status: CommandRouteStatus; + message: string; + json: boolean; +} + +export type { CommandRouterContext }; + +interface CommandRouterGraphPayloads { + providerStatus?: GraphProviderStatus; + graphPipeline?: GraphPipelineResult; + graphQuery?: GraphFactQueryResult | GraphNamedQueryResult; + graphSearch?: GraphSearchResult; + inspectResult?: InspectRouteResult; + graphImpact?: GraphImpactResult; + graphReviewContext?: GraphReviewContextResult; + graphChanges?: GraphDetectChangesResult; + graphServe?: GraphServeTransportStatus; +} + +export type { CommandRouterGraphPayloads }; + +interface CommandRouterActionPayloads { + validationResult?: ValidationResult; + validationStatus?: ValidationStatusPayload; + receipt?: PreWriteValidationReceipt; + editPlan?: EditPlan; + editResult?: EditCommandResult; +} + +export type { CommandRouterActionPayloads }; + +interface CommandRouterProductPayloads { + repoState?: OpcoreRepoStatePayload; + runtimeInfo?: OpcoreRuntimeInfoPayload; + opcoreDoctor?: OpcoreDoctorPayload; + opcoreInit?: OpcoreInitPlanPayload; + opcoreMeasure?: OpcoreMeasureDelta; + opcoreTry?: OpcoreTryPayload; + timing?: CommandTiming; +} + +export type { CommandRouterProductPayloads }; + +interface CommandRouterResult + extends CommandRouterContext, + CommandRouterGraphPayloads, + CommandRouterActionPayloads, + CommandRouterProductPayloads { + schemaVersion: 1; + exitCode: number; +} + +export type { CommandRouterResult }; + +interface ParsedCommandArgv { + args: readonly string[]; + json: boolean; +} + +export type { ParsedCommandArgv }; + +interface CommandRouterResultInput + extends CommandRouterContext, + CommandRouterGraphPayloads, + CommandRouterActionPayloads, + CommandRouterProductPayloads {} + +export type { CommandRouterResultInput }; + +interface CommandAdapterRequest { + schemaVersion: 1; + bin: string; + argv: readonly string[]; + args: readonly string[]; + json: boolean; + group: CommandGroupContract; + canonicalCommand: readonly string[]; +} + +export type { CommandAdapterRequest }; + +type CommandAdapter = (request: CommandAdapterRequest) => CommandRouterResult | Promise; + +export type { CommandAdapter }; + +type CommandRouterWriter = (text: string) => void; + +export type { CommandRouterWriter }; + +interface RouteCommandAdapterOptions { + bin: string; + argv: readonly string[]; + groupName: string; + adapter: CommandAdapter; + args?: readonly string[]; + json?: boolean; + showHelpOnEmpty?: boolean; + validateFirstRouteArg?: boolean; +} + +export type { RouteCommandAdapterOptions }; + +interface RunCommandAdapterCliOptions extends Omit { + argv?: readonly string[]; + stdout?: CommandRouterWriter; + stderr?: CommandRouterWriter; +} + +export type { RunCommandAdapterCliOptions }; diff --git a/packages/contracts/src/command/validators.ts b/packages/contracts/src/command/validators.ts new file mode 100644 index 0000000..c632e80 --- /dev/null +++ b/packages/contracts/src/command/validators.ts @@ -0,0 +1,193 @@ +import { + validateBoolean, + validateOptional, + validateRequiredObject, +} from "../shared/validators-02.js"; +import { validateEditCommandResult, validateEditPlanPayload } from "../edit/validators.js"; +import { validateGraphPipelineResult } from "../graph/daemon-validators-01.js"; +import { validateGraphServeTransportStatus } from "../graph/daemon-validators-02.js"; +import { isNamedQueryResult } from "../graph/payload-validators.js"; +import { validateProviderStatus } from "../graph/provider-validators.js"; +import { + validateGraphDetectChangesResult, + validateGraphFactQueryResult, + validateGraphImpactResult, + validateGraphNamedQueryResult, + validateGraphReviewContextResult, +} from "../graph/query-validators.js"; +import { validateGraphSearchResult } from "../graph/search-validators.js"; +import { validateInspectRouteResult } from "../inspect/validators.js"; +import { + validateOpcoreDoctorPayload, + validateOpcoreInitPlanPayload, + validateOpcoreRuntimeInfoPayload, +} from "../product/init-validators-01.js"; +import { validateCommandTiming } from "../product/metrics-validators-01.js"; +import { validateOpcoreMeasureDelta, validateOpcoreTryPayload } from "../product/metrics-validators-03.js"; +import { validateOpcoreRepoStatePayload } from "../product/status-validators.js"; +import { validateExitCodeForStatus, validateNonEmptyString, validateStringArray } from "../shared/validators-01.js"; +import { + validatePreWriteValidationReceipt, + validateValidationStatusPayload, +} from "../validation/prewrite-status-validators-01.js"; +import { validateValidationResultPayload } from "../validation/result-validator.js"; +import type { CommandGroupContract, CommandRouterManifest } from "./contracts.js"; +import { validateCommandOwner, validateCommandRouteStatus } from "./helper-validators.js"; +import type { CommandRouterResult } from "./router-contracts.js"; + +function validateCommandRouterManifestHeader(manifest: CommandRouterManifest): void { + validateRequiredObject(manifest, "Command router manifest is required"); + if (manifest.schemaVersion !== 1) { + throw new Error("Command router manifest schemaVersion must be 1"); + } + if (typeof manifest.packageName !== "string" || manifest.packageName.length === 0) { + throw new Error("Command router manifest packageName must be a non-empty string"); + } +} + +export { validateCommandRouterManifestHeader }; + +function validateManifestBins(bins: readonly string[]): void { + if (!Array.isArray(bins) || bins.length === 0) { + throw new Error("Command router manifest must include bins"); + } + for (const bin of bins) { + if (typeof bin !== "string" || bin.length === 0) { + throw new Error("Command router manifest bins must be non-empty strings"); + } + } +} + +export { validateManifestBins }; + +function validateManifestGroups(commandGroups: readonly CommandGroupContract[]): Set { + const groupNames = new Set(); + for (const group of commandGroups) { + validateCommandOwner(group.owner); + validateNonEmptyString(group.name, "Command group name"); + validateStringArray(group.canonicalCommand, "Command group canonicalCommand", { allowEmpty: false }); + validateStringArray(group.commands, "Command group commands", { + allowEmpty: false, + }); + validateNonEmptyString(group.summary, "Command group summary"); + groupNames.add(group.name); + } + + return groupNames; +} + +export { validateManifestGroups }; + +function validateManifestOwnershipBoundaries(boundaries: CommandRouterManifest["ownershipBoundaries"]): void { + for (const boundary of boundaries) { + validateCommandOwner(boundary.owner); + validateNonEmptyString(boundary.summary, "Command ownership boundary summary"); + } +} + +export { validateManifestOwnershipBoundaries }; + +function validateCommandRouterResult(result: CommandRouterResult): CommandRouterResult { + validateRequiredObject(result, "Command router result is required"); + if (result.schemaVersion !== 1) { + throw new Error("Command router result schemaVersion must be 1"); + } + validateNonEmptyString(result.bin, "Command router result bin"); + validateStringArray(result.argv, "Command router result argv", { + allowEmpty: true, + allowEmptyValues: true, + }); + validateStringArray(result.canonicalCommand, "Command router result canonicalCommand", { allowEmpty: false }); + validateCommandOwner(result.owner); + validateCommandRouteStatus(result.status); + validateExitCodeForStatus(result.exitCode, result.status); + validateNonEmptyString(result.message, "Command router result message"); + validateBoolean(result.json, "Command router result json"); + validateCommandRouterGraphPayloads(result); + validateCommandRouterValidationPayloads(result); + validateCommandRouterProductPayloads(result); + validateCommandRouterPayloadOwners(result); + validateCommandRouterEditMessage(result); + return result; +} + +export { validateCommandRouterResult }; + +function validateCommandRouterGraphPayloads(result: CommandRouterResult): void { + validateOptional(result.providerStatus, validateProviderStatus); + validateOptional(result.graphPipeline, validateGraphPipelineResult); + validateOptional(result.graphQuery, (query) => { + if (isNamedQueryResult(query)) validateGraphNamedQueryResult(query); + else validateGraphFactQueryResult(query); + }); + validateOptional(result.graphSearch, validateGraphSearchResult); + validateOptional(result.inspectResult, validateInspectRouteResult); + validateOptional(result.graphImpact, validateGraphImpactResult); + validateOptional(result.graphReviewContext, validateGraphReviewContextResult); + validateOptional(result.graphChanges, validateGraphDetectChangesResult); + validateOptional(result.graphServe, validateGraphServeTransportStatus); +} + +export { validateCommandRouterGraphPayloads }; + +function validateCommandRouterValidationPayloads(result: CommandRouterResult): void { + validateOptional(result.validationResult, validateValidationResultPayload); + validateOptional(result.validationStatus, validateValidationStatusPayload); + validateOptional(result.receipt, validatePreWriteValidationReceipt); + validateOptional(result.editPlan, validateEditPlanPayload); + validateOptional(result.editResult, validateEditCommandResult); +} + +export { validateCommandRouterValidationPayloads }; + +function validateCommandRouterProductPayloads(result: CommandRouterResult): void { + validateOptional(result.repoState, validateOpcoreRepoStatePayload); + validateOptional(result.runtimeInfo, validateOpcoreRuntimeInfoPayload); + validateOptional(result.opcoreDoctor, validateOpcoreDoctorPayload); + validateOptional(result.opcoreInit, validateOpcoreInitPlanPayload); + validateOptional(result.opcoreMeasure, validateOpcoreMeasureDelta); + validateOptional(result.opcoreTry, validateOpcoreTryPayload); + validateOptional(result.timing, validateCommandTiming); +} + +export { validateCommandRouterProductPayloads }; + +function validateCommandRouterPayloadOwners(result: CommandRouterResult): void { + validatePayloadOwner(result.repoState, result.owner, "runtime", "Opcore repoState payload requires runtime owner"); + validatePayloadOwner( + result.runtimeInfo, + result.owner, + "runtime", + "Opcore runtime info payload requires runtime owner", + ); + validatePayloadOwner(result.opcoreDoctor, result.owner, "runtime", "Opcore doctor payload requires runtime owner"); + validatePayloadOwner(result.opcoreInit, result.owner, "runtime", "Opcore init payload requires runtime owner"); + validatePayloadOwner(result.opcoreMeasure, result.owner, "runtime", "Opcore measure payload requires runtime owner"); + validatePayloadOwner(result.opcoreTry, result.owner, "runtime", "Opcore try payload requires runtime owner"); + validatePayloadOwner(result.editPlan, result.owner, "edit", "Edit router payloads require edit owner"); + validatePayloadOwner(result.editResult, result.owner, "edit", "Edit router payloads require edit owner"); +} + +export { validateCommandRouterPayloadOwners }; + +function validatePayloadOwner( + payload: unknown, + owner: CommandRouterResult["owner"], + expectedOwner: CommandRouterResult["owner"], + message: string, +): void { + if (payload !== undefined && owner !== expectedOwner) throw new Error(message); +} + +export { validatePayloadOwner }; + +function validateCommandRouterEditMessage(result: CommandRouterResult): void { + if (result.owner !== "edit" || result.status !== "ok") return; + if (result.editPlan !== undefined || result.editResult !== undefined) return; + const hiddenPayloadPattern = /"?(editPlan|editResult|planId|changes|afterState)"?\s*[:{[]/; + if (hiddenPayloadPattern.test(result.message)) { + throw new Error("Edit router payloads must use editPlan/editResult fields, not message strings"); + } +} + +export { validateCommandRouterEditMessage }; diff --git a/packages/contracts/src/command/vocabulary.ts b/packages/contracts/src/command/vocabulary.ts new file mode 100644 index 0000000..cc353e6 --- /dev/null +++ b/packages/contracts/src/command/vocabulary.ts @@ -0,0 +1,56 @@ +const commandOwners = ["graph", "inspect", "edit", "validation", "runtime"] as const; + +export { commandOwners }; + +type CommandOwner = (typeof commandOwners)[number]; + +export type { CommandOwner }; + +const commandRouteStatuses = ["ok", "error", "not_implemented", "unsupported"] as const; + +export { commandRouteStatuses }; + +type CommandRouteStatus = (typeof commandRouteStatuses)[number]; + +export type { CommandRouteStatus }; + +const commandTimingProcessStates = ["cold", "warm"] as const; + +export { commandTimingProcessStates }; + +type CommandTimingProcessState = (typeof commandTimingProcessStates)[number]; + +export type { CommandTimingProcessState }; + +const commandTimingDegradationReasons = ["no_source", "no_paths"] as const; + +export { commandTimingDegradationReasons }; + +type CommandTimingDegradationReason = (typeof commandTimingDegradationReasons)[number]; + +export type { CommandTimingDegradationReason }; + +const latencyBudgetResultStatuses = ["pass", "over"] as const; + +export { latencyBudgetResultStatuses }; + +type LatencyBudgetResultStatus = (typeof latencyBudgetResultStatuses)[number]; + +export type { LatencyBudgetResultStatus }; + +const commandLatencyTelemetryBins = ["opcore", "opcore-asp-provider"] as const; + +export { commandLatencyTelemetryBins }; + +type CommandLatencyTelemetryBin = (typeof commandLatencyTelemetryBins)[number]; + +export type { CommandLatencyTelemetryBin }; + +const commandLatencyTelemetryArtifactPolicy = { + path: ".opcore/telemetry.jsonl", + maxRecords: 500, + maxBytes: 1024 * 1024, + rotation: "ring_buffer", +} as const; + +export { commandLatencyTelemetryArtifactPolicy }; diff --git a/packages/contracts/src/edit/contracts.ts b/packages/contracts/src/edit/contracts.ts new file mode 100644 index 0000000..eaa477e --- /dev/null +++ b/packages/contracts/src/edit/contracts.ts @@ -0,0 +1,98 @@ +import type { RepoIdentity } from "../graph/provider-contracts-01.js"; +import type { ValidationResult } from "../validation/capability-contracts.js"; +import type { ValidationRequest } from "../validation/request-contracts.js"; +import type { EditRefusalCategory } from "./vocabulary.js"; + +interface RepoRelativeChangeBase { + path: string; + checksumBefore?: string; + checksumAfter?: string; +} + +export type { RepoRelativeChangeBase }; + +type RepoRelativeChange = + | (RepoRelativeChangeBase & { + kind: "create" | "replace"; + content: string; + }) + | (RepoRelativeChangeBase & { + kind: "delete"; + }) + | { + kind: "rename"; + path: string; + toPath: string; + checksumBefore?: string; + }; + +export type { RepoRelativeChange }; + +interface AtomicApplyMetadata { + strategy: "all_or_nothing"; + planHash?: string; + expectedBaseSha?: string; +} + +export type { AtomicApplyMetadata }; + +interface EditPlanValidationRequirement { + required: boolean; + request: ValidationRequest; +} + +export type { EditPlanValidationRequirement }; + +interface EditPlan { + planId: string; + repo: RepoIdentity; + changes: readonly RepoRelativeChange[]; + atomic: AtomicApplyMetadata; + validation: EditPlanValidationRequirement; +} + +export type { EditPlan }; + +interface EditRefusal { + category: EditRefusalCategory; + message: string; + path?: string; +} + +export type { EditRefusal }; + +interface EditPlanResult { + planId: string; + ok: boolean; + applied: boolean; + appliedAt?: string; + refusal?: EditRefusal; + validation?: ValidationResult; +} + +export type { EditPlanResult }; + +interface EditPlanRollbackState { + completed: boolean; + restoredPaths: readonly string[]; + failedPaths: readonly string[]; + cleanupFailedPaths: readonly string[]; +} + +export type { EditPlanRollbackState }; + +interface EditCommandResult { + ok: boolean; + applied: boolean; + planId?: string; + planHash?: string; + appliedAt?: string; + matchCount?: number; + afterState?: Readonly>; + validationRequest?: ValidationRequest; + validation?: ValidationResult; + refusal?: EditRefusal; + rollback?: EditPlanRollbackState; +} + +export type { EditCommandResult }; diff --git a/packages/contracts/src/edit/refusal-validator.ts b/packages/contracts/src/edit/refusal-validator.ts new file mode 100644 index 0000000..b7e40cf --- /dev/null +++ b/packages/contracts/src/edit/refusal-validator.ts @@ -0,0 +1,17 @@ +import { includesString, validateRequiredObject } from "../shared/primitives.js"; +import { validateRepoRelativePath } from "../shared/path-validators.js"; +import { validateNonEmptyString } from "../shared/validators-01.js"; +import type { EditRefusal } from "./contracts.js"; +import { editRefusalCategories } from "./vocabulary.js"; + +function validateEditRefusal(refusal: EditRefusal): EditRefusal { + validateRequiredObject(refusal, "Edit refusal is required"); + if (!includesString(editRefusalCategories, refusal.category)) { + throw new Error(`Unknown edit refusal category: ${String(refusal.category)}`); + } + validateNonEmptyString(refusal.message, "Edit refusal message"); + if (refusal.path !== undefined) validateRepoRelativePath(refusal.path); + return refusal; +} + +export { validateEditRefusal }; diff --git a/packages/contracts/src/edit/validators.ts b/packages/contracts/src/edit/validators.ts new file mode 100644 index 0000000..0924a92 --- /dev/null +++ b/packages/contracts/src/edit/validators.ts @@ -0,0 +1,126 @@ +import { + validateBoolean, + validateOptional, + validateRequiredObject, +} from "../shared/validators-02.js"; +import { + validateRepoIdentity, + validateRepoRelativePath, + validateRepoRelativePaths, +} from "../shared/path-validators.js"; +import { validateNonEmptyString, validateNonNegativeInteger } from "../shared/validators-01.js"; +import { validateValidationRequestPayload, validateValidationResultPayload } from "../validation/result-validator.js"; +import type { EditCommandResult, EditPlan, EditPlanRollbackState, RepoRelativeChange } from "./contracts.js"; +import { validateEditRefusal } from "./refusal-validator.js"; + +function validateEditPlanPayload(plan: EditPlan): EditPlan { + validateRequiredObject(plan, "Edit plan is required"); + validateNonEmptyString(plan.planId, "Edit plan planId"); + validateRepoIdentity(plan.repo); + if (!Array.isArray(plan.changes)) { + throw new Error("Edit plan changes must be an array"); + } + for (const change of plan.changes) validateRepoRelativeChange(change); + validateRequiredObject(plan.atomic, "Edit plan atomic metadata is required"); + if (plan.atomic.strategy !== "all_or_nothing") { + throw new Error("Edit plan atomic strategy must be all_or_nothing"); + } + if (plan.atomic.planHash !== undefined) validateNonEmptyString(plan.atomic.planHash, "Edit plan planHash"); + if (plan.atomic.expectedBaseSha !== undefined) + validateNonEmptyString(plan.atomic.expectedBaseSha, "Edit plan expectedBaseSha"); + validateRequiredObject(plan.validation, "Edit plan validation requirement is required"); + if (typeof plan.validation.required !== "boolean") { + throw new Error("Edit plan validation required must be boolean"); + } + validateValidationRequestPayload(plan.validation.request); + return plan; +} + +export { validateEditPlanPayload }; + +function validateEditCommandResult(result: EditCommandResult): EditCommandResult { + validateRequiredObject(result, "Edit command result is required"); + validateBoolean(result.ok, "Edit command result ok"); + validateBoolean(result.applied, "Edit command result applied"); + validateOptional(result.planId, (value) => validateNonEmptyString(value, "Edit command result planId")); + validateOptional(result.planHash, (value) => validateNonEmptyString(value, "Edit command result planHash")); + validateOptional(result.appliedAt, (value) => validateNonEmptyString(value, "Edit command result appliedAt")); + validateOptional(result.matchCount, (value) => + validateNonNegativeInteger(value, "Edit command result matchCount"), + ); + validateOptional(result.afterState, validateEditAfterState); + validateOptional(result.validationRequest, validateValidationRequestPayload); + validateOptional(result.validation, validateValidationResultPayload); + validateOptional(result.refusal, validateEditRefusal); + validateOptional(result.rollback, validateEditPlanRollbackState); + if (!result.ok && result.refusal === undefined) { + throw new Error("Edit command result refusal is required when ok=false"); + } + if (result.ok && result.refusal !== undefined) { + throw new Error("Edit command result ok=true must not include refusal"); + } + return result; +} + +export { validateEditCommandResult }; + +function validateEditPlanRollbackState(rollback: EditPlanRollbackState): EditPlanRollbackState { + validateRequiredObject(rollback, "Edit rollback state is required"); + if (typeof rollback.completed !== "boolean") { + throw new Error("Edit rollback completed must be boolean"); + } + validateRepoRelativePaths(rollback.restoredPaths, "Edit rollback restoredPaths"); + validateRepoRelativePaths(rollback.failedPaths, "Edit rollback failedPaths"); + if (!Array.isArray(rollback.cleanupFailedPaths)) { + throw new Error("Edit rollback cleanupFailedPaths must be an array"); + } + for (const path of rollback.cleanupFailedPaths) { + validateNonEmptyString(path, "Edit rollback cleanupFailedPaths path"); + } + return rollback; +} + +export { validateEditPlanRollbackState }; + +function validateRepoRelativeChange(change: RepoRelativeChange): RepoRelativeChange { + validateRequiredObject(change, "Repo-relative change is required"); + if (change.kind === "create" || change.kind === "replace") { + validateRepoRelativePath(change.path); + if (typeof change.content !== "string") throw new Error("Repo-relative write change content must be string"); + if (change.checksumBefore !== undefined) + validateNonEmptyString(change.checksumBefore, "Repo-relative change checksumBefore"); + if (change.checksumAfter !== undefined) + validateNonEmptyString(change.checksumAfter, "Repo-relative change checksumAfter"); + return change; + } + if (change.kind === "delete") { + validateRepoRelativePath(change.path); + if (change.checksumBefore !== undefined) + validateNonEmptyString(change.checksumBefore, "Repo-relative change checksumBefore"); + return change; + } + if (change.kind === "rename") { + validateRepoRelativePath(change.path); + validateRepoRelativePath(change.toPath); + if (change.checksumBefore !== undefined) + validateNonEmptyString(change.checksumBefore, "Repo-relative change checksumBefore"); + return change; + } + throw new Error(`Unknown repo-relative change kind: ${String((change as { kind?: unknown }).kind)}`); +} + +export { validateRepoRelativeChange }; + +function validateEditAfterState(afterState: Readonly>): void { + if (!afterState || typeof afterState !== "object" || Array.isArray(afterState)) { + throw new Error("Edit command result afterState must be an object"); + } + for (const [path, content] of Object.entries(afterState)) { + validateRepoRelativePath(path); + if (typeof content !== "string" && content !== null) { + throw new Error(`Edit command result afterState for ${path} must be string or null`); + } + } +} + +export { validateEditAfterState }; diff --git a/packages/contracts/src/edit/vocabulary.ts b/packages/contracts/src/edit/vocabulary.ts new file mode 100644 index 0000000..f5aff93 --- /dev/null +++ b/packages/contracts/src/edit/vocabulary.ts @@ -0,0 +1,17 @@ +const editRefusalCategories = [ + "absolute_path", + "parent_directory", + "ambiguous_repo_identity", + "validation_failed", + "provider_required_missing", + "schema_mismatch", + "unsafe_edit", + "conflict", + "unsupported_change", +] as const; + +export { editRefusalCategories }; + +type EditRefusalCategory = (typeof editRefusalCategories)[number]; + +export type { EditRefusalCategory }; diff --git a/packages/contracts/src/graph/daemon-validators-01.ts b/packages/contracts/src/graph/daemon-validators-01.ts new file mode 100644 index 0000000..f0ddeb7 --- /dev/null +++ b/packages/contracts/src/graph/daemon-validators-01.ts @@ -0,0 +1,193 @@ +import { + validateBoolean, + validateExactValue, + validateOptional, + validateRequiredObject, +} from "../shared/validators-02.js"; +import { validateRepoIdentity, validateRepoRelativePath } from "../shared/path-validators.js"; +import { validateNonEmptyString, validateStringArray } from "../shared/validators-01.js"; +import { validateGraphDaemonOperation } from "./helper-validators.js"; +import { + validateGraphWalCheckpointSummary, + validateGraphWatchLifecycle, +} from "./protocol-validators.js"; +import type { + GraphDaemonRequest, + GraphDaemonResponse, + GraphPipelinePhaseTiming, + GraphPipelineResult, + GraphPipelineSummary, +} from "./pipeline-contracts.js"; +import { validateProviderStatus } from "./provider-validators.js"; +import { + validateGraphDetectChangesRequest, + validateGraphDetectChangesResult, + validateGraphFactQueryRequest, + validateGraphFactQueryResult, + validateGraphImpactRequest, + validateGraphImpactResult, + validateGraphNamedQueryRequest, + validateGraphNamedQueryResult, + validateGraphReviewContextRequest, + validateGraphReviewContextResult, +} from "./query-validators.js"; +import { validateGraphSearchRequest, validateGraphSearchResult } from "./search-validators.js"; +import { GRAPH_SCHEMA_VERSION } from "./vocabulary-01.js"; + +function validateGraphDaemonRequest(request: GraphDaemonRequest): GraphDaemonRequest { + validateRequiredObject(request, "Graph daemon request is required"); + validateExactValue( + request.protocol, + "opcore.graph.daemon", + "Graph daemon request protocol must be opcore.graph.daemon", + ); + validateNonEmptyString(request.requestId, "Graph daemon request requestId"); + validateExactValue( + request.schemaVersion, + GRAPH_SCHEMA_VERSION, + `Graph daemon request schemaVersion must be ${GRAPH_SCHEMA_VERSION}`, + ); + validateGraphDaemonOperation(request.operation); + validateRepoIdentity(request.repo); + validateGraphDaemonQueryRequest(request); + validateGraphDaemonRequestOptions(request); + return request; +} + +export { validateGraphDaemonRequest }; + +function validateGraphDaemonQueryRequest(request: GraphDaemonRequest): void { + validateOptional(request.query, validateGraphFactQueryRequest); + validateOptional(request.namedQuery, validateGraphNamedQueryRequest); + validateOptional(request.impact, validateGraphImpactRequest); + validateOptional(request.reviewContext, validateGraphReviewContextRequest); + validateOptional(request.changes, validateGraphDetectChangesRequest); + validateOptional(request.search, validateGraphSearchRequest); + if (request.operation !== "query" || request.query !== undefined) return; + const envelopes = [request.namedQuery, request.impact, request.reviewContext, request.changes, request.search]; + if (!envelopes.some((value) => value !== undefined)) { + throw new Error("Graph daemon query request must include query"); + } +} + +function validateGraphDaemonRequestOptions(request: GraphDaemonRequest): void { + validateOptional(request.baseRef, (value) => validateNonEmptyString(value, "Graph daemon request baseRef")); + validateOptional(request.paths, (value) => validateGraphDaemonPaths(value, "paths")); + validateOptional(request.watchPaths, (value) => validateGraphDaemonPaths(value, "watchPaths")); + validateOptional(request.pollIntervalMs, (value) => + validateFiniteNumberAtLeast(value, 1, "Graph daemon request pollIntervalMs must be positive"), + ); + validateOptional(request.idleTimeoutMs, (value) => + validateFiniteNumberAtLeast( + value, + 0, + "Graph daemon request idleTimeoutMs must be a non-negative number", + ), + ); + validateOptional(request.once, (value) => validateBoolean(value, "Graph daemon request once")); + validateOptional(request.maxWalBytes, (value) => + validateFiniteNumberAtLeast(value, 1, "Graph daemon request maxWalBytes must be positive"), + ); +} + +function validateGraphDaemonPaths(paths: readonly string[], field: "paths" | "watchPaths"): void { + validateStringArray(paths, `Graph daemon request ${field}`, { allowEmpty: true }); + for (const path of paths) validateRepoRelativePath(path); +} + +function validateFiniteNumberAtLeast(value: number, minimum: number, message: string): void { + if (!Number.isFinite(value) || value < minimum) throw new Error(message); +} + +function validateGraphDaemonResponse(response: GraphDaemonResponse): GraphDaemonResponse { + validateRequiredObject(response, "Graph daemon response is required"); + validateExactValue( + response.protocol, + "opcore.graph.daemon", + "Graph daemon response protocol must be opcore.graph.daemon", + ); + validateNonEmptyString(response.requestId, "Graph daemon response requestId"); + validateExactValue( + response.schemaVersion, + GRAPH_SCHEMA_VERSION, + `Graph daemon response schemaVersion must be ${GRAPH_SCHEMA_VERSION}`, + ); + validateProviderStatus(response.status); + validateOptional(response.result, validateGraphFactQueryResult); + validateOptional(response.namedQuery, validateGraphNamedQueryResult); + validateOptional(response.impact, validateGraphImpactResult); + validateOptional(response.reviewContext, validateGraphReviewContextResult); + validateOptional(response.changes, validateGraphDetectChangesResult); + validateOptional(response.search, validateGraphSearchResult); + validateOptional(response.pipeline, validateGraphPipelineResult); + validateOptional(response.lifecycle, validateGraphWatchLifecycle); + return response; +} + +export { validateGraphDaemonResponse }; + +function validateGraphPipelineResult(result: GraphPipelineResult): GraphPipelineResult { + validateRequiredObject(result, "Graph pipeline result is required"); + validateGraphPipelineSummary(result.summary); + validateProviderStatus(result.status); + if (result.lifecycle !== undefined) validateGraphWatchLifecycle(result.lifecycle); + return result; +} + +export { validateGraphPipelineResult }; + +function validateGraphPipelineSummary(summary: GraphPipelineSummary): GraphPipelineSummary { + validateRequiredObject(summary, "Graph pipeline summary is required"); + if (!["build", "update", "watch"].includes(summary.operation)) { + throw new Error(`Unknown graph pipeline operation: ${String(summary.operation)}`); + } + validateRepoIdentity(summary.repo); + validateOptional(summary.storePath, (value) => validateNonEmptyString(value, "Graph pipeline summary storePath")); + validateNonEmptyString(summary.startedAt, "Graph pipeline summary startedAt"); + validateNonEmptyString(summary.completedAt, "Graph pipeline summary completedAt"); + validateGraphPipelineCounts(summary); + validateStringArray(summary.changedFiles, "Graph pipeline summary changedFiles", { allowEmpty: true }); + validateStringArray(summary.deletedFiles, "Graph pipeline summary deletedFiles", { allowEmpty: true }); + for (const path of summary.changedFiles) validateRepoRelativePath(path); + for (const path of summary.deletedFiles) validateRepoRelativePath(path); + validateBoolean(summary.fullRebuildRequired, "Graph pipeline summary fullRebuildRequired"); + if (!Array.isArray(summary.phaseTimings) || summary.phaseTimings.length === 0) { + throw new Error("Graph pipeline summary phaseTimings must be non-empty"); + } + for (const timing of summary.phaseTimings) validateGraphPipelinePhaseTiming(timing); + validateOptional(summary.baseRef, (value) => validateNonEmptyString(value, "Graph pipeline summary baseRef")); + validateOptional(summary.watchPaths, validateGraphPipelineWatchPaths); + validateOptional(summary.walCheckpoint, validateGraphWalCheckpointSummary); + return summary; +} + +export { validateGraphPipelineSummary }; + +function validateGraphPipelineCounts(summary: GraphPipelineSummary): void { + for (const key of ["durationMs", "discoveredFiles", "parsedFiles", "unchangedFiles", "diagnosticsCount"] as const) { + if (typeof summary[key] !== "number" || summary[key] < 0) { + throw new Error(`Graph pipeline summary ${key} must be a non-negative number`); + } + } +} + +function validateGraphPipelineWatchPaths(paths: readonly string[]): void { + validateStringArray(paths, "Graph pipeline summary watchPaths", { allowEmpty: true }); + for (const path of paths) validateRepoRelativePath(path); +} + +function validateGraphPipelinePhaseTiming(timing: GraphPipelinePhaseTiming): GraphPipelinePhaseTiming { + validateRequiredObject(timing, "Graph pipeline phase timing is required"); + validateNonEmptyString(timing.phase, "Graph pipeline phase timing phase"); + validateNonEmptyString(timing.startedAt, "Graph pipeline phase timing startedAt"); + validateNonEmptyString(timing.completedAt, "Graph pipeline phase timing completedAt"); + if (typeof timing.durationMs !== "number" || timing.durationMs < 0) { + throw new Error("Graph pipeline phase timing durationMs must be non-negative"); + } + if (timing.fileCount !== undefined && (typeof timing.fileCount !== "number" || timing.fileCount < 0)) { + throw new Error("Graph pipeline phase timing fileCount must be non-negative"); + } + return timing; +} + +export { validateGraphPipelinePhaseTiming }; diff --git a/packages/contracts/src/graph/daemon-validators-02.ts b/packages/contracts/src/graph/daemon-validators-02.ts new file mode 100644 index 0000000..a3852cc --- /dev/null +++ b/packages/contracts/src/graph/daemon-validators-02.ts @@ -0,0 +1,47 @@ +import { + validateExactValue, + validateOptional, + validateRequiredObject, +} from "../shared/validators-02.js"; +import { includesString } from "../shared/primitives.js"; +import { validateRepoIdentity } from "../shared/path-validators.js"; +import { validateNonEmptyString } from "../shared/validators-01.js"; +import type { GraphServeTransportStatus } from "./pipeline-contracts.js"; +import { + validateGraphProviderArtifactMetadata, + validateProviderFailure, +} from "./protocol-validators.js"; + +function validateGraphServeTransportStatus(status: GraphServeTransportStatus): GraphServeTransportStatus { + validateRequiredObject(status, "Graph serve transport status is required"); + validateExactValue(status.schemaVersion, 1, "Graph serve transport status schemaVersion must be 1"); + validateExactValue( + status.protocol, + "opcore.graph.daemon", + "Graph serve transport status protocol must be opcore.graph.daemon", + ); + validateExactValue(status.transport, "stdio", "Graph serve transport status transport must be stdio"); + if (!includesString(["ready", "error", "stopped"] as const, status.state)) { + throw new Error(`Unknown graph serve transport state: ${String(status.state)}`); + } + validateRepoIdentity(status.repo); + validateNonEmptyString(status.provider, "Graph serve transport status provider"); + validateOptional(status.pid, validateGraphServePid); + validateOptional(status.artifact, validateGraphProviderArtifactMetadata); + validateOptional(status.failure, validateProviderFailure); + if (status.state === "error" && status.failure === undefined) { + throw new Error("Graph serve transport error status must include failure"); + } + validateOptional(status.message, (value) => + validateNonEmptyString(value, "Graph serve transport status message"), + ); + return status; +} + +export { validateGraphServeTransportStatus }; + +function validateGraphServePid(pid: number): void { + if (!Number.isInteger(pid) || pid < 1) { + throw new Error("Graph serve transport status pid must be positive"); + } +} diff --git a/packages/contracts/src/graph/helper-validators.ts b/packages/contracts/src/graph/helper-validators.ts new file mode 100644 index 0000000..5e5cb0a --- /dev/null +++ b/packages/contracts/src/graph/helper-validators.ts @@ -0,0 +1,156 @@ +import { includesString } from "../shared/primitives.js"; +import { + validateRepoIdentity, + validateRepoRelativePath, + validateRepoRelativePaths, +} from "../shared/path-validators.js"; +import { validateNonEmptyString, validateStringArray } from "../shared/validators-01.js"; +import type { GraphDaemonOperation} from "./pipeline-contracts.js"; +import { graphDaemonOperations } from "./pipeline-contracts.js"; +import type { RepoIdentity } from "./provider-contracts-01.js"; +import type { + GraphFactQuerySelector, + GraphNamedQueryKind, + GraphProviderQueryKind, + GraphTraversalMetadata} from "./query-contracts-01.js"; +import { + graphFactQueryKinds, + graphNamedQueryKinds, +} from "./query-contracts-01.js"; +import type { GraphSearchMode, GraphSearchResultEntry, GraphSearchSummary } from "./search-contracts.js"; +import type { GraphProviderMode} from "./vocabulary-01.js"; +import { GRAPH_SCHEMA_VERSION, graphProviderModes } from "./vocabulary-01.js"; + +function validateGraphDaemonOperation(operation: unknown): GraphDaemonOperation { + if (!includesString(graphDaemonOperations, operation)) { + throw new Error(`Unknown graph daemon operation: ${String(operation)}`); + } + return operation; +} + +export { validateGraphDaemonOperation }; + +function validateGraphFactQueryKind(kind: unknown): GraphFactQuerySelector["kind"] { + if (!includesString(graphFactQueryKinds, kind)) { + throw new Error(`Unknown graph fact query kind: ${String(kind)}`); + } + return kind; +} + +export { validateGraphFactQueryKind }; + +function validateGraphNamedQueryKind(kind: unknown): GraphNamedQueryKind { + if (!includesString(graphNamedQueryKinds, kind)) { + throw new Error(`Unknown graph named query kind: ${String(kind)}`); + } + return kind; +} + +export { validateGraphNamedQueryKind }; + +function validateGraphProviderQueryKind(kind: unknown): GraphProviderQueryKind { + if ( + !includesString(graphFactQueryKinds, kind) && + !includesString(graphNamedQueryKinds, kind) && + kind !== "review_context" && + kind !== "detect_changes" && + kind !== "search" + ) { + throw new Error(`Unknown graph provider query kind: ${String(kind)}`); + } + return kind; +} + +export { validateGraphProviderQueryKind }; + +function validateGraphQueryRequestBase( + request: { + requestId?: string; + repo: RepoIdentity; + schemaVersion: number; + mode: GraphProviderMode; + }, + label: string, +): void { + if (!request || typeof request !== "object") throw new Error(`${label} is required`); + if (request.requestId !== undefined) validateNonEmptyString(request.requestId, `${label} requestId`); + validateRepoIdentity(request.repo); + if (request.schemaVersion !== GRAPH_SCHEMA_VERSION) + throw new Error(`${label} schemaVersion must be ${GRAPH_SCHEMA_VERSION}`); + if (!includesString(graphProviderModes, request.mode)) + throw new Error(`Unknown ${label} mode: ${String(request.mode)}`); +} + +export { validateGraphQueryRequestBase }; + +function validateTraversalOptions(maxDepth: number | undefined, limit: number | undefined, label: string): void { + if (maxDepth !== undefined && (!Number.isFinite(maxDepth) || maxDepth < 0)) { + throw new Error(`${label} maxDepth must be a non-negative number`); + } + if (limit !== undefined && (!Number.isFinite(limit) || limit < 1)) { + throw new Error(`${label} limit must be a positive number`); + } +} + +export { validateTraversalOptions }; + +function validateGraphTraversalMetadata(metadata: GraphTraversalMetadata): GraphTraversalMetadata { + if (!metadata || typeof metadata !== "object") throw new Error("Graph traversal metadata is required"); + if (typeof metadata.maxDepth !== "number" || metadata.maxDepth < 0) { + throw new Error("Graph traversal metadata maxDepth must be non-negative"); + } + if (typeof metadata.truncated !== "boolean") throw new Error("Graph traversal metadata truncated must be boolean"); + if (typeof metadata.total !== "number" || metadata.total < 0) + throw new Error("Graph traversal metadata total must be non-negative"); + if (typeof metadata.empty !== "boolean") throw new Error("Graph traversal metadata empty must be boolean"); + return metadata; +} + +export { validateGraphTraversalMetadata }; + +function validateGraphSearchMode(mode: GraphSearchMode): GraphSearchMode { + if (!mode || typeof mode !== "object") throw new Error("Graph search mode is required"); + validateNonEmptyString(mode.engine, "Graph search mode engine"); + validateNonEmptyString(mode.querySyntax, "Graph search mode querySyntax"); + if (!Number.isFinite(mode.limit) || mode.limit < 1) + throw new Error("Graph search mode limit must be a positive number"); + validateRepoRelativePaths(mode.contextFiles, "Graph search mode contextFiles"); + return mode; +} + +export { validateGraphSearchMode }; + +function validateGraphSearchSummary(summary: GraphSearchSummary): GraphSearchSummary { + if (!summary || typeof summary !== "object") throw new Error("Graph search summary is required"); + validateNonEmptyString(summary.query, "Graph search summary query"); + for (const key of ["total", "returned", "limit"] as const) { + if (!Number.isFinite(summary[key]) || summary[key] < (key === "limit" ? 1 : 0)) { + throw new Error(`Graph search summary ${key} must be a non-negative number`); + } + } + validateStringArray(summary.indexedNodeKinds, "Graph search summary indexedNodeKinds", { allowEmpty: true }); + validateRepoRelativePaths(summary.contextFiles, "Graph search summary contextFiles"); + return summary; +} + +export { validateGraphSearchSummary }; + +function validateGraphSearchResultEntry(entry: GraphSearchResultEntry): GraphSearchResultEntry { + if (!entry || typeof entry !== "object") throw new Error("Graph search result entry is required"); + validateNonEmptyString(entry.nodeId, "Graph search result entry nodeId"); + validateNonEmptyString(entry.kind, "Graph search result entry kind"); + if (entry.path !== undefined) validateRepoRelativePath(entry.path); + if (entry.name !== undefined) validateNonEmptyString(entry.name, "Graph search result entry name"); + validateNonEmptyString(entry.qualifiedName, "Graph search result entry qualifiedName"); + if (entry.filePath !== undefined) validateRepoRelativePath(entry.filePath); + validateNonEmptyString(entry.signature, "Graph search result entry signature"); + if (!Number.isFinite(entry.score)) throw new Error("Graph search result entry score must be numeric"); + if (!Number.isFinite(entry.rank) || entry.rank < 1) + throw new Error("Graph search result entry rank must be a positive number"); + validateStringArray(entry.matches, "Graph search result entry matches", { + allowEmpty: true, + }); + return entry; +} + +export { validateGraphSearchResultEntry }; diff --git a/packages/contracts/src/graph/payload-validators.ts b/packages/contracts/src/graph/payload-validators.ts new file mode 100644 index 0000000..0745792 --- /dev/null +++ b/packages/contracts/src/graph/payload-validators.ts @@ -0,0 +1,75 @@ +import { validateRequiredObject } from "../shared/validators-02.js"; +import { validateRepoRelativePath } from "../shared/path-validators.js"; +import { validateNonEmptyString } from "../shared/validators-01.js"; +import type { GraphExtractionDiagnostic } from "./provider-contracts-01.js"; +import type { GraphFactEdge, GraphFactNode } from "./provider-contracts-02.js"; +import { validateGraphExtractionDiagnostics } from "./protocol-validators.js"; +import { validateProviderStatus } from "./provider-validators.js"; +import type { GraphFactQueryResult, GraphNamedQueryResult } from "./query-contracts-01.js"; +import type { + GraphDetectChangesResult, + GraphImpactResult, + GraphRenamedFile, + GraphReviewContextResult, +} from "./query-contracts-02.js"; + +function validateGraphPayloadResult( + result: GraphNamedQueryResult | GraphImpactResult | GraphDetectChangesResult | GraphReviewContextResult, + label: string, + validatePayload: (payload: Record) => void, +): void { + if (!result || typeof result !== "object") throw new Error(`${label} is required`); + if (result.requestId !== undefined) validateNonEmptyString(result.requestId, `${label} requestId`); + const status = validateProviderStatus(result.status); + const payload: Record = { ...result }; + const payloadKeys = Object.keys(payload).filter((key) => key !== "requestId" && key !== "status"); + if (status.state !== "available") { + if (payloadKeys.length > 0) throw new Error(`${label} ${status.state} result must not include graph data`); + return; + } + validatePayload(payload); + if (payload.diagnostics !== undefined) { + validateGraphExtractionDiagnostics(payload.diagnostics as readonly GraphExtractionDiagnostic[]); + } +} + +export { validateGraphPayloadResult }; + + +function validateRenamedFiles(renamedFiles: readonly GraphRenamedFile[]): void { + if (!Array.isArray(renamedFiles)) throw new Error("Graph renamedFiles must be an array"); + for (const renamed of renamedFiles) { + validateRepoRelativePath(renamed.fromPath); + validateRepoRelativePath(renamed.toPath); + } +} + +export { validateRenamedFiles }; + +function isNamedQueryResult(result: GraphFactQueryResult | GraphNamedQueryResult): result is GraphNamedQueryResult { + return Object.hasOwn(result, "queryKind") || Object.hasOwn(result, "traversal"); +} + +export { isNamedQueryResult }; + +function validateGraphFactNode(node: GraphFactNode): GraphFactNode { + validateRequiredObject(node, "Graph fact node is required"); + validateNonEmptyString(node.id, "Graph fact node id"); + validateNonEmptyString(node.kind, "Graph fact node kind"); + if (node.path !== undefined) validateRepoRelativePath(node.path); + if (node.name !== undefined) validateNonEmptyString(node.name, "Graph fact node name"); + return node; +} + +export { validateGraphFactNode }; + +function validateGraphFactEdge(edge: GraphFactEdge): GraphFactEdge { + validateRequiredObject(edge, "Graph fact edge is required"); + if (edge.id !== undefined) validateNonEmptyString(edge.id, "Graph fact edge id"); + validateNonEmptyString(edge.kind, "Graph fact edge kind"); + validateNonEmptyString(edge.from, "Graph fact edge from"); + validateNonEmptyString(edge.to, "Graph fact edge to"); + return edge; +} + +export { validateGraphFactEdge }; diff --git a/packages/contracts/src/graph/pipeline-contracts.ts b/packages/contracts/src/graph/pipeline-contracts.ts new file mode 100644 index 0000000..ec6c7b6 --- /dev/null +++ b/packages/contracts/src/graph/pipeline-contracts.ts @@ -0,0 +1,150 @@ +import type { GraphProviderArtifactMetadata, ProviderFailure, RepoIdentity } from "./provider-contracts-01.js"; +import type { GraphProviderStatus } from "./provider-contracts-02.js"; +import type { + GraphFactQueryRequest, + GraphFactQueryResult, + GraphImpactRequest, + GraphNamedQueryRequest, + GraphNamedQueryResult, +} from "./query-contracts-01.js"; +import type { + GraphDetectChangesRequest, + GraphDetectChangesResult, + GraphImpactResult, + GraphReviewContextRequest, + GraphReviewContextResult, +} from "./query-contracts-02.js"; +import type { GraphSearchRequest, GraphSearchResult } from "./search-contracts.js"; + +type GraphPipelineOperation = "build" | "update" | "watch"; + +export type { GraphPipelineOperation }; + +interface GraphPipelinePhaseTiming { + phase: "discovery" | "extraction" | "store" | "watch" | "status" | (string & {}); + startedAt: string; + completedAt: string; + durationMs: number; + fileCount?: number; +} + +export type { GraphPipelinePhaseTiming }; + +interface GraphWalCheckpointSummary { + walPath: string; + bytesBefore: number; + bytesAfter: number; + budgetBytes: number; + checkpointed: boolean; +} + +export type { GraphWalCheckpointSummary }; + +interface GraphPipelineSummary { + operation: GraphPipelineOperation; + repo: RepoIdentity; + storePath?: string; + startedAt: string; + completedAt: string; + durationMs: number; + discoveredFiles: number; + parsedFiles: number; + changedFiles: readonly string[]; + deletedFiles: readonly string[]; + unchangedFiles: number; + fullRebuildRequired: boolean; + diagnosticsCount: number; + phaseTimings: readonly GraphPipelinePhaseTiming[]; + baseRef?: string; + watchPaths?: readonly string[]; + walCheckpoint?: GraphWalCheckpointSummary; +} + +export type { GraphPipelineSummary }; + +interface GraphWatchLifecycle { + state: "warming" | "available" | "error" | "stopped"; + pid?: number; + startedAt: string; + updatedAt: string; + pidPath: string; + statePath: string; + logPath: string; + pollIntervalMs: number; + idleTimeoutMs: number; + watchPaths?: readonly string[]; + message?: string; +} + +export type { GraphWatchLifecycle }; + +interface GraphServeTransportStatus { + schemaVersion: 1; + protocol: "opcore.graph.daemon"; + transport: "stdio"; + state: "ready" | "error" | "stopped"; + repo: RepoIdentity; + provider: "opcore-graph" | (string & {}); + pid?: number; + artifact?: GraphProviderArtifactMetadata; + failure?: ProviderFailure; + message?: string; +} + +export type { GraphServeTransportStatus }; + +interface GraphPipelineResult { + summary: GraphPipelineSummary; + status: GraphProviderStatus; + lifecycle?: GraphWatchLifecycle; +} + +export type { GraphPipelineResult }; + +type GraphDaemonOperation = "build" | "update" | "watch" | "status" | "query" | "ping" | "health" | "shutdown"; + +export type { GraphDaemonOperation }; + +const graphDaemonOperations = ["build", "update", "watch", "status", "query", "ping", "health", "shutdown"] as const; + +export { graphDaemonOperations }; + +interface GraphDaemonRequest { + protocol: "opcore.graph.daemon"; + requestId: string; + schemaVersion: number; + operation: GraphDaemonOperation; + repo: RepoIdentity; + query?: GraphFactQueryRequest; + namedQuery?: GraphNamedQueryRequest; + impact?: GraphImpactRequest; + reviewContext?: GraphReviewContextRequest; + changes?: GraphDetectChangesRequest; + search?: GraphSearchRequest; + baseRef?: string; + paths?: readonly string[]; + watchPaths?: readonly string[]; + pollIntervalMs?: number; + idleTimeoutMs?: number; + once?: boolean; + maxWalBytes?: number; +} + +export type { GraphDaemonRequest }; + +interface GraphDaemonResponse { + protocol: "opcore.graph.daemon"; + requestId: string; + schemaVersion: number; + status: GraphProviderStatus; + result?: GraphFactQueryResult; + namedQuery?: GraphNamedQueryResult; + impact?: GraphImpactResult; + reviewContext?: GraphReviewContextResult; + changes?: GraphDetectChangesResult; + search?: GraphSearchResult; + pipeline?: GraphPipelineResult; + lifecycle?: GraphWatchLifecycle; +} + +export type { GraphDaemonResponse }; diff --git a/packages/contracts/src/graph/protocol-validators.ts b/packages/contracts/src/graph/protocol-validators.ts new file mode 100644 index 0000000..5923cfa --- /dev/null +++ b/packages/contracts/src/graph/protocol-validators.ts @@ -0,0 +1,132 @@ +import { + includesString, + validateOptional, + validateRequiredObject, +} from "../shared/primitives.js"; +import { validateRepoRelativePath } from "../shared/path-validators.js"; +import { validateNonEmptyString, validateStringArray } from "../shared/validators-01.js"; +import type { + GraphExtractionDiagnostic, + GraphProviderArtifactMetadata, + ProviderFailure, +} from "./provider-contracts-01.js"; +import type { GraphWalCheckpointSummary, GraphWatchLifecycle } from "./pipeline-contracts.js"; +import { providerFailureCategories } from "./vocabulary-01.js"; +import { graphExtractionDiagnosticCategories } from "./vocabulary-02.js"; + +function validateGraphWatchLifecycle(lifecycle: GraphWatchLifecycle): GraphWatchLifecycle { + validateRequiredObject(lifecycle, "Graph watch lifecycle is required"); + if (!["warming", "available", "error", "stopped"].includes(lifecycle.state)) { + throw new Error(`Unknown graph watch lifecycle state: ${String(lifecycle.state)}`); + } + if (lifecycle.pid !== undefined && (!Number.isInteger(lifecycle.pid) || lifecycle.pid < 1)) { + throw new Error("Graph watch lifecycle pid must be positive"); + } + validateNonEmptyString(lifecycle.startedAt, "Graph watch lifecycle startedAt"); + validateNonEmptyString(lifecycle.updatedAt, "Graph watch lifecycle updatedAt"); + validateNonEmptyString(lifecycle.pidPath, "Graph watch lifecycle pidPath"); + validateNonEmptyString(lifecycle.statePath, "Graph watch lifecycle statePath"); + validateNonEmptyString(lifecycle.logPath, "Graph watch lifecycle logPath"); + if (typeof lifecycle.pollIntervalMs !== "number" || lifecycle.pollIntervalMs < 1) { + throw new Error("Graph watch lifecycle pollIntervalMs must be positive"); + } + if ( + typeof lifecycle.idleTimeoutMs !== "number" || + !Number.isFinite(lifecycle.idleTimeoutMs) || + lifecycle.idleTimeoutMs < 0 + ) { + throw new Error("Graph watch lifecycle idleTimeoutMs must be a non-negative number"); + } + validateOptional(lifecycle.watchPaths, (paths) => { + validateStringArray(paths, "Graph watch lifecycle watchPaths", { allowEmpty: true }); + for (const path of paths) validateRepoRelativePath(path); + }); + validateOptional(lifecycle.message, (value) => validateNonEmptyString(value, "Graph watch lifecycle message")); + return lifecycle; +} + +export { validateGraphWatchLifecycle }; + +function validateGraphWalCheckpointSummary(summary: GraphWalCheckpointSummary): GraphWalCheckpointSummary { + validateNonEmptyString(summary.walPath, "Graph WAL checkpoint walPath"); + for (const key of ["bytesBefore", "bytesAfter", "budgetBytes"] as const) { + if (typeof summary[key] !== "number" || summary[key] < 0) { + throw new Error(`Graph WAL checkpoint ${key} must be non-negative`); + } + } + if (typeof summary.checkpointed !== "boolean") { + throw new Error("Graph WAL checkpoint checkpointed must be boolean"); + } + return summary; +} + +export { validateGraphWalCheckpointSummary }; + +function validateGraphProviderArtifactMetadata(metadata: GraphProviderArtifactMetadata): GraphProviderArtifactMetadata { + validateRequiredObject(metadata, "Graph provider artifact metadata is required"); + for (const key of [ + "artifactName", + "artifactVersion", + "targetPlatform", + "binaryPath", + "checksumPath", + "checksumSha256", + "buildProfile", + ] as const) { + validateNonEmptyString(metadata[key], `Graph provider artifact metadata ${key}`); + } + for (const key of ["binaryPath", "checksumPath"] as const) { + validateRepoRelativePath(metadata[key]); + if (metadata[key].startsWith("packages/") || metadata[key].startsWith("../")) { + throw new Error(`Graph provider artifact metadata ${key} must be package-relative`); + } + } + return metadata; +} + +export { validateGraphProviderArtifactMetadata }; + +function validateGraphExtractionDiagnostics( + diagnostics: readonly GraphExtractionDiagnostic[], +): readonly GraphExtractionDiagnostic[] { + if (!Array.isArray(diagnostics)) { + throw new Error("Graph extraction diagnostics must be an array"); + } + for (const diagnostic of diagnostics) validateGraphExtractionDiagnostic(diagnostic); + return diagnostics; +} + +export { validateGraphExtractionDiagnostics }; + +function validateGraphExtractionDiagnostic(diagnostic: GraphExtractionDiagnostic): GraphExtractionDiagnostic { + validateRequiredObject(diagnostic, "Graph extraction diagnostic is required"); + if (!includesString(graphExtractionDiagnosticCategories, diagnostic.category)) { + throw new Error(`Unknown graph extraction diagnostic category: ${String(diagnostic.category)}`); + } + if (!includesString(["info", "warning", "error"] as const, diagnostic.severity)) { + throw new Error(`Unknown graph extraction diagnostic severity: ${String(diagnostic.severity)}`); + } + validateNonEmptyString(diagnostic.message, "Graph extraction diagnostic message"); + if (diagnostic.path !== undefined) validateRepoRelativePath(diagnostic.path); + if (diagnostic.language !== undefined) { + validateNonEmptyString(diagnostic.language, "Graph extraction diagnostic language"); + } + return diagnostic; +} + +export { validateGraphExtractionDiagnostic }; + +function validateProviderFailure(failure: ProviderFailure): ProviderFailure { + validateRequiredObject(failure, "Provider failure is required"); + if (!includesString(providerFailureCategories, failure.category)) { + throw new Error(`Unknown provider failure category: ${String(failure.category)}`); + } + validateNonEmptyString(failure.message, "Provider failure message"); + if (failure.retryable !== undefined && typeof failure.retryable !== "boolean") { + throw new Error("Provider failure retryable must be boolean"); + } + if (failure.cause !== undefined) validateNonEmptyString(failure.cause, "Provider failure cause"); + return failure; +} + +export { validateProviderFailure }; diff --git a/packages/contracts/src/graph/provider-contracts-01.ts b/packages/contracts/src/graph/provider-contracts-01.ts new file mode 100644 index 0000000..8be8f20 --- /dev/null +++ b/packages/contracts/src/graph/provider-contracts-01.ts @@ -0,0 +1,162 @@ +import type { GraphDaemonOperation, GraphWalCheckpointSummary, GraphWatchLifecycle } from "./pipeline-contracts.js"; +import type { GraphProviderQueryKind } from "./query-contracts-01.js"; +import type { + GraphEdgeKind, + GraphNodeKind, + GraphProviderErrorFailureCategory, + GraphProviderMode, + GraphProviderStatusState, + ProviderFailureCategory, +} from "./vocabulary-01.js"; +import type { GraphExtractionDiagnosticCategory } from "./vocabulary-02.js"; + +interface RepoIdentity { + repoId?: string; + repoRoot?: string; + remoteUrl?: string; + commitSha?: string; +} + +export type { RepoIdentity }; + +interface GraphFreshness { + generatedAt: string; + ageMs: number; + maxAgeMs?: number; + stale: boolean; + reason?: string; +} + +export type { GraphFreshness }; + +interface GraphProviderArtifactMetadata { + artifactName: "opcore-graph-core" | (string & {}); + artifactVersion: string; + targetPlatform: string; + binaryPath: string; + checksumPath: string; + checksumSha256: string; + buildProfile: string; +} + +export type { GraphProviderArtifactMetadata }; + +interface GraphProviderCapabilityHandshake { + provider: "opcore-graph" | (string & {}); + graphSchemaVersion: number; + artifactName: "opcore-graph-core" | (string & {}); + artifactVersion: string; + targetPlatform: string; + supportedOperations: readonly GraphDaemonOperation[]; + nodeKinds: readonly GraphNodeKind[]; + edgeKinds: readonly GraphEdgeKind[]; + queryKinds: readonly GraphProviderQueryKind[]; + artifact: GraphProviderArtifactMetadata; +} + +export type { GraphProviderCapabilityHandshake }; + +interface ProviderFailure { + category: ProviderFailureCategory; + message: string; + retryable?: boolean; + cause?: string; +} + +export type { ProviderFailure }; + +type ProviderFailureWithCategory = ProviderFailure & { category: Category }; + +export type { ProviderFailureWithCategory }; + +interface GraphExtractionDiagnostic { + category: GraphExtractionDiagnosticCategory; + severity: "info" | "warning" | "error"; + message: string; + path?: string; + language?: string; +} + +export type { GraphExtractionDiagnostic }; + +interface GraphProviderStatusBase { + state: GraphProviderStatusState; + mode: GraphProviderMode; + provider: string; + schemaVersion: number; + message?: string; +} + +export type { GraphProviderStatusBase }; + +interface GraphProviderAvailableStatus extends GraphProviderStatusBase { + state: "available"; + repo: RepoIdentity; + freshness: GraphFreshness; + dbPath?: string; + nodes_by_kind: Readonly>; + edges_by_kind: Readonly>; + capabilities?: readonly string[]; + handshake?: GraphProviderCapabilityHandshake; + walCheckpoint?: GraphWalCheckpointSummary; +} + +export type { GraphProviderAvailableStatus }; + +interface GraphProviderWarmingStatus extends GraphProviderStatusBase { + state: "warming"; + repo: RepoIdentity; + freshness: GraphFreshness; + lifecycle?: GraphWatchLifecycle; +} + +export type { GraphProviderWarmingStatus }; + +interface GraphProviderSkippedStatus extends GraphProviderStatusBase { + state: "skipped"; + mode: "optional"; + failure: ProviderFailureWithCategory<"provider_missing">; +} + +export type { GraphProviderSkippedStatus }; + +interface GraphProviderRequiredMissingStatus extends GraphProviderStatusBase { + state: "required_missing"; + mode: "required"; + failure: ProviderFailureWithCategory<"provider_missing">; +} + +export type { GraphProviderRequiredMissingStatus }; + +interface GraphProviderStaleStatus extends GraphProviderStatusBase { + state: "stale"; + repo: RepoIdentity; + freshness: GraphFreshness; + failure: ProviderFailureWithCategory<"stale_snapshot">; +} + +export type { GraphProviderStaleStatus }; + +interface GraphProviderSchemaMismatchStatus extends GraphProviderStatusBase { + state: "schema_mismatch"; + expectedSchemaVersion: number; + actualSchemaVersion: number; + failure: ProviderFailureWithCategory<"schema_mismatch">; +} + +export type { GraphProviderSchemaMismatchStatus }; + +interface GraphProviderDaemonUnavailableStatus extends GraphProviderStatusBase { + state: "daemon_unavailable"; + failure: ProviderFailureWithCategory<"daemon_unavailable">; +} + +export type { GraphProviderDaemonUnavailableStatus }; + +interface GraphProviderErrorStatus extends GraphProviderStatusBase { + state: "error"; + failure: ProviderFailureWithCategory; + diagnostics?: readonly GraphExtractionDiagnostic[]; +} + +export type { GraphProviderErrorStatus }; diff --git a/packages/contracts/src/graph/provider-contracts-02.ts b/packages/contracts/src/graph/provider-contracts-02.ts new file mode 100644 index 0000000..ef681cf --- /dev/null +++ b/packages/contracts/src/graph/provider-contracts-02.ts @@ -0,0 +1,69 @@ +import type { JsonValue } from "../shared/json.js"; +import type { + GraphFreshness, + GraphProviderAvailableStatus, + GraphProviderDaemonUnavailableStatus, + GraphProviderErrorStatus, + GraphProviderRequiredMissingStatus, + GraphProviderSchemaMismatchStatus, + GraphProviderSkippedStatus, + GraphProviderStaleStatus, + GraphProviderWarmingStatus, + RepoIdentity, +} from "./provider-contracts-01.js"; +import type { GraphEdgeKind, GraphNodeKind } from "./vocabulary-01.js"; + +type GraphProviderStatus = + | GraphProviderAvailableStatus + | GraphProviderWarmingStatus + | GraphProviderSkippedStatus + | GraphProviderRequiredMissingStatus + | GraphProviderStaleStatus + | GraphProviderSchemaMismatchStatus + | GraphProviderDaemonUnavailableStatus + | GraphProviderErrorStatus; + +export type { GraphProviderStatus }; + +type GraphProviderFailureStatus = Exclude< + GraphProviderStatus, + GraphProviderAvailableStatus | GraphProviderWarmingStatus +>; + +export type { GraphProviderFailureStatus }; + +type GraphProviderNonAvailableStatus = Exclude; + +export type { GraphProviderNonAvailableStatus }; + +interface GraphFactNode { + id: string; + kind: GraphNodeKind; + path?: string; + name?: string; + attributes?: Record; +} + +export type { GraphFactNode }; + +interface GraphFactEdge { + id?: string; + kind: GraphEdgeKind; + from: string; + to: string; + attributes?: Record; +} + +export type { GraphFactEdge }; + +interface GraphSnapshotMetadata { + schemaVersion: number; + provider: string; + repo: RepoIdentity; + generatedAt: string; + freshness: GraphFreshness; + nodeKinds: readonly GraphNodeKind[]; + edgeKinds: readonly GraphEdgeKind[]; +} + +export type { GraphSnapshotMetadata }; diff --git a/packages/contracts/src/graph/provider-validators.ts b/packages/contracts/src/graph/provider-validators.ts new file mode 100644 index 0000000..88d45f8 --- /dev/null +++ b/packages/contracts/src/graph/provider-validators.ts @@ -0,0 +1,159 @@ +import { validateOptional, validateRequiredObject } from "../shared/validators-02.js"; +import { includesString } from "../shared/primitives.js"; +import { validateRepoIdentity } from "../shared/path-validators.js"; +import { validateGraphFreshness, validateNonEmptyString, validateStringArray } from "../shared/validators-01.js"; +import { validateGraphDaemonOperation, validateGraphProviderQueryKind } from "./helper-validators.js"; +import { + validateGraphExtractionDiagnostics, + validateGraphProviderArtifactMetadata, + validateGraphWalCheckpointSummary, + validateGraphWatchLifecycle, +} from "./protocol-validators.js"; +import type { GraphProviderCapabilityHandshake } from "./provider-contracts-01.js"; +import type { GraphProviderFailureStatus, GraphProviderStatus } from "./provider-contracts-02.js"; +import { + graphProviderFailureCategoriesByState, + graphProviderModes, + graphProviderStatusStates, + providerFailureCategories, +} from "./vocabulary-01.js"; + +function validateProviderStatus(status: GraphProviderStatus): GraphProviderStatus { + validateRequiredObject(status, "Graph provider status is required"); + validateProviderStatusHeader(status); + if (status.state === "available") return validateAvailableProviderStatus(status); + if (status.state === "warming") return validateWarmingProviderStatus(status); + validateProviderFailureStatus(status); + return status; +} + +export { validateProviderStatus }; + +function validateProviderStatusHeader(status: GraphProviderStatus): void { + if (!includesString(graphProviderStatusStates, status.state)) { + throw new Error(`Unknown graph provider status state: ${String(status.state)}`); + } + if (!includesString(graphProviderModes, status.mode)) { + throw new Error(`Unknown graph provider mode: ${String(status.mode)}`); + } + if (typeof status.provider !== "string" || status.provider.length === 0) { + throw new Error("Graph provider status must include provider"); + } + if (typeof status.schemaVersion !== "number") { + throw new Error("Graph provider status must include numeric schemaVersion"); + } + if (status.state === "skipped" && status.mode !== "optional") { + throw new Error("Skipped graph provider status must use optional mode"); + } + if (status.state === "required_missing" && status.mode !== "required") { + throw new Error("Required-missing graph provider status must use required mode"); + } +} + +function validateAvailableProviderStatus( + status: Extract, +): GraphProviderStatus { + validateRepoIdentity(status.repo); + validateGraphFreshness(status.freshness, "Available"); + validateGraphKindCounts(status.nodes_by_kind, "nodes_by_kind"); + validateGraphKindCounts(status.edges_by_kind, "edges_by_kind"); + validateOptional(status.handshake, validateGraphProviderCapabilityHandshake); + validateOptional(status.walCheckpoint, validateGraphWalCheckpointSummary); + return status; +} + +function validateWarmingProviderStatus( + status: Extract, +): GraphProviderStatus { + validateRepoIdentity(status.repo); + validateGraphFreshness(status.freshness, "Warming"); + validateOptional(status.lifecycle, validateGraphWatchLifecycle); + return status; +} + +function validateGraphKindCounts(counts: Readonly>, label: string): void { + if (!counts || typeof counts !== "object" || Array.isArray(counts)) { + throw new Error(`Graph provider status ${label} must be an object`); + } + for (const [kind, count] of Object.entries(counts)) { + if (kind.length === 0) throw new Error(`Graph provider status ${label} kind must not be empty`); + if (!Number.isInteger(count) || count < 0) { + throw new Error(`Graph provider status ${label}.${kind} must be a non-negative integer`); + } + } +} + +export { validateGraphKindCounts }; + +function validateProviderFailureStatus(status: GraphProviderFailureStatus): void { + if (!status.failure?.category) { + throw new Error(`Graph provider ${status.state} status must include failure.category`); + } + if (!includesString(providerFailureCategories, status.failure.category)) { + throw new Error(`Unknown graph provider failure category: ${status.failure.category}`); + } + const allowedCategories = graphProviderFailureCategoriesByState[status.state]; + if (!includesString(allowedCategories, status.failure.category)) { + throw new Error( + `Graph provider ${status.state} failure category must be one of ${allowedCategories.join(", ")}; ` + + `got ${status.failure.category}`, + ); + } + validateStaleProviderStatus(status); + validateSchemaMismatchProviderStatus(status); + validateErrorProviderStatus(status); +} + +export { validateProviderFailureStatus }; + +function validateStaleProviderStatus(status: GraphProviderFailureStatus): void { + if (status.state !== "stale") return; + if (!status.repo) throw new Error("Stale graph provider status must include repo"); + validateRepoIdentity(status.repo); + validateGraphFreshness(status.freshness, "Stale"); +} + +function validateSchemaMismatchProviderStatus(status: GraphProviderFailureStatus): void { + if (status.state !== "schema_mismatch") return; + if (typeof status.expectedSchemaVersion !== "number") { + throw new Error("Schema-mismatch graph provider status must include expectedSchemaVersion"); + } + if (typeof status.actualSchemaVersion !== "number") { + throw new Error("Schema-mismatch graph provider status must include actualSchemaVersion"); + } +} + +function validateErrorProviderStatus(status: GraphProviderFailureStatus): void { + if (status.state === "error") validateOptional(status.diagnostics, validateGraphExtractionDiagnostics); +} + +function validateGraphProviderCapabilityHandshake( + handshake: GraphProviderCapabilityHandshake, +): GraphProviderCapabilityHandshake { + validateRequiredObject(handshake, "Graph provider capability handshake is required"); + validateNonEmptyString(handshake.provider, "Graph provider capability handshake provider"); + if (typeof handshake.graphSchemaVersion !== "number") { + throw new Error("Graph provider capability handshake graphSchemaVersion must be numeric"); + } + validateNonEmptyString(handshake.artifactName, "Graph provider capability handshake artifactName"); + validateNonEmptyString(handshake.artifactVersion, "Graph provider capability handshake artifactVersion"); + validateNonEmptyString(handshake.targetPlatform, "Graph provider capability handshake targetPlatform"); + validateStringArray(handshake.supportedOperations, "Graph provider capability handshake supportedOperations", { + allowEmpty: false, + }); + for (const operation of handshake.supportedOperations) validateGraphDaemonOperation(operation); + validateStringArray(handshake.nodeKinds, "Graph provider capability handshake nodeKinds", { allowEmpty: false }); + validateStringArray(handshake.edgeKinds, "Graph provider capability handshake edgeKinds", { allowEmpty: false }); + validateStringArray(handshake.queryKinds, "Graph provider capability handshake queryKinds", { allowEmpty: false }); + for (const queryKind of handshake.queryKinds) validateGraphProviderQueryKind(queryKind); + validateGraphProviderArtifactMetadata(handshake.artifact); + if (handshake.artifact.artifactName !== handshake.artifactName) { + throw new Error("Graph provider capability handshake artifactName must match artifact metadata"); + } + if (handshake.artifact.targetPlatform !== handshake.targetPlatform) { + throw new Error("Graph provider capability handshake targetPlatform must match artifact metadata"); + } + return handshake; +} + +export { validateGraphProviderCapabilityHandshake }; diff --git a/packages/contracts/src/graph/query-contracts-01.ts b/packages/contracts/src/graph/query-contracts-01.ts new file mode 100644 index 0000000..f26f3ad --- /dev/null +++ b/packages/contracts/src/graph/query-contracts-01.ts @@ -0,0 +1,157 @@ +import type { GraphExtractionDiagnostic, GraphProviderAvailableStatus, RepoIdentity } from "./provider-contracts-01.js"; +import type { + GraphFactEdge, + GraphFactNode, + GraphProviderFailureStatus, + GraphSnapshotMetadata, +} from "./provider-contracts-02.js"; +import type { GraphEdgeKind, GraphNodeKind, GraphProviderMode } from "./vocabulary-01.js"; + +interface GraphFactQuerySelector { + kind: "nodes" | "edges" | "neighbors" | "symbols" | "impact"; + nodeKinds?: readonly GraphNodeKind[]; + edgeKinds?: readonly GraphEdgeKind[]; + ids?: readonly string[]; + text?: string; + limit?: number; +} + +export type { GraphFactQuerySelector }; + +const graphFactQueryKinds = ["nodes", "edges", "neighbors", "symbols", "impact"] as const; + +export { graphFactQueryKinds }; + +const graphNamedQueryKinds = [ + "callers_of", + "callees_of", + "importers_of", + "imports_of", + "tests_for", + "inheritors_of", + "children_of", + "file_summary", +] as const; + +export { graphNamedQueryKinds }; + +type GraphNamedQueryKind = (typeof graphNamedQueryKinds)[number]; + +export type { GraphNamedQueryKind }; + +type GraphProviderQueryKind = + | GraphFactQuerySelector["kind"] + | GraphNamedQueryKind + | "review_context" + | "detect_changes" + | "search"; + +export type { GraphProviderQueryKind }; + +interface GraphFactQueryRequest { + requestId?: string; + repo: RepoIdentity; + schemaVersion: number; + mode: GraphProviderMode; + selector: GraphFactQuerySelector; +} + +export type { GraphFactQueryRequest }; + +interface GraphFactQueryAvailableResult { + requestId?: string; + status: GraphProviderAvailableStatus; + metadata: GraphSnapshotMetadata; + nodes: readonly GraphFactNode[]; + edges: readonly GraphFactEdge[]; + diagnostics?: readonly GraphExtractionDiagnostic[]; +} + +export type { GraphFactQueryAvailableResult }; + +interface GraphFactQueryFailureResult { + requestId?: string; + status: GraphProviderFailureStatus; +} + +export type { GraphFactQueryFailureResult }; + +type GraphFactQueryResult = GraphFactQueryAvailableResult | GraphFactQueryFailureResult; + +export type { GraphFactQueryResult }; + +interface GraphTraversalMetadata { + maxDepth: number; + truncated: boolean; + total: number; + empty: boolean; +} + +export type { GraphTraversalMetadata }; + +interface GraphNamedQueryRequest { + requestId?: string; + repo: RepoIdentity; + schemaVersion: number; + mode: GraphProviderMode; + queryKind: GraphNamedQueryKind; + target: string; + maxDepth?: number; + limit?: number; +} + +export type { GraphNamedQueryRequest }; + +interface GraphNamedQueryAvailableResult { + requestId?: string; + status: GraphProviderAvailableStatus; + metadata: GraphSnapshotMetadata; + queryKind: GraphNamedQueryKind; + target: string; + nodes: readonly GraphFactNode[]; + edges: readonly GraphFactEdge[]; + traversal: GraphTraversalMetadata; + diagnostics?: readonly GraphExtractionDiagnostic[]; +} + +export type { GraphNamedQueryAvailableResult }; + +interface GraphNamedQueryFailureResult { + requestId?: string; + status: GraphProviderFailureStatus; +} + +export type { GraphNamedQueryFailureResult }; + +type GraphNamedQueryResult = GraphNamedQueryAvailableResult | GraphNamedQueryFailureResult; + +export type { GraphNamedQueryResult }; + +interface GraphImpactRequest { + requestId?: string; + repo: RepoIdentity; + schemaVersion: number; + mode: GraphProviderMode; + files: readonly string[]; + baseRef?: string; + maxDepth?: number; + limit?: number; +} + +export type { GraphImpactRequest }; + +interface GraphImpactAvailableResult { + requestId?: string; + status: GraphProviderAvailableStatus; + metadata: GraphSnapshotMetadata; + changedFiles: readonly string[]; + impactedFiles: readonly string[]; + impactedSymbols: readonly string[]; + tests: readonly string[]; + nodes: readonly GraphFactNode[]; + edges: readonly GraphFactEdge[]; + traversal: GraphTraversalMetadata; + diagnostics?: readonly GraphExtractionDiagnostic[]; +} + +export type { GraphImpactAvailableResult }; diff --git a/packages/contracts/src/graph/query-contracts-02.ts b/packages/contracts/src/graph/query-contracts-02.ts new file mode 100644 index 0000000..98fc90b --- /dev/null +++ b/packages/contracts/src/graph/query-contracts-02.ts @@ -0,0 +1,105 @@ +import type { GraphExtractionDiagnostic, GraphProviderAvailableStatus, RepoIdentity } from "./provider-contracts-01.js"; +import type { + GraphFactEdge, + GraphFactNode, + GraphProviderFailureStatus, + GraphSnapshotMetadata, +} from "./provider-contracts-02.js"; +import type { GraphImpactAvailableResult, GraphTraversalMetadata } from "./query-contracts-01.js"; +import type { GraphProviderMode } from "./vocabulary-01.js"; + +interface GraphImpactFailureResult { + requestId?: string; + status: GraphProviderFailureStatus; +} + +export type { GraphImpactFailureResult }; + +type GraphImpactResult = GraphImpactAvailableResult | GraphImpactFailureResult; + +export type { GraphImpactResult }; + +interface GraphRenamedFile { + fromPath: string; + toPath: string; + checksumBefore?: string; + checksumAfter?: string; +} + +export type { GraphRenamedFile }; + +interface GraphDetectChangesRequest { + requestId?: string; + repo: RepoIdentity; + schemaVersion: number; + mode: GraphProviderMode; + files?: readonly string[]; + baseRef?: string; +} + +export type { GraphDetectChangesRequest }; + +interface GraphDetectChangesAvailableResult { + requestId?: string; + status: GraphProviderAvailableStatus; + metadata: GraphSnapshotMetadata; + changedFiles: readonly string[]; + deletedFiles: readonly string[]; + renamedFiles: readonly GraphRenamedFile[]; + diagnostics?: readonly GraphExtractionDiagnostic[]; +} + +export type { GraphDetectChangesAvailableResult }; + +interface GraphDetectChangesFailureResult { + requestId?: string; + status: GraphProviderFailureStatus; +} + +export type { GraphDetectChangesFailureResult }; + +type GraphDetectChangesResult = GraphDetectChangesAvailableResult | GraphDetectChangesFailureResult; + +export type { GraphDetectChangesResult }; + +interface GraphReviewContextRequest { + requestId?: string; + repo: RepoIdentity; + schemaVersion: number; + mode: GraphProviderMode; + files?: readonly string[]; + baseRef?: string; + maxDepth?: number; + limit?: number; +} + +export type { GraphReviewContextRequest }; + +interface GraphReviewContextAvailableResult { + requestId?: string; + status: GraphProviderAvailableStatus; + metadata: GraphSnapshotMetadata; + changedFiles: readonly string[]; + deletedFiles: readonly string[]; + renamedFiles: readonly GraphRenamedFile[]; + impactedFiles: readonly string[]; + impactedSymbols: readonly string[]; + tests: readonly string[]; + nodes: readonly GraphFactNode[]; + edges: readonly GraphFactEdge[]; + traversal: GraphTraversalMetadata; + diagnostics?: readonly GraphExtractionDiagnostic[]; +} + +export type { GraphReviewContextAvailableResult }; + +interface GraphReviewContextFailureResult { + requestId?: string; + status: GraphProviderFailureStatus; +} + +export type { GraphReviewContextFailureResult }; + +type GraphReviewContextResult = GraphReviewContextAvailableResult | GraphReviewContextFailureResult; + +export type { GraphReviewContextResult }; diff --git a/packages/contracts/src/graph/query-validators.ts b/packages/contracts/src/graph/query-validators.ts new file mode 100644 index 0000000..f22ec37 --- /dev/null +++ b/packages/contracts/src/graph/query-validators.ts @@ -0,0 +1,231 @@ +import { validateOptional, validateRequiredObject } from "../shared/validators-02.js"; +import { includesString } from "../shared/primitives.js"; +import { + validateRepoIdentity, + validateRepoRelativePath, + validateRepoRelativePaths, +} from "../shared/path-validators.js"; +import { validateNonEmptyString, validateStringArray } from "../shared/validators-01.js"; +import { validateGraphSnapshotMetadata } from "../shared/validators-02.js"; +import { + validateGraphFactQueryKind, + validateGraphNamedQueryKind, + validateGraphQueryRequestBase, + validateGraphTraversalMetadata, + validateTraversalOptions, +} from "./helper-validators.js"; +import { + validateGraphFactEdge, + validateGraphFactNode, + validateGraphPayloadResult, + validateRenamedFiles, +} from "./payload-validators.js"; +import { validateGraphExtractionDiagnostics } from "./protocol-validators.js"; +import type { GraphExtractionDiagnostic } from "./provider-contracts-01.js"; +import type { GraphFactEdge, GraphFactNode, GraphSnapshotMetadata } from "./provider-contracts-02.js"; +import { validateProviderStatus } from "./provider-validators.js"; +import type { + GraphFactQueryRequest, + GraphFactQueryResult, + GraphImpactRequest, + GraphNamedQueryRequest, + GraphNamedQueryResult, + GraphTraversalMetadata, +} from "./query-contracts-01.js"; +import type { + GraphDetectChangesRequest, + GraphDetectChangesResult, + GraphImpactResult, + GraphRenamedFile, + GraphReviewContextRequest, + GraphReviewContextResult, +} from "./query-contracts-02.js"; +import { GRAPH_SCHEMA_VERSION, graphProviderModes } from "./vocabulary-01.js"; + +function validateGraphFactQueryRequest(request: GraphFactQueryRequest): GraphFactQueryRequest { + validateRequiredObject(request, "Graph fact query request is required"); + if (request.requestId !== undefined) validateNonEmptyString(request.requestId, "Graph fact query request requestId"); + validateRepoIdentity(request.repo); + if (request.schemaVersion !== GRAPH_SCHEMA_VERSION) { + throw new Error(`Graph fact query request schemaVersion must be ${GRAPH_SCHEMA_VERSION}`); + } + if (!includesString(graphProviderModes, request.mode)) { + throw new Error(`Unknown graph fact query request mode: ${String(request.mode)}`); + } + validateRequiredObject(request.selector, "Graph fact query request selector is required"); + validateGraphFactQueryKind(request.selector.kind); + if (request.selector.nodeKinds !== undefined) { + validateStringArray(request.selector.nodeKinds, "Graph fact query selector nodeKinds", { allowEmpty: true }); + } + if (request.selector.edgeKinds !== undefined) { + validateStringArray(request.selector.edgeKinds, "Graph fact query selector edgeKinds", { allowEmpty: true }); + } + if (request.selector.ids !== undefined) { + validateStringArray(request.selector.ids, "Graph fact query selector ids", { + allowEmpty: true, + }); + } + if ( + request.selector.limit !== undefined && + (typeof request.selector.limit !== "number" || request.selector.limit < 1) + ) { + throw new Error("Graph fact query selector limit must be a positive number"); + } + return request; +} + +export { validateGraphFactQueryRequest }; + +function validateGraphFactQueryResult(result: GraphFactQueryResult): GraphFactQueryResult { + validateRequiredObject(result, "Graph fact query result is required"); + validateOptional(result.requestId, (value) => validateNonEmptyString(value, "Graph fact query result requestId")); + const status = validateProviderStatus(result.status); + const payload = result as { + metadata?: unknown; + nodes?: unknown; + edges?: unknown; + diagnostics?: unknown; + }; + if (status.state !== "available") { + validateUnavailableGraphFactQueryPayload(payload, status.state); + return result; + } + validateAvailableGraphFactQueryPayload(payload); + return result; +} + +export { validateGraphFactQueryResult }; + +interface GraphFactQueryPayload { + metadata?: unknown; + nodes?: unknown; + edges?: unknown; + diagnostics?: unknown; +} + +function validateUnavailableGraphFactQueryPayload( + payload: GraphFactQueryPayload, + state: string, +): void { + const hasGraphData = + Object.hasOwn(payload, "metadata") || Object.hasOwn(payload, "nodes") || Object.hasOwn(payload, "edges"); + if (hasGraphData) throw new Error(`Graph query ${state} result must not include graph data`); +} + +function validateAvailableGraphFactQueryPayload(payload: GraphFactQueryPayload): void { + if (!payload.metadata || !Array.isArray(payload.nodes) || !Array.isArray(payload.edges)) { + throw new Error("Available graph query result must include metadata, nodes, and edges"); + } + validateGraphSnapshotMetadata(payload.metadata as GraphSnapshotMetadata); + for (const node of payload.nodes) validateGraphFactNode(node as GraphFactNode); + for (const edge of payload.edges) validateGraphFactEdge(edge as GraphFactEdge); + validateOptional(payload.diagnostics, (value) => + validateGraphExtractionDiagnostics(value as readonly GraphExtractionDiagnostic[]), + ); +} + +function validateGraphNamedQueryRequest(request: GraphNamedQueryRequest): GraphNamedQueryRequest { + validateGraphQueryRequestBase(request, "Graph named query request"); + validateGraphNamedQueryKind(request.queryKind); + validateNonEmptyString(request.target, "Graph named query request target"); + validateTraversalOptions(request.maxDepth, request.limit, "Graph named query request"); + return request; +} + +export { validateGraphNamedQueryRequest }; + +function validateGraphNamedQueryResult(result: GraphNamedQueryResult): GraphNamedQueryResult { + validateGraphPayloadResult(result, "Graph named query result", (payload) => { + validateGraphSnapshotMetadata(payload.metadata as GraphSnapshotMetadata); + validateGraphNamedQueryKind(payload.queryKind); + validateNonEmptyString(payload.target, "Graph named query result target"); + for (const node of payload.nodes as readonly GraphFactNode[]) validateGraphFactNode(node); + for (const edge of payload.edges as readonly GraphFactEdge[]) validateGraphFactEdge(edge); + validateGraphTraversalMetadata(payload.traversal as GraphTraversalMetadata); + }); + return result; +} + +export { validateGraphNamedQueryResult }; + +function validateGraphImpactRequest(request: GraphImpactRequest): GraphImpactRequest { + validateGraphQueryRequestBase(request, "Graph impact request"); + validateStringArray(request.files, "Graph impact request files", { + allowEmpty: false, + }); + for (const file of request.files) validateRepoRelativePath(file); + if (request.baseRef !== undefined) validateNonEmptyString(request.baseRef, "Graph impact request baseRef"); + validateTraversalOptions(request.maxDepth, request.limit, "Graph impact request"); + return request; +} + +export { validateGraphImpactRequest }; + +function validateGraphImpactResult(result: GraphImpactResult): GraphImpactResult { + validateGraphPayloadResult(result, "Graph impact result", (payload) => { + validateGraphSnapshotMetadata(payload.metadata as GraphSnapshotMetadata); + validateRepoRelativePaths(payload.changedFiles, "Graph impact result changedFiles"); + validateRepoRelativePaths(payload.impactedFiles, "Graph impact result impactedFiles"); + validateStringArray(payload.impactedSymbols as readonly string[], "Graph impact result impactedSymbols", { + allowEmpty: true, + }); + validateRepoRelativePaths(payload.tests, "Graph impact result tests"); + for (const node of payload.nodes as readonly GraphFactNode[]) validateGraphFactNode(node); + for (const edge of payload.edges as readonly GraphFactEdge[]) validateGraphFactEdge(edge); + validateGraphTraversalMetadata(payload.traversal as GraphTraversalMetadata); + }); + return result; +} + +export { validateGraphImpactResult }; + +function validateGraphDetectChangesRequest(request: GraphDetectChangesRequest): GraphDetectChangesRequest { + validateGraphQueryRequestBase(request, "Graph detect-changes request"); + if (request.files !== undefined) validateRepoRelativePaths(request.files, "Graph detect-changes request files"); + if (request.baseRef !== undefined) validateNonEmptyString(request.baseRef, "Graph detect-changes request baseRef"); + return request; +} + +export { validateGraphDetectChangesRequest }; + +function validateGraphDetectChangesResult(result: GraphDetectChangesResult): GraphDetectChangesResult { + validateGraphPayloadResult(result, "Graph detect-changes result", (payload) => { + validateGraphSnapshotMetadata(payload.metadata as GraphSnapshotMetadata); + validateRepoRelativePaths(payload.changedFiles, "Graph detect-changes result changedFiles"); + validateRepoRelativePaths(payload.deletedFiles, "Graph detect-changes result deletedFiles"); + validateRenamedFiles(payload.renamedFiles as readonly GraphRenamedFile[]); + }); + return result; +} + +export { validateGraphDetectChangesResult }; + +function validateGraphReviewContextRequest(request: GraphReviewContextRequest): GraphReviewContextRequest { + validateGraphQueryRequestBase(request, "Graph review-context request"); + if (request.files !== undefined) validateRepoRelativePaths(request.files, "Graph review-context request files"); + if (request.baseRef !== undefined) validateNonEmptyString(request.baseRef, "Graph review-context request baseRef"); + validateTraversalOptions(request.maxDepth, request.limit, "Graph review-context request"); + return request; +} + +export { validateGraphReviewContextRequest }; + +function validateGraphReviewContextResult(result: GraphReviewContextResult): GraphReviewContextResult { + validateGraphPayloadResult(result, "Graph review-context result", (payload) => { + validateGraphSnapshotMetadata(payload.metadata as GraphSnapshotMetadata); + validateRepoRelativePaths(payload.changedFiles, "Graph review-context result changedFiles"); + validateRepoRelativePaths(payload.deletedFiles, "Graph review-context result deletedFiles"); + validateRenamedFiles(payload.renamedFiles as readonly GraphRenamedFile[]); + validateRepoRelativePaths(payload.impactedFiles, "Graph review-context result impactedFiles"); + validateStringArray(payload.impactedSymbols as readonly string[], "Graph review-context result impactedSymbols", { + allowEmpty: true, + }); + validateRepoRelativePaths(payload.tests, "Graph review-context result tests"); + for (const node of payload.nodes as readonly GraphFactNode[]) validateGraphFactNode(node); + for (const edge of payload.edges as readonly GraphFactEdge[]) validateGraphFactEdge(edge); + validateGraphTraversalMetadata(payload.traversal as GraphTraversalMetadata); + }); + return result; +} + +export { validateGraphReviewContextResult }; diff --git a/packages/contracts/src/graph/search-contracts.ts b/packages/contracts/src/graph/search-contracts.ts new file mode 100644 index 0000000..17c5fb0 --- /dev/null +++ b/packages/contracts/src/graph/search-contracts.ts @@ -0,0 +1,77 @@ +import type { GraphExtractionDiagnostic, GraphProviderAvailableStatus, RepoIdentity } from "./provider-contracts-01.js"; +import type { GraphProviderNonAvailableStatus, GraphSnapshotMetadata } from "./provider-contracts-02.js"; +import type { GraphNodeKind, GraphProviderMode } from "./vocabulary-01.js"; + +interface GraphSearchRequest { + requestId?: string; + repo: RepoIdentity; + schemaVersion: number; + mode: GraphProviderMode; + query: string; + limit?: number; + files?: readonly string[]; +} + +export type { GraphSearchRequest }; + +interface GraphSearchMode { + engine: "fts5" | (string & {}); + querySyntax: "fts5" | (string & {}); + limit: number; + contextFiles: readonly string[]; +} + +export type { GraphSearchMode }; + +interface GraphSearchResultEntry { + nodeId: string; + kind: GraphNodeKind; + path?: string; + name?: string; + qualifiedName: string; + filePath?: string; + signature: string; + score: number; + rank: number; + matches: readonly string[]; +} + +export type { GraphSearchResultEntry }; + +interface GraphSearchSummary { + query: string; + total: number; + returned: number; + limit: number; + indexedNodeKinds: readonly GraphNodeKind[]; + contextFiles: readonly string[]; +} + +export type { GraphSearchSummary }; + +interface GraphSearchAvailableResult { + requestId?: string; + status: GraphProviderAvailableStatus; + metadata: GraphSnapshotMetadata; + query: string; + searchMode: GraphSearchMode; + summary: GraphSearchSummary; + results: readonly GraphSearchResultEntry[]; + hints: readonly string[]; + diagnostics?: readonly GraphExtractionDiagnostic[]; +} + +export type { GraphSearchAvailableResult }; + +interface GraphSearchFailureResult { + requestId?: string; + status: GraphProviderNonAvailableStatus; + hints?: readonly string[]; + diagnostics?: readonly GraphExtractionDiagnostic[]; +} + +export type { GraphSearchFailureResult }; + +type GraphSearchResult = GraphSearchAvailableResult | GraphSearchFailureResult; + +export type { GraphSearchResult }; diff --git a/packages/contracts/src/graph/search-validators.ts b/packages/contracts/src/graph/search-validators.ts new file mode 100644 index 0000000..ff30742 --- /dev/null +++ b/packages/contracts/src/graph/search-validators.ts @@ -0,0 +1,100 @@ +import { validateNonEmptyString, validateStringArray } from "../shared/validators-01.js"; +import { validateRepoRelativePaths } from "../shared/path-validators.js"; +import { + validateGraphSnapshotMetadata, + validateOptional, + validateRequiredObject, +} from "../shared/validators-02.js"; +import { + validateGraphQueryRequestBase, + validateGraphSearchMode, + validateGraphSearchResultEntry, + validateGraphSearchSummary, +} from "./helper-validators.js"; +import { validateGraphExtractionDiagnostics } from "./protocol-validators.js"; +import type { GraphExtractionDiagnostic } from "./provider-contracts-01.js"; +import type { GraphSnapshotMetadata } from "./provider-contracts-02.js"; +import { validateProviderStatus } from "./provider-validators.js"; +import type { + GraphSearchMode, + GraphSearchRequest, + GraphSearchResult, + GraphSearchResultEntry, + GraphSearchSummary, +} from "./search-contracts.js"; + +function validateGraphSearchRequest(request: GraphSearchRequest): GraphSearchRequest { + validateGraphQueryRequestBase(request, "Graph search request"); + validateNonEmptyString(request.query, "Graph search request query"); + if (request.query.trim().length === 0) throw new Error("Graph search request query must not be empty"); + if (request.limit !== undefined && (!Number.isFinite(request.limit) || request.limit < 1)) { + throw new Error("Graph search request limit must be a positive number"); + } + if (request.files !== undefined) validateRepoRelativePaths(request.files, "Graph search request files"); + return request; +} + +export { validateGraphSearchRequest }; + +function validateGraphSearchResult(result: GraphSearchResult): GraphSearchResult { + validateRequiredObject(result, "Graph search result is required"); + validateOptional(result.requestId, (value) => validateNonEmptyString(value, "Graph search result requestId")); + const status = validateProviderStatus(result.status); + const payload = result as GraphSearchPayload; + if (status.state !== "available") { + validateUnavailableGraphSearchPayload(payload, status.state); + return result; + } + validateAvailableGraphSearchPayload(payload); + return result; +} + +export { validateGraphSearchResult }; + +interface GraphSearchPayload { + metadata?: unknown; + query?: unknown; + searchMode?: unknown; + summary?: unknown; + results?: unknown; + hints?: unknown; + diagnostics?: unknown; +} + +function validateUnavailableGraphSearchPayload( + payload: GraphSearchPayload, + state: GraphSearchResult["status"]["state"], +): void { + const dataFields = ["metadata", "query", "searchMode", "summary", "results"] as const; + if (dataFields.some((field) => Object.hasOwn(payload, field))) { + throw new Error(`Graph search ${state} result must not include search data`); + } + validateGraphSearchOptionalEvidence(payload); +} + +function validateAvailableGraphSearchPayload(payload: GraphSearchPayload): void { + if ( + !payload.metadata || + typeof payload.query !== "string" || + !payload.searchMode || + !payload.summary || + !Array.isArray(payload.results) + ) { + throw new Error("Available graph search result must include metadata, query, searchMode, summary, and results"); + } + validateGraphSnapshotMetadata(payload.metadata as GraphSnapshotMetadata); + validateNonEmptyString(payload.query, "Graph search result query"); + validateGraphSearchMode(payload.searchMode as GraphSearchMode); + validateGraphSearchSummary(payload.summary as GraphSearchSummary); + for (const entry of payload.results as readonly GraphSearchResultEntry[]) validateGraphSearchResultEntry(entry); + validateGraphSearchOptionalEvidence(payload); +} + +function validateGraphSearchOptionalEvidence(payload: GraphSearchPayload): void { + validateOptional(payload.hints, (value) => + validateStringArray(value as readonly string[], "Graph search result hints", { allowEmpty: true }), + ); + validateOptional(payload.diagnostics, (value) => + validateGraphExtractionDiagnostics(value as readonly GraphExtractionDiagnostic[]), + ); +} diff --git a/packages/contracts/src/graph/vocabulary-01.ts b/packages/contracts/src/graph/vocabulary-01.ts new file mode 100644 index 0000000..1d871ee --- /dev/null +++ b/packages/contracts/src/graph/vocabulary-01.ts @@ -0,0 +1,139 @@ +const GRAPH_SCHEMA_VERSION = 1 as const; + +export { GRAPH_SCHEMA_VERSION }; + +const CLONE_PROTOCOL = "opcore.clone.v1" as const; + +export { CLONE_PROTOCOL }; + +const graphProviderModes = ["optional", "required"] as const; + +export { graphProviderModes }; + +type GraphProviderMode = (typeof graphProviderModes)[number]; + +export type { GraphProviderMode }; + +const graphProviderStatusStates = [ + "available", + "warming", + "skipped", + "required_missing", + "stale", + "schema_mismatch", + "daemon_unavailable", + "error", +] as const; + +export { graphProviderStatusStates }; + +type GraphProviderStatusState = (typeof graphProviderStatusStates)[number]; + +export type { GraphProviderStatusState }; + +const requiredGraphNodeKinds = [ + "repo", + "package", + "file", + "symbol", + "test", + "File", + "Module", + "Class", + "Function", + "Variable", + "Type", + "Test", + "Struct", + "Enum", + "Trait", + "Impl", + "Method", + "TypeAlias", + "Const", + "Static", + "Macro", +] as const; + +export { requiredGraphNodeKinds }; + +type GraphNodeKind = (typeof requiredGraphNodeKinds)[number] | (string & {}); + +export type { GraphNodeKind }; + +const requiredGraphEdgeKinds = [ + "CONTAINS", + "DECLARES", + "IMPORTS_FROM", + "CALLS", + "TESTED_BY", + "INHERITS", + "IMPLEMENTS", + "DEPENDS_ON", +] as const; + +export { requiredGraphEdgeKinds }; + +type GraphEdgeKind = (typeof requiredGraphEdgeKinds)[number] | (string & {}); + +export type { GraphEdgeKind }; + +const graphSnapshotMetadataKeys = [ + "schemaVersion", + "provider", + "repo", + "generatedAt", + "freshness", + "nodeKinds", + "edgeKinds", +] as const; + +export { graphSnapshotMetadataKeys }; + +type GraphSnapshotMetadataKey = (typeof graphSnapshotMetadataKeys)[number]; + +export type { GraphSnapshotMetadataKey }; + +const providerFailureCategories = [ + "provider_missing", + "daemon_unavailable", + "schema_mismatch", + "stale_snapshot", + "query_failed", + "incompatible_provider", + "provider_error", + "permission_denied", + "unsupported_mode", + "unknown", +] as const; + +export { providerFailureCategories }; + +type ProviderFailureCategory = (typeof providerFailureCategories)[number]; + +export type { ProviderFailureCategory }; + +const graphProviderFailureCategoriesByState = { + skipped: ["provider_missing"], + required_missing: ["provider_missing"], + stale: ["stale_snapshot"], + schema_mismatch: ["schema_mismatch"], + daemon_unavailable: ["daemon_unavailable"], + error: [ + "query_failed", + "incompatible_provider", + "provider_error", + "permission_denied", + "unsupported_mode", + "unknown", + ], +} as const satisfies Record< + Exclude, + readonly ProviderFailureCategory[] +>; + +export { graphProviderFailureCategoriesByState }; + +type GraphProviderErrorFailureCategory = (typeof graphProviderFailureCategoriesByState.error)[number]; + +export type { GraphProviderErrorFailureCategory }; diff --git a/packages/contracts/src/graph/vocabulary-02.ts b/packages/contracts/src/graph/vocabulary-02.ts new file mode 100644 index 0000000..0eba8f2 --- /dev/null +++ b/packages/contracts/src/graph/vocabulary-02.ts @@ -0,0 +1,18 @@ +const graphExtractionDiagnosticCategories = [ + "missing_tsconfig", + "malformed_tsconfig", + "unsupported_language", + "parse_error", + "missing_parser", + "unresolved_import", + "max_files_exceeded", + "max_depth_exceeded", + "path_traversal", + "io_error", +] as const; + +export { graphExtractionDiagnosticCategories }; + +type GraphExtractionDiagnosticCategory = (typeof graphExtractionDiagnosticCategories)[number]; + +export type { GraphExtractionDiagnosticCategory }; diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 027509b..d3c95d8 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -1,10654 +1,272 @@ -export const GRAPH_SCHEMA_VERSION = 1 as const; -export const CLONE_PROTOCOL = "opcore.clone.v1" as const; - -export const graphProviderModes = ["optional", "required"] as const; -export type GraphProviderMode = (typeof graphProviderModes)[number]; - -export const graphProviderStatusStates = [ - "available", - "warming", - "skipped", - "required_missing", - "stale", - "schema_mismatch", - "daemon_unavailable", - "error" -] as const; -export type GraphProviderStatusState = (typeof graphProviderStatusStates)[number]; - -export const requiredGraphNodeKinds = [ - "repo", - "package", - "file", - "symbol", - "test", - "File", - "Module", - "Class", - "Function", - "Variable", - "Type", - "Test", - "Struct", - "Enum", - "Trait", - "Impl", - "Method", - "TypeAlias", - "Const", - "Static", - "Macro" -] as const; -export type GraphNodeKind = (typeof requiredGraphNodeKinds)[number] | (string & {}); - -export const requiredGraphEdgeKinds = [ - "CONTAINS", - "DECLARES", - "IMPORTS_FROM", - "CALLS", - "TESTED_BY", - "INHERITS", - "IMPLEMENTS", - "DEPENDS_ON" -] as const; -export type GraphEdgeKind = (typeof requiredGraphEdgeKinds)[number] | (string & {}); - -export const graphSnapshotMetadataKeys = [ - "schemaVersion", - "provider", - "repo", - "generatedAt", - "freshness", - "nodeKinds", - "edgeKinds" -] as const; -export type GraphSnapshotMetadataKey = (typeof graphSnapshotMetadataKeys)[number]; - -export const providerFailureCategories = [ - "provider_missing", - "daemon_unavailable", - "schema_mismatch", - "stale_snapshot", - "query_failed", - "incompatible_provider", - "provider_error", - "permission_denied", - "unsupported_mode", - "unknown" -] as const; -export type ProviderFailureCategory = (typeof providerFailureCategories)[number]; - -export const graphProviderFailureCategoriesByState = { - skipped: ["provider_missing"], - required_missing: ["provider_missing"], - stale: ["stale_snapshot"], - schema_mismatch: ["schema_mismatch"], - daemon_unavailable: ["daemon_unavailable"], - error: ["query_failed", "incompatible_provider", "provider_error", "permission_denied", "unsupported_mode", "unknown"] -} as const satisfies Record, readonly ProviderFailureCategory[]>; -export type GraphProviderErrorFailureCategory = (typeof graphProviderFailureCategoriesByState.error)[number]; - -export const graphExtractionDiagnosticCategories = [ - "missing_tsconfig", - "malformed_tsconfig", - "unsupported_language", - "parse_error", - "missing_parser", - "unresolved_import", - "max_files_exceeded", - "max_depth_exceeded", - "path_traversal", - "io_error" -] as const; -export type GraphExtractionDiagnosticCategory = (typeof graphExtractionDiagnosticCategories)[number]; - -export const editRefusalCategories = [ - "absolute_path", - "parent_directory", - "ambiguous_repo_identity", - "validation_failed", - "provider_required_missing", - "schema_mismatch", - "unsafe_edit", - "conflict", - "unsupported_change" -] as const; -export type EditRefusalCategory = (typeof editRefusalCategories)[number]; - -export const validationDiagnosticCategories = [ - "syntax", - "types", - "lint", - "test", - "graph", - "policy", - "provider", - "infrastructure", - "edit_safety" -] as const; -export type ValidationDiagnosticCategory = (typeof validationDiagnosticCategories)[number]; - -export const validationResultStatuses = [ - "passed", - "policy_failure", - "infrastructure_failure", - "provider_failure", - "unsupported_request", - "invalid_payload", - "skipped", - "refused" -] as const; -export type ValidationResultStatus = (typeof validationResultStatuses)[number]; - -export const validationFailureCategories = [ - "policy_failure", - "infrastructure_failure", - "provider_failure", - "unsupported_request", - "invalid_payload", - "skipped" -] as const; -export type ValidationFailureCategory = (typeof validationFailureCategories)[number]; - -export const validationReportModes = ["all", "introduced"] as const; -export type ValidationReportMode = (typeof validationReportModes)[number]; - -export const validationCheckRunStatuses = [ - "passed", - "policy_failure", - "infrastructure_failure", - "provider_failure", - "unsupported_request", - "skipped" -] as const; -export type ValidationCheckRunStatus = (typeof validationCheckRunStatuses)[number]; - -export const validationCheckOutcomes = [ - "passed", - "findings", - "tool_unavailable", - "invalid_config", - "timeout", - "unsupported_target", - "tool_failure" -] as const; -export type ValidationCheckOutcome = (typeof validationCheckOutcomes)[number]; - -export const pythonValidationCapabilityRunStatuses = [...validationCheckOutcomes] as const; -export type PythonValidationCapabilityRunStatus = (typeof pythonValidationCapabilityRunStatuses)[number]; - -export const pythonValidationAuthorities = ["mypy", "pyright"] as const; -export type PythonValidationAuthority = (typeof pythonValidationAuthorities)[number]; - -export const pythonValidationAuthoritySources = ["explicit", "project_config"] as const; -export type PythonValidationAuthoritySource = (typeof pythonValidationAuthoritySources)[number]; - -export const pythonValidationCapabilityTerminationKinds = ["exited", "timeout", "signal", "spawn_error"] as const; -export type PythonValidationCapabilityTerminationKind = (typeof pythonValidationCapabilityTerminationKinds)[number]; - -export const pythonValidationCapabilities = ["types", "ruff_lint", "ruff_format", "pytest"] as const; -export type PythonValidationCapability = (typeof pythonValidationCapabilities)[number]; - -export const pythonValidationCapabilityStates = [ - ...validationCheckOutcomes, - "not_applicable", - "disabled" -] as const; -export type PythonValidationCapabilityState = (typeof pythonValidationCapabilityStates)[number]; - -export const pythonValidationCapabilityTerminations = [ - "exited", - "timeout", - "signal", - "spawn_error", - "overflow" -] as const; -export type PythonValidationCapabilityTermination = (typeof pythonValidationCapabilityTerminations)[number]; - -export const validationSkippedCheckReasons = [ - "graph_unavailable", - "unsupported_scope", - "not_requested", - "no_files", - "provider_failure" -] as const; -export type ValidationSkippedCheckReason = (typeof validationSkippedCheckReasons)[number]; - -export const validationCheckIdPattern = "^[a-z][a-z0-9]*(?:[._:-][a-z0-9]+)*$" as const; -const validationCheckIdRegex = new RegExp(validationCheckIdPattern); -const latencyStableIdRegex = /^[a-z][a-z0-9_-]*$/; -const latencyTelemetryCommandTokenRegex = /^(?=.*[A-Za-z0-9])[-@A-Za-z0-9._,:=]+$/; -const latencyTelemetrySourceFileExtensionRegex = - /\.(?:[cm]?[tj]sx?|mjs|cjs|jsonl?|rs|pyi?|mdx?|toml|lock|ya?ml|txt|inc|css|s[ac]ss|html?|vue|svelte|go|java|rb|php|swift|kts?|scala|lua|cs|c|cc|cpp|h|hpp)(?:$|[,=:])/i; - -export type JsonPrimitive = string | number | boolean | null; -export type JsonValue = JsonPrimitive | JsonValue[] | { [key: string]: JsonValue }; - -export interface RepoIdentity { - repoId?: string; - repoRoot?: string; - remoteUrl?: string; - commitSha?: string; -} - -export interface GraphFreshness { - generatedAt: string; - ageMs: number; - maxAgeMs?: number; - stale: boolean; - reason?: string; -} - -export interface GraphProviderArtifactMetadata { - artifactName: "opcore-graph-core" | (string & {}); - artifactVersion: string; - targetPlatform: string; - binaryPath: string; - checksumPath: string; - checksumSha256: string; - buildProfile: string; -} - -export interface GraphProviderCapabilityHandshake { - provider: "opcore-graph" | (string & {}); - graphSchemaVersion: number; - artifactName: "opcore-graph-core" | (string & {}); - artifactVersion: string; - targetPlatform: string; - supportedOperations: readonly GraphDaemonOperation[]; - nodeKinds: readonly GraphNodeKind[]; - edgeKinds: readonly GraphEdgeKind[]; - queryKinds: readonly GraphProviderQueryKind[]; - artifact: GraphProviderArtifactMetadata; -} - -export interface ProviderFailure { - category: ProviderFailureCategory; - message: string; - retryable?: boolean; - cause?: string; -} -export type ProviderFailureWithCategory = ProviderFailure & { category: Category }; - -export interface GraphExtractionDiagnostic { - category: GraphExtractionDiagnosticCategory; - severity: "info" | "warning" | "error"; - message: string; - path?: string; - language?: string; -} - -export interface GraphProviderStatusBase { - state: GraphProviderStatusState; - mode: GraphProviderMode; - provider: string; - schemaVersion: number; - message?: string; -} - -export interface GraphProviderAvailableStatus extends GraphProviderStatusBase { - state: "available"; - repo: RepoIdentity; - freshness: GraphFreshness; - dbPath?: string; - nodes_by_kind: Readonly>; - edges_by_kind: Readonly>; - capabilities?: readonly string[]; - handshake?: GraphProviderCapabilityHandshake; - walCheckpoint?: GraphWalCheckpointSummary; -} - -export interface GraphProviderWarmingStatus extends GraphProviderStatusBase { - state: "warming"; - repo: RepoIdentity; - freshness: GraphFreshness; - lifecycle?: GraphWatchLifecycle; -} - -export interface GraphProviderSkippedStatus extends GraphProviderStatusBase { - state: "skipped"; - mode: "optional"; - failure: ProviderFailureWithCategory<"provider_missing">; -} - -export interface GraphProviderRequiredMissingStatus extends GraphProviderStatusBase { - state: "required_missing"; - mode: "required"; - failure: ProviderFailureWithCategory<"provider_missing">; -} - -export interface GraphProviderStaleStatus extends GraphProviderStatusBase { - state: "stale"; - repo: RepoIdentity; - freshness: GraphFreshness; - failure: ProviderFailureWithCategory<"stale_snapshot">; -} - -export interface GraphProviderSchemaMismatchStatus extends GraphProviderStatusBase { - state: "schema_mismatch"; - expectedSchemaVersion: number; - actualSchemaVersion: number; - failure: ProviderFailureWithCategory<"schema_mismatch">; -} - -export interface GraphProviderDaemonUnavailableStatus extends GraphProviderStatusBase { - state: "daemon_unavailable"; - failure: ProviderFailureWithCategory<"daemon_unavailable">; -} - -export interface GraphProviderErrorStatus extends GraphProviderStatusBase { - state: "error"; - failure: ProviderFailureWithCategory; - diagnostics?: readonly GraphExtractionDiagnostic[]; -} - -export type GraphProviderStatus = - | GraphProviderAvailableStatus - | GraphProviderWarmingStatus - | GraphProviderSkippedStatus - | GraphProviderRequiredMissingStatus - | GraphProviderStaleStatus - | GraphProviderSchemaMismatchStatus - | GraphProviderDaemonUnavailableStatus - | GraphProviderErrorStatus; - -export type GraphProviderFailureStatus = Exclude; -export type GraphProviderNonAvailableStatus = Exclude; - -export interface GraphFactNode { - id: string; - kind: GraphNodeKind; - path?: string; - name?: string; - attributes?: Record; -} - -export interface GraphFactEdge { - id?: string; - kind: GraphEdgeKind; - from: string; - to: string; - attributes?: Record; -} - -export interface GraphSnapshotMetadata { - schemaVersion: number; - provider: string; - repo: RepoIdentity; - generatedAt: string; - freshness: GraphFreshness; - nodeKinds: readonly GraphNodeKind[]; - edgeKinds: readonly GraphEdgeKind[]; -} - -export interface GraphFactQuerySelector { - kind: "nodes" | "edges" | "neighbors" | "symbols" | "impact"; - nodeKinds?: readonly GraphNodeKind[]; - edgeKinds?: readonly GraphEdgeKind[]; - ids?: readonly string[]; - text?: string; - limit?: number; -} - -export const graphFactQueryKinds = ["nodes", "edges", "neighbors", "symbols", "impact"] as const; - -export const graphNamedQueryKinds = [ - "callers_of", - "callees_of", - "importers_of", - "imports_of", - "tests_for", - "inheritors_of", - "children_of", - "file_summary" -] as const; -export type GraphNamedQueryKind = (typeof graphNamedQueryKinds)[number]; -export type GraphProviderQueryKind = GraphFactQuerySelector["kind"] | GraphNamedQueryKind | "review_context" | "detect_changes" | "search"; - -export interface GraphFactQueryRequest { - requestId?: string; - repo: RepoIdentity; - schemaVersion: number; - mode: GraphProviderMode; - selector: GraphFactQuerySelector; -} - -export interface GraphFactQueryAvailableResult { - requestId?: string; - status: GraphProviderAvailableStatus; - metadata: GraphSnapshotMetadata; - nodes: readonly GraphFactNode[]; - edges: readonly GraphFactEdge[]; - diagnostics?: readonly GraphExtractionDiagnostic[]; -} - -export interface GraphFactQueryFailureResult { - requestId?: string; - status: GraphProviderFailureStatus; -} - -export type GraphFactQueryResult = GraphFactQueryAvailableResult | GraphFactQueryFailureResult; - -export interface GraphTraversalMetadata { - maxDepth: number; - truncated: boolean; - total: number; - empty: boolean; -} - -export interface GraphNamedQueryRequest { - requestId?: string; - repo: RepoIdentity; - schemaVersion: number; - mode: GraphProviderMode; - queryKind: GraphNamedQueryKind; - target: string; - maxDepth?: number; - limit?: number; -} - -export interface GraphNamedQueryAvailableResult { - requestId?: string; - status: GraphProviderAvailableStatus; - metadata: GraphSnapshotMetadata; - queryKind: GraphNamedQueryKind; - target: string; - nodes: readonly GraphFactNode[]; - edges: readonly GraphFactEdge[]; - traversal: GraphTraversalMetadata; - diagnostics?: readonly GraphExtractionDiagnostic[]; -} - -export interface GraphNamedQueryFailureResult { - requestId?: string; - status: GraphProviderFailureStatus; -} - -export type GraphNamedQueryResult = GraphNamedQueryAvailableResult | GraphNamedQueryFailureResult; - -export interface GraphImpactRequest { - requestId?: string; - repo: RepoIdentity; - schemaVersion: number; - mode: GraphProviderMode; - files: readonly string[]; - baseRef?: string; - maxDepth?: number; - limit?: number; -} - -export interface GraphImpactAvailableResult { - requestId?: string; - status: GraphProviderAvailableStatus; - metadata: GraphSnapshotMetadata; - changedFiles: readonly string[]; - impactedFiles: readonly string[]; - impactedSymbols: readonly string[]; - tests: readonly string[]; - nodes: readonly GraphFactNode[]; - edges: readonly GraphFactEdge[]; - traversal: GraphTraversalMetadata; - diagnostics?: readonly GraphExtractionDiagnostic[]; -} - -export interface GraphImpactFailureResult { - requestId?: string; - status: GraphProviderFailureStatus; -} - -export type GraphImpactResult = GraphImpactAvailableResult | GraphImpactFailureResult; - -export interface GraphRenamedFile { - fromPath: string; - toPath: string; - checksumBefore?: string; - checksumAfter?: string; -} - -export interface GraphDetectChangesRequest { - requestId?: string; - repo: RepoIdentity; - schemaVersion: number; - mode: GraphProviderMode; - files?: readonly string[]; - baseRef?: string; -} - -export interface GraphDetectChangesAvailableResult { - requestId?: string; - status: GraphProviderAvailableStatus; - metadata: GraphSnapshotMetadata; - changedFiles: readonly string[]; - deletedFiles: readonly string[]; - renamedFiles: readonly GraphRenamedFile[]; - diagnostics?: readonly GraphExtractionDiagnostic[]; -} - -export interface GraphDetectChangesFailureResult { - requestId?: string; - status: GraphProviderFailureStatus; -} - -export type GraphDetectChangesResult = GraphDetectChangesAvailableResult | GraphDetectChangesFailureResult; - -export interface GraphReviewContextRequest { - requestId?: string; - repo: RepoIdentity; - schemaVersion: number; - mode: GraphProviderMode; - files?: readonly string[]; - baseRef?: string; - maxDepth?: number; - limit?: number; -} - -export interface GraphReviewContextAvailableResult { - requestId?: string; - status: GraphProviderAvailableStatus; - metadata: GraphSnapshotMetadata; - changedFiles: readonly string[]; - deletedFiles: readonly string[]; - renamedFiles: readonly GraphRenamedFile[]; - impactedFiles: readonly string[]; - impactedSymbols: readonly string[]; - tests: readonly string[]; - nodes: readonly GraphFactNode[]; - edges: readonly GraphFactEdge[]; - traversal: GraphTraversalMetadata; - diagnostics?: readonly GraphExtractionDiagnostic[]; -} - -export interface GraphReviewContextFailureResult { - requestId?: string; - status: GraphProviderFailureStatus; -} - -export type GraphReviewContextResult = GraphReviewContextAvailableResult | GraphReviewContextFailureResult; - -export interface InspectSymbolTarget { - kind: "node" | "file_symbol"; - nodeId?: string; - path?: string; - symbolName?: string; - line?: number; - column?: number; -} - -export type InspectReferenceTarget = InspectSymbolTarget; - -export interface InspectTextSpan { - startLine: number; - startColumn: number; - endLine: number; - endColumn: number; - startOffset?: number; - endOffset?: number; -} - -export type InspectReferenceSpan = InspectTextSpan; - -export interface InspectSymbolSummary { - id: string; - name: string; - kind?: GraphNodeKind; -} - -export interface InspectSymbolEvidence { - graphNodeIds: readonly string[]; - resolver: "graph" | "language_service"; -} - -export interface InspectReferenceEntry { - file: string; - line: number; - column: number; - text: string; - span: InspectTextSpan; - symbol: InspectSymbolSummary; - isDefinition: boolean; - isDeclaration?: boolean; - evidence: InspectSymbolEvidence; -} - -export const inspectSignatureKinds = [ - "function", - "method", - "constructor", - "interface", - "type_alias", - "class", - "variable_function" -] as const; -export type InspectSignatureKind = (typeof inspectSignatureKinds)[number]; - -export interface InspectSignatureParameter { - name: string; - type: string; - optional: boolean; - rest?: boolean; - defaultValue?: string; -} - -export interface InspectSignatureTypeParameter { - name: string; - constraint?: string; - default?: string; -} - -export interface InspectSignatureEntry { - file: string; - line: number; - column: number; - text: string; - signature: string; - kind: InspectSignatureKind; - parameters: readonly InspectSignatureParameter[]; - typeParameters: readonly InspectSignatureTypeParameter[]; - exported: boolean; - async: boolean; - returnType?: string; - span: InspectTextSpan; - symbol: InspectSymbolSummary; - overloadIndex?: number; - evidence: InspectSymbolEvidence; -} - -export const inspectImplementationKinds = ["implements", "inherited_implements", "extends", "interface_extends"] as const; -export type InspectImplementationKind = (typeof inspectImplementationKinds)[number]; - -export interface InspectImplementationEntry { - file: string; - line: number; - column: number; - text: string; - span: InspectTextSpan; - kind: InspectImplementationKind; - symbol: InspectSymbolSummary; - target: InspectSymbolSummary; - isDeclaration?: boolean; - evidence: InspectSymbolEvidence; -} - -export const inspectFailureCategories = [ - "graph_unavailable", - "target_ambiguous", - "target_not_found", - "unsupported_language", - "malformed_target", - "language_service_error", - "unsupported_route" -] as const; -export type InspectFailureCategory = (typeof inspectFailureCategories)[number]; - -export interface InspectRouteFailure { - category: InspectFailureCategory; - message: string; - candidates?: readonly InspectSymbolTarget[]; -} - -export interface InspectReferenceResult { - route: "references"; - status: "ok" | "degraded"; - target: InspectSymbolTarget; - providerStatus: GraphProviderStatus; - failure?: InspectRouteFailure; - references: readonly InspectReferenceEntry[]; -} - -export interface InspectSignatureResult { - route: "signature"; - status: "ok" | "degraded"; - target: InspectSymbolTarget; - providerStatus: GraphProviderStatus; - failure?: InspectRouteFailure; - signatures: readonly InspectSignatureEntry[]; -} - -export interface InspectImplementationResult { - route: "implementations"; - status: "ok" | "degraded"; - target: InspectSymbolTarget; - providerStatus: GraphProviderStatus; - failure?: InspectRouteFailure; - implementations: readonly InspectImplementationEntry[]; -} - -export interface InspectRouteErrorResult { - route: "references" | "signature" | "implementations"; - status: "error" | "degraded"; - target?: InspectSymbolTarget; - providerStatus?: GraphProviderStatus; - failure: InspectRouteFailure; -} - -export type InspectRouteResult = - | InspectReferenceResult - | InspectSignatureResult - | InspectImplementationResult - | InspectRouteErrorResult; - -export const aspWarmMethodNames = ["inspect/references", "edit/rename", "check/evaluate", "session/shutdown"] as const; -export type AspWarmMethodName = (typeof aspWarmMethodNames)[number]; - -export interface AspWarmProviderSummary { - id: "opcore"; - capabilityFamily: "inspect" | "edit" | "session"; -} - -export interface AspWarmInspectReferencesParams { - path: string; - symbolName: string; - line?: number; - column?: number; - limit?: number; -} - -export interface AspWarmInspectReferencesOkResult { - route: "references"; - status: "ok"; - target: InspectReferenceTarget; - references: readonly InspectReferenceEntry[]; -} - -export interface AspWarmInspectReferencesErrorResult { - route: "references"; - status: "error"; - target?: InspectReferenceTarget; - failure: InspectRouteFailure; -} - -export interface AspWarmInspectReferencesResponse { - provider: AspWarmProviderSummary; - inspectResult: AspWarmInspectReferencesOkResult | AspWarmInspectReferencesErrorResult; - timing: CommandTiming; -} - -export interface SymbolEditTarget { - path: string; - name: string; - line?: number; - column?: number; - nodeId?: string; -} - -export interface AspWarmEditRenameParams { - target: SymbolEditTarget; - newName: string; -} - -export interface AspWarmAffectedChecksum { - path: string; - checksumBefore?: string; - checksumAfter?: string; -} - -export interface AspWarmEditRenamePreviewResult { - route: "rename"; - status: "preview"; - changes: readonly RepoRelativeChange[]; - affectedChecksums: readonly AspWarmAffectedChecksum[]; -} - -export interface AspWarmEditRenameRefusedResult { - route: "rename"; - status: "refused"; - refusal: EditRefusal; -} - -export interface AspWarmEditRenameResponse { - provider: AspWarmProviderSummary; - editResult: AspWarmEditRenamePreviewResult | AspWarmEditRenameRefusedResult; - timing: CommandTiming; -} - -export interface AspWarmSessionShutdownResponse { - provider: AspWarmProviderSummary; - session: { - state: "shutdown"; - }; - timing: CommandTiming; -} - -export interface GraphSearchRequest { - requestId?: string; - repo: RepoIdentity; - schemaVersion: number; - mode: GraphProviderMode; - query: string; - limit?: number; - files?: readonly string[]; -} - -export interface GraphSearchMode { - engine: "fts5" | (string & {}); - querySyntax: "fts5" | (string & {}); - limit: number; - contextFiles: readonly string[]; -} - -export interface GraphSearchResultEntry { - nodeId: string; - kind: GraphNodeKind; - path?: string; - name?: string; - qualifiedName: string; - filePath?: string; - signature: string; - score: number; - rank: number; - matches: readonly string[]; -} - -export interface GraphSearchSummary { - query: string; - total: number; - returned: number; - limit: number; - indexedNodeKinds: readonly GraphNodeKind[]; - contextFiles: readonly string[]; -} - -export interface GraphSearchAvailableResult { - requestId?: string; - status: GraphProviderAvailableStatus; - metadata: GraphSnapshotMetadata; - query: string; - searchMode: GraphSearchMode; - summary: GraphSearchSummary; - results: readonly GraphSearchResultEntry[]; - hints: readonly string[]; - diagnostics?: readonly GraphExtractionDiagnostic[]; -} - -export interface GraphSearchFailureResult { - requestId?: string; - status: GraphProviderNonAvailableStatus; - hints?: readonly string[]; - diagnostics?: readonly GraphExtractionDiagnostic[]; -} - -export type GraphSearchResult = GraphSearchAvailableResult | GraphSearchFailureResult; - -export type GraphPipelineOperation = "build" | "update" | "watch"; - -export interface GraphPipelinePhaseTiming { - phase: "discovery" | "extraction" | "store" | "watch" | "status" | (string & {}); - startedAt: string; - completedAt: string; - durationMs: number; - fileCount?: number; -} - -export interface GraphWalCheckpointSummary { - walPath: string; - bytesBefore: number; - bytesAfter: number; - budgetBytes: number; - checkpointed: boolean; -} - -export interface GraphPipelineSummary { - operation: GraphPipelineOperation; - repo: RepoIdentity; - storePath?: string; - startedAt: string; - completedAt: string; - durationMs: number; - discoveredFiles: number; - parsedFiles: number; - changedFiles: readonly string[]; - deletedFiles: readonly string[]; - unchangedFiles: number; - fullRebuildRequired: boolean; - diagnosticsCount: number; - phaseTimings: readonly GraphPipelinePhaseTiming[]; - baseRef?: string; - watchPaths?: readonly string[]; - walCheckpoint?: GraphWalCheckpointSummary; -} - -export interface GraphWatchLifecycle { - state: "warming" | "available" | "error" | "stopped"; - pid?: number; - startedAt: string; - updatedAt: string; - pidPath: string; - statePath: string; - logPath: string; - pollIntervalMs: number; - idleTimeoutMs: number; - watchPaths?: readonly string[]; - message?: string; -} - -export interface GraphServeTransportStatus { - schemaVersion: 1; - protocol: "opcore.graph.daemon"; - transport: "stdio"; - state: "ready" | "error" | "stopped"; - repo: RepoIdentity; - provider: "opcore-graph" | (string & {}); - pid?: number; - artifact?: GraphProviderArtifactMetadata; - failure?: ProviderFailure; - message?: string; -} - -export interface GraphPipelineResult { - summary: GraphPipelineSummary; - status: GraphProviderStatus; - lifecycle?: GraphWatchLifecycle; -} - -export type GraphDaemonOperation = "build" | "update" | "watch" | "status" | "query" | "ping" | "health" | "shutdown"; -export const graphDaemonOperations = ["build", "update", "watch", "status", "query", "ping", "health", "shutdown"] as const; - -export interface GraphDaemonRequest { - protocol: "opcore.graph.daemon"; - requestId: string; - schemaVersion: number; - operation: GraphDaemonOperation; - repo: RepoIdentity; - query?: GraphFactQueryRequest; - namedQuery?: GraphNamedQueryRequest; - impact?: GraphImpactRequest; - reviewContext?: GraphReviewContextRequest; - changes?: GraphDetectChangesRequest; - search?: GraphSearchRequest; - baseRef?: string; - paths?: readonly string[]; - watchPaths?: readonly string[]; - pollIntervalMs?: number; - idleTimeoutMs?: number; - once?: boolean; - maxWalBytes?: number; -} - -export interface GraphDaemonResponse { - protocol: "opcore.graph.daemon"; - requestId: string; - schemaVersion: number; - status: GraphProviderStatus; - result?: GraphFactQueryResult; - namedQuery?: GraphNamedQueryResult; - impact?: GraphImpactResult; - reviewContext?: GraphReviewContextResult; - changes?: GraphDetectChangesResult; - search?: GraphSearchResult; - pipeline?: GraphPipelineResult; - lifecycle?: GraphWatchLifecycle; -} - -export interface RepoRelativeChangeBase { - path: string; - checksumBefore?: string; - checksumAfter?: string; -} - -export type RepoRelativeChange = - | (RepoRelativeChangeBase & { - kind: "create" | "replace"; - content: string; - }) - | (RepoRelativeChangeBase & { - kind: "delete"; - }) - | { - kind: "rename"; - path: string; - toPath: string; - checksumBefore?: string; - }; - -export interface AtomicApplyMetadata { - strategy: "all_or_nothing"; - planHash?: string; - expectedBaseSha?: string; -} - -export interface EditPlanValidationRequirement { - required: boolean; - request: ValidationRequest; -} - -export interface EditPlan { - planId: string; - repo: RepoIdentity; - changes: readonly RepoRelativeChange[]; - atomic: AtomicApplyMetadata; - validation: EditPlanValidationRequirement; -} - -export interface EditRefusal { - category: EditRefusalCategory; - message: string; - path?: string; -} - -export interface EditPlanResult { - planId: string; - ok: boolean; - applied: boolean; - appliedAt?: string; - refusal?: EditRefusal; - validation?: ValidationResult; -} - -export interface EditPlanRollbackState { - completed: boolean; - restoredPaths: readonly string[]; - failedPaths: readonly string[]; - cleanupFailedPaths: readonly string[]; -} - -export interface EditCommandResult { - ok: boolean; - applied: boolean; - planId?: string; - planHash?: string; - appliedAt?: string; - matchCount?: number; - afterState?: Readonly>; - validationRequest?: ValidationRequest; - validation?: ValidationResult; - refusal?: EditRefusal; - rollback?: EditPlanRollbackState; -} - -export const validationScopeKinds = ["files", "changed", "staged", "tree", "all", "repo", "package"] as const; -export type ValidationScopeKind = (typeof validationScopeKinds)[number]; - -export type ValidationScope = - | { - kind: "files"; - files: readonly string[]; - } - | { - kind: "changed"; - baseRef: string; - } - | { - kind: "staged"; - } - | { - kind: "tree"; - treeRef: string; - changedFrom: string; - } - | { - kind: "all"; - } - | { - kind: "repo"; - } - | { - kind: "package"; - packageName: string; - packageRoot: string; - }; - -export type HypotheticalOverlay = - | { - path: string; - action: "write"; - content: string; - checksumBefore?: string; - } - | { - path: string; - action: "delete"; - checksumBefore?: string; - }; - -export const cloneReportModes = ["all", "introduced"] as const; -export type CloneReportMode = (typeof cloneReportModes)[number]; - -export const cloneSourceReadModes = ["disk", "gitIndex", "gitTree"] as const; -export type CloneSourceReadMode = (typeof cloneSourceReadModes)[number]; - -export interface CloneAnalysisRequest { - protocol: typeof CLONE_PROTOCOL; - requestId?: string; - schemaVersion: 1; - repo: RepoIdentity; - reportMode: CloneReportMode; - paths?: readonly string[]; - sourcePaths?: readonly string[]; - sourceReadMode?: CloneSourceReadMode; - sourceTreeRef?: string; - overlays: readonly HypotheticalOverlay[]; - windowSize?: number; - minLines?: number; - minTokens?: number; - threshold?: number; - partitions?: readonly (readonly string[])[]; - exclude?: readonly string[]; - modes?: readonly string[]; -} - -export interface CloneFinding { - cloneClassId: string; - contentHash: string; - path: string; - peerPath: string; - paths: readonly string[]; - lineCount: number; - tokenCount: number; - introduced: boolean; -} - -export interface CloneAnalysisSummary { - analyzedFiles: number; - cloneClassCount: number; - findingCount: number; - overlayCount: number; -} - -export interface CloneAnalysisResult { - protocol: typeof CLONE_PROTOCOL; - requestId?: string; - schemaVersion: 1; - repo: RepoIdentity; - reportMode: CloneReportMode; - status: "passed"; - persisted: boolean; - dbPath?: string; - findings: readonly CloneFinding[]; - summary: CloneAnalysisSummary; -} - -export interface ValidationFailure { - category: ValidationFailureCategory; - message: string; - retryable?: boolean; - cause?: string; -} - -export interface ValidationGraphConfig { - mode: GraphProviderMode; - provider?: string; - maxAgeMs?: number; - status?: GraphProviderStatus; -} - -export interface ValidationRequest { - requestId?: string; - repo: RepoIdentity; - scope: ValidationScope; - graph: ValidationGraphConfig; - overlays: readonly HypotheticalOverlay[]; - checks?: readonly string[]; - reportMode?: ValidationReportMode; -} - -export const PYTHON_PROJECT_CONTEXT_SCHEMA_ID = "opcore.python.project-context.v1" as const; -export const PYTHON_VALIDATION_CAPABILITY_RUN_SCHEMA_ID = "opcore.python.validation-capability-run" as const; - -export const pythonProjectContextOutcomes = ["resolved", "degraded", "unsupported", "ambiguous"] as const; -export type PythonProjectContextOutcome = (typeof pythonProjectContextOutcomes)[number]; - -export const pythonProjectContextReasonCodes = [ - "missing_config", - "invalid_config", - "conflicting_managers", - "conflicting_targets", - "interpreter_unavailable", - "tool_unavailable", - "probe_timeout", - "probe_signal", - "probe_spawn_failure", - "probe_exit_failure", - "malformed_probe_output", - "unsupported_target", - "unsupported_platform", - "path_refused", - "symlink_refused", - "incompatible_interpreter", - "ambiguous_path" -] as const; -export type PythonProjectContextReasonCode = (typeof pythonProjectContextReasonCodes)[number]; - -export const pythonProjectManagerKinds = ["pip", "uv", "poetry", "pdm", "pipenv"] as const; -export type PythonProjectManagerKind = (typeof pythonProjectManagerKinds)[number]; - -export const pythonProjectLayoutKinds = ["flat", "src", "namespace", "stub", "package"] as const; -export type PythonProjectLayoutKind = (typeof pythonProjectLayoutKinds)[number]; - -export const pythonProjectExecutableSources = [ - "explicit_override", - "active_environment", - "project_local_environment", - "manager_environment", - "path" -] as const; -export type PythonProjectExecutableSource = (typeof pythonProjectExecutableSources)[number]; - -export const pythonProjectToolKinds = ["mypy", "pyright", "ruff", "pytest", "build"] as const; -export type PythonProjectToolKind = (typeof pythonProjectToolKinds)[number]; - -export interface PythonProjectContextReason { - code: PythonProjectContextReasonCode; - message: string; - path?: string; - tool?: string; -} - -export interface PythonProjectFileEvidence { - path: string; - role: "boundary" | "config" | "lock" | "requirements" | "build" | "layout"; -} - -export interface PythonProjectManagerEvidence { - kind: PythonProjectManagerKind; - configFiles: readonly string[]; - lockFiles: readonly string[]; -} - -export interface PythonProjectExecutableProvenance { - executable: string; - argv: readonly string[]; - cwd: string; - source: PythonProjectExecutableSource; - version?: string; - configFile?: string; -} - -export interface PythonInterpreterProvenance extends PythonProjectExecutableProvenance { - version: string; - implementation: string; - platform: string; - architecture: string; - abi: string; - soabi: string; -} - -export interface PythonProjectToolProvenance extends PythonProjectExecutableProvenance { - tool: PythonProjectToolKind; - available: boolean; -} - -export interface PythonProjectTarget { - requiresPython?: string; - version?: string; - platform?: string; - implementation?: string; - conflicts: readonly string[]; -} - -export interface PythonProjectLayoutEvidence { - kinds: readonly PythonProjectLayoutKind[]; - paths: readonly string[]; -} - -export interface PythonProjectBuildSystem { - configFile: string; - backend?: string; - requires: readonly string[]; -} - -export interface PythonProjectContext { - schemaId: typeof PYTHON_PROJECT_CONTEXT_SCHEMA_ID; - schemaVersion: 1; - target: string; - repositoryRoot: string; - projectRoot: string; - projectBoundary: string; - sourceRoots: readonly string[]; - layout: PythonProjectLayoutEvidence; - evidence: readonly PythonProjectFileEvidence[]; - targetRuntime: PythonProjectTarget; - managers: readonly PythonProjectManagerEvidence[]; - buildSystem?: PythonProjectBuildSystem; - interpreter?: PythonInterpreterProvenance; - tools: readonly PythonProjectToolProvenance[]; - projectKey: string; - contextFingerprint: string; - outcome: PythonProjectContextOutcome; - reasons: readonly PythonProjectContextReason[]; -} - -export interface PythonValidationCapabilityToolProvenance { - name: PythonValidationAuthority; - /** Portable executable locator: repo:, project:, path:, or external:. */ - executable: string; - argv: readonly string[]; - cwd: string; - source: PythonProjectExecutableSource; - version?: string; - configFile?: string; -} - -export interface PythonValidationCapabilityExecution { - termination: PythonValidationCapabilityTerminationKind; - exitCode?: number; - signal?: string; - failureSummary?: string; -} - -/** Portable, source-free evidence for one attempted Python capability in one canonical project. */ -export interface PythonTypesValidationCapabilityRun { - schemaId: typeof PYTHON_VALIDATION_CAPABILITY_RUN_SCHEMA_ID; - schemaVersion: 1; - capability: "types"; - checkId: "python.types"; - projectKey: string; - contextFingerprint: string; - projectRoot: string; - targets: readonly string[]; - selectedSourcePaths: readonly string[]; - selectedConfigPaths: readonly string[]; - afterStateManifestFingerprint: string; - authority?: PythonValidationAuthority; - authoritySource?: PythonValidationAuthoritySource; - status: PythonValidationCapabilityRunStatus; - tool?: PythonValidationCapabilityToolProvenance; - execution?: PythonValidationCapabilityExecution; - durationMs: number; - diagnosticCount: number; - errorCount: number; - warningCount: number; - noteCount: number; -} - -export interface ValidationDiagnostic { - category: ValidationDiagnosticCategory; - message: string; - path?: string; - severity: "info" | "warning" | "error"; - code?: string; - line?: number; - column?: number; - endLine?: number; - endColumn?: number; - tool?: ValidationDiagnosticToolProvenance; -} - -export interface ValidationDiagnosticToolProvenance { - name: string; - command: string; - version?: string; - source?: string; - cwd?: string; -} - -export interface ValidationCheckManifestEntry { - checkId: string; - owner: string; - adapter: string; - defaultSeverity: ValidationDiagnostic["severity"]; - supportedScopes: readonly ValidationScopeKind[]; - requiresGraph: boolean; -} - -export interface PythonRuffValidationCapabilityRun { - schemaId: typeof PYTHON_VALIDATION_CAPABILITY_RUN_SCHEMA_ID; - schemaVersion: 1; - checkId: "python.ruff-lint" | "python.ruff-format"; - capability: "ruff_lint" | "ruff_format"; - state: PythonValidationCapabilityState; - projectKey?: string; - contextFingerprint?: string; - afterStateManifestFingerprint?: string; - sourcePaths?: readonly string[]; - configPaths?: readonly string[]; - executable?: string; - command?: string; - argv?: readonly string[]; - cwd?: string; - configPath?: string; - toolVersion?: string; - toolSource?: PythonProjectExecutableSource; - termination?: PythonValidationCapabilityTermination; - exitCode?: number; - signal?: string; - invocations?: readonly PythonValidationCapabilityInvocation[]; - durationMs: number; - diagnosticCount: number; - failureMessage?: string; -} - -export type PythonValidationCapabilityRun = - | PythonTypesValidationCapabilityRun - | PythonRuffValidationCapabilityRun - | PythonPytestValidationCapabilityRun; - -export interface PythonValidationCapabilityInvocation { - argv: readonly string[]; - termination: PythonValidationCapabilityTermination; - exitCode?: number; - signal?: string; - durationMs: number; -} - -export interface ValidationCheckRunSummary { - checkId: string; - status: ValidationCheckRunStatus; - outcome?: ValidationCheckOutcome; - durationMs?: number; - diagnosticCount?: number; - failureMessage?: string; - pythonCapabilityRuns?: readonly PythonValidationCapabilityRun[]; -} - -export interface ValidationSkippedCheck { - checkId: string; - reason: ValidationSkippedCheckReason; - message: string; -} - -export interface ValidationResultManifest { - schemaVersion: number; - checks: readonly string[]; - generatedAt: string; - entries?: readonly ValidationCheckManifestEntry[]; - durationMs?: number; - runs?: readonly ValidationCheckRunSummary[]; - skippedChecks?: readonly ValidationSkippedCheck[]; -} - -export const pythonCapabilityActivations = ["enabled", "disabled", "not_applicable"] as const; -export type PythonCapabilityActivation = (typeof pythonCapabilityActivations)[number]; - -export const pythonPytestSelectionModes = ["none", "direct_argv", "manifest"] as const; -export type PythonPytestSelectionMode = (typeof pythonPytestSelectionModes)[number]; - -export const pythonCapabilityProcessTerminations = ["exited", "timeout", "signal", "spawn_error", "overflow"] as const; -export type PythonCapabilityProcessTermination = (typeof pythonCapabilityProcessTerminations)[number]; - -export interface PythonCapabilityCounts { - candidateCount: number; - collectedCount: number; - executedCount: number; - passedCount: number; - failedCount: number; - skippedCount: number; - xfailedCount: number; - xpassedCount: number; - errorCount: number; -} - -export interface PythonCapabilityCleanupEvidence { - attempted: boolean; - ok: boolean; - failureMessage?: string; -} - -export interface PythonCapabilityInvocation { - stage: "collection" | "execution"; - command: string; - argsDigest: string; - argCount: number; - selectionMode: PythonPytestSelectionMode; - selectionDigest?: string; - durationMs: number; - termination: PythonCapabilityProcessTermination; - exitCode?: number; - signal?: string; - outputBytes: number; - stdoutDigest?: string; - stderrDigest?: string; -} - -export interface PythonPytestValidationCapabilityRun { - capability: "pytest"; - checkId: string; - activation: PythonCapabilityActivation; - outcome: string; - message: string; - projectKey?: string; - projectRoot?: string; - configFile?: string; - targetCount?: number; - candidatePaths?: readonly string[]; - collectedNodeIds?: readonly string[]; - afterStateFingerprint?: string; - selectionMode?: PythonPytestSelectionMode; - selectionDigest?: string; - counts?: PythonCapabilityCounts; - collection?: PythonCapabilityInvocation; - execution?: PythonCapabilityInvocation; - cleanup?: PythonCapabilityCleanupEvidence; -} - -export interface ValidationResult { - ok: boolean; - status: ValidationResultStatus; - diagnostics: readonly ValidationDiagnostic[]; - graphStatus?: GraphProviderStatus; - failure?: ValidationFailure; - refusal?: EditRefusal; - manifest?: ValidationResultManifest; - pythonProjectContexts?: readonly PythonProjectContext[]; - pythonCapabilityRuns?: readonly PythonValidationCapabilityRun[]; -} - -export interface RequiredContextDocPolicy { - filenames: readonly string[]; - requiredPaths: readonly string[]; - requireRoot?: boolean; - minimumContentLength: number; - maxLines?: number; - maxSectionLines?: number; -} - -export const requiredContextDocPolicy = { - filenames: ["AGENTS.md", "CLAUDE.md"], - requiredPaths: ["."], - requireRoot: true, - minimumContentLength: 120 -} as const satisfies RequiredContextDocPolicy; - -export interface PreWriteValidationOverlaySummary { - count: number; - writeCount: number; - deleteCount: number; - paths: readonly string[]; -} - -export interface PreWriteValidationFailureSummary { - category: ValidationResultStatus; - message: string; - cause?: string; - retryable?: boolean; -} - -export interface PreWriteValidationReceipt { - schemaVersion: 1; - kind: "pre_write_validation"; - route: "validate.pre-write"; - canonicalCommand: readonly string[]; - generatedAt: string; - durationMs: number; - timeoutMs: number; - ok: boolean; - requestId?: string; - repo?: RepoIdentity; - scope?: ValidationScope; - checks?: readonly string[]; - graph?: { - mode: GraphProviderMode; - provider?: string; - status?: GraphProviderStatus; - }; - overlays?: PreWriteValidationOverlaySummary; - validationStatus: ValidationResultStatus; - diagnosticCount: number; - failureSummary?: PreWriteValidationFailureSummary; -} - -export const validationDaemonReadinessStates = ["not_configured", "ready", "unavailable", "error"] as const; -export type ValidationDaemonReadinessState = (typeof validationDaemonReadinessStates)[number]; - -export const validationAdapterRuntimeStates = ["available", "degraded", "unavailable"] as const; -export type ValidationAdapterRuntimeState = (typeof validationAdapterRuntimeStates)[number]; - -export interface ValidationAdapterToolchainStatus { - tool: string; - available: boolean; - command?: string; - version?: string; - failureMessage?: string; - cwd?: string; - configFile?: string; - source?: string; -} - -export interface ValidationAdapterDegradedCheckStatus { - checkId: string; - status: ValidationCheckRunStatus; - reason: string; - message: string; - requiredTool?: string; - retainedCompatibility?: boolean; - followUpIssue?: string; - currentUsage?: { - opcore: boolean; - orchestra: boolean; - covibes: boolean; - gateway: boolean; - }; -} - -export interface ValidationAdapterRuntimeStatus { - adapter: string; - status: ValidationAdapterRuntimeState; - checkIds: readonly string[]; - toolchain?: readonly ValidationAdapterToolchainStatus[]; - degradedChecks?: readonly ValidationAdapterDegradedCheckStatus[]; - tempWorkspaceRequired?: boolean; -} - -export interface ValidationStatusPayload { - schemaVersion: 1; - ready: boolean; - generatedAt: string; - adapterRegistry: { - checkRoutes: readonly string[]; - validateRoutes: readonly string[]; - checkIds: readonly string[]; - entries: readonly ValidationCheckManifestEntry[]; - adapters?: readonly ValidationAdapterRuntimeStatus[]; - }; - graph: { - mode: GraphProviderMode; - status: GraphProviderStatus; - }; - daemon?: { - state: ValidationDaemonReadinessState; - message?: string; - }; -} - -export const managedToolDescriptorCommandGroups = ["graph", "inspect", "edit", "check", "validate", "status", "doctor"] as const; -export type ManagedToolDescriptorCommandGroupName = (typeof managedToolDescriptorCommandGroups)[number]; - -const managedToolDescriptorCommandGroupPackageNames: Record = { - graph: "opcore", - inspect: "opcore", - edit: "opcore", - check: "opcore", - validate: "opcore", - status: "opcore", - doctor: "opcore" -}; - -export const managedToolDescriptorArtifactTypes = [ - "entrypoint", - "descriptor", - "schema", - "manifest", - "native_binary", - "checksum", - "receipt" -] as const; -export type ManagedToolDescriptorArtifactType = (typeof managedToolDescriptorArtifactTypes)[number]; - -export interface ManagedToolDescriptor { - schemaVersion: 1; - descriptorKind: "aggregate_opcore"; - aggregateIdentity: { - name: "opcore"; - releaseLine: "opcore"; - packageName: "opcore"; - version?: string; - }; - packageIdentity: { - packageName: "opcore"; - artifactName: "opcore"; - version?: string; - }; - entrypoints: readonly ManagedToolDescriptorEntrypoint[]; - commandGroups: readonly ManagedToolDescriptorCommandGroup[]; - healthProbes: readonly ManagedToolDescriptorHealthProbe[]; - capabilities: ManagedToolDescriptorCapabilities; - artifacts: readonly ManagedToolDescriptorArtifactReference[]; - checksums: readonly ManagedToolDescriptorChecksumReference[]; - provenanceHooks: readonly ManagedToolDescriptorProvenanceHook[]; - optionalSurfaces: readonly GraphReleaseOptionalSurfaceReceipt[]; -} - -export interface ManagedToolDescriptorEntrypoint { - bin: "opcore"; - packageName: "opcore"; - path: string; - command: readonly string[]; -} - -export interface ManagedToolDescriptorCommandGroup { - name: ManagedToolDescriptorCommandGroupName; - canonicalCommand: readonly string[]; - commands: readonly string[]; - packageName: string; -} - -export interface ManagedToolDescriptorHealthProbe { - id: string; - command: readonly string[]; - expectedExitCode: 0; - output: "json"; -} - -export interface ManagedToolDescriptorCapabilities { - graph: { - provider: "opcore-graph"; - schemaVersion: 1; - commands: readonly string[]; - queryKinds: readonly string[]; - daemonOperations: readonly string[]; - nativeArtifacts: readonly ManagedToolDescriptorNativeArtifact[]; - }; - edit: { - commands: readonly string[]; - safeEditModes: readonly string[]; - symbolEditModes: readonly string[]; - validationRequiredForApply: true; - dryRun: true; - }; - validation: { - checkRoutes: readonly string[]; - validateRoutes: readonly string[]; - scopeModes: readonly ValidationScopeKind[]; - graphModes: readonly GraphProviderMode[]; - hypothetical: true; - statusSurfaces: readonly ("status" | "doctor")[]; - pythonProjectContext: { - schemaId: typeof PYTHON_PROJECT_CONTEXT_SCHEMA_ID; - outcomes: readonly PythonProjectContextOutcome[]; - readOnly: true; - installs: false; - }; - writeGate: { - initScopes: readonly OpcoreInitScope[]; - harnesses: readonly ("claude-code" | "codex")[]; - adapterPath: string; - validationCommand: readonly string[]; - adapterErrorPolicy: "fail_open"; - validationErrorPolicy: "fail_closed"; - codexBoundary: "pretooluse_guardrail"; - }; - checkIds: readonly string[]; - }; -} - -export interface ManagedToolDescriptorNativeArtifact { - targetPlatform: GraphCoreNativeSupportedTarget; - packageName: "opcore"; - bundledPackageName: GraphCoreNativePackageName; - binaryPath: string; - metadataPath: string; - checksumPath: string; - artifactIds: { - binaryArtifactId: string; - metadataArtifactId: string; - checksumId: string; - checksumArtifactId: string; - }; -} - -export interface ManagedToolDescriptorArtifactReference { - id: string; - packageName: string; - path: string; - type: ManagedToolDescriptorArtifactType; - required: boolean; - checksumRef?: string; -} - -export interface ManagedToolDescriptorChecksumReference { - id: string; - packageName: string; - path: string; - algorithm: "sha256"; - artifactRef: string; - required: boolean; - value?: string; -} - -export interface ManagedToolDescriptorProvenanceHook { - id: string; - command: readonly string[]; - expectedExitCode: 0; -} - -export const commandOwners = ["graph", "inspect", "edit", "validation", "runtime"] as const; -export type CommandOwner = (typeof commandOwners)[number]; - -export const commandRouteStatuses = ["ok", "error", "not_implemented", "unsupported"] as const; -export type CommandRouteStatus = (typeof commandRouteStatuses)[number]; - -export const commandTimingProcessStates = ["cold", "warm"] as const; -export type CommandTimingProcessState = (typeof commandTimingProcessStates)[number]; - -export const commandTimingDegradationReasons = ["no_source", "no_paths"] as const; -export type CommandTimingDegradationReason = (typeof commandTimingDegradationReasons)[number]; - -export const latencyBudgetResultStatuses = ["pass", "over"] as const; -export type LatencyBudgetResultStatus = (typeof latencyBudgetResultStatuses)[number]; - -export const commandLatencyTelemetryBins = ["opcore", "opcore-asp-provider"] as const; -export type CommandLatencyTelemetryBin = (typeof commandLatencyTelemetryBins)[number]; - -export const commandLatencyTelemetryArtifactPolicy = { - path: ".opcore/telemetry.jsonl", - maxRecords: 500, - maxBytes: 1024 * 1024, - rotation: "ring_buffer" -} as const; - -export const graphReferenceEvidenceClassifications = ["required", "supporting", "optional", "deferred"] as const; -export type GraphReferenceEvidenceClassification = (typeof graphReferenceEvidenceClassifications)[number]; - -export const graphReleaseCoreCommandIds = [ - "opcore-graph-build", - "opcore-graph-update", - "opcore-graph-watch", - "opcore-graph-status", - "opcore-graph-query", - "opcore-graph-impact", - "opcore-graph-search", - "opcore-graph-serve" -] as const; -export type GraphReleaseCoreCommandId = (typeof graphReleaseCoreCommandIds)[number]; - -export const graphReleaseRustCommandIds = [ - "opcore-graph-rust-build", - "opcore-graph-rust-update", - "opcore-graph-rust-watch", - "opcore-graph-rust-status", - "opcore-graph-rust-query", - "opcore-graph-rust-impact", - "opcore-graph-rust-search", - "opcore-graph-rust-serve" -] as const; -export type GraphReleaseRustCommandId = (typeof graphReleaseRustCommandIds)[number]; - -export const graphReleaseBenchmarkMetrics = [ - "install_setup_ms", - "cold_build_ms", - "incremental_update_ms", - "impact_cold_ms", - "impact_hot_ms", - "search_ms", - "daemon_startup_ms", - "daemon_query_ms", - "db_size_bytes", - "wal_size_bytes" -] as const; -export type GraphReleaseBenchmarkMetric = (typeof graphReleaseBenchmarkMetrics)[number]; - -export const graphReleaseRequiredChildren = ["#35", "#8", "#9", "#10", "#11", "#12", "#19", "#47"] as const; -export type GraphReleaseRequiredChild = (typeof graphReleaseRequiredChildren)[number]; - -export const graphReleaseDeferredChildren = ["#13", "#14", "#15", "#16"] as const; -export type GraphReleaseDeferredChild = (typeof graphReleaseDeferredChildren)[number]; - -export const graphReleaseOptionalAnalysisSurfaces = [ - { - issue: "#13", - id: "coverage", - classification: "deferred", - status: "deferred" - }, - { - issue: "#14", - id: "flows", - classification: "optional", - status: "deferred" - }, - { - issue: "#15", - id: "communities", - classification: "optional", - status: "deferred" - }, - { - issue: "#16", - id: "read_only_suggestions", - classification: "supporting", - status: "deferred" - } -] as const; -export type GraphReleaseOptionalAnalysisSurface = (typeof graphReleaseOptionalAnalysisSurfaces)[number]; - -export const graphReleaseHandoffIssues = ["#7", "#28", "#29"] as const; -export type GraphReleaseHandoffIssue = (typeof graphReleaseHandoffIssues)[number]; - -export const graphReleaseDirectSqliteQueryIds = [ - "status-counts", - "status-edge-counts", - "impact-edges-from-file", - "search-by-name", - "freshness-metadata" -] as const; -export type GraphReleaseDirectSqliteQueryId = (typeof graphReleaseDirectSqliteQueryIds)[number]; - -export const graphReleaseServeTransportIds = [ - "serve-jsonl-ping", - "serve-jsonl-status", - "serve-jsonl-query", - "serve-jsonl-search", - "serve-jsonl-shutdown" -] as const; -export type GraphReleaseServeTransportId = (typeof graphReleaseServeTransportIds)[number]; - -export const graphReleaseReportReceiptIds = ["conformance", "pack", "license", "provenance"] as const; -export type GraphReleaseReportReceiptId = (typeof graphReleaseReportReceiptIds)[number]; - -export const graphCoreNativeSupportedTargets = ["darwin-arm64", "darwin-x64", "linux-x64"] as const; -export type GraphCoreNativeSupportedTarget = (typeof graphCoreNativeSupportedTargets)[number]; - -export const graphCoreNativePackageNames = [ - "@the-open-engine/opcore-graph-core-darwin-arm64", - "@the-open-engine/opcore-graph-core-darwin-x64", - "@the-open-engine/opcore-graph-core-linux-x64" -] as const; -export type GraphCoreNativePackageName = (typeof graphCoreNativePackageNames)[number]; - -export const graphCoreNativePackageNamesByTarget = { - "darwin-arm64": "@the-open-engine/opcore-graph-core-darwin-arm64", - "darwin-x64": "@the-open-engine/opcore-graph-core-darwin-x64", - "linux-x64": "@the-open-engine/opcore-graph-core-linux-x64" -} as const satisfies Record; - -export function graphCoreNativePackageNameForTarget(target: GraphCoreNativeSupportedTarget): GraphCoreNativePackageName { - return graphCoreNativePackageNamesByTarget[target]; -} - -export const releaseReceiptPackageNames = ["opcore"] as const; -export type ReleaseReceiptPackageName = (typeof releaseReceiptPackageNames)[number]; - -export const releaseReceiptBundledPackageNames = [ - "@the-open-engine/opcore-asp-provider", - "@the-open-engine/opcore-contracts", - "@the-open-engine/opcore-edit", - "@the-open-engine/opcore-graph", - "@the-open-engine/opcore-validation", - "@the-open-engine/opcore-validation-clone", - "@the-open-engine/opcore-validation-docs", - "@the-open-engine/opcore-validation-python", - "@the-open-engine/opcore-validation-rust", - "@the-open-engine/opcore-validation-typescript", - ...graphCoreNativePackageNames -] as const; - -export const releaseReceiptCommandGroups = ["graph", "inspect", "edit", "check", "validate", "status", "doctor"] as const; -export type ReleaseReceiptCommandGroupName = (typeof releaseReceiptCommandGroups)[number]; - -export const releaseReceiptReportIds = [ - "package-inspection", - "license", - "provenance", - "release-hygiene", - "graph-release", - "secret-history" -] as const; -export type ReleaseReceiptReportId = (typeof releaseReceiptReportIds)[number]; - -export const releaseReceiptSecretFindingScopes = ["current-tree", "git-history"] as const; -export type ReleaseReceiptSecretFindingScope = (typeof releaseReceiptSecretFindingScopes)[number]; - -export const releaseCutoverRequiredCommandIds = [ - "opcore-scan", - "opcore-status", - "opcore-check-changed", - "opcore-measure", - "opcore-try", - "status", - "doctor", - "graph-build", - "graph-status", - "graph-query", - "graph-impact", - "graph-review-context", - "graph-detect-changes", - "graph-search", - "graph-serve", - "inspect-symbols", - "inspect-definition", - "inspect-references", - "inspect-signature", - "inspect-implementations", - "inspect-search", - "edit-preview", - "edit-apply", - "edit-refused", - "check-files", - "validate-request", - "validate-pre-write-pass", - "validate-pre-write-fail" -] as const; -export type ReleaseCutoverCommandId = (typeof releaseCutoverRequiredCommandIds)[number]; - -export const releaseCutoverRustCommandIds = [ - "graph-rust-build", - "graph-rust-status", - "graph-rust-query", - "graph-rust-impact", - "graph-rust-review-context", - "graph-rust-detect-changes", - "graph-rust-search" -] as const; -export type ReleaseCutoverRustCommandId = (typeof releaseCutoverRustCommandIds)[number]; - -export const releaseCutoverPythonCommandIds = [ - "opcore-python-scan", - "opcore-python-status", - "opcore-python-check-changed", - "opcore-python-measure", - "graph-python-build", - "graph-python-status", - "graph-python-query", - "graph-python-search" -] as const; -export type ReleaseCutoverPythonCommandId = (typeof releaseCutoverPythonCommandIds)[number]; - -export const releaseCutoverNegativeCheckIds = [ - "missing-required-graph-check", - "missing-required-graph-validate", - "python-types-degraded-no-tools", - "python-source-hygiene-no-ruff", - "python-relevant-tests-no-pytest", - "python-toolchain-degraded-no-tools" -] as const; -export type ReleaseCutoverNegativeCheckId = (typeof releaseCutoverNegativeCheckIds)[number]; - -export const releaseCutoverCurrentToolGuardrailIds = [ - "current-tools-validate-changed", - "current-tools-validate-rust-graph" -] as const; -export type ReleaseCutoverCurrentToolGuardrailId = (typeof releaseCutoverCurrentToolGuardrailIds)[number]; - -export const releaseCutoverInputIssues = ["#17", "#29", "#58"] as const; -export type ReleaseCutoverInputIssue = (typeof releaseCutoverInputIssues)[number]; - -export const rustOldRoxComparisonSurfaceIds = [ - "rust.rustdoc", - "rust.import-graph", - "rust.dead-code", - "rust.unused-deps", - "rust.function-metrics", - "current-tools:validate-rust-graph" -] as const; -export type RustOldRoxComparisonSurfaceId = (typeof rustOldRoxComparisonSurfaceIds)[number]; - -export const rustOldRoxComparisonReplacementStatuses = ["retained", "deferred"] as const; -export type RustOldRoxComparisonReplacementStatus = (typeof rustOldRoxComparisonReplacementStatuses)[number]; - -export const aspDogfoodRequiredGuardrailIds = ["current-tools-validate-changed", "current-tools-validate-rust-graph"] as const; -export type AspDogfoodRequiredGuardrailId = (typeof aspDogfoodRequiredGuardrailIds)[number]; - -export const aspDogfoodOptionalGuardrailIds = ["current-tools-validate-all"] as const; -export type AspDogfoodOptionalGuardrailId = (typeof aspDogfoodOptionalGuardrailIds)[number]; - -export const aspDogfoodGuardrailIds = [...aspDogfoodRequiredGuardrailIds, ...aspDogfoodOptionalGuardrailIds] as const; -export type AspDogfoodGuardrailId = (typeof aspDogfoodGuardrailIds)[number]; - -export const aspDogfoodUnsupportedSurfaceIds = ["inspect", "edit"] as const; -export type AspDogfoodUnsupportedSurfaceId = (typeof aspDogfoodUnsupportedSurfaceIds)[number]; - -export const aspDogfoodForbiddenProviderMarkers = ["opcore asp serve", "opcore asp", "dist/bin/lattice", ".ace/runtime"] as const; -export type AspDogfoodForbiddenProviderMarker = (typeof aspDogfoodForbiddenProviderMarkers)[number]; -const legacyAspProviderBinMarker = ["lattice", "asp", "provider"].join("-"); - -const releaseCutoverRequestFilePlaceholder = ""; -const releaseCutoverMissingGraphRepoPlaceholder = ""; -const releaseCutoverRequiredGraphRequestPlaceholder = ""; - -type ReleaseCutoverExpectedCommandStatus = CommandRouteStatus; - -interface ReleaseCutoverCommandExpectation { - readonly canonicalCommand: readonly string[]; - readonly requestFileBasename?: string; - readonly owner: CommandOwner; - readonly status: ReleaseCutoverExpectedCommandStatus; - readonly exitCode: 0 | 1 | 2 | 64; - readonly bin: "opcore"; -} - -const releaseCutoverCommandExpectations = { - "opcore-scan": { canonicalCommand: ["opcore", "scan"], owner: "runtime", status: "ok", exitCode: 0, bin: "opcore" }, - "opcore-status": { canonicalCommand: ["opcore", "status"], owner: "runtime", status: "ok", exitCode: 0, bin: "opcore" }, - "opcore-check-changed": { - canonicalCommand: ["opcore", "check", "changed", "--report-mode", "introduced", "--base", "HEAD", "--checks", "typescript.syntax"], - owner: "validation", - status: "ok", - exitCode: 0, - bin: "opcore" - }, - "opcore-measure": { canonicalCommand: ["opcore", "measure"], owner: "runtime", status: "ok", exitCode: 0, bin: "opcore" }, - "opcore-try": { canonicalCommand: ["opcore", "try"], owner: "runtime", status: "ok", exitCode: 0, bin: "opcore" }, - status: { canonicalCommand: ["opcore", "status"], owner: "runtime", status: "ok", exitCode: 0, bin: "opcore" }, - doctor: { canonicalCommand: ["opcore", "doctor"], owner: "runtime", status: "ok", exitCode: 0, bin: "opcore" }, - "graph-build": { canonicalCommand: ["opcore", "graph", "build"], owner: "graph", status: "ok", exitCode: 0, bin: "opcore" }, - "graph-status": { canonicalCommand: ["opcore", "graph", "status"], owner: "graph", status: "ok", exitCode: 0, bin: "opcore" }, - "graph-query": { canonicalCommand: ["opcore", "graph", "query"], owner: "graph", status: "ok", exitCode: 0, bin: "opcore" }, - "graph-impact": { - canonicalCommand: ["opcore", "graph", "impact", "--files", "src/components/GreetingCard.tsx"], - owner: "graph", - status: "ok", - exitCode: 0, - bin: "opcore" - }, - "graph-review-context": { - canonicalCommand: ["opcore", "graph", "review-context", "--files", "src/components/GreetingCard.tsx"], - owner: "graph", - status: "ok", - exitCode: 0, - bin: "opcore" - }, - "graph-detect-changes": { - canonicalCommand: ["opcore", "graph", "detect-changes", "--files", "src/components/GreetingCard.tsx"], - owner: "graph", - status: "ok", - exitCode: 0, - bin: "opcore" - }, - "graph-search": { - canonicalCommand: ["opcore", "graph", "search", "Greeting", "--limit", "5"], - owner: "graph", - status: "ok", - exitCode: 0, - bin: "opcore" - }, - "graph-serve": { canonicalCommand: ["opcore", "graph", "serve"], owner: "graph", status: "ok", exitCode: 0, bin: "opcore" }, - "inspect-symbols": { - canonicalCommand: ["opcore", "inspect", "symbols", "Greeting", "--limit", "5"], - owner: "inspect", - status: "ok", - exitCode: 0, - bin: "opcore" - }, - "inspect-definition": { - canonicalCommand: ["opcore", "inspect", "definition", "GreetingCard"], - owner: "inspect", - status: "ok", - exitCode: 0, - bin: "opcore" - }, - "inspect-references": { - canonicalCommand: ["opcore", "inspect", "references", "function:src/components/GreetingCard.tsx#GreetingCard", "--limit", "5"], - owner: "inspect", - status: "ok", - exitCode: 0, - bin: "opcore" - }, - "inspect-signature": { - canonicalCommand: ["opcore", "inspect", "signature", "function:src/components/GreetingCard.tsx#GreetingCard"], - owner: "inspect", - status: "ok", - exitCode: 0, - bin: "opcore" - }, - "inspect-implementations": { - canonicalCommand: ["opcore", "inspect", "implementations", "class:src/models.ts#GreetingModel"], - owner: "inspect", - status: "ok", - exitCode: 0, - bin: "opcore" - }, - "inspect-search": { - canonicalCommand: ["opcore", "inspect", "search", "Greeting", "--limit", "5"], - owner: "inspect", - status: "ok", - exitCode: 0, - bin: "opcore" - }, - "edit-preview": { - canonicalCommand: [ - "opcore", - "edit", - "exact", - "--path", - "src/cutover.ts", - "--expected", - "export const cutoverValue: number = 1;", - "--replacement", - "export const cutoverValue: number = 2;" - ], - owner: "edit", - status: "ok", - exitCode: 0, - bin: "opcore" - }, - "edit-apply": { - canonicalCommand: [ - "opcore", - "edit", - "exact", - "--path", - "src/cutover.ts", - "--expected", - "export const cutoverValue: number = 1;", - "--replacement", - "export const cutoverValue: number = 2;", - "--apply" - ], - owner: "edit", - status: "ok", - exitCode: 0, - bin: "opcore" - }, - "edit-refused": { - canonicalCommand: [ - "opcore", - "edit", - "exact", - "--path", - "src/cutover.ts", - "--expected", - "export const cutoverValue: number = 2;", - "--replacement", - "export const cutoverValue: number = missingCutoverSymbol;", - "--apply" - ], - owner: "edit", - status: "error", - exitCode: 1, - bin: "opcore" - }, - "check-files": { - canonicalCommand: ["opcore", "check", "files", "src/cutover.ts", "--checks", "typescript.syntax,typescript.types"], - owner: "validation", - status: "ok", - exitCode: 0, - bin: "opcore" - }, - "validate-request": { - canonicalCommand: ["opcore", "validate", "request", "--request-file", releaseCutoverRequestFilePlaceholder], - requestFileBasename: "validate-request.json", - owner: "validation", - status: "ok", - exitCode: 0, - bin: "opcore" - }, - "validate-pre-write-pass": { - canonicalCommand: [ - "opcore", - "validate", - "pre-write", - "--request-file", - releaseCutoverRequestFilePlaceholder, - "--timeout-ms", - "30000" - ], - requestFileBasename: "pre-write-pass.json", - owner: "validation", - status: "ok", - exitCode: 0, - bin: "opcore" - }, - "validate-pre-write-fail": { - canonicalCommand: [ - "opcore", - "validate", - "pre-write", - "--request-file", - releaseCutoverRequestFilePlaceholder, - "--timeout-ms", - "30000" - ], - requestFileBasename: "pre-write-fail.json", - owner: "validation", - status: "error", - exitCode: 1, - bin: "opcore" - } -} as const satisfies Record; - -const releaseCutoverRustCommandExpectations = { - "graph-rust-build": { canonicalCommand: ["opcore", "graph", "build"], owner: "graph", status: "ok", exitCode: 0, bin: "opcore" }, - "graph-rust-status": { canonicalCommand: ["opcore", "graph", "status"], owner: "graph", status: "ok", exitCode: 0, bin: "opcore" }, - "graph-rust-query": { canonicalCommand: ["opcore", "graph", "query"], owner: "graph", status: "ok", exitCode: 0, bin: "opcore" }, - "graph-rust-impact": { - canonicalCommand: ["opcore", "graph", "impact", "--files", "src/helpers.rs"], - owner: "graph", - status: "ok", - exitCode: 0, - bin: "opcore" - }, - "graph-rust-review-context": { - canonicalCommand: ["opcore", "graph", "review-context", "--files", "src/helpers.rs"], - owner: "graph", - status: "ok", - exitCode: 0, - bin: "opcore" - }, - "graph-rust-detect-changes": { - canonicalCommand: ["opcore", "graph", "detect-changes", "--files", "src/helpers.rs"], - owner: "graph", - status: "ok", - exitCode: 0, - bin: "opcore" - }, - "graph-rust-search": { - canonicalCommand: ["opcore", "graph", "search", "Widget", "--limit", "5"], - owner: "graph", - status: "ok", - exitCode: 0, - bin: "opcore" - } -} as const satisfies Record; - -const releaseCutoverPythonCommandExpectations = { - "opcore-python-scan": { canonicalCommand: ["opcore", "scan"], owner: "runtime", status: "ok", exitCode: 0, bin: "opcore" }, - "opcore-python-status": { canonicalCommand: ["opcore", "status"], owner: "runtime", status: "ok", exitCode: 0, bin: "opcore" }, - "opcore-python-check-changed": { - canonicalCommand: [ - "opcore", - "check", - "changed", - "--report-mode", - "introduced", - "--base", - "HEAD", - "--checks", - "python.syntax,python.source-hygiene" - ], - owner: "validation", - status: "ok", - exitCode: 0, - bin: "opcore" - }, - "opcore-python-measure": { canonicalCommand: ["opcore", "measure"], owner: "runtime", status: "ok", exitCode: 0, bin: "opcore" }, - "graph-python-build": { canonicalCommand: ["opcore", "graph", "build"], owner: "graph", status: "ok", exitCode: 0, bin: "opcore" }, - "graph-python-status": { canonicalCommand: ["opcore", "graph", "status"], owner: "graph", status: "ok", exitCode: 0, bin: "opcore" }, - "graph-python-query": { canonicalCommand: ["opcore", "graph", "query"], owner: "graph", status: "ok", exitCode: 0, bin: "opcore" }, - "graph-python-search": { - canonicalCommand: ["opcore", "graph", "search", "Greeter", "--limit", "5"], - owner: "graph", - status: "ok", - exitCode: 0, - bin: "opcore" - } -} as const satisfies Record; - -const releaseCutoverPythonEvidenceExpectations = { - "opcore-python-scan": ["python-coverage", "python-validation", "python-types-degraded"], - "opcore-python-status": ["python-coverage", "python-validation"], - "opcore-python-check-changed": ["python-syntax", "python-source-hygiene"], - "opcore-python-measure": ["python-measure-delta"], - "graph-python-build": ["python-graph-provider"], - "graph-python-status": ["python-graph-provider"], - "graph-python-query": ["src/acme/app.py", "Greeter", "build_name"], - "graph-python-search": ["src/acme/app.py", "Greeter"] -} as const satisfies Record; - -const releaseCutoverNegativeCheckExpectations = { - "missing-required-graph-check": [ - "opcore", - "check", - "files", - "src/index.ts", - "--repo", - releaseCutoverMissingGraphRepoPlaceholder, - "--graph-mode", - "required", - "--checks", - "typescript.import-graph" - ], - "missing-required-graph-validate": [ - "opcore", - "validate", - "request", - "--request-file", - releaseCutoverRequiredGraphRequestPlaceholder - ], - "python-types-degraded-no-tools": ["opcore", "check", "files", "src/acme/app.py", "--checks", "python.types"], - "python-source-hygiene-no-ruff": ["opcore", "check", "files", "src/acme/app.py", "--checks", "python.source-hygiene"], - "python-relevant-tests-no-pytest": ["opcore", "check", "files", "src/acme/app.py", "--checks", "python.relevant-tests"], - "python-toolchain-degraded-no-tools": ["opcore", "status"] -} as const satisfies Record; - -export interface CommandExitSemantics { - ok: 0; - error: 1; - notImplemented: 2; - unsupported: 64; - jsonStable: boolean; -} - -export interface CommandGroupContract { - name: string; - owner: CommandOwner; - canonicalCommand: readonly string[]; - commands: readonly string[]; - summary: string; -} - -export interface CommandRouterManifest { - schemaVersion: 1; - packageName: "opcore" | (string & {}); - bins: readonly string[]; - exitSemantics: CommandExitSemantics; - ownershipBoundaries: readonly { - owner: CommandOwner; - summary: string; - }[]; - commandGroups: readonly CommandGroupContract[]; -} - -export interface OpcoreRepoStatePayload { - schemaVersion: 1; - repo: { - root: string; - requestedPath: string; - git: { - available: boolean; - branch?: string; - changed?: number; - staged?: number; - unstaged?: number; - untracked?: number; - conflicted?: number; - clean?: boolean; - }; - }; - coverage: { - totalFiles: number; - languages: readonly { - language: string; - files: number; - graphSupported: boolean; - validationSupported: boolean; - }[]; - graph: { - supportedFiles: number; - extensions: readonly { - extension: string; - count: number; - }[]; - }; - validation: { - supportedFiles: number; - retainedFiles: number; - extensions: readonly { - extension: string; - count: number; - }[]; - }; - unsupported: { - totalFiles: number; - stacks: readonly { - extension: string; - language: string; - count: number; - examples: readonly string[]; - }[]; - }; - }; - graph: { - state: GraphProviderStatusState; - mode: GraphProviderMode; - provider: string; - action: string; - message?: string; - status: GraphProviderStatus; - }; - validation: { - ready: boolean; - checkCount: number; - policy: OpcoreValidationPolicySummary; - adapters: readonly { - adapter: string; - status: ValidationAdapterRuntimeState; - checkCount: number; - degradedChecks: readonly string[]; - missingTools: readonly string[]; - }[]; - degradedToolchains: readonly { - adapter: string; - tool: string; - failureMessage?: string; - }[]; - pythonProjectContexts?: readonly PythonProjectContext[]; - }; - activation: { - ready: boolean; - level: "ready" | "degraded" | "blocked"; - summary: string; - asp: { - state: "enrolled" | "not_enrolled"; - paths: readonly string[]; - }; - }; - warnings: readonly string[]; - blockers: readonly string[]; - nextActions: readonly string[]; -} - -export interface OpcoreValidationPolicySummary { - path: ".opcore/config"; - state: "missing" | "loaded"; - adapters: readonly string[]; - packs: readonly string[]; - disabledChecks: readonly string[]; - defaultChecks: readonly string[]; - configuredChecks: readonly string[]; -} - -export const opcoreRuntimeArtifactSources = ["source_checkout", "installed_package", "unknown"] as const; -export type OpcoreRuntimeArtifactSource = (typeof opcoreRuntimeArtifactSources)[number]; - -export interface OpcoreRuntimeInfoPayload { - schemaVersion: 1; - packageName: "opcore"; - version: string; - bin: "opcore"; - artifactSource: OpcoreRuntimeArtifactSource; - packageRoot: string; - entrypoint: string; -} - -export interface OpcoreDoctorPayload { - schemaVersion: 1; - runtime: OpcoreRuntimeInfoPayload; - repo: { - root: string; - requestedPath: string; - }; - config: { - path: ".opcore/config"; - state: "found" | "missing" | "unreadable"; - message?: string; - }; - checks: { - count: number; - ids: readonly string[]; - }; - policy: OpcoreValidationPolicySummary; - graph: GraphProviderStatus; - generatedState: { - ignored: readonly string[]; - guidance: string; - }; - nextActions: readonly string[]; -} - -export const opcoreInitScopes = ["repo", "global"] as const; -export type OpcoreInitScope = (typeof opcoreInitScopes)[number]; - -export interface OpcoreInitAction { - kind: "write" | "upsert_block" | "create_hook" | "wire_harness" | "restore" | "remove"; - path: string; - targetScope: OpcoreInitScope; - summary: string; - requiresApproval: boolean; - outsideOpcore: boolean; -} - -export interface OpcoreInitScanSummary { - totalFiles: number; - graphSupportedFiles: number; - validationSupportedFiles: number; - validationRetainedFiles: number; - unsupportedFiles: number; - languages: readonly { - language: string; - files: number; - graphSupported: boolean; - validationSupported: boolean; - }[]; - unsupportedStacks: readonly { - extension: string; - language: string; - count: number; - examples: readonly string[]; - }[]; - degradedRustTools: readonly { - adapter: string; - tool: string; - failureMessage?: string; - }[]; - diagnosticCount: number; - validationStatus: ValidationResultStatus; - failedChecks: readonly string[]; - graphState: GraphProviderStatusState; - activationLevel: "ready" | "degraded" | "blocked"; -} - -export interface OpcoreInitLanguageSetting { - language: string; - files: number; - state: "supported" | "retained" | "unsupported" | "degraded"; - graph: "supported" | "unsupported"; - validation: "supported" | "retained" | "unsupported" | "degraded"; - checks: readonly string[]; - notes: readonly string[]; -} - -export interface OpcoreInitPythonEnvironment { - dependencyManagers: readonly { - kind: "pyproject" | "requirements" | "pipfile" | "poetry" | "uv"; - path: string; - }[]; - virtualEnvironments: readonly { - kind: "venv"; - path: string; - }[]; - notes: readonly string[]; - contexts?: readonly PythonProjectContext[]; -} - -export interface OpcoreInitSettings { - languages: readonly OpcoreInitLanguageSetting[]; - python?: OpcoreInitPythonEnvironment; -} - -export interface OpcoreInitInteraction { - tty: boolean; - promptState: "not_requested" | "requested" | "approved" | "declined"; -} - -export interface OpcoreInitTiming { - scanMs: number; - planMs: number; - promptMs: number; - applyMs: number; - totalMs: number; - firstOutputMs: number; -} - -export interface OpcoreInitPlanPayload { - schemaVersion: 1; - mode: "plan" | "apply" | "undo"; - approved: boolean; - repo: { - root: string; - requestedPath: string; - }; - options: { - scope: OpcoreInitScope; - failClosedHook: boolean; - dryRun: boolean; - }; - agentFiles: readonly string[]; - actions: readonly OpcoreInitAction[]; - warnings: readonly string[]; - nextActions: readonly string[]; - undoAvailable: boolean; - scan: OpcoreInitScanSummary; - settings: OpcoreInitSettings; - interaction: OpcoreInitInteraction; - timings: OpcoreInitTiming; -} - -export interface OpcoreMetricEvidence { - source: string; - path: string; - message: string; - checkId?: string; - code?: string; - line?: number; - column?: number; -} - -export interface OpcoreMetricSignal { - id: string; - title: string; - category: "coverage" | "typescript" | "rust" | "graph" | (string & {}); - severity: "info" | "warning" | "error"; - count: number; - evidence: readonly OpcoreMetricEvidence[]; -} - -export interface OpcoreMetricDegradation { - id: string; - title: string; - source: string; - severity: "info" | "warning" | "error"; - message: string; - checkId?: string; - requiredTool?: string; -} - -export interface OpcoreMetricReport { - schemaVersion: 1; - kind: "opcore_metric_report"; - generatedAt: string; - repo: { - root: string; - requestedPath: string; - git: OpcoreRepoStatePayload["repo"]["git"]; - }; - coverage: OpcoreRepoStatePayload["coverage"]; - graph: { - state: GraphProviderStatusState; - mode: GraphProviderMode; - provider: string; - }; - validation: { - status?: ValidationResultStatus; - diagnosticCount: number; - checkCount: number; - policy?: OpcoreValidationPolicySummary; - pythonProjectContexts?: readonly PythonProjectContext[]; - }; - signals: readonly OpcoreMetricSignal[]; - degradations: readonly OpcoreMetricDegradation[]; - warnings: readonly string[]; - nextActions: readonly string[]; -} - -export interface OpcoreMetricHistoryEntry { - schemaVersion: 1; - kind: "opcore_metric_history_entry"; - recordedAt: string; - report: OpcoreMetricReport; -} - -export interface OpcoreMeasureSignalCount { - id: string; - title: string; - count: number; -} - -export interface OpcoreMeasureSignalDelta { - id: string; - title: string; - currentCount: number; - comparisonCount: number; - delta: number; -} - -export const opcoreMeasureLatencyStatuses = ["ok", "slower", "over_budget"] as const; -export type OpcoreMeasureLatencyStatus = (typeof opcoreMeasureLatencyStatuses)[number]; -export const opcoreMeasureLatencyFindingStatuses = ["slower", "over_budget"] as const; -export type OpcoreMeasureLatencyFindingStatus = (typeof opcoreMeasureLatencyFindingStatuses)[number]; - -export interface OpcoreMeasureLatencyPhase { - phase: string; - durationMs: number; -} - -export interface OpcoreMeasureLatencyFinding { - canonicalCommand: readonly string[]; - repoShapeBucket: string; - processState: CommandTimingProcessState; - status: OpcoreMeasureLatencyFindingStatus; - currentDurationMs: number; - dominantPhase?: OpcoreMeasureLatencyPhase; - baselineDurationMs?: number; - previousDurationMs?: number; - baselineDeltaMs?: number; - previousDeltaMs?: number; - budgetMs?: number; - overBudgetMs?: number; -} - -export interface OpcoreMeasureLatencyReport { - kind: "opcore_latency_report"; - recordCount: number; - budgetCount: number; - findings: readonly OpcoreMeasureLatencyFinding[]; -} - -export interface OpcoreMeasureComparison { - recordedAt: string; - generatedAt: string; - coverage: OpcoreMetricReport["coverage"]; - signals: readonly OpcoreMeasureSignalCount[]; - deltas: readonly OpcoreMeasureSignalDelta[]; -} - -export interface OpcoreMeasureDelta { - schemaVersion: 1; - kind: "opcore_measure_delta"; - generatedAt: string; - current: { - generatedAt: string; - coverage: OpcoreMetricReport["coverage"]; - signals: readonly OpcoreMeasureSignalCount[]; - }; - latency?: OpcoreMeasureLatencyReport; - baseline?: OpcoreMeasureComparison; - previous?: OpcoreMeasureComparison; - warnings: readonly string[]; - degradations: readonly OpcoreMetricDegradation[]; - nextActions: readonly string[]; -} - -export interface OpcoreTrySignalSummary { - id: string; - title: string; - count: number; - delta: number; -} - -export interface OpcoreTryScenario { - id: string; - repoRoot: string; - title: string; - commands: readonly string[]; - coverage: { - totalFiles: number; - validationSupportedFiles: number; - unsupportedFiles: number; - }; - signals: readonly OpcoreTrySignalSummary[]; -} - -export interface OpcoreTryCommandSummary { - scenarioId: string; - command: readonly string[]; - canonicalCommand: readonly string[]; - owner: CommandOwner; - status: CommandRouteStatus; - exitCode: number; -} - -export interface OpcoreTryPayload { - schemaVersion: 1; - sampleRoot: string; - published: false; - scenarios: readonly OpcoreTryScenario[]; - commands: readonly OpcoreTryCommandSummary[]; -} - -export type CommandTimingPhase = Pick; - -export interface CommandTiming { - durationMs: number; - phases: readonly CommandTimingPhase[]; - processState: CommandTimingProcessState; - degradations?: readonly CommandTimingDegradationReason[]; -} - -export interface RepoShapeFingerprint { - totalFiles: number; - languages: readonly { - language: string; - files: number; - }[]; - graph: { - supportedFiles: number; - unsupportedFiles: number; - }; - git: { - available: boolean; - clean?: boolean; - }; -} - -export interface CommandLatencyRecord { - schemaVersion: 1; - recordedAt: string; - bin: string; - canonicalCommand: readonly string[]; - owner: CommandOwner; - status: CommandRouteStatus; - exitCode: number; - repo: RepoShapeFingerprint; - timing: CommandTiming; - opcoreVersion: string; -} - -export interface LatencyPhaseBudget { - phase: string; - budgetMs: number; -} - -export interface LatencyBudget { - schemaVersion: 1; - canonicalCommand: readonly string[]; - scope: string; - repoShapeBucket: string; - budgetMs: number; - phaseBudgets?: readonly LatencyPhaseBudget[]; -} - -export interface LatencyBudgetResult { - schemaVersion: 1; - status: LatencyBudgetResultStatus; - budget: LatencyBudget; - observed: { - canonicalCommand: readonly string[]; - phase: string; - durationMs: number; - }; - evidence: { - canonicalCommand: readonly string[]; - phase: string; - repoShapeBucket: string; - observedMs: number; - budgetMs: number; - overByMs: number; - }; -} - -export interface CommandRouterResult { - schemaVersion: 1; - bin: string; - argv: readonly string[]; - canonicalCommand: readonly string[]; - owner: CommandOwner; - status: CommandRouteStatus; - exitCode: number; - message: string; - json: boolean; - providerStatus?: GraphProviderStatus; - graphPipeline?: GraphPipelineResult; - graphQuery?: GraphFactQueryResult | GraphNamedQueryResult; - graphSearch?: GraphSearchResult; - inspectResult?: InspectRouteResult; - graphImpact?: GraphImpactResult; - graphReviewContext?: GraphReviewContextResult; - graphChanges?: GraphDetectChangesResult; - graphServe?: GraphServeTransportStatus; - validationResult?: ValidationResult; - validationStatus?: ValidationStatusPayload; - receipt?: PreWriteValidationReceipt; - editPlan?: EditPlan; - editResult?: EditCommandResult; - repoState?: OpcoreRepoStatePayload; - runtimeInfo?: OpcoreRuntimeInfoPayload; - opcoreDoctor?: OpcoreDoctorPayload; - opcoreInit?: OpcoreInitPlanPayload; - opcoreMeasure?: OpcoreMeasureDelta; - opcoreTry?: OpcoreTryPayload; - timing?: CommandTiming; -} - -export interface ParsedCommandArgv { - args: readonly string[]; - json: boolean; -} - -export interface CommandRouterResultInput { - bin: string; - argv: readonly string[]; - canonicalCommand: readonly string[]; - owner: CommandOwner; - status: CommandRouteStatus; - json: boolean; - message: string; - providerStatus?: GraphProviderStatus; - graphPipeline?: GraphPipelineResult; - graphQuery?: GraphFactQueryResult | GraphNamedQueryResult; - graphSearch?: GraphSearchResult; - inspectResult?: InspectRouteResult; - graphImpact?: GraphImpactResult; - graphReviewContext?: GraphReviewContextResult; - graphChanges?: GraphDetectChangesResult; - graphServe?: GraphServeTransportStatus; - validationResult?: ValidationResult; - validationStatus?: ValidationStatusPayload; - receipt?: PreWriteValidationReceipt; - editPlan?: EditPlan; - editResult?: EditCommandResult; - repoState?: OpcoreRepoStatePayload; - runtimeInfo?: OpcoreRuntimeInfoPayload; - opcoreDoctor?: OpcoreDoctorPayload; - opcoreInit?: OpcoreInitPlanPayload; - opcoreMeasure?: OpcoreMeasureDelta; - opcoreTry?: OpcoreTryPayload; - timing?: CommandTiming; -} - -export interface CommandAdapterRequest { - schemaVersion: 1; - bin: string; - argv: readonly string[]; - args: readonly string[]; - json: boolean; - group: CommandGroupContract; - canonicalCommand: readonly string[]; -} - -export type CommandAdapter = (request: CommandAdapterRequest) => CommandRouterResult | Promise; - -export type CommandRouterWriter = (text: string) => void; - -export interface RouteCommandAdapterOptions { - bin: string; - argv: readonly string[]; - groupName: string; - adapter: CommandAdapter; - args?: readonly string[]; - json?: boolean; - showHelpOnEmpty?: boolean; - validateFirstRouteArg?: boolean; -} - -export interface RunCommandAdapterCliOptions extends Omit { - argv?: readonly string[]; - stdout?: CommandRouterWriter; - stderr?: CommandRouterWriter; -} - -export const commandExitSemantics: CommandExitSemantics = { - ok: 0, - error: 1, - notImplemented: 2, - unsupported: 64, - jsonStable: true -}; - -export const commandRouterManifest: CommandRouterManifest = { - schemaVersion: 1, - packageName: "opcore", - bins: ["opcore"], - exitSemantics: commandExitSemantics, - ownershipBoundaries: [ - { - owner: "graph", - summary: "Graph provider owns extraction, persistent facts, freshness, query, search, and impact contracts." - }, - { - owner: "inspect", - summary: "Inspect owns read-only code intelligence over graph facts and language-service surfaces." - }, - { - owner: "edit", - summary: "Edit planner owns symbol-aware rename, move, signature, patch, and tree edit orchestration." - }, - { - owner: "validation", - summary: "Validation owns checks, hypothetical validation, manifests, failure policy, and check status." - }, - { - owner: "runtime", - summary: "Runtime owns shared router health, help, and doctor surfaces." - } - ], - commandGroups: [ - { - name: "graph", - owner: "graph", - canonicalCommand: ["opcore", "graph"], - commands: [ - "build", - "update", - "watch", - "status", - "query", - "serve", - "impact", - "review-context", - "detect-changes", - "search" - ], - summary: - "GraphProvider build, update, watch, status, query, impact, review context, change detection, daemon lifecycle, and freshness behavior." - }, - { - name: "inspect", - owner: "inspect", - canonicalCommand: ["opcore", "inspect"], - commands: ["symbols", "definition", "references", "signature", "implementations", "search"], - summary: "Read-only code intelligence over graph and inspect-owned language services." - }, - { - name: "edit", - owner: "edit", - canonicalCommand: ["opcore", "edit"], - commands: ["exact", "multi", "search-replace", "check", "apply", "patch", "tree", "rename", "move", "signature"], - summary: "Exact edit, multi-edit, search-replace, patch/tree, graph-backed symbol rename/move/signature, preview/check, and apply routes." - }, - { - name: "check", - owner: "validation", - canonicalCommand: ["opcore", "check"], - commands: ["files", "staged", "changed", "tree", "all", "manifest"], - summary: "Mechanical check execution and check manifest behavior." - }, - { - name: "validate", - owner: "validation", - canonicalCommand: ["opcore", "validate"], - commands: ["request", "hypothetical", "pre-write", "manifest"], - summary: "Hypothetical, pre-write, and validation request behavior." - }, - { - name: "status", - owner: "runtime", - canonicalCommand: ["opcore", "status"], - commands: ["status"], - summary: "Shared router and runtime health status." - }, - { - name: "doctor", - owner: "runtime", - canonicalCommand: ["opcore", "doctor"], - commands: ["doctor"], - summary: "Shared runtime diagnostic summary." - } - ] -}; - -export interface GraphReferenceEvidenceSurfaceBase { - id: string; - classification: GraphReferenceEvidenceClassification; - fixtures: readonly string[]; -} - -export interface GraphReferenceEvidenceExitSemantics { - success: 0; - failure: string; -} - -export interface GraphReferenceEvidenceCommandSurface extends GraphReferenceEvidenceSurfaceBase { - referenceTool: string; - referenceCommand: readonly string[]; - canonicalCommand: readonly string[]; - flags: readonly string[]; - positionals: readonly string[]; - exitSemantics: GraphReferenceEvidenceExitSemantics; -} - -export interface GraphReferenceEvidenceJsonOutputSurface extends GraphReferenceEvidenceSurfaceBase { - command: string; - requiredFields: readonly string[]; - exitSemantics: GraphReferenceEvidenceExitSemantics; -} - -export interface GraphReferenceEvidenceSqliteFixture extends GraphReferenceEvidenceSurfaceBase { - fixture: string; - tables: readonly string[]; - indexes: readonly string[]; - metadataKeys: readonly string[]; - nodeKinds: readonly string[]; - edgeKinds: readonly string[]; - directReaderQueries: readonly string[]; -} - -export interface GraphReferenceEvidenceDaemonFixture extends GraphReferenceEvidenceSurfaceBase { - fixture: string; - protocol: "opcore.graph.daemon" | "reference-mcp-stdio-baseline-only" | (string & {}); - envelopes: readonly string[]; -} - -export interface GraphReferenceEvidenceBaselineReceipt extends GraphReferenceEvidenceSurfaceBase { - metric: string; - receipt: string; - label: "reference_evidence_non_implementation_input"; - sourceAvailability: "available" | "unavailable"; - nonImplementationInput: true; -} - -export interface GraphReferenceEvidenceOptionalAnalysisSurface extends GraphReferenceEvidenceSurfaceBase { - issue: GraphReleaseDeferredChild; - id: GraphReleaseOptionalAnalysisSurface["id"] | (string & {}); - status: "deferred"; -} - -export interface GraphReferenceEvidenceGoldenCorpusRef extends GraphReferenceEvidenceSurfaceBase { - fixture: string; - covers: readonly string[]; -} - -export interface GraphReferenceEvidenceProvenance { - containsPythonCrgSource: false; - containsPackageMetadata: false; - containsGitHistory: false; - referenceReceiptsAreImplementationInput: false; - implementationPackageNames: readonly string[]; - allowedMentionPaths: readonly string[]; -} - -export interface GraphReferenceEvidenceManifest { - schemaVersion: 1; - issue: "#19"; - origin: "covibes-authored-synthetic"; - fixtureRefs: readonly string[]; - commandSurfaces: readonly GraphReferenceEvidenceCommandSurface[]; - jsonOutputSurfaces: readonly GraphReferenceEvidenceJsonOutputSurface[]; - sqliteFixtures: readonly GraphReferenceEvidenceSqliteFixture[]; - daemonFixtures: readonly GraphReferenceEvidenceDaemonFixture[]; - baselineReceipts: readonly GraphReferenceEvidenceBaselineReceipt[]; - optionalAnalysisSurfaces: readonly GraphReferenceEvidenceOptionalAnalysisSurface[]; - goldenCorpus: GraphReferenceEvidenceGoldenCorpusRef; - provenance: GraphReferenceEvidenceProvenance; -} - -export interface GraphReleaseCommandCoverage { - id: GraphReleaseCoreCommandId; - bin: "opcore"; - command: readonly string[]; - canonicalCommand: readonly string[]; - status: "passed"; - exitCode: 0; - fixture: string; - durationMs: number; -} - -export interface GraphReleaseRustCommandCoverage { - id: GraphReleaseRustCommandId; - bin: "opcore"; - command: readonly string[]; - canonicalCommand: readonly string[]; - status: "passed"; - exitCode: 0; - fixture: string; - durationMs: number; -} - -export interface GraphReleaseDirectSqliteQueryReceipt { - id: GraphReleaseDirectSqliteQueryId; - query: string; - status: "passed"; - rowCount: number; - fixture: string; -} - -export interface GraphReleaseServeTransportReceipt { - id: GraphReleaseServeTransportId; - protocol: "opcore.graph.daemon" | "jsonrpc-2.0" | (string & {}); - operation: "ping" | "status" | "query" | "search" | "shutdown" | (string & {}); - status: "passed"; - exitCode: 0; -} - -export interface GraphReleaseBenchmarkReceipt { - metric: GraphReleaseBenchmarkMetric; - value: number; - unit: "ms" | "bytes"; - baselineIssue: "#19"; - baselineReceipt: string; - comparison: "recorded" | "within_baseline" | "above_baseline" | "below_baseline"; -} - -export interface GraphReleasePackageInspection { - packageName: "@the-open-engine/opcore-graph"; - tarballName: string; - fileCount: number; - files: readonly string[]; - forbiddenMarkersAbsent: true; - generatedBuildMetadataAbsent: true; - privatePathsAbsent: true; - pythonCrgSourceAbsent: true; - pythonGraphPackageMetadataAbsent: true; - pythonCrgGitHistoryAbsent: true; - forbiddenImplementationPackageNamesAbsent: true; - inspections: readonly string[]; -} - -export interface GraphReleaseNativeArtifactEvidence { - packageName: GraphCoreNativePackageName; - targetPlatform: GraphCoreNativeSupportedTarget; - metadata: GraphProviderArtifactMetadata; - binaryPath: "opcore-graph-core"; - checksumPath: "opcore-graph-core.sha256"; - metadataPath: "metadata.json"; - binarySha256: string; - checksumFileSha256: string; - metadataSha256: string; - packageFiles: readonly string[]; -} - -export interface GraphReleaseReportReceipt { - id: GraphReleaseReportReceiptId; - command: readonly string[]; - status: "passed"; - exitCode: 0; - path: string; - checksumSha256?: string; -} - -export interface GraphReleaseOptionalSurfaceReceipt { - issue: GraphReleaseDeferredChild; - id: GraphReleaseOptionalAnalysisSurface["id"] | (string & {}); - classification: GraphReferenceEvidenceClassification; - status: "unsupported" | "deferred"; -} - -export interface GraphReleaseHandoffReceipt { - issue: GraphReleaseHandoffIssue; - receiptPath: string; - checksumSha256: string; - rollbackNote: string; -} - -export interface GraphReleasePackageVersion { - packageName: string; - version: string; -} - -export interface GraphReleaseReceipt { - schemaVersion: 1; - issue: "#17"; - origin: "covibes-authored-synthetic"; - generatedAt: string; - commitSha: string; - graphPackageVersions: readonly GraphReleasePackageVersion[]; - graphProviderSchemaVersion: 1; - requiredChildren: readonly string[]; - deferredChildren: readonly string[]; - commandCoverage: readonly GraphReleaseCommandCoverage[]; - rustCommandCoverage: readonly GraphReleaseRustCommandCoverage[]; - directSqliteQueries: readonly GraphReleaseDirectSqliteQueryReceipt[]; - serveTransport: readonly GraphReleaseServeTransportReceipt[]; - benchmarks: readonly GraphReleaseBenchmarkReceipt[]; - packageInspection: GraphReleasePackageInspection; - supportedNativeTargets: readonly GraphCoreNativeSupportedTarget[]; - nativeArtifacts: readonly GraphReleaseNativeArtifactEvidence[]; - reportReceipts: readonly GraphReleaseReportReceipt[]; - graphArtifact: GraphProviderArtifactMetadata; - optionalSurfaces: readonly GraphReleaseOptionalSurfaceReceipt[]; - handoff: readonly GraphReleaseHandoffReceipt[]; -} - -export interface ReleaseReceiptTarballEvidence { - filename: string; - path: string; - sha256: string; - integrity?: string; - shasum?: string; -} - -export interface ReleaseReceiptPackageManifestMetadata { - name: ReleaseReceiptPackageName; - version: string; - license: string; - main?: string; - types?: string; - files: readonly string[]; - bins: Readonly>; - dependencies: Readonly>; - optionalDependencies?: Readonly>; - bundledDependencies: readonly string[]; - os?: readonly string[]; - cpu?: readonly string[]; -} - -export interface ReleaseReceiptNativeArtifactEvidence { - packageName: "opcore"; - bundledPackageName: GraphCoreNativePackageName; - targetPlatform: GraphCoreNativeSupportedTarget; - metadata: GraphProviderArtifactMetadata; - binaryPath: string; - checksumPath: string; - metadataPath: string; - binarySha256: string; - checksumFileSha256: string; - metadataSha256: string; - descriptorArtifactId: string; - descriptorChecksumId: string; -} - -export interface ReleaseReceiptPackageEvidence { - packageName: ReleaseReceiptPackageName; - packageRoot: string; - version: string; - manifest: ReleaseReceiptPackageManifestMetadata; - tarball: ReleaseReceiptTarballEvidence; - files: readonly string[]; - fileCount: number; - expectedFiles: readonly string[]; - expectedFileCount: number; - bins: Readonly>; - descriptorReferences: readonly ManagedToolDescriptorArtifactReference[]; - nativeArtifacts: readonly ReleaseReceiptNativeArtifactEvidence[]; -} - -export interface ReleaseReceiptDescriptorCommandGroupEvidence { - name: ReleaseReceiptCommandGroupName; - canonicalCommand: readonly string[]; - packageName: string; -} - -export interface ReleaseReceiptResolvedArtifactEvidence { - id: string; - packageName: ReleaseReceiptPackageName; - path: string; - type: ManagedToolDescriptorArtifactType; - required: boolean; - packageFile: true; - checksumRef?: string; -} - -export interface ReleaseReceiptResolvedChecksumEvidence { - id: string; - packageName: ReleaseReceiptPackageName; - path: string; - algorithm: "sha256"; - artifactRef: string; - required: boolean; - packageFile: true; - value: string; -} - -export interface ReleaseReceiptDescriptorEvidence { - path: string; - packageName: "opcore"; - checksumSha256: string; - descriptor: ManagedToolDescriptor; - commandGroups: readonly ReleaseReceiptDescriptorCommandGroupEvidence[]; - resolvedArtifacts: readonly ReleaseReceiptResolvedArtifactEvidence[]; - resolvedChecksums: readonly ReleaseReceiptResolvedChecksumEvidence[]; -} - -export interface ReleaseReceiptLicensePackageEvidence { - name: string; - version: string; - license: string; - source: string; - bundled: boolean; -} - -export interface ReleaseReceiptLicenseEvidence { - reportPath: string; - reportSha256: string; - productionDependencyCount: number; - bundledDependencyCount: number; - workspacePackageCount: number; - unresolvedLicenseCount: 0; - packages: readonly ReleaseReceiptLicensePackageEvidence[]; -} - -export interface ReleaseReceiptProvenanceFinding { - scope: "current-tree" | "git-history"; - marker: string; - path?: string; - commit?: string; - line?: number; -} - -export interface ReleaseReceiptProvenanceEvidence { - reportPath: string; - reportSha256: string; - scannedFileCount: number; - historyCommitCount: number; - findingCount: 0; - findings: readonly ReleaseReceiptProvenanceFinding[]; -} - -export interface ReleaseReceiptSecretFinding { - scope: ReleaseReceiptSecretFindingScope; - kind: string; - path?: string; - commit?: string; - line?: number; - fingerprint: string; - allowlisted: boolean; -} - -export interface ReleaseReceiptSecretHistoryEvidence { - allowlistPath: string; - allowlistSha256: string; - currentTreeScannedFileCount: number; - gitHistoryScannedCommitCount: number; - findingCount: 0; - findings: readonly ReleaseReceiptSecretFinding[]; -} - -export interface ReleaseReceiptReport { - id: ReleaseReceiptReportId; - command: readonly string[]; - status: "passed"; - exitCode: 0; - path?: string; - checksumSha256?: string; - summary: string; -} - -export interface ReleaseReceiptGraphReleaseEvidence { - path: string; - issue: "#17"; - checksumSha256: string; -} - -export interface ReleaseReceipt { - schemaVersion: 1; - issue: "#29"; - origin: "covibes-authored-release-proof"; - generatedAt: string; - commitSha: string; - privateRepo: true; - packageNames: readonly ReleaseReceiptPackageName[]; - commandGroups: readonly ReleaseReceiptCommandGroupName[]; - packages: readonly ReleaseReceiptPackageEvidence[]; - descriptor: ReleaseReceiptDescriptorEvidence; - nativeArtifacts: readonly ReleaseReceiptNativeArtifactEvidence[]; - license: ReleaseReceiptLicenseEvidence; - provenance: ReleaseReceiptProvenanceEvidence; - secretHistory: ReleaseReceiptSecretHistoryEvidence; - reports: readonly ReleaseReceiptReport[]; - graphReleaseReceipt: ReleaseReceiptGraphReleaseEvidence; -} - -export interface ReleaseCutoverTarballEvidence { - filename: string; - sha256: string; -} - -export interface ReleaseCutoverInstalledManifestEvidence { - path: string; - sha256: string; - bins: Readonly>; -} - -export interface ReleaseCutoverInstalledFileEvidence { - path: string; - sha256: string; -} - -export interface ReleaseCutoverInstalledPackageEvidence { - packageName: ReleaseReceiptPackageName; - version: string; - tarball: ReleaseCutoverTarballEvidence; - installedManifest: ReleaseCutoverInstalledManifestEvidence; - installedFiles: readonly ReleaseCutoverInstalledFileEvidence[]; -} - -export interface ReleaseCutoverDescriptorEvidence { - path: string; - packageName: "opcore"; - checksumSha256: string; - descriptor: ManagedToolDescriptor; - resolvedArtifacts: readonly ReleaseReceiptResolvedArtifactEvidence[]; - resolvedChecksums: readonly ReleaseReceiptResolvedChecksumEvidence[]; -} - -export interface ReleaseCutoverEnvironmentIsolationEvidence { - currentToolEnvCleared: true; - clearedEnvVarCount: number; - pathSanitized: true; - aceRuntimeBinExcluded: true; - siblingCovibesExcluded: true; - opcoreBinOnly: true; - oldBinsAbsent: { - lattice: true; - crg: true; - cix: true; - rox: true; - }; -} - -export interface ReleaseCutoverCommandReceipt { - id: ReleaseCutoverCommandId; - command: readonly string[]; - canonicalCommand: readonly string[]; - owner: CommandOwner; - status: CommandRouteStatus; - exitCode: number; - binPath: string; - stdoutSha256: string; - stderrSha256: string; - assertion: string; -} - -export interface ReleaseCutoverRustCommandReceipt { - id: ReleaseCutoverRustCommandId; - command: readonly string[]; - canonicalCommand: readonly string[]; - owner: "graph"; - status: "ok"; - exitCode: 0; - binPath: string; - stdoutSha256: string; - stderrSha256: string; - assertion: string; -} - -export interface ReleaseCutoverPythonCommandReceipt { - id: ReleaseCutoverPythonCommandId; - command: readonly string[]; - canonicalCommand: readonly string[]; - evidence: readonly string[]; - owner: CommandOwner; - status: "ok"; - exitCode: 0; - binPath: string; - stdoutSha256: string; - stderrSha256: string; - assertion: string; -} - -export interface ReleaseCutoverNegativeCheck { - id: ReleaseCutoverNegativeCheckId; - command: readonly string[]; - status: "passed"; - exitCode: 0; - assertion: string; -} - -export interface ReleaseCutoverCurrentToolGuardrailReceipt { - id: ReleaseCutoverCurrentToolGuardrailId; - command: readonly ["npm", "run", "current-tools:validate-changed"] | readonly ["npm", "run", "current-tools:validate-rust-graph"]; - status: "passed"; - exitCode: 0; - stdoutSha256: string; - stderrSha256: string; - retained: true; - assertion: string; - oldToolReplacementClaimed: false; -} - -export interface ReleaseCutoverForbiddenMarkerScan { - scannedTextCount: number; - findingCount: 0; - markersBlocked: readonly string[]; -} - -export interface ReleaseCutoverInputEvidence { - issue: ReleaseCutoverInputIssue; - path: string; - checksumSha256: string; -} - -export interface ReleaseCutoverReceipt { - schemaVersion: 1; - issue: "#30"; - origin: "covibes-authored-cutover-proof"; - generatedAt: string; - commitSha: string; - privateRepo: true; - packageNames: readonly ReleaseReceiptPackageName[]; - installedPackages: readonly ReleaseCutoverInstalledPackageEvidence[]; - descriptor: ReleaseCutoverDescriptorEvidence; - environmentIsolation: ReleaseCutoverEnvironmentIsolationEvidence; - commandReceipts: readonly ReleaseCutoverCommandReceipt[]; - rustCommandReceipts: readonly ReleaseCutoverRustCommandReceipt[]; - pythonCommandReceipts: readonly ReleaseCutoverPythonCommandReceipt[]; - negativeChecks: readonly ReleaseCutoverNegativeCheck[]; - currentToolGuardrails: readonly ReleaseCutoverCurrentToolGuardrailReceipt[]; - oldToolReplacementClaimed: false; - forbiddenMarkerScan: ReleaseCutoverForbiddenMarkerScan; - inputEvidence: readonly ReleaseCutoverInputEvidence[]; -} - -export interface RustOldRoxComparisonSurfaceReceipt { - id: RustOldRoxComparisonSurfaceId; - graphEvidenceExists: boolean; - graphEvidence: readonly string[]; - stillUniquelyProvidedByCurrentTools: readonly string[]; - replacementStatus: RustOldRoxComparisonReplacementStatus; -} - -export interface RustOldRoxComparisonGuardrailReceipt { - id: "current-tools:validate-rust-graph"; - command: readonly ["npm", "run", "current-tools:validate-rust-graph"]; - replacementStatus: "retained"; - oldToolReplacementClaimed: false; -} - -export interface RustOldRoxComparisonReceipt { - schemaVersion: 1; - issue: "#29"; - origin: "covibes-authored-old-rox-comparison"; - generatedAt: string; - privateRepo: true; - oldToolReplacementClaimed: false; - publicReleaseActions: readonly []; - surfaces: readonly RustOldRoxComparisonSurfaceReceipt[]; - guardrails: readonly RustOldRoxComparisonGuardrailReceipt[]; -} - -export interface AspDogfoodManagerEvidence { - bootstrapSource: "local-sibling"; - aspRepoPath: string; - aspBinPath: string; - cliPath: string; - commitSha: string; -} - -export interface AspDogfoodAspHomeEvidence { - path: string; - temp: true; - isolated: true; - sharedStateMutated: false; - pathSanitized: true; - aceRuntimeBinExcluded: true; -} - -export interface AspDogfoodHostFixtureEvidence { - repo: string; - temp: true; - sourceRepoMutated: false; - baselineCommitted: true; - changedPaths: readonly string[]; -} - -export interface AspDogfoodCommandRunReceipt { - id: string; - command: readonly string[]; - status: "passed" | "failed" | "retained-not-run"; - exitCode: number | null; - stdoutSha256: string; - stderrSha256: string; - output?: unknown; - assertion: string; -} - -export interface AspDogfoodProviderManifestEvidence { - manifestPath: string; - manifestSha256: string; - manifest: unknown; -} - -export interface AspDogfoodProviderEvidence { - providerId: "opcore"; - packageName: "opcore"; - binPath: string; - indexPath: string; - indexSha256: string; - command: readonly ["opcore-asp-provider", "--stdio"]; - entrypoint: { - transport: "stdio"; - bin: string; - args: readonly ["--stdio"]; - }; - manifest: AspDogfoodProviderManifestEvidence; -} - -export interface AspDogfoodRepoEnrollmentEvidence { - repo: string; - mode: "advisory" | "shadow"; - repoAdd: AspDogfoodCommandRunReceipt; - repoEnable: AspDogfoodCommandRunReceipt; - repoStatus: AspDogfoodCommandRunReceipt; -} - -export interface AspDogfoodManagerStateEvidence { - status: AspDogfoodCommandRunReceipt; - serverAdd: AspDogfoodCommandRunReceipt; - serverStatus: AspDogfoodCommandRunReceipt; -} - -export interface AspDogfoodHostCheckEvidence extends AspDogfoodCommandRunReceipt { - hostDecision: unknown; - receipt: unknown; - assurance: { - mode: string; - transactionGuarantee: string; - }; -} - -export interface AspDogfoodHostEvaluationEvidence { - check: AspDogfoodHostCheckEvidence; - ciVerify?: AspDogfoodCommandRunReceipt; -} - -export interface AspDogfoodProviderProbeEvidence extends AspDogfoodCommandRunReceipt { - assessment: unknown; - validAsOf: unknown; - coverage: unknown; - diagnosticsCount: number; - hostOwnedFieldLeak: false; -} - -export interface AspDogfoodGuardrailReceipt extends AspDogfoodCommandRunReceipt { - id: AspDogfoodGuardrailId; - retained: true; -} - -export interface AspDogfoodUnsupportedSurfaceEvidence { - surface: AspDogfoodUnsupportedSurfaceId; - status: "degraded" | "retained-old-tool-gate" | "parity-blocker"; - cleanCoverage: false; - blocker: string; -} - -export interface AspDogfoodParityBlocker { - source: string; - detail: string; -} - -export interface AspDogfoodAuthorityEvidence { - hostOwnsDecisions: true; - providerOutputIsHostDecision: false; - localAuthorityOverride: { - present: false; - sharedAuthorityWeakened: false; - }; -} - -export interface AspDogfoodForbiddenMarkerScan { - scannedTextCount: number; - findingCount: 0; - markersBlocked: readonly AspDogfoodForbiddenProviderMarker[]; -} - -export interface AspDogfoodReceipt { - schemaVersion: 1; - issue: "#120"; - origin: "covibes-authored-asp-dogfood-proof"; - generatedAt: string; - commitSha: string; - privateRepo: true; - bootstrapSource: "local-sibling"; - packageNames: readonly ReleaseReceiptPackageName[]; - installedPackages: readonly ReleaseCutoverInstalledPackageEvidence[]; - manager: AspDogfoodManagerEvidence; - aspHome: AspDogfoodAspHomeEvidence; - hostFixture: AspDogfoodHostFixtureEvidence; - provider: AspDogfoodProviderEvidence; - managerState: AspDogfoodManagerStateEvidence; - repoEnrollment: AspDogfoodRepoEnrollmentEvidence; - hostEvaluation: AspDogfoodHostEvaluationEvidence; - providerProbe: AspDogfoodProviderProbeEvidence; - currentToolGuardrails: readonly AspDogfoodGuardrailReceipt[]; - unsupportedSurfaces: readonly AspDogfoodUnsupportedSurfaceEvidence[]; - parityBlockers: readonly AspDogfoodParityBlocker[]; - authority: AspDogfoodAuthorityEvidence; - publicReleaseActions: readonly []; - oldToolReplacementClaimed: false; - forbiddenMarkerScan: AspDogfoodForbiddenMarkerScan; -} - -declare const process: { - argv: string[]; - stdout: { - write(text: string): void; - }; - stderr: { - write(text: string): void; - }; -}; - -const commandHelpArgs = new Set(["--help", "-h", "help"]); - -export function parseCommandArgv(argv: readonly string[]): ParsedCommandArgv { - return { - args: argv.filter((arg) => arg !== "--json"), - json: argv.includes("--json") - }; -} - -export function normalizeCommandBin(bin: string): string { - const normalized = bin.replaceAll("\\", "/").split("/").at(-1) ?? bin; - return normalized.endsWith(".js") ? "opcore" : normalized; -} - -export function commandExitCodeForStatus(status: CommandRouteStatus): number { - if (status === "ok") return commandRouterManifest.exitSemantics.ok; - if (status === "error") return commandRouterManifest.exitSemantics.error; - if (status === "not_implemented") return commandRouterManifest.exitSemantics.notImplemented; - return commandRouterManifest.exitSemantics.unsupported; -} - -export function createCommandRouterResult(input: CommandRouterResultInput): CommandRouterResult { - return validateCommandRouterResult(withoutUndefinedProperties({ - schemaVersion: 1, - bin: input.bin, - argv: input.argv, - canonicalCommand: input.canonicalCommand, - owner: input.owner, - status: input.status, - exitCode: commandExitCodeForStatus(input.status), - message: input.message, - json: input.json, - providerStatus: input.providerStatus, - graphPipeline: input.graphPipeline, - graphQuery: input.graphQuery, - graphSearch: input.graphSearch, - inspectResult: input.inspectResult, - graphImpact: input.graphImpact, - graphReviewContext: input.graphReviewContext, - graphChanges: input.graphChanges, - graphServe: input.graphServe, - validationResult: input.validationResult, - validationStatus: input.validationStatus, - receipt: input.receipt, - editPlan: input.editPlan, - editResult: input.editResult, - repoState: input.repoState, - runtimeInfo: input.runtimeInfo, - opcoreDoctor: input.opcoreDoctor, - opcoreInit: input.opcoreInit, - opcoreMeasure: input.opcoreMeasure, - opcoreTry: input.opcoreTry, - timing: input.timing - }) as CommandRouterResult); -} - -export function commandGroupByName(groupName: string): CommandGroupContract | undefined { - return commandRouterManifest.commandGroups.find((group) => group.name === groupName); -} - -export async function routeCommandAdapter(options: RouteCommandAdapterOptions): Promise { - const parsedArgv = parseCommandArgv(options.argv); - const parsed = { - args: options.args ?? parsedArgv.args, - json: options.json ?? parsedArgv.json - }; - const bin = normalizeCommandBin(options.bin); - const group = commandGroupByName(options.groupName); - if (!group) { - return createCommandRouterResult({ - bin, - argv: options.argv, - canonicalCommand: ["opcore", options.groupName], - owner: "runtime", - status: "unsupported", - json: parsed.json, - message: `Unsupported opcore command group: ${options.groupName}` - }); - } - - const showHelpOnEmpty = options.showHelpOnEmpty ?? group.name !== "check"; - if (parsed.args.some((arg) => commandHelpArgs.has(arg)) || (parsed.args.length === 0 && showHelpOnEmpty)) { - const routeName = parsed.args.find((arg) => !commandHelpArgs.has(arg) && !arg.startsWith("-")); - return commandHelpResult(bin, options.argv, parsed.json, group.name, routeName); - } - - const canonicalCommand = [...group.canonicalCommand, ...parsed.args.map(canonicalCommandArg)]; - const firstRouteArg = parsed.args.find((arg) => !arg.startsWith("-")); - const validateFirstRouteArg = options.validateFirstRouteArg ?? true; - if (validateFirstRouteArg && firstRouteArg && !group.commands.includes(firstRouteArg)) { - return createCommandRouterResult({ - bin, - argv: options.argv, - canonicalCommand, - owner: group.owner, - status: "unsupported", - json: parsed.json, - message: `${canonicalCommand.join(" ")} is not a supported ${group.name} route.` - }); - } - - const adapterRequest = validateCommandAdapterRequest({ - schemaVersion: 1, - bin, - argv: options.argv, - args: parsed.args, - json: parsed.json, - group, - canonicalCommand - }); - try { - return validateCommandRouterResult(await options.adapter(adapterRequest)); - } catch (error) { - return createCommandRouterResult({ - bin, - argv: options.argv, - canonicalCommand, - owner: group.owner, - status: "error", - json: parsed.json, - message: `${canonicalCommand.join(" ")} failed: ${errorMessage(error)}` - }); - } -} - -export async function runCommandAdapterCli(options: RunCommandAdapterCliOptions): Promise { - const stdout = options.stdout ?? ((text: string) => process.stdout.write(text)); - const stderr = options.stderr ?? ((text: string) => process.stderr.write(text)); - const argv = options.argv ?? process.argv.slice(2); - const routed = await routeCommandAdapter({ - ...options, - argv - }); - const text = routed.json ? JSON.stringify(routed) : routed.message; - const write = routed.json || routed.status === "ok" ? stdout : stderr; - write(`${text}\n`); - return routed.exitCode; -} - -function commandHelpResult( - bin: string, - argv: readonly string[], - json: boolean, - groupName?: string, - routeName?: string -): CommandRouterResult { - const group = groupName ? commandGroupByName(groupName) : undefined; - const canonicalCommand = group && routeName ? [...group.canonicalCommand, routeName, "help"] : group ? [...group.canonicalCommand, "help"] : ["opcore", "help"]; - return createCommandRouterResult({ - bin, - argv, - canonicalCommand, - owner: group?.owner ?? "runtime", - status: "ok", - json, - message: commandHelpMessage(groupName, routeName) - }); -} - -function canonicalCommandArg(arg: string): string { - return arg.length === 0 ? "" : arg; -} - -function errorMessage(error: unknown): string { - return error instanceof Error ? error.message : String(error); -} - -function commandHelpMessage(groupName?: string, routeName?: string): string { - if (!groupName) { - return [ - "Opcore - local code intelligence and edit safety for coding agents.", - "Groups: graph, inspect, edit, check, validate, status, doctor" - ].join("\n"); - } - const group = commandGroupByName(groupName); - if (!group) return `Unknown opcore command group: ${groupName}`; - const routeHelp = routeName ? commandRouteHelpMessage(groupName, routeName) : undefined; - if (routeHelp !== undefined) return routeHelp; - return [ - `${group.canonicalCommand.join(" ")} - ${group.summary}`, - `Commands: ${group.commands.join(", ")}`, - ...(groupName === "graph" ? [`Syntax: ${contractHelpSyntax(group)}`] : []), - `Example: ${contractHelpExample(groupName)}` - ].join("\n"); -} - -function commandRouteHelpMessage(groupName: string, routeName: string): string | undefined { - if (groupName === "graph" && routeName === "update") { - return [ - "Usage: opcore graph update [--repo ] [--base ] [--paths ] [--json]", - "Flags:", - " --repo Repository root to update.", - " --base Optional base ref for changed-file metadata.", - " --paths Optional repo-relative paths to refresh.", - " --json Emit structured JSON.", - "Defaults:", - " --repo defaults to the current working directory; JSON output is summary-oriented.", - "Examples:", - " opcore graph update --repo . --base HEAD --json", - " opcore graph update --repo . --paths src tests --json", - "Exit codes: 0 updated, 1 update failed, 64 unsupported." - ].join("\n"); - } - if (groupName === "graph" && routeName === "build") { - return [ - "Usage: opcore graph build [--repo ] [--paths ] [--json]", - "Flags:", - " --repo Repository root to build.", - " --paths Optional repo-relative paths to index.", - " --json Emit structured JSON.", - "Defaults:", - " --repo defaults to the current working directory; JSON output is summary-oriented.", - "Examples:", - " opcore graph build --repo . --json", - "Exit codes: 0 built, 1 build failed, 64 unsupported." - ].join("\n"); - } - if (groupName === "graph" && routeName === "status") { - return [ - "Usage: opcore graph status [--repo ] [--json]", - "Flags:", - " --repo Repository root to inspect.", - " --json Emit structured JSON.", - "Defaults:", - " --repo defaults to the current working directory.", - "Examples:", - " opcore graph status --repo . --json", - "Exit codes: 0 available or stale status read, 1 status failed, 64 unsupported." - ].join("\n"); - } - if (groupName === "validate" && routeName === "pre-write") { - return [ - "Usage: opcore validate pre-write --request-file [--timeout-ms ] [--json]", - "Flags:", - " --request-file ValidationRequest JSON payload.", - " --timeout-ms Pre-write timeout in milliseconds.", - " --json Emit structured JSON.", - "Defaults:", - " --timeout-ms defaults to 30000.", - "Examples:", - " opcore validate pre-write --request-file ./validation-request.json --timeout-ms 30000 --json", - "Exit codes: 0 passed, 1 findings or errors, 64 unsupported." - ].join("\n"); - } - return undefined; -} - -function contractHelpSyntax(group: CommandGroupContract): string { - return `${group.canonicalCommand.join(" ")} <${group.commands.join("|")}> --repo . [--json]`; -} - -function contractHelpExample(groupName: string): string { - if (groupName === "graph") return 'opcore graph search "GreetingCard" --repo . --limit 5'; - if (groupName === "inspect") return "opcore inspect definition GreetingCard --repo ."; - if (groupName === "edit") return 'opcore edit exact --path src/a.ts --expected "old" --replacement "new" --json'; - if (groupName === "check") return "opcore check files --files src/index.ts --json"; - if (groupName === "validate") return "opcore validate pre-write --request-file ./validation-request.json --timeout-ms 30000 --json"; - if (groupName === "status") return "opcore status"; - if (groupName === "doctor") return "opcore doctor --json"; - return `opcore ${groupName} --help`; -} - -export function validateCommandRouterManifest(manifest: CommandRouterManifest): CommandRouterManifest { - validateCommandRouterManifestHeader(manifest); - validateManifestBins(manifest.bins); - validateCommandExitSemantics(manifest.exitSemantics); - - validateManifestGroups(manifest.commandGroups); - validateManifestOwnershipBoundaries(manifest.ownershipBoundaries); - - return manifest; -} - -export function validateManagedToolDescriptor(descriptor: ManagedToolDescriptor): ManagedToolDescriptor { - if (!descriptor || typeof descriptor !== "object") { - throw new Error("Managed tool descriptor is required"); - } - if (descriptor.schemaVersion !== 1) throw new Error("Managed tool descriptor schemaVersion must be 1"); - if (descriptor.descriptorKind !== "aggregate_opcore") { - throw new Error("Managed tool descriptor descriptorKind must be aggregate_opcore"); - } - validateManagedToolIdentity(descriptor); - validateManagedToolEntrypoints(descriptor.entrypoints); - validateManagedToolCommandGroups(descriptor.commandGroups); - validateManagedToolHealthProbes(descriptor.healthProbes); - validateManagedToolCapabilities(descriptor.capabilities); - const artifactReferences = validateManagedToolArtifacts(descriptor.artifacts); - validateManagedToolChecksums(descriptor.checksums, artifactReferences); - validateManagedToolProvenanceHooks(descriptor.provenanceHooks); - validateGraphReleaseOptionalSurfaces(descriptor.optionalSurfaces); - validateManagedToolDescriptorForbiddenStrings(descriptor); - return descriptor; -} - -function validateManagedToolIdentity(descriptor: ManagedToolDescriptor): void { - const aggregate = descriptor.aggregateIdentity; - if (!aggregate || typeof aggregate !== "object") throw new Error("Managed tool descriptor aggregateIdentity is required"); - if (aggregate.name !== "opcore") throw new Error("Managed tool descriptor aggregateIdentity.name must be opcore"); - if (aggregate.releaseLine !== "opcore") throw new Error("Managed tool descriptor aggregateIdentity.releaseLine must be opcore"); - if (aggregate.packageName !== "opcore") { - throw new Error("Managed tool descriptor aggregateIdentity.packageName must be opcore"); - } - if (aggregate.version !== undefined) validateNonEmptyString(aggregate.version, "Managed tool descriptor aggregateIdentity.version"); - - const packageIdentity = descriptor.packageIdentity; - if (!packageIdentity || typeof packageIdentity !== "object") { - throw new Error("Managed tool descriptor packageIdentity is required"); - } - if (packageIdentity.packageName !== "opcore") { - throw new Error("Managed tool descriptor packageIdentity.packageName must be opcore"); - } - if (packageIdentity.artifactName !== "opcore") { - throw new Error("Managed tool descriptor packageIdentity.artifactName must be opcore"); - } - if (packageIdentity.version !== undefined) validateNonEmptyString(packageIdentity.version, "Managed tool descriptor packageIdentity.version"); -} - -function validateManagedToolEntrypoints(entrypoints: readonly ManagedToolDescriptorEntrypoint[]): void { - validateNonEmptyArray(entrypoints, "Managed tool descriptor entrypoints"); - for (const entrypoint of entrypoints) { - if (entrypoint?.bin !== "opcore" && ["lattice", "crg", "cix", "rox"].includes(String(entrypoint?.bin))) { - throw new Error("Managed tool descriptor must not reference old public aliases"); - } - } - validateExactStringSet( - entrypoints.map((entrypoint) => entrypoint.bin), - ["opcore"], - "Managed tool descriptor entrypoint bins" - ); - for (const entrypoint of entrypoints) { - if (!entrypoint || typeof entrypoint !== "object") throw new Error("Managed tool descriptor entrypoint is required"); - if (entrypoint.bin !== "opcore") throw new Error("Managed tool descriptor must expose the opcore entrypoint"); - if (entrypoint.packageName !== "opcore") { - throw new Error("Managed tool descriptor entrypoint packageName must be opcore"); - } - validateManagedToolPackagePath(entrypoint.path, "Managed tool descriptor entrypoint path"); - if (entrypoint.path !== "dist/index.js") { - throw new Error("Managed tool descriptor entrypoint path must be dist/index.js"); - } - validateExactStringSequence(entrypoint.command, ["opcore"], "Managed tool descriptor entrypoint command"); - } -} - -function validateManagedToolCommandGroups(commandGroups: readonly ManagedToolDescriptorCommandGroup[]): void { - validateNonEmptyArray(commandGroups, "Managed tool descriptor command groups"); - validateExactStringSet( - commandGroups.map((group) => group.name), - managedToolDescriptorCommandGroups, - "Managed tool descriptor command groups" - ); - for (const group of commandGroups) { - if (!group || typeof group !== "object") throw new Error("Managed tool descriptor command group is required"); - if (!includesString(managedToolDescriptorCommandGroups, group.name)) { - throw new Error(`Unknown managed tool descriptor command group: ${String(group.name)}`); - } - const expectedCanonical = ["opcore", group.name]; - validateExactStringSequence(group.canonicalCommand, expectedCanonical, `Managed tool descriptor ${group.name} canonicalCommand`); - validateStringArray(group.commands, `Managed tool descriptor ${group.name} commands`, { allowEmpty: false }); - const expectedPackageName = managedToolDescriptorCommandGroupPackageNames[group.name]; - if (group.packageName !== expectedPackageName) { - throw new Error(`Managed tool descriptor ${group.name} packageName must be ${expectedPackageName}`); - } - validateManagedToolCommandTokens(group.commands, `Managed tool descriptor ${group.name} commands`); - } -} - -function validateManagedToolHealthProbes(healthProbes: readonly ManagedToolDescriptorHealthProbe[]): void { - validateNonEmptyArray(healthProbes, "Managed tool descriptor health probes"); - let hasStatus = false; - let hasDoctor = false; - for (const probe of healthProbes) { - if (!probe || typeof probe !== "object") throw new Error("Managed tool descriptor health probe is required"); - validateNonEmptyString(probe.id, "Managed tool descriptor health probe id"); - validateStringArray(probe.command, "Managed tool descriptor health probe command", { allowEmpty: false }); - validateManagedToolCommandTokens(probe.command, "Managed tool descriptor health probe command"); - if (probe.command[0] !== "opcore") throw new Error("Managed tool descriptor health probes must use opcore commands"); - if (probe.expectedExitCode !== 0) throw new Error("Managed tool descriptor health probe expectedExitCode must be 0"); - if (probe.output !== "json") throw new Error("Managed tool descriptor health probe output must be json"); - if (sameStringArray(probe.command, ["opcore", "status", "--json"])) hasStatus = true; - if (sameStringArray(probe.command, ["opcore", "doctor", "--json"])) hasDoctor = true; - } - if (!hasStatus) throw new Error("Managed tool descriptor health probes must include status"); - if (!hasDoctor) throw new Error("Managed tool descriptor health probes must include doctor"); -} - -function validateManagedToolCapabilities(capabilities: ManagedToolDescriptorCapabilities): void { - if (!capabilities || typeof capabilities !== "object") throw new Error("Managed tool descriptor capabilities are required"); - validateManagedToolGraphCapabilities(capabilities.graph); - validateManagedToolEditCapabilities(capabilities.edit); - validateManagedToolValidationCapabilities(capabilities.validation); -} - -function validateManagedToolGraphCapabilities(graph: ManagedToolDescriptorCapabilities["graph"]): void { - if (!graph || typeof graph !== "object") throw new Error("Managed tool descriptor graph capabilities are required"); - if (graph.provider !== "opcore-graph") throw new Error("Managed tool descriptor graph provider must be opcore-graph"); - if (graph.schemaVersion !== 1) throw new Error("Managed tool descriptor graph schemaVersion must be 1"); - validateExactStringSet( - graph.commands, - ["build", "update", "watch", "status", "query", "impact", "review-context", "detect-changes", "search", "serve"], - "Managed tool descriptor graph commands" - ); - validateStringArray(graph.queryKinds, "Managed tool descriptor graph queryKinds", { allowEmpty: false }); - validateExactStringSet( - graph.daemonOperations, - ["ping", "status", "query", "search", "shutdown"], - "Managed tool descriptor graph daemonOperations" - ); - if (!Array.isArray(graph.nativeArtifacts)) { - throw new Error("Managed tool descriptor graph native artifacts are required"); - } - validateExactStringSet( - graph.nativeArtifacts.map((artifact) => artifact?.targetPlatform), - graphCoreNativeSupportedTargets, - "Managed tool descriptor graph native targets" - ); - for (const artifact of graph.nativeArtifacts) { - if (!artifact || typeof artifact !== "object") throw new Error("Managed tool descriptor graph native artifact is required"); - const expectedBundledPackageName = graphCoreNativePackageNameForTarget(artifact.targetPlatform); - if (artifact.packageName !== "opcore") { - throw new Error(`Managed tool descriptor graph native packageName for ${artifact.targetPlatform} must be opcore`); - } - if (artifact.bundledPackageName !== expectedBundledPackageName) { - throw new Error(`Managed tool descriptor graph native bundledPackageName for ${artifact.targetPlatform} must be ${expectedBundledPackageName}`); - } - if (artifact.binaryPath !== bundledGraphCoreNativePath(expectedBundledPackageName, "opcore-graph-core")) { - throw new Error("Managed tool descriptor graph native binaryPath must point at bundled opcore-graph-core"); - } - if (artifact.metadataPath !== bundledGraphCoreNativePath(expectedBundledPackageName, "metadata.json")) { - throw new Error("Managed tool descriptor graph native metadataPath must point at bundled metadata.json"); - } - if (artifact.checksumPath !== bundledGraphCoreNativePath(expectedBundledPackageName, "opcore-graph-core.sha256")) { - throw new Error("Managed tool descriptor graph native checksumPath must point at bundled opcore-graph-core.sha256"); - } - if (!artifact.artifactIds || typeof artifact.artifactIds !== "object") { - throw new Error("Managed tool descriptor graph native artifact ids are required"); - } - const suffix = artifact.targetPlatform; - if (artifact.artifactIds.binaryArtifactId !== `graph-core-binary-${suffix}`) { - throw new Error(`Managed tool descriptor graph native binary artifact id must be graph-core-binary-${suffix}`); - } - if (artifact.artifactIds.metadataArtifactId !== `graph-core-metadata-${suffix}`) { - throw new Error(`Managed tool descriptor graph native metadata artifact id must be graph-core-metadata-${suffix}`); - } - if (artifact.artifactIds.checksumArtifactId !== `graph-core-checksum-${suffix}`) { - throw new Error(`Managed tool descriptor graph native checksum artifact id must be graph-core-checksum-${suffix}`); - } - if (artifact.artifactIds.checksumId !== `graph-core-binary-sha256-${suffix}`) { - throw new Error(`Managed tool descriptor graph native checksum id must be graph-core-binary-sha256-${suffix}`); - } - } -} - -function validateManagedToolEditCapabilities(edit: ManagedToolDescriptorCapabilities["edit"]): void { - if (!edit || typeof edit !== "object") throw new Error("Managed tool descriptor edit capabilities are required"); - validateExactStringSet( - edit.commands, - ["exact", "multi", "search-replace", "patch", "tree", "rename", "move", "signature", "check", "apply"], - "Managed tool descriptor edit commands" - ); - validateExactStringSet(edit.safeEditModes, ["exact", "multi", "search-replace", "patch", "tree"], "Managed tool descriptor safe edit modes"); - validateExactStringSet(edit.symbolEditModes, ["rename", "move", "signature"], "Managed tool descriptor symbol edit modes"); - if (edit.validationRequiredForApply !== true) { - throw new Error("Managed tool descriptor edit validationRequiredForApply must be true"); - } - if (edit.dryRun !== true) throw new Error("Managed tool descriptor edit dryRun must be true"); -} - -function validateManagedToolValidationCapabilities(validation: ManagedToolDescriptorCapabilities["validation"]): void { - if (!validation || typeof validation !== "object") throw new Error("Managed tool descriptor validation capabilities are required"); - validateExactStringSet( - validation.checkRoutes, - ["files", "staged", "changed", "tree", "all", "manifest"], - "Managed tool descriptor check routes" - ); - validateExactStringSet(validation.validateRoutes, ["request", "hypothetical", "pre-write", "manifest"], "Managed tool descriptor validate routes"); - validateExactStringSet(validation.scopeModes, validationScopeKinds, "Managed tool descriptor validation scope modes"); - validateExactStringSet(validation.graphModes, graphProviderModes, "Managed tool descriptor validation graph modes"); - if (validation.hypothetical !== true) throw new Error("Managed tool descriptor validation hypothetical must be true"); - validateExactStringSet(validation.statusSurfaces, ["status", "doctor"], "Managed tool descriptor validation status surfaces"); - if (!validation.pythonProjectContext || typeof validation.pythonProjectContext !== "object") { - throw new Error("Managed tool descriptor Python project context capability is required"); - } - if (validation.pythonProjectContext.schemaId !== PYTHON_PROJECT_CONTEXT_SCHEMA_ID) { - throw new Error(`Managed tool descriptor Python project context schemaId must be ${PYTHON_PROJECT_CONTEXT_SCHEMA_ID}`); - } - validateExactStringSet( - validation.pythonProjectContext.outcomes, - pythonProjectContextOutcomes, - "Managed tool descriptor Python project context outcomes" - ); - if (validation.pythonProjectContext.readOnly !== true || validation.pythonProjectContext.installs !== false) { - throw new Error("Managed tool descriptor Python project context must be read-only and no-install"); - } - validateManagedToolValidationWriteGate(validation.writeGate); - validateValidationChecks(validation.checkIds, "Managed tool descriptor validation checkIds"); -} - -function validateManagedToolValidationWriteGate( - writeGate: ManagedToolDescriptorCapabilities["validation"]["writeGate"] -): void { - if (!writeGate || typeof writeGate !== "object") { - throw new Error("Managed tool descriptor validation writeGate is required"); - } - validateExactStringSet(writeGate.initScopes, opcoreInitScopes, "Managed tool descriptor writeGate initScopes"); - validateExactStringSet(writeGate.harnesses, ["claude-code", "codex"], "Managed tool descriptor writeGate harnesses"); - validateManagedToolPackagePath(writeGate.adapterPath, "Managed tool descriptor writeGate adapterPath"); - if (writeGate.adapterPath !== "dist/agent-gate.js") { - throw new Error("Managed tool descriptor writeGate adapterPath must be dist/agent-gate.js"); - } - validateExactStringSequence( - writeGate.validationCommand, - ["opcore", "validate", "pre-write", "--request-file", "", "--timeout-ms", "30000", "--json"], - "Managed tool descriptor writeGate validationCommand" - ); - if (writeGate.adapterErrorPolicy !== "fail_open") { - throw new Error("Managed tool descriptor writeGate adapterErrorPolicy must be fail_open"); - } - if (writeGate.validationErrorPolicy !== "fail_closed") { - throw new Error("Managed tool descriptor writeGate validationErrorPolicy must be fail_closed"); - } - if (writeGate.codexBoundary !== "pretooluse_guardrail") { - throw new Error("Managed tool descriptor writeGate codexBoundary must be pretooluse_guardrail"); - } -} - -interface ManagedToolDescriptorArtifactValidationState { - artifactIds: Set; - artifactChecksumRefs: readonly { artifactId: string; checksumRef: string }[]; -} - -function validateManagedToolArtifacts(artifacts: readonly ManagedToolDescriptorArtifactReference[]): ManagedToolDescriptorArtifactValidationState { - validateNonEmptyArray(artifacts, "Managed tool descriptor artifacts"); - const artifactIds = new Set(); - const artifactChecksumRefs: { artifactId: string; checksumRef: string }[] = []; - for (const artifact of artifacts) { - if (!artifact || typeof artifact !== "object") throw new Error("Managed tool descriptor artifact is required"); - validateNonEmptyString(artifact.id, "Managed tool descriptor artifact id"); - if (artifactIds.has(artifact.id)) throw new Error(`Managed tool descriptor artifact id must be unique: ${artifact.id}`); - artifactIds.add(artifact.id); - validateNonEmptyString(artifact.packageName, "Managed tool descriptor artifact packageName"); - validateManagedToolPackagePath(artifact.path, "Managed tool descriptor artifact path"); - if (!includesString(managedToolDescriptorArtifactTypes, artifact.type)) { - throw new Error(`Unknown managed tool descriptor artifact type: ${String(artifact.type)}`); - } - if (typeof artifact.required !== "boolean") throw new Error("Managed tool descriptor artifact required must be boolean"); - if (artifact.checksumRef !== undefined) { - validateNonEmptyString(artifact.checksumRef, "Managed tool descriptor artifact checksumRef"); - artifactChecksumRefs.push({ artifactId: artifact.id, checksumRef: artifact.checksumRef }); - } - } - - for (const target of graphCoreNativeSupportedTargets) { - const bundledPackageName = graphCoreNativePackageNameForTarget(target); - const binaryPath = bundledGraphCoreNativePath(bundledPackageName, "opcore-graph-core"); - const metadataPath = bundledGraphCoreNativePath(bundledPackageName, "metadata.json"); - const checksumPath = bundledGraphCoreNativePath(bundledPackageName, "opcore-graph-core.sha256"); - const binary = artifacts.find((artifact) => artifact.id === `graph-core-binary-${target}`); - const metadata = artifacts.find((artifact) => artifact.id === `graph-core-metadata-${target}`); - const checksum = artifacts.find((artifact) => artifact.id === `graph-core-checksum-${target}`); - if ( - !binary || - binary.packageName !== "opcore" || - binary.type !== "native_binary" || - !binary.required || - binary.path !== binaryPath || - binary.checksumRef !== `graph-core-binary-sha256-${target}` - ) { - throw new Error(`Managed tool descriptor must include graph native binary artifact for ${target}`); - } - if (!metadata || metadata.packageName !== "opcore" || metadata.type !== "manifest" || !metadata.required || metadata.path !== metadataPath) { - throw new Error(`Managed tool descriptor must include graph native metadata artifact for ${target}`); - } - if ( - !checksum || - checksum.packageName !== "opcore" || - checksum.type !== "checksum" || - !checksum.required || - checksum.path !== checksumPath - ) { - throw new Error(`Managed tool descriptor must include graph native checksum artifact for ${target}`); - } - } - if ( - !artifacts.some( - (artifact) => - artifact.id === "descriptor" && - artifact.packageName === "opcore" && - artifact.path === "dist/descriptors/opcore.managed-tool.json" && - artifact.type === "descriptor" && - artifact.required - ) - ) { - throw new Error("Managed tool descriptor must include packaged descriptor artifact"); - } - return { artifactIds, artifactChecksumRefs }; -} - -function validateManagedToolChecksums( - checksums: readonly ManagedToolDescriptorChecksumReference[], - artifactReferences: ManagedToolDescriptorArtifactValidationState -): void { - validateNonEmptyArray(checksums, "Managed tool descriptor checksums"); - const checksumIds = new Set(); - for (const checksum of checksums) { - if (!checksum || typeof checksum !== "object") throw new Error("Managed tool descriptor checksum is required"); - validateNonEmptyString(checksum.id, "Managed tool descriptor checksum id"); - if (checksumIds.has(checksum.id)) throw new Error(`Managed tool descriptor checksum id must be unique: ${checksum.id}`); - checksumIds.add(checksum.id); - validateNonEmptyString(checksum.packageName, "Managed tool descriptor checksum packageName"); - validateManagedToolPackagePath(checksum.path, "Managed tool descriptor checksum path"); - if (checksum.algorithm !== "sha256") throw new Error("Managed tool descriptor checksum algorithm must be sha256"); - validateNonEmptyString(checksum.artifactRef, "Managed tool descriptor checksum artifactRef"); - if (!artifactReferences.artifactIds.has(checksum.artifactRef)) { - throw new Error(`Managed tool descriptor checksum artifactRef must reference an artifact: ${checksum.artifactRef}`); - } - if (typeof checksum.required !== "boolean") throw new Error("Managed tool descriptor checksum required must be boolean"); - if (checksum.value !== undefined && !/^[a-f0-9]{64}$/i.test(checksum.value)) { - throw new Error("Managed tool descriptor checksum value must be a sha256 hex digest"); - } - } - for (const target of graphCoreNativeSupportedTargets) { - if (!checksums.some((checksum) => checksum.id === `graph-core-binary-sha256-${target}` && checksum.artifactRef === `graph-core-binary-${target}`)) { - throw new Error(`Managed tool descriptor must include graph native checksum reference for ${target}`); - } - } - for (const artifactChecksumRef of artifactReferences.artifactChecksumRefs) { - if (!checksumIds.has(artifactChecksumRef.checksumRef)) { - throw new Error( - `Managed tool descriptor artifact checksumRef must reference a checksum: ${artifactChecksumRef.artifactId} -> ${artifactChecksumRef.checksumRef}` - ); - } - } -} - -function validateManagedToolProvenanceHooks(provenanceHooks: readonly ManagedToolDescriptorProvenanceHook[]): void { - validateNonEmptyArray(provenanceHooks, "Managed tool descriptor provenance hooks"); - for (const hook of provenanceHooks) { - if (!hook || typeof hook !== "object") throw new Error("Managed tool descriptor provenance hook is required"); - validateNonEmptyString(hook.id, "Managed tool descriptor provenance hook id"); - validateStringArray(hook.command, "Managed tool descriptor provenance hook command", { allowEmpty: false }); - validateManagedToolCommandTokens(hook.command, "Managed tool descriptor provenance hook command"); - if (hook.expectedExitCode !== 0) throw new Error("Managed tool descriptor provenance hook expectedExitCode must be 0"); - } -} - -const managedToolPrivateRuntimePathPattern = /(?:^|\/)(?:\.ace|\.agents|\.claude|\.codex|\.gemini|\.opencode)(?:\/|$)/; - -function normalizeManagedToolDescriptorString(value: string): string { - return value.replaceAll("\\", "/"); -} - -function validateManagedToolPackagePath(path: string, label: string): string { - const normalized = validateRepoRelativePath(path); - if (normalized === "~" || normalized.startsWith("~/")) { - throw new Error(`${label} must not reference private home paths`); - } - if (managedToolPrivateRuntimePathPattern.test(normalized)) { - throw new Error(`${label} must not reference private runtime paths`); - } - if (normalized.includes("LATTICE_CURRENT_TOOLS_DIR")) { - throw new Error(`${label} must not reference current-tool environment variables`); - } - return normalized; -} - -function validateManagedToolCommandTokens(tokens: readonly string[], label: string): void { - for (const token of tokens) { - validateNonEmptyString(token, label); - if (["lattice", "crg", "cix", "rox"].includes(token)) throw new Error("Managed tool descriptor must not reference old public aliases"); - const normalizedToken = normalizeManagedToolDescriptorString(token); - if (managedToolPrivateRuntimePathPattern.test(normalizedToken) || normalizedToken.includes("LATTICE_CURRENT_TOOLS_DIR")) { - throw new Error("Managed tool descriptor must not reference current-tool runtime paths"); - } - } -} - -function validateManagedToolDescriptorForbiddenStrings(value: unknown): void { - const oldAliasPattern = /(^|[\\/\s])(?:lattice|crg|cix|rox)(?:$|[\\/\s])/i; - for (const text of collectStrings(value)) { - if (oldAliasPattern.test(text)) throw new Error("Managed tool descriptor must not reference old public aliases"); - const normalizedText = normalizeManagedToolDescriptorString(text); - if (managedToolPrivateRuntimePathPattern.test(normalizedText) || normalizedText.includes("LATTICE_CURRENT_TOOLS_DIR")) { - throw new Error("Managed tool descriptor must not reference current-tool runtime paths"); - } - if (normalizedText.includes("/Users/tom")) { - throw new Error("Managed tool descriptor must not reference private paths"); - } - } -} - -function sameStringArray(actual: readonly string[], expected: readonly string[]): boolean { - return actual.length === expected.length && actual.every((value, index) => value === expected[index]); -} - -function withoutUndefinedProperties>(value: T): Partial { - return Object.fromEntries(Object.entries(value).filter(([, entry]) => entry !== undefined)) as Partial; -} - -function validateCommandRouterManifestHeader(manifest: CommandRouterManifest): void { - if (!manifest || typeof manifest !== "object") { - throw new Error("Command router manifest is required"); - } - if (manifest.schemaVersion !== 1) { - throw new Error("Command router manifest schemaVersion must be 1"); - } - if (typeof manifest.packageName !== "string" || manifest.packageName.length === 0) { - throw new Error("Command router manifest packageName must be a non-empty string"); - } -} - -function validateManifestBins(bins: readonly string[]): void { - if (!Array.isArray(bins) || bins.length === 0) { - throw new Error("Command router manifest must include bins"); - } - for (const bin of bins) { - if (typeof bin !== "string" || bin.length === 0) { - throw new Error("Command router manifest bins must be non-empty strings"); - } - } -} - -function validateManifestGroups(commandGroups: readonly CommandGroupContract[]): Set { - const groupNames = new Set(); - for (const group of commandGroups) { - validateCommandOwner(group.owner); - validateNonEmptyString(group.name, "Command group name"); - validateStringArray(group.canonicalCommand, "Command group canonicalCommand", { allowEmpty: false }); - validateStringArray(group.commands, "Command group commands", { allowEmpty: false }); - validateNonEmptyString(group.summary, "Command group summary"); - groupNames.add(group.name); - } - - return groupNames; -} - -function validateManifestOwnershipBoundaries(boundaries: CommandRouterManifest["ownershipBoundaries"]): void { - for (const boundary of boundaries) { - validateCommandOwner(boundary.owner); - validateNonEmptyString(boundary.summary, "Command ownership boundary summary"); - } -} - -export function validateCommandRouterResult(result: CommandRouterResult): CommandRouterResult { - if (!result || typeof result !== "object") { - throw new Error("Command router result is required"); - } - if (result.schemaVersion !== 1) { - throw new Error("Command router result schemaVersion must be 1"); - } - validateNonEmptyString(result.bin, "Command router result bin"); - validateStringArray(result.argv, "Command router result argv", { allowEmpty: true, allowEmptyValues: true }); - validateStringArray(result.canonicalCommand, "Command router result canonicalCommand", { allowEmpty: false }); - validateCommandOwner(result.owner); - validateCommandRouteStatus(result.status); - validateExitCodeForStatus(result.exitCode, result.status); - validateNonEmptyString(result.message, "Command router result message"); - if (typeof result.json !== "boolean") { - throw new Error("Command router result json must be boolean"); - } - if (result.providerStatus !== undefined) validateProviderStatus(result.providerStatus); - if (result.graphPipeline !== undefined) validateGraphPipelineResult(result.graphPipeline); - if (result.graphQuery !== undefined) { - if (isNamedQueryResult(result.graphQuery)) validateGraphNamedQueryResult(result.graphQuery); - else validateGraphFactQueryResult(result.graphQuery); - } - if (result.graphSearch !== undefined) validateGraphSearchResult(result.graphSearch); - if (result.inspectResult !== undefined) validateInspectRouteResult(result.inspectResult); - if (result.graphImpact !== undefined) validateGraphImpactResult(result.graphImpact); - if (result.graphReviewContext !== undefined) validateGraphReviewContextResult(result.graphReviewContext); - if (result.graphChanges !== undefined) validateGraphDetectChangesResult(result.graphChanges); - if (result.graphServe !== undefined) validateGraphServeTransportStatus(result.graphServe); - if (result.validationResult !== undefined) validateValidationResultPayload(result.validationResult); - if (result.validationStatus !== undefined) validateValidationStatusPayload(result.validationStatus); - if (result.receipt !== undefined) validatePreWriteValidationReceipt(result.receipt); - if (result.editPlan !== undefined) validateEditPlanPayload(result.editPlan); - if (result.editResult !== undefined) validateEditCommandResult(result.editResult); - if (result.repoState !== undefined) validateOpcoreRepoStatePayload(result.repoState); - if (result.runtimeInfo !== undefined) validateOpcoreRuntimeInfoPayload(result.runtimeInfo); - if (result.opcoreDoctor !== undefined) validateOpcoreDoctorPayload(result.opcoreDoctor); - if (result.opcoreInit !== undefined) validateOpcoreInitPlanPayload(result.opcoreInit); - if (result.opcoreMeasure !== undefined) validateOpcoreMeasureDelta(result.opcoreMeasure); - if (result.opcoreTry !== undefined) validateOpcoreTryPayload(result.opcoreTry); - if (result.timing !== undefined) validateCommandTiming(result.timing); - if (result.repoState !== undefined && result.owner !== "runtime") { - throw new Error("Opcore repoState payload requires runtime owner"); - } - if (result.runtimeInfo !== undefined && result.owner !== "runtime") { - throw new Error("Opcore runtime info payload requires runtime owner"); - } - if (result.opcoreDoctor !== undefined && result.owner !== "runtime") { - throw new Error("Opcore doctor payload requires runtime owner"); - } - if (result.opcoreInit !== undefined && result.owner !== "runtime") { - throw new Error("Opcore init payload requires runtime owner"); - } - if (result.opcoreMeasure !== undefined && result.owner !== "runtime") { - throw new Error("Opcore measure payload requires runtime owner"); - } - if (result.opcoreTry !== undefined && result.owner !== "runtime") { - throw new Error("Opcore try payload requires runtime owner"); - } - if ((result.editPlan !== undefined || result.editResult !== undefined) && result.owner !== "edit") { - throw new Error("Edit router payloads require edit owner"); - } - if (result.owner === "edit" && result.status === "ok" && result.editPlan === undefined && result.editResult === undefined) { - const hiddenPayloadPattern = /"?(editPlan|editResult|planId|changes|afterState)"?\s*[:{[]/; - if (hiddenPayloadPattern.test(result.message)) { - throw new Error("Edit router payloads must use editPlan/editResult fields, not message strings"); - } - } - return result; -} - -export function validateOpcoreRepoStatePayload(payload: OpcoreRepoStatePayload): OpcoreRepoStatePayload { - if (!payload || typeof payload !== "object") { - throw new Error("Opcore repo state payload is required"); - } - if (payload.schemaVersion !== 1) { - throw new Error("Opcore repo state payload schemaVersion must be 1"); - } - if (!payload.repo || typeof payload.repo !== "object") { - throw new Error("Opcore repo state repo is required"); - } - validateNonEmptyString(payload.repo.root, "Opcore repo state repo root"); - validateNonEmptyString(payload.repo.requestedPath, "Opcore repo state requested path"); - if (!payload.repo.git || typeof payload.repo.git !== "object") { - throw new Error("Opcore repo state git payload is required"); - } - if (typeof payload.repo.git.available !== "boolean") { - throw new Error("Opcore repo state git available must be boolean"); - } - if (payload.repo.git.branch !== undefined) validateNonEmptyString(payload.repo.git.branch, "Opcore repo state git branch"); - for (const [key, value] of Object.entries(payload.repo.git)) { - if (key === "available" || key === "branch" || key === "clean") continue; - validateNonNegativeInteger(value, `Opcore repo state git ${key}`); - } - if (payload.repo.git.clean !== undefined && typeof payload.repo.git.clean !== "boolean") { - throw new Error("Opcore repo state git clean must be boolean"); - } - - if (!payload.coverage || typeof payload.coverage !== "object") { - throw new Error("Opcore repo state coverage is required"); - } - validateNonNegativeInteger(payload.coverage.totalFiles, "Opcore repo state coverage totalFiles"); - if (!Array.isArray(payload.coverage.languages)) { - throw new Error("Opcore repo state coverage languages must be an array"); - } - for (const language of payload.coverage.languages) { - validateNonEmptyString(language.language, "Opcore repo state language"); - validateNonNegativeInteger(language.files, "Opcore repo state language files"); - if (typeof language.graphSupported !== "boolean" || typeof language.validationSupported !== "boolean") { - throw new Error("Opcore repo state language support flags must be boolean"); - } - } - validateOpcoreCoverageCounts(payload.coverage.graph, "graph"); - validateOpcoreCoverageCounts(payload.coverage.validation, "validation"); - validateNonNegativeInteger(payload.coverage.validation.retainedFiles, "Opcore repo state validation retainedFiles"); - if (!payload.coverage.unsupported || typeof payload.coverage.unsupported !== "object") { - throw new Error("Opcore repo state unsupported coverage is required"); - } - validateNonNegativeInteger(payload.coverage.unsupported.totalFiles, "Opcore repo state unsupported totalFiles"); - if (!Array.isArray(payload.coverage.unsupported.stacks)) { - throw new Error("Opcore repo state unsupported stacks must be an array"); - } - for (const stack of payload.coverage.unsupported.stacks) { - validateNonEmptyString(stack.extension, "Opcore repo state unsupported extension"); - validateNonEmptyString(stack.language, "Opcore repo state unsupported language"); - validateNonNegativeInteger(stack.count, "Opcore repo state unsupported count"); - validateStringArray(stack.examples, "Opcore repo state unsupported examples", { allowEmpty: true }); - } - - if (!payload.graph || typeof payload.graph !== "object") { - throw new Error("Opcore repo state graph is required"); - } - if (!includesString(graphProviderStatusStates, payload.graph.state)) { - throw new Error(`Unknown Opcore repo state graph state: ${String(payload.graph.state)}`); - } - if (!includesString(graphProviderModes, payload.graph.mode)) { - throw new Error(`Unknown Opcore repo state graph mode: ${String(payload.graph.mode)}`); - } - validateNonEmptyString(payload.graph.provider, "Opcore repo state graph provider"); - validateNonEmptyString(payload.graph.action, "Opcore repo state graph action"); - if (payload.graph.message !== undefined) validateNonEmptyString(payload.graph.message, "Opcore repo state graph message"); - const graphStatus = validateProviderStatus(payload.graph.status); - if (graphStatus.state !== payload.graph.state || graphStatus.mode !== payload.graph.mode || graphStatus.provider !== payload.graph.provider) { - throw new Error("Opcore repo state graph summary must match provider status"); - } - - if (!payload.validation || typeof payload.validation !== "object") { - throw new Error("Opcore repo state validation is required"); - } - if (typeof payload.validation.ready !== "boolean") { - throw new Error("Opcore repo state validation ready must be boolean"); - } - validateNonNegativeInteger(payload.validation.checkCount, "Opcore repo state validation checkCount"); - validateOpcoreValidationPolicySummary(payload.validation.policy, "Opcore repo state validation policy"); - if (!Array.isArray(payload.validation.adapters)) { - throw new Error("Opcore repo state validation adapters must be an array"); - } - for (const adapter of payload.validation.adapters) { - validateNonEmptyString(adapter.adapter, "Opcore repo state validation adapter"); - if (!includesString(validationAdapterRuntimeStates, adapter.status)) { - throw new Error(`Unknown Opcore validation adapter status: ${String(adapter.status)}`); - } - validateNonNegativeInteger(adapter.checkCount, "Opcore repo state validation adapter checkCount"); - validateStringArray(adapter.degradedChecks, "Opcore repo state validation degradedChecks", { allowEmpty: true }); - validateStringArray(adapter.missingTools, "Opcore repo state validation missingTools", { allowEmpty: true }); - } - if (!Array.isArray(payload.validation.degradedToolchains)) { - throw new Error("Opcore repo state validation degradedToolchains must be an array"); - } - for (const tool of payload.validation.degradedToolchains) { - validateNonEmptyString(tool.adapter, "Opcore repo state validation degraded adapter"); - validateNonEmptyString(tool.tool, "Opcore repo state validation degraded tool"); - if (tool.failureMessage !== undefined) { - validateNonEmptyString(tool.failureMessage, "Opcore repo state validation degraded failureMessage"); - } - } - if (payload.validation.pythonProjectContexts !== undefined) { - validatePythonProjectContexts(payload.validation.pythonProjectContexts); - } - - if (!payload.activation || typeof payload.activation !== "object") { - throw new Error("Opcore repo state activation is required"); - } - if (typeof payload.activation.ready !== "boolean") { - throw new Error("Opcore repo state activation ready must be boolean"); - } - if (!includesString(["ready", "degraded", "blocked"] as const, payload.activation.level)) { - throw new Error(`Unknown Opcore activation level: ${String(payload.activation.level)}`); - } - validateNonEmptyString(payload.activation.summary, "Opcore repo state activation summary"); - if (!payload.activation.asp || typeof payload.activation.asp !== "object") { - throw new Error("Opcore repo state ASP status is required"); - } - if (!includesString(["enrolled", "not_enrolled"] as const, payload.activation.asp.state)) { - throw new Error(`Unknown Opcore ASP state: ${String(payload.activation.asp.state)}`); - } - validateStringArray(payload.activation.asp.paths, "Opcore repo state ASP paths", { allowEmpty: true }); - validateStringArray(payload.warnings, "Opcore repo state warnings", { allowEmpty: true }); - validateStringArray(payload.blockers, "Opcore repo state blockers", { allowEmpty: true }); - validateStringArray(payload.nextActions, "Opcore repo state nextActions", { allowEmpty: false }); - return payload; -} - -function validateOpcoreValidationPolicySummary( - summary: OpcoreValidationPolicySummary, - label: string -): OpcoreValidationPolicySummary { - if (!summary || typeof summary !== "object") { - throw new Error(`${label} is required`); - } - if (summary.path !== ".opcore/config") { - throw new Error(`${label} path must be .opcore/config`); - } - if (!includesString(["missing", "loaded"] as const, summary.state)) { - throw new Error(`Unknown ${label} state: ${String(summary.state)}`); - } - validateStringArray(summary.adapters, `${label} adapters`, { allowEmpty: true }); - validateStringArray(summary.packs, `${label} packs`, { allowEmpty: true }); - validateStringArray(summary.disabledChecks, `${label} disabledChecks`, { allowEmpty: true }); - validateStringArray(summary.defaultChecks, `${label} defaultChecks`, { allowEmpty: true }); - validateStringArray(summary.configuredChecks, `${label} configuredChecks`, { allowEmpty: true }); - return summary; -} - -export function validateOpcoreRuntimeInfoPayload(payload: OpcoreRuntimeInfoPayload): OpcoreRuntimeInfoPayload { - if (!payload || typeof payload !== "object") { - throw new Error("Opcore runtime info payload is required"); - } - if (payload.schemaVersion !== 1) { - throw new Error("Opcore runtime info schemaVersion must be 1"); - } - if (payload.packageName !== "opcore") { - throw new Error("Opcore runtime info packageName must be opcore"); - } - validateNonEmptyString(payload.version, "Opcore runtime info version"); - if (payload.bin !== "opcore") { - throw new Error("Opcore runtime info bin must be opcore"); - } - if (!includesString(opcoreRuntimeArtifactSources, payload.artifactSource)) { - throw new Error(`Unknown Opcore runtime artifact source: ${String(payload.artifactSource)}`); - } - validateNonEmptyString(payload.packageRoot, "Opcore runtime info packageRoot"); - validateNonEmptyString(payload.entrypoint, "Opcore runtime info entrypoint"); - return payload; -} - -export function validateOpcoreDoctorPayload(payload: OpcoreDoctorPayload): OpcoreDoctorPayload { - if (!payload || typeof payload !== "object") { - throw new Error("Opcore doctor payload is required"); - } - if (payload.schemaVersion !== 1) { - throw new Error("Opcore doctor payload schemaVersion must be 1"); - } - validateOpcoreRuntimeInfoPayload(payload.runtime); - if (!payload.repo || typeof payload.repo !== "object") { - throw new Error("Opcore doctor repo is required"); - } - validateNonEmptyString(payload.repo.root, "Opcore doctor repo root"); - validateNonEmptyString(payload.repo.requestedPath, "Opcore doctor repo requestedPath"); - if (!payload.config || typeof payload.config !== "object") { - throw new Error("Opcore doctor config is required"); - } - if (payload.config.path !== ".opcore/config") { - throw new Error("Opcore doctor config path must be .opcore/config"); - } - if (!includesString(["found", "missing", "unreadable"] as const, payload.config.state)) { - throw new Error(`Unknown Opcore doctor config state: ${String(payload.config.state)}`); - } - if (payload.config.message !== undefined) validateNonEmptyString(payload.config.message, "Opcore doctor config message"); - if (!payload.checks || typeof payload.checks !== "object") { - throw new Error("Opcore doctor checks are required"); - } - validateNonNegativeInteger(payload.checks.count, "Opcore doctor checks count"); - validateStringArray(payload.checks.ids, "Opcore doctor checks ids", { allowEmpty: false }); - if (payload.checks.ids.length !== payload.checks.count) { - throw new Error("Opcore doctor checks count must match ids length"); - } - validateOpcoreValidationPolicySummary(payload.policy, "Opcore doctor policy"); - validateProviderStatus(payload.graph); - if (!payload.generatedState || typeof payload.generatedState !== "object") { - throw new Error("Opcore doctor generatedState is required"); - } - validateStringArray(payload.generatedState.ignored, "Opcore doctor generatedState ignored", { allowEmpty: false }); - validateNonEmptyString(payload.generatedState.guidance, "Opcore doctor generatedState guidance"); - validateStringArray(payload.nextActions, "Opcore doctor nextActions", { allowEmpty: false }); - return payload; -} - -export function validateOpcoreInitPlanPayload(payload: OpcoreInitPlanPayload): OpcoreInitPlanPayload { - if (!payload || typeof payload !== "object") { - throw new Error("Opcore init payload is required"); - } - if (payload.schemaVersion !== 1) { - throw new Error("Opcore init payload schemaVersion must be 1"); - } - if (!includesString(["plan", "apply", "undo"] as const, payload.mode)) { - throw new Error(`Unknown Opcore init mode: ${String(payload.mode)}`); - } - if (typeof payload.approved !== "boolean") { - throw new Error("Opcore init approved must be boolean"); - } - if (payload.mode === "plan" && payload.approved) { - throw new Error("Opcore init approved plan must use apply mode"); - } - if (payload.mode === "apply" && !payload.approved) { - throw new Error("Opcore init apply mode requires approval"); - } - if (!payload.repo || typeof payload.repo !== "object") { - throw new Error("Opcore init repo is required"); - } - validateNonEmptyString(payload.repo.root, "Opcore init repo root"); - validateNonEmptyString(payload.repo.requestedPath, "Opcore init requested path"); - if (!payload.options || typeof payload.options !== "object") { - throw new Error("Opcore init options are required"); - } - if (!includesString(opcoreInitScopes, payload.options.scope)) { - throw new Error(`Unknown Opcore init scope: ${String(payload.options.scope)}`); - } - if (typeof payload.options.failClosedHook !== "boolean") { - throw new Error("Opcore init failClosedHook option must be boolean"); - } - if (typeof payload.options.dryRun !== "boolean") { - throw new Error("Opcore init dryRun option must be boolean"); - } - validateStringArray(payload.agentFiles, "Opcore init agentFiles", { allowEmpty: true }); - for (const agentFile of payload.agentFiles) validateRepoRelativePath(agentFile); - validateNonEmptyArray(payload.actions, "Opcore init actions"); - for (const action of payload.actions) validateOpcoreInitAction(action); - validateStringArray(payload.warnings, "Opcore init warnings", { allowEmpty: true }); - validateStringArray(payload.nextActions, "Opcore init nextActions", { allowEmpty: false }); - if (typeof payload.undoAvailable !== "boolean") { - throw new Error("Opcore init undoAvailable must be boolean"); - } - validateOpcoreInitScanSummary(payload.scan); - validateOpcoreInitSettings(payload.settings); - validateOpcoreInitInteraction(payload.interaction); - validateOpcoreInitTiming(payload.timings); - return payload; -} - -function validateOpcoreInitScanSummary(scan: OpcoreInitScanSummary): OpcoreInitScanSummary { - if (!scan || typeof scan !== "object") { - throw new Error("Opcore init scan summary is required"); - } - validateNonNegativeInteger(scan.totalFiles, "Opcore init scan totalFiles"); - validateNonNegativeInteger(scan.graphSupportedFiles, "Opcore init scan graphSupportedFiles"); - validateNonNegativeInteger(scan.validationSupportedFiles, "Opcore init scan validationSupportedFiles"); - validateNonNegativeInteger(scan.validationRetainedFiles, "Opcore init scan validationRetainedFiles"); - validateNonNegativeInteger(scan.unsupportedFiles, "Opcore init scan unsupportedFiles"); - validateOpcoreCoverageLanguages(scan.languages, "Opcore init scan"); - validateOpcoreUnsupportedStacks(scan.unsupportedStacks, "Opcore init scan"); - if (!Array.isArray(scan.degradedRustTools)) { - throw new Error("Opcore init scan degradedRustTools must be an array"); - } - for (const tool of scan.degradedRustTools) { - if (!tool || typeof tool !== "object") { - throw new Error("Opcore init scan degraded Rust tool is required"); - } - validateNonEmptyString(tool.adapter, "Opcore init scan degraded Rust adapter"); - validateNonEmptyString(tool.tool, "Opcore init scan degraded Rust tool"); - if (tool.failureMessage !== undefined) validateNonEmptyString(tool.failureMessage, "Opcore init scan degraded Rust failureMessage"); - } - validateNonNegativeInteger(scan.diagnosticCount, "Opcore init scan diagnosticCount"); - if (!includesString(validationResultStatuses, scan.validationStatus)) { - throw new Error(`Unknown Opcore init scan validationStatus: ${String(scan.validationStatus)}`); - } - validateStringArray(scan.failedChecks, "Opcore init scan failedChecks", { allowEmpty: true }); - if (!includesString(graphProviderStatusStates, scan.graphState)) { - throw new Error(`Unknown Opcore init scan graphState: ${String(scan.graphState)}`); - } - if (!includesString(["ready", "degraded", "blocked"] as const, scan.activationLevel)) { - throw new Error(`Unknown Opcore init scan activationLevel: ${String(scan.activationLevel)}`); - } - return scan; -} - -function validateOpcoreInitSettings(settings: OpcoreInitSettings): OpcoreInitSettings { - if (!settings || typeof settings !== "object") { - throw new Error("Opcore init settings are required"); - } - if (!Array.isArray(settings.languages)) { - throw new Error("Opcore init settings languages must be an array"); - } - for (const language of settings.languages) { - validateOpcoreInitLanguageSetting(language); - } - if (settings.python !== undefined) validateOpcoreInitPythonEnvironment(settings.python); - return settings; -} - -function validateOpcoreInitLanguageSetting(setting: OpcoreInitLanguageSetting): OpcoreInitLanguageSetting { - if (!setting || typeof setting !== "object") { - throw new Error("Opcore init language setting is required"); - } - validateNonEmptyString(setting.language, "Opcore init language setting language"); - validateNonNegativeInteger(setting.files, "Opcore init language setting files"); - if (!includesString(["supported", "retained", "unsupported", "degraded"] as const, setting.state)) { - throw new Error(`Unknown Opcore init language setting state: ${String(setting.state)}`); - } - if (!includesString(["supported", "unsupported"] as const, setting.graph)) { - throw new Error(`Unknown Opcore init language setting graph: ${String(setting.graph)}`); - } - if (!includesString(["supported", "retained", "unsupported", "degraded"] as const, setting.validation)) { - throw new Error(`Unknown Opcore init language setting validation: ${String(setting.validation)}`); - } - validateValidationChecks(setting.checks, "Opcore init language setting checks"); - validateStringArray(setting.notes, "Opcore init language setting notes", { allowEmpty: true }); - return setting; -} - -function validateOpcoreInitPythonEnvironment(environment: OpcoreInitPythonEnvironment): OpcoreInitPythonEnvironment { - if (!environment || typeof environment !== "object") { - throw new Error("Opcore init Python environment is required"); - } - if (!Array.isArray(environment.dependencyManagers)) { - throw new Error("Opcore init Python dependencyManagers must be an array"); - } - for (const manager of environment.dependencyManagers) { - if (!manager || typeof manager !== "object") { - throw new Error("Opcore init Python dependency manager is required"); - } - if (!includesString(["pyproject", "requirements", "pipfile", "poetry", "uv"] as const, manager.kind)) { - throw new Error(`Unknown Opcore init Python dependency manager kind: ${String(manager.kind)}`); - } - validateRepoRelativePath(manager.path); - } - if (!Array.isArray(environment.virtualEnvironments)) { - throw new Error("Opcore init Python virtualEnvironments must be an array"); - } - for (const virtualEnvironment of environment.virtualEnvironments) { - if (!virtualEnvironment || typeof virtualEnvironment !== "object") { - throw new Error("Opcore init Python virtual environment is required"); - } - if (virtualEnvironment.kind !== "venv") { - throw new Error(`Unknown Opcore init Python virtual environment kind: ${String(virtualEnvironment.kind)}`); - } - validateRepoRelativePath(virtualEnvironment.path); - } - validateStringArray(environment.notes, "Opcore init Python environment notes", { allowEmpty: true }); - if (environment.contexts !== undefined) validatePythonProjectContexts(environment.contexts); - return environment; -} - -function validateOpcoreInitInteraction(interaction: OpcoreInitInteraction): OpcoreInitInteraction { - if (!interaction || typeof interaction !== "object") { - throw new Error("Opcore init interaction is required"); - } - if (typeof interaction.tty !== "boolean") { - throw new Error("Opcore init interaction tty must be boolean"); - } - if (!includesString(["not_requested", "requested", "approved", "declined"] as const, interaction.promptState)) { - throw new Error(`Unknown Opcore init interaction promptState: ${String(interaction.promptState)}`); - } - return interaction; -} - -function validateOpcoreInitTiming(timing: OpcoreInitTiming): OpcoreInitTiming { - if (!timing || typeof timing !== "object") { - throw new Error("Opcore init timings are required"); - } - validateNonNegativeNumber(timing.scanMs, "Opcore init timing scanMs"); - validateNonNegativeNumber(timing.planMs, "Opcore init timing planMs"); - validateNonNegativeNumber(timing.promptMs, "Opcore init timing promptMs"); - validateNonNegativeNumber(timing.applyMs, "Opcore init timing applyMs"); - validateNonNegativeNumber(timing.totalMs, "Opcore init timing totalMs"); - validateNonNegativeNumber(timing.firstOutputMs, "Opcore init timing firstOutputMs"); - return timing; -} - -export function validateCommandTiming(timing: CommandTiming): CommandTiming { - assertNoOpaqueScoreFields(timing, "Command timing"); - assertNoTelemetrySourceFields(timing, "Command timing"); - if (!timing || typeof timing !== "object") { - throw new Error("Command timing is required"); - } - validateNonNegativeNumber(timing.durationMs, "Command timing durationMs"); - if (!Array.isArray(timing.phases)) { - throw new Error("Command timing phases must be an array"); - } - for (const phase of timing.phases) validateCommandTimingPhase(phase); - if (!includesString(commandTimingProcessStates, timing.processState)) { - throw new Error(`Unknown command timing processState: ${String(timing.processState)}`); - } - if (timing.degradations !== undefined) { - validateStringArray(timing.degradations, "Command timing degradations", { allowEmpty: true }); - for (const degradation of timing.degradations) { - if (!includesString(commandTimingDegradationReasons, degradation)) { - throw new Error(`Unknown command timing degradation: ${String(degradation)}`); - } - } - } - return timing; -} - -export function validateRepoShapeFingerprint(fingerprint: RepoShapeFingerprint): RepoShapeFingerprint { - assertNoOpaqueScoreFields(fingerprint, "Repo shape fingerprint"); - assertNoTelemetrySourceFields(fingerprint, "Repo shape fingerprint"); - if (!fingerprint || typeof fingerprint !== "object") { - throw new Error("Repo shape fingerprint is required"); - } - validateNonNegativeInteger(fingerprint.totalFiles, "Repo shape fingerprint totalFiles"); - if (!Array.isArray(fingerprint.languages)) { - throw new Error("Repo shape fingerprint languages must be an array"); - } - for (const language of fingerprint.languages) { - validateNonEmptyString(language.language, "Repo shape fingerprint language"); - validateNonNegativeInteger(language.files, "Repo shape fingerprint language files"); - } - if (!fingerprint.graph || typeof fingerprint.graph !== "object") { - throw new Error("Repo shape fingerprint graph is required"); - } - validateNonNegativeInteger(fingerprint.graph.supportedFiles, "Repo shape fingerprint graph supportedFiles"); - validateNonNegativeInteger(fingerprint.graph.unsupportedFiles, "Repo shape fingerprint graph unsupportedFiles"); - if (!fingerprint.git || typeof fingerprint.git !== "object") { - throw new Error("Repo shape fingerprint git is required"); - } - if (typeof fingerprint.git.available !== "boolean") { - throw new Error("Repo shape fingerprint git available must be boolean"); - } - if (fingerprint.git.clean !== undefined && typeof fingerprint.git.clean !== "boolean") { - throw new Error("Repo shape fingerprint git clean must be boolean"); - } - return fingerprint; -} - -export function validateCommandLatencyRecord(record: CommandLatencyRecord): CommandLatencyRecord { - assertNoOpaqueScoreFields(record, "Command latency record"); - assertNoTelemetrySourceFields(record, "Command latency record"); - if (!record || typeof record !== "object") { - throw new Error("Command latency record is required"); - } - if (record.schemaVersion !== 1) { - throw new Error("Command latency record schemaVersion must be 1"); - } - validateNonEmptyString(record.recordedAt, "Command latency record recordedAt"); - validateLatencyTelemetryCommandBin(record.bin, "Command latency record bin"); - validateLatencyCanonicalCommand(record.canonicalCommand, "Command latency record canonicalCommand"); - validateCommandOwner(record.owner); - const status = validateCommandRouteStatus(record.status); - validateExitCodeForStatus(record.exitCode, status); - validateRepoShapeFingerprint(record.repo); - validateCommandTiming(record.timing); - validateNonEmptyString(record.opcoreVersion, "Command latency record opcoreVersion"); - return record; -} - -export function validateLatencyBudget(budget: LatencyBudget): LatencyBudget { - assertNoOpaqueScoreFields(budget, "Latency budget"); - assertNoTelemetrySourceFields(budget, "Latency budget"); - if (!budget || typeof budget !== "object") { - throw new Error("Latency budget is required"); - } - if (budget.schemaVersion !== 1) { - throw new Error("Latency budget schemaVersion must be 1"); - } - validateLatencyCanonicalCommand(budget.canonicalCommand, "Latency budget canonicalCommand"); - validateLatencyStableId(budget.scope, "Latency budget scope"); - validateLatencyStableId(budget.repoShapeBucket, "Latency budget repoShapeBucket"); - validateNonNegativeNumber(budget.budgetMs, "Latency budget budgetMs"); - if (budget.phaseBudgets !== undefined) { - if (!Array.isArray(budget.phaseBudgets)) { - throw new Error("Latency budget phaseBudgets must be an array"); - } - const phases = new Set(); - for (const phaseBudget of budget.phaseBudgets) { - const validatedPhaseBudget = validateLatencyPhaseBudget(phaseBudget); - if (phases.has(validatedPhaseBudget.phase)) { - throw new Error("Latency budget phaseBudgets must not include duplicate phases"); - } - phases.add(validatedPhaseBudget.phase); - } - } - return budget; -} - -export function validateLatencyBudgetResult(result: LatencyBudgetResult): LatencyBudgetResult { - assertNoOpaqueScoreFields(result, "Latency budget result"); - assertNoTelemetrySourceFields(result, "Latency budget result"); - if (!result || typeof result !== "object") { - throw new Error("Latency budget result is required"); - } - if (result.schemaVersion !== 1) { - throw new Error("Latency budget result schemaVersion must be 1"); - } - if (!includesString(latencyBudgetResultStatuses, result.status)) { - throw new Error(`Unknown latency budget result status: ${String(result.status)}`); - } - const budget = validateLatencyBudget(result.budget); - if (!result.observed || typeof result.observed !== "object") { - throw new Error("Latency budget result observed is required"); - } - validateLatencyCanonicalCommand(result.observed.canonicalCommand, "Latency budget result observed canonicalCommand"); - validateLatencyStableId(result.observed.phase, "Latency budget result observed phase"); - validateNonNegativeNumber(result.observed.durationMs, "Latency budget result observed durationMs"); - if (!result.evidence || typeof result.evidence !== "object") { - throw new Error("Latency budget result evidence is required"); - } - validateLatencyCanonicalCommand(result.evidence.canonicalCommand, "Latency budget result evidence canonicalCommand"); - validateLatencyStableId(result.evidence.phase, "Latency budget result evidence phase"); - validateLatencyStableId(result.evidence.repoShapeBucket, "Latency budget result evidence repoShapeBucket"); - validateNonNegativeNumber(result.evidence.observedMs, "Latency budget result evidence observedMs"); - validateNonNegativeNumber(result.evidence.budgetMs, "Latency budget result evidence budgetMs"); - validateNonNegativeNumber(result.evidence.overByMs, "Latency budget result evidence overByMs"); - if (!sameStringArray(result.observed.canonicalCommand, result.evidence.canonicalCommand)) { - throw new Error("Latency budget result observed and evidence commands must match"); - } - if (!sameStringArray(budget.canonicalCommand, result.evidence.canonicalCommand)) { - throw new Error("Latency budget result evidence command must match budget command"); - } - if (result.observed.phase !== result.evidence.phase) { - throw new Error("Latency budget result observed and evidence phases must match"); - } - if (budget.repoShapeBucket !== result.evidence.repoShapeBucket) { - throw new Error("Latency budget result evidence bucket must match budget bucket"); - } - if (result.observed.durationMs !== result.evidence.observedMs) { - throw new Error("Latency budget result observed duration must match evidence observedMs"); - } - const appliedBudgetMs = resolveLatencyAppliedBudgetMs(budget, result.evidence.phase); - if (result.evidence.budgetMs !== appliedBudgetMs) { - throw new Error("Latency budget result evidence budgetMs must match the applied budget"); - } - const computedOverByMs = Math.max(0, result.evidence.observedMs - appliedBudgetMs); - if (result.evidence.overByMs !== computedOverByMs) { - throw new Error("Latency budget result overByMs must equal observedMs over budgetMs"); - } - if (result.status === "pass" && result.evidence.overByMs !== 0) { - throw new Error("Latency budget pass result must not exceed budget"); - } - if (result.status === "over" && result.evidence.overByMs <= 0) { - throw new Error("Latency budget over result must exceed budget"); - } - return result; -} - -function validateOpcoreInitAction(action: OpcoreInitAction): OpcoreInitAction { - if (!action || typeof action !== "object") { - throw new Error("Opcore init action is required"); - } - if (!includesString(["write", "upsert_block", "create_hook", "wire_harness", "restore", "remove"] as const, action.kind)) { - throw new Error(`Unknown Opcore init action kind: ${String(action.kind)}`); - } - if (!includesString(opcoreInitScopes, action.targetScope)) { - throw new Error(`Unknown Opcore init action targetScope: ${String(action.targetScope)}`); - } - const rawPath = validateNonEmptyString(action.path, "Opcore init action path"); - const path = action.targetScope === "global" ? validateHomeRelativePath(rawPath) : validateRepoRelativePath(rawPath); - validateNonEmptyString(action.summary, "Opcore init action summary"); - if (typeof action.requiresApproval !== "boolean") { - throw new Error("Opcore init action requiresApproval must be boolean"); - } - if (typeof action.outsideOpcore !== "boolean") { - throw new Error("Opcore init action outsideOpcore must be boolean"); - } - const insideOpcore = action.targetScope === "global" - ? path === "~/.opcore" || path.startsWith("~/.opcore/") - : path === ".opcore" || path.startsWith(".opcore/"); - if (action.outsideOpcore === insideOpcore) { - throw new Error("Opcore init action outsideOpcore must match action path"); - } - if (action.outsideOpcore && !action.requiresApproval) { - throw new Error("Opcore init action outside .opcore requires approval"); - } - return action; -} - -export function validateOpcoreMetricReport(report: OpcoreMetricReport): OpcoreMetricReport { - assertNoOpaqueScoreFields(report, "Opcore metric report"); - if (!report || typeof report !== "object") { - throw new Error("Opcore metric report is required"); - } - if (report.schemaVersion !== 1) { - throw new Error("Opcore metric report schemaVersion must be 1"); - } - if (report.kind !== "opcore_metric_report") { - throw new Error("Opcore metric report kind must be opcore_metric_report"); - } - validateNonEmptyString(report.generatedAt, "Opcore metric report generatedAt"); - if (!report.repo || typeof report.repo !== "object") { - throw new Error("Opcore metric report repo is required"); - } - validateNonEmptyString(report.repo.root, "Opcore metric report repo root"); - validateNonEmptyString(report.repo.requestedPath, "Opcore metric report repo requestedPath"); - if (!report.repo.git || typeof report.repo.git !== "object") { - throw new Error("Opcore metric report repo git is required"); - } - if (typeof report.repo.git.available !== "boolean") { - throw new Error("Opcore metric report repo git available must be boolean"); - } - if (report.repo.git.branch !== undefined) validateNonEmptyString(report.repo.git.branch, "Opcore metric report git branch"); - for (const [key, value] of Object.entries(report.repo.git)) { - if (key === "available" || key === "branch" || key === "clean") continue; - validateNonNegativeInteger(value, `Opcore metric report git ${key}`); - } - if (report.repo.git.clean !== undefined && typeof report.repo.git.clean !== "boolean") { - throw new Error("Opcore metric report git clean must be boolean"); - } - validateOpcoreMetricCoverage(report.coverage, "Opcore metric report coverage"); - if (!report.graph || typeof report.graph !== "object") { - throw new Error("Opcore metric report graph is required"); - } - if (!includesString(graphProviderStatusStates, report.graph.state)) { - throw new Error(`Unknown Opcore metric graph state: ${String(report.graph.state)}`); - } - if (!includesString(graphProviderModes, report.graph.mode)) { - throw new Error(`Unknown Opcore metric graph mode: ${String(report.graph.mode)}`); - } - validateNonEmptyString(report.graph.provider, "Opcore metric report graph provider"); - if (!report.validation || typeof report.validation !== "object") { - throw new Error("Opcore metric report validation is required"); - } - if (report.validation.status !== undefined && !includesString(validationResultStatuses, report.validation.status)) { - throw new Error(`Unknown Opcore metric validation status: ${String(report.validation.status)}`); - } - validateNonNegativeInteger(report.validation.diagnosticCount, "Opcore metric report diagnosticCount"); - validateNonNegativeInteger(report.validation.checkCount, "Opcore metric report checkCount"); - if (report.validation.policy !== undefined) { - validateOpcoreValidationPolicySummary(report.validation.policy, "Opcore metric report validation policy"); - } - if (report.validation.pythonProjectContexts !== undefined) { - validatePythonProjectContexts(report.validation.pythonProjectContexts); - } - if (!Array.isArray(report.signals)) { - throw new Error("Opcore metric report signals must be an array"); - } - for (const signal of report.signals) validateOpcoreMetricSignal(signal); - if (!Array.isArray(report.degradations)) { - throw new Error("Opcore metric report degradations must be an array"); - } - for (const degradation of report.degradations) validateOpcoreMetricDegradation(degradation); - validateStringArray(report.warnings, "Opcore metric report warnings", { allowEmpty: true }); - validateStringArray(report.nextActions, "Opcore metric report nextActions", { allowEmpty: false }); - return report; -} - -export function validateOpcoreMetricHistoryEntry(entry: OpcoreMetricHistoryEntry): OpcoreMetricHistoryEntry { - assertNoOpaqueScoreFields(entry, "Opcore metric history entry"); - if (!entry || typeof entry !== "object") { - throw new Error("Opcore metric history entry is required"); - } - if (entry.schemaVersion !== 1) { - throw new Error("Opcore metric history entry schemaVersion must be 1"); - } - if (entry.kind !== "opcore_metric_history_entry") { - throw new Error("Opcore metric history entry kind must be opcore_metric_history_entry"); - } - validateNonEmptyString(entry.recordedAt, "Opcore metric history entry recordedAt"); - validateOpcoreMetricReport(entry.report); - return entry; -} - -export function validateOpcoreMeasureDelta(delta: OpcoreMeasureDelta): OpcoreMeasureDelta { - assertNoOpaqueScoreFields(delta, "Opcore measure delta"); - if (!delta || typeof delta !== "object") { - throw new Error("Opcore measure delta is required"); - } - if (delta.schemaVersion !== 1) { - throw new Error("Opcore measure delta schemaVersion must be 1"); - } - if (delta.kind !== "opcore_measure_delta") { - throw new Error("Opcore measure delta kind must be opcore_measure_delta"); - } - validateNonEmptyString(delta.generatedAt, "Opcore measure delta generatedAt"); - if (!delta.current || typeof delta.current !== "object") { - throw new Error("Opcore measure delta current is required"); - } - validateNonEmptyString(delta.current.generatedAt, "Opcore measure delta current generatedAt"); - validateOpcoreMetricCoverage(delta.current.coverage, "Opcore measure delta current coverage"); - validateOpcoreMeasureSignalCounts(delta.current.signals, "Opcore measure delta current signals"); - if (delta.latency !== undefined) validateOpcoreMeasureLatencyReport(delta.latency); - if (delta.baseline !== undefined) validateOpcoreMeasureComparison(delta.baseline, "baseline"); - if (delta.previous !== undefined) validateOpcoreMeasureComparison(delta.previous, "previous"); - validateStringArray(delta.warnings, "Opcore measure delta warnings", { allowEmpty: true }); - if (!Array.isArray(delta.degradations)) { - throw new Error("Opcore measure delta degradations must be an array"); - } - for (const degradation of delta.degradations) validateOpcoreMetricDegradation(degradation); - validateStringArray(delta.nextActions, "Opcore measure delta nextActions", { allowEmpty: false }); - return delta; -} - -export function validateOpcoreTryPayload(payload: OpcoreTryPayload): OpcoreTryPayload { - assertNoOpaqueScoreFields(payload, "Opcore try payload"); - if (!payload || typeof payload !== "object") { - throw new Error("Opcore try payload is required"); - } - if (payload.schemaVersion !== 1) { - throw new Error("Opcore try payload schemaVersion must be 1"); - } - validateNonEmptyString(payload.sampleRoot, "Opcore try sampleRoot"); - if (payload.published !== false) { - throw new Error("Opcore try published must be false"); - } - validateNonEmptyArray(payload.scenarios, "Opcore try scenarios"); - for (const scenario of payload.scenarios) validateOpcoreTryScenario(scenario); - validateNonEmptyArray(payload.commands, "Opcore try commands"); - for (const command of payload.commands) validateOpcoreTryCommand(command); - return payload; -} - -function validateOpcoreTryScenario(scenario: OpcoreTryScenario): OpcoreTryScenario { - if (!scenario || typeof scenario !== "object") { - throw new Error("Opcore try scenario is required"); - } - validateNonEmptyString(scenario.id, "Opcore try scenario id"); - validateNonEmptyString(scenario.repoRoot, "Opcore try scenario repoRoot"); - validateNonEmptyString(scenario.title, "Opcore try scenario title"); - validateStringArray(scenario.commands, "Opcore try scenario commands", { allowEmpty: false }); - if (!scenario.coverage || typeof scenario.coverage !== "object") { - throw new Error("Opcore try scenario coverage is required"); - } - validateNonNegativeInteger(scenario.coverage.totalFiles, "Opcore try scenario totalFiles"); - validateNonNegativeInteger(scenario.coverage.validationSupportedFiles, "Opcore try scenario validationSupportedFiles"); - validateNonNegativeInteger(scenario.coverage.unsupportedFiles, "Opcore try scenario unsupportedFiles"); - if (!Array.isArray(scenario.signals)) { - throw new Error("Opcore try scenario signals must be an array"); - } - for (const signal of scenario.signals) validateOpcoreTrySignal(signal); - return scenario; -} - -function validateOpcoreTrySignal(signal: OpcoreTrySignalSummary): OpcoreTrySignalSummary { - if (!signal || typeof signal !== "object") { - throw new Error("Opcore try signal is required"); - } - validateNonEmptyString(signal.id, "Opcore try signal id"); - validateNonEmptyString(signal.title, "Opcore try signal title"); - validateNonNegativeInteger(signal.count, "Opcore try signal count"); - if (!Number.isInteger(signal.delta)) { - throw new Error("Opcore try signal delta must be an integer"); - } - return signal; -} - -function validateOpcoreTryCommand(command: OpcoreTryCommandSummary): OpcoreTryCommandSummary { - if (!command || typeof command !== "object") { - throw new Error("Opcore try command is required"); - } - validateNonEmptyString(command.scenarioId, "Opcore try command scenarioId"); - validateStringArray(command.command, "Opcore try command", { allowEmpty: false }); - validateStringArray(command.canonicalCommand, "Opcore try command canonicalCommand", { allowEmpty: false }); - validateCommandOwner(command.owner); - validateCommandRouteStatus(command.status); - validateExitCodeForStatus(command.exitCode, command.status); - return command; -} - -function validateOpcoreMetricCoverage(coverage: OpcoreRepoStatePayload["coverage"], label: string): void { - if (!coverage || typeof coverage !== "object") { - throw new Error(`${label} is required`); - } - validateNonNegativeInteger(coverage.totalFiles, `${label} totalFiles`); - validateOpcoreCoverageLanguages(coverage.languages, label); - validateOpcoreCoverageCounts(coverage.graph, "graph"); - validateOpcoreCoverageCounts(coverage.validation, "validation"); - validateNonNegativeInteger(coverage.validation.retainedFiles, `${label} validation retainedFiles`); - validateOpcoreUnsupportedSection(coverage.unsupported, label); -} - -function validateOpcoreCoverageLanguages( - languages: OpcoreRepoStatePayload["coverage"]["languages"], - label: string -): void { - if (!Array.isArray(languages)) { - throw new Error(`${label} languages must be an array`); - } - for (const language of languages) { - validateNonEmptyString(language.language, `${label} language`); - validateNonNegativeInteger(language.files, `${label} language files`); - if (typeof language.graphSupported !== "boolean" || typeof language.validationSupported !== "boolean") { - throw new Error(`${label} language support flags must be boolean`); - } - } -} - -function validateOpcoreUnsupportedSection( - unsupported: OpcoreRepoStatePayload["coverage"]["unsupported"], - label: string -): void { - if (!unsupported || typeof unsupported !== "object") { - throw new Error(`${label} unsupported is required`); - } - validateNonNegativeInteger(unsupported.totalFiles, `${label} unsupported totalFiles`); - validateOpcoreUnsupportedStacks(unsupported.stacks, label); -} - -function validateOpcoreUnsupportedStacks( - stacks: OpcoreRepoStatePayload["coverage"]["unsupported"]["stacks"], - label: string -): void { - if (!Array.isArray(stacks)) { - throw new Error(`${label} unsupported stacks must be an array`); - } - for (const stack of stacks) { - validateNonEmptyString(stack.extension, `${label} unsupported extension`); - validateNonEmptyString(stack.language, `${label} unsupported language`); - validateNonNegativeInteger(stack.count, `${label} unsupported count`); - validateStringArray(stack.examples, `${label} unsupported examples`, { allowEmpty: true }); - } -} - -function validateOpcoreMetricSignal(signal: OpcoreMetricSignal): OpcoreMetricSignal { - if (!signal || typeof signal !== "object") { - throw new Error("Opcore metric signal is required"); - } - validateNonEmptyString(signal.id, "Opcore metric signal id"); - validateNonEmptyString(signal.title, "Opcore metric signal title"); - validateNonEmptyString(signal.category, "Opcore metric signal category"); - if (!includesString(["info", "warning", "error"] as const, signal.severity)) { - throw new Error(`Unknown Opcore metric signal severity: ${String(signal.severity)}`); - } - if (!Number.isInteger(signal.count) || signal.count <= 0) { - throw new Error("Opcore metric signal count must be a positive integer"); - } - if (!Array.isArray(signal.evidence) || signal.evidence.length === 0) { - throw new Error("Opcore metric signal evidence must be a non-empty array"); - } - for (const evidence of signal.evidence) validateOpcoreMetricEvidence(evidence); - return signal; -} - -function validateOpcoreMetricEvidence(evidence: OpcoreMetricEvidence): OpcoreMetricEvidence { - if (!evidence || typeof evidence !== "object") { - throw new Error("Opcore metric evidence is required"); - } - validateNonEmptyString(evidence.source, "Opcore metric evidence source"); - validateRepoRelativePath(validateNonEmptyString(evidence.path, "Opcore metric evidence path")); - validateNonEmptyString(evidence.message, "Opcore metric evidence message"); - if (evidence.checkId !== undefined) validateValidationCheckId(evidence.checkId, "Opcore metric evidence checkId"); - if (evidence.code !== undefined) validateNonEmptyString(evidence.code, "Opcore metric evidence code"); - if (evidence.line !== undefined && (!Number.isInteger(evidence.line) || evidence.line < 1)) { - throw new Error("Opcore metric evidence line must be a positive integer"); - } - if (evidence.column !== undefined && (!Number.isInteger(evidence.column) || evidence.column < 1)) { - throw new Error("Opcore metric evidence column must be a positive integer"); - } - return evidence; -} - -function validateOpcoreMetricDegradation(degradation: OpcoreMetricDegradation): OpcoreMetricDegradation { - if (!degradation || typeof degradation !== "object") { - throw new Error("Opcore metric degradation is required"); - } - validateNonEmptyString(degradation.id, "Opcore metric degradation id"); - validateNonEmptyString(degradation.title, "Opcore metric degradation title"); - validateNonEmptyString(degradation.source, "Opcore metric degradation source"); - if (!includesString(["info", "warning", "error"] as const, degradation.severity)) { - throw new Error(`Unknown Opcore metric degradation severity: ${String(degradation.severity)}`); - } - validateNonEmptyString(degradation.message, "Opcore metric degradation message"); - if (degradation.checkId !== undefined) validateValidationCheckId(degradation.checkId, "Opcore metric degradation checkId"); - if (degradation.requiredTool !== undefined) { - validateNonEmptyString(degradation.requiredTool, "Opcore metric degradation requiredTool"); - } - return degradation; -} - -function validateOpcoreMeasureLatencyReport(report: OpcoreMeasureLatencyReport): OpcoreMeasureLatencyReport { - assertNoOpaqueScoreFields(report, "Opcore measure latency report"); - assertNoTelemetrySourceFields(report, "Opcore measure latency report"); - if (!report || typeof report !== "object") { - throw new Error("Opcore measure latency report is required"); - } - if (report.kind !== "opcore_latency_report") { - throw new Error("Opcore measure latency report kind must be opcore_latency_report"); - } - validateNonNegativeInteger(report.recordCount, "Opcore measure latency report recordCount"); - validateNonNegativeInteger(report.budgetCount, "Opcore measure latency report budgetCount"); - if (!Array.isArray(report.findings)) { - throw new Error("Opcore measure latency report findings must be an array"); - } - for (const finding of report.findings) validateOpcoreMeasureLatencyFinding(finding); - return report; -} - -function validateOpcoreMeasureLatencyFinding(finding: OpcoreMeasureLatencyFinding): OpcoreMeasureLatencyFinding { - assertNoOpaqueScoreFields(finding, "Opcore measure latency finding"); - assertNoTelemetrySourceFields(finding, "Opcore measure latency finding"); - if (!finding || typeof finding !== "object") { - throw new Error("Opcore measure latency finding is required"); - } - validateLatencyCanonicalCommand(finding.canonicalCommand, "Opcore measure latency finding canonicalCommand"); - validateLatencyStableId(finding.repoShapeBucket, "Opcore measure latency finding repoShapeBucket"); - if (!includesString(commandTimingProcessStates, finding.processState)) { - throw new Error(`Unknown Opcore measure latency processState: ${String(finding.processState)}`); - } - if (!includesString(opcoreMeasureLatencyFindingStatuses, finding.status)) { - throw new Error(`Unknown Opcore measure latency status: ${String(finding.status)}`); - } - validateNonNegativeNumber(finding.currentDurationMs, "Opcore measure latency finding currentDurationMs"); - if (finding.dominantPhase !== undefined) validateOpcoreMeasureLatencyPhase(finding.dominantPhase); - if (finding.baselineDurationMs !== undefined) validateNonNegativeNumber(finding.baselineDurationMs, "Opcore measure latency finding baselineDurationMs"); - if (finding.previousDurationMs !== undefined) validateNonNegativeNumber(finding.previousDurationMs, "Opcore measure latency finding previousDurationMs"); - if (finding.baselineDeltaMs !== undefined && !Number.isFinite(finding.baselineDeltaMs)) { - throw new Error("Opcore measure latency finding baselineDeltaMs must be a finite number"); - } - if (finding.previousDeltaMs !== undefined && !Number.isFinite(finding.previousDeltaMs)) { - throw new Error("Opcore measure latency finding previousDeltaMs must be a finite number"); - } - if (finding.budgetMs !== undefined) validateNonNegativeNumber(finding.budgetMs, "Opcore measure latency finding budgetMs"); - if (finding.overBudgetMs !== undefined) validateNonNegativeNumber(finding.overBudgetMs, "Opcore measure latency finding overBudgetMs"); - if (finding.status === "over_budget" && (finding.overBudgetMs ?? 0) <= 0) { - throw new Error("Opcore measure latency over_budget finding must include overBudgetMs"); - } - return finding; -} - -function validateOpcoreMeasureLatencyPhase(phase: OpcoreMeasureLatencyPhase): OpcoreMeasureLatencyPhase { - if (!phase || typeof phase !== "object") { - throw new Error("Opcore measure latency phase is required"); - } - validateLatencyStableId(phase.phase, "Opcore measure latency phase"); - validateNonNegativeNumber(phase.durationMs, "Opcore measure latency phase durationMs"); - return phase; -} - -function validateOpcoreMeasureComparison(comparison: OpcoreMeasureComparison, label: string): OpcoreMeasureComparison { - if (!comparison || typeof comparison !== "object") { - throw new Error(`Opcore measure delta ${label} comparison is required`); - } - validateNonEmptyString(comparison.recordedAt, `Opcore measure delta ${label} recordedAt`); - validateNonEmptyString(comparison.generatedAt, `Opcore measure delta ${label} generatedAt`); - validateOpcoreMetricCoverage(comparison.coverage, `Opcore measure delta ${label} coverage`); - validateOpcoreMeasureSignalCounts(comparison.signals, `Opcore measure delta ${label} signals`); - if (!Array.isArray(comparison.deltas)) { - throw new Error(`Opcore measure delta ${label} deltas must be an array`); - } - for (const entry of comparison.deltas) validateOpcoreMeasureSignalDelta(entry, label); - return comparison; -} - -function validateOpcoreMeasureSignalCounts(counts: readonly OpcoreMeasureSignalCount[], label: string): void { - if (!Array.isArray(counts)) { - throw new Error(`${label} must be an array`); - } - for (const count of counts) { - if (!count || typeof count !== "object") { - throw new Error(`${label} entry is required`); - } - validateNonEmptyString(count.id, `${label} id`); - validateNonEmptyString(count.title, `${label} title`); - validateNonNegativeInteger(count.count, `${label} count`); - } -} - -function validateOpcoreMeasureSignalDelta(delta: OpcoreMeasureSignalDelta, label: string): OpcoreMeasureSignalDelta { - if (!delta || typeof delta !== "object") { - throw new Error(`Opcore measure delta ${label} entry is required`); - } - validateNonEmptyString(delta.id, `Opcore measure delta ${label} id`); - validateNonEmptyString(delta.title, `Opcore measure delta ${label} title`); - validateNonNegativeInteger(delta.currentCount, `Opcore measure delta ${label} currentCount`); - validateNonNegativeInteger(delta.comparisonCount, `Opcore measure delta ${label} comparisonCount`); - if (!Number.isInteger(delta.delta)) { - throw new Error(`Opcore measure delta ${label} delta must be an integer`); - } - return delta; -} - -function assertNoOpaqueScoreFields(value: unknown, label: string): void { - if (!value || typeof value !== "object") return; - if (Array.isArray(value)) { - for (const entry of value) assertNoOpaqueScoreFields(entry, label); - return; - } - for (const [key, entry] of Object.entries(value)) { - if (key === "score" || key === "blendedScore") { - throw new Error(`${label} must not include opaque score fields`); - } - assertNoOpaqueScoreFields(entry, label); - } -} - -function assertNoTelemetrySourceFields(value: unknown, label: string): void { - const blockedKeys = new Set([ - "root", - "requestedPath", - "path", - "paths", - "examples", - "content", - "contents", - "source", - "secret", - "secrets", - "token", - "tokens", - "apiKey", - "password" - ]); - visitTelemetryValue(value); - - function visitTelemetryValue(entry: unknown): void { - if (!entry || typeof entry !== "object") return; - if (Array.isArray(entry)) { - for (const item of entry) visitTelemetryValue(item); - return; - } - for (const [key, child] of Object.entries(entry)) { - if (blockedKeys.has(key)) { - throw new Error(`${label} must remain source-safe and must not include ${key}`); - } - visitTelemetryValue(child); - } - } -} - -function validateCommandTimingPhase(phase: CommandTimingPhase): CommandTimingPhase { - if (!phase || typeof phase !== "object") { - throw new Error("Command timing phase is required"); - } - validateLatencyStableId(phase.phase, "Command timing phase"); - validateNonNegativeNumber(phase.durationMs, "Command timing phase durationMs"); - if (phase.fileCount !== undefined) validateNonNegativeInteger(phase.fileCount, "Command timing phase fileCount"); - return phase; -} - -function validateLatencyPhaseBudget(phaseBudget: LatencyPhaseBudget): LatencyPhaseBudget { - if (!phaseBudget || typeof phaseBudget !== "object") { - throw new Error("Latency phase budget is required"); - } - validateLatencyStableId(phaseBudget.phase, "Latency phase budget phase"); - validateNonNegativeNumber(phaseBudget.budgetMs, "Latency phase budget budgetMs"); - return phaseBudget; -} - -function resolveLatencyAppliedBudgetMs(budget: LatencyBudget, phase: string): number { - if (phase === "total") return budget.budgetMs; - const phaseBudget = budget.phaseBudgets?.find((entry) => entry.phase === phase); - if (!phaseBudget) { - throw new Error("Latency budget result phase must match total or a configured phase budget"); - } - return phaseBudget.budgetMs; -} - -function validateLatencyStableId(value: unknown, label: string): string { - const stableId = validateNonEmptyString(value, label); - if (!latencyStableIdRegex.test(stableId)) { - throw new Error(`${label} must be a stable latency id`); - } - return stableId; -} - -function validateLatencyTelemetryCommandBin(value: unknown, label: string): CommandLatencyTelemetryBin { - const bin = validateNonEmptyString(value, label); - if (!includesString(commandLatencyTelemetryBins, bin)) { - throw new Error(`${label} must be a source-safe command bin`); - } - return bin; -} - -function validateLatencyCanonicalCommand(command: readonly string[], label: string): readonly string[] { - const parts = validateStringArray(command, label, { allowEmpty: false }); - for (const [index, part] of parts.entries()) { - validateLatencyCanonicalCommandToken(part, `${label} entry ${index}`); - } - return parts; -} - -function validateLatencyCanonicalCommandToken(value: string, label: string): string { - if (!latencyTelemetryCommandTokenRegex.test(value)) { - throw new Error(`${label} must be a source-safe canonicalCommand token`); - } - if ( - value.includes("/") || - value.includes("\\") || - value === "." || - value === ".." || - value.startsWith("~") || - /^[A-Za-z]:/.test(value) || - /^file:/i.test(value) || - latencyTelemetrySourceFileExtensionRegex.test(value) - ) { - throw new Error(`${label} must be a source-safe canonicalCommand token`); - } - return value; -} - -function validateOpcoreCoverageCounts( - section: - | OpcoreRepoStatePayload["coverage"]["graph"] - | OpcoreRepoStatePayload["coverage"]["validation"], - label: string -): void { - if (!section || typeof section !== "object") { - throw new Error(`Opcore repo state ${label} coverage is required`); - } - validateNonNegativeInteger(section.supportedFiles, `Opcore repo state ${label} supportedFiles`); - if (!Array.isArray(section.extensions)) { - throw new Error(`Opcore repo state ${label} extensions must be an array`); - } - for (const entry of section.extensions) { - validateNonEmptyString(entry.extension, `Opcore repo state ${label} extension`); - validateNonNegativeInteger(entry.count, `Opcore repo state ${label} count`); - } -} - -export function validateCommandAdapterRequest(request: CommandAdapterRequest): CommandAdapterRequest { - if (!request || typeof request !== "object") { - throw new Error("Command adapter request is required"); - } - if (request.schemaVersion !== 1) { - throw new Error("Command adapter request schemaVersion must be 1"); - } - validateNonEmptyString(request.bin, "Command adapter request bin"); - validateStringArray(request.argv, "Command adapter request argv", { allowEmpty: true, allowEmptyValues: true }); - validateStringArray(request.args, "Command adapter request args", { allowEmpty: true, allowEmptyValues: true }); - if (typeof request.json !== "boolean") { - throw new Error("Command adapter request json must be boolean"); - } - if (!request.group || typeof request.group !== "object") { - throw new Error("Command adapter request group is required"); - } - validateManifestGroups([request.group]); - validateStringArray(request.canonicalCommand, "Command adapter request canonicalCommand", { allowEmpty: false }); - if (!request.group.canonicalCommand.every((part, index) => request.canonicalCommand[index] === part)) { - throw new Error("Command adapter request canonicalCommand must start with the group canonicalCommand"); - } - return request; -} - -export function validateGraphReferenceEvidenceManifest(manifest: GraphReferenceEvidenceManifest): GraphReferenceEvidenceManifest { - if (!manifest || typeof manifest !== "object") { - throw new Error("Graph reference evidence manifest is required"); - } - if (manifest.schemaVersion !== 1) { - throw new Error("Graph reference evidence manifest schemaVersion must be 1"); - } - if (manifest.issue !== "#19") { - throw new Error("Graph reference evidence manifest issue must be #19"); - } - if (manifest.origin !== "covibes-authored-synthetic") { - throw new Error("Graph reference evidence manifest origin must be covibes-authored-synthetic"); - } - - validateStringArray(manifest.fixtureRefs, "Graph reference evidence manifest fixtureRefs", { allowEmpty: false }); - validateGraphReferenceEvidenceCommandSurfaces(manifest.commandSurfaces); - validateGraphReferenceEvidenceJsonOutputSurfaces(manifest.jsonOutputSurfaces); - validateGraphReferenceEvidenceSqliteFixtures(manifest.sqliteFixtures); - validateGraphReferenceEvidenceDaemonFixtures(manifest.daemonFixtures); - validateGraphReferenceEvidenceBaselineReceipts(manifest.baselineReceipts); - validateGraphReferenceEvidenceOptionalSurfaces(manifest.optionalAnalysisSurfaces); - validateGraphReferenceEvidenceGoldenCorpus(manifest.goldenCorpus); - validateGraphReferenceEvidenceProvenance(manifest.provenance); - validateGraphReferenceEvidenceSourceFreeStrings(manifest); - - return manifest; -} - -export function validateGraphReleaseReceipt(receipt: GraphReleaseReceipt): GraphReleaseReceipt { - if (!receipt || typeof receipt !== "object") { - throw new Error("Graph release receipt is required"); - } - if (receipt.schemaVersion !== 1) { - throw new Error("Graph release receipt schemaVersion must be 1"); - } - if (receipt.issue !== "#17") { - throw new Error("Graph release receipt issue must be #17"); - } - if (receipt.origin !== "covibes-authored-synthetic") { - throw new Error("Graph release receipt origin must be covibes-authored-synthetic"); - } - - validateNonEmptyString(receipt.generatedAt, "Graph release receipt generatedAt"); - validateNonEmptyString(receipt.commitSha, "Graph release receipt commitSha"); - if (receipt.graphProviderSchemaVersion !== 1) { - throw new Error("Graph release receipt graphProviderSchemaVersion must be 1"); - } - validateGraphReleasePackageVersions(receipt.graphPackageVersions); - validateExactStringSet(receipt.requiredChildren, graphReleaseRequiredChildren, "Graph release required children"); - validateExactStringSet(receipt.deferredChildren, graphReleaseDeferredChildren, "Graph release deferred children"); - validateGraphReleaseCommandCoverage(receipt.commandCoverage); - validateGraphReleaseRustCommandCoverage(receipt.rustCommandCoverage); - validateGraphReleaseDirectSqliteQueries(receipt.directSqliteQueries); - validateGraphReleaseServeTransport(receipt.serveTransport); - validateGraphReleaseBenchmarks(receipt.benchmarks); - validateGraphReleasePackageInspection(receipt.packageInspection); - validateExactStringSet(receipt.supportedNativeTargets, graphCoreNativeSupportedTargets, "Graph release supported native targets"); - validateGraphReleaseNativeArtifacts(receipt.nativeArtifacts); - validateGraphReleaseReportReceipts(receipt.reportReceipts); - validateGraphProviderArtifactMetadata(receipt.graphArtifact); - validateGraphReleaseOptionalSurfaces(receipt.optionalSurfaces); - validateGraphReleaseHandoff(receipt.handoff); - validateGraphReleaseSourceFreeStrings(receipt); - - return receipt; -} - -export function validateReleaseReceipt(receipt: ReleaseReceipt): ReleaseReceipt { - if (!receipt || typeof receipt !== "object") throw new Error("Release receipt is required"); - if (receipt.schemaVersion !== 1) throw new Error("Release receipt schemaVersion must be 1"); - if (receipt.issue !== "#29") throw new Error("Release receipt issue must be #29"); - if (receipt.origin !== "covibes-authored-release-proof") { - throw new Error("Release receipt origin must be covibes-authored-release-proof"); - } - validateNonEmptyString(receipt.generatedAt, "Release receipt generatedAt"); - validateNonEmptyString(receipt.commitSha, "Release receipt commitSha"); - if (receipt.privateRepo !== true) throw new Error("Release receipt maintainer evidence marker must be true"); - validateExactStringSet(receipt.packageNames, releaseReceiptPackageNames, "Release receipt package names"); - validateExactStringSet(receipt.commandGroups, releaseReceiptCommandGroups, "Release receipt command groups"); - validateReleaseReceiptPackages(receipt.packages); - validateReleaseReceiptDescriptor(receipt.descriptor, receipt.packages); - validateReleaseReceiptNativeArtifacts(receipt.nativeArtifacts, receipt.packages, receipt.descriptor); - validateReleaseReceiptLicense(receipt.license); - validateReleaseReceiptProvenance(receipt.provenance); - validateReleaseReceiptSecretHistory(receipt.secretHistory); - validateReleaseReceiptReports(receipt.reports); - validateReleaseReceiptGraphReleaseEvidence(receipt.graphReleaseReceipt); - return receipt; -} - -export function validateReleaseCutoverReceipt(receipt: ReleaseCutoverReceipt): ReleaseCutoverReceipt { - if (!receipt || typeof receipt !== "object") throw new Error("Release cutover receipt is required"); - if (receipt.schemaVersion !== 1) throw new Error("Release cutover receipt schemaVersion must be 1"); - if (receipt.issue !== "#30") throw new Error("Release cutover receipt issue must be #30"); - if (receipt.origin !== "covibes-authored-cutover-proof") { - throw new Error("Release cutover receipt origin must be covibes-authored-cutover-proof"); - } - validateNonEmptyString(receipt.generatedAt, "Release cutover receipt generatedAt"); - validateNonEmptyString(receipt.commitSha, "Release cutover receipt commitSha"); - if (receipt.privateRepo !== true) throw new Error("Release cutover receipt maintainer evidence marker must be true"); - validateExactStringSet(receipt.packageNames, releaseReceiptPackageNames, "Release cutover receipt package names"); - validateReleaseCutoverInstalledPackages(receipt.installedPackages); - validateReleaseCutoverDescriptor(receipt.descriptor); - validateReleaseCutoverEnvironmentIsolation(receipt.environmentIsolation); - validateReleaseCutoverCommandReceipts(receipt.commandReceipts); - validateReleaseCutoverRustCommandReceipts(receipt.rustCommandReceipts); - validateReleaseCutoverPythonCommandReceipts(receipt.pythonCommandReceipts); - validateReleaseCutoverNegativeChecks(receipt.negativeChecks); - validateReleaseCutoverCurrentToolGuardrails(receipt.currentToolGuardrails); - if (receipt.oldToolReplacementClaimed !== false) { - throw new Error("Release cutover receipt must not claim old-tool replacement"); - } - validateReleaseCutoverForbiddenMarkerScan(receipt.forbiddenMarkerScan); - validateReleaseCutoverInputEvidence(receipt.inputEvidence); - return receipt; -} - -export function validateRustOldRoxComparisonReceipt(receipt: RustOldRoxComparisonReceipt): RustOldRoxComparisonReceipt { - if (!receipt || typeof receipt !== "object") throw new Error("Rust old-Rox comparison receipt is required"); - if (receipt.schemaVersion !== 1) throw new Error("Rust old-Rox comparison receipt schemaVersion must be 1"); - if (receipt.issue !== "#29") throw new Error("Rust old-Rox comparison receipt issue must be #29"); - if (receipt.origin !== "covibes-authored-old-rox-comparison") { - throw new Error("Rust old-Rox comparison receipt origin must be covibes-authored-old-rox-comparison"); - } - validateNonEmptyString(receipt.generatedAt, "Rust old-Rox comparison generatedAt"); - if (receipt.privateRepo !== true) throw new Error("Rust old-Rox comparison receipt privateRepo must be true"); - if (receipt.oldToolReplacementClaimed !== false) throw new Error("Rust old-Rox comparison receipt must not claim old-tool replacement"); - if (!Array.isArray(receipt.publicReleaseActions) || receipt.publicReleaseActions.length !== 0) { - throw new Error("Rust old-Rox comparison receipt public release actions must be empty"); - } - validateRustOldRoxComparisonSurfaces(receipt.surfaces); - validateRustOldRoxComparisonGuardrails(receipt.guardrails); - return receipt; -} - -export function validateAspDogfoodReceipt(receipt: AspDogfoodReceipt): AspDogfoodReceipt { - if (!receipt || typeof receipt !== "object") throw new Error("ASP dogfood receipt is required"); - if (receipt.schemaVersion !== 1) throw new Error("ASP dogfood receipt schemaVersion must be 1"); - if (receipt.issue !== "#120") throw new Error("ASP dogfood receipt issue must be #120"); - if (receipt.origin !== "covibes-authored-asp-dogfood-proof") { - throw new Error("ASP dogfood receipt origin must be covibes-authored-asp-dogfood-proof"); - } - validateNonEmptyString(receipt.generatedAt, "ASP dogfood receipt generatedAt"); - validateNonEmptyString(receipt.commitSha, "ASP dogfood receipt commitSha"); - if (receipt.privateRepo !== true) throw new Error("ASP dogfood receipt privateRepo must be true"); - if (receipt.bootstrapSource !== "local-sibling") throw new Error("ASP dogfood bootstrapSource must be local-sibling"); - validateExactStringSet(receipt.packageNames, releaseReceiptPackageNames, "ASP dogfood receipt package names"); - validateReleaseCutoverInstalledPackages(receipt.installedPackages); - validateAspDogfoodManager(receipt.manager); - validateAspDogfoodAspHome(receipt.aspHome); - validateAspDogfoodHostFixture(receipt.hostFixture); - validateAspDogfoodProvider(receipt.provider); - validateAspDogfoodManagerState(receipt.managerState); - validateAspDogfoodRepoEnrollment(receipt.repoEnrollment); - validateAspDogfoodHostEvaluation(receipt.hostEvaluation); - validateAspDogfoodProviderProbe(receipt.providerProbe); - validateAspDogfoodGuardrails(receipt.currentToolGuardrails); - validateAspDogfoodUnsupportedSurfaces(receipt.unsupportedSurfaces); - validateAspDogfoodParityBlockers(receipt.parityBlockers); - validateAspDogfoodAuthority(receipt.authority); - if (!Array.isArray(receipt.publicReleaseActions) || receipt.publicReleaseActions.length !== 0) { - throw new Error("ASP dogfood receipt must not record public publish, registry, or standard-readiness actions"); - } - if (receipt.oldToolReplacementClaimed !== false) throw new Error("ASP dogfood receipt must not claim old-tool replacement"); - validateAspDogfoodForbiddenMarkerScan(receipt.forbiddenMarkerScan); - validateAspDogfoodForbiddenProviderEntrypoint(receipt); - return receipt; -} - -export function validateRepoRelativePath(path: string): string { - if (typeof path !== "string" || path.length === 0) { - throw new Error("Repo-relative path must be a non-empty string"); - } - if (path.includes("\0")) { - throw new Error(`Repo-relative path contains a null byte: ${path}`); - } - if (/^[\\/]/.test(path) || /^[A-Za-z]:[\\/]/.test(path)) { - throw new Error(`Repo-relative path must not be absolute: ${path}`); - } - const normalized = path.replaceAll("\\", "/"); - if ( - normalized === "." || - normalized === ".." || - normalized.startsWith("../") || - normalized.includes("/../") || - normalized.endsWith("/..") - ) { - throw new Error(`Repo-relative path must not escape the repository: ${path}`); - } - return normalized; -} - -export function validateHomeRelativePath(path: string): string { - if (typeof path !== "string" || path.length === 0) { - throw new Error("Home-relative path must be a non-empty string"); - } - if (path.includes("\0")) { - throw new Error(`Home-relative path contains a null byte: ${path}`); - } - const normalized = path.replaceAll("\\", "/"); - if (!normalized.startsWith("~/")) { - throw new Error(`Home-relative path must start with ~/: ${path}`); - } - if ( - normalized === "~/" || - normalized === "~/." || - normalized.includes("/../") || - normalized.endsWith("/..") || - normalized.includes("//") - ) { - throw new Error(`Home-relative path must not escape the home directory: ${path}`); - } - return normalized; -} - -export function validateRepoIdentity(repo: RepoIdentity): RepoIdentity { - if (!repo || typeof repo !== "object") { - throw new Error("Repo identity is required"); - } - if (repo.repoId && repo.repoRoot) { - throw new Error("Repo identity is ambiguous: use repoId or repoRoot, not both"); - } - if (!repo.repoId && !repo.repoRoot && !repo.remoteUrl) { - throw new Error("Repo identity must include repoId, repoRoot, or remoteUrl"); - } - return repo; -} - -export function validateProviderStatus(status: GraphProviderStatus): GraphProviderStatus { - if (!status || typeof status !== "object") { - throw new Error("Graph provider status is required"); - } - if (!includesString(graphProviderStatusStates, status.state)) { - throw new Error(`Unknown graph provider status state: ${String(status.state)}`); - } - if (!includesString(graphProviderModes, status.mode)) { - throw new Error(`Unknown graph provider mode: ${String(status.mode)}`); - } - if (typeof status.provider !== "string" || status.provider.length === 0) { - throw new Error("Graph provider status must include provider"); - } - if (typeof status.schemaVersion !== "number") { - throw new Error("Graph provider status must include numeric schemaVersion"); - } - if (status.state === "skipped" && status.mode !== "optional") { - throw new Error("Skipped graph provider status must use optional mode"); - } - if (status.state === "required_missing" && status.mode !== "required") { - throw new Error("Required-missing graph provider status must use required mode"); - } - if (status.state === "available") { - validateRepoIdentity(status.repo); - validateGraphFreshness(status.freshness, "Available"); - validateGraphKindCounts(status.nodes_by_kind, "nodes_by_kind"); - validateGraphKindCounts(status.edges_by_kind, "edges_by_kind"); - if (status.handshake !== undefined) validateGraphProviderCapabilityHandshake(status.handshake); - if (status.walCheckpoint !== undefined) validateGraphWalCheckpointSummary(status.walCheckpoint); - return status; - } - if (status.state === "warming") { - validateRepoIdentity(status.repo); - validateGraphFreshness(status.freshness, "Warming"); - if (status.lifecycle !== undefined) validateGraphWatchLifecycle(status.lifecycle); - return status; - } - validateProviderFailureStatus(status); - return status; -} - -function validateGraphKindCounts(counts: Readonly>, label: string): void { - if (!counts || typeof counts !== "object" || Array.isArray(counts)) { - throw new Error(`Graph provider status ${label} must be an object`); - } - for (const [kind, count] of Object.entries(counts)) { - if (kind.length === 0) throw new Error(`Graph provider status ${label} kind must not be empty`); - if (!Number.isInteger(count) || count < 0) { - throw new Error(`Graph provider status ${label}.${kind} must be a non-negative integer`); - } - } -} - -function validateProviderFailureStatus(status: GraphProviderFailureStatus): void { - if (!status.failure?.category) { - throw new Error(`Graph provider ${status.state} status must include failure.category`); - } - if (!includesString(providerFailureCategories, status.failure.category)) { - throw new Error(`Unknown graph provider failure category: ${status.failure.category}`); - } - const allowedCategories = graphProviderFailureCategoriesByState[status.state]; - if (!includesString(allowedCategories, status.failure.category)) { - throw new Error( - `Graph provider ${status.state} failure category must be one of ${allowedCategories.join(", ")}; got ${status.failure.category}` - ); - } - if (status.state === "stale") { - if (!status.repo) { - throw new Error("Stale graph provider status must include repo"); - } - validateRepoIdentity(status.repo); - validateGraphFreshness(status.freshness, "Stale"); - } - if (status.state === "schema_mismatch") { - if (typeof status.expectedSchemaVersion !== "number") { - throw new Error("Schema-mismatch graph provider status must include expectedSchemaVersion"); - } - if (typeof status.actualSchemaVersion !== "number") { - throw new Error("Schema-mismatch graph provider status must include actualSchemaVersion"); - } - } - if (status.state === "error" && status.diagnostics !== undefined) { - validateGraphExtractionDiagnostics(status.diagnostics); - } -} - -export function validateGraphProviderArtifactMetadata( - metadata: GraphProviderArtifactMetadata -): GraphProviderArtifactMetadata { - if (!metadata || typeof metadata !== "object") { - throw new Error("Graph provider artifact metadata is required"); - } - for (const key of [ - "artifactName", - "artifactVersion", - "targetPlatform", - "binaryPath", - "checksumPath", - "checksumSha256", - "buildProfile" - ] as const) { - validateNonEmptyString(metadata[key], `Graph provider artifact metadata ${key}`); - } - for (const key of ["binaryPath", "checksumPath"] as const) { - validateRepoRelativePath(metadata[key]); - if (metadata[key].startsWith("packages/") || metadata[key].startsWith("../")) { - throw new Error(`Graph provider artifact metadata ${key} must be package-relative`); - } - } - return metadata; -} - -export function validateGraphProviderCapabilityHandshake( - handshake: GraphProviderCapabilityHandshake -): GraphProviderCapabilityHandshake { - if (!handshake || typeof handshake !== "object") { - throw new Error("Graph provider capability handshake is required"); - } - validateNonEmptyString(handshake.provider, "Graph provider capability handshake provider"); - if (typeof handshake.graphSchemaVersion !== "number") { - throw new Error("Graph provider capability handshake graphSchemaVersion must be numeric"); - } - validateNonEmptyString(handshake.artifactName, "Graph provider capability handshake artifactName"); - validateNonEmptyString(handshake.artifactVersion, "Graph provider capability handshake artifactVersion"); - validateNonEmptyString(handshake.targetPlatform, "Graph provider capability handshake targetPlatform"); - validateStringArray(handshake.supportedOperations, "Graph provider capability handshake supportedOperations", { - allowEmpty: false - }); - for (const operation of handshake.supportedOperations) validateGraphDaemonOperation(operation); - validateStringArray(handshake.nodeKinds, "Graph provider capability handshake nodeKinds", { allowEmpty: false }); - validateStringArray(handshake.edgeKinds, "Graph provider capability handshake edgeKinds", { allowEmpty: false }); - validateStringArray(handshake.queryKinds, "Graph provider capability handshake queryKinds", { allowEmpty: false }); - for (const queryKind of handshake.queryKinds) validateGraphProviderQueryKind(queryKind); - validateGraphProviderArtifactMetadata(handshake.artifact); - if (handshake.artifact.artifactName !== handshake.artifactName) { - throw new Error("Graph provider capability handshake artifactName must match artifact metadata"); - } - if (handshake.artifact.targetPlatform !== handshake.targetPlatform) { - throw new Error("Graph provider capability handshake targetPlatform must match artifact metadata"); - } - return handshake; -} - -export function validateGraphFactQueryRequest(request: GraphFactQueryRequest): GraphFactQueryRequest { - if (!request || typeof request !== "object") { - throw new Error("Graph fact query request is required"); - } - if (request.requestId !== undefined) validateNonEmptyString(request.requestId, "Graph fact query request requestId"); - validateRepoIdentity(request.repo); - if (request.schemaVersion !== GRAPH_SCHEMA_VERSION) { - throw new Error(`Graph fact query request schemaVersion must be ${GRAPH_SCHEMA_VERSION}`); - } - if (!includesString(graphProviderModes, request.mode)) { - throw new Error(`Unknown graph fact query request mode: ${String(request.mode)}`); - } - if (!request.selector || typeof request.selector !== "object") { - throw new Error("Graph fact query request selector is required"); - } - validateGraphFactQueryKind(request.selector.kind); - if (request.selector.nodeKinds !== undefined) { - validateStringArray(request.selector.nodeKinds, "Graph fact query selector nodeKinds", { allowEmpty: true }); - } - if (request.selector.edgeKinds !== undefined) { - validateStringArray(request.selector.edgeKinds, "Graph fact query selector edgeKinds", { allowEmpty: true }); - } - if (request.selector.ids !== undefined) { - validateStringArray(request.selector.ids, "Graph fact query selector ids", { allowEmpty: true }); - } - if (request.selector.limit !== undefined && (typeof request.selector.limit !== "number" || request.selector.limit < 1)) { - throw new Error("Graph fact query selector limit must be a positive number"); - } - return request; -} - -export function validateGraphFactQueryResult(result: GraphFactQueryResult): GraphFactQueryResult { - if (!result || typeof result !== "object") { - throw new Error("Graph fact query result is required"); - } - if (result.requestId !== undefined) validateNonEmptyString(result.requestId, "Graph fact query result requestId"); - const status = validateProviderStatus(result.status); - const payload = result as { - metadata?: unknown; - nodes?: unknown; - edges?: unknown; - diagnostics?: unknown; - }; - const hasGraphData = - Object.hasOwn(payload, "metadata") || Object.hasOwn(payload, "nodes") || Object.hasOwn(payload, "edges"); - if (status.state !== "available") { - if (hasGraphData) { - throw new Error(`Graph query ${status.state} result must not include graph data`); - } - return result; - } - if (!payload.metadata || !Array.isArray(payload.nodes) || !Array.isArray(payload.edges)) { - throw new Error("Available graph query result must include metadata, nodes, and edges"); - } - validateGraphSnapshotMetadata(payload.metadata as GraphSnapshotMetadata); - for (const node of payload.nodes) validateGraphFactNode(node as GraphFactNode); - for (const edge of payload.edges) validateGraphFactEdge(edge as GraphFactEdge); - if (payload.diagnostics !== undefined) { - validateGraphExtractionDiagnostics(payload.diagnostics as readonly GraphExtractionDiagnostic[]); - } - return result; -} - -export function validateGraphNamedQueryRequest(request: GraphNamedQueryRequest): GraphNamedQueryRequest { - validateGraphQueryRequestBase(request, "Graph named query request"); - validateGraphNamedQueryKind(request.queryKind); - validateNonEmptyString(request.target, "Graph named query request target"); - validateTraversalOptions(request.maxDepth, request.limit, "Graph named query request"); - return request; -} - -export function validateGraphNamedQueryResult(result: GraphNamedQueryResult): GraphNamedQueryResult { - validateGraphPayloadResult(result, "Graph named query result", (payload) => { - validateGraphSnapshotMetadata(payload.metadata as GraphSnapshotMetadata); - validateGraphNamedQueryKind(payload.queryKind); - validateNonEmptyString(payload.target, "Graph named query result target"); - for (const node of payload.nodes as readonly GraphFactNode[]) validateGraphFactNode(node); - for (const edge of payload.edges as readonly GraphFactEdge[]) validateGraphFactEdge(edge); - validateGraphTraversalMetadata(payload.traversal as GraphTraversalMetadata); - }); - return result; -} - -export function validateGraphImpactRequest(request: GraphImpactRequest): GraphImpactRequest { - validateGraphQueryRequestBase(request, "Graph impact request"); - validateStringArray(request.files, "Graph impact request files", { allowEmpty: false }); - for (const file of request.files) validateRepoRelativePath(file); - if (request.baseRef !== undefined) validateNonEmptyString(request.baseRef, "Graph impact request baseRef"); - validateTraversalOptions(request.maxDepth, request.limit, "Graph impact request"); - return request; -} - -export function validateGraphImpactResult(result: GraphImpactResult): GraphImpactResult { - validateGraphPayloadResult(result, "Graph impact result", (payload) => { - validateGraphSnapshotMetadata(payload.metadata as GraphSnapshotMetadata); - validateRepoRelativePaths(payload.changedFiles, "Graph impact result changedFiles"); - validateRepoRelativePaths(payload.impactedFiles, "Graph impact result impactedFiles"); - validateStringArray(payload.impactedSymbols as readonly string[], "Graph impact result impactedSymbols", { allowEmpty: true }); - validateRepoRelativePaths(payload.tests, "Graph impact result tests"); - for (const node of payload.nodes as readonly GraphFactNode[]) validateGraphFactNode(node); - for (const edge of payload.edges as readonly GraphFactEdge[]) validateGraphFactEdge(edge); - validateGraphTraversalMetadata(payload.traversal as GraphTraversalMetadata); - }); - return result; -} - -export function validateGraphDetectChangesRequest(request: GraphDetectChangesRequest): GraphDetectChangesRequest { - validateGraphQueryRequestBase(request, "Graph detect-changes request"); - if (request.files !== undefined) validateRepoRelativePaths(request.files, "Graph detect-changes request files"); - if (request.baseRef !== undefined) validateNonEmptyString(request.baseRef, "Graph detect-changes request baseRef"); - return request; -} - -export function validateGraphDetectChangesResult(result: GraphDetectChangesResult): GraphDetectChangesResult { - validateGraphPayloadResult(result, "Graph detect-changes result", (payload) => { - validateGraphSnapshotMetadata(payload.metadata as GraphSnapshotMetadata); - validateRepoRelativePaths(payload.changedFiles, "Graph detect-changes result changedFiles"); - validateRepoRelativePaths(payload.deletedFiles, "Graph detect-changes result deletedFiles"); - validateRenamedFiles(payload.renamedFiles as readonly GraphRenamedFile[]); - }); - return result; -} - -export function validateGraphReviewContextRequest(request: GraphReviewContextRequest): GraphReviewContextRequest { - validateGraphQueryRequestBase(request, "Graph review-context request"); - if (request.files !== undefined) validateRepoRelativePaths(request.files, "Graph review-context request files"); - if (request.baseRef !== undefined) validateNonEmptyString(request.baseRef, "Graph review-context request baseRef"); - validateTraversalOptions(request.maxDepth, request.limit, "Graph review-context request"); - return request; -} - -export function validateGraphReviewContextResult(result: GraphReviewContextResult): GraphReviewContextResult { - validateGraphPayloadResult(result, "Graph review-context result", (payload) => { - validateGraphSnapshotMetadata(payload.metadata as GraphSnapshotMetadata); - validateRepoRelativePaths(payload.changedFiles, "Graph review-context result changedFiles"); - validateRepoRelativePaths(payload.deletedFiles, "Graph review-context result deletedFiles"); - validateRenamedFiles(payload.renamedFiles as readonly GraphRenamedFile[]); - validateRepoRelativePaths(payload.impactedFiles, "Graph review-context result impactedFiles"); - validateStringArray(payload.impactedSymbols as readonly string[], "Graph review-context result impactedSymbols", { allowEmpty: true }); - validateRepoRelativePaths(payload.tests, "Graph review-context result tests"); - for (const node of payload.nodes as readonly GraphFactNode[]) validateGraphFactNode(node); - for (const edge of payload.edges as readonly GraphFactEdge[]) validateGraphFactEdge(edge); - validateGraphTraversalMetadata(payload.traversal as GraphTraversalMetadata); - }); - return result; -} - -export function validateGraphSearchRequest(request: GraphSearchRequest): GraphSearchRequest { - validateGraphQueryRequestBase(request, "Graph search request"); - validateNonEmptyString(request.query, "Graph search request query"); - if (request.query.trim().length === 0) throw new Error("Graph search request query must not be empty"); - if (request.limit !== undefined && (!Number.isFinite(request.limit) || request.limit < 1)) { - throw new Error("Graph search request limit must be a positive number"); - } - if (request.files !== undefined) validateRepoRelativePaths(request.files, "Graph search request files"); - return request; -} - -export function validateGraphSearchResult(result: GraphSearchResult): GraphSearchResult { - if (!result || typeof result !== "object") throw new Error("Graph search result is required"); - if (result.requestId !== undefined) validateNonEmptyString(result.requestId, "Graph search result requestId"); - const status = validateProviderStatus(result.status); - const payload = result as { - metadata?: unknown; - query?: unknown; - searchMode?: unknown; - summary?: unknown; - results?: unknown; - hints?: unknown; - diagnostics?: unknown; - }; - const hasSearchData = - Object.hasOwn(payload, "metadata") || - Object.hasOwn(payload, "query") || - Object.hasOwn(payload, "searchMode") || - Object.hasOwn(payload, "summary") || - Object.hasOwn(payload, "results"); - if (status.state !== "available") { - if (hasSearchData) throw new Error(`Graph search ${status.state} result must not include search data`); - if (payload.hints !== undefined) validateStringArray(payload.hints as readonly string[], "Graph search result hints", { allowEmpty: true }); - if (payload.diagnostics !== undefined) validateGraphExtractionDiagnostics(payload.diagnostics as readonly GraphExtractionDiagnostic[]); - return result; - } - if (!payload.metadata || typeof payload.query !== "string" || !payload.searchMode || !payload.summary || !Array.isArray(payload.results)) { - throw new Error("Available graph search result must include metadata, query, searchMode, summary, and results"); - } - validateGraphSnapshotMetadata(payload.metadata as GraphSnapshotMetadata); - validateNonEmptyString(payload.query, "Graph search result query"); - validateGraphSearchMode(payload.searchMode as GraphSearchMode); - validateGraphSearchSummary(payload.summary as GraphSearchSummary); - for (const entry of payload.results as readonly GraphSearchResultEntry[]) validateGraphSearchResultEntry(entry); - if (payload.hints !== undefined) validateStringArray(payload.hints as readonly string[], "Graph search result hints", { allowEmpty: true }); - if (payload.diagnostics !== undefined) validateGraphExtractionDiagnostics(payload.diagnostics as readonly GraphExtractionDiagnostic[]); - return result; -} - -export function validateInspectRouteResult(result: InspectRouteResult): InspectRouteResult { - if (!result || typeof result !== "object") throw new Error("Inspect route result is required"); - const route = validateInspectRouteName((result as { route?: unknown }).route); - if (!includesString(["ok", "error", "degraded"] as const, result.status)) { - throw new Error(`Unknown inspect route result status: ${String((result as { status?: unknown }).status)}`); - } - if (result.providerStatus !== undefined) validateProviderStatus(result.providerStatus); - if (result.status === "ok") { - validateInspectSymbolTarget(result.target, `Inspect ${route} target`); - if (result.providerStatus === undefined || result.providerStatus.state !== "available") { - throw new Error(`Successful inspect ${route} result requires available providerStatus`); - } - validateInspectRoutePayload(result, route); - if (Object.hasOwn(result, "failure")) throw new Error(`Successful inspect ${route} result must not include failure`); - } else if (result.status === "degraded" && inspectResultHasPayload(result, route)) { - const target = result.target; - if (target === undefined) throw new Error(`Degraded inspect ${route} result requires target`); - validateInspectSymbolTarget(target, `Inspect ${route} target`); - if (result.providerStatus === undefined) throw new Error(`Degraded inspect ${route} result requires providerStatus`); - validateInspectRoutePayload(result, route); - const failure = result.failure; - if (failure === undefined) throw new Error(`Degraded inspect ${route} result requires failure`); - validateInspectRouteFailure(failure); - } else { - if (result.target !== undefined) validateInspectSymbolTarget(result.target, `Inspect ${route} target`); - const failure = result.failure; - if (failure === undefined) throw new Error(`Failed inspect ${route} result requires failure`); - validateInspectRouteFailure(failure); - for (const field of ["references", "signatures", "implementations"] as const) { - if (Object.hasOwn(result, field)) throw new Error(`Failed inspect ${route} result must not include ${field}`); - } - } - return result; -} - -function inspectResultHasPayload(result: InspectRouteResult, route: InspectRouteResult["route"]): boolean { - return ( - (route === "references" && Object.hasOwn(result, "references")) || - (route === "signature" && Object.hasOwn(result, "signatures")) || - (route === "implementations" && Object.hasOwn(result, "implementations")) - ); -} - -export function validateGraphDaemonRequest(request: GraphDaemonRequest): GraphDaemonRequest { - if (!request || typeof request !== "object") { - throw new Error("Graph daemon request is required"); - } - if (request.protocol !== "opcore.graph.daemon") { - throw new Error("Graph daemon request protocol must be opcore.graph.daemon"); - } - validateNonEmptyString(request.requestId, "Graph daemon request requestId"); - if (request.schemaVersion !== GRAPH_SCHEMA_VERSION) { - throw new Error(`Graph daemon request schemaVersion must be ${GRAPH_SCHEMA_VERSION}`); - } - validateGraphDaemonOperation(request.operation); - validateRepoIdentity(request.repo); - if (request.query !== undefined) validateGraphFactQueryRequest(request.query); - if (request.namedQuery !== undefined) validateGraphNamedQueryRequest(request.namedQuery); - if (request.impact !== undefined) validateGraphImpactRequest(request.impact); - if (request.reviewContext !== undefined) validateGraphReviewContextRequest(request.reviewContext); - if (request.changes !== undefined) validateGraphDetectChangesRequest(request.changes); - if (request.search !== undefined) validateGraphSearchRequest(request.search); - if (request.operation === "query" && request.query === undefined) { - const hasQueryEnvelope = - request.namedQuery !== undefined || - request.impact !== undefined || - request.reviewContext !== undefined || - request.changes !== undefined || - request.search !== undefined; - if (!hasQueryEnvelope) throw new Error("Graph daemon query request must include query"); - } - if (request.baseRef !== undefined) validateNonEmptyString(request.baseRef, "Graph daemon request baseRef"); - if (request.paths !== undefined) { - validateStringArray(request.paths, "Graph daemon request paths", { allowEmpty: true }); - for (const path of request.paths) validateRepoRelativePath(path); - } - if (request.watchPaths !== undefined) { - validateStringArray(request.watchPaths, "Graph daemon request watchPaths", { allowEmpty: true }); - for (const path of request.watchPaths) validateRepoRelativePath(path); - } - if ( - request.pollIntervalMs !== undefined && - (!Number.isFinite(request.pollIntervalMs) || request.pollIntervalMs < 1) - ) { - throw new Error("Graph daemon request pollIntervalMs must be positive"); - } - if ( - request.idleTimeoutMs !== undefined && - (!Number.isFinite(request.idleTimeoutMs) || request.idleTimeoutMs < 0) - ) { - throw new Error("Graph daemon request idleTimeoutMs must be a non-negative number"); - } - if (request.once !== undefined && typeof request.once !== "boolean") { - throw new Error("Graph daemon request once must be boolean"); - } - if (request.maxWalBytes !== undefined && (!Number.isFinite(request.maxWalBytes) || request.maxWalBytes < 1)) { - throw new Error("Graph daemon request maxWalBytes must be positive"); - } - return request; -} - -export function validateGraphDaemonResponse(response: GraphDaemonResponse): GraphDaemonResponse { - if (!response || typeof response !== "object") { - throw new Error("Graph daemon response is required"); - } - if (response.protocol !== "opcore.graph.daemon") { - throw new Error("Graph daemon response protocol must be opcore.graph.daemon"); - } - validateNonEmptyString(response.requestId, "Graph daemon response requestId"); - if (response.schemaVersion !== GRAPH_SCHEMA_VERSION) { - throw new Error(`Graph daemon response schemaVersion must be ${GRAPH_SCHEMA_VERSION}`); - } - validateProviderStatus(response.status); - if (response.result !== undefined) validateGraphFactQueryResult(response.result); - if (response.namedQuery !== undefined) validateGraphNamedQueryResult(response.namedQuery); - if (response.impact !== undefined) validateGraphImpactResult(response.impact); - if (response.reviewContext !== undefined) validateGraphReviewContextResult(response.reviewContext); - if (response.changes !== undefined) validateGraphDetectChangesResult(response.changes); - if (response.search !== undefined) validateGraphSearchResult(response.search); - if (response.pipeline !== undefined) validateGraphPipelineResult(response.pipeline); - if (response.lifecycle !== undefined) validateGraphWatchLifecycle(response.lifecycle); - return response; -} - -export function validateGraphPipelineResult(result: GraphPipelineResult): GraphPipelineResult { - if (!result || typeof result !== "object") { - throw new Error("Graph pipeline result is required"); - } - validateGraphPipelineSummary(result.summary); - validateProviderStatus(result.status); - if (result.lifecycle !== undefined) validateGraphWatchLifecycle(result.lifecycle); - return result; -} - -export function validateGraphPipelineSummary(summary: GraphPipelineSummary): GraphPipelineSummary { - if (!summary || typeof summary !== "object") { - throw new Error("Graph pipeline summary is required"); - } - if (!["build", "update", "watch"].includes(summary.operation)) { - throw new Error(`Unknown graph pipeline operation: ${String(summary.operation)}`); - } - validateRepoIdentity(summary.repo); - if (summary.storePath !== undefined) validateNonEmptyString(summary.storePath, "Graph pipeline summary storePath"); - validateNonEmptyString(summary.startedAt, "Graph pipeline summary startedAt"); - validateNonEmptyString(summary.completedAt, "Graph pipeline summary completedAt"); - for (const key of ["durationMs", "discoveredFiles", "parsedFiles", "unchangedFiles", "diagnosticsCount"] as const) { - if (typeof summary[key] !== "number" || summary[key] < 0) { - throw new Error(`Graph pipeline summary ${key} must be a non-negative number`); - } - } - validateStringArray(summary.changedFiles, "Graph pipeline summary changedFiles", { allowEmpty: true }); - validateStringArray(summary.deletedFiles, "Graph pipeline summary deletedFiles", { allowEmpty: true }); - for (const path of summary.changedFiles) validateRepoRelativePath(path); - for (const path of summary.deletedFiles) validateRepoRelativePath(path); - if (typeof summary.fullRebuildRequired !== "boolean") { - throw new Error("Graph pipeline summary fullRebuildRequired must be boolean"); - } - if (!Array.isArray(summary.phaseTimings) || summary.phaseTimings.length === 0) { - throw new Error("Graph pipeline summary phaseTimings must be non-empty"); - } - for (const timing of summary.phaseTimings) validateGraphPipelinePhaseTiming(timing); - if (summary.baseRef !== undefined) validateNonEmptyString(summary.baseRef, "Graph pipeline summary baseRef"); - if (summary.watchPaths !== undefined) { - validateStringArray(summary.watchPaths, "Graph pipeline summary watchPaths", { allowEmpty: true }); - for (const path of summary.watchPaths) validateRepoRelativePath(path); - } - if (summary.walCheckpoint !== undefined) validateGraphWalCheckpointSummary(summary.walCheckpoint); - return summary; -} - -function validateGraphPipelinePhaseTiming(timing: GraphPipelinePhaseTiming): GraphPipelinePhaseTiming { - if (!timing || typeof timing !== "object") { - throw new Error("Graph pipeline phase timing is required"); - } - validateNonEmptyString(timing.phase, "Graph pipeline phase timing phase"); - validateNonEmptyString(timing.startedAt, "Graph pipeline phase timing startedAt"); - validateNonEmptyString(timing.completedAt, "Graph pipeline phase timing completedAt"); - if (typeof timing.durationMs !== "number" || timing.durationMs < 0) { - throw new Error("Graph pipeline phase timing durationMs must be non-negative"); - } - if (timing.fileCount !== undefined && (typeof timing.fileCount !== "number" || timing.fileCount < 0)) { - throw new Error("Graph pipeline phase timing fileCount must be non-negative"); - } - return timing; -} - -function validateGraphWalCheckpointSummary(summary: GraphWalCheckpointSummary): GraphWalCheckpointSummary { - validateNonEmptyString(summary.walPath, "Graph WAL checkpoint walPath"); - for (const key of ["bytesBefore", "bytesAfter", "budgetBytes"] as const) { - if (typeof summary[key] !== "number" || summary[key] < 0) { - throw new Error(`Graph WAL checkpoint ${key} must be non-negative`); - } - } - if (typeof summary.checkpointed !== "boolean") { - throw new Error("Graph WAL checkpoint checkpointed must be boolean"); - } - return summary; -} - -export function validateGraphWatchLifecycle(lifecycle: GraphWatchLifecycle): GraphWatchLifecycle { - if (!lifecycle || typeof lifecycle !== "object") { - throw new Error("Graph watch lifecycle is required"); - } - if (!["warming", "available", "error", "stopped"].includes(lifecycle.state)) { - throw new Error(`Unknown graph watch lifecycle state: ${String(lifecycle.state)}`); - } - if (lifecycle.pid !== undefined && (!Number.isInteger(lifecycle.pid) || lifecycle.pid < 1)) { - throw new Error("Graph watch lifecycle pid must be positive"); - } - validateNonEmptyString(lifecycle.startedAt, "Graph watch lifecycle startedAt"); - validateNonEmptyString(lifecycle.updatedAt, "Graph watch lifecycle updatedAt"); - validateNonEmptyString(lifecycle.pidPath, "Graph watch lifecycle pidPath"); - validateNonEmptyString(lifecycle.statePath, "Graph watch lifecycle statePath"); - validateNonEmptyString(lifecycle.logPath, "Graph watch lifecycle logPath"); - if (typeof lifecycle.pollIntervalMs !== "number" || lifecycle.pollIntervalMs < 1) { - throw new Error("Graph watch lifecycle pollIntervalMs must be positive"); - } - if ( - typeof lifecycle.idleTimeoutMs !== "number" || - !Number.isFinite(lifecycle.idleTimeoutMs) || - lifecycle.idleTimeoutMs < 0 - ) { - throw new Error("Graph watch lifecycle idleTimeoutMs must be a non-negative number"); - } - if (lifecycle.watchPaths !== undefined) { - validateStringArray(lifecycle.watchPaths, "Graph watch lifecycle watchPaths", { allowEmpty: true }); - for (const path of lifecycle.watchPaths) validateRepoRelativePath(path); - } - if (lifecycle.message !== undefined) validateNonEmptyString(lifecycle.message, "Graph watch lifecycle message"); - return lifecycle; -} - -export function validateGraphServeTransportStatus(status: GraphServeTransportStatus): GraphServeTransportStatus { - if (!status || typeof status !== "object") { - throw new Error("Graph serve transport status is required"); - } - if (status.schemaVersion !== 1) { - throw new Error("Graph serve transport status schemaVersion must be 1"); - } - if (status.protocol !== "opcore.graph.daemon") { - throw new Error("Graph serve transport status protocol must be opcore.graph.daemon"); - } - if (status.transport !== "stdio") { - throw new Error("Graph serve transport status transport must be stdio"); - } - if (!includesString(["ready", "error", "stopped"] as const, status.state)) { - throw new Error(`Unknown graph serve transport state: ${String(status.state)}`); - } - validateRepoIdentity(status.repo); - validateNonEmptyString(status.provider, "Graph serve transport status provider"); - if (status.pid !== undefined && (!Number.isInteger(status.pid) || status.pid < 1)) { - throw new Error("Graph serve transport status pid must be positive"); - } - if (status.artifact !== undefined) validateGraphProviderArtifactMetadata(status.artifact); - if (status.failure !== undefined) validateProviderFailure(status.failure); - if (status.state === "error" && status.failure === undefined) { - throw new Error("Graph serve transport error status must include failure"); - } - if (status.message !== undefined) validateNonEmptyString(status.message, "Graph serve transport status message"); - return status; -} - -export function validateCloneAnalysisRequest(request: CloneAnalysisRequest): CloneAnalysisRequest { - if (!request || typeof request !== "object") { - throw new Error("Clone analysis request is required"); - } - if (request.protocol !== CLONE_PROTOCOL) { - throw new Error(`Clone analysis request protocol must be ${CLONE_PROTOCOL}`); - } - if (request.requestId !== undefined) validateNonEmptyString(request.requestId, "Clone analysis request requestId"); - if (request.schemaVersion !== 1) { - throw new Error("Clone analysis request schemaVersion must be 1"); - } - validateRepoIdentity(request.repo); - validateCloneReportMode(request.reportMode, "Clone analysis request reportMode"); - if (request.paths !== undefined) validateRepoRelativePaths(request.paths, "Clone analysis request paths"); - if (request.sourcePaths !== undefined) validateRepoRelativePaths(request.sourcePaths, "Clone analysis request sourcePaths"); - if (request.sourceReadMode !== undefined) validateCloneSourceReadMode(request.sourceReadMode, "Clone analysis request sourceReadMode"); - if (request.sourceTreeRef !== undefined) validateNonEmptyString(request.sourceTreeRef, "Clone analysis request sourceTreeRef"); - validateHypotheticalOverlays(request.overlays); - if (request.windowSize !== undefined) validatePositiveInteger(request.windowSize, "Clone analysis request windowSize"); - if (request.minLines !== undefined) validatePositiveInteger(request.minLines, "Clone analysis request minLines"); - if (request.minTokens !== undefined) validatePositiveInteger(request.minTokens, "Clone analysis request minTokens"); - if (request.threshold !== undefined) validatePositiveInteger(request.threshold, "Clone analysis request threshold"); - if (request.partitions !== undefined) validateCloneAnalysisPartitions(request.partitions); - if (request.exclude !== undefined) validateStringArray(request.exclude, "Clone analysis request exclude", { allowEmpty: true }); - if (request.modes !== undefined) validateStringArray(request.modes, "Clone analysis request modes", { allowEmpty: true }); - return request; -} - -function validateCloneSourceReadMode(value: unknown, label: string): asserts value is CloneSourceReadMode { - if (!cloneSourceReadModes.includes(value as CloneSourceReadMode)) { - throw new Error(`${label} must be one of ${cloneSourceReadModes.join(", ")}`); - } -} - -function validateCloneAnalysisPartitions(partitions: readonly (readonly string[])[]): void { - if (!Array.isArray(partitions)) throw new Error("Clone analysis request partitions must be an array"); - for (const [index, partition] of partitions.entries()) { - validateStringArray(partition, `Clone analysis request partitions[${index}]`, { allowEmpty: false }); - } -} - -export function validateCloneAnalysisResult(result: CloneAnalysisResult): CloneAnalysisResult { - if (!result || typeof result !== "object") { - throw new Error("Clone analysis result is required"); - } - if (result.protocol !== CLONE_PROTOCOL) { - throw new Error(`Clone analysis result protocol must be ${CLONE_PROTOCOL}`); - } - if (result.requestId !== undefined) validateNonEmptyString(result.requestId, "Clone analysis result requestId"); - if (result.schemaVersion !== 1) { - throw new Error("Clone analysis result schemaVersion must be 1"); - } - validateRepoIdentity(result.repo); - validateCloneReportMode(result.reportMode, "Clone analysis result reportMode"); - if (result.status !== "passed") { - throw new Error("Clone analysis result status must be passed"); - } - if (typeof result.persisted !== "boolean") { - throw new Error("Clone analysis result persisted must be boolean"); - } - if (result.dbPath !== undefined) validateRepoRelativePath(result.dbPath); - if (!Array.isArray(result.findings)) { - throw new Error("Clone analysis result findings must be an array"); - } - for (const finding of result.findings) validateCloneFinding(finding); - validateCloneAnalysisSummary(result.summary, result.findings.length); - return result; -} - -function validateCloneReportMode(mode: unknown, label: string): CloneReportMode { - if (!includesString(cloneReportModes, mode)) { - throw new Error(`Unknown ${label}: ${String(mode)}`); - } - return mode; -} - -function validateCloneFinding(finding: CloneFinding): CloneFinding { - if (!finding || typeof finding !== "object") { - throw new Error("Clone finding is required"); - } - for (const forbidden of ["line", "column", "startLine", "endLine", "startColumn", "endColumn"]) { - if (Object.hasOwn(finding, forbidden)) { - throw new Error("Clone finding identity must stay line-free"); - } - } - validateCloneClassId(finding.cloneClassId, "Clone finding cloneClassId"); - validateSha256Hex(finding.contentHash, "Clone finding contentHash"); - const path = validateRepoRelativePath(finding.path); - const peerPath = validateRepoRelativePath(finding.peerPath); - if (path === peerPath) { - throw new Error("Clone finding path and peerPath must be distinct"); - } - validateRepoRelativePaths(finding.paths, "Clone finding paths"); - if (!finding.paths.includes(path) || !finding.paths.includes(peerPath)) { - throw new Error("Clone finding paths must include path and peerPath"); - } - validatePositiveInteger(finding.lineCount, "Clone finding lineCount"); - validatePositiveInteger(finding.tokenCount, "Clone finding tokenCount"); - if (typeof finding.introduced !== "boolean") { - throw new Error("Clone finding introduced must be boolean"); - } - return finding; -} - -function validateCloneAnalysisSummary(summary: CloneAnalysisSummary, findingsLength: number): CloneAnalysisSummary { - if (!summary || typeof summary !== "object") { - throw new Error("Clone analysis summary is required"); - } - validateNonNegativeInteger(summary.analyzedFiles, "Clone analysis summary analyzedFiles"); - validateNonNegativeInteger(summary.cloneClassCount, "Clone analysis summary cloneClassCount"); - validateNonNegativeInteger(summary.findingCount, "Clone analysis summary findingCount"); - validateNonNegativeInteger(summary.overlayCount, "Clone analysis summary overlayCount"); - if (summary.findingCount !== findingsLength) { - throw new Error("Clone analysis summary findingCount must equal findings length"); - } - return summary; -} - -function validateCloneClassId(value: unknown, label: string): string { - const id = validateNonEmptyString(value, label); - if (!/^clone-[a-f0-9]{16}$/u.test(id)) { - throw new Error(`${label} must be a stable clone id`); - } - return id; -} - -function validateSha256Hex(value: unknown, label: string): string { - const sha = validateNonEmptyString(value, label); - if (!/^[a-f0-9]{64}$/u.test(sha)) { - throw new Error(`${label} must be a SHA-256 hex digest`); - } - return sha; -} - -export function validateValidationRequestPayload(request: ValidationRequest): ValidationRequest { - if (!request || typeof request !== "object") { - throw new Error("Validation request is required"); - } - if (request.requestId !== undefined) validateNonEmptyString(request.requestId, "Validation request requestId"); - validateRepoIdentity(request.repo); - validateValidationScope(request.scope); - validateValidationGraphConfig(request.graph); - validateHypotheticalOverlays(request.overlays); - if (request.checks !== undefined) validateValidationChecks(request.checks, "Validation request checks"); - if (request.reportMode !== undefined && !includesString(validationReportModes, request.reportMode)) { - throw new Error(`Unknown validation request reportMode: ${String(request.reportMode)}`); - } - return request; -} - -export function validateValidationResultPayload(result: ValidationResult): ValidationResult { - if (!result || typeof result !== "object") { - throw new Error("Validation result is required"); - } - if (typeof result.ok !== "boolean") { - throw new Error("Validation result ok must be boolean"); - } - if (!includesString(validationResultStatuses, result.status)) { - throw new Error(`Unknown validation result status: ${String(result.status)}`); - } - if (result.status === "passed" && !result.ok) { - throw new Error("Validation passed result must use ok=true"); - } - if (result.ok && result.status !== "passed") { - throw new Error("Validation result ok=true must use passed status"); - } - validateValidationDiagnostics(result.diagnostics); - if (result.graphStatus !== undefined) validateProviderStatus(result.graphStatus); - if (result.failure !== undefined) validateValidationFailure(result.failure); - if (result.refusal !== undefined) validateEditRefusal(result.refusal); - if (result.status === "refused" && result.refusal === undefined) { - throw new Error("Validation refused result must include refusal"); - } - if (result.status === "refused" && result.failure !== undefined) { - throw new Error("Validation refused result must not include failure"); - } - if (includesString(validationFailureCategories, result.status) && result.failure === undefined) { - throw new Error(`Validation ${result.status} result must include failure`); - } - if (includesString(validationFailureCategories, result.status)) { - if (result.failure?.category !== result.status) { - throw new Error("Validation failure category must match result status"); - } - if (result.refusal !== undefined) { - throw new Error("Validation failure result must not include refusal"); - } - } - if (result.status === "passed" && (result.failure !== undefined || result.refusal !== undefined)) { - throw new Error("Validation passed result must not include failure or refusal"); - } - if (result.manifest !== undefined) { - validateValidationResultManifest(result.manifest); - } - if (result.pythonProjectContexts !== undefined) validatePythonProjectContexts(result.pythonProjectContexts); - if (result.pythonCapabilityRuns !== undefined) validatePythonValidationCapabilityRuns(result.pythonCapabilityRuns); - return result; -} - -export function validatePythonValidationCapabilityRun( - run: PythonValidationCapabilityRun -): PythonValidationCapabilityRun { - if (run.capability === "types") return validatePythonTypesValidationCapabilityRun(run); - if (run.capability === "pytest") return validatePythonPytestValidationCapabilityRun(run); - return validatePythonRuffValidationCapabilityRun(run); -} - -function validatePythonTypesValidationCapabilityRun( - run: PythonTypesValidationCapabilityRun -): PythonTypesValidationCapabilityRun { - if (!run || typeof run !== "object") throw new Error("Python validation capability run is required"); - validatePythonCapabilityRunShape(run); - validatePythonCapabilityRunIdentity(run); - validatePythonCapabilityRunCounts(run); - if (run.tool !== undefined) validatePythonValidationCapabilityTool(run.tool, run); - if (run.execution !== undefined) validatePythonValidationCapabilityExecution(run.execution); - validatePythonCapabilityRunStatus(run); - return run; -} - -function validatePythonCapabilityRunShape(run: PythonTypesValidationCapabilityRun): void { - validateExactObjectKeys(run, [ - "schemaId", "schemaVersion", "capability", "checkId", "projectKey", "contextFingerprint", "projectRoot", - "targets", "selectedSourcePaths", "selectedConfigPaths", "afterStateManifestFingerprint", "authority", - "authoritySource", "status", "tool", "execution", "durationMs", "diagnosticCount", "errorCount", - "warningCount", "noteCount" - ], "Python validation capability run"); - if (run.schemaId !== PYTHON_VALIDATION_CAPABILITY_RUN_SCHEMA_ID) { - throw new Error(`Python validation capability run schemaId must be ${PYTHON_VALIDATION_CAPABILITY_RUN_SCHEMA_ID}`); - } - if (run.schemaVersion !== 1) throw new Error("Python validation capability run schemaVersion must be 1"); - if (run.capability !== "types" || run.checkId !== "python.types") { - throw new Error("Python validation capability run must describe python.types"); - } -} - -function validatePythonCapabilityRunIdentity(run: PythonTypesValidationCapabilityRun): void { - validateSha256Identity(run.projectKey, "Python validation capability run projectKey"); - validateSha256Identity(run.contextFingerprint, "Python validation capability run contextFingerprint"); - validatePythonProjectRoot(run.projectRoot, "Python validation capability run projectRoot"); - validateSortedUniqueRepoPaths(run.targets, "Python validation capability run targets", false); - validateSortedUniqueRepoPaths(run.selectedSourcePaths, "Python validation capability run selectedSourcePaths", false); - validateSortedUniqueRepoPaths(run.selectedConfigPaths, "Python validation capability run selectedConfigPaths", true); - for (const target of run.targets) { - if (!run.selectedSourcePaths.includes(target)) { - throw new Error("Python validation capability run targets must be selected source paths"); - } - } - validateSha256Identity(run.afterStateManifestFingerprint, "Python validation capability run afterStateManifestFingerprint"); - if (run.authority !== undefined && !includesString(pythonValidationAuthorities, run.authority)) { - throw new Error(`Unknown Python validation authority: ${String(run.authority)}`); - } - if (run.authoritySource !== undefined && !includesString(pythonValidationAuthoritySources, run.authoritySource)) { - throw new Error(`Unknown Python validation authority source: ${String(run.authoritySource)}`); - } - if ((run.authority === undefined) !== (run.authoritySource === undefined)) { - throw new Error("Python validation capability authority and authoritySource must be present together"); - } - if (run.authority === undefined && run.status !== "invalid_config" && run.status !== "unsupported_target") { - throw new Error(`Python validation capability run ${run.status} requires selected authority evidence`); - } - if (!includesString(pythonValidationCapabilityRunStatuses, run.status)) { - throw new Error(`Unknown Python validation capability run status: ${String(run.status)}`); - } -} - -function validatePythonCapabilityRunCounts(run: PythonTypesValidationCapabilityRun): void { - for (const [key, value] of [ - ["durationMs", run.durationMs], ["diagnosticCount", run.diagnosticCount], ["errorCount", run.errorCount], - ["warningCount", run.warningCount], ["noteCount", run.noteCount] - ] as const) validateNonNegativeInteger(value, `Python validation capability run ${key}`); - if (run.diagnosticCount !== run.errorCount + run.warningCount + run.noteCount) { - throw new Error("Python validation capability run diagnosticCount must equal severity counts"); - } -} - -function validatePythonCapabilityRunStatus(run: PythonTypesValidationCapabilityRun): void { - if (run.status === "passed") validatePassedPythonCapability(run); - if (run.status === "findings") validateFindingsPythonCapability(run); - if (run.status === "timeout") validateTimeoutPythonCapability(run); - if (run.status === "invalid_config") validateInvalidPythonCapability(run); - if (run.status === "unsupported_target") validateUnexecutedPythonCapability(run); - if (run.status === "tool_unavailable") validateUnavailablePythonCapability(run); - if (run.status === "tool_failure") validateFailedPythonCapability(run); -} - -function validatePassedPythonCapability(run: PythonTypesValidationCapabilityRun): void { - requireExitedPythonCapability(run); - if (run.execution?.exitCode !== 0 || run.errorCount !== 0) { - throw new Error("Passed Python validation capability run requires exit 0 and zero errors"); - } -} - -function validateFindingsPythonCapability(run: PythonTypesValidationCapabilityRun): void { - requireExitedPythonCapability(run); - if (run.execution?.exitCode !== 1 || run.diagnosticCount === 0) { - throw new Error("Findings Python validation capability run requires exit 1 and diagnostics"); - } - if (run.errorCount + run.warningCount === 0) { - throw new Error("Findings Python validation capability run requires an error or warning"); - } -} - -function requireExitedPythonCapability(run: PythonTypesValidationCapabilityRun): void { - if (run.tool === undefined || run.execution?.termination !== "exited") { - throw new Error(`Python validation capability run ${run.status} requires exited tool evidence`); - } -} - -function validateTimeoutPythonCapability(run: PythonTypesValidationCapabilityRun): void { - if (run.tool === undefined || run.execution?.termination !== "timeout" || run.execution.failureSummary === undefined) { - throw new Error("Timeout Python validation capability run requires tool and timeout failure evidence"); - } -} - -function validateInvalidPythonCapability(run: PythonTypesValidationCapabilityRun): void { - if (run.authority === undefined && (run.tool !== undefined || run.execution !== undefined)) { - throw new Error("Unselected invalid-config Python validation capability run must not include tool or execution evidence"); - } - if (run.execution === undefined) return; - if (run.tool === undefined || run.execution.termination !== "exited" || run.execution.failureSummary === undefined) { - throw new Error("Executed invalid-config Python validation capability run requires exited tool failure evidence"); - } -} - -function validateUnexecutedPythonCapability(run: PythonTypesValidationCapabilityRun): void { - if (run.execution !== undefined) throw new Error(`${run.status} Python validation capability run must not include execution evidence`); -} - -function validateUnavailablePythonCapability(run: PythonTypesValidationCapabilityRun): void { - if (run.tool === undefined || run.execution !== undefined) { - throw new Error("Tool-unavailable Python validation capability run requires tool provenance without execution"); - } -} - -function validateFailedPythonCapability(run: PythonTypesValidationCapabilityRun): void { - if (run.tool === undefined || run.execution === undefined || run.execution.termination === "timeout" || run.execution.failureSummary === undefined) { - throw new Error("Tool-failure Python validation capability run requires non-timeout tool failure evidence"); - } -} - -export function validatePythonValidationCapabilityRuns( - runs: readonly PythonValidationCapabilityRun[] -): readonly PythonValidationCapabilityRun[] { - if (!Array.isArray(runs)) throw new Error("Python validation capability runs must be an array"); - for (const run of runs) validatePythonValidationCapabilityRun(run); - return runs; -} - -function validatePythonPytestValidationCapabilityRun( - run: PythonPytestValidationCapabilityRun -): PythonPytestValidationCapabilityRun { - if (!run || typeof run !== "object") throw new Error("Python pytest capability run is required"); - validateExactObjectKeys(run, [ - "capability", - "checkId", - "activation", - "outcome", - "message", - "projectKey", - "projectRoot", - "configFile", - "targetCount", - "candidatePaths", - "collectedNodeIds", - "afterStateFingerprint", - "selectionMode", - "selectionDigest", - "counts", - "collection", - "execution", - "cleanup" - ], "Python pytest capability run"); - if (run.capability !== "pytest" || run.checkId !== "python.pytest") { - throw new Error("Python pytest capability run must describe python.pytest"); - } - if (!includesString(pythonCapabilityActivations, run.activation)) { - throw new Error(`Unknown Python pytest capability activation: ${String(run.activation)}`); - } - validateNonEmptyString(run.outcome, "Python pytest capability run outcome"); - validateNonEmptyString(run.message, "Python pytest capability run message"); - if (run.projectKey !== undefined) validateSha256Identity(run.projectKey, "Python pytest capability run projectKey"); - if (run.projectRoot !== undefined) validatePythonProjectRoot(run.projectRoot, "Python pytest capability run projectRoot"); - if (run.configFile !== undefined) validateRepoRelativePath(run.configFile); - if (run.targetCount !== undefined) validateNonNegativeInteger(run.targetCount, "Python pytest capability run targetCount"); - if (run.candidatePaths !== undefined) validateRepoPathArray(run.candidatePaths, "Python pytest capability run candidatePaths"); - if (run.collectedNodeIds !== undefined) { - validateStringArray(run.collectedNodeIds, "Python pytest capability run collectedNodeIds", { allowEmpty: true }); - } - if (run.afterStateFingerprint !== undefined) { - validateSha256Identity(run.afterStateFingerprint, "Python pytest capability run afterStateFingerprint"); - } - if (run.selectionMode !== undefined && !includesString(pythonPytestSelectionModes, run.selectionMode)) { - throw new Error(`Unknown Python pytest capability selection mode: ${String(run.selectionMode)}`); - } - if (run.selectionDigest !== undefined) { - validateSha256Identity(run.selectionDigest, "Python pytest capability run selectionDigest"); - } - if (run.counts !== undefined) validatePythonPytestCapabilityCounts(run.counts); - if (run.collection !== undefined) validatePythonPytestCapabilityInvocation(run.collection, "Python pytest capability collection"); - if (run.execution !== undefined) validatePythonPytestCapabilityInvocation(run.execution, "Python pytest capability execution"); - if (run.cleanup !== undefined) validatePythonPytestCapabilityCleanupEvidence(run.cleanup); - return run; -} - -function validatePythonPytestCapabilityCounts(counts: PythonCapabilityCounts): void { - if (!counts || typeof counts !== "object") throw new Error("Python pytest capability counts are required"); - validateExactObjectKeys(counts, [ - "candidateCount", - "collectedCount", - "executedCount", - "passedCount", - "failedCount", - "skippedCount", - "xfailedCount", - "xpassedCount", - "errorCount" - ], "Python pytest capability counts"); - for (const key of Object.keys(counts) as (keyof PythonCapabilityCounts)[]) { - validateNonNegativeInteger(counts[key], `Python pytest capability counts ${key}`); - } -} - -function validatePythonPytestCapabilityInvocation(invocation: PythonCapabilityInvocation, label: string): void { - if (!invocation || typeof invocation !== "object") throw new Error(`${label} is required`); - validateExactObjectKeys(invocation, [ - "stage", - "command", - "argsDigest", - "argCount", - "selectionMode", - "selectionDigest", - "durationMs", - "termination", - "exitCode", - "signal", - "outputBytes", - "stdoutDigest", - "stderrDigest" - ], label); - if (!includesString(["collection", "execution"] as const, invocation.stage)) { - throw new Error(`${label} stage must be collection or execution`); - } - validateNonEmptyString(invocation.command, `${label} command`); - validateSha256Identity(invocation.argsDigest, `${label} argsDigest`); - validateNonNegativeInteger(invocation.argCount, `${label} argCount`); - if (!includesString(pythonPytestSelectionModes, invocation.selectionMode)) { - throw new Error(`Unknown ${label} selectionMode: ${String(invocation.selectionMode)}`); - } - if (invocation.selectionDigest !== undefined) { - validateSha256Identity(invocation.selectionDigest, `${label} selectionDigest`); - } - validateNonNegativeNumber(invocation.durationMs, `${label} durationMs`); - if (!includesString(pythonCapabilityProcessTerminations, invocation.termination)) { - throw new Error(`Unknown ${label} termination: ${String(invocation.termination)}`); - } - if (invocation.exitCode !== undefined) validateNonNegativeInteger(invocation.exitCode, `${label} exitCode`); - if (invocation.signal !== undefined) validateNonEmptyString(invocation.signal, `${label} signal`); - validateNonNegativeInteger(invocation.outputBytes, `${label} outputBytes`); - if (invocation.stdoutDigest !== undefined) validateSha256Identity(invocation.stdoutDigest, `${label} stdoutDigest`); - if (invocation.stderrDigest !== undefined) validateSha256Identity(invocation.stderrDigest, `${label} stderrDigest`); -} - -function validatePythonPytestCapabilityCleanupEvidence(cleanup: PythonCapabilityCleanupEvidence): void { - if (!cleanup || typeof cleanup !== "object") throw new Error("Python pytest capability cleanup evidence is required"); - validateExactObjectKeys(cleanup, ["attempted", "ok", "failureMessage"], "Python pytest capability cleanup evidence"); - if (typeof cleanup.attempted !== "boolean") throw new Error("Python pytest capability cleanup attempted must be boolean"); - if (typeof cleanup.ok !== "boolean") throw new Error("Python pytest capability cleanup ok must be boolean"); - if (cleanup.failureMessage !== undefined) { - validateNonEmptyString(cleanup.failureMessage, "Python pytest capability cleanup failureMessage"); - } -} - -function validatePythonValidationCapabilityTool( - tool: PythonValidationCapabilityToolProvenance, - run: PythonTypesValidationCapabilityRun -): void { - validateExactObjectKeys(tool, ["name", "executable", "argv", "cwd", "source", "version", "configFile"], "Python validation capability tool"); - if (run.authority === undefined || tool.name !== run.authority) throw new Error("Python validation capability tool must match authority"); - validatePortablePythonCapabilityExecutable(tool.executable); - validateStringArray(tool.argv, "Python validation capability tool argv", { allowEmpty: false }); - if (tool.argv[0] !== tool.executable) throw new Error("Python validation capability tool argv must start with executable"); - for (const argument of tool.argv) { - if (containsHostAbsolutePath(argument)) { - throw new Error("Python validation capability tool requires portable argv without host-absolute paths"); - } - } - validatePythonProjectRoot(tool.cwd, "Python validation capability tool cwd"); - if (tool.cwd !== run.projectRoot) throw new Error("Python validation capability tool cwd must equal projectRoot"); - if (!includesString(pythonProjectExecutableSources, tool.source)) { - throw new Error(`Unknown Python validation capability tool source: ${String(tool.source)}`); - } - if (tool.version !== undefined) { - validateNonEmptyString(tool.version, "Python validation capability tool version"); - if (!/^[0-9]+\.[0-9][-+._A-Za-z0-9]*$/u.test(tool.version)) { - throw new Error("Python validation capability tool version must be exact version provenance"); - } - } - if (tool.configFile !== undefined) { - validateRepoRelativePath(tool.configFile); - if (!run.selectedConfigPaths.includes(tool.configFile)) { - throw new Error("Python validation capability tool configFile must be a selected config path"); - } - } -} - -function validatePortablePythonCapabilityExecutable(executable: string): void { - validateNonEmptyString(executable, "Python validation capability tool executable"); - const match = /^(repo|project|path|external):(.+)$/u.exec(executable); - if (match === null) throw new Error("Python validation capability tool requires a portable executable locator"); - const [, kind, value] = match; - if (kind === "repo" || kind === "project") { - try { - validateRepoRelativePath(value); - } catch { - throw new Error("Python validation capability tool requires a portable executable locator"); - } - return; - } - if (!/^[A-Za-z0-9_.+-]+$/u.test(value)) { - throw new Error("Python validation capability tool requires a portable executable locator"); - } -} - -function containsHostAbsolutePath(value: string): boolean { - return /^(?:\/|\\\\|[A-Za-z]:[\\/])/u.test(value) || - /[\s("'=](?:\/|\\\\|[A-Za-z]:[\\/])/u.test(value) || - /file:\/\//iu.test(value); -} - -function validatePythonValidationCapabilityExecution(execution: PythonValidationCapabilityExecution): void { - validateExactObjectKeys(execution, ["termination", "exitCode", "signal", "failureSummary"], "Python validation capability execution"); - if (!includesString(pythonValidationCapabilityTerminationKinds, execution.termination)) { - throw new Error(`Unknown Python validation capability termination: ${String(execution.termination)}`); - } - validatePythonCapabilityExit(execution); - validatePythonCapabilitySignal(execution); - validatePythonCapabilityFailureSummary(execution); -} - -function validatePythonCapabilityExit(execution: PythonValidationCapabilityExecution): void { - if (execution.exitCode !== undefined) validateNonNegativeInteger(execution.exitCode, "Python validation capability execution exitCode"); - if (execution.termination === "exited" && execution.exitCode === undefined) { - throw new Error("Exited Python validation capability execution requires exitCode"); - } - if (execution.termination !== "exited" && execution.exitCode !== undefined) { - throw new Error("Non-exited Python validation capability execution must not include exitCode"); - } -} - -function validatePythonCapabilitySignal(execution: PythonValidationCapabilityExecution): void { - if (execution.termination === "signal" && execution.signal === undefined) { - throw new Error("Signaled Python validation capability execution requires signal"); - } - if (execution.termination !== "signal" && execution.signal !== undefined) { - throw new Error("Non-signaled Python validation capability execution must not include signal"); - } - if (execution.signal !== undefined) validateNonEmptyString(execution.signal, "Python validation capability execution signal"); -} - -function validatePythonCapabilityFailureSummary(execution: PythonValidationCapabilityExecution): void { - if (execution.failureSummary !== undefined) { - validateNonEmptyString(execution.failureSummary, "Python validation capability execution failureSummary"); - if (execution.failureSummary.length > 1024) throw new Error("Python validation capability execution failureSummary is too long"); - if (containsHostAbsolutePath(execution.failureSummary)) { - throw new Error("Python validation capability execution failureSummary must not contain host-absolute paths"); - } - } - if (execution.termination !== "exited" && execution.failureSummary === undefined) { - throw new Error("Non-exited Python validation capability execution requires failureSummary"); - } -} - -function validateSortedUniqueRepoPaths(values: readonly string[], label: string, allowEmpty: boolean): void { - if (!Array.isArray(values) || (!allowEmpty && values.length === 0)) { - throw new Error(`${label} must be ${allowEmpty ? "an" : "a non-empty"} array`); - } - for (const value of values) validateRepoRelativePath(value); - const sorted = [...new Set(values)].sort(); - if (sorted.length !== values.length || sorted.some((value, index) => value !== values[index])) { - throw new Error(`${label} must be sorted and unique`); - } -} - -export function validatePythonProjectContext(context: PythonProjectContext): PythonProjectContext { - if (!context || typeof context !== "object") throw new Error("Python project context is required"); - validateExactObjectKeys(context, [ - "schemaId", "schemaVersion", "target", "repositoryRoot", "projectRoot", "projectBoundary", "sourceRoots", - "layout", "evidence", "targetRuntime", "managers", "buildSystem", "interpreter", "tools", "projectKey", - "contextFingerprint", "outcome", "reasons" - ], "Python project context"); - if (context.schemaId !== PYTHON_PROJECT_CONTEXT_SCHEMA_ID) { - throw new Error(`Python project context schemaId must be ${PYTHON_PROJECT_CONTEXT_SCHEMA_ID}`); - } - if (context.schemaVersion !== 1) throw new Error("Python project context schemaVersion must be 1"); - validateRepoRelativePath(context.target); - if (!/\.pyi?$/u.test(context.target)) throw new Error("Python project context target must be a .py or .pyi path"); - validateNonEmptyString(context.repositoryRoot, "Python project context repositoryRoot"); - validatePythonProjectRoot(context.projectRoot, "Python project context projectRoot"); - validatePythonProjectRoot(context.projectBoundary, "Python project context projectBoundary"); - validatePythonProjectRoots(context.sourceRoots, "Python project context sourceRoots"); - if (!context.layout || typeof context.layout !== "object") throw new Error("Python project context layout is required"); - validateExactObjectKeys(context.layout, ["kinds", "paths"], "Python project context layout"); - validateExactEnumArray(context.layout.kinds, pythonProjectLayoutKinds, "Python project context layout kinds", false); - validatePythonProjectRoots(context.layout.paths, "Python project context layout paths"); - if (!Array.isArray(context.evidence)) throw new Error("Python project context evidence must be an array"); - for (const entry of context.evidence) { - validateExactObjectKeys(entry, ["path", "role"], "Python project context evidence"); - validateRepoRelativePath(entry.path); - if (!includesString(["boundary", "config", "lock", "requirements", "build", "layout"] as const, entry.role)) { - throw new Error(`Unknown Python project evidence role: ${String(entry.role)}`); - } - } - validatePythonProjectTarget(context.targetRuntime); - if (!Array.isArray(context.managers)) throw new Error("Python project context managers must be an array"); - for (const manager of context.managers) { - validateExactObjectKeys(manager, ["kind", "configFiles", "lockFiles"], "Python project manager evidence"); - if (!includesString(pythonProjectManagerKinds, manager.kind)) { - throw new Error(`Unknown Python project manager kind: ${String(manager.kind)}`); - } - validateRepoPathArray(manager.configFiles, "Python project manager configFiles"); - validateRepoPathArray(manager.lockFiles, "Python project manager lockFiles"); - } - if (context.buildSystem !== undefined) { - validateExactObjectKeys(context.buildSystem, ["configFile", "backend", "requires"], "Python project buildSystem"); - validateRepoRelativePath(context.buildSystem.configFile); - if (context.buildSystem.backend !== undefined) { - validateNonEmptyString(context.buildSystem.backend, "Python project buildSystem backend"); - } - validateStringArray(context.buildSystem.requires, "Python project buildSystem requires", { allowEmpty: true }); - } - if (context.interpreter !== undefined) validatePythonInterpreterProvenance(context.interpreter); - if (!Array.isArray(context.tools)) throw new Error("Python project context tools must be an array"); - for (const tool of context.tools) { - validateExactObjectKeys( - tool, - ["tool", "available", "executable", "argv", "cwd", "source", "version", "configFile"], - "Python project tool provenance" - ); - if (!includesString(pythonProjectToolKinds, tool.tool)) throw new Error(`Unknown Python project tool: ${String(tool.tool)}`); - if (typeof tool.available !== "boolean") throw new Error("Python project tool available must be boolean"); - validatePythonExecutableProvenance(tool, `Python project tool ${tool.tool}`); - if (tool.available && tool.version === undefined) { - throw new Error(`Available Python project tool ${tool.tool} must include version provenance`); - } - } - validateSha256Identity(context.projectKey, "Python project context projectKey"); - validateSha256Identity(context.contextFingerprint, "Python project context contextFingerprint"); - if (!includesString(pythonProjectContextOutcomes, context.outcome)) { - throw new Error(`Unknown Python project context outcome: ${String(context.outcome)}`); - } - if (!Array.isArray(context.reasons)) throw new Error("Python project context reasons must be an array"); - for (const reason of context.reasons) { - validateExactObjectKeys(reason, ["code", "message", "path", "tool"], "Python project context reason"); - if (!includesString(pythonProjectContextReasonCodes, reason.code)) { - throw new Error(`Unknown Python project context reason: ${String(reason.code)}`); - } - validateNonEmptyString(reason.message, "Python project context reason message"); - if (reason.path !== undefined) validateRepoRelativePath(reason.path); - if (reason.tool !== undefined) validateNonEmptyString(reason.tool, "Python project context reason tool"); - } - if (context.outcome === "resolved" && context.reasons.length > 0) { - throw new Error("Resolved Python project context must not include reasons"); - } - if (context.outcome !== "resolved" && context.reasons.length === 0) { - throw new Error("Non-resolved Python project context must include reasons"); - } - return context; -} - -export function validatePythonProjectContexts(contexts: readonly PythonProjectContext[]): readonly PythonProjectContext[] { - if (!Array.isArray(contexts)) throw new Error("Python project contexts must be an array"); - const targets = new Set(); - for (const context of contexts) { - validatePythonProjectContext(context); - if (targets.has(context.target)) throw new Error(`Duplicate Python project context target: ${context.target}`); - targets.add(context.target); - } - return contexts; -} - -function validatePythonCapabilityCounts(counts: PythonCapabilityCounts): void { - if (!counts || typeof counts !== "object") throw new Error("Python capability counts are required"); - validateExactObjectKeys(counts, [ - "candidateCount", - "collectedCount", - "executedCount", - "passedCount", - "failedCount", - "skippedCount", - "xfailedCount", - "xpassedCount", - "errorCount" - ], "Python capability counts"); - for (const key of Object.keys(counts) as (keyof PythonCapabilityCounts)[]) { - validateNonNegativeInteger(counts[key], `Python capability counts ${key}`); - } -} - -function validatePythonCapabilityCleanupEvidence(cleanup: PythonCapabilityCleanupEvidence): void { - if (!cleanup || typeof cleanup !== "object") throw new Error("Python capability cleanup evidence is required"); - validateExactObjectKeys(cleanup, ["attempted", "ok", "failureMessage"], "Python capability cleanup evidence"); - if (typeof cleanup.attempted !== "boolean") throw new Error("Python capability cleanup attempted must be boolean"); - if (typeof cleanup.ok !== "boolean") throw new Error("Python capability cleanup ok must be boolean"); - if (cleanup.failureMessage !== undefined) { - validateNonEmptyString(cleanup.failureMessage, "Python capability cleanup failureMessage"); - } -} - -function validatePythonCapabilityInvocation(invocation: PythonCapabilityInvocation, label: string): void { - if (!invocation || typeof invocation !== "object") throw new Error(`${label} is required`); - validateExactObjectKeys(invocation, [ - "stage", - "command", - "argsDigest", - "argCount", - "selectionMode", - "selectionDigest", - "durationMs", - "termination", - "exitCode", - "signal", - "outputBytes", - "stdoutDigest", - "stderrDigest" - ], label); - if (!includesString(["collection", "execution"] as const, invocation.stage)) { - throw new Error(`${label} stage must be collection or execution`); - } - validateNonEmptyString(invocation.command, `${label} command`); - validateSha256Identity(invocation.argsDigest, `${label} argsDigest`); - validateNonNegativeInteger(invocation.argCount, `${label} argCount`); - if (!includesString(pythonPytestSelectionModes, invocation.selectionMode)) { - throw new Error(`Unknown ${label} selectionMode: ${String(invocation.selectionMode)}`); - } - if (invocation.selectionDigest !== undefined) validateSha256Identity(invocation.selectionDigest, `${label} selectionDigest`); - validateNonNegativeInteger(invocation.durationMs, `${label} durationMs`); - if (!includesString(pythonCapabilityProcessTerminations, invocation.termination)) { - throw new Error(`Unknown ${label} termination: ${String(invocation.termination)}`); - } - if (invocation.exitCode !== undefined) validateNonNegativeInteger(invocation.exitCode, `${label} exitCode`); - if (invocation.signal !== undefined) validateNonEmptyString(invocation.signal, `${label} signal`); - validateNonNegativeInteger(invocation.outputBytes, `${label} outputBytes`); - if (invocation.stdoutDigest !== undefined) validateSha256Identity(invocation.stdoutDigest, `${label} stdoutDigest`); - if (invocation.stderrDigest !== undefined) validateSha256Identity(invocation.stderrDigest, `${label} stderrDigest`); -} - -function validatePythonProjectTarget(target: PythonProjectTarget): void { - if (!target || typeof target !== "object") throw new Error("Python project targetRuntime is required"); - validateExactObjectKeys( - target, - ["requiresPython", "version", "platform", "implementation", "conflicts"], - "Python project targetRuntime" - ); - for (const [key, value] of Object.entries(target)) { - if (key === "conflicts") continue; - if (value !== undefined) validateNonEmptyString(value, `Python project targetRuntime ${key}`); - } - validateStringArray(target.conflicts, "Python project targetRuntime conflicts", { allowEmpty: true }); -} - -function validatePythonExecutableProvenance(value: PythonProjectExecutableProvenance, label: string): void { - if (!value || typeof value !== "object") throw new Error(`${label} provenance is required`); - validateNonEmptyString(value.executable, `${label} executable`); - validateStringArray(value.argv, `${label} argv`, { allowEmpty: false }); - if (value.argv[0] !== value.executable) throw new Error(`${label} argv must start with executable`); - validateNonEmptyString(value.cwd, `${label} cwd`); - if (!includesString(pythonProjectExecutableSources, value.source)) throw new Error(`Unknown ${label} source: ${String(value.source)}`); - if (value.version !== undefined) { - validateNonEmptyString(value.version, `${label} version`); - if (!/^[0-9]+\.[0-9][-+._A-Za-z0-9]*$/u.test(value.version)) { - throw new Error(`${label} version must be exact version provenance`); - } - } - if (value.configFile !== undefined) validateRepoRelativePath(value.configFile); -} - -function validatePythonInterpreterProvenance(value: PythonInterpreterProvenance): void { - validateExactObjectKeys(value, [ - "executable", "argv", "cwd", "source", "version", "configFile", "implementation", "platform", "architecture", - "abi", "soabi" - ], "Python interpreter provenance"); - validatePythonExecutableProvenance(value, "Python interpreter"); - if (!/^\d+\.\d+\.\d+(?:(?:a|b|rc)\d+)?(?:\+[A-Za-z0-9]+(?:[._-][A-Za-z0-9]+)*)?$/u.test(value.version)) { - throw new Error("Python interpreter version must be an exact Python version"); - } - for (const [key, field] of [ - ["implementation", value.implementation], - ["platform", value.platform], - ["architecture", value.architecture], - ["abi", value.abi], - ["soabi", value.soabi] - ] as const) { - validateNonEmptyString(field, `Python interpreter ${key}`); - } -} - -function validatePythonProjectRoot(value: string, label: string): void { - if (value === ".") return; - validateRepoRelativePath(value); -} - -function validatePythonProjectRoots(values: readonly string[], label: string): void { - if (!Array.isArray(values) || values.length === 0) throw new Error(`${label} must be a non-empty array`); - for (const value of values) validatePythonProjectRoot(value, label); -} - -function validateRepoPathArray(values: readonly string[], label: string): void { - if (!Array.isArray(values)) throw new Error(`${label} must be an array`); - for (const value of values) validateRepoRelativePath(value); -} - -function validateExactEnumArray( - values: readonly T[], allowed: readonly T[], label: string, requireAll: boolean -): void { - if (!Array.isArray(values) || values.length === 0) throw new Error(`${label} must be a non-empty array`); - const seen = new Set(); - for (const value of values) { - if (!includesString(allowed, value)) throw new Error(`Unknown ${label} value: ${String(value)}`); - if (seen.has(value)) throw new Error(`${label} must not contain duplicates`); - seen.add(value); - } - if (requireAll && seen.size !== allowed.length) throw new Error(`${label} must contain every supported value`); -} - -function validateSha256Identity(value: string, label: string): void { - if (!/^sha256:[a-f0-9]{64}$/u.test(value)) throw new Error(`${label} must be a sha256 identity`); -} - -function validateExactObjectKeys(value: object, allowedKeys: readonly string[], label: string): void { - const allowed = new Set(allowedKeys); - const unexpected = Object.keys(value).filter((key) => !allowed.has(key)); - if (unexpected.length > 0) { - throw new Error(`${label} has unexpected properties: ${unexpected.sort().join(", ")}`); - } -} - -export function validateRequiredContextDocPolicy(policy: RequiredContextDocPolicy): RequiredContextDocPolicy { - if (!policy || typeof policy !== "object") { - throw new Error("Required context doc policy is required"); - } - validateStringArray(policy.filenames, "Required context doc policy filenames", { allowEmpty: false }); - for (const filename of policy.filenames) validateContextDocFilename(filename); - validateStringArray(policy.requiredPaths, "Required context doc policy requiredPaths", { allowEmpty: false }); - for (const path of policy.requiredPaths) validateContextDocRequiredPath(path); - if (policy.requireRoot !== undefined && typeof policy.requireRoot !== "boolean") { - throw new Error("Required context doc policy requireRoot must be boolean"); - } - if (!Number.isInteger(policy.minimumContentLength) || policy.minimumContentLength < 1) { - throw new Error("Required context doc policy minimumContentLength must be a positive integer"); - } - if (policy.maxLines !== undefined) validatePositiveInteger(policy.maxLines, "Required context doc policy maxLines"); - if (policy.maxSectionLines !== undefined) { - validatePositiveInteger(policy.maxSectionLines, "Required context doc policy maxSectionLines"); - } - return policy; -} - -export function validatePreWriteValidationReceipt(receipt: PreWriteValidationReceipt): PreWriteValidationReceipt { - if (!receipt || typeof receipt !== "object") { - throw new Error("Pre-write validation receipt is required"); - } - if (receipt.schemaVersion !== 1) { - throw new Error("Pre-write validation receipt schemaVersion must be 1"); - } - if (receipt.kind !== "pre_write_validation") { - throw new Error("Pre-write validation receipt kind must be pre_write_validation"); - } - if (receipt.route !== "validate.pre-write") { - throw new Error("Pre-write validation receipt route must be validate.pre-write"); - } - validateStringArray(receipt.canonicalCommand, "Pre-write validation receipt canonicalCommand", { allowEmpty: false }); - validateNonEmptyString(receipt.generatedAt, "Pre-write validation receipt generatedAt"); - validateNonNegativeNumber(receipt.durationMs, "Pre-write validation receipt durationMs"); - if (!Number.isInteger(receipt.timeoutMs) || receipt.timeoutMs < 1) { - throw new Error("Pre-write validation receipt timeoutMs must be a positive integer"); - } - if (typeof receipt.ok !== "boolean") { - throw new Error("Pre-write validation receipt ok must be boolean"); - } - if (receipt.requestId !== undefined) validateNonEmptyString(receipt.requestId, "Pre-write validation receipt requestId"); - if (receipt.repo !== undefined) validateRepoIdentity(receipt.repo); - if (receipt.scope !== undefined) validateValidationScope(receipt.scope); - if (receipt.checks !== undefined) validateValidationChecks(receipt.checks, "Pre-write validation receipt checks"); - if (receipt.graph !== undefined) validatePreWriteValidationGraph(receipt.graph); - if (receipt.overlays !== undefined) validatePreWriteValidationOverlaySummary(receipt.overlays); - if (!includesString(validationResultStatuses, receipt.validationStatus)) { - throw new Error(`Unknown pre-write validation receipt status: ${String(receipt.validationStatus)}`); - } - if (!Number.isInteger(receipt.diagnosticCount) || receipt.diagnosticCount < 0) { - throw new Error("Pre-write validation receipt diagnosticCount must be a non-negative integer"); - } - if (receipt.failureSummary !== undefined) validatePreWriteValidationFailureSummary(receipt.failureSummary); - if (receipt.ok) { - if (receipt.validationStatus !== "passed") { - throw new Error("Pre-write validation pass receipt must use passed validationStatus"); - } - if ( - receipt.repo === undefined || - receipt.scope === undefined || - receipt.checks === undefined || - receipt.graph === undefined || - receipt.overlays === undefined - ) { - throw new Error("Pre-write validation pass receipt must include repo, scope, checks, graph, and overlays"); - } - if (receipt.failureSummary !== undefined) { - throw new Error("Pre-write validation pass receipt must not include failureSummary"); - } - } else { - if (receipt.validationStatus === "passed") { - throw new Error("Pre-write validation failure receipt must not use passed validationStatus"); - } - if (receipt.failureSummary === undefined) { - throw new Error("Pre-write validation failure receipt must include failureSummary"); - } - } - return receipt; -} - -export function validateValidationStatusPayload(payload: ValidationStatusPayload): ValidationStatusPayload { - if (!payload || typeof payload !== "object") { - throw new Error("Validation status payload is required"); - } - if (payload.schemaVersion !== 1) { - throw new Error("Validation status payload schemaVersion must be 1"); - } - if (typeof payload.ready !== "boolean") { - throw new Error("Validation status payload ready must be boolean"); - } - validateNonEmptyString(payload.generatedAt, "Validation status payload generatedAt"); - if (!payload.adapterRegistry || typeof payload.adapterRegistry !== "object") { - throw new Error("Validation status payload adapterRegistry is required"); - } - validateExactStringSet( - payload.adapterRegistry.checkRoutes, - ["files", "staged", "changed", "tree", "all", "manifest"], - "Validation status payload checkRoutes" - ); - validateExactStringSet( - payload.adapterRegistry.validateRoutes, - ["request", "hypothetical", "pre-write", "manifest"], - "Validation status payload validateRoutes" - ); - validateValidationChecks(payload.adapterRegistry.checkIds, "Validation status payload checkIds"); - if (!Array.isArray(payload.adapterRegistry.entries)) { - throw new Error("Validation status payload entries must be an array"); - } - for (const entry of payload.adapterRegistry.entries) validateValidationCheckManifestEntry(entry); - if (payload.adapterRegistry.adapters !== undefined) { - if (!Array.isArray(payload.adapterRegistry.adapters)) { - throw new Error("Validation status payload adapters must be an array"); - } - for (const adapter of payload.adapterRegistry.adapters) validateValidationAdapterRuntimeStatus(adapter); - } - if (!payload.graph || typeof payload.graph !== "object") { - throw new Error("Validation status payload graph is required"); - } - if (!includesString(graphProviderModes, payload.graph.mode)) { - throw new Error(`Unknown validation status graph mode: ${String(payload.graph.mode)}`); - } - const graphStatus = validateProviderStatus(payload.graph.status); - if (graphStatus.mode !== payload.graph.mode) { - throw new Error("Validation status graph status mode must match graph mode"); - } - if (payload.daemon !== undefined) { - if (!payload.daemon || typeof payload.daemon !== "object") { - throw new Error("Validation status daemon must be an object"); - } - if (!includesString(validationDaemonReadinessStates, payload.daemon.state)) { - throw new Error(`Unknown validation daemon readiness state: ${String(payload.daemon.state)}`); - } - if (payload.daemon.message !== undefined) validateNonEmptyString(payload.daemon.message, "Validation status daemon message"); - } - return payload; -} - -function validateValidationAdapterRuntimeStatus(status: ValidationAdapterRuntimeStatus): ValidationAdapterRuntimeStatus { - if (!status || typeof status !== "object") { - throw new Error("Validation adapter runtime status is required"); - } - validateNonEmptyString(status.adapter, "Validation adapter runtime status adapter"); - if (!includesString(validationAdapterRuntimeStates, status.status)) { - throw new Error(`Unknown validation adapter runtime status: ${String(status.status)}`); - } - validateValidationChecks(status.checkIds, "Validation adapter runtime status checkIds"); - if (status.toolchain !== undefined) { - if (!Array.isArray(status.toolchain)) { - throw new Error("Validation adapter runtime status toolchain must be an array"); - } - for (const tool of status.toolchain) validateValidationAdapterToolchainStatus(tool); - } - if (status.degradedChecks !== undefined) { - if (!Array.isArray(status.degradedChecks)) { - throw new Error("Validation adapter runtime status degradedChecks must be an array"); - } - for (const degradedCheck of status.degradedChecks) validateValidationAdapterDegradedCheckStatus(degradedCheck); - } - if (status.tempWorkspaceRequired !== undefined && typeof status.tempWorkspaceRequired !== "boolean") { - throw new Error("Validation adapter runtime status tempWorkspaceRequired must be boolean"); - } - return status; -} - -function validateValidationAdapterToolchainStatus(status: ValidationAdapterToolchainStatus): ValidationAdapterToolchainStatus { - if (!status || typeof status !== "object") { - throw new Error("Validation adapter toolchain status is required"); - } - validateNonEmptyString(status.tool, "Validation adapter toolchain status tool"); - if (typeof status.available !== "boolean") { - throw new Error("Validation adapter toolchain status available must be boolean"); - } - if (status.command !== undefined) validateNonEmptyString(status.command, "Validation adapter toolchain status command"); - if (status.version !== undefined) validateNonEmptyString(status.version, "Validation adapter toolchain status version"); - if (status.failureMessage !== undefined) { - validateNonEmptyString(status.failureMessage, "Validation adapter toolchain status failureMessage"); - } - if (status.cwd !== undefined) validateNonEmptyString(status.cwd, "Validation adapter toolchain status cwd"); - if (status.configFile !== undefined) { - validateNonEmptyString(status.configFile, "Validation adapter toolchain status configFile"); - } - if (status.source !== undefined) validateNonEmptyString(status.source, "Validation adapter toolchain status source"); - return status; -} - -function validateValidationAdapterDegradedCheckStatus( - status: ValidationAdapterDegradedCheckStatus -): ValidationAdapterDegradedCheckStatus { - if (!status || typeof status !== "object") { - throw new Error("Validation adapter degraded check status is required"); - } - validateValidationCheckId(status.checkId, "Validation adapter degraded check status checkId"); - if (!includesString(validationCheckRunStatuses, status.status)) { - throw new Error(`Unknown validation adapter degraded check status: ${String(status.status)}`); - } - validateNonEmptyString(status.reason, "Validation adapter degraded check status reason"); - validateNonEmptyString(status.message, "Validation adapter degraded check status message"); - if (status.requiredTool !== undefined) { - validateNonEmptyString(status.requiredTool, "Validation adapter degraded check status requiredTool"); - } - if (status.retainedCompatibility !== undefined && typeof status.retainedCompatibility !== "boolean") { - throw new Error("Validation adapter degraded check status retainedCompatibility must be boolean"); - } - if (status.followUpIssue !== undefined) { - validateNonEmptyString(status.followUpIssue, "Validation adapter degraded check status followUpIssue"); - } - if (status.currentUsage !== undefined) { - validateValidationAdapterCurrentUsage(status.currentUsage); - } - return status; -} - -function validateValidationAdapterCurrentUsage( - currentUsage: ValidationAdapterDegradedCheckStatus["currentUsage"] -): NonNullable { - if (!currentUsage || typeof currentUsage !== "object") { - throw new Error("Validation adapter degraded check status currentUsage is required when present"); - } - for (const key of ["opcore", "orchestra", "covibes", "gateway"] as const) { - if (typeof currentUsage[key] !== "boolean") { - throw new Error(`Validation adapter degraded check status currentUsage.${key} must be boolean`); - } - } - return currentUsage; -} - -function validateValidationScope(scope: ValidationScope): ValidationScope { - if (!scope || typeof scope !== "object") { - throw new Error("Validation scope is required"); - } - if (!includesString(validationScopeKinds, scope.kind)) { - throw new Error(`Unknown validation scope kind: ${String((scope as { kind?: unknown }).kind)}`); - } - if (scope.kind === "files") { - validateStringArray(scope.files, "Validation scope files", { allowEmpty: false }); - for (const file of scope.files) validateRepoRelativePath(file); - } - if (scope.kind === "changed") { - validateNonEmptyString(scope.baseRef, "Validation changed scope baseRef"); - } - if (scope.kind === "tree") { - validateNonEmptyString(scope.treeRef, "Validation tree scope treeRef"); - validateNonEmptyString(scope.changedFrom, "Validation tree scope changedFrom"); - } - if (scope.kind === "package") { - validateNonEmptyString(scope.packageName, "Validation package scope packageName"); - validateRepoRelativePath(scope.packageRoot); - } - return scope; -} - -function validateValidationGraphConfig(graph: ValidationGraphConfig): ValidationGraphConfig { - if (!graph || typeof graph !== "object") { - throw new Error("Validation graph config is required"); - } - if (!includesString(graphProviderModes, graph.mode)) { - throw new Error(`Unknown validation graph mode: ${String(graph.mode)}`); - } - if (graph.provider !== undefined) validateNonEmptyString(graph.provider, "Validation graph provider"); - if (graph.maxAgeMs !== undefined && (typeof graph.maxAgeMs !== "number" || graph.maxAgeMs < 0)) { - throw new Error("Validation graph maxAgeMs must be non-negative"); - } - if (graph.status !== undefined) { - validateProviderStatus(graph.status); - if (graph.status.mode !== graph.mode) { - throw new Error("Validation graph status mode must match graph mode"); - } - if (graph.provider !== undefined && graph.status.provider !== graph.provider) { - throw new Error("Validation graph status provider must match graph provider"); - } - } - return graph; -} - -function validateHypotheticalOverlays(overlays: readonly HypotheticalOverlay[]): readonly HypotheticalOverlay[] { - if (!Array.isArray(overlays)) { - throw new Error("Validation request overlays must be an array"); - } - const normalizedPaths = new Set(); - for (const overlay of overlays) { - validateHypotheticalOverlay(overlay); - const normalizedPath = validateRepoRelativePath(overlay.path); - if (normalizedPaths.has(normalizedPath)) { - throw new Error(`Validation request overlays include duplicate path: ${normalizedPath}`); - } - normalizedPaths.add(normalizedPath); - } - return overlays; -} - -function validateHypotheticalOverlay(overlay: HypotheticalOverlay): HypotheticalOverlay { - if (!overlay || typeof overlay !== "object") { - throw new Error("Validation request overlay is required"); - } - validateRepoRelativePath(overlay.path); - if (!includesString(["write", "delete"] as const, overlay.action)) { - throw new Error(`Unknown validation overlay action: ${String((overlay as { action?: unknown }).action)}`); - } - if (overlay.action === "write") { - if (typeof overlay.content !== "string") { - throw new Error("Validation write overlay must include content"); - } - } - if (overlay.action === "delete" && Object.hasOwn(overlay, "content")) { - throw new Error("Validation delete overlay must not include content"); - } - if (overlay.checksumBefore !== undefined) { - validateNonEmptyString(overlay.checksumBefore, "Validation overlay checksumBefore"); - } - return overlay; -} - -function validateValidationDiagnostics(diagnostics: readonly ValidationDiagnostic[]): readonly ValidationDiagnostic[] { - if (!Array.isArray(diagnostics)) { - throw new Error("Validation result diagnostics must be an array"); - } - for (const diagnostic of diagnostics) validateValidationDiagnostic(diagnostic); - return diagnostics; -} - -function validateValidationDiagnostic(diagnostic: ValidationDiagnostic): ValidationDiagnostic { - if (!diagnostic || typeof diagnostic !== "object") { - throw new Error("Validation diagnostic is required"); - } - if (!includesString(validationDiagnosticCategories, diagnostic.category)) { - throw new Error(`Unknown validation diagnostic category: ${String(diagnostic.category)}`); - } - validateNonEmptyString(diagnostic.message, "Validation diagnostic message"); - if (diagnostic.path !== undefined) validateRepoRelativePath(diagnostic.path); - if (!includesString(["info", "warning", "error"] as const, diagnostic.severity)) { - throw new Error(`Unknown validation diagnostic severity: ${String(diagnostic.severity)}`); - } - if (diagnostic.code !== undefined) validateNonEmptyString(diagnostic.code, "Validation diagnostic code"); - validateValidationDiagnosticLocation(diagnostic); - if (diagnostic.tool !== undefined) validateValidationDiagnosticTool(diagnostic.tool); - return diagnostic; -} - -function validateValidationDiagnosticLocation(diagnostic: ValidationDiagnostic): void { - for (const field of ["line", "column", "endLine", "endColumn"] as const) { - if (diagnostic[field] !== undefined) validatePositiveInteger(diagnostic[field], `Validation diagnostic ${field}`); - } - if (diagnostic.column !== undefined && diagnostic.line === undefined) { - throw new Error("Validation diagnostic column requires line"); - } - if ((diagnostic.endLine !== undefined || diagnostic.endColumn !== undefined) && diagnostic.line === undefined) { - throw new Error("Validation diagnostic end location requires line"); - } - if (diagnostic.endColumn !== undefined && diagnostic.endLine === undefined) { - throw new Error("Validation diagnostic endColumn requires endLine"); - } - if (diagnostic.line !== undefined && diagnostic.endLine !== undefined) { - const startsAfterEnd = diagnostic.endLine < diagnostic.line || - (diagnostic.endLine === diagnostic.line && diagnostic.column !== undefined && diagnostic.endColumn !== undefined && diagnostic.endColumn < diagnostic.column); - if (startsAfterEnd) throw new Error("Validation diagnostic end location must not precede start location"); - } -} - -function validateValidationDiagnosticTool(tool: ValidationDiagnosticToolProvenance): void { - if (!tool || typeof tool !== "object") throw new Error("Validation diagnostic tool provenance is required"); - validateNonEmptyString(tool.name, "Validation diagnostic tool name"); - validateNonEmptyString(tool.command, "Validation diagnostic tool command"); - if (tool.version !== undefined) validateNonEmptyString(tool.version, "Validation diagnostic tool version"); - if (tool.source !== undefined) validateNonEmptyString(tool.source, "Validation diagnostic tool source"); - if (tool.cwd !== undefined) validateNonEmptyString(tool.cwd, "Validation diagnostic tool cwd"); -} - -function validateValidationResultManifest(manifest: ValidationResultManifest): ValidationResultManifest { - if (!manifest || typeof manifest !== "object") { - throw new Error("Validation result manifest is required"); - } - if (manifest.schemaVersion !== GRAPH_SCHEMA_VERSION) { - throw new Error(`Validation result manifest schemaVersion must be ${GRAPH_SCHEMA_VERSION}`); - } - validateValidationChecks(manifest.checks, "Validation result manifest checks"); - validateNonEmptyString(manifest.generatedAt, "Validation result manifest generatedAt"); - if (manifest.durationMs !== undefined) validateNonNegativeNumber(manifest.durationMs, "Validation result manifest durationMs"); - if (manifest.entries !== undefined) { - if (!Array.isArray(manifest.entries)) { - throw new Error("Validation result manifest entries must be an array"); - } - for (const entry of manifest.entries) validateValidationCheckManifestEntry(entry); - } - if (manifest.runs !== undefined) { - if (!Array.isArray(manifest.runs)) { - throw new Error("Validation result manifest runs must be an array"); - } - for (const run of manifest.runs) validateValidationCheckRunSummary(run); - } - if (manifest.skippedChecks !== undefined) { - if (!Array.isArray(manifest.skippedChecks)) { - throw new Error("Validation result manifest skippedChecks must be an array"); - } - for (const skippedCheck of manifest.skippedChecks) validateValidationSkippedCheck(skippedCheck); - } - return manifest; -} - -function validatePreWriteValidationGraph(graph: PreWriteValidationReceipt["graph"]): void { - if (!graph || typeof graph !== "object") { - throw new Error("Pre-write validation receipt graph is required"); - } - if (!includesString(graphProviderModes, graph.mode)) { - throw new Error(`Unknown pre-write validation receipt graph mode: ${String(graph.mode)}`); - } - if (graph.provider !== undefined) validateNonEmptyString(graph.provider, "Pre-write validation receipt graph provider"); - if (graph.status !== undefined) { - validateProviderStatus(graph.status); - if (graph.status.mode !== graph.mode) { - throw new Error("Pre-write validation receipt graph status mode must match graph mode"); - } - if (graph.provider !== undefined && graph.status.provider !== graph.provider) { - throw new Error("Pre-write validation receipt graph status provider must match graph provider"); - } - } -} - -function validatePreWriteValidationOverlaySummary(summary: PreWriteValidationOverlaySummary): void { - if (!summary || typeof summary !== "object") { - throw new Error("Pre-write validation receipt overlays are required"); - } - for (const key of ["count", "writeCount", "deleteCount"] as const) { - if (!Number.isInteger(summary[key]) || summary[key] < 0) { - throw new Error(`Pre-write validation receipt overlays ${key} must be a non-negative integer`); - } - } - validateStringArray(summary.paths, "Pre-write validation receipt overlay paths", { allowEmpty: true }); - for (const path of summary.paths) validateRepoRelativePath(path); - if (summary.count !== summary.writeCount + summary.deleteCount) { - throw new Error("Pre-write validation receipt overlay count must equal writeCount plus deleteCount"); - } - if (summary.count !== summary.paths.length) { - throw new Error("Pre-write validation receipt overlay count must equal paths length"); - } -} - -function validatePreWriteValidationFailureSummary(summary: PreWriteValidationFailureSummary): void { - if (!summary || typeof summary !== "object") { - throw new Error("Pre-write validation receipt failureSummary is required"); - } - if (!includesString(validationResultStatuses, summary.category)) { - throw new Error(`Unknown pre-write validation receipt failure category: ${String(summary.category)}`); - } - if (summary.category === "passed") { - throw new Error("Pre-write validation receipt failure category must not be passed"); - } - validateNonEmptyString(summary.message, "Pre-write validation receipt failureSummary message"); - if (summary.cause !== undefined) validateNonEmptyString(summary.cause, "Pre-write validation receipt failureSummary cause"); - if (summary.retryable !== undefined && typeof summary.retryable !== "boolean") { - throw new Error("Pre-write validation receipt failureSummary retryable must be boolean"); - } -} - -function validateValidationCheckManifestEntry(entry: ValidationCheckManifestEntry): ValidationCheckManifestEntry { - if (!entry || typeof entry !== "object") { - throw new Error("Validation check manifest entry is required"); - } - validateValidationCheckId(entry.checkId, "Validation check manifest entry checkId"); - validateNonEmptyString(entry.owner, "Validation check manifest entry owner"); - validateNonEmptyString(entry.adapter, "Validation check manifest entry adapter"); - if (!includesString(["info", "warning", "error"] as const, entry.defaultSeverity)) { - throw new Error(`Unknown validation check manifest entry defaultSeverity: ${String(entry.defaultSeverity)}`); - } - if (!Array.isArray(entry.supportedScopes) || entry.supportedScopes.length === 0) { - throw new Error("Validation check manifest entry supportedScopes must be a non-empty array"); - } - for (const scopeKind of entry.supportedScopes) { - if (!includesString(validationScopeKinds, scopeKind)) { - throw new Error(`Unknown validation check manifest entry supported scope: ${String(scopeKind)}`); - } - } - if (typeof entry.requiresGraph !== "boolean") { - throw new Error("Validation check manifest entry requiresGraph must be boolean"); - } - return entry; -} - -function validateValidationCheckRunSummary(run: ValidationCheckRunSummary): ValidationCheckRunSummary { - if (!run || typeof run !== "object") { - throw new Error("Validation check run summary is required"); - } - validateValidationCheckId(run.checkId, "Validation check run summary checkId"); - if (!includesString(validationCheckRunStatuses, run.status)) { - throw new Error(`Unknown validation check run status: ${String(run.status)}`); - } - if (run.outcome !== undefined && !includesString(validationCheckOutcomes, run.outcome)) { - throw new Error(`Unknown validation check outcome: ${String(run.outcome)}`); - } - validateValidationCheckOutcomeStatus(run); - if (run.durationMs !== undefined) validateNonNegativeNumber(run.durationMs, "Validation check run summary durationMs"); - if (run.diagnosticCount !== undefined) validateNonNegativeInteger(run.diagnosticCount, "Validation check run summary diagnosticCount"); - if (run.failureMessage !== undefined) validateNonEmptyString(run.failureMessage, "Validation check run summary failureMessage"); - if ( - includesString(["infrastructure_failure", "provider_failure", "unsupported_request"] as const, run.status) && - run.failureMessage === undefined - ) { - throw new Error("Validation check run summary failureMessage is required for failure statuses"); - } - if (run.pythonCapabilityRuns !== undefined) { - if (!Array.isArray(run.pythonCapabilityRuns)) { - throw new Error("Validation check run summary pythonCapabilityRuns must be an array"); - } - for (const capabilityRun of run.pythonCapabilityRuns) validatePythonValidationCapabilityRun(capabilityRun); - } - return run; -} - -function validateValidationCheckOutcomeStatus(run: ValidationCheckRunSummary): void { - if (run.outcome === undefined) return; - const expectedStatus: Record = { - passed: "passed", - findings: "policy_failure", - tool_unavailable: "unsupported_request", - invalid_config: "unsupported_request", - timeout: "infrastructure_failure", - unsupported_target: "unsupported_request", - tool_failure: "infrastructure_failure" - }; - if (run.status !== expectedStatus[run.outcome]) { - throw new Error(`Validation check outcome ${run.outcome} requires status ${expectedStatus[run.outcome]}`); - } -} - -function validatePythonRuffValidationCapabilityRun( - run: PythonRuffValidationCapabilityRun -): PythonRuffValidationCapabilityRun { - if (!run || typeof run !== "object") { - throw new Error("Python validation capability run is required"); - } - validateExactObjectKeys(run, [ - "schemaId", "schemaVersion", "checkId", "capability", "state", "projectKey", "contextFingerprint", - "afterStateManifestFingerprint", "sourcePaths", "configPaths", "executable", "command", "argv", "cwd", - "configPath", "toolVersion", "toolSource", "termination", "exitCode", "signal", "invocations", - "durationMs", "diagnosticCount", "failureMessage" - ], "Python Ruff validation capability run"); - if (run.schemaId !== PYTHON_VALIDATION_CAPABILITY_RUN_SCHEMA_ID) { - throw new Error(`Python validation capability run schemaId must be ${PYTHON_VALIDATION_CAPABILITY_RUN_SCHEMA_ID}`); - } - if (run.schemaVersion !== 1) throw new Error("Python validation capability run schemaVersion must be 1"); - validateValidationCheckId(run.checkId, "Python validation capability run checkId"); - if (!includesString(["ruff_lint", "ruff_format"] as const, run.capability)) { - throw new Error(`Unknown Python Ruff validation capability: ${String(run.capability)}`); - } - if (!includesString(pythonValidationCapabilityStates, run.state)) { - throw new Error(`Unknown Python validation capability state: ${String(run.state)}`); - } - if (run.projectKey !== undefined) validateSha256Identity(run.projectKey, "Python validation capability run projectKey"); - if (run.contextFingerprint !== undefined) { - validateSha256Identity(run.contextFingerprint, "Python validation capability run contextFingerprint"); - } - if (run.afterStateManifestFingerprint !== undefined) { - validateSha256Identity( - run.afterStateManifestFingerprint, - "Python validation capability run afterStateManifestFingerprint" - ); - } - if (run.sourcePaths !== undefined) { - validateStringArray(run.sourcePaths, "Python validation capability run sourcePaths", { allowEmpty: true }); - for (const path of run.sourcePaths) validateRepoRelativePath(path); - } - if (run.configPaths !== undefined) { - validateStringArray(run.configPaths, "Python validation capability run configPaths", { allowEmpty: true }); - for (const path of run.configPaths) validateRepoRelativePath(path); - } - if (run.executable !== undefined) validatePortablePythonCapabilityExecutable(run.executable); - if (run.command !== undefined) validateNonEmptyString(run.command, "Python validation capability run command"); - if (run.argv !== undefined) { - validateStringArray(run.argv, "Python validation capability run argv", { allowEmpty: false }); - validatePortablePythonCapabilityArgv(run.argv); - } - if (run.cwd !== undefined) validateNonEmptyString(run.cwd, "Python validation capability run cwd"); - if (run.configPath !== undefined) validateRepoRelativePath(run.configPath); - if (run.toolVersion !== undefined) { - validateNonEmptyString(run.toolVersion, "Python validation capability run toolVersion"); - if (!/^[0-9]+\.[0-9][-+._A-Za-z0-9]*$/u.test(run.toolVersion)) { - throw new Error("Python validation capability run toolVersion must be exact version provenance"); - } - } - if (run.toolSource !== undefined && !includesString(pythonProjectExecutableSources, run.toolSource)) { - throw new Error(`Unknown Python validation capability run toolSource: ${String(run.toolSource)}`); - } - if (run.termination !== undefined && !includesString(pythonValidationCapabilityTerminations, run.termination)) { - throw new Error(`Unknown Python validation capability termination: ${String(run.termination)}`); - } - if (run.exitCode !== undefined) validateNonNegativeInteger(run.exitCode, "Python validation capability run exitCode"); - if (run.signal !== undefined) validateNonEmptyString(run.signal, "Python validation capability run signal"); - if (run.invocations !== undefined) { - if (!Array.isArray(run.invocations) || run.invocations.length === 0) { - throw new Error("Python validation capability run invocations must be a non-empty array"); - } - for (const invocation of run.invocations) { - validatePythonValidationCapabilityInvocation(invocation); - if (run.executable !== undefined && invocation.argv[0] !== run.executable) { - throw new Error("Python validation capability invocation argv must start with executable"); - } - } - } - validateNonNegativeNumber(run.durationMs, "Python validation capability run durationMs"); - validateNonNegativeInteger(run.diagnosticCount, "Python validation capability run diagnosticCount"); - if (run.failureMessage !== undefined) { - validateNonEmptyString(run.failureMessage, "Python validation capability run failureMessage"); - if (containsHostAbsolutePath(run.failureMessage)) { - throw new Error("Python validation capability run failureMessage must not contain host-absolute paths"); - } - } - if (run.state === "not_applicable" || run.state === "disabled") { - if ( - run.termination !== undefined || - run.exitCode !== undefined || - run.signal !== undefined || - run.command !== undefined || - run.argv !== undefined || - run.invocations !== undefined - ) { - throw new Error(`Python validation capability state ${run.state} must not record a process invocation`); - } - } - if (run.signal !== undefined && run.termination !== "signal") { - throw new Error("Python validation capability run signal requires signal termination"); - } - if (run.exitCode !== undefined && run.termination !== "exited") { - throw new Error("Python validation capability run exitCode requires exited termination"); - } - validateRuffCapabilityRun(run); - return run; -} - -function validatePythonValidationCapabilityInvocation(invocation: PythonValidationCapabilityInvocation): void { - if (!invocation || typeof invocation !== "object") { - throw new Error("Python validation capability invocation is required"); - } - validateStringArray(invocation.argv, "Python validation capability invocation argv", { allowEmpty: false }); - validatePortablePythonCapabilityArgv(invocation.argv); - if (!includesString(pythonValidationCapabilityTerminations, invocation.termination)) { - throw new Error(`Unknown Python validation capability invocation termination: ${String(invocation.termination)}`); - } - if (invocation.exitCode !== undefined) { - validateNonNegativeInteger(invocation.exitCode, "Python validation capability invocation exitCode"); - if (invocation.termination !== "exited") { - throw new Error("Python validation capability invocation exitCode requires exited termination"); - } - } - if (invocation.signal !== undefined) { - validateNonEmptyString(invocation.signal, "Python validation capability invocation signal"); - if (invocation.termination !== "signal") { - throw new Error("Python validation capability invocation signal requires signal termination"); - } - } - validateRuffTerminationEvidence(invocation, "Python validation capability invocation"); - validateNonNegativeNumber(invocation.durationMs, "Python validation capability invocation durationMs"); -} - -function validateRuffCapabilityRun(run: PythonRuffValidationCapabilityRun): void { - const expectedCheckId = - run.capability === "ruff_lint" - ? "python.ruff-lint" - : run.capability === "ruff_format" - ? "python.ruff-format" - : undefined; - if (expectedCheckId === undefined) return; - if (run.checkId !== expectedCheckId) { - throw new Error(`Python validation capability ${run.capability} requires checkId ${expectedCheckId}`); - } - if (run.state === "not_applicable" || run.state === "disabled") return; - const exactStateFields: readonly (keyof PythonRuffValidationCapabilityRun)[] = [ - "projectKey", - "contextFingerprint", - "afterStateManifestFingerprint", - "sourcePaths", - "configPaths", - "cwd" - ]; - for (const field of exactStateFields) { - if (run[field] === undefined) { - throw new Error(`Activated Ruff capability run requires ${field}`); - } - } - if ((run.sourcePaths?.length ?? 0) === 0) { - throw new Error("Activated Ruff capability run requires at least one source path"); - } - if (run.cwd !== "." && run.cwd !== undefined) validateRepoRelativePath(run.cwd); - if ( - run.state === "tool_unavailable" || - run.state === "unsupported_target" - ) { - requireRuffFailureMessage(run); - rejectRuffProcessEvidence(run); - return; - } - if (run.state === "timeout") { - requireRuffFailureMessage(run); - requireExecutedRuffCapability(run); - if (run.termination !== "timeout") { - throw new Error("Ruff capability timeout requires timeout termination"); - } - validateExecutedRuffCapabilityCoherence(run); - return; - } - if (run.state === "invalid_config") { - requireRuffFailureMessage(run); - if (!hasRuffProcessEvidence(run)) return; - requireExecutedRuffCapability(run); - if (run.termination !== "exited" || run.exitCode !== 2) { - throw new Error("Executed Ruff capability invalid_config requires exited configuration-rejection evidence"); - } - validateExecutedRuffCapabilityCoherence(run); - return; - } - if (run.state === "tool_failure") { - requireRuffFailureMessage(run); - if (!hasRuffProcessEvidence(run)) return; - requireExecutedRuffCapability(run); - if (run.termination === "timeout") { - throw new Error("Executed Ruff capability tool_failure must not use timeout termination"); - } - validateExecutedRuffCapabilityCoherence(run); - return; - } - if (run.state !== "passed" && run.state !== "findings") return; - requireExecutedRuffCapability(run); - if (run.termination !== "exited") { - throw new Error("Executed Ruff capability run requires exited termination"); - } - for (const invocation of run.invocations ?? []) { - if (invocation.termination !== "exited" || (invocation.exitCode !== 0 && invocation.exitCode !== 1)) { - throw new Error("Executed Ruff capability invocation requires exited Ruff result code 0 or 1"); - } - } - validateExecutedRuffCapabilityCoherence(run); - const expectedExitCode = run.state === "passed" ? 0 : 1; - if (run.exitCode !== expectedExitCode) { - throw new Error(`Executed Ruff capability state ${run.state} requires exitCode ${expectedExitCode}`); - } - if (run.state === "passed" && run.diagnosticCount !== 0) { - throw new Error("Passed Ruff capability run requires zero diagnostics"); - } - if (run.state === "findings" && run.diagnosticCount <= 0) { - throw new Error("Ruff findings capability run requires positive diagnosticCount"); - } -} - -function requireRuffFailureMessage(run: PythonRuffValidationCapabilityRun): void { - if (run.failureMessage === undefined) { - throw new Error(`Ruff capability state ${run.state} requires failureMessage`); - } -} - -function hasRuffProcessEvidence(run: PythonRuffValidationCapabilityRun): boolean { - return ( - run.command !== undefined || - run.argv !== undefined || - run.termination !== undefined || - run.exitCode !== undefined || - run.signal !== undefined || - run.invocations !== undefined - ); -} - -function rejectRuffProcessEvidence(run: PythonRuffValidationCapabilityRun): void { - if (hasRuffProcessEvidence(run)) { - throw new Error(`Ruff capability state ${run.state} must not record process evidence`); - } -} - -function requireExecutedRuffCapability(run: PythonRuffValidationCapabilityRun): void { - const executionFields: readonly (keyof PythonRuffValidationCapabilityRun)[] = [ - "executable", - "command", - "argv", - "toolVersion", - "toolSource", - "termination", - "invocations" - ]; - for (const field of executionFields) { - if (run[field] === undefined) { - throw new Error(`Executed Ruff capability run requires ${field}`); - } - } - if (run.durationMs <= 0) { - throw new Error("Executed Ruff capability run requires positive durationMs"); - } - if (run.argv?.[0] !== run.executable) { - throw new Error("Executed Ruff capability run argv must start with executable"); - } - if (run.command !== run.argv?.join(" ")) { - throw new Error("Executed Ruff capability run command must match argv"); - } - validateRuffTerminationEvidence(run, "Executed Ruff capability run"); - for (const invocation of run.invocations ?? []) { - if (invocation.argv[0] !== run.executable) { - throw new Error("Executed Ruff capability invocation argv must start with executable"); - } - if (invocation.durationMs <= 0) { - throw new Error("Executed Ruff capability invocation requires positive durationMs"); - } - } -} - -function validateExecutedRuffCapabilityCoherence(run: PythonRuffValidationCapabilityRun): void { - const matchingInvocation = run.invocations?.some((invocation) => - invocation.termination === run.termination && - invocation.exitCode === run.exitCode && - invocation.signal === run.signal && - invocation.argv.length === run.argv?.length && - invocation.argv.every((argument, index) => argument === run.argv?.[index]) - ); - if (matchingInvocation !== true) { - throw new Error("Executed Ruff capability run requires an invocation matching its argv and termination evidence"); - } -} - -function validateRuffTerminationEvidence( - evidence: Pick< - PythonRuffValidationCapabilityRun | PythonValidationCapabilityInvocation, - "termination" | "exitCode" | "signal" - >, - label: string -): void { - if (evidence.termination === "exited") { - if (evidence.exitCode === undefined || evidence.signal !== undefined) { - throw new Error(`${label} exited termination requires exitCode without signal`); - } - return; - } - if (evidence.termination === "signal") { - if (evidence.signal === undefined || evidence.exitCode !== undefined) { - throw new Error(`${label} signal termination requires signal without exitCode`); - } - return; - } - if (evidence.exitCode !== undefined || evidence.signal !== undefined) { - throw new Error(`${label} ${String(evidence.termination)} termination must not record exitCode or signal`); - } -} - -function validatePortablePythonCapabilityArgv(argv: readonly string[]): void { - for (const argument of argv) { - if (containsHostAbsolutePath(argument)) { - throw new Error("Python validation capability run requires portable argv without host-absolute paths"); - } - } -} - -function validateValidationSkippedCheck(skippedCheck: ValidationSkippedCheck): ValidationSkippedCheck { - if (!skippedCheck || typeof skippedCheck !== "object") { - throw new Error("Validation skipped check is required"); - } - validateValidationCheckId(skippedCheck.checkId, "Validation skipped check checkId"); - if (!includesString(validationSkippedCheckReasons, skippedCheck.reason)) { - throw new Error(`Unknown validation skipped reason: ${String(skippedCheck.reason)}`); - } - validateNonEmptyString(skippedCheck.message, "Validation skipped check message"); - return skippedCheck; -} - -function validateValidationFailure(failure: ValidationFailure): ValidationFailure { - if (!failure || typeof failure !== "object") { - throw new Error("Validation failure is required"); - } - if (!includesString(validationFailureCategories, failure.category)) { - throw new Error(`Unknown validation failure category: ${String(failure.category)}`); - } - validateNonEmptyString(failure.message, "Validation failure message"); - if (failure.retryable !== undefined && typeof failure.retryable !== "boolean") { - throw new Error("Validation failure retryable must be boolean"); - } - if (failure.cause !== undefined) validateNonEmptyString(failure.cause, "Validation failure cause"); - return failure; -} - -function validateContextDocFilename(filename: string): string { - validateRepoRelativePath(filename); - if (filename.includes("/")) { - throw new Error(`Required context doc policy filename must be a basename: ${filename}`); - } - return filename; -} - -function validateContextDocRequiredPath(path: string): string { - if (path === ".") return path; - return validateRepoRelativePath(path); -} - -export function validateEditPlanPayload(plan: EditPlan): EditPlan { - if (!plan || typeof plan !== "object") { - throw new Error("Edit plan is required"); - } - validateNonEmptyString(plan.planId, "Edit plan planId"); - validateRepoIdentity(plan.repo); - if (!Array.isArray(plan.changes)) { - throw new Error("Edit plan changes must be an array"); - } - for (const change of plan.changes) validateRepoRelativeChange(change); - if (!plan.atomic || typeof plan.atomic !== "object") { - throw new Error("Edit plan atomic metadata is required"); - } - if (plan.atomic.strategy !== "all_or_nothing") { - throw new Error("Edit plan atomic strategy must be all_or_nothing"); - } - if (plan.atomic.planHash !== undefined) validateNonEmptyString(plan.atomic.planHash, "Edit plan planHash"); - if (plan.atomic.expectedBaseSha !== undefined) validateNonEmptyString(plan.atomic.expectedBaseSha, "Edit plan expectedBaseSha"); - if (!plan.validation || typeof plan.validation !== "object") { - throw new Error("Edit plan validation requirement is required"); - } - if (typeof plan.validation.required !== "boolean") { - throw new Error("Edit plan validation required must be boolean"); - } - validateValidationRequestPayload(plan.validation.request); - return plan; -} - -export function validateEditCommandResult(result: EditCommandResult): EditCommandResult { - if (!result || typeof result !== "object") { - throw new Error("Edit command result is required"); - } - if (typeof result.ok !== "boolean") { - throw new Error("Edit command result ok must be boolean"); - } - if (typeof result.applied !== "boolean") { - throw new Error("Edit command result applied must be boolean"); - } - if (result.planId !== undefined) validateNonEmptyString(result.planId, "Edit command result planId"); - if (result.planHash !== undefined) validateNonEmptyString(result.planHash, "Edit command result planHash"); - if (result.appliedAt !== undefined) validateNonEmptyString(result.appliedAt, "Edit command result appliedAt"); - if (result.matchCount !== undefined) validateNonNegativeInteger(result.matchCount, "Edit command result matchCount"); - if (result.afterState !== undefined) validateEditAfterState(result.afterState); - if (result.validationRequest !== undefined) validateValidationRequestPayload(result.validationRequest); - if (result.validation !== undefined) validateValidationResultPayload(result.validation); - if (result.refusal !== undefined) validateEditRefusal(result.refusal); - if (result.rollback !== undefined) validateEditPlanRollbackState(result.rollback); - if (!result.ok && result.refusal === undefined) { - throw new Error("Edit command result refusal is required when ok=false"); - } - if (result.ok && result.refusal !== undefined) { - throw new Error("Edit command result ok=true must not include refusal"); - } - return result; -} - -function validateEditPlanRollbackState(rollback: EditPlanRollbackState): EditPlanRollbackState { - if (!rollback || typeof rollback !== "object") { - throw new Error("Edit rollback state is required"); - } - if (typeof rollback.completed !== "boolean") { - throw new Error("Edit rollback completed must be boolean"); - } - validateRepoRelativePaths(rollback.restoredPaths, "Edit rollback restoredPaths"); - validateRepoRelativePaths(rollback.failedPaths, "Edit rollback failedPaths"); - if (!Array.isArray(rollback.cleanupFailedPaths)) { - throw new Error("Edit rollback cleanupFailedPaths must be an array"); - } - for (const path of rollback.cleanupFailedPaths) { - validateNonEmptyString(path, "Edit rollback cleanupFailedPaths path"); - } - return rollback; -} - -function validateRepoRelativeChange(change: RepoRelativeChange): RepoRelativeChange { - if (!change || typeof change !== "object") { - throw new Error("Repo-relative change is required"); - } - if (change.kind === "create" || change.kind === "replace") { - validateRepoRelativePath(change.path); - if (typeof change.content !== "string") throw new Error("Repo-relative write change content must be string"); - if (change.checksumBefore !== undefined) validateNonEmptyString(change.checksumBefore, "Repo-relative change checksumBefore"); - if (change.checksumAfter !== undefined) validateNonEmptyString(change.checksumAfter, "Repo-relative change checksumAfter"); - return change; - } - if (change.kind === "delete") { - validateRepoRelativePath(change.path); - if (change.checksumBefore !== undefined) validateNonEmptyString(change.checksumBefore, "Repo-relative change checksumBefore"); - return change; - } - if (change.kind === "rename") { - validateRepoRelativePath(change.path); - validateRepoRelativePath(change.toPath); - if (change.checksumBefore !== undefined) validateNonEmptyString(change.checksumBefore, "Repo-relative change checksumBefore"); - return change; - } - throw new Error(`Unknown repo-relative change kind: ${String((change as { kind?: unknown }).kind)}`); -} - -function validateEditAfterState(afterState: Readonly>): void { - if (!afterState || typeof afterState !== "object" || Array.isArray(afterState)) { - throw new Error("Edit command result afterState must be an object"); - } - for (const [path, content] of Object.entries(afterState)) { - validateRepoRelativePath(path); - if (typeof content !== "string" && content !== null) { - throw new Error(`Edit command result afterState for ${path} must be string or null`); - } - } -} - -function validateEditRefusal(refusal: EditRefusal): EditRefusal { - if (!refusal || typeof refusal !== "object") { - throw new Error("Edit refusal is required"); - } - if (!includesString(editRefusalCategories, refusal.category)) { - throw new Error(`Unknown edit refusal category: ${String(refusal.category)}`); - } - validateNonEmptyString(refusal.message, "Edit refusal message"); - if (refusal.path !== undefined) validateRepoRelativePath(refusal.path); - return refusal; -} - -function includesString(values: T, value: unknown): value is T[number] { - return typeof value === "string" && values.includes(value); -} - -function validateGraphDaemonOperation(operation: unknown): GraphDaemonOperation { - if (!includesString(graphDaemonOperations, operation)) { - throw new Error(`Unknown graph daemon operation: ${String(operation)}`); - } - return operation; -} - -function validateGraphFactQueryKind(kind: unknown): GraphFactQuerySelector["kind"] { - if (!includesString(graphFactQueryKinds, kind)) { - throw new Error(`Unknown graph fact query kind: ${String(kind)}`); - } - return kind; -} - -function validateGraphNamedQueryKind(kind: unknown): GraphNamedQueryKind { - if (!includesString(graphNamedQueryKinds, kind)) { - throw new Error(`Unknown graph named query kind: ${String(kind)}`); - } - return kind; -} - -function validateGraphProviderQueryKind(kind: unknown): GraphProviderQueryKind { - if ( - !includesString(graphFactQueryKinds, kind) && - !includesString(graphNamedQueryKinds, kind) && - kind !== "review_context" && - kind !== "detect_changes" && - kind !== "search" - ) { - throw new Error(`Unknown graph provider query kind: ${String(kind)}`); - } - return kind; -} - -function validateGraphQueryRequestBase( - request: { requestId?: string; repo: RepoIdentity; schemaVersion: number; mode: GraphProviderMode }, - label: string -): void { - if (!request || typeof request !== "object") throw new Error(`${label} is required`); - if (request.requestId !== undefined) validateNonEmptyString(request.requestId, `${label} requestId`); - validateRepoIdentity(request.repo); - if (request.schemaVersion !== GRAPH_SCHEMA_VERSION) throw new Error(`${label} schemaVersion must be ${GRAPH_SCHEMA_VERSION}`); - if (!includesString(graphProviderModes, request.mode)) throw new Error(`Unknown ${label} mode: ${String(request.mode)}`); -} - -function validateTraversalOptions(maxDepth: number | undefined, limit: number | undefined, label: string): void { - if (maxDepth !== undefined && (!Number.isFinite(maxDepth) || maxDepth < 0)) { - throw new Error(`${label} maxDepth must be a non-negative number`); - } - if (limit !== undefined && (!Number.isFinite(limit) || limit < 1)) { - throw new Error(`${label} limit must be a positive number`); - } -} - -function validateGraphTraversalMetadata(metadata: GraphTraversalMetadata): GraphTraversalMetadata { - if (!metadata || typeof metadata !== "object") throw new Error("Graph traversal metadata is required"); - if (typeof metadata.maxDepth !== "number" || metadata.maxDepth < 0) { - throw new Error("Graph traversal metadata maxDepth must be non-negative"); - } - if (typeof metadata.truncated !== "boolean") throw new Error("Graph traversal metadata truncated must be boolean"); - if (typeof metadata.total !== "number" || metadata.total < 0) throw new Error("Graph traversal metadata total must be non-negative"); - if (typeof metadata.empty !== "boolean") throw new Error("Graph traversal metadata empty must be boolean"); - return metadata; -} - -function validateGraphSearchMode(mode: GraphSearchMode): GraphSearchMode { - if (!mode || typeof mode !== "object") throw new Error("Graph search mode is required"); - validateNonEmptyString(mode.engine, "Graph search mode engine"); - validateNonEmptyString(mode.querySyntax, "Graph search mode querySyntax"); - if (!Number.isFinite(mode.limit) || mode.limit < 1) throw new Error("Graph search mode limit must be a positive number"); - validateRepoRelativePaths(mode.contextFiles, "Graph search mode contextFiles"); - return mode; -} - -function validateGraphSearchSummary(summary: GraphSearchSummary): GraphSearchSummary { - if (!summary || typeof summary !== "object") throw new Error("Graph search summary is required"); - validateNonEmptyString(summary.query, "Graph search summary query"); - for (const key of ["total", "returned", "limit"] as const) { - if (!Number.isFinite(summary[key]) || summary[key] < (key === "limit" ? 1 : 0)) { - throw new Error(`Graph search summary ${key} must be a non-negative number`); - } - } - validateStringArray(summary.indexedNodeKinds, "Graph search summary indexedNodeKinds", { allowEmpty: true }); - validateRepoRelativePaths(summary.contextFiles, "Graph search summary contextFiles"); - return summary; -} - -function validateGraphSearchResultEntry(entry: GraphSearchResultEntry): GraphSearchResultEntry { - if (!entry || typeof entry !== "object") throw new Error("Graph search result entry is required"); - validateNonEmptyString(entry.nodeId, "Graph search result entry nodeId"); - validateNonEmptyString(entry.kind, "Graph search result entry kind"); - if (entry.path !== undefined) validateRepoRelativePath(entry.path); - if (entry.name !== undefined) validateNonEmptyString(entry.name, "Graph search result entry name"); - validateNonEmptyString(entry.qualifiedName, "Graph search result entry qualifiedName"); - if (entry.filePath !== undefined) validateRepoRelativePath(entry.filePath); - validateNonEmptyString(entry.signature, "Graph search result entry signature"); - if (!Number.isFinite(entry.score)) throw new Error("Graph search result entry score must be numeric"); - if (!Number.isFinite(entry.rank) || entry.rank < 1) throw new Error("Graph search result entry rank must be a positive number"); - validateStringArray(entry.matches, "Graph search result entry matches", { allowEmpty: true }); - return entry; -} - -function validateInspectRouteName(route: unknown): InspectRouteResult["route"] { - if (!includesString(["references", "signature", "implementations"] as const, route)) { - throw new Error(`Unknown inspect route result route: ${String(route)}`); - } - return route; -} - -function validateInspectRoutePayload(result: InspectRouteResult, route: InspectRouteResult["route"]): void { - const payloadFields = ["references", "signatures", "implementations"] as const; - const expectedField = - route === "references" ? "references" : route === "signature" ? "signatures" : "implementations"; - for (const field of payloadFields) { - const hasField = Object.hasOwn(result, field); - if (field !== expectedField && hasField) { - throw new Error(`Inspect ${route} result must not include ${field}`); - } - } - const payload = result as unknown as { - references?: readonly InspectReferenceEntry[]; - signatures?: readonly InspectSignatureEntry[]; - implementations?: readonly InspectImplementationEntry[]; - }; - if (route === "references") { - if (!Array.isArray(payload.references)) throw new Error("Inspect references result references must be an array"); - for (const reference of payload.references) validateInspectReferenceEntry(reference); - return; - } - if (route === "signature") { - if (!Array.isArray(payload.signatures)) throw new Error("Inspect signature result signatures must be an array"); - for (const signature of payload.signatures) validateInspectSignatureEntry(signature); - return; - } - if (!Array.isArray(payload.implementations)) throw new Error("Inspect implementations result implementations must be an array"); - for (const implementation of payload.implementations) validateInspectImplementationEntry(implementation); -} - -function validateInspectSymbolTarget(target: InspectSymbolTarget, label: string): InspectSymbolTarget { - if (!target || typeof target !== "object") throw new Error(`${label} is required`); - if (!includesString(["node", "file_symbol"] as const, target.kind)) { - throw new Error(`Unknown ${label} kind: ${String((target as { kind?: unknown }).kind)}`); - } - if (target.kind === "node") { - validateNonEmptyString(target.nodeId, `${label} nodeId`); - if (target.path !== undefined || target.symbolName !== undefined || target.line !== undefined || target.column !== undefined) { - throw new Error(`${label} node target must not include file-symbol fields`); - } - } else { - validateRepoRelativePath(validateNonEmptyString(target.path, `${label} path`)); - validateNonEmptyString(target.symbolName, `${label} symbolName`); - if (target.line !== undefined) validatePositiveInteger(target.line, `${label} line`); - if (target.column !== undefined) validatePositiveInteger(target.column, `${label} column`); - if (target.nodeId !== undefined) validateNonEmptyString(target.nodeId, `${label} nodeId`); - } - return target; -} - -const validateInspectReferenceTarget = validateInspectSymbolTarget; - -function validateInspectReferenceEntry(entry: InspectReferenceEntry): InspectReferenceEntry { - if (!entry || typeof entry !== "object") throw new Error("Inspect reference entry is required"); - validateRepoRelativePath(entry.file); - validatePositiveInteger(entry.line, "Inspect reference entry line"); - validatePositiveInteger(entry.column, "Inspect reference entry column"); - validateNonEmptyString(entry.text, "Inspect reference entry text"); - validateInspectTextSpan(entry.span, "Inspect reference span"); - validateInspectSymbolSummary(entry.symbol, "Inspect reference entry symbol"); - if (typeof entry.isDefinition !== "boolean") throw new Error("Inspect reference entry isDefinition must be boolean"); - if (entry.isDeclaration !== undefined && typeof entry.isDeclaration !== "boolean") { - throw new Error("Inspect reference entry isDeclaration must be boolean"); - } - validateInspectSymbolEvidence(entry.evidence, "Inspect reference entry evidence"); - return entry; -} - -function validateInspectSignatureEntry(entry: InspectSignatureEntry): InspectSignatureEntry { - if (!entry || typeof entry !== "object") throw new Error("Inspect signature entry is required"); - validateRepoRelativePath(entry.file); - validatePositiveInteger(entry.line, "Inspect signature entry line"); - validatePositiveInteger(entry.column, "Inspect signature entry column"); - validateNonEmptyString(entry.text, "Inspect signature entry text"); - validateNonEmptyString(entry.signature, "Inspect signature entry signature"); - if (!includesString(inspectSignatureKinds, entry.kind)) { - throw new Error(`Unknown inspect signature entry kind: ${String(entry.kind)}`); - } - if (!Array.isArray(entry.parameters)) throw new Error("Inspect signature entry parameters must be an array"); - for (const parameter of entry.parameters) validateInspectSignatureParameter(parameter); - if (!Array.isArray(entry.typeParameters)) throw new Error("Inspect signature entry typeParameters must be an array"); - for (const typeParameter of entry.typeParameters) validateInspectSignatureTypeParameter(typeParameter); - if (typeof entry.exported !== "boolean") throw new Error("Inspect signature entry exported must be boolean"); - if (typeof entry.async !== "boolean") throw new Error("Inspect signature entry async must be boolean"); - if (entry.returnType !== undefined) validateNonEmptyString(entry.returnType, "Inspect signature entry returnType"); - validateInspectTextSpan(entry.span, "Inspect signature span"); - validateInspectSymbolSummary(entry.symbol, "Inspect signature entry symbol"); - if (entry.overloadIndex !== undefined) validateNonNegativeInteger(entry.overloadIndex, "Inspect signature entry overloadIndex"); - validateInspectSymbolEvidence(entry.evidence, "Inspect signature entry evidence"); - return entry; -} - -function validateInspectSignatureParameter(parameter: InspectSignatureParameter): InspectSignatureParameter { - if (!parameter || typeof parameter !== "object") throw new Error("Inspect signature parameter is required"); - validateNonEmptyString(parameter.name, "Inspect signature parameter name"); - validateNonEmptyString(parameter.type, "Inspect signature parameter type"); - if (typeof parameter.optional !== "boolean") throw new Error("Inspect signature parameter optional must be boolean"); - if (parameter.rest !== undefined && typeof parameter.rest !== "boolean") { - throw new Error("Inspect signature parameter rest must be boolean"); - } - if (parameter.defaultValue !== undefined) validateNonEmptyString(parameter.defaultValue, "Inspect signature parameter defaultValue"); - return parameter; -} - -function validateInspectSignatureTypeParameter(typeParameter: InspectSignatureTypeParameter): InspectSignatureTypeParameter { - if (!typeParameter || typeof typeParameter !== "object") throw new Error("Inspect signature typeParameter is required"); - validateNonEmptyString(typeParameter.name, "Inspect signature typeParameter name"); - if (typeParameter.constraint !== undefined) { - validateNonEmptyString(typeParameter.constraint, "Inspect signature typeParameter constraint"); - } - if (typeParameter.default !== undefined) { - validateNonEmptyString(typeParameter.default, "Inspect signature typeParameter default"); - } - return typeParameter; -} - -function validateInspectImplementationEntry(entry: InspectImplementationEntry): InspectImplementationEntry { - if (!entry || typeof entry !== "object") throw new Error("Inspect implementation entry is required"); - validateRepoRelativePath(entry.file); - validatePositiveInteger(entry.line, "Inspect implementation entry line"); - validatePositiveInteger(entry.column, "Inspect implementation entry column"); - validateNonEmptyString(entry.text, "Inspect implementation entry text"); - validateInspectTextSpan(entry.span, "Inspect implementation span"); - if (Object.hasOwn(entry, "implements")) throw new Error("Inspect implementation entry must use target, not implements"); - if (!includesString(inspectImplementationKinds, entry.kind)) { - throw new Error(`Unknown Inspect implementation entry kind: ${String(entry.kind)}`); - } - validateInspectSymbolSummary(entry.symbol, "Inspect implementation entry symbol"); - validateInspectSymbolSummary(entry.target, "Inspect implementation entry target"); - if (entry.isDeclaration !== undefined && typeof entry.isDeclaration !== "boolean") { - throw new Error("Inspect implementation entry isDeclaration must be boolean"); - } - validateInspectSymbolEvidence(entry.evidence, "Inspect implementation entry evidence"); - return entry; -} - -function validateInspectTextSpan(span: InspectTextSpan, label: string): InspectTextSpan { - if (!span || typeof span !== "object") throw new Error(`${label} is required`); - validatePositiveInteger(span.startLine, `${label} startLine`); - validatePositiveInteger(span.startColumn, `${label} startColumn`); - validatePositiveInteger(span.endLine, `${label} endLine`); - validatePositiveInteger(span.endColumn, `${label} endColumn`); - if (span.endLine < span.startLine || (span.endLine === span.startLine && span.endColumn < span.startColumn)) { - throw new Error(`${label} end must be after start`); - } - if (span.startOffset !== undefined) validateNonNegativeInteger(span.startOffset, `${label} startOffset`); - if (span.endOffset !== undefined) validateNonNegativeInteger(span.endOffset, `${label} endOffset`); - return span; -} - -function validateInspectSymbolSummary(symbol: InspectSymbolSummary, label: string): InspectSymbolSummary { - if (!symbol || typeof symbol !== "object") throw new Error(`${label} is required`); - validateNonEmptyString(symbol.id, `${label} id`); - validateNonEmptyString(symbol.name, `${label} name`); - if (symbol.kind !== undefined) validateNonEmptyString(symbol.kind, `${label} kind`); - return symbol; -} - -function validateInspectSymbolEvidence(evidence: InspectSymbolEvidence, label: string): InspectSymbolEvidence { - if (!evidence || typeof evidence !== "object") throw new Error(`${label} is required`); - validateStringArray(evidence.graphNodeIds, `${label} graphNodeIds`, { allowEmpty: true }); - if (!includesString(["graph", "language_service"] as const, evidence.resolver)) { - throw new Error(`Unknown ${label} resolver: ${String(evidence.resolver)}`); - } - return evidence; -} - -function validateInspectRouteFailure(failure: InspectRouteFailure): InspectRouteFailure { - if (!failure || typeof failure !== "object") throw new Error("Inspect route failure is required"); - if (!includesString(inspectFailureCategories, failure.category)) { - throw new Error(`Unknown inspect route failure category: ${String(failure.category)}`); - } - validateNonEmptyString(failure.message, "Inspect route failure message"); - if (failure.candidates !== undefined) { - if (!Array.isArray(failure.candidates)) throw new Error("Inspect route failure candidates must be an array"); - for (const candidate of failure.candidates) validateInspectSymbolTarget(candidate, "Inspect route failure candidate"); - } - return failure; -} - -function validateGraphPayloadResult( - result: - | GraphNamedQueryResult - | GraphImpactResult - | GraphDetectChangesResult - | GraphReviewContextResult, - label: string, - validatePayload: (payload: Record) => void -): void { - if (!result || typeof result !== "object") throw new Error(`${label} is required`); - if (result.requestId !== undefined) validateNonEmptyString(result.requestId, `${label} requestId`); - const status = validateProviderStatus(result.status); - const payload = result as unknown as Record; - const payloadKeys = Object.keys(payload).filter((key) => key !== "requestId" && key !== "status"); - if (status.state !== "available") { - if (payloadKeys.length > 0) throw new Error(`${label} ${status.state} result must not include graph data`); - return; - } - validatePayload(payload); - if (payload.diagnostics !== undefined) { - validateGraphExtractionDiagnostics(payload.diagnostics as readonly GraphExtractionDiagnostic[]); - } -} - -function validateRepoRelativePaths(paths: unknown, label: string): readonly string[] { - validateStringArray(paths as readonly string[] | undefined, label, { allowEmpty: true }); - for (const path of paths as readonly string[]) validateRepoRelativePath(path); - return paths as readonly string[]; -} - -function validateRenamedFiles(renamedFiles: readonly GraphRenamedFile[]): void { - if (!Array.isArray(renamedFiles)) throw new Error("Graph renamedFiles must be an array"); - for (const renamed of renamedFiles) { - validateRepoRelativePath(renamed.fromPath); - validateRepoRelativePath(renamed.toPath); - } -} - -function isNamedQueryResult(result: GraphFactQueryResult | GraphNamedQueryResult): result is GraphNamedQueryResult { - return Object.hasOwn(result, "queryKind") || Object.hasOwn(result, "traversal"); -} - -function validateGraphFactNode(node: GraphFactNode): GraphFactNode { - if (!node || typeof node !== "object") { - throw new Error("Graph fact node is required"); - } - validateNonEmptyString(node.id, "Graph fact node id"); - validateNonEmptyString(node.kind, "Graph fact node kind"); - if (node.path !== undefined) validateRepoRelativePath(node.path); - if (node.name !== undefined) validateNonEmptyString(node.name, "Graph fact node name"); - return node; -} - -function validateGraphFactEdge(edge: GraphFactEdge): GraphFactEdge { - if (!edge || typeof edge !== "object") { - throw new Error("Graph fact edge is required"); - } - if (edge.id !== undefined) validateNonEmptyString(edge.id, "Graph fact edge id"); - validateNonEmptyString(edge.kind, "Graph fact edge kind"); - validateNonEmptyString(edge.from, "Graph fact edge from"); - validateNonEmptyString(edge.to, "Graph fact edge to"); - return edge; -} - -function validateGraphExtractionDiagnostics( - diagnostics: readonly GraphExtractionDiagnostic[] -): readonly GraphExtractionDiagnostic[] { - if (!Array.isArray(diagnostics)) { - throw new Error("Graph extraction diagnostics must be an array"); - } - for (const diagnostic of diagnostics) validateGraphExtractionDiagnostic(diagnostic); - return diagnostics; -} - -function validateGraphExtractionDiagnostic(diagnostic: GraphExtractionDiagnostic): GraphExtractionDiagnostic { - if (!diagnostic || typeof diagnostic !== "object") { - throw new Error("Graph extraction diagnostic is required"); - } - if (!includesString(graphExtractionDiagnosticCategories, diagnostic.category)) { - throw new Error(`Unknown graph extraction diagnostic category: ${String(diagnostic.category)}`); - } - if (!includesString(["info", "warning", "error"] as const, diagnostic.severity)) { - throw new Error(`Unknown graph extraction diagnostic severity: ${String(diagnostic.severity)}`); - } - validateNonEmptyString(diagnostic.message, "Graph extraction diagnostic message"); - if (diagnostic.path !== undefined) validateRepoRelativePath(diagnostic.path); - if (diagnostic.language !== undefined) validateNonEmptyString(diagnostic.language, "Graph extraction diagnostic language"); - return diagnostic; -} - -function validateProviderFailure(failure: ProviderFailure): ProviderFailure { - if (!failure || typeof failure !== "object") { - throw new Error("Provider failure is required"); - } - if (!includesString(providerFailureCategories, failure.category)) { - throw new Error(`Unknown provider failure category: ${String(failure.category)}`); - } - validateNonEmptyString(failure.message, "Provider failure message"); - if (failure.retryable !== undefined && typeof failure.retryable !== "boolean") { - throw new Error("Provider failure retryable must be boolean"); - } - if (failure.cause !== undefined) validateNonEmptyString(failure.cause, "Provider failure cause"); - return failure; -} - -function validateCommandOwner(owner: unknown): CommandOwner { - if (!includesString(commandOwners, owner)) { - throw new Error(`Unknown command owner: ${String(owner)}`); - } - return owner; -} - -function validateCommandRouteStatus(status: unknown): CommandRouteStatus { - if (!includesString(commandRouteStatuses, status)) { - throw new Error(`Unknown command route status: ${String(status)}`); - } - return status; -} - -function validateGraphReferenceEvidenceSurfaceClassification(classification: unknown): GraphReferenceEvidenceClassification { - if (!includesString(graphReferenceEvidenceClassifications, classification)) { - throw new Error(`Unknown graph reference evidence surface classification: ${String(classification)}`); - } - return classification; -} - -function validateGraphReferenceEvidenceCommandSurfaces(surfaces: readonly GraphReferenceEvidenceCommandSurface[]): void { - validateNonEmptyArray(surfaces, "Graph reference evidence commandSurfaces"); - for (const surface of surfaces) { - validateGraphReferenceEvidenceSurfaceBase(surface, "Graph reference evidence command surface"); - validateNonEmptyString(surface.referenceTool, "Graph reference evidence command surface referenceTool"); - validateStringArray(surface.referenceCommand, "Graph reference evidence command surface referenceCommand", { allowEmpty: true }); - validateStringArray(surface.canonicalCommand, "Graph reference evidence command surface canonicalCommand", { allowEmpty: false }); - validateStringArray(surface.flags, "Graph reference evidence command surface flags", { allowEmpty: true }); - validateStringArray(surface.positionals, "Graph reference evidence command surface positionals", { allowEmpty: true }); - validateGraphReferenceEvidenceExitSemantics(surface.exitSemantics, "Graph reference evidence command surface"); - } -} - -function validateGraphReferenceEvidenceJsonOutputSurfaces(surfaces: readonly GraphReferenceEvidenceJsonOutputSurface[]): void { - validateNonEmptyArray(surfaces, "Graph reference evidence jsonOutputSurfaces"); - for (const surface of surfaces) { - validateGraphReferenceEvidenceSurfaceBase(surface, "Graph reference evidence JSON output surface"); - validateNonEmptyString(surface.command, "Graph reference evidence JSON output surface command"); - validateStringArray(surface.requiredFields, "Graph reference evidence JSON output surface requiredFields", { allowEmpty: false }); - validateGraphReferenceEvidenceExitSemantics(surface.exitSemantics, "Graph reference evidence JSON output surface"); - } -} - -function validateGraphReferenceEvidenceSqliteFixtures(fixtures: readonly GraphReferenceEvidenceSqliteFixture[]): void { - validateNonEmptyArray(fixtures, "Graph reference evidence sqliteFixtures"); - for (const fixture of fixtures) { - validateGraphReferenceEvidenceSurfaceBase(fixture, "Graph reference evidence SQLite fixture"); - validateNonEmptyString(fixture.fixture, "Graph reference evidence SQLite fixture path"); - validateStringArray(fixture.tables, "Graph reference evidence SQLite fixture tables", { allowEmpty: false }); - validateStringArray(fixture.indexes, "Graph reference evidence SQLite fixture indexes", { allowEmpty: false }); - validateStringArray(fixture.metadataKeys, "Graph reference evidence SQLite fixture metadataKeys", { allowEmpty: false }); - validateStringArray(fixture.nodeKinds, "Graph reference evidence SQLite fixture nodeKinds", { allowEmpty: false }); - validateStringArray(fixture.edgeKinds, "Graph reference evidence SQLite fixture edgeKinds", { allowEmpty: false }); - validateStringArray(fixture.directReaderQueries, "Graph reference evidence SQLite fixture directReaderQueries", { allowEmpty: false }); - } -} - -function validateGraphReferenceEvidenceDaemonFixtures(fixtures: readonly GraphReferenceEvidenceDaemonFixture[]): void { - validateNonEmptyArray(fixtures, "Graph reference evidence daemonFixtures"); - for (const fixture of fixtures) { - validateGraphReferenceEvidenceSurfaceBase(fixture, "Graph reference evidence daemon fixture"); - validateNonEmptyString(fixture.fixture, "Graph reference evidence daemon fixture path"); - validateNonEmptyString(fixture.protocol, "Graph reference evidence daemon fixture protocol"); - validateStringArray(fixture.envelopes, "Graph reference evidence daemon fixture envelopes", { allowEmpty: false }); - } -} - -function validateGraphReferenceEvidenceBaselineReceipts(receipts: readonly GraphReferenceEvidenceBaselineReceipt[]): void { - validateNonEmptyArray(receipts, "Graph reference evidence baselineReceipts"); - for (const receipt of receipts) { - validateGraphReferenceEvidenceSurfaceBase(receipt, "Graph reference evidence baseline receipt"); - validateNonEmptyString(receipt.metric, "Graph reference evidence baseline receipt metric"); - validateNonEmptyString(receipt.receipt, "Graph reference evidence baseline receipt path"); - if (receipt.label !== "reference_evidence_non_implementation_input") { - throw new Error("Graph reference evidence baseline receipt label must be reference_evidence_non_implementation_input"); - } - if (receipt.sourceAvailability !== "available" && receipt.sourceAvailability !== "unavailable") { - throw new Error("Graph reference evidence baseline receipt sourceAvailability must be available or unavailable"); - } - if (receipt.nonImplementationInput !== true) { - throw new Error("Graph reference evidence baseline receipt must be non-implementation input"); - } - } -} - -function validateGraphReferenceEvidenceOptionalSurfaces(surfaces: readonly GraphReferenceEvidenceOptionalAnalysisSurface[]): void { - validateNonEmptyArray(surfaces, "Graph reference evidence optionalAnalysisSurfaces"); - for (const surface of surfaces) { - validateGraphReferenceEvidenceSurfaceBase(surface, "Graph reference evidence optional analysis surface"); - validateGraphReleaseDeferredChild(surface.issue, "Graph reference evidence optional analysis surface issue"); - if (surface.status !== "deferred") throw new Error("Graph reference evidence optional analysis surface status must be deferred"); - if (surface.classification === "required") { - throw new Error("Graph reference evidence optional analysis surfaces must not mark staged graph release surfaces as required"); - } - } - validateGraphReleaseOptionalAnalysisSurfaceSet( - surfaces, - "Graph reference evidence optional analysis surfaces" - ); -} - -function validateGraphReferenceEvidenceGoldenCorpus(corpus: GraphReferenceEvidenceGoldenCorpusRef): void { - validateGraphReferenceEvidenceSurfaceBase(corpus, "Graph reference evidence golden corpus"); - validateNonEmptyString(corpus.fixture, "Graph reference evidence golden corpus fixture"); - validateStringArray(corpus.covers, "Graph reference evidence golden corpus covers", { allowEmpty: false }); -} - -function validateGraphReferenceEvidenceSurfaceBase(surface: GraphReferenceEvidenceSurfaceBase, label: string): void { - if (!surface || typeof surface !== "object") throw new Error(`${label} is required`); - validateNonEmptyString(surface.id, `${label} id`); - validateGraphReferenceEvidenceSurfaceClassification(surface.classification); - validateStringArray(surface.fixtures, `${label} fixtures`, { allowEmpty: true }); - if (surface.classification === "required" && surface.fixtures.length === 0) { - throw new Error(`${label} required surface must include fixture coverage`); - } -} - -function validateGraphReferenceEvidenceExitSemantics(exitSemantics: GraphReferenceEvidenceExitSemantics, label: string): void { - if (!exitSemantics || typeof exitSemantics !== "object") { - throw new Error(`${label} exitSemantics is required`); - } - if (exitSemantics.success !== 0) throw new Error(`${label} exitSemantics success must be 0`); - validateNonEmptyString(exitSemantics.failure, `${label} exitSemantics failure`); -} - -function validateGraphReferenceEvidenceProvenance(provenance: GraphReferenceEvidenceProvenance): void { - if (!provenance || typeof provenance !== "object") { - throw new Error("Graph reference evidence provenance is required"); - } - if (provenance.containsPythonCrgSource !== false) { - throw new Error("Graph reference evidence manifest must not contain Python CRG source"); - } - if (provenance.containsPackageMetadata !== false) { - throw new Error("Graph reference evidence manifest must not contain Python CRG package metadata"); - } - if (provenance.containsGitHistory !== false) { - throw new Error("Graph reference evidence manifest must not contain Python CRG git history"); - } - if (provenance.referenceReceiptsAreImplementationInput !== false) { - throw new Error("Graph reference evidence receipts must not be implementation input"); - } - validateStringArray(provenance.implementationPackageNames, "Graph reference evidence implementationPackageNames", { - allowEmpty: false - }); - for (const name of provenance.implementationPackageNames) { - if (/\bcrg\b|code-review-graph|gungnir/i.test(name)) { - throw new Error(`Graph reference evidence manifest uses a forbidden implementation package name: ${name}`); - } - } - validateStringArray(provenance.allowedMentionPaths, "Graph reference evidence allowedMentionPaths", { allowEmpty: false }); -} - -function validateGraphReferenceEvidenceSourceFreeStrings(value: unknown): void { - const forbidden = [/tirth8205/i, /pyproject\.toml/i, /setup\.py/i, /setup\.cfg/i, /Pipfile/i, /git clone/i]; - for (const text of collectStrings(value)) { - const pattern = forbidden.find((entry) => entry.test(text)); - if (pattern) throw new Error(`Graph reference evidence manifest contains forbidden source provenance: ${text}`); - } -} - -function validateGraphReleasePackageVersions(versions: readonly GraphReleasePackageVersion[]): void { - validateNonEmptyArray(versions, "Graph release graphPackageVersions"); - for (const version of versions) { - if (!version || typeof version !== "object") throw new Error("Graph release package version is required"); - validateNonEmptyString(version.packageName, "Graph release package version packageName"); - validateNonEmptyString(version.version, "Graph release package version version"); - } - if (!versions.some((version) => version.packageName === "@the-open-engine/opcore-graph")) { - throw new Error("Graph release package versions must include @the-open-engine/opcore-graph"); - } -} - -function validateGraphReleaseCommandCoverage(coverage: readonly GraphReleaseCommandCoverage[]): void { - validateNonEmptyArray(coverage, "Graph release commandCoverage"); - validateExactStringSet( - coverage.map((entry) => entry.id), - graphReleaseCoreCommandIds, - "Graph release command coverage ids" - ); - for (const entry of coverage) { - if (!entry || typeof entry !== "object") throw new Error("Graph release command coverage entry is required"); - validateGraphReleaseCoreCommandId(entry.id); - if (entry.bin !== "opcore") throw new Error(`Unknown graph release command bin: ${String(entry.bin)}`); - validateStringArray(entry.command, "Graph release command coverage command", { allowEmpty: false }); - validateStringArray(entry.canonicalCommand, "Graph release command coverage canonicalCommand", { allowEmpty: false }); - if (entry.status !== "passed") throw new Error("Graph release command coverage status must be passed"); - if (entry.exitCode !== 0) throw new Error("Graph release command coverage exitCode must be 0"); - validateNonEmptyString(entry.fixture, "Graph release command coverage fixture"); - if (typeof entry.durationMs !== "number" || entry.durationMs <= 0) { - throw new Error("Graph release command coverage durationMs must be positive"); - } - const route = graphReleaseRouteForCommandId(entry.id); - if (entry.bin !== route.bin) throw new Error(`Graph release command ${entry.id} must use ${route.bin}`); - if (entry.command.join("\0") !== route.command.join("\0")) { - throw new Error(`Graph release command ${entry.id} command must be ${route.command.join(" ")}`); - } - if (entry.canonicalCommand.join("\0") !== route.canonicalCommand.join("\0")) { - throw new Error(`Graph release command ${entry.id} canonicalCommand must be ${route.canonicalCommand.join(" ")}`); - } - } -} - -function validateGraphReleaseRustCommandCoverage(coverage: readonly GraphReleaseRustCommandCoverage[]): void { - validateNonEmptyArray(coverage, "Graph release rustCommandCoverage"); - validateExactStringSet( - coverage.map((entry) => entry.id), - graphReleaseRustCommandIds, - "Graph release Rust command coverage ids" - ); - for (const entry of coverage) { - if (!entry || typeof entry !== "object") throw new Error("Graph release Rust command coverage entry is required"); - validateGraphReleaseRustCommandId(entry.id); - if (entry.bin !== "opcore") throw new Error(`Unknown graph release Rust command bin: ${String(entry.bin)}`); - validateStringArray(entry.command, "Graph release Rust command coverage command", { allowEmpty: false }); - validateStringArray(entry.canonicalCommand, "Graph release Rust command coverage canonicalCommand", { allowEmpty: false }); - if (entry.status !== "passed") throw new Error("Graph release Rust command coverage status must be passed"); - if (entry.exitCode !== 0) throw new Error("Graph release Rust command coverage exitCode must be 0"); - validateNonEmptyString(entry.fixture, "Graph release Rust command coverage fixture"); - if (typeof entry.durationMs !== "number" || entry.durationMs <= 0) { - throw new Error("Graph release Rust command coverage durationMs must be positive"); - } - const route = graphReleaseRouteForRustCommandId(entry.id); - if (entry.bin !== route.bin) throw new Error(`Graph release Rust command ${entry.id} must use ${route.bin}`); - if (entry.command.join("\0") !== route.command.join("\0")) { - throw new Error(`Graph release Rust command ${entry.id} route must match ${route.command.join(" ")}`); - } - if (entry.canonicalCommand.join("\0") !== route.canonicalCommand.join("\0")) { - throw new Error(`Graph release Rust command ${entry.id} route must match ${route.canonicalCommand.join(" ")}`); - } - } -} - -function validateGraphReleaseDirectSqliteQueries(queries: readonly GraphReleaseDirectSqliteQueryReceipt[]): void { - validateNonEmptyArray(queries, "Graph release directSqliteQueries"); - validateExactStringSet( - queries.map((entry) => entry.id), - graphReleaseDirectSqliteQueryIds, - "Graph release direct SQLite query ids" - ); - for (const query of queries) { - if (!query || typeof query !== "object") throw new Error("Graph release direct SQLite query receipt is required"); - if (!includesString(graphReleaseDirectSqliteQueryIds, query.id)) { - throw new Error(`Unknown graph release direct SQLite query id: ${String(query.id)}`); - } - validateNonEmptyString(query.query, "Graph release direct SQLite query query"); - if (query.status !== "passed") throw new Error("Graph release direct SQLite query status must be passed"); - if (typeof query.rowCount !== "number" || query.rowCount < 0) { - throw new Error("Graph release direct SQLite query rowCount must be non-negative"); - } - validateNonEmptyString(query.fixture, "Graph release direct SQLite query fixture"); - } -} - -function validateGraphReleaseServeTransport(receipts: readonly GraphReleaseServeTransportReceipt[]): void { - validateNonEmptyArray(receipts, "Graph release serveTransport"); - validateExactStringSet( - receipts.map((entry) => entry.id), - graphReleaseServeTransportIds, - "Graph release serve transport ids" - ); - for (const receipt of receipts) { - if (!receipt || typeof receipt !== "object") throw new Error("Graph release serve transport receipt is required"); - validateGraphReleaseServeTransportId(receipt.id); - if (receipt.protocol !== "opcore.graph.daemon") { - throw new Error("Graph release serve transport protocol must be opcore.graph.daemon"); - } - validateNonEmptyString(receipt.operation, "Graph release serve transport operation"); - if (receipt.operation !== graphReleaseOperationForServeTransportId(receipt.id)) { - throw new Error(`Graph release serve transport ${receipt.id} operation must be ${graphReleaseOperationForServeTransportId(receipt.id)}`); - } - if (receipt.status !== "passed") throw new Error("Graph release serve transport status must be passed"); - if (receipt.exitCode !== 0) throw new Error("Graph release serve transport exitCode must be 0"); - } -} - -function validateGraphReleaseBenchmarks(benchmarks: readonly GraphReleaseBenchmarkReceipt[]): void { - validateNonEmptyArray(benchmarks, "Graph release benchmarks"); - validateExactStringSet( - benchmarks.map((entry) => entry.metric), - graphReleaseBenchmarkMetrics, - "Graph release benchmark metrics" - ); - for (const benchmark of benchmarks) { - if (!benchmark || typeof benchmark !== "object") throw new Error("Graph release benchmark receipt is required"); - validateGraphReleaseBenchmarkMetric(benchmark.metric); - if (typeof benchmark.value !== "number" || benchmark.value <= 0) { - throw new Error("Graph release benchmark value must be positive"); - } - if (benchmark.unit !== "ms" && benchmark.unit !== "bytes") { - throw new Error("Graph release benchmark unit must be ms or bytes"); - } - if (benchmark.metric.endsWith("_bytes") && benchmark.unit !== "bytes") { - throw new Error(`Graph release benchmark ${benchmark.metric} must use bytes`); - } - if (!benchmark.metric.endsWith("_bytes") && benchmark.unit !== "ms") { - throw new Error(`Graph release benchmark ${benchmark.metric} must use ms`); - } - if (benchmark.baselineIssue !== "#19") throw new Error("Graph release benchmark baselineIssue must be #19"); - validateNonEmptyString(benchmark.baselineReceipt, "Graph release benchmark baselineReceipt"); - if (!["recorded", "within_baseline", "above_baseline", "below_baseline"].includes(benchmark.comparison)) { - throw new Error(`Unknown graph release benchmark comparison: ${String(benchmark.comparison)}`); - } - } -} - -function validateGraphReleasePackageInspection(inspection: GraphReleasePackageInspection): void { - if (!inspection || typeof inspection !== "object") throw new Error("Graph release packageInspection is required"); - if (inspection.packageName !== "@the-open-engine/opcore-graph") { - throw new Error("Graph release packageInspection packageName must be @the-open-engine/opcore-graph"); - } - validateNonEmptyString(inspection.tarballName, "Graph release packageInspection tarballName"); - if (typeof inspection.fileCount !== "number" || inspection.fileCount <= 0) { - throw new Error("Graph release packageInspection fileCount must be positive"); - } - validateStringArray(inspection.files, "Graph release packageInspection files", { allowEmpty: false }); - if (inspection.fileCount !== inspection.files.length) { - throw new Error("Graph release packageInspection fileCount must equal files length"); - } - validateStringArray(inspection.inspections, "Graph release packageInspection inspections", { allowEmpty: false }); - for (const key of [ - "forbiddenMarkersAbsent", - "generatedBuildMetadataAbsent", - "privatePathsAbsent", - "pythonCrgSourceAbsent", - "pythonGraphPackageMetadataAbsent", - "pythonCrgGitHistoryAbsent", - "forbiddenImplementationPackageNamesAbsent" - ] as const) { - if (inspection[key] !== true) throw new Error(`Graph release packageInspection ${key} must be true`); - } -} - -function validateGraphReleaseNativeArtifacts(nativeArtifacts: readonly GraphReleaseNativeArtifactEvidence[]): void { - validateNonEmptyArray(nativeArtifacts, "Graph release nativeArtifacts"); - validateExactStringSet( - nativeArtifacts.map((artifact) => artifact.targetPlatform), - graphCoreNativeSupportedTargets, - "Graph release native artifact targets" - ); - for (const nativeArtifact of nativeArtifacts) { - if (!nativeArtifact || typeof nativeArtifact !== "object") throw new Error("Graph release native artifact evidence is required"); - const expectedPackageName = graphCoreNativePackageNameForTarget(nativeArtifact.targetPlatform); - if (nativeArtifact.packageName !== expectedPackageName) { - throw new Error(`Graph release native artifact packageName for ${nativeArtifact.targetPlatform} must be ${expectedPackageName}`); - } - validateGraphProviderArtifactMetadata(nativeArtifact.metadata); - if (nativeArtifact.metadata.targetPlatform !== nativeArtifact.targetPlatform) { - throw new Error("Graph release native artifact targetPlatform must match metadata"); - } - if (nativeArtifact.binaryPath !== "opcore-graph-core") throw new Error("Graph release native binaryPath must be opcore-graph-core"); - if (nativeArtifact.checksumPath !== "opcore-graph-core.sha256") { - throw new Error("Graph release native checksumPath must be opcore-graph-core.sha256"); - } - if (nativeArtifact.metadataPath !== "metadata.json") throw new Error("Graph release native metadataPath must be metadata.json"); - if (nativeArtifact.metadata.binaryPath !== nativeArtifact.binaryPath) { - throw new Error("Graph release native artifact binaryPath must match metadata"); - } - if (nativeArtifact.metadata.checksumPath !== nativeArtifact.checksumPath) { - throw new Error("Graph release native artifact checksumPath must match metadata"); - } - if (nativeArtifact.metadata.checksumSha256 !== nativeArtifact.binarySha256) { - throw new Error("Graph release native artifact metadata checksum must match binary sha256"); - } - validateSha256(nativeArtifact.binarySha256, "Graph release native artifact binarySha256"); - validateSha256(nativeArtifact.checksumFileSha256, "Graph release native artifact checksumFileSha256"); - validateSha256(nativeArtifact.metadataSha256, "Graph release native artifact metadataSha256"); - validateExactStringSet( - nativeArtifact.packageFiles, - ["package.json", "README.md", "opcore-graph-core", "opcore-graph-core.sha256", "metadata.json"], - `Graph release native package files ${nativeArtifact.targetPlatform}` - ); - } -} - -function validateGraphReleaseReportReceipts(receipts: readonly GraphReleaseReportReceipt[]): void { - validateNonEmptyArray(receipts, "Graph release reportReceipts"); - validateExactStringSet( - receipts.map((entry) => entry.id), - graphReleaseReportReceiptIds, - "Graph release report receipt ids" - ); - for (const receipt of receipts) { - if (!receipt || typeof receipt !== "object") throw new Error("Graph release report receipt is required"); - if (!includesString(graphReleaseReportReceiptIds, receipt.id)) { - throw new Error(`Unknown graph release report receipt id: ${String(receipt.id)}`); - } - validateStringArray(receipt.command, "Graph release report receipt command", { allowEmpty: false }); - if (receipt.status !== "passed") throw new Error("Graph release report receipt status must be passed"); - if (receipt.exitCode !== 0) throw new Error("Graph release report receipt exitCode must be 0"); - validateNonEmptyString(receipt.path, "Graph release report receipt path"); - if (receipt.checksumSha256 !== undefined) validateNonEmptyString(receipt.checksumSha256, "Graph release report receipt checksumSha256"); - } -} - -function validateGraphReleaseOptionalSurfaces(surfaces: readonly GraphReleaseOptionalSurfaceReceipt[]): void { - validateNonEmptyArray(surfaces, "Graph release optionalSurfaces"); - for (const surface of surfaces) { - if (!surface || typeof surface !== "object") throw new Error("Graph release optional surface is required"); - validateGraphReleaseDeferredChild(surface.issue, "Graph release optional surface issue"); - validateNonEmptyString(surface.id, "Graph release optional surface id"); - validateGraphReferenceEvidenceSurfaceClassification(surface.classification); - if (surface.status !== "unsupported" && surface.status !== "deferred") { - throw new Error("Graph release optional surface status must be unsupported or deferred"); - } - if (surface.classification === "required") { - throw new Error("Graph release optional surfaces must not mark staged graph release surfaces as required"); - } - } - validateGraphReleaseOptionalAnalysisSurfaceSet(surfaces, "Graph release optional surfaces"); -} - -function validateGraphReleaseOptionalAnalysisSurfaceSet( - surfaces: readonly Pick[], - label: string -): void { - const actual = surfaces.map(graphReleaseOptionalSurfaceKey).sort(); - const expected = graphReleaseOptionalAnalysisSurfaces.map(graphReleaseOptionalSurfaceKey).sort(); - if (actual.length !== expected.length || actual.some((value, index) => value !== expected[index])) { - throw new Error(`${label} must match staged graph release surfaces`); - } -} - -function graphReleaseOptionalSurfaceKey( - surface: Pick -): string { - return `${surface.issue}:${surface.id}:${surface.classification}:${surface.status}`; -} - -function validateGraphReleaseDeferredChild(issue: unknown, label = "Graph release deferred child"): GraphReleaseDeferredChild { - if (!includesString(graphReleaseDeferredChildren, issue)) { - throw new Error(`${label} must be one of ${graphReleaseDeferredChildren.join(", ")}`); - } - return issue; -} - -function validateGraphReleaseHandoff(handoff: readonly GraphReleaseHandoffReceipt[]): void { - validateNonEmptyArray(handoff, "Graph release handoff"); - validateExactStringSet( - handoff.map((entry) => entry.issue), - graphReleaseHandoffIssues, - "Graph release handoff issues" - ); - for (const entry of handoff) { - if (!entry || typeof entry !== "object") throw new Error("Graph release handoff entry is required"); - validateGraphReleaseHandoffIssue(entry.issue); - validateNonEmptyString(entry.receiptPath, "Graph release handoff receiptPath"); - validateNonEmptyString(entry.checksumSha256, "Graph release handoff checksumSha256"); - validateNonEmptyString(entry.rollbackNote, "Graph release handoff rollbackNote"); - } -} - -function validateGraphReleaseCoreCommandId(id: unknown): GraphReleaseCoreCommandId { - if (!includesString(graphReleaseCoreCommandIds, id)) { - throw new Error(`Unknown graph release command id: ${String(id)}`); - } - return id; -} - -function validateGraphReleaseRustCommandId(id: unknown): GraphReleaseRustCommandId { - if (!includesString(graphReleaseRustCommandIds, id)) { - throw new Error(`Unknown graph release Rust command id: ${String(id)}`); - } - return id; -} - -function validateGraphReleaseBenchmarkMetric(metric: unknown): GraphReleaseBenchmarkMetric { - if (!includesString(graphReleaseBenchmarkMetrics, metric)) { - throw new Error(`Unknown graph release benchmark metric: ${String(metric)}`); - } - return metric; -} - -function validateGraphReleaseHandoffIssue(issue: unknown): GraphReleaseHandoffIssue { - if (!includesString(graphReleaseHandoffIssues, issue)) { - throw new Error(`Unknown graph release handoff issue: ${String(issue)}`); - } - return issue; -} - -function validateGraphReleaseServeTransportId(id: unknown): GraphReleaseServeTransportId { - if (!includesString(graphReleaseServeTransportIds, id)) { - throw new Error(`Unknown graph release serve transport id: ${String(id)}`); - } - return id; -} - -function graphReleaseOperationForServeTransportId(id: GraphReleaseServeTransportId): string { - return id.replace("serve-jsonl-", ""); -} - -function graphReleaseRouteForCommandId(id: GraphReleaseCoreCommandId): { - bin: "opcore"; - command: readonly string[]; - canonicalCommand: readonly string[]; -} { - const command = id.replace("opcore-graph-", ""); - return { - bin: "opcore", - command: ["graph", command], - canonicalCommand: ["opcore", "graph", command] - }; -} - -function graphReleaseRouteForRustCommandId(id: GraphReleaseRustCommandId): { - bin: "opcore"; - command: readonly string[]; - canonicalCommand: readonly string[]; -} { - const command = id.replace("opcore-graph-rust-", ""); - return { - bin: "opcore", - command: ["graph", command], - canonicalCommand: ["opcore", "graph", command] - }; -} - -function validateReleaseReceiptPackages(packages: readonly ReleaseReceiptPackageEvidence[]): void { - validateNonEmptyArray(packages, "Release receipt package evidence"); - validateExactStringSet( - packages.map((entry) => entry.packageName), - releaseReceiptPackageNames, - "Release receipt package evidence" - ); - for (const packageEvidence of packages) validateReleaseReceiptPackage(packageEvidence); -} - -function validateReleaseReceiptPackage(packageEvidence: ReleaseReceiptPackageEvidence): void { - if (!packageEvidence || typeof packageEvidence !== "object") throw new Error("Release receipt package evidence entry is required"); - validateReleaseReceiptPackageName(packageEvidence.packageName, "Release receipt package evidence packageName"); - validateRepoRelativePath(packageEvidence.packageRoot); - validateNonEmptyString(packageEvidence.version, "Release receipt package evidence version"); - validateReleaseReceiptPackageManifest(packageEvidence.manifest, packageEvidence.packageName); - validateReleaseReceiptTarball(packageEvidence.tarball); - validateStringArray(packageEvidence.files, "Release receipt package evidence files", { allowEmpty: false }); - validateStringArray(packageEvidence.expectedFiles, "Release receipt package evidence expectedFiles", { allowEmpty: false }); - if (!Number.isInteger(packageEvidence.fileCount) || packageEvidence.fileCount !== packageEvidence.files.length) { - throw new Error("Release receipt package evidence fileCount must equal files length"); - } - if (!Number.isInteger(packageEvidence.expectedFileCount) || packageEvidence.expectedFileCount !== packageEvidence.expectedFiles.length) { - throw new Error("Release receipt package evidence expectedFileCount must equal expectedFiles length"); - } - validateExactStringSet(packageEvidence.files, packageEvidence.expectedFiles, `${packageEvidence.packageName} packed files`); - validateReleaseReceiptBins(packageEvidence.bins, packageEvidence.packageName); - for (const descriptorReference of packageEvidence.descriptorReferences) { - validateManagedToolDescriptorPackageReference(descriptorReference, packageEvidence.packageName); - if (!packageEvidence.files.includes(descriptorReference.path)) { - throw new Error(`Release receipt descriptor reference ${descriptorReference.id} is not in ${packageEvidence.packageName} packed files`); - } - } - if (packageEvidence.packageName === "opcore") { - validateNonEmptyArray(packageEvidence.nativeArtifacts, "Release receipt native package artifacts"); - validateExactStringSet( - packageEvidence.nativeArtifacts.map((entry) => entry.targetPlatform), - graphCoreNativeSupportedTargets, - "Release receipt Opcore bundled native artifact targets" - ); - } else if (isGraphCoreNativePackageName(packageEvidence.packageName)) { - throw new Error("Release receipt must not publish native graph-core packages separately"); - } else if (packageEvidence.nativeArtifacts.length > 0) { - throw new Error(`${packageEvidence.packageName} must not report native graph artifacts`); - } - for (const nativeArtifact of packageEvidence.nativeArtifacts) validateReleaseReceiptNativeArtifact(nativeArtifact); -} - -function validateReleaseReceiptPackageManifest( - manifest: ReleaseReceiptPackageManifestMetadata, - packageName: ReleaseReceiptPackageName -): void { - if (!manifest || typeof manifest !== "object") throw new Error("Release receipt package manifest is required"); - if (manifest.name !== packageName) throw new Error(`Release receipt package manifest name must match ${packageName}`); - if (manifest.name.includes("lattice") || manifest.name.includes("crg") || manifest.name.includes("cix") || manifest.name.includes("rox")) { - throw new Error(`Release receipt package manifest uses old public package identity: ${manifest.name}`); - } - validateNonEmptyString(manifest.version, "Release receipt package manifest version"); - validateNonEmptyString(manifest.license, "Release receipt package manifest license"); - if (isGraphCoreNativePackageName(packageName)) { - if (manifest.main !== undefined || manifest.types !== undefined) { - throw new Error("Release receipt native package manifest must not declare main or types"); - } - } else { - if (manifest.main === undefined || manifest.types === undefined) { - throw new Error("Release receipt package manifest must declare main and types"); - } - validateRepoRelativePath(manifest.main); - validateRepoRelativePath(manifest.types); - } - validateStringArray(manifest.files, "Release receipt package manifest files", { allowEmpty: false }); - validateReleaseReceiptBins(manifest.bins, packageName); - validateStringRecord(manifest.dependencies, "Release receipt package manifest dependencies"); - if (manifest.optionalDependencies !== undefined) { - validateStringRecord(manifest.optionalDependencies, "Release receipt package manifest optionalDependencies"); - } - validateStringArray(manifest.bundledDependencies, "Release receipt package manifest bundledDependencies", { allowEmpty: true }); -} - -function validateReleaseReceiptTarball(tarball: ReleaseReceiptTarballEvidence): void { - if (!tarball || typeof tarball !== "object") throw new Error("Release receipt tarball evidence is required"); - validateNonEmptyString(tarball.filename, "Release receipt tarball filename"); - validateRepoRelativePath(tarball.path); - validateSha256(tarball.sha256, "Release receipt tarball sha256"); - if (tarball.integrity !== undefined) validateNonEmptyString(tarball.integrity, "Release receipt tarball integrity"); - if (tarball.shasum !== undefined) validateNonEmptyString(tarball.shasum, "Release receipt tarball shasum"); -} - -function validateReleaseReceiptDescriptor( - descriptorEvidence: ReleaseReceiptDescriptorEvidence, - packages: readonly ReleaseReceiptPackageEvidence[] -): void { - if (!descriptorEvidence || typeof descriptorEvidence !== "object") throw new Error("Release receipt descriptor evidence is required"); - validateRepoRelativePath(descriptorEvidence.path); - if (descriptorEvidence.packageName !== "opcore") { - throw new Error("Release receipt descriptor packageName must be opcore"); - } - validateSha256(descriptorEvidence.checksumSha256, "Release receipt descriptor checksumSha256"); - const descriptor = validateManagedToolDescriptor(descriptorEvidence.descriptor); - validateExactStringSet( - descriptorEvidence.commandGroups.map((entry) => entry.name), - releaseReceiptCommandGroups, - "Release receipt descriptor command groups" - ); - for (const group of descriptorEvidence.commandGroups) { - validateReleaseReceiptCommandGroupName(group.name, "Release receipt descriptor command group name"); - validateExactStringSequence(group.canonicalCommand, ["opcore", group.name], `Release receipt descriptor ${group.name} canonicalCommand`); - const descriptorGroup = descriptor.commandGroups.find((entry) => entry.name === group.name); - if (!descriptorGroup) throw new Error(`Release receipt descriptor command group missing from descriptor: ${group.name}`); - if (group.packageName !== descriptorGroup.packageName) { - throw new Error(`Release receipt descriptor command group ${group.name} packageName must match descriptor`); - } - } - validateReleaseResolvedArtifacts(descriptorEvidence.resolvedArtifacts, descriptor.artifacts, packages); - validateReleaseResolvedChecksums(descriptorEvidence.resolvedChecksums, descriptor.checksums, packages); -} - -function validateReleaseResolvedArtifacts( - resolvedArtifacts: readonly ReleaseReceiptResolvedArtifactEvidence[], - descriptorArtifacts: readonly ManagedToolDescriptorArtifactReference[], - packages: readonly ReleaseReceiptPackageEvidence[] -): void { - validateNonEmptyArray(resolvedArtifacts, "Release receipt descriptor resolvedArtifacts"); - validateExactStringSet( - resolvedArtifacts.map((entry) => entry.id), - descriptorArtifacts.map((entry) => entry.id), - "Release receipt descriptor resolved artifact ids" - ); - for (const resolved of resolvedArtifacts) { - validateReleaseReceiptPackageName(resolved.packageName, "Release receipt descriptor resolved artifact packageName"); - validateRepoRelativePath(resolved.path); - if (!includesString(managedToolDescriptorArtifactTypes, resolved.type)) { - throw new Error(`Unknown release receipt descriptor resolved artifact type: ${String(resolved.type)}`); - } - if (resolved.packageFile !== true) throw new Error(`Release receipt resolved artifact ${resolved.id} must resolve to a package file`); - const descriptorArtifact = descriptorArtifacts.find((entry) => entry.id === resolved.id); - if (!descriptorArtifact) throw new Error(`Release receipt resolved artifact is not declared by descriptor: ${resolved.id}`); - if ( - descriptorArtifact.packageName !== resolved.packageName || - descriptorArtifact.path !== resolved.path || - descriptorArtifact.type !== resolved.type || - descriptorArtifact.required !== resolved.required || - descriptorArtifact.checksumRef !== resolved.checksumRef - ) { - throw new Error(`Release receipt resolved artifact must mirror descriptor: ${resolved.id}`); - } - if (!packageEvidenceIncludesFile(packages, resolved.packageName, resolved.path)) { - throw new Error(`Release receipt resolved artifact ${resolved.id} is not present in packed package files`); - } - } -} - -function validateReleaseResolvedChecksums( - resolvedChecksums: readonly ReleaseReceiptResolvedChecksumEvidence[], - descriptorChecksums: readonly ManagedToolDescriptorChecksumReference[], - packages: readonly ReleaseReceiptPackageEvidence[] -): void { - validateNonEmptyArray(resolvedChecksums, "Release receipt descriptor resolvedChecksums"); - validateExactStringSet( - resolvedChecksums.map((entry) => entry.id), - descriptorChecksums.map((entry) => entry.id), - "Release receipt descriptor resolved checksum ids" - ); - for (const resolved of resolvedChecksums) { - validateReleaseReceiptPackageName(resolved.packageName, "Release receipt descriptor resolved checksum packageName"); - validateRepoRelativePath(resolved.path); - if (resolved.algorithm !== "sha256") throw new Error("Release receipt descriptor checksum algorithm must be sha256"); - if (resolved.packageFile !== true) throw new Error(`Release receipt resolved checksum ${resolved.id} must resolve to a package file`); - validateSha256(resolved.value, "Release receipt descriptor checksum value"); - const descriptorChecksum = descriptorChecksums.find((entry) => entry.id === resolved.id); - if (!descriptorChecksum) throw new Error(`Release receipt resolved checksum is not declared by descriptor: ${resolved.id}`); - if ( - descriptorChecksum.packageName !== resolved.packageName || - descriptorChecksum.path !== resolved.path || - descriptorChecksum.algorithm !== resolved.algorithm || - descriptorChecksum.artifactRef !== resolved.artifactRef || - descriptorChecksum.required !== resolved.required - ) { - throw new Error(`Release receipt resolved checksum must mirror descriptor: ${resolved.id}`); - } - if (descriptorChecksum.value !== undefined && descriptorChecksum.value !== resolved.value) { - throw new Error(`Release receipt resolved checksum value must match descriptor: ${resolved.id}`); - } - if (!packageEvidenceIncludesFile(packages, resolved.packageName, resolved.path)) { - throw new Error(`Release receipt resolved checksum ${resolved.id} is not present in packed package files`); - } - } -} - -function validateReleaseReceiptNativeArtifacts( - nativeArtifacts: readonly ReleaseReceiptNativeArtifactEvidence[], - packages: readonly ReleaseReceiptPackageEvidence[], - descriptorEvidence: ReleaseReceiptDescriptorEvidence -): void { - validateNonEmptyArray(nativeArtifacts, "Release receipt native artifacts"); - validateExactStringSet( - nativeArtifacts.map((artifact) => artifact.targetPlatform), - graphCoreNativeSupportedTargets, - "Release receipt native artifact targets" - ); - for (const nativeArtifact of nativeArtifacts) { - validateReleaseReceiptNativeArtifact(nativeArtifact); - if (!packageEvidenceIncludesFile(packages, nativeArtifact.packageName, nativeArtifact.binaryPath)) { - throw new Error("Release receipt native artifact binary must be present in native package files"); - } - if (!packageEvidenceIncludesFile(packages, nativeArtifact.packageName, nativeArtifact.checksumPath)) { - throw new Error("Release receipt native artifact checksum must be present in native package files"); - } - if (!packageEvidenceIncludesFile(packages, nativeArtifact.packageName, nativeArtifact.metadataPath)) { - throw new Error("Release receipt native artifact metadata must be present in native package files"); - } - const binaryArtifact = descriptorEvidence.resolvedArtifacts.find((artifact) => artifact.id === nativeArtifact.descriptorArtifactId); - if (!binaryArtifact || binaryArtifact.packageName !== nativeArtifact.packageName || binaryArtifact.path !== nativeArtifact.binaryPath) { - throw new Error("Release receipt native artifact binary must resolve from descriptor artifacts"); - } - const checksum = descriptorEvidence.resolvedChecksums.find((entry) => entry.id === nativeArtifact.descriptorChecksumId); - if ( - !checksum || - checksum.packageName !== nativeArtifact.packageName || - checksum.path !== nativeArtifact.checksumPath || - checksum.value !== nativeArtifact.binarySha256 - ) { - throw new Error("Release receipt native artifact checksum must resolve from descriptor checksum evidence"); - } - } -} - -function validateReleaseReceiptNativeArtifact(nativeArtifact: ReleaseReceiptNativeArtifactEvidence): void { - if (!nativeArtifact || typeof nativeArtifact !== "object") throw new Error("Release receipt native artifact evidence is required"); - if (nativeArtifact.packageName !== "opcore") { - throw new Error("Release receipt native artifact packageName must be opcore"); - } - if (!isGraphCoreNativePackageName(nativeArtifact.bundledPackageName)) { - throw new Error("Release receipt native artifact bundledPackageName must be an Opcore graph-core native package"); - } - const expectedTarget = graphCoreNativeTargetForPackageName(nativeArtifact.bundledPackageName); - if (nativeArtifact.targetPlatform !== expectedTarget) { - throw new Error(`Release receipt native artifact targetPlatform must be ${expectedTarget}`); - } - if (nativeArtifact.binaryPath !== bundledGraphCoreNativePath(nativeArtifact.bundledPackageName, "opcore-graph-core")) { - throw new Error("Release receipt native artifact binaryPath must point at the bundled native binary"); - } - if (nativeArtifact.checksumPath !== bundledGraphCoreNativePath(nativeArtifact.bundledPackageName, "opcore-graph-core.sha256")) { - throw new Error("Release receipt native artifact checksumPath must point at the bundled native checksum"); - } - if (nativeArtifact.metadataPath !== bundledGraphCoreNativePath(nativeArtifact.bundledPackageName, "metadata.json")) { - throw new Error("Release receipt native artifact metadataPath must point at the bundled native metadata"); - } - validateGraphProviderArtifactMetadata(nativeArtifact.metadata); - validateRepoRelativePath(nativeArtifact.binaryPath); - validateRepoRelativePath(nativeArtifact.checksumPath); - validateRepoRelativePath(nativeArtifact.metadataPath); - validateSha256(nativeArtifact.binarySha256, "Release receipt native artifact binarySha256"); - validateSha256(nativeArtifact.checksumFileSha256, "Release receipt native artifact checksumFileSha256"); - validateSha256(nativeArtifact.metadataSha256, "Release receipt native artifact metadataSha256"); - validateNonEmptyString(nativeArtifact.descriptorArtifactId, "Release receipt native artifact descriptorArtifactId"); - validateNonEmptyString(nativeArtifact.descriptorChecksumId, "Release receipt native artifact descriptorChecksumId"); - if (nativeArtifact.metadata.targetPlatform !== nativeArtifact.targetPlatform) { - throw new Error("Release receipt native artifact targetPlatform must match metadata"); - } - if (nativeArtifact.metadata.binaryPath !== "opcore-graph-core") { - throw new Error("Release receipt native artifact binaryPath must match metadata"); - } - if (nativeArtifact.metadata.checksumPath !== "opcore-graph-core.sha256") { - throw new Error("Release receipt native artifact checksumPath must match metadata"); - } - if (nativeArtifact.metadata.checksumSha256 !== nativeArtifact.binarySha256) { - throw new Error("Release receipt native artifact binary sha256 must match metadata checksum"); - } -} - -function validateReleaseReceiptLicense(license: ReleaseReceiptLicenseEvidence): void { - if (!license || typeof license !== "object") throw new Error("Release receipt license evidence is required"); - validateRepoRelativePath(license.reportPath); - validateSha256(license.reportSha256, "Release receipt license reportSha256"); - validateNonNegativeInteger(license.productionDependencyCount, "Release receipt license productionDependencyCount"); - validateNonNegativeInteger(license.bundledDependencyCount, "Release receipt license bundledDependencyCount"); - validateNonNegativeInteger(license.workspacePackageCount, "Release receipt license workspacePackageCount"); - if (license.workspacePackageCount < releaseReceiptPackageNames.length) { - throw new Error(`Release receipt license workspacePackageCount must be at least ${releaseReceiptPackageNames.length}`); - } - if (license.unresolvedLicenseCount !== 0) throw new Error("Release receipt license unresolvedLicenseCount must be 0"); - if (!Array.isArray(license.packages)) throw new Error("Release receipt license packages must be an array"); - for (const packageEvidence of license.packages) { - validateNonEmptyString(packageEvidence.name, "Release receipt license package name"); - validateNonEmptyString(packageEvidence.version, "Release receipt license package version"); - validateNonEmptyString(packageEvidence.license, "Release receipt license package license"); - validateNonEmptyString(packageEvidence.source, "Release receipt license package source"); - if (typeof packageEvidence.bundled !== "boolean") throw new Error("Release receipt license package bundled must be boolean"); - } -} - -function validateReleaseReceiptProvenance(provenance: ReleaseReceiptProvenanceEvidence): void { - if (!provenance || typeof provenance !== "object") throw new Error("Release receipt provenance evidence is required"); - validateRepoRelativePath(provenance.reportPath); - validateSha256(provenance.reportSha256, "Release receipt provenance reportSha256"); - validateNonNegativeInteger(provenance.scannedFileCount, "Release receipt provenance scannedFileCount"); - validateNonNegativeInteger(provenance.historyCommitCount, "Release receipt provenance historyCommitCount"); - if (provenance.findingCount !== 0 || provenance.findings.length !== 0) { - throw new Error("Release receipt provenance findings must be empty"); - } -} - -function validateReleaseReceiptSecretHistory(secretHistory: ReleaseReceiptSecretHistoryEvidence): void { - if (!secretHistory || typeof secretHistory !== "object") throw new Error("Release receipt secret history evidence is required"); - validateRepoRelativePath(secretHistory.allowlistPath); - validateSha256(secretHistory.allowlistSha256, "Release receipt secret history allowlistSha256"); - validateNonNegativeInteger(secretHistory.currentTreeScannedFileCount, "Release receipt secret history currentTreeScannedFileCount"); - validateNonNegativeInteger(secretHistory.gitHistoryScannedCommitCount, "Release receipt secret history gitHistoryScannedCommitCount"); - if (secretHistory.findingCount !== 0 || secretHistory.findings.length !== 0) { - throw new Error("Release receipt secret findings must be empty"); - } -} - -function validateReleaseReceiptReports(reports: readonly ReleaseReceiptReport[]): void { - validateNonEmptyArray(reports, "Release receipt reports"); - validateExactStringSet( - reports.map((entry) => entry.id), - releaseReceiptReportIds, - "Release receipt reports" - ); - for (const report of reports) { - validateReleaseReceiptReportId(report.id, "Release receipt report id"); - validateStringArray(report.command, "Release receipt report command", { allowEmpty: false }); - if (report.status !== "passed") throw new Error("Release receipt report status must be passed"); - if (report.exitCode !== 0) throw new Error("Release receipt report exitCode must be 0"); - if (report.path !== undefined) validateRepoRelativePath(report.path); - if (report.checksumSha256 !== undefined) validateSha256(report.checksumSha256, "Release receipt report checksumSha256"); - validateNonEmptyString(report.summary, "Release receipt report summary"); - } -} - -function validateReleaseReceiptGraphReleaseEvidence(evidence: ReleaseReceiptGraphReleaseEvidence): void { - if (!evidence || typeof evidence !== "object") throw new Error("Release receipt graph release evidence is required"); - validateRepoRelativePath(evidence.path); - if (evidence.issue !== "#17") throw new Error("Release receipt graph release evidence issue must be #17"); - validateSha256(evidence.checksumSha256, "Release receipt graph release checksumSha256"); -} - -function validateReleaseReceiptBins(bins: Readonly>, packageName: ReleaseReceiptPackageName): void { - if (!bins || typeof bins !== "object" || Array.isArray(bins)) throw new Error("Release receipt bins must be an object"); - const binNames = Object.keys(bins); - for (const bin of binNames) { - validateNonEmptyString(bin, "Release receipt bin name"); - if (["lattice", "crg", "cix", "rox"].includes(bin)) throw new Error(`Release receipt package exposes old public bin ${bin}`); - validateRepoRelativePath(bins[bin]); - } - if (packageName === "opcore") { - validateExactStringSet(binNames, ["opcore", "opcore-asp-provider"], "Release receipt Opcore package bins"); - } else if (binNames.length > 0) { - throw new Error(`${packageName} must not expose CLI bins`); - } -} - -function validateReleaseCutoverInstalledPackages(packages: readonly ReleaseCutoverInstalledPackageEvidence[]): void { - validateNonEmptyArray(packages, "Release cutover installed package evidence"); - validateReleaseCutoverInstalledPackageSet(packages.map((entry) => entry.packageName)); - for (const entry of packages) { - if (!entry || typeof entry !== "object") throw new Error("Release cutover installed package evidence entry is required"); - validateReleaseReceiptPackageName(entry.packageName, "Release cutover installed package packageName"); - validateNonEmptyString(entry.version, "Release cutover installed package version"); - if (!entry.tarball || typeof entry.tarball !== "object") throw new Error("Release cutover tarball evidence is required"); - validateNonEmptyString(entry.tarball.filename, "Release cutover tarball filename"); - validateSha256(entry.tarball.sha256, "Release cutover tarball sha256"); - if (!entry.installedManifest || typeof entry.installedManifest !== "object") { - throw new Error("Release cutover installed manifest evidence is required"); - } - validateNonEmptyString(entry.installedManifest.path, "Release cutover installed manifest path"); - if (!entry.installedManifest.path.includes("node_modules/") || !entry.installedManifest.path.endsWith("package.json")) { - throw new Error("Release cutover installed manifest path must be inside node_modules and end with package.json"); - } - validateSha256(entry.installedManifest.sha256, "Release cutover installed manifest sha256"); - validateReleaseReceiptBins(entry.installedManifest.bins, entry.packageName); - validateReleaseCutoverInstalledFiles(entry); - } -} - -function validateReleaseCutoverInstalledFiles(entry: ReleaseCutoverInstalledPackageEvidence): void { - validateNonEmptyArray(entry.installedFiles, "Release cutover installed files"); - const prefix = `node_modules/${entry.packageName}/`; - const paths = []; - for (const file of entry.installedFiles) { - if (!file || typeof file !== "object") throw new Error("Release cutover installed file evidence entry is required"); - validateNonEmptyString(file.path, "Release cutover installed file path"); - if (!file.path.startsWith(prefix)) { - throw new Error(`Release cutover installed file path must be inside ${prefix}`); - } - validateSha256(file.sha256, "Release cutover installed file sha256"); - paths.push(file.path); - } - if (new Set(paths).size !== paths.length) { - throw new Error("Release cutover installed file paths must be unique"); - } - if (!paths.includes(entry.installedManifest.path)) { - throw new Error("Release cutover installed files must include package.json"); - } - const binPaths = Object.values(entry.installedManifest.bins).map((path) => `${prefix}${path}`); - for (const binPath of binPaths) { - if (!paths.includes(binPath)) throw new Error(`Release cutover installed files must include bin target ${binPath}`); - } - if (entry.packageName === "opcore") { - if (!paths.includes("node_modules/opcore/node_modules/@the-open-engine/opcore-asp-provider/dist/manifests/asp-server.json")) { - throw new Error("Release cutover Opcore installed files must include bundled canonical asp-server.json"); - } - for (const target of graphCoreNativeSupportedTargets) { - const bundledPackageName = graphCoreNativePackageNameForTarget(target); - if (!paths.includes(`node_modules/opcore/${bundledGraphCoreNativePath(bundledPackageName, "opcore-graph-core")}`)) { - throw new Error(`Release cutover Opcore installed files must include bundled native binary for ${target}`); - } - } - } -} - -function validateReleaseCutoverInstalledPackageSet(packageNames: readonly string[]): void { - validateExactStringSet(packageNames, releaseReceiptPackageNames, "Release cutover installed package evidence"); - for (const packageName of packageNames) { - validateReleaseReceiptPackageName(packageName, "Release cutover installed package packageName"); - } -} - -function validateReleaseCutoverDescriptor(descriptorEvidence: ReleaseCutoverDescriptorEvidence): void { - if (!descriptorEvidence || typeof descriptorEvidence !== "object") throw new Error("Release cutover descriptor evidence is required"); - validateNonEmptyString(descriptorEvidence.path, "Release cutover descriptor path"); - if (descriptorEvidence.packageName !== "opcore") { - throw new Error("Release cutover descriptor packageName must be opcore"); - } - validateSha256(descriptorEvidence.checksumSha256, "Release cutover descriptor checksumSha256"); - const descriptor = validateManagedToolDescriptor(descriptorEvidence.descriptor); - validateNonEmptyArray(descriptorEvidence.resolvedArtifacts, "Release cutover descriptor resolvedArtifacts"); - validateExactStringSet( - descriptorEvidence.resolvedArtifacts.map((entry) => entry.id), - descriptor.artifacts.map((entry) => entry.id), - "Release cutover descriptor resolved artifact ids" - ); - for (const artifact of descriptorEvidence.resolvedArtifacts) { - validateReleaseReceiptPackageName(artifact.packageName, "Release cutover descriptor resolved artifact packageName"); - validateNonEmptyString(artifact.path, "Release cutover descriptor resolved artifact path"); - validateNonEmptyString(artifact.id, "Release cutover descriptor resolved artifact id"); - if (!includesString(managedToolDescriptorArtifactTypes, artifact.type)) { - throw new Error(`Unknown release cutover descriptor resolved artifact type: ${String(artifact.type)}`); - } - if (artifact.packageFile !== true) throw new Error("Release cutover descriptor resolved artifacts must be package files"); - } - validateNonEmptyArray(descriptorEvidence.resolvedChecksums, "Release cutover descriptor resolvedChecksums"); - validateExactStringSet( - descriptorEvidence.resolvedChecksums.map((entry) => entry.id), - descriptor.checksums.map((entry) => entry.id), - "Release cutover descriptor resolved checksum ids" - ); - for (const checksum of descriptorEvidence.resolvedChecksums) { - validateReleaseReceiptPackageName(checksum.packageName, "Release cutover descriptor resolved checksum packageName"); - validateNonEmptyString(checksum.path, "Release cutover descriptor resolved checksum path"); - validateNonEmptyString(checksum.id, "Release cutover descriptor resolved checksum id"); - if (checksum.algorithm !== "sha256") throw new Error("Release cutover descriptor checksum algorithm must be sha256"); - validateSha256(checksum.value, "Release cutover descriptor resolved checksum value"); - if (checksum.packageFile !== true) throw new Error("Release cutover descriptor resolved checksums must be package files"); - } -} - -function validateReleaseCutoverEnvironmentIsolation(environment: ReleaseCutoverEnvironmentIsolationEvidence): void { - if (!environment || typeof environment !== "object") throw new Error("Release cutover environmentIsolation is required"); - if (environment.currentToolEnvCleared !== true) throw new Error("Release cutover current-tool environment must be cleared"); - if (!Number.isInteger(environment.clearedEnvVarCount) || environment.clearedEnvVarCount < 5) { - throw new Error("Release cutover clearedEnvVarCount must be at least 5"); - } - if (environment.pathSanitized !== true) throw new Error("Release cutover PATH must be sanitized"); - if (environment.aceRuntimeBinExcluded !== true) throw new Error("Release cutover ACE runtime bin must be excluded"); - if (environment.siblingCovibesExcluded !== true) throw new Error("Release cutover sibling Covibes paths must be excluded"); - if (environment.opcoreBinOnly !== true) throw new Error("Release cutover installed project must expose only Opcore-owned bins"); - const oldBins = environment.oldBinsAbsent; - if (!oldBins || oldBins.lattice !== true || oldBins.crg !== true || oldBins.cix !== true || oldBins.rox !== true) { - throw new Error("Release cutover old public bins must be absent"); - } -} - -function validateReleaseCutoverCommandReceipts(receipts: readonly ReleaseCutoverCommandReceipt[]): void { - validateNonEmptyArray(receipts, "Release cutover command receipts"); - validateExactStringSet( - receipts.map((entry) => entry.id), - releaseCutoverRequiredCommandIds, - "Release cutover command receipts" - ); - for (const receipt of receipts) { - if (!receipt || typeof receipt !== "object") throw new Error("Release cutover command receipt is required"); - if (!includesString(releaseCutoverRequiredCommandIds, receipt.id)) { - throw new Error(`Unknown release cutover command receipt id: ${String(receipt.id)}`); - } - validateStringArray(receipt.command, "Release cutover command receipt command", { allowEmpty: false }); - validateStringArray(receipt.canonicalCommand, "Release cutover command receipt canonicalCommand", { allowEmpty: false }); - validateExactStringSequence(receipt.command, receipt.canonicalCommand, `Release cutover ${receipt.id} command`); - const expected = releaseCutoverCommandExpectations[receipt.id]; - if (receipt.command[0] !== expected.bin) { - throw new Error(`Release cutover command ${receipt.id} command must use canonical ${expected.bin} bin`); - } - if (receipt.canonicalCommand[0] !== expected.bin) { - throw new Error(`Release cutover command ${receipt.id} canonicalCommand must use canonical ${expected.bin} bin`); - } - validateCommandOwner(receipt.owner); - validateReleaseCutoverExpectedCommand(receipt.canonicalCommand, expected, receipt.id); - if (receipt.owner !== expected.owner) { - throw new Error(`Release cutover command ${receipt.id} owner must match expected ${expected.owner}`); - } - const status = validateCommandRouteStatus(receipt.status); - if (receipt.status === "not_implemented") { - throw new Error("Release cutover command receipts must not be not_implemented"); - } - if (status !== expected.status) { - throw new Error(`Release cutover command ${receipt.id} status must match expected ${expected.status}`); - } - validateExitCodeForStatus(receipt.exitCode, status); - if (receipt.exitCode !== expected.exitCode) { - throw new Error(`Release cutover command ${receipt.id} exitCode must match expected ${expected.exitCode}`); - } - validateNonEmptyString(receipt.binPath, "Release cutover command receipt binPath"); - if (!receipt.binPath.endsWith(`node_modules/.bin/${expected.bin}`)) { - throw new Error(`Release cutover command receipt binPath must use installed node_modules/.bin/${expected.bin}`); - } - validateSha256(receipt.stdoutSha256, "Release cutover command receipt stdoutSha256"); - validateSha256(receipt.stderrSha256, "Release cutover command receipt stderrSha256"); - validateNonEmptyString(receipt.assertion, "Release cutover command receipt assertion"); - } -} - -function validateReleaseCutoverRustCommandReceipts(receipts: readonly ReleaseCutoverRustCommandReceipt[]): void { - validateNonEmptyArray(receipts, "Release cutover Rust command receipts"); - validateExactStringSet( - receipts.map((entry) => entry.id), - releaseCutoverRustCommandIds, - "Release cutover Rust command receipts" - ); - for (const receipt of receipts) { - if (!receipt || typeof receipt !== "object") throw new Error("Release cutover Rust command receipt is required"); - if (!includesString(releaseCutoverRustCommandIds, receipt.id)) { - throw new Error(`Unknown release cutover Rust command receipt id: ${String(receipt.id)}`); - } - validateStringArray(receipt.command, "Release cutover Rust command receipt command", { allowEmpty: false }); - validateStringArray(receipt.canonicalCommand, "Release cutover Rust command receipt canonicalCommand", { allowEmpty: false }); - validateExactStringSequence(receipt.command, receipt.canonicalCommand, `Release cutover Rust ${receipt.id} command`); - const expected = releaseCutoverRustCommandExpectations[receipt.id]; - validateReleaseCutoverExpectedCommand(receipt.canonicalCommand, expected, receipt.id); - if (receipt.owner !== "graph") throw new Error(`Release cutover Rust command ${receipt.id} owner must be graph`); - if (receipt.status !== "ok") throw new Error(`Release cutover Rust command ${receipt.id} status must be ok`); - if (receipt.exitCode !== 0) throw new Error(`Release cutover Rust command ${receipt.id} exitCode must be 0`); - validateNonEmptyString(receipt.binPath, "Release cutover Rust command receipt binPath"); - if (!receipt.binPath.endsWith("node_modules/.bin/opcore")) { - throw new Error("Release cutover Rust command receipt binPath must use installed node_modules/.bin/opcore"); - } - validateSha256(receipt.stdoutSha256, "Release cutover Rust command receipt stdoutSha256"); - validateSha256(receipt.stderrSha256, "Release cutover Rust command receipt stderrSha256"); - validateNonEmptyString(receipt.assertion, "Release cutover Rust command receipt assertion"); - } -} - -function validateReleaseCutoverPythonCommandReceipts(receipts: readonly ReleaseCutoverPythonCommandReceipt[]): void { - validateNonEmptyArray(receipts, "Release cutover Python command receipts"); - validateExactStringSet( - receipts.map((entry) => entry.id), - releaseCutoverPythonCommandIds, - "Release cutover Python command receipts" - ); - for (const receipt of receipts) { - if (!receipt || typeof receipt !== "object") throw new Error("Release cutover Python command receipt is required"); - if (!includesString(releaseCutoverPythonCommandIds, receipt.id)) { - throw new Error(`Unknown release cutover Python command receipt id: ${String(receipt.id)}`); - } - validateStringArray(receipt.command, "Release cutover Python command receipt command", { allowEmpty: false }); - validateStringArray(receipt.canonicalCommand, "Release cutover Python command receipt canonicalCommand", { allowEmpty: false }); - validateStringArray(receipt.evidence, "Release cutover Python command receipt evidence", { allowEmpty: false }); - validateExactStringSequence(receipt.command, receipt.canonicalCommand, `Release cutover Python ${receipt.id} command`); - const expected = releaseCutoverPythonCommandExpectations[receipt.id]; - validateExactStringSet( - receipt.evidence, - releaseCutoverPythonEvidenceExpectations[receipt.id], - `Release cutover Python command ${receipt.id} evidence` - ); - if (receipt.command[0] !== expected.bin) { - throw new Error(`Release cutover Python command ${receipt.id} command must use canonical ${expected.bin} bin`); - } - if (receipt.canonicalCommand[0] !== expected.bin) { - throw new Error(`Release cutover Python command ${receipt.id} canonicalCommand must use canonical ${expected.bin} bin`); - } - validateCommandOwner(receipt.owner); - validateReleaseCutoverExpectedCommand(receipt.canonicalCommand, expected, receipt.id); - if (receipt.owner !== expected.owner) { - throw new Error(`Release cutover Python command ${receipt.id} owner must match expected ${expected.owner}`); - } - if (receipt.status !== "ok") throw new Error(`Release cutover Python command ${receipt.id} status must be ok`); - if (receipt.exitCode !== 0) throw new Error(`Release cutover Python command ${receipt.id} exitCode must be 0`); - validateNonEmptyString(receipt.binPath, "Release cutover Python command receipt binPath"); - if (!receipt.binPath.endsWith(`node_modules/.bin/${expected.bin}`)) { - throw new Error(`Release cutover Python command receipt binPath must use installed node_modules/.bin/${expected.bin}`); - } - validateSha256(receipt.stdoutSha256, "Release cutover Python command receipt stdoutSha256"); - validateSha256(receipt.stderrSha256, "Release cutover Python command receipt stderrSha256"); - validateNonEmptyString(receipt.assertion, "Release cutover Python command receipt assertion"); - } -} - -function validateReleaseCutoverExpectedCommand( - command: readonly string[], - expectation: ReleaseCutoverCommandExpectation, - id: ReleaseCutoverCommandId | ReleaseCutoverRustCommandId | ReleaseCutoverPythonCommandId -): void { - if (!releaseCutoverCommandMatchesExpectation(command, expectation)) { - throw new Error(`Release cutover command ${id} canonicalCommand must match expected ${formatReleaseCutoverCommand(expectation)}`); - } -} - -function releaseCutoverCommandMatchesExpectation( - command: readonly string[], - expectation: ReleaseCutoverCommandExpectation -): boolean { - if (command.length !== expectation.canonicalCommand.length) return false; - return expectation.canonicalCommand.every((expected, index) => { - const actual = command[index]; - if (expected !== releaseCutoverRequestFilePlaceholder) return actual === expected; - return releaseCutoverPathBasename(actual) === expectation.requestFileBasename; - }); -} - -function releaseCutoverPathBasename(path: string): string { - const normalized = path.replaceAll("\\", "/"); - const parts = normalized.split("/"); - return parts[parts.length - 1] ?? normalized; -} - -function formatReleaseCutoverCommand(expectation: ReleaseCutoverCommandExpectation): string { - return expectation.canonicalCommand - .map((part) => (part === releaseCutoverRequestFilePlaceholder ? `<${expectation.requestFileBasename}>` : part)) - .join(" "); -} - -function validateReleaseCutoverNegativeChecks(checks: readonly ReleaseCutoverNegativeCheck[]): void { - validateNonEmptyArray(checks, "Release cutover negative checks"); - validateExactStringSet( - checks.map((entry) => entry.id), - releaseCutoverNegativeCheckIds, - "Release cutover negative checks" - ); - for (const check of checks) { - if (!check || typeof check !== "object") throw new Error("Release cutover negative check is required"); - validateNonEmptyString(check.id, "Release cutover negative check id"); - if (!includesString(releaseCutoverNegativeCheckIds, check.id)) { - throw new Error(`Unknown release cutover negative check id: ${String(check.id)}`); - } - validateStringArray(check.command, "Release cutover negative check command", { allowEmpty: false }); - validateExactStringSequence( - check.command, - releaseCutoverNegativeCheckExpectations[check.id], - `Release cutover negative check ${check.id} command` - ); - if (check.status !== "passed") throw new Error("Release cutover negative check status must be passed"); - if (check.exitCode !== 0) throw new Error("Release cutover negative check exitCode must be 0"); - validateNonEmptyString(check.assertion, "Release cutover negative check assertion"); - } -} - -function validateReleaseCutoverCurrentToolGuardrails(guardrails: readonly ReleaseCutoverCurrentToolGuardrailReceipt[]): void { - validateNonEmptyArray(guardrails, "Release cutover current-tool guardrails"); - validateExactStringSet( - guardrails.map((entry) => entry.id), - releaseCutoverCurrentToolGuardrailIds, - "Release cutover current-tool guardrails" - ); - for (const guardrail of guardrails) { - if (!guardrail || typeof guardrail !== "object") throw new Error("Release cutover current-tool guardrail is required"); - if (!includesString(releaseCutoverCurrentToolGuardrailIds, guardrail.id)) { - throw new Error(`Unknown release cutover current-tool guardrail id: ${String(guardrail.id)}`); - } - const expectedCommand = guardrail.id === "current-tools-validate-changed" - ? ["npm", "run", "current-tools:validate-changed"] as const - : ["npm", "run", "current-tools:validate-rust-graph"] as const; - validateExactStringSequence(guardrail.command, expectedCommand, `Release cutover guardrail ${guardrail.id} command`); - if (guardrail.status !== "passed") throw new Error("Release cutover current-tool guardrail status must be passed"); - if (guardrail.exitCode !== 0) throw new Error("Release cutover current-tool guardrail exitCode must be 0"); - validateSha256(guardrail.stdoutSha256, "Release cutover current-tool guardrail stdoutSha256"); - validateSha256(guardrail.stderrSha256, "Release cutover current-tool guardrail stderrSha256"); - if (guardrail.retained !== true) throw new Error("Release cutover current-tool guardrail must be retained"); - validateNonEmptyString(guardrail.assertion, "Release cutover current-tool guardrail assertion"); - if (guardrail.oldToolReplacementClaimed !== false) { - throw new Error("Release cutover current-tool guardrail must not claim old-tool replacement"); - } - } -} - -function validateReleaseCutoverForbiddenMarkerScan(scan: ReleaseCutoverForbiddenMarkerScan): void { - if (!scan || typeof scan !== "object") throw new Error("Release cutover forbiddenMarkerScan is required"); - validateNonNegativeInteger(scan.scannedTextCount, "Release cutover forbidden marker scannedTextCount"); - if (scan.scannedTextCount === 0) throw new Error("Release cutover forbidden marker scan must scan at least one text"); - if (scan.findingCount !== 0) throw new Error("Release cutover forbidden marker findingCount must be 0"); - validateStringArray(scan.markersBlocked, "Release cutover forbidden marker labels", { allowEmpty: false }); -} - -function validateReleaseCutoverInputEvidence(evidence: readonly ReleaseCutoverInputEvidence[]): void { - validateNonEmptyArray(evidence, "Release cutover input evidence"); - validateExactStringSet( - evidence.map((entry) => entry.issue), - releaseCutoverInputIssues, - "Release cutover input evidence issues" - ); - for (const entry of evidence) { - if (!entry || typeof entry !== "object") throw new Error("Release cutover input evidence entry is required"); - if (!includesString(releaseCutoverInputIssues, entry.issue)) { - throw new Error(`Unknown release cutover input evidence issue: ${String(entry.issue)}`); - } - validateNonEmptyString(entry.path, "Release cutover input evidence path"); - validateSha256(entry.checksumSha256, "Release cutover input evidence checksumSha256"); - } -} - -function validateRustOldRoxComparisonSurfaces(surfaces: readonly RustOldRoxComparisonSurfaceReceipt[]): void { - validateNonEmptyArray(surfaces, "Rust old-Rox comparison surfaces"); - validateExactStringSet( - surfaces.map((entry) => entry.id), - rustOldRoxComparisonSurfaceIds, - "Rust old-Rox comparison surfaces" - ); - for (const surface of surfaces) { - if (!surface || typeof surface !== "object") throw new Error("Rust old-Rox comparison surface is required"); - if (!includesString(rustOldRoxComparisonSurfaceIds, surface.id)) { - throw new Error(`Unknown Rust old-Rox comparison surface: ${String(surface.id)}`); - } - if (typeof surface.graphEvidenceExists !== "boolean") { - throw new Error("Rust old-Rox comparison graphEvidenceExists must be boolean"); - } - validateStringArray(surface.graphEvidence, "Rust old-Rox comparison graph evidence", { allowEmpty: false }); - if (surface.graphEvidenceExists && surface.graphEvidence.length === 0) { - throw new Error("Rust old-Rox comparison graph evidence must describe existing evidence"); - } - validateStringArray(surface.stillUniquelyProvidedByCurrentTools, "Rust old-Rox comparison current tool evidence", { - allowEmpty: false - }); - if (!includesString(rustOldRoxComparisonReplacementStatuses, surface.replacementStatus)) { - throw new Error("Rust old-Rox comparison replacementStatus must be retained or deferred"); - } - } -} - -function validateRustOldRoxComparisonGuardrails(guardrails: readonly RustOldRoxComparisonGuardrailReceipt[]): void { - validateNonEmptyArray(guardrails, "Rust old-Rox comparison guardrails"); - validateExactStringSet( - guardrails.map((entry) => entry.id), - ["current-tools:validate-rust-graph"] as const, - "Rust old-Rox comparison guardrails" - ); - for (const guardrail of guardrails) { - if (!guardrail || typeof guardrail !== "object") throw new Error("Rust old-Rox comparison guardrail is required"); - if (guardrail.id !== "current-tools:validate-rust-graph") { - throw new Error("Rust old-Rox comparison guardrail id must be current-tools:validate-rust-graph"); - } - validateExactStringSequence( - guardrail.command, - ["npm", "run", "current-tools:validate-rust-graph"], - "Rust old-Rox comparison guardrail command" - ); - if (guardrail.replacementStatus !== "retained") { - throw new Error("Rust old-Rox comparison guardrail replacementStatus must be retained"); - } - if (guardrail.oldToolReplacementClaimed !== false) { - throw new Error("Rust old-Rox comparison guardrail must not claim old-tool replacement"); - } - } -} - -function validateAspDogfoodManager(manager: AspDogfoodManagerEvidence): void { - if (!manager || typeof manager !== "object") throw new Error("ASP dogfood manager evidence is required"); - if (manager.bootstrapSource !== "local-sibling") throw new Error("ASP dogfood bootstrapSource must be local-sibling"); - validateNonEmptyString(manager.aspRepoPath, "ASP dogfood manager aspRepoPath"); - validateNonEmptyString(manager.aspBinPath, "ASP dogfood manager aspBinPath"); - validateNonEmptyString(manager.cliPath, "ASP dogfood manager cliPath"); - validateNonEmptyString(manager.commitSha, "ASP dogfood manager commitSha"); -} - -function validateAspDogfoodAspHome(aspHome: AspDogfoodAspHomeEvidence): void { - if (!aspHome || typeof aspHome !== "object") throw new Error("ASP dogfood ASP_HOME evidence is required"); - validateNonEmptyString(aspHome.path, "ASP dogfood ASP_HOME path"); - if (aspHome.temp !== true) throw new Error("ASP dogfood ASP_HOME must be temporary"); - if (aspHome.isolated !== true) throw new Error("ASP dogfood ASP_HOME must be isolated"); - if (aspHome.sharedStateMutated !== false) throw new Error("ASP dogfood shared ASP state must not be mutated"); - if (aspHome.pathSanitized !== true) throw new Error("ASP dogfood PATH must be sanitized for manager execution"); - if (aspHome.aceRuntimeBinExcluded !== true) throw new Error("ASP dogfood manager PATH must exclude .ace/runtime"); -} - -function validateAspDogfoodHostFixture(fixture: AspDogfoodHostFixtureEvidence): void { - if (!fixture || typeof fixture !== "object") throw new Error("ASP dogfood host fixture evidence is required"); - validateNonEmptyString(fixture.repo, "ASP dogfood host fixture repo"); - if (fixture.temp !== true) throw new Error("ASP dogfood host fixture repo must be temporary"); - if (fixture.sourceRepoMutated !== false) throw new Error("ASP dogfood host fixture must not mutate the source repo"); - if (fixture.baselineCommitted !== true) throw new Error("ASP dogfood host fixture must commit a baseline"); - validateStringArray(fixture.changedPaths, "ASP dogfood host fixture changedPaths", { allowEmpty: false }); - for (const path of fixture.changedPaths) validateRepoRelativePath(path); -} - -function validateAspDogfoodProvider(provider: AspDogfoodProviderEvidence): void { - if (!provider || typeof provider !== "object") throw new Error("ASP dogfood provider evidence is required"); - if (provider.providerId !== "opcore") throw new Error("ASP dogfood providerId must be opcore"); - if (provider.packageName !== "opcore") { - throw new Error("ASP dogfood provider package must be opcore"); - } - validateNonEmptyString(provider.binPath, "ASP dogfood provider binPath"); - if (!provider.binPath.endsWith("node_modules/.bin/opcore-asp-provider")) { - throw new Error("ASP dogfood provider binPath must use installed node_modules/.bin/opcore-asp-provider"); - } - validateNonEmptyString(provider.indexPath, "ASP dogfood provider indexPath"); - if (!provider.indexPath.endsWith("node_modules/opcore/node_modules/@the-open-engine/opcore-asp-provider/dist/index.js")) { - throw new Error("ASP dogfood provider indexPath must be bundled opcore-asp-provider dist/index.js"); - } - validateSha256(provider.indexSha256, "ASP dogfood provider indexSha256"); - validateExactStringSequence(provider.command, ["opcore-asp-provider", "--stdio"], "ASP dogfood provider command"); - if (!provider.entrypoint || typeof provider.entrypoint !== "object") throw new Error("ASP dogfood provider entrypoint is required"); - if (provider.entrypoint.transport !== "stdio") throw new Error("ASP dogfood provider entrypoint transport must be stdio"); - validateAspDogfoodProviderBinPath(provider.entrypoint.bin, "ASP dogfood provider entrypoint bin"); - validateExactStringSequence(provider.entrypoint.args, ["--stdio"], "ASP dogfood provider entrypoint args"); - validateAspDogfoodProviderManifest(provider.manifest); -} - -function validateAspDogfoodProviderManifest(manifestEvidence: AspDogfoodProviderManifestEvidence): void { - if (!manifestEvidence || typeof manifestEvidence !== "object") throw new Error("ASP dogfood provider manifest evidence is required"); - validateNonEmptyString(manifestEvidence.manifestPath, "ASP dogfood provider manifestPath"); - validateSha256(manifestEvidence.manifestSha256, "ASP dogfood provider manifestSha256"); - if (!manifestEvidence.manifest || typeof manifestEvidence.manifest !== "object") { - throw new Error("ASP dogfood provider manifest must be structured metadata"); - } - const manifest = manifestEvidence.manifest as Record; - if (manifest.manifestVersion !== "asp-server/0.1") throw new Error("ASP dogfood provider manifestVersion must be asp-server/0.1"); - const server = manifest.server as Record | undefined; - if (!server || server.id !== "opcore") throw new Error("ASP dogfood provider manifest server.id must be opcore"); - const entrypoint = manifest.entrypoint as Record | undefined; - if (!entrypoint || entrypoint.transport !== "stdio" || typeof entrypoint.bin !== "string") { - throw new Error("ASP dogfood provider manifest entrypoint must be opcore-asp-provider --stdio"); - } - validateAspDogfoodProviderBinPath(entrypoint.bin, "ASP dogfood provider manifest entrypoint bin"); - validateExactStringSequence(entrypoint.args as readonly string[], ["--stdio"], "ASP dogfood provider manifest entrypoint args"); - validateExactStringSet((manifest.capabilities as readonly string[]) ?? [], ["check"], "ASP dogfood provider manifest capabilities"); -} - -function validateAspDogfoodProviderBinPath(value: unknown, label: string): void { - validateNonEmptyString(value, label); - const normalized = String(value).replaceAll("\\", "/"); - if (!/node_modules\/\.bin\/opcore-asp-provider(?:\.cmd)?$/.test(normalized)) { - throw new Error(`${label} must use installed node_modules/.bin/opcore-asp-provider`); - } -} - -function validateAspDogfoodManagerState(state: AspDogfoodManagerStateEvidence): void { - if (!state || typeof state !== "object") throw new Error("ASP dogfood managerState is required"); - validateAspDogfoodPassedCommandRun(state.status, "asp-status", "ASP dogfood manager status"); - validateAspDogfoodPassedCommandRun(state.serverAdd, "asp-server-add", "ASP dogfood manager server add"); - validateAspDogfoodPassedCommandRun(state.serverStatus, "asp-server-status", "ASP dogfood manager server status"); - if (!state.serverStatus.output || typeof state.serverStatus.output !== "object") { - throw new Error("ASP dogfood server status output is required"); - } -} - -function validateAspDogfoodRepoEnrollment(enrollment: AspDogfoodRepoEnrollmentEvidence): void { - if (!enrollment || typeof enrollment !== "object") throw new Error("ASP dogfood repo enrollment is required"); - validateNonEmptyString(enrollment.repo, "ASP dogfood repo enrollment repo"); - if (enrollment.mode !== "advisory" && enrollment.mode !== "shadow") { - throw new Error("ASP dogfood repo enrollment mode must be advisory or shadow"); - } - validateAspDogfoodPassedCommandRun(enrollment.repoAdd, "asp-repo-add", "ASP dogfood repo add"); - validateAspDogfoodPassedCommandRun(enrollment.repoEnable, "asp-repo-enable", "ASP dogfood repo enable"); - validateAspDogfoodPassedCommandRun(enrollment.repoStatus, "asp-repo-status", "ASP dogfood repo status"); -} - -function validateAspDogfoodHostEvaluation(evaluation: AspDogfoodHostEvaluationEvidence): void { - if (!evaluation || typeof evaluation !== "object") throw new Error("ASP dogfood host evaluation is required"); - validateAspDogfoodPassedCommandRun(evaluation.check, "asp-check-changed", "ASP dogfood host check"); - if (!evaluation.check.command.includes("check")) throw new Error("ASP dogfood host check must run asp check"); - if (!evaluation.check.hostDecision || typeof evaluation.check.hostDecision !== "object") { - throw new Error("ASP dogfood host decision is required"); - } - if (!evaluation.check.receipt || typeof evaluation.check.receipt !== "object") { - throw new Error("ASP dogfood host receipt is required"); - } - validateAspDogfoodHostAuthorityEvidence(evaluation.check.hostDecision, "ASP dogfood host decision", { requireProviderProvenance: false }); - validateAspDogfoodHostAuthorityEvidence(evaluation.check.receipt, "ASP dogfood host receipt", { requireProviderProvenance: true }); - if (!evaluation.check.assurance || typeof evaluation.check.assurance !== "object") { - throw new Error("ASP dogfood host assurance is required"); - } - validateNonEmptyString(evaluation.check.assurance.mode, "ASP dogfood host assurance mode"); - validateNonEmptyString(evaluation.check.assurance.transactionGuarantee, "ASP dogfood host transactionGuarantee"); - if (evaluation.ciVerify !== undefined) { - validateAspDogfoodCommandRun(evaluation.ciVerify, "asp-ci-verify", "ASP dogfood CI verify"); - if (!evaluation.ciVerify.command.includes("ci") || !evaluation.ciVerify.command.includes("verify")) { - throw new Error("ASP dogfood CI verifier must run asp ci verify"); - } - } -} - -function validateAspDogfoodHostAuthorityEvidence( - value: unknown, - label: string, - options: { requireProviderProvenance: boolean } -): void { - if (!value || typeof value !== "object") throw new Error(`${label} is required`); - const record = value as Record; - const authorityEvidence = record.authorityEvidence; - if (!Array.isArray(authorityEvidence) || authorityEvidence.length === 0) { - throw new Error(`${label} must include host authorityEvidence`); - } - const providerProvenance = record.providerProvenance; - if (options.requireProviderProvenance && (!Array.isArray(providerProvenance) || providerProvenance.length === 0)) { - throw new Error(`${label} must include providerProvenance`); - } -} - -function validateAspDogfoodProviderProbe(probe: AspDogfoodProviderProbeEvidence): void { - if (!probe || typeof probe !== "object") throw new Error("ASP dogfood provider probe is required"); - validateAspDogfoodPassedCommandRun(probe, "provider-probe", "ASP dogfood provider probe"); - validateExactStringSequence(probe.command, ["opcore-asp-provider", "--stdio"], "ASP dogfood provider probe command"); - if (!probe.assessment || typeof probe.assessment !== "object") throw new Error("ASP dogfood provider probe assessment is required"); - if (!probe.validAsOf || typeof probe.validAsOf !== "object") throw new Error("ASP dogfood provider probe validAsOf is required"); - if (!probe.coverage || typeof probe.coverage !== "object") throw new Error("ASP dogfood provider probe coverage is required"); - validateNonNegativeInteger(probe.diagnosticsCount, "ASP dogfood provider probe diagnosticsCount"); - if (probe.hostOwnedFieldLeak !== false) throw new Error("ASP dogfood provider output must not contain host-owned decision fields"); - assertNoAspDogfoodHostOwnedFields(probe.assessment); -} - -function validateAspDogfoodGuardrails(guardrails: readonly AspDogfoodGuardrailReceipt[]): void { - validateNonEmptyArray(guardrails, "ASP dogfood current-tool guardrails"); - validateExactStringSet( - guardrails.map((entry) => entry.id), - aspDogfoodGuardrailIds, - "ASP dogfood current-tool guardrail ids" - ); - for (const guardrail of guardrails) { - if (!includesString(aspDogfoodGuardrailIds, guardrail.id)) { - throw new Error(`Unknown ASP dogfood guardrail id: ${String(guardrail.id)}`); - } - validateAspDogfoodCommandRun(guardrail, guardrail.id, `ASP dogfood guardrail ${guardrail.id}`); - if (guardrail.retained !== true) throw new Error("ASP dogfood old-tool guardrails must be retained"); - if (includesString(aspDogfoodRequiredGuardrailIds, guardrail.id) && guardrail.status !== "passed") { - throw new Error(`ASP dogfood required guardrail ${guardrail.id} must pass`); - } - if (guardrail.id === "current-tools-validate-all" && guardrail.status !== "passed" && guardrail.status !== "retained-not-run") { - throw new Error("ASP dogfood current-tools-validate-all must pass or be retained-not-run"); - } - } -} - -function validateAspDogfoodUnsupportedSurfaces(surfaces: readonly AspDogfoodUnsupportedSurfaceEvidence[]): void { - validateNonEmptyArray(surfaces, "ASP dogfood unsupported surfaces"); - validateExactStringSet( - surfaces.map((entry) => entry.surface), - aspDogfoodUnsupportedSurfaceIds, - "ASP dogfood unsupported surfaces" - ); - for (const entry of surfaces) { - if (!entry || typeof entry !== "object") throw new Error("ASP dogfood unsupported surface entry is required"); - if (!includesString(aspDogfoodUnsupportedSurfaceIds, entry.surface)) { - throw new Error(`Unknown ASP dogfood unsupported surface: ${String(entry.surface)}`); - } - if (!includesString(["degraded", "retained-old-tool-gate", "parity-blocker"] as const, entry.status)) { - throw new Error("ASP dogfood unsupported surface status must be degraded, retained-old-tool-gate, or parity-blocker"); - } - if (entry.cleanCoverage !== false) throw new Error("ASP dogfood unsupported inspect/edit surfaces must not be represented as clean coverage"); - validateNonEmptyString(entry.blocker, "ASP dogfood unsupported surface blocker"); - } -} - -function validateAspDogfoodParityBlockers(blockers: readonly AspDogfoodParityBlocker[]): void { - validateNonEmptyArray(blockers, "ASP dogfood parity blockers"); - for (const blocker of blockers) { - if (!blocker || typeof blocker !== "object") throw new Error("ASP dogfood parity blocker is required"); - validateNonEmptyString(blocker.source, "ASP dogfood parity blocker source"); - validateNonEmptyString(blocker.detail, "ASP dogfood parity blocker detail"); - } -} - -function validateAspDogfoodAuthority(authority: AspDogfoodAuthorityEvidence): void { - if (!authority || typeof authority !== "object") throw new Error("ASP dogfood authority evidence is required"); - if (authority.hostOwnsDecisions !== true) throw new Error("ASP dogfood host must own decisions"); - if (authority.providerOutputIsHostDecision !== false) throw new Error("ASP dogfood provider output must not be treated as host decision"); - if (!authority.localAuthorityOverride || typeof authority.localAuthorityOverride !== "object") { - throw new Error("ASP dogfood local authority override evidence is required"); - } - if (authority.localAuthorityOverride.present !== false || authority.localAuthorityOverride.sharedAuthorityWeakened !== false) { - throw new Error("ASP dogfood must not silently weaken shared authority through local override"); - } -} - -function validateAspDogfoodForbiddenMarkerScan(scan: AspDogfoodForbiddenMarkerScan): void { - if (!scan || typeof scan !== "object") throw new Error("ASP dogfood forbidden marker scan is required"); - validatePositiveInteger(scan.scannedTextCount, "ASP dogfood forbidden marker scannedTextCount"); - if (scan.findingCount !== 0) throw new Error("ASP dogfood forbidden marker findingCount must be 0"); - validateExactStringSet(scan.markersBlocked, aspDogfoodForbiddenProviderMarkers, "ASP dogfood forbidden provider markers"); -} - -function validateAspDogfoodCommandRun(receipt: AspDogfoodCommandRunReceipt, expectedId: string, label: string): void { - if (!receipt || typeof receipt !== "object") throw new Error(`${label} receipt is required`); - if (receipt.id !== expectedId) throw new Error(`${label} id must be ${expectedId}`); - validateStringArray(receipt.command, `${label} command`, { allowEmpty: false }); - if (!includesString(["passed", "failed", "retained-not-run"] as const, receipt.status)) { - throw new Error(`${label} status must be passed, failed, or retained-not-run`); - } - if (receipt.status === "passed" && receipt.exitCode !== 0) throw new Error(`${label} passed status must use exitCode 0`); - if (receipt.status === "retained-not-run" && receipt.exitCode !== null) { - throw new Error(`${label} retained-not-run status must use null exitCode`); - } - if (receipt.status === "failed") validateNonNegativeInteger(receipt.exitCode, `${label} exitCode`); - validateSha256(receipt.stdoutSha256, `${label} stdoutSha256`); - validateSha256(receipt.stderrSha256, `${label} stderrSha256`); - validateNonEmptyString(receipt.assertion, `${label} assertion`); -} - -function validateAspDogfoodPassedCommandRun(receipt: AspDogfoodCommandRunReceipt, expectedId: string, label: string): void { - validateAspDogfoodCommandRun(receipt, expectedId, label); - if (receipt.status !== "passed") throw new Error(`${label} status must be passed`); - if (receipt.exitCode !== 0) throw new Error(`${label} passed status must use exitCode 0`); -} - -function validateAspDogfoodForbiddenProviderEntrypoint(receipt: AspDogfoodReceipt): void { - const providerTexts = collectStrings(receipt.provider); - const findings: string[] = []; - for (const text of providerTexts) { - const normalized = text.replaceAll("\\", "/").toLowerCase(); - for (const marker of [...aspDogfoodForbiddenProviderMarkers, legacyAspProviderBinMarker]) { - if (normalized.includes(marker.toLowerCase())) findings.push(marker); - } - } - if (findings.length > 0) { - throw new Error(`ASP dogfood provider entrypoint contains forbidden marker: ${[...new Set(findings)].join(", ")}`); - } -} - -function assertNoAspDogfoodHostOwnedFields(value: unknown, path = "$"): void { - if (!value || typeof value !== "object") return; - if (Array.isArray(value)) { - value.forEach((entry, index) => assertNoAspDogfoodHostOwnedFields(entry, `${path}[${index}]`)); - return; - } - const forbidden = new Set(["decision", "verdict", "pass", "authority", "authorityEvidence", "assurance", "transactionGuarantee", "applyReceipt"]); - for (const [key, child] of Object.entries(value)) { - if (forbidden.has(key)) throw new Error(`ASP dogfood provider output contains host-owned field ${path}.${key}`); - assertNoAspDogfoodHostOwnedFields(child, `${path}.${key}`); - } -} - -function validateManagedToolDescriptorPackageReference( - reference: ManagedToolDescriptorArtifactReference, - packageName: ReleaseReceiptPackageName -): void { - if (!reference || typeof reference !== "object") throw new Error("Release receipt descriptor reference is required"); - validateNonEmptyString(reference.id, "Release receipt descriptor reference id"); - if (reference.packageName !== packageName) { - throw new Error(`Release receipt descriptor reference packageName must be ${packageName}`); - } - validateRepoRelativePath(reference.path); - if (!includesString(managedToolDescriptorArtifactTypes, reference.type)) { - throw new Error(`Unknown release receipt descriptor reference type: ${String(reference.type)}`); - } - if (typeof reference.required !== "boolean") throw new Error("Release receipt descriptor reference required must be boolean"); - if (reference.checksumRef !== undefined) validateNonEmptyString(reference.checksumRef, "Release receipt descriptor reference checksumRef"); -} - -function validateReleaseReceiptPackageName(value: unknown, label: string): ReleaseReceiptPackageName { - if (!includesString(releaseReceiptPackageNames, value)) { - throw new Error(`${label} must be one of ${releaseReceiptPackageNames.join(", ")}`); - } - return value; -} - -function isGraphCoreNativePackageName(value: unknown): value is GraphCoreNativePackageName { - return includesString(graphCoreNativePackageNames, value); -} - -function bundledGraphCoreNativePath(packageName: GraphCoreNativePackageName, file: "metadata.json" | "opcore-graph-core" | "opcore-graph-core.sha256"): string { - return `node_modules/${packageName}/${file}`; -} - -function graphCoreNativeTargetForPackageName(packageName: GraphCoreNativePackageName): GraphCoreNativeSupportedTarget { - const target = graphCoreNativeSupportedTargets.find((entry) => graphCoreNativePackageNamesByTarget[entry] === packageName); - if (!target) throw new Error(`Unknown Opcore graph-core native package: ${packageName}`); - return target; -} - -function validateReleaseReceiptCommandGroupName(value: unknown, label: string): ReleaseReceiptCommandGroupName { - if (!includesString(releaseReceiptCommandGroups, value)) { - throw new Error(`${label} must be one of ${releaseReceiptCommandGroups.join(", ")}`); - } - return value; -} - -function validateReleaseReceiptReportId(value: unknown, label: string): ReleaseReceiptReportId { - if (!includesString(releaseReceiptReportIds, value)) { - throw new Error(`${label} must be one of ${releaseReceiptReportIds.join(", ")}`); - } - return value; -} - -function validateStringRecord(value: Readonly>, label: string): void { - if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`${label} must be an object`); - for (const [key, recordValue] of Object.entries(value)) { - validateNonEmptyString(key, `${label} key`); - validateNonEmptyString(recordValue, `${label}.${key}`); - } -} - -function packageEvidenceIncludesFile( - packages: readonly ReleaseReceiptPackageEvidence[], - packageName: ReleaseReceiptPackageName, - path: string -): boolean { - return packages.find((entry) => entry.packageName === packageName)?.files.includes(path) ?? false; -} - -function validateSha256(value: unknown, label: string): string { - const text = validateNonEmptyString(value, label); - if (!/^[a-f0-9]{64}$/i.test(text)) throw new Error(`${label} must be a sha256 hex digest`); - return text; -} - -function validateExactStringSet(actual: readonly string[], expected: readonly string[], label: string): void { - validateStringArray(actual, label, { allowEmpty: false }); - const actualSorted = [...actual].sort(); - const expectedSorted = [...expected].sort(); - if ( - actualSorted.length !== expectedSorted.length || - actualSorted.some((value, index) => value !== expectedSorted[index]) - ) { - throw new Error(`${label} must exactly match ${expected.join(", ")}`); - } -} - -function validateExactStringSequence(actual: readonly string[], expected: readonly string[], label: string): void { - validateStringArray(actual, label, { allowEmpty: false }); - if (!sameStringArray(actual, expected)) { - throw new Error(`${label} must exactly match ${expected.join(" ")}`); - } -} - -function validateGraphReleaseSourceFreeStrings(value: unknown): void { - const forbidden = [/tirth8205/i, /pyproject\.toml/i, /setup\.py/i, /setup\.cfg/i, /Pipfile/i, /git clone/i, /code-review-graph/i, /gungnir/i]; - for (const text of collectStrings(value)) { - const pattern = forbidden.find((entry) => entry.test(text)); - if (pattern) throw new Error(`Graph release receipt contains forbidden source provenance: ${text}`); - } -} - -function validateCommandExitSemantics(exitSemantics: CommandExitSemantics): CommandExitSemantics { - if (!exitSemantics || typeof exitSemantics !== "object") { - throw new Error("Command router manifest must include exitSemantics"); - } - if (exitSemantics.ok !== 0) throw new Error("Command exit semantics ok must be 0"); - if (exitSemantics.error !== 1) throw new Error("Command exit semantics error must be 1"); - if (exitSemantics.notImplemented !== 2) throw new Error("Command exit semantics notImplemented must be 2"); - if (exitSemantics.unsupported !== 64) throw new Error("Command exit semantics unsupported must be 64"); - if (typeof exitSemantics.jsonStable !== "boolean") { - throw new Error("Command exit semantics jsonStable must be boolean"); - } - return exitSemantics; -} - -function validateExitCodeForStatus(exitCode: unknown, status: CommandRouteStatus): number { - if (typeof exitCode !== "number" || !Number.isInteger(exitCode) || exitCode < 0) { - throw new Error("Command route exitCode must be a non-negative integer"); - } - if (status === "ok" && exitCode !== 0) throw new Error("Command route ok status must use exitCode 0"); - if (status === "error" && exitCode !== 1) throw new Error("Command route error status must use exitCode 1"); - if (status === "not_implemented" && exitCode !== 2) { - throw new Error("Command route not_implemented status must use exitCode 2"); - } - if (status === "unsupported" && exitCode !== 64) { - throw new Error("Command route unsupported status must use exitCode 64"); - } - return exitCode; -} - -function validateStringArray( - values: readonly string[] | undefined, - label: string, - options: { allowEmpty: boolean; allowEmptyValues?: boolean } -): readonly string[] { - if (!Array.isArray(values)) { - throw new Error(`${label} must be an array`); - } - if (!options.allowEmpty && values.length === 0) { - throw new Error(`${label} must not be empty`); - } - for (const value of values) { - if (options.allowEmptyValues === true) { - if (typeof value !== "string") throw new Error(`${label} must contain only strings`); - } else { - validateNonEmptyString(value, label); - } - } - return values; -} - -function validateValidationChecks(checks: readonly string[], label: string): readonly string[] { - validateStringArray(checks, label, { allowEmpty: true }); - for (const check of checks) { - if (check.trim().length === 0) { - throw new Error(`${label} entries must include non-whitespace content`); - } - validateValidationCheckId(check, `${label} entry`); - } - return checks; -} - -function validateValidationCheckId(checkId: unknown, label: string): string { - const value = validateNonEmptyString(checkId, label); - if (!validationCheckIdRegex.test(value)) { - throw new Error(`${label} must be a stable validation check id`); - } - return value; -} - -function validateNonNegativeNumber(value: unknown, label: string): number { - if (typeof value !== "number" || !Number.isFinite(value) || value < 0) { - throw new Error(`${label} must be a non-negative number`); - } - return value; -} - -function validateNonNegativeInteger(value: unknown, label: string): number { - if (!Number.isInteger(value) || (value as number) < 0) { - throw new Error(`${label} must be a non-negative integer`); - } - return value as number; -} - -function validatePositiveInteger(value: unknown, label: string): number { - if (!Number.isInteger(value) || (value as number) < 1) { - throw new Error(`${label} must be a positive integer`); - } - return value as number; -} - -function validateNonEmptyArray(values: readonly unknown[] | undefined, label: string): readonly unknown[] { - if (!Array.isArray(values) || values.length === 0) { - throw new Error(`${label} must be a non-empty array`); - } - return values; -} - -function validateNonEmptyString(value: unknown, label: string): string { - if (typeof value !== "string" || value.length === 0) { - throw new Error(`${label} must be a non-empty string`); - } - return value; -} - -function validateGraphFreshness(freshness: GraphFreshness | undefined, label: string): GraphFreshness { - if (!freshness || typeof freshness !== "object") { - throw new Error(`${label} graph provider status must include freshness`); - } - if (typeof freshness.generatedAt !== "string" || freshness.generatedAt.length === 0) { - throw new Error(`${label} graph provider freshness must include generatedAt`); - } - if (typeof freshness.ageMs !== "number") { - throw new Error(`${label} graph provider freshness must include numeric ageMs`); - } - if (typeof freshness.stale !== "boolean") { - throw new Error(`${label} graph provider freshness must include stale`); - } - return freshness; -} - -function validateGraphSnapshotMetadata(metadata: GraphSnapshotMetadata): GraphSnapshotMetadata { - if (!metadata || typeof metadata !== "object") { - throw new Error("Graph snapshot metadata is required"); - } - if (typeof metadata.schemaVersion !== "number") { - throw new Error("Graph snapshot metadata must include numeric schemaVersion"); - } - if (typeof metadata.provider !== "string" || metadata.provider.length === 0) { - throw new Error("Graph snapshot metadata must include provider"); - } - validateRepoIdentity(metadata.repo); - validateGraphFreshness(metadata.freshness, "Graph snapshot"); - if (!Array.isArray(metadata.nodeKinds) || !Array.isArray(metadata.edgeKinds)) { - throw new Error("Graph snapshot metadata must include nodeKinds and edgeKinds"); - } - return metadata; -} - -function collectStrings(value: unknown): string[] { - if (typeof value === "string") return [value]; - if (Array.isArray(value)) return value.flatMap((entry) => collectStrings(entry)); - if (!value || typeof value !== "object") return []; - return Object.values(value).flatMap((entry) => collectStrings(entry)); -} +export { + GRAPH_SCHEMA_VERSION, CLONE_PROTOCOL, graphProviderModes, graphProviderStatusStates, requiredGraphNodeKinds, + requiredGraphEdgeKinds, graphSnapshotMetadataKeys, providerFailureCategories, + graphProviderFailureCategoriesByState, } from "./graph/vocabulary-01.js"; +export type { + GraphProviderMode, GraphProviderStatusState, GraphNodeKind, GraphEdgeKind, GraphSnapshotMetadataKey, + ProviderFailureCategory, GraphProviderErrorFailureCategory, } from "./graph/vocabulary-01.js"; +export { graphExtractionDiagnosticCategories } from "./graph/vocabulary-02.js"; +export type { GraphExtractionDiagnosticCategory } from "./graph/vocabulary-02.js"; +export { editRefusalCategories } from "./edit/vocabulary.js"; +export type { EditRefusalCategory } from "./edit/vocabulary.js"; +export { + validationDiagnosticCategories, validationResultStatuses, validationFailureCategories, validationReportModes, + validationCheckRunStatuses, validationCheckOutcomes, pythonValidationCapabilityRunStatuses, + pythonValidationAuthorities, } from "./validation/vocabulary-01.js"; +export type { + ValidationDiagnosticCategory, ValidationResultStatus, ValidationFailureCategory, ValidationReportMode, + ValidationCheckRunStatus, ValidationCheckOutcome, PythonValidationCapabilityRunStatus, + PythonValidationAuthority, } from "./validation/vocabulary-01.js"; +export { + pythonValidationAuthoritySources, pythonValidationCapabilityTerminationKinds, pythonValidationCapabilities, + pythonValidationCapabilityStates, pythonValidationCapabilityTerminations, validationSkippedCheckReasons, + validationCheckIdPattern, } from "./validation/vocabulary-02.js"; +export type { + PythonValidationAuthoritySource, PythonValidationCapabilityTerminationKind, PythonValidationCapability, + PythonValidationCapabilityState, PythonValidationCapabilityTermination, ValidationSkippedCheckReason, +} from "./validation/vocabulary-02.js"; +export type { JsonPrimitive, JsonValue } from "./shared/json.js"; +export type { + RepoIdentity, GraphFreshness, GraphProviderArtifactMetadata, GraphProviderCapabilityHandshake, + ProviderFailure, ProviderFailureWithCategory, GraphExtractionDiagnostic, GraphProviderStatusBase, + GraphProviderAvailableStatus, GraphProviderWarmingStatus, GraphProviderSkippedStatus, + GraphProviderRequiredMissingStatus, GraphProviderStaleStatus, GraphProviderSchemaMismatchStatus, + GraphProviderDaemonUnavailableStatus, GraphProviderErrorStatus, } from "./graph/provider-contracts-01.js"; +export type { + GraphProviderStatus, GraphProviderFailureStatus, GraphProviderNonAvailableStatus, GraphFactNode, + GraphFactEdge, GraphSnapshotMetadata, } from "./graph/provider-contracts-02.js"; +export { graphFactQueryKinds, graphNamedQueryKinds } from "./graph/query-contracts-01.js"; +export type { + GraphFactQuerySelector, GraphNamedQueryKind, GraphProviderQueryKind, GraphFactQueryRequest, + GraphFactQueryAvailableResult, GraphFactQueryFailureResult, GraphFactQueryResult, GraphTraversalMetadata, + GraphNamedQueryRequest, GraphNamedQueryAvailableResult, GraphNamedQueryFailureResult, GraphNamedQueryResult, + GraphImpactRequest, GraphImpactAvailableResult, } from "./graph/query-contracts-01.js"; +export type { + GraphImpactFailureResult, GraphImpactResult, GraphRenamedFile, GraphDetectChangesRequest, + GraphDetectChangesAvailableResult, GraphDetectChangesFailureResult, GraphDetectChangesResult, + GraphReviewContextRequest, GraphReviewContextAvailableResult, GraphReviewContextFailureResult, + GraphReviewContextResult, } from "./graph/query-contracts-02.js"; +export { + inspectSignatureKinds, inspectImplementationKinds, inspectFailureCategories, +} from "./inspect/contracts-01.js"; +export type { + InspectSymbolTarget, InspectReferenceTarget, InspectTextSpan, InspectReferenceSpan, InspectSymbolSummary, + InspectSymbolEvidence, InspectReferenceEntry, InspectSignatureKind, InspectSignatureParameter, + InspectSignatureTypeParameter, InspectSignatureEntry, InspectImplementationKind, InspectImplementationEntry, +} from "./inspect/contracts-01.js"; +export type { + InspectFailureCategory, InspectRouteFailure, InspectReferenceResult, InspectSignatureResult, + InspectImplementationResult, InspectRouteErrorResult, InspectRouteResult, } from "./inspect/contracts-02.js"; +export { aspWarmMethodNames } from "./inspect/warm-contracts.js"; +export type { + AspWarmMethodName, AspWarmProviderSummary, AspWarmInspectReferencesParams, AspWarmInspectReferencesOkResult, + AspWarmInspectReferencesErrorResult, AspWarmInspectReferencesResponse, SymbolEditTarget, + AspWarmEditRenameParams, AspWarmAffectedChecksum, AspWarmEditRenamePreviewResult, + AspWarmEditRenameRefusedResult, AspWarmEditRenameResponse, AspWarmSessionShutdownResponse, +} from "./inspect/warm-contracts.js"; +export type { + GraphSearchRequest, GraphSearchMode, GraphSearchResultEntry, GraphSearchSummary, GraphSearchAvailableResult, + GraphSearchFailureResult, GraphSearchResult, } from "./graph/search-contracts.js"; +export { graphDaemonOperations } from "./graph/pipeline-contracts.js"; +export type { + GraphPipelineOperation, GraphPipelinePhaseTiming, GraphWalCheckpointSummary, GraphPipelineSummary, + GraphWatchLifecycle, GraphServeTransportStatus, GraphPipelineResult, GraphDaemonOperation, + GraphDaemonRequest, GraphDaemonResponse, } from "./graph/pipeline-contracts.js"; +export type { + RepoRelativeChangeBase, RepoRelativeChange, AtomicApplyMetadata, EditPlanValidationRequirement, EditPlan, + EditRefusal, EditPlanResult, EditPlanRollbackState, EditCommandResult, } from "./edit/contracts.js"; +export { + validationScopeKinds, cloneReportModes, cloneSourceReadModes, } from "./validation/request-contracts.js"; +export type { + ValidationScopeKind, ValidationScope, HypotheticalOverlay, CloneReportMode, CloneSourceReadMode, + CloneAnalysisRequest, CloneFinding, CloneAnalysisSummary, CloneAnalysisResult, ValidationFailure, + ValidationGraphConfig, ValidationRequest, } from "./validation/request-contracts.js"; +export { + PYTHON_PROJECT_CONTEXT_SCHEMA_ID, PYTHON_VALIDATION_CAPABILITY_RUN_SCHEMA_ID, pythonProjectContextOutcomes, + pythonProjectContextReasonCodes, pythonProjectManagerKinds, pythonProjectLayoutKinds, + pythonProjectExecutableSources, pythonProjectToolKinds, } from "./validation/python-project-contracts-01.js"; +export type { + PythonProjectContextOutcome, PythonProjectContextReasonCode, PythonProjectManagerKind, + PythonProjectLayoutKind, PythonProjectExecutableSource, PythonProjectToolKind, PythonProjectContextReason, + PythonProjectFileEvidence, } from "./validation/python-project-contracts-01.js"; +export type { + PythonProjectManagerEvidence, PythonProjectExecutableProvenance, PythonInterpreterProvenance, + PythonProjectToolProvenance, PythonProjectTarget, PythonProjectLayoutEvidence, PythonProjectBuildSystem, + PythonProjectContext, PythonValidationCapabilityToolProvenance, PythonValidationCapabilityExecution, + PythonTypesValidationCapabilityRun, } from "./validation/python-project-contracts-02.js"; +export type { + ValidationDiagnostic, ValidationDiagnosticToolProvenance, ValidationCheckManifestEntry, + PythonRuffValidationCapabilityRun, PythonValidationCapabilityRun, PythonValidationCapabilityInvocation, + ValidationCheckRunSummary, ValidationSkippedCheck, ValidationResultManifest, +} from "./validation/diagnostic-contracts.js"; +export { + pythonCapabilityActivations, pythonPytestSelectionModes, pythonCapabilityProcessTerminations, +} from "./validation/capability-contracts.js"; +export type { + PythonCapabilityActivation, PythonPytestSelectionMode, PythonCapabilityProcessTermination, + PythonCapabilityCounts, PythonCapabilityCleanupEvidence, PythonCapabilityInvocation, + PythonPytestValidationCapabilityRun, ValidationResult, } from "./validation/capability-contracts.js"; +export { + requiredContextDocPolicy, validationDaemonReadinessStates, validationAdapterRuntimeStates, +} from "./validation/status-contracts.js"; +export type { + RequiredContextDocPolicy, PreWriteValidationOverlaySummary, PreWriteValidationFailureSummary, + PreWriteValidationReceipt, ValidationDaemonReadinessState, ValidationAdapterRuntimeState, + ValidationAdapterToolchainStatus, ValidationAdapterDegradedCheckStatus, ValidationAdapterRuntimeStatus, + ValidationStatusPayload, } from "./validation/status-contracts.js"; +export { managedToolDescriptorCommandGroups, managedToolDescriptorArtifactTypes } from "./managed/contracts.js"; +export type { + ManagedToolDescriptorCommandGroupName, ManagedToolDescriptorArtifactType, ManagedToolDescriptor, + ManagedToolDescriptorEntrypoint, ManagedToolDescriptorCommandGroup, ManagedToolDescriptorHealthProbe, + ManagedToolDescriptorCapabilities, ManagedToolDescriptorNativeArtifact, + ManagedToolDescriptorArtifactReference, ManagedToolDescriptorChecksumReference, + ManagedToolDescriptorProvenanceHook, } from "./managed/contracts.js"; +export { + commandOwners, commandRouteStatuses, commandTimingProcessStates, commandTimingDegradationReasons, + latencyBudgetResultStatuses, commandLatencyTelemetryBins, commandLatencyTelemetryArtifactPolicy, +} from "./command/vocabulary.js"; +export type { + CommandOwner, CommandRouteStatus, CommandTimingProcessState, CommandTimingDegradationReason, + LatencyBudgetResultStatus, CommandLatencyTelemetryBin, } from "./command/vocabulary.js"; +export { + graphReleaseSurfaceClassifications, graphReleaseCoreCommandIds, graphReleaseRustCommandIds, + graphReleaseBenchmarkMetrics, graphReleaseRequiredChildren, graphReleaseDeferredChildren, + graphReleaseOptionalAnalysisSurfaces, graphReleaseHandoffIssues, } from "./release/graph-vocabulary-01.js"; +export type { + GraphReleaseSurfaceClassification, GraphReleaseCoreCommandId, GraphReleaseRustCommandId, + GraphReleaseBenchmarkMetric, GraphReleaseRequiredChild, GraphReleaseDeferredChild, + GraphReleaseOptionalAnalysisSurface, GraphReleaseHandoffIssue, } from "./release/graph-vocabulary-01.js"; +export { + graphReleaseDirectSqliteQueryIds, graphReleaseServeTransportIds, graphReleaseReportReceiptIds, + graphCoreNativeSupportedTargets, graphCoreNativePackageNames, graphCoreNativePackageNamesByTarget, + graphCoreNativePackageNameForTarget, } from "./release/graph-vocabulary-02.js"; +export type { + GraphReleaseDirectSqliteQueryId, GraphReleaseServeTransportId, GraphReleaseReportReceiptId, + GraphCoreNativeSupportedTarget, GraphCoreNativePackageName, } from "./release/graph-vocabulary-02.js"; +export { + releaseReceiptPackageNames, releaseReceiptBundledPackageNames, releaseReceiptCommandGroups, + releaseReceiptReportIds, releaseReceiptSecretFindingScopes, releaseCutoverRequiredCommandIds, + releaseCutoverRustCommandIds, releaseCutoverPythonCommandIds, releaseCutoverNegativeCheckIds, +} from "./release/vocabulary-01.js"; +export type { + ReleaseReceiptPackageName, ReleaseReceiptCommandGroupName, ReleaseReceiptReportId, + ReleaseReceiptSecretFindingScope, ReleaseCutoverCommandId, ReleaseCutoverRustCommandId, + ReleaseCutoverPythonCommandId, } from "./release/vocabulary-01.js"; +export { + releaseCutoverInputIssues, aspDogfoodUnsupportedSurfaceIds, aspDogfoodForbiddenProviderMarkers, +} from "./release/vocabulary-02.js"; +export type { + ReleaseCutoverNegativeCheckId, ReleaseCutoverInputIssue, AspDogfoodUnsupportedSurfaceId, + AspDogfoodForbiddenProviderMarker, } from "./release/vocabulary-02.js"; +export type { CommandExitSemantics, CommandGroupContract, CommandRouterManifest } from "./command/contracts.js"; +export { opcoreRuntimeArtifactSources } from "./product/status-contracts.js"; +export type { + OpcoreRepoStatePayload, OpcoreValidationPolicySummary, OpcoreRuntimeArtifactSource, OpcoreRuntimeInfoPayload, + OpcoreDoctorPayload, } from "./product/status-contracts.js"; +export { opcoreInitScopes } from "./product/init-contracts.js"; +export type { + OpcoreInitScope, OpcoreInitAction, OpcoreInitScanSummary, OpcoreInitLanguageSetting, + OpcoreInitPythonEnvironment, OpcoreInitSettings, OpcoreInitInteraction, OpcoreInitTiming, + OpcoreInitPlanPayload, } from "./product/init-contracts.js"; +export { + opcoreMeasureLatencyStatuses, opcoreMeasureLatencyFindingStatuses, } from "./product/metrics-contracts-01.js"; +export type { + OpcoreMetricEvidence, OpcoreMetricSignal, OpcoreMetricDegradation, OpcoreMetricReport, + OpcoreMetricHistoryEntry, OpcoreMeasureSignalCount, OpcoreMeasureSignalDelta, OpcoreMeasureLatencyStatus, + OpcoreMeasureLatencyFindingStatus, OpcoreMeasureLatencyPhase, OpcoreMeasureLatencyFinding, + OpcoreMeasureLatencyReport, OpcoreMeasureComparison, OpcoreMeasureDelta, +} from "./product/metrics-contracts-01.js"; +export type { + OpcoreTrySignalSummary, OpcoreTryScenario, OpcoreTryCommandSummary, OpcoreTryPayload, +} from "./product/metrics-contracts-02.js"; +export type { + CommandTimingPhase, CommandTiming, RepoShapeFingerprint, CommandLatencyRecord, LatencyPhaseBudget, + LatencyBudget, LatencyBudgetResult, } from "./product/latency-contracts.js"; +export type { + CommandRouterResult, ParsedCommandArgv, CommandRouterResultInput, CommandAdapterRequest, CommandAdapter, + CommandRouterWriter, RouteCommandAdapterOptions, RunCommandAdapterCliOptions, +} from "./command/router-contracts.js"; +export { commandExitSemantics, commandRouterManifest } from "./command/manifest.js"; +export type { + GraphReleaseCommandCoverage, GraphReleaseRustCommandCoverage, GraphReleaseDirectSqliteQueryReceipt, + GraphReleaseServeTransportReceipt, GraphReleaseBenchmarkReceipt, GraphReleasePackageInspection, + GraphReleaseNativeArtifactEvidence, GraphReleaseReportReceipt, GraphReleaseOptionalSurfaceReceipt, + GraphReleaseHandoffReceipt, GraphReleasePackageVersion, GraphReleaseReceipt, +} from "./release/graph-contracts.js"; +export type { + ReleaseReceiptTarballEvidence, ReleaseReceiptPackageManifestMetadata, ReleaseReceiptNativeArtifactEvidence, + ReleaseReceiptPackageEvidence, ReleaseReceiptDescriptorCommandGroupEvidence, + ReleaseReceiptResolvedArtifactEvidence, ReleaseReceiptResolvedChecksumEvidence, + ReleaseReceiptDescriptorEvidence, ReleaseReceiptLicensePackageEvidence, ReleaseReceiptLicenseEvidence, + ReleaseReceiptProvenanceFinding, ReleaseReceiptProvenanceEvidence, ReleaseReceiptSecretFinding, + ReleaseReceiptSecretHistoryEvidence, ReleaseReceiptReport, ReleaseReceiptGraphReleaseEvidence, +} from "./release/receipt-contracts-01.js"; +export type { ReleaseReceipt } from "./release/receipt-contracts-02.js"; +export type { + ReleaseCutoverTarballEvidence, ReleaseCutoverInstalledManifestEvidence, ReleaseCutoverInstalledFileEvidence, + ReleaseCutoverInstalledPackageEvidence, ReleaseCutoverDescriptorEvidence, + ReleaseCutoverEnvironmentIsolationEvidence, ReleaseCutoverCommandReceipt, ReleaseCutoverRustCommandReceipt, + ReleaseCutoverPythonCommandReceipt, ReleaseCutoverNegativeCheck, OpcoreSelfValidationReceipt, + ReleaseCutoverForbiddenMarkerScan, ReleaseCutoverInputEvidence, ReleaseCutoverReceipt, +} from "./release/cutover-contracts.js"; +export type { + AspDogfoodManagerEvidence, AspDogfoodAspHomeEvidence, AspDogfoodHostFixtureEvidence, + AspDogfoodCommandRunReceipt, AspDogfoodProviderManifestEvidence, AspDogfoodProviderEvidence, + AspDogfoodRepoEnrollmentEvidence, AspDogfoodManagerStateEvidence, AspDogfoodHostCheckEvidence, + AspDogfoodHostEvaluationEvidence, AspDogfoodProviderProbeEvidence, AspDogfoodUnsupportedSurfaceEvidence, + AspDogfoodParityBlocker, AspDogfoodAuthorityEvidence, AspDogfoodForbiddenMarkerScan, AspDogfoodReceipt, +} from "./release/asp-contracts-01.js"; +export { + parseCommandArgv, normalizeCommandBin, commandExitCodeForStatus, createCommandRouterResult, + routeCommandAdapter, runCommandAdapterCli, } from "./command/router-01.js"; +export { commandGroupByName } from "./command/router-02.js"; +export { validateCommandRouterManifest, validateManagedToolDescriptor } from "./managed/validators-01.js"; +export { validateCommandRouterResult } from "./command/validators.js"; +export { validateOpcoreRepoStatePayload } from "./product/status-validators.js"; +export { + validateOpcoreRuntimeInfoPayload, validateOpcoreDoctorPayload, validateOpcoreInitPlanPayload, +} from "./product/init-validators-01.js"; +export { + validateCommandTiming, validateRepoShapeFingerprint, validateCommandLatencyRecord, validateLatencyBudget, +} from "./product/metrics-validators-01.js"; +export { validateLatencyBudgetResult, validateOpcoreMetricReport } from "./product/metrics-validators-02.js"; +export { + validateOpcoreMetricHistoryEntry, validateOpcoreMeasureDelta, validateOpcoreTryPayload, +} from "./product/metrics-validators-03.js"; +export { validateCommandAdapterRequest } from "./command/adapter-validator.js"; +export { + validateGraphReleaseReceipt, validateReleaseReceipt, validateReleaseCutoverReceipt, + validateAspDogfoodReceipt, } from "./release/public-validators.js"; +export { + validateRepoRelativePath, validateHomeRelativePath, validateRepoIdentity, +} from "./shared/path-validators.js"; +export { + validateProviderStatus, validateGraphProviderCapabilityHandshake, +} from "./graph/provider-validators.js"; +export { validateGraphProviderArtifactMetadata } from "./graph/protocol-validators.js"; +export { + validateGraphFactQueryRequest, validateGraphFactQueryResult, validateGraphNamedQueryRequest, + validateGraphNamedQueryResult, validateGraphImpactRequest, validateGraphImpactResult, + validateGraphDetectChangesRequest, validateGraphDetectChangesResult, validateGraphReviewContextRequest, + validateGraphReviewContextResult, } from "./graph/query-validators.js"; +export { validateGraphSearchRequest, validateGraphSearchResult } from "./graph/search-validators.js"; +export { validateInspectRouteResult } from "./inspect/validators.js"; +export { + validateGraphDaemonRequest, validateGraphDaemonResponse, validateGraphPipelineResult, + validateGraphPipelineSummary, } from "./graph/daemon-validators-01.js"; +export { validateGraphServeTransportStatus } from "./graph/daemon-validators-02.js"; +export { validateGraphWatchLifecycle } from "./graph/protocol-validators.js"; +export { validateCloneAnalysisRequest, validateCloneAnalysisResult } from "./clone/validators.js"; +export { + validateValidationRequestPayload, validateValidationResultPayload, } from "./validation/result-validator.js"; +export { + validatePythonValidationCapabilityRun, validatePythonValidationCapabilityRuns, +} from "./validation/python-types-validators.js"; +export { + validatePythonProjectContext, validatePythonProjectContexts, +} from "./validation/python-project-validators-01.js"; +export { validateRequiredContextDocPolicy } from "./validation/python-project-validators-02.js"; +export { + validatePreWriteValidationReceipt, validateValidationStatusPayload, +} from "./validation/prewrite-status-validators-01.js"; +export { validateEditPlanPayload, validateEditCommandResult } from "./edit/validators.js"; diff --git a/packages/contracts/src/inspect/contracts-01.ts b/packages/contracts/src/inspect/contracts-01.ts new file mode 100644 index 0000000..602979c --- /dev/null +++ b/packages/contracts/src/inspect/contracts-01.ts @@ -0,0 +1,149 @@ +import type { GraphNodeKind } from "../graph/vocabulary-01.js"; + +interface InspectSymbolTarget { + kind: "node" | "file_symbol"; + nodeId?: string; + path?: string; + symbolName?: string; + line?: number; + column?: number; +} + +export type { InspectSymbolTarget }; + +type InspectReferenceTarget = InspectSymbolTarget; + +export type { InspectReferenceTarget }; + +interface InspectTextSpan { + startLine: number; + startColumn: number; + endLine: number; + endColumn: number; + startOffset?: number; + endOffset?: number; +} + +export type { InspectTextSpan }; + +type InspectReferenceSpan = InspectTextSpan; + +export type { InspectReferenceSpan }; + +interface InspectSymbolSummary { + id: string; + name: string; + kind?: GraphNodeKind; +} + +export type { InspectSymbolSummary }; + +interface InspectSymbolEvidence { + graphNodeIds: readonly string[]; + resolver: "graph" | "language_service"; +} + +export type { InspectSymbolEvidence }; + +interface InspectReferenceEntry { + file: string; + line: number; + column: number; + text: string; + span: InspectTextSpan; + symbol: InspectSymbolSummary; + isDefinition: boolean; + isDeclaration?: boolean; + evidence: InspectSymbolEvidence; +} + +export type { InspectReferenceEntry }; + +const inspectSignatureKinds = [ + "function", + "method", + "constructor", + "interface", + "type_alias", + "class", + "variable_function", +] as const; + +export { inspectSignatureKinds }; + +type InspectSignatureKind = (typeof inspectSignatureKinds)[number]; + +export type { InspectSignatureKind }; + +interface InspectSignatureParameter { + name: string; + type: string; + optional: boolean; + rest?: boolean; + defaultValue?: string; +} + +export type { InspectSignatureParameter }; + +interface InspectSignatureTypeParameter { + name: string; + constraint?: string; + default?: string; +} + +export type { InspectSignatureTypeParameter }; + +interface InspectSignatureEntry { + file: string; + line: number; + column: number; + text: string; + signature: string; + kind: InspectSignatureKind; + parameters: readonly InspectSignatureParameter[]; + typeParameters: readonly InspectSignatureTypeParameter[]; + exported: boolean; + async: boolean; + returnType?: string; + span: InspectTextSpan; + symbol: InspectSymbolSummary; + overloadIndex?: number; + evidence: InspectSymbolEvidence; +} + +export type { InspectSignatureEntry }; + +const inspectImplementationKinds = ["implements", "inherited_implements", "extends", "interface_extends"] as const; + +export { inspectImplementationKinds }; + +type InspectImplementationKind = (typeof inspectImplementationKinds)[number]; + +export type { InspectImplementationKind }; + +interface InspectImplementationEntry { + file: string; + line: number; + column: number; + text: string; + span: InspectTextSpan; + kind: InspectImplementationKind; + symbol: InspectSymbolSummary; + target: InspectSymbolSummary; + isDeclaration?: boolean; + evidence: InspectSymbolEvidence; +} + +export type { InspectImplementationEntry }; + +const inspectFailureCategories = [ + "graph_unavailable", + "target_ambiguous", + "target_not_found", + "unsupported_language", + "malformed_target", + "language_service_error", + "unsupported_route", +] as const; + +export { inspectFailureCategories }; diff --git a/packages/contracts/src/inspect/contracts-02.ts b/packages/contracts/src/inspect/contracts-02.ts new file mode 100644 index 0000000..af3847a --- /dev/null +++ b/packages/contracts/src/inspect/contracts-02.ts @@ -0,0 +1,71 @@ +import type { GraphProviderStatus } from "../graph/provider-contracts-02.js"; +import type { + InspectImplementationEntry, + InspectReferenceEntry, + InspectSignatureEntry, + InspectSymbolTarget, + inspectFailureCategories, +} from "./contracts-01.js"; + +type InspectFailureCategory = (typeof inspectFailureCategories)[number]; + +export type { InspectFailureCategory }; + +interface InspectRouteFailure { + category: InspectFailureCategory; + message: string; + candidates?: readonly InspectSymbolTarget[]; +} + +export type { InspectRouteFailure }; + +interface InspectReferenceResult { + route: "references"; + status: "ok" | "degraded"; + target: InspectSymbolTarget; + providerStatus: GraphProviderStatus; + failure?: InspectRouteFailure; + references: readonly InspectReferenceEntry[]; +} + +export type { InspectReferenceResult }; + +interface InspectSignatureResult { + route: "signature"; + status: "ok" | "degraded"; + target: InspectSymbolTarget; + providerStatus: GraphProviderStatus; + failure?: InspectRouteFailure; + signatures: readonly InspectSignatureEntry[]; +} + +export type { InspectSignatureResult }; + +interface InspectImplementationResult { + route: "implementations"; + status: "ok" | "degraded"; + target: InspectSymbolTarget; + providerStatus: GraphProviderStatus; + failure?: InspectRouteFailure; + implementations: readonly InspectImplementationEntry[]; +} + +export type { InspectImplementationResult }; + +interface InspectRouteErrorResult { + route: "references" | "signature" | "implementations"; + status: "error" | "degraded"; + target?: InspectSymbolTarget; + providerStatus?: GraphProviderStatus; + failure: InspectRouteFailure; +} + +export type { InspectRouteErrorResult }; + +type InspectRouteResult = + | InspectReferenceResult + | InspectSignatureResult + | InspectImplementationResult + | InspectRouteErrorResult; + +export type { InspectRouteResult }; diff --git a/packages/contracts/src/inspect/helper-validators-01.ts b/packages/contracts/src/inspect/helper-validators-01.ts new file mode 100644 index 0000000..3e1c5c2 --- /dev/null +++ b/packages/contracts/src/inspect/helper-validators-01.ts @@ -0,0 +1,263 @@ +import { includesString } from "../shared/primitives.js"; +import { validateRepoRelativePath } from "../shared/path-validators.js"; +import { + validateNonEmptyString, + validateNonNegativeInteger, + validatePositiveInteger, + validateStringArray, +} from "../shared/validators-01.js"; +import { + validateBoolean, + validateOptional, + validateRequiredObject, +} from "../shared/validators-02.js"; +import type { + InspectImplementationEntry, + InspectReferenceEntry, + InspectSignatureEntry, + InspectSignatureParameter, + InspectSignatureTypeParameter, + InspectSymbolEvidence, + InspectSymbolSummary, + InspectSymbolTarget, + InspectTextSpan} from "./contracts-01.js"; +import { + inspectImplementationKinds, + inspectSignatureKinds, +} from "./contracts-01.js"; +import type { InspectRouteResult } from "./contracts-02.js"; + +function validateInspectRouteName(route: unknown): InspectRouteResult["route"] { + if (!includesString(["references", "signature", "implementations"] as const, route)) { + throw new Error(`Unknown inspect route result route: ${String(route)}`); + } + return route; +} + +export { validateInspectRouteName }; + +function validateInspectRoutePayload(result: InspectRouteResult, route: InspectRouteResult["route"]): void { + validateInspectRoutePayloadFields(result, route); + if (route === "references") { + return validateInspectReferencesPayload("references" in result ? result.references : undefined); + } + if (route === "signature") { + return validateInspectSignaturesPayload("signatures" in result ? result.signatures : undefined); + } + validateInspectImplementationsPayload("implementations" in result ? result.implementations : undefined); +} + +export { validateInspectRoutePayload }; + +function validateInspectRoutePayloadFields(result: InspectRouteResult, route: InspectRouteResult["route"]): void { + const payloadFields = ["references", "signatures", "implementations"] as const; + const expectedFields = { + references: "references", + signature: "signatures", + implementations: "implementations", + } as const; + const expectedField = expectedFields[route]; + for (const field of payloadFields) { + if (field !== expectedField && Object.hasOwn(result, field)) { + throw new Error(`Inspect ${route} result must not include ${field}`); + } + } +} + +function validateInspectReferencesPayload(references: readonly InspectReferenceEntry[] | undefined): void { + if (!Array.isArray(references)) throw new Error("Inspect references result references must be an array"); + for (const reference of references) validateInspectReferenceEntry(reference); +} + +function validateInspectSignaturesPayload(signatures: readonly InspectSignatureEntry[] | undefined): void { + if (!Array.isArray(signatures)) throw new Error("Inspect signature result signatures must be an array"); + for (const signature of signatures) validateInspectSignatureEntry(signature); +} + +function validateInspectImplementationsPayload( + implementations: readonly InspectImplementationEntry[] | undefined, +): void { + if (!Array.isArray(implementations)) + throw new Error("Inspect implementations result implementations must be an array"); + for (const implementation of implementations) validateInspectImplementationEntry(implementation); +} + +function validateInspectSymbolTarget(target: InspectSymbolTarget, label: string): InspectSymbolTarget { + validateRequiredObject(target, `${label} is required`); + if (!includesString(["node", "file_symbol"] as const, target.kind)) { + throw new Error(`Unknown ${label} kind: ${String((target as { kind?: unknown }).kind)}`); + } + if (target.kind === "node") validateInspectNodeTarget(target, label); + else validateInspectFileSymbolTarget(target, label); + return target; +} + +export { validateInspectSymbolTarget }; + +function validateInspectNodeTarget(target: InspectSymbolTarget, label: string): void { + validateNonEmptyString(target.nodeId, `${label} nodeId`); + const fileSymbolFields = [target.path, target.symbolName, target.line, target.column]; + if (fileSymbolFields.some((value) => value !== undefined)) { + throw new Error(`${label} node target must not include file-symbol fields`); + } +} + +function validateInspectFileSymbolTarget(target: InspectSymbolTarget, label: string): void { + validateRepoRelativePath(validateNonEmptyString(target.path, `${label} path`)); + validateNonEmptyString(target.symbolName, `${label} symbolName`); + validateOptional(target.line, (value) => validatePositiveInteger(value, `${label} line`)); + validateOptional(target.column, (value) => validatePositiveInteger(value, `${label} column`)); + validateOptional(target.nodeId, (value) => validateNonEmptyString(value, `${label} nodeId`)); +} + +const validateInspectReferenceTarget = validateInspectSymbolTarget; + +export { validateInspectReferenceTarget }; + +function validateInspectReferenceEntry(entry: InspectReferenceEntry): InspectReferenceEntry { + if (!entry || typeof entry !== "object") throw new Error("Inspect reference entry is required"); + validateRepoRelativePath(entry.file); + validatePositiveInteger(entry.line, "Inspect reference entry line"); + validatePositiveInteger(entry.column, "Inspect reference entry column"); + validateNonEmptyString(entry.text, "Inspect reference entry text"); + validateInspectTextSpan(entry.span, "Inspect reference span"); + validateInspectSymbolSummary(entry.symbol, "Inspect reference entry symbol"); + if (typeof entry.isDefinition !== "boolean") throw new Error("Inspect reference entry isDefinition must be boolean"); + if (entry.isDeclaration !== undefined && typeof entry.isDeclaration !== "boolean") { + throw new Error("Inspect reference entry isDeclaration must be boolean"); + } + validateInspectSymbolEvidence(entry.evidence, "Inspect reference entry evidence"); + return entry; +} + +export { validateInspectReferenceEntry }; + +function validateInspectSymbolEvidence(evidence: InspectSymbolEvidence, label: string): InspectSymbolEvidence { + if (!evidence || typeof evidence !== "object") throw new Error(`${label} is required`); + validateStringArray(evidence.graphNodeIds, `${label} graphNodeIds`, { + allowEmpty: true, + }); + if (!includesString(["graph", "language_service"] as const, evidence.resolver)) { + throw new Error(`Unknown ${label} resolver: ${String(evidence.resolver)}`); + } + return evidence; +} + +export { validateInspectSymbolEvidence }; + +function validateInspectSignatureEntry(entry: InspectSignatureEntry): InspectSignatureEntry { + validateRequiredObject(entry, "Inspect signature entry is required"); + validateRepoRelativePath(entry.file); + validatePositiveInteger(entry.line, "Inspect signature entry line"); + validatePositiveInteger(entry.column, "Inspect signature entry column"); + validateNonEmptyString(entry.text, "Inspect signature entry text"); + validateNonEmptyString(entry.signature, "Inspect signature entry signature"); + if (!includesString(inspectSignatureKinds, entry.kind)) { + throw new Error(`Unknown inspect signature entry kind: ${String(entry.kind)}`); + } + validateInspectSignatureParameters(entry); + validateBoolean(entry.exported, "Inspect signature entry exported"); + validateBoolean(entry.async, "Inspect signature entry async"); + validateOptional(entry.returnType, (value) => + validateNonEmptyString(value, "Inspect signature entry returnType"), + ); + validateInspectTextSpan(entry.span, "Inspect signature span"); + validateInspectSymbolSummary(entry.symbol, "Inspect signature entry symbol"); + validateOptional(entry.overloadIndex, (value) => + validateNonNegativeInteger(value, "Inspect signature entry overloadIndex"), + ); + validateInspectSymbolEvidence(entry.evidence, "Inspect signature entry evidence"); + return entry; +} + +export { validateInspectSignatureEntry }; + +function validateInspectSignatureParameters(entry: InspectSignatureEntry): void { + if (!Array.isArray(entry.parameters)) throw new Error("Inspect signature entry parameters must be an array"); + for (const parameter of entry.parameters) validateInspectSignatureParameter(parameter); + if (!Array.isArray(entry.typeParameters)) { + throw new Error("Inspect signature entry typeParameters must be an array"); + } + for (const typeParameter of entry.typeParameters) validateInspectSignatureTypeParameter(typeParameter); +} + +function validateInspectSignatureParameter(parameter: InspectSignatureParameter): InspectSignatureParameter { + if (!parameter || typeof parameter !== "object") throw new Error("Inspect signature parameter is required"); + validateNonEmptyString(parameter.name, "Inspect signature parameter name"); + validateNonEmptyString(parameter.type, "Inspect signature parameter type"); + if (typeof parameter.optional !== "boolean") throw new Error("Inspect signature parameter optional must be boolean"); + if (parameter.rest !== undefined && typeof parameter.rest !== "boolean") { + throw new Error("Inspect signature parameter rest must be boolean"); + } + if (parameter.defaultValue !== undefined) + validateNonEmptyString(parameter.defaultValue, "Inspect signature parameter defaultValue"); + return parameter; +} + +export { validateInspectSignatureParameter }; + +function validateInspectSignatureTypeParameter( + typeParameter: InspectSignatureTypeParameter, +): InspectSignatureTypeParameter { + if (!typeParameter || typeof typeParameter !== "object") + throw new Error("Inspect signature typeParameter is required"); + validateNonEmptyString(typeParameter.name, "Inspect signature typeParameter name"); + if (typeParameter.constraint !== undefined) { + validateNonEmptyString(typeParameter.constraint, "Inspect signature typeParameter constraint"); + } + if (typeParameter.default !== undefined) { + validateNonEmptyString(typeParameter.default, "Inspect signature typeParameter default"); + } + return typeParameter; +} + +export { validateInspectSignatureTypeParameter }; + +function validateInspectImplementationEntry(entry: InspectImplementationEntry): InspectImplementationEntry { + if (!entry || typeof entry !== "object") throw new Error("Inspect implementation entry is required"); + validateRepoRelativePath(entry.file); + validatePositiveInteger(entry.line, "Inspect implementation entry line"); + validatePositiveInteger(entry.column, "Inspect implementation entry column"); + validateNonEmptyString(entry.text, "Inspect implementation entry text"); + validateInspectTextSpan(entry.span, "Inspect implementation span"); + if (Object.hasOwn(entry, "implements")) + throw new Error("Inspect implementation entry must use target, not implements"); + if (!includesString(inspectImplementationKinds, entry.kind)) { + throw new Error(`Unknown Inspect implementation entry kind: ${String(entry.kind)}`); + } + validateInspectSymbolSummary(entry.symbol, "Inspect implementation entry symbol"); + validateInspectSymbolSummary(entry.target, "Inspect implementation entry target"); + if (entry.isDeclaration !== undefined && typeof entry.isDeclaration !== "boolean") { + throw new Error("Inspect implementation entry isDeclaration must be boolean"); + } + validateInspectSymbolEvidence(entry.evidence, "Inspect implementation entry evidence"); + return entry; +} + +export { validateInspectImplementationEntry }; + +function validateInspectTextSpan(span: InspectTextSpan, label: string): InspectTextSpan { + if (!span || typeof span !== "object") throw new Error(`${label} is required`); + validatePositiveInteger(span.startLine, `${label} startLine`); + validatePositiveInteger(span.startColumn, `${label} startColumn`); + validatePositiveInteger(span.endLine, `${label} endLine`); + validatePositiveInteger(span.endColumn, `${label} endColumn`); + if (span.endLine < span.startLine || (span.endLine === span.startLine && span.endColumn < span.startColumn)) { + throw new Error(`${label} end must be after start`); + } + if (span.startOffset !== undefined) validateNonNegativeInteger(span.startOffset, `${label} startOffset`); + if (span.endOffset !== undefined) validateNonNegativeInteger(span.endOffset, `${label} endOffset`); + return span; +} + +export { validateInspectTextSpan }; + +function validateInspectSymbolSummary(symbol: InspectSymbolSummary, label: string): InspectSymbolSummary { + if (!symbol || typeof symbol !== "object") throw new Error(`${label} is required`); + validateNonEmptyString(symbol.id, `${label} id`); + validateNonEmptyString(symbol.name, `${label} name`); + if (symbol.kind !== undefined) validateNonEmptyString(symbol.kind, `${label} kind`); + return symbol; +} + +export { validateInspectSymbolSummary }; diff --git a/packages/contracts/src/inspect/helper-validators-02.ts b/packages/contracts/src/inspect/helper-validators-02.ts new file mode 100644 index 0000000..a58c30f --- /dev/null +++ b/packages/contracts/src/inspect/helper-validators-02.ts @@ -0,0 +1,21 @@ +import { includesString } from "../shared/primitives.js"; +import { validateNonEmptyString } from "../shared/validators-01.js"; +import { inspectFailureCategories } from "./contracts-01.js"; +import type { InspectRouteFailure } from "./contracts-02.js"; +import { validateInspectSymbolTarget } from "./helper-validators-01.js"; + +function validateInspectRouteFailure(failure: InspectRouteFailure): InspectRouteFailure { + if (!failure || typeof failure !== "object") throw new Error("Inspect route failure is required"); + if (!includesString(inspectFailureCategories, failure.category)) { + throw new Error(`Unknown inspect route failure category: ${String(failure.category)}`); + } + validateNonEmptyString(failure.message, "Inspect route failure message"); + if (failure.candidates !== undefined) { + if (!Array.isArray(failure.candidates)) throw new Error("Inspect route failure candidates must be an array"); + for (const candidate of failure.candidates) + validateInspectSymbolTarget(candidate, "Inspect route failure candidate"); + } + return failure; +} + +export { validateInspectRouteFailure }; diff --git a/packages/contracts/src/inspect/validators.ts b/packages/contracts/src/inspect/validators.ts new file mode 100644 index 0000000..5bdec23 --- /dev/null +++ b/packages/contracts/src/inspect/validators.ts @@ -0,0 +1,72 @@ +import { includesString } from "../shared/primitives.js"; +import { validateProviderStatus } from "../graph/provider-validators.js"; +import { validateOptional, validateRequiredObject } from "../shared/validators-02.js"; +import type { InspectRouteResult } from "./contracts-02.js"; +import { + validateInspectRouteName, + validateInspectRoutePayload, + validateInspectSymbolTarget, +} from "./helper-validators-01.js"; +import { validateInspectRouteFailure } from "./helper-validators-02.js"; + +function validateInspectRouteResult(result: InspectRouteResult): InspectRouteResult { + validateRequiredObject(result, "Inspect route result is required"); + const route = validateInspectRouteName((result as { route?: unknown }).route); + if (!includesString(["ok", "error", "degraded"] as const, result.status)) { + throw new Error(`Unknown inspect route result status: ${String((result as { status?: unknown }).status)}`); + } + validateOptional(result.providerStatus, validateProviderStatus); + if (result.status === "ok") validateSuccessfulInspectResult(result, route); + else if (result.status === "degraded" && inspectResultHasPayload(result, route)) + validateDegradedInspectResult(result, route); + else validateFailedInspectResult(result, route); + return result; +} + +export { validateInspectRouteResult }; + +function validateSuccessfulInspectResult(result: InspectRouteResult, route: InspectRouteResult["route"]): void { + const target = result.target; + if (target === undefined) throw new Error(`Successful inspect ${route} result requires target`); + validateInspectSymbolTarget(target, `Inspect ${route} target`); + if (result.providerStatus === undefined || result.providerStatus.state !== "available") { + throw new Error(`Successful inspect ${route} result requires available providerStatus`); + } + validateInspectRoutePayload(result, route); + if (Object.hasOwn(result, "failure")) { + throw new Error(`Successful inspect ${route} result must not include failure`); + } +} + +function validateDegradedInspectResult(result: InspectRouteResult, route: InspectRouteResult["route"]): void { + const target = result.target; + if (target === undefined) throw new Error(`Degraded inspect ${route} result requires target`); + validateInspectSymbolTarget(target, `Inspect ${route} target`); + if (result.providerStatus === undefined) { + throw new Error(`Degraded inspect ${route} result requires providerStatus`); + } + validateInspectRoutePayload(result, route); + const failure = result.failure; + if (failure === undefined) throw new Error(`Degraded inspect ${route} result requires failure`); + validateInspectRouteFailure(failure); +} + +function validateFailedInspectResult(result: InspectRouteResult, route: InspectRouteResult["route"]): void { + validateOptional(result.target, (target) => validateInspectSymbolTarget(target, `Inspect ${route} target`)); + const failure = result.failure; + if (failure === undefined) throw new Error(`Failed inspect ${route} result requires failure`); + validateInspectRouteFailure(failure); + for (const field of ["references", "signatures", "implementations"] as const) { + if (Object.hasOwn(result, field)) throw new Error(`Failed inspect ${route} result must not include ${field}`); + } +} + +function inspectResultHasPayload(result: InspectRouteResult, route: InspectRouteResult["route"]): boolean { + return ( + (route === "references" && Object.hasOwn(result, "references")) || + (route === "signature" && Object.hasOwn(result, "signatures")) || + (route === "implementations" && Object.hasOwn(result, "implementations")) + ); +} + +export { inspectResultHasPayload }; diff --git a/packages/contracts/src/inspect/warm-contracts.ts b/packages/contracts/src/inspect/warm-contracts.ts new file mode 100644 index 0000000..b06e760 --- /dev/null +++ b/packages/contracts/src/inspect/warm-contracts.ts @@ -0,0 +1,115 @@ +import type { EditRefusal, RepoRelativeChange } from "../edit/contracts.js"; +import type { CommandTiming } from "../product/latency-contracts.js"; +import type { InspectReferenceEntry, InspectReferenceTarget } from "./contracts-01.js"; +import type { InspectRouteFailure } from "./contracts-02.js"; + +const aspWarmMethodNames = ["inspect/references", "edit/rename", "check/evaluate", "session/shutdown"] as const; + +export { aspWarmMethodNames }; + +type AspWarmMethodName = (typeof aspWarmMethodNames)[number]; + +export type { AspWarmMethodName }; + +interface AspWarmProviderSummary { + id: "opcore"; + capabilityFamily: "inspect" | "edit" | "session"; +} + +export type { AspWarmProviderSummary }; + +interface AspWarmInspectReferencesParams { + path: string; + symbolName: string; + line?: number; + column?: number; + limit?: number; +} + +export type { AspWarmInspectReferencesParams }; + +interface AspWarmInspectReferencesOkResult { + route: "references"; + status: "ok"; + target: InspectReferenceTarget; + references: readonly InspectReferenceEntry[]; +} + +export type { AspWarmInspectReferencesOkResult }; + +interface AspWarmInspectReferencesErrorResult { + route: "references"; + status: "error"; + target?: InspectReferenceTarget; + failure: InspectRouteFailure; +} + +export type { AspWarmInspectReferencesErrorResult }; + +interface AspWarmInspectReferencesResponse { + provider: AspWarmProviderSummary; + inspectResult: AspWarmInspectReferencesOkResult | AspWarmInspectReferencesErrorResult; + timing: CommandTiming; +} + +export type { AspWarmInspectReferencesResponse }; + +interface SymbolEditTarget { + path: string; + name: string; + line?: number; + column?: number; + nodeId?: string; +} + +export type { SymbolEditTarget }; + +interface AspWarmEditRenameParams { + target: SymbolEditTarget; + newName: string; +} + +export type { AspWarmEditRenameParams }; + +interface AspWarmAffectedChecksum { + path: string; + checksumBefore?: string; + checksumAfter?: string; +} + +export type { AspWarmAffectedChecksum }; + +interface AspWarmEditRenamePreviewResult { + route: "rename"; + status: "preview"; + changes: readonly RepoRelativeChange[]; + affectedChecksums: readonly AspWarmAffectedChecksum[]; +} + +export type { AspWarmEditRenamePreviewResult }; + +interface AspWarmEditRenameRefusedResult { + route: "rename"; + status: "refused"; + refusal: EditRefusal; +} + +export type { AspWarmEditRenameRefusedResult }; + +interface AspWarmEditRenameResponse { + provider: AspWarmProviderSummary; + editResult: AspWarmEditRenamePreviewResult | AspWarmEditRenameRefusedResult; + timing: CommandTiming; +} + +export type { AspWarmEditRenameResponse }; + +interface AspWarmSessionShutdownResponse { + provider: AspWarmProviderSummary; + session: { + state: "shutdown"; + }; + timing: CommandTiming; +} + +export type { AspWarmSessionShutdownResponse }; diff --git a/packages/contracts/src/managed/contracts.ts b/packages/contracts/src/managed/contracts.ts new file mode 100644 index 0000000..b3ce395 --- /dev/null +++ b/packages/contracts/src/managed/contracts.ts @@ -0,0 +1,198 @@ +import type { GraphProviderMode } from "../graph/vocabulary-01.js"; +import type { OpcoreInitScope } from "../product/init-contracts.js"; +import type { GraphReleaseOptionalSurfaceReceipt } from "../release/graph-contracts.js"; +import type { GraphCoreNativePackageName, GraphCoreNativeSupportedTarget } from "../release/graph-vocabulary-02.js"; +import type { + PYTHON_PROJECT_CONTEXT_SCHEMA_ID, + PythonProjectContextOutcome, +} from "../validation/python-project-contracts-01.js"; +import type { ValidationScopeKind } from "../validation/request-contracts.js"; + +const managedToolDescriptorCommandGroups = [ + "graph", + "inspect", + "edit", + "check", + "validate", + "status", + "doctor", +] as const; + +export { managedToolDescriptorCommandGroups }; + +type ManagedToolDescriptorCommandGroupName = (typeof managedToolDescriptorCommandGroups)[number]; + +export type { ManagedToolDescriptorCommandGroupName }; + +const managedToolDescriptorCommandGroupPackageNames: Record = { + graph: "opcore", + inspect: "opcore", + edit: "opcore", + check: "opcore", + validate: "opcore", + status: "opcore", + doctor: "opcore", +}; + +export { managedToolDescriptorCommandGroupPackageNames }; + +const managedToolDescriptorArtifactTypes = [ + "entrypoint", + "descriptor", + "schema", + "manifest", + "native_binary", + "checksum", + "receipt", +] as const; + +export { managedToolDescriptorArtifactTypes }; + +type ManagedToolDescriptorArtifactType = (typeof managedToolDescriptorArtifactTypes)[number]; + +export type { ManagedToolDescriptorArtifactType }; + +interface ManagedToolDescriptor { + schemaVersion: 1; + descriptorKind: "aggregate_opcore"; + aggregateIdentity: { + name: "opcore"; + releaseLine: "opcore"; + packageName: "opcore"; + version?: string; + }; + packageIdentity: { + packageName: "opcore"; + artifactName: "opcore"; + version?: string; + }; + entrypoints: readonly ManagedToolDescriptorEntrypoint[]; + commandGroups: readonly ManagedToolDescriptorCommandGroup[]; + healthProbes: readonly ManagedToolDescriptorHealthProbe[]; + capabilities: ManagedToolDescriptorCapabilities; + artifacts: readonly ManagedToolDescriptorArtifactReference[]; + checksums: readonly ManagedToolDescriptorChecksumReference[]; + provenanceHooks: readonly ManagedToolDescriptorProvenanceHook[]; + optionalSurfaces: readonly GraphReleaseOptionalSurfaceReceipt[]; +} + +export type { ManagedToolDescriptor }; + +interface ManagedToolDescriptorEntrypoint { + bin: "opcore"; + packageName: "opcore"; + path: string; + command: readonly string[]; +} + +export type { ManagedToolDescriptorEntrypoint }; + +interface ManagedToolDescriptorCommandGroup { + name: ManagedToolDescriptorCommandGroupName; + canonicalCommand: readonly string[]; + commands: readonly string[]; + packageName: string; +} + +export type { ManagedToolDescriptorCommandGroup }; + +interface ManagedToolDescriptorHealthProbe { + id: string; + command: readonly string[]; + expectedExitCode: 0; + output: "json"; +} + +export type { ManagedToolDescriptorHealthProbe }; + +interface ManagedToolDescriptorCapabilities { + graph: { + provider: "opcore-graph"; + schemaVersion: 1; + commands: readonly string[]; + queryKinds: readonly string[]; + daemonOperations: readonly string[]; + nativeArtifacts: readonly ManagedToolDescriptorNativeArtifact[]; + }; + edit: { + commands: readonly string[]; + safeEditModes: readonly string[]; + symbolEditModes: readonly string[]; + validationRequiredForApply: true; + dryRun: true; + }; + validation: { + checkRoutes: readonly string[]; + validateRoutes: readonly string[]; + scopeModes: readonly ValidationScopeKind[]; + graphModes: readonly GraphProviderMode[]; + hypothetical: true; + statusSurfaces: readonly ("status" | "doctor")[]; + pythonProjectContext: { + schemaId: typeof PYTHON_PROJECT_CONTEXT_SCHEMA_ID; + outcomes: readonly PythonProjectContextOutcome[]; + readOnly: true; + installs: false; + }; + writeGate: { + initScopes: readonly OpcoreInitScope[]; + harnesses: readonly ("claude-code" | "codex")[]; + adapterPath: string; + validationCommand: readonly string[]; + adapterErrorPolicy: "fail_open"; + validationErrorPolicy: "fail_closed"; + codexBoundary: "pretooluse_guardrail"; + }; + checkIds: readonly string[]; + }; +} + +export type { ManagedToolDescriptorCapabilities }; + +interface ManagedToolDescriptorNativeArtifact { + targetPlatform: GraphCoreNativeSupportedTarget; + packageName: "opcore"; + bundledPackageName: GraphCoreNativePackageName; + binaryPath: string; + metadataPath: string; + checksumPath: string; + artifactIds: { + binaryArtifactId: string; + metadataArtifactId: string; + checksumId: string; + checksumArtifactId: string; + }; +} + +export type { ManagedToolDescriptorNativeArtifact }; + +interface ManagedToolDescriptorArtifactReference { + id: string; + packageName: string; + path: string; + type: ManagedToolDescriptorArtifactType; + required: boolean; + checksumRef?: string; +} + +export type { ManagedToolDescriptorArtifactReference }; + +interface ManagedToolDescriptorChecksumReference { + id: string; + packageName: string; + path: string; + algorithm: "sha256"; + artifactRef: string; + required: boolean; + value?: string; +} + +export type { ManagedToolDescriptorChecksumReference }; + +interface ManagedToolDescriptorProvenanceHook { + id: string; + command: readonly string[]; + expectedExitCode: 0; +} + +export type { ManagedToolDescriptorProvenanceHook }; diff --git a/packages/contracts/src/managed/helper-validators.ts b/packages/contracts/src/managed/helper-validators.ts new file mode 100644 index 0000000..9ee3877 --- /dev/null +++ b/packages/contracts/src/managed/helper-validators.ts @@ -0,0 +1,105 @@ +import { includesString } from "../shared/primitives.js"; +import type { + GraphCoreNativePackageName, + GraphCoreNativeSupportedTarget} from "../release/graph-vocabulary-02.js"; +import { + graphCoreNativePackageNames, + graphCoreNativePackageNamesByTarget, + graphCoreNativeSupportedTargets, +} from "../release/graph-vocabulary-02.js"; +import type { + ReleaseReceiptCommandGroupName, + ReleaseReceiptPackageName, + ReleaseReceiptReportId} from "../release/vocabulary-01.js"; +import { + releaseReceiptCommandGroups, + releaseReceiptPackageNames, + releaseReceiptReportIds, +} from "../release/vocabulary-01.js"; +import { validateRepoRelativePath } from "../shared/path-validators.js"; +import { validateNonEmptyString } from "../shared/validators-01.js"; +import type { ManagedToolDescriptorArtifactReference} from "./contracts.js"; +import { managedToolDescriptorArtifactTypes } from "./contracts.js"; + +function validateManagedToolDescriptorPackageReference( + reference: ManagedToolDescriptorArtifactReference, + packageName: ReleaseReceiptPackageName, +): void { + if (!reference || typeof reference !== "object") throw new Error("Release receipt descriptor reference is required"); + validateNonEmptyString(reference.id, "Release receipt descriptor reference id"); + if (reference.packageName !== packageName) { + throw new Error(`Release receipt descriptor reference packageName must be ${packageName}`); + } + validateRepoRelativePath(reference.path); + if (!includesString(managedToolDescriptorArtifactTypes, reference.type)) { + throw new Error(`Unknown release receipt descriptor reference type: ${String(reference.type)}`); + } + if (typeof reference.required !== "boolean") + throw new Error("Release receipt descriptor reference required must be boolean"); + if (reference.checksumRef !== undefined) + validateNonEmptyString(reference.checksumRef, "Release receipt descriptor reference checksumRef"); +} + +export { validateManagedToolDescriptorPackageReference }; + +function validateReleaseReceiptPackageName(value: unknown, label: string): ReleaseReceiptPackageName { + if (!includesString(releaseReceiptPackageNames, value)) { + throw new Error(`${label} must be one of ${releaseReceiptPackageNames.join(", ")}`); + } + return value; +} + +export { validateReleaseReceiptPackageName }; + +function isGraphCoreNativePackageName(value: unknown): value is GraphCoreNativePackageName { + return includesString(graphCoreNativePackageNames, value); +} + +export { isGraphCoreNativePackageName }; + +function bundledGraphCoreNativePath( + packageName: GraphCoreNativePackageName, + file: "metadata.json" | "opcore-graph-core" | "opcore-graph-core.sha256", +): string { + return `node_modules/${packageName}/${file}`; +} + +export { bundledGraphCoreNativePath }; + +function graphCoreNativeTargetForPackageName(packageName: GraphCoreNativePackageName): GraphCoreNativeSupportedTarget { + const target = graphCoreNativeSupportedTargets.find( + (entry) => graphCoreNativePackageNamesByTarget[entry] === packageName, + ); + if (!target) throw new Error(`Unknown Opcore graph-core native package: ${packageName}`); + return target; +} + +export { graphCoreNativeTargetForPackageName }; + +function validateReleaseReceiptCommandGroupName(value: unknown, label: string): ReleaseReceiptCommandGroupName { + if (!includesString(releaseReceiptCommandGroups, value)) { + throw new Error(`${label} must be one of ${releaseReceiptCommandGroups.join(", ")}`); + } + return value; +} + +export { validateReleaseReceiptCommandGroupName }; + +function validateReleaseReceiptReportId(value: unknown, label: string): ReleaseReceiptReportId { + if (!includesString(releaseReceiptReportIds, value)) { + throw new Error(`${label} must be one of ${releaseReceiptReportIds.join(", ")}`); + } + return value; +} + +export { validateReleaseReceiptReportId }; + +function validateStringRecord(value: Readonly>, label: string): void { + if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`${label} must be an object`); + for (const [key, recordValue] of Object.entries(value)) { + validateNonEmptyString(key, `${label} key`); + validateNonEmptyString(recordValue, `${label}.${key}`); + } +} + +export { validateStringRecord }; diff --git a/packages/contracts/src/managed/validators-01.ts b/packages/contracts/src/managed/validators-01.ts new file mode 100644 index 0000000..701ae27 --- /dev/null +++ b/packages/contracts/src/managed/validators-01.ts @@ -0,0 +1,198 @@ +import { validateRequiredObject } from "../shared/validators-02.js"; +import type { CommandRouterManifest } from "../command/contracts.js"; +import { + validateCommandRouterManifestHeader, + validateManifestBins, + validateManifestGroups, + validateManifestOwnershipBoundaries, +} from "../command/validators.js"; +import { includesString, sameStringArray } from "../shared/primitives.js"; +import { validateGraphReleaseOptionalSurfaces } from "../release/graph-optional-validators.js"; +import { + validateCommandExitSemantics, + validateExactStringSequence, + validateExactStringSet, + validateNonEmptyArray, + validateNonEmptyString, + validateStringArray, +} from "../shared/validators-01.js"; +import type { + ManagedToolDescriptor, + ManagedToolDescriptorCapabilities, + ManagedToolDescriptorCommandGroup, + ManagedToolDescriptorEntrypoint, + ManagedToolDescriptorHealthProbe} from "./contracts.js"; +import { + managedToolDescriptorCommandGroupPackageNames, + managedToolDescriptorCommandGroups, +} from "./contracts.js"; +import { + validateManagedToolEditCapabilities, + validateManagedToolGraphCapabilities, + validateManagedToolValidationCapabilities, +} from "./validators-02.js"; +import { + validateManagedToolArtifacts, + validateManagedToolChecksums, + validateManagedToolCommandTokens, + validateManagedToolDescriptorForbiddenStrings, + validateManagedToolPackagePath, + validateManagedToolProvenanceHooks, +} from "./validators-03.js"; + +function validateCommandRouterManifest(manifest: CommandRouterManifest): CommandRouterManifest { + validateCommandRouterManifestHeader(manifest); + validateManifestBins(manifest.bins); + validateCommandExitSemantics(manifest.exitSemantics); + + validateManifestGroups(manifest.commandGroups); + validateManifestOwnershipBoundaries(manifest.ownershipBoundaries); + + return manifest; +} + +export { validateCommandRouterManifest }; + +function validateManagedToolDescriptor(descriptor: ManagedToolDescriptor): ManagedToolDescriptor { + validateRequiredObject(descriptor, "Managed tool descriptor is required"); + if (descriptor.schemaVersion !== 1) throw new Error("Managed tool descriptor schemaVersion must be 1"); + if (descriptor.descriptorKind !== "aggregate_opcore") { + throw new Error("Managed tool descriptor descriptorKind must be aggregate_opcore"); + } + validateManagedToolIdentity(descriptor); + validateManagedToolEntrypoints(descriptor.entrypoints); + validateManagedToolCommandGroups(descriptor.commandGroups); + validateManagedToolHealthProbes(descriptor.healthProbes); + validateManagedToolCapabilities(descriptor.capabilities); + const artifactReferences = validateManagedToolArtifacts(descriptor.artifacts); + validateManagedToolChecksums(descriptor.checksums, artifactReferences); + validateManagedToolProvenanceHooks(descriptor.provenanceHooks); + validateGraphReleaseOptionalSurfaces(descriptor.optionalSurfaces); + validateManagedToolDescriptorForbiddenStrings(descriptor); + return descriptor; +} + +export { validateManagedToolDescriptor }; + +function validateManagedToolIdentity(descriptor: ManagedToolDescriptor): void { + const aggregate = descriptor.aggregateIdentity; + if (!aggregate || typeof aggregate !== "object") + throw new Error("Managed tool descriptor aggregateIdentity is required"); + if (aggregate.name !== "opcore") throw new Error("Managed tool descriptor aggregateIdentity.name must be opcore"); + if (aggregate.releaseLine !== "opcore") + throw new Error("Managed tool descriptor aggregateIdentity.releaseLine must be opcore"); + if (aggregate.packageName !== "opcore") { + throw new Error("Managed tool descriptor aggregateIdentity.packageName must be opcore"); + } + if (aggregate.version !== undefined) + validateNonEmptyString(aggregate.version, "Managed tool descriptor aggregateIdentity.version"); + + const packageIdentity = descriptor.packageIdentity; + validateRequiredObject(packageIdentity, "Managed tool descriptor packageIdentity is required"); + if (packageIdentity.packageName !== "opcore") { + throw new Error("Managed tool descriptor packageIdentity.packageName must be opcore"); + } + if (packageIdentity.artifactName !== "opcore") { + throw new Error("Managed tool descriptor packageIdentity.artifactName must be opcore"); + } + if (packageIdentity.version !== undefined) + validateNonEmptyString(packageIdentity.version, "Managed tool descriptor packageIdentity.version"); +} + +export { validateManagedToolIdentity }; + +function validateManagedToolEntrypoints(entrypoints: readonly ManagedToolDescriptorEntrypoint[]): void { + validateNonEmptyArray(entrypoints, "Managed tool descriptor entrypoints"); + validateExactStringSet( + entrypoints.map((entrypoint) => entrypoint.bin), + ["opcore"], + "Managed tool descriptor entrypoint bins", + ); + for (const entrypoint of entrypoints) { + if (!entrypoint || typeof entrypoint !== "object") + throw new Error("Managed tool descriptor entrypoint is required"); + if (entrypoint.bin !== "opcore") throw new Error("Managed tool descriptor must expose the opcore entrypoint"); + if (entrypoint.packageName !== "opcore") { + throw new Error("Managed tool descriptor entrypoint packageName must be opcore"); + } + validateManagedToolPackagePath(entrypoint.path, "Managed tool descriptor entrypoint path"); + if (entrypoint.path !== "dist/index.js") { + throw new Error("Managed tool descriptor entrypoint path must be dist/index.js"); + } + validateExactStringSequence(entrypoint.command, ["opcore"], "Managed tool descriptor entrypoint command"); + } +} + +export { validateManagedToolEntrypoints }; + +function validateManagedToolCommandGroups(commandGroups: readonly ManagedToolDescriptorCommandGroup[]): void { + validateNonEmptyArray(commandGroups, "Managed tool descriptor command groups"); + validateExactStringSet( + commandGroups.map((group) => group.name), + managedToolDescriptorCommandGroups, + "Managed tool descriptor command groups", + ); + for (const group of commandGroups) { + if (!group || typeof group !== "object") throw new Error("Managed tool descriptor command group is required"); + if (!includesString(managedToolDescriptorCommandGroups, group.name)) { + throw new Error(`Unknown managed tool descriptor command group: ${String(group.name)}`); + } + const expectedCanonical = ["opcore", group.name]; + validateExactStringSequence( + group.canonicalCommand, + expectedCanonical, + `Managed tool descriptor ${group.name} canonicalCommand`, + ); + validateStringArray(group.commands, `Managed tool descriptor ${group.name} commands`, { allowEmpty: false }); + const expectedPackageName = managedToolDescriptorCommandGroupPackageNames[group.name]; + if (group.packageName !== expectedPackageName) { + throw new Error(`Managed tool descriptor ${group.name} packageName must be ${expectedPackageName}`); + } + validateManagedToolCommandTokens(group.commands, `Managed tool descriptor ${group.name} commands`); + } +} + +export { validateManagedToolCommandGroups }; + +function validateManagedToolHealthProbes(healthProbes: readonly ManagedToolDescriptorHealthProbe[]): void { + validateNonEmptyArray(healthProbes, "Managed tool descriptor health probes"); + for (const probe of healthProbes) validateManagedToolHealthProbe(probe); + validateRequiredHealthProbe(healthProbes, ["opcore", "status", "--json"], "status"); + validateRequiredHealthProbe(healthProbes, ["opcore", "doctor", "--json"], "doctor"); +} + +export { validateManagedToolHealthProbes }; + +function validateManagedToolHealthProbe(probe: ManagedToolDescriptorHealthProbe): void { + if (!probe || typeof probe !== "object") throw new Error("Managed tool descriptor health probe is required"); + validateNonEmptyString(probe.id, "Managed tool descriptor health probe id"); + validateStringArray(probe.command, "Managed tool descriptor health probe command", { allowEmpty: false }); + validateManagedToolCommandTokens(probe.command, "Managed tool descriptor health probe command"); + if (probe.command[0] !== "opcore") { + throw new Error("Managed tool descriptor health probes must use opcore commands"); + } + if (probe.expectedExitCode !== 0) { + throw new Error("Managed tool descriptor health probe expectedExitCode must be 0"); + } + if (probe.output !== "json") throw new Error("Managed tool descriptor health probe output must be json"); +} + +function validateRequiredHealthProbe( + probes: readonly ManagedToolDescriptorHealthProbe[], + command: readonly string[], + name: string, +): void { + if (!probes.some((probe) => sameStringArray(probe.command, command))) { + throw new Error(`Managed tool descriptor health probes must include ${name}`); + } +} + +function validateManagedToolCapabilities(capabilities: ManagedToolDescriptorCapabilities): void { + if (!capabilities || typeof capabilities !== "object") + throw new Error("Managed tool descriptor capabilities are required"); + validateManagedToolGraphCapabilities(capabilities.graph); + validateManagedToolEditCapabilities(capabilities.edit); + validateManagedToolValidationCapabilities(capabilities.validation); +} + +export { validateManagedToolCapabilities }; diff --git a/packages/contracts/src/managed/validators-02.ts b/packages/contracts/src/managed/validators-02.ts new file mode 100644 index 0000000..ae53e1b --- /dev/null +++ b/packages/contracts/src/managed/validators-02.ts @@ -0,0 +1,236 @@ +import { + validateExactValue, + validateRequiredObject, +} from "../shared/validators-02.js"; +import { graphProviderModes } from "../graph/vocabulary-01.js"; +import { opcoreInitScopes } from "../product/init-contracts.js"; +import { + graphCoreNativePackageNameForTarget, + graphCoreNativeSupportedTargets, +} from "../release/graph-vocabulary-02.js"; +import { + validateExactStringSequence, + validateExactStringSet, + validateStringArray, + validateValidationChecks, +} from "../shared/validators-01.js"; +import { + PYTHON_PROJECT_CONTEXT_SCHEMA_ID, + pythonProjectContextOutcomes, +} from "../validation/python-project-contracts-01.js"; +import { validationScopeKinds } from "../validation/request-contracts.js"; +import type { ManagedToolDescriptorCapabilities } from "./contracts.js"; +import { bundledGraphCoreNativePath } from "./helper-validators.js"; +import { validateManagedToolPackagePath } from "./validators-03.js"; + +function validateManagedToolGraphCapabilities(graph: ManagedToolDescriptorCapabilities["graph"]): void { + if (!graph || typeof graph !== "object") throw new Error("Managed tool descriptor graph capabilities are required"); + if (graph.provider !== "opcore-graph") throw new Error("Managed tool descriptor graph provider must be opcore-graph"); + if (graph.schemaVersion !== 1) throw new Error("Managed tool descriptor graph schemaVersion must be 1"); + validateExactStringSet( + graph.commands, + ["build", "update", "watch", "status", "query", "impact", "review-context", "detect-changes", "search", "serve"], + "Managed tool descriptor graph commands", + ); + validateStringArray(graph.queryKinds, "Managed tool descriptor graph queryKinds", { allowEmpty: false }); + validateExactStringSet( + graph.daemonOperations, + ["ping", "status", "query", "search", "shutdown"], + "Managed tool descriptor graph daemonOperations", + ); + validateManagedToolGraphNativeArtifacts(graph.nativeArtifacts); +} + +export { validateManagedToolGraphCapabilities }; + +function validateManagedToolGraphNativeArtifacts( + nativeArtifacts: ManagedToolDescriptorCapabilities["graph"]["nativeArtifacts"], +): void { + if (!Array.isArray(nativeArtifacts)) throw new Error("Managed tool descriptor graph native artifacts are required"); + validateExactStringSet( + nativeArtifacts.map((artifact) => artifact?.targetPlatform), + graphCoreNativeSupportedTargets, + "Managed tool descriptor graph native targets", + ); + for (const artifact of nativeArtifacts) validateManagedToolGraphNativeArtifact(artifact); +} + +export { validateManagedToolGraphNativeArtifacts }; + +function validateManagedToolGraphNativeArtifact( + artifact: ManagedToolDescriptorCapabilities["graph"]["nativeArtifacts"][number], +): void { + validateRequiredObject(artifact, "Managed tool descriptor graph native artifact is required"); + const target = artifact.targetPlatform; + const bundledPackage = graphCoreNativePackageNameForTarget(target); + validateExactValue( + artifact.packageName, + "opcore", + `Managed tool descriptor graph native packageName for ${target} must be opcore`, + ); + validateExactValue( + artifact.bundledPackageName, + bundledPackage, + `Managed tool descriptor graph native bundledPackageName for ${target} must be ${bundledPackage}`, + ); + validateGraphNativeArtifactPaths(artifact, bundledPackage); + validateGraphNativeArtifactIds(artifact); +} + +export { validateManagedToolGraphNativeArtifact }; + +function validateGraphNativeArtifactPaths( + artifact: ManagedToolDescriptorCapabilities["graph"]["nativeArtifacts"][number], + bundledPackage: ReturnType, +): void { + validateExactValue( + artifact.binaryPath, + bundledGraphCoreNativePath(bundledPackage, "opcore-graph-core"), + "Managed tool descriptor graph native binaryPath must point at bundled opcore-graph-core", + ); + validateExactValue( + artifact.metadataPath, + bundledGraphCoreNativePath(bundledPackage, "metadata.json"), + "Managed tool descriptor graph native metadataPath must point at bundled metadata.json", + ); + validateExactValue( + artifact.checksumPath, + bundledGraphCoreNativePath(bundledPackage, "opcore-graph-core.sha256"), + "Managed tool descriptor graph native checksumPath must point at bundled opcore-graph-core.sha256", + ); +} + +export { validateGraphNativeArtifactPaths }; + +function validateGraphNativeArtifactIds( + artifact: ManagedToolDescriptorCapabilities["graph"]["nativeArtifacts"][number], +): void { + validateRequiredObject(artifact.artifactIds, "Managed tool descriptor graph native artifact ids are required"); + const suffix = artifact.targetPlatform; + validateExactValue( + artifact.artifactIds.binaryArtifactId, + `graph-core-binary-${suffix}`, + `Managed tool descriptor graph native binary artifact id must be graph-core-binary-${suffix}`, + ); + validateExactValue( + artifact.artifactIds.metadataArtifactId, + `graph-core-metadata-${suffix}`, + `Managed tool descriptor graph native metadata artifact id must be graph-core-metadata-${suffix}`, + ); + validateExactValue( + artifact.artifactIds.checksumArtifactId, + `graph-core-checksum-${suffix}`, + `Managed tool descriptor graph native checksum artifact id must be graph-core-checksum-${suffix}`, + ); + validateExactValue( + artifact.artifactIds.checksumId, + `graph-core-binary-sha256-${suffix}`, + `Managed tool descriptor graph native checksum id must be graph-core-binary-sha256-${suffix}`, + ); +} + +export { validateGraphNativeArtifactIds }; + +function validateManagedToolEditCapabilities(edit: ManagedToolDescriptorCapabilities["edit"]): void { + if (!edit || typeof edit !== "object") throw new Error("Managed tool descriptor edit capabilities are required"); + validateExactStringSet( + edit.commands, + ["exact", "multi", "search-replace", "patch", "tree", "rename", "move", "signature", "check", "apply"], + "Managed tool descriptor edit commands", + ); + validateExactStringSet( + edit.safeEditModes, + ["exact", "multi", "search-replace", "patch", "tree"], + "Managed tool descriptor safe edit modes", + ); + validateExactStringSet( + edit.symbolEditModes, + ["rename", "move", "signature"], + "Managed tool descriptor symbol edit modes", + ); + if (edit.validationRequiredForApply !== true) { + throw new Error("Managed tool descriptor edit validationRequiredForApply must be true"); + } + if (edit.dryRun !== true) throw new Error("Managed tool descriptor edit dryRun must be true"); +} + +export { validateManagedToolEditCapabilities }; + +function validateManagedToolValidationCapabilities(validation: ManagedToolDescriptorCapabilities["validation"]): void { + if (!validation || typeof validation !== "object") + throw new Error("Managed tool descriptor validation capabilities are required"); + validateExactStringSet( + validation.checkRoutes, + ["files", "staged", "changed", "tree", "all", "manifest"], + "Managed tool descriptor check routes", + ); + validateExactStringSet( + validation.validateRoutes, + ["request", "hypothetical", "pre-write", "manifest"], + "Managed tool descriptor validate routes", + ); + validateExactStringSet(validation.scopeModes, validationScopeKinds, "Managed tool descriptor validation scope modes"); + validateExactStringSet(validation.graphModes, graphProviderModes, "Managed tool descriptor validation graph modes"); + if (validation.hypothetical !== true) throw new Error("Managed tool descriptor validation hypothetical must be true"); + validateExactStringSet( + validation.statusSurfaces, + ["status", "doctor"], + "Managed tool descriptor validation status surfaces", + ); + validateRequiredObject( + validation.pythonProjectContext, + "Managed tool descriptor Python project context capability is required", + ); + if (validation.pythonProjectContext.schemaId !== PYTHON_PROJECT_CONTEXT_SCHEMA_ID) { + throw new Error( + `Managed tool descriptor Python project context schemaId must be ${PYTHON_PROJECT_CONTEXT_SCHEMA_ID}`, + ); + } + validateExactStringSet( + validation.pythonProjectContext.outcomes, + pythonProjectContextOutcomes, + "Managed tool descriptor Python project context outcomes", + ); + if (validation.pythonProjectContext.readOnly !== true || validation.pythonProjectContext.installs !== false) { + throw new Error("Managed tool descriptor Python project context must be read-only and no-install"); + } + validateManagedToolValidationWriteGate(validation.writeGate); + validateValidationChecks(validation.checkIds, "Managed tool descriptor validation checkIds"); +} + +export { validateManagedToolValidationCapabilities }; + +function validateManagedToolValidationWriteGate( + writeGate: ManagedToolDescriptorCapabilities["validation"]["writeGate"], +): void { + validateRequiredObject(writeGate, "Managed tool descriptor validation writeGate is required"); + validateExactStringSet(writeGate.initScopes, opcoreInitScopes, "Managed tool descriptor writeGate initScopes"); + validateExactStringSet(writeGate.harnesses, ["claude-code", "codex"], "Managed tool descriptor writeGate harnesses"); + validateManagedToolPackagePath(writeGate.adapterPath, "Managed tool descriptor writeGate adapterPath"); + if (writeGate.adapterPath !== "dist/agent-gate.js") { + throw new Error("Managed tool descriptor writeGate adapterPath must be dist/agent-gate.js"); + } + validateExactStringSequence( + writeGate.validationCommand, + ["opcore", "validate", "pre-write", "--request-file", "", "--timeout-ms", "30000", "--json"], + "Managed tool descriptor writeGate validationCommand", + ); + if (writeGate.adapterErrorPolicy !== "fail_open") { + throw new Error("Managed tool descriptor writeGate adapterErrorPolicy must be fail_open"); + } + if (writeGate.validationErrorPolicy !== "fail_closed") { + throw new Error("Managed tool descriptor writeGate validationErrorPolicy must be fail_closed"); + } + if (writeGate.codexBoundary !== "pretooluse_guardrail") { + throw new Error("Managed tool descriptor writeGate codexBoundary must be pretooluse_guardrail"); + } +} + +export { validateManagedToolValidationWriteGate }; + +interface ManagedToolDescriptorArtifactValidationState { + artifactIds: Set; + artifactChecksumRefs: readonly { artifactId: string; checksumRef: string }[]; +} + +export type { ManagedToolDescriptorArtifactValidationState }; diff --git a/packages/contracts/src/managed/validators-03.ts b/packages/contracts/src/managed/validators-03.ts new file mode 100644 index 0000000..b162f16 --- /dev/null +++ b/packages/contracts/src/managed/validators-03.ts @@ -0,0 +1,267 @@ +import { includesString } from "../shared/primitives.js"; +import { + graphCoreNativePackageNameForTarget, + graphCoreNativeSupportedTargets, +} from "../release/graph-vocabulary-02.js"; +import { validateRepoRelativePath } from "../shared/path-validators.js"; +import { validateNonEmptyArray, validateNonEmptyString, validateStringArray } from "../shared/validators-01.js"; +import { + collectStrings, + validateBoolean, + validateExactValue, + validateOptional, + validateRequiredObject, +} from "../shared/validators-02.js"; +import type { + ManagedToolDescriptorArtifactReference, + ManagedToolDescriptorChecksumReference, + ManagedToolDescriptorProvenanceHook} from "./contracts.js"; +import { + managedToolDescriptorArtifactTypes, +} from "./contracts.js"; +import { bundledGraphCoreNativePath } from "./helper-validators.js"; +import type { ManagedToolDescriptorArtifactValidationState } from "./validators-02.js"; + +function validateManagedToolArtifacts( + artifacts: readonly ManagedToolDescriptorArtifactReference[], +): ManagedToolDescriptorArtifactValidationState { + validateNonEmptyArray(artifacts, "Managed tool descriptor artifacts"); + const artifactIds = new Set(); + const artifactChecksumRefs: { artifactId: string; checksumRef: string }[] = []; + for (const artifact of artifacts) { + validateManagedToolArtifact(artifact, artifactIds, artifactChecksumRefs); + } + + for (const target of graphCoreNativeSupportedTargets) validateGraphNativeArtifacts(artifacts, target); + if (!artifacts.some(isPackagedManagedToolDescriptor)) { + throw new Error("Managed tool descriptor must include packaged descriptor artifact"); + } + return { artifactIds, artifactChecksumRefs }; +} + +export { validateManagedToolArtifacts }; + +function validateManagedToolArtifact( + artifact: ManagedToolDescriptorArtifactReference, + artifactIds: Set, + artifactChecksumRefs: { artifactId: string; checksumRef: string }[], +): void { + validateRequiredObject(artifact, "Managed tool descriptor artifact is required"); + validateNonEmptyString(artifact.id, "Managed tool descriptor artifact id"); + if (artifactIds.has(artifact.id)) { + throw new Error(`Managed tool descriptor artifact id must be unique: ${artifact.id}`); + } + artifactIds.add(artifact.id); + validateNonEmptyString(artifact.packageName, "Managed tool descriptor artifact packageName"); + validateManagedToolPackagePath(artifact.path, "Managed tool descriptor artifact path"); + if (!includesString(managedToolDescriptorArtifactTypes, artifact.type)) { + throw new Error(`Unknown managed tool descriptor artifact type: ${String(artifact.type)}`); + } + validateBoolean(artifact.required, "Managed tool descriptor artifact required"); + validateOptional(artifact.checksumRef, (checksumRef) => { + validateNonEmptyString(checksumRef, "Managed tool descriptor artifact checksumRef"); + artifactChecksumRefs.push({ artifactId: artifact.id, checksumRef }); + }); +} + +export { validateManagedToolArtifact }; + +function validateGraphNativeArtifacts( + artifacts: readonly ManagedToolDescriptorArtifactReference[], + target: (typeof graphCoreNativeSupportedTargets)[number], +): void { + const bundledPackage = graphCoreNativePackageNameForTarget(target); + validateGraphNativeArtifact( + artifacts.find((artifact) => artifact.id === `graph-core-binary-${target}`), + { + type: "native_binary", + path: bundledGraphCoreNativePath(bundledPackage, "opcore-graph-core"), + checksumRef: `graph-core-binary-sha256-${target}`, + }, + `Managed tool descriptor must include graph native binary artifact for ${target}`, + ); + validateGraphNativeArtifact( + artifacts.find((artifact) => artifact.id === `graph-core-metadata-${target}`), + { + type: "manifest", + path: bundledGraphCoreNativePath(bundledPackage, "metadata.json"), + }, + `Managed tool descriptor must include graph native metadata artifact for ${target}`, + ); + validateGraphNativeArtifact( + artifacts.find((artifact) => artifact.id === `graph-core-checksum-${target}`), + { + type: "checksum", + path: bundledGraphCoreNativePath(bundledPackage, "opcore-graph-core.sha256"), + }, + `Managed tool descriptor must include graph native checksum artifact for ${target}`, + ); +} + +export { validateGraphNativeArtifacts }; + +interface ExpectedGraphNativeArtifact { + type: ManagedToolDescriptorArtifactReference["type"]; + path: string; + checksumRef?: string; +} + +function validateGraphNativeArtifact( + artifact: ManagedToolDescriptorArtifactReference | undefined, + expected: ExpectedGraphNativeArtifact, + message: string, +): void { + if (!artifact) throw new Error(message); + validateExactValue(artifact.packageName, "opcore", message); + validateExactValue(artifact.type, expected.type, message); + validateExactValue(artifact.required, true, message); + validateExactValue(artifact.path, expected.path, message); + validateOptional(expected.checksumRef, (checksumRef) => + validateExactValue(artifact.checksumRef, checksumRef, message), + ); +} + +export { validateGraphNativeArtifact }; + +function isPackagedManagedToolDescriptor(artifact: ManagedToolDescriptorArtifactReference): boolean { + return ( + artifact.id === "descriptor" && + artifact.packageName === "opcore" && + artifact.path === "dist/descriptors/opcore.managed-tool.json" && + artifact.type === "descriptor" && + artifact.required + ); +} + +export { isPackagedManagedToolDescriptor }; + +function validateManagedToolChecksums( + checksums: readonly ManagedToolDescriptorChecksumReference[], + artifactReferences: ManagedToolDescriptorArtifactValidationState, +): void { + validateNonEmptyArray(checksums, "Managed tool descriptor checksums"); + const checksumIds = new Set(); + for (const checksum of checksums) { + validateManagedToolChecksum(checksum, checksumIds, artifactReferences.artifactIds); + } + for (const target of graphCoreNativeSupportedTargets) { + if ( + !checksums.some( + (checksum) => + checksum.id === `graph-core-binary-sha256-${target}` && + checksum.artifactRef === `graph-core-binary-${target}`, + ) + ) { + throw new Error(`Managed tool descriptor must include graph native checksum reference for ${target}`); + } + } + for (const artifactChecksumRef of artifactReferences.artifactChecksumRefs) { + if (!checksumIds.has(artifactChecksumRef.checksumRef)) { + throw new Error( + `Managed tool descriptor artifact checksumRef must reference a checksum: ${artifactChecksumRef.artifactId} ` + + `-> ${artifactChecksumRef.checksumRef}`, + ); + } + } +} + +export { validateManagedToolChecksums }; + +function validateManagedToolChecksum( + checksum: ManagedToolDescriptorChecksumReference, + checksumIds: Set, + artifactIds: Set, +): void { + validateRequiredObject(checksum, "Managed tool descriptor checksum is required"); + validateNonEmptyString(checksum.id, "Managed tool descriptor checksum id"); + if (checksumIds.has(checksum.id)) { + throw new Error(`Managed tool descriptor checksum id must be unique: ${checksum.id}`); + } + checksumIds.add(checksum.id); + validateNonEmptyString(checksum.packageName, "Managed tool descriptor checksum packageName"); + validateManagedToolPackagePath(checksum.path, "Managed tool descriptor checksum path"); + validateExactValue( + checksum.algorithm, + "sha256", + "Managed tool descriptor checksum algorithm must be sha256", + ); + validateNonEmptyString(checksum.artifactRef, "Managed tool descriptor checksum artifactRef"); + if (!artifactIds.has(checksum.artifactRef)) { + throw new Error(`Managed tool descriptor checksum artifactRef must reference an artifact: ${checksum.artifactRef}`); + } + validateBoolean(checksum.required, "Managed tool descriptor checksum required"); + validateOptional(checksum.value, validateManagedToolChecksumValue); +} + +export { validateManagedToolChecksum }; + +function validateManagedToolChecksumValue(value: string): void { + if (!/^[a-f0-9]{64}$/i.test(value)) { + throw new Error("Managed tool descriptor checksum value must be a sha256 hex digest"); + } +} + +export { validateManagedToolChecksumValue }; + +function validateManagedToolProvenanceHooks(provenanceHooks: readonly ManagedToolDescriptorProvenanceHook[]): void { + validateNonEmptyArray(provenanceHooks, "Managed tool descriptor provenance hooks"); + for (const hook of provenanceHooks) { + if (!hook || typeof hook !== "object") throw new Error("Managed tool descriptor provenance hook is required"); + validateNonEmptyString(hook.id, "Managed tool descriptor provenance hook id"); + validateStringArray(hook.command, "Managed tool descriptor provenance hook command", { allowEmpty: false }); + validateManagedToolCommandTokens(hook.command, "Managed tool descriptor provenance hook command"); + if (hook.expectedExitCode !== 0) + throw new Error("Managed tool descriptor provenance hook expectedExitCode must be 0"); + } +} + +export { validateManagedToolProvenanceHooks }; + +const managedToolPrivateRuntimePathPattern = /(?:^|\/)(?:\.agents|\.claude|\.codex|\.gemini|\.opencode)(?:\/|$)/; + +export { managedToolPrivateRuntimePathPattern }; + +function normalizeManagedToolDescriptorString(value: string): string { + return value.replaceAll("\\", "/"); +} + +export { normalizeManagedToolDescriptorString }; + +function validateManagedToolPackagePath(path: string, label: string): string { + const normalized = validateRepoRelativePath(path); + if (normalized === "~" || normalized.startsWith("~/")) { + throw new Error(`${label} must not reference private home paths`); + } + if (managedToolPrivateRuntimePathPattern.test(normalized)) { + throw new Error(`${label} must not reference private runtime paths`); + } + return normalized; +} + +export { validateManagedToolPackagePath }; + +function validateManagedToolCommandTokens(tokens: readonly string[], label: string): void { + for (const token of tokens) { + validateNonEmptyString(token, label); + const normalizedToken = normalizeManagedToolDescriptorString(token); + if (managedToolPrivateRuntimePathPattern.test(normalizedToken)) { + throw new Error("Managed tool descriptor must not reference private runtime paths"); + } + } +} + +export { validateManagedToolCommandTokens }; + +function validateManagedToolDescriptorForbiddenStrings(value: unknown): void { + for (const text of collectStrings(value)) { + const normalizedText = normalizeManagedToolDescriptorString(text); + if (managedToolPrivateRuntimePathPattern.test(normalizedText)) { + throw new Error("Managed tool descriptor must not reference private runtime paths"); + } + if (normalizedText.includes("/Users/tom")) { + throw new Error("Managed tool descriptor must not reference private paths"); + } + } +} + +export { validateManagedToolDescriptorForbiddenStrings }; diff --git a/packages/contracts/src/product/init-contracts.ts b/packages/contracts/src/product/init-contracts.ts new file mode 100644 index 0000000..bb35ec8 --- /dev/null +++ b/packages/contracts/src/product/init-contracts.ts @@ -0,0 +1,132 @@ +import type { GraphProviderStatusState } from "../graph/vocabulary-01.js"; +import type { PythonProjectContext } from "../validation/python-project-contracts-02.js"; +import type { ValidationResultStatus } from "../validation/vocabulary-01.js"; + +const opcoreInitScopes = ["repo", "global"] as const; + +export { opcoreInitScopes }; + +type OpcoreInitScope = (typeof opcoreInitScopes)[number]; + +export type { OpcoreInitScope }; + +interface OpcoreInitAction { + kind: "write" | "upsert_block" | "create_hook" | "wire_harness" | "restore" | "remove"; + path: string; + targetScope: OpcoreInitScope; + summary: string; + requiresApproval: boolean; + outsideOpcore: boolean; +} + +export type { OpcoreInitAction }; + +interface OpcoreInitScanSummary { + totalFiles: number; + graphSupportedFiles: number; + validationSupportedFiles: number; + validationRetainedFiles: number; + unsupportedFiles: number; + languages: readonly { + language: string; + files: number; + graphSupported: boolean; + validationSupported: boolean; + }[]; + unsupportedStacks: readonly { + extension: string; + language: string; + count: number; + examples: readonly string[]; + }[]; + degradedRustTools: readonly { + adapter: string; + tool: string; + failureMessage?: string; + }[]; + diagnosticCount: number; + validationStatus: ValidationResultStatus; + failedChecks: readonly string[]; + graphState: GraphProviderStatusState; + activationLevel: "ready" | "degraded" | "blocked"; +} + +export type { OpcoreInitScanSummary }; + +interface OpcoreInitLanguageSetting { + language: string; + files: number; + state: "supported" | "retained" | "unsupported" | "degraded"; + graph: "supported" | "unsupported"; + validation: "supported" | "retained" | "unsupported" | "degraded"; + checks: readonly string[]; + notes: readonly string[]; +} + +export type { OpcoreInitLanguageSetting }; + +interface OpcoreInitPythonEnvironment { + dependencyManagers: readonly { + kind: "pyproject" | "requirements" | "pipfile" | "poetry" | "uv"; + path: string; + }[]; + virtualEnvironments: readonly { + kind: "venv"; + path: string; + }[]; + notes: readonly string[]; + contexts?: readonly PythonProjectContext[]; +} + +export type { OpcoreInitPythonEnvironment }; + +interface OpcoreInitSettings { + languages: readonly OpcoreInitLanguageSetting[]; + python?: OpcoreInitPythonEnvironment; +} + +export type { OpcoreInitSettings }; + +interface OpcoreInitInteraction { + tty: boolean; + promptState: "not_requested" | "requested" | "approved" | "declined"; +} + +export type { OpcoreInitInteraction }; + +interface OpcoreInitTiming { + scanMs: number; + planMs: number; + promptMs: number; + applyMs: number; + totalMs: number; + firstOutputMs: number; +} + +export type { OpcoreInitTiming }; + +interface OpcoreInitPlanPayload { + schemaVersion: 1; + mode: "plan" | "apply" | "undo"; + approved: boolean; + repo: { + root: string; + requestedPath: string; + }; + options: { + scope: OpcoreInitScope; + failClosedHook: boolean; + dryRun: boolean; + }; + agentFiles: readonly string[]; + actions: readonly OpcoreInitAction[]; + warnings: readonly string[]; + nextActions: readonly string[]; + undoAvailable: boolean; + scan: OpcoreInitScanSummary; + settings: OpcoreInitSettings; + interaction: OpcoreInitInteraction; + timings: OpcoreInitTiming; +} + +export type { OpcoreInitPlanPayload }; diff --git a/packages/contracts/src/product/init-validators-01.ts b/packages/contracts/src/product/init-validators-01.ts new file mode 100644 index 0000000..6794c91 --- /dev/null +++ b/packages/contracts/src/product/init-validators-01.ts @@ -0,0 +1,180 @@ +import { + includesString, + validateBoolean, + validateOptional, + validateRequiredObject, +} from "../shared/primitives.js"; +import { validateProviderStatus } from "../graph/provider-validators.js"; +import { graphProviderStatusStates } from "../graph/vocabulary-01.js"; +import { validateRepoRelativePath } from "../shared/path-validators.js"; +import { + validateNonEmptyArray, + validateNonEmptyString, + validateNonNegativeInteger, + validateStringArray, +} from "../shared/validators-01.js"; +import { validationResultStatuses } from "../validation/vocabulary-01.js"; +import type { OpcoreInitPlanPayload, OpcoreInitScanSummary} from "./init-contracts.js"; +import { opcoreInitScopes } from "./init-contracts.js"; +import { + validateOpcoreInitInteraction, + validateOpcoreInitSettings, + validateOpcoreInitTiming, +} from "./init-validators-02.js"; +import { validateOpcoreInitAction } from "./metrics-validators-02.js"; +import { + validateOpcoreCoverageLanguages, + validateOpcoreUnsupportedStacks, +} from "./metrics-coverage-validators.js"; +import type { OpcoreDoctorPayload, OpcoreRuntimeInfoPayload} from "./status-contracts.js"; +import { opcoreRuntimeArtifactSources } from "./status-contracts.js"; +import { validateOpcoreValidationPolicySummary } from "./status-validators.js"; + +function validateOpcoreRuntimeInfoPayload(payload: OpcoreRuntimeInfoPayload): OpcoreRuntimeInfoPayload { + validateRequiredObject(payload, "Opcore runtime info payload is required"); + if (payload.schemaVersion !== 1) { + throw new Error("Opcore runtime info schemaVersion must be 1"); + } + if (payload.packageName !== "opcore") { + throw new Error("Opcore runtime info packageName must be opcore"); + } + validateNonEmptyString(payload.version, "Opcore runtime info version"); + if (payload.bin !== "opcore") { + throw new Error("Opcore runtime info bin must be opcore"); + } + if (!includesString(opcoreRuntimeArtifactSources, payload.artifactSource)) { + throw new Error(`Unknown Opcore runtime artifact source: ${String(payload.artifactSource)}`); + } + validateNonEmptyString(payload.packageRoot, "Opcore runtime info packageRoot"); + validateNonEmptyString(payload.entrypoint, "Opcore runtime info entrypoint"); + return payload; +} + +export { validateOpcoreRuntimeInfoPayload }; + +function validateOpcoreDoctorPayload(payload: OpcoreDoctorPayload): OpcoreDoctorPayload { + validateRequiredObject(payload, "Opcore doctor payload is required"); + if (payload.schemaVersion !== 1) { + throw new Error("Opcore doctor payload schemaVersion must be 1"); + } + validateOpcoreRuntimeInfoPayload(payload.runtime); + validateRequiredObject(payload.repo, "Opcore doctor repo is required"); + validateNonEmptyString(payload.repo.root, "Opcore doctor repo root"); + validateNonEmptyString(payload.repo.requestedPath, "Opcore doctor repo requestedPath"); + validateRequiredObject(payload.config, "Opcore doctor config is required"); + if (payload.config.path !== ".opcore/config") { + throw new Error("Opcore doctor config path must be .opcore/config"); + } + if (!includesString(["found", "missing", "unreadable"] as const, payload.config.state)) { + throw new Error(`Unknown Opcore doctor config state: ${String(payload.config.state)}`); + } + if (payload.config.message !== undefined) + validateNonEmptyString(payload.config.message, "Opcore doctor config message"); + validateRequiredObject(payload.checks, "Opcore doctor checks are required"); + validateNonNegativeInteger(payload.checks.count, "Opcore doctor checks count"); + validateStringArray(payload.checks.ids, "Opcore doctor checks ids", { + allowEmpty: false, + }); + if (payload.checks.ids.length !== payload.checks.count) { + throw new Error("Opcore doctor checks count must match ids length"); + } + validateOpcoreValidationPolicySummary(payload.policy, "Opcore doctor policy"); + validateProviderStatus(payload.graph); + validateRequiredObject(payload.generatedState, "Opcore doctor generatedState is required"); + validateStringArray(payload.generatedState.ignored, "Opcore doctor generatedState ignored", { allowEmpty: false }); + validateNonEmptyString(payload.generatedState.guidance, "Opcore doctor generatedState guidance"); + validateStringArray(payload.nextActions, "Opcore doctor nextActions", { + allowEmpty: false, + }); + return payload; +} + +export { validateOpcoreDoctorPayload }; + +function validateOpcoreInitPlanPayload(payload: OpcoreInitPlanPayload): OpcoreInitPlanPayload { + validateRequiredObject(payload, "Opcore init payload is required"); + if (payload.schemaVersion !== 1) { + throw new Error("Opcore init payload schemaVersion must be 1"); + } + if (!includesString(["plan", "apply", "undo"] as const, payload.mode)) { + throw new Error(`Unknown Opcore init mode: ${String(payload.mode)}`); + } + validateBoolean(payload.approved, "Opcore init approved"); + validateOpcoreInitApproval(payload); + validateRequiredObject(payload.repo, "Opcore init repo is required"); + validateNonEmptyString(payload.repo.root, "Opcore init repo root"); + validateNonEmptyString(payload.repo.requestedPath, "Opcore init requested path"); + validateRequiredObject(payload.options, "Opcore init options are required"); + if (!includesString(opcoreInitScopes, payload.options.scope)) { + throw new Error(`Unknown Opcore init scope: ${String(payload.options.scope)}`); + } + validateBoolean(payload.options.failClosedHook, "Opcore init failClosedHook option"); + validateBoolean(payload.options.dryRun, "Opcore init dryRun option"); + validateStringArray(payload.agentFiles, "Opcore init agentFiles", { + allowEmpty: true, + }); + for (const agentFile of payload.agentFiles) validateRepoRelativePath(agentFile); + validateNonEmptyArray(payload.actions, "Opcore init actions"); + for (const action of payload.actions) validateOpcoreInitAction(action); + validateStringArray(payload.warnings, "Opcore init warnings", { + allowEmpty: true, + }); + validateStringArray(payload.nextActions, "Opcore init nextActions", { + allowEmpty: false, + }); + validateBoolean(payload.undoAvailable, "Opcore init undoAvailable"); + validateOpcoreInitScanSummary(payload.scan); + validateOpcoreInitSettings(payload.settings); + validateOpcoreInitInteraction(payload.interaction); + validateOpcoreInitTiming(payload.timings); + return payload; +} + +export { validateOpcoreInitPlanPayload }; + +function validateOpcoreInitApproval(payload: OpcoreInitPlanPayload): void { + if (payload.mode === "plan" && payload.approved) { + throw new Error("Opcore init approved plan must use apply mode"); + } + if (payload.mode === "apply" && !payload.approved) { + throw new Error("Opcore init apply mode requires approval"); + } +} + +function validateOpcoreInitScanSummary(scan: OpcoreInitScanSummary): OpcoreInitScanSummary { + validateRequiredObject(scan, "Opcore init scan summary is required"); + validateNonNegativeInteger(scan.totalFiles, "Opcore init scan totalFiles"); + validateNonNegativeInteger(scan.graphSupportedFiles, "Opcore init scan graphSupportedFiles"); + validateNonNegativeInteger(scan.validationSupportedFiles, "Opcore init scan validationSupportedFiles"); + validateNonNegativeInteger(scan.validationRetainedFiles, "Opcore init scan validationRetainedFiles"); + validateNonNegativeInteger(scan.unsupportedFiles, "Opcore init scan unsupportedFiles"); + validateOpcoreCoverageLanguages(scan.languages, "Opcore init scan"); + validateOpcoreUnsupportedStacks(scan.unsupportedStacks, "Opcore init scan"); + if (!Array.isArray(scan.degradedRustTools)) { + throw new Error("Opcore init scan degradedRustTools must be an array"); + } + for (const tool of scan.degradedRustTools) { + validateRequiredObject(tool, "Opcore init scan degraded Rust tool is required"); + validateNonEmptyString(tool.adapter, "Opcore init scan degraded Rust adapter"); + validateNonEmptyString(tool.tool, "Opcore init scan degraded Rust tool"); + validateOptional(tool.failureMessage, (value) => + validateNonEmptyString(value, "Opcore init scan degraded Rust failureMessage"), + ); + } + validateNonNegativeInteger(scan.diagnosticCount, "Opcore init scan diagnosticCount"); + if (!includesString(validationResultStatuses, scan.validationStatus)) { + throw new Error(`Unknown Opcore init scan validationStatus: ${String(scan.validationStatus)}`); + } + validateStringArray(scan.failedChecks, "Opcore init scan failedChecks", { + allowEmpty: true, + }); + if (!includesString(graphProviderStatusStates, scan.graphState)) { + throw new Error(`Unknown Opcore init scan graphState: ${String(scan.graphState)}`); + } + if (!includesString(["ready", "degraded", "blocked"] as const, scan.activationLevel)) { + throw new Error(`Unknown Opcore init scan activationLevel: ${String(scan.activationLevel)}`); + } + return scan; +} + +export { validateOpcoreInitScanSummary }; diff --git a/packages/contracts/src/product/init-validators-02.ts b/packages/contracts/src/product/init-validators-02.ts new file mode 100644 index 0000000..3332311 --- /dev/null +++ b/packages/contracts/src/product/init-validators-02.ts @@ -0,0 +1,109 @@ +import { validateRequiredObject } from "../shared/validators-02.js"; +import { includesString } from "../shared/primitives.js"; +import { validateRepoRelativePath } from "../shared/path-validators.js"; +import { + validateNonEmptyString, + validateNonNegativeInteger, + validateNonNegativeNumber, + validateStringArray, + validateValidationChecks, +} from "../shared/validators-01.js"; +import { validatePythonProjectContexts } from "../validation/python-project-validators-01.js"; +import type { + OpcoreInitInteraction, + OpcoreInitLanguageSetting, + OpcoreInitPythonEnvironment, + OpcoreInitSettings, + OpcoreInitTiming, +} from "./init-contracts.js"; + +function validateOpcoreInitSettings(settings: OpcoreInitSettings): OpcoreInitSettings { + validateRequiredObject(settings, "Opcore init settings are required"); + if (!Array.isArray(settings.languages)) { + throw new Error("Opcore init settings languages must be an array"); + } + for (const language of settings.languages) { + validateOpcoreInitLanguageSetting(language); + } + if (settings.python !== undefined) validateOpcoreInitPythonEnvironment(settings.python); + return settings; +} + +export { validateOpcoreInitSettings }; + +function validateOpcoreInitLanguageSetting(setting: OpcoreInitLanguageSetting): OpcoreInitLanguageSetting { + validateRequiredObject(setting, "Opcore init language setting is required"); + validateNonEmptyString(setting.language, "Opcore init language setting language"); + validateNonNegativeInteger(setting.files, "Opcore init language setting files"); + if (!includesString(["supported", "retained", "unsupported", "degraded"] as const, setting.state)) { + throw new Error(`Unknown Opcore init language setting state: ${String(setting.state)}`); + } + if (!includesString(["supported", "unsupported"] as const, setting.graph)) { + throw new Error(`Unknown Opcore init language setting graph: ${String(setting.graph)}`); + } + if (!includesString(["supported", "retained", "unsupported", "degraded"] as const, setting.validation)) { + throw new Error(`Unknown Opcore init language setting validation: ${String(setting.validation)}`); + } + validateValidationChecks(setting.checks, "Opcore init language setting checks"); + validateStringArray(setting.notes, "Opcore init language setting notes", { + allowEmpty: true, + }); + return setting; +} + +export { validateOpcoreInitLanguageSetting }; + +function validateOpcoreInitPythonEnvironment(environment: OpcoreInitPythonEnvironment): OpcoreInitPythonEnvironment { + validateRequiredObject(environment, "Opcore init Python environment is required"); + if (!Array.isArray(environment.dependencyManagers)) { + throw new Error("Opcore init Python dependencyManagers must be an array"); + } + for (const manager of environment.dependencyManagers) { + validateRequiredObject(manager, "Opcore init Python dependency manager is required"); + if (!includesString(["pyproject", "requirements", "pipfile", "poetry", "uv"] as const, manager.kind)) { + throw new Error(`Unknown Opcore init Python dependency manager kind: ${String(manager.kind)}`); + } + validateRepoRelativePath(manager.path); + } + if (!Array.isArray(environment.virtualEnvironments)) { + throw new Error("Opcore init Python virtualEnvironments must be an array"); + } + for (const virtualEnvironment of environment.virtualEnvironments) { + validateRequiredObject(virtualEnvironment, "Opcore init Python virtual environment is required"); + if (virtualEnvironment.kind !== "venv") { + throw new Error(`Unknown Opcore init Python virtual environment kind: ${String(virtualEnvironment.kind)}`); + } + validateRepoRelativePath(virtualEnvironment.path); + } + validateStringArray(environment.notes, "Opcore init Python environment notes", { allowEmpty: true }); + if (environment.contexts !== undefined) validatePythonProjectContexts(environment.contexts); + return environment; +} + +export { validateOpcoreInitPythonEnvironment }; + +function validateOpcoreInitInteraction(interaction: OpcoreInitInteraction): OpcoreInitInteraction { + validateRequiredObject(interaction, "Opcore init interaction is required"); + if (typeof interaction.tty !== "boolean") { + throw new Error("Opcore init interaction tty must be boolean"); + } + if (!includesString(["not_requested", "requested", "approved", "declined"] as const, interaction.promptState)) { + throw new Error(`Unknown Opcore init interaction promptState: ${String(interaction.promptState)}`); + } + return interaction; +} + +export { validateOpcoreInitInteraction }; + +function validateOpcoreInitTiming(timing: OpcoreInitTiming): OpcoreInitTiming { + validateRequiredObject(timing, "Opcore init timings are required"); + validateNonNegativeNumber(timing.scanMs, "Opcore init timing scanMs"); + validateNonNegativeNumber(timing.planMs, "Opcore init timing planMs"); + validateNonNegativeNumber(timing.promptMs, "Opcore init timing promptMs"); + validateNonNegativeNumber(timing.applyMs, "Opcore init timing applyMs"); + validateNonNegativeNumber(timing.totalMs, "Opcore init timing totalMs"); + validateNonNegativeNumber(timing.firstOutputMs, "Opcore init timing firstOutputMs"); + return timing; +} + +export { validateOpcoreInitTiming }; diff --git a/packages/contracts/src/product/latency-contracts.ts b/packages/contracts/src/product/latency-contracts.ts new file mode 100644 index 0000000..24a7cc7 --- /dev/null +++ b/packages/contracts/src/product/latency-contracts.ts @@ -0,0 +1,93 @@ +import type { + CommandOwner, + CommandRouteStatus, + CommandTimingDegradationReason, + CommandTimingProcessState, + LatencyBudgetResultStatus, +} from "../command/vocabulary.js"; +import type { GraphPipelinePhaseTiming } from "../graph/pipeline-contracts.js"; + +type CommandTimingPhase = Pick; + +export type { CommandTimingPhase }; + +interface CommandTiming { + durationMs: number; + phases: readonly CommandTimingPhase[]; + processState: CommandTimingProcessState; + degradations?: readonly CommandTimingDegradationReason[]; +} + +export type { CommandTiming }; + +interface RepoShapeFingerprint { + totalFiles: number; + languages: readonly { + language: string; + files: number; + }[]; + graph: { + supportedFiles: number; + unsupportedFiles: number; + }; + git: { + available: boolean; + clean?: boolean; + }; +} + +export type { RepoShapeFingerprint }; + +interface CommandLatencyRecord { + schemaVersion: 1; + recordedAt: string; + bin: string; + canonicalCommand: readonly string[]; + owner: CommandOwner; + status: CommandRouteStatus; + exitCode: number; + repo: RepoShapeFingerprint; + timing: CommandTiming; + opcoreVersion: string; +} + +export type { CommandLatencyRecord }; + +interface LatencyPhaseBudget { + phase: string; + budgetMs: number; +} + +export type { LatencyPhaseBudget }; + +interface LatencyBudget { + schemaVersion: 1; + canonicalCommand: readonly string[]; + scope: string; + repoShapeBucket: string; + budgetMs: number; + phaseBudgets?: readonly LatencyPhaseBudget[]; +} + +export type { LatencyBudget }; + +interface LatencyBudgetResult { + schemaVersion: 1; + status: LatencyBudgetResultStatus; + budget: LatencyBudget; + observed: { + canonicalCommand: readonly string[]; + phase: string; + durationMs: number; + }; + evidence: { + canonicalCommand: readonly string[]; + phase: string; + repoShapeBucket: string; + observedMs: number; + budgetMs: number; + overByMs: number; + }; +} + +export type { LatencyBudgetResult }; diff --git a/packages/contracts/src/product/metrics-contracts-01.ts b/packages/contracts/src/product/metrics-contracts-01.ts new file mode 100644 index 0000000..50f1af2 --- /dev/null +++ b/packages/contracts/src/product/metrics-contracts-01.ts @@ -0,0 +1,175 @@ +import type { CommandTimingProcessState } from "../command/vocabulary.js"; +import type { GraphProviderMode, GraphProviderStatusState } from "../graph/vocabulary-01.js"; +import type { PythonProjectContext } from "../validation/python-project-contracts-02.js"; +import type { ValidationResultStatus } from "../validation/vocabulary-01.js"; +import type { OpcoreRepoStatePayload, OpcoreValidationPolicySummary } from "./status-contracts.js"; + +interface OpcoreMetricEvidence { + source: string; + path: string; + message: string; + checkId?: string; + code?: string; + line?: number; + column?: number; +} + +export type { OpcoreMetricEvidence }; + +interface OpcoreMetricSignal { + id: string; + title: string; + category: "coverage" | "typescript" | "rust" | "graph" | (string & {}); + severity: "info" | "warning" | "error"; + count: number; + evidence: readonly OpcoreMetricEvidence[]; +} + +export type { OpcoreMetricSignal }; + +interface OpcoreMetricDegradation { + id: string; + title: string; + source: string; + severity: "info" | "warning" | "error"; + message: string; + checkId?: string; + requiredTool?: string; +} + +export type { OpcoreMetricDegradation }; + +interface OpcoreMetricReport { + schemaVersion: 1; + kind: "opcore_metric_report"; + generatedAt: string; + repo: { + root: string; + requestedPath: string; + git: OpcoreRepoStatePayload["repo"]["git"]; + }; + coverage: OpcoreRepoStatePayload["coverage"]; + graph: { + state: GraphProviderStatusState; + mode: GraphProviderMode; + provider: string; + }; + validation: { + status?: ValidationResultStatus; + diagnosticCount: number; + checkCount: number; + policy?: OpcoreValidationPolicySummary; + pythonProjectContexts?: readonly PythonProjectContext[]; + }; + signals: readonly OpcoreMetricSignal[]; + degradations: readonly OpcoreMetricDegradation[]; + warnings: readonly string[]; + nextActions: readonly string[]; +} + +export type { OpcoreMetricReport }; + +interface OpcoreMetricHistoryEntry { + schemaVersion: 1; + kind: "opcore_metric_history_entry"; + recordedAt: string; + report: OpcoreMetricReport; +} + +export type { OpcoreMetricHistoryEntry }; + +interface OpcoreMeasureSignalCount { + id: string; + title: string; + count: number; +} + +export type { OpcoreMeasureSignalCount }; + +interface OpcoreMeasureSignalDelta { + id: string; + title: string; + currentCount: number; + comparisonCount: number; + delta: number; +} + +export type { OpcoreMeasureSignalDelta }; + +const opcoreMeasureLatencyStatuses = ["ok", "slower", "over_budget"] as const; + +export { opcoreMeasureLatencyStatuses }; + +type OpcoreMeasureLatencyStatus = (typeof opcoreMeasureLatencyStatuses)[number]; + +export type { OpcoreMeasureLatencyStatus }; + +const opcoreMeasureLatencyFindingStatuses = ["slower", "over_budget"] as const; + +export { opcoreMeasureLatencyFindingStatuses }; + +type OpcoreMeasureLatencyFindingStatus = (typeof opcoreMeasureLatencyFindingStatuses)[number]; + +export type { OpcoreMeasureLatencyFindingStatus }; + +interface OpcoreMeasureLatencyPhase { + phase: string; + durationMs: number; +} + +export type { OpcoreMeasureLatencyPhase }; + +interface OpcoreMeasureLatencyFinding { + canonicalCommand: readonly string[]; + repoShapeBucket: string; + processState: CommandTimingProcessState; + status: OpcoreMeasureLatencyFindingStatus; + currentDurationMs: number; + dominantPhase?: OpcoreMeasureLatencyPhase; + baselineDurationMs?: number; + previousDurationMs?: number; + baselineDeltaMs?: number; + previousDeltaMs?: number; + budgetMs?: number; + overBudgetMs?: number; +} + +export type { OpcoreMeasureLatencyFinding }; + +interface OpcoreMeasureLatencyReport { + kind: "opcore_latency_report"; + recordCount: number; + budgetCount: number; + findings: readonly OpcoreMeasureLatencyFinding[]; +} + +export type { OpcoreMeasureLatencyReport }; + +interface OpcoreMeasureComparison { + recordedAt: string; + generatedAt: string; + coverage: OpcoreMetricReport["coverage"]; + signals: readonly OpcoreMeasureSignalCount[]; + deltas: readonly OpcoreMeasureSignalDelta[]; +} + +export type { OpcoreMeasureComparison }; + +interface OpcoreMeasureDelta { + schemaVersion: 1; + kind: "opcore_measure_delta"; + generatedAt: string; + current: { + generatedAt: string; + coverage: OpcoreMetricReport["coverage"]; + signals: readonly OpcoreMeasureSignalCount[]; + }; + latency?: OpcoreMeasureLatencyReport; + baseline?: OpcoreMeasureComparison; + previous?: OpcoreMeasureComparison; + warnings: readonly string[]; + degradations: readonly OpcoreMetricDegradation[]; + nextActions: readonly string[]; +} + +export type { OpcoreMeasureDelta }; diff --git a/packages/contracts/src/product/metrics-contracts-02.ts b/packages/contracts/src/product/metrics-contracts-02.ts new file mode 100644 index 0000000..12a9454 --- /dev/null +++ b/packages/contracts/src/product/metrics-contracts-02.ts @@ -0,0 +1,46 @@ +import type { CommandOwner, CommandRouteStatus } from "../command/vocabulary.js"; + +interface OpcoreTrySignalSummary { + id: string; + title: string; + count: number; + delta: number; +} + +export type { OpcoreTrySignalSummary }; + +interface OpcoreTryScenario { + id: string; + repoRoot: string; + title: string; + commands: readonly string[]; + coverage: { + totalFiles: number; + validationSupportedFiles: number; + unsupportedFiles: number; + }; + signals: readonly OpcoreTrySignalSummary[]; +} + +export type { OpcoreTryScenario }; + +interface OpcoreTryCommandSummary { + scenarioId: string; + command: readonly string[]; + canonicalCommand: readonly string[]; + owner: CommandOwner; + status: CommandRouteStatus; + exitCode: number; +} + +export type { OpcoreTryCommandSummary }; + +interface OpcoreTryPayload { + schemaVersion: 1; + sampleRoot: string; + published: false; + scenarios: readonly OpcoreTryScenario[]; + commands: readonly OpcoreTryCommandSummary[]; +} + +export type { OpcoreTryPayload }; diff --git a/packages/contracts/src/product/metrics-coverage-validators.ts b/packages/contracts/src/product/metrics-coverage-validators.ts new file mode 100644 index 0000000..561dd33 --- /dev/null +++ b/packages/contracts/src/product/metrics-coverage-validators.ts @@ -0,0 +1,68 @@ +import { validateRequiredObject } from "../shared/validators-02.js"; +import { + validateNonEmptyString, + validateNonNegativeInteger, + validateStringArray, +} from "../shared/validators-01.js"; +import { validateOpcoreCoverageCounts } from "./metrics-validators-05.js"; +import type { OpcoreRepoStatePayload } from "./status-contracts.js"; + +function validateOpcoreMetricCoverage(coverage: OpcoreRepoStatePayload["coverage"], label: string): void { + validateRequiredObject(coverage, `${label} is required`); + validateNonNegativeInteger(coverage.totalFiles, `${label} totalFiles`); + validateOpcoreCoverageLanguages(coverage.languages, label); + validateOpcoreCoverageCounts(coverage.graph, "graph"); + validateOpcoreCoverageCounts(coverage.validation, "validation"); + validateNonNegativeInteger(coverage.validation.retainedFiles, `${label} validation retainedFiles`); + validateOpcoreUnsupportedSection(coverage.unsupported, label); +} + +export { validateOpcoreMetricCoverage }; + +function validateOpcoreCoverageLanguages( + languages: OpcoreRepoStatePayload["coverage"]["languages"], + label: string, +): void { + if (!Array.isArray(languages)) { + throw new Error(`${label} languages must be an array`); + } + for (const language of languages) { + validateNonEmptyString(language.language, `${label} language`); + validateNonNegativeInteger(language.files, `${label} language files`); + if (typeof language.graphSupported !== "boolean" || typeof language.validationSupported !== "boolean") { + throw new Error(`${label} language support flags must be boolean`); + } + } +} + +export { validateOpcoreCoverageLanguages }; + +function validateOpcoreUnsupportedSection( + unsupported: OpcoreRepoStatePayload["coverage"]["unsupported"], + label: string, +): void { + validateRequiredObject(unsupported, `${label} unsupported is required`); + validateNonNegativeInteger(unsupported.totalFiles, `${label} unsupported totalFiles`); + validateOpcoreUnsupportedStacks(unsupported.stacks, label); +} + +export { validateOpcoreUnsupportedSection }; + +function validateOpcoreUnsupportedStacks( + stacks: OpcoreRepoStatePayload["coverage"]["unsupported"]["stacks"], + label: string, +): void { + if (!Array.isArray(stacks)) { + throw new Error(`${label} unsupported stacks must be an array`); + } + for (const stack of stacks) { + validateNonEmptyString(stack.extension, `${label} unsupported extension`); + validateNonEmptyString(stack.language, `${label} unsupported language`); + validateNonNegativeInteger(stack.count, `${label} unsupported count`); + validateStringArray(stack.examples, `${label} unsupported examples`, { + allowEmpty: true, + }); + } +} + +export { validateOpcoreUnsupportedStacks }; diff --git a/packages/contracts/src/product/metrics-validators-01.ts b/packages/contracts/src/product/metrics-validators-01.ts new file mode 100644 index 0000000..c082a8f --- /dev/null +++ b/packages/contracts/src/product/metrics-validators-01.ts @@ -0,0 +1,125 @@ +import { validateRequiredObject } from "../shared/validators-02.js"; +import { validateCommandOwner, validateCommandRouteStatus } from "../command/helper-validators.js"; +import { commandTimingDegradationReasons, commandTimingProcessStates } from "../command/vocabulary.js"; +import { includesString } from "../shared/primitives.js"; +import { + validateExitCodeForStatus, + validateNonEmptyString, + validateNonNegativeInteger, + validateNonNegativeNumber, + validateStringArray, +} from "../shared/validators-01.js"; +import type { CommandLatencyRecord, CommandTiming, LatencyBudget, RepoShapeFingerprint } from "./latency-contracts.js"; +import { + assertNoOpaqueScoreFields, + assertNoTelemetrySourceFields, + validateCommandTimingPhase, + validateLatencyCanonicalCommand, + validateLatencyPhaseBudget, + validateLatencyStableId, + validateLatencyTelemetryCommandBin, +} from "./metrics-validators-05.js"; + +function validateCommandTiming(timing: CommandTiming): CommandTiming { + assertNoOpaqueScoreFields(timing, "Command timing"); + assertNoTelemetrySourceFields(timing, "Command timing"); + validateRequiredObject(timing, "Command timing is required"); + validateNonNegativeNumber(timing.durationMs, "Command timing durationMs"); + if (!Array.isArray(timing.phases)) { + throw new Error("Command timing phases must be an array"); + } + for (const phase of timing.phases) validateCommandTimingPhase(phase); + if (!includesString(commandTimingProcessStates, timing.processState)) { + throw new Error(`Unknown command timing processState: ${String(timing.processState)}`); + } + if (timing.degradations !== undefined) { + validateStringArray(timing.degradations, "Command timing degradations", { + allowEmpty: true, + }); + for (const degradation of timing.degradations) { + if (!includesString(commandTimingDegradationReasons, degradation)) { + throw new Error(`Unknown command timing degradation: ${String(degradation)}`); + } + } + } + return timing; +} + +export { validateCommandTiming }; + +function validateRepoShapeFingerprint(fingerprint: RepoShapeFingerprint): RepoShapeFingerprint { + assertNoOpaqueScoreFields(fingerprint, "Repo shape fingerprint"); + assertNoTelemetrySourceFields(fingerprint, "Repo shape fingerprint"); + validateRequiredObject(fingerprint, "Repo shape fingerprint is required"); + validateNonNegativeInteger(fingerprint.totalFiles, "Repo shape fingerprint totalFiles"); + if (!Array.isArray(fingerprint.languages)) { + throw new Error("Repo shape fingerprint languages must be an array"); + } + for (const language of fingerprint.languages) { + validateNonEmptyString(language.language, "Repo shape fingerprint language"); + validateNonNegativeInteger(language.files, "Repo shape fingerprint language files"); + } + validateRequiredObject(fingerprint.graph, "Repo shape fingerprint graph is required"); + validateNonNegativeInteger(fingerprint.graph.supportedFiles, "Repo shape fingerprint graph supportedFiles"); + validateNonNegativeInteger(fingerprint.graph.unsupportedFiles, "Repo shape fingerprint graph unsupportedFiles"); + validateRequiredObject(fingerprint.git, "Repo shape fingerprint git is required"); + if (typeof fingerprint.git.available !== "boolean") { + throw new Error("Repo shape fingerprint git available must be boolean"); + } + if (fingerprint.git.clean !== undefined && typeof fingerprint.git.clean !== "boolean") { + throw new Error("Repo shape fingerprint git clean must be boolean"); + } + return fingerprint; +} + +export { validateRepoShapeFingerprint }; + +function validateCommandLatencyRecord(record: CommandLatencyRecord): CommandLatencyRecord { + assertNoOpaqueScoreFields(record, "Command latency record"); + assertNoTelemetrySourceFields(record, "Command latency record"); + validateRequiredObject(record, "Command latency record is required"); + if (record.schemaVersion !== 1) { + throw new Error("Command latency record schemaVersion must be 1"); + } + validateNonEmptyString(record.recordedAt, "Command latency record recordedAt"); + validateLatencyTelemetryCommandBin(record.bin, "Command latency record bin"); + validateLatencyCanonicalCommand(record.canonicalCommand, "Command latency record canonicalCommand"); + validateCommandOwner(record.owner); + const status = validateCommandRouteStatus(record.status); + validateExitCodeForStatus(record.exitCode, status); + validateRepoShapeFingerprint(record.repo); + validateCommandTiming(record.timing); + validateNonEmptyString(record.opcoreVersion, "Command latency record opcoreVersion"); + return record; +} + +export { validateCommandLatencyRecord }; + +function validateLatencyBudget(budget: LatencyBudget): LatencyBudget { + assertNoOpaqueScoreFields(budget, "Latency budget"); + assertNoTelemetrySourceFields(budget, "Latency budget"); + validateRequiredObject(budget, "Latency budget is required"); + if (budget.schemaVersion !== 1) { + throw new Error("Latency budget schemaVersion must be 1"); + } + validateLatencyCanonicalCommand(budget.canonicalCommand, "Latency budget canonicalCommand"); + validateLatencyStableId(budget.scope, "Latency budget scope"); + validateLatencyStableId(budget.repoShapeBucket, "Latency budget repoShapeBucket"); + validateNonNegativeNumber(budget.budgetMs, "Latency budget budgetMs"); + if (budget.phaseBudgets !== undefined) { + if (!Array.isArray(budget.phaseBudgets)) { + throw new Error("Latency budget phaseBudgets must be an array"); + } + const phases = new Set(); + for (const phaseBudget of budget.phaseBudgets) { + const validatedPhaseBudget = validateLatencyPhaseBudget(phaseBudget); + if (phases.has(validatedPhaseBudget.phase)) { + throw new Error("Latency budget phaseBudgets must not include duplicate phases"); + } + phases.add(validatedPhaseBudget.phase); + } + } + return budget; +} + +export { validateLatencyBudget }; diff --git a/packages/contracts/src/product/metrics-validators-02.ts b/packages/contracts/src/product/metrics-validators-02.ts new file mode 100644 index 0000000..a763314 --- /dev/null +++ b/packages/contracts/src/product/metrics-validators-02.ts @@ -0,0 +1,212 @@ +import { + validateBoolean, + validateOptional, + validateRequiredObject, +} from "../shared/validators-02.js"; +import { latencyBudgetResultStatuses } from "../command/vocabulary.js"; +import { includesString, sameStringArray } from "../shared/primitives.js"; +import { graphProviderModes, graphProviderStatusStates } from "../graph/vocabulary-01.js"; +import { validateHomeRelativePath, validateRepoRelativePath } from "../shared/path-validators.js"; +import { + validateNonEmptyString, + validateNonNegativeInteger, + validateNonNegativeNumber, + validateStringArray, +} from "../shared/validators-01.js"; +import { validatePythonProjectContexts } from "../validation/python-project-validators-01.js"; +import { validationResultStatuses } from "../validation/vocabulary-01.js"; +import type { OpcoreInitAction} from "./init-contracts.js"; +import { opcoreInitScopes } from "./init-contracts.js"; +import type { LatencyBudgetResult } from "./latency-contracts.js"; +import type { OpcoreMetricReport } from "./metrics-contracts-01.js"; +import { validateOpcoreMetricCoverage } from "./metrics-coverage-validators.js"; +import { validateLatencyBudget } from "./metrics-validators-01.js"; +import { validateOpcoreMetricDegradation, validateOpcoreMetricSignal } from "./metrics-validators-04.js"; +import { + assertNoOpaqueScoreFields, + assertNoTelemetrySourceFields, + resolveLatencyAppliedBudgetMs, + validateLatencyCanonicalCommand, + validateLatencyStableId, +} from "./metrics-validators-05.js"; +import { validateOpcoreValidationPolicySummary } from "./status-validators.js"; + +function validateLatencyBudgetResult(result: LatencyBudgetResult): LatencyBudgetResult { + assertNoOpaqueScoreFields(result, "Latency budget result"); + assertNoTelemetrySourceFields(result, "Latency budget result"); + validateRequiredObject(result, "Latency budget result is required"); + if (result.schemaVersion !== 1) { + throw new Error("Latency budget result schemaVersion must be 1"); + } + if (!includesString(latencyBudgetResultStatuses, result.status)) { + throw new Error(`Unknown latency budget result status: ${String(result.status)}`); + } + const budget = validateLatencyBudget(result.budget); + validateLatencyBudgetResultEvidence(result); + validateLatencyBudgetResultConsistency(result, budget); + validateLatencyBudgetResultStatus(result); + return result; +} + +export { validateLatencyBudgetResult }; + +function validateLatencyBudgetResultEvidence(result: LatencyBudgetResult): void { + validateRequiredObject(result.observed, "Latency budget result observed is required"); + validateLatencyCanonicalCommand(result.observed.canonicalCommand, "Latency budget result observed canonicalCommand"); + validateLatencyStableId(result.observed.phase, "Latency budget result observed phase"); + validateNonNegativeNumber(result.observed.durationMs, "Latency budget result observed durationMs"); + validateRequiredObject(result.evidence, "Latency budget result evidence is required"); + validateLatencyCanonicalCommand(result.evidence.canonicalCommand, "Latency budget result evidence canonicalCommand"); + validateLatencyStableId(result.evidence.phase, "Latency budget result evidence phase"); + validateLatencyStableId(result.evidence.repoShapeBucket, "Latency budget result evidence repoShapeBucket"); + validateNonNegativeNumber(result.evidence.observedMs, "Latency budget result evidence observedMs"); + validateNonNegativeNumber(result.evidence.budgetMs, "Latency budget result evidence budgetMs"); + validateNonNegativeNumber(result.evidence.overByMs, "Latency budget result evidence overByMs"); +} + +function validateLatencyBudgetResultConsistency( + result: LatencyBudgetResult, + budget: ReturnType, +): void { + if (!sameStringArray(result.observed.canonicalCommand, result.evidence.canonicalCommand)) { + throw new Error("Latency budget result observed and evidence commands must match"); + } + if (!sameStringArray(budget.canonicalCommand, result.evidence.canonicalCommand)) { + throw new Error("Latency budget result evidence command must match budget command"); + } + if (result.observed.phase !== result.evidence.phase) { + throw new Error("Latency budget result observed and evidence phases must match"); + } + if (budget.repoShapeBucket !== result.evidence.repoShapeBucket) { + throw new Error("Latency budget result evidence bucket must match budget bucket"); + } + if (result.observed.durationMs !== result.evidence.observedMs) { + throw new Error("Latency budget result observed duration must match evidence observedMs"); + } + const appliedBudgetMs = resolveLatencyAppliedBudgetMs(budget, result.evidence.phase); + if (result.evidence.budgetMs !== appliedBudgetMs) { + throw new Error("Latency budget result evidence budgetMs must match the applied budget"); + } + const computedOverByMs = Math.max(0, result.evidence.observedMs - appliedBudgetMs); + if (result.evidence.overByMs !== computedOverByMs) { + throw new Error("Latency budget result overByMs must equal observedMs over budgetMs"); + } +} + +function validateLatencyBudgetResultStatus(result: LatencyBudgetResult): void { + if (result.status === "pass" && result.evidence.overByMs !== 0) { + throw new Error("Latency budget pass result must not exceed budget"); + } + if (result.status === "over" && result.evidence.overByMs <= 0) { + throw new Error("Latency budget over result must exceed budget"); + } +} + +function validateOpcoreInitAction(action: OpcoreInitAction): OpcoreInitAction { + validateRequiredObject(action, "Opcore init action is required"); + if ( + !includesString(["write", "upsert_block", "create_hook", "wire_harness", "restore", "remove"] as const, action.kind) + ) { + throw new Error(`Unknown Opcore init action kind: ${String(action.kind)}`); + } + if (!includesString(opcoreInitScopes, action.targetScope)) { + throw new Error(`Unknown Opcore init action targetScope: ${String(action.targetScope)}`); + } + const rawPath = validateNonEmptyString(action.path, "Opcore init action path"); + const path = action.targetScope === "global" ? validateHomeRelativePath(rawPath) : validateRepoRelativePath(rawPath); + validateNonEmptyString(action.summary, "Opcore init action summary"); + validateBoolean(action.requiresApproval, "Opcore init action requiresApproval"); + validateBoolean(action.outsideOpcore, "Opcore init action outsideOpcore"); + const insideOpcore = isOpcoreInitPath(action.targetScope, path); + if (action.outsideOpcore === insideOpcore) { + throw new Error("Opcore init action outsideOpcore must match action path"); + } + if (action.outsideOpcore && !action.requiresApproval) { + throw new Error("Opcore init action outside .opcore requires approval"); + } + return action; +} + +export { validateOpcoreInitAction }; + +function isOpcoreInitPath(scope: OpcoreInitAction["targetScope"], path: string): boolean { + if (scope === "global") return path === "~/.opcore" || path.startsWith("~/.opcore/"); + return path === ".opcore" || path.startsWith(".opcore/"); +} + +function validateOpcoreMetricReport(report: OpcoreMetricReport): OpcoreMetricReport { + assertNoOpaqueScoreFields(report, "Opcore metric report"); + validateRequiredObject(report, "Opcore metric report is required"); + if (report.schemaVersion !== 1) { + throw new Error("Opcore metric report schemaVersion must be 1"); + } + if (report.kind !== "opcore_metric_report") { + throw new Error("Opcore metric report kind must be opcore_metric_report"); + } + validateNonEmptyString(report.generatedAt, "Opcore metric report generatedAt"); + validateOpcoreMetricReportRepo(report); + validateOpcoreMetricCoverage(report.coverage, "Opcore metric report coverage"); + validateOpcoreMetricReportGraph(report); + validateOpcoreMetricReportValidation(report); + validateOpcoreMetricReportFindings(report); + validateStringArray(report.warnings, "Opcore metric report warnings", { + allowEmpty: true, + }); + validateStringArray(report.nextActions, "Opcore metric report nextActions", { + allowEmpty: false, + }); + return report; +} + +export { validateOpcoreMetricReport }; + +function validateOpcoreMetricReportRepo(report: OpcoreMetricReport): void { + validateRequiredObject(report.repo, "Opcore metric report repo is required"); + validateNonEmptyString(report.repo.root, "Opcore metric report repo root"); + validateNonEmptyString(report.repo.requestedPath, "Opcore metric report repo requestedPath"); + validateRequiredObject(report.repo.git, "Opcore metric report repo git is required"); + validateBoolean(report.repo.git.available, "Opcore metric report repo git available"); + validateOptional(report.repo.git.branch, (value) => + validateNonEmptyString(value, "Opcore metric report git branch"), + ); + for (const [key, value] of Object.entries(report.repo.git)) { + if (key === "available" || key === "branch" || key === "clean") continue; + validateNonNegativeInteger(value, `Opcore metric report git ${key}`); + } + validateOptional(report.repo.git.clean, (value) => validateBoolean(value, "Opcore metric report git clean")); +} + +function validateOpcoreMetricReportGraph(report: OpcoreMetricReport): void { + validateRequiredObject(report.graph, "Opcore metric report graph is required"); + if (!includesString(graphProviderStatusStates, report.graph.state)) { + throw new Error(`Unknown Opcore metric graph state: ${String(report.graph.state)}`); + } + if (!includesString(graphProviderModes, report.graph.mode)) { + throw new Error(`Unknown Opcore metric graph mode: ${String(report.graph.mode)}`); + } + validateNonEmptyString(report.graph.provider, "Opcore metric report graph provider"); +} + +function validateOpcoreMetricReportValidation(report: OpcoreMetricReport): void { + validateRequiredObject(report.validation, "Opcore metric report validation is required"); + if (report.validation.status !== undefined && !includesString(validationResultStatuses, report.validation.status)) { + throw new Error(`Unknown Opcore metric validation status: ${String(report.validation.status)}`); + } + validateNonNegativeInteger(report.validation.diagnosticCount, "Opcore metric report diagnosticCount"); + validateNonNegativeInteger(report.validation.checkCount, "Opcore metric report checkCount"); + validateOptional(report.validation.policy, (value) => + validateOpcoreValidationPolicySummary(value, "Opcore metric report validation policy"), + ); + validateOptional(report.validation.pythonProjectContexts, validatePythonProjectContexts); +} + +function validateOpcoreMetricReportFindings(report: OpcoreMetricReport): void { + if (!Array.isArray(report.signals)) { + throw new Error("Opcore metric report signals must be an array"); + } + for (const signal of report.signals) validateOpcoreMetricSignal(signal); + if (!Array.isArray(report.degradations)) { + throw new Error("Opcore metric report degradations must be an array"); + } + for (const degradation of report.degradations) validateOpcoreMetricDegradation(degradation); +} diff --git a/packages/contracts/src/product/metrics-validators-03.ts b/packages/contracts/src/product/metrics-validators-03.ts new file mode 100644 index 0000000..61a25cd --- /dev/null +++ b/packages/contracts/src/product/metrics-validators-03.ts @@ -0,0 +1,144 @@ +import { validateRequiredObject } from "../shared/validators-02.js"; +import { validateCommandOwner, validateCommandRouteStatus } from "../command/helper-validators.js"; +import { + validateExitCodeForStatus, + validateNonEmptyArray, + validateNonEmptyString, + validateNonNegativeInteger, + validateStringArray, +} from "../shared/validators-01.js"; +import type { OpcoreMeasureDelta, OpcoreMetricHistoryEntry } from "./metrics-contracts-01.js"; +import type { + OpcoreTryCommandSummary, + OpcoreTryPayload, + OpcoreTryScenario, + OpcoreTrySignalSummary, +} from "./metrics-contracts-02.js"; +import { validateOpcoreMetricCoverage } from "./metrics-coverage-validators.js"; +import { validateOpcoreMetricReport } from "./metrics-validators-02.js"; +import { + validateOpcoreMeasureComparison, + validateOpcoreMeasureLatencyReport, + validateOpcoreMeasureSignalCounts, + validateOpcoreMetricDegradation, +} from "./metrics-validators-04.js"; +import { assertNoOpaqueScoreFields } from "./metrics-validators-05.js"; + +function validateOpcoreMetricHistoryEntry(entry: OpcoreMetricHistoryEntry): OpcoreMetricHistoryEntry { + assertNoOpaqueScoreFields(entry, "Opcore metric history entry"); + validateRequiredObject(entry, "Opcore metric history entry is required"); + if (entry.schemaVersion !== 1) { + throw new Error("Opcore metric history entry schemaVersion must be 1"); + } + if (entry.kind !== "opcore_metric_history_entry") { + throw new Error("Opcore metric history entry kind must be opcore_metric_history_entry"); + } + validateNonEmptyString(entry.recordedAt, "Opcore metric history entry recordedAt"); + validateOpcoreMetricReport(entry.report); + return entry; +} + +export { validateOpcoreMetricHistoryEntry }; + +function validateOpcoreMeasureDelta(delta: OpcoreMeasureDelta): OpcoreMeasureDelta { + assertNoOpaqueScoreFields(delta, "Opcore measure delta"); + validateRequiredObject(delta, "Opcore measure delta is required"); + if (delta.schemaVersion !== 1) { + throw new Error("Opcore measure delta schemaVersion must be 1"); + } + if (delta.kind !== "opcore_measure_delta") { + throw new Error("Opcore measure delta kind must be opcore_measure_delta"); + } + validateNonEmptyString(delta.generatedAt, "Opcore measure delta generatedAt"); + validateRequiredObject(delta.current, "Opcore measure delta current is required"); + validateNonEmptyString(delta.current.generatedAt, "Opcore measure delta current generatedAt"); + validateOpcoreMetricCoverage(delta.current.coverage, "Opcore measure delta current coverage"); + validateOpcoreMeasureSignalCounts(delta.current.signals, "Opcore measure delta current signals"); + if (delta.latency !== undefined) validateOpcoreMeasureLatencyReport(delta.latency); + if (delta.baseline !== undefined) validateOpcoreMeasureComparison(delta.baseline, "baseline"); + if (delta.previous !== undefined) validateOpcoreMeasureComparison(delta.previous, "previous"); + validateStringArray(delta.warnings, "Opcore measure delta warnings", { + allowEmpty: true, + }); + if (!Array.isArray(delta.degradations)) { + throw new Error("Opcore measure delta degradations must be an array"); + } + for (const degradation of delta.degradations) validateOpcoreMetricDegradation(degradation); + validateStringArray(delta.nextActions, "Opcore measure delta nextActions", { + allowEmpty: false, + }); + return delta; +} + +export { validateOpcoreMeasureDelta }; + +function validateOpcoreTryPayload(payload: OpcoreTryPayload): OpcoreTryPayload { + assertNoOpaqueScoreFields(payload, "Opcore try payload"); + validateRequiredObject(payload, "Opcore try payload is required"); + if (payload.schemaVersion !== 1) { + throw new Error("Opcore try payload schemaVersion must be 1"); + } + validateNonEmptyString(payload.sampleRoot, "Opcore try sampleRoot"); + if (payload.published !== false) { + throw new Error("Opcore try published must be false"); + } + validateNonEmptyArray(payload.scenarios, "Opcore try scenarios"); + for (const scenario of payload.scenarios) validateOpcoreTryScenario(scenario); + validateNonEmptyArray(payload.commands, "Opcore try commands"); + for (const command of payload.commands) validateOpcoreTryCommand(command); + return payload; +} + +export { validateOpcoreTryPayload }; + +function validateOpcoreTryScenario(scenario: OpcoreTryScenario): OpcoreTryScenario { + validateRequiredObject(scenario, "Opcore try scenario is required"); + validateNonEmptyString(scenario.id, "Opcore try scenario id"); + validateNonEmptyString(scenario.repoRoot, "Opcore try scenario repoRoot"); + validateNonEmptyString(scenario.title, "Opcore try scenario title"); + validateStringArray(scenario.commands, "Opcore try scenario commands", { + allowEmpty: false, + }); + validateRequiredObject(scenario.coverage, "Opcore try scenario coverage is required"); + validateNonNegativeInteger(scenario.coverage.totalFiles, "Opcore try scenario totalFiles"); + validateNonNegativeInteger( + scenario.coverage.validationSupportedFiles, + "Opcore try scenario validationSupportedFiles", + ); + validateNonNegativeInteger(scenario.coverage.unsupportedFiles, "Opcore try scenario unsupportedFiles"); + if (!Array.isArray(scenario.signals)) { + throw new Error("Opcore try scenario signals must be an array"); + } + for (const signal of scenario.signals) validateOpcoreTrySignal(signal); + return scenario; +} + +export { validateOpcoreTryScenario }; + +function validateOpcoreTrySignal(signal: OpcoreTrySignalSummary): OpcoreTrySignalSummary { + validateRequiredObject(signal, "Opcore try signal is required"); + validateNonEmptyString(signal.id, "Opcore try signal id"); + validateNonEmptyString(signal.title, "Opcore try signal title"); + validateNonNegativeInteger(signal.count, "Opcore try signal count"); + if (!Number.isInteger(signal.delta)) { + throw new Error("Opcore try signal delta must be an integer"); + } + return signal; +} + +export { validateOpcoreTrySignal }; + +function validateOpcoreTryCommand(command: OpcoreTryCommandSummary): OpcoreTryCommandSummary { + validateRequiredObject(command, "Opcore try command is required"); + validateNonEmptyString(command.scenarioId, "Opcore try command scenarioId"); + validateStringArray(command.command, "Opcore try command", { + allowEmpty: false, + }); + validateStringArray(command.canonicalCommand, "Opcore try command canonicalCommand", { allowEmpty: false }); + validateCommandOwner(command.owner); + validateCommandRouteStatus(command.status); + validateExitCodeForStatus(command.exitCode, command.status); + return command; +} + +export { validateOpcoreTryCommand }; diff --git a/packages/contracts/src/product/metrics-validators-04.ts b/packages/contracts/src/product/metrics-validators-04.ts new file mode 100644 index 0000000..a7a27bf --- /dev/null +++ b/packages/contracts/src/product/metrics-validators-04.ts @@ -0,0 +1,203 @@ +import { validateOptional, validateRequiredObject } from "../shared/validators-02.js"; +import { commandTimingProcessStates } from "../command/vocabulary.js"; +import { includesString } from "../shared/primitives.js"; +import { validateRepoRelativePath } from "../shared/path-validators.js"; +import { + validateNonEmptyString, + validateNonNegativeInteger, + validateNonNegativeNumber, + validateValidationCheckId, +} from "../shared/validators-01.js"; +import type { + OpcoreMeasureComparison, + OpcoreMeasureLatencyFinding, + OpcoreMeasureLatencyPhase, + OpcoreMeasureLatencyReport, + OpcoreMeasureSignalCount, + OpcoreMeasureSignalDelta, + OpcoreMetricDegradation, + OpcoreMetricEvidence, + OpcoreMetricSignal} from "./metrics-contracts-01.js"; +import { + opcoreMeasureLatencyFindingStatuses, +} from "./metrics-contracts-01.js"; +import { validateOpcoreMetricCoverage } from "./metrics-coverage-validators.js"; +import { + assertNoOpaqueScoreFields, + assertNoTelemetrySourceFields, + validateLatencyCanonicalCommand, + validateLatencyStableId, +} from "./metrics-validators-05.js"; + +function validateOpcoreMetricSignal(signal: OpcoreMetricSignal): OpcoreMetricSignal { + validateRequiredObject(signal, "Opcore metric signal is required"); + validateNonEmptyString(signal.id, "Opcore metric signal id"); + validateNonEmptyString(signal.title, "Opcore metric signal title"); + validateNonEmptyString(signal.category, "Opcore metric signal category"); + if (!includesString(["info", "warning", "error"] as const, signal.severity)) { + throw new Error(`Unknown Opcore metric signal severity: ${String(signal.severity)}`); + } + if (!Number.isInteger(signal.count) || signal.count <= 0) { + throw new Error("Opcore metric signal count must be a positive integer"); + } + if (!Array.isArray(signal.evidence) || signal.evidence.length === 0) { + throw new Error("Opcore metric signal evidence must be a non-empty array"); + } + for (const evidence of signal.evidence) validateOpcoreMetricEvidence(evidence); + return signal; +} + +export { validateOpcoreMetricSignal }; + +function validateOpcoreMetricEvidence(evidence: OpcoreMetricEvidence): OpcoreMetricEvidence { + validateRequiredObject(evidence, "Opcore metric evidence is required"); + validateNonEmptyString(evidence.source, "Opcore metric evidence source"); + validateRepoRelativePath(validateNonEmptyString(evidence.path, "Opcore metric evidence path")); + validateNonEmptyString(evidence.message, "Opcore metric evidence message"); + if (evidence.checkId !== undefined) validateValidationCheckId(evidence.checkId, "Opcore metric evidence checkId"); + if (evidence.code !== undefined) validateNonEmptyString(evidence.code, "Opcore metric evidence code"); + if (evidence.line !== undefined && (!Number.isInteger(evidence.line) || evidence.line < 1)) { + throw new Error("Opcore metric evidence line must be a positive integer"); + } + if (evidence.column !== undefined && (!Number.isInteger(evidence.column) || evidence.column < 1)) { + throw new Error("Opcore metric evidence column must be a positive integer"); + } + return evidence; +} + +export { validateOpcoreMetricEvidence }; + +function validateOpcoreMetricDegradation(degradation: OpcoreMetricDegradation): OpcoreMetricDegradation { + validateRequiredObject(degradation, "Opcore metric degradation is required"); + validateNonEmptyString(degradation.id, "Opcore metric degradation id"); + validateNonEmptyString(degradation.title, "Opcore metric degradation title"); + validateNonEmptyString(degradation.source, "Opcore metric degradation source"); + if (!includesString(["info", "warning", "error"] as const, degradation.severity)) { + throw new Error(`Unknown Opcore metric degradation severity: ${String(degradation.severity)}`); + } + validateNonEmptyString(degradation.message, "Opcore metric degradation message"); + if (degradation.checkId !== undefined) + validateValidationCheckId(degradation.checkId, "Opcore metric degradation checkId"); + if (degradation.requiredTool !== undefined) { + validateNonEmptyString(degradation.requiredTool, "Opcore metric degradation requiredTool"); + } + return degradation; +} + +export { validateOpcoreMetricDegradation }; + +function validateOpcoreMeasureLatencyReport(report: OpcoreMeasureLatencyReport): OpcoreMeasureLatencyReport { + assertNoOpaqueScoreFields(report, "Opcore measure latency report"); + assertNoTelemetrySourceFields(report, "Opcore measure latency report"); + validateRequiredObject(report, "Opcore measure latency report is required"); + if (report.kind !== "opcore_latency_report") { + throw new Error("Opcore measure latency report kind must be opcore_latency_report"); + } + validateNonNegativeInteger(report.recordCount, "Opcore measure latency report recordCount"); + validateNonNegativeInteger(report.budgetCount, "Opcore measure latency report budgetCount"); + if (!Array.isArray(report.findings)) { + throw new Error("Opcore measure latency report findings must be an array"); + } + for (const finding of report.findings) validateOpcoreMeasureLatencyFinding(finding); + return report; +} + +export { validateOpcoreMeasureLatencyReport }; + +function validateOpcoreMeasureLatencyFinding(finding: OpcoreMeasureLatencyFinding): OpcoreMeasureLatencyFinding { + assertNoOpaqueScoreFields(finding, "Opcore measure latency finding"); + assertNoTelemetrySourceFields(finding, "Opcore measure latency finding"); + validateRequiredObject(finding, "Opcore measure latency finding is required"); + validateLatencyCanonicalCommand(finding.canonicalCommand, "Opcore measure latency finding canonicalCommand"); + validateLatencyStableId(finding.repoShapeBucket, "Opcore measure latency finding repoShapeBucket"); + if (!includesString(commandTimingProcessStates, finding.processState)) { + throw new Error(`Unknown Opcore measure latency processState: ${String(finding.processState)}`); + } + if (!includesString(opcoreMeasureLatencyFindingStatuses, finding.status)) { + throw new Error(`Unknown Opcore measure latency status: ${String(finding.status)}`); + } + validateNonNegativeNumber(finding.currentDurationMs, "Opcore measure latency finding currentDurationMs"); + validateOptional(finding.dominantPhase, validateOpcoreMeasureLatencyPhase); + validateOptional(finding.baselineDurationMs, (value) => + validateNonNegativeNumber(value, "Opcore measure latency finding baselineDurationMs"), + ); + validateOptional(finding.previousDurationMs, (value) => + validateNonNegativeNumber(value, "Opcore measure latency finding previousDurationMs"), + ); + validateOptional(finding.baselineDeltaMs, (value) => + validateFiniteLatencyDelta(value, "baselineDeltaMs"), + ); + validateOptional(finding.previousDeltaMs, (value) => + validateFiniteLatencyDelta(value, "previousDeltaMs"), + ); + validateOptional(finding.budgetMs, (value) => + validateNonNegativeNumber(value, "Opcore measure latency finding budgetMs"), + ); + validateOptional(finding.overBudgetMs, (value) => + validateNonNegativeNumber(value, "Opcore measure latency finding overBudgetMs"), + ); + if (finding.status === "over_budget" && (finding.overBudgetMs ?? 0) <= 0) { + throw new Error("Opcore measure latency over_budget finding must include overBudgetMs"); + } + return finding; +} + +export { validateOpcoreMeasureLatencyFinding }; + +function validateFiniteLatencyDelta(value: number, field: "baselineDeltaMs" | "previousDeltaMs"): void { + if (!Number.isFinite(value)) { + throw new Error(`Opcore measure latency finding ${field} must be a finite number`); + } +} + +function validateOpcoreMeasureLatencyPhase(phase: OpcoreMeasureLatencyPhase): OpcoreMeasureLatencyPhase { + validateRequiredObject(phase, "Opcore measure latency phase is required"); + validateLatencyStableId(phase.phase, "Opcore measure latency phase"); + validateNonNegativeNumber(phase.durationMs, "Opcore measure latency phase durationMs"); + return phase; +} + +export { validateOpcoreMeasureLatencyPhase }; + +function validateOpcoreMeasureComparison(comparison: OpcoreMeasureComparison, label: string): OpcoreMeasureComparison { + validateRequiredObject(comparison, `Opcore measure delta ${label} comparison is required`); + validateNonEmptyString(comparison.recordedAt, `Opcore measure delta ${label} recordedAt`); + validateNonEmptyString(comparison.generatedAt, `Opcore measure delta ${label} generatedAt`); + validateOpcoreMetricCoverage(comparison.coverage, `Opcore measure delta ${label} coverage`); + validateOpcoreMeasureSignalCounts(comparison.signals, `Opcore measure delta ${label} signals`); + if (!Array.isArray(comparison.deltas)) { + throw new Error(`Opcore measure delta ${label} deltas must be an array`); + } + for (const entry of comparison.deltas) validateOpcoreMeasureSignalDelta(entry, label); + return comparison; +} + +export { validateOpcoreMeasureComparison }; + +function validateOpcoreMeasureSignalCounts(counts: readonly OpcoreMeasureSignalCount[], label: string): void { + if (!Array.isArray(counts)) { + throw new Error(`${label} must be an array`); + } + for (const count of counts) { + validateRequiredObject(count, `${label} entry is required`); + validateNonEmptyString(count.id, `${label} id`); + validateNonEmptyString(count.title, `${label} title`); + validateNonNegativeInteger(count.count, `${label} count`); + } +} + +export { validateOpcoreMeasureSignalCounts }; + +function validateOpcoreMeasureSignalDelta(delta: OpcoreMeasureSignalDelta, label: string): OpcoreMeasureSignalDelta { + validateRequiredObject(delta, `Opcore measure delta ${label} entry is required`); + validateNonEmptyString(delta.id, `Opcore measure delta ${label} id`); + validateNonEmptyString(delta.title, `Opcore measure delta ${label} title`); + validateNonNegativeInteger(delta.currentCount, `Opcore measure delta ${label} currentCount`); + validateNonNegativeInteger(delta.comparisonCount, `Opcore measure delta ${label} comparisonCount`); + if (!Number.isInteger(delta.delta)) { + throw new Error(`Opcore measure delta ${label} delta must be an integer`); + } + return delta; +} + +export { validateOpcoreMeasureSignalDelta }; diff --git a/packages/contracts/src/product/metrics-validators-05.ts b/packages/contracts/src/product/metrics-validators-05.ts new file mode 100644 index 0000000..cc0fe89 --- /dev/null +++ b/packages/contracts/src/product/metrics-validators-05.ts @@ -0,0 +1,164 @@ +import { validateRequiredObject } from "../shared/validators-02.js"; +import type { CommandLatencyTelemetryBin} from "../command/vocabulary.js"; +import { commandLatencyTelemetryBins } from "../command/vocabulary.js"; +import { includesString } from "../shared/primitives.js"; +import { + validateNonEmptyString, + validateNonNegativeInteger, + validateNonNegativeNumber, + validateStringArray, +} from "../shared/validators-01.js"; +import { latencyStableIdRegex, latencyTelemetryCommandTokenRegex } from "../validation/vocabulary-02.js"; +import { latencyTelemetrySourceFileExtensionRegex } from "../validation/vocabulary-03.js"; +import type { CommandTimingPhase, LatencyBudget, LatencyPhaseBudget } from "./latency-contracts.js"; +import type { OpcoreRepoStatePayload } from "./status-contracts.js"; + +function assertNoOpaqueScoreFields(value: unknown, label: string): void { + if (!value || typeof value !== "object") return; + if (Array.isArray(value)) { + for (const entry of value) assertNoOpaqueScoreFields(entry, label); + return; + } + for (const [key, entry] of Object.entries(value)) { + if (key === "score" || key === "blendedScore") { + throw new Error(`${label} must not include opaque score fields`); + } + assertNoOpaqueScoreFields(entry, label); + } +} + +export { assertNoOpaqueScoreFields }; + +function assertNoTelemetrySourceFields(value: unknown, label: string): void { + const blockedKeys = new Set([ + "root", + "requestedPath", + "path", + "paths", + "examples", + "content", + "contents", + "source", + "secret", + "secrets", + "token", + "tokens", + "apiKey", + "password", + ]); + visitTelemetryValue(value); + + function visitTelemetryValue(entry: unknown): void { + if (!entry || typeof entry !== "object") return; + if (Array.isArray(entry)) { + for (const item of entry) visitTelemetryValue(item); + return; + } + for (const [key, child] of Object.entries(entry)) { + if (blockedKeys.has(key)) { + throw new Error(`${label} must remain source-safe and must not include ${key}`); + } + visitTelemetryValue(child); + } + } +} + +export { assertNoTelemetrySourceFields }; + +function validateCommandTimingPhase(phase: CommandTimingPhase): CommandTimingPhase { + validateRequiredObject(phase, "Command timing phase is required"); + validateLatencyStableId(phase.phase, "Command timing phase"); + validateNonNegativeNumber(phase.durationMs, "Command timing phase durationMs"); + if (phase.fileCount !== undefined) validateNonNegativeInteger(phase.fileCount, "Command timing phase fileCount"); + return phase; +} + +export { validateCommandTimingPhase }; + +function validateLatencyPhaseBudget(phaseBudget: LatencyPhaseBudget): LatencyPhaseBudget { + validateRequiredObject(phaseBudget, "Latency phase budget is required"); + validateLatencyStableId(phaseBudget.phase, "Latency phase budget phase"); + validateNonNegativeNumber(phaseBudget.budgetMs, "Latency phase budget budgetMs"); + return phaseBudget; +} + +export { validateLatencyPhaseBudget }; + +function resolveLatencyAppliedBudgetMs(budget: LatencyBudget, phase: string): number { + if (phase === "total") return budget.budgetMs; + const phaseBudget = budget.phaseBudgets?.find((entry) => entry.phase === phase); + if (!phaseBudget) { + throw new Error("Latency budget result phase must match total or a configured phase budget"); + } + return phaseBudget.budgetMs; +} + +export { resolveLatencyAppliedBudgetMs }; + +function validateLatencyStableId(value: unknown, label: string): string { + const stableId = validateNonEmptyString(value, label); + if (!latencyStableIdRegex.test(stableId)) { + throw new Error(`${label} must be a stable latency id`); + } + return stableId; +} + +export { validateLatencyStableId }; + +function validateLatencyTelemetryCommandBin(value: unknown, label: string): CommandLatencyTelemetryBin { + const bin = validateNonEmptyString(value, label); + if (!includesString(commandLatencyTelemetryBins, bin)) { + throw new Error(`${label} must be a source-safe command bin`); + } + return bin; +} + +export { validateLatencyTelemetryCommandBin }; + +function validateLatencyCanonicalCommand(command: readonly string[], label: string): readonly string[] { + const parts = validateStringArray(command, label, { allowEmpty: false }); + for (const [index, part] of parts.entries()) { + validateLatencyCanonicalCommandToken(part, `${label} entry ${index}`); + } + return parts; +} + +export { validateLatencyCanonicalCommand }; + +function validateLatencyCanonicalCommandToken(value: string, label: string): string { + if (!latencyTelemetryCommandTokenRegex.test(value)) { + throw new Error(`${label} must be a source-safe canonicalCommand token`); + } + if ( + value.includes("/") || + value.includes("\\") || + value === "." || + value === ".." || + value.startsWith("~") || + /^[A-Za-z]:/.test(value) || + /^file:/i.test(value) || + latencyTelemetrySourceFileExtensionRegex.test(value) + ) { + throw new Error(`${label} must be a source-safe canonicalCommand token`); + } + return value; +} + +export { validateLatencyCanonicalCommandToken }; + +function validateOpcoreCoverageCounts( + section: OpcoreRepoStatePayload["coverage"]["graph"] | OpcoreRepoStatePayload["coverage"]["validation"], + label: string, +): void { + validateRequiredObject(section, `Opcore repo state ${label} coverage is required`); + validateNonNegativeInteger(section.supportedFiles, `Opcore repo state ${label} supportedFiles`); + if (!Array.isArray(section.extensions)) { + throw new Error(`Opcore repo state ${label} extensions must be an array`); + } + for (const entry of section.extensions) { + validateNonEmptyString(entry.extension, `Opcore repo state ${label} extension`); + validateNonNegativeInteger(entry.count, `Opcore repo state ${label} count`); + } +} + +export { validateOpcoreCoverageCounts }; diff --git a/packages/contracts/src/product/status-contracts.ts b/packages/contracts/src/product/status-contracts.ts new file mode 100644 index 0000000..ca36067 --- /dev/null +++ b/packages/contracts/src/product/status-contracts.ts @@ -0,0 +1,154 @@ +import type { GraphProviderStatus } from "../graph/provider-contracts-02.js"; +import type { GraphProviderMode, GraphProviderStatusState } from "../graph/vocabulary-01.js"; +import type { PythonProjectContext } from "../validation/python-project-contracts-02.js"; +import type { ValidationAdapterRuntimeState } from "../validation/status-contracts.js"; + +interface OpcoreRepoStatePayload { + schemaVersion: 1; + repo: { + root: string; + requestedPath: string; + git: { + available: boolean; + branch?: string; + changed?: number; + staged?: number; + unstaged?: number; + untracked?: number; + conflicted?: number; + clean?: boolean; + }; + }; + coverage: { + totalFiles: number; + languages: readonly { + language: string; + files: number; + graphSupported: boolean; + validationSupported: boolean; + }[]; + graph: { + supportedFiles: number; + extensions: readonly { + extension: string; + count: number; + }[]; + }; + validation: { + supportedFiles: number; + retainedFiles: number; + extensions: readonly { + extension: string; + count: number; + }[]; + }; + unsupported: { + totalFiles: number; + stacks: readonly { + extension: string; + language: string; + count: number; + examples: readonly string[]; + }[]; + }; + }; + graph: { + state: GraphProviderStatusState; + mode: GraphProviderMode; + provider: string; + action: string; + message?: string; + status: GraphProviderStatus; + }; + validation: { + ready: boolean; + checkCount: number; + policy: OpcoreValidationPolicySummary; + adapters: readonly { + adapter: string; + status: ValidationAdapterRuntimeState; + checkCount: number; + degradedChecks: readonly string[]; + missingTools: readonly string[]; + }[]; + degradedToolchains: readonly { + adapter: string; + tool: string; + failureMessage?: string; + }[]; + pythonProjectContexts?: readonly PythonProjectContext[]; + }; + activation: { + ready: boolean; + level: "ready" | "degraded" | "blocked"; + summary: string; + asp: { + state: "enrolled" | "not_enrolled"; + paths: readonly string[]; + }; + }; + warnings: readonly string[]; + blockers: readonly string[]; + nextActions: readonly string[]; +} + +export type { OpcoreRepoStatePayload }; + +interface OpcoreValidationPolicySummary { + path: ".opcore/config"; + state: "missing" | "loaded"; + adapters: readonly string[]; + packs: readonly string[]; + disabledChecks: readonly string[]; + defaultChecks: readonly string[]; + configuredChecks: readonly string[]; +} + +export type { OpcoreValidationPolicySummary }; + +const opcoreRuntimeArtifactSources = ["source_checkout", "installed_package", "unknown"] as const; + +export { opcoreRuntimeArtifactSources }; + +type OpcoreRuntimeArtifactSource = (typeof opcoreRuntimeArtifactSources)[number]; + +export type { OpcoreRuntimeArtifactSource }; + +interface OpcoreRuntimeInfoPayload { + schemaVersion: 1; + packageName: "opcore"; + version: string; + bin: "opcore"; + artifactSource: OpcoreRuntimeArtifactSource; + packageRoot: string; + entrypoint: string; +} + +export type { OpcoreRuntimeInfoPayload }; + +interface OpcoreDoctorPayload { + schemaVersion: 1; + runtime: OpcoreRuntimeInfoPayload; + repo: { + root: string; + requestedPath: string; + }; + config: { + path: ".opcore/config"; + state: "found" | "missing" | "unreadable"; + message?: string; + }; + checks: { + count: number; + ids: readonly string[]; + }; + policy: OpcoreValidationPolicySummary; + graph: GraphProviderStatus; + generatedState: { + ignored: readonly string[]; + guidance: string; + }; + nextActions: readonly string[]; +} + +export type { OpcoreDoctorPayload }; diff --git a/packages/contracts/src/product/status-validators.ts b/packages/contracts/src/product/status-validators.ts new file mode 100644 index 0000000..7417cb3 --- /dev/null +++ b/packages/contracts/src/product/status-validators.ts @@ -0,0 +1,180 @@ +import { validateRequiredObject } from "../shared/validators-02.js"; +import { includesString } from "../shared/primitives.js"; +import { validateProviderStatus } from "../graph/provider-validators.js"; +import { graphProviderModes, graphProviderStatusStates } from "../graph/vocabulary-01.js"; +import { validateNonEmptyString, validateNonNegativeInteger, validateStringArray } from "../shared/validators-01.js"; +import { validateArray, validateBoolean, validateObject, validateOptional } from "../shared/validators-02.js"; +import { validatePythonProjectContexts } from "../validation/python-project-validators-01.js"; +import { validationAdapterRuntimeStates } from "../validation/status-contracts.js"; +import { validateOpcoreCoverageCounts } from "./metrics-validators-05.js"; +import type { OpcoreRepoStatePayload, OpcoreValidationPolicySummary } from "./status-contracts.js"; + +function validateOpcoreRepoStatePayload(payload: OpcoreRepoStatePayload): OpcoreRepoStatePayload { + validateObject(payload, "Opcore repo state payload"); + if (payload.schemaVersion !== 1) { + throw new Error("Opcore repo state payload schemaVersion must be 1"); + } + validateOpcoreRepo(payload.repo); + validateOpcoreCoverage(payload.coverage); + validateOpcoreGraph(payload.graph); + validateOpcoreValidation(payload.validation); + validateOpcoreActivation(payload.activation); + validateStringArray(payload.warnings, "Opcore repo state warnings", { allowEmpty: true }); + validateStringArray(payload.blockers, "Opcore repo state blockers", { allowEmpty: true }); + validateStringArray(payload.nextActions, "Opcore repo state nextActions", { allowEmpty: false }); + return payload; +} + +export { validateOpcoreRepoStatePayload }; + +function validateOpcoreRepo(repo: OpcoreRepoStatePayload["repo"]): void { + validateObject(repo, "Opcore repo state repo"); + validateNonEmptyString(repo.root, "Opcore repo state repo root"); + validateNonEmptyString(repo.requestedPath, "Opcore repo state requested path"); + validateObject(repo.git, "Opcore repo state git payload"); + validateBoolean(repo.git.available, "Opcore repo state git available"); + validateOptional(repo.git.branch, (branch) => validateNonEmptyString(branch, "Opcore repo state git branch")); + for (const [key, value] of Object.entries(repo.git)) { + if (key === "available" || key === "branch" || key === "clean") continue; + validateNonNegativeInteger(value, `Opcore repo state git ${key}`); + } + validateOptional(repo.git.clean, (clean) => validateBoolean(clean, "Opcore repo state git clean")); +} + +export { validateOpcoreRepo }; + +function validateOpcoreCoverage(coverage: OpcoreRepoStatePayload["coverage"]): void { + validateObject(coverage, "Opcore repo state coverage"); + validateNonNegativeInteger(coverage.totalFiles, "Opcore repo state coverage totalFiles"); + validateArray(coverage.languages, "Opcore repo state coverage languages"); + for (const language of coverage.languages) validateOpcoreLanguageCoverage(language); + validateOpcoreCoverageCounts(coverage.graph, "graph"); + validateOpcoreCoverageCounts(coverage.validation, "validation"); + validateNonNegativeInteger(coverage.validation.retainedFiles, "Opcore repo state validation retainedFiles"); + validateObject(coverage.unsupported, "Opcore repo state unsupported coverage"); + validateNonNegativeInteger(coverage.unsupported.totalFiles, "Opcore repo state unsupported totalFiles"); + validateArray(coverage.unsupported.stacks, "Opcore repo state unsupported stacks"); + for (const stack of coverage.unsupported.stacks) validateOpcoreUnsupportedStack(stack); +} + +export { validateOpcoreCoverage }; + +function validateOpcoreLanguageCoverage(language: OpcoreRepoStatePayload["coverage"]["languages"][number]): void { + validateNonEmptyString(language.language, "Opcore repo state language"); + validateNonNegativeInteger(language.files, "Opcore repo state language files"); + validateBoolean(language.graphSupported, "Opcore repo state language graphSupported"); + validateBoolean(language.validationSupported, "Opcore repo state language validationSupported"); +} + +export { validateOpcoreLanguageCoverage }; + +function validateOpcoreUnsupportedStack( + stack: OpcoreRepoStatePayload["coverage"]["unsupported"]["stacks"][number], +): void { + validateNonEmptyString(stack.extension, "Opcore repo state unsupported extension"); + validateNonEmptyString(stack.language, "Opcore repo state unsupported language"); + validateNonNegativeInteger(stack.count, "Opcore repo state unsupported count"); + validateStringArray(stack.examples, "Opcore repo state unsupported examples", { allowEmpty: true }); +} + +export { validateOpcoreUnsupportedStack }; + +function validateOpcoreGraph(graph: OpcoreRepoStatePayload["graph"]): void { + validateObject(graph, "Opcore repo state graph"); + if (!includesString(graphProviderStatusStates, graph.state)) { + throw new Error(`Unknown Opcore repo state graph state: ${String(graph.state)}`); + } + if (!includesString(graphProviderModes, graph.mode)) { + throw new Error(`Unknown Opcore repo state graph mode: ${String(graph.mode)}`); + } + validateNonEmptyString(graph.provider, "Opcore repo state graph provider"); + validateNonEmptyString(graph.action, "Opcore repo state graph action"); + validateOptional(graph.message, (message) => validateNonEmptyString(message, "Opcore repo state graph message")); + const graphStatus = validateProviderStatus(graph.status); + if (graphStatus.state !== graph.state || graphStatus.mode !== graph.mode || graphStatus.provider !== graph.provider) { + throw new Error("Opcore repo state graph summary must match provider status"); + } +} + +export { validateOpcoreGraph }; + +function validateOpcoreValidation(validation: OpcoreRepoStatePayload["validation"]): void { + validateObject(validation, "Opcore repo state validation"); + validateBoolean(validation.ready, "Opcore repo state validation ready"); + validateNonNegativeInteger(validation.checkCount, "Opcore repo state validation checkCount"); + validateOpcoreValidationPolicySummary(validation.policy, "Opcore repo state validation policy"); + validateArray(validation.adapters, "Opcore repo state validation adapters"); + for (const adapter of validation.adapters) validateOpcoreAdapter(adapter); + validateArray(validation.degradedToolchains, "Opcore repo state validation degradedToolchains"); + for (const tool of validation.degradedToolchains) validateOpcoreDegradedTool(tool); + validateOptional(validation.pythonProjectContexts, validatePythonProjectContexts); +} + +export { validateOpcoreValidation }; + +function validateOpcoreAdapter(adapter: OpcoreRepoStatePayload["validation"]["adapters"][number]): void { + validateNonEmptyString(adapter.adapter, "Opcore repo state validation adapter"); + if (!includesString(validationAdapterRuntimeStates, adapter.status)) { + throw new Error(`Unknown Opcore validation adapter status: ${String(adapter.status)}`); + } + validateNonNegativeInteger(adapter.checkCount, "Opcore repo state validation adapter checkCount"); + validateStringArray(adapter.degradedChecks, "Opcore repo state validation degradedChecks", { allowEmpty: true }); + validateStringArray(adapter.missingTools, "Opcore repo state validation missingTools", { allowEmpty: true }); +} + +export { validateOpcoreAdapter }; + +function validateOpcoreDegradedTool(tool: OpcoreRepoStatePayload["validation"]["degradedToolchains"][number]): void { + validateNonEmptyString(tool.adapter, "Opcore repo state validation degraded adapter"); + validateNonEmptyString(tool.tool, "Opcore repo state validation degraded tool"); + validateOptional(tool.failureMessage, (message) => + validateNonEmptyString(message, "Opcore repo state validation degraded failureMessage"), + ); +} + +export { validateOpcoreDegradedTool }; + +function validateOpcoreActivation(activation: OpcoreRepoStatePayload["activation"]): void { + validateObject(activation, "Opcore repo state activation"); + validateBoolean(activation.ready, "Opcore repo state activation ready"); + if (!includesString(["ready", "degraded", "blocked"] as const, activation.level)) { + throw new Error(`Unknown Opcore activation level: ${String(activation.level)}`); + } + validateNonEmptyString(activation.summary, "Opcore repo state activation summary"); + validateObject(activation.asp, "Opcore repo state ASP status"); + if (!includesString(["enrolled", "not_enrolled"] as const, activation.asp.state)) { + throw new Error(`Unknown Opcore ASP state: ${String(activation.asp.state)}`); + } + validateStringArray(activation.asp.paths, "Opcore repo state ASP paths", { allowEmpty: true }); +} + +export { validateOpcoreActivation }; + +function validateOpcoreValidationPolicySummary( + summary: OpcoreValidationPolicySummary, + label: string, +): OpcoreValidationPolicySummary { + validateRequiredObject(summary, `${label} is required`); + if (summary.path !== ".opcore/config") { + throw new Error(`${label} path must be .opcore/config`); + } + if (!includesString(["missing", "loaded"] as const, summary.state)) { + throw new Error(`Unknown ${label} state: ${String(summary.state)}`); + } + validateStringArray(summary.adapters, `${label} adapters`, { + allowEmpty: true, + }); + validateStringArray(summary.packs, `${label} packs`, { allowEmpty: true }); + validateStringArray(summary.disabledChecks, `${label} disabledChecks`, { + allowEmpty: true, + }); + validateStringArray(summary.defaultChecks, `${label} defaultChecks`, { + allowEmpty: true, + }); + validateStringArray(summary.configuredChecks, `${label} configuredChecks`, { + allowEmpty: true, + }); + return summary; +} + +export { validateOpcoreValidationPolicySummary }; diff --git a/packages/contracts/src/release/asp-contracts-01.ts b/packages/contracts/src/release/asp-contracts-01.ts new file mode 100644 index 0000000..cd27c37 --- /dev/null +++ b/packages/contracts/src/release/asp-contracts-01.ts @@ -0,0 +1,189 @@ +import type { OpcoreSelfValidationReceipt, ReleaseCutoverInstalledPackageEvidence } from "./cutover-contracts.js"; +import type { ReleaseReceiptPackageName } from "./vocabulary-01.js"; +import type { AspDogfoodForbiddenProviderMarker, AspDogfoodUnsupportedSurfaceId } from "./vocabulary-02.js"; + +interface AspDogfoodManagerEvidence { + bootstrapSource: "local-sibling"; + aspRepoPath: string; + aspBinPath: string; + cliPath: string; + commitSha: string; +} + +export type { AspDogfoodManagerEvidence }; + +interface AspDogfoodAspHomeEvidence { + path: string; + temp: true; + isolated: true; + sharedStateMutated: false; + pathSanitized: true; +} + +export type { AspDogfoodAspHomeEvidence }; + +interface AspDogfoodHostFixtureEvidence { + repo: string; + temp: true; + sourceRepoMutated: false; + baselineCommitted: true; + changedPaths: readonly string[]; +} + +export type { AspDogfoodHostFixtureEvidence }; + +interface AspDogfoodCommandRunReceipt { + id: string; + command: readonly string[]; + status: "passed" | "failed" | "retained-not-run"; + exitCode: number | null; + stdoutSha256: string; + stderrSha256: string; + output?: unknown; + assertion: string; +} + +export type { AspDogfoodCommandRunReceipt }; + +interface AspDogfoodProviderManifestEvidence { + manifestPath: string; + manifestSha256: string; + manifest: unknown; +} + +export type { AspDogfoodProviderManifestEvidence }; + +interface AspDogfoodProviderEvidence { + providerId: "opcore"; + packageName: "opcore"; + binPath: string; + indexPath: string; + indexSha256: string; + command: readonly ["opcore-asp-provider", "--stdio"]; + entrypoint: { + transport: "stdio"; + bin: string; + args: readonly ["--stdio"]; + }; + manifest: AspDogfoodProviderManifestEvidence; +} + +export type { AspDogfoodProviderEvidence }; + +interface AspDogfoodRepoEnrollmentEvidence { + repo: string; + mode: "advisory" | "shadow"; + repoAdd: AspDogfoodCommandRunReceipt; + repoEnable: AspDogfoodCommandRunReceipt; + repoStatus: AspDogfoodCommandRunReceipt; +} + +export type { AspDogfoodRepoEnrollmentEvidence }; + +interface AspDogfoodManagerStateEvidence { + status: AspDogfoodCommandRunReceipt; + serverAdd: AspDogfoodCommandRunReceipt; + serverStatus: AspDogfoodCommandRunReceipt; +} + +export type { AspDogfoodManagerStateEvidence }; + +interface AspDogfoodHostCheckEvidence extends AspDogfoodCommandRunReceipt { + hostDecision: unknown; + receipt: unknown; + assurance: { + mode: string; + transactionGuarantee: string; + }; +} + +export type { AspDogfoodHostCheckEvidence }; + +interface AspDogfoodHostEvaluationEvidence { + check: AspDogfoodHostCheckEvidence; + ciVerify?: AspDogfoodCommandRunReceipt; +} + +export type { AspDogfoodHostEvaluationEvidence }; + +interface AspDogfoodProviderProbeEvidence extends AspDogfoodCommandRunReceipt { + assessment: unknown; + validAsOf: unknown; + coverage: unknown; + diagnosticsCount: number; + hostOwnedFieldLeak: false; +} + +export type { AspDogfoodProviderProbeEvidence }; + +interface AspDogfoodUnsupportedSurfaceEvidence { + surface: AspDogfoodUnsupportedSurfaceId; + status: "degraded" | "parity-blocker"; + cleanCoverage: false; + blocker: string; +} + +export type { AspDogfoodUnsupportedSurfaceEvidence }; + +interface AspDogfoodParityBlocker { + source: string; + detail: string; +} + +export type { AspDogfoodParityBlocker }; + +interface AspDogfoodAuthorityEvidence { + hostOwnsDecisions: true; + providerOutputIsHostDecision: false; + localAuthorityOverride: { + present: false; + sharedAuthorityWeakened: false; + }; +} + +export type { AspDogfoodAuthorityEvidence }; + +interface AspDogfoodForbiddenMarkerScan { + scannedTextCount: number; + findingCount: 0; + markersBlocked: readonly AspDogfoodForbiddenProviderMarker[]; +} + +export type { AspDogfoodForbiddenMarkerScan }; + +interface AspDogfoodReceiptIdentity { + schemaVersion: 1; + issue: "#120"; + origin: "covibes-authored-asp-dogfood-proof"; + generatedAt: string; + commitSha: string; + privateRepo: true; + bootstrapSource: "local-sibling"; + packageNames: readonly ReleaseReceiptPackageName[]; + installedPackages: readonly ReleaseCutoverInstalledPackageEvidence[]; +} + +export type { AspDogfoodReceiptIdentity }; + +interface AspDogfoodReceiptEvidence { + manager: AspDogfoodManagerEvidence; + aspHome: AspDogfoodAspHomeEvidence; + hostFixture: AspDogfoodHostFixtureEvidence; + provider: AspDogfoodProviderEvidence; + managerState: AspDogfoodManagerStateEvidence; + repoEnrollment: AspDogfoodRepoEnrollmentEvidence; + hostEvaluation: AspDogfoodHostEvaluationEvidence; + providerProbe: AspDogfoodProviderProbeEvidence; + selfValidation: OpcoreSelfValidationReceipt; + unsupportedSurfaces: readonly AspDogfoodUnsupportedSurfaceEvidence[]; + parityBlockers: readonly AspDogfoodParityBlocker[]; + authority: AspDogfoodAuthorityEvidence; + publicReleaseActions: readonly []; + forbiddenMarkerScan: AspDogfoodForbiddenMarkerScan; +} + +export type { AspDogfoodReceiptEvidence }; + +interface AspDogfoodReceipt extends AspDogfoodReceiptIdentity, AspDogfoodReceiptEvidence {} + +export type { AspDogfoodReceipt }; diff --git a/packages/contracts/src/release/asp-validators-01.ts b/packages/contracts/src/release/asp-validators-01.ts new file mode 100644 index 0000000..efef708 --- /dev/null +++ b/packages/contracts/src/release/asp-validators-01.ts @@ -0,0 +1,218 @@ +import { validateRequiredObject } from "../shared/validators-02.js"; +import { validateRepoRelativePath } from "../shared/path-validators.js"; +import { + validateExactStringSequence, + validateExactStringSet, + validateNonEmptyString, + validateNonNegativeInteger, + validateSha256, + validateStringArray, +} from "../shared/validators-01.js"; +import type { + AspDogfoodAspHomeEvidence, + AspDogfoodHostEvaluationEvidence, + AspDogfoodHostFixtureEvidence, + AspDogfoodManagerEvidence, + AspDogfoodManagerStateEvidence, + AspDogfoodProviderEvidence, + AspDogfoodProviderManifestEvidence, + AspDogfoodProviderProbeEvidence, + AspDogfoodRepoEnrollmentEvidence, +} from "./asp-contracts-01.js"; +import { + assertNoAspDogfoodHostOwnedFields, + validateAspDogfoodCommandRun, + validateAspDogfoodPassedCommandRun, +} from "./asp-validators-02.js"; + +function validateAspDogfoodManager(manager: AspDogfoodManagerEvidence): void { + if (!manager || typeof manager !== "object") throw new Error("ASP dogfood manager evidence is required"); + if (manager.bootstrapSource !== "local-sibling") throw new Error("ASP dogfood bootstrapSource must be local-sibling"); + validateNonEmptyString(manager.aspRepoPath, "ASP dogfood manager aspRepoPath"); + validateNonEmptyString(manager.aspBinPath, "ASP dogfood manager aspBinPath"); + validateNonEmptyString(manager.cliPath, "ASP dogfood manager cliPath"); + validateNonEmptyString(manager.commitSha, "ASP dogfood manager commitSha"); +} + +export { validateAspDogfoodManager }; + +function validateAspDogfoodAspHome(aspHome: AspDogfoodAspHomeEvidence): void { + if (!aspHome || typeof aspHome !== "object") throw new Error("ASP dogfood ASP_HOME evidence is required"); + validateNonEmptyString(aspHome.path, "ASP dogfood ASP_HOME path"); + if (aspHome.temp !== true) throw new Error("ASP dogfood ASP_HOME must be temporary"); + if (aspHome.isolated !== true) throw new Error("ASP dogfood ASP_HOME must be isolated"); + if (aspHome.sharedStateMutated !== false) throw new Error("ASP dogfood shared ASP state must not be mutated"); + if (aspHome.pathSanitized !== true) throw new Error("ASP dogfood PATH must be sanitized for manager execution"); +} + +export { validateAspDogfoodAspHome }; + +function validateAspDogfoodHostFixture(fixture: AspDogfoodHostFixtureEvidence): void { + if (!fixture || typeof fixture !== "object") throw new Error("ASP dogfood host fixture evidence is required"); + validateNonEmptyString(fixture.repo, "ASP dogfood host fixture repo"); + if (fixture.temp !== true) throw new Error("ASP dogfood host fixture repo must be temporary"); + if (fixture.sourceRepoMutated !== false) throw new Error("ASP dogfood host fixture must not mutate the source repo"); + if (fixture.baselineCommitted !== true) throw new Error("ASP dogfood host fixture must commit a baseline"); + validateStringArray(fixture.changedPaths, "ASP dogfood host fixture changedPaths", { allowEmpty: false }); + for (const path of fixture.changedPaths) validateRepoRelativePath(path); +} + +export { validateAspDogfoodHostFixture }; + +function validateAspDogfoodProvider(provider: AspDogfoodProviderEvidence): void { + if (!provider || typeof provider !== "object") throw new Error("ASP dogfood provider evidence is required"); + if (provider.providerId !== "opcore") throw new Error("ASP dogfood providerId must be opcore"); + if (provider.packageName !== "opcore") { + throw new Error("ASP dogfood provider package must be opcore"); + } + validateNonEmptyString(provider.binPath, "ASP dogfood provider binPath"); + if (!provider.binPath.endsWith("node_modules/.bin/opcore-asp-provider")) { + throw new Error("ASP dogfood provider binPath must use installed node_modules/.bin/opcore-asp-provider"); + } + validateNonEmptyString(provider.indexPath, "ASP dogfood provider indexPath"); + if ( + !provider.indexPath.endsWith("node_modules/opcore/node_modules/@the-open-engine/opcore-asp-provider/dist/index.js") + ) { + throw new Error("ASP dogfood provider indexPath must be bundled opcore-asp-provider dist/index.js"); + } + validateSha256(provider.indexSha256, "ASP dogfood provider indexSha256"); + validateExactStringSequence(provider.command, ["opcore-asp-provider", "--stdio"], "ASP dogfood provider command"); + if (!provider.entrypoint || typeof provider.entrypoint !== "object") + throw new Error("ASP dogfood provider entrypoint is required"); + if (provider.entrypoint.transport !== "stdio") + throw new Error("ASP dogfood provider entrypoint transport must be stdio"); + validateAspDogfoodProviderBinPath(provider.entrypoint.bin, "ASP dogfood provider entrypoint bin"); + validateExactStringSequence(provider.entrypoint.args, ["--stdio"], "ASP dogfood provider entrypoint args"); + validateAspDogfoodProviderManifest(provider.manifest); +} + +export { validateAspDogfoodProvider }; + +function validateAspDogfoodProviderManifest(manifestEvidence: AspDogfoodProviderManifestEvidence): void { + if (!manifestEvidence || typeof manifestEvidence !== "object") + throw new Error("ASP dogfood provider manifest evidence is required"); + validateNonEmptyString(manifestEvidence.manifestPath, "ASP dogfood provider manifestPath"); + validateSha256(manifestEvidence.manifestSha256, "ASP dogfood provider manifestSha256"); + validateRequiredObject(manifestEvidence.manifest, "ASP dogfood provider manifest must be structured metadata"); + const manifest = manifestEvidence.manifest as Record; + if (manifest.manifestVersion !== "asp-server/0.1") + throw new Error("ASP dogfood provider manifestVersion must be asp-server/0.1"); + const server = manifest.server as Record | undefined; + if (!server || server.id !== "opcore") throw new Error("ASP dogfood provider manifest server.id must be opcore"); + const entrypoint = manifest.entrypoint as Record | undefined; + if (!entrypoint || entrypoint.transport !== "stdio" || typeof entrypoint.bin !== "string") { + throw new Error("ASP dogfood provider manifest entrypoint must be opcore-asp-provider --stdio"); + } + validateAspDogfoodProviderBinPath(entrypoint.bin, "ASP dogfood provider manifest entrypoint bin"); + validateExactStringSequence( + entrypoint.args as readonly string[], + ["--stdio"], + "ASP dogfood provider manifest entrypoint args", + ); + const capabilities = manifest.capabilities; + if (!Array.isArray(capabilities)) { + throw new Error("ASP dogfood provider manifest capabilities must be an array"); + } + validateExactStringSet( + capabilities, + ["check"], + "ASP dogfood provider manifest capabilities", + ); +} + +export { validateAspDogfoodProviderManifest }; + +function validateAspDogfoodProviderBinPath(value: unknown, label: string): void { + validateNonEmptyString(value, label); + const normalized = String(value).replaceAll("\\", "/"); + if (!/node_modules\/\.bin\/opcore-asp-provider(?:\.cmd)?$/.test(normalized)) { + throw new Error(`${label} must use installed node_modules/.bin/opcore-asp-provider`); + } +} + +export { validateAspDogfoodProviderBinPath }; + +function validateAspDogfoodManagerState(state: AspDogfoodManagerStateEvidence): void { + if (!state || typeof state !== "object") throw new Error("ASP dogfood managerState is required"); + validateAspDogfoodPassedCommandRun(state.status, "asp-status", "ASP dogfood manager status"); + validateAspDogfoodPassedCommandRun(state.serverAdd, "asp-server-add", "ASP dogfood manager server add"); + validateAspDogfoodPassedCommandRun(state.serverStatus, "asp-server-status", "ASP dogfood manager server status"); + validateRequiredObject(state.serverStatus.output, "ASP dogfood server status output is required"); +} + +export { validateAspDogfoodManagerState }; + +function validateAspDogfoodRepoEnrollment(enrollment: AspDogfoodRepoEnrollmentEvidence): void { + if (!enrollment || typeof enrollment !== "object") throw new Error("ASP dogfood repo enrollment is required"); + validateNonEmptyString(enrollment.repo, "ASP dogfood repo enrollment repo"); + if (enrollment.mode !== "advisory" && enrollment.mode !== "shadow") { + throw new Error("ASP dogfood repo enrollment mode must be advisory or shadow"); + } + validateAspDogfoodPassedCommandRun(enrollment.repoAdd, "asp-repo-add", "ASP dogfood repo add"); + validateAspDogfoodPassedCommandRun(enrollment.repoEnable, "asp-repo-enable", "ASP dogfood repo enable"); + validateAspDogfoodPassedCommandRun(enrollment.repoStatus, "asp-repo-status", "ASP dogfood repo status"); +} + +export { validateAspDogfoodRepoEnrollment }; + +function validateAspDogfoodHostEvaluation(evaluation: AspDogfoodHostEvaluationEvidence): void { + if (!evaluation || typeof evaluation !== "object") throw new Error("ASP dogfood host evaluation is required"); + validateAspDogfoodPassedCommandRun(evaluation.check, "asp-check-changed", "ASP dogfood host check"); + if (!evaluation.check.command.includes("check")) throw new Error("ASP dogfood host check must run asp check"); + validateRequiredObject(evaluation.check.hostDecision, "ASP dogfood host decision is required"); + validateRequiredObject(evaluation.check.receipt, "ASP dogfood host receipt is required"); + validateAspDogfoodHostAuthorityEvidence(evaluation.check.hostDecision, "ASP dogfood host decision", { + requireProviderProvenance: false, + }); + validateAspDogfoodHostAuthorityEvidence(evaluation.check.receipt, "ASP dogfood host receipt", { + requireProviderProvenance: true, + }); + validateRequiredObject(evaluation.check.assurance, "ASP dogfood host assurance is required"); + validateNonEmptyString(evaluation.check.assurance.mode, "ASP dogfood host assurance mode"); + validateNonEmptyString(evaluation.check.assurance.transactionGuarantee, "ASP dogfood host transactionGuarantee"); + if (evaluation.ciVerify !== undefined) { + validateAspDogfoodCommandRun(evaluation.ciVerify, "asp-ci-verify", "ASP dogfood CI verify"); + if (!evaluation.ciVerify.command.includes("ci") || !evaluation.ciVerify.command.includes("verify")) { + throw new Error("ASP dogfood CI verifier must run asp ci verify"); + } + } +} + +export { validateAspDogfoodHostEvaluation }; + +function validateAspDogfoodHostAuthorityEvidence( + value: unknown, + label: string, + options: { requireProviderProvenance: boolean }, +): void { + if (!value || typeof value !== "object") throw new Error(`${label} is required`); + const record = value as Record; + const authorityEvidence = record.authorityEvidence; + if (!Array.isArray(authorityEvidence) || authorityEvidence.length === 0) { + throw new Error(`${label} must include host authorityEvidence`); + } + const providerProvenance = record.providerProvenance; + if (options.requireProviderProvenance && (!Array.isArray(providerProvenance) || providerProvenance.length === 0)) { + throw new Error(`${label} must include providerProvenance`); + } +} + +export { validateAspDogfoodHostAuthorityEvidence }; + +function validateAspDogfoodProviderProbe(probe: AspDogfoodProviderProbeEvidence): void { + if (!probe || typeof probe !== "object") throw new Error("ASP dogfood provider probe is required"); + validateAspDogfoodPassedCommandRun(probe, "provider-probe", "ASP dogfood provider probe"); + validateExactStringSequence(probe.command, ["opcore-asp-provider", "--stdio"], "ASP dogfood provider probe command"); + if (!probe.assessment || typeof probe.assessment !== "object") + throw new Error("ASP dogfood provider probe assessment is required"); + if (!probe.validAsOf || typeof probe.validAsOf !== "object") + throw new Error("ASP dogfood provider probe validAsOf is required"); + if (!probe.coverage || typeof probe.coverage !== "object") + throw new Error("ASP dogfood provider probe coverage is required"); + validateNonNegativeInteger(probe.diagnosticsCount, "ASP dogfood provider probe diagnosticsCount"); + if (probe.hostOwnedFieldLeak !== false) + throw new Error("ASP dogfood provider output must not contain host-owned decision fields"); + assertNoAspDogfoodHostOwnedFields(probe.assessment); +} + +export { validateAspDogfoodProviderProbe }; diff --git a/packages/contracts/src/release/asp-validators-02.ts b/packages/contracts/src/release/asp-validators-02.ts new file mode 100644 index 0000000..18fefb4 --- /dev/null +++ b/packages/contracts/src/release/asp-validators-02.ts @@ -0,0 +1,158 @@ +import { validateRequiredObject } from "../shared/validators-02.js"; +import { includesString } from "../shared/primitives.js"; +import { + validateExactStringSet, + validateNonEmptyArray, + validateNonEmptyString, + validateNonNegativeInteger, + validatePositiveInteger, + validateSha256, + validateStringArray, +} from "../shared/validators-01.js"; +import { collectStrings } from "../shared/validators-02.js"; +import type { + AspDogfoodAuthorityEvidence, + AspDogfoodCommandRunReceipt, + AspDogfoodForbiddenMarkerScan, + AspDogfoodParityBlocker, + AspDogfoodReceipt, + AspDogfoodUnsupportedSurfaceEvidence, +} from "./asp-contracts-01.js"; +import { aspDogfoodForbiddenProviderMarkers, aspDogfoodUnsupportedSurfaceIds } from "./vocabulary-02.js"; + +function validateAspDogfoodUnsupportedSurfaces(surfaces: readonly AspDogfoodUnsupportedSurfaceEvidence[]): void { + validateNonEmptyArray(surfaces, "ASP dogfood unsupported surfaces"); + validateExactStringSet( + surfaces.map((entry) => entry.surface), + aspDogfoodUnsupportedSurfaceIds, + "ASP dogfood unsupported surfaces", + ); + for (const entry of surfaces) { + if (!entry || typeof entry !== "object") throw new Error("ASP dogfood unsupported surface entry is required"); + if (!includesString(aspDogfoodUnsupportedSurfaceIds, entry.surface)) { + throw new Error(`Unknown ASP dogfood unsupported surface: ${String(entry.surface)}`); + } + if (!includesString(["degraded", "parity-blocker"] as const, entry.status)) { + throw new Error("ASP dogfood unsupported surface status must be degraded or parity-blocker"); + } + if (entry.cleanCoverage !== false) + throw new Error("ASP dogfood unsupported inspect/edit surfaces must not be represented as clean coverage"); + validateNonEmptyString(entry.blocker, "ASP dogfood unsupported surface blocker"); + } +} + +export { validateAspDogfoodUnsupportedSurfaces }; + +function validateAspDogfoodParityBlockers(blockers: readonly AspDogfoodParityBlocker[]): void { + if (!Array.isArray(blockers)) throw new Error("ASP dogfood parity blockers must be an array"); + for (const blocker of blockers) { + if (!blocker || typeof blocker !== "object") throw new Error("ASP dogfood parity blocker is required"); + validateNonEmptyString(blocker.source, "ASP dogfood parity blocker source"); + validateNonEmptyString(blocker.detail, "ASP dogfood parity blocker detail"); + } +} + +export { validateAspDogfoodParityBlockers }; + +function validateAspDogfoodAuthority(authority: AspDogfoodAuthorityEvidence): void { + if (!authority || typeof authority !== "object") throw new Error("ASP dogfood authority evidence is required"); + if (authority.hostOwnsDecisions !== true) throw new Error("ASP dogfood host must own decisions"); + if (authority.providerOutputIsHostDecision !== false) + throw new Error("ASP dogfood provider output must not be treated as host decision"); + validateRequiredObject(authority.localAuthorityOverride, "ASP dogfood local authority override evidence is required"); + if ( + authority.localAuthorityOverride.present !== false || + authority.localAuthorityOverride.sharedAuthorityWeakened !== false + ) { + throw new Error("ASP dogfood must not silently weaken shared authority through local override"); + } +} + +export { validateAspDogfoodAuthority }; + +function validateAspDogfoodForbiddenMarkerScan(scan: AspDogfoodForbiddenMarkerScan): void { + if (!scan || typeof scan !== "object") throw new Error("ASP dogfood forbidden marker scan is required"); + validatePositiveInteger(scan.scannedTextCount, "ASP dogfood forbidden marker scannedTextCount"); + if (scan.findingCount !== 0) throw new Error("ASP dogfood forbidden marker findingCount must be 0"); + validateExactStringSet( + scan.markersBlocked, + aspDogfoodForbiddenProviderMarkers, + "ASP dogfood forbidden provider markers", + ); +} + +export { validateAspDogfoodForbiddenMarkerScan }; + +function validateAspDogfoodCommandRun(receipt: AspDogfoodCommandRunReceipt, expectedId: string, label: string): void { + if (!receipt || typeof receipt !== "object") throw new Error(`${label} receipt is required`); + if (receipt.id !== expectedId) throw new Error(`${label} id must be ${expectedId}`); + validateStringArray(receipt.command, `${label} command`, { + allowEmpty: false, + }); + if (!includesString(["passed", "failed", "retained-not-run"] as const, receipt.status)) { + throw new Error(`${label} status must be passed, failed, or retained-not-run`); + } + if (receipt.status === "passed" && receipt.exitCode !== 0) + throw new Error(`${label} passed status must use exitCode 0`); + if (receipt.status === "retained-not-run" && receipt.exitCode !== null) { + throw new Error(`${label} retained-not-run status must use null exitCode`); + } + if (receipt.status === "failed") validateNonNegativeInteger(receipt.exitCode, `${label} exitCode`); + validateSha256(receipt.stdoutSha256, `${label} stdoutSha256`); + validateSha256(receipt.stderrSha256, `${label} stderrSha256`); + validateNonEmptyString(receipt.assertion, `${label} assertion`); +} + +export { validateAspDogfoodCommandRun }; + +function validateAspDogfoodPassedCommandRun( + receipt: AspDogfoodCommandRunReceipt, + expectedId: string, + label: string, +): void { + validateAspDogfoodCommandRun(receipt, expectedId, label); + if (receipt.status !== "passed") throw new Error(`${label} status must be passed`); + if (receipt.exitCode !== 0) throw new Error(`${label} passed status must use exitCode 0`); +} + +export { validateAspDogfoodPassedCommandRun }; + +function validateAspDogfoodForbiddenProviderEntrypoint(receipt: AspDogfoodReceipt): void { + const providerTexts = collectStrings(receipt.provider); + const findings: string[] = []; + for (const text of providerTexts) { + const normalized = text.replaceAll("\\", "/").toLowerCase(); + for (const marker of aspDogfoodForbiddenProviderMarkers) { + if (normalized.includes(marker.toLowerCase())) findings.push(marker); + } + } + if (findings.length > 0) { + throw new Error(`ASP dogfood provider entrypoint contains forbidden marker: ${[...new Set(findings)].join(", ")}`); + } +} + +export { validateAspDogfoodForbiddenProviderEntrypoint }; + +function assertNoAspDogfoodHostOwnedFields(value: unknown, path = "$"): void { + if (!value || typeof value !== "object") return; + if (Array.isArray(value)) { + value.forEach((entry, index) => assertNoAspDogfoodHostOwnedFields(entry, `${path}[${index}]`)); + return; + } + const forbidden = new Set([ + "decision", + "verdict", + "pass", + "authority", + "authorityEvidence", + "assurance", + "transactionGuarantee", + "applyReceipt", + ]); + for (const [key, child] of Object.entries(value)) { + if (forbidden.has(key)) throw new Error(`ASP dogfood provider output contains host-owned field ${path}.${key}`); + assertNoAspDogfoodHostOwnedFields(child, `${path}.${key}`); + } +} + +export { assertNoAspDogfoodHostOwnedFields }; diff --git a/packages/contracts/src/release/cutover-contracts.ts b/packages/contracts/src/release/cutover-contracts.ts new file mode 100644 index 0000000..6e6a20c --- /dev/null +++ b/packages/contracts/src/release/cutover-contracts.ts @@ -0,0 +1,170 @@ +import type { CommandOwner, CommandRouteStatus } from "../command/vocabulary.js"; +import type { ManagedToolDescriptor } from "../managed/contracts.js"; +import type { + ReleaseReceiptResolvedArtifactEvidence, + ReleaseReceiptResolvedChecksumEvidence, +} from "./receipt-contracts-01.js"; +import type { + ReleaseCutoverCommandId, + ReleaseCutoverPythonCommandId, + ReleaseCutoverRustCommandId, + ReleaseReceiptPackageName, +} from "./vocabulary-01.js"; +import type { ReleaseCutoverInputIssue, ReleaseCutoverNegativeCheckId } from "./vocabulary-02.js"; + +interface ReleaseCutoverTarballEvidence { + filename: string; + sha256: string; +} + +export type { ReleaseCutoverTarballEvidence }; + +interface ReleaseCutoverInstalledManifestEvidence { + path: string; + sha256: string; + bins: Readonly>; +} + +export type { ReleaseCutoverInstalledManifestEvidence }; + +interface ReleaseCutoverInstalledFileEvidence { + path: string; + sha256: string; +} + +export type { ReleaseCutoverInstalledFileEvidence }; + +interface ReleaseCutoverInstalledPackageEvidence { + packageName: ReleaseReceiptPackageName; + version: string; + tarball: ReleaseCutoverTarballEvidence; + installedManifest: ReleaseCutoverInstalledManifestEvidence; + installedFiles: readonly ReleaseCutoverInstalledFileEvidence[]; +} + +export type { ReleaseCutoverInstalledPackageEvidence }; + +interface ReleaseCutoverDescriptorEvidence { + path: string; + packageName: "opcore"; + checksumSha256: string; + descriptor: ManagedToolDescriptor; + resolvedArtifacts: readonly ReleaseReceiptResolvedArtifactEvidence[]; + resolvedChecksums: readonly ReleaseReceiptResolvedChecksumEvidence[]; +} + +export type { ReleaseCutoverDescriptorEvidence }; + +interface ReleaseCutoverEnvironmentIsolationEvidence { + pathSanitized: true; + siblingRepositoriesExcluded: true; + opcoreBinsVerified: true; +} + +export type { ReleaseCutoverEnvironmentIsolationEvidence }; + +interface ReleaseCutoverCommandReceipt { + id: ReleaseCutoverCommandId; + command: readonly string[]; + canonicalCommand: readonly string[]; + owner: CommandOwner; + status: CommandRouteStatus; + exitCode: number; + binPath: string; + stdoutSha256: string; + stderrSha256: string; + assertion: string; +} + +export type { ReleaseCutoverCommandReceipt }; + +interface ReleaseCutoverRustCommandReceipt { + id: ReleaseCutoverRustCommandId; + command: readonly string[]; + canonicalCommand: readonly string[]; + owner: "graph"; + status: "ok"; + exitCode: 0; + binPath: string; + stdoutSha256: string; + stderrSha256: string; + assertion: string; +} + +export type { ReleaseCutoverRustCommandReceipt }; + +interface ReleaseCutoverPythonCommandReceipt { + id: ReleaseCutoverPythonCommandId; + command: readonly string[]; + canonicalCommand: readonly string[]; + evidence: readonly string[]; + owner: CommandOwner; + status: "ok"; + exitCode: 0; + binPath: string; + stdoutSha256: string; + stderrSha256: string; + assertion: string; +} + +export type { ReleaseCutoverPythonCommandReceipt }; + +interface ReleaseCutoverNegativeCheck { + id: ReleaseCutoverNegativeCheckId; + command: readonly string[]; + status: "passed"; + exitCode: 0; + assertion: string; +} + +export type { ReleaseCutoverNegativeCheck }; + +interface OpcoreSelfValidationReceipt { + id: "opcore-self-check"; + command: readonly ["npm", "run", "opcore:self-check"]; + status: "passed"; + exitCode: 0; + stdoutSha256: string; + stderrSha256: string; + assertion: string; +} + +export type { OpcoreSelfValidationReceipt }; + +interface ReleaseCutoverForbiddenMarkerScan { + scannedTextCount: number; + findingCount: 0; + markersBlocked: readonly string[]; +} + +export type { ReleaseCutoverForbiddenMarkerScan }; + +interface ReleaseCutoverInputEvidence { + issue: ReleaseCutoverInputIssue; + path: string; + checksumSha256: string; +} + +export type { ReleaseCutoverInputEvidence }; + +interface ReleaseCutoverReceipt { + schemaVersion: 1; + issue: "#30"; + origin: "covibes-authored-cutover-proof"; + generatedAt: string; + commitSha: string; + privateRepo: true; + packageNames: readonly ReleaseReceiptPackageName[]; + installedPackages: readonly ReleaseCutoverInstalledPackageEvidence[]; + descriptor: ReleaseCutoverDescriptorEvidence; + environmentIsolation: ReleaseCutoverEnvironmentIsolationEvidence; + commandReceipts: readonly ReleaseCutoverCommandReceipt[]; + rustCommandReceipts: readonly ReleaseCutoverRustCommandReceipt[]; + pythonCommandReceipts: readonly ReleaseCutoverPythonCommandReceipt[]; + negativeChecks: readonly ReleaseCutoverNegativeCheck[]; + selfValidation: OpcoreSelfValidationReceipt; + forbiddenMarkerScan: ReleaseCutoverForbiddenMarkerScan; + inputEvidence: readonly ReleaseCutoverInputEvidence[]; +} + +export type { ReleaseCutoverReceipt }; diff --git a/packages/contracts/src/release/cutover-validators-01.ts b/packages/contracts/src/release/cutover-validators-01.ts new file mode 100644 index 0000000..b4b8cd4 --- /dev/null +++ b/packages/contracts/src/release/cutover-validators-01.ts @@ -0,0 +1,232 @@ +import { + validateExactValue, + validateRequiredObject, +} from "../shared/validators-02.js"; +import { validateCommandOwner, validateCommandRouteStatus } from "../command/helper-validators.js"; +import { includesString } from "../shared/primitives.js"; +import { managedToolDescriptorArtifactTypes } from "../managed/contracts.js"; +import { bundledGraphCoreNativePath, validateReleaseReceiptPackageName } from "../managed/helper-validators.js"; +import { validateManagedToolDescriptor } from "../managed/validators-01.js"; +import { + validateExactStringSequence, + validateExactStringSet, + validateExitCodeForStatus, + validateNonEmptyArray, + validateNonEmptyString, + validateSha256, + validateStringArray, +} from "../shared/validators-01.js"; +import type { + ReleaseCutoverCommandReceipt, + ReleaseCutoverDescriptorEvidence, + ReleaseCutoverEnvironmentIsolationEvidence, + ReleaseCutoverInstalledPackageEvidence, +} from "./cutover-contracts.js"; +import { validateReleaseCutoverExpectedCommand } from "./cutover-validators-02.js"; +import { graphCoreNativePackageNameForTarget, graphCoreNativeSupportedTargets } from "./graph-vocabulary-02.js"; +import { validateReleaseReceiptBins } from "./receipt-validators-03.js"; +import { releaseCutoverRequiredCommandIds, releaseReceiptPackageNames } from "./vocabulary-01.js"; +import { releaseCutoverCommandExpectations } from "./vocabulary-03.js"; + +function validateReleaseCutoverInstalledPackages(packages: readonly ReleaseCutoverInstalledPackageEvidence[]): void { + validateNonEmptyArray(packages, "Release cutover installed package evidence"); + validateReleaseCutoverInstalledPackageSet(packages.map((entry) => entry.packageName)); + for (const entry of packages) { + if (!entry || typeof entry !== "object") + throw new Error("Release cutover installed package evidence entry is required"); + validateReleaseReceiptPackageName(entry.packageName, "Release cutover installed package packageName"); + validateNonEmptyString(entry.version, "Release cutover installed package version"); + if (!entry.tarball || typeof entry.tarball !== "object") + throw new Error("Release cutover tarball evidence is required"); + validateNonEmptyString(entry.tarball.filename, "Release cutover tarball filename"); + validateSha256(entry.tarball.sha256, "Release cutover tarball sha256"); + validateRequiredObject(entry.installedManifest, "Release cutover installed manifest evidence is required"); + validateNonEmptyString(entry.installedManifest.path, "Release cutover installed manifest path"); + if ( + !entry.installedManifest.path.includes("node_modules/") || + !entry.installedManifest.path.endsWith("package.json") + ) { + throw new Error("Release cutover installed manifest path must be inside node_modules and end with package.json"); + } + validateSha256(entry.installedManifest.sha256, "Release cutover installed manifest sha256"); + validateReleaseReceiptBins(entry.installedManifest.bins, entry.packageName); + validateReleaseCutoverInstalledFiles(entry); + } +} + +export { validateReleaseCutoverInstalledPackages }; + +function validateReleaseCutoverInstalledFiles(entry: ReleaseCutoverInstalledPackageEvidence): void { + validateNonEmptyArray(entry.installedFiles, "Release cutover installed files"); + const prefix = `node_modules/${entry.packageName}/`; + const paths = []; + for (const file of entry.installedFiles) { + if (!file || typeof file !== "object") throw new Error("Release cutover installed file evidence entry is required"); + validateNonEmptyString(file.path, "Release cutover installed file path"); + if (!file.path.startsWith(prefix)) { + throw new Error(`Release cutover installed file path must be inside ${prefix}`); + } + validateSha256(file.sha256, "Release cutover installed file sha256"); + paths.push(file.path); + } + if (new Set(paths).size !== paths.length) { + throw new Error("Release cutover installed file paths must be unique"); + } + if (!paths.includes(entry.installedManifest.path)) { + throw new Error("Release cutover installed files must include package.json"); + } + const binPaths = Object.values(entry.installedManifest.bins).map((path) => `${prefix}${path}`); + for (const binPath of binPaths) { + if (!paths.includes(binPath)) throw new Error(`Release cutover installed files must include bin target ${binPath}`); + } + if (entry.packageName === "opcore") validateReleaseCutoverOpcoreFiles(paths); +} + +export { validateReleaseCutoverInstalledFiles }; + +function validateReleaseCutoverOpcoreFiles(paths: readonly string[]): void { + const aspManifest = + "node_modules/opcore/node_modules/@the-open-engine/opcore-asp-provider/dist/manifests/asp-server.json"; + if (!paths.includes(aspManifest)) { + throw new Error("Release cutover Opcore installed files must include bundled canonical asp-server.json"); + } + for (const target of graphCoreNativeSupportedTargets) { + const bundledPackageName = graphCoreNativePackageNameForTarget(target); + const binary = `node_modules/opcore/${bundledGraphCoreNativePath(bundledPackageName, "opcore-graph-core")}`; + if (!paths.includes(binary)) { + throw new Error(`Release cutover Opcore installed files must include bundled native binary for ${target}`); + } + } +} + +function validateReleaseCutoverInstalledPackageSet(packageNames: readonly string[]): void { + validateExactStringSet(packageNames, releaseReceiptPackageNames, "Release cutover installed package evidence"); + for (const packageName of packageNames) { + validateReleaseReceiptPackageName(packageName, "Release cutover installed package packageName"); + } +} + +export { validateReleaseCutoverInstalledPackageSet }; + +function validateReleaseCutoverDescriptor(descriptorEvidence: ReleaseCutoverDescriptorEvidence): void { + if (!descriptorEvidence || typeof descriptorEvidence !== "object") + throw new Error("Release cutover descriptor evidence is required"); + validateNonEmptyString(descriptorEvidence.path, "Release cutover descriptor path"); + if (descriptorEvidence.packageName !== "opcore") { + throw new Error("Release cutover descriptor packageName must be opcore"); + } + validateSha256(descriptorEvidence.checksumSha256, "Release cutover descriptor checksumSha256"); + const descriptor = validateManagedToolDescriptor(descriptorEvidence.descriptor); + validateNonEmptyArray(descriptorEvidence.resolvedArtifacts, "Release cutover descriptor resolvedArtifacts"); + validateExactStringSet( + descriptorEvidence.resolvedArtifacts.map((entry) => entry.id), + descriptor.artifacts.map((entry) => entry.id), + "Release cutover descriptor resolved artifact ids", + ); + for (const artifact of descriptorEvidence.resolvedArtifacts) { + validateReleaseReceiptPackageName(artifact.packageName, "Release cutover descriptor resolved artifact packageName"); + validateNonEmptyString(artifact.path, "Release cutover descriptor resolved artifact path"); + validateNonEmptyString(artifact.id, "Release cutover descriptor resolved artifact id"); + if (!includesString(managedToolDescriptorArtifactTypes, artifact.type)) { + throw new Error(`Unknown release cutover descriptor resolved artifact type: ${String(artifact.type)}`); + } + if (artifact.packageFile !== true) + throw new Error("Release cutover descriptor resolved artifacts must be package files"); + } + validateNonEmptyArray(descriptorEvidence.resolvedChecksums, "Release cutover descriptor resolvedChecksums"); + validateExactStringSet( + descriptorEvidence.resolvedChecksums.map((entry) => entry.id), + descriptor.checksums.map((entry) => entry.id), + "Release cutover descriptor resolved checksum ids", + ); + for (const checksum of descriptorEvidence.resolvedChecksums) { + validateReleaseReceiptPackageName(checksum.packageName, "Release cutover descriptor resolved checksum packageName"); + validateNonEmptyString(checksum.path, "Release cutover descriptor resolved checksum path"); + validateNonEmptyString(checksum.id, "Release cutover descriptor resolved checksum id"); + if (checksum.algorithm !== "sha256") + throw new Error("Release cutover descriptor checksum algorithm must be sha256"); + validateSha256(checksum.value, "Release cutover descriptor resolved checksum value"); + if (checksum.packageFile !== true) + throw new Error("Release cutover descriptor resolved checksums must be package files"); + } +} + +export { validateReleaseCutoverDescriptor }; + +function validateReleaseCutoverEnvironmentIsolation(environment: ReleaseCutoverEnvironmentIsolationEvidence): void { + if (!environment || typeof environment !== "object") + throw new Error("Release cutover environmentIsolation is required"); + if (environment.pathSanitized !== true) throw new Error("Release cutover PATH must be sanitized"); + if (environment.siblingRepositoriesExcluded !== true) { + throw new Error("Release cutover sibling repository paths must be excluded"); + } + if (environment.opcoreBinsVerified !== true) { + throw new Error("Release cutover installed project bins must be verified"); + } +} + +export { validateReleaseCutoverEnvironmentIsolation }; + +function validateReleaseCutoverCommandReceipts(receipts: readonly ReleaseCutoverCommandReceipt[]): void { + validateNonEmptyArray(receipts, "Release cutover command receipts"); + validateExactStringSet( + receipts.map((entry) => entry.id), + releaseCutoverRequiredCommandIds, + "Release cutover command receipts", + ); + for (const receipt of receipts) validateReleaseCutoverCommandReceipt(receipt); +} + +export { validateReleaseCutoverCommandReceipts }; + +function validateReleaseCutoverCommandReceipt(receipt: ReleaseCutoverCommandReceipt): void { + validateRequiredObject(receipt, "Release cutover command receipt is required"); + if (!includesString(releaseCutoverRequiredCommandIds, receipt.id)) { + throw new Error(`Unknown release cutover command receipt id: ${String(receipt.id)}`); + } + validateStringArray(receipt.command, "Release cutover command receipt command", { allowEmpty: false }); + validateStringArray(receipt.canonicalCommand, "Release cutover command receipt canonicalCommand", { + allowEmpty: false, + }); + validateExactStringSequence(receipt.command, receipt.canonicalCommand, `Release cutover ${receipt.id} command`); + const expected = releaseCutoverCommandExpectations[receipt.id]; + validateExactValue( + receipt.command[0], + expected.bin, + `Release cutover command ${receipt.id} command must use canonical ${expected.bin} bin`, + ); + validateExactValue( + receipt.canonicalCommand[0], + expected.bin, + `Release cutover command ${receipt.id} canonicalCommand must use canonical ${expected.bin} bin`, + ); + validateCommandOwner(receipt.owner); + validateReleaseCutoverExpectedCommand(receipt.canonicalCommand, expected, receipt.id); + validateExactValue( + receipt.owner, + expected.owner, + `Release cutover command ${receipt.id} owner must match expected ${expected.owner}`, + ); + const status = validateCommandRouteStatus(receipt.status); + if (receipt.status === "not_implemented") { + throw new Error("Release cutover command receipts must not be not_implemented"); + } + validateExactValue( + status, + expected.status, + `Release cutover command ${receipt.id} status must match expected ${expected.status}`, + ); + validateExitCodeForStatus(receipt.exitCode, status); + validateExactValue( + receipt.exitCode, + expected.exitCode, + `Release cutover command ${receipt.id} exitCode must match expected ${expected.exitCode}`, + ); + validateNonEmptyString(receipt.binPath, "Release cutover command receipt binPath"); + if (!receipt.binPath.endsWith(`node_modules/.bin/${expected.bin}`)) { + throw new Error(`Release cutover command receipt binPath must use installed node_modules/.bin/${expected.bin}`); + } + validateSha256(receipt.stdoutSha256, "Release cutover command receipt stdoutSha256"); + validateSha256(receipt.stderrSha256, "Release cutover command receipt stderrSha256"); + validateNonEmptyString(receipt.assertion, "Release cutover command receipt assertion"); +} diff --git a/packages/contracts/src/release/cutover-validators-02.ts b/packages/contracts/src/release/cutover-validators-02.ts new file mode 100644 index 0000000..ac46529 --- /dev/null +++ b/packages/contracts/src/release/cutover-validators-02.ts @@ -0,0 +1,244 @@ +import { validateCommandOwner } from "../command/helper-validators.js"; +import { includesString } from "../shared/primitives.js"; +import { + validateExactValue, + validateRequiredObject, +} from "../shared/validators-02.js"; +import { + validateExactStringSequence, + validateExactStringSet, + validateNonEmptyArray, + validateNonEmptyString, + validateNonNegativeInteger, + validateSha256, + validateStringArray, +} from "../shared/validators-01.js"; +import type { + ReleaseCutoverForbiddenMarkerScan, + ReleaseCutoverInputEvidence, + ReleaseCutoverNegativeCheck, + ReleaseCutoverPythonCommandReceipt, + ReleaseCutoverRustCommandReceipt, +} from "./cutover-contracts.js"; +import type { + ReleaseCutoverCommandId, + ReleaseCutoverPythonCommandId, + ReleaseCutoverRustCommandId} from "./vocabulary-01.js"; +import { + releaseCutoverNegativeCheckIds, + releaseCutoverPythonCommandIds, + releaseCutoverRustCommandIds, +} from "./vocabulary-01.js"; +import type { + ReleaseCutoverCommandExpectation} from "./vocabulary-02.js"; +import { + releaseCutoverInputIssues, + releaseCutoverRequestFilePlaceholder, +} from "./vocabulary-02.js"; +import { + releaseCutoverNegativeCheckExpectations, + releaseCutoverPythonCommandExpectations, + releaseCutoverPythonEvidenceExpectations, + releaseCutoverRustCommandExpectations, +} from "./vocabulary-04.js"; + +function validateReleaseCutoverRustCommandReceipts(receipts: readonly ReleaseCutoverRustCommandReceipt[]): void { + validateNonEmptyArray(receipts, "Release cutover Rust command receipts"); + validateExactStringSet( + receipts.map((entry) => entry.id), + releaseCutoverRustCommandIds, + "Release cutover Rust command receipts", + ); + for (const receipt of receipts) { + if (!receipt || typeof receipt !== "object") throw new Error("Release cutover Rust command receipt is required"); + if (!includesString(releaseCutoverRustCommandIds, receipt.id)) { + throw new Error(`Unknown release cutover Rust command receipt id: ${String(receipt.id)}`); + } + validateStringArray(receipt.command, "Release cutover Rust command receipt command", { allowEmpty: false }); + validateStringArray(receipt.canonicalCommand, "Release cutover Rust command receipt canonicalCommand", { + allowEmpty: false, + }); + validateExactStringSequence( + receipt.command, + receipt.canonicalCommand, + `Release cutover Rust ${receipt.id} command`, + ); + const expected = releaseCutoverRustCommandExpectations[receipt.id]; + validateReleaseCutoverExpectedCommand(receipt.canonicalCommand, expected, receipt.id); + if (receipt.owner !== "graph") throw new Error(`Release cutover Rust command ${receipt.id} owner must be graph`); + if (receipt.status !== "ok") throw new Error(`Release cutover Rust command ${receipt.id} status must be ok`); + if (receipt.exitCode !== 0) throw new Error(`Release cutover Rust command ${receipt.id} exitCode must be 0`); + validateNonEmptyString(receipt.binPath, "Release cutover Rust command receipt binPath"); + if (!receipt.binPath.endsWith("node_modules/.bin/opcore")) { + throw new Error("Release cutover Rust command receipt binPath must use installed node_modules/.bin/opcore"); + } + validateSha256(receipt.stdoutSha256, "Release cutover Rust command receipt stdoutSha256"); + validateSha256(receipt.stderrSha256, "Release cutover Rust command receipt stderrSha256"); + validateNonEmptyString(receipt.assertion, "Release cutover Rust command receipt assertion"); + } +} + +export { validateReleaseCutoverRustCommandReceipts }; + +function validateReleaseCutoverPythonCommandReceipts(receipts: readonly ReleaseCutoverPythonCommandReceipt[]): void { + validateNonEmptyArray(receipts, "Release cutover Python command receipts"); + validateExactStringSet( + receipts.map((entry) => entry.id), + releaseCutoverPythonCommandIds, + "Release cutover Python command receipts", + ); + for (const receipt of receipts) validateReleaseCutoverPythonCommandReceipt(receipt); +} + +export { validateReleaseCutoverPythonCommandReceipts }; + +function validateReleaseCutoverPythonCommandReceipt(receipt: ReleaseCutoverPythonCommandReceipt): void { + validateRequiredObject(receipt, "Release cutover Python command receipt is required"); + if (!includesString(releaseCutoverPythonCommandIds, receipt.id)) { + throw new Error(`Unknown release cutover Python command receipt id: ${String(receipt.id)}`); + } + validateStringArray(receipt.command, "Release cutover Python command receipt command", { allowEmpty: false }); + validateStringArray(receipt.canonicalCommand, "Release cutover Python command receipt canonicalCommand", { + allowEmpty: false, + }); + validateStringArray(receipt.evidence, "Release cutover Python command receipt evidence", { allowEmpty: false }); + validateExactStringSequence( + receipt.command, + receipt.canonicalCommand, + `Release cutover Python ${receipt.id} command`, + ); + const expected = releaseCutoverPythonCommandExpectations[receipt.id]; + validateExactStringSet( + receipt.evidence, + releaseCutoverPythonEvidenceExpectations[receipt.id], + `Release cutover Python command ${receipt.id} evidence`, + ); + validateExactValue( + receipt.command[0], + expected.bin, + `Release cutover Python command ${receipt.id} command must use canonical ${expected.bin} bin`, + ); + validateExactValue( + receipt.canonicalCommand[0], + expected.bin, + `Release cutover Python command ${receipt.id} canonicalCommand must use canonical ${expected.bin} bin`, + ); + validateCommandOwner(receipt.owner); + validateReleaseCutoverExpectedCommand(receipt.canonicalCommand, expected, receipt.id); + validateExactValue( + receipt.owner, + expected.owner, + `Release cutover Python command ${receipt.id} owner must match expected ${expected.owner}`, + ); + validateExactValue(receipt.status, "ok", `Release cutover Python command ${receipt.id} status must be ok`); + validateExactValue(receipt.exitCode, 0, `Release cutover Python command ${receipt.id} exitCode must be 0`); + validateNonEmptyString(receipt.binPath, "Release cutover Python command receipt binPath"); + if (!receipt.binPath.endsWith(`node_modules/.bin/${expected.bin}`)) { + throw new Error( + `Release cutover Python command receipt binPath must use installed node_modules/.bin/${expected.bin}`, + ); + } + validateSha256(receipt.stdoutSha256, "Release cutover Python command receipt stdoutSha256"); + validateSha256(receipt.stderrSha256, "Release cutover Python command receipt stderrSha256"); + validateNonEmptyString(receipt.assertion, "Release cutover Python command receipt assertion"); +} + +function validateReleaseCutoverExpectedCommand( + command: readonly string[], + expectation: ReleaseCutoverCommandExpectation, + id: ReleaseCutoverCommandId | ReleaseCutoverRustCommandId | ReleaseCutoverPythonCommandId, +): void { + if (!releaseCutoverCommandMatchesExpectation(command, expectation)) { + throw new Error( + `Release cutover command ${id} canonicalCommand must match expected ${formatReleaseCutoverCommand(expectation)}`, + ); + } +} + +export { validateReleaseCutoverExpectedCommand }; + +function releaseCutoverCommandMatchesExpectation( + command: readonly string[], + expectation: ReleaseCutoverCommandExpectation, +): boolean { + if (command.length !== expectation.canonicalCommand.length) return false; + return expectation.canonicalCommand.every((expected, index) => { + const actual = command[index]; + if (expected !== releaseCutoverRequestFilePlaceholder) return actual === expected; + return releaseCutoverPathBasename(actual) === expectation.requestFileBasename; + }); +} + +export { releaseCutoverCommandMatchesExpectation }; + +function releaseCutoverPathBasename(path: string): string { + const normalized = path.replaceAll("\\", "/"); + const parts = normalized.split("/"); + return parts[parts.length - 1] ?? normalized; +} + +export { releaseCutoverPathBasename }; + +function formatReleaseCutoverCommand(expectation: ReleaseCutoverCommandExpectation): string { + return expectation.canonicalCommand + .map((part) => (part === releaseCutoverRequestFilePlaceholder ? `<${expectation.requestFileBasename}>` : part)) + .join(" "); +} + +export { formatReleaseCutoverCommand }; + +function validateReleaseCutoverNegativeChecks(checks: readonly ReleaseCutoverNegativeCheck[]): void { + validateNonEmptyArray(checks, "Release cutover negative checks"); + validateExactStringSet( + checks.map((entry) => entry.id), + releaseCutoverNegativeCheckIds, + "Release cutover negative checks", + ); + for (const check of checks) { + if (!check || typeof check !== "object") throw new Error("Release cutover negative check is required"); + validateNonEmptyString(check.id, "Release cutover negative check id"); + if (!includesString(releaseCutoverNegativeCheckIds, check.id)) { + throw new Error(`Unknown release cutover negative check id: ${String(check.id)}`); + } + validateStringArray(check.command, "Release cutover negative check command", { allowEmpty: false }); + validateExactStringSequence( + check.command, + releaseCutoverNegativeCheckExpectations[check.id], + `Release cutover negative check ${check.id} command`, + ); + if (check.status !== "passed") throw new Error("Release cutover negative check status must be passed"); + if (check.exitCode !== 0) throw new Error("Release cutover negative check exitCode must be 0"); + validateNonEmptyString(check.assertion, "Release cutover negative check assertion"); + } +} + +export { validateReleaseCutoverNegativeChecks }; + +function validateReleaseCutoverForbiddenMarkerScan(scan: ReleaseCutoverForbiddenMarkerScan): void { + if (!scan || typeof scan !== "object") throw new Error("Release cutover forbiddenMarkerScan is required"); + validateNonNegativeInteger(scan.scannedTextCount, "Release cutover forbidden marker scannedTextCount"); + if (scan.scannedTextCount === 0) throw new Error("Release cutover forbidden marker scan must scan at least one text"); + if (scan.findingCount !== 0) throw new Error("Release cutover forbidden marker findingCount must be 0"); + validateStringArray(scan.markersBlocked, "Release cutover forbidden marker labels", { allowEmpty: false }); +} + +export { validateReleaseCutoverForbiddenMarkerScan }; + +function validateReleaseCutoverInputEvidence(evidence: readonly ReleaseCutoverInputEvidence[]): void { + validateNonEmptyArray(evidence, "Release cutover input evidence"); + validateExactStringSet( + evidence.map((entry) => entry.issue), + releaseCutoverInputIssues, + "Release cutover input evidence issues", + ); + for (const entry of evidence) { + if (!entry || typeof entry !== "object") throw new Error("Release cutover input evidence entry is required"); + if (!includesString(releaseCutoverInputIssues, entry.issue)) { + throw new Error(`Unknown release cutover input evidence issue: ${String(entry.issue)}`); + } + validateNonEmptyString(entry.path, "Release cutover input evidence path"); + validateSha256(entry.checksumSha256, "Release cutover input evidence checksumSha256"); + } +} + +export { validateReleaseCutoverInputEvidence }; diff --git a/packages/contracts/src/release/graph-contracts.ts b/packages/contracts/src/release/graph-contracts.ts new file mode 100644 index 0000000..56af1d8 --- /dev/null +++ b/packages/contracts/src/release/graph-contracts.ts @@ -0,0 +1,177 @@ +import type { GraphProviderArtifactMetadata } from "../graph/provider-contracts-01.js"; +import type { + GraphReleaseBenchmarkMetric, + GraphReleaseCoreCommandId, + GraphReleaseDeferredChild, + GraphReleaseHandoffIssue, + GraphReleaseOptionalAnalysisSurface, + GraphReleaseRustCommandId, + GraphReleaseSurfaceClassification, +} from "./graph-vocabulary-01.js"; +import type { + GraphCoreNativePackageName, + GraphCoreNativeSupportedTarget, + GraphReleaseDirectSqliteQueryId, + GraphReleaseReportReceiptId, + GraphReleaseServeTransportId, +} from "./graph-vocabulary-02.js"; + +interface GraphReleaseCommandCoverage { + id: GraphReleaseCoreCommandId; + bin: "opcore"; + command: readonly string[]; + canonicalCommand: readonly string[]; + status: "passed"; + exitCode: 0; + fixture: string; + durationMs: number; +} + +export type { GraphReleaseCommandCoverage }; + +interface GraphReleaseRustCommandCoverage { + id: GraphReleaseRustCommandId; + bin: "opcore"; + command: readonly string[]; + canonicalCommand: readonly string[]; + status: "passed"; + exitCode: 0; + fixture: string; + durationMs: number; +} + +export type { GraphReleaseRustCommandCoverage }; + +interface GraphReleaseDirectSqliteQueryReceipt { + id: GraphReleaseDirectSqliteQueryId; + query: string; + status: "passed"; + rowCount: number; + fixture: string; +} + +export type { GraphReleaseDirectSqliteQueryReceipt }; + +interface GraphReleaseServeTransportReceipt { + id: GraphReleaseServeTransportId; + protocol: "opcore.graph.daemon" | "jsonrpc-2.0" | (string & {}); + operation: "ping" | "status" | "query" | "search" | "shutdown" | (string & {}); + status: "passed"; + exitCode: 0; +} + +export type { GraphReleaseServeTransportReceipt }; + +interface GraphReleaseBenchmarkReceipt { + metric: GraphReleaseBenchmarkMetric; + value: number; + unit: "ms" | "bytes"; + baselineIssue: "#19"; + baselineReceipt: string; + comparison: "recorded" | "within_baseline" | "above_baseline" | "below_baseline"; +} + +export type { GraphReleaseBenchmarkReceipt }; + +interface GraphReleasePackageInspection { + packageName: "@the-open-engine/opcore-graph"; + tarballName: string; + fileCount: number; + files: readonly string[]; + forbiddenMarkersAbsent: true; + generatedBuildMetadataAbsent: true; + privatePathsAbsent: true; + sourceProvenanceAbsent: true; + packageMetadataAbsent: true; + gitHistoryAbsent: true; + foreignImplementationNamesAbsent: true; + inspections: readonly string[]; +} + +export type { GraphReleasePackageInspection }; + +interface GraphReleaseNativeArtifactEvidence { + packageName: GraphCoreNativePackageName; + targetPlatform: GraphCoreNativeSupportedTarget; + metadata: GraphProviderArtifactMetadata; + binaryPath: "opcore-graph-core"; + checksumPath: "opcore-graph-core.sha256"; + metadataPath: "metadata.json"; + binarySha256: string; + checksumFileSha256: string; + metadataSha256: string; + packageFiles: readonly string[]; +} + +export type { GraphReleaseNativeArtifactEvidence }; + +interface GraphReleaseReportReceipt { + id: GraphReleaseReportReceiptId; + command: readonly string[]; + status: "passed"; + exitCode: 0; + path: string; + checksumSha256?: string; +} + +export type { GraphReleaseReportReceipt }; + +interface GraphReleaseOptionalSurfaceReceipt { + issue: GraphReleaseDeferredChild; + id: GraphReleaseOptionalAnalysisSurface["id"] | (string & {}); + classification: GraphReleaseSurfaceClassification; + status: "unsupported" | "deferred"; +} + +export type { GraphReleaseOptionalSurfaceReceipt }; + +interface GraphReleaseHandoffReceipt { + issue: GraphReleaseHandoffIssue; + receiptPath: string; + checksumSha256: string; + rollbackNote: string; +} + +export type { GraphReleaseHandoffReceipt }; + +interface GraphReleasePackageVersion { + packageName: string; + version: string; +} + +export type { GraphReleasePackageVersion }; + +interface GraphReleaseReceiptIdentity { + schemaVersion: 1; + issue: "#17"; + origin: "covibes-authored-synthetic"; + generatedAt: string; + commitSha: string; + graphPackageVersions: readonly GraphReleasePackageVersion[]; + graphProviderSchemaVersion: 1; + requiredChildren: readonly string[]; + deferredChildren: readonly string[]; +} + +export type { GraphReleaseReceiptIdentity }; + +interface GraphReleaseReceiptEvidence { + commandCoverage: readonly GraphReleaseCommandCoverage[]; + rustCommandCoverage: readonly GraphReleaseRustCommandCoverage[]; + directSqliteQueries: readonly GraphReleaseDirectSqliteQueryReceipt[]; + serveTransport: readonly GraphReleaseServeTransportReceipt[]; + benchmarks: readonly GraphReleaseBenchmarkReceipt[]; + packageInspection: GraphReleasePackageInspection; + supportedNativeTargets: readonly GraphCoreNativeSupportedTarget[]; + nativeArtifacts: readonly GraphReleaseNativeArtifactEvidence[]; + reportReceipts: readonly GraphReleaseReportReceipt[]; + graphArtifact: GraphProviderArtifactMetadata; + optionalSurfaces: readonly GraphReleaseOptionalSurfaceReceipt[]; + handoff: readonly GraphReleaseHandoffReceipt[]; +} + +export type { GraphReleaseReceiptEvidence }; + +interface GraphReleaseReceipt extends GraphReleaseReceiptIdentity, GraphReleaseReceiptEvidence {} + +export type { GraphReleaseReceipt }; diff --git a/packages/contracts/src/release/graph-optional-validators.ts b/packages/contracts/src/release/graph-optional-validators.ts new file mode 100644 index 0000000..e2f2d78 --- /dev/null +++ b/packages/contracts/src/release/graph-optional-validators.ts @@ -0,0 +1,62 @@ +import { validateGraphReleaseSurfaceClassification } from "../command/helper-validators.js"; +import { includesString } from "../shared/primitives.js"; +import { validateNonEmptyArray, validateNonEmptyString } from "../shared/validators-01.js"; +import type { GraphReleaseOptionalSurfaceReceipt } from "./graph-contracts.js"; +import type { + GraphReleaseDeferredChild} from "./graph-vocabulary-01.js"; +import { + graphReleaseDeferredChildren, + graphReleaseOptionalAnalysisSurfaces, +} from "./graph-vocabulary-01.js"; + +function validateGraphReleaseOptionalSurfaces(surfaces: readonly GraphReleaseOptionalSurfaceReceipt[]): void { + validateNonEmptyArray(surfaces, "Graph release optionalSurfaces"); + for (const surface of surfaces) { + if (!surface || typeof surface !== "object") throw new Error("Graph release optional surface is required"); + validateGraphReleaseDeferredChild(surface.issue, "Graph release optional surface issue"); + validateNonEmptyString(surface.id, "Graph release optional surface id"); + validateGraphReleaseSurfaceClassification(surface.classification); + if (surface.status !== "unsupported" && surface.status !== "deferred") { + throw new Error("Graph release optional surface status must be unsupported or deferred"); + } + if (surface.classification === "required") { + throw new Error("Graph release optional surfaces must not mark staged graph release surfaces as required"); + } + } + validateGraphReleaseOptionalAnalysisSurfaceSet(surfaces, "Graph release optional surfaces"); +} + +export { validateGraphReleaseOptionalSurfaces }; + +function validateGraphReleaseOptionalAnalysisSurfaceSet( + surfaces: readonly Pick[], + label: string, +): void { + const actual = surfaces.map(graphReleaseOptionalSurfaceKey).sort(); + const expected = graphReleaseOptionalAnalysisSurfaces.map(graphReleaseOptionalSurfaceKey).sort(); + if (actual.length !== expected.length || actual.some((value, index) => value !== expected[index])) { + throw new Error(`${label} must match staged graph release surfaces`); + } +} + +export { validateGraphReleaseOptionalAnalysisSurfaceSet }; + +function graphReleaseOptionalSurfaceKey( + surface: Pick, +): string { + return `${surface.issue}:${surface.id}:${surface.classification}:${surface.status}`; +} + +export { graphReleaseOptionalSurfaceKey }; + +function validateGraphReleaseDeferredChild( + issue: unknown, + label = "Graph release deferred child", +): GraphReleaseDeferredChild { + if (!includesString(graphReleaseDeferredChildren, issue)) { + throw new Error(`${label} must be one of ${graphReleaseDeferredChildren.join(", ")}`); + } + return issue; +} + +export { validateGraphReleaseDeferredChild }; diff --git a/packages/contracts/src/release/graph-validators-01.ts b/packages/contracts/src/release/graph-validators-01.ts new file mode 100644 index 0000000..34dd960 --- /dev/null +++ b/packages/contracts/src/release/graph-validators-01.ts @@ -0,0 +1,214 @@ +import { includesString } from "../shared/primitives.js"; +import { + validateExactStringSet, + validateNonEmptyArray, + validateNonEmptyString, + validateStringArray, +} from "../shared/validators-01.js"; +import { + validateExactValue, + validateRequiredObject, +} from "../shared/validators-02.js"; +import type { + GraphReleaseBenchmarkReceipt, + GraphReleaseCommandCoverage, + GraphReleaseDirectSqliteQueryReceipt, + GraphReleasePackageVersion, + GraphReleaseRustCommandCoverage, + GraphReleaseServeTransportReceipt, +} from "./graph-contracts.js"; +import { + validateGraphReleaseBenchmarkMetric, + validateGraphReleaseCoreCommandId, + validateGraphReleaseRustCommandId, +} from "./graph-validators-02.js"; +import { + graphReleaseOperationForServeTransportId, + graphReleaseRouteForCommandId, + graphReleaseRouteForRustCommandId, + validateGraphReleaseServeTransportId, +} from "./graph-validators-03.js"; +import { + graphReleaseBenchmarkMetrics, + graphReleaseCoreCommandIds, + graphReleaseRustCommandIds, +} from "./graph-vocabulary-01.js"; +import { graphReleaseDirectSqliteQueryIds, graphReleaseServeTransportIds } from "./graph-vocabulary-02.js"; + +function validateGraphReleasePackageVersions(versions: readonly GraphReleasePackageVersion[]): void { + validateNonEmptyArray(versions, "Graph release graphPackageVersions"); + for (const version of versions) { + if (!version || typeof version !== "object") throw new Error("Graph release package version is required"); + validateNonEmptyString(version.packageName, "Graph release package version packageName"); + validateNonEmptyString(version.version, "Graph release package version version"); + } + if (!versions.some((version) => version.packageName === "@the-open-engine/opcore-graph")) { + throw new Error("Graph release package versions must include @the-open-engine/opcore-graph"); + } +} + +export { validateGraphReleasePackageVersions }; + +function validateGraphReleaseCommandCoverage(coverage: readonly GraphReleaseCommandCoverage[]): void { + validateNonEmptyArray(coverage, "Graph release commandCoverage"); + validateExactStringSet( + coverage.map((entry) => entry.id), + graphReleaseCoreCommandIds, + "Graph release command coverage ids", + ); + for (const entry of coverage) validateGraphReleaseCommandCoverageEntry(entry); +} + +export { validateGraphReleaseCommandCoverage }; + +function validateGraphReleaseCommandCoverageEntry(entry: GraphReleaseCommandCoverage): void { + validateRequiredObject(entry, "Graph release command coverage entry is required"); + validateGraphReleaseCoreCommandId(entry.id); + validateExactValue(entry.bin, "opcore", `Unknown graph release command bin: ${String(entry.bin)}`); + validateStringArray(entry.command, "Graph release command coverage command", { allowEmpty: false }); + validateStringArray(entry.canonicalCommand, "Graph release command coverage canonicalCommand", { + allowEmpty: false, + }); + validateExactValue(entry.status, "passed", "Graph release command coverage status must be passed"); + validateExactValue(entry.exitCode, 0, "Graph release command coverage exitCode must be 0"); + validateNonEmptyString(entry.fixture, "Graph release command coverage fixture"); + if (typeof entry.durationMs !== "number" || entry.durationMs <= 0) { + throw new Error("Graph release command coverage durationMs must be positive"); + } + const route = graphReleaseRouteForCommandId(entry.id); + validateExactValue(entry.bin, route.bin, `Graph release command ${entry.id} must use ${route.bin}`); + validateExactValue( + entry.command.join("\0"), + route.command.join("\0"), + `Graph release command ${entry.id} command must be ${route.command.join(" ")}`, + ); + validateExactValue( + entry.canonicalCommand.join("\0"), + route.canonicalCommand.join("\0"), + `Graph release command ${entry.id} canonicalCommand must be ${route.canonicalCommand.join(" ")}`, + ); +} + +function validateGraphReleaseRustCommandCoverage(coverage: readonly GraphReleaseRustCommandCoverage[]): void { + validateNonEmptyArray(coverage, "Graph release rustCommandCoverage"); + validateExactStringSet( + coverage.map((entry) => entry.id), + graphReleaseRustCommandIds, + "Graph release Rust command coverage ids", + ); + for (const entry of coverage) validateGraphReleaseRustCommandCoverageEntry(entry); +} + +export { validateGraphReleaseRustCommandCoverage }; + +function validateGraphReleaseRustCommandCoverageEntry(entry: GraphReleaseRustCommandCoverage): void { + validateRequiredObject(entry, "Graph release Rust command coverage entry is required"); + validateGraphReleaseRustCommandId(entry.id); + validateExactValue(entry.bin, "opcore", `Unknown graph release Rust command bin: ${String(entry.bin)}`); + validateStringArray(entry.command, "Graph release Rust command coverage command", { allowEmpty: false }); + validateStringArray(entry.canonicalCommand, "Graph release Rust command coverage canonicalCommand", { + allowEmpty: false, + }); + validateExactValue(entry.status, "passed", "Graph release Rust command coverage status must be passed"); + validateExactValue(entry.exitCode, 0, "Graph release Rust command coverage exitCode must be 0"); + validateNonEmptyString(entry.fixture, "Graph release Rust command coverage fixture"); + if (typeof entry.durationMs !== "number" || entry.durationMs <= 0) { + throw new Error("Graph release Rust command coverage durationMs must be positive"); + } + const route = graphReleaseRouteForRustCommandId(entry.id); + validateExactValue(entry.bin, route.bin, `Graph release Rust command ${entry.id} must use ${route.bin}`); + validateExactValue( + entry.command.join("\0"), + route.command.join("\0"), + `Graph release Rust command ${entry.id} route must match ${route.command.join(" ")}`, + ); + validateExactValue( + entry.canonicalCommand.join("\0"), + route.canonicalCommand.join("\0"), + `Graph release Rust command ${entry.id} route must match ${route.canonicalCommand.join(" ")}`, + ); +} + +function validateGraphReleaseDirectSqliteQueries(queries: readonly GraphReleaseDirectSqliteQueryReceipt[]): void { + validateNonEmptyArray(queries, "Graph release directSqliteQueries"); + validateExactStringSet( + queries.map((entry) => entry.id), + graphReleaseDirectSqliteQueryIds, + "Graph release direct SQLite query ids", + ); + for (const query of queries) { + if (!query || typeof query !== "object") throw new Error("Graph release direct SQLite query receipt is required"); + if (!includesString(graphReleaseDirectSqliteQueryIds, query.id)) { + throw new Error(`Unknown graph release direct SQLite query id: ${String(query.id)}`); + } + validateNonEmptyString(query.query, "Graph release direct SQLite query query"); + if (query.status !== "passed") throw new Error("Graph release direct SQLite query status must be passed"); + if (typeof query.rowCount !== "number" || query.rowCount < 0) { + throw new Error("Graph release direct SQLite query rowCount must be non-negative"); + } + validateNonEmptyString(query.fixture, "Graph release direct SQLite query fixture"); + } +} + +export { validateGraphReleaseDirectSqliteQueries }; + +function validateGraphReleaseServeTransport(receipts: readonly GraphReleaseServeTransportReceipt[]): void { + validateNonEmptyArray(receipts, "Graph release serveTransport"); + validateExactStringSet( + receipts.map((entry) => entry.id), + graphReleaseServeTransportIds, + "Graph release serve transport ids", + ); + for (const receipt of receipts) { + if (!receipt || typeof receipt !== "object") throw new Error("Graph release serve transport receipt is required"); + validateGraphReleaseServeTransportId(receipt.id); + if (receipt.protocol !== "opcore.graph.daemon") { + throw new Error("Graph release serve transport protocol must be opcore.graph.daemon"); + } + validateNonEmptyString(receipt.operation, "Graph release serve transport operation"); + if (receipt.operation !== graphReleaseOperationForServeTransportId(receipt.id)) { + throw new Error( + `Graph release serve transport ${receipt.id} operation must be ` + + graphReleaseOperationForServeTransportId(receipt.id), + ); + } + if (receipt.status !== "passed") throw new Error("Graph release serve transport status must be passed"); + if (receipt.exitCode !== 0) throw new Error("Graph release serve transport exitCode must be 0"); + } +} + +export { validateGraphReleaseServeTransport }; + +function validateGraphReleaseBenchmarks(benchmarks: readonly GraphReleaseBenchmarkReceipt[]): void { + validateNonEmptyArray(benchmarks, "Graph release benchmarks"); + validateExactStringSet( + benchmarks.map((entry) => entry.metric), + graphReleaseBenchmarkMetrics, + "Graph release benchmark metrics", + ); + for (const benchmark of benchmarks) validateGraphReleaseBenchmark(benchmark); +} + +export { validateGraphReleaseBenchmarks }; + +function validateGraphReleaseBenchmark(benchmark: GraphReleaseBenchmarkReceipt): void { + validateRequiredObject(benchmark, "Graph release benchmark receipt is required"); + validateGraphReleaseBenchmarkMetric(benchmark.metric); + if (typeof benchmark.value !== "number" || benchmark.value <= 0) { + throw new Error("Graph release benchmark value must be positive"); + } + if (benchmark.unit !== "ms" && benchmark.unit !== "bytes") { + throw new Error("Graph release benchmark unit must be ms or bytes"); + } + const expectedUnit = benchmark.metric.endsWith("_bytes") ? "bytes" : "ms"; + validateExactValue( + benchmark.unit, + expectedUnit, + `Graph release benchmark ${benchmark.metric} must use ${expectedUnit}`, + ); + validateExactValue(benchmark.baselineIssue, "#19", "Graph release benchmark baselineIssue must be #19"); + validateNonEmptyString(benchmark.baselineReceipt, "Graph release benchmark baselineReceipt"); + if (!["recorded", "within_baseline", "above_baseline", "below_baseline"].includes(benchmark.comparison)) { + throw new Error(`Unknown graph release benchmark comparison: ${String(benchmark.comparison)}`); + } +} diff --git a/packages/contracts/src/release/graph-validators-02.ts b/packages/contracts/src/release/graph-validators-02.ts new file mode 100644 index 0000000..65ce31a --- /dev/null +++ b/packages/contracts/src/release/graph-validators-02.ts @@ -0,0 +1,198 @@ +import { includesString } from "../shared/primitives.js"; +import { validateGraphProviderArtifactMetadata } from "../graph/protocol-validators.js"; +import { + validateExactStringSet, + validateNonEmptyArray, + validateNonEmptyString, + validateSha256, + validateStringArray, +} from "../shared/validators-01.js"; +import { + validateExactValue, + validateRequiredObject, +} from "../shared/validators-02.js"; +import type { + GraphReleaseHandoffReceipt, + GraphReleaseNativeArtifactEvidence, + GraphReleasePackageInspection, + GraphReleaseReportReceipt, +} from "./graph-contracts.js"; +import { validateGraphReleaseHandoffIssue } from "./graph-validators-03.js"; +import type { + GraphReleaseBenchmarkMetric, + GraphReleaseCoreCommandId, + GraphReleaseRustCommandId} from "./graph-vocabulary-01.js"; +import { + graphReleaseBenchmarkMetrics, + graphReleaseCoreCommandIds, + graphReleaseHandoffIssues, + graphReleaseRustCommandIds, +} from "./graph-vocabulary-01.js"; +import { + graphCoreNativePackageNameForTarget, + graphCoreNativeSupportedTargets, + graphReleaseReportReceiptIds, +} from "./graph-vocabulary-02.js"; + +function validateGraphReleasePackageInspection(inspection: GraphReleasePackageInspection): void { + if (!inspection || typeof inspection !== "object") throw new Error("Graph release packageInspection is required"); + if (inspection.packageName !== "@the-open-engine/opcore-graph") { + throw new Error("Graph release packageInspection packageName must be @the-open-engine/opcore-graph"); + } + validateNonEmptyString(inspection.tarballName, "Graph release packageInspection tarballName"); + if (typeof inspection.fileCount !== "number" || inspection.fileCount <= 0) { + throw new Error("Graph release packageInspection fileCount must be positive"); + } + validateStringArray(inspection.files, "Graph release packageInspection files", { allowEmpty: false }); + if (inspection.fileCount !== inspection.files.length) { + throw new Error("Graph release packageInspection fileCount must equal files length"); + } + validateStringArray(inspection.inspections, "Graph release packageInspection inspections", { allowEmpty: false }); + for (const key of [ + "forbiddenMarkersAbsent", + "generatedBuildMetadataAbsent", + "privatePathsAbsent", + "sourceProvenanceAbsent", + "packageMetadataAbsent", + "gitHistoryAbsent", + "foreignImplementationNamesAbsent", + ] as const) { + if (inspection[key] !== true) throw new Error(`Graph release packageInspection ${key} must be true`); + } +} + +export { validateGraphReleasePackageInspection }; + +function validateGraphReleaseNativeArtifacts(nativeArtifacts: readonly GraphReleaseNativeArtifactEvidence[]): void { + validateNonEmptyArray(nativeArtifacts, "Graph release nativeArtifacts"); + validateExactStringSet( + nativeArtifacts.map((artifact) => artifact.targetPlatform), + graphCoreNativeSupportedTargets, + "Graph release native artifact targets", + ); + for (const nativeArtifact of nativeArtifacts) validateGraphReleaseNativeArtifact(nativeArtifact); +} + +export { validateGraphReleaseNativeArtifacts }; + +function validateGraphReleaseNativeArtifact(nativeArtifact: GraphReleaseNativeArtifactEvidence): void { + validateRequiredObject(nativeArtifact, "Graph release native artifact evidence is required"); + const expectedPackageName = graphCoreNativePackageNameForTarget(nativeArtifact.targetPlatform); + validateExactValue( + nativeArtifact.packageName, + expectedPackageName, + `Graph release native artifact packageName for ${nativeArtifact.targetPlatform} must be ${expectedPackageName}`, + ); + validateGraphProviderArtifactMetadata(nativeArtifact.metadata); + validateExactValue( + nativeArtifact.metadata.targetPlatform, + nativeArtifact.targetPlatform, + "Graph release native artifact targetPlatform must match metadata", + ); + validateExactValue( + nativeArtifact.binaryPath, + "opcore-graph-core", + "Graph release native binaryPath must be opcore-graph-core", + ); + validateExactValue( + nativeArtifact.checksumPath, + "opcore-graph-core.sha256", + "Graph release native checksumPath must be opcore-graph-core.sha256", + ); + validateExactValue( + nativeArtifact.metadataPath, + "metadata.json", + "Graph release native metadataPath must be metadata.json", + ); + validateExactValue( + nativeArtifact.metadata.binaryPath, + nativeArtifact.binaryPath, + "Graph release native artifact binaryPath must match metadata", + ); + validateExactValue( + nativeArtifact.metadata.checksumPath, + nativeArtifact.checksumPath, + "Graph release native artifact checksumPath must match metadata", + ); + validateExactValue( + nativeArtifact.metadata.checksumSha256, + nativeArtifact.binarySha256, + "Graph release native artifact metadata checksum must match binary sha256", + ); + validateSha256(nativeArtifact.binarySha256, "Graph release native artifact binarySha256"); + validateSha256(nativeArtifact.checksumFileSha256, "Graph release native artifact checksumFileSha256"); + validateSha256(nativeArtifact.metadataSha256, "Graph release native artifact metadataSha256"); + validateExactStringSet( + nativeArtifact.packageFiles, + ["package.json", "README.md", "opcore-graph-core", "opcore-graph-core.sha256", "metadata.json"], + `Graph release native package files ${nativeArtifact.targetPlatform}`, + ); +} + +function validateGraphReleaseReportReceipts(receipts: readonly GraphReleaseReportReceipt[]): void { + validateNonEmptyArray(receipts, "Graph release reportReceipts"); + validateExactStringSet( + receipts.map((entry) => entry.id), + graphReleaseReportReceiptIds, + "Graph release report receipt ids", + ); + for (const receipt of receipts) { + if (!receipt || typeof receipt !== "object") throw new Error("Graph release report receipt is required"); + if (!includesString(graphReleaseReportReceiptIds, receipt.id)) { + throw new Error(`Unknown graph release report receipt id: ${String(receipt.id)}`); + } + validateStringArray(receipt.command, "Graph release report receipt command", { allowEmpty: false }); + if (receipt.status !== "passed") throw new Error("Graph release report receipt status must be passed"); + if (receipt.exitCode !== 0) throw new Error("Graph release report receipt exitCode must be 0"); + validateNonEmptyString(receipt.path, "Graph release report receipt path"); + if (receipt.checksumSha256 !== undefined) + validateNonEmptyString(receipt.checksumSha256, "Graph release report receipt checksumSha256"); + } +} + +export { validateGraphReleaseReportReceipts }; + +function validateGraphReleaseHandoff(handoff: readonly GraphReleaseHandoffReceipt[]): void { + validateNonEmptyArray(handoff, "Graph release handoff"); + validateExactStringSet( + handoff.map((entry) => entry.issue), + graphReleaseHandoffIssues, + "Graph release handoff issues", + ); + for (const entry of handoff) { + if (!entry || typeof entry !== "object") throw new Error("Graph release handoff entry is required"); + validateGraphReleaseHandoffIssue(entry.issue); + validateNonEmptyString(entry.receiptPath, "Graph release handoff receiptPath"); + validateNonEmptyString(entry.checksumSha256, "Graph release handoff checksumSha256"); + validateNonEmptyString(entry.rollbackNote, "Graph release handoff rollbackNote"); + } +} + +export { validateGraphReleaseHandoff }; + +function validateGraphReleaseCoreCommandId(id: unknown): GraphReleaseCoreCommandId { + if (!includesString(graphReleaseCoreCommandIds, id)) { + throw new Error(`Unknown graph release command id: ${String(id)}`); + } + return id; +} + +export { validateGraphReleaseCoreCommandId }; + +function validateGraphReleaseRustCommandId(id: unknown): GraphReleaseRustCommandId { + if (!includesString(graphReleaseRustCommandIds, id)) { + throw new Error(`Unknown graph release Rust command id: ${String(id)}`); + } + return id; +} + +export { validateGraphReleaseRustCommandId }; + +function validateGraphReleaseBenchmarkMetric(metric: unknown): GraphReleaseBenchmarkMetric { + if (!includesString(graphReleaseBenchmarkMetrics, metric)) { + throw new Error(`Unknown graph release benchmark metric: ${String(metric)}`); + } + return metric; +} + +export { validateGraphReleaseBenchmarkMetric }; diff --git a/packages/contracts/src/release/graph-validators-03.ts b/packages/contracts/src/release/graph-validators-03.ts new file mode 100644 index 0000000..9f0d537 --- /dev/null +++ b/packages/contracts/src/release/graph-validators-03.ts @@ -0,0 +1,80 @@ +import { includesString } from "../shared/primitives.js"; +import { validateExactStringSet, validateNonEmptyArray } from "../shared/validators-01.js"; +import type { + GraphReleaseCoreCommandId, + GraphReleaseHandoffIssue, + GraphReleaseRustCommandId} from "./graph-vocabulary-01.js"; +import { + graphReleaseHandoffIssues, +} from "./graph-vocabulary-01.js"; +import type { GraphReleaseServeTransportId} from "./graph-vocabulary-02.js"; +import { graphReleaseServeTransportIds } from "./graph-vocabulary-02.js"; +import type { ReleaseReceiptPackageEvidence } from "./receipt-contracts-01.js"; +import { validateReleaseReceiptPackage } from "./receipt-validators-01.js"; +import { releaseReceiptPackageNames } from "./vocabulary-01.js"; + +function validateGraphReleaseHandoffIssue(issue: unknown): GraphReleaseHandoffIssue { + if (!includesString(graphReleaseHandoffIssues, issue)) { + throw new Error(`Unknown graph release handoff issue: ${String(issue)}`); + } + return issue; +} + +export { validateGraphReleaseHandoffIssue }; + +function validateGraphReleaseServeTransportId(id: unknown): GraphReleaseServeTransportId { + if (!includesString(graphReleaseServeTransportIds, id)) { + throw new Error(`Unknown graph release serve transport id: ${String(id)}`); + } + return id; +} + +export { validateGraphReleaseServeTransportId }; + +function graphReleaseOperationForServeTransportId(id: GraphReleaseServeTransportId): string { + return id.replace("serve-jsonl-", ""); +} + +export { graphReleaseOperationForServeTransportId }; + +function graphReleaseRouteForCommandId(id: GraphReleaseCoreCommandId): { + bin: "opcore"; + command: readonly string[]; + canonicalCommand: readonly string[]; +} { + const command = id.replace("opcore-graph-", ""); + return { + bin: "opcore", + command: ["graph", command], + canonicalCommand: ["opcore", "graph", command], + }; +} + +export { graphReleaseRouteForCommandId }; + +function graphReleaseRouteForRustCommandId(id: GraphReleaseRustCommandId): { + bin: "opcore"; + command: readonly string[]; + canonicalCommand: readonly string[]; +} { + const command = id.replace("opcore-graph-rust-", ""); + return { + bin: "opcore", + command: ["graph", command], + canonicalCommand: ["opcore", "graph", command], + }; +} + +export { graphReleaseRouteForRustCommandId }; + +function validateReleaseReceiptPackages(packages: readonly ReleaseReceiptPackageEvidence[]): void { + validateNonEmptyArray(packages, "Release receipt package evidence"); + validateExactStringSet( + packages.map((entry) => entry.packageName), + releaseReceiptPackageNames, + "Release receipt package evidence", + ); + for (const packageEvidence of packages) validateReleaseReceiptPackage(packageEvidence); +} + +export { validateReleaseReceiptPackages }; diff --git a/packages/contracts/src/release/graph-vocabulary-01.ts b/packages/contracts/src/release/graph-vocabulary-01.ts new file mode 100644 index 0000000..27ac4ec --- /dev/null +++ b/packages/contracts/src/release/graph-vocabulary-01.ts @@ -0,0 +1,117 @@ +const graphReleaseSurfaceClassifications = ["required", "supporting", "optional", "deferred"] as const; + +export { graphReleaseSurfaceClassifications }; + +type GraphReleaseSurfaceClassification = (typeof graphReleaseSurfaceClassifications)[number]; + +export type { GraphReleaseSurfaceClassification }; + +const graphReleaseCoreCommandIds = [ + "opcore-graph-build", + "opcore-graph-update", + "opcore-graph-watch", + "opcore-graph-status", + "opcore-graph-query", + "opcore-graph-impact", + "opcore-graph-search", + "opcore-graph-serve", +] as const; + +export { graphReleaseCoreCommandIds }; + +type GraphReleaseCoreCommandId = (typeof graphReleaseCoreCommandIds)[number]; + +export type { GraphReleaseCoreCommandId }; + +const graphReleaseRustCommandIds = [ + "opcore-graph-rust-build", + "opcore-graph-rust-update", + "opcore-graph-rust-watch", + "opcore-graph-rust-status", + "opcore-graph-rust-query", + "opcore-graph-rust-impact", + "opcore-graph-rust-search", + "opcore-graph-rust-serve", +] as const; + +export { graphReleaseRustCommandIds }; + +type GraphReleaseRustCommandId = (typeof graphReleaseRustCommandIds)[number]; + +export type { GraphReleaseRustCommandId }; + +const graphReleaseBenchmarkMetrics = [ + "install_setup_ms", + "cold_build_ms", + "incremental_update_ms", + "impact_cold_ms", + "impact_hot_ms", + "search_ms", + "daemon_startup_ms", + "daemon_query_ms", + "db_size_bytes", + "wal_size_bytes", +] as const; + +export { graphReleaseBenchmarkMetrics }; + +type GraphReleaseBenchmarkMetric = (typeof graphReleaseBenchmarkMetrics)[number]; + +export type { GraphReleaseBenchmarkMetric }; + +const graphReleaseRequiredChildren = ["#35", "#8", "#9", "#10", "#11", "#12", "#19", "#47"] as const; + +export { graphReleaseRequiredChildren }; + +type GraphReleaseRequiredChild = (typeof graphReleaseRequiredChildren)[number]; + +export type { GraphReleaseRequiredChild }; + +const graphReleaseDeferredChildren = ["#13", "#14", "#15", "#16"] as const; + +export { graphReleaseDeferredChildren }; + +type GraphReleaseDeferredChild = (typeof graphReleaseDeferredChildren)[number]; + +export type { GraphReleaseDeferredChild }; + +const graphReleaseOptionalAnalysisSurfaces = [ + { + issue: "#13", + id: "coverage", + classification: "deferred", + status: "deferred", + }, + { + issue: "#14", + id: "flows", + classification: "optional", + status: "deferred", + }, + { + issue: "#15", + id: "communities", + classification: "optional", + status: "deferred", + }, + { + issue: "#16", + id: "read_only_suggestions", + classification: "supporting", + status: "deferred", + }, +] as const; + +export { graphReleaseOptionalAnalysisSurfaces }; + +type GraphReleaseOptionalAnalysisSurface = (typeof graphReleaseOptionalAnalysisSurfaces)[number]; + +export type { GraphReleaseOptionalAnalysisSurface }; + +const graphReleaseHandoffIssues = ["#7", "#28", "#29"] as const; + +export { graphReleaseHandoffIssues }; + +type GraphReleaseHandoffIssue = (typeof graphReleaseHandoffIssues)[number]; + +export type { GraphReleaseHandoffIssue }; diff --git a/packages/contracts/src/release/graph-vocabulary-02.ts b/packages/contracts/src/release/graph-vocabulary-02.ts new file mode 100644 index 0000000..76a9c6c --- /dev/null +++ b/packages/contracts/src/release/graph-vocabulary-02.ts @@ -0,0 +1,69 @@ +const graphReleaseDirectSqliteQueryIds = [ + "status-counts", + "status-edge-counts", + "impact-edges-from-file", + "search-by-name", + "freshness-metadata", +] as const; + +export { graphReleaseDirectSqliteQueryIds }; + +type GraphReleaseDirectSqliteQueryId = (typeof graphReleaseDirectSqliteQueryIds)[number]; + +export type { GraphReleaseDirectSqliteQueryId }; + +const graphReleaseServeTransportIds = [ + "serve-jsonl-ping", + "serve-jsonl-status", + "serve-jsonl-query", + "serve-jsonl-search", + "serve-jsonl-shutdown", +] as const; + +export { graphReleaseServeTransportIds }; + +type GraphReleaseServeTransportId = (typeof graphReleaseServeTransportIds)[number]; + +export type { GraphReleaseServeTransportId }; + +const graphReleaseReportReceiptIds = ["conformance", "pack", "license", "provenance"] as const; + +export { graphReleaseReportReceiptIds }; + +type GraphReleaseReportReceiptId = (typeof graphReleaseReportReceiptIds)[number]; + +export type { GraphReleaseReportReceiptId }; + +const graphCoreNativeSupportedTargets = ["darwin-arm64", "darwin-x64", "linux-x64"] as const; + +export { graphCoreNativeSupportedTargets }; + +type GraphCoreNativeSupportedTarget = (typeof graphCoreNativeSupportedTargets)[number]; + +export type { GraphCoreNativeSupportedTarget }; + +const graphCoreNativePackageNames = [ + "@the-open-engine/opcore-graph-core-darwin-arm64", + "@the-open-engine/opcore-graph-core-darwin-x64", + "@the-open-engine/opcore-graph-core-linux-x64", +] as const; + +export { graphCoreNativePackageNames }; + +type GraphCoreNativePackageName = (typeof graphCoreNativePackageNames)[number]; + +export type { GraphCoreNativePackageName }; + +const graphCoreNativePackageNamesByTarget = { + "darwin-arm64": "@the-open-engine/opcore-graph-core-darwin-arm64", + "darwin-x64": "@the-open-engine/opcore-graph-core-darwin-x64", + "linux-x64": "@the-open-engine/opcore-graph-core-linux-x64", +} as const satisfies Record; + +export { graphCoreNativePackageNamesByTarget }; + +function graphCoreNativePackageNameForTarget(target: GraphCoreNativeSupportedTarget): GraphCoreNativePackageName { + return graphCoreNativePackageNamesByTarget[target]; +} + +export { graphCoreNativePackageNameForTarget }; diff --git a/packages/contracts/src/release/public-validators.ts b/packages/contracts/src/release/public-validators.ts new file mode 100644 index 0000000..0d0b679 --- /dev/null +++ b/packages/contracts/src/release/public-validators.ts @@ -0,0 +1,212 @@ +import { validateRequiredObject } from "../shared/validators-02.js"; +import { validateGraphProviderArtifactMetadata } from "../graph/protocol-validators.js"; +import { + validateExactStringSequence, + validateExactStringSet, + validateGraphReleaseSourceFreeStrings, + validateNonEmptyString, + validateSha256, +} from "../shared/validators-01.js"; +import type { AspDogfoodReceipt } from "./asp-contracts-01.js"; +import { + validateAspDogfoodAspHome, + validateAspDogfoodHostEvaluation, + validateAspDogfoodHostFixture, + validateAspDogfoodManager, + validateAspDogfoodManagerState, + validateAspDogfoodProvider, + validateAspDogfoodProviderProbe, + validateAspDogfoodRepoEnrollment, +} from "./asp-validators-01.js"; +import { + validateAspDogfoodAuthority, + validateAspDogfoodForbiddenMarkerScan, + validateAspDogfoodForbiddenProviderEntrypoint, + validateAspDogfoodParityBlockers, + validateAspDogfoodUnsupportedSurfaces, +} from "./asp-validators-02.js"; +import type { OpcoreSelfValidationReceipt, ReleaseCutoverReceipt } from "./cutover-contracts.js"; +import { + validateReleaseCutoverCommandReceipts, + validateReleaseCutoverDescriptor, + validateReleaseCutoverEnvironmentIsolation, + validateReleaseCutoverInstalledPackages, +} from "./cutover-validators-01.js"; +import { + validateReleaseCutoverForbiddenMarkerScan, + validateReleaseCutoverInputEvidence, + validateReleaseCutoverNegativeChecks, + validateReleaseCutoverPythonCommandReceipts, + validateReleaseCutoverRustCommandReceipts, +} from "./cutover-validators-02.js"; +import type { GraphReleaseReceipt } from "./graph-contracts.js"; +import { + validateGraphReleaseBenchmarks, + validateGraphReleaseCommandCoverage, + validateGraphReleaseDirectSqliteQueries, + validateGraphReleasePackageVersions, + validateGraphReleaseRustCommandCoverage, + validateGraphReleaseServeTransport, +} from "./graph-validators-01.js"; +import { + validateGraphReleaseHandoff, + validateGraphReleaseNativeArtifacts, + validateGraphReleasePackageInspection, + validateGraphReleaseReportReceipts, +} from "./graph-validators-02.js"; +import { validateGraphReleaseOptionalSurfaces } from "./graph-optional-validators.js"; +import { validateReleaseReceiptPackages } from "./graph-validators-03.js"; +import { graphReleaseDeferredChildren, graphReleaseRequiredChildren } from "./graph-vocabulary-01.js"; +import { graphCoreNativeSupportedTargets } from "./graph-vocabulary-02.js"; +import type { ReleaseReceipt } from "./receipt-contracts-02.js"; +import { validateReleaseReceiptDescriptor } from "./receipt-validators-01.js"; +import { + validateReleaseReceiptLicense, + validateReleaseReceiptNativeArtifacts, + validateReleaseReceiptProvenance, + validateReleaseReceiptSecretHistory, +} from "./receipt-validators-02.js"; +import { validateReleaseReceiptGraphReleaseEvidence, validateReleaseReceiptReports } from "./receipt-validators-03.js"; +import { releaseReceiptCommandGroups, releaseReceiptPackageNames } from "./vocabulary-01.js"; + +function validateGraphReleaseReceipt(receipt: GraphReleaseReceipt): GraphReleaseReceipt { + validateRequiredObject(receipt, "Graph release receipt is required"); + if (receipt.schemaVersion !== 1) { + throw new Error("Graph release receipt schemaVersion must be 1"); + } + if (receipt.issue !== "#17") { + throw new Error("Graph release receipt issue must be #17"); + } + if (receipt.origin !== "covibes-authored-synthetic") { + throw new Error("Graph release receipt origin must be covibes-authored-synthetic"); + } + + validateNonEmptyString(receipt.generatedAt, "Graph release receipt generatedAt"); + validateNonEmptyString(receipt.commitSha, "Graph release receipt commitSha"); + if (receipt.graphProviderSchemaVersion !== 1) { + throw new Error("Graph release receipt graphProviderSchemaVersion must be 1"); + } + validateGraphReleasePackageVersions(receipt.graphPackageVersions); + validateExactStringSet(receipt.requiredChildren, graphReleaseRequiredChildren, "Graph release required children"); + validateExactStringSet(receipt.deferredChildren, graphReleaseDeferredChildren, "Graph release deferred children"); + validateGraphReleaseCommandCoverage(receipt.commandCoverage); + validateGraphReleaseRustCommandCoverage(receipt.rustCommandCoverage); + validateGraphReleaseDirectSqliteQueries(receipt.directSqliteQueries); + validateGraphReleaseServeTransport(receipt.serveTransport); + validateGraphReleaseBenchmarks(receipt.benchmarks); + validateGraphReleasePackageInspection(receipt.packageInspection); + validateExactStringSet( + receipt.supportedNativeTargets, + graphCoreNativeSupportedTargets, + "Graph release supported native targets", + ); + validateGraphReleaseNativeArtifacts(receipt.nativeArtifacts); + validateGraphReleaseReportReceipts(receipt.reportReceipts); + validateGraphProviderArtifactMetadata(receipt.graphArtifact); + validateGraphReleaseOptionalSurfaces(receipt.optionalSurfaces); + validateGraphReleaseHandoff(receipt.handoff); + validateGraphReleaseSourceFreeStrings(receipt); + + return receipt; +} + +export { validateGraphReleaseReceipt }; + +function validateReleaseReceipt(receipt: ReleaseReceipt): ReleaseReceipt { + if (!receipt || typeof receipt !== "object") throw new Error("Release receipt is required"); + if (receipt.schemaVersion !== 1) throw new Error("Release receipt schemaVersion must be 1"); + if (receipt.issue !== "#29") throw new Error("Release receipt issue must be #29"); + if (receipt.origin !== "covibes-authored-release-proof") { + throw new Error("Release receipt origin must be covibes-authored-release-proof"); + } + validateNonEmptyString(receipt.generatedAt, "Release receipt generatedAt"); + validateNonEmptyString(receipt.commitSha, "Release receipt commitSha"); + if (receipt.privateRepo !== true) throw new Error("Release receipt maintainer evidence marker must be true"); + validateExactStringSet(receipt.packageNames, releaseReceiptPackageNames, "Release receipt package names"); + validateExactStringSet(receipt.commandGroups, releaseReceiptCommandGroups, "Release receipt command groups"); + validateReleaseReceiptPackages(receipt.packages); + validateReleaseReceiptDescriptor(receipt.descriptor, receipt.packages); + validateReleaseReceiptNativeArtifacts(receipt.nativeArtifacts, receipt.packages, receipt.descriptor); + validateReleaseReceiptLicense(receipt.license); + validateReleaseReceiptProvenance(receipt.provenance); + validateReleaseReceiptSecretHistory(receipt.secretHistory); + validateReleaseReceiptReports(receipt.reports); + validateReleaseReceiptGraphReleaseEvidence(receipt.graphReleaseReceipt); + return receipt; +} + +export { validateReleaseReceipt }; + +const validateOpcoreSelfValidation = (receipt: OpcoreSelfValidationReceipt, label: string): void => { + if (!receipt || typeof receipt !== "object") throw new Error(`${label} receipt is required`); + if (receipt.id !== "opcore-self-check") throw new Error(`${label} id must be opcore-self-check`); + validateExactStringSequence(receipt.command, ["npm", "run", "opcore:self-check"], `${label} command`); + if (receipt.status !== "passed") throw new Error(`${label} status must be passed`); + if (receipt.exitCode !== 0) throw new Error(`${label} exitCode must be 0`); + validateSha256(receipt.stdoutSha256, `${label} stdoutSha256`); + validateSha256(receipt.stderrSha256, `${label} stderrSha256`); + validateNonEmptyString(receipt.assertion, `${label} assertion`); +}; + +export { validateOpcoreSelfValidation }; + +function validateReleaseCutoverReceipt(receipt: ReleaseCutoverReceipt): ReleaseCutoverReceipt { + if (!receipt || typeof receipt !== "object") throw new Error("Release cutover receipt is required"); + if (receipt.schemaVersion !== 1) throw new Error("Release cutover receipt schemaVersion must be 1"); + if (receipt.issue !== "#30") throw new Error("Release cutover receipt issue must be #30"); + if (receipt.origin !== "covibes-authored-cutover-proof") { + throw new Error("Release cutover receipt origin must be covibes-authored-cutover-proof"); + } + validateNonEmptyString(receipt.generatedAt, "Release cutover receipt generatedAt"); + validateNonEmptyString(receipt.commitSha, "Release cutover receipt commitSha"); + if (receipt.privateRepo !== true) throw new Error("Release cutover receipt maintainer evidence marker must be true"); + validateExactStringSet(receipt.packageNames, releaseReceiptPackageNames, "Release cutover receipt package names"); + validateReleaseCutoverInstalledPackages(receipt.installedPackages); + validateReleaseCutoverDescriptor(receipt.descriptor); + validateReleaseCutoverEnvironmentIsolation(receipt.environmentIsolation); + validateReleaseCutoverCommandReceipts(receipt.commandReceipts); + validateReleaseCutoverRustCommandReceipts(receipt.rustCommandReceipts); + validateReleaseCutoverPythonCommandReceipts(receipt.pythonCommandReceipts); + validateReleaseCutoverNegativeChecks(receipt.negativeChecks); + validateOpcoreSelfValidation(receipt.selfValidation, "Release cutover self-validation"); + validateReleaseCutoverForbiddenMarkerScan(receipt.forbiddenMarkerScan); + validateReleaseCutoverInputEvidence(receipt.inputEvidence); + return receipt; +} + +export { validateReleaseCutoverReceipt }; + +function validateAspDogfoodReceipt(receipt: AspDogfoodReceipt): AspDogfoodReceipt { + if (!receipt || typeof receipt !== "object") throw new Error("ASP dogfood receipt is required"); + if (receipt.schemaVersion !== 1) throw new Error("ASP dogfood receipt schemaVersion must be 1"); + if (receipt.issue !== "#120") throw new Error("ASP dogfood receipt issue must be #120"); + if (receipt.origin !== "covibes-authored-asp-dogfood-proof") { + throw new Error("ASP dogfood receipt origin must be covibes-authored-asp-dogfood-proof"); + } + validateNonEmptyString(receipt.generatedAt, "ASP dogfood receipt generatedAt"); + validateNonEmptyString(receipt.commitSha, "ASP dogfood receipt commitSha"); + if (receipt.privateRepo !== true) throw new Error("ASP dogfood receipt privateRepo must be true"); + if (receipt.bootstrapSource !== "local-sibling") throw new Error("ASP dogfood bootstrapSource must be local-sibling"); + validateExactStringSet(receipt.packageNames, releaseReceiptPackageNames, "ASP dogfood receipt package names"); + validateReleaseCutoverInstalledPackages(receipt.installedPackages); + validateAspDogfoodManager(receipt.manager); + validateAspDogfoodAspHome(receipt.aspHome); + validateAspDogfoodHostFixture(receipt.hostFixture); + validateAspDogfoodProvider(receipt.provider); + validateAspDogfoodManagerState(receipt.managerState); + validateAspDogfoodRepoEnrollment(receipt.repoEnrollment); + validateAspDogfoodHostEvaluation(receipt.hostEvaluation); + validateAspDogfoodProviderProbe(receipt.providerProbe); + validateOpcoreSelfValidation(receipt.selfValidation, "ASP dogfood self-validation"); + validateAspDogfoodUnsupportedSurfaces(receipt.unsupportedSurfaces); + validateAspDogfoodParityBlockers(receipt.parityBlockers); + validateAspDogfoodAuthority(receipt.authority); + if (!Array.isArray(receipt.publicReleaseActions) || receipt.publicReleaseActions.length !== 0) { + throw new Error("ASP dogfood receipt must not record public publish, registry, or standard-readiness actions"); + } + validateAspDogfoodForbiddenMarkerScan(receipt.forbiddenMarkerScan); + validateAspDogfoodForbiddenProviderEntrypoint(receipt); + return receipt; +} + +export { validateAspDogfoodReceipt }; diff --git a/packages/contracts/src/release/receipt-contracts-01.ts b/packages/contracts/src/release/receipt-contracts-01.ts new file mode 100644 index 0000000..c5ab099 --- /dev/null +++ b/packages/contracts/src/release/receipt-contracts-01.ts @@ -0,0 +1,205 @@ +import type { GraphProviderArtifactMetadata } from "../graph/provider-contracts-01.js"; +import type { + ManagedToolDescriptor, + ManagedToolDescriptorArtifactReference, + ManagedToolDescriptorArtifactType, +} from "../managed/contracts.js"; +import type { GraphCoreNativePackageName, GraphCoreNativeSupportedTarget } from "./graph-vocabulary-02.js"; +import type { + ReleaseReceiptCommandGroupName, + ReleaseReceiptPackageName, + ReleaseReceiptReportId, + ReleaseReceiptSecretFindingScope, +} from "./vocabulary-01.js"; + +interface ReleaseReceiptTarballEvidence { + filename: string; + path: string; + sha256: string; + integrity?: string; + shasum?: string; +} + +export type { ReleaseReceiptTarballEvidence }; + +interface ReleaseReceiptPackageManifestMetadata { + name: ReleaseReceiptPackageName; + version: string; + license: string; + main?: string; + types?: string; + files: readonly string[]; + bins: Readonly>; + dependencies: Readonly>; + optionalDependencies?: Readonly>; + bundledDependencies: readonly string[]; + os?: readonly string[]; + cpu?: readonly string[]; +} + +export type { ReleaseReceiptPackageManifestMetadata }; + +interface ReleaseReceiptNativeArtifactEvidence { + packageName: "opcore"; + bundledPackageName: GraphCoreNativePackageName; + targetPlatform: GraphCoreNativeSupportedTarget; + metadata: GraphProviderArtifactMetadata; + binaryPath: string; + checksumPath: string; + metadataPath: string; + binarySha256: string; + checksumFileSha256: string; + metadataSha256: string; + descriptorArtifactId: string; + descriptorChecksumId: string; +} + +export type { ReleaseReceiptNativeArtifactEvidence }; + +interface ReleaseReceiptPackageEvidence { + packageName: ReleaseReceiptPackageName; + packageRoot: string; + version: string; + manifest: ReleaseReceiptPackageManifestMetadata; + tarball: ReleaseReceiptTarballEvidence; + files: readonly string[]; + fileCount: number; + expectedFiles: readonly string[]; + expectedFileCount: number; + bins: Readonly>; + descriptorReferences: readonly ManagedToolDescriptorArtifactReference[]; + nativeArtifacts: readonly ReleaseReceiptNativeArtifactEvidence[]; +} + +export type { ReleaseReceiptPackageEvidence }; + +interface ReleaseReceiptDescriptorCommandGroupEvidence { + name: ReleaseReceiptCommandGroupName; + canonicalCommand: readonly string[]; + packageName: string; +} + +export type { ReleaseReceiptDescriptorCommandGroupEvidence }; + +interface ReleaseReceiptResolvedArtifactEvidence { + id: string; + packageName: ReleaseReceiptPackageName; + path: string; + type: ManagedToolDescriptorArtifactType; + required: boolean; + packageFile: true; + checksumRef?: string; +} + +export type { ReleaseReceiptResolvedArtifactEvidence }; + +interface ReleaseReceiptResolvedChecksumEvidence { + id: string; + packageName: ReleaseReceiptPackageName; + path: string; + algorithm: "sha256"; + artifactRef: string; + required: boolean; + packageFile: true; + value: string; +} + +export type { ReleaseReceiptResolvedChecksumEvidence }; + +interface ReleaseReceiptDescriptorEvidence { + path: string; + packageName: "opcore"; + checksumSha256: string; + descriptor: ManagedToolDescriptor; + commandGroups: readonly ReleaseReceiptDescriptorCommandGroupEvidence[]; + resolvedArtifacts: readonly ReleaseReceiptResolvedArtifactEvidence[]; + resolvedChecksums: readonly ReleaseReceiptResolvedChecksumEvidence[]; +} + +export type { ReleaseReceiptDescriptorEvidence }; + +interface ReleaseReceiptLicensePackageEvidence { + name: string; + version: string; + license: string; + source: string; + bundled: boolean; +} + +export type { ReleaseReceiptLicensePackageEvidence }; + +interface ReleaseReceiptLicenseEvidence { + reportPath: string; + reportSha256: string; + productionDependencyCount: number; + bundledDependencyCount: number; + workspacePackageCount: number; + unresolvedLicenseCount: 0; + packages: readonly ReleaseReceiptLicensePackageEvidence[]; +} + +export type { ReleaseReceiptLicenseEvidence }; + +interface ReleaseReceiptProvenanceFinding { + scope: "current-tree" | "git-history"; + marker: string; + path?: string; + commit?: string; + line?: number; +} + +export type { ReleaseReceiptProvenanceFinding }; + +interface ReleaseReceiptProvenanceEvidence { + reportPath: string; + reportSha256: string; + scannedFileCount: number; + historyCommitCount: number; + findingCount: 0; + findings: readonly ReleaseReceiptProvenanceFinding[]; +} + +export type { ReleaseReceiptProvenanceEvidence }; + +interface ReleaseReceiptSecretFinding { + scope: ReleaseReceiptSecretFindingScope; + kind: string; + path?: string; + commit?: string; + line?: number; + fingerprint: string; + allowlisted: boolean; +} + +export type { ReleaseReceiptSecretFinding }; + +interface ReleaseReceiptSecretHistoryEvidence { + allowlistPath: string; + allowlistSha256: string; + currentTreeScannedFileCount: number; + gitHistoryScannedCommitCount: number; + findingCount: 0; + findings: readonly ReleaseReceiptSecretFinding[]; +} + +export type { ReleaseReceiptSecretHistoryEvidence }; + +interface ReleaseReceiptReport { + id: ReleaseReceiptReportId; + command: readonly string[]; + status: "passed"; + exitCode: 0; + path?: string; + checksumSha256?: string; + summary: string; +} + +export type { ReleaseReceiptReport }; + +interface ReleaseReceiptGraphReleaseEvidence { + path: string; + issue: "#17"; + checksumSha256: string; +} + +export type { ReleaseReceiptGraphReleaseEvidence }; diff --git a/packages/contracts/src/release/receipt-contracts-02.ts b/packages/contracts/src/release/receipt-contracts-02.ts new file mode 100644 index 0000000..283d899 --- /dev/null +++ b/packages/contracts/src/release/receipt-contracts-02.ts @@ -0,0 +1,32 @@ +import type { + ReleaseReceiptDescriptorEvidence, + ReleaseReceiptGraphReleaseEvidence, + ReleaseReceiptLicenseEvidence, + ReleaseReceiptNativeArtifactEvidence, + ReleaseReceiptPackageEvidence, + ReleaseReceiptProvenanceEvidence, + ReleaseReceiptReport, + ReleaseReceiptSecretHistoryEvidence, +} from "./receipt-contracts-01.js"; +import type { ReleaseReceiptCommandGroupName, ReleaseReceiptPackageName } from "./vocabulary-01.js"; + +interface ReleaseReceipt { + schemaVersion: 1; + issue: "#29"; + origin: "covibes-authored-release-proof"; + generatedAt: string; + commitSha: string; + privateRepo: true; + packageNames: readonly ReleaseReceiptPackageName[]; + commandGroups: readonly ReleaseReceiptCommandGroupName[]; + packages: readonly ReleaseReceiptPackageEvidence[]; + descriptor: ReleaseReceiptDescriptorEvidence; + nativeArtifacts: readonly ReleaseReceiptNativeArtifactEvidence[]; + license: ReleaseReceiptLicenseEvidence; + provenance: ReleaseReceiptProvenanceEvidence; + secretHistory: ReleaseReceiptSecretHistoryEvidence; + reports: readonly ReleaseReceiptReport[]; + graphReleaseReceipt: ReleaseReceiptGraphReleaseEvidence; +} + +export type { ReleaseReceipt }; diff --git a/packages/contracts/src/release/receipt-validators-01.ts b/packages/contracts/src/release/receipt-validators-01.ts new file mode 100644 index 0000000..372ade4 --- /dev/null +++ b/packages/contracts/src/release/receipt-validators-01.ts @@ -0,0 +1,239 @@ +import { includesString } from "../shared/primitives.js"; +import type { ManagedToolDescriptorArtifactReference} from "../managed/contracts.js"; +import { managedToolDescriptorArtifactTypes } from "../managed/contracts.js"; +import { + isGraphCoreNativePackageName, + validateManagedToolDescriptorPackageReference, + validateReleaseReceiptCommandGroupName, + validateReleaseReceiptPackageName, + validateStringRecord, +} from "../managed/helper-validators.js"; +import { validateManagedToolDescriptor } from "../managed/validators-01.js"; +import { validateRepoRelativePath } from "../shared/path-validators.js"; +import { + packageEvidenceIncludesFile, + validateExactStringSequence, + validateExactStringSet, + validateNonEmptyArray, + validateNonEmptyString, + validateSha256, + validateStringArray, +} from "../shared/validators-01.js"; +import { + validateExactValue, + validateRequiredObject, +} from "../shared/validators-02.js"; +import { graphCoreNativeSupportedTargets } from "./graph-vocabulary-02.js"; +import type { + ReleaseReceiptDescriptorEvidence, + ReleaseReceiptPackageEvidence, + ReleaseReceiptPackageManifestMetadata, + ReleaseReceiptResolvedArtifactEvidence, + ReleaseReceiptTarballEvidence, +} from "./receipt-contracts-01.js"; +import { validateReleaseReceiptNativeArtifact, validateReleaseResolvedChecksums } from "./receipt-validators-02.js"; +import { validateReleaseReceiptBins } from "./receipt-validators-03.js"; +import type { ReleaseReceiptPackageName} from "./vocabulary-01.js"; +import { releaseReceiptCommandGroups } from "./vocabulary-01.js"; + +function validateReleaseReceiptPackage(packageEvidence: ReleaseReceiptPackageEvidence): void { + validateRequiredObject(packageEvidence, "Release receipt package evidence entry is required"); + validateReleaseReceiptPackageName(packageEvidence.packageName, "Release receipt package evidence packageName"); + validateRepoRelativePath(packageEvidence.packageRoot); + validateNonEmptyString(packageEvidence.version, "Release receipt package evidence version"); + validateReleaseReceiptPackageManifest(packageEvidence.manifest, packageEvidence.packageName); + validateReleaseReceiptTarball(packageEvidence.tarball); + validateStringArray(packageEvidence.files, "Release receipt package evidence files", { allowEmpty: false }); + validateStringArray(packageEvidence.expectedFiles, "Release receipt package evidence expectedFiles", { + allowEmpty: false, + }); + validateReleaseReceiptPackageCounts(packageEvidence); + validateExactStringSet( + packageEvidence.files, + packageEvidence.expectedFiles, + `${packageEvidence.packageName} packed files`, + ); + validateReleaseReceiptBins(packageEvidence.bins, packageEvidence.packageName); + validateReleaseReceiptDescriptorReferences(packageEvidence); + validateReleaseReceiptPackageNativeArtifacts(packageEvidence); + for (const nativeArtifact of packageEvidence.nativeArtifacts) validateReleaseReceiptNativeArtifact(nativeArtifact); +} + +export { validateReleaseReceiptPackage }; + +function validateReleaseReceiptPackageCounts(packageEvidence: ReleaseReceiptPackageEvidence): void { + if (!Number.isInteger(packageEvidence.fileCount) || packageEvidence.fileCount !== packageEvidence.files.length) { + throw new Error("Release receipt package evidence fileCount must equal files length"); + } + if ( + !Number.isInteger(packageEvidence.expectedFileCount) || + packageEvidence.expectedFileCount !== packageEvidence.expectedFiles.length + ) { + throw new Error("Release receipt package evidence expectedFileCount must equal expectedFiles length"); + } +} + +function validateReleaseReceiptDescriptorReferences(packageEvidence: ReleaseReceiptPackageEvidence): void { + for (const descriptorReference of packageEvidence.descriptorReferences) { + validateManagedToolDescriptorPackageReference(descriptorReference, packageEvidence.packageName); + if (!packageEvidence.files.includes(descriptorReference.path)) { + throw new Error( + `Release receipt descriptor reference ${descriptorReference.id} is not in ` + + `${packageEvidence.packageName} packed files`, + ); + } + } +} + +function validateReleaseReceiptPackageNativeArtifacts(packageEvidence: ReleaseReceiptPackageEvidence): void { + if (packageEvidence.packageName === "opcore") { + validateNonEmptyArray(packageEvidence.nativeArtifacts, "Release receipt native package artifacts"); + validateExactStringSet( + packageEvidence.nativeArtifacts.map((entry) => entry.targetPlatform), + graphCoreNativeSupportedTargets, + "Release receipt Opcore bundled native artifact targets", + ); + } else if (isGraphCoreNativePackageName(packageEvidence.packageName)) { + throw new Error("Release receipt must not publish native graph-core packages separately"); + } else if (packageEvidence.nativeArtifacts.length > 0) { + throw new Error(`${packageEvidence.packageName} must not report native graph artifacts`); + } +} + +function validateReleaseReceiptPackageManifest( + manifest: ReleaseReceiptPackageManifestMetadata, + packageName: ReleaseReceiptPackageName, +): void { + if (!manifest || typeof manifest !== "object") throw new Error("Release receipt package manifest is required"); + if (manifest.name !== packageName) throw new Error(`Release receipt package manifest name must match ${packageName}`); + validateNonEmptyString(manifest.version, "Release receipt package manifest version"); + validateNonEmptyString(manifest.license, "Release receipt package manifest license"); + if (isGraphCoreNativePackageName(packageName)) { + if (manifest.main !== undefined || manifest.types !== undefined) { + throw new Error("Release receipt native package manifest must not declare main or types"); + } + } else { + if (manifest.main === undefined || manifest.types === undefined) { + throw new Error("Release receipt package manifest must declare main and types"); + } + validateRepoRelativePath(manifest.main); + validateRepoRelativePath(manifest.types); + } + validateStringArray(manifest.files, "Release receipt package manifest files", { allowEmpty: false }); + validateReleaseReceiptBins(manifest.bins, packageName); + validateStringRecord(manifest.dependencies, "Release receipt package manifest dependencies"); + if (manifest.optionalDependencies !== undefined) { + validateStringRecord(manifest.optionalDependencies, "Release receipt package manifest optionalDependencies"); + } + validateStringArray(manifest.bundledDependencies, "Release receipt package manifest bundledDependencies", { + allowEmpty: true, + }); +} + +export { validateReleaseReceiptPackageManifest }; + +function validateReleaseReceiptTarball(tarball: ReleaseReceiptTarballEvidence): void { + if (!tarball || typeof tarball !== "object") throw new Error("Release receipt tarball evidence is required"); + validateNonEmptyString(tarball.filename, "Release receipt tarball filename"); + validateRepoRelativePath(tarball.path); + validateSha256(tarball.sha256, "Release receipt tarball sha256"); + if (tarball.integrity !== undefined) validateNonEmptyString(tarball.integrity, "Release receipt tarball integrity"); + if (tarball.shasum !== undefined) validateNonEmptyString(tarball.shasum, "Release receipt tarball shasum"); +} + +export { validateReleaseReceiptTarball }; + +function validateReleaseReceiptDescriptor( + descriptorEvidence: ReleaseReceiptDescriptorEvidence, + packages: readonly ReleaseReceiptPackageEvidence[], +): void { + if (!descriptorEvidence || typeof descriptorEvidence !== "object") + throw new Error("Release receipt descriptor evidence is required"); + validateRepoRelativePath(descriptorEvidence.path); + if (descriptorEvidence.packageName !== "opcore") { + throw new Error("Release receipt descriptor packageName must be opcore"); + } + validateSha256(descriptorEvidence.checksumSha256, "Release receipt descriptor checksumSha256"); + const descriptor = validateManagedToolDescriptor(descriptorEvidence.descriptor); + validateExactStringSet( + descriptorEvidence.commandGroups.map((entry) => entry.name), + releaseReceiptCommandGroups, + "Release receipt descriptor command groups", + ); + for (const group of descriptorEvidence.commandGroups) { + validateReleaseReceiptCommandGroupName(group.name, "Release receipt descriptor command group name"); + validateExactStringSequence( + group.canonicalCommand, + ["opcore", group.name], + `Release receipt descriptor ${group.name} canonicalCommand`, + ); + const descriptorGroup = descriptor.commandGroups.find((entry) => entry.name === group.name); + if (!descriptorGroup) + throw new Error(`Release receipt descriptor command group missing from descriptor: ${group.name}`); + if (group.packageName !== descriptorGroup.packageName) { + throw new Error(`Release receipt descriptor command group ${group.name} packageName must match descriptor`); + } + } + validateReleaseResolvedArtifacts(descriptorEvidence.resolvedArtifacts, descriptor.artifacts, packages); + validateReleaseResolvedChecksums(descriptorEvidence.resolvedChecksums, descriptor.checksums, packages); +} + +export { validateReleaseReceiptDescriptor }; + +function validateReleaseResolvedArtifacts( + resolvedArtifacts: readonly ReleaseReceiptResolvedArtifactEvidence[], + descriptorArtifacts: readonly ManagedToolDescriptorArtifactReference[], + packages: readonly ReleaseReceiptPackageEvidence[], +): void { + validateNonEmptyArray(resolvedArtifacts, "Release receipt descriptor resolvedArtifacts"); + validateExactStringSet( + resolvedArtifacts.map((entry) => entry.id), + descriptorArtifacts.map((entry) => entry.id), + "Release receipt descriptor resolved artifact ids", + ); + for (const resolved of resolvedArtifacts) { + validateReleaseResolvedArtifact(resolved, descriptorArtifacts, packages); + } +} + +export { validateReleaseResolvedArtifacts }; + +function validateReleaseResolvedArtifact( + resolved: ReleaseReceiptResolvedArtifactEvidence, + descriptorArtifacts: readonly ManagedToolDescriptorArtifactReference[], + packages: readonly ReleaseReceiptPackageEvidence[], +): void { + validateReleaseReceiptPackageName(resolved.packageName, "Release receipt descriptor resolved artifact packageName"); + validateRepoRelativePath(resolved.path); + if (!includesString(managedToolDescriptorArtifactTypes, resolved.type)) { + throw new Error(`Unknown release receipt descriptor resolved artifact type: ${String(resolved.type)}`); + } + validateExactValue( + resolved.packageFile, + true, + `Release receipt resolved artifact ${resolved.id} must resolve to a package file`, + ); + const descriptorArtifact = descriptorArtifacts.find((entry) => entry.id === resolved.id); + if (!descriptorArtifact) { + throw new Error(`Release receipt resolved artifact is not declared by descriptor: ${resolved.id}`); + } + validateReleaseResolvedArtifactMirror(resolved, descriptorArtifact); + if (!packageEvidenceIncludesFile(packages, resolved.packageName, resolved.path)) { + throw new Error(`Release receipt resolved artifact ${resolved.id} is not present in packed package files`); + } +} + +function validateReleaseResolvedArtifactMirror( + resolved: ReleaseReceiptResolvedArtifactEvidence, + descriptorArtifact: ManagedToolDescriptorArtifactReference, +): void { + if ( + descriptorArtifact.packageName !== resolved.packageName || + descriptorArtifact.path !== resolved.path || + descriptorArtifact.type !== resolved.type || + descriptorArtifact.required !== resolved.required || + descriptorArtifact.checksumRef !== resolved.checksumRef + ) { + throw new Error(`Release receipt resolved artifact must mirror descriptor: ${resolved.id}`); + } +} diff --git a/packages/contracts/src/release/receipt-validators-02.ts b/packages/contracts/src/release/receipt-validators-02.ts new file mode 100644 index 0000000..4982064 --- /dev/null +++ b/packages/contracts/src/release/receipt-validators-02.ts @@ -0,0 +1,287 @@ +import { validateGraphProviderArtifactMetadata } from "../graph/protocol-validators.js"; +import type { ManagedToolDescriptorChecksumReference } from "../managed/contracts.js"; +import { + bundledGraphCoreNativePath, + graphCoreNativeTargetForPackageName, + isGraphCoreNativePackageName, + validateReleaseReceiptPackageName, +} from "../managed/helper-validators.js"; +import { validateRepoRelativePath } from "../shared/path-validators.js"; +import { + packageEvidenceIncludesFile, + validateExactStringSet, + validateNonEmptyArray, + validateNonEmptyString, + validateNonNegativeInteger, + validateSha256, +} from "../shared/validators-01.js"; +import { + validateExactValue, + validateRequiredObject, +} from "../shared/validators-02.js"; +import { graphCoreNativeSupportedTargets } from "./graph-vocabulary-02.js"; +import type { + ReleaseReceiptDescriptorEvidence, + ReleaseReceiptLicenseEvidence, + ReleaseReceiptNativeArtifactEvidence, + ReleaseReceiptPackageEvidence, + ReleaseReceiptProvenanceEvidence, + ReleaseReceiptResolvedChecksumEvidence, + ReleaseReceiptSecretHistoryEvidence, +} from "./receipt-contracts-01.js"; +import { releaseReceiptPackageNames } from "./vocabulary-01.js"; + +function validateReleaseResolvedChecksums( + resolvedChecksums: readonly ReleaseReceiptResolvedChecksumEvidence[], + descriptorChecksums: readonly ManagedToolDescriptorChecksumReference[], + packages: readonly ReleaseReceiptPackageEvidence[], +): void { + validateNonEmptyArray(resolvedChecksums, "Release receipt descriptor resolvedChecksums"); + validateExactStringSet( + resolvedChecksums.map((entry) => entry.id), + descriptorChecksums.map((entry) => entry.id), + "Release receipt descriptor resolved checksum ids", + ); + for (const resolved of resolvedChecksums) { + validateReleaseResolvedChecksum(resolved, descriptorChecksums, packages); + } +} + +export { validateReleaseResolvedChecksums }; + +function validateReleaseResolvedChecksum( + resolved: ReleaseReceiptResolvedChecksumEvidence, + descriptorChecksums: readonly ManagedToolDescriptorChecksumReference[], + packages: readonly ReleaseReceiptPackageEvidence[], +): void { + validateReleaseReceiptPackageName(resolved.packageName, "Release receipt descriptor resolved checksum packageName"); + validateRepoRelativePath(resolved.path); + validateExactValue( + resolved.algorithm, + "sha256", + "Release receipt descriptor checksum algorithm must be sha256", + ); + validateExactValue( + resolved.packageFile, + true, + `Release receipt resolved checksum ${resolved.id} must resolve to a package file`, + ); + validateSha256(resolved.value, "Release receipt descriptor checksum value"); + const descriptorChecksum = descriptorChecksums.find((entry) => entry.id === resolved.id); + if (!descriptorChecksum) { + throw new Error(`Release receipt resolved checksum is not declared by descriptor: ${resolved.id}`); + } + validateReleaseResolvedChecksumMirror(resolved, descriptorChecksum); + if (!packageEvidenceIncludesFile(packages, resolved.packageName, resolved.path)) { + throw new Error(`Release receipt resolved checksum ${resolved.id} is not present in packed package files`); + } +} + +function validateReleaseResolvedChecksumMirror( + resolved: ReleaseReceiptResolvedChecksumEvidence, + descriptorChecksum: ManagedToolDescriptorChecksumReference, +): void { + if ( + descriptorChecksum.packageName !== resolved.packageName || + descriptorChecksum.path !== resolved.path || + descriptorChecksum.algorithm !== resolved.algorithm || + descriptorChecksum.artifactRef !== resolved.artifactRef || + descriptorChecksum.required !== resolved.required + ) { + throw new Error(`Release receipt resolved checksum must mirror descriptor: ${resolved.id}`); + } + if (descriptorChecksum.value !== undefined && descriptorChecksum.value !== resolved.value) { + throw new Error(`Release receipt resolved checksum value must match descriptor: ${resolved.id}`); + } +} + +function validateReleaseReceiptNativeArtifacts( + nativeArtifacts: readonly ReleaseReceiptNativeArtifactEvidence[], + packages: readonly ReleaseReceiptPackageEvidence[], + descriptorEvidence: ReleaseReceiptDescriptorEvidence, +): void { + validateNonEmptyArray(nativeArtifacts, "Release receipt native artifacts"); + validateExactStringSet( + nativeArtifacts.map((artifact) => artifact.targetPlatform), + graphCoreNativeSupportedTargets, + "Release receipt native artifact targets", + ); + for (const nativeArtifact of nativeArtifacts) { + validateReleaseReceiptNativeArtifactBinding(nativeArtifact, packages, descriptorEvidence); + } +} + +export { validateReleaseReceiptNativeArtifacts }; + +function validateReleaseReceiptNativeArtifactBinding( + nativeArtifact: ReleaseReceiptNativeArtifactEvidence, + packages: readonly ReleaseReceiptPackageEvidence[], + descriptorEvidence: ReleaseReceiptDescriptorEvidence, +): void { + validateReleaseReceiptNativeArtifact(nativeArtifact); + validateReleaseReceiptNativeArtifactFiles(nativeArtifact, packages); + const binaryArtifact = descriptorEvidence.resolvedArtifacts.find( + (artifact) => artifact.id === nativeArtifact.descriptorArtifactId, + ); + if ( + !binaryArtifact || + binaryArtifact.packageName !== nativeArtifact.packageName || + binaryArtifact.path !== nativeArtifact.binaryPath + ) { + throw new Error("Release receipt native artifact binary must resolve from descriptor artifacts"); + } + const checksum = descriptorEvidence.resolvedChecksums.find( + (entry) => entry.id === nativeArtifact.descriptorChecksumId, + ); + if ( + !checksum || + checksum.packageName !== nativeArtifact.packageName || + checksum.path !== nativeArtifact.checksumPath || + checksum.value !== nativeArtifact.binarySha256 + ) { + throw new Error("Release receipt native artifact checksum must resolve from descriptor checksum evidence"); + } +} + +function validateReleaseReceiptNativeArtifactFiles( + nativeArtifact: ReleaseReceiptNativeArtifactEvidence, + packages: readonly ReleaseReceiptPackageEvidence[], +): void { + const files = [ + [nativeArtifact.binaryPath, "binary"], + [nativeArtifact.checksumPath, "checksum"], + [nativeArtifact.metadataPath, "metadata"], + ] as const; + for (const [path, label] of files) { + if (!packageEvidenceIncludesFile(packages, nativeArtifact.packageName, path)) { + throw new Error(`Release receipt native artifact ${label} must be present in native package files`); + } + } +} + +function validateReleaseReceiptNativeArtifact(nativeArtifact: ReleaseReceiptNativeArtifactEvidence): void { + validateRequiredObject(nativeArtifact, "Release receipt native artifact evidence is required"); + validateExactValue( + nativeArtifact.packageName, + "opcore", + "Release receipt native artifact packageName must be opcore", + ); + if (!isGraphCoreNativePackageName(nativeArtifact.bundledPackageName)) { + throw new Error("Release receipt native artifact bundledPackageName must be an Opcore graph-core native package"); + } + const expectedTarget = graphCoreNativeTargetForPackageName(nativeArtifact.bundledPackageName); + validateExactValue( + nativeArtifact.targetPlatform, + expectedTarget, + `Release receipt native artifact targetPlatform must be ${expectedTarget}`, + ); + validateExactValue( + nativeArtifact.binaryPath, + bundledGraphCoreNativePath(nativeArtifact.bundledPackageName, "opcore-graph-core"), + "Release receipt native artifact binaryPath must point at the bundled native binary", + ); + validateExactValue( + nativeArtifact.checksumPath, + bundledGraphCoreNativePath(nativeArtifact.bundledPackageName, "opcore-graph-core.sha256"), + "Release receipt native artifact checksumPath must point at the bundled native checksum", + ); + validateExactValue( + nativeArtifact.metadataPath, + bundledGraphCoreNativePath(nativeArtifact.bundledPackageName, "metadata.json"), + "Release receipt native artifact metadataPath must point at the bundled native metadata", + ); + validateGraphProviderArtifactMetadata(nativeArtifact.metadata); + validateRepoRelativePath(nativeArtifact.binaryPath); + validateRepoRelativePath(nativeArtifact.checksumPath); + validateRepoRelativePath(nativeArtifact.metadataPath); + validateSha256(nativeArtifact.binarySha256, "Release receipt native artifact binarySha256"); + validateSha256(nativeArtifact.checksumFileSha256, "Release receipt native artifact checksumFileSha256"); + validateSha256(nativeArtifact.metadataSha256, "Release receipt native artifact metadataSha256"); + validateNonEmptyString(nativeArtifact.descriptorArtifactId, "Release receipt native artifact descriptorArtifactId"); + validateNonEmptyString(nativeArtifact.descriptorChecksumId, "Release receipt native artifact descriptorChecksumId"); + validateReleaseReceiptNativeArtifactMetadata(nativeArtifact); +} + +export { validateReleaseReceiptNativeArtifact }; + +function validateReleaseReceiptNativeArtifactMetadata(nativeArtifact: ReleaseReceiptNativeArtifactEvidence): void { + validateExactValue( + nativeArtifact.metadata.targetPlatform, + nativeArtifact.targetPlatform, + "Release receipt native artifact targetPlatform must match metadata", + ); + validateExactValue( + nativeArtifact.metadata.binaryPath, + "opcore-graph-core", + "Release receipt native artifact binaryPath must match metadata", + ); + validateExactValue( + nativeArtifact.metadata.checksumPath, + "opcore-graph-core.sha256", + "Release receipt native artifact checksumPath must match metadata", + ); + validateExactValue( + nativeArtifact.metadata.checksumSha256, + nativeArtifact.binarySha256, + "Release receipt native artifact binary sha256 must match metadata checksum", + ); +} + +function validateReleaseReceiptLicense(license: ReleaseReceiptLicenseEvidence): void { + if (!license || typeof license !== "object") throw new Error("Release receipt license evidence is required"); + validateRepoRelativePath(license.reportPath); + validateSha256(license.reportSha256, "Release receipt license reportSha256"); + validateNonNegativeInteger(license.productionDependencyCount, "Release receipt license productionDependencyCount"); + validateNonNegativeInteger(license.bundledDependencyCount, "Release receipt license bundledDependencyCount"); + validateNonNegativeInteger(license.workspacePackageCount, "Release receipt license workspacePackageCount"); + if (license.workspacePackageCount < releaseReceiptPackageNames.length) { + throw new Error( + `Release receipt license workspacePackageCount must be at least ${releaseReceiptPackageNames.length}`, + ); + } + if (license.unresolvedLicenseCount !== 0) throw new Error("Release receipt license unresolvedLicenseCount must be 0"); + if (!Array.isArray(license.packages)) throw new Error("Release receipt license packages must be an array"); + for (const packageEvidence of license.packages) { + validateNonEmptyString(packageEvidence.name, "Release receipt license package name"); + validateNonEmptyString(packageEvidence.version, "Release receipt license package version"); + validateNonEmptyString(packageEvidence.license, "Release receipt license package license"); + validateNonEmptyString(packageEvidence.source, "Release receipt license package source"); + if (typeof packageEvidence.bundled !== "boolean") + throw new Error("Release receipt license package bundled must be boolean"); + } +} + +export { validateReleaseReceiptLicense }; + +function validateReleaseReceiptProvenance(provenance: ReleaseReceiptProvenanceEvidence): void { + if (!provenance || typeof provenance !== "object") throw new Error("Release receipt provenance evidence is required"); + validateRepoRelativePath(provenance.reportPath); + validateSha256(provenance.reportSha256, "Release receipt provenance reportSha256"); + validateNonNegativeInteger(provenance.scannedFileCount, "Release receipt provenance scannedFileCount"); + validateNonNegativeInteger(provenance.historyCommitCount, "Release receipt provenance historyCommitCount"); + if (provenance.findingCount !== 0 || provenance.findings.length !== 0) { + throw new Error("Release receipt provenance findings must be empty"); + } +} + +export { validateReleaseReceiptProvenance }; + +function validateReleaseReceiptSecretHistory(secretHistory: ReleaseReceiptSecretHistoryEvidence): void { + if (!secretHistory || typeof secretHistory !== "object") + throw new Error("Release receipt secret history evidence is required"); + validateRepoRelativePath(secretHistory.allowlistPath); + validateSha256(secretHistory.allowlistSha256, "Release receipt secret history allowlistSha256"); + validateNonNegativeInteger( + secretHistory.currentTreeScannedFileCount, + "Release receipt secret history currentTreeScannedFileCount", + ); + validateNonNegativeInteger( + secretHistory.gitHistoryScannedCommitCount, + "Release receipt secret history gitHistoryScannedCommitCount", + ); + if (secretHistory.findingCount !== 0 || secretHistory.findings.length !== 0) { + throw new Error("Release receipt secret findings must be empty"); + } +} + +export { validateReleaseReceiptSecretHistory }; diff --git a/packages/contracts/src/release/receipt-validators-03.ts b/packages/contracts/src/release/receipt-validators-03.ts new file mode 100644 index 0000000..e1acb0f --- /dev/null +++ b/packages/contracts/src/release/receipt-validators-03.ts @@ -0,0 +1,64 @@ +import { validateReleaseReceiptReportId } from "../managed/helper-validators.js"; +import { validateRepoRelativePath } from "../shared/path-validators.js"; +import { + validateExactStringSet, + validateNonEmptyArray, + validateNonEmptyString, + validateSha256, + validateStringArray, +} from "../shared/validators-01.js"; +import type { ReleaseReceiptGraphReleaseEvidence, ReleaseReceiptReport } from "./receipt-contracts-01.js"; +import type { ReleaseReceiptPackageName} from "./vocabulary-01.js"; +import { releaseReceiptReportIds } from "./vocabulary-01.js"; + +function validateReleaseReceiptReports(reports: readonly ReleaseReceiptReport[]): void { + validateNonEmptyArray(reports, "Release receipt reports"); + validateExactStringSet( + reports.map((entry) => entry.id), + releaseReceiptReportIds, + "Release receipt reports", + ); + for (const report of reports) { + validateReleaseReceiptReportId(report.id, "Release receipt report id"); + validateStringArray(report.command, "Release receipt report command", { + allowEmpty: false, + }); + if (report.status !== "passed") throw new Error("Release receipt report status must be passed"); + if (report.exitCode !== 0) throw new Error("Release receipt report exitCode must be 0"); + if (report.path !== undefined) validateRepoRelativePath(report.path); + if (report.checksumSha256 !== undefined) + validateSha256(report.checksumSha256, "Release receipt report checksumSha256"); + validateNonEmptyString(report.summary, "Release receipt report summary"); + } +} + +export { validateReleaseReceiptReports }; + +function validateReleaseReceiptGraphReleaseEvidence(evidence: ReleaseReceiptGraphReleaseEvidence): void { + if (!evidence || typeof evidence !== "object") throw new Error("Release receipt graph release evidence is required"); + validateRepoRelativePath(evidence.path); + if (evidence.issue !== "#17") throw new Error("Release receipt graph release evidence issue must be #17"); + validateSha256(evidence.checksumSha256, "Release receipt graph release checksumSha256"); +} + +export { validateReleaseReceiptGraphReleaseEvidence }; + +function validateReleaseReceiptBins( + bins: Readonly>, + packageName: ReleaseReceiptPackageName, +): void { + if (!bins || typeof bins !== "object" || Array.isArray(bins)) + throw new Error("Release receipt bins must be an object"); + const binNames = Object.keys(bins); + for (const bin of binNames) { + validateNonEmptyString(bin, "Release receipt bin name"); + validateRepoRelativePath(bins[bin]); + } + if (packageName === "opcore") { + validateExactStringSet(binNames, ["opcore", "opcore-asp-provider"], "Release receipt Opcore package bins"); + } else if (binNames.length > 0) { + throw new Error(`${packageName} must not expose CLI bins`); + } +} + +export { validateReleaseReceiptBins }; diff --git a/packages/contracts/src/release/vocabulary-01.ts b/packages/contracts/src/release/vocabulary-01.ts new file mode 100644 index 0000000..f96bc57 --- /dev/null +++ b/packages/contracts/src/release/vocabulary-01.ts @@ -0,0 +1,137 @@ +import { graphCoreNativePackageNames } from "./graph-vocabulary-02.js"; + +const releaseReceiptPackageNames = ["opcore"] as const; + +export { releaseReceiptPackageNames }; + +type ReleaseReceiptPackageName = (typeof releaseReceiptPackageNames)[number]; + +export type { ReleaseReceiptPackageName }; + +const releaseReceiptBundledPackageNames = [ + "@the-open-engine/opcore-asp-provider", + "@the-open-engine/opcore-contracts", + "@the-open-engine/opcore-edit", + "@the-open-engine/opcore-graph", + "@the-open-engine/opcore-validation", + "@the-open-engine/opcore-validation-clone", + "@the-open-engine/opcore-validation-docs", + "@the-open-engine/opcore-validation-python", + "@the-open-engine/opcore-validation-rust", + "@the-open-engine/opcore-validation-typescript", + ...graphCoreNativePackageNames, +] as const; + +export { releaseReceiptBundledPackageNames }; + +const releaseReceiptCommandGroups = ["graph", "inspect", "edit", "check", "validate", "status", "doctor"] as const; + +export { releaseReceiptCommandGroups }; + +type ReleaseReceiptCommandGroupName = (typeof releaseReceiptCommandGroups)[number]; + +export type { ReleaseReceiptCommandGroupName }; + +const releaseReceiptReportIds = [ + "package-inspection", + "license", + "provenance", + "release-hygiene", + "graph-release", + "secret-history", +] as const; + +export { releaseReceiptReportIds }; + +type ReleaseReceiptReportId = (typeof releaseReceiptReportIds)[number]; + +export type { ReleaseReceiptReportId }; + +const releaseReceiptSecretFindingScopes = ["current-tree", "git-history"] as const; + +export { releaseReceiptSecretFindingScopes }; + +type ReleaseReceiptSecretFindingScope = (typeof releaseReceiptSecretFindingScopes)[number]; + +export type { ReleaseReceiptSecretFindingScope }; + +const releaseCutoverRequiredCommandIds = [ + "opcore-scan", + "opcore-status", + "opcore-check-changed", + "opcore-measure", + "opcore-try", + "status", + "doctor", + "graph-build", + "graph-status", + "graph-query", + "graph-impact", + "graph-review-context", + "graph-detect-changes", + "graph-search", + "graph-serve", + "inspect-symbols", + "inspect-definition", + "inspect-references", + "inspect-signature", + "inspect-implementations", + "inspect-search", + "edit-preview", + "edit-apply", + "edit-refused", + "check-files", + "validate-request", + "validate-pre-write-pass", + "validate-pre-write-fail", +] as const; + +export { releaseCutoverRequiredCommandIds }; + +type ReleaseCutoverCommandId = (typeof releaseCutoverRequiredCommandIds)[number]; + +export type { ReleaseCutoverCommandId }; + +const releaseCutoverRustCommandIds = [ + "graph-rust-build", + "graph-rust-status", + "graph-rust-query", + "graph-rust-impact", + "graph-rust-review-context", + "graph-rust-detect-changes", + "graph-rust-search", +] as const; + +export { releaseCutoverRustCommandIds }; + +type ReleaseCutoverRustCommandId = (typeof releaseCutoverRustCommandIds)[number]; + +export type { ReleaseCutoverRustCommandId }; + +const releaseCutoverPythonCommandIds = [ + "opcore-python-scan", + "opcore-python-status", + "opcore-python-check-changed", + "opcore-python-measure", + "graph-python-build", + "graph-python-status", + "graph-python-query", + "graph-python-search", +] as const; + +export { releaseCutoverPythonCommandIds }; + +type ReleaseCutoverPythonCommandId = (typeof releaseCutoverPythonCommandIds)[number]; + +export type { ReleaseCutoverPythonCommandId }; + +const releaseCutoverNegativeCheckIds = [ + "missing-required-graph-check", + "missing-required-graph-validate", + "python-types-degraded-no-tools", + "python-source-hygiene-no-ruff", + "python-relevant-tests-no-pytest", + "python-toolchain-degraded-no-tools", +] as const; + +export { releaseCutoverNegativeCheckIds }; diff --git a/packages/contracts/src/release/vocabulary-02.ts b/packages/contracts/src/release/vocabulary-02.ts new file mode 100644 index 0000000..1a658a8 --- /dev/null +++ b/packages/contracts/src/release/vocabulary-02.ts @@ -0,0 +1,57 @@ +import type { CommandOwner, CommandRouteStatus } from "../command/vocabulary.js"; +import type { releaseCutoverNegativeCheckIds } from "./vocabulary-01.js"; + +type ReleaseCutoverNegativeCheckId = (typeof releaseCutoverNegativeCheckIds)[number]; + +export type { ReleaseCutoverNegativeCheckId }; + +const releaseCutoverInputIssues = ["#17", "#29", "#58"] as const; + +export { releaseCutoverInputIssues }; + +type ReleaseCutoverInputIssue = (typeof releaseCutoverInputIssues)[number]; + +export type { ReleaseCutoverInputIssue }; + +const aspDogfoodUnsupportedSurfaceIds = ["inspect", "edit"] as const; + +export { aspDogfoodUnsupportedSurfaceIds }; + +type AspDogfoodUnsupportedSurfaceId = (typeof aspDogfoodUnsupportedSurfaceIds)[number]; + +export type { AspDogfoodUnsupportedSurfaceId }; + +const aspDogfoodForbiddenProviderMarkers = ["opcore asp serve", "opcore asp"] as const; + +export { aspDogfoodForbiddenProviderMarkers }; + +type AspDogfoodForbiddenProviderMarker = (typeof aspDogfoodForbiddenProviderMarkers)[number]; + +export type { AspDogfoodForbiddenProviderMarker }; + +const releaseCutoverRequestFilePlaceholder = ""; + +export { releaseCutoverRequestFilePlaceholder }; + +const releaseCutoverMissingGraphRepoPlaceholder = ""; + +export { releaseCutoverMissingGraphRepoPlaceholder }; + +const releaseCutoverRequiredGraphRequestPlaceholder = ""; + +export { releaseCutoverRequiredGraphRequestPlaceholder }; + +type ReleaseCutoverExpectedCommandStatus = CommandRouteStatus; + +export type { ReleaseCutoverExpectedCommandStatus }; + +interface ReleaseCutoverCommandExpectation { + readonly canonicalCommand: readonly string[]; + readonly requestFileBasename?: string; + readonly owner: CommandOwner; + readonly status: ReleaseCutoverExpectedCommandStatus; + readonly exitCode: 0 | 1 | 2 | 64; + readonly bin: "opcore"; +} + +export type { ReleaseCutoverCommandExpectation }; diff --git a/packages/contracts/src/release/vocabulary-03.ts b/packages/contracts/src/release/vocabulary-03.ts new file mode 100644 index 0000000..8f90457 --- /dev/null +++ b/packages/contracts/src/release/vocabulary-03.ts @@ -0,0 +1,272 @@ +import type { ReleaseCutoverCommandId } from "./vocabulary-01.js"; +import type { ReleaseCutoverCommandExpectation} from "./vocabulary-02.js"; +import { releaseCutoverRequestFilePlaceholder } from "./vocabulary-02.js"; + +const releaseCutoverCommandExpectations = { + "opcore-scan": { + canonicalCommand: ["opcore", "scan"], + owner: "runtime", + status: "ok", + exitCode: 0, + bin: "opcore", + }, + "opcore-status": { + canonicalCommand: ["opcore", "status"], + owner: "runtime", + status: "ok", + exitCode: 0, + bin: "opcore", + }, + "opcore-check-changed": { + canonicalCommand: [ + "opcore", + "check", + "changed", + "--report-mode", + "introduced", + "--base", + "HEAD", + "--checks", + "typescript.syntax", + ], + owner: "validation", + status: "ok", + exitCode: 0, + bin: "opcore", + }, + "opcore-measure": { + canonicalCommand: ["opcore", "measure"], + owner: "runtime", + status: "ok", + exitCode: 0, + bin: "opcore", + }, + "opcore-try": { + canonicalCommand: ["opcore", "try"], + owner: "runtime", + status: "ok", + exitCode: 0, + bin: "opcore", + }, + status: { + canonicalCommand: ["opcore", "status"], + owner: "runtime", + status: "ok", + exitCode: 0, + bin: "opcore", + }, + doctor: { + canonicalCommand: ["opcore", "doctor"], + owner: "runtime", + status: "ok", + exitCode: 0, + bin: "opcore", + }, + "graph-build": { + canonicalCommand: ["opcore", "graph", "build"], + owner: "graph", + status: "ok", + exitCode: 0, + bin: "opcore", + }, + "graph-status": { + canonicalCommand: ["opcore", "graph", "status"], + owner: "graph", + status: "ok", + exitCode: 0, + bin: "opcore", + }, + "graph-query": { + canonicalCommand: ["opcore", "graph", "query"], + owner: "graph", + status: "ok", + exitCode: 0, + bin: "opcore", + }, + "graph-impact": { + canonicalCommand: ["opcore", "graph", "impact", "--files", "src/components/GreetingCard.tsx"], + owner: "graph", + status: "ok", + exitCode: 0, + bin: "opcore", + }, + "graph-review-context": { + canonicalCommand: ["opcore", "graph", "review-context", "--files", "src/components/GreetingCard.tsx"], + owner: "graph", + status: "ok", + exitCode: 0, + bin: "opcore", + }, + "graph-detect-changes": { + canonicalCommand: ["opcore", "graph", "detect-changes", "--files", "src/components/GreetingCard.tsx"], + owner: "graph", + status: "ok", + exitCode: 0, + bin: "opcore", + }, + "graph-search": { + canonicalCommand: ["opcore", "graph", "search", "Greeting", "--limit", "5"], + owner: "graph", + status: "ok", + exitCode: 0, + bin: "opcore", + }, + "graph-serve": { + canonicalCommand: ["opcore", "graph", "serve"], + owner: "graph", + status: "ok", + exitCode: 0, + bin: "opcore", + }, + "inspect-symbols": { + canonicalCommand: ["opcore", "inspect", "symbols", "Greeting", "--limit", "5"], + owner: "inspect", + status: "ok", + exitCode: 0, + bin: "opcore", + }, + "inspect-definition": { + canonicalCommand: ["opcore", "inspect", "definition", "GreetingCard"], + owner: "inspect", + status: "ok", + exitCode: 0, + bin: "opcore", + }, + "inspect-references": { + canonicalCommand: [ + "opcore", + "inspect", + "references", + "function:src/components/GreetingCard.tsx#GreetingCard", + "--limit", + "5", + ], + owner: "inspect", + status: "ok", + exitCode: 0, + bin: "opcore", + }, + "inspect-signature": { + canonicalCommand: ["opcore", "inspect", "signature", "function:src/components/GreetingCard.tsx#GreetingCard"], + owner: "inspect", + status: "ok", + exitCode: 0, + bin: "opcore", + }, + "inspect-implementations": { + canonicalCommand: ["opcore", "inspect", "implementations", "class:src/models.ts#GreetingModel"], + owner: "inspect", + status: "ok", + exitCode: 0, + bin: "opcore", + }, + "inspect-search": { + canonicalCommand: ["opcore", "inspect", "search", "Greeting", "--limit", "5"], + owner: "inspect", + status: "ok", + exitCode: 0, + bin: "opcore", + }, + "edit-preview": { + canonicalCommand: [ + "opcore", + "edit", + "exact", + "--path", + "src/cutover.ts", + "--expected", + "export const cutoverValue: number = 1;", + "--replacement", + "export const cutoverValue: number = 2;", + ], + owner: "edit", + status: "ok", + exitCode: 0, + bin: "opcore", + }, + "edit-apply": { + canonicalCommand: [ + "opcore", + "edit", + "exact", + "--path", + "src/cutover.ts", + "--expected", + "export const cutoverValue: number = 1;", + "--replacement", + "export const cutoverValue: number = 2;", + "--apply", + ], + owner: "edit", + status: "ok", + exitCode: 0, + bin: "opcore", + }, + "edit-refused": { + canonicalCommand: [ + "opcore", + "edit", + "exact", + "--path", + "src/cutover.ts", + "--expected", + "export const cutoverValue: number = 2;", + "--replacement", + "export const cutoverValue: number = missingCutoverSymbol;", + "--apply", + ], + owner: "edit", + status: "error", + exitCode: 1, + bin: "opcore", + }, + "check-files": { + canonicalCommand: ["opcore", "check", "files", "src/cutover.ts", "--checks", "typescript.syntax,typescript.types"], + owner: "validation", + status: "ok", + exitCode: 0, + bin: "opcore", + }, + "validate-request": { + canonicalCommand: ["opcore", "validate", "request", "--request-file", releaseCutoverRequestFilePlaceholder], + requestFileBasename: "validate-request.json", + owner: "validation", + status: "ok", + exitCode: 0, + bin: "opcore", + }, + "validate-pre-write-pass": { + canonicalCommand: [ + "opcore", + "validate", + "pre-write", + "--request-file", + releaseCutoverRequestFilePlaceholder, + "--timeout-ms", + "30000", + ], + requestFileBasename: "pre-write-pass.json", + owner: "validation", + status: "ok", + exitCode: 0, + bin: "opcore", + }, + "validate-pre-write-fail": { + canonicalCommand: [ + "opcore", + "validate", + "pre-write", + "--request-file", + releaseCutoverRequestFilePlaceholder, + "--timeout-ms", + "30000", + ], + requestFileBasename: "pre-write-fail.json", + owner: "validation", + status: "error", + exitCode: 1, + bin: "opcore", + }, +} as const satisfies Record; + +export { releaseCutoverCommandExpectations }; diff --git a/packages/contracts/src/release/vocabulary-04.ts b/packages/contracts/src/release/vocabulary-04.ts new file mode 100644 index 0000000..e38e3ac --- /dev/null +++ b/packages/contracts/src/release/vocabulary-04.ts @@ -0,0 +1,181 @@ +import type { ReleaseCutoverPythonCommandId, ReleaseCutoverRustCommandId } from "./vocabulary-01.js"; +import type { + ReleaseCutoverCommandExpectation, + ReleaseCutoverNegativeCheckId} from "./vocabulary-02.js"; +import { + releaseCutoverMissingGraphRepoPlaceholder, + releaseCutoverRequiredGraphRequestPlaceholder, +} from "./vocabulary-02.js"; + +const releaseCutoverRustCommandExpectations = { + "graph-rust-build": { + canonicalCommand: ["opcore", "graph", "build"], + owner: "graph", + status: "ok", + exitCode: 0, + bin: "opcore", + }, + "graph-rust-status": { + canonicalCommand: ["opcore", "graph", "status"], + owner: "graph", + status: "ok", + exitCode: 0, + bin: "opcore", + }, + "graph-rust-query": { + canonicalCommand: ["opcore", "graph", "query"], + owner: "graph", + status: "ok", + exitCode: 0, + bin: "opcore", + }, + "graph-rust-impact": { + canonicalCommand: ["opcore", "graph", "impact", "--files", "src/helpers.rs"], + owner: "graph", + status: "ok", + exitCode: 0, + bin: "opcore", + }, + "graph-rust-review-context": { + canonicalCommand: ["opcore", "graph", "review-context", "--files", "src/helpers.rs"], + owner: "graph", + status: "ok", + exitCode: 0, + bin: "opcore", + }, + "graph-rust-detect-changes": { + canonicalCommand: ["opcore", "graph", "detect-changes", "--files", "src/helpers.rs"], + owner: "graph", + status: "ok", + exitCode: 0, + bin: "opcore", + }, + "graph-rust-search": { + canonicalCommand: ["opcore", "graph", "search", "Widget", "--limit", "5"], + owner: "graph", + status: "ok", + exitCode: 0, + bin: "opcore", + }, +} as const satisfies Record; + +export { releaseCutoverRustCommandExpectations }; + +const releaseCutoverPythonCommandExpectations = { + "opcore-python-scan": { + canonicalCommand: ["opcore", "scan"], + owner: "runtime", + status: "ok", + exitCode: 0, + bin: "opcore", + }, + "opcore-python-status": { + canonicalCommand: ["opcore", "status"], + owner: "runtime", + status: "ok", + exitCode: 0, + bin: "opcore", + }, + "opcore-python-check-changed": { + canonicalCommand: [ + "opcore", + "check", + "changed", + "--report-mode", + "introduced", + "--base", + "HEAD", + "--checks", + "python.syntax,python.source-hygiene", + ], + owner: "validation", + status: "ok", + exitCode: 0, + bin: "opcore", + }, + "opcore-python-measure": { + canonicalCommand: ["opcore", "measure"], + owner: "runtime", + status: "ok", + exitCode: 0, + bin: "opcore", + }, + "graph-python-build": { + canonicalCommand: ["opcore", "graph", "build"], + owner: "graph", + status: "ok", + exitCode: 0, + bin: "opcore", + }, + "graph-python-status": { + canonicalCommand: ["opcore", "graph", "status"], + owner: "graph", + status: "ok", + exitCode: 0, + bin: "opcore", + }, + "graph-python-query": { + canonicalCommand: ["opcore", "graph", "query"], + owner: "graph", + status: "ok", + exitCode: 0, + bin: "opcore", + }, + "graph-python-search": { + canonicalCommand: ["opcore", "graph", "search", "Greeter", "--limit", "5"], + owner: "graph", + status: "ok", + exitCode: 0, + bin: "opcore", + }, +} as const satisfies Record; + +export { releaseCutoverPythonCommandExpectations }; + +const releaseCutoverPythonEvidenceExpectations = { + "opcore-python-scan": ["python-coverage", "python-validation", "python-types-degraded"], + "opcore-python-status": ["python-coverage", "python-validation"], + "opcore-python-check-changed": ["python-syntax", "python-source-hygiene"], + "opcore-python-measure": ["python-measure-delta"], + "graph-python-build": ["python-graph-provider"], + "graph-python-status": ["python-graph-provider"], + "graph-python-query": ["src/acme/app.py", "Greeter", "build_name"], + "graph-python-search": ["src/acme/app.py", "Greeter"], +} as const satisfies Record; + +export { releaseCutoverPythonEvidenceExpectations }; + +const releaseCutoverNegativeCheckExpectations = { + "missing-required-graph-check": [ + "opcore", + "check", + "files", + "src/index.ts", + "--repo", + releaseCutoverMissingGraphRepoPlaceholder, + "--graph-mode", + "required", + "--checks", + "typescript.import-graph", + ], + "missing-required-graph-validate": [ + "opcore", + "validate", + "request", + "--request-file", + releaseCutoverRequiredGraphRequestPlaceholder, + ], + "python-types-degraded-no-tools": ["opcore", "check", "files", "src/acme/app.py", "--checks", "python.types"], + "python-source-hygiene-no-ruff": ["opcore", "check", "files", "src/acme/app.py", "--checks", "python.source-hygiene"], + "python-relevant-tests-no-pytest": [ + "opcore", + "check", + "files", + "src/acme/app.py", + "--checks", + "python.relevant-tests", + ], + "python-toolchain-degraded-no-tools": ["opcore", "status"], +} as const satisfies Record; + +export { releaseCutoverNegativeCheckExpectations }; diff --git a/packages/contracts/src/shared/json.ts b/packages/contracts/src/shared/json.ts new file mode 100644 index 0000000..23bd33a --- /dev/null +++ b/packages/contracts/src/shared/json.ts @@ -0,0 +1,7 @@ +type JsonPrimitive = string | number | boolean | null; + +export type { JsonPrimitive }; + +type JsonValue = JsonPrimitive | JsonValue[] | { [key: string]: JsonValue }; + +export type { JsonValue }; diff --git a/packages/contracts/src/shared/path-validators.ts b/packages/contracts/src/shared/path-validators.ts new file mode 100644 index 0000000..ca816df --- /dev/null +++ b/packages/contracts/src/shared/path-validators.ts @@ -0,0 +1,80 @@ +import { validateRequiredObject } from "./primitives.js"; +import { validateStringArray } from "./validators-01.js"; +import type { RepoIdentity } from "../graph/provider-contracts-01.js"; + +function validateRepoRelativePath(path: string): string { + if (typeof path !== "string" || path.length === 0) { + throw new Error("Repo-relative path must be a non-empty string"); + } + if (path.includes("\0")) { + throw new Error(`Repo-relative path contains a null byte: ${path}`); + } + if (/^[\\/]/.test(path) || /^[A-Za-z]:[\\/]/.test(path)) { + throw new Error(`Repo-relative path must not be absolute: ${path}`); + } + const normalized = path.replaceAll("\\", "/"); + if (repoPathEscapesRoot(normalized)) { + throw new Error(`Repo-relative path must not escape the repository: ${path}`); + } + return normalized; +} + +export { validateRepoRelativePath }; + +function validateRepoRelativePaths(paths: unknown, label: string): readonly string[] { + validateStringArray(paths as readonly string[] | undefined, label, { + allowEmpty: true, + }); + for (const path of paths as readonly string[]) validateRepoRelativePath(path); + return paths as readonly string[]; +} + +export { validateRepoRelativePaths }; + +function repoPathEscapesRoot(path: string): boolean { + return ( + path === "." || + path === ".." || + path.startsWith("../") || + path.includes("/../") || + path.endsWith("/..") + ); +} + +function validateHomeRelativePath(path: string): string { + if (typeof path !== "string" || path.length === 0) { + throw new Error("Home-relative path must be a non-empty string"); + } + if (path.includes("\0")) { + throw new Error(`Home-relative path contains a null byte: ${path}`); + } + const normalized = path.replaceAll("\\", "/"); + if (!normalized.startsWith("~/")) { + throw new Error(`Home-relative path must start with ~/: ${path}`); + } + if ( + normalized === "~/" || + normalized === "~/." || + normalized.includes("/../") || + normalized.endsWith("/..") || + normalized.includes("//") + ) { + throw new Error(`Home-relative path must not escape the home directory: ${path}`); + } + return normalized; +} + +export { validateHomeRelativePath }; + +function validateRepoIdentity(repo: RepoIdentity): RepoIdentity { + validateRequiredObject(repo, "Repo identity is required"); + if (repo.repoId && repo.repoRoot) { + throw new Error("Repo identity is ambiguous: use repoId or repoRoot, not both"); + } + if (!repo.repoId && !repo.repoRoot && !repo.remoteUrl) { + throw new Error("Repo identity must include repoId, repoRoot, or remoteUrl"); + } + return repo; +} + +export { validateRepoIdentity }; diff --git a/packages/contracts/src/shared/primitives.ts b/packages/contracts/src/shared/primitives.ts new file mode 100644 index 0000000..4b6015f --- /dev/null +++ b/packages/contracts/src/shared/primitives.ts @@ -0,0 +1,62 @@ +function collectStrings(value: unknown): string[] { + if (typeof value === "string") return [value]; + if (Array.isArray(value)) return value.flatMap((entry) => collectStrings(entry)); + if (!value || typeof value !== "object") return []; + return Object.values(value).flatMap((entry) => collectStrings(entry)); +} + +export { collectStrings }; + +function includesString(values: T, value: unknown): value is T[number] { + return typeof value === "string" && values.includes(value); +} + +export { includesString }; + +function sameStringArray(actual: readonly string[], expected: readonly string[]): boolean { + return actual.length === expected.length && actual.every((value, index) => value === expected[index]); +} + +export { sameStringArray }; + +function withoutUndefinedProperties>(value: T): Partial { + return Object.fromEntries(Object.entries(value).filter(([, entry]) => entry !== undefined)) as Partial; +} + +export { withoutUndefinedProperties }; + +function validateOptional(value: T | undefined, validator: (value: T) => void): void { + if (value !== undefined) validator(value); +} + +export { validateOptional }; + +function validateBoolean(value: unknown, label: string): asserts value is boolean { + if (typeof value !== "boolean") throw new Error(`${label} must be boolean`); +} + +export { validateBoolean }; + +function validateObject(value: unknown, label: string): asserts value is object { + if (!value || typeof value !== "object") throw new Error(`${label} is required`); +} + +export { validateObject }; + +function validateArray(value: unknown, label: string): asserts value is readonly unknown[] { + if (!Array.isArray(value)) throw new Error(`${label} must be an array`); +} + +export { validateArray }; + +function validateRequiredObject(value: T, message: string): asserts value is T & object { + if (!value || typeof value !== "object") throw new Error(message); +} + +export { validateRequiredObject }; + +function validateExactValue(value: T, expected: T, message: string): void { + if (value !== expected) throw new Error(message); +} + +export { validateExactValue }; diff --git a/packages/contracts/src/shared/validators-01.ts b/packages/contracts/src/shared/validators-01.ts new file mode 100644 index 0000000..a539628 --- /dev/null +++ b/packages/contracts/src/shared/validators-01.ts @@ -0,0 +1,202 @@ +import { + collectStrings, + sameStringArray, + validateRequiredObject, +} from "./primitives.js"; +import type { CommandExitSemantics } from "../command/contracts.js"; +import type { CommandRouteStatus } from "../command/vocabulary.js"; +import type { GraphFreshness } from "../graph/provider-contracts-01.js"; +import type { ReleaseReceiptPackageEvidence } from "../release/receipt-contracts-01.js"; +import type { ReleaseReceiptPackageName } from "../release/vocabulary-01.js"; +import { validationCheckIdRegex } from "../validation/vocabulary-02.js"; + +function packageEvidenceIncludesFile( + packages: readonly ReleaseReceiptPackageEvidence[], + packageName: ReleaseReceiptPackageName, + path: string, +): boolean { + return packages.find((entry) => entry.packageName === packageName)?.files.includes(path) ?? false; +} + +export { packageEvidenceIncludesFile }; + +function validateSha256(value: unknown, label: string): string { + const text = validateNonEmptyString(value, label); + if (!/^[a-f0-9]{64}$/i.test(text)) throw new Error(`${label} must be a sha256 hex digest`); + return text; +} + +export { validateSha256 }; + +function validateExactStringSet(actual: readonly string[], expected: readonly string[], label: string): void { + validateStringArray(actual, label, { allowEmpty: false }); + const actualSorted = [...actual].sort(); + const expectedSorted = [...expected].sort(); + if ( + actualSorted.length !== expectedSorted.length || + actualSorted.some((value, index) => value !== expectedSorted[index]) + ) { + throw new Error(`${label} must exactly match ${expected.join(", ")}`); + } +} + +export { validateExactStringSet }; + +function validateExactStringSequence(actual: readonly string[], expected: readonly string[], label: string): void { + validateStringArray(actual, label, { allowEmpty: false }); + if (!sameStringArray(actual, expected)) { + throw new Error(`${label} must exactly match ${expected.join(" ")}`); + } +} + +export { validateExactStringSequence }; + +function validateGraphReleaseSourceFreeStrings(value: unknown): void { + const forbidden = [/tirth8205/i, /pyproject\.toml/i, /setup\.py/i, /setup\.cfg/i, /Pipfile/i, /git clone/i]; + for (const text of collectStrings(value)) { + const pattern = forbidden.find((entry) => entry.test(text)); + if (pattern) throw new Error(`Graph release receipt contains forbidden source provenance: ${text}`); + } +} + +export { validateGraphReleaseSourceFreeStrings }; + +function validateCommandExitSemantics(exitSemantics: CommandExitSemantics): CommandExitSemantics { + validateRequiredObject(exitSemantics, "Command router manifest must include exitSemantics"); + if (exitSemantics.ok !== 0) throw new Error("Command exit semantics ok must be 0"); + if (exitSemantics.error !== 1) throw new Error("Command exit semantics error must be 1"); + if (exitSemantics.notImplemented !== 2) throw new Error("Command exit semantics notImplemented must be 2"); + if (exitSemantics.unsupported !== 64) throw new Error("Command exit semantics unsupported must be 64"); + if (typeof exitSemantics.jsonStable !== "boolean") { + throw new Error("Command exit semantics jsonStable must be boolean"); + } + return exitSemantics; +} + +export { validateCommandExitSemantics }; + +function validateExitCodeForStatus(exitCode: unknown, status: CommandRouteStatus): number { + if (typeof exitCode !== "number" || !Number.isInteger(exitCode) || exitCode < 0) { + throw new Error("Command route exitCode must be a non-negative integer"); + } + const expected = { + ok: { code: 0, message: "Command route ok status must use exitCode 0" }, + error: { code: 1, message: "Command route error status must use exitCode 1" }, + not_implemented: { + code: 2, + message: "Command route not_implemented status must use exitCode 2", + }, + unsupported: { code: 64, message: "Command route unsupported status must use exitCode 64" }, + }[status]; + if (exitCode !== expected.code) throw new Error(expected.message); + return exitCode; +} + +export { validateExitCodeForStatus }; + +function validateStringArray( + values: readonly string[] | undefined, + label: string, + options: { allowEmpty: boolean; allowEmptyValues?: boolean }, +): readonly string[] { + if (!Array.isArray(values)) { + throw new Error(`${label} must be an array`); + } + if (!options.allowEmpty && values.length === 0) { + throw new Error(`${label} must not be empty`); + } + for (const value of values) { + if (options.allowEmptyValues === true) { + if (typeof value !== "string") throw new Error(`${label} must contain only strings`); + } else { + validateNonEmptyString(value, label); + } + } + return values; +} + +export { validateStringArray }; + +function validateValidationChecks(checks: readonly string[], label: string): readonly string[] { + validateStringArray(checks, label, { allowEmpty: true }); + for (const check of checks) { + if (check.trim().length === 0) { + throw new Error(`${label} entries must include non-whitespace content`); + } + validateValidationCheckId(check, `${label} entry`); + } + return checks; +} + +export { validateValidationChecks }; + +function validateValidationCheckId(checkId: unknown, label: string): string { + const value = validateNonEmptyString(checkId, label); + if (!validationCheckIdRegex.test(value)) { + throw new Error(`${label} must be a stable validation check id`); + } + return value; +} + +export { validateValidationCheckId }; + +function validateNonNegativeNumber(value: unknown, label: string): number { + if (typeof value !== "number" || !Number.isFinite(value) || value < 0) { + throw new Error(`${label} must be a non-negative number`); + } + return value; +} + +export { validateNonNegativeNumber }; + +function validateNonNegativeInteger(value: unknown, label: string): number { + if (!Number.isInteger(value) || (value as number) < 0) { + throw new Error(`${label} must be a non-negative integer`); + } + return value as number; +} + +export { validateNonNegativeInteger }; + +function validatePositiveInteger(value: unknown, label: string): number { + if (!Number.isInteger(value) || (value as number) < 1) { + throw new Error(`${label} must be a positive integer`); + } + return value as number; +} + +export { validatePositiveInteger }; + +function validateNonEmptyArray(values: readonly unknown[] | undefined, label: string): readonly unknown[] { + if (!Array.isArray(values) || values.length === 0) { + throw new Error(`${label} must be a non-empty array`); + } + return values; +} + +export { validateNonEmptyArray }; + +function validateNonEmptyString(value: unknown, label: string): string { + if (typeof value !== "string" || value.length === 0) { + throw new Error(`${label} must be a non-empty string`); + } + return value; +} + +export { validateNonEmptyString }; + +function validateGraphFreshness(freshness: GraphFreshness | undefined, label: string): GraphFreshness { + validateRequiredObject(freshness, `${label} graph provider status must include freshness`); + if (typeof freshness.generatedAt !== "string" || freshness.generatedAt.length === 0) { + throw new Error(`${label} graph provider freshness must include generatedAt`); + } + if (typeof freshness.ageMs !== "number") { + throw new Error(`${label} graph provider freshness must include numeric ageMs`); + } + if (typeof freshness.stale !== "boolean") { + throw new Error(`${label} graph provider freshness must include stale`); + } + return freshness; +} + +export { validateGraphFreshness }; diff --git a/packages/contracts/src/shared/validators-02.ts b/packages/contracts/src/shared/validators-02.ts new file mode 100644 index 0000000..090ecbc --- /dev/null +++ b/packages/contracts/src/shared/validators-02.ts @@ -0,0 +1,33 @@ +import type { GraphSnapshotMetadata } from "../graph/provider-contracts-02.js"; +import { validateRepoIdentity } from "./path-validators.js"; +import { validateGraphFreshness } from "./validators-01.js"; +export { + collectStrings, + validateArray, + validateBoolean, + validateExactValue, + validateObject, + validateOptional, + validateRequiredObject, +} from "./primitives.js"; +import { + validateRequiredObject, +} from "./primitives.js"; + +function validateGraphSnapshotMetadata(metadata: GraphSnapshotMetadata): GraphSnapshotMetadata { + validateRequiredObject(metadata, "Graph snapshot metadata is required"); + if (typeof metadata.schemaVersion !== "number") { + throw new Error("Graph snapshot metadata must include numeric schemaVersion"); + } + if (typeof metadata.provider !== "string" || metadata.provider.length === 0) { + throw new Error("Graph snapshot metadata must include provider"); + } + validateRepoIdentity(metadata.repo); + validateGraphFreshness(metadata.freshness, "Graph snapshot"); + if (!Array.isArray(metadata.nodeKinds) || !Array.isArray(metadata.edgeKinds)) { + throw new Error("Graph snapshot metadata must include nodeKinds and edgeKinds"); + } + return metadata; +} + +export { validateGraphSnapshotMetadata }; diff --git a/packages/contracts/src/validation/capability-contracts.ts b/packages/contracts/src/validation/capability-contracts.ts new file mode 100644 index 0000000..62ac0f1 --- /dev/null +++ b/packages/contracts/src/validation/capability-contracts.ts @@ -0,0 +1,111 @@ +import type { EditRefusal } from "../edit/contracts.js"; +import type { GraphProviderStatus } from "../graph/provider-contracts-02.js"; +import type { + PythonValidationCapabilityRun, + ValidationDiagnostic, + ValidationResultManifest, +} from "./diagnostic-contracts.js"; +import type { PythonProjectContext } from "./python-project-contracts-02.js"; +import type { ValidationFailure } from "./request-contracts.js"; +import type { ValidationResultStatus } from "./vocabulary-01.js"; + +const pythonCapabilityActivations = ["enabled", "disabled", "not_applicable"] as const; + +export { pythonCapabilityActivations }; + +type PythonCapabilityActivation = (typeof pythonCapabilityActivations)[number]; + +export type { PythonCapabilityActivation }; + +const pythonPytestSelectionModes = ["none", "direct_argv", "manifest"] as const; + +export { pythonPytestSelectionModes }; + +type PythonPytestSelectionMode = (typeof pythonPytestSelectionModes)[number]; + +export type { PythonPytestSelectionMode }; + +const pythonCapabilityProcessTerminations = ["exited", "timeout", "signal", "spawn_error", "overflow"] as const; + +export { pythonCapabilityProcessTerminations }; + +type PythonCapabilityProcessTermination = (typeof pythonCapabilityProcessTerminations)[number]; + +export type { PythonCapabilityProcessTermination }; + +interface PythonCapabilityCounts { + candidateCount: number; + collectedCount: number; + executedCount: number; + passedCount: number; + failedCount: number; + skippedCount: number; + xfailedCount: number; + xpassedCount: number; + errorCount: number; +} + +export type { PythonCapabilityCounts }; + +interface PythonCapabilityCleanupEvidence { + attempted: boolean; + ok: boolean; + failureMessage?: string; +} + +export type { PythonCapabilityCleanupEvidence }; + +interface PythonCapabilityInvocation { + stage: "collection" | "execution"; + command: string; + argsDigest: string; + argCount: number; + selectionMode: PythonPytestSelectionMode; + selectionDigest?: string; + durationMs: number; + termination: PythonCapabilityProcessTermination; + exitCode?: number; + signal?: string; + outputBytes: number; + stdoutDigest?: string; + stderrDigest?: string; +} + +export type { PythonCapabilityInvocation }; + +interface PythonPytestValidationCapabilityRun { + capability: "pytest"; + checkId: string; + activation: PythonCapabilityActivation; + outcome: string; + message: string; + projectKey?: string; + projectRoot?: string; + configFile?: string; + targetCount?: number; + candidatePaths?: readonly string[]; + collectedNodeIds?: readonly string[]; + afterStateFingerprint?: string; + selectionMode?: PythonPytestSelectionMode; + selectionDigest?: string; + counts?: PythonCapabilityCounts; + collection?: PythonCapabilityInvocation; + execution?: PythonCapabilityInvocation; + cleanup?: PythonCapabilityCleanupEvidence; +} + +export type { PythonPytestValidationCapabilityRun }; + +interface ValidationResult { + ok: boolean; + status: ValidationResultStatus; + diagnostics: readonly ValidationDiagnostic[]; + graphStatus?: GraphProviderStatus; + failure?: ValidationFailure; + refusal?: EditRefusal; + manifest?: ValidationResultManifest; + pythonProjectContexts?: readonly PythonProjectContext[]; + pythonCapabilityRuns?: readonly PythonValidationCapabilityRun[]; +} + +export type { ValidationResult }; diff --git a/packages/contracts/src/validation/diagnostic-contracts.ts b/packages/contracts/src/validation/diagnostic-contracts.ts new file mode 100644 index 0000000..e7ca5e9 --- /dev/null +++ b/packages/contracts/src/validation/diagnostic-contracts.ts @@ -0,0 +1,140 @@ +import type { PythonPytestValidationCapabilityRun } from "./capability-contracts.js"; +import type { + PYTHON_VALIDATION_CAPABILITY_RUN_SCHEMA_ID, + PythonProjectExecutableSource, +} from "./python-project-contracts-01.js"; +import type { PythonTypesValidationCapabilityRun } from "./python-project-contracts-02.js"; +import type { ValidationScopeKind } from "./request-contracts.js"; +import type { + ValidationCheckOutcome, + ValidationCheckRunStatus, + ValidationDiagnosticCategory, +} from "./vocabulary-01.js"; +import type { + PythonValidationCapabilityState, + PythonValidationCapabilityTermination, + ValidationSkippedCheckReason, +} from "./vocabulary-02.js"; + +interface ValidationDiagnostic { + category: ValidationDiagnosticCategory; + message: string; + path?: string; + severity: "info" | "warning" | "error"; + code?: string; + line?: number; + column?: number; + endLine?: number; + endColumn?: number; + tool?: ValidationDiagnosticToolProvenance; +} + +export type { ValidationDiagnostic }; + +interface ValidationDiagnosticToolProvenance { + name: string; + command: string; + version?: string; + source?: string; + cwd?: string; +} + +export type { ValidationDiagnosticToolProvenance }; + +interface ValidationCheckManifestEntry { + checkId: string; + owner: string; + adapter: string; + defaultSeverity: ValidationDiagnostic["severity"]; + supportedScopes: readonly ValidationScopeKind[]; + requiresGraph: boolean; +} + +export type { ValidationCheckManifestEntry }; + +interface PythonRuffCapabilityIdentity { + schemaId: typeof PYTHON_VALIDATION_CAPABILITY_RUN_SCHEMA_ID; + schemaVersion: 1; + checkId: "python.ruff-lint" | "python.ruff-format"; + capability: "ruff_lint" | "ruff_format"; + state: PythonValidationCapabilityState; + projectKey?: string; + contextFingerprint?: string; + afterStateManifestFingerprint?: string; + sourcePaths?: readonly string[]; + configPaths?: readonly string[]; + executable?: string; + command?: string; +} + +export type { PythonRuffCapabilityIdentity }; + +interface PythonRuffCapabilityExecution { + argv?: readonly string[]; + cwd?: string; + configPath?: string; + toolVersion?: string; + toolSource?: PythonProjectExecutableSource; + termination?: PythonValidationCapabilityTermination; + exitCode?: number; + signal?: string; + invocations?: readonly PythonValidationCapabilityInvocation[]; + durationMs: number; + diagnosticCount: number; + failureMessage?: string; +} + +export type { PythonRuffCapabilityExecution }; + +interface PythonRuffValidationCapabilityRun extends PythonRuffCapabilityIdentity, PythonRuffCapabilityExecution {} + +export type { PythonRuffValidationCapabilityRun }; + +type PythonValidationCapabilityRun = + | PythonTypesValidationCapabilityRun + | PythonRuffValidationCapabilityRun + | PythonPytestValidationCapabilityRun; + +export type { PythonValidationCapabilityRun }; + +interface PythonValidationCapabilityInvocation { + argv: readonly string[]; + termination: PythonValidationCapabilityTermination; + exitCode?: number; + signal?: string; + durationMs: number; +} + +export type { PythonValidationCapabilityInvocation }; + +interface ValidationCheckRunSummary { + checkId: string; + status: ValidationCheckRunStatus; + outcome?: ValidationCheckOutcome; + durationMs?: number; + diagnosticCount?: number; + failureMessage?: string; + pythonCapabilityRuns?: readonly PythonValidationCapabilityRun[]; +} + +export type { ValidationCheckRunSummary }; + +interface ValidationSkippedCheck { + checkId: string; + reason: ValidationSkippedCheckReason; + message: string; +} + +export type { ValidationSkippedCheck }; + +interface ValidationResultManifest { + schemaVersion: number; + checks: readonly string[]; + generatedAt: string; + entries?: readonly ValidationCheckManifestEntry[]; + durationMs?: number; + runs?: readonly ValidationCheckRunSummary[]; + skippedChecks?: readonly ValidationSkippedCheck[]; +} + +export type { ValidationResultManifest }; diff --git a/packages/contracts/src/validation/prewrite-status-validators-01.ts b/packages/contracts/src/validation/prewrite-status-validators-01.ts new file mode 100644 index 0000000..b0c5a23 --- /dev/null +++ b/packages/contracts/src/validation/prewrite-status-validators-01.ts @@ -0,0 +1,192 @@ +import { + validateBoolean, + validateExactValue, + validateOptional, + validateRequiredObject, +} from "../shared/validators-02.js"; +import { includesString } from "../shared/primitives.js"; +import { validateProviderStatus } from "../graph/provider-validators.js"; +import { graphProviderModes } from "../graph/vocabulary-01.js"; +import { validateRepoIdentity } from "../shared/path-validators.js"; +import { + validateExactStringSet, + validateNonEmptyString, + validateNonNegativeNumber, + validateStringArray, + validateValidationChecks, +} from "../shared/validators-01.js"; +import { + validateValidationAdapterDegradedCheckStatus, + validateValidationAdapterToolchainStatus, +} from "./prewrite-status-validators-02.js"; +import { validateValidationScope } from "./request-validators-01.js"; +import { + validatePreWriteValidationFailureSummary, + validatePreWriteValidationGraph, + validatePreWriteValidationOverlaySummary, + validateValidationCheckManifestEntry, +} from "./request-validators-02.js"; +import type { + PreWriteValidationReceipt, + ValidationAdapterRuntimeStatus, + ValidationStatusPayload} from "./status-contracts.js"; +import { + validationAdapterRuntimeStates, + validationDaemonReadinessStates, +} from "./status-contracts.js"; +import { validationResultStatuses } from "./vocabulary-01.js"; + +function validatePreWriteValidationReceipt(receipt: PreWriteValidationReceipt): PreWriteValidationReceipt { + validateRequiredObject(receipt, "Pre-write validation receipt is required"); + validateExactValue(receipt.schemaVersion, 1, "Pre-write validation receipt schemaVersion must be 1"); + validateExactValue( + receipt.kind, + "pre_write_validation", + "Pre-write validation receipt kind must be pre_write_validation", + ); + validateExactValue( + receipt.route, + "validate.pre-write", + "Pre-write validation receipt route must be validate.pre-write", + ); + validateStringArray(receipt.canonicalCommand, "Pre-write validation receipt canonicalCommand", { allowEmpty: false }); + validateNonEmptyString(receipt.generatedAt, "Pre-write validation receipt generatedAt"); + validateNonNegativeNumber(receipt.durationMs, "Pre-write validation receipt durationMs"); + if (!Number.isInteger(receipt.timeoutMs) || receipt.timeoutMs < 1) { + throw new Error("Pre-write validation receipt timeoutMs must be a positive integer"); + } + validateBoolean(receipt.ok, "Pre-write validation receipt ok"); + validatePreWriteValidationReceiptOptionals(receipt); + if (!includesString(validationResultStatuses, receipt.validationStatus)) { + throw new Error(`Unknown pre-write validation receipt status: ${String(receipt.validationStatus)}`); + } + if (!Number.isInteger(receipt.diagnosticCount) || receipt.diagnosticCount < 0) { + throw new Error("Pre-write validation receipt diagnosticCount must be a non-negative integer"); + } + validatePreWriteValidationReceiptOutcome(receipt); + return receipt; +} + +export { validatePreWriteValidationReceipt }; + +function validatePreWriteValidationReceiptOptionals(receipt: PreWriteValidationReceipt): void { + validateOptional(receipt.requestId, (value) => + validateNonEmptyString(value, "Pre-write validation receipt requestId"), + ); + validateOptional(receipt.repo, validateRepoIdentity); + validateOptional(receipt.scope, validateValidationScope); + validateOptional(receipt.checks, (value) => + validateValidationChecks(value, "Pre-write validation receipt checks"), + ); + validateOptional(receipt.graph, validatePreWriteValidationGraph); + validateOptional(receipt.overlays, validatePreWriteValidationOverlaySummary); + validateOptional(receipt.failureSummary, validatePreWriteValidationFailureSummary); +} + +function validatePreWriteValidationReceiptOutcome(receipt: PreWriteValidationReceipt): void { + if (!receipt.ok) { + if (receipt.validationStatus === "passed") { + throw new Error("Pre-write validation failure receipt must not use passed validationStatus"); + } + if (receipt.failureSummary === undefined) { + throw new Error("Pre-write validation failure receipt must include failureSummary"); + } + return; + } + if (receipt.validationStatus !== "passed") { + throw new Error("Pre-write validation pass receipt must use passed validationStatus"); + } + const requiredEvidence = [receipt.repo, receipt.scope, receipt.checks, receipt.graph, receipt.overlays]; + if (requiredEvidence.some((value) => value === undefined)) { + throw new Error("Pre-write validation pass receipt must include repo, scope, checks, graph, and overlays"); + } + if (receipt.failureSummary !== undefined) { + throw new Error("Pre-write validation pass receipt must not include failureSummary"); + } +} + +function validateValidationStatusPayload(payload: ValidationStatusPayload): ValidationStatusPayload { + validateRequiredObject(payload, "Validation status payload is required"); + validateExactValue(payload.schemaVersion, 1, "Validation status payload schemaVersion must be 1"); + validateBoolean(payload.ready, "Validation status payload ready"); + validateNonEmptyString(payload.generatedAt, "Validation status payload generatedAt"); + validateValidationStatusAdapterRegistry(payload); + validateValidationStatusGraph(payload); + validateOptional(payload.daemon, validateValidationDaemonStatus); + return payload; +} + +export { validateValidationStatusPayload }; + +function validateValidationStatusAdapterRegistry(payload: ValidationStatusPayload): void { + validateRequiredObject(payload.adapterRegistry, "Validation status payload adapterRegistry is required"); + validateExactStringSet( + payload.adapterRegistry.checkRoutes, + ["files", "staged", "changed", "tree", "all", "manifest"], + "Validation status payload checkRoutes", + ); + validateExactStringSet( + payload.adapterRegistry.validateRoutes, + ["request", "hypothetical", "pre-write", "manifest"], + "Validation status payload validateRoutes", + ); + validateValidationChecks(payload.adapterRegistry.checkIds, "Validation status payload checkIds"); + if (!Array.isArray(payload.adapterRegistry.entries)) { + throw new Error("Validation status payload entries must be an array"); + } + for (const entry of payload.adapterRegistry.entries) validateValidationCheckManifestEntry(entry); + validateOptional(payload.adapterRegistry.adapters, (adapters) => { + if (!Array.isArray(payload.adapterRegistry.adapters)) { + throw new Error("Validation status payload adapters must be an array"); + } + for (const adapter of adapters) validateValidationAdapterRuntimeStatus(adapter); + }); +} + +function validateValidationStatusGraph(payload: ValidationStatusPayload): void { + validateRequiredObject(payload.graph, "Validation status payload graph is required"); + if (!includesString(graphProviderModes, payload.graph.mode)) { + throw new Error(`Unknown validation status graph mode: ${String(payload.graph.mode)}`); + } + const graphStatus = validateProviderStatus(payload.graph.status); + if (graphStatus.mode !== payload.graph.mode) { + throw new Error("Validation status graph status mode must match graph mode"); + } +} + +function validateValidationDaemonStatus(daemon: NonNullable): void { + validateRequiredObject(daemon, "Validation status daemon must be an object"); + if (!includesString(validationDaemonReadinessStates, daemon.state)) { + throw new Error(`Unknown validation daemon readiness state: ${String(daemon.state)}`); + } + validateOptional(daemon.message, (value) => validateNonEmptyString(value, "Validation status daemon message")); +} + +function validateValidationAdapterRuntimeStatus( + status: ValidationAdapterRuntimeStatus, +): ValidationAdapterRuntimeStatus { + validateRequiredObject(status, "Validation adapter runtime status is required"); + validateNonEmptyString(status.adapter, "Validation adapter runtime status adapter"); + if (!includesString(validationAdapterRuntimeStates, status.status)) { + throw new Error(`Unknown validation adapter runtime status: ${String(status.status)}`); + } + validateValidationChecks(status.checkIds, "Validation adapter runtime status checkIds"); + if (status.toolchain !== undefined) { + if (!Array.isArray(status.toolchain)) { + throw new Error("Validation adapter runtime status toolchain must be an array"); + } + for (const tool of status.toolchain) validateValidationAdapterToolchainStatus(tool); + } + if (status.degradedChecks !== undefined) { + if (!Array.isArray(status.degradedChecks)) { + throw new Error("Validation adapter runtime status degradedChecks must be an array"); + } + for (const degradedCheck of status.degradedChecks) validateValidationAdapterDegradedCheckStatus(degradedCheck); + } + if (status.tempWorkspaceRequired !== undefined && typeof status.tempWorkspaceRequired !== "boolean") { + throw new Error("Validation adapter runtime status tempWorkspaceRequired must be boolean"); + } + return status; +} + +export { validateValidationAdapterRuntimeStatus }; diff --git a/packages/contracts/src/validation/prewrite-status-validators-02.ts b/packages/contracts/src/validation/prewrite-status-validators-02.ts new file mode 100644 index 0000000..8205489 --- /dev/null +++ b/packages/contracts/src/validation/prewrite-status-validators-02.ts @@ -0,0 +1,74 @@ +import { validateRequiredObject } from "../shared/validators-02.js"; +import { includesString } from "../shared/primitives.js"; +import { validateNonEmptyString, validateValidationCheckId } from "../shared/validators-01.js"; +import type { ValidationAdapterDegradedCheckStatus, ValidationAdapterToolchainStatus } from "./status-contracts.js"; +import { validationCheckRunStatuses } from "./vocabulary-01.js"; + +function validateValidationAdapterToolchainStatus( + status: ValidationAdapterToolchainStatus, +): ValidationAdapterToolchainStatus { + validateRequiredObject(status, "Validation adapter toolchain status is required"); + validateNonEmptyString(status.tool, "Validation adapter toolchain status tool"); + if (typeof status.available !== "boolean") { + throw new Error("Validation adapter toolchain status available must be boolean"); + } + if (status.command !== undefined) + validateNonEmptyString(status.command, "Validation adapter toolchain status command"); + if (status.version !== undefined) + validateNonEmptyString(status.version, "Validation adapter toolchain status version"); + if (status.failureMessage !== undefined) { + validateNonEmptyString(status.failureMessage, "Validation adapter toolchain status failureMessage"); + } + if (status.cwd !== undefined) validateNonEmptyString(status.cwd, "Validation adapter toolchain status cwd"); + if (status.configFile !== undefined) { + validateNonEmptyString(status.configFile, "Validation adapter toolchain status configFile"); + } + if (status.source !== undefined) validateNonEmptyString(status.source, "Validation adapter toolchain status source"); + return status; +} + +export { validateValidationAdapterToolchainStatus }; + +function validateValidationAdapterDegradedCheckStatus( + status: ValidationAdapterDegradedCheckStatus, +): ValidationAdapterDegradedCheckStatus { + validateRequiredObject(status, "Validation adapter degraded check status is required"); + validateValidationCheckId(status.checkId, "Validation adapter degraded check status checkId"); + if (!includesString(validationCheckRunStatuses, status.status)) { + throw new Error(`Unknown validation adapter degraded check status: ${String(status.status)}`); + } + validateNonEmptyString(status.reason, "Validation adapter degraded check status reason"); + validateNonEmptyString(status.message, "Validation adapter degraded check status message"); + if (status.requiredTool !== undefined) { + validateNonEmptyString(status.requiredTool, "Validation adapter degraded check status requiredTool"); + } + if (status.retainedCompatibility !== undefined && typeof status.retainedCompatibility !== "boolean") { + throw new Error("Validation adapter degraded check status retainedCompatibility must be boolean"); + } + if (status.followUpIssue !== undefined) { + validateNonEmptyString(status.followUpIssue, "Validation adapter degraded check status followUpIssue"); + } + if (status.currentUsage !== undefined) { + validateValidationAdapterCurrentUsage(status.currentUsage); + } + return status; +} + +export { validateValidationAdapterDegradedCheckStatus }; + +function validateValidationAdapterCurrentUsage( + currentUsage: ValidationAdapterDegradedCheckStatus["currentUsage"], +): NonNullable { + validateRequiredObject( + currentUsage, + "Validation adapter degraded check status currentUsage is required when present", + ); + for (const key of ["opcore", "orchestra", "covibes", "gateway"] as const) { + if (typeof currentUsage[key] !== "boolean") { + throw new Error(`Validation adapter degraded check status currentUsage.${key} must be boolean`); + } + } + return currentUsage; +} + +export { validateValidationAdapterCurrentUsage }; diff --git a/packages/contracts/src/validation/python-project-contracts-01.ts b/packages/contracts/src/validation/python-project-contracts-01.ts new file mode 100644 index 0000000..8b4f0eb --- /dev/null +++ b/packages/contracts/src/validation/python-project-contracts-01.ts @@ -0,0 +1,95 @@ +const PYTHON_PROJECT_CONTEXT_SCHEMA_ID = "opcore.python.project-context.v1" as const; + +export { PYTHON_PROJECT_CONTEXT_SCHEMA_ID }; + +const PYTHON_VALIDATION_CAPABILITY_RUN_SCHEMA_ID = "opcore.python.validation-capability-run" as const; + +export { PYTHON_VALIDATION_CAPABILITY_RUN_SCHEMA_ID }; + +const pythonProjectContextOutcomes = ["resolved", "degraded", "unsupported", "ambiguous"] as const; + +export { pythonProjectContextOutcomes }; + +type PythonProjectContextOutcome = (typeof pythonProjectContextOutcomes)[number]; + +export type { PythonProjectContextOutcome }; + +const pythonProjectContextReasonCodes = [ + "missing_config", + "invalid_config", + "conflicting_managers", + "conflicting_targets", + "interpreter_unavailable", + "tool_unavailable", + "probe_timeout", + "probe_signal", + "probe_spawn_failure", + "probe_exit_failure", + "malformed_probe_output", + "unsupported_target", + "unsupported_platform", + "path_refused", + "symlink_refused", + "incompatible_interpreter", + "ambiguous_path", +] as const; + +export { pythonProjectContextReasonCodes }; + +type PythonProjectContextReasonCode = (typeof pythonProjectContextReasonCodes)[number]; + +export type { PythonProjectContextReasonCode }; + +const pythonProjectManagerKinds = ["pip", "uv", "poetry", "pdm", "pipenv"] as const; + +export { pythonProjectManagerKinds }; + +type PythonProjectManagerKind = (typeof pythonProjectManagerKinds)[number]; + +export type { PythonProjectManagerKind }; + +const pythonProjectLayoutKinds = ["flat", "src", "namespace", "stub", "package"] as const; + +export { pythonProjectLayoutKinds }; + +type PythonProjectLayoutKind = (typeof pythonProjectLayoutKinds)[number]; + +export type { PythonProjectLayoutKind }; + +const pythonProjectExecutableSources = [ + "explicit_override", + "active_environment", + "project_local_environment", + "manager_environment", + "path", +] as const; + +export { pythonProjectExecutableSources }; + +type PythonProjectExecutableSource = (typeof pythonProjectExecutableSources)[number]; + +export type { PythonProjectExecutableSource }; + +const pythonProjectToolKinds = ["mypy", "pyright", "ruff", "pytest", "build"] as const; + +export { pythonProjectToolKinds }; + +type PythonProjectToolKind = (typeof pythonProjectToolKinds)[number]; + +export type { PythonProjectToolKind }; + +interface PythonProjectContextReason { + code: PythonProjectContextReasonCode; + message: string; + path?: string; + tool?: string; +} + +export type { PythonProjectContextReason }; + +interface PythonProjectFileEvidence { + path: string; + role: "boundary" | "config" | "lock" | "requirements" | "build" | "layout"; +} + +export type { PythonProjectFileEvidence }; diff --git a/packages/contracts/src/validation/python-project-contracts-02.ts b/packages/contracts/src/validation/python-project-contracts-02.ts new file mode 100644 index 0000000..4e31801 --- /dev/null +++ b/packages/contracts/src/validation/python-project-contracts-02.ts @@ -0,0 +1,156 @@ +import type { + PYTHON_PROJECT_CONTEXT_SCHEMA_ID, + PYTHON_VALIDATION_CAPABILITY_RUN_SCHEMA_ID, + PythonProjectContextOutcome, + PythonProjectContextReason, + PythonProjectExecutableSource, + PythonProjectFileEvidence, + PythonProjectLayoutKind, + PythonProjectManagerKind, + PythonProjectToolKind, +} from "./python-project-contracts-01.js"; +import type { PythonValidationAuthority, PythonValidationCapabilityRunStatus } from "./vocabulary-01.js"; +import type { PythonValidationAuthoritySource, PythonValidationCapabilityTerminationKind } from "./vocabulary-02.js"; + +interface PythonProjectManagerEvidence { + kind: PythonProjectManagerKind; + configFiles: readonly string[]; + lockFiles: readonly string[]; +} + +export type { PythonProjectManagerEvidence }; + +interface PythonProjectExecutableProvenance { + executable: string; + argv: readonly string[]; + cwd: string; + source: PythonProjectExecutableSource; + version?: string; + configFile?: string; +} + +export type { PythonProjectExecutableProvenance }; + +interface PythonInterpreterProvenance extends PythonProjectExecutableProvenance { + version: string; + implementation: string; + platform: string; + architecture: string; + abi: string; + soabi: string; +} + +export type { PythonInterpreterProvenance }; + +interface PythonProjectToolProvenance extends PythonProjectExecutableProvenance { + tool: PythonProjectToolKind; + available: boolean; +} + +export type { PythonProjectToolProvenance }; + +interface PythonProjectTarget { + requiresPython?: string; + version?: string; + platform?: string; + implementation?: string; + conflicts: readonly string[]; +} + +export type { PythonProjectTarget }; + +interface PythonProjectLayoutEvidence { + kinds: readonly PythonProjectLayoutKind[]; + paths: readonly string[]; +} + +export type { PythonProjectLayoutEvidence }; + +interface PythonProjectBuildSystem { + configFile: string; + backend?: string; + requires: readonly string[]; +} + +export type { PythonProjectBuildSystem }; + +interface PythonProjectContext { + schemaId: typeof PYTHON_PROJECT_CONTEXT_SCHEMA_ID; + schemaVersion: 1; + target: string; + repositoryRoot: string; + projectRoot: string; + projectBoundary: string; + sourceRoots: readonly string[]; + layout: PythonProjectLayoutEvidence; + evidence: readonly PythonProjectFileEvidence[]; + targetRuntime: PythonProjectTarget; + managers: readonly PythonProjectManagerEvidence[]; + buildSystem?: PythonProjectBuildSystem; + interpreter?: PythonInterpreterProvenance; + tools: readonly PythonProjectToolProvenance[]; + projectKey: string; + contextFingerprint: string; + outcome: PythonProjectContextOutcome; + reasons: readonly PythonProjectContextReason[]; +} + +export type { PythonProjectContext }; + +interface PythonValidationCapabilityToolProvenance { + name: PythonValidationAuthority; + /** Portable executable locator: repo:, project:, path:, or external:. */ + executable: string; + argv: readonly string[]; + cwd: string; + source: PythonProjectExecutableSource; + version?: string; + configFile?: string; +} + +export type { PythonValidationCapabilityToolProvenance }; + +interface PythonValidationCapabilityExecution { + termination: PythonValidationCapabilityTerminationKind; + exitCode?: number; + signal?: string; + failureSummary?: string; +} + +export type { PythonValidationCapabilityExecution }; + +interface PythonTypesCapabilityIdentity { + schemaId: typeof PYTHON_VALIDATION_CAPABILITY_RUN_SCHEMA_ID; + schemaVersion: 1; + capability: "types"; + checkId: "python.types"; + projectKey: string; + contextFingerprint: string; + projectRoot: string; + targets: readonly string[]; + selectedSourcePaths: readonly string[]; + selectedConfigPaths: readonly string[]; + afterStateManifestFingerprint: string; + authority?: PythonValidationAuthority; + authoritySource?: PythonValidationAuthoritySource; +} + +export type { PythonTypesCapabilityIdentity }; + +interface PythonTypesCapabilityOutcome { + status: PythonValidationCapabilityRunStatus; + tool?: PythonValidationCapabilityToolProvenance; + execution?: PythonValidationCapabilityExecution; + durationMs: number; + diagnosticCount: number; + errorCount: number; + warningCount: number; + noteCount: number; +} + +export type { PythonTypesCapabilityOutcome }; + +/** Portable, source-free evidence for one attempted Python capability in one canonical project. */ +interface PythonTypesValidationCapabilityRun extends PythonTypesCapabilityIdentity, PythonTypesCapabilityOutcome {} + +export type { PythonTypesValidationCapabilityRun }; diff --git a/packages/contracts/src/validation/python-project-validators-01.ts b/packages/contracts/src/validation/python-project-validators-01.ts new file mode 100644 index 0000000..b27a1a0 --- /dev/null +++ b/packages/contracts/src/validation/python-project-validators-01.ts @@ -0,0 +1,295 @@ +import { includesString } from "../shared/primitives.js"; +import { validateRepoRelativePath } from "../shared/path-validators.js"; +import { validateNonEmptyString, validateNonNegativeInteger, validateStringArray } from "../shared/validators-01.js"; +import { validateArray, validateObject, validateOptional } from "../shared/validators-02.js"; +import type { + PythonCapabilityCleanupEvidence, + PythonCapabilityCounts, + PythonCapabilityInvocation} from "./capability-contracts.js"; +import { + pythonCapabilityProcessTerminations, + pythonPytestSelectionModes, +} from "./capability-contracts.js"; +import { + PYTHON_PROJECT_CONTEXT_SCHEMA_ID, + pythonProjectContextOutcomes, + pythonProjectContextReasonCodes, + pythonProjectLayoutKinds, + pythonProjectManagerKinds, + pythonProjectToolKinds, +} from "./python-project-contracts-01.js"; +import type { PythonProjectContext } from "./python-project-contracts-02.js"; +import { validateExactObjectKeys } from "./python-validator-primitives.js"; +import { + validateExactEnumArray, + validatePythonExecutableProvenance, + validatePythonInterpreterProvenance, + validatePythonProjectRoot, + validatePythonProjectRoots, + validatePythonProjectTarget, + validateRepoPathArray, + validateSha256Identity, +} from "./python-project-validators-02.js"; + +function validatePythonProjectContext(context: PythonProjectContext): PythonProjectContext { + validateObject(context, "Python project context"); + validatePythonProjectContextIdentity(context); + validatePythonProjectLayout(context); + validatePythonProjectEvidence(context); + validatePythonProjectTarget(context.targetRuntime); + validatePythonProjectManagers(context.managers); + validateOptional(context.buildSystem, validatePythonProjectBuildSystem); + validateOptional(context.interpreter, validatePythonInterpreterProvenance); + validatePythonProjectTools(context.tools); + validatePythonProjectOutcome(context); + return context; +} + +export { validatePythonProjectContext }; + +function validatePythonProjectContextIdentity(context: PythonProjectContext): void { + validateExactObjectKeys( + context, + [ + "schemaId", + "schemaVersion", + "target", + "repositoryRoot", + "projectRoot", + "projectBoundary", + "sourceRoots", + "layout", + "evidence", + "targetRuntime", + "managers", + "buildSystem", + "interpreter", + "tools", + "projectKey", + "contextFingerprint", + "outcome", + "reasons", + ], + "Python project context", + ); + if (context.schemaId !== PYTHON_PROJECT_CONTEXT_SCHEMA_ID) { + throw new Error(`Python project context schemaId must be ${PYTHON_PROJECT_CONTEXT_SCHEMA_ID}`); + } + if (context.schemaVersion !== 1) throw new Error("Python project context schemaVersion must be 1"); + validateRepoRelativePath(context.target); + if (!/\.pyi?$/u.test(context.target)) throw new Error("Python project context target must be a .py or .pyi path"); + validateNonEmptyString(context.repositoryRoot, "Python project context repositoryRoot"); + validatePythonProjectRoot(context.projectRoot, "Python project context projectRoot"); + validatePythonProjectRoot(context.projectBoundary, "Python project context projectBoundary"); + validatePythonProjectRoots(context.sourceRoots, "Python project context sourceRoots"); +} + +export { validatePythonProjectContextIdentity }; + +function validatePythonProjectLayout(context: PythonProjectContext): void { + validateObject(context.layout, "Python project context layout"); + validateExactObjectKeys(context.layout, ["kinds", "paths"], "Python project context layout"); + validateExactEnumArray(context.layout.kinds, pythonProjectLayoutKinds, "Python project context layout kinds", false); + validatePythonProjectRoots(context.layout.paths, "Python project context layout paths"); +} + +export { validatePythonProjectLayout }; + +function validatePythonProjectEvidence(context: PythonProjectContext): void { + validateArray(context.evidence, "Python project context evidence"); + for (const entry of context.evidence) { + validateExactObjectKeys(entry, ["path", "role"], "Python project context evidence"); + validateRepoRelativePath(entry.path); + if (!includesString(["boundary", "config", "lock", "requirements", "build", "layout"] as const, entry.role)) { + throw new Error(`Unknown Python project evidence role: ${String(entry.role)}`); + } + } +} + +export { validatePythonProjectEvidence }; + +function validatePythonProjectManagers(managers: PythonProjectContext["managers"]): void { + validateArray(managers, "Python project context managers"); + for (const manager of managers) { + validateExactObjectKeys(manager, ["kind", "configFiles", "lockFiles"], "Python project manager evidence"); + if (!includesString(pythonProjectManagerKinds, manager.kind)) { + throw new Error(`Unknown Python project manager kind: ${String(manager.kind)}`); + } + validateRepoPathArray(manager.configFiles, "Python project manager configFiles"); + validateRepoPathArray(manager.lockFiles, "Python project manager lockFiles"); + } +} + +export { validatePythonProjectManagers }; + +function validatePythonProjectBuildSystem(buildSystem: NonNullable): void { + validateExactObjectKeys(buildSystem, ["configFile", "backend", "requires"], "Python project buildSystem"); + validateRepoRelativePath(buildSystem.configFile); + validateOptional(buildSystem.backend, (backend) => + validateNonEmptyString(backend, "Python project buildSystem backend"), + ); + validateStringArray(buildSystem.requires, "Python project buildSystem requires", { allowEmpty: true }); +} + +export { validatePythonProjectBuildSystem }; + +function validatePythonProjectTools(tools: PythonProjectContext["tools"]): void { + validateArray(tools, "Python project context tools"); + for (const tool of tools) { + validateExactObjectKeys( + tool, + ["tool", "available", "executable", "argv", "cwd", "source", "version", "configFile"], + "Python project tool provenance", + ); + if (!includesString(pythonProjectToolKinds, tool.tool)) + throw new Error(`Unknown Python project tool: ${String(tool.tool)}`); + if (typeof tool.available !== "boolean") throw new Error("Python project tool available must be boolean"); + validatePythonExecutableProvenance(tool, `Python project tool ${tool.tool}`); + if (tool.available && tool.version === undefined) { + throw new Error(`Available Python project tool ${tool.tool} must include version provenance`); + } + } +} + +export { validatePythonProjectTools }; + +function validatePythonProjectOutcome(context: PythonProjectContext): void { + validateSha256Identity(context.projectKey, "Python project context projectKey"); + validateSha256Identity(context.contextFingerprint, "Python project context contextFingerprint"); + if (!includesString(pythonProjectContextOutcomes, context.outcome)) { + throw new Error(`Unknown Python project context outcome: ${String(context.outcome)}`); + } + validateArray(context.reasons, "Python project context reasons"); + for (const reason of context.reasons) { + validateExactObjectKeys(reason, ["code", "message", "path", "tool"], "Python project context reason"); + if (!includesString(pythonProjectContextReasonCodes, reason.code)) { + throw new Error(`Unknown Python project context reason: ${String(reason.code)}`); + } + validateNonEmptyString(reason.message, "Python project context reason message"); + validateOptional(reason.path, validateRepoRelativePath); + validateOptional(reason.tool, (tool) => validateNonEmptyString(tool, "Python project context reason tool")); + } + if (context.outcome === "resolved" && context.reasons.length > 0) { + throw new Error("Resolved Python project context must not include reasons"); + } + if (context.outcome !== "resolved" && context.reasons.length === 0) { + throw new Error("Non-resolved Python project context must include reasons"); + } +} + +export { validatePythonProjectOutcome }; + +function validatePythonProjectContexts(contexts: readonly PythonProjectContext[]): readonly PythonProjectContext[] { + if (!Array.isArray(contexts)) throw new Error("Python project contexts must be an array"); + const targets = new Set(); + for (const context of contexts) { + validatePythonProjectContext(context); + if (targets.has(context.target)) throw new Error(`Duplicate Python project context target: ${context.target}`); + targets.add(context.target); + } + return contexts; +} + +export { validatePythonProjectContexts }; + +function validatePythonCapabilityCounts(counts: PythonCapabilityCounts): void { + if (!counts || typeof counts !== "object") throw new Error("Python capability counts are required"); + validateExactObjectKeys( + counts, + [ + "candidateCount", + "collectedCount", + "executedCount", + "passedCount", + "failedCount", + "skippedCount", + "xfailedCount", + "xpassedCount", + "errorCount", + ], + "Python capability counts", + ); + for (const key of Object.keys(counts) as (keyof PythonCapabilityCounts)[]) { + validateNonNegativeInteger(counts[key], `Python capability counts ${key}`); + } +} + +export { validatePythonCapabilityCounts }; + +function validatePythonCapabilityCleanupEvidence(cleanup: PythonCapabilityCleanupEvidence): void { + if (!cleanup || typeof cleanup !== "object") throw new Error("Python capability cleanup evidence is required"); + validateExactObjectKeys(cleanup, ["attempted", "ok", "failureMessage"], "Python capability cleanup evidence"); + if (typeof cleanup.attempted !== "boolean") throw new Error("Python capability cleanup attempted must be boolean"); + if (typeof cleanup.ok !== "boolean") throw new Error("Python capability cleanup ok must be boolean"); + if (cleanup.failureMessage !== undefined) { + validateNonEmptyString(cleanup.failureMessage, "Python capability cleanup failureMessage"); + } +} + +export { validatePythonCapabilityCleanupEvidence }; + +function validatePythonCapabilityInvocation(invocation: PythonCapabilityInvocation, label: string): void { + validatePythonCapabilityInvocationWithDuration( + invocation, + label, + (value, durationLabel) => validateNonNegativeInteger(value, durationLabel), + ); +} + +export { validatePythonCapabilityInvocation }; + +type PythonCapabilityDurationValidator = (value: unknown, label: string) => number; + +function validatePythonCapabilityInvocationWithDuration( + invocation: PythonCapabilityInvocation, + label: string, + validateDuration: PythonCapabilityDurationValidator, +): void { + if (!invocation || typeof invocation !== "object") throw new Error(`${label} is required`); + validateExactObjectKeys( + invocation, + [ + "stage", + "command", + "argsDigest", + "argCount", + "selectionMode", + "selectionDigest", + "durationMs", + "termination", + "exitCode", + "signal", + "outputBytes", + "stdoutDigest", + "stderrDigest", + ], + label, + ); + if (!includesString(["collection", "execution"] as const, invocation.stage)) { + throw new Error(`${label} stage must be collection or execution`); + } + validateNonEmptyString(invocation.command, `${label} command`); + validateSha256Identity(invocation.argsDigest, `${label} argsDigest`); + validateNonNegativeInteger(invocation.argCount, `${label} argCount`); + if (!includesString(pythonPytestSelectionModes, invocation.selectionMode)) { + throw new Error(`Unknown ${label} selectionMode: ${String(invocation.selectionMode)}`); + } + validateOptional(invocation.selectionDigest, (value) => + validateSha256Identity(value, `${label} selectionDigest`), + ); + validateDuration(invocation.durationMs, `${label} durationMs`); + if (!includesString(pythonCapabilityProcessTerminations, invocation.termination)) { + throw new Error(`Unknown ${label} termination: ${String(invocation.termination)}`); + } + validateOptional(invocation.exitCode, (value) => validateNonNegativeInteger(value, `${label} exitCode`)); + validateOptional(invocation.signal, (value) => validateNonEmptyString(value, `${label} signal`)); + validateNonNegativeInteger(invocation.outputBytes, `${label} outputBytes`); + validateOptional(invocation.stdoutDigest, (value) => + validateSha256Identity(value, `${label} stdoutDigest`), + ); + validateOptional(invocation.stderrDigest, (value) => + validateSha256Identity(value, `${label} stderrDigest`), + ); +} + +export { validatePythonCapabilityInvocationWithDuration }; diff --git a/packages/contracts/src/validation/python-project-validators-02.ts b/packages/contracts/src/validation/python-project-validators-02.ts new file mode 100644 index 0000000..47bd184 --- /dev/null +++ b/packages/contracts/src/validation/python-project-validators-02.ts @@ -0,0 +1,153 @@ +import { validateRequiredObject } from "../shared/validators-02.js"; +import { includesString } from "../shared/primitives.js"; +import { validateRepoRelativePath } from "../shared/path-validators.js"; +import { validateNonEmptyString, validatePositiveInteger, validateStringArray } from "../shared/validators-01.js"; +import { pythonProjectExecutableSources } from "./python-project-contracts-01.js"; +import type { + PythonInterpreterProvenance, + PythonProjectExecutableProvenance, + PythonProjectTarget, +} from "./python-project-contracts-02.js"; +import { + validateContextDocFilename, + validateContextDocRequiredPath, + validateExactObjectKeys, +} from "./python-validator-primitives.js"; +import type { RequiredContextDocPolicy } from "./status-contracts.js"; + +function validatePythonProjectTarget(target: PythonProjectTarget): void { + if (!target || typeof target !== "object") throw new Error("Python project targetRuntime is required"); + validateExactObjectKeys( + target, + ["requiresPython", "version", "platform", "implementation", "conflicts"], + "Python project targetRuntime", + ); + for (const [key, value] of Object.entries(target)) { + if (key === "conflicts") continue; + if (value !== undefined) validateNonEmptyString(value, `Python project targetRuntime ${key}`); + } + validateStringArray(target.conflicts, "Python project targetRuntime conflicts", { allowEmpty: true }); +} + +export { validatePythonProjectTarget }; + +function validatePythonExecutableProvenance(value: PythonProjectExecutableProvenance, label: string): void { + if (!value || typeof value !== "object") throw new Error(`${label} provenance is required`); + validateNonEmptyString(value.executable, `${label} executable`); + validateStringArray(value.argv, `${label} argv`, { allowEmpty: false }); + if (value.argv[0] !== value.executable) throw new Error(`${label} argv must start with executable`); + validateNonEmptyString(value.cwd, `${label} cwd`); + if (!includesString(pythonProjectExecutableSources, value.source)) + throw new Error(`Unknown ${label} source: ${String(value.source)}`); + if (value.version !== undefined) { + validateNonEmptyString(value.version, `${label} version`); + if (!/^[0-9]+\.[0-9][-+._A-Za-z0-9]*$/u.test(value.version)) { + throw new Error(`${label} version must be exact version provenance`); + } + } + if (value.configFile !== undefined) validateRepoRelativePath(value.configFile); +} + +export { validatePythonExecutableProvenance }; + +function validatePythonInterpreterProvenance(value: PythonInterpreterProvenance): void { + validateExactObjectKeys( + value, + [ + "executable", + "argv", + "cwd", + "source", + "version", + "configFile", + "implementation", + "platform", + "architecture", + "abi", + "soabi", + ], + "Python interpreter provenance", + ); + validatePythonExecutableProvenance(value, "Python interpreter"); + if (!/^\d+\.\d+\.\d+(?:(?:a|b|rc)\d+)?(?:\+[A-Za-z0-9]+(?:[._-][A-Za-z0-9]+)*)?$/u.test(value.version)) { + throw new Error("Python interpreter version must be an exact Python version"); + } + for (const [key, field] of [ + ["implementation", value.implementation], + ["platform", value.platform], + ["architecture", value.architecture], + ["abi", value.abi], + ["soabi", value.soabi], + ] as const) { + validateNonEmptyString(field, `Python interpreter ${key}`); + } +} + +export { validatePythonInterpreterProvenance }; + +function validatePythonProjectRoot(value: string, label: string): void { + validateNonEmptyString(label, "Python project root label"); + if (value === ".") return; + validateRepoRelativePath(value); +} + +export { validatePythonProjectRoot }; + +function validatePythonProjectRoots(values: readonly string[], label: string): void { + if (!Array.isArray(values) || values.length === 0) throw new Error(`${label} must be a non-empty array`); + for (const value of values) validatePythonProjectRoot(value, label); +} + +export { validatePythonProjectRoots }; + +function validateRepoPathArray(values: readonly string[], label: string): void { + if (!Array.isArray(values)) throw new Error(`${label} must be an array`); + for (const value of values) validateRepoRelativePath(value); +} + +export { validateRepoPathArray }; + +function validateExactEnumArray( + values: readonly T[], + allowed: readonly T[], + label: string, + requireAll: boolean, +): void { + if (!Array.isArray(values) || values.length === 0) throw new Error(`${label} must be a non-empty array`); + const seen = new Set(); + for (const value of values) { + if (!includesString(allowed, value)) throw new Error(`Unknown ${label} value: ${String(value)}`); + if (seen.has(value)) throw new Error(`${label} must not contain duplicates`); + seen.add(value); + } + if (requireAll && seen.size !== allowed.length) throw new Error(`${label} must contain every supported value`); +} + +export { validateExactEnumArray }; + +function validateSha256Identity(value: string, label: string): void { + if (!/^sha256:[a-f0-9]{64}$/u.test(value)) throw new Error(`${label} must be a sha256 identity`); +} + +export { validateSha256Identity }; + +function validateRequiredContextDocPolicy(policy: RequiredContextDocPolicy): RequiredContextDocPolicy { + validateRequiredObject(policy, "Required context doc policy is required"); + validateStringArray(policy.filenames, "Required context doc policy filenames", { allowEmpty: false }); + for (const filename of policy.filenames) validateContextDocFilename(filename); + validateStringArray(policy.requiredPaths, "Required context doc policy requiredPaths", { allowEmpty: false }); + for (const path of policy.requiredPaths) validateContextDocRequiredPath(path); + if (policy.requireRoot !== undefined && typeof policy.requireRoot !== "boolean") { + throw new Error("Required context doc policy requireRoot must be boolean"); + } + if (!Number.isInteger(policy.minimumContentLength) || policy.minimumContentLength < 1) { + throw new Error("Required context doc policy minimumContentLength must be a positive integer"); + } + if (policy.maxLines !== undefined) validatePositiveInteger(policy.maxLines, "Required context doc policy maxLines"); + if (policy.maxSectionLines !== undefined) { + validatePositiveInteger(policy.maxSectionLines, "Required context doc policy maxSectionLines"); + } + return policy; +} + +export { validateRequiredContextDocPolicy }; diff --git a/packages/contracts/src/validation/python-pytest-validators-01.ts b/packages/contracts/src/validation/python-pytest-validators-01.ts new file mode 100644 index 0000000..bc50cb7 --- /dev/null +++ b/packages/contracts/src/validation/python-pytest-validators-01.ts @@ -0,0 +1,210 @@ +import { includesString } from "../shared/primitives.js"; +import { validateRepoRelativePath } from "../shared/path-validators.js"; +import { + validateNonEmptyString, + validateNonNegativeInteger, + validateNonNegativeNumber, + validateStringArray, +} from "../shared/validators-01.js"; +import { + validateExactValue, + validateOptional, + validateRequiredObject, +} from "../shared/validators-02.js"; +import type { + PythonCapabilityCleanupEvidence, + PythonCapabilityCounts, + PythonCapabilityInvocation, + PythonPytestValidationCapabilityRun} from "./capability-contracts.js"; +import { + pythonCapabilityActivations, + pythonPytestSelectionModes, +} from "./capability-contracts.js"; +import { pythonProjectExecutableSources } from "./python-project-contracts-01.js"; +import type { + PythonTypesValidationCapabilityRun, + PythonValidationCapabilityToolProvenance, +} from "./python-project-contracts-02.js"; +import { validateExactObjectKeys } from "./python-validator-primitives.js"; +import { + validatePythonProjectRoot, + validateRepoPathArray, + validateSha256Identity, +} from "./python-project-validators-02.js"; +import { validatePythonCapabilityInvocationWithDuration } from "./python-project-validators-01.js"; +import { containsHostAbsolutePath } from "./python-validator-primitives.js"; +import { validatePortablePythonCapabilityExecutable } from "./python-pytest-validators-02.js"; + +function validatePythonPytestValidationCapabilityRun( + run: PythonPytestValidationCapabilityRun, +): PythonPytestValidationCapabilityRun { + validateRequiredObject(run, "Python pytest capability run is required"); + validateExactObjectKeys( + run, + [ + "capability", + "checkId", + "activation", + "outcome", + "message", + "projectKey", + "projectRoot", + "configFile", + "targetCount", + "candidatePaths", + "collectedNodeIds", + "afterStateFingerprint", + "selectionMode", + "selectionDigest", + "counts", + "collection", + "execution", + "cleanup", + ], + "Python pytest capability run", + ); + if (run.capability !== "pytest" || run.checkId !== "python.pytest") { + throw new Error("Python pytest capability run must describe python.pytest"); + } + if (!includesString(pythonCapabilityActivations, run.activation)) { + throw new Error(`Unknown Python pytest capability activation: ${String(run.activation)}`); + } + validateNonEmptyString(run.outcome, "Python pytest capability run outcome"); + validateNonEmptyString(run.message, "Python pytest capability run message"); + validatePythonPytestCapabilityIdentity(run); + validatePythonPytestCapabilityExecutionEvidence(run); + return run; +} + +export { validatePythonPytestValidationCapabilityRun }; + +function validatePythonPytestCapabilityIdentity(run: PythonPytestValidationCapabilityRun): void { + validateOptional(run.projectKey, (value) => + validateSha256Identity(value, "Python pytest capability run projectKey"), + ); + validateOptional(run.projectRoot, (value) => + validatePythonProjectRoot(value, "Python pytest capability run projectRoot"), + ); + validateOptional(run.configFile, validateRepoRelativePath); + validateOptional(run.targetCount, (value) => + validateNonNegativeInteger(value, "Python pytest capability run targetCount"), + ); + validateOptional(run.candidatePaths, (value) => + validateRepoPathArray(value, "Python pytest capability run candidatePaths"), + ); + validateOptional(run.collectedNodeIds, (value) => { + validateStringArray(value, "Python pytest capability run collectedNodeIds", { allowEmpty: true }); + }); + validateOptional(run.afterStateFingerprint, (value) => + validateSha256Identity(value, "Python pytest capability run afterStateFingerprint"), + ); + if (run.selectionMode !== undefined && !includesString(pythonPytestSelectionModes, run.selectionMode)) { + throw new Error(`Unknown Python pytest capability selection mode: ${String(run.selectionMode)}`); + } + validateOptional(run.selectionDigest, (value) => + validateSha256Identity(value, "Python pytest capability run selectionDigest"), + ); +} + +function validatePythonPytestCapabilityExecutionEvidence(run: PythonPytestValidationCapabilityRun): void { + validateOptional(run.counts, validatePythonPytestCapabilityCounts); + validateOptional(run.collection, (value) => + validatePythonPytestCapabilityInvocation(value, "Python pytest capability collection"), + ); + validateOptional(run.execution, (value) => + validatePythonPytestCapabilityInvocation(value, "Python pytest capability execution"), + ); + validateOptional(run.cleanup, validatePythonPytestCapabilityCleanupEvidence); +} + +function validatePythonPytestCapabilityCounts(counts: PythonCapabilityCounts): void { + if (!counts || typeof counts !== "object") throw new Error("Python pytest capability counts are required"); + validateExactObjectKeys( + counts, + [ + "candidateCount", + "collectedCount", + "executedCount", + "passedCount", + "failedCount", + "skippedCount", + "xfailedCount", + "xpassedCount", + "errorCount", + ], + "Python pytest capability counts", + ); + for (const key of Object.keys(counts) as (keyof PythonCapabilityCounts)[]) { + validateNonNegativeInteger(counts[key], `Python pytest capability counts ${key}`); + } +} + +export { validatePythonPytestCapabilityCounts }; + +function validatePythonPytestCapabilityInvocation(invocation: PythonCapabilityInvocation, label: string): void { + validatePythonCapabilityInvocationWithDuration( + invocation, + label, + (value, durationLabel) => validateNonNegativeNumber(value, durationLabel), + ); +} + +export { validatePythonPytestCapabilityInvocation }; + +function validatePythonPytestCapabilityCleanupEvidence(cleanup: PythonCapabilityCleanupEvidence): void { + if (!cleanup || typeof cleanup !== "object") throw new Error("Python pytest capability cleanup evidence is required"); + validateExactObjectKeys(cleanup, ["attempted", "ok", "failureMessage"], "Python pytest capability cleanup evidence"); + if (typeof cleanup.attempted !== "boolean") + throw new Error("Python pytest capability cleanup attempted must be boolean"); + if (typeof cleanup.ok !== "boolean") throw new Error("Python pytest capability cleanup ok must be boolean"); + if (cleanup.failureMessage !== undefined) { + validateNonEmptyString(cleanup.failureMessage, "Python pytest capability cleanup failureMessage"); + } +} + +export { validatePythonPytestCapabilityCleanupEvidence }; + +function validatePythonValidationCapabilityTool( + tool: PythonValidationCapabilityToolProvenance, + run: PythonTypesValidationCapabilityRun, +): void { + validateExactObjectKeys( + tool, + ["name", "executable", "argv", "cwd", "source", "version", "configFile"], + "Python validation capability tool", + ); + validateExactValue(tool.name, run.authority, "Python validation capability tool must match authority"); + validatePortablePythonCapabilityExecutable(tool.executable); + validateStringArray(tool.argv, "Python validation capability tool argv", { + allowEmpty: false, + }); + validateExactValue( + tool.argv[0], + tool.executable, + "Python validation capability tool argv must start with executable", + ); + for (const argument of tool.argv) { + if (containsHostAbsolutePath(argument)) { + throw new Error("Python validation capability tool requires portable argv without host-absolute paths"); + } + } + validatePythonProjectRoot(tool.cwd, "Python validation capability tool cwd"); + validateExactValue(tool.cwd, run.projectRoot, "Python validation capability tool cwd must equal projectRoot"); + if (!includesString(pythonProjectExecutableSources, tool.source)) { + throw new Error(`Unknown Python validation capability tool source: ${String(tool.source)}`); + } + validateOptional(tool.version, (version) => { + validateNonEmptyString(tool.version, "Python validation capability tool version"); + if (!/^[0-9]+\.[0-9][-+._A-Za-z0-9]*$/u.test(version)) { + throw new Error("Python validation capability tool version must be exact version provenance"); + } + }); + validateOptional(tool.configFile, (configFile) => { + validateRepoRelativePath(configFile); + if (!run.selectedConfigPaths.includes(configFile)) { + throw new Error("Python validation capability tool configFile must be a selected config path"); + } + }); +} + +export { validatePythonValidationCapabilityTool }; diff --git a/packages/contracts/src/validation/python-pytest-validators-02.ts b/packages/contracts/src/validation/python-pytest-validators-02.ts new file mode 100644 index 0000000..0e15a5e --- /dev/null +++ b/packages/contracts/src/validation/python-pytest-validators-02.ts @@ -0,0 +1,100 @@ +import { includesString } from "../shared/primitives.js"; +import { validateRepoRelativePath } from "../shared/path-validators.js"; +import { validateNonEmptyString, validateNonNegativeInteger } from "../shared/validators-01.js"; +import type { PythonValidationCapabilityExecution } from "./python-project-contracts-02.js"; +import { + containsHostAbsolutePath, + validateExactObjectKeys, +} from "./python-validator-primitives.js"; +import { pythonValidationCapabilityTerminationKinds } from "./vocabulary-02.js"; + +function validatePortablePythonCapabilityExecutable(executable: string): void { + validateNonEmptyString(executable, "Python validation capability tool executable"); + const match = /^(repo|project|path|external):(.+)$/u.exec(executable); + if (match === null) throw new Error("Python validation capability tool requires a portable executable locator"); + const [, kind, value] = match; + if (kind === "repo" || kind === "project") { + try { + validateRepoRelativePath(value); + } catch { + throw new Error("Python validation capability tool requires a portable executable locator"); + } + return; + } + if (!/^[A-Za-z0-9_.+-]+$/u.test(value)) { + throw new Error("Python validation capability tool requires a portable executable locator"); + } +} + +export { validatePortablePythonCapabilityExecutable }; + +function validatePythonValidationCapabilityExecution(execution: PythonValidationCapabilityExecution): void { + validateExactObjectKeys( + execution, + ["termination", "exitCode", "signal", "failureSummary"], + "Python validation capability execution", + ); + if (!includesString(pythonValidationCapabilityTerminationKinds, execution.termination)) { + throw new Error(`Unknown Python validation capability termination: ${String(execution.termination)}`); + } + validatePythonCapabilityExit(execution); + validatePythonCapabilitySignal(execution); + validatePythonCapabilityFailureSummary(execution); +} + +export { validatePythonValidationCapabilityExecution }; + +function validatePythonCapabilityExit(execution: PythonValidationCapabilityExecution): void { + if (execution.exitCode !== undefined) + validateNonNegativeInteger(execution.exitCode, "Python validation capability execution exitCode"); + if (execution.termination === "exited" && execution.exitCode === undefined) { + throw new Error("Exited Python validation capability execution requires exitCode"); + } + if (execution.termination !== "exited" && execution.exitCode !== undefined) { + throw new Error("Non-exited Python validation capability execution must not include exitCode"); + } +} + +export { validatePythonCapabilityExit }; + +function validatePythonCapabilitySignal(execution: PythonValidationCapabilityExecution): void { + if (execution.termination === "signal" && execution.signal === undefined) { + throw new Error("Signaled Python validation capability execution requires signal"); + } + if (execution.termination !== "signal" && execution.signal !== undefined) { + throw new Error("Non-signaled Python validation capability execution must not include signal"); + } + if (execution.signal !== undefined) + validateNonEmptyString(execution.signal, "Python validation capability execution signal"); +} + +export { validatePythonCapabilitySignal }; + +function validatePythonCapabilityFailureSummary(execution: PythonValidationCapabilityExecution): void { + if (execution.failureSummary !== undefined) { + validateNonEmptyString(execution.failureSummary, "Python validation capability execution failureSummary"); + if (execution.failureSummary.length > 1024) + throw new Error("Python validation capability execution failureSummary is too long"); + if (containsHostAbsolutePath(execution.failureSummary)) { + throw new Error("Python validation capability execution failureSummary must not contain host-absolute paths"); + } + } + if (execution.termination !== "exited" && execution.failureSummary === undefined) { + throw new Error("Non-exited Python validation capability execution requires failureSummary"); + } +} + +export { validatePythonCapabilityFailureSummary }; + +function validateSortedUniqueRepoPaths(values: readonly string[], label: string, allowEmpty: boolean): void { + if (!Array.isArray(values) || (!allowEmpty && values.length === 0)) { + throw new Error(`${label} must be ${allowEmpty ? "an" : "a non-empty"} array`); + } + for (const value of values) validateRepoRelativePath(value); + const sorted = [...new Set(values)].sort(); + if (sorted.length !== values.length || sorted.some((value, index) => value !== values[index])) { + throw new Error(`${label} must be sorted and unique`); + } +} + +export { validateSortedUniqueRepoPaths }; diff --git a/packages/contracts/src/validation/python-ruff-validators-01.ts b/packages/contracts/src/validation/python-ruff-validators-01.ts new file mode 100644 index 0000000..42f8830 --- /dev/null +++ b/packages/contracts/src/validation/python-ruff-validators-01.ts @@ -0,0 +1,230 @@ +import { + includesString, + validateArray, + validateObject, + validateOptional, + validateRequiredObject, +} from "../shared/primitives.js"; +import { validateRepoRelativePath } from "../shared/path-validators.js"; +import { + validateNonEmptyString, + validateNonNegativeInteger, + validateNonNegativeNumber, + validateStringArray, + validateValidationCheckId, +} from "../shared/validators-01.js"; +import type { + PythonRuffValidationCapabilityRun, + PythonValidationCapabilityInvocation, +} from "./diagnostic-contracts.js"; +import { pythonProjectExecutableSources } from "./python-project-contracts-01.js"; +import { validateSha256Identity } from "./python-project-validators-02.js"; +import { validatePortablePythonCapabilityExecutable } from "./python-pytest-validators-02.js"; +import { + containsHostAbsolutePath, + validateExactObjectKeys, + validatePythonCapabilityRunSchema, +} from "./python-validator-primitives.js"; +import { validateRuffCapabilityRun } from "./python-ruff-validators-02.js"; +import { validatePortablePythonCapabilityArgv, validateRuffTerminationEvidence } from "./python-ruff-validators-03.js"; +import { pythonValidationCapabilityStates, pythonValidationCapabilityTerminations } from "./vocabulary-02.js"; + +function validatePythonRuffValidationCapabilityRun( + run: PythonRuffValidationCapabilityRun, +): PythonRuffValidationCapabilityRun { + validateObject(run, "Python validation capability run"); + validatePythonRuffRunHeader(run); + validatePythonRuffRunIdentity(run); + validatePythonRuffRunProcess(run); + validatePythonRuffRunInvocations(run); + validatePythonRuffRunOutcome(run); + validateRuffCapabilityRun(run); + return run; +} + +export { validatePythonRuffValidationCapabilityRun }; + +function validatePythonRuffRunHeader(run: PythonRuffValidationCapabilityRun): void { + validateExactObjectKeys( + run, + [ + "schemaId", + "schemaVersion", + "checkId", + "capability", + "state", + "projectKey", + "contextFingerprint", + "afterStateManifestFingerprint", + "sourcePaths", + "configPaths", + "executable", + "command", + "argv", + "cwd", + "configPath", + "toolVersion", + "toolSource", + "termination", + "exitCode", + "signal", + "invocations", + "durationMs", + "diagnosticCount", + "failureMessage", + ], + "Python Ruff validation capability run", + ); + validatePythonCapabilityRunSchema(run); + validateValidationCheckId(run.checkId, "Python validation capability run checkId"); + if (!includesString(["ruff_lint", "ruff_format"] as const, run.capability)) { + throw new Error(`Unknown Python Ruff validation capability: ${String(run.capability)}`); + } + if (!includesString(pythonValidationCapabilityStates, run.state)) { + throw new Error(`Unknown Python validation capability state: ${String(run.state)}`); + } +} + +export { validatePythonRuffRunHeader }; + +function validatePythonRuffRunIdentity(run: PythonRuffValidationCapabilityRun): void { + validateOptional(run.projectKey, (value) => + validateSha256Identity(value, "Python validation capability run projectKey"), + ); + validateOptional(run.contextFingerprint, (value) => + validateSha256Identity(value, "Python validation capability run contextFingerprint"), + ); + validateOptional(run.afterStateManifestFingerprint, (value) => + validateSha256Identity(value, "Python validation capability run afterStateManifestFingerprint"), + ); + validateOptional(run.sourcePaths, (paths) => validatePythonRuffPaths(paths, "sourcePaths")); + validateOptional(run.configPaths, (paths) => validatePythonRuffPaths(paths, "configPaths")); +} + +export { validatePythonRuffRunIdentity }; + +function validatePythonRuffPaths(paths: readonly string[], label: string): void { + validateStringArray(paths, `Python validation capability run ${label}`, { allowEmpty: true }); + for (const path of paths) validateRepoRelativePath(path); +} + +export { validatePythonRuffPaths }; + +function validatePythonRuffRunProcess(run: PythonRuffValidationCapabilityRun): void { + validateOptional(run.executable, validatePortablePythonCapabilityExecutable); + validateOptional(run.command, (command) => + validateNonEmptyString(command, "Python validation capability run command"), + ); + validateOptional(run.argv, (argv) => { + validateStringArray(run.argv, "Python validation capability run argv", { + allowEmpty: false, + }); + validatePortablePythonCapabilityArgv(argv); + }); + validateOptional(run.cwd, (cwd) => validateNonEmptyString(cwd, "Python validation capability run cwd")); + validateOptional(run.configPath, validateRepoRelativePath); + validatePythonRuffTool(run); + validateOptional(run.termination, (termination) => { + if (!includesString(pythonValidationCapabilityTerminations, termination)) { + throw new Error(`Unknown Python validation capability termination: ${String(termination)}`); + } + }); + validateOptional(run.exitCode, (exitCode) => + validateNonNegativeInteger(exitCode, "Python validation capability run exitCode"), + ); + validateOptional(run.signal, (signal) => validateNonEmptyString(signal, "Python validation capability run signal")); +} + +export { validatePythonRuffRunProcess }; + +function validatePythonRuffTool(run: PythonRuffValidationCapabilityRun): void { + if (run.toolVersion !== undefined) { + validateNonEmptyString(run.toolVersion, "Python validation capability run toolVersion"); + if (!/^[0-9]+\.[0-9][-+._A-Za-z0-9]*$/u.test(run.toolVersion)) { + throw new Error("Python validation capability run toolVersion must be exact version provenance"); + } + } + if (run.toolSource !== undefined && !includesString(pythonProjectExecutableSources, run.toolSource)) { + throw new Error(`Unknown Python validation capability run toolSource: ${String(run.toolSource)}`); + } +} + +export { validatePythonRuffTool }; + +function validatePythonRuffRunInvocations(run: PythonRuffValidationCapabilityRun): void { + validateOptional(run.invocations, (invocations) => { + validateArray(invocations, "Python validation capability run invocations"); + if (invocations.length === 0) { + throw new Error("Python validation capability run invocations must be a non-empty array"); + } + for (const invocation of invocations) { + validatePythonValidationCapabilityInvocation(invocation); + if (run.executable !== undefined && invocation.argv[0] !== run.executable) { + throw new Error("Python validation capability invocation argv must start with executable"); + } + } + }); +} + +export { validatePythonRuffRunInvocations }; + +function validatePythonRuffRunOutcome(run: PythonRuffValidationCapabilityRun): void { + validateNonNegativeNumber(run.durationMs, "Python validation capability run durationMs"); + validateNonNegativeInteger(run.diagnosticCount, "Python validation capability run diagnosticCount"); + validateOptional(run.failureMessage, (failureMessage) => { + validateNonEmptyString(failureMessage, "Python validation capability run failureMessage"); + if (containsHostAbsolutePath(failureMessage)) { + throw new Error("Python validation capability run failureMessage must not contain host-absolute paths"); + } + }); + validateInactivePythonRuffRun(run); + validatePythonRuffRunTerminationFields(run); +} + +export { validatePythonRuffRunOutcome }; + +function validateInactivePythonRuffRun(run: PythonRuffValidationCapabilityRun): void { + if (run.state !== "not_applicable" && run.state !== "disabled") return; + const processEvidence = [run.termination, run.exitCode, run.signal, run.command, run.argv, run.invocations]; + if (processEvidence.some((value) => value !== undefined)) { + throw new Error(`Python validation capability state ${run.state} must not record a process invocation`); + } +} + +export { validateInactivePythonRuffRun }; + +function validatePythonRuffRunTerminationFields(run: PythonRuffValidationCapabilityRun): void { + if (run.signal !== undefined && run.termination !== "signal") { + throw new Error("Python validation capability run signal requires signal termination"); + } + if (run.exitCode !== undefined && run.termination !== "exited") { + throw new Error("Python validation capability run exitCode requires exited termination"); + } +} + +export { validatePythonRuffRunTerminationFields }; + +function validatePythonValidationCapabilityInvocation(invocation: PythonValidationCapabilityInvocation): void { + validateRequiredObject(invocation, "Python validation capability invocation is required"); + validateStringArray(invocation.argv, "Python validation capability invocation argv", { allowEmpty: false }); + validatePortablePythonCapabilityArgv(invocation.argv); + if (!includesString(pythonValidationCapabilityTerminations, invocation.termination)) { + throw new Error(`Unknown Python validation capability invocation termination: ${String(invocation.termination)}`); + } + if (invocation.exitCode !== undefined) { + validateNonNegativeInteger(invocation.exitCode, "Python validation capability invocation exitCode"); + if (invocation.termination !== "exited") { + throw new Error("Python validation capability invocation exitCode requires exited termination"); + } + } + if (invocation.signal !== undefined) { + validateNonEmptyString(invocation.signal, "Python validation capability invocation signal"); + if (invocation.termination !== "signal") { + throw new Error("Python validation capability invocation signal requires signal termination"); + } + } + validateRuffTerminationEvidence(invocation, "Python validation capability invocation"); + validateNonNegativeNumber(invocation.durationMs, "Python validation capability invocation durationMs"); +} + +export { validatePythonValidationCapabilityInvocation }; diff --git a/packages/contracts/src/validation/python-ruff-validators-02.ts b/packages/contracts/src/validation/python-ruff-validators-02.ts new file mode 100644 index 0000000..6bd01b4 --- /dev/null +++ b/packages/contracts/src/validation/python-ruff-validators-02.ts @@ -0,0 +1,244 @@ +import { validateRepoRelativePath } from "../shared/path-validators.js"; +import type { PythonRuffValidationCapabilityRun } from "./diagnostic-contracts.js"; +import { validateRuffTerminationEvidence } from "./python-ruff-validators-03.js"; + +function validateRuffCapabilityRun(run: PythonRuffValidationCapabilityRun): void { + if (!validateRuffCapabilityIdentity(run)) return; + if (run.state === "not_applicable" || run.state === "disabled") return; + validateActivatedRuffExactState(run); + validateRuffCapabilityState(run); +} + +export { validateRuffCapabilityRun }; + +function validateRuffCapabilityIdentity(run: PythonRuffValidationCapabilityRun): boolean { + const expectedCheckId = expectedRuffCheckId(run.capability); + if (expectedCheckId === undefined) return false; + if (run.checkId !== expectedCheckId) { + throw new Error(`Python validation capability ${run.capability} requires checkId ${expectedCheckId}`); + } + return true; +} + +export { validateRuffCapabilityIdentity }; + +function validateRuffCapabilityState(run: PythonRuffValidationCapabilityRun): void { + switch (run.state) { + case "tool_unavailable": + case "unsupported_target": + return validateUnavailableRuffCapability(run); + case "timeout": + return validateTimeoutRuffCapability(run); + case "invalid_config": + return validateInvalidConfigRuffCapability(run); + case "tool_failure": + return validateFailedRuffCapability(run); + case "passed": + case "findings": + return validateCompletedRuffCapability(run); + } +} + +export { validateRuffCapabilityState }; + +function expectedRuffCheckId( + capability: PythonRuffValidationCapabilityRun["capability"], +): "python.ruff-lint" | "python.ruff-format" | undefined { + if (capability === "ruff_lint") return "python.ruff-lint"; + if (capability === "ruff_format") return "python.ruff-format"; + return undefined; +} + +export { expectedRuffCheckId }; + +function validateActivatedRuffExactState(run: PythonRuffValidationCapabilityRun): void { + const exactStateFields: readonly (keyof PythonRuffValidationCapabilityRun)[] = [ + "projectKey", + "contextFingerprint", + "afterStateManifestFingerprint", + "sourcePaths", + "configPaths", + "cwd", + ]; + for (const field of exactStateFields) { + if (run[field] === undefined) { + throw new Error(`Activated Ruff capability run requires ${field}`); + } + } + if ((run.sourcePaths?.length ?? 0) === 0) { + throw new Error("Activated Ruff capability run requires at least one source path"); + } + if (run.cwd !== "." && run.cwd !== undefined) validateRepoRelativePath(run.cwd); +} + +export { validateActivatedRuffExactState }; + +function validateUnavailableRuffCapability(run: PythonRuffValidationCapabilityRun): void { + requireRuffFailureMessage(run); + rejectRuffProcessEvidence(run); +} + +export { validateUnavailableRuffCapability }; + +function validateTimeoutRuffCapability(run: PythonRuffValidationCapabilityRun): void { + requireRuffFailureMessage(run); + requireExecutedRuffCapability(run); + if (run.termination !== "timeout") { + throw new Error("Ruff capability timeout requires timeout termination"); + } + validateExecutedRuffCapabilityCoherence(run); +} + +export { validateTimeoutRuffCapability }; + +function validateInvalidConfigRuffCapability(run: PythonRuffValidationCapabilityRun): void { + requireRuffFailureMessage(run); + if (!hasRuffProcessEvidence(run)) return; + requireExecutedRuffCapability(run); + if (run.termination !== "exited" || run.exitCode !== 2) { + throw new Error("Executed Ruff capability invalid_config requires exited configuration-rejection evidence"); + } + validateExecutedRuffCapabilityCoherence(run); +} + +export { validateInvalidConfigRuffCapability }; + +function validateFailedRuffCapability(run: PythonRuffValidationCapabilityRun): void { + requireRuffFailureMessage(run); + if (!hasRuffProcessEvidence(run)) return; + requireExecutedRuffCapability(run); + if (run.termination === "timeout") { + throw new Error("Executed Ruff capability tool_failure must not use timeout termination"); + } + validateExecutedRuffCapabilityCoherence(run); +} + +export { validateFailedRuffCapability }; + +function validateCompletedRuffCapability(run: PythonRuffValidationCapabilityRun): void { + requireExecutedRuffCapability(run); + if (run.termination !== "exited") { + throw new Error("Executed Ruff capability run requires exited termination"); + } + validateCompletedRuffInvocations(run); + validateExecutedRuffCapabilityCoherence(run); + validateCompletedRuffResult(run); +} + +export { validateCompletedRuffCapability }; + +function validateCompletedRuffInvocations(run: PythonRuffValidationCapabilityRun): void { + for (const invocation of requireRuffInvocations(run)) { + if (invocation.termination !== "exited" || (invocation.exitCode !== 0 && invocation.exitCode !== 1)) { + throw new Error("Executed Ruff capability invocation requires exited Ruff result code 0 or 1"); + } + } +} + +export { validateCompletedRuffInvocations }; + +function validateCompletedRuffResult(run: PythonRuffValidationCapabilityRun): void { + const expectedExitCode = run.state === "passed" ? 0 : 1; + if (run.exitCode !== expectedExitCode) { + throw new Error(`Executed Ruff capability state ${run.state} requires exitCode ${expectedExitCode}`); + } + if (run.state === "passed" && run.diagnosticCount !== 0) { + throw new Error("Passed Ruff capability run requires zero diagnostics"); + } + if (run.state === "findings" && run.diagnosticCount <= 0) { + throw new Error("Ruff findings capability run requires positive diagnosticCount"); + } +} + +export { validateCompletedRuffResult }; + +function requireRuffFailureMessage(run: PythonRuffValidationCapabilityRun): void { + if (run.failureMessage === undefined) { + throw new Error(`Ruff capability state ${run.state} requires failureMessage`); + } +} + +export { requireRuffFailureMessage }; + +function hasRuffProcessEvidence(run: PythonRuffValidationCapabilityRun): boolean { + return ( + run.command !== undefined || + run.argv !== undefined || + run.termination !== undefined || + run.exitCode !== undefined || + run.signal !== undefined || + run.invocations !== undefined + ); +} + +export { hasRuffProcessEvidence }; + +function rejectRuffProcessEvidence(run: PythonRuffValidationCapabilityRun): void { + if (hasRuffProcessEvidence(run)) { + throw new Error(`Ruff capability state ${run.state} must not record process evidence`); + } +} + +export { rejectRuffProcessEvidence }; + +function requireExecutedRuffCapability(run: PythonRuffValidationCapabilityRun): void { + const executionFields: readonly (keyof PythonRuffValidationCapabilityRun)[] = [ + "executable", + "command", + "argv", + "toolVersion", + "toolSource", + "termination", + "invocations", + ]; + for (const field of executionFields) { + if (run[field] === undefined) { + throw new Error(`Executed Ruff capability run requires ${field}`); + } + } + if (run.durationMs <= 0) { + throw new Error("Executed Ruff capability run requires positive durationMs"); + } + if (run.argv?.[0] !== run.executable) { + throw new Error("Executed Ruff capability run argv must start with executable"); + } + if (run.command !== run.argv?.join(" ")) { + throw new Error("Executed Ruff capability run command must match argv"); + } + validateRuffTerminationEvidence(run, "Executed Ruff capability run"); + for (const invocation of requireRuffInvocations(run)) { + if (invocation.argv[0] !== run.executable) { + throw new Error("Executed Ruff capability invocation argv must start with executable"); + } + if (invocation.durationMs <= 0) { + throw new Error("Executed Ruff capability invocation requires positive durationMs"); + } + } +} + +export { requireExecutedRuffCapability }; + +function requireRuffInvocations( + run: PythonRuffValidationCapabilityRun, +): NonNullable { + if (run.invocations === undefined) { + throw new Error("Executed Ruff capability run requires invocations"); + } + return run.invocations; +} + +function validateExecutedRuffCapabilityCoherence(run: PythonRuffValidationCapabilityRun): void { + const matchingInvocation = run.invocations?.some( + (invocation) => + invocation.termination === run.termination && + invocation.exitCode === run.exitCode && + invocation.signal === run.signal && + invocation.argv.length === run.argv?.length && + invocation.argv.every((argument, index) => argument === run.argv?.[index]), + ); + if (matchingInvocation !== true) { + throw new Error("Executed Ruff capability run requires an invocation matching its argv and termination evidence"); + } +} + +export { validateExecutedRuffCapabilityCoherence }; diff --git a/packages/contracts/src/validation/python-ruff-validators-03.ts b/packages/contracts/src/validation/python-ruff-validators-03.ts new file mode 100644 index 0000000..d64fc07 --- /dev/null +++ b/packages/contracts/src/validation/python-ruff-validators-03.ts @@ -0,0 +1,75 @@ +import { validateRequiredObject } from "../shared/validators-02.js"; +import { includesString } from "../shared/primitives.js"; +import { validateNonEmptyString, validateValidationCheckId } from "../shared/validators-01.js"; +import type { + PythonRuffValidationCapabilityRun, + PythonValidationCapabilityInvocation, + ValidationSkippedCheck, +} from "./diagnostic-contracts.js"; +import { containsHostAbsolutePath } from "./python-validator-primitives.js"; +import type { ValidationFailure } from "./request-contracts.js"; +import { validationFailureCategories } from "./vocabulary-01.js"; +import { validationSkippedCheckReasons } from "./vocabulary-02.js"; + +function validateRuffTerminationEvidence( + evidence: Pick< + PythonRuffValidationCapabilityRun | PythonValidationCapabilityInvocation, + "termination" | "exitCode" | "signal" + >, + label: string, +): void { + if (evidence.termination === "exited") { + if (evidence.exitCode === undefined || evidence.signal !== undefined) { + throw new Error(`${label} exited termination requires exitCode without signal`); + } + return; + } + if (evidence.termination === "signal") { + if (evidence.signal === undefined || evidence.exitCode !== undefined) { + throw new Error(`${label} signal termination requires signal without exitCode`); + } + return; + } + if (evidence.exitCode !== undefined || evidence.signal !== undefined) { + throw new Error(`${label} ${String(evidence.termination)} termination must not record exitCode or signal`); + } +} + +export { validateRuffTerminationEvidence }; + +function validatePortablePythonCapabilityArgv(argv: readonly string[]): void { + for (const argument of argv) { + if (containsHostAbsolutePath(argument)) { + throw new Error("Python validation capability run requires portable argv without host-absolute paths"); + } + } +} + +export { validatePortablePythonCapabilityArgv }; + +function validateValidationSkippedCheck(skippedCheck: ValidationSkippedCheck): ValidationSkippedCheck { + validateRequiredObject(skippedCheck, "Validation skipped check is required"); + validateValidationCheckId(skippedCheck.checkId, "Validation skipped check checkId"); + if (!includesString(validationSkippedCheckReasons, skippedCheck.reason)) { + throw new Error(`Unknown validation skipped reason: ${String(skippedCheck.reason)}`); + } + validateNonEmptyString(skippedCheck.message, "Validation skipped check message"); + return skippedCheck; +} + +export { validateValidationSkippedCheck }; + +function validateValidationFailure(failure: ValidationFailure): ValidationFailure { + validateRequiredObject(failure, "Validation failure is required"); + if (!includesString(validationFailureCategories, failure.category)) { + throw new Error(`Unknown validation failure category: ${String(failure.category)}`); + } + validateNonEmptyString(failure.message, "Validation failure message"); + if (failure.retryable !== undefined && typeof failure.retryable !== "boolean") { + throw new Error("Validation failure retryable must be boolean"); + } + if (failure.cause !== undefined) validateNonEmptyString(failure.cause, "Validation failure cause"); + return failure; +} + +export { validateValidationFailure }; diff --git a/packages/contracts/src/validation/python-types-validators.ts b/packages/contracts/src/validation/python-types-validators.ts new file mode 100644 index 0000000..2f74151 --- /dev/null +++ b/packages/contracts/src/validation/python-types-validators.ts @@ -0,0 +1,247 @@ +import { includesString } from "../shared/primitives.js"; +import { validateNonNegativeInteger } from "../shared/validators-01.js"; +import type { PythonValidationCapabilityRun } from "./diagnostic-contracts.js"; +import type { PythonTypesValidationCapabilityRun } from "./python-project-contracts-02.js"; +import { + validatePythonProjectRoot, + validateSha256Identity, +} from "./python-project-validators-02.js"; +import { + validatePythonPytestValidationCapabilityRun, + validatePythonValidationCapabilityTool, +} from "./python-pytest-validators-01.js"; +import { + validatePythonValidationCapabilityExecution, + validateSortedUniqueRepoPaths, +} from "./python-pytest-validators-02.js"; +import { validatePythonRuffValidationCapabilityRun } from "./python-ruff-validators-01.js"; +import { + validateExactObjectKeys, + validatePythonCapabilityRunSchema, +} from "./python-validator-primitives.js"; +import { pythonValidationAuthorities, pythonValidationCapabilityRunStatuses } from "./vocabulary-01.js"; +import { pythonValidationAuthoritySources } from "./vocabulary-02.js"; + +function validatePythonValidationCapabilityRun(run: PythonValidationCapabilityRun): PythonValidationCapabilityRun { + if (run.capability === "types") return validatePythonTypesValidationCapabilityRun(run); + if (run.capability === "pytest") return validatePythonPytestValidationCapabilityRun(run); + return validatePythonRuffValidationCapabilityRun(run); +} + +export { validatePythonValidationCapabilityRun }; + +function validatePythonTypesValidationCapabilityRun( + run: PythonTypesValidationCapabilityRun, +): PythonTypesValidationCapabilityRun { + if (!run || typeof run !== "object") throw new Error("Python validation capability run is required"); + validatePythonCapabilityRunShape(run); + validatePythonCapabilityRunIdentity(run); + validatePythonCapabilityRunCounts(run); + if (run.tool !== undefined) validatePythonValidationCapabilityTool(run.tool, run); + if (run.execution !== undefined) validatePythonValidationCapabilityExecution(run.execution); + validatePythonCapabilityRunStatus(run); + return run; +} + +export { validatePythonTypesValidationCapabilityRun }; + +function validatePythonCapabilityRunShape(run: PythonTypesValidationCapabilityRun): void { + validateExactObjectKeys( + run, + [ + "schemaId", + "schemaVersion", + "capability", + "checkId", + "projectKey", + "contextFingerprint", + "projectRoot", + "targets", + "selectedSourcePaths", + "selectedConfigPaths", + "afterStateManifestFingerprint", + "authority", + "authoritySource", + "status", + "tool", + "execution", + "durationMs", + "diagnosticCount", + "errorCount", + "warningCount", + "noteCount", + ], + "Python validation capability run", + ); + validatePythonCapabilityRunSchema(run); + if (run.capability !== "types" || run.checkId !== "python.types") { + throw new Error("Python validation capability run must describe python.types"); + } +} + +export { validatePythonCapabilityRunShape }; + +function validatePythonCapabilityRunIdentity(run: PythonTypesValidationCapabilityRun): void { + validateSha256Identity(run.projectKey, "Python validation capability run projectKey"); + validateSha256Identity(run.contextFingerprint, "Python validation capability run contextFingerprint"); + validatePythonProjectRoot(run.projectRoot, "Python validation capability run projectRoot"); + validateSortedUniqueRepoPaths(run.targets, "Python validation capability run targets", false); + validateSortedUniqueRepoPaths(run.selectedSourcePaths, "Python validation capability run selectedSourcePaths", false); + validateSortedUniqueRepoPaths(run.selectedConfigPaths, "Python validation capability run selectedConfigPaths", true); + validatePythonCapabilityRunTargets(run); + validateSha256Identity( + run.afterStateManifestFingerprint, + "Python validation capability run afterStateManifestFingerprint", + ); + validatePythonCapabilityRunAuthority(run); + if (!includesString(pythonValidationCapabilityRunStatuses, run.status)) { + throw new Error(`Unknown Python validation capability run status: ${String(run.status)}`); + } +} + +export { validatePythonCapabilityRunIdentity }; + +function validatePythonCapabilityRunTargets(run: PythonTypesValidationCapabilityRun): void { + for (const target of run.targets) { + if (!run.selectedSourcePaths.includes(target)) { + throw new Error("Python validation capability run targets must be selected source paths"); + } + } +} + +function validatePythonCapabilityRunAuthority(run: PythonTypesValidationCapabilityRun): void { + if (run.authority !== undefined && !includesString(pythonValidationAuthorities, run.authority)) { + throw new Error(`Unknown Python validation authority: ${String(run.authority)}`); + } + if (run.authoritySource !== undefined && !includesString(pythonValidationAuthoritySources, run.authoritySource)) { + throw new Error(`Unknown Python validation authority source: ${String(run.authoritySource)}`); + } + if ((run.authority === undefined) !== (run.authoritySource === undefined)) { + throw new Error("Python validation capability authority and authoritySource must be present together"); + } + const permitsMissingAuthority = run.status === "invalid_config" || run.status === "unsupported_target"; + if (run.authority === undefined && !permitsMissingAuthority) { + throw new Error(`Python validation capability run ${run.status} requires selected authority evidence`); + } +} + +function validatePythonCapabilityRunCounts(run: PythonTypesValidationCapabilityRun): void { + for (const [key, value] of [ + ["durationMs", run.durationMs], + ["diagnosticCount", run.diagnosticCount], + ["errorCount", run.errorCount], + ["warningCount", run.warningCount], + ["noteCount", run.noteCount], + ] as const) + validateNonNegativeInteger(value, `Python validation capability run ${key}`); + if (run.diagnosticCount !== run.errorCount + run.warningCount + run.noteCount) { + throw new Error("Python validation capability run diagnosticCount must equal severity counts"); + } +} + +export { validatePythonCapabilityRunCounts }; + +function validatePythonCapabilityRunStatus(run: PythonTypesValidationCapabilityRun): void { + if (run.status === "passed") validatePassedPythonCapability(run); + if (run.status === "findings") validateFindingsPythonCapability(run); + if (run.status === "timeout") validateTimeoutPythonCapability(run); + if (run.status === "invalid_config") validateInvalidPythonCapability(run); + if (run.status === "unsupported_target") validateUnexecutedPythonCapability(run); + if (run.status === "tool_unavailable") validateUnavailablePythonCapability(run); + if (run.status === "tool_failure") validateFailedPythonCapability(run); +} + +export { validatePythonCapabilityRunStatus }; + +function validatePassedPythonCapability(run: PythonTypesValidationCapabilityRun): void { + requireExitedPythonCapability(run); + if (run.execution?.exitCode !== 0 || run.errorCount !== 0) { + throw new Error("Passed Python validation capability run requires exit 0 and zero errors"); + } +} + +export { validatePassedPythonCapability }; + +function validateFindingsPythonCapability(run: PythonTypesValidationCapabilityRun): void { + requireExitedPythonCapability(run); + if (run.execution?.exitCode !== 1 || run.diagnosticCount === 0) { + throw new Error("Findings Python validation capability run requires exit 1 and diagnostics"); + } + if (run.errorCount + run.warningCount === 0) { + throw new Error("Findings Python validation capability run requires an error or warning"); + } +} + +export { validateFindingsPythonCapability }; + +function requireExitedPythonCapability(run: PythonTypesValidationCapabilityRun): void { + if (run.tool === undefined || run.execution?.termination !== "exited") { + throw new Error(`Python validation capability run ${run.status} requires exited tool evidence`); + } +} + +export { requireExitedPythonCapability }; + +function validateTimeoutPythonCapability(run: PythonTypesValidationCapabilityRun): void { + if ( + run.tool === undefined || + run.execution?.termination !== "timeout" || + run.execution.failureSummary === undefined + ) { + throw new Error("Timeout Python validation capability run requires tool and timeout failure evidence"); + } +} + +export { validateTimeoutPythonCapability }; + +function validateInvalidPythonCapability(run: PythonTypesValidationCapabilityRun): void { + if (run.authority === undefined && (run.tool !== undefined || run.execution !== undefined)) { + throw new Error( + "Unselected invalid-config Python validation capability run must not include tool or execution evidence", + ); + } + if (run.execution === undefined) return; + if (run.tool === undefined || run.execution.termination !== "exited" || run.execution.failureSummary === undefined) { + throw new Error("Executed invalid-config Python validation capability run requires exited tool failure evidence"); + } +} + +export { validateInvalidPythonCapability }; + +function validateUnexecutedPythonCapability(run: PythonTypesValidationCapabilityRun): void { + if (run.execution !== undefined) + throw new Error(`${run.status} Python validation capability run must not include execution evidence`); +} + +export { validateUnexecutedPythonCapability }; + +function validateUnavailablePythonCapability(run: PythonTypesValidationCapabilityRun): void { + if (run.tool === undefined || run.execution !== undefined) { + throw new Error("Tool-unavailable Python validation capability run requires tool provenance without execution"); + } +} + +export { validateUnavailablePythonCapability }; + +function validateFailedPythonCapability(run: PythonTypesValidationCapabilityRun): void { + if ( + run.tool === undefined || + run.execution === undefined || + run.execution.termination === "timeout" || + run.execution.failureSummary === undefined + ) { + throw new Error("Tool-failure Python validation capability run requires non-timeout tool failure evidence"); + } +} + +export { validateFailedPythonCapability }; + +function validatePythonValidationCapabilityRuns( + runs: readonly PythonValidationCapabilityRun[], +): readonly PythonValidationCapabilityRun[] { + if (!Array.isArray(runs)) throw new Error("Python validation capability runs must be an array"); + for (const run of runs) validatePythonValidationCapabilityRun(run); + return runs; +} + +export { validatePythonValidationCapabilityRuns }; diff --git a/packages/contracts/src/validation/python-validator-primitives.ts b/packages/contracts/src/validation/python-validator-primitives.ts new file mode 100644 index 0000000..3c51cd5 --- /dev/null +++ b/packages/contracts/src/validation/python-validator-primitives.ts @@ -0,0 +1,48 @@ +import { validateRepoRelativePath } from "../shared/path-validators.js"; +import { PYTHON_VALIDATION_CAPABILITY_RUN_SCHEMA_ID } from "./python-project-contracts-01.js"; + +function validatePythonCapabilityRunSchema(run: { schemaId: string; schemaVersion: number }): void { + if (run.schemaId !== PYTHON_VALIDATION_CAPABILITY_RUN_SCHEMA_ID) { + throw new Error(`Python validation capability run schemaId must be ${PYTHON_VALIDATION_CAPABILITY_RUN_SCHEMA_ID}`); + } + if (run.schemaVersion !== 1) throw new Error("Python validation capability run schemaVersion must be 1"); +} + +export { validatePythonCapabilityRunSchema }; + +function validateExactObjectKeys(value: object, allowedKeys: readonly string[], label: string): void { + const allowed = new Set(allowedKeys); + const unexpected = Object.keys(value).filter((key) => !allowed.has(key)); + if (unexpected.length > 0) { + throw new Error(`${label} has unexpected properties: ${unexpected.sort().join(", ")}`); + } +} + +export { validateExactObjectKeys }; + +function containsHostAbsolutePath(value: string): boolean { + return ( + /^(?:\/|\\\\|[A-Za-z]:[\\/])/u.test(value) || + /[\s("'=](?:\/|\\\\|[A-Za-z]:[\\/])/u.test(value) || + /file:\/\//iu.test(value) + ); +} + +export { containsHostAbsolutePath }; + +function validateContextDocFilename(filename: string): string { + validateRepoRelativePath(filename); + if (filename.includes("/")) { + throw new Error(`Required context doc policy filename must be a basename: ${filename}`); + } + return filename; +} + +export { validateContextDocFilename }; + +function validateContextDocRequiredPath(path: string): string { + if (path === ".") return path; + return validateRepoRelativePath(path); +} + +export { validateContextDocRequiredPath }; diff --git a/packages/contracts/src/validation/request-contracts.ts b/packages/contracts/src/validation/request-contracts.ts new file mode 100644 index 0000000..5978723 --- /dev/null +++ b/packages/contracts/src/validation/request-contracts.ts @@ -0,0 +1,163 @@ +import type { RepoIdentity } from "../graph/provider-contracts-01.js"; +import type { GraphProviderStatus } from "../graph/provider-contracts-02.js"; +import type { CLONE_PROTOCOL, GraphProviderMode } from "../graph/vocabulary-01.js"; +import type { ValidationFailureCategory, ValidationReportMode } from "./vocabulary-01.js"; + +const validationScopeKinds = ["files", "changed", "staged", "tree", "all", "repo", "package"] as const; + +export { validationScopeKinds }; + +type ValidationScopeKind = (typeof validationScopeKinds)[number]; + +export type { ValidationScopeKind }; + +type ValidationScope = + | { + kind: "files"; + files: readonly string[]; + } + | { + kind: "changed"; + baseRef: string; + } + | { + kind: "staged"; + } + | { + kind: "tree"; + treeRef: string; + changedFrom: string; + } + | { + kind: "all"; + } + | { + kind: "repo"; + } + | { + kind: "package"; + packageName: string; + packageRoot: string; + }; + +export type { ValidationScope }; + +type HypotheticalOverlay = + | { + path: string; + action: "write"; + content: string; + checksumBefore?: string; + } + | { + path: string; + action: "delete"; + checksumBefore?: string; + }; + +export type { HypotheticalOverlay }; + +const cloneReportModes = ["all", "introduced"] as const; + +export { cloneReportModes }; + +type CloneReportMode = (typeof cloneReportModes)[number]; + +export type { CloneReportMode }; + +const cloneSourceReadModes = ["disk", "gitIndex", "gitTree"] as const; + +export { cloneSourceReadModes }; + +type CloneSourceReadMode = (typeof cloneSourceReadModes)[number]; + +export type { CloneSourceReadMode }; + +interface CloneAnalysisRequest { + protocol: typeof CLONE_PROTOCOL; + requestId?: string; + schemaVersion: 1; + repo: RepoIdentity; + reportMode: CloneReportMode; + paths?: readonly string[]; + sourcePaths?: readonly string[]; + sourceReadMode?: CloneSourceReadMode; + sourceTreeRef?: string; + overlays: readonly HypotheticalOverlay[]; + windowSize?: number; + minLines?: number; + minTokens?: number; + threshold?: number; + partitions?: readonly (readonly string[])[]; + exclude?: readonly string[]; + modes?: readonly string[]; +} + +export type { CloneAnalysisRequest }; + +interface CloneFinding { + cloneClassId: string; + contentHash: string; + path: string; + peerPath: string; + paths: readonly string[]; + lineCount: number; + tokenCount: number; + introduced: boolean; +} + +export type { CloneFinding }; + +interface CloneAnalysisSummary { + analyzedFiles: number; + cloneClassCount: number; + findingCount: number; + overlayCount: number; +} + +export type { CloneAnalysisSummary }; + +interface CloneAnalysisResult { + protocol: typeof CLONE_PROTOCOL; + requestId?: string; + schemaVersion: 1; + repo: RepoIdentity; + reportMode: CloneReportMode; + status: "passed"; + persisted: boolean; + dbPath?: string; + findings: readonly CloneFinding[]; + summary: CloneAnalysisSummary; +} + +export type { CloneAnalysisResult }; + +interface ValidationFailure { + category: ValidationFailureCategory; + message: string; + retryable?: boolean; + cause?: string; +} + +export type { ValidationFailure }; + +interface ValidationGraphConfig { + mode: GraphProviderMode; + provider?: string; + maxAgeMs?: number; + status?: GraphProviderStatus; +} + +export type { ValidationGraphConfig }; + +interface ValidationRequest { + requestId?: string; + repo: RepoIdentity; + scope: ValidationScope; + graph: ValidationGraphConfig; + overlays: readonly HypotheticalOverlay[]; + checks?: readonly string[]; + reportMode?: ValidationReportMode; +} + +export type { ValidationRequest }; diff --git a/packages/contracts/src/validation/request-validators-01.ts b/packages/contracts/src/validation/request-validators-01.ts new file mode 100644 index 0000000..8e800e7 --- /dev/null +++ b/packages/contracts/src/validation/request-validators-01.ts @@ -0,0 +1,179 @@ +import { validateOptional, validateRequiredObject } from "../shared/validators-02.js"; +import { includesString } from "../shared/primitives.js"; +import { validateProviderStatus } from "../graph/provider-validators.js"; +import { graphProviderModes } from "../graph/vocabulary-01.js"; +import { validateRepoRelativePath } from "../shared/path-validators.js"; +import { validateNonEmptyString, validatePositiveInteger, validateStringArray } from "../shared/validators-01.js"; +import type { ValidationDiagnostic, ValidationDiagnosticToolProvenance } from "./diagnostic-contracts.js"; +import type { + HypotheticalOverlay, + ValidationGraphConfig, + ValidationScope} from "./request-contracts.js"; +import { + validationScopeKinds, +} from "./request-contracts.js"; +import { validationDiagnosticCategories } from "./vocabulary-01.js"; + +function validateValidationScope(scope: ValidationScope): ValidationScope { + validateRequiredObject(scope, "Validation scope is required"); + if (!includesString(validationScopeKinds, scope.kind)) { + throw new Error(`Unknown validation scope kind: ${String((scope as { kind?: unknown }).kind)}`); + } + if (scope.kind === "files") { + validateStringArray(scope.files, "Validation scope files", { + allowEmpty: false, + }); + for (const file of scope.files) validateRepoRelativePath(file); + } + if (scope.kind === "changed") { + validateNonEmptyString(scope.baseRef, "Validation changed scope baseRef"); + } + if (scope.kind === "tree") { + validateNonEmptyString(scope.treeRef, "Validation tree scope treeRef"); + validateNonEmptyString(scope.changedFrom, "Validation tree scope changedFrom"); + } + if (scope.kind === "package") { + validateNonEmptyString(scope.packageName, "Validation package scope packageName"); + validateRepoRelativePath(scope.packageRoot); + } + return scope; +} + +export { validateValidationScope }; + +function validateValidationGraphConfig(graph: ValidationGraphConfig): ValidationGraphConfig { + validateRequiredObject(graph, "Validation graph config is required"); + if (!includesString(graphProviderModes, graph.mode)) { + throw new Error(`Unknown validation graph mode: ${String(graph.mode)}`); + } + if (graph.provider !== undefined) validateNonEmptyString(graph.provider, "Validation graph provider"); + if (graph.maxAgeMs !== undefined && (typeof graph.maxAgeMs !== "number" || graph.maxAgeMs < 0)) { + throw new Error("Validation graph maxAgeMs must be non-negative"); + } + if (graph.status !== undefined) { + validateProviderStatus(graph.status); + if (graph.status.mode !== graph.mode) { + throw new Error("Validation graph status mode must match graph mode"); + } + if (graph.provider !== undefined && graph.status.provider !== graph.provider) { + throw new Error("Validation graph status provider must match graph provider"); + } + } + return graph; +} + +export { validateValidationGraphConfig }; + +function validateHypotheticalOverlays(overlays: readonly HypotheticalOverlay[]): readonly HypotheticalOverlay[] { + if (!Array.isArray(overlays)) { + throw new Error("Validation request overlays must be an array"); + } + const normalizedPaths = new Set(); + for (const overlay of overlays) { + validateHypotheticalOverlay(overlay); + const normalizedPath = validateRepoRelativePath(overlay.path); + if (normalizedPaths.has(normalizedPath)) { + throw new Error(`Validation request overlays include duplicate path: ${normalizedPath}`); + } + normalizedPaths.add(normalizedPath); + } + return overlays; +} + +export { validateHypotheticalOverlays }; + +function validateHypotheticalOverlay(overlay: HypotheticalOverlay): HypotheticalOverlay { + validateRequiredObject(overlay, "Validation request overlay is required"); + validateRepoRelativePath(overlay.path); + if (!includesString(["write", "delete"] as const, overlay.action)) { + throw new Error(`Unknown validation overlay action: ${String((overlay as { action?: unknown }).action)}`); + } + if (overlay.action === "write") { + if (typeof overlay.content !== "string") { + throw new Error("Validation write overlay must include content"); + } + } + if (overlay.action === "delete" && Object.hasOwn(overlay, "content")) { + throw new Error("Validation delete overlay must not include content"); + } + if (overlay.checksumBefore !== undefined) { + validateNonEmptyString(overlay.checksumBefore, "Validation overlay checksumBefore"); + } + return overlay; +} + +export { validateHypotheticalOverlay }; + +function validateValidationDiagnostics(diagnostics: readonly ValidationDiagnostic[]): readonly ValidationDiagnostic[] { + if (!Array.isArray(diagnostics)) { + throw new Error("Validation result diagnostics must be an array"); + } + for (const diagnostic of diagnostics) validateValidationDiagnostic(diagnostic); + return diagnostics; +} + +export { validateValidationDiagnostics }; + +function validateValidationDiagnostic(diagnostic: ValidationDiagnostic): ValidationDiagnostic { + validateRequiredObject(diagnostic, "Validation diagnostic is required"); + if (!includesString(validationDiagnosticCategories, diagnostic.category)) { + throw new Error(`Unknown validation diagnostic category: ${String(diagnostic.category)}`); + } + validateNonEmptyString(diagnostic.message, "Validation diagnostic message"); + if (diagnostic.path !== undefined) validateRepoRelativePath(diagnostic.path); + if (!includesString(["info", "warning", "error"] as const, diagnostic.severity)) { + throw new Error(`Unknown validation diagnostic severity: ${String(diagnostic.severity)}`); + } + if (diagnostic.code !== undefined) validateNonEmptyString(diagnostic.code, "Validation diagnostic code"); + validateValidationDiagnosticLocation(diagnostic); + if (diagnostic.tool !== undefined) validateValidationDiagnosticTool(diagnostic.tool); + return diagnostic; +} + +export { validateValidationDiagnostic }; + +function validateValidationDiagnosticLocation(diagnostic: ValidationDiagnostic): void { + for (const field of ["line", "column", "endLine", "endColumn"] as const) { + if (diagnostic[field] !== undefined) validatePositiveInteger(diagnostic[field], `Validation diagnostic ${field}`); + } + validateValidationDiagnosticLocationPresence(diagnostic); + validateValidationDiagnosticLocationOrder(diagnostic); +} + +export { validateValidationDiagnosticLocation }; + +function validateValidationDiagnosticLocationPresence(diagnostic: ValidationDiagnostic): void { + if (diagnostic.column !== undefined && diagnostic.line === undefined) { + throw new Error("Validation diagnostic column requires line"); + } + const hasEndLocation = diagnostic.endLine !== undefined || diagnostic.endColumn !== undefined; + if (hasEndLocation && diagnostic.line === undefined) { + throw new Error("Validation diagnostic end location requires line"); + } + if (diagnostic.endColumn !== undefined && diagnostic.endLine === undefined) { + throw new Error("Validation diagnostic endColumn requires endLine"); + } +} + +function validateValidationDiagnosticLocationOrder(diagnostic: ValidationDiagnostic): void { + if (diagnostic.line !== undefined && diagnostic.endLine !== undefined) { + const startsAfterEnd = + diagnostic.endLine < diagnostic.line || + (diagnostic.endLine === diagnostic.line && + diagnostic.column !== undefined && + diagnostic.endColumn !== undefined && + diagnostic.endColumn < diagnostic.column); + if (startsAfterEnd) throw new Error("Validation diagnostic end location must not precede start location"); + } +} + +function validateValidationDiagnosticTool(tool: ValidationDiagnosticToolProvenance): void { + if (!tool || typeof tool !== "object") throw new Error("Validation diagnostic tool provenance is required"); + validateNonEmptyString(tool.name, "Validation diagnostic tool name"); + validateNonEmptyString(tool.command, "Validation diagnostic tool command"); + validateOptional(tool.version, (value) => validateNonEmptyString(value, "Validation diagnostic tool version")); + validateOptional(tool.source, (value) => validateNonEmptyString(value, "Validation diagnostic tool source")); + validateOptional(tool.cwd, (value) => validateNonEmptyString(value, "Validation diagnostic tool cwd")); +} + +export { validateValidationDiagnosticTool }; diff --git a/packages/contracts/src/validation/request-validators-02.ts b/packages/contracts/src/validation/request-validators-02.ts new file mode 100644 index 0000000..163ef55 --- /dev/null +++ b/packages/contracts/src/validation/request-validators-02.ts @@ -0,0 +1,204 @@ +import { validateOptional, validateRequiredObject } from "../shared/validators-02.js"; +import { includesString } from "../shared/primitives.js"; +import { validateProviderStatus } from "../graph/provider-validators.js"; +import { GRAPH_SCHEMA_VERSION, graphProviderModes } from "../graph/vocabulary-01.js"; +import { validateRepoRelativePath } from "../shared/path-validators.js"; +import { + validateNonEmptyString, + validateNonNegativeInteger, + validateNonNegativeNumber, + validateStringArray, + validateValidationCheckId, + validateValidationChecks, +} from "../shared/validators-01.js"; +import type { + ValidationCheckManifestEntry, + ValidationCheckRunSummary, + ValidationResultManifest, +} from "./diagnostic-contracts.js"; +import { validateValidationSkippedCheck } from "./python-ruff-validators-03.js"; +import { validatePythonValidationCapabilityRun } from "./python-types-validators.js"; +import { validationScopeKinds } from "./request-contracts.js"; +import type { + PreWriteValidationFailureSummary, + PreWriteValidationOverlaySummary, + PreWriteValidationReceipt, +} from "./status-contracts.js"; +import type { + ValidationCheckOutcome, + ValidationCheckRunStatus} from "./vocabulary-01.js"; +import { + validationCheckOutcomes, + validationCheckRunStatuses, + validationResultStatuses, +} from "./vocabulary-01.js"; + +function validateValidationResultManifest(manifest: ValidationResultManifest): ValidationResultManifest { + validateRequiredObject(manifest, "Validation result manifest is required"); + if (manifest.schemaVersion !== GRAPH_SCHEMA_VERSION) { + throw new Error(`Validation result manifest schemaVersion must be ${GRAPH_SCHEMA_VERSION}`); + } + validateValidationChecks(manifest.checks, "Validation result manifest checks"); + validateNonEmptyString(manifest.generatedAt, "Validation result manifest generatedAt"); + validateOptional(manifest.durationMs, (value) => + validateNonNegativeNumber(value, "Validation result manifest durationMs"), + ); + validateOptional(manifest.entries, validateValidationManifestEntries); + validateOptional(manifest.runs, validateValidationManifestRuns); + validateOptional(manifest.skippedChecks, validateValidationManifestSkippedChecks); + return manifest; +} + +export { validateValidationResultManifest }; + +function validateValidationManifestEntries(entries: readonly ValidationCheckManifestEntry[]): void { + if (!Array.isArray(entries)) throw new Error("Validation result manifest entries must be an array"); + for (const entry of entries) validateValidationCheckManifestEntry(entry); +} + +function validateValidationManifestRuns(runs: readonly ValidationCheckRunSummary[]): void { + if (!Array.isArray(runs)) throw new Error("Validation result manifest runs must be an array"); + for (const run of runs) validateValidationCheckRunSummary(run); +} + +function validateValidationManifestSkippedChecks( + skippedChecks: NonNullable, +): void { + if (!Array.isArray(skippedChecks)) throw new Error("Validation result manifest skippedChecks must be an array"); + for (const skippedCheck of skippedChecks) validateValidationSkippedCheck(skippedCheck); +} + +function validatePreWriteValidationGraph(graph: PreWriteValidationReceipt["graph"]): void { + validateRequiredObject(graph, "Pre-write validation receipt graph is required"); + if (!includesString(graphProviderModes, graph.mode)) { + throw new Error(`Unknown pre-write validation receipt graph mode: ${String(graph.mode)}`); + } + if (graph.provider !== undefined) + validateNonEmptyString(graph.provider, "Pre-write validation receipt graph provider"); + if (graph.status !== undefined) { + validateProviderStatus(graph.status); + if (graph.status.mode !== graph.mode) { + throw new Error("Pre-write validation receipt graph status mode must match graph mode"); + } + if (graph.provider !== undefined && graph.status.provider !== graph.provider) { + throw new Error("Pre-write validation receipt graph status provider must match graph provider"); + } + } +} + +export { validatePreWriteValidationGraph }; + +function validatePreWriteValidationOverlaySummary(summary: PreWriteValidationOverlaySummary): void { + validateRequiredObject(summary, "Pre-write validation receipt overlays are required"); + for (const key of ["count", "writeCount", "deleteCount"] as const) { + if (!Number.isInteger(summary[key]) || summary[key] < 0) { + throw new Error(`Pre-write validation receipt overlays ${key} must be a non-negative integer`); + } + } + validateStringArray(summary.paths, "Pre-write validation receipt overlay paths", { allowEmpty: true }); + for (const path of summary.paths) validateRepoRelativePath(path); + if (summary.count !== summary.writeCount + summary.deleteCount) { + throw new Error("Pre-write validation receipt overlay count must equal writeCount plus deleteCount"); + } + if (summary.count !== summary.paths.length) { + throw new Error("Pre-write validation receipt overlay count must equal paths length"); + } +} + +export { validatePreWriteValidationOverlaySummary }; + +function validatePreWriteValidationFailureSummary(summary: PreWriteValidationFailureSummary): void { + validateRequiredObject(summary, "Pre-write validation receipt failureSummary is required"); + if (!includesString(validationResultStatuses, summary.category)) { + throw new Error(`Unknown pre-write validation receipt failure category: ${String(summary.category)}`); + } + if (summary.category === "passed") { + throw new Error("Pre-write validation receipt failure category must not be passed"); + } + validateNonEmptyString(summary.message, "Pre-write validation receipt failureSummary message"); + if (summary.cause !== undefined) + validateNonEmptyString(summary.cause, "Pre-write validation receipt failureSummary cause"); + if (summary.retryable !== undefined && typeof summary.retryable !== "boolean") { + throw new Error("Pre-write validation receipt failureSummary retryable must be boolean"); + } +} + +export { validatePreWriteValidationFailureSummary }; + +function validateValidationCheckManifestEntry(entry: ValidationCheckManifestEntry): ValidationCheckManifestEntry { + validateRequiredObject(entry, "Validation check manifest entry is required"); + validateValidationCheckId(entry.checkId, "Validation check manifest entry checkId"); + validateNonEmptyString(entry.owner, "Validation check manifest entry owner"); + validateNonEmptyString(entry.adapter, "Validation check manifest entry adapter"); + if (!includesString(["info", "warning", "error"] as const, entry.defaultSeverity)) { + throw new Error(`Unknown validation check manifest entry defaultSeverity: ${String(entry.defaultSeverity)}`); + } + if (!Array.isArray(entry.supportedScopes) || entry.supportedScopes.length === 0) { + throw new Error("Validation check manifest entry supportedScopes must be a non-empty array"); + } + for (const scopeKind of entry.supportedScopes) { + if (!includesString(validationScopeKinds, scopeKind)) { + throw new Error(`Unknown validation check manifest entry supported scope: ${String(scopeKind)}`); + } + } + if (typeof entry.requiresGraph !== "boolean") { + throw new Error("Validation check manifest entry requiresGraph must be boolean"); + } + return entry; +} + +export { validateValidationCheckManifestEntry }; + +function validateValidationCheckRunSummary(run: ValidationCheckRunSummary): ValidationCheckRunSummary { + validateRequiredObject(run, "Validation check run summary is required"); + validateValidationCheckId(run.checkId, "Validation check run summary checkId"); + if (!includesString(validationCheckRunStatuses, run.status)) { + throw new Error(`Unknown validation check run status: ${String(run.status)}`); + } + if (run.outcome !== undefined && !includesString(validationCheckOutcomes, run.outcome)) { + throw new Error(`Unknown validation check outcome: ${String(run.outcome)}`); + } + validateValidationCheckOutcomeStatus(run); + validateOptional(run.durationMs, (value) => + validateNonNegativeNumber(value, "Validation check run summary durationMs"), + ); + validateOptional(run.diagnosticCount, (value) => + validateNonNegativeInteger(value, "Validation check run summary diagnosticCount"), + ); + validateOptional(run.failureMessage, (value) => + validateNonEmptyString(value, "Validation check run summary failureMessage"), + ); + if ( + includesString(["infrastructure_failure", "provider_failure", "unsupported_request"] as const, run.status) && + run.failureMessage === undefined + ) { + throw new Error("Validation check run summary failureMessage is required for failure statuses"); + } + validateOptional(run.pythonCapabilityRuns, (runs) => { + if (!Array.isArray(runs)) { + throw new Error("Validation check run summary pythonCapabilityRuns must be an array"); + } + for (const capabilityRun of runs) validatePythonValidationCapabilityRun(capabilityRun); + }); + return run; +} + +export { validateValidationCheckRunSummary }; + +function validateValidationCheckOutcomeStatus(run: ValidationCheckRunSummary): void { + if (run.outcome === undefined) return; + const expectedStatus: Record = { + passed: "passed", + findings: "policy_failure", + tool_unavailable: "unsupported_request", + invalid_config: "unsupported_request", + timeout: "infrastructure_failure", + unsupported_target: "unsupported_request", + tool_failure: "infrastructure_failure", + }; + if (run.status !== expectedStatus[run.outcome]) { + throw new Error(`Validation check outcome ${run.outcome} requires status ${expectedStatus[run.outcome]}`); + } +} + +export { validateValidationCheckOutcomeStatus }; diff --git a/packages/contracts/src/validation/result-validator.ts b/packages/contracts/src/validation/result-validator.ts new file mode 100644 index 0000000..b538e14 --- /dev/null +++ b/packages/contracts/src/validation/result-validator.ts @@ -0,0 +1,95 @@ +import { + validateBoolean, + validateOptional, + validateRequiredObject, +} from "../shared/validators-02.js"; +import { validateEditRefusal } from "../edit/refusal-validator.js"; +import { validateProviderStatus } from "../graph/provider-validators.js"; +import { validateRepoIdentity } from "../shared/path-validators.js"; +import { includesString } from "../shared/primitives.js"; +import { validateNonEmptyString, validateValidationChecks } from "../shared/validators-01.js"; +import type { ValidationResult } from "./capability-contracts.js"; +import { validatePythonProjectContexts } from "./python-project-validators-01.js"; +import { validateValidationFailure } from "./python-ruff-validators-03.js"; +import { validatePythonValidationCapabilityRuns } from "./python-types-validators.js"; +import type { ValidationRequest } from "./request-contracts.js"; +import { + validateHypotheticalOverlays, + validateValidationDiagnostics, + validateValidationGraphConfig, + validateValidationScope, +} from "./request-validators-01.js"; +import { validateValidationResultManifest } from "./request-validators-02.js"; +import { validationFailureCategories, validationReportModes, validationResultStatuses } from "./vocabulary-01.js"; + +function validateValidationRequestPayload(request: ValidationRequest): ValidationRequest { + validateRequiredObject(request, "Validation request is required"); + if (request.requestId !== undefined) validateNonEmptyString(request.requestId, "Validation request requestId"); + validateRepoIdentity(request.repo); + validateValidationScope(request.scope); + validateValidationGraphConfig(request.graph); + validateHypotheticalOverlays(request.overlays); + if (request.checks !== undefined) validateValidationChecks(request.checks, "Validation request checks"); + if (request.reportMode !== undefined && !includesString(validationReportModes, request.reportMode)) { + throw new Error(`Unknown validation request reportMode: ${String(request.reportMode)}`); + } + return request; +} + +export { validateValidationRequestPayload }; + +function validateValidationResultPayload(result: ValidationResult): ValidationResult { + validateRequiredObject(result, "Validation result is required"); + validateBoolean(result.ok, "Validation result ok"); + if (!includesString(validationResultStatuses, result.status)) { + throw new Error(`Unknown validation result status: ${String(result.status)}`); + } + if (result.status === "passed" && !result.ok) { + throw new Error("Validation passed result must use ok=true"); + } + if (result.ok && result.status !== "passed") { + throw new Error("Validation result ok=true must use passed status"); + } + validateValidationDiagnostics(result.diagnostics); + validateOptional(result.graphStatus, validateProviderStatus); + validateOptional(result.failure, validateValidationFailure); + validateOptional(result.refusal, validateEditRefusal); + validateValidationResultRefusal(result); + validateValidationResultFailure(result); + validateValidationResultSuccess(result); + validateOptional(result.manifest, validateValidationResultManifest); + validateOptional(result.pythonProjectContexts, validatePythonProjectContexts); + validateOptional(result.pythonCapabilityRuns, validatePythonValidationCapabilityRuns); + return result; +} + +export { validateValidationResultPayload }; + +function validateValidationResultRefusal(result: ValidationResult): void { + if (result.status === "refused" && result.refusal === undefined) { + throw new Error("Validation refused result must include refusal"); + } + if (result.status === "refused" && result.failure !== undefined) { + throw new Error("Validation refused result must not include failure"); + } +} + +function validateValidationResultFailure(result: ValidationResult): void { + if (includesString(validationFailureCategories, result.status) && result.failure === undefined) { + throw new Error(`Validation ${result.status} result must include failure`); + } + if (includesString(validationFailureCategories, result.status)) { + if (result.failure?.category !== result.status) { + throw new Error("Validation failure category must match result status"); + } + if (result.refusal !== undefined) { + throw new Error("Validation failure result must not include refusal"); + } + } +} + +function validateValidationResultSuccess(result: ValidationResult): void { + if (result.status === "passed" && (result.failure !== undefined || result.refusal !== undefined)) { + throw new Error("Validation passed result must not include failure or refusal"); + } +} diff --git a/packages/contracts/src/validation/status-contracts.ts b/packages/contracts/src/validation/status-contracts.ts new file mode 100644 index 0000000..a03970a --- /dev/null +++ b/packages/contracts/src/validation/status-contracts.ts @@ -0,0 +1,151 @@ +import type { RepoIdentity } from "../graph/provider-contracts-01.js"; +import type { GraphProviderStatus } from "../graph/provider-contracts-02.js"; +import type { GraphProviderMode } from "../graph/vocabulary-01.js"; +import type { ValidationCheckManifestEntry } from "./diagnostic-contracts.js"; +import type { ValidationScope } from "./request-contracts.js"; +import type { ValidationCheckRunStatus, ValidationResultStatus } from "./vocabulary-01.js"; + +interface RequiredContextDocPolicy { + filenames: readonly string[]; + requiredPaths: readonly string[]; + requireRoot?: boolean; + minimumContentLength: number; + maxLines?: number; + maxSectionLines?: number; +} + +export type { RequiredContextDocPolicy }; + +const requiredContextDocPolicy = { + filenames: ["AGENTS.md", "CLAUDE.md"], + requiredPaths: ["."], + requireRoot: true, + minimumContentLength: 120, +} as const satisfies RequiredContextDocPolicy; + +export { requiredContextDocPolicy }; + +interface PreWriteValidationOverlaySummary { + count: number; + writeCount: number; + deleteCount: number; + paths: readonly string[]; +} + +export type { PreWriteValidationOverlaySummary }; + +interface PreWriteValidationFailureSummary { + category: ValidationResultStatus; + message: string; + cause?: string; + retryable?: boolean; +} + +export type { PreWriteValidationFailureSummary }; + +interface PreWriteValidationReceipt { + schemaVersion: 1; + kind: "pre_write_validation"; + route: "validate.pre-write"; + canonicalCommand: readonly string[]; + generatedAt: string; + durationMs: number; + timeoutMs: number; + ok: boolean; + requestId?: string; + repo?: RepoIdentity; + scope?: ValidationScope; + checks?: readonly string[]; + graph?: { + mode: GraphProviderMode; + provider?: string; + status?: GraphProviderStatus; + }; + overlays?: PreWriteValidationOverlaySummary; + validationStatus: ValidationResultStatus; + diagnosticCount: number; + failureSummary?: PreWriteValidationFailureSummary; +} + +export type { PreWriteValidationReceipt }; + +const validationDaemonReadinessStates = ["not_configured", "ready", "unavailable", "error"] as const; + +export { validationDaemonReadinessStates }; + +type ValidationDaemonReadinessState = (typeof validationDaemonReadinessStates)[number]; + +export type { ValidationDaemonReadinessState }; + +const validationAdapterRuntimeStates = ["available", "degraded", "unavailable"] as const; + +export { validationAdapterRuntimeStates }; + +type ValidationAdapterRuntimeState = (typeof validationAdapterRuntimeStates)[number]; + +export type { ValidationAdapterRuntimeState }; + +interface ValidationAdapterToolchainStatus { + tool: string; + available: boolean; + command?: string; + version?: string; + failureMessage?: string; + cwd?: string; + configFile?: string; + source?: string; +} + +export type { ValidationAdapterToolchainStatus }; + +interface ValidationAdapterDegradedCheckStatus { + checkId: string; + status: ValidationCheckRunStatus; + reason: string; + message: string; + requiredTool?: string; + retainedCompatibility?: boolean; + followUpIssue?: string; + currentUsage?: { + opcore: boolean; + orchestra: boolean; + covibes: boolean; + gateway: boolean; + }; +} + +export type { ValidationAdapterDegradedCheckStatus }; + +interface ValidationAdapterRuntimeStatus { + adapter: string; + status: ValidationAdapterRuntimeState; + checkIds: readonly string[]; + toolchain?: readonly ValidationAdapterToolchainStatus[]; + degradedChecks?: readonly ValidationAdapterDegradedCheckStatus[]; + tempWorkspaceRequired?: boolean; +} + +export type { ValidationAdapterRuntimeStatus }; + +interface ValidationStatusPayload { + schemaVersion: 1; + ready: boolean; + generatedAt: string; + adapterRegistry: { + checkRoutes: readonly string[]; + validateRoutes: readonly string[]; + checkIds: readonly string[]; + entries: readonly ValidationCheckManifestEntry[]; + adapters?: readonly ValidationAdapterRuntimeStatus[]; + }; + graph: { + mode: GraphProviderMode; + status: GraphProviderStatus; + }; + daemon?: { + state: ValidationDaemonReadinessState; + message?: string; + }; +} + +export type { ValidationStatusPayload }; diff --git a/packages/contracts/src/validation/vocabulary-01.ts b/packages/contracts/src/validation/vocabulary-01.ts new file mode 100644 index 0000000..7be2eb6 --- /dev/null +++ b/packages/contracts/src/validation/vocabulary-01.ts @@ -0,0 +1,104 @@ +const validationDiagnosticCategories = [ + "syntax", + "types", + "lint", + "test", + "graph", + "policy", + "provider", + "infrastructure", + "edit_safety", +] as const; + +export { validationDiagnosticCategories }; + +type ValidationDiagnosticCategory = (typeof validationDiagnosticCategories)[number]; + +export type { ValidationDiagnosticCategory }; + +const validationResultStatuses = [ + "passed", + "policy_failure", + "infrastructure_failure", + "provider_failure", + "unsupported_request", + "invalid_payload", + "skipped", + "refused", +] as const; + +export { validationResultStatuses }; + +type ValidationResultStatus = (typeof validationResultStatuses)[number]; + +export type { ValidationResultStatus }; + +const validationFailureCategories = [ + "policy_failure", + "infrastructure_failure", + "provider_failure", + "unsupported_request", + "invalid_payload", + "skipped", +] as const; + +export { validationFailureCategories }; + +type ValidationFailureCategory = (typeof validationFailureCategories)[number]; + +export type { ValidationFailureCategory }; + +const validationReportModes = ["all", "introduced"] as const; + +export { validationReportModes }; + +type ValidationReportMode = (typeof validationReportModes)[number]; + +export type { ValidationReportMode }; + +const validationCheckRunStatuses = [ + "passed", + "policy_failure", + "infrastructure_failure", + "provider_failure", + "unsupported_request", + "skipped", +] as const; + +export { validationCheckRunStatuses }; + +type ValidationCheckRunStatus = (typeof validationCheckRunStatuses)[number]; + +export type { ValidationCheckRunStatus }; + +const validationCheckOutcomes = [ + "passed", + "findings", + "tool_unavailable", + "invalid_config", + "timeout", + "unsupported_target", + "tool_failure", +] as const; + +export { validationCheckOutcomes }; + +type ValidationCheckOutcome = (typeof validationCheckOutcomes)[number]; + +export type { ValidationCheckOutcome }; + +const pythonValidationCapabilityRunStatuses = [...validationCheckOutcomes] as const; + +export { pythonValidationCapabilityRunStatuses }; + +type PythonValidationCapabilityRunStatus = (typeof pythonValidationCapabilityRunStatuses)[number]; + +export type { PythonValidationCapabilityRunStatus }; + +const pythonValidationAuthorities = ["mypy", "pyright"] as const; + +export { pythonValidationAuthorities }; + +type PythonValidationAuthority = (typeof pythonValidationAuthorities)[number]; + +export type { PythonValidationAuthority }; diff --git a/packages/contracts/src/validation/vocabulary-02.ts b/packages/contracts/src/validation/vocabulary-02.ts new file mode 100644 index 0000000..574c28b --- /dev/null +++ b/packages/contracts/src/validation/vocabulary-02.ts @@ -0,0 +1,71 @@ +import { validationCheckOutcomes } from "./vocabulary-01.js"; + +const pythonValidationAuthoritySources = ["explicit", "project_config"] as const; + +export { pythonValidationAuthoritySources }; + +type PythonValidationAuthoritySource = (typeof pythonValidationAuthoritySources)[number]; + +export type { PythonValidationAuthoritySource }; + +const pythonValidationCapabilityTerminationKinds = ["exited", "timeout", "signal", "spawn_error"] as const; + +export { pythonValidationCapabilityTerminationKinds }; + +type PythonValidationCapabilityTerminationKind = (typeof pythonValidationCapabilityTerminationKinds)[number]; + +export type { PythonValidationCapabilityTerminationKind }; + +const pythonValidationCapabilities = ["types", "ruff_lint", "ruff_format", "pytest"] as const; + +export { pythonValidationCapabilities }; + +type PythonValidationCapability = (typeof pythonValidationCapabilities)[number]; + +export type { PythonValidationCapability }; + +const pythonValidationCapabilityStates = [...validationCheckOutcomes, "not_applicable", "disabled"] as const; + +export { pythonValidationCapabilityStates }; + +type PythonValidationCapabilityState = (typeof pythonValidationCapabilityStates)[number]; + +export type { PythonValidationCapabilityState }; + +const pythonValidationCapabilityTerminations = ["exited", "timeout", "signal", "spawn_error", "overflow"] as const; + +export { pythonValidationCapabilityTerminations }; + +type PythonValidationCapabilityTermination = (typeof pythonValidationCapabilityTerminations)[number]; + +export type { PythonValidationCapabilityTermination }; + +const validationSkippedCheckReasons = [ + "graph_unavailable", + "unsupported_scope", + "not_requested", + "no_files", + "provider_failure", +] as const; + +export { validationSkippedCheckReasons }; + +type ValidationSkippedCheckReason = (typeof validationSkippedCheckReasons)[number]; + +export type { ValidationSkippedCheckReason }; + +const validationCheckIdPattern = "^[a-z][a-z0-9]*(?:[._:-][a-z0-9]+)*$" as const; + +export { validationCheckIdPattern }; + +const validationCheckIdRegex = new RegExp(validationCheckIdPattern); + +export { validationCheckIdRegex }; + +const latencyStableIdRegex = /^[a-z][a-z0-9_-]*$/; + +export { latencyStableIdRegex }; + +const latencyTelemetryCommandTokenRegex = /^(?=.*[A-Za-z0-9])[-@A-Za-z0-9._,:=]+$/; + +export { latencyTelemetryCommandTokenRegex }; diff --git a/packages/contracts/src/validation/vocabulary-03.ts b/packages/contracts/src/validation/vocabulary-03.ts new file mode 100644 index 0000000..0fde4d7 --- /dev/null +++ b/packages/contracts/src/validation/vocabulary-03.ts @@ -0,0 +1,7 @@ +const latencyTelemetrySourceFileExtensionRegex = new RegExp( + String.raw`\.(?:[cm]?[tj]sx?|mjs|cjs|jsonl?|rs|pyi?|mdx?|toml|lock|ya?ml|txt|inc|css|s[ac]ss|` + + String.raw`html?|vue|svelte|go|java|rb|php|swift|kts?|scala|lua|cs|c|cc|cpp|h|hpp)(?:$|[,=:])`, + "i", +); + +export { latencyTelemetrySourceFileExtensionRegex }; diff --git a/packages/edit/src/index.ts b/packages/edit/src/index.ts index 100bf1b..cd4b3c1 100644 --- a/packages/edit/src/index.ts +++ b/packages/edit/src/index.ts @@ -10,6 +10,7 @@ export * from "./patch-tree-command.js"; export * from "./path-policy.js"; export * from "./planner.js"; export * from "./language-service.js"; +export * from "./typescript-project/index.js"; export * from "./symbol-command.js"; export * from "./symbol-graph.js"; export * from "./symbol-preview.js"; diff --git a/packages/edit/src/language-service.ts b/packages/edit/src/language-service.ts index 125707a..f6139f9 100644 --- a/packages/edit/src/language-service.ts +++ b/packages/edit/src/language-service.ts @@ -1,11 +1,21 @@ import { existsSync, readFileSync, realpathSync, readdirSync, statSync } from "node:fs"; -import { dirname, extname, isAbsolute, join, relative, resolve } from "node:path"; +import { dirname, extname, join, relative, resolve } from "node:path"; import type { EditRefusal, RepoRelativeChange } from "@the-open-engine/opcore-contracts"; import { decodeTextContent } from "./content-policy.js"; -import { Node, Project, SyntaxKind, ts, type SourceFile, type Symbol as MorphSymbol } from "ts-morph"; +import { Node, SyntaxKind, type Project, type SourceFile, type Symbol as MorphSymbol } from "ts-morph"; import { calculateEditChecksum } from "./hash.js"; import { normalizeEditRepoRelativePath } from "./path-policy.js"; import type { MoveSymbolEditRequest, RenameSymbolEditRequest, SignatureParameterChange, SignatureSymbolEditRequest, SymbolEditTarget } from "./symbol-requests.js"; +import { + TypeScriptProjectService, + defaultTypeScriptProjectExcludedDirectories, + isPathInside as isInside, + isSafeExistingFileInsideRepo, + normalizeModulePath, + type TypeScriptProjectContext, + type TypeScriptProjectOptions, + type TypeScriptProjectScope +} from "./typescript-project/index.js"; export type SymbolMaterializationResult = | { @@ -22,24 +32,36 @@ export interface AffectedChecksum { checksumAfter?: string; } -export type SymbolEditLanguageServiceProjectScope = "import_closure" | "whole_repo"; +export type SymbolEditLanguageServiceProjectScope = TypeScriptProjectScope; -export interface SymbolEditLanguageServiceOptions { - project?: Project; - projectScope?: SymbolEditLanguageServiceProjectScope; - projectTsconfigPath?: string; - snapshotProject?: (project: Project) => unknown; - revertProject?: (project: Project, snapshot: unknown) => void; -} - -const symbolEditProjectScopes = new WeakMap(); +export interface SymbolEditLanguageServiceOptions extends TypeScriptProjectOptions {} const sourceFileExtensions = new Set([".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"]); -const extensionlessImportCandidates = [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".d.ts"] as const; -const excludedDirectories = new Set(["node_modules", "dist", "build", ".git", ".lattice"]); +const symbolEditProjectService = new TypeScriptProjectService({ + sourceExtensions: [...sourceFileExtensions], + extensionlessImportCandidates: [ + ".ts", + ".tsx", + ".js", + ".jsx", + ".mjs", + ".cjs", + ".d.ts" + ], + excludedDirectories: defaultTypeScriptProjectExcludedDirectories, + allowDirectoryRoots: true, + configMode: "project_references", + discoverAllConfigs: true, + tolerateMalformedImportConfig: false, + defaultIncludeDependents: true +}); export function isSupportedSymbolSourcePath(path: string): boolean { - return sourceFileExtensions.has(extname(path).toLowerCase()); + return symbolEditProjectService.isSupportedSourcePath(path); +} + +export function listSymbolEditLanguageServiceSourceFiles(repoRoot: string): string[] { + return symbolEditProjectService.listSourceFiles(repoRoot); } export function materializeRenameSymbolEdit( @@ -160,34 +182,7 @@ export function materializeSignatureSymbolEdit( return mergedChangesResult(changeSets); } -function createProject(repoRoot: string, preferredRepoPath: string | undefined, options: SymbolEditLanguageServiceOptions): ProjectContext { - const rawTsconfigPath = join(repoRoot, "tsconfig.json"); - const preferredAbsolutePath = preferredRepoPath === undefined ? undefined : resolve(repoRoot, preferredRepoPath); - const tsconfigPath = resolveSymbolEditTsconfigPath(repoRoot, options.projectTsconfigPath) ?? resolveTsconfigPath(repoRoot, rawTsconfigPath, preferredAbsolutePath); - const projectScope = options.projectScope ?? "import_closure"; - if (options.project !== undefined && canUseInjectedProject(options.project, projectScope)) { - if (projectScope === "import_closure" && preferredRepoPath !== undefined) { - addScopedSourceFilesToProject(repoRoot, tsconfigPath, options.project, [preferredRepoPath]); - } - return { - project: options.project, - ...(options.projectTsconfigPath ? { tsconfigPath: options.projectTsconfigPath } : {}), - ...(options.snapshotProject ? { snapshotProject: options.snapshotProject } : {}), - ...(options.revertProject ? { revertProject: options.revertProject } : {}) - }; - } - return { - project: createProjectForTsconfig(repoRoot, tsconfigPath, preferredRepoPath, projectScope), - ...(tsconfigPath ? { tsconfigPath } : {}) - }; -} - -type ProjectContext = { - project: Project; - tsconfigPath?: string; - snapshotProject?: (project: Project) => unknown; - revertProject?: (project: Project, snapshot: unknown) => void; -}; +type ProjectContext = TypeScriptProjectContext; function createProjectResult( repoRoot: string, @@ -195,7 +190,15 @@ function createProjectResult( options: SymbolEditLanguageServiceOptions ): { ok: true; value: ProjectContext } | { ok: false; refusal: EditRefusal } { try { - return { ok: true, value: createProject(repoRoot, preferredRepoPath, options) }; + const context = symbolEditProjectService.createContexts( + repoRoot, + preferredRepoPath, + options + )[0]; + if (context === undefined) { + throw new Error("No TypeScript project context was available"); + } + return { ok: true, value: context }; } catch (error) { return projectConfigurationRefusal(error, preferredRepoPath); } @@ -207,36 +210,28 @@ function createProjectContextsResult( options: SymbolEditLanguageServiceOptions ): { ok: true; value: ProjectContext[] } | { ok: false; refusal: EditRefusal } { try { - return { ok: true, value: createProjectContexts(repoRoot, preferredRepoPath, options) }; + return { + ok: true, + value: symbolEditProjectService.createContexts( + repoRoot, + preferredRepoPath, + options + ) + }; } catch (error) { return projectConfigurationRefusal(error, preferredRepoPath); } } -function createProjectContexts(repoRoot: string, preferredRepoPath: string | undefined, options: SymbolEditLanguageServiceOptions): ProjectContext[] { - if (options.project !== undefined) return [createProject(repoRoot, preferredRepoPath, options)]; - const rawTsconfigPath = join(repoRoot, "tsconfig.json"); - const preferredAbsolutePath = preferredRepoPath === undefined ? undefined : resolve(repoRoot, preferredRepoPath); - const preferredTsconfigPath = resolveSymbolEditTsconfigPath(repoRoot, options.projectTsconfigPath) ?? resolveTsconfigPath(repoRoot, rawTsconfigPath, preferredAbsolutePath); - const projectScope = options.projectScope ?? "import_closure"; - const orderedPaths: string[] = []; - if (preferredTsconfigPath !== undefined) orderedPaths.push(resolve(preferredTsconfigPath)); - for (const tsconfigPath of collectProjectTsconfigPaths(repoRoot)) { - if (!orderedPaths.includes(tsconfigPath)) orderedPaths.push(tsconfigPath); - } - if (orderedPaths.length === 0) return [{ project: createProjectForTsconfig(repoRoot, undefined, preferredRepoPath, projectScope) }]; - return orderedPaths.map((tsconfigPath) => ({ - project: createProjectForTsconfig(repoRoot, tsconfigPath, preferredRepoPath, projectScope), - tsconfigPath - })); -} - -function canUseInjectedProject(project: Project, requiredScope: SymbolEditLanguageServiceProjectScope): boolean { - return requiredScope !== "whole_repo" || symbolEditProjectScopes.get(project) === "whole_repo"; -} - -function projectConfigurationRefusal(error: unknown, path?: string): { ok: false; refusal: EditRefusal } { - return refused("unsafe_edit", `TypeScript project configuration cannot be loaded for symbol edit: ${errorMessage(error)}`, path); +function projectConfigurationRefusal( + error: unknown, + path?: string +): { ok: false; refusal: EditRefusal } { + return refused( + "unsafe_edit", + `TypeScript project configuration cannot be loaded for symbol edit: ${errorMessage(error)}`, + path + ); } export function createSymbolEditLanguageServiceProject( @@ -244,351 +239,15 @@ export function createSymbolEditLanguageServiceProject( preferredRepoPath?: string, options: SymbolEditLanguageServiceOptions = {} ): Project { - return createProject(repoRoot, preferredRepoPath, options).project; + return symbolEditProjectService.createProject( + repoRoot, + preferredRepoPath, + options + ); } function withProjectSnapshot(context: ProjectContext, run: () => T): T { - if (context.snapshotProject === undefined || context.revertProject === undefined) return run(); - const snapshot = context.snapshotProject(context.project); - try { - return run(); - } finally { - context.revertProject(context.project, snapshot); - } -} - -function createProjectForTsconfig( - repoRoot: string, - tsconfigPath: string | undefined, - preferredRepoPath: string | undefined, - scope: SymbolEditLanguageServiceProjectScope -): Project { - const project = new Project({ - tsConfigFilePath: tsconfigPath, - skipAddingFilesFromTsConfig: true, - skipFileDependencyResolution: true, - compilerOptions: { - allowJs: true, - checkJs: false - } - }); - const sourceFiles = scope === "whole_repo" || preferredRepoPath === undefined - ? listSourceFiles(repoRoot) - : scopedSourceFiles(repoRoot, tsconfigPath, [preferredRepoPath], { includeDependents: true }); - for (const filePath of sourceFiles) { - if (!project.getSourceFile(filePath)) project.addSourceFileAtPath(filePath); - } - symbolEditProjectScopes.set(project, scope); - return project; -} - -function addScopedSourceFilesToProject( - repoRoot: string, - tsconfigPath: string | undefined, - project: Project, - rootRepoPaths: readonly string[] -): void { - for (const filePath of scopedSourceFiles(repoRoot, tsconfigPath, rootRepoPaths, { includeDependents: true })) { - if (project.getSourceFile(filePath) === undefined) project.addSourceFileAtPath(filePath); - } -} - -function collectProjectTsconfigPaths(repoRoot: string): string[] { - const discovered = new Set(); - const queue = [join(repoRoot, "tsconfig.json"), ...listTsconfigJsonPaths(repoRoot)]; - for (let index = 0; index < queue.length; index += 1) { - const tsconfigPath = resolve(queue[index]); - if (discovered.has(tsconfigPath) || !isSafeExistingFileInsideRepo(repoRoot, tsconfigPath)) continue; - discovered.add(tsconfigPath); - try { - const config = parseTsconfig(tsconfigPath); - for (const referencePath of referencedTsconfigPaths(repoRoot, tsconfigPath, config)) queue.push(referencePath); - } catch { - // Ignore malformed auxiliary configs here; the preferred project path keeps existing behavior. - } - } - return [...discovered].sort(); -} - -function listTsconfigJsonPaths(repoRoot: string): string[] { - const paths: string[] = []; - visit(repoRoot); - return paths.sort(); - - function visit(directory: string): void { - for (const entry of readdirSync(directory, { withFileTypes: true }).sort((left, right) => left.name.localeCompare(right.name))) { - const path = join(directory, entry.name); - if (entry.isDirectory()) { - if (!excludedDirectories.has(entry.name)) visit(path); - } else if (entry.isFile() && entry.name === "tsconfig.json" && isSafeExistingFileInsideRepo(repoRoot, path)) { - paths.push(resolve(path)); - } - } - } -} - -function resolveTsconfigPath(repoRoot: string, tsconfigPath: string, preferredAbsolutePath?: string): string | undefined { - return resolveTsconfigPathInternal(repoRoot, tsconfigPath, preferredAbsolutePath, new Set()); -} - -function resolveTsconfigPathInternal(repoRoot: string, tsconfigPath: string, preferredAbsolutePath: string | undefined, seen: Set): string | undefined { - if (!isSafeExistingFileInsideRepo(repoRoot, tsconfigPath)) return undefined; - const normalizedTsconfigPath = resolve(tsconfigPath); - if (seen.has(normalizedTsconfigPath)) return undefined; - seen.add(normalizedTsconfigPath); - try { - const config = parseTsconfig(tsconfigPath); - const referenceOnly = Array.isArray(config.references) && config.references.length > 0 && Array.isArray(config.files) && config.files.length === 0 && config.include === undefined; - if (!referenceOnly) return tsconfigPath; - const candidates = referencedTsconfigPaths(repoRoot, tsconfigPath, config); - if (preferredAbsolutePath !== undefined) { - const ownedCandidates = candidates - .flatMap((candidate) => { - const resolvedCandidate = resolveTsconfigPathInternal(repoRoot, candidate, preferredAbsolutePath, new Set(seen)); - return resolvedCandidate === undefined ? [] : [resolvedCandidate]; - }) - .filter((candidate, index, values) => values.indexOf(candidate) === index) - .filter((candidate) => isInside(dirname(candidate), preferredAbsolutePath)) - .sort((left, right) => dirname(right).length - dirname(left).length || left.localeCompare(right)); - if (ownedCandidates.length > 0) return ownedCandidates[0]; - } - return candidates[0] ?? tsconfigPath; - } catch { - return tsconfigPath; - } -} - -type TsconfigJson = { - references?: readonly { path?: string }[]; - files?: readonly string[]; - include?: readonly string[]; - compilerOptions?: { - baseUrl?: unknown; - paths?: unknown; - }; -}; - -function parseTsconfig(tsconfigPath: string): TsconfigJson { - const parsed = ts.parseConfigFileTextToJson(tsconfigPath, readFileSync(tsconfigPath, "utf8")); - if (parsed.error !== undefined) throw new Error(ts.flattenDiagnosticMessageText(parsed.error.messageText, "\n")); - return parsed.config as TsconfigJson; -} - -function referencedTsconfigPaths(repoRoot: string, tsconfigPath: string, config: TsconfigJson): string[] { - const candidates: string[] = []; - for (const reference of config.references ?? []) { - if (!reference.path) continue; - const candidate = reference.path.endsWith(".json") - ? resolve(dirname(tsconfigPath), reference.path) - : resolve(dirname(tsconfigPath), reference.path, "tsconfig.json"); - if (isSafeExistingFileInsideRepo(repoRoot, candidate)) candidates.push(candidate); - } - return candidates; -} - -interface ImportResolutionOptions { - baseUrl: string; - hasBaseUrl: boolean; - paths: Readonly>; -} - -function scopedSourceFiles( - repoRoot: string, - tsconfigPath: string | undefined, - rootRepoPaths: readonly string[], - options: { includeDependents: boolean } -): string[] { - const importOptions = importResolutionOptions(repoRoot, tsconfigPath); - const importTargetsByFile = new Map(); - const allSourceFiles = options.includeDependents ? listSourceFiles(repoRoot) : []; - const roots = rootSourceFiles(repoRoot, rootRepoPaths); - const reverseTargets = new Set(roots); - const selected = new Set(); - addForwardClosure(roots, selected); - - if (options.includeDependents) { - let changed = true; - while (changed) { - changed = false; - for (const filePath of allSourceFiles) { - if (selected.has(filePath)) continue; - const importsReverseTarget = importTargets(filePath).some((importedPath) => reverseTargets.has(importedPath)); - if (!importsReverseTarget) continue; - const beforeSize = selected.size; - addForwardClosure([filePath], selected); - reverseTargets.add(filePath); - if (selected.size !== beforeSize) changed = true; - } - } - } - - return [...selected].sort(); - - function addForwardClosure(rootFiles: readonly string[], selectedFiles: Set): void { - const pending = [...rootFiles].sort(); - for (let index = 0; index < pending.length; index += 1) { - const filePath = pending[index]; - if (selectedFiles.has(filePath)) continue; - selectedFiles.add(filePath); - for (const importedPath of importTargets(filePath)) { - if (!selectedFiles.has(importedPath) && !pending.includes(importedPath)) pending.push(importedPath); - } - } - } - - function importTargets(filePath: string): readonly string[] { - const cached = importTargetsByFile.get(filePath); - if (cached !== undefined) return cached; - const resolvedTargets = moduleImportSpecifiers(readFileSync(filePath, "utf8")) - .flatMap((specifier) => { - const resolvedImport = resolveImportSpecifier(repoRoot, filePath, specifier, importOptions); - return resolvedImport === undefined ? [] : [resolvedImport]; - }) - .sort(); - importTargetsByFile.set(filePath, resolvedTargets); - return resolvedTargets; - } -} - -function rootSourceFiles(repoRoot: string, rootRepoPaths: readonly string[]): string[] { - const files: string[] = []; - for (const rootRepoPath of rootRepoPaths) { - const absolutePath = resolve(repoRoot, rootRepoPath); - if (isSupportedSymbolSourcePath(absolutePath) && isSafeExistingFileInsideRepo(repoRoot, absolutePath)) { - files.push(resolve(absolutePath)); - continue; - } - if (!isSafeExistingDirectoryInsideRepo(repoRoot, absolutePath)) continue; - const directoryRoot = resolve(absolutePath); - files.push(...listSourceFiles(repoRoot).filter((filePath) => isInside(directoryRoot, filePath))); - } - return uniqueSorted(files); -} - -function moduleImportSpecifiers(text: string): readonly string[] { - const specifiers = new Set(); - for (const match of text.matchAll(/\b(?:import|export)\s+(?:type\s+)?(?:[^"'`;]*?\s+from\s+)?["']([^"']+)["']/gu)) { - if (match[1]) specifiers.add(match[1]); - } - for (const match of text.matchAll(/ `${basePath}${candidateExtension}`), - ...extensionlessImportCandidates.map((candidateExtension) => join(basePath, `index${candidateExtension}`)) - ]); -} - -function importResolutionOptions(repoRoot: string, tsconfigPath: string | undefined): ImportResolutionOptions { - const configDirectory = tsconfigPath === undefined ? repoRoot : dirname(tsconfigPath); - const config = tsconfigPath === undefined ? undefined : parseTsconfig(tsconfigPath); - const compilerOptions = config?.compilerOptions; - const baseUrl = typeof compilerOptions?.baseUrl === "string" && compilerOptions.baseUrl.length > 0 - ? resolve(configDirectory, compilerOptions.baseUrl) - : configDirectory; - return { - baseUrl, - hasBaseUrl: typeof compilerOptions?.baseUrl === "string" && compilerOptions.baseUrl.length > 0, - paths: normalizePathMappings(compilerOptions?.paths) - }; -} - -function normalizePathMappings(paths: unknown): Readonly> { - if (paths === null || typeof paths !== "object" || Array.isArray(paths)) return {}; - const normalized: Record = {}; - for (const [pattern, targets] of Object.entries(paths)) { - if (Array.isArray(targets)) normalized[pattern] = targets.filter((target): target is string => typeof target === "string"); - } - return normalized; -} - -function sortedPathMappings(paths: Readonly>): readonly [string, readonly string[]][] { - return Object.entries(paths) - .filter((entry): entry is [string, readonly string[]] => entry[1].length > 0) - .sort((left, right) => pathPatternRank(right[0]) - pathPatternRank(left[0])); -} - -function pathPatternRank(pattern: string): number { - const starIndex = pattern.indexOf("*"); - if (starIndex === -1) return pattern.length * 2 + 1; - return pattern.length - 1; -} - -function matchPathPattern(pattern: string, specifier: string): string | undefined { - const starIndex = pattern.indexOf("*"); - if (starIndex === -1) return pattern === specifier ? "" : undefined; - const prefix = pattern.slice(0, starIndex); - const suffix = pattern.slice(starIndex + 1); - if (!specifier.startsWith(prefix) || !specifier.endsWith(suffix)) return undefined; - return specifier.slice(prefix.length, specifier.length - suffix.length); -} - -function applyPathMappingTarget(target: string, wildcard: string): string { - return target.includes("*") ? target.replaceAll("*", wildcard) : target; -} - -function listSourceFiles(repoRoot: string): string[] { - const files: string[] = []; - visit(repoRoot); - return files.sort(); - - function visit(directory: string): void { - for (const entry of readdirSync(directory, { withFileTypes: true }).sort((left, right) => left.name.localeCompare(right.name))) { - const path = join(directory, entry.name); - if (entry.isDirectory()) { - if (!excludedDirectories.has(entry.name)) visit(path); - } else if (entry.isFile() && isSupportedSymbolSourcePath(path)) { - const repoPath = normalizeModulePath(relative(repoRoot, path)); - if (validateExistingPathInsideRepoSync(repoRoot, path, repoPath, "Source file", "file").ok) files.push(path); - } - } - } -} - -function resolveSymbolEditTsconfigPath(repoRoot: string, tsconfigPath: string | undefined): string | undefined { - if (tsconfigPath === undefined) return undefined; - const absolutePath = resolve(repoRoot, tsconfigPath); - return isSafeExistingFileInsideRepo(repoRoot, absolutePath) ? absolutePath : undefined; + return symbolEditProjectService.withSnapshot(context, run); } function sourceFileForTarget(project: Project, repoRoot: string, repoPath: string): { ok: true; value: SourceFile } | { ok: false; refusal: EditRefusal } { @@ -1052,24 +711,6 @@ function toRepoPath(repoRoot: string, absolutePath: string): { ok: true; value: return normalized.ok ? { ok: true, value: normalized.value } : normalized; } -function isSafeExistingFileInsideRepo(repoRoot: string, absolutePath: string): boolean { - if (!isInside(repoRoot, absolutePath) || !existsSync(absolutePath)) return false; - try { - return isInside(repoRoot, realpathSync(absolutePath)) && statSync(absolutePath).isFile(); - } catch { - return false; - } -} - -function isSafeExistingDirectoryInsideRepo(repoRoot: string, absolutePath: string): boolean { - if (!isInside(repoRoot, absolutePath) || !existsSync(absolutePath)) return false; - try { - return isInside(repoRoot, realpathSync(absolutePath)) && statSync(absolutePath).isDirectory(); - } catch { - return false; - } -} - function validateExistingPathInsideRepoSync( repoRoot: string, absolutePath: string, @@ -1101,47 +742,11 @@ function requireSupportedSource(path: string): { ok: true } | { ok: false; refus return isSupportedSymbolSourcePath(path) ? { ok: true } : refused("unsupported_change", `Unsupported symbol edit language for ${path}`, path); } -function normalizeModulePath(path: string): string { - return path.replaceAll("\\", "/"); -} - -function isRepoResolvableSpecifier(specifier: string): boolean { - return specifier.length > 0 && !specifier.startsWith("/") && !specifier.includes("://"); -} - -function isRelativeSpecifier(specifier: string): boolean { - return specifier.startsWith("./") || specifier.startsWith("../"); -} - -function sourceExtension(path: string): string | undefined { - if (path.endsWith(".d.ts")) return ".d.ts"; - const match = /\.[^./]+$/u.exec(path); - return match?.[0]; -} - -function replaceImportExtension(path: string, extension: string): string { - if (path.endsWith(".d.ts")) return `${path.slice(0, -".d.ts".length)}${extension}`; - return path.replace(/\.[^./]+$/u, extension); -} - -function uniqueSorted(values: readonly string[]): string[] { - return [...new Set(values)].sort(); -} - -function unique(values: readonly string[]): readonly string[] { - return [...new Set(values)]; -} - function withExtension(filePath: string, extension: string): string { const current = extname(filePath); return current.length === 0 ? `${filePath}${extension}` : `${filePath.slice(0, -current.length)}${extension}`; } -function isInside(parentPath: string, childPath: string): boolean { - const relativePath = relative(resolve(parentPath), resolve(childPath)); - return relativePath === "" || (!relativePath.startsWith("..") && !isAbsolute(relativePath)); -} - function compareSourceMoves(left: SourceFileMove, right: SourceFileMove): number { return left.fromPath.localeCompare(right.fromPath) || left.toPath.localeCompare(right.toPath); } diff --git a/packages/edit/src/path-policy.ts b/packages/edit/src/path-policy.ts index 3f96854..79511e0 100644 --- a/packages/edit/src/path-policy.ts +++ b/packages/edit/src/path-policy.ts @@ -32,15 +32,11 @@ export interface ValidatedCreatePath { const patchTreeForbiddenRoots = [ ".git", - ".ace", ".agents", ".claude", ".codex", ".gemini", ".opencode", - ".code-review-graph", - ".rox-cache", - ".robustness-engine-cache", ".opcore/graph", "target", "node_modules" diff --git a/packages/edit/src/typescript-project/filesystem-discovery.ts b/packages/edit/src/typescript-project/filesystem-discovery.ts new file mode 100644 index 0000000..ab27941 --- /dev/null +++ b/packages/edit/src/typescript-project/filesystem-discovery.ts @@ -0,0 +1,30 @@ +import { readdirSync } from "node:fs"; +import { join, resolve } from "node:path"; +import { isSafeExistingFileInsideRepo } from "./path-policy.js"; + +export function discoverRepoFiles( + repoRoot: string, + excludedDirectories: ReadonlySet, + include: (path: string, name: string) => boolean +): string[] { + const files: string[] = []; + visit(repoRoot); + return files.sort(); + + function visit(directory: string): void { + const entries = readdirSync(directory, { withFileTypes: true }) + .sort((left, right) => left.name.localeCompare(right.name)); + for (const entry of entries) { + const path = join(directory, entry.name); + if (entry.isDirectory()) { + if (!excludedDirectories.has(entry.name)) visit(path); + } else if ( + entry.isFile() && + include(path, entry.name) && + isSafeExistingFileInsideRepo(repoRoot, path) + ) { + files.push(resolve(path)); + } + } + } +} diff --git a/packages/edit/src/typescript-project/import-resolution.ts b/packages/edit/src/typescript-project/import-resolution.ts new file mode 100644 index 0000000..7a51353 --- /dev/null +++ b/packages/edit/src/typescript-project/import-resolution.ts @@ -0,0 +1,232 @@ +import { readFileSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { + isSafeExistingFileInsideRepo, + replaceImportExtension, + sourceExtension, + uniqueValues +} from "./path-policy.js"; +import { TypeScriptConfigService } from "./tsconfig.js"; +import type { TypeScriptProjectProfile } from "./types.js"; + +interface ImportResolverRequest { + repoRoot: string; + tsconfigPath: string | undefined; + profile: TypeScriptProjectProfile; +} + +interface ImportResolutionOptions { + baseUrl: string; + hasBaseUrl: boolean; + paths: Readonly>; +} + +export class TypeScriptImportResolver { + private readonly options: ImportResolutionOptions; + private readonly configService: TypeScriptConfigService; + + constructor(private readonly request: ImportResolverRequest) { + this.configService = new TypeScriptConfigService(request.profile); + this.options = this.readOptions(); + } + + resolve(filePath: string): readonly string[] { + return this.moduleSpecifiers(readFileSync(filePath, "utf8")) + .flatMap((specifier) => { + const resolved = this.resolveSpecifier(filePath, specifier); + return resolved === undefined ? [] : [resolved]; + }) + .sort(); + } + + private moduleSpecifiers(text: string): readonly string[] { + const specifiers = new Set(); + const staticPattern = /\b(?:import|export)\s+(?:type\s+)?(?:[^"'`;]*?\s+from\s+)?["']([^"']+)["']/gu; + const referencePattern = / + this.isRepoResolvable(specifier) + ).sort(); + } + + private collectMatches( + specifiers: Set, + text: string, + pattern: RegExp + ): void { + for (const match of text.matchAll(pattern)) { + if (match[1]) specifiers.add(match[1]); + } + } + + private resolveSpecifier( + fromPath: string, + specifier: string + ): string | undefined { + if (this.isRelative(specifier)) { + return this.resolveModulePath( + resolve(dirname(fromPath), specifier) + ); + } + const mapped = this.resolveMapped(specifier); + if (mapped !== undefined) return mapped; + return this.options.hasBaseUrl + ? this.resolveModulePath(resolve(this.options.baseUrl, specifier)) + : undefined; + } + + private resolveMapped(specifier: string): string | undefined { + for (const [pattern, targets] of this.sortedPathMappings()) { + const wildcard = this.matchPathPattern(pattern, specifier); + if (wildcard === undefined) continue; + for (const target of targets) { + const candidate = resolve( + this.options.baseUrl, + this.applyPathMappingTarget(target, wildcard) + ); + const resolved = this.resolveModulePath(candidate); + if (resolved !== undefined) return resolved; + } + } + return undefined; + } + + private resolveModulePath(basePath: string): string | undefined { + for (const candidate of this.modulePathCandidates(basePath)) { + if ( + this.request.profile.sourceExtensions.includes( + sourceExtension(candidate) ?? "" + ) && + isSafeExistingFileInsideRepo(this.request.repoRoot, candidate) + ) { + return resolve(candidate); + } + } + return undefined; + } + + private modulePathCandidates(basePath: string): readonly string[] { + const extension = sourceExtension(basePath); + if (extension === ".js" || extension === ".jsx") { + return this.javascriptImportCandidates(basePath, extension); + } + if (extension !== undefined) return [basePath]; + return uniqueValues([ + ...this.request.profile.extensionlessImportCandidates.map( + (candidate) => `${basePath}${candidate}` + ), + ...this.request.profile.extensionlessImportCandidates.map( + (candidate) => join(basePath, `index${candidate}`) + ) + ]); + } + + private javascriptImportCandidates( + basePath: string, + extension: ".js" | ".jsx" + ): readonly string[] { + const typeScriptCandidates = extension === ".jsx" + ? [replaceImportExtension(basePath, ".tsx"), replaceImportExtension(basePath, ".ts")] + : [replaceImportExtension(basePath, ".ts"), replaceImportExtension(basePath, ".tsx")]; + return uniqueValues([ + ...typeScriptCandidates, + replaceImportExtension(basePath, ".d.ts"), + basePath, + replaceImportExtension(basePath, extension === ".js" ? ".jsx" : ".js") + ]); + } + + private readOptions(): ImportResolutionOptions { + const configDirectory = this.request.tsconfigPath === undefined + ? this.request.repoRoot + : dirname(this.request.tsconfigPath); + const config = this.readConfig(); + const compilerOptions = config?.compilerOptions; + const configuredBaseUrl = compilerOptions?.baseUrl; + const hasBaseUrl = + typeof configuredBaseUrl === "string" && configuredBaseUrl.length > 0; + return { + baseUrl: hasBaseUrl + ? resolve(configDirectory, configuredBaseUrl) + : configDirectory, + hasBaseUrl, + paths: this.normalizePathMappings(compilerOptions?.paths) + }; + } + + private readConfig() { + if (this.request.tsconfigPath === undefined) return undefined; + try { + return this.configService.parse(this.request.tsconfigPath); + } catch (error) { + if (!this.request.profile.tolerateMalformedImportConfig) throw error; + return undefined; + } + } + + private normalizePathMappings( + paths: unknown + ): Readonly> { + if (paths === null || typeof paths !== "object" || Array.isArray(paths)) { + return {}; + } + const normalized: Record = {}; + for (const [pattern, targets] of Object.entries(paths)) { + if (Array.isArray(targets)) { + normalized[pattern] = targets.filter( + (target): target is string => typeof target === "string" + ); + } + } + return normalized; + } + + private sortedPathMappings(): readonly [string, readonly string[]][] { + return Object.entries(this.options.paths) + .filter( + (entry): entry is [string, readonly string[]] => entry[1].length > 0 + ) + .sort( + (left, right) => + this.pathPatternRank(right[0]) - this.pathPatternRank(left[0]) + ); + } + + private pathPatternRank(pattern: string): number { + const starIndex = pattern.indexOf("*"); + return starIndex === -1 ? pattern.length * 2 + 1 : pattern.length - 1; + } + + private matchPathPattern( + pattern: string, + specifier: string + ): string | undefined { + const starIndex = pattern.indexOf("*"); + if (starIndex === -1) return pattern === specifier ? "" : undefined; + const prefix = pattern.slice(0, starIndex); + const suffix = pattern.slice(starIndex + 1); + if (!specifier.startsWith(prefix) || !specifier.endsWith(suffix)) { + return undefined; + } + return specifier.slice(prefix.length, specifier.length - suffix.length); + } + + private applyPathMappingTarget(target: string, wildcard: string): string { + return target.includes("*") ? target.replaceAll("*", wildcard) : target; + } + + private isRepoResolvable(specifier: string): boolean { + return ( + specifier.length > 0 && + !specifier.startsWith("/") && + !specifier.includes("://") + ); + } + + private isRelative(specifier: string): boolean { + return specifier.startsWith("./") || specifier.startsWith("../"); + } +} diff --git a/packages/edit/src/typescript-project/index.ts b/packages/edit/src/typescript-project/index.ts new file mode 100644 index 0000000..94a5ec1 --- /dev/null +++ b/packages/edit/src/typescript-project/index.ts @@ -0,0 +1,4 @@ +export * from "./path-policy.js"; +export * from "./project-service.js"; +export * from "./source-discovery.js"; +export * from "./types.js"; diff --git a/packages/edit/src/typescript-project/path-policy.ts b/packages/edit/src/typescript-project/path-policy.ts new file mode 100644 index 0000000..44fb324 --- /dev/null +++ b/packages/edit/src/typescript-project/path-policy.ts @@ -0,0 +1,71 @@ +import { existsSync, realpathSync, statSync } from "node:fs"; +import { isAbsolute, relative, resolve } from "node:path"; + +export function isPathInside(parentPath: string, childPath: string): boolean { + const childRelativePath = relative(resolve(parentPath), resolve(childPath)); + return ( + childRelativePath === "" || + (!childRelativePath.startsWith("..") && !isAbsolute(childRelativePath)) + ); +} + +export function isSafeExistingFileInsideRepo( + repoRoot: string, + absolutePath: string +): boolean { + if (!isPathInside(repoRoot, absolutePath) || !existsSync(absolutePath)) { + return false; + } + try { + return ( + isPathInside(repoRoot, realpathSync(absolutePath)) && + statSync(absolutePath).isFile() + ); + } catch { + return false; + } +} + +export function isSafeExistingDirectoryInsideRepo( + repoRoot: string, + absolutePath: string +): boolean { + if (!isPathInside(repoRoot, absolutePath) || !existsSync(absolutePath)) { + return false; + } + try { + return ( + isPathInside(repoRoot, realpathSync(absolutePath)) && + statSync(absolutePath).isDirectory() + ); + } catch { + return false; + } +} + +export function normalizeModulePath(path: string): string { + return path.replaceAll("\\", "/"); +} + +export function sourceExtension(path: string): string | undefined { + if (path.endsWith(".d.ts")) return ".d.ts"; + return /\.[^./]+$/u.exec(path)?.[0]; +} + +export function replaceImportExtension( + path: string, + extension: string +): string { + if (path.endsWith(".d.ts")) { + return `${path.slice(0, -".d.ts".length)}${extension}`; + } + return path.replace(/\.[^./]+$/u, extension); +} + +export function uniqueValues(values: readonly T[]): readonly T[] { + return [...new Set(values)]; +} + +export function uniqueSortedValues(values: readonly string[]): string[] { + return [...new Set(values)].sort(); +} diff --git a/packages/edit/src/typescript-project/project-service.ts b/packages/edit/src/typescript-project/project-service.ts new file mode 100644 index 0000000..29402a2 --- /dev/null +++ b/packages/edit/src/typescript-project/project-service.ts @@ -0,0 +1,223 @@ +import { resolve } from "node:path"; +import { Project } from "ts-morph"; +import { TypeScriptSourceService } from "./source-discovery.js"; +import { TypeScriptConfigService } from "./tsconfig.js"; +import type { + TypeScriptProjectContext, + TypeScriptProjectOptions, + TypeScriptProjectProfile, + TypeScriptProjectScope +} from "./types.js"; + +const projectScopes = new WeakMap(); + +export class TypeScriptProjectService { + private readonly configService: TypeScriptConfigService; + private readonly sourceService: TypeScriptSourceService; + + constructor(private readonly profile: TypeScriptProjectProfile) { + this.configService = new TypeScriptConfigService(profile); + this.sourceService = new TypeScriptSourceService(profile); + } + + isSupportedSourcePath(path: string): boolean { + return this.sourceService.isSupported(path); + } + + listSourceFiles(repoRoot: string): string[] { + return this.sourceService.list(repoRoot); + } + + createProject( + repoRoot: string, + preferredRepoPath: string | undefined, + options: TypeScriptProjectOptions = {} + ): Project { + return this.createContext(repoRoot, preferredRepoPath, options).project; + } + + createContexts( + repoRoot: string, + preferredRepoPath: string | undefined, + options: TypeScriptProjectOptions = {} + ): TypeScriptProjectContext[] { + if (options.project !== undefined) { + return [this.createContext(repoRoot, preferredRepoPath, options)]; + } + const preferredConfig = this.preferredConfig( + repoRoot, + preferredRepoPath, + options + ); + const configPaths = this.contextConfigPaths(preferredConfig, repoRoot); + if (configPaths.length === 0) { + return [ + this.buildContext(repoRoot, preferredRepoPath, undefined, options) + ]; + } + return configPaths.map((configPath) => + this.buildContext(repoRoot, preferredRepoPath, configPath, options) + ); + } + + withSnapshot(context: TypeScriptProjectContext, run: () => T): T { + if ( + context.snapshotProject === undefined || + context.revertProject === undefined + ) { + return run(); + } + const snapshot = context.snapshotProject(context.project); + try { + return run(); + } finally { + context.revertProject(context.project, snapshot); + } + } + + private createContext( + repoRoot: string, + preferredRepoPath: string | undefined, + options: TypeScriptProjectOptions + ): TypeScriptProjectContext { + const scope = projectScope(options); + const configPath = this.preferredConfig( + repoRoot, + preferredRepoPath, + options + ); + if ( + options.project !== undefined && + this.canUseInjectedProject(options.project, scope) + ) { + this.expandInjectedProject( + repoRoot, + preferredRepoPath, + configPath, + options + ); + return { + project: options.project, + ...(configPath ? { tsconfigPath: configPath } : {}), + ...(options.snapshotProject + ? { snapshotProject: options.snapshotProject } + : {}), + ...(options.revertProject + ? { revertProject: options.revertProject } + : {}) + }; + } + return this.buildContext( + repoRoot, + preferredRepoPath, + configPath, + options + ); + } + + private buildContext( + repoRoot: string, + preferredRepoPath: string | undefined, + configPath: string | undefined, + options: TypeScriptProjectOptions + ): TypeScriptProjectContext { + const scope = projectScope(options); + const project = new Project({ + tsConfigFilePath: configPath, + skipAddingFilesFromTsConfig: true, + skipFileDependencyResolution: true, + compilerOptions: { allowJs: true, checkJs: false } + }); + const sourceFiles = + scope === "whole_repo" || preferredRepoPath === undefined + ? this.sourceService.list(repoRoot) + : this.sourceService.scoped({ + repoRoot, + tsconfigPath: configPath, + rootRepoPaths: [preferredRepoPath], + includeDependents: + options.includeDependents ?? + this.profile.defaultIncludeDependents + }); + addSourceFiles(project, sourceFiles); + projectScopes.set(project, scope); + return { + project, + ...(configPath ? { tsconfigPath: configPath } : {}) + }; + } + + private expandInjectedProject( + repoRoot: string, + preferredRepoPath: string | undefined, + configPath: string | undefined, + options: TypeScriptProjectOptions + ): void { + if ( + projectScope(options) !== "import_closure" || + preferredRepoPath === undefined || + options.project === undefined + ) { + return; + } + const sourceFiles = this.sourceService.scoped({ + repoRoot, + tsconfigPath: configPath, + rootRepoPaths: [preferredRepoPath], + includeDependents: + options.includeDependents ?? this.profile.defaultIncludeDependents + }); + addSourceFiles(options.project, sourceFiles); + } + + private preferredConfig( + repoRoot: string, + preferredRepoPath: string | undefined, + options: TypeScriptProjectOptions + ): string | undefined { + return this.configService.preferred( + repoRoot, + preferredRepoPath, + options.projectTsconfigPath + ); + } + + private contextConfigPaths( + preferredConfig: string | undefined, + repoRoot: string + ): string[] { + if (!this.profile.discoverAllConfigs) { + return preferredConfig === undefined ? [] : [preferredConfig]; + } + const ordered: string[] = []; + if (preferredConfig !== undefined) ordered.push(resolve(preferredConfig)); + for (const configPath of this.configService.collect(repoRoot)) { + if (!ordered.includes(configPath)) ordered.push(configPath); + } + return ordered; + } + + private canUseInjectedProject( + project: Project, + requiredScope: TypeScriptProjectScope + ): boolean { + return ( + requiredScope !== "whole_repo" || + projectScopes.get(project) === "whole_repo" + ); + } +} + +function addSourceFiles(project: Project, filePaths: readonly string[]): void { + for (const filePath of filePaths) { + if (project.getSourceFile(filePath) === undefined) { + project.addSourceFileAtPath(filePath); + } + } +} + +function projectScope(options: TypeScriptProjectOptions): TypeScriptProjectScope { + return options.projectScope === undefined + ? "import_closure" + : options.projectScope; +} diff --git a/packages/edit/src/typescript-project/source-discovery.ts b/packages/edit/src/typescript-project/source-discovery.ts new file mode 100644 index 0000000..a301852 --- /dev/null +++ b/packages/edit/src/typescript-project/source-discovery.ts @@ -0,0 +1,130 @@ +import { extname, resolve } from "node:path"; +import { discoverRepoFiles } from "./filesystem-discovery.js"; +import { TypeScriptImportResolver } from "./import-resolution.js"; +import { + isPathInside, + isSafeExistingDirectoryInsideRepo, + isSafeExistingFileInsideRepo, + uniqueSortedValues +} from "./path-policy.js"; +import type { TypeScriptProjectProfile } from "./types.js"; + +export interface SourceClosureRequest { + repoRoot: string; + tsconfigPath: string | undefined; + rootRepoPaths: readonly string[]; + includeDependents: boolean; +} + +export class TypeScriptSourceService { + constructor(private readonly profile: TypeScriptProjectProfile) {} + + isSupported(path: string): boolean { + return this.profile.sourceExtensions.includes(extname(path).toLowerCase()); + } + + list(repoRoot: string): string[] { + return discoverRepoFiles( + repoRoot, + this.profile.excludedDirectories, + (path) => this.isSupported(path) + ); + } + + scoped(request: SourceClosureRequest): string[] { + return new SourceClosure(this, this.profile, request).collect(); + } + + rootFiles(request: SourceClosureRequest): string[] { + const files: string[] = []; + for (const rootRepoPath of request.rootRepoPaths) { + const absolutePath = resolve(request.repoRoot, rootRepoPath); + if ( + this.isSupported(absolutePath) && + isSafeExistingFileInsideRepo(request.repoRoot, absolutePath) + ) { + files.push(resolve(absolutePath)); + } else if ( + this.profile.allowDirectoryRoots && + isSafeExistingDirectoryInsideRepo(request.repoRoot, absolutePath) + ) { + files.push( + ...this.list(request.repoRoot) + .filter((filePath) => isPathInside(absolutePath, filePath)) + ); + } + } + return uniqueSortedValues(files); + } + +} + +class SourceClosure { + private readonly resolver: TypeScriptImportResolver; + private readonly importsByFile = new Map(); + private readonly selected = new Set(); + private readonly reverseTargets: Set; + private readonly allSourceFiles: readonly string[]; + + constructor( + private readonly sourceService: TypeScriptSourceService, + profile: TypeScriptProjectProfile, + private readonly request: SourceClosureRequest + ) { + this.resolver = new TypeScriptImportResolver({ + repoRoot: request.repoRoot, + tsconfigPath: request.tsconfigPath, + profile + }); + const roots = sourceService.rootFiles(request); + this.reverseTargets = new Set(roots); + this.allSourceFiles = request.includeDependents + ? sourceService.list(request.repoRoot) + : []; + this.addForwardClosure(roots); + } + + collect(): string[] { + if (this.request.includeDependents) this.addDependents(); + return [...this.selected].sort(); + } + + private addDependents(): void { + let changed = true; + while (changed) { + changed = false; + for (const filePath of this.allSourceFiles) { + if (this.selected.has(filePath)) continue; + const importsSelected = this.imports(filePath) + .some((target) => this.reverseTargets.has(target)); + if (!importsSelected) continue; + const sizeBefore = this.selected.size; + this.addForwardClosure([filePath]); + this.reverseTargets.add(filePath); + if (this.selected.size !== sizeBefore) changed = true; + } + } + } + + private addForwardClosure(rootFiles: readonly string[]): void { + const pending = [...rootFiles].sort(); + for (let index = 0; index < pending.length; index += 1) { + const filePath = pending[index]; + if (this.selected.has(filePath)) continue; + this.selected.add(filePath); + for (const importedPath of this.imports(filePath)) { + if (!this.selected.has(importedPath) && !pending.includes(importedPath)) { + pending.push(importedPath); + } + } + } + } + + private imports(filePath: string): readonly string[] { + const cached = this.importsByFile.get(filePath); + if (cached !== undefined) return cached; + const imports = this.resolver.resolve(filePath); + this.importsByFile.set(filePath, imports); + return imports; + } +} diff --git a/packages/edit/src/typescript-project/tsconfig.ts b/packages/edit/src/typescript-project/tsconfig.ts new file mode 100644 index 0000000..02b2342 --- /dev/null +++ b/packages/edit/src/typescript-project/tsconfig.ts @@ -0,0 +1,197 @@ +import { readFileSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { ts } from "ts-morph"; +import { discoverRepoFiles } from "./filesystem-discovery.js"; +import { + isPathInside, + isSafeExistingFileInsideRepo +} from "./path-policy.js"; +import type { TypeScriptProjectProfile } from "./types.js"; + +export interface TypeScriptConfigJson { + references?: readonly { path?: string }[]; + files?: readonly string[]; + include?: readonly string[]; + compilerOptions?: { + baseUrl?: unknown; + paths?: unknown; + }; +} + +export class TypeScriptConfigService { + constructor(private readonly profile: TypeScriptProjectProfile) {} + + preferred( + repoRoot: string, + preferredRepoPath: string | undefined, + explicitPath: string | undefined + ): string | undefined { + const explicit = this.resolveExplicit(repoRoot, explicitPath); + if (explicit !== undefined) return explicit; + const rootConfig = join(repoRoot, "tsconfig.json"); + if (this.profile.configMode === "root") { + return isSafeExistingFileInsideRepo(repoRoot, rootConfig) + ? resolve(rootConfig) + : undefined; + } + const preferredAbsolutePath = preferredRepoPath === undefined + ? undefined + : resolve(repoRoot, preferredRepoPath); + return this.resolveReferenceAware( + repoRoot, + rootConfig, + preferredAbsolutePath, + new Set() + ); + } + + collect(repoRoot: string): string[] { + const discovered = new Set(); + const queue = [ + join(repoRoot, "tsconfig.json"), + ...this.list(repoRoot) + ]; + for (let index = 0; index < queue.length; index += 1) { + const configPath = resolve(queue[index]); + if ( + discovered.has(configPath) || + !isSafeExistingFileInsideRepo(repoRoot, configPath) + ) { + continue; + } + discovered.add(configPath); + this.appendReferences(repoRoot, configPath, queue); + } + return [...discovered].sort(); + } + + parse(configPath: string): TypeScriptConfigJson { + const parsed = ts.parseConfigFileTextToJson( + configPath, + readFileSync(configPath, "utf8") + ); + if (parsed.error !== undefined) { + throw new Error( + ts.flattenDiagnosticMessageText(parsed.error.messageText, "\n") + ); + } + return parsed.config as TypeScriptConfigJson; + } + + private resolveExplicit( + repoRoot: string, + configPath: string | undefined + ): string | undefined { + if (configPath === undefined) return undefined; + const absolutePath = resolve(repoRoot, configPath); + return isSafeExistingFileInsideRepo(repoRoot, absolutePath) + ? absolutePath + : undefined; + } + + private resolveReferenceAware( + repoRoot: string, + configPath: string, + preferredAbsolutePath: string | undefined, + seen: Set + ): string | undefined { + if (!isSafeExistingFileInsideRepo(repoRoot, configPath)) return undefined; + const normalizedPath = resolve(configPath); + if (seen.has(normalizedPath)) return undefined; + seen.add(normalizedPath); + try { + const config = this.parse(configPath); + if (!this.isReferenceOnly(config)) return configPath; + const candidates = this.referencedPaths(repoRoot, configPath, config); + const selected = this.selectReference( + repoRoot, + candidates, + preferredAbsolutePath, + seen + ); + if (selected !== undefined) return selected; + if (candidates[0] !== undefined) return candidates[0]; + return configPath; + } catch { + return configPath; + } + } + + private selectReference( + repoRoot: string, + candidates: readonly string[], + preferredAbsolutePath: string | undefined, + seen: ReadonlySet + ): string | undefined { + if (preferredAbsolutePath === undefined) return undefined; + return candidates + .flatMap((candidate) => { + const resolved = this.resolveReferenceAware( + repoRoot, + candidate, + preferredAbsolutePath, + new Set(seen) + ); + return resolved === undefined ? [] : [resolved]; + }) + .filter((candidate, index, values) => values.indexOf(candidate) === index) + .filter((candidate) => + isPathInside(dirname(candidate), preferredAbsolutePath) + ) + .sort( + (left, right) => + dirname(right).length - dirname(left).length || + left.localeCompare(right) + )[0]; + } + + private isReferenceOnly(config: TypeScriptConfigJson): boolean { + return ( + Array.isArray(config.references) && + config.references.length > 0 && + Array.isArray(config.files) && + config.files.length === 0 && + config.include === undefined + ); + } + + private appendReferences( + repoRoot: string, + configPath: string, + queue: string[] + ): void { + try { + const config = this.parse(configPath); + queue.push(...this.referencedPaths(repoRoot, configPath, config)); + } catch { + // Malformed auxiliary configs do not replace the preferred project config. + } + } + + private referencedPaths( + repoRoot: string, + configPath: string, + config: TypeScriptConfigJson + ): string[] { + const candidates: string[] = []; + const references = config.references; + for (const reference of references === undefined ? [] : references) { + if (!reference.path) continue; + const candidate = reference.path.endsWith(".json") + ? resolve(dirname(configPath), reference.path) + : resolve(dirname(configPath), reference.path, "tsconfig.json"); + if (isSafeExistingFileInsideRepo(repoRoot, candidate)) { + candidates.push(candidate); + } + } + return candidates; + } + + private list(repoRoot: string): string[] { + return discoverRepoFiles( + repoRoot, + this.profile.excludedDirectories, + (_path, name) => name === "tsconfig.json" + ); + } +} diff --git a/packages/edit/src/typescript-project/types.ts b/packages/edit/src/typescript-project/types.ts new file mode 100644 index 0000000..b571146 --- /dev/null +++ b/packages/edit/src/typescript-project/types.ts @@ -0,0 +1,47 @@ +import type { Project } from "ts-morph"; + +export type TypeScriptProjectScope = "import_closure" | "whole_repo"; + +export interface TypeScriptProjectProfile { + sourceExtensions: readonly string[]; + extensionlessImportCandidates: readonly string[]; + excludedDirectories: ReadonlySet; + allowDirectoryRoots: boolean; + configMode: "root" | "project_references"; + discoverAllConfigs: boolean; + tolerateMalformedImportConfig: boolean; + defaultIncludeDependents: boolean; +} + +export interface TypeScriptProjectOptions { + project?: Project; + projectScope?: TypeScriptProjectScope; + projectTsconfigPath?: string; + includeDependents?: boolean; + snapshotProject?: (project: Project) => unknown; + revertProject?: (project: Project, snapshot: unknown) => void; +} + +export interface TypeScriptProjectContext { + project: Project; + tsconfigPath?: string; + snapshotProject?: (project: Project) => unknown; + revertProject?: (project: Project, snapshot: unknown) => void; +} + +export const defaultTypeScriptProjectExcludedDirectories = new Set([ + ".agents", + ".claude", + ".codex", + ".gemini", + ".git", + ".lattice", + ".opencode", + ".opcore", + ".pnpm", + "build", + "dist", + "node_modules", + "target", + "vendor" +]); diff --git a/packages/fixtures/graph-pipeline/pipeline-fixtures.json b/packages/fixtures/graph-pipeline/pipeline-fixtures.json index df12239..0e4fb37 100644 --- a/packages/fixtures/graph-pipeline/pipeline-fixtures.json +++ b/packages/fixtures/graph-pipeline/pipeline-fixtures.json @@ -42,8 +42,8 @@ { "id": "graph-pipeline-ignore-file-v1", "operation": "build", - "ignoreFiles": [".gitignore", ".code-review-graphignore"], - "excludedFiles": ["ignored/generated.ts", "ignored/crg.ts"] + "ignoreFiles": [".gitignore"], + "excludedFiles": ["ignored/generated.ts"] }, { "id": "graph-pipeline-schema-mismatch-v1", diff --git a/packages/fixtures/graph-reference-evidence/baseline-receipts.json b/packages/fixtures/graph-reference-evidence/baseline-receipts.json deleted file mode 100644 index fdeb54e..0000000 --- a/packages/fixtures/graph-reference-evidence/baseline-receipts.json +++ /dev/null @@ -1,115 +0,0 @@ -{ - "schemaVersion": 1, - "issue": "#19", - "label": "reference_evidence_non_implementation_input", - "origin": "covibes-authored-synthetic", - "sourceTool": "current external graph dev wrapper", - "sourceAvailability": "available", - "collectedAt": "2026-06-04T10:00:25.536Z", - "receipts": [ - { - "id": "baseline-install-setup", - "metric": "install_setup_ms", - "value": 109, - "unit": "ms", - "sourceAvailability": "available", - "nonImplementationInput": true, - "command": ".ace/runtime/bin/crg --help", - "exitCode": 0, - "stderr": "" - }, - { - "id": "baseline-cold-build", - "metric": "cold_build_ms", - "value": 237, - "unit": "ms", - "sourceAvailability": "available", - "nonImplementationInput": true, - "command": ".ace/runtime/bin/crg build --repo . --json", - "exitCode": 0, - "stderr": "INFO: Progress: 24/24 files parsed" - }, - { - "id": "baseline-incremental-update", - "metric": "incremental_update_ms", - "value": 169, - "unit": "ms", - "sourceAvailability": "available", - "nonImplementationInput": true, - "command": ".ace/runtime/bin/crg update --base HEAD --repo . --json", - "exitCode": 0, - "stderr": "" - }, - { - "id": "baseline-impact-cold", - "metric": "impact_cold_ms", - "value": 164, - "unit": "ms", - "sourceAvailability": "available", - "nonImplementationInput": true, - "command": ".ace/runtime/bin/crg impact --files packages/contracts/src/index.ts --repo . --json", - "exitCode": 0, - "stderr": "" - }, - { - "id": "baseline-impact-hot", - "metric": "impact_hot_ms", - "value": 172, - "unit": "ms", - "sourceAvailability": "available", - "nonImplementationInput": true, - "command": ".ace/runtime/bin/crg impact --files packages/contracts/src/index.ts --repo . --json", - "exitCode": 0, - "stderr": "" - }, - { - "id": "baseline-search", - "metric": "search_ms", - "value": 170, - "unit": "ms", - "sourceAvailability": "available", - "nonImplementationInput": true, - "command": ".ace/runtime/bin/crg search GraphProvider --limit 5 --repo . --json", - "exitCode": 0, - "stderr": "" - }, - { - "id": "baseline-db-size", - "metric": "db_size_bytes", - "value": 913408, - "unit": "bytes", - "sourceAvailability": "available", - "nonImplementationInput": true, - "command": "stat .code-review-graph/graph.db" - }, - { - "id": "baseline-wal-size", - "metric": "wal_size_bytes", - "value": 1, - "unit": "bytes", - "sourceAvailability": "unavailable", - "nonImplementationInput": true, - "command": "stat .code-review-graph/graph.db-wal" - }, - { - "id": "baseline-daemon-startup", - "metric": "daemon_startup_ms", - "value": 102, - "unit": "ms", - "sourceAvailability": "available", - "nonImplementationInput": true, - "command": ".ace/runtime/bin/crg serve --help", - "exitCode": 0, - "stderr": "" - }, - { - "id": "baseline-daemon-query", - "metric": "daemon_query_ms", - "value": 1, - "unit": "ms", - "sourceAvailability": "available", - "nonImplementationInput": true, - "command": "opcore.graph.daemon synthetic query envelope" - } - ] -} diff --git a/packages/fixtures/graph-reference-evidence/daemon-socket-fixtures.json b/packages/fixtures/graph-reference-evidence/daemon-socket-fixtures.json deleted file mode 100644 index b68b12e..0000000 --- a/packages/fixtures/graph-reference-evidence/daemon-socket-fixtures.json +++ /dev/null @@ -1,482 +0,0 @@ -{ - "schemaVersion": 1, - "issue": "#19", - "origin": "covibes-authored-synthetic", - "classification": "required", - "serveTransportIssue": "#47", - "protocols": ["opcore.graph.daemon", "jsonrpc-2.0", "reference-mcp-stdio-baseline-only"], - "envelopes": [ - { - "id": "ping-request", - "direction": "client_to_daemon", - "protocol": "opcore.graph.daemon", - "payload": { - "protocol": "opcore.graph.daemon", - "requestId": "req-ping-1", - "schemaVersion": 1, - "operation": "status", - "repo": { - "repoId": "synthetic-reference" - } - } - }, - { - "id": "ping-response", - "direction": "daemon_to_client", - "protocol": "opcore.graph.daemon", - "payload": { - "protocol": "opcore.graph.daemon", - "requestId": "req-ping-1", - "schemaVersion": 1, - "status": { - "state": "available", - "mode": "required", - "provider": "opcore-graph", - "schemaVersion": 1, - "repo": { - "repoId": "synthetic-reference" - }, - "freshness": { - "generatedAt": "2026-06-04T00:00:00.000Z", - "ageMs": 0, - "stale": false - } - } - } - }, - { - "id": "health-status-request", - "direction": "client_to_daemon", - "protocol": "opcore.graph.daemon", - "payload": { - "protocol": "opcore.graph.daemon", - "requestId": "req-health-1", - "schemaVersion": 1, - "operation": "status", - "repo": { - "repoId": "synthetic-reference" - } - } - }, - { - "id": "health-status-response", - "direction": "daemon_to_client", - "protocol": "opcore.graph.daemon", - "payload": { - "protocol": "opcore.graph.daemon", - "requestId": "req-health-1", - "schemaVersion": 1, - "status": { - "state": "available", - "mode": "required", - "provider": "opcore-graph", - "schemaVersion": 1, - "repo": { - "repoId": "synthetic-reference" - }, - "freshness": { - "generatedAt": "2026-06-04T00:00:00.000Z", - "ageMs": 25, - "maxAgeMs": 60000, - "stale": false - }, - "capabilities": ["status", "query", "impact"] - } - } - }, - { - "id": "query-request", - "direction": "client_to_daemon", - "protocol": "opcore.graph.daemon", - "payload": { - "protocol": "opcore.graph.daemon", - "requestId": "req-query-1", - "schemaVersion": 1, - "operation": "query", - "repo": { - "repoId": "synthetic-reference" - }, - "query": { - "requestId": "query-1", - "repo": { - "repoId": "synthetic-reference" - }, - "schemaVersion": 1, - "mode": "required", - "selector": { - "kind": "symbols", - "text": "login", - "limit": 20 - } - } - } - }, - { - "id": "impact-request", - "direction": "client_to_daemon", - "protocol": "opcore.graph.daemon", - "payload": { - "protocol": "opcore.graph.daemon", - "requestId": "req-impact-1", - "schemaVersion": 1, - "operation": "query", - "repo": { - "repoId": "synthetic-reference" - }, - "query": { - "requestId": "impact-1", - "repo": { - "repoId": "synthetic-reference" - }, - "schemaVersion": 1, - "mode": "required", - "selector": { - "kind": "impact", - "ids": ["file:src/auth.ts"], - "limit": 50 - } - } - } - }, - { - "id": "success-response", - "direction": "daemon_to_client", - "protocol": "opcore.graph.daemon", - "payload": { - "protocol": "opcore.graph.daemon", - "requestId": "req-query-1", - "schemaVersion": 1, - "status": { - "state": "available", - "mode": "required", - "provider": "opcore-graph", - "schemaVersion": 1, - "repo": { - "repoId": "synthetic-reference" - }, - "freshness": { - "generatedAt": "2026-06-04T00:00:00.000Z", - "ageMs": 30, - "stale": false - } - }, - "result": { - "requestId": "query-1", - "status": { - "state": "available", - "mode": "required", - "provider": "opcore-graph", - "schemaVersion": 1, - "repo": { - "repoId": "synthetic-reference" - }, - "freshness": { - "generatedAt": "2026-06-04T00:00:00.000Z", - "ageMs": 30, - "stale": false - } - }, - "metadata": { - "schemaVersion": 1, - "provider": "opcore-graph", - "repo": { - "repoId": "synthetic-reference" - }, - "generatedAt": "2026-06-04T00:00:00.000Z", - "freshness": { - "generatedAt": "2026-06-04T00:00:00.000Z", - "ageMs": 30, - "stale": false - }, - "nodeKinds": ["file", "symbol", "test"], - "edgeKinds": ["CONTAINS", "DECLARES", "IMPORTS_FROM", "CALLS", "TESTED_BY"] - }, - "nodes": [ - { - "id": "symbol:src/auth.ts#login", - "kind": "symbol", - "path": "src/auth.ts", - "name": "login" - } - ], - "edges": [ - { - "kind": "DECLARES", - "from": "file:src/auth.ts", - "to": "symbol:src/auth.ts#login" - } - ] - } - } - }, - { - "id": "stale-response", - "direction": "daemon_to_client", - "protocol": "opcore.graph.daemon", - "payload": { - "protocol": "opcore.graph.daemon", - "requestId": "req-query-stale", - "schemaVersion": 1, - "status": { - "state": "stale", - "mode": "required", - "provider": "opcore-graph", - "schemaVersion": 1, - "repo": { - "repoId": "synthetic-reference" - }, - "freshness": { - "generatedAt": "2026-06-03T23:00:00.000Z", - "ageMs": 3600000, - "maxAgeMs": 60000, - "stale": true, - "reason": "snapshot older than freshness policy" - }, - "failure": { - "category": "stale_snapshot", - "message": "Graph snapshot is stale", - "retryable": true - } - } - } - }, - { - "id": "schema-mismatch-response", - "direction": "daemon_to_client", - "protocol": "opcore.graph.daemon", - "payload": { - "protocol": "opcore.graph.daemon", - "requestId": "req-schema-mismatch", - "schemaVersion": 1, - "status": { - "state": "schema_mismatch", - "mode": "required", - "provider": "opcore-graph", - "schemaVersion": 1, - "expectedSchemaVersion": 1, - "actualSchemaVersion": 0, - "failure": { - "category": "schema_mismatch", - "message": "Graph schema version is incompatible", - "retryable": false - } - } - } - }, - { - "id": "daemon-unavailable-response", - "direction": "client_error", - "protocol": "opcore.graph.daemon", - "payload": { - "protocol": "opcore.graph.daemon", - "requestId": "req-daemon-unavailable", - "schemaVersion": 1, - "status": { - "state": "daemon_unavailable", - "mode": "required", - "provider": "opcore-graph", - "schemaVersion": 1, - "failure": { - "category": "daemon_unavailable", - "message": "Graph daemon socket is unavailable", - "retryable": true - } - } - } - }, - { - "id": "serve-jsonl-ping", - "direction": "client_to_daemon", - "protocol": "opcore.graph.daemon", - "issue": "#47", - "payload": { - "protocol": "opcore.graph.daemon", - "requestId": "serve-ping-1", - "schemaVersion": 1, - "operation": "ping", - "repo": { - "repoId": "synthetic-reference" - } - } - }, - { - "id": "serve-jsonl-status", - "direction": "client_to_daemon", - "protocol": "opcore.graph.daemon", - "issue": "#47", - "payload": { - "protocol": "opcore.graph.daemon", - "requestId": "serve-status-1", - "schemaVersion": 1, - "operation": "status", - "repo": { - "repoId": "synthetic-reference" - } - } - }, - { - "id": "serve-jsonl-query", - "direction": "client_to_daemon", - "protocol": "opcore.graph.daemon", - "issue": "#47", - "payload": { - "protocol": "opcore.graph.daemon", - "requestId": "serve-query-1", - "schemaVersion": 1, - "operation": "query", - "repo": { - "repoId": "synthetic-reference" - }, - "query": { - "requestId": "serve-query-1", - "repo": { - "repoId": "synthetic-reference" - }, - "schemaVersion": 1, - "mode": "required", - "selector": { - "kind": "nodes", - "limit": 10 - } - } - } - }, - { - "id": "serve-jsonl-search", - "direction": "client_to_daemon", - "protocol": "opcore.graph.daemon", - "issue": "#47", - "payload": { - "protocol": "opcore.graph.daemon", - "requestId": "serve-search-1", - "schemaVersion": 1, - "operation": "query", - "repo": { - "repoId": "synthetic-reference" - }, - "search": { - "requestId": "serve-search-1", - "repo": { - "repoId": "synthetic-reference" - }, - "schemaVersion": 1, - "mode": "required", - "query": "Greeting", - "limit": 5 - } - } - }, - { - "id": "serve-jsonl-shutdown", - "direction": "client_to_daemon", - "protocol": "opcore.graph.daemon", - "issue": "#47", - "payload": { - "protocol": "opcore.graph.daemon", - "requestId": "serve-shutdown-1", - "schemaVersion": 1, - "operation": "shutdown", - "repo": { - "repoId": "synthetic-reference" - } - } - }, - { - "id": "serve-invalid-repo", - "direction": "daemon_to_client", - "protocol": "opcore.graph.daemon", - "issue": "#47", - "payload": { - "protocol": "opcore.graph.daemon", - "requestId": "serve-invalid-repo-1", - "schemaVersion": 1, - "status": { - "state": "required_missing", - "mode": "required", - "provider": "opcore-graph", - "schemaVersion": 1, - "failure": { - "category": "provider_missing", - "message": "repoRoot is not a directory" - } - } - } - }, - { - "id": "serve-schema-mismatch", - "direction": "daemon_to_client", - "protocol": "opcore.graph.daemon", - "issue": "#47", - "payload": { - "protocol": "opcore.graph.daemon", - "requestId": "serve-schema-mismatch-1", - "schemaVersion": 1, - "status": { - "state": "schema_mismatch", - "mode": "required", - "provider": "opcore-graph", - "schemaVersion": 1, - "expectedSchemaVersion": 1, - "actualSchemaVersion": 2, - "failure": { - "category": "schema_mismatch", - "message": "Graph daemon request schemaVersion mismatch" - } - } - } - }, - { - "id": "mcp-initialize", - "direction": "client_to_daemon", - "protocol": "jsonrpc-2.0", - "issue": "#47", - "payload": { - "jsonrpc": "2.0", - "id": "mcp-init-1", - "method": "initialize", - "params": { - "protocolVersion": "2024-11-05" - } - } - }, - { - "id": "mcp-initialized-notification", - "direction": "client_to_daemon", - "protocol": "jsonrpc-2.0", - "issue": "#47", - "expectResponse": false, - "payload": { - "jsonrpc": "2.0", - "method": "notifications/initialized", - "params": {} - } - }, - { - "id": "mcp-status", - "direction": "client_to_daemon", - "protocol": "jsonrpc-2.0", - "issue": "#47", - "payload": { - "jsonrpc": "2.0", - "id": "mcp-status-1", - "method": "opcore.graph/status", - "params": { - "repo": { - "repoId": "synthetic-reference" - } - } - } - }, - { - "id": "reference-mcp-stdio-baseline", - "direction": "baseline_only", - "protocol": "reference-mcp-stdio-baseline-only", - "baselineOnly": true, - "payload": { - "transport": "stdio", - "command": ["crg", "serve", "--repo", "."], - "note": "Captured only to preserve downstream transport expectations; not a Opcore-owned daemon protocol." - } - } - ] -} diff --git a/packages/fixtures/graph-reference-evidence/golden-corpus.json b/packages/fixtures/graph-reference-evidence/golden-corpus.json deleted file mode 100644 index 5fc8acb..0000000 --- a/packages/fixtures/graph-reference-evidence/golden-corpus.json +++ /dev/null @@ -1,335 +0,0 @@ -{ - "schemaVersion": 1, - "issue": "#19", - "id": "graph-reference-evidence-golden-corpus-v1", - "origin": "covibes-authored-synthetic", - "containsSourceCode": false, - "description": "Synthetic graph reference corpus authored for Opcore contracts; file contents are descriptors, not upstream source.", - "files": [ - { - "path": "src/auth.ts", - "language": "typescript", - "symbols": ["login", "logout"], - "hash": "sha256:synthetic-auth" - }, - { - "path": "src/session.ts", - "language": "typescript", - "symbols": ["createSession"], - "hash": "sha256:synthetic-session" - }, - { - "path": "tests/auth.test.ts", - "language": "typescript", - "symbols": ["login rejects invalid password"], - "hash": "sha256:synthetic-auth-test" - }, - { - "path": "src/lib.rs", - "language": "rust", - "symbols": ["Widget", "Service"], - "hash": "sha256:synthetic-rust-lib" - }, - { - "path": "src/helpers.rs", - "language": "rust", - "symbols": ["helpers::assist"], - "hash": "sha256:synthetic-rust-helpers" - }, - { - "path": "src/consumer.rs", - "language": "rust", - "symbols": ["consumer::run", "consumer.tests::test_run"], - "hash": "sha256:synthetic-rust-consumer" - } - ], - "expectedFacts": { - "parser": { - "nodes": [ - { - "id": "file:src/auth.ts", - "kind": "File", - "name": "src/auth.ts" - }, - { - "id": "file:src/session.ts", - "kind": "File", - "name": "src/session.ts" - }, - { - "id": "file:tests/auth.test.ts", - "kind": "File", - "name": "tests/auth.test.ts" - }, - { - "id": "function:src/auth.ts#login", - "kind": "Function", - "name": "login", - "filePath": "src/auth.ts" - }, - { - "id": "function:src/auth.ts#logout", - "kind": "Function", - "name": "logout", - "filePath": "src/auth.ts" - }, - { - "id": "function:src/session.ts#createSession", - "kind": "Function", - "name": "createSession", - "filePath": "src/session.ts" - }, - { - "id": "test:tests/auth.test.ts#login rejects invalid password", - "kind": "Test", - "name": "login rejects invalid password", - "filePath": "tests/auth.test.ts" - }, - { - "id": "file:src/lib.rs", - "kind": "File", - "name": "src/lib.rs" - }, - { - "id": "file:src/helpers.rs", - "kind": "File", - "name": "src/helpers.rs" - }, - { - "id": "file:src/consumer.rs", - "kind": "File", - "name": "src/consumer.rs" - }, - { - "id": "module:src/lib.rs#crate", - "kind": "Module", - "name": "crate", - "filePath": "src/lib.rs" - }, - { - "id": "module:src/helpers.rs#helpers", - "kind": "Module", - "name": "helpers", - "filePath": "src/helpers.rs" - }, - { - "id": "module:src/consumer.rs#consumer", - "kind": "Module", - "name": "consumer", - "filePath": "src/consumer.rs" - }, - { - "id": "struct:src/lib.rs#Widget", - "kind": "Struct", - "name": "Widget", - "filePath": "src/lib.rs" - }, - { - "id": "trait:src/lib.rs#Service", - "kind": "Trait", - "name": "Service", - "filePath": "src/lib.rs" - }, - { - "id": "impl:src/lib.rs#impl Service for Widget", - "kind": "Impl", - "name": "impl Service for Widget", - "filePath": "src/lib.rs" - }, - { - "id": "method:src/lib.rs#Widget::handle", - "kind": "Method", - "name": "Widget::handle", - "filePath": "src/lib.rs" - }, - { - "id": "function:src/helpers.rs#helpers::assist", - "kind": "Function", - "name": "helpers::assist", - "filePath": "src/helpers.rs" - }, - { - "id": "function:src/consumer.rs#consumer::run", - "kind": "Function", - "name": "consumer::run", - "filePath": "src/consumer.rs" - }, - { - "id": "test:src/consumer.rs#consumer.tests::test_run", - "kind": "Test", - "name": "consumer.tests::test_run", - "filePath": "src/consumer.rs" - } - ] - }, - "store": { - "metadata": { - "schema_version": "6", - "last_build_type": "full" - }, - "edges": [ - { - "kind": "CONTAINS", - "source": "file:src/auth.ts", - "target": "function:src/auth.ts#login" - }, - { - "kind": "CONTAINS", - "source": "file:src/auth.ts", - "target": "function:src/auth.ts#logout" - }, - { - "kind": "CONTAINS", - "source": "file:src/session.ts", - "target": "function:src/session.ts#createSession" - }, - { - "kind": "CONTAINS", - "source": "file:tests/auth.test.ts", - "target": "test:tests/auth.test.ts#login rejects invalid password" - }, - { - "kind": "CALLS", - "source": "function:src/auth.ts#login", - "target": "function:src/session.ts#createSession" - }, - { - "kind": "TESTED_BY", - "source": "function:src/auth.ts#login", - "target": "test:tests/auth.test.ts#login rejects invalid password" - }, - { - "kind": "CONTAINS", - "source": "file:src/lib.rs", - "target": "module:src/lib.rs#crate" - }, - { - "kind": "CONTAINS", - "source": "file:src/helpers.rs", - "target": "module:src/helpers.rs#helpers" - }, - { - "kind": "CONTAINS", - "source": "file:src/consumer.rs", - "target": "module:src/consumer.rs#consumer" - }, - { - "kind": "CONTAINS", - "source": "module:src/lib.rs#crate", - "target": "struct:src/lib.rs#Widget" - }, - { - "kind": "CONTAINS", - "source": "module:src/lib.rs#crate", - "target": "trait:src/lib.rs#Service" - }, - { - "kind": "CONTAINS", - "source": "module:src/lib.rs#crate", - "target": "impl:src/lib.rs#impl Service for Widget" - }, - { - "kind": "CONTAINS", - "source": "impl:src/lib.rs#impl Service for Widget", - "target": "method:src/lib.rs#Widget::handle" - }, - { - "kind": "CONTAINS", - "source": "module:src/helpers.rs#helpers", - "target": "function:src/helpers.rs#helpers::assist" - }, - { - "kind": "CONTAINS", - "source": "module:src/consumer.rs#consumer", - "target": "function:src/consumer.rs#consumer::run" - }, - { - "kind": "CONTAINS", - "source": "module:src/consumer.rs#consumer", - "target": "test:src/consumer.rs#consumer.tests::test_run" - }, - { - "kind": "IMPORTS_FROM", - "source": "file:src/consumer.rs", - "target": "file:src/helpers.rs" - }, - { - "kind": "IMPORTS_FROM", - "source": "file:src/consumer.rs", - "target": "file:src/lib.rs" - }, - { - "kind": "DEPENDS_ON", - "source": "file:src/consumer.rs", - "target": "file:src/helpers.rs" - }, - { - "kind": "DEPENDS_ON", - "source": "file:src/consumer.rs", - "target": "file:src/lib.rs" - }, - { - "kind": "IMPLEMENTS", - "source": "impl:src/lib.rs#impl Service for Widget", - "target": "trait:src/lib.rs#Service" - }, - { - "kind": "CALLS", - "source": "function:src/consumer.rs#consumer::run", - "target": "function:src/helpers.rs#helpers::assist" - }, - { - "kind": "CALLS", - "source": "test:src/consumer.rs#consumer.tests::test_run", - "target": "function:src/consumer.rs#consumer::run" - }, - { - "kind": "TESTED_BY", - "source": "function:src/consumer.rs#consumer::run", - "target": "test:src/consumer.rs#consumer.tests::test_run" - } - ] - }, - "query": { - "pattern": "callers_of", - "target": "createSession", - "expectedResultIds": ["function:src/auth.ts#login"], - "expectedEdgeKinds": ["CALLS"] - }, - "search": { - "query": "login", - "searchMode": "keyword", - "expectedResultIds": ["function:src/auth.ts#login", "test:tests/auth.test.ts#login rejects invalid password"], - "expectedHints": ["query_graph", "get_flow", "get_impact_radius"] - }, - "freshness": { - "freshAfterBuild": true, - "staleWhenOlderThanMs": 60000, - "requiredMetadataKeys": ["schema_version", "last_updated", "last_build_type"] - }, - "status": { - "totalNodes": 20, - "totalEdges": 24, - "nodesByKind": { - "File": 6, - "Function": 5, - "Test": 2, - "Module": 3, - "Struct": 1, - "Trait": 1, - "Impl": 1, - "Method": 1 - }, - "edgesByKind": { - "CALLS": 3, - "CONTAINS": 14, - "TESTED_BY": 2, - "IMPORTS_FROM": 2, - "DEPENDS_ON": 2, - "IMPLEMENTS": 1 - }, - "languages": ["typescript", "rust"], - "embeddingsCount": 0 - } - } -} diff --git a/packages/fixtures/graph-reference-evidence/manifest.json b/packages/fixtures/graph-reference-evidence/manifest.json deleted file mode 100644 index 126506b..0000000 --- a/packages/fixtures/graph-reference-evidence/manifest.json +++ /dev/null @@ -1,696 +0,0 @@ -{ - "schemaVersion": 1, - "issue": "#19", - "origin": "covibes-authored-synthetic", - "fixtureRefs": [ - "packages/fixtures/graph-reference-evidence/sqlite-fixtures.json", - "packages/fixtures/graph-reference-evidence/daemon-socket-fixtures.json", - "packages/fixtures/graph-reference-evidence/golden-corpus.json", - "packages/fixtures/graph-reference-evidence/baseline-receipts.json" - ], - "commandSurfaces": [ - { - "id": "graph-reference-build", - "classification": "required", - "referenceTool": "crg", - "referenceCommand": [ - "build" - ], - "canonicalCommand": [ - "opcore", - "graph", - "build" - ], - "flags": [ - "--repo", - "--json" - ], - "positionals": [], - "fixtures": [ - "build-json", - "graph-reference-evidence-golden-corpus-v1", - "baseline-cold-build" - ], - "exitSemantics": { - "success": 0, - "failure": "nonzero on parse, IO, or graph store failure" - } - }, - { - "id": "graph-reference-update", - "classification": "required", - "referenceTool": "crg", - "referenceCommand": [ - "update" - ], - "canonicalCommand": [ - "opcore", - "graph", - "update" - ], - "flags": [ - "--base", - "--repo", - "--json" - ], - "positionals": [], - "fixtures": [ - "update-json", - "freshness-fixture", - "baseline-incremental-update" - ], - "exitSemantics": { - "success": 0, - "failure": "nonzero on diff, parse, IO, or graph store failure" - } - }, - { - "id": "graph-reference-watch", - "classification": "required", - "referenceTool": "crg", - "referenceCommand": [ - "watch" - ], - "canonicalCommand": [ - "opcore", - "graph", - "watch" - ], - "flags": [ - "--repo" - ], - "positionals": [], - "fixtures": [ - "daemon-health-status" - ], - "exitSemantics": { - "success": 0, - "failure": "nonzero on watcher setup or graph refresh failure" - } - }, - { - "id": "graph-reference-status", - "classification": "required", - "referenceTool": "crg", - "referenceCommand": [ - "status" - ], - "canonicalCommand": [ - "opcore", - "graph", - "status" - ], - "flags": [ - "--repo", - "--json" - ], - "positionals": [], - "fixtures": [ - "status-json", - "sqlite-required-views", - "status-expected-facts" - ], - "exitSemantics": { - "success": 0, - "failure": "nonzero when graph metadata or database cannot be read" - } - }, - { - "id": "graph-reference-query", - "classification": "required", - "referenceTool": "crg", - "referenceCommand": [ - "query" - ], - "canonicalCommand": [ - "opcore", - "graph", - "query" - ], - "flags": [ - "--repo", - "--json" - ], - "positionals": [ - "pattern", - "target" - ], - "fixtures": [ - "query-json", - "daemon-query-request", - "query-expected-facts" - ], - "exitSemantics": { - "success": 0, - "failure": "nonzero on unsupported pattern, stale graph policy failure, or query execution failure" - } - }, - { - "id": "graph-reference-impact", - "classification": "required", - "referenceTool": "crg", - "referenceCommand": [ - "impact" - ], - "canonicalCommand": [ - "opcore", - "graph", - "impact" - ], - "flags": [ - "--base", - "--max-depth", - "--files", - "--repo", - "--json" - ], - "positionals": [], - "fixtures": [ - "impact-json", - "daemon-impact-request", - "baseline-impact-hot", - "baseline-impact-cold" - ], - "exitSemantics": { - "success": 0, - "failure": "nonzero on diff, traversal, stale graph policy, or store read failure" - } - }, - { - "id": "graph-reference-search", - "classification": "required", - "referenceTool": "crg", - "referenceCommand": [ - "search" - ], - "canonicalCommand": [ - "opcore", - "graph", - "search" - ], - "flags": [ - "--kind", - "--limit", - "--model", - "--repo", - "--json" - ], - "positionals": [ - "query" - ], - "fixtures": [ - "search-json", - "search-expected-facts", - "baseline-search" - ], - "exitSemantics": { - "success": 0, - "failure": "nonzero on invalid kind, embedding model mismatch, or graph read failure" - } - }, - { - "id": "graph-reference-serve", - "classification": "required", - "referenceTool": "crg", - "referenceCommand": [ - "serve" - ], - "canonicalCommand": [ - "opcore", - "graph", - "serve" - ], - "flags": [ - "--repo" - ], - "positionals": [], - "fixtures": [ - "serve-jsonl-ping", - "serve-jsonl-status", - "serve-jsonl-query", - "serve-jsonl-search", - "serve-jsonl-shutdown", - "mcp-initialize", - "mcp-status", - "reference-mcp-stdio-baseline" - ], - "exitSemantics": { - "success": 0, - "failure": "nonzero on daemon transport setup failure" - } - }, - { - "id": "opcore-graph-inspect", - "classification": "deferred", - "referenceTool": "crg", - "referenceCommand": [], - "canonicalCommand": [ - "opcore", - "graph", - "inspect" - ], - "flags": [], - "positionals": [], - "fixtures": [ - "inspect-deferred-marker" - ], - "exitSemantics": { - "success": 0, - "failure": "deferred canonical-only command remains out of #19 implementation scope" - } - } - ], - "jsonOutputSurfaces": [ - { - "id": "build-json", - "command": "build", - "classification": "required", - "requiredFields": [ - "files_parsed", - "total_nodes", - "total_edges", - "errors" - ], - "fixtures": [ - "graph-reference-evidence-golden-corpus-v1" - ], - "exitSemantics": { - "success": 0, - "failure": "nonzero with stderr diagnostics" - } - }, - { - "id": "update-json", - "command": "update", - "classification": "required", - "requiredFields": [ - "files_updated", - "total_nodes", - "total_edges", - "changed_files", - "dependent_files" - ], - "fixtures": [ - "freshness-expected-facts" - ], - "exitSemantics": { - "success": 0, - "failure": "nonzero with stderr diagnostics" - } - }, - { - "id": "status-json", - "command": "status", - "classification": "required", - "requiredFields": [ - "status", - "summary", - "total_nodes", - "total_edges", - "nodes_by_kind", - "edges_by_kind", - "languages", - "files_count", - "last_updated" - ], - "fixtures": [ - "status-expected-facts", - "sqlite-required-views" - ], - "exitSemantics": { - "success": 0, - "failure": "nonzero with stderr diagnostics" - } - }, - { - "id": "query-json", - "command": "query", - "classification": "required", - "requiredFields": [ - "status", - "pattern", - "target", - "description", - "summary", - "results", - "edges" - ], - "fixtures": [ - "query-expected-facts", - "daemon-query-request" - ], - "exitSemantics": { - "success": 0, - "failure": "nonzero with JSON error or stderr diagnostics" - } - }, - { - "id": "impact-json", - "command": "impact", - "classification": "required", - "requiredFields": [ - "status", - "summary", - "changed_files", - "changed_nodes", - "impacted_nodes", - "impacted_files", - "edges", - "truncated", - "total_impacted" - ], - "fixtures": [ - "impact-expected-facts", - "daemon-impact-request" - ], - "exitSemantics": { - "success": 0, - "failure": "nonzero with JSON error or stderr diagnostics" - } - }, - { - "id": "search-json", - "command": "search", - "classification": "required", - "requiredFields": [ - "status", - "query", - "search_mode", - "summary", - "results", - "_hints" - ], - "fixtures": [ - "search-expected-facts" - ], - "exitSemantics": { - "success": 0, - "failure": "nonzero with JSON error or stderr diagnostics" - } - } - ], - "sqliteFixtures": [ - { - "id": "sqlite-required-views", - "classification": "required", - "fixture": "packages/fixtures/graph-reference-evidence/sqlite-fixtures.json", - "tables": [ - "metadata", - "nodes", - "edges", - "nodes_fts" - ], - "indexes": [ - "idx_nodes_file", - "idx_nodes_kind", - "idx_nodes_qualified", - "idx_edges_source", - "idx_edges_target", - "idx_edges_kind", - "idx_edges_file", - "idx_nodes_exported_name" - ], - "metadataKeys": [ - "schema_version", - "last_updated", - "last_build_type" - ], - "nodeKinds": [ - "File", - "Function", - "Test", - "Module", - "Struct", - "Enum", - "Trait", - "Impl", - "Method", - "TypeAlias", - "Const", - "Static", - "Macro" - ], - "edgeKinds": [ - "CALLS", - "CONTAINS", - "IMPORTS_FROM", - "TESTED_BY", - "IMPLEMENTS", - "DEPENDS_ON", - "INHERITS" - ], - "directReaderQueries": [ - "status-counts", - "status-edge-counts", - "impact-edges-from-file", - "search-by-name", - "freshness-metadata" - ], - "fixtures": [ - "packages/fixtures/graph-reference-evidence/sqlite-fixtures.json" - ] - } - ], - "daemonFixtures": [ - { - "id": "daemon-hot-query", - "classification": "required", - "fixture": "packages/fixtures/graph-reference-evidence/daemon-socket-fixtures.json", - "protocol": "opcore.graph.daemon", - "envelopes": [ - "ping-request", - "ping-response", - "health-status-request", - "health-status-response", - "query-request", - "impact-request", - "success-response", - "serve-jsonl-ping", - "serve-jsonl-status", - "serve-jsonl-query", - "serve-jsonl-search", - "serve-jsonl-shutdown", - "serve-invalid-repo", - "serve-schema-mismatch", - "mcp-initialize", - "mcp-status", - "stale-response", - "schema-mismatch-response", - "daemon-unavailable-response" - ], - "fixtures": [ - "packages/fixtures/graph-reference-evidence/daemon-socket-fixtures.json" - ] - } - ], - "baselineReceipts": [ - { - "id": "baseline-install-setup", - "metric": "install_setup_ms", - "classification": "required", - "receipt": "packages/fixtures/graph-reference-evidence/baseline-receipts.json", - "label": "reference_evidence_non_implementation_input", - "sourceAvailability": "available", - "nonImplementationInput": true, - "fixtures": [ - "baseline-receipts" - ] - }, - { - "id": "baseline-cold-build", - "metric": "cold_build_ms", - "classification": "required", - "receipt": "packages/fixtures/graph-reference-evidence/baseline-receipts.json", - "label": "reference_evidence_non_implementation_input", - "sourceAvailability": "available", - "nonImplementationInput": true, - "fixtures": [ - "baseline-receipts" - ] - }, - { - "id": "baseline-incremental-update", - "metric": "incremental_update_ms", - "classification": "required", - "receipt": "packages/fixtures/graph-reference-evidence/baseline-receipts.json", - "label": "reference_evidence_non_implementation_input", - "sourceAvailability": "available", - "nonImplementationInput": true, - "fixtures": [ - "baseline-receipts" - ] - }, - { - "id": "baseline-impact-cold", - "metric": "impact_cold_ms", - "classification": "required", - "receipt": "packages/fixtures/graph-reference-evidence/baseline-receipts.json", - "label": "reference_evidence_non_implementation_input", - "sourceAvailability": "available", - "nonImplementationInput": true, - "fixtures": [ - "baseline-receipts" - ] - }, - { - "id": "baseline-impact-hot", - "metric": "impact_hot_ms", - "classification": "required", - "receipt": "packages/fixtures/graph-reference-evidence/baseline-receipts.json", - "label": "reference_evidence_non_implementation_input", - "sourceAvailability": "available", - "nonImplementationInput": true, - "fixtures": [ - "baseline-receipts" - ] - }, - { - "id": "baseline-search", - "metric": "search_ms", - "classification": "required", - "receipt": "packages/fixtures/graph-reference-evidence/baseline-receipts.json", - "label": "reference_evidence_non_implementation_input", - "sourceAvailability": "available", - "nonImplementationInput": true, - "fixtures": [ - "baseline-receipts" - ] - }, - { - "id": "baseline-db-size", - "metric": "db_size_bytes", - "classification": "required", - "receipt": "packages/fixtures/graph-reference-evidence/baseline-receipts.json", - "label": "reference_evidence_non_implementation_input", - "sourceAvailability": "available", - "nonImplementationInput": true, - "fixtures": [ - "baseline-receipts" - ] - }, - { - "id": "baseline-wal-size", - "metric": "wal_size_bytes", - "classification": "required", - "receipt": "packages/fixtures/graph-reference-evidence/baseline-receipts.json", - "label": "reference_evidence_non_implementation_input", - "sourceAvailability": "unavailable", - "nonImplementationInput": true, - "fixtures": [ - "baseline-receipts" - ] - }, - { - "id": "baseline-daemon-startup", - "metric": "daemon_startup_ms", - "classification": "required", - "receipt": "packages/fixtures/graph-reference-evidence/baseline-receipts.json", - "label": "reference_evidence_non_implementation_input", - "sourceAvailability": "available", - "nonImplementationInput": true, - "fixtures": [ - "baseline-receipts" - ] - }, - { - "id": "baseline-daemon-query", - "metric": "daemon_query_ms", - "classification": "required", - "receipt": "packages/fixtures/graph-reference-evidence/baseline-receipts.json", - "label": "reference_evidence_non_implementation_input", - "sourceAvailability": "available", - "nonImplementationInput": true, - "fixtures": [ - "baseline-receipts" - ] - } - ], - "optionalAnalysisSurfaces": [ - { - "issue": "#13", - "id": "coverage", - "classification": "deferred", - "status": "deferred", - "fixtures": [ - "coverage-deferred-marker" - ] - }, - { - "issue": "#14", - "id": "flows", - "classification": "optional", - "status": "deferred", - "fixtures": [ - "sqlite-flows", - "reference-command-flows" - ] - }, - { - "issue": "#15", - "id": "communities", - "classification": "optional", - "status": "deferred", - "fixtures": [ - "sqlite-communities", - "reference-command-communities" - ] - }, - { - "issue": "#16", - "id": "read_only_suggestions", - "classification": "supporting", - "status": "deferred", - "fixtures": [ - "read-only-refactor-baseline" - ] - } - ], - "goldenCorpus": { - "id": "graph-reference-evidence-golden-corpus-v1", - "classification": "required", - "fixture": "packages/fixtures/graph-reference-evidence/golden-corpus.json", - "covers": [ - "parser", - "store", - "query", - "search", - "freshness", - "status", - "code-review-graph-cli-surface", - "crg-watch-roots-ignore-reconcile", - "crg-wal-health-checkpoint-pressure", - "crg-hot-query-socket", - "crg-mcp-tool-surface", - "crg-impact-query-review-search", - "rust-source-extraction-fixtures", - "rust-store-freshness-fts", - "rust-query-impact-search", - "mixed-rust-ts-source-fixture", - "lattice-native-graph-provider-surfaces", - "lattice-current-tools-graph-status", - "lattice-crg-reference-baseline-release-fixtures", - "covibes-crg-watch-ci-unit-gate", - "covibes-push-ready-crg-freshness-impact-gate", - "covibes-agent-guidance-crg-watch-and-reads", - "mcp-server-name-code-review-graph-compatibility" - ], - "fixtures": [ - "packages/fixtures/graph-reference-evidence/golden-corpus.json" - ] - }, - "provenance": { - "containsPythonCrgSource": false, - "containsPackageMetadata": false, - "containsGitHistory": false, - "referenceReceiptsAreImplementationInput": false, - "implementationPackageNames": [ - "@the-open-engine/opcore-graph", - "@the-open-engine/opcore-contracts", - "@the-open-engine/opcore-fixtures" - ], - "allowedMentionPaths": [ - "docs/graph-reference-evidence/", - "packages/fixtures/graph-reference-evidence/" - ] - } -} diff --git a/packages/fixtures/graph-reference-evidence/sqlite-fixtures.json b/packages/fixtures/graph-reference-evidence/sqlite-fixtures.json deleted file mode 100644 index c62aada..0000000 --- a/packages/fixtures/graph-reference-evidence/sqlite-fixtures.json +++ /dev/null @@ -1,174 +0,0 @@ -{ - "schemaVersion": 1, - "issue": "#19", - "origin": "covibes-authored-synthetic", - "classification": "required", - "metadata": { - "schemaVersion": { - "key": "schema_version", - "value": "6" - }, - "requiredKeys": [ - "schema_version", - "last_updated", - "last_build_type" - ], - "freshnessKeys": [ - "last_updated", - "last_build_type" - ] - }, - "tables": [ - { - "name": "metadata", - "columns": [ - "key", - "value" - ] - }, - { - "name": "nodes", - "columns": [ - "id", - "kind", - "name", - "qualified_name", - "file_path", - "line_start", - "line_end", - "language", - "parent_name", - "params", - "return_type", - "modifiers", - "is_test", - "is_exported", - "file_hash", - "extra", - "updated_at", - "signature" - ] - }, - { - "name": "edges", - "columns": [ - "id", - "kind", - "source_qualified", - "target_qualified", - "file_path", - "line", - "extra", - "updated_at" - ] - }, - { - "name": "nodes_fts", - "columns": [ - "name", - "qualified_name", - "file_path", - "signature" - ] - } - ], - "indexes": [ - "idx_nodes_file", - "idx_nodes_kind", - "idx_nodes_qualified", - "idx_edges_source", - "idx_edges_target", - "idx_edges_kind", - "idx_edges_file", - "idx_nodes_exported_name" - ], - "nodeKinds": [ - "File", - "Function", - "Test", - "Module", - "Struct", - "Enum", - "Trait", - "Impl", - "Method", - "TypeAlias", - "Const", - "Static", - "Macro" - ], - "edgeKinds": [ - "CALLS", - "CONTAINS", - "IMPORTS_FROM", - "TESTED_BY", - "IMPLEMENTS", - "DEPENDS_ON", - "INHERITS" - ], - "directReaderQueries": [ - { - "id": "status-counts", - "consumer": "validation graph-aware summaries", - "sql": "select kind, count(*) as count from nodes group by kind order by kind" - }, - { - "id": "status-edge-counts", - "consumer": "Covibes agent status display", - "sql": "select kind, count(*) as count from edges group by kind order by kind" - }, - { - "id": "impact-edges-from-file", - "consumer": "edit planning blast-radius preflight", - "sql": "select kind, source_qualified, target_qualified from edges where file_path = ?" - }, - { - "id": "search-by-name", - "consumer": "ACE symbol search fallback", - "sql": "select qualified_name, kind, file_path, line_start, line_end from nodes where name like ? order by kind, qualified_name limit ?" - }, - { - "id": "freshness-metadata", - "consumer": "Zeroshot local validation freshness gate", - "sql": "select key, value from metadata where key in ('schema_version', 'last_updated', 'last_build_type') order by key" - } - ], - "optionalAnalysisTables": [ - { - "issue": "#14", - "id": "flows", - "classification": "optional", - "tables": [ - "flows", - "flow_memberships" - ], - "indexes": [ - "idx_flows_criticality", - "idx_flows_entry", - "idx_flow_memberships_node" - ] - }, - { - "issue": "#15", - "id": "communities", - "classification": "optional", - "tables": [ - "communities" - ], - "indexes": [ - "idx_nodes_community", - "idx_communities_parent", - "idx_communities_cohesion" - ] - }, - { - "issue": "#16", - "id": "embeddings", - "classification": "supporting", - "tables": [ - "embeddings" - ], - "indexes": [] - } - ] -} diff --git a/packages/fixtures/graph-release/release-readiness-fixture.json b/packages/fixtures/graph-release/release-readiness-fixture.json index f7bbbee..1989463 100644 --- a/packages/fixtures/graph-release/release-readiness-fixture.json +++ b/packages/fixtures/graph-release/release-readiness-fixture.json @@ -399,7 +399,7 @@ "value": 2, "unit": "ms", "baselineIssue": "#19", - "baselineReceipt": "packages/fixtures/graph-reference-evidence/baseline-receipts.json", + "baselineReceipt": "docs/release/graph-release-receipt.json", "comparison": "recorded" }, { @@ -407,7 +407,7 @@ "value": 466, "unit": "ms", "baselineIssue": "#19", - "baselineReceipt": "packages/fixtures/graph-reference-evidence/baseline-receipts.json", + "baselineReceipt": "docs/release/graph-release-receipt.json", "comparison": "recorded" }, { @@ -415,7 +415,7 @@ "value": 171, "unit": "ms", "baselineIssue": "#19", - "baselineReceipt": "packages/fixtures/graph-reference-evidence/baseline-receipts.json", + "baselineReceipt": "docs/release/graph-release-receipt.json", "comparison": "recorded" }, { @@ -423,7 +423,7 @@ "value": 174, "unit": "ms", "baselineIssue": "#19", - "baselineReceipt": "packages/fixtures/graph-reference-evidence/baseline-receipts.json", + "baselineReceipt": "docs/release/graph-release-receipt.json", "comparison": "recorded" }, { @@ -431,7 +431,7 @@ "value": 171, "unit": "ms", "baselineIssue": "#19", - "baselineReceipt": "packages/fixtures/graph-reference-evidence/baseline-receipts.json", + "baselineReceipt": "docs/release/graph-release-receipt.json", "comparison": "recorded" }, { @@ -439,7 +439,7 @@ "value": 172, "unit": "ms", "baselineIssue": "#19", - "baselineReceipt": "packages/fixtures/graph-reference-evidence/baseline-receipts.json", + "baselineReceipt": "docs/release/graph-release-receipt.json", "comparison": "recorded" }, { @@ -447,7 +447,7 @@ "value": 175, "unit": "ms", "baselineIssue": "#19", - "baselineReceipt": "packages/fixtures/graph-reference-evidence/baseline-receipts.json", + "baselineReceipt": "docs/release/graph-release-receipt.json", "comparison": "recorded" }, { @@ -455,7 +455,7 @@ "value": 175, "unit": "ms", "baselineIssue": "#19", - "baselineReceipt": "packages/fixtures/graph-reference-evidence/baseline-receipts.json", + "baselineReceipt": "docs/release/graph-release-receipt.json", "comparison": "recorded" }, { @@ -463,7 +463,7 @@ "value": 192512, "unit": "bytes", "baselineIssue": "#19", - "baselineReceipt": "packages/fixtures/graph-reference-evidence/baseline-receipts.json", + "baselineReceipt": "docs/release/graph-release-receipt.json", "comparison": "recorded" }, { @@ -471,7 +471,7 @@ "value": 350232, "unit": "bytes", "baselineIssue": "#19", - "baselineReceipt": "packages/fixtures/graph-reference-evidence/baseline-receipts.json", + "baselineReceipt": "docs/release/graph-release-receipt.json", "comparison": "recorded" } ], @@ -498,19 +498,19 @@ "dist/sidecar.d.ts", "dist/sidecar.d.ts.map" ], - "forbiddenMarkersAbsent": true, - "generatedBuildMetadataAbsent": true, - "privatePathsAbsent": true, - "pythonCrgSourceAbsent": true, - "pythonGraphPackageMetadataAbsent": true, - "pythonCrgGitHistoryAbsent": true, - "forbiddenImplementationPackageNamesAbsent": true, "inspections": [ "npm-pack-dry-run", "package-file-scan", "package-content-scan", "provenance-marker-scan" - ] + ], + "forbiddenMarkersAbsent": true, + "generatedBuildMetadataAbsent": true, + "privatePathsAbsent": true, + "sourceProvenanceAbsent": true, + "packageMetadataAbsent": true, + "gitHistoryAbsent": true, + "foreignImplementationNamesAbsent": true }, "reportReceipts": [ { @@ -598,19 +598,19 @@ "issue": "#7", "receiptPath": "docs/release/graph-release-receipt.payload.json", "checksumSha256": "58ab75e806803f02a94286de55a5c625b5173bb476aec6e785d69450cca09315", - "rollbackNote": "Keep ACE wrappers on current external tools if receipt regresses." + "rollbackNote": "Block release and repair Opcore self-validation if receipt regresses." }, { "issue": "#28", "receiptPath": "docs/release/graph-release-receipt.payload.json", "checksumSha256": "58ab75e806803f02a94286de55a5c625b5173bb476aec6e785d69450cca09315", - "rollbackNote": "Keep ACE wrappers on current external tools if receipt regresses." + "rollbackNote": "Block release and repair Opcore self-validation if receipt regresses." }, { "issue": "#29", "receiptPath": "docs/release/graph-release-receipt.payload.json", "checksumSha256": "58ab75e806803f02a94286de55a5c625b5173bb476aec6e785d69450cca09315", - "rollbackNote": "Keep ACE wrappers on current external tools if receipt regresses." + "rollbackNote": "Block release and repair Opcore self-validation if receipt regresses." } ], "supportedNativeTargets": [ diff --git a/packages/fixtures/graph-robustness/watch-roots/.code-review-graphignore b/packages/fixtures/graph-robustness/watch-roots/.code-review-graphignore deleted file mode 100644 index e4df439..0000000 --- a/packages/fixtures/graph-robustness/watch-roots/.code-review-graphignore +++ /dev/null @@ -1 +0,0 @@ -crg-ignored/** diff --git a/packages/fixtures/graph-robustness/watch-roots/.gitignore b/packages/fixtures/graph-robustness/watch-roots/.gitignore new file mode 100644 index 0000000..8947f55 --- /dev/null +++ b/packages/fixtures/graph-robustness/watch-roots/.gitignore @@ -0,0 +1 @@ +policy-ignored/** diff --git a/packages/fixtures/graph-robustness/watch-roots/crg-ignored/drop.ts b/packages/fixtures/graph-robustness/watch-roots/crg-ignored/drop.ts deleted file mode 100644 index 90d479a..0000000 --- a/packages/fixtures/graph-robustness/watch-roots/crg-ignored/drop.ts +++ /dev/null @@ -1 +0,0 @@ -export const ignoredByCrgIgnore = true; diff --git a/packages/fixtures/graph-serve/serve-fixtures.json b/packages/fixtures/graph-serve/serve-fixtures.json new file mode 100644 index 0000000..4b391a9 --- /dev/null +++ b/packages/fixtures/graph-serve/serve-fixtures.json @@ -0,0 +1,35 @@ +{ + "schemaVersion": 1, + "issue": "#47", + "origin": "covibes-authored-synthetic", + "protocols": ["opcore.graph.daemon", "jsonrpc-2.0"], + "envelopes": [ + { + "id": "serve-jsonl-ping", + "direction": "client_to_daemon", + "protocol": "opcore.graph.daemon", + "payload": { + "protocol": "opcore.graph.daemon", + "requestId": "serve-ping-1", + "schemaVersion": 1, + "operation": "ping", + "repo": { + "repoId": "synthetic" + } + } + }, + { + "id": "mcp-initialize", + "direction": "client_to_daemon", + "protocol": "jsonrpc-2.0", + "payload": { + "jsonrpc": "2.0", + "id": "mcp-init-1", + "method": "initialize", + "params": { + "protocolVersion": "2024-11-05" + } + } + } + ] +} diff --git a/packages/fixtures/package.json b/packages/fixtures/package.json index 1d0e1a4..895babb 100644 --- a/packages/fixtures/package.json +++ b/packages/fixtures/package.json @@ -43,11 +43,11 @@ "descriptors", "graph-search", "graph-release", + "graph-serve", "graph-query", "graph-pipeline", "validation-contract", "validation-python", - "graph-reference-evidence", "inspect-symbol-parity", "source-extraction", "README.md" diff --git a/packages/fixtures/source-extraction/wave1/wave1.expected.json b/packages/fixtures/source-extraction/wave1/wave1.expected.json index 534ff8e..9d2bf1c 100644 --- a/packages/fixtures/source-extraction/wave1/wave1.expected.json +++ b/packages/fixtures/source-extraction/wave1/wave1.expected.json @@ -362,6 +362,21 @@ "class:src/models.ts#GreetingModel", "test:src/__tests__/greeting.test.ts#renders greeting cards" ], + [ + "TESTED_BY", + "file:src/components/GreetingCard.tsx", + "file:src/__tests__/greeting.test.ts" + ], + [ + "TESTED_BY", + "file:src/math.js", + "file:src/__tests__/greeting.test.ts" + ], + [ + "TESTED_BY", + "file:src/models.ts", + "file:src/__tests__/greeting.test.ts" + ], [ "TESTED_BY", "function:src/components/GreetingCard.tsx#GreetingCard", diff --git a/packages/fixtures/src/index.ts b/packages/fixtures/src/index.ts index c495555..4beb3a8 100644 --- a/packages/fixtures/src/index.ts +++ b/packages/fixtures/src/index.ts @@ -23,11 +23,6 @@ export const fixtureIds = [ "inspect-symbol-parity-v1", "validation-contract-v1", "installed-artifact-smoke-v1", - "graph-reference-evidence-manifest-v1", - "graph-reference-evidence-sqlite-fixtures-v1", - "graph-reference-evidence-daemon-socket-fixtures-v1", - "graph-reference-evidence-golden-corpus-v1", - "graph-reference-evidence-baseline-receipts-v1", "graph-release-readiness-v1" ] as const; @@ -719,13 +714,13 @@ export const conformanceFixtureMetadata = [ id: "graph-serve-transport-v1", packageTrack: "fixtures", status: "graph_serve_transport", - dataFile: "packages/fixtures/graph-reference-evidence/daemon-socket-fixtures.json", + dataFile: "packages/fixtures/graph-serve/serve-fixtures.json", graphServe: { commands: ["serve"], protocols: ["opcore.graph.daemon", "jsonrpc-2.0"], operations: ["ping", "status", "query", "search", "shutdown"], failureStates: ["required_missing", "stale", "schema_mismatch", "daemon_unavailable", "error"], - dataFile: "packages/fixtures/graph-reference-evidence/daemon-socket-fixtures.json" + dataFile: "packages/fixtures/graph-serve/serve-fixtures.json" } }, { @@ -838,56 +833,6 @@ export const conformanceFixtureMetadata = [ optionalSurfaces: ["#13:coverage:deferred", "#14:flows:deferred", "#15:communities:deferred", "#16:read_only_suggestions:deferred"] } }, - { - origin: fixtureOrigin, - containsSourceCode: false, - issue: "#19", - schemaVersion: 1, - id: "graph-reference-evidence-manifest-v1", - packageTrack: "fixtures", - status: "graph_reference_evidence_manifest", - dataFile: "packages/fixtures/graph-reference-evidence/manifest.json" - }, - { - origin: fixtureOrigin, - containsSourceCode: false, - issue: "#19", - schemaVersion: 1, - id: "graph-reference-evidence-sqlite-fixtures-v1", - packageTrack: "fixtures", - status: "graph_reference_evidence_sqlite_fixture", - dataFile: "packages/fixtures/graph-reference-evidence/sqlite-fixtures.json" - }, - { - origin: fixtureOrigin, - containsSourceCode: false, - issue: "#19", - schemaVersion: 1, - id: "graph-reference-evidence-daemon-socket-fixtures-v1", - packageTrack: "fixtures", - status: "graph_reference_evidence_daemon_socket_fixture", - dataFile: "packages/fixtures/graph-reference-evidence/daemon-socket-fixtures.json" - }, - { - origin: fixtureOrigin, - containsSourceCode: false, - issue: "#19", - schemaVersion: 1, - id: "graph-reference-evidence-golden-corpus-v1", - packageTrack: "fixtures", - status: "graph_reference_evidence_golden_corpus", - dataFile: "packages/fixtures/graph-reference-evidence/golden-corpus.json" - }, - { - origin: fixtureOrigin, - containsSourceCode: false, - issue: "#19", - schemaVersion: 1, - id: "graph-reference-evidence-baseline-receipts-v1", - packageTrack: "fixtures", - status: "graph_reference_evidence_baseline_receipts", - dataFile: "packages/fixtures/graph-reference-evidence/baseline-receipts.json" - }, { origin: fixtureOrigin, containsSourceCode: false, diff --git a/packages/graph/README.md b/packages/graph/README.md index 91ceead..87b0487 100644 --- a/packages/graph/README.md +++ b/packages/graph/README.md @@ -4,4 +4,4 @@ Opcore graph provider package for repository graph extraction, query, search, an The package also owns canonical Python repo-import analysis. `analyzePythonImports` accepts only supplied `.py`/`.pyi` after-state files, materializes them in an isolated temporary repository, runs the installed graph-core build/query path, returns sorted directed `IMPORTS_FROM` file edges, and always removes temporary state. It never reads or writes the target worktree. -`createEphemeralGraphSnapshot` materializes a complete, bounded validation-visible source universe under an isolated root, builds graph-core once, binds query metadata to the logical target repository, and provides idempotent recursive disposal. Incomplete/truncated listings and file, depth, byte, build, or query failures are loud; target sources and persistent graph artifacts are never mutated. +`createEphemeralGraphSnapshot` materializes a complete, bounded validation-visible source universe plus root `tsconfig.json` under an isolated root, builds graph-core once, binds query metadata to the logical target repository, and provides idempotent recursive disposal. Root TypeScript aliases therefore resolve against the exact before/after state. Incomplete/truncated listings and file, depth, byte, build, or query failures are loud; target sources and persistent graph artifacts are never mutated. diff --git a/packages/graph/src/ephemeral-snapshot.ts b/packages/graph/src/ephemeral-snapshot.ts index 0e284f5..dcea7a5 100644 --- a/packages/graph/src/ephemeral-snapshot.ts +++ b/packages/graph/src/ephemeral-snapshot.ts @@ -76,9 +76,9 @@ export async function createEphemeralGraphSnapshotWithOperations( ): Promise { const paths = normalizeUniverse(options.sourceUniverse); const limits = normalizeLimits(options.limits); - const sourcePaths = paths.filter(isSupportedGraphSourcePath); - enforcePathLimits(sourcePaths, limits); - const materialized = await materializeSnapshotSources(sourcePaths, options.readFile, limits); + const materializationPaths = paths.filter(isGraphMaterializationPath); + enforcePathLimits(materializationPaths, limits); + const materialized = await materializeSnapshotSources(materializationPaths, options.readFile, limits); let disposed = false; try { const build = operations.build(materialized.repo); @@ -187,8 +187,8 @@ function writeSourceFile(repoRoot: string, path: string, content: string): void writeFileSync(absolutePath, content); } -function isSupportedGraphSourcePath(path: string): boolean { - return /\.(?:tsx?|[cm]ts|jsx?|pyi?|rs)$/u.test(path); +function isGraphMaterializationPath(path: string): boolean { + return path === "tsconfig.json" || /\.(?:tsx?|[cm]ts|jsx?|pyi?|rs)$/u.test(path); } function bindStatus(status: GraphProviderStatus, mode: GraphProviderMode, logicalRepo: RepoIdentity): GraphProviderStatus { diff --git a/packages/opcore-graph-core-linux-x64/metadata.json b/packages/opcore-graph-core-linux-x64/metadata.json index fb3e3eb..68ec61c 100644 --- a/packages/opcore-graph-core-linux-x64/metadata.json +++ b/packages/opcore-graph-core-linux-x64/metadata.json @@ -4,6 +4,6 @@ "targetPlatform": "linux-x64", "binaryPath": "opcore-graph-core", "checksumPath": "opcore-graph-core.sha256", - "checksumSha256": "c3a771435a7a8172a9e5f0bb20b0d111453efc70901e95308094664f77e7bdf1", + "checksumSha256": "70402bf42a09c01ea01f72199ee1985684a217da940cd1454bd4614ba63b12cf", "buildProfile": "release" } diff --git a/packages/opcore-graph-core-linux-x64/opcore-graph-core b/packages/opcore-graph-core-linux-x64/opcore-graph-core index d347eb9..76df93b 100755 Binary files a/packages/opcore-graph-core-linux-x64/opcore-graph-core and b/packages/opcore-graph-core-linux-x64/opcore-graph-core differ diff --git a/packages/opcore-graph-core-linux-x64/opcore-graph-core.sha256 b/packages/opcore-graph-core-linux-x64/opcore-graph-core.sha256 index 9ef64d0..4022cb9 100644 --- a/packages/opcore-graph-core-linux-x64/opcore-graph-core.sha256 +++ b/packages/opcore-graph-core-linux-x64/opcore-graph-core.sha256 @@ -1 +1 @@ -c3a771435a7a8172a9e5f0bb20b0d111453efc70901e95308094664f77e7bdf1 opcore-graph-core +70402bf42a09c01ea01f72199ee1985684a217da940cd1454bd4614ba63b12cf opcore-graph-core diff --git a/packages/opcore/src/advanced/asp-warm/warm-project-registry.ts b/packages/opcore/src/advanced/asp-warm/warm-project-registry.ts index 0fd99ac..d30fc80 100644 --- a/packages/opcore/src/advanced/asp-warm/warm-project-registry.ts +++ b/packages/opcore/src/advanced/asp-warm/warm-project-registry.ts @@ -1,12 +1,15 @@ -import { existsSync, readFileSync, realpathSync, readdirSync } from "node:fs"; -import { join, relative, resolve } from "node:path"; +import { existsSync, readFileSync, realpathSync } from "node:fs"; +import { join, resolve } from "node:path"; import type { Project, SourceFile } from "ts-morph"; import type { CommandTimingProcessState } from "@the-open-engine/opcore-contracts"; -import { isSupportedSymbolSourcePath } from "@the-open-engine/opcore-edit"; +import { + isPathInside as isInside, + listSymbolEditLanguageServiceSourceFiles +} from "@the-open-engine/opcore-edit"; import { createInspectLanguageServiceProject, type InspectLanguageServiceProjectScope -} from "../inspect-language-service.js"; +} from "../inspect-typescript-project.js"; export type WarmProjectScope = InspectLanguageServiceProjectScope; @@ -203,51 +206,16 @@ function refreshSourceFile(sourceFile: SourceFile): void { function currentSourceFileFingerprint(repoRoot: string, scope: WarmProjectScope): string { if (scope !== "whole_repo") return "scoped"; - return listWarmSourceFiles(repoRoot).join("\n"); + return listSymbolEditLanguageServiceSourceFiles(repoRoot).join("\n"); } function addWarmEditSourceFiles(project: Project, repoRoot: string, scope: WarmProjectScope): void { if (scope !== "whole_repo") return; - for (const filePath of listWarmSourceFiles(repoRoot)) { + for (const filePath of listSymbolEditLanguageServiceSourceFiles(repoRoot)) { if (project.getSourceFile(filePath) === undefined) project.addSourceFileAtPath(filePath); } } -function listWarmSourceFiles(repoRoot: string): string[] { - const files: string[] = []; - visit(repoRoot); - return files.sort(); - - function visit(directory: string): void { - for (const entry of readdirSync(directory, { withFileTypes: true }).sort((left, right) => left.name.localeCompare(right.name))) { - const path = join(directory, entry.name); - if (entry.isDirectory()) { - if (!excludedDirectories.has(entry.name)) visit(path); - } else if (entry.isFile() && isSupportedSymbolSourcePath(path) && isInside(repoRoot, path)) { - files.push(resolve(path)); - } - } - } -} - -const excludedDirectories = new Set([ - ".ace", - ".agents", - ".claude", - ".codex", - ".gemini", - ".git", - ".lattice", - ".opencode", - ".pnpm", - ".robustness-engine-cache", - ".rox-cache", - "dist", - "node_modules", - "target", - "vendor" -]); - function currentGitEpoch(repoRoot: string): string { const gitDir = resolveGitDir(repoRoot); if (gitDir === undefined) return "nogit"; @@ -282,8 +250,3 @@ function realpathIfPossible(path: string): string { return path; } } - -function isInside(root: string, target: string): boolean { - const relativePath = relative(resolve(root), resolve(target)); - return relativePath === "" || (!relativePath.startsWith("..") && !relativePath.startsWith("/") && !/^[A-Za-z]:/.test(relativePath)); -} diff --git a/packages/opcore/src/advanced/inspect-language-service.ts b/packages/opcore/src/advanced/inspect-language-service.ts index dee29f8..788f070 100644 --- a/packages/opcore/src/advanced/inspect-language-service.ts +++ b/packages/opcore/src/advanced/inspect-language-service.ts @@ -1,5 +1,5 @@ -import { existsSync, readFileSync, realpathSync, readdirSync, statSync } from "node:fs"; -import { dirname, extname, join, relative, resolve } from "node:path"; +import { realpathSync } from "node:fs"; +import { extname, relative, resolve } from "node:path"; import type { GraphFactEdge, GraphNodeKind, @@ -15,18 +15,34 @@ import type { InspectSymbolSummary, InspectSymbolTarget } from "@the-open-engine/opcore-contracts"; +import { + isPathInside as isInside, + isSafeExistingFileInsideRepo, + normalizeModulePath, + type TypeScriptProjectContext +} from "@the-open-engine/opcore-edit"; import { Node, - Project, SyntaxKind, - ts, type ClassDeclaration, type InterfaceDeclaration, type ParameterDeclaration, + type Project, type SourceFile, type Symbol as MorphSymbol, type TypeParameterDeclaration } from "ts-morph"; +import { + inspectProjectService, + type InspectLanguageServiceOptions, + type InspectLanguageServiceProjectScope +} from "./inspect-typescript-project.js"; + +export { + createInspectLanguageServiceProject, + type InspectLanguageServiceOptions, + type InspectLanguageServiceProjectScope +} from "./inspect-typescript-project.js"; export interface InspectReferenceRequest { path: string; @@ -101,38 +117,7 @@ export type InspectImplementationResolution = candidates?: readonly InspectSymbolTarget[]; }; -export type InspectLanguageServiceProjectScope = "import_closure" | "whole_repo"; - -export interface InspectLanguageServiceOptions { - project?: Project; - projectScope?: InspectLanguageServiceProjectScope; - projectTsconfigPath?: string; - includeDependents?: boolean; - snapshotProject?: (project: Project) => unknown; - revertProject?: (project: Project, snapshot: unknown) => void; -} - -const inspectProjectScopes = new WeakMap(); - -const sourceFileExtensions = new Set([".ts", ".tsx", ".js", ".jsx"]); const implementationSourceFileExtensions = new Set([".ts", ".tsx"]); -const excludedDirectories = new Set([ - ".ace", - ".agents", - ".claude", - ".codex", - ".gemini", - ".git", - ".lattice", - ".opencode", - ".pnpm", - ".robustness-engine-cache", - ".rox-cache", - "dist", - "node_modules", - "target", - "vendor" -]); type ReferenceEntry = { getNode(): Node; @@ -150,7 +135,7 @@ type ReferenceFindableNode = Node & { }; export function isSupportedInspectSourcePath(path: string): boolean { - return sourceFileExtensions.has(extname(path).toLowerCase()); + return inspectProjectService.isSupportedSourcePath(path); } export function isSupportedInspectImplementationSourcePath(path: string): boolean { @@ -183,8 +168,11 @@ export function resolveInspectSignatures( } try { - for (const context of createProjectContexts(normalizedRepoRoot, request.path, options)) { - const resolution = withProjectSnapshot(context, () => resolveInspectSignatureInProject(normalizedRepoRoot, context, absoluteTargetPath, request, baseTarget)); + for (const context of inspectProjectService.createContexts(normalizedRepoRoot, request.path, options)) { + const resolution = inspectProjectService.withSnapshot( + context, + () => resolveInspectSignatureInProject(normalizedRepoRoot, context, absoluteTargetPath, request, baseTarget) + ); return resolution; } return { @@ -287,12 +275,12 @@ export function resolveInspectReferences( try { const projectScope = options.projectScope ?? "import_closure"; const includeDependents = projectScope === "whole_repo" ? options.includeDependents === true : true; - for (const context of createProjectContexts(normalizedRepoRoot, request.path, { + for (const context of inspectProjectService.createContexts(normalizedRepoRoot, request.path, { ...options, includeDependents, projectScope })) { - const resolution = withProjectSnapshot(context, () => { + const resolution = inspectProjectService.withSnapshot(context, () => { const sourceFile = context.project.getSourceFile(absoluteTargetPath) ?? context.project.addSourceFileAtPath(absoluteTargetPath); const target = findReferenceTarget(normalizedRepoRoot, sourceFile, request); if (!target.ok) return target; @@ -351,8 +339,12 @@ export function resolveInspectImplementations( try { const projectScope: InspectLanguageServiceProjectScope = request.allowGraphless ? "whole_repo" : (options.projectScope ?? "import_closure"); - for (const context of createProjectContexts(normalizedRepoRoot, preflight.path, { ...options, projectScope })) { - const resolution = withProjectSnapshot(context, () => { + for (const context of inspectProjectService.createContexts( + normalizedRepoRoot, + preflight.path, + { ...options, projectScope } + )) { + const resolution = inspectProjectService.withSnapshot(context, () => { const targetResolution = resolveImplementationTargetInProject(context.project, normalizedRepoRoot, request, preflight.candidate); if (!targetResolution.ok) return targetResolution; const targetCandidate = targetResolution.candidate; @@ -1284,329 +1276,7 @@ function applyLimit(entries: readonly InspectReferenceEntry[], limit: number | u return limit === undefined ? entries : entries.slice(0, limit); } -type ProjectContext = { - project: Project; - tsconfigPath?: string; - snapshotProject?: (project: Project) => unknown; - revertProject?: (project: Project, snapshot: unknown) => void; -}; - -export function createInspectLanguageServiceProject( - repoRoot: string, - preferredRepoPath: string, - options: InspectLanguageServiceOptions = {} -): Project { - const preferredTsconfigPath = resolveInspectTsconfigPath(repoRoot, options.projectTsconfigPath) ?? tsconfigForInspectRoot(repoRoot); - return createProjectForTsconfig(repoRoot, preferredTsconfigPath, preferredRepoPath, { - includeDependents: options.includeDependents === true, - scope: options.projectScope ?? "import_closure" - }); -} - -function createProjectContexts(repoRoot: string, preferredRepoPath: string, options: InspectLanguageServiceOptions = {}): ProjectContext[] { - const projectScope = options.projectScope ?? "import_closure"; - const preferredTsconfigPath = resolveInspectTsconfigPath(repoRoot, options.projectTsconfigPath) ?? tsconfigForInspectRoot(repoRoot); - if (options.project !== undefined && canUseInjectedProject(options.project, projectScope)) { - if (projectScope === "import_closure") { - addScopedSourceFilesToProject(repoRoot, preferredTsconfigPath, options.project, [preferredRepoPath], { - includeDependents: options.includeDependents === true - }); - } - return [ - { - project: options.project, - ...(options.projectTsconfigPath ? { tsconfigPath: options.projectTsconfigPath } : {}), - ...(options.snapshotProject ? { snapshotProject: options.snapshotProject } : {}), - ...(options.revertProject ? { revertProject: options.revertProject } : {}) - } - ]; - } - return [ - { - project: createProjectForTsconfig(repoRoot, preferredTsconfigPath, preferredRepoPath, { - includeDependents: options.includeDependents === true, - scope: projectScope - }), - ...(preferredTsconfigPath ? { tsconfigPath: preferredTsconfigPath } : {}) - } - ]; -} - -function canUseInjectedProject(project: Project, requiredScope: InspectLanguageServiceProjectScope): boolean { - return requiredScope !== "whole_repo" || inspectProjectScopes.get(project) === "whole_repo"; -} - -function withProjectSnapshot(context: ProjectContext, run: () => T): T { - if (context.snapshotProject === undefined || context.revertProject === undefined) return run(); - const snapshot = context.snapshotProject(context.project); - try { - return run(); - } finally { - context.revertProject(context.project, snapshot); - } -} - -function createProjectForTsconfig( - repoRoot: string, - tsconfigPath: string | undefined, - preferredRepoPath: string, - options: { - includeDependents: boolean; - scope: InspectLanguageServiceProjectScope; - } -): Project { - const projectOptions = { - tsConfigFilePath: tsconfigPath, - skipAddingFilesFromTsConfig: true, - skipFileDependencyResolution: true, - compilerOptions: { - allowJs: true, - checkJs: false - } - }; - const project = new Project(projectOptions); - const sourceFiles = options.scope === "whole_repo" - ? listSourceFiles(repoRoot) - : scopedSourceFiles(repoRoot, tsconfigPath, [preferredRepoPath], { includeDependents: options.includeDependents }); - for (const filePath of sourceFiles) { - if (project.getSourceFile(filePath)) continue; - project.addSourceFileAtPath(filePath); - } - inspectProjectScopes.set(project, options.scope); - return project; -} - -function addScopedSourceFilesToProject( - repoRoot: string, - tsconfigPath: string | undefined, - project: Project, - rootRepoPaths: readonly string[], - options: { includeDependents: boolean } -): void { - for (const filePath of scopedSourceFiles(repoRoot, tsconfigPath, rootRepoPaths, options)) { - if (project.getSourceFile(filePath) === undefined) project.addSourceFileAtPath(filePath); - } -} - -interface ImportResolutionOptions { - baseUrl: string; - hasBaseUrl: boolean; - paths: Readonly>; -} - -type TsconfigJson = { - compilerOptions?: { - baseUrl?: unknown; - paths?: unknown; - }; -}; - -const extensionlessCandidates = [".ts", ".tsx", ".js", ".jsx", ".d.ts"] as const; - -function scopedSourceFiles( - repoRoot: string, - tsconfigPath: string | undefined, - rootRepoPaths: readonly string[], - options: { includeDependents: boolean } -): string[] { - const importOptions = importResolutionOptions(repoRoot, tsconfigPath); - const importTargetsByFile = new Map(); - const allSourceFiles = options.includeDependents ? listSourceFiles(repoRoot) : []; - const roots = rootSourceFiles(repoRoot, rootRepoPaths); - const reverseTargets = new Set(roots); - const selected = new Set(); - addForwardClosure(roots, selected); - - if (options.includeDependents) { - let changed = true; - while (changed) { - changed = false; - for (const filePath of allSourceFiles) { - if (selected.has(filePath)) continue; - const importsReverseTarget = importTargets(filePath).some((importedPath) => reverseTargets.has(importedPath)); - if (!importsReverseTarget) continue; - const beforeSize = selected.size; - addForwardClosure([filePath], selected); - reverseTargets.add(filePath); - if (selected.size !== beforeSize) changed = true; - } - } - } - - return [...selected].sort(); - - function addForwardClosure(rootFiles: readonly string[], selectedFiles: Set): void { - const pending = [...rootFiles].sort(); - for (let index = 0; index < pending.length; index += 1) { - const filePath = pending[index]; - if (selectedFiles.has(filePath)) continue; - selectedFiles.add(filePath); - for (const importedPath of importTargets(filePath)) { - if (!selectedFiles.has(importedPath) && !pending.includes(importedPath)) pending.push(importedPath); - } - } - } - - function importTargets(filePath: string): readonly string[] { - const cached = importTargetsByFile.get(filePath); - if (cached !== undefined) return cached; - const resolvedTargets = moduleImportSpecifiers(readFileSync(filePath, "utf8")) - .flatMap((specifier) => { - const resolvedImport = resolveImportSpecifier(repoRoot, filePath, specifier, importOptions); - return resolvedImport === undefined ? [] : [resolvedImport]; - }) - .sort(); - importTargetsByFile.set(filePath, resolvedTargets); - return resolvedTargets; - } -} - -function rootSourceFiles(repoRoot: string, rootRepoPaths: readonly string[]): string[] { - return uniqueSorted( - rootRepoPaths - .map((path) => resolve(repoRoot, path)) - .filter((path) => isSupportedInspectSourcePath(path) && isSafeExistingFileInsideRepo(repoRoot, path)) - ); -} - -function moduleImportSpecifiers(text: string): readonly string[] { - const specifiers = new Set(); - for (const match of text.matchAll(/\b(?:import|export)\s+(?:type\s+)?(?:[^"'`;]*?\s+from\s+)?["']([^"']+)["']/gu)) { - if (match[1]) specifiers.add(match[1]); - } - for (const match of text.matchAll(/ `${basePath}${candidateExtension}`), - ...extensionlessCandidates.map((candidateExtension) => join(basePath, `index${candidateExtension}`)) - ]); -} - -function importResolutionOptions(repoRoot: string, tsconfigPath: string | undefined): ImportResolutionOptions { - const configDirectory = tsconfigPath === undefined ? repoRoot : dirname(tsconfigPath); - const config = tsconfigPath === undefined ? undefined : parseTsconfigForImports(tsconfigPath); - const compilerOptions = config?.compilerOptions; - const baseUrl = typeof compilerOptions?.baseUrl === "string" && compilerOptions.baseUrl.length > 0 - ? resolve(configDirectory, compilerOptions.baseUrl) - : configDirectory; - return { - baseUrl, - hasBaseUrl: typeof compilerOptions?.baseUrl === "string" && compilerOptions.baseUrl.length > 0, - paths: normalizePathMappings(compilerOptions?.paths) - }; -} - -function parseTsconfigForImports(tsconfigPath: string): TsconfigJson | undefined { - try { - const parsed = ts.parseConfigFileTextToJson(tsconfigPath, readFileSync(tsconfigPath, "utf8")); - return parsed.error === undefined ? parsed.config as TsconfigJson : undefined; - } catch { - return undefined; - } -} - -function normalizePathMappings(paths: unknown): Readonly> { - if (paths === null || typeof paths !== "object" || Array.isArray(paths)) return {}; - const normalized: Record = {}; - for (const [pattern, targets] of Object.entries(paths)) { - if (Array.isArray(targets)) normalized[pattern] = targets.filter((target): target is string => typeof target === "string"); - } - return normalized; -} - -function sortedPathMappings(paths: Readonly>): readonly [string, readonly string[]][] { - return Object.entries(paths) - .filter((entry): entry is [string, readonly string[]] => entry[1].length > 0) - .sort((left, right) => pathPatternRank(right[0]) - pathPatternRank(left[0])); -} - -function pathPatternRank(pattern: string): number { - const starIndex = pattern.indexOf("*"); - if (starIndex === -1) return pattern.length * 2 + 1; - return pattern.length - 1; -} - -function matchPathPattern(pattern: string, specifier: string): string | undefined { - const starIndex = pattern.indexOf("*"); - if (starIndex === -1) return pattern === specifier ? "" : undefined; - const prefix = pattern.slice(0, starIndex); - const suffix = pattern.slice(starIndex + 1); - if (!specifier.startsWith(prefix) || !specifier.endsWith(suffix)) return undefined; - return specifier.slice(prefix.length, specifier.length - suffix.length); -} - -function applyPathMappingTarget(target: string, wildcard: string): string { - return target.includes("*") ? target.replaceAll("*", wildcard) : target; -} - -function tsconfigForInspectRoot(repoRoot: string): string | undefined { - const tsconfigPath = join(repoRoot, "tsconfig.json"); - return isSafeExistingFileInsideRepo(repoRoot, tsconfigPath) ? resolve(tsconfigPath) : undefined; -} - -function resolveInspectTsconfigPath(repoRoot: string, tsconfigPath: string | undefined): string | undefined { - if (tsconfigPath === undefined) return undefined; - const absolutePath = resolve(repoRoot, tsconfigPath); - return isSafeExistingFileInsideRepo(repoRoot, absolutePath) ? absolutePath : undefined; -} - -function listSourceFiles(repoRoot: string): string[] { - const files: string[] = []; - visit(repoRoot); - return files.sort(); - - function visit(directory: string): void { - for (const entry of readdirSync(directory, { withFileTypes: true }).sort((left: { name: string }, right: { name: string }) => left.name.localeCompare(right.name))) { - const path = join(directory, entry.name); - if (entry.isDirectory()) { - if (!excludedDirectories.has(entry.name)) visit(path); - } else if (entry.isFile() && isSupportedInspectSourcePath(path) && isSafeExistingFileInsideRepo(repoRoot, path)) { - files.push(resolve(path)); - } - } - } -} +type ProjectContext = TypeScriptProjectContext; function symbolIdentity(node: Node): string { const declaration = symbolDeclaration(node.getSymbol()) ?? node; @@ -1773,51 +1443,6 @@ function compareSignatureEntries(left: InspectSignatureEntry, right: InspectSign ); } -function isSafeExistingFileInsideRepo(repoRoot: string, absolutePath: string): boolean { - if (!isInside(repoRoot, absolutePath) || !existsSync(absolutePath)) return false; - try { - return isInside(repoRoot, realpathSync(absolutePath)) && statSync(absolutePath).isFile(); - } catch { - return false; - } -} - -function isInside(root: string, target: string): boolean { - const relativePath = relative(resolve(root), resolve(target)); - return relativePath === "" || (!relativePath.startsWith("..") && !relativePath.startsWith("/") && !/^[A-Za-z]:/.test(relativePath)); -} - -function normalizeModulePath(path: string): string { - return path.replaceAll("\\", "/"); -} - -function isRepoResolvableSpecifier(specifier: string): boolean { - return specifier.length > 0 && !specifier.startsWith("/") && !specifier.includes("://"); -} - -function isRelativeSpecifier(specifier: string): boolean { - return specifier.startsWith("./") || specifier.startsWith("../"); -} - -function sourceExtension(path: string): string | undefined { - if (path.endsWith(".d.ts")) return ".d.ts"; - const match = /\.[^./]+$/u.exec(path); - return match?.[0]; -} - -function replaceExtension(path: string, extension: string): string { - if (path.endsWith(".d.ts")) return `${path.slice(0, -".d.ts".length)}${extension}`; - return path.replace(/\.[^./]+$/u, extension); -} - -function uniqueSorted(values: readonly string[]): string[] { - return [...new Set(values)].sort(); -} - -function unique(values: readonly string[]): readonly string[] { - return [...new Set(values)]; -} - function errorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error); } diff --git a/packages/opcore/src/advanced/inspect-typescript-project.ts b/packages/opcore/src/advanced/inspect-typescript-project.ts new file mode 100644 index 0000000..20cab76 --- /dev/null +++ b/packages/opcore/src/advanced/inspect-typescript-project.ts @@ -0,0 +1,30 @@ +import { + TypeScriptProjectService, + defaultTypeScriptProjectExcludedDirectories, + type TypeScriptProjectOptions, + type TypeScriptProjectScope +} from "@the-open-engine/opcore-edit"; +import type { Project } from "ts-morph"; + +export type InspectLanguageServiceProjectScope = TypeScriptProjectScope; + +export interface InspectLanguageServiceOptions extends TypeScriptProjectOptions {} + +export const inspectProjectService = new TypeScriptProjectService({ + sourceExtensions: [".ts", ".tsx", ".js", ".jsx"], + extensionlessImportCandidates: [".ts", ".tsx", ".js", ".jsx", ".d.ts"], + excludedDirectories: defaultTypeScriptProjectExcludedDirectories, + allowDirectoryRoots: false, + configMode: "root", + discoverAllConfigs: false, + tolerateMalformedImportConfig: true, + defaultIncludeDependents: false +}); + +export function createInspectLanguageServiceProject( + repoRoot: string, + preferredRepoPath: string, + options: InspectLanguageServiceOptions = {} +): Project { + return inspectProjectService.createProject(repoRoot, preferredRepoPath, options); +} diff --git a/packages/opcore/src/doctor.ts b/packages/opcore/src/doctor.ts index d85c0c9..686b537 100644 --- a/packages/opcore/src/doctor.ts +++ b/packages/opcore/src/doctor.ts @@ -12,11 +12,7 @@ declare const process: { }; const helpArgs = new Set(["--help", "-h", "help"]); -const generatedStateIgnores = [ - ".opcore/", - ".rox-cache/", - ".robustness-engine-cache/" -]; +const generatedStateIgnores = [".opcore/"]; export async function routeOpcoreDoctor(argv: readonly string[], parsed: ParsedCommandArgv): Promise { const rest = parsed.args.slice(1); diff --git a/packages/opcore/src/init-action-helpers.ts b/packages/opcore/src/init-action-helpers.ts new file mode 100644 index 0000000..b14ac85 --- /dev/null +++ b/packages/opcore/src/init-action-helpers.ts @@ -0,0 +1,19 @@ +import type { OpcoreInitAction } from "@the-open-engine/opcore-contracts"; +import { AGENT_SKILL_PATHS } from "./init-constants.js"; +import type { InitScope } from "./init-types.js"; + +export function actionPath(scope: InitScope, path: string): string { + return scope === "global" ? `~/${path}` : path; +} + +export function createSkillActions(scope: InitScope, enabled: boolean): OpcoreInitAction[] { + if (!enabled) return []; + return AGENT_SKILL_PATHS.map((path) => ({ + kind: "write", + path: actionPath(scope, path), + targetScope: scope, + summary: "Install the Opcore agent skill.", + requiresApproval: true, + outsideOpcore: true + })); +} diff --git a/packages/opcore/src/init-actions-global.ts b/packages/opcore/src/init-actions-global.ts new file mode 100644 index 0000000..d3d9cbd --- /dev/null +++ b/packages/opcore/src/init-actions-global.ts @@ -0,0 +1,49 @@ +import type { OpcoreInitAction } from "@the-open-engine/opcore-contracts"; +import { + AGENT_GATE_HOOK_PATH, + CLAUDE_SETTINGS_PATH, + CODEX_HOOKS_PATH +} from "./init-constants.js"; +import { actionPath, createSkillActions } from "./init-action-helpers.js"; +import type { ParsedInitArgs } from "./init-types.js"; + +export function createGlobalInitActions(options: ParsedInitArgs): OpcoreInitAction[] { + return [ + ...globalHookAction(options.writeGateHooks), + ...createSkillActions("global", options.agentSkill), + ...globalHarnessActions(options.writeGateHooks) + ]; +} + +function globalHookAction(enabled: boolean): OpcoreInitAction[] { + return enabled ? [{ + kind: "create_hook", + path: actionPath("global", AGENT_GATE_HOOK_PATH), + targetScope: "global", + summary: "Install the global Opcore write-gate adapter script.", + requiresApproval: false, + outsideOpcore: false + }] : []; +} + +function globalHarnessActions(enabled: boolean): OpcoreInitAction[] { + if (!enabled) return []; + return [ + { + kind: "wire_harness", + path: actionPath("global", CLAUDE_SETTINGS_PATH), + targetScope: "global", + summary: "Merge the Opcore Claude Code PreToolUse write gate.", + requiresApproval: true, + outsideOpcore: true + }, + { + kind: "wire_harness", + path: actionPath("global", CODEX_HOOKS_PATH), + targetScope: "global", + summary: "Merge the Opcore Codex PreToolUse write gate guardrail.", + requiresApproval: true, + outsideOpcore: true + } + ]; +} diff --git a/packages/opcore/src/init-actions-repo.ts b/packages/opcore/src/init-actions-repo.ts new file mode 100644 index 0000000..844f934 --- /dev/null +++ b/packages/opcore/src/init-actions-repo.ts @@ -0,0 +1,93 @@ +import type { OpcoreInitAction } from "@the-open-engine/opcore-contracts"; +import { + ACTIVE_PRE_COMMIT_HOOK_PATH, + AGENT_GATE_HOOK_PATH, + CLAUDE_SETTINGS_PATH, + CODEX_HOOKS_PATH, + CONFIG_PATH, + FAIL_CLOSED_HOOK_ACTIVATION_COMMAND, + GITIGNORE_PATH, + HOOK_PATH +} from "./init-constants.js"; +import { createSkillActions } from "./init-action-helpers.js"; +import type { ParsedInitArgs } from "./init-types.js"; + +export interface RepoActionInput { + agentFiles: readonly string[]; + options: ParsedInitArgs; + gitignoreWritePlanned: boolean; + activePreCommitWritePlanned: boolean; +} + +export function createRepoInitActions(input: RepoActionInput): OpcoreInitAction[] { + const actions = [ + configAction(), + ...guidanceActions(input.agentFiles), + ...createSkillActions("repo", input.options.agentSkill), + ...writeGateActions(input.options.writeGateHooks) + ]; + if (input.gitignoreWritePlanned) actions.push(gitignoreAction()); + if (input.activePreCommitWritePlanned) actions.push(preCommitAction()); + if (input.options.failClosedHook) actions.push(failClosedAction()); + return actions; +} + +function configAction(): OpcoreInitAction { + return { + kind: "write", path: CONFIG_PATH, targetScope: "repo", + summary: "Write additive Opcore init config.", requiresApproval: false, outsideOpcore: false + }; +} + +function guidanceActions(agentFiles: readonly string[]): OpcoreInitAction[] { + return agentFiles.map((path) => ({ + kind: "upsert_block", path, targetScope: "repo", + summary: "Add or update delimited Opcore agent guidance.", + requiresApproval: true, outsideOpcore: true + })); +} + +function writeGateActions(enabled: boolean): OpcoreInitAction[] { + if (!enabled) return []; + return [ + { + kind: "create_hook", path: AGENT_GATE_HOOK_PATH, targetScope: "repo", + summary: "Install the repo-local Opcore write-gate adapter script.", + requiresApproval: false, outsideOpcore: false + }, + { + kind: "wire_harness", path: CLAUDE_SETTINGS_PATH, targetScope: "repo", + summary: "Merge the Opcore Claude Code PreToolUse write gate.", + requiresApproval: true, outsideOpcore: true + }, + { + kind: "wire_harness", path: CODEX_HOOKS_PATH, targetScope: "repo", + summary: "Merge the Opcore Codex PreToolUse write gate guardrail.", + requiresApproval: true, outsideOpcore: true + } + ]; +} + +function gitignoreAction(): OpcoreInitAction { + return { + kind: "write", path: GITIGNORE_PATH, targetScope: "repo", + summary: "Append managed .opcore/ gitignore entry.", requiresApproval: true, outsideOpcore: true + }; +} + +function preCommitAction(): OpcoreInitAction { + return { + kind: "create_hook", path: ACTIVE_PRE_COMMIT_HOOK_PATH, targetScope: "repo", + summary: "Install active Git pre-commit hook that runs `opcore check --changed`.", + requiresApproval: true, outsideOpcore: true + }; +} + +function failClosedAction(): OpcoreInitAction { + return { + kind: "create_hook", path: HOOK_PATH, targetScope: "repo", + summary: `Manual install required: create fail-closed pre-commit hook script; ` + + `activate with \`${FAIL_CLOSED_HOOK_ACTIVATION_COMMAND}\`.`, + requiresApproval: false, outsideOpcore: false + }; +} diff --git a/packages/opcore/src/init-actions.ts b/packages/opcore/src/init-actions.ts new file mode 100644 index 0000000..103747d --- /dev/null +++ b/packages/opcore/src/init-actions.ts @@ -0,0 +1,17 @@ +import type { OpcoreInitAction } from "@the-open-engine/opcore-contracts"; +import { createGlobalInitActions } from "./init-actions-global.js"; +import { createRepoInitActions } from "./init-actions-repo.js"; +import type { InitScope, ParsedInitArgs } from "./init-types.js"; + +export interface InitActionInput { + scope: InitScope; + agentFiles: readonly string[]; + options: ParsedInitArgs; + gitignoreWritePlanned: boolean; + activePreCommitWritePlanned: boolean; +} + +export function createInitActions(input: InitActionInput): OpcoreInitAction[] { + if (input.scope === "global") return createGlobalInitActions(input.options); + return createRepoInitActions(input); +} diff --git a/packages/opcore/src/init-apply.ts b/packages/opcore/src/init-apply.ts new file mode 100644 index 0000000..ff32cf8 --- /dev/null +++ b/packages/opcore/src/init-apply.ts @@ -0,0 +1,69 @@ +import { rmSync } from "node:fs"; +import { removeEmptyOpcoreHookDir } from "./init-files.js"; +import { + assertMutationPath, + resolveRepoPath +} from "./init-paths.js"; +import { + readUndoMetadata, + readUndoMetadataIfExists, + undoPathForScope +} from "./init-undo-metadata.js"; +import { + priorEntry, + restoreUndoEntry, + writeScopedFile +} from "./init-write.js"; +import type { + InitScope, + PlannedWrite, + UndoMetadata +} from "./init-types.js"; + +export function applyInit(root: string, scope: InitScope, writes: readonly PlannedWrite[]): void { + const scopedWrites = writes.filter((write) => write.targetScope === scope); + const previous = readUndoMetadataIfExists(root, scope); + const previousPaths = previous === undefined + ? [] + : previous.entries.map((entry) => entry.path); + const touchedPaths = uniqueStrings([ + ...previousPaths, + ...scopedWrites.map((write) => write.path), + undoPathForScope(scope) + ]); + for (const path of touchedPaths) assertMutationPath(root, path, `Opcore ${scope} init target`); + const metadata: UndoMetadata = { + schemaVersion: 1, + kind: scope === "global" ? "opcore_global_init_undo" : "opcore_init_undo", + ...(scope === "global" ? { homeRoot: root } : { repoRoot: root }), + entries: touchedPaths.map((path) => { + const previousEntry = previous?.entries.find((entry) => entry.path === path); + return previousEntry ?? + priorEntry(root, path, scopedWrites.find((write) => write.path === path)); + }) + }; + for (const write of scopedWrites) writeScopedFile(root, write); + writeScopedFile(root, { + kind: "write", path: undoPathForScope(scope), targetScope: scope, + content: `${JSON.stringify(metadata, null, 2)}\n` + }); +} + +export function applyUndo(root: string, scope: InitScope): void { + const metadata = readUndoMetadata(root, scope); + const undoPath = undoPathForScope(scope); + for (const entry of metadata.entries) { + assertMutationPath(root, entry.path, "Opcore init undo target"); + } + for (const entry of metadata.entries.filter((entry) => entry.path !== undoPath)) { + restoreUndoEntry(root, entry); + } + const undoEntry = metadata.entries.find((entry) => entry.path === undoPath); + if (undoEntry) restoreUndoEntry(root, undoEntry); + else rmSync(resolveRepoPath(root, undoPath), { force: true }); + removeEmptyOpcoreHookDir(root); +} + +function uniqueStrings(values: readonly string[]): string[] { + return [...new Set(values)]; +} diff --git a/packages/opcore/src/init-args.ts b/packages/opcore/src/init-args.ts new file mode 100644 index 0000000..7b179d3 --- /dev/null +++ b/packages/opcore/src/init-args.ts @@ -0,0 +1,116 @@ +import type { + InitScope, + OpcoreSetupCommand, + ParsedInitArgs +} from "./init-types.js"; + +declare const process: { cwd(): string }; + +type FlagHandler = (parsed: ParsedInitArgs) => void; + +interface RepoArgumentResult { + handled: boolean; + consumed: number; + message?: string; +} + +export function parseOpcoreInitArgs( + args: readonly string[], + command: OpcoreSetupCommand +): { ok: true; args: ParsedInitArgs } | { ok: false; message: string } { + const parsed = createDefaultArgs(command); + const handlers = createFlagHandlers(command); + for (let index = 0; index < args.length; index += 1) { + const repoArgument = parseRepoArgument(args, index, parsed, command); + if (repoArgument.message) return { ok: false, message: repoArgument.message }; + if (repoArgument.handled) { + index += repoArgument.consumed; + continue; + } + const handler = handlers[args[index]]; + if (!handler) return { ok: false, message: `opcore ${command}: unsupported argument ${args[index]}` }; + handler(parsed); + } + return { ok: true, args: parsed }; +} + +function createDefaultArgs(command: OpcoreSetupCommand): ParsedInitArgs { + return { + command, + repo: process.cwd(), + repoExplicit: false, + scope: "repo", + scopeExplicit: false, + approved: false, + dryRun: false, + failClosedHook: false, + agentSkill: command === "install", + writeGateHooks: true, + activePreCommitHook: command === "install", + undo: command === "uninstall" + }; +} + +function createFlagHandlers(command: OpcoreSetupCommand): Record { + const handlers: Record = { + "--global": (parsed) => setScope(parsed, "global"), + "--local": (parsed) => setScope(parsed, "repo"), + "--approve": approve, + "--yes": approve, + "--dry-run": (parsed) => { + parsed.dryRun = true; + }, + "--fail-closed-hook": (parsed) => { + parsed.failClosedHook = true; + } + }; + if (command === "install") { + handlers["--no-pre-commit"] = (parsed) => { + parsed.activePreCommitHook = false; + }; + handlers["--no-skill"] = (parsed) => { + parsed.agentSkill = false; + }; + } else { + handlers["--undo"] = (parsed) => { + parsed.undo = true; + }; + } + return handlers; +} + +function parseRepoArgument( + args: readonly string[], + index: number, + parsed: ParsedInitArgs, + command: OpcoreSetupCommand +): RepoArgumentResult { + const arg = args[index]; + if (arg === "--repo") { + const value = args[index + 1]; + if (!value || value.startsWith("--")) { + return { handled: true, consumed: 0, message: `opcore ${command}: --repo requires a path` }; + } + setRepo(parsed, value); + return { handled: true, consumed: 1 }; + } + if (!arg.startsWith("--repo=")) return { handled: false, consumed: 0 }; + const value = arg.slice("--repo=".length); + if (!value) return { handled: true, consumed: 0, message: `opcore ${command}: --repo requires a path` }; + setRepo(parsed, value); + return { handled: true, consumed: 0 }; +} + +function setRepo(parsed: ParsedInitArgs, repo: string): void { + parsed.repo = repo; + parsed.repoExplicit = true; + setScope(parsed, "repo"); +} + +function setScope(parsed: ParsedInitArgs, scope: InitScope): void { + parsed.scope = scope; + parsed.scopeExplicit = true; +} +function approve(parsed: ParsedInitArgs): void { + parsed.approved = true; +} diff --git a/packages/opcore/src/init-config.ts b/packages/opcore/src/init-config.ts new file mode 100644 index 0000000..4743995 --- /dev/null +++ b/packages/opcore/src/init-config.ts @@ -0,0 +1,59 @@ +import type { + OpcoreInitScanSummary, + OpcoreInitSettings +} from "@the-open-engine/opcore-contracts"; +import { CONFIG_PATH } from "./init-constants.js"; +import { isPlainObject } from "./init-data.js"; +import { readJsonObject } from "./init-files.js"; + +export interface InitConfigInput { + repoRoot: string; + failClosedHook: boolean; + activePreCommitHook: boolean; + writeGateHooks: boolean; + scan: OpcoreInitScanSummary; + settings: OpcoreInitSettings; +} + +export function createConfig(input: InitConfigInput): Record { + const existing = readJsonObject(input.repoRoot, CONFIG_PATH); + const hooks = isPlainObject(existing.hooks) ? existing.hooks : {}; + const guidance = isPlainObject(existing.guidance) ? existing.guidance : {}; + const onboarding = isPlainObject(existing.onboarding) ? existing.onboarding : {}; + return { + ...existing, + schemaVersion: 1, + kind: "opcore_init_config", + onboarding: { + ...onboarding, + scan: isPlainObject(onboarding.scan) ? onboarding.scan : input.scan, + languages: Array.isArray(onboarding.languages) ? onboarding.languages : input.settings.languages, + timingPayload: true + }, + guidance: { + ...guidance, + checkCommand: "opcore check --changed", + preserveExistingGuardrails: true, + treatUnsupportedCoverageHonestly: true, + directProductAuthority: "opcore" + }, + hooks: createHookConfig(hooks, input) + }; +} + +function createHookConfig( + existing: Record, + input: InitConfigInput +): Record { + const harnesses = input.writeGateHooks + ? ["claude-code", "codex"] + : Array.isArray(existing.harnesses) ? existing.harnesses : []; + return { + ...existing, + failClosedPreCommit: + existing.failClosedPreCommit === true || input.failClosedHook || input.activePreCommitHook, + activePreCommit: existing.activePreCommit === true || input.activePreCommitHook, + writeGate: existing.writeGate === true || input.writeGateHooks, + harnesses + }; +} diff --git a/packages/opcore/src/init-constants.ts b/packages/opcore/src/init-constants.ts new file mode 100644 index 0000000..c4fa0bb --- /dev/null +++ b/packages/opcore/src/init-constants.ts @@ -0,0 +1,28 @@ +export const HELP_ARGS = new Set(["--help", "-h", "help"]); +export const AGENT_FILE_CANDIDATES = [ + "AGENTS.md", + "CLAUDE.md", + "GEMINI.md", + ".github/copilot-instructions.md", + ".codex/AGENTS.md", + ".opencode/AGENTS.md" +] as const; + +export const BEGIN_MARKER = ""; +export const END_MARKER = ""; +export const CONFIG_PATH = ".opcore/config"; +export const UNDO_PATH = ".opcore/init-undo.json"; +export const HOOK_PATH = ".opcore/hooks/pre-commit-opcore-check.sh"; +export const AGENT_GATE_HOOK_PATH = ".opcore/hooks/opcore-agent-gate.mjs"; +export const REPO_AGENT_SKILL_PATH = ".agents/skills/opcore/SKILL.md"; +export const CLAUDE_AGENT_SKILL_PATH = ".claude/skills/opcore/SKILL.md"; +export const AGENT_SKILL_PATHS = [REPO_AGENT_SKILL_PATH, CLAUDE_AGENT_SKILL_PATH] as const; +export const CLAUDE_SETTINGS_PATH = ".claude/settings.json"; +export const CODEX_HOOKS_PATH = ".codex/hooks.json"; +export const ACTIVE_PRE_COMMIT_HOOK_PATH = ".git/hooks/pre-commit"; +export const FAIL_CLOSED_HOOK_ACTIVATION_COMMAND = + "cp .opcore/hooks/pre-commit-opcore-check.sh .git/hooks/pre-commit"; +export const GITIGNORE_PATH = ".gitignore"; +export const OPCORE_IGNORE_LINE = ".opcore/"; +export const DEFAULT_INIT_PROGRESS_INTERVAL_MS = 5000; +export const GLOBAL_UNDO_PATH = ".opcore/init-undo.json"; diff --git a/packages/opcore/src/init-context-payload.ts b/packages/opcore/src/init-context-payload.ts new file mode 100644 index 0000000..2395230 --- /dev/null +++ b/packages/opcore/src/init-context-payload.ts @@ -0,0 +1,26 @@ +import type { OpcoreInitPlanPayload } from "@the-open-engine/opcore-contracts"; +import type { InitContext, ParsedInitArgs } from "./init-types.js"; + +type InitContextPayload = Pick< + OpcoreInitPlanPayload, + "scan" | "settings" | "interaction" | "timings" +>; + +export function initContextPayload(context: InitContext): InitContextPayload { + return { + scan: context.scan, + settings: context.settings, + interaction: context.interaction, + timings: context.timings + }; +} + +export function initPayloadOptions( + options: ParsedInitArgs +): OpcoreInitPlanPayload["options"] { + return { + scope: options.scope, + failClosedHook: options.failClosedHook, + dryRun: options.dryRun + }; +} diff --git a/packages/opcore/src/init-data.ts b/packages/opcore/src/init-data.ts new file mode 100644 index 0000000..a3d1431 --- /dev/null +++ b/packages/opcore/src/init-data.ts @@ -0,0 +1,15 @@ +export function isPlainObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +export function trimRightPreserve(text: string): string { + return text.replace(/\s+$/u, ""); +} + +export function trimLeftPreserve(text: string): string { + return text.replace(/^\s+/u, ""); +} + +export function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/packages/opcore/src/init-files.ts b/packages/opcore/src/init-files.ts new file mode 100644 index 0000000..5f07453 --- /dev/null +++ b/packages/opcore/src/init-files.ts @@ -0,0 +1,32 @@ +import { readFileSync, readdirSync, rmSync } from "node:fs"; +import { + assertExistingRepoPath, + lstatIfExists, + repoPathExists, + resolveRepoPath +} from "./init-paths.js"; +import { isPlainObject } from "./init-data.js"; + +export function readJsonObject(root: string, path: string): Record { + const content = readOptionalRepoFile(root, path); + if (content === undefined) return {}; + const parsed = JSON.parse(content) as unknown; + if (!isPlainObject(parsed)) throw new Error(`${path} must contain a JSON object`); + return parsed; +} + +export function readJsonObjectIfExists(root: string, path: string): Record { + return repoPathExists(root, path) ? readJsonObject(root, path) : {}; +} + +export function readOptionalRepoFile(root: string, path: string): string | undefined { + if (!repoPathExists(root, path)) return undefined; + return readFileSync(assertExistingRepoPath(root, path, "Existing repo file", "file"), "utf8"); +} + +export function removeEmptyOpcoreHookDir(root: string): void { + const hooksDir = resolveRepoPath(root, ".opcore/hooks"); + if (!lstatIfExists(hooksDir)) return; + assertExistingRepoPath(root, ".opcore/hooks", "Opcore hooks directory", "directory"); + if (readdirSync(hooksDir).length === 0) rmSync(hooksDir, { recursive: true, force: true }); +} diff --git a/packages/opcore/src/init-format-summary.ts b/packages/opcore/src/init-format-summary.ts new file mode 100644 index 0000000..80663e2 --- /dev/null +++ b/packages/opcore/src/init-format-summary.ts @@ -0,0 +1,53 @@ +import type { OpcoreInitPlanPayload } from "@the-open-engine/opcore-contracts"; + +export function approvalLine( + payload: OpcoreInitPlanPayload, + applied: boolean, + approvalFlag?: string +): string { + if (payload.interaction.promptState === "requested") return "Approval: awaiting TTY response."; + if (payload.interaction.promptState === "declined") return "Approval: declined; no files written."; + if (applied) return "Approval: applied."; + const required = approvalFlag ?? (payload.mode === "undo" ? "--undo --approve" : "--approve"); + return payload.mode === "undo" + ? `Approval: required; rerun with ${required} to restore/remove recorded files.` + : `Approval: required; rerun with ${required} to write this setup.`; +} + +export function languageSummary(payload: OpcoreInitPlanPayload): string { + return payload.scan.languages.length === 0 + ? "none" + : payload.scan.languages.map((entry) => `${entry.language} ${entry.files}`).join(", "); +} + +export function unsupportedSummary(payload: OpcoreInitPlanPayload): string { + return payload.scan.unsupportedStacks.length === 0 + ? "none" + : payload.scan.unsupportedStacks.map((stack) => `${stack.language} ${stack.count}`).join(", "); +} + +export function degradedToolSummary(payload: OpcoreInitPlanPayload): string { + return payload.scan.degradedRustTools.length === 0 + ? "none" + : payload.scan.degradedRustTools.map((tool) => `${tool.adapter}:${tool.tool}`).join(", "); +} + +export function warningLines(payload: OpcoreInitPlanPayload): string[] { + return payload.warnings.length === 0 + ? [" none"] + : payload.warnings.map((warning) => ` ${warning}`); +} + +export function pythonManagerSummary(payload: OpcoreInitPlanPayload): string { + const managers = payload.settings.python?.dependencyManagers; + return managers?.length + ? managers.map((manager) => `${manager.kind}:${manager.path}`).join(", ") + : "none"; +} + +export function pythonEnvironmentSummary(payload: OpcoreInitPlanPayload): string { + const environments = payload.settings.python?.virtualEnvironments; + return environments?.length + ? environments.map((environment) => environment.path).join(", ") + : "none"; +} diff --git a/packages/opcore/src/init-format.ts b/packages/opcore/src/init-format.ts new file mode 100644 index 0000000..1bc8425 --- /dev/null +++ b/packages/opcore/src/init-format.ts @@ -0,0 +1,80 @@ +import type { OpcoreInitPlanPayload } from "@the-open-engine/opcore-contracts"; +import { + ACTIVE_PRE_COMMIT_HOOK_PATH, + CLAUDE_SETTINGS_PATH +} from "./init-constants.js"; +import type { OpcoreSetupCommand } from "./init-types.js"; +import { + approvalLine, + degradedToolSummary, + languageSummary, + pythonEnvironmentSummary, + pythonManagerSummary, + unsupportedSummary, + warningLines +} from "./init-format-summary.js"; + +export function formatSetupPlan( + payload: OpcoreInitPlanPayload, + applied: boolean, + command: OpcoreSetupCommand +): string { + if (command === "install") return formatInstallPlan(payload, applied); + if (command === "uninstall") return formatUninstallPlan(payload, applied); + return formatInitPlan(payload, applied); +} + +function formatInstallPlan(payload: OpcoreInitPlanPayload, applied: boolean): string { + const skillEnabled = payload.actions.some((action) => action.path.endsWith("/skills/opcore/SKILL.md")); + const hooksEnabled = payload.actions.some((action) => action.path.endsWith(CLAUDE_SETTINGS_PATH)); + const preCommitEnabled = payload.actions.some((action) => action.path === ACTIVE_PRE_COMMIT_HOOK_PATH); + const scanSummary = + `Analyzed ${payload.scan.totalFiles} files; validation=${payload.scan.validationStatus}; ` + + `diagnostics=${payload.scan.diagnosticCount}.`; + return [ + "Opcore install:", ` ${scanSummary}`, "Setup choices:", + `${skillEnabled ? "[x]" : "[ ]"} Install Opcore agent skill`, + `${hooksEnabled ? "[x]" : "[ ]"} Install Claude Code and Codex write-gate hooks`, + `${preCommitEnabled ? "[x]" : "[ ]"} Install Git pre-commit hook`, "", + formatInitPlan(payload, applied, "--yes") + ].join("\n"); +} + +function formatUninstallPlan(payload: OpcoreInitPlanPayload, applied: boolean): string { + return [ + "Opcore uninstall:", + " Restore or remove only files recorded in .opcore/init-undo.json.", + "", + formatInitPlan(payload, applied, "--yes") + ].join("\n"); +} + +function formatInitPlan(payload: OpcoreInitPlanPayload, applied: boolean, approvalFlag?: string): string { + const actions = payload.actions.map((action) => `- ${action.kind} ${action.path}: ${action.summary}`); + return [ + "Coverage:", ` files=${payload.scan.totalFiles}`, + ` graph-supported=${payload.scan.graphSupportedFiles}`, + ` validation-supported=${payload.scan.validationSupportedFiles}`, + ` validation-retained=${payload.scan.validationRetainedFiles}`, + ` unsupported=${unsupportedSummary(payload)}`, ` languages=${languageSummary(payload)}`, + ` degraded-validation-tools=${degradedToolSummary(payload)}`, + ` python-dependency-managers=${pythonManagerSummary(payload)}`, + ` python-virtualenvs=${pythonEnvironmentSummary(payload)}`, + "Findings:", ` diagnostics=${payload.scan.diagnosticCount}`, + ` validation=${payload.scan.validationStatus}`, + ` failed-checks=${payload.scan.failedChecks.length === 0 ? "none" : payload.scan.failedChecks.join(", ")}`, + ` graph=${payload.scan.graphState}`, ` activation=${payload.scan.activationLevel}`, + "Warnings:", ...warningLines(payload), payload.mode === "undo" ? "Undo:" : "Setup:", + `Repo: ${payload.repo.root}`, `Scope: ${payload.options.scope}`, `Mode: ${payload.mode}`, + `Approved: ${payload.approved ? "yes" : "no"}`, "Actions:", ...actions, + approvalLine(payload, applied, approvalFlag), "Timing:", + ` first-output-ms=${payload.timings.firstOutputMs} scan-ms=${payload.timings.scanMs} ` + + `total-ms=${payload.timings.totalMs}` + ].join("\n"); +} + +export function formatInteractiveOutcome(payload: OpcoreInitPlanPayload, command: OpcoreSetupCommand): string { + return payload.approved + ? `opcore ${command} applied\nApproval: applied.` + : `opcore ${command} declined\nApproval: declined; no files written.`; +} diff --git a/packages/opcore/src/init-gitignore.ts b/packages/opcore/src/init-gitignore.ts new file mode 100644 index 0000000..b4230aa --- /dev/null +++ b/packages/opcore/src/init-gitignore.ts @@ -0,0 +1,46 @@ +import { + OPCORE_IGNORE_LINE +} from "./init-constants.js"; +import type { ManagedLineUndoEntry } from "./init-types.js"; + +export function gitignoreIgnoresOpcore(content: string): boolean { + let ignored = false; + for (const rawLine of content.split(/\r\n|\n|\r/u)) { + const line = rawLine.trim(); + if (line.length === 0 || line.startsWith("#")) continue; + const negated = line.startsWith("!"); + const pattern = negated ? line.slice(1).trim() : line; + if (isOpcoreGitignorePattern(pattern)) ignored = !negated; + } + return ignored; +} + +function isOpcoreGitignorePattern(pattern: string): boolean { + return pattern === ".opcore" || + pattern === ".opcore/" || + pattern === "/.opcore" || + pattern === "/.opcore/" || + pattern === ".opcore/**" || + pattern === "/.opcore/**"; +} + +export function appendManagedGitignoreLine(existing: string | undefined): string { + if (existing === undefined || existing.length === 0) return `${OPCORE_IGNORE_LINE}\n`; + return `${existing.endsWith("\n") || existing.endsWith("\r") ? "" : "\n"}${OPCORE_IGNORE_LINE}\n`; +} + +export function removeManagedGitignoreLine( + current: string, + entry: ManagedLineUndoEntry +): { content: string; removed: boolean } { + if (entry.appended !== undefined && current.endsWith(entry.appended)) { + return { content: current.slice(0, -entry.appended.length), removed: true }; + } + const matchedChunks = current.match(/[^\r\n]*(?:\r\n|\n|\r|$)/gu); + const chunks = matchedChunks === null ? [] : matchedChunks; + const meaningful = chunks.filter((chunk) => chunk.length > 0); + const index = meaningful.findIndex((chunk) => chunk.replace(/(?:\r\n|\n|\r)$/u, "") === entry.line); + if (index < 0) return { content: current, removed: false }; + meaningful.splice(index, 1); + return { content: meaningful.join(""), removed: true }; +} diff --git a/packages/opcore/src/init-guidance.ts b/packages/opcore/src/init-guidance.ts new file mode 100644 index 0000000..abd8c9b --- /dev/null +++ b/packages/opcore/src/init-guidance.ts @@ -0,0 +1,73 @@ +import { + AGENT_FILE_CANDIDATES, + BEGIN_MARKER, + END_MARKER +} from "./init-constants.js"; +import { trimLeftPreserve, trimRightPreserve } from "./init-data.js"; +import { readOptionalRepoFile } from "./init-files.js"; +import { assertExistingRepoPath, repoPathExists } from "./init-paths.js"; + +export function detectAgentFiles(repoRoot: string): string[] { + const existing = AGENT_FILE_CANDIDATES.filter((path) => repoPathExists(repoRoot, path)); + for (const path of existing) { + assertExistingRepoPath(repoRoot, path, "Existing agent guidance file", "file"); + } + return existing.length > 0 ? [...existing] : ["AGENTS.md"]; +} + +export function upsertOpcoreBlock(existing: string | undefined): string { + const block = guidanceBlock(); + if (existing === undefined || existing.length === 0) return `${block}\n`; + const begin = existing.indexOf(BEGIN_MARKER); + const end = existing.indexOf(END_MARKER); + if ((begin === -1) !== (end === -1) || (begin !== -1 && end < begin)) { + throw new Error("existing Opcore init guidance markers are unbalanced"); + } + if (begin !== -1) { + const replacementEnd = end + END_MARKER.length; + return ( + `${trimRightPreserve(existing.slice(0, begin))}\n\n${block}\n` + + trimLeftPreserve(existing.slice(replacementEnd)) + ).replace(/\n{3,}/g, "\n\n"); + } + return `${trimRightPreserve(existing)}\n\n${block}\n`; +} + +export function agentGuidanceWrite(repoRoot: string, path: string): string { + return upsertOpcoreBlock(readOptionalRepoFile(repoRoot, path)); +} + +function guidanceBlock(): string { + return [ + BEGIN_MARKER, + "## Opcore", + "", + "- Run `opcore check --changed` before finalizing edits.", + "- Preserve existing repo lint/test/CI/pre-commit guardrails.", + "- Treat unsupported stacks and degraded tools honestly.", + "- For Python repos, require one configured per-project type authority; treat absent, conflicting, " + + "unavailable, or deferred authority and missing ruff/pytest as degraded coverage, not a pass.", + "- Use Opcore validation directly; ASP hosts retain their own decision authority.", + END_MARKER + ].join("\n"); +} + +export function opcoreAgentSkillContent(): string { + return [ + "---", + "name: opcore", + "description: Use when working in a repository that has installed Opcore robustness checks.", + "---", + "", + "# Opcore", + "", + "Use Opcore as the repository-local robustness gate for coding-agent edits.", + "", + "- Run `opcore status` to inspect activation and coverage before broad work.", + "- Run `opcore check --changed` before finalizing source edits.", + "- Treat unsupported stacks and degraded tools honestly; do not report them as clean coverage.", + "- Preserve existing lint, test, CI, pre-commit, and agent guardrails.", + "- The installed write gate is a hook guardrail for supported edit tools, not host authority.", + "" + ].join("\n"); +} diff --git a/packages/opcore/src/init-help.ts b/packages/opcore/src/init-help.ts new file mode 100644 index 0000000..e9cfc1d --- /dev/null +++ b/packages/opcore/src/init-help.ts @@ -0,0 +1,62 @@ +import type { OpcoreSetupCommand } from "./init-types.js"; + +export function opcoreSetupHelpMessage(command: OpcoreSetupCommand): string { + if (command === "install") return opcoreInstallHelpMessage(); + if (command === "uninstall") return opcoreUninstallHelpMessage(); + return opcoreInitHelpMessage(); +} + +function opcoreInitHelpMessage(): string { + return [ + "Usage:", + " opcore init [--repo ] [--local|--global] [--approve] [--json]", + " opcore init --undo --approve [--repo ] [--local|--global] [--json]", + "Flags:", + " --repo Repository root to set up.", + " --local Force repo-scoped setup.", + " --global Install the write gate in user-level agent settings.", + " --approve Apply the proposed additive setup.", + " --undo Revert files recorded in .opcore/init-undo.json.", + " --fail-closed-hook Add the optional fail-closed pre-commit hook.", + " --json Emit structured JSON.", + "Defaults:", + " Without --approve, init is plan-only outside an interactive approval prompt.", + " Inside a Git repo on a TTY, init asks whether to install for this repo or globally.", + "Examples:", + " opcore init --repo . --json", + " opcore init --repo . --approve", + " opcore init --global --approve", + "Exit codes: 0 planned or applied, 1 setup error, 64 unsupported." + ].join("\n"); +} + +function opcoreInstallHelpMessage(): string { + return [ + "Usage:", " opcore install [--repo ] [--local|--global] [--yes] [--json]", + "Flags:", " --repo Repository root to set up.", + " --local Force repo-scoped setup.", + " --global Install user-level agent skills and write-gate hooks.", + " --yes Apply the proposed setup without prompting.", + " --no-skill Do not install the Opcore agent skill.", + " --no-pre-commit Do not install the repo Git pre-commit hook.", + " --json Emit structured JSON.", + "Defaults:", + " install scans first, then applies on --yes or an interactive default-yes approval prompt.", + "Examples:", " opcore install", " opcore install --repo . --yes", + "Exit codes: 0 planned or applied, 1 setup error, 64 unsupported." + ].join("\n"); +} + +function opcoreUninstallHelpMessage(): string { + return [ + "Usage:", " opcore uninstall [--repo ] [--local|--global] [--yes] [--json]", + "Flags:", " --repo Repository root to restore/remove recorded setup from.", + " --local Force repo-scoped uninstall.", + " --global Restore/remove user-level recorded setup.", + " --yes Apply the uninstall without prompting.", + " --json Emit structured JSON.", + "Defaults:", " uninstall restores or removes only files recorded in .opcore/init-undo.json.", + "Examples:", " opcore uninstall --repo . --yes", " opcore uninstall --global --yes", + "Exit codes: 0 planned or applied, 1 setup error, 64 unsupported." + ].join("\n"); +} diff --git a/packages/opcore/src/init-hooks.ts b/packages/opcore/src/init-hooks.ts new file mode 100644 index 0000000..8f39674 --- /dev/null +++ b/packages/opcore/src/init-hooks.ts @@ -0,0 +1,81 @@ +import { + FAIL_CLOSED_HOOK_ACTIVATION_COMMAND +} from "./init-constants.js"; +import { isPlainObject } from "./init-data.js"; +import type { InitScope } from "./init-types.js"; + +export function failClosedHookContent(): string { + return [ + "#!/usr/bin/env sh", + "# Manual install required.", + "# This script is not active until installed.", + `# Activation command: ${FAIL_CLOSED_HOOK_ACTIVATION_COMMAND}`, + "set -eu", + "opcore check --changed", + "" + ].join("\n"); +} + +export function activePreCommitHookContent(): string { + return [ + "#!/usr/bin/env sh", + "# Installed by opcore install. Remove with opcore uninstall.", + "set -eu", + "opcore check --changed", + "" + ].join("\n"); +} + +export function mergeClaudeSettings(existing: Record, scope: InitScope): Record { + return mergePreToolUseHook(existing, { + matcher: "Edit|MultiEdit|Write", + command: agentGateCommand("claude", scope), + statusMessage: "Running Opcore write gate" + }); +} + +export function mergeCodexHooks(existing: Record, scope: InitScope): Record { + return mergePreToolUseHook(existing, { + matcher: "apply_patch|Edit|Write", + command: agentGateCommand("codex", scope), + statusMessage: "Running Opcore write gate" + }); +} + +function mergePreToolUseHook( + existing: Record, + hook: { matcher: string; command: string; statusMessage: string } +): Record { + const hooks = isPlainObject(existing.hooks) ? existing.hooks : {}; + const preToolUse = Array.isArray(hooks.PreToolUse) ? [...hooks.PreToolUse] : []; + const groupIndex = preToolUse.findIndex((entry) => isPlainObject(entry) && entry.matcher === hook.matcher); + const hookEntry = { + type: "command", command: hook.command, timeout: 30, statusMessage: hook.statusMessage + }; + if (groupIndex >= 0) { + const group = preToolUse[groupIndex]; + if (isPlainObject(group)) { + const groupHooks = Array.isArray(group.hooks) ? [...group.hooks] : []; + const alreadyPresent = groupHooks.some( + (entry) => isPlainObject(entry) && + typeof entry.command === "string" && + entry.command.includes("opcore-agent-gate.mjs") + ); + preToolUse[groupIndex] = { + ...group, + hooks: alreadyPresent ? groupHooks : [...groupHooks, hookEntry] + }; + } + } else { + preToolUse.push({ matcher: hook.matcher, hooks: [hookEntry] }); + } + return { ...existing, hooks: { ...hooks, PreToolUse: preToolUse } }; +} + +function agentGateCommand(harness: "claude" | "codex", scope: InitScope): string { + const hook = scope === "global" + ? "$HOME/.opcore/hooks/opcore-agent-gate.mjs" + : '"$(git rev-parse --show-toplevel)/.opcore/hooks/opcore-agent-gate.mjs"'; + const repo = scope === "global" ? "" : ' --repo "$(git rev-parse --show-toplevel)"'; + return `node ${hook} --harness ${harness}${repo}`; +} diff --git a/packages/opcore/src/init-language-settings.ts b/packages/opcore/src/init-language-settings.ts new file mode 100644 index 0000000..c2e081d --- /dev/null +++ b/packages/opcore/src/init-language-settings.ts @@ -0,0 +1,96 @@ +import type { + OpcoreInitLanguageSetting, + OpcoreInitPythonEnvironment, + OpcoreRepoStatePayload +} from "@the-open-engine/opcore-contracts"; + +export interface LanguageSettingContext { + unsupportedLanguages: ReadonlySet; + degradedRustTools: readonly string[]; + degradedPythonTools: readonly string[]; + rustHasActiveValidationInput: boolean; + retainedFiles: number; + pythonProject: OpcoreInitPythonEnvironment; +} + +export function createLanguageSetting( + language: OpcoreRepoStatePayload["coverage"]["languages"][number], + context: LanguageSettingContext +): OpcoreInitLanguageSetting { + const validation = languageValidationState(language, context); + return { + language: language.language, + files: language.files, + state: validation, + graph: language.graphSupported ? "supported" : "unsupported", + validation, + checks: checksForLanguage(language.language, validation), + notes: languageNotes(language.language, validation, context) + }; +} + +function languageValidationState( + language: OpcoreRepoStatePayload["coverage"]["languages"][number], + context: LanguageSettingContext +): OpcoreInitLanguageSetting["validation"] { + const rustRetainedOnly = + language.language === "Rust" && + !context.rustHasActiveValidationInput && + context.retainedFiles > 0; + if (context.unsupportedLanguages.has(language.language) && !language.validationSupported) return "unsupported"; + if (rustRetainedOnly) return "retained"; + if ( + language.validationSupported && + degradedValidationTools(language.language, context).length > 0 + ) { + return "degraded"; + } + return language.validationSupported ? "supported" : "unsupported"; +} + +function degradedValidationTools( + language: string, + context: LanguageSettingContext +): readonly string[] { + if (language === "Rust") return context.degradedRustTools; + if (language === "Python") return context.degradedPythonTools; + return []; +} + +function checksForLanguage(language: string, validation: OpcoreInitLanguageSetting["validation"]): string[] { + if (validation === "unsupported" || validation === "retained") return []; + if (language === "TypeScript" || language === "JavaScript") { + return [ + "typescript.syntax", "typescript.types", "typescript.import-graph", "typescript.dead-code", + "typescript.function-metrics", "typescript.relevant-tests", "typescript.file-length" + ]; + } + if (language === "Rust") { + return [ + "rust.source-hygiene", "rust.fmt", "rust.cargo-check", "rust.clippy", "rust.rustdoc", + "rust.import-graph", "rust.dead-code", "rust.unused-deps", "rust.file-length", "rust.function-metrics" + ]; + } + if (language === "Python") { + return [ + "python.syntax", "python.source-hygiene", "python.types", "python.import-graph", + "python.dead-code", "python.relevant-tests", "python.pytest" + ]; + } + return []; +} + +function languageNotes( + language: string, + validation: OpcoreInitLanguageSetting["validation"], + context: LanguageSettingContext +): string[] { + if (validation === "unsupported") return ["Unsupported stack counted without fabricated checks."]; + if (validation === "retained") return ["Retained for compatibility; no active checks configured."]; + const degradedTools = language === "Python" ? context.degradedPythonTools : context.degradedRustTools; + const notes = validation === "degraded" + ? [`${language} validation tools degraded: ${degradedTools.join(", ")}.`] + : []; + if (language === "Python") notes.push(...context.pythonProject.notes); + return notes; +} diff --git a/packages/opcore/src/init-messages.ts b/packages/opcore/src/init-messages.ts new file mode 100644 index 0000000..128441c --- /dev/null +++ b/packages/opcore/src/init-messages.ts @@ -0,0 +1,101 @@ +import type { + OpcoreInitPlanPayload, + OpcoreInitScanSummary +} from "@the-open-engine/opcore-contracts"; +import { + FAIL_CLOSED_HOOK_ACTIVATION_COMMAND +} from "./init-constants.js"; +import type { + InitScope, + OpcoreSetupCommand, + ParsedInitArgs +} from "./init-types.js"; + +export interface InitWarningInput { + scan: OpcoreInitScanSummary; + git: boolean; + failClosedHook: boolean; + scope: InitScope; + activePreCommitHook: boolean; + activePreCommitRequested: boolean; + linkedGitWorktree: boolean; +} + +export function initWarnings(input: InitWarningInput): string[] { + const warnings: string[] = []; + if (input.scan.unsupportedStacks.length > 0) { + const stacks = input.scan.unsupportedStacks.map((stack) => `${stack.language} (${stack.count})`); + warnings.push(`Unsupported stacks: ${stacks.join(", ")}`); + } + if (input.scan.degradedRustTools.length > 0) { + const tools = input.scan.degradedRustTools.map((tool) => tool.tool); + warnings.push(`Degraded validation tools: ${tools.join(", ")}`); + } + if (!input.git) warnings.push("No Git repository detected; .opcore/ ignore entry not written."); + warnings.push("Do not weaken existing lint, test, CI, pre-commit, or agent guardrails."); + warnings.push(scopeWarning(input.scope)); + warnings.push(hookWarning(input)); + return warnings; +} + +function scopeWarning(scope: InitScope): string { + return scope === "global" + ? "Global write-gate hooks apply across repos; undo removes only Opcore-recorded global hook entries." + : "Repo write-gate hooks are additive; Codex project hooks may require trust review before they run."; +} + +function hookWarning(input: InitWarningInput): string { + if (input.failClosedHook) { + return `Fail-closed hook script is opt-in. Manual install required: ${FAIL_CLOSED_HOOK_ACTIVATION_COMMAND}`; + } + if (input.activePreCommitHook && input.git) { + return "Git pre-commit hook will run opcore check --changed when no existing .git/hooks/pre-commit is present."; + } + if (input.linkedGitWorktree) { + return "Linked Git worktree detected; Opcore will not install .git/hooks/pre-commit from this checkout."; + } + if (input.activePreCommitRequested) { + return "Existing .git/hooks/pre-commit detected; Opcore will not overwrite it."; + } + return "Fail-closed hooks are opt-in and are not created unless --fail-closed-hook is approved."; +} + +export function initNextActions(options: ParsedInitArgs): string[] { + const approveFlag = options.command === "install" ? "--yes" : "--approve"; + const scopedCommand = options.scope === "global" + ? `opcore ${options.command} --global ${approveFlag}` + : `opcore ${options.command} ${approveFlag}`; + const actions = options.dryRun + ? [`Run ${scopedCommand} to apply this plan.`] + : [`Review this plan, then run ${scopedCommand} to write setup.`]; + actions.push(options.scope === "repo" + ? "Claude Code and Codex write-gate hooks are installed by Opcore setup; " + + "review Codex project hook trust with /hooks if Codex asks." + : "Global Claude Code and Codex write-gate hooks are installed by Opcore setup; " + + "review Codex hook trust with /hooks if Codex asks."); + if (options.failClosedHook) actions.push(failClosedHookManualInstallAction()); + return actions; +} + +export function appliedInitNextActions( + payload: OpcoreInitPlanPayload, + command: OpcoreSetupCommand +): string[] { + const undoCommand = command === "install" + ? payload.options.scope === "global" ? "opcore uninstall --global --yes" : "opcore uninstall --yes" + : payload.options.scope === "global" + ? "opcore init --global --undo --approve" + : "opcore init --undo --approve"; + const actions = [`Run ${undoCommand} to restore or remove recorded setup files.`]; + actions.push(payload.options.scope === "repo" + ? "Claude Code write calls are blocked on non-ok receipts. " + + "Codex uses a PreToolUse guardrail and may require hook trust review." + : "Global Claude Code write calls are blocked on non-ok receipts. " + + "Codex uses a PreToolUse guardrail and may require hook trust review."); + if (payload.options.failClosedHook) actions.push(failClosedHookManualInstallAction()); + return actions; +} + +function failClosedHookManualInstallAction(): string { + return `Manual install required before the fail-closed hook is active: ${FAIL_CLOSED_HOOK_ACTIVATION_COMMAND}`; +} diff --git a/packages/opcore/src/init-paths.ts b/packages/opcore/src/init-paths.ts new file mode 100644 index 0000000..0c7185b --- /dev/null +++ b/packages/opcore/src/init-paths.ts @@ -0,0 +1,95 @@ +import { lstatSync, realpathSync, statSync } from "node:fs"; +import { dirname, isAbsolute, relative, resolve, sep } from "node:path"; +import { errorMessage } from "./init-data.js"; +import { isMissingPathError, resolveRepoPath } from "./repo-paths.js"; + +export { resolveRepoPath } from "./repo-paths.js"; + +interface ExistingPathRequest { + root: string; + absolute: string; + displayPath: string; + label: string; + expected: "file" | "directory"; +} + +export function assertMutationPath(root: string, path: string, label: string): string { + const absolute = resolveRepoPath(root, path); + assertExistingAncestorInsideRepo(root, absolute, path, label); + if (lstatIfExists(absolute)) assertExistingRepoPath(root, path, label, "file"); + return absolute; +} + +export function assertExistingRepoPath( + root: string, + path: string, + label: string, + expected: "file" | "directory" +): string { + const absolute = resolveRepoPath(root, path); + assertExistingAncestorInsideRepo(root, absolute, path, label); + return assertExistingAbsolutePath({ root, absolute, displayPath: path, label, expected }); +} + +function assertExistingAbsolutePath(request: ExistingPathRequest): string { + const lstat = lstatIfExists(request.absolute); + if (!lstat) throw new Error(`${request.label} does not exist: ${request.displayPath}`); + if (lstat.isSymbolicLink()) throw new Error(`${request.label} must not be a symlink: ${request.displayPath}`); + let realPath: string; + try { + realPath = realpathSync(request.absolute); + } catch (error) { + throw new Error(`${request.label} symlink cannot be resolved for ${request.displayPath}: ${errorMessage(error)}`); + } + if (!isInsideRoot(request.root, realPath)) { + throw new Error(`${request.label} resolves outside repository through a symlink: ${request.displayPath}`); + } + const stat = statSync(request.absolute); + if (request.expected === "file" && !stat.isFile()) { + throw new Error(`${request.label} is not a file: ${request.displayPath}`); + } + if (request.expected === "directory" && !stat.isDirectory()) { + throw new Error(`${request.label} is not a directory: ${request.displayPath}`); + } + return request.absolute; +} + +function assertExistingAncestorInsideRepo(root: string, absolute: string, path: string, label: string): void { + const parent = relative(resolve(root), dirname(absolute)); + if (parent === "") return; + if (parent.startsWith("..") || isAbsolute(parent)) { + throw new Error(`${label} parent cannot be resolved inside repository: ${path}`); + } + let current = resolve(root); + for (const segment of parent.split(sep)) { + if (!segment) continue; + current = resolve(current, segment); + if (!lstatIfExists(current)) return; + const displayPath = relative(resolve(root), current) || "."; + assertExistingAbsolutePath({ + root, absolute: current, displayPath, label: `${label} parent`, expected: "directory" + }); + } +} + +export function repoPathExists(root: string, path: string): boolean { + return lstatIfExists(resolveRepoPath(root, path)) !== undefined; +} + +export function isLinkedGitWorktree(root: string): boolean { + return lstatIfExists(resolveRepoPath(root, ".git"))?.isFile() === true; +} + +export function lstatIfExists(path: string): ReturnType | undefined { + try { + return lstatSync(path); + } catch (error) { + if (isMissingPathError(error)) return undefined; + throw error; + } +} + +function isInsideRoot(root: string, path: string): boolean { + const normalized = relative(resolve(root), resolve(path)); + return normalized === "" || (!normalized.startsWith("..") && !isAbsolute(normalized)); +} diff --git a/packages/opcore/src/init-payloads.ts b/packages/opcore/src/init-payloads.ts new file mode 100644 index 0000000..0fffcdd --- /dev/null +++ b/packages/opcore/src/init-payloads.ts @@ -0,0 +1,38 @@ +import type { OpcoreInitPlanPayload } from "@the-open-engine/opcore-contracts"; +import { appliedInitNextActions } from "./init-messages.js"; +import { repoPathExists } from "./init-paths.js"; +import { undoPathForScope } from "./init-undo-metadata.js"; +import { undoAppliedNextAction } from "./init-undo-plan.js"; +import type { + InitScope, + OpcoreSetupCommand +} from "./init-types.js"; + +export function appliedInitPayload( + payload: OpcoreInitPlanPayload, + root: string, + scope: InitScope, + command: OpcoreSetupCommand +): OpcoreInitPlanPayload { + return { + ...payload, + mode: "apply", + approved: true, + nextActions: appliedInitNextActions(payload, command), + undoAvailable: repoPathExists(root, undoPathForScope(scope)) + }; +} + +export function appliedUndoPayload( + payload: OpcoreInitPlanPayload, + root: string, + scope: InitScope, + command: OpcoreSetupCommand +): OpcoreInitPlanPayload { + return { + ...payload, + approved: true, + nextActions: [undoAppliedNextAction(command)], + undoAvailable: repoPathExists(root, undoPathForScope(scope)) + }; +} diff --git a/packages/opcore/src/init-plan-flow.ts b/packages/opcore/src/init-plan-flow.ts new file mode 100644 index 0000000..91a384b --- /dev/null +++ b/packages/opcore/src/init-plan-flow.ts @@ -0,0 +1,104 @@ +import type { + CommandRouterResult, + OpcoreInitPlanPayload +} from "@the-open-engine/opcore-contracts"; +import { applyInit } from "./init-apply.js"; +import { + formatInteractiveOutcome, + formatSetupPlan +} from "./init-format.js"; +import { appliedInitPayload } from "./init-payloads.js"; +import { planInit } from "./init-plan.js"; +import { + approvalPromptSuffix, + isApprovedAnswer, + parseScopeAnswer, + scopeRoot, + shouldPromptForApproval, + shouldPromptForScope +} from "./init-prompts.js"; +import { createInitRouterResult } from "./init-result.js"; +import { elapsedMs, nowMs, withContext } from "./init-timing.js"; +import type { + PlannedInit, + SetupSession +} from "./init-types.js"; + +interface ApprovalOutcome { + approved: boolean; + prompted: boolean; + payload: OpcoreInitPlanPayload; +} + +export async function routeOpcoreInitPlanOrApply( + session: SetupSession +): Promise { + await promptForScope(session); + const planStartedAt = nowMs(); + const planned = planInit({ + repoRoot: session.repoRoot, requestedPath: session.requestedPath, + git: session.git, homeRoot: session.homeRoot, + options: session.options, context: session.context + }); + session.timing.planMs = elapsedMs(planStartedAt); + const outcome = await promptForApproval(session, planned); + const root = scopeRoot(session.repoRoot, session.homeRoot, session.options.scope); + if (outcome.approved) { + const applyStartedAt = nowMs(); + applyInit(root, session.options.scope, planned.writes); + session.timing.applyMs = elapsedMs(applyStartedAt); + } + let payload = outcome.approved + ? appliedInitPayload(outcome.payload, root, session.options.scope, session.command) + : outcome.payload; + payload = withContext(payload, session.context, session.timing); + return createInitRouterResult({ + argv: session.argv, json: session.json, status: "ok", + message: outcome.prompted + ? formatInteractiveOutcome(payload, session.command) + : formatSetupPlan(payload, outcome.approved, session.command), + canonicalCommand: ["opcore", session.command], opcoreInit: payload + }); +} + +async function promptForScope(session: SetupSession): Promise { + if (!shouldPromptForScope(session.json, session.options, session.git, session.runtime)) return; + const startedAt = nowMs(); + const answer = await session.runtime.readLine( + "Install the Opcore write gate for THIS repo, or GLOBALLY for all repos? [repo/global] " + ); + session.timing.promptMs += elapsedMs(startedAt); + session.options = { + ...session.options, scope: parseScopeAnswer(answer), scopeExplicit: true + }; +} + +async function promptForApproval( + session: SetupSession, + planned: PlannedInit +): Promise { + let payload = withContext(planned.payload, session.context, session.timing); + let approved = session.options.approved && !session.options.dryRun; + if (!shouldPromptForApproval(session.json, session.options, session.runtime)) { + return { approved, prompted: false, payload }; + } + session.context.interaction = { tty: true, promptState: "requested" }; + payload = withContext(payload, session.context, session.timing); + const startedAt = nowMs(); + const answer = await session.runtime.readLine( + `${formatSetupPlan(payload, false, session.command)}\n` + + `Apply setup? ${approvalPromptSuffix(session.command)} ` + ); + session.timing.promptMs += elapsedMs(startedAt); + approved = isApprovedAnswer(answer, session.command); + session.context.interaction = { + tty: true, promptState: approved ? "approved" : "declined" + }; + if (!approved) { + payload = { + ...payload, + nextActions: [`No files written. Rerun opcore ${session.command} when ready.`] + }; + } + return { approved, prompted: true, payload }; +} diff --git a/packages/opcore/src/init-plan-global.ts b/packages/opcore/src/init-plan-global.ts new file mode 100644 index 0000000..dce3020 --- /dev/null +++ b/packages/opcore/src/init-plan-global.ts @@ -0,0 +1,78 @@ +import { opcoreAgentGateHookScriptContent } from "./agent-gate.js"; +import { + AGENT_GATE_HOOK_PATH, + AGENT_SKILL_PATHS, + CLAUDE_SETTINGS_PATH, + CODEX_HOOKS_PATH, + GLOBAL_UNDO_PATH +} from "./init-constants.js"; +import { createInitActions } from "./init-actions.js"; +import { readJsonObjectIfExists } from "./init-files.js"; +import { opcoreAgentSkillContent } from "./init-guidance.js"; +import { mergeClaudeSettings, mergeCodexHooks } from "./init-hooks.js"; +import { initContextPayload, initPayloadOptions } from "./init-context-payload.js"; +import { initNextActions, initWarnings } from "./init-messages.js"; +import type { InitPlanInput } from "./init-plan.js"; +import { repoPathExists } from "./init-paths.js"; +import type { PlannedInit, PlannedWrite } from "./init-types.js"; + +export function planGlobalInit(input: InitPlanInput): PlannedInit { + const writes = createGlobalWrites(input); + return { + writes, + payload: { + schemaVersion: 1, + mode: "plan", + approved: false, + repo: { root: input.repoRoot, requestedPath: input.requestedPath }, + options: initPayloadOptions(input.options), + agentFiles: [], + actions: createInitActions({ + scope: "global", agentFiles: [], options: input.options, + gitignoreWritePlanned: false, activePreCommitWritePlanned: false + }), + warnings: initWarnings({ + scan: input.context.scan, git: true, failClosedHook: false, scope: "global", + activePreCommitHook: false, activePreCommitRequested: false, linkedGitWorktree: false + }), + nextActions: initNextActions(input.options), + undoAvailable: repoPathExists(input.homeRoot, GLOBAL_UNDO_PATH), + ...initContextPayload(input.context) + } + }; +} + +function createGlobalWrites(input: InitPlanInput): PlannedWrite[] { + const writes: PlannedWrite[] = []; + if (input.options.writeGateHooks) { + writes.push({ + kind: "write", path: AGENT_GATE_HOOK_PATH, targetScope: "global", + content: opcoreAgentGateHookScriptContent(), executable: true + }); + } + if (input.options.agentSkill) { + writes.push(...AGENT_SKILL_PATHS.map((path) => ({ + kind: "write" as const, path, targetScope: "global" as const, + content: opcoreAgentSkillContent() + }))); + } + if (input.options.writeGateHooks) writes.push(...globalHarnessWrites(input.homeRoot)); + return writes; +} + +function globalHarnessWrites(homeRoot: string): PlannedWrite[] { + return [ + { + kind: "write", path: CLAUDE_SETTINGS_PATH, targetScope: "global", + content: `${JSON.stringify(mergeClaudeSettings( + readJsonObjectIfExists(homeRoot, CLAUDE_SETTINGS_PATH), "global" + ), null, 2)}\n` + }, + { + kind: "write", path: CODEX_HOOKS_PATH, targetScope: "global", + content: `${JSON.stringify(mergeCodexHooks( + readJsonObjectIfExists(homeRoot, CODEX_HOOKS_PATH), "global" + ), null, 2)}\n` + } + ]; +} diff --git a/packages/opcore/src/init-plan-repo.ts b/packages/opcore/src/init-plan-repo.ts new file mode 100644 index 0000000..4dfd061 --- /dev/null +++ b/packages/opcore/src/init-plan-repo.ts @@ -0,0 +1,52 @@ +import { + ACTIVE_PRE_COMMIT_HOOK_PATH, + GITIGNORE_PATH, + UNDO_PATH +} from "./init-constants.js"; +import { createInitActions } from "./init-actions.js"; +import { detectAgentFiles } from "./init-guidance.js"; +import { initContextPayload, initPayloadOptions } from "./init-context-payload.js"; +import { initNextActions, initWarnings } from "./init-messages.js"; +import type { InitPlanInput } from "./init-plan.js"; +import { createRepoWrites } from "./init-plan-writes.js"; +import { isLinkedGitWorktree, repoPathExists } from "./init-paths.js"; +import type { PlannedInit } from "./init-types.js"; + +export function planRepoInit(input: InitPlanInput): PlannedInit { + const agentFiles = detectAgentFiles(input.repoRoot); + const linkedGitWorktree = input.git && isLinkedGitWorktree(input.repoRoot); + const activePreCommitWritePlanned = + input.options.activePreCommitHook && + input.git && + !linkedGitWorktree && + !repoPathExists(input.repoRoot, ACTIVE_PRE_COMMIT_HOOK_PATH); + const writes = createRepoWrites({ plan: input, agentFiles, activePreCommitWritePlanned }); + const gitignoreWritePlanned = writes.some((write) => write.path === GITIGNORE_PATH); + return { + writes, + payload: { + schemaVersion: 1, + mode: "plan", + approved: false, + repo: { root: input.repoRoot, requestedPath: input.requestedPath }, + options: initPayloadOptions(input.options), + agentFiles, + actions: createInitActions({ + scope: "repo", agentFiles, options: input.options, + gitignoreWritePlanned, activePreCommitWritePlanned + }), + warnings: initWarnings({ + scan: input.context.scan, + git: input.git, + failClosedHook: input.options.failClosedHook, + scope: "repo", + activePreCommitHook: activePreCommitWritePlanned, + activePreCommitRequested: input.options.activePreCommitHook && input.git, + linkedGitWorktree + }), + nextActions: initNextActions(input.options), + undoAvailable: repoPathExists(input.repoRoot, UNDO_PATH), + ...initContextPayload(input.context) + } + }; +} diff --git a/packages/opcore/src/init-plan-writes.ts b/packages/opcore/src/init-plan-writes.ts new file mode 100644 index 0000000..3917c68 --- /dev/null +++ b/packages/opcore/src/init-plan-writes.ts @@ -0,0 +1,110 @@ +import { opcoreAgentGateHookScriptContent } from "./agent-gate.js"; +import { + ACTIVE_PRE_COMMIT_HOOK_PATH, + AGENT_GATE_HOOK_PATH, + AGENT_SKILL_PATHS, + CLAUDE_SETTINGS_PATH, + CODEX_HOOKS_PATH, + CONFIG_PATH, + GITIGNORE_PATH, + HOOK_PATH, + OPCORE_IGNORE_LINE +} from "./init-constants.js"; +import { createConfig } from "./init-config.js"; +import { readJsonObjectIfExists, readOptionalRepoFile } from "./init-files.js"; +import { gitignoreIgnoresOpcore } from "./init-gitignore.js"; +import { agentGuidanceWrite, opcoreAgentSkillContent } from "./init-guidance.js"; +import { + activePreCommitHookContent, + failClosedHookContent, + mergeClaudeSettings, + mergeCodexHooks +} from "./init-hooks.js"; +import type { InitPlanInput } from "./init-plan.js"; +import type { PlannedWrite } from "./init-types.js"; + +export interface RepoWriteInput { + plan: InitPlanInput; + agentFiles: readonly string[]; + activePreCommitWritePlanned: boolean; +} + +export function createRepoWrites(input: RepoWriteInput): PlannedWrite[] { + const writes = baseRepoWrites(input); + if (input.plan.options.writeGateHooks) writes.push(...writeGateWrites(input.plan.repoRoot)); + if (input.plan.options.agentSkill) writes.push(...skillWrites()); + if (input.plan.git) writes.push(...gitWrites(input)); + if (input.plan.options.failClosedHook) { + writes.push({ + kind: "write", path: HOOK_PATH, targetScope: "repo", + content: failClosedHookContent(), executable: true + }); + } + return writes; +} + +function baseRepoWrites(input: RepoWriteInput): PlannedWrite[] { + const config = createConfig({ + repoRoot: input.plan.repoRoot, + failClosedHook: input.plan.options.failClosedHook, + activePreCommitHook: input.activePreCommitWritePlanned, + writeGateHooks: input.plan.options.writeGateHooks, + scan: input.plan.context.scan, + settings: input.plan.context.settings + }); + return [ + { + kind: "write", path: CONFIG_PATH, targetScope: "repo", + content: `${JSON.stringify(config, null, 2)}\n` + }, + ...input.agentFiles.map((path) => ({ + kind: "write" as const, path, targetScope: "repo" as const, + content: agentGuidanceWrite(input.plan.repoRoot, path) + })) + ]; +} + +function writeGateWrites(repoRoot: string): PlannedWrite[] { + return [ + { + kind: "write", path: AGENT_GATE_HOOK_PATH, targetScope: "repo", + content: opcoreAgentGateHookScriptContent(), executable: true + }, + { + kind: "write", path: CLAUDE_SETTINGS_PATH, targetScope: "repo", + content: `${JSON.stringify(mergeClaudeSettings( + readJsonObjectIfExists(repoRoot, CLAUDE_SETTINGS_PATH), "repo" + ), null, 2)}\n` + }, + { + kind: "write", path: CODEX_HOOKS_PATH, targetScope: "repo", + content: `${JSON.stringify(mergeCodexHooks( + readJsonObjectIfExists(repoRoot, CODEX_HOOKS_PATH), "repo" + ), null, 2)}\n` + } + ]; +} + +function skillWrites(): PlannedWrite[] { + return AGENT_SKILL_PATHS.map((path) => ({ + kind: "write", path, targetScope: "repo", content: opcoreAgentSkillContent() + })); +} + +function gitWrites(input: RepoWriteInput): PlannedWrite[] { + const writes: PlannedWrite[] = []; + const gitignore = readOptionalRepoFile(input.plan.repoRoot, GITIGNORE_PATH); + if (!gitignoreIgnoresOpcore(gitignore ?? "")) { + writes.push({ + kind: "append_managed_line", path: GITIGNORE_PATH, + targetScope: "repo", line: OPCORE_IGNORE_LINE + }); + } + if (input.activePreCommitWritePlanned) { + writes.push({ + kind: "write", path: ACTIVE_PRE_COMMIT_HOOK_PATH, targetScope: "repo", + content: activePreCommitHookContent(), executable: true + }); + } + return writes; +} diff --git a/packages/opcore/src/init-plan.ts b/packages/opcore/src/init-plan.ts new file mode 100644 index 0000000..0c0c17a --- /dev/null +++ b/packages/opcore/src/init-plan.ts @@ -0,0 +1,20 @@ +import type { + InitContext, + ParsedInitArgs, + PlannedInit +} from "./init-types.js"; +import { planGlobalInit } from "./init-plan-global.js"; +import { planRepoInit } from "./init-plan-repo.js"; + +export interface InitPlanInput { + repoRoot: string; + requestedPath: string; + git: boolean; + homeRoot: string; + options: ParsedInitArgs; + context: InitContext; +} + +export function planInit(input: InitPlanInput): PlannedInit { + return input.options.scope === "global" ? planGlobalInit(input) : planRepoInit(input); +} diff --git a/packages/opcore/src/init-prompts.ts b/packages/opcore/src/init-prompts.ts new file mode 100644 index 0000000..1a43cff --- /dev/null +++ b/packages/opcore/src/init-prompts.ts @@ -0,0 +1,66 @@ +import { realpathSync } from "node:fs"; +import { homedir } from "node:os"; +import { resolve } from "node:path"; +import type { + InitScope, + OpcoreInitRuntime, + OpcoreSetupCommand, + ParsedInitArgs +} from "./init-types.js"; + +export function shouldPromptForApproval( + json: boolean, + options: ParsedInitArgs, + runtime: OpcoreInitRuntime +): runtime is OpcoreInitRuntime & { readLine: (prompt: string) => Promise } { + return !json && + !options.approved && + !options.dryRun && + !options.undo && + isInteractiveRuntime(runtime) && + typeof runtime.readLine === "function"; +} + +export function shouldPromptForScope( + json: boolean, + options: ParsedInitArgs, + git: boolean, + runtime: OpcoreInitRuntime +): runtime is OpcoreInitRuntime & { readLine: (prompt: string) => Promise } { + return git && + !json && + !options.approved && + !options.dryRun && + !options.undo && + !options.scopeExplicit && + !options.repoExplicit && + isInteractiveRuntime(runtime) && + typeof runtime.readLine === "function"; +} + +export function parseScopeAnswer(answer: string | undefined): InitScope { + const normalized = (answer ?? "").trim().toLowerCase(); + return normalized === "g" || normalized === "global" ? "global" : "repo"; +} + +export function initHomeRoot(runtime: OpcoreInitRuntime): string { + return realpathSync(resolve(runtime.homeDir ?? homedir())); +} + +export function scopeRoot(repoRoot: string, homeRoot: string, scope: InitScope): string { + return scope === "global" ? homeRoot : repoRoot; +} + +export function isInteractiveRuntime(runtime: OpcoreInitRuntime): boolean { + return runtime.stdinIsTTY === true && runtime.stdoutIsTTY === true; +} + +export function isApprovedAnswer(answer: string | undefined, command: OpcoreSetupCommand): boolean { + const normalized = (answer ?? "").trim().toLowerCase(); + if (command === "install") return normalized === "" || normalized === "y" || normalized === "yes"; + return normalized === "y" || normalized === "yes"; +} + +export function approvalPromptSuffix(command: OpcoreSetupCommand): string { + return command === "install" ? "[Y/n]" : "[y/N]"; +} diff --git a/packages/opcore/src/init-python-settings.ts b/packages/opcore/src/init-python-settings.ts new file mode 100644 index 0000000..758ddb8 --- /dev/null +++ b/packages/opcore/src/init-python-settings.ts @@ -0,0 +1,70 @@ +import type { + OpcoreInitPythonEnvironment, + OpcoreRepoStatePayload +} from "@the-open-engine/opcore-contracts"; +import { relative } from "node:path"; + +const managerKinds = { + pip: "requirements", + uv: "uv", + poetry: "poetry", + pdm: "pyproject", + pipenv: "pipfile" +} as const; +type PythonContexts = NonNullable; + +export function pythonEnvironmentFromContexts(contexts: PythonContexts): OpcoreInitPythonEnvironment { + const managers = new Map(); + const environments = new Map(); + for (const context of contexts) { + collectManagers(context, managers); + const path = pythonEnvironmentPath(context); + if (path) environments.set(path, { kind: "venv", path }); + } + const projectRoots = uniqueSorted(contexts.map((context) => context.projectRoot)); + const outcomes = uniqueSorted(contexts.map((context) => context.outcome)); + const evidence = uniqueSorted(contexts.flatMap((context) => context.evidence.map((entry) => entry.path))); + return { + dependencyManagers: [...managers.values()].sort((left, right) => left.path.localeCompare(right.path)), + virtualEnvironments: [...environments.values()].sort((left, right) => left.path.localeCompare(right.path)), + notes: contexts.length === 0 ? [] : contextNotes(projectRoots, evidence, outcomes), + contexts + }; +} + +function collectManagers( + context: PythonContexts[number], + managers: Map +): void { + for (const manager of context.managers) { + const path = manager.lockFiles[0] ?? manager.configFiles[0]; + if (!path) continue; + const value = { kind: managerKinds[manager.kind], path }; + managers.set(`${value.kind}\0${value.path}`, value); + } +} + +function pythonEnvironmentPath(context: PythonContexts[number]): string | undefined { + if (context.interpreter?.source !== "project_local_environment") return undefined; + const executable = context.interpreter.executable.replaceAll("\\", "/"); + const suffix = executable.match(/\/(?:bin\/python[^/]*|Scripts\/python\.exe|python\.exe)$/u)?.[0]; + if (!suffix) return undefined; + const path = relative(context.repositoryRoot, executable.slice(0, -suffix.length)).replaceAll("\\", "/"); + return path.length > 0 && !path.startsWith("..") ? path : undefined; +} + +function contextNotes(projectRoots: string[], evidence: string[], outcomes: string[]): string[] { + return [ + `Canonical Python project contexts: ${projectRoots.join(", ") || "."}.`, + `Canonical Python project evidence: ${evidence.join(", ")}.`, + `Python context outcomes: ${outcomes.join(", ")}.` + ]; +} + +function uniqueSorted(values: readonly string[]): string[] { + return [...new Set(values)].sort(); +} + +export function hasPythonEnvironmentSignals(environment: OpcoreInitPythonEnvironment): boolean { + return (environment.contexts?.length ?? 0) > 0; +} diff --git a/packages/opcore/src/init-result.ts b/packages/opcore/src/init-result.ts new file mode 100644 index 0000000..1c49e76 --- /dev/null +++ b/packages/opcore/src/init-result.ts @@ -0,0 +1,27 @@ +import type { + CommandRouterResult, + OpcoreInitPlanPayload +} from "@the-open-engine/opcore-contracts"; +import { createCommandRouterResult } from "@the-open-engine/opcore-contracts"; + +export interface InitRouterResultInput { + argv: readonly string[]; + json: boolean; + status: "ok" | "error"; + message: string; + canonicalCommand?: readonly string[]; + opcoreInit?: OpcoreInitPlanPayload; +} + +export function createInitRouterResult(input: InitRouterResultInput): CommandRouterResult { + return createCommandRouterResult({ + bin: "opcore", + argv: input.argv, + canonicalCommand: input.canonicalCommand ?? ["opcore", "init"], + owner: "runtime", + status: input.status, + json: input.json, + message: input.message, + opcoreInit: input.opcoreInit + }); +} diff --git a/packages/opcore/src/init-router.ts b/packages/opcore/src/init-router.ts new file mode 100644 index 0000000..1bd7f71 --- /dev/null +++ b/packages/opcore/src/init-router.ts @@ -0,0 +1,73 @@ +import type { + CommandRouterResult, + ParsedCommandArgv +} from "@the-open-engine/opcore-contracts"; +import { parseOpcoreInitArgs } from "./init-args.js"; +import { opcoreSetupHelpMessage } from "./init-help.js"; +import { createInitRouterResult } from "./init-result.js"; +import { runResolvedSetup } from "./init-session.js"; +import { + HELP_ARGS +} from "./init-constants.js"; +import type { + OpcoreInitRuntime, + OpcoreSetupCommand +} from "./init-types.js"; +import { createInstallWizard } from "./init-wizard-render.js"; +import { resolveRepo } from "./status.js"; + +export async function routeOpcoreInit( + argv: readonly string[], + parsed: ParsedCommandArgv, + runtime: OpcoreInitRuntime = {} +): Promise { + return routeOpcoreSetup(argv, parsed, runtime, "init"); +} + +export async function routeOpcoreInstall( + argv: readonly string[], + parsed: ParsedCommandArgv, + runtime: OpcoreInitRuntime = {} +): Promise { + return routeOpcoreSetup(argv, parsed, runtime, "install"); +} + +export async function routeOpcoreUninstall( + argv: readonly string[], + parsed: ParsedCommandArgv, + runtime: OpcoreInitRuntime = {} +): Promise { + return routeOpcoreSetup(argv, parsed, runtime, "uninstall"); +} + +async function routeOpcoreSetup( + argv: readonly string[], + parsed: ParsedCommandArgv, + runtime: OpcoreInitRuntime, + command: OpcoreSetupCommand +): Promise { + const rest = parsed.args.slice(1); + if (rest.some((arg) => HELP_ARGS.has(arg))) { + return createInitRouterResult({ + argv, json: parsed.json, status: "ok", message: opcoreSetupHelpMessage(command), + canonicalCommand: ["opcore", command, "help"] + }); + } + const initArgs = parseOpcoreInitArgs(rest, command); + if (!initArgs.ok) { + return createInitRouterResult({ + argv, json: parsed.json, status: "error", message: initArgs.message + }); + } + const resolution = resolveRepo(initArgs.args.repo, `opcore ${command}`); + if (!resolution.ok) { + return createInitRouterResult({ + argv, json: parsed.json, status: "error", message: resolution.message + }); + } + return runResolvedSetup({ + argv, parsed, runtime, command, options: initArgs.args, + resolution: resolution.resolution, + wizard: createInstallWizard(parsed.json, initArgs.args, runtime, command) + }); +} diff --git a/packages/opcore/src/init-session.ts b/packages/opcore/src/init-session.ts new file mode 100644 index 0000000..2281882 --- /dev/null +++ b/packages/opcore/src/init-session.ts @@ -0,0 +1,102 @@ +import type { CommandRouterResult } from "@the-open-engine/opcore-contracts"; +import type { InstallWizardRenderer } from "./install-wizard.js"; +import { createOpcoreScanAnalysis } from "./scan.js"; +import { initHomeRoot } from "./init-prompts.js"; +import { routeOpcoreInitPlanOrApply } from "./init-plan-flow.js"; +import { createInitRouterResult } from "./init-result.js"; +import { createInitScanSummary, createInitSettings } from "./init-settings.js"; +import { + createTimingState, + elapsedMs, + finalizeTimings, + nowMs, + startInitScanProgress, + type InitScanProgress +} from "./init-timing.js"; +import type { + OpcoreInitRuntime, + OpcoreSetupCommand, + ParsedInitArgs, + SetupSession +} from "./init-types.js"; +import { routeOpcoreInitUndo } from "./init-undo-flow.js"; +import { runInstallWizardFlow } from "./init-wizard-flow.js"; +import { + repoDisplayLabel, + startWizardScanProgress +} from "./init-wizard-render.js"; +import type { RepoResolution } from "./status.js"; +import type { ParsedCommandArgv } from "@the-open-engine/opcore-contracts"; +import { errorMessage } from "./init-data.js"; + +export interface ResolvedSetupInput { + argv: readonly string[]; + parsed: ParsedCommandArgv; + runtime: OpcoreInitRuntime; + command: OpcoreSetupCommand; + options: ParsedInitArgs; + resolution: RepoResolution; + wizard?: InstallWizardRenderer; +} + +export async function runResolvedSetup(input: ResolvedSetupInput): Promise { + try { + const timing = createTimingState(); + const analysis = await scanForSetup(input, timing); + const context = { + scan: createInitScanSummary(analysis.repoState, analysis.validationResult), + settings: createInitSettings(analysis.repoState), + interaction: { tty: isInteractive(input.runtime), promptState: "not_requested" as const }, + timings: finalizeTimings(timing) + }; + const session: SetupSession = { + argv: input.argv, json: input.parsed.json, parsed: input.parsed, + repoRoot: input.resolution.root, requestedPath: input.resolution.requestedPath, + git: input.resolution.git, homeRoot: initHomeRoot(input.runtime), + options: input.options, context, timing, runtime: input.runtime, command: input.command + }; + if (session.options.undo) return routeOpcoreInitUndo(session); + if (input.wizard) return await runInstallWizardFlow({ ...session, wizard: input.wizard }); + return await routeOpcoreInitPlanOrApply(session); + } catch (error) { + return createInitRouterResult({ + argv: input.argv, json: input.parsed.json, status: "error", + message: `opcore ${input.command} failed: ${errorMessage(error)}` + }); + } finally { + input.wizard?.showCursor(); + } +} + +async function scanForSetup( + input: ResolvedSetupInput, + timing: ReturnType +): Promise>> { + const startedAt = nowMs(); + const progress = setupProgress(input); + const scan = input.runtime.scanAnalysis ?? createOpcoreScanAnalysis; + try { + const analysis = await scan(input.resolution); + timing.scanMs = elapsedMs(startedAt); + progress?.complete(timing.scanMs, analysis.repoState.coverage.totalFiles); + return analysis; + } catch (error) { + timing.scanMs = elapsedMs(startedAt); + progress?.fail(timing.scanMs); + throw error; + } +} + +function setupProgress(input: ResolvedSetupInput): InitScanProgress | undefined { + if (input.wizard) { + return startWizardScanProgress( + input.wizard, input.runtime, + repoDisplayLabel(input.resolution.root, initHomeRoot(input.runtime)) + ); + } + return startInitScanProgress(input.parsed.json, input.runtime, input.command); +} + +function isInteractive(runtime: OpcoreInitRuntime): boolean { + return runtime.stdinIsTTY === true && runtime.stdoutIsTTY === true; +} diff --git a/packages/opcore/src/init-settings.ts b/packages/opcore/src/init-settings.ts new file mode 100644 index 0000000..ffdb743 --- /dev/null +++ b/packages/opcore/src/init-settings.ts @@ -0,0 +1,66 @@ +import type { + OpcoreInitScanSummary, + OpcoreInitSettings, + OpcoreRepoStatePayload, + ValidationResult +} from "@the-open-engine/opcore-contracts"; +import { + createLanguageSetting, + type LanguageSettingContext +} from "./init-language-settings.js"; +import { + hasPythonEnvironmentSignals, + pythonEnvironmentFromContexts +} from "./init-python-settings.js"; +import { failedValidationCheckIds } from "./scan-presentation.js"; +import { scanValidationDiagnosticTotal } from "./scan-validation-preview.js"; + +const rustActiveValidationKinds = new Set([".rs", ".inc", "Cargo.toml"]); + +export function createInitScanSummary( + repoState: OpcoreRepoStatePayload, + validationResult: ValidationResult +): OpcoreInitScanSummary { + return { + totalFiles: repoState.coverage.totalFiles, + graphSupportedFiles: repoState.coverage.graph.supportedFiles, + validationSupportedFiles: repoState.coverage.validation.supportedFiles, + validationRetainedFiles: repoState.coverage.validation.retainedFiles, + unsupportedFiles: repoState.coverage.unsupported.totalFiles, + languages: repoState.coverage.languages, + unsupportedStacks: repoState.coverage.unsupported.stacks, + degradedRustTools: repoState.validation.degradedToolchains, + diagnosticCount: scanValidationDiagnosticTotal(validationResult), + validationStatus: validationResult.status, + failedChecks: failedValidationCheckIds(validationResult), + graphState: repoState.graph.state, + activationLevel: repoState.activation.level + }; +} + +export function createInitSettings(repoState: OpcoreRepoStatePayload): OpcoreInitSettings { + const contexts = repoState.validation.pythonProjectContexts; + const pythonProject = pythonEnvironmentFromContexts( + contexts === undefined ? [] : contexts + ); + const context: LanguageSettingContext = { + unsupportedLanguages: new Set(repoState.coverage.unsupported.stacks.map((stack) => stack.language)), + degradedRustTools: degradedTools(repoState, "rust"), + degradedPythonTools: degradedTools(repoState, "python"), + rustHasActiveValidationInput: repoState.coverage.validation.extensions.some((entry) => + rustActiveValidationKinds.has(entry.extension) + ), + retainedFiles: repoState.coverage.validation.retainedFiles, + pythonProject + }; + return { + languages: repoState.coverage.languages.map((language) => createLanguageSetting(language, context)), + ...(hasPythonEnvironmentSignals(pythonProject) ? { python: pythonProject } : {}) + }; +} + +function degradedTools(repoState: OpcoreRepoStatePayload, adapter: "rust" | "python"): string[] { + return repoState.validation.degradedToolchains + .filter((tool) => tool.adapter === adapter) + .map((tool) => tool.tool); +} diff --git a/packages/opcore/src/init-timing.ts b/packages/opcore/src/init-timing.ts new file mode 100644 index 0000000..7730fab --- /dev/null +++ b/packages/opcore/src/init-timing.ts @@ -0,0 +1,104 @@ +import type { OpcoreInitPlanPayload, OpcoreInitTiming } from "@the-open-engine/opcore-contracts"; +import type { + InitContext, + OpcoreInitRuntime, + OpcoreSetupCommand, + TimingState +} from "./init-types.js"; +import { DEFAULT_INIT_PROGRESS_INTERVAL_MS } from "./init-constants.js"; + +export interface InitScanProgress { + complete(scanMs: number, totalFiles?: number): void; + fail(scanMs: number): void; +} + +export function createTimingState(): TimingState { + return { + startedAt: nowMs(), + scanMs: 0, + planMs: 0, + promptMs: 0, + applyMs: 0 + }; +} + +export function withContext( + payload: OpcoreInitPlanPayload, + context: InitContext, + timing: TimingState +): OpcoreInitPlanPayload { + return { + ...payload, + scan: context.scan, + settings: context.settings, + interaction: context.interaction, + timings: finalizeTimings(timing) + }; +} + +export function startInitScanProgress( + json: boolean, + runtime: OpcoreInitRuntime, + command: OpcoreSetupCommand +): InitScanProgress | undefined { + if (json || runtime.stderrIsTTY !== true || typeof runtime.writeStderr !== "function") return undefined; + const startedAt = nowMs(); + const intervalMs = normalizeProgressIntervalMs(runtime.initProgressIntervalMs); + let finished = false; + const write = (text: string) => writeProgress(runtime.writeStderr, text); + const writeProgressLine = (text: string) => write(`\r\x1b[2K${text}`); + write(`Opcore ${command}: scanning repository before setup...`); + const timer = setInterval(() => { + if (finished) return; + const elapsedSeconds = Math.max(1, Math.floor(elapsedMs(startedAt) / 1000)); + writeProgressLine(`Opcore ${command}: still scanning repository before setup (${elapsedSeconds}s elapsed)...`); + }, intervalMs) as ReturnType & { unref?: () => void }; + timer.unref?.(); + return { + complete: (scanMs) => { + if (finished) return; + finished = true; + clearInterval(timer); + writeProgressLine(`Opcore ${command}: scan complete in ${scanMs}ms.\n`); + }, + fail: (scanMs) => { + if (finished) return; + finished = true; + clearInterval(timer); + writeProgressLine(`Opcore ${command}: scan failed after ${scanMs}ms.\n`); + } + }; +} + +function normalizeProgressIntervalMs(value: number | undefined): number { + return typeof value === "number" && Number.isFinite(value) && value > 0 + ? Math.max(1, Math.floor(value)) + : DEFAULT_INIT_PROGRESS_INTERVAL_MS; +} + +export function writeProgress(writeStderr: ((text: string) => void) | undefined, text: string): void { + try { + writeStderr?.(text); + } catch { + // Progress output must never change init scan/apply semantics. + } +} + +export function nowMs(): number { + return Date.now(); +} + +export function elapsedMs(startedAt: number): number { + return Math.max(0, Date.now() - startedAt); +} + +export function finalizeTimings(timing: TimingState): OpcoreInitTiming { + return { + scanMs: timing.scanMs, + planMs: timing.planMs, + promptMs: timing.promptMs, + applyMs: timing.applyMs, + totalMs: elapsedMs(timing.startedAt), + firstOutputMs: timing.scanMs + }; +} diff --git a/packages/opcore/src/init-types.ts b/packages/opcore/src/init-types.ts new file mode 100644 index 0000000..9aad1ae --- /dev/null +++ b/packages/opcore/src/init-types.ts @@ -0,0 +1,125 @@ +import type { + OpcoreInitInteraction, + OpcoreInitPlanPayload, + OpcoreInitScanSummary, + OpcoreInitSettings, + OpcoreInitTiming, + ParsedCommandArgv +} from "@the-open-engine/opcore-contracts"; +import type { InstallWizardRenderer } from "./install-wizard.js"; +import type { OpcoreScanAnalysis } from "./scan.js"; +import type { RepoResolution } from "./status.js"; + +export type OpcoreSetupCommand = "init" | "install" | "uninstall"; +export type InitScope = "repo" | "global"; + +export interface ParsedInitArgs { + command: OpcoreSetupCommand; + repo: string; + repoExplicit: boolean; + scope: InitScope; + scopeExplicit: boolean; + approved: boolean; + dryRun: boolean; + failClosedHook: boolean; + agentSkill: boolean; + writeGateHooks: boolean; + activePreCommitHook: boolean; + undo: boolean; +} + +export interface OpcoreInitRuntime { + stdinIsTTY?: boolean; + stdoutIsTTY?: boolean; + stderrIsTTY?: boolean; + stderrColor?: boolean; + stderrTrueColor?: boolean; + homeDir?: string; + writeStderr?: (text: string) => void; + scanAnalysis?: (resolution: RepoResolution) => Promise; + initProgressIntervalMs?: number; + readLine?: (prompt: string) => Promise; + readKey?: () => Promise; + initWizardMotion?: boolean; +} + +export interface InitContext { + scan: OpcoreInitScanSummary; + settings: OpcoreInitSettings; + interaction: OpcoreInitInteraction; + timings: OpcoreInitTiming; +} + +export interface TimingState { + startedAt: number; + scanMs: number; + planMs: number; + promptMs: number; + applyMs: number; +} + +export interface PlannedInit { + payload: OpcoreInitPlanPayload; + writes: readonly PlannedWrite[]; +} + +export type PlannedWrite = PlannedFileWrite | PlannedManagedLineAppend; + +export interface PlannedFileWrite { + kind: "write"; + path: string; + targetScope: InitScope; + content: string; + executable?: boolean; +} + +export interface PlannedManagedLineAppend { + kind: "append_managed_line"; + path: string; + targetScope: "repo"; + line: string; +} + +export interface UndoMetadata { + schemaVersion: 1; + kind: "opcore_init_undo" | "opcore_global_init_undo"; + repoRoot?: string; + homeRoot?: string; + entries: readonly UndoEntry[]; +} + +export type UndoEntry = FileUndoEntry | ManagedLineUndoEntry; + +export interface FileUndoEntry { + kind?: "restore_file"; + path: string; + existed: boolean; + content?: string; +} + +export interface ManagedLineUndoEntry { + kind: "append_managed_line"; + path: string; + existed: boolean; + line: string; + appended?: string; +} + +export interface SetupSession { + argv: readonly string[]; + json: boolean; + parsed: ParsedCommandArgv; + repoRoot: string; + requestedPath: string; + git: boolean; + homeRoot: string; + options: ParsedInitArgs; + context: InitContext; + timing: TimingState; + runtime: OpcoreInitRuntime; + command: OpcoreSetupCommand; +} + +export interface WizardSession extends SetupSession { + wizard: InstallWizardRenderer; +} diff --git a/packages/opcore/src/init-undo-entry.ts b/packages/opcore/src/init-undo-entry.ts new file mode 100644 index 0000000..c48c5db --- /dev/null +++ b/packages/opcore/src/init-undo-entry.ts @@ -0,0 +1,98 @@ +import { + GITIGNORE_PATH, + OPCORE_IGNORE_LINE +} from "./init-constants.js"; +import { isPlainObject } from "./init-data.js"; +import { resolveRepoPath } from "./init-paths.js"; +import type { + ManagedLineUndoEntry, + UndoEntry +} from "./init-types.js"; + +export interface UndoEntryParseInput { + value: unknown; + allowedPaths: ReadonlySet; + root: string; + seenPaths: Set; +} + +export function parseUndoEntry(input: UndoEntryParseInput): UndoEntry { + const entry = input.value; + if (!isPlainObject(entry) || typeof entry.path !== "string" || typeof entry.existed !== "boolean") { + throw new Error(".opcore/init-undo.json contains an invalid entry"); + } + validateEntryPath(entry.path, input); + const kind = typeof entry.kind === "string" ? entry.kind : "restore_file"; + if (kind === "append_managed_line") return parseManagedLineEntry(entry, input.root); + return parseFileEntry(entry, kind, input.root); +} + +function validateEntryPath(path: string, input: UndoEntryParseInput): void { + if (!input.allowedPaths.has(path)) { + throw new Error(`.opcore/init-undo.json contains unsupported path: ${path}`); + } + if (input.seenPaths.has(path)) { + throw new Error(`.opcore/init-undo.json contains duplicate path: ${path}`); + } + input.seenPaths.add(path); +} + +function parseManagedLineEntry( + entry: Record, + root: string +): ManagedLineUndoEntry { + const path = entry.path as string; + if (path !== GITIGNORE_PATH) { + throw new Error(`.opcore/init-undo.json append-managed-line entry targets unsupported path: ${path}`); + } + if (entry.line !== OPCORE_IGNORE_LINE) { + throw new Error(`.opcore/init-undo.json append-managed-line entry for ${path} has invalid line`); + } + validateAppendedText(entry, path); + resolveRepoPath(root, path); + return { + kind: "append_managed_line", + path, + existed: entry.existed as boolean, + line: OPCORE_IGNORE_LINE, + ...(typeof entry.appended === "string" ? { appended: entry.appended } : {}) + }; +} + +function validateAppendedText(entry: Record, path: string): void { + if (!("appended" in entry) || entry.appended === undefined) return; + if (entry.appended === `${OPCORE_IGNORE_LINE}\n`) return; + if (entry.appended === `\n${OPCORE_IGNORE_LINE}\n`) return; + throw new Error(`.opcore/init-undo.json append-managed-line entry for ${path} has invalid appended text`); +} + +function parseFileEntry( + entry: Record, + kind: string, + root: string +): UndoEntry { + const path = entry.path as string; + if (path === GITIGNORE_PATH) { + throw new Error(".opcore/init-undo.json .gitignore entry must use managed-line undo metadata"); + } + if (kind !== "restore_file") { + throw new Error(`.opcore/init-undo.json contains unsupported entry kind: ${kind}`); + } + validateFileContent(entry, path); + resolveRepoPath(root, path); + return { + kind: "restore_file", + path, + existed: entry.existed as boolean, + ...(typeof entry.content === "string" ? { content: entry.content } : {}) + }; +} + +function validateFileContent(entry: Record, path: string): void { + if (entry.existed && typeof entry.content !== "string") { + throw new Error(`.opcore/init-undo.json restore entry for ${path} is missing string content`); + } + if (!entry.existed && "content" in entry && entry.content !== undefined && typeof entry.content !== "string") { + throw new Error(`.opcore/init-undo.json remove entry for ${path} has invalid content`); + } +} diff --git a/packages/opcore/src/init-undo-flow.ts b/packages/opcore/src/init-undo-flow.ts new file mode 100644 index 0000000..55409ef --- /dev/null +++ b/packages/opcore/src/init-undo-flow.ts @@ -0,0 +1,43 @@ +import type { CommandRouterResult } from "@the-open-engine/opcore-contracts"; +import { applyUndo } from "./init-apply.js"; +import { formatSetupPlan } from "./init-format.js"; +import { appliedUndoPayload } from "./init-payloads.js"; +import { scopeRoot } from "./init-prompts.js"; +import { createInitRouterResult } from "./init-result.js"; +import { elapsedMs, nowMs, withContext } from "./init-timing.js"; +import type { SetupSession } from "./init-types.js"; +import { planUndo } from "./init-undo-plan.js"; + +export function routeOpcoreInitUndo(session: SetupSession): CommandRouterResult { + const planStartedAt = nowMs(); + const undo = planUndo({ + repoRoot: session.repoRoot, + requestedPath: session.requestedPath, + homeRoot: session.homeRoot, + options: session.options, + context: session.context + }); + session.timing.planMs = elapsedMs(planStartedAt); + const approved = session.options.approved && !session.options.dryRun; + const root = scopeRoot(session.repoRoot, session.homeRoot, session.options.scope); + if (approved) { + const applyStartedAt = nowMs(); + applyUndo(root, session.options.scope); + session.timing.applyMs = elapsedMs(applyStartedAt); + } + const payload = withContext( + approved + ? appliedUndoPayload(undo, root, session.options.scope, session.command) + : undo, + session.context, + session.timing + ); + return createInitRouterResult({ + argv: session.argv, + json: session.json, + status: "ok", + message: formatSetupPlan(payload, approved, session.command), + canonicalCommand: ["opcore", session.command], + opcoreInit: payload + }); +} diff --git a/packages/opcore/src/init-undo-metadata.ts b/packages/opcore/src/init-undo-metadata.ts new file mode 100644 index 0000000..7213677 --- /dev/null +++ b/packages/opcore/src/init-undo-metadata.ts @@ -0,0 +1,77 @@ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { + ACTIVE_PRE_COMMIT_HOOK_PATH, + AGENT_FILE_CANDIDATES, + AGENT_GATE_HOOK_PATH, + AGENT_SKILL_PATHS, + CLAUDE_SETTINGS_PATH, + CODEX_HOOKS_PATH, + CONFIG_PATH, + GITIGNORE_PATH, + GLOBAL_UNDO_PATH, + HOOK_PATH, + UNDO_PATH +} from "./init-constants.js"; +import { isPlainObject } from "./init-data.js"; +import { assertExistingRepoPath, repoPathExists } from "./init-paths.js"; +import { parseUndoEntry } from "./init-undo-entry.js"; +import type { + InitScope, + UndoMetadata +} from "./init-types.js"; + +const allowedRepoPaths = new Set([ + CONFIG_PATH, UNDO_PATH, HOOK_PATH, AGENT_GATE_HOOK_PATH, ...AGENT_SKILL_PATHS, + CLAUDE_SETTINGS_PATH, CODEX_HOOKS_PATH, ACTIVE_PRE_COMMIT_HOOK_PATH, + GITIGNORE_PATH, ...AGENT_FILE_CANDIDATES +]); +const allowedGlobalPaths = new Set([ + GLOBAL_UNDO_PATH, AGENT_GATE_HOOK_PATH, ...AGENT_SKILL_PATHS, + CLAUDE_SETTINGS_PATH, CODEX_HOOKS_PATH +]); + +export function readUndoMetadata(root: string, scope: InitScope): UndoMetadata { + const path = undoPathForScope(scope); + const raw = readFileSync(assertExistingRepoPath(root, path, "Opcore init undo metadata", "file"), "utf8"); + const parsed = JSON.parse(raw) as unknown; + const envelope = validateUndoEnvelope(parsed, root, scope); + const seenPaths = new Set(); + const allowedPaths = scope === "global" ? allowedGlobalPaths : allowedRepoPaths; + const entries = envelope.entries.map((value) => + parseUndoEntry({ value, allowedPaths, root, seenPaths }) + ); + return { + schemaVersion: 1, + kind: envelope.kind, + ...(scope === "global" ? { homeRoot: envelope.recordedRoot } : { repoRoot: envelope.recordedRoot }), + entries + }; +} + +function validateUndoEnvelope( + value: unknown, + root: string, + scope: InitScope +): { kind: UndoMetadata["kind"]; recordedRoot: string; entries: unknown[] } { + const expectedKind = scope === "global" ? "opcore_global_init_undo" : "opcore_init_undo"; + if (!isPlainObject(value) || + value.schemaVersion !== 1 || + value.kind !== expectedKind || + !Array.isArray(value.entries)) { + throw new Error(".opcore/init-undo.json is not valid Opcore init undo metadata"); + } + const recordedRoot = scope === "global" ? value.homeRoot : value.repoRoot; + if (typeof recordedRoot !== "string" || resolve(recordedRoot) !== resolve(root)) { + throw new Error(".opcore/init-undo.json repoRoot does not match this repository"); + } + return { kind: expectedKind, recordedRoot, entries: value.entries }; +} + +export function readUndoMetadataIfExists(root: string, scope: InitScope): UndoMetadata | undefined { + return repoPathExists(root, undoPathForScope(scope)) ? readUndoMetadata(root, scope) : undefined; +} + +export function undoPathForScope(_scope: InitScope): string { + return UNDO_PATH; +} diff --git a/packages/opcore/src/init-undo-plan.ts b/packages/opcore/src/init-undo-plan.ts new file mode 100644 index 0000000..dc6ad80 --- /dev/null +++ b/packages/opcore/src/init-undo-plan.ts @@ -0,0 +1,80 @@ +import type { OpcoreInitPlanPayload } from "@the-open-engine/opcore-contracts"; +import { AGENT_FILE_CANDIDATES } from "./init-constants.js"; +import { actionPath } from "./init-action-helpers.js"; +import { initContextPayload, initPayloadOptions } from "./init-context-payload.js"; +import { scopeRoot } from "./init-prompts.js"; +import { readUndoMetadata } from "./init-undo-metadata.js"; +import type { + InitContext, + OpcoreSetupCommand, + ParsedInitArgs +} from "./init-types.js"; + +export interface UndoPlanInput { + repoRoot: string; + requestedPath: string; + homeRoot: string; + options: ParsedInitArgs; + context: InitContext; +} + +export function planUndo(input: UndoPlanInput): OpcoreInitPlanPayload { + const root = scopeRoot(input.repoRoot, input.homeRoot, input.options.scope); + const metadata = readUndoMetadata(root, input.options.scope); + return { + schemaVersion: 1, + mode: "undo", + approved: input.options.approved && !input.options.dryRun, + repo: { root: input.repoRoot, requestedPath: input.requestedPath }, + options: initPayloadOptions(input.options), + agentFiles: metadata.entries + .map((entry) => entry.path) + .filter((path) => AGENT_FILE_CANDIDATES.includes( + path as (typeof AGENT_FILE_CANDIDATES)[number] + )), + actions: metadata.entries.map((entry) => ({ + kind: entry.kind === "append_managed_line" ? "remove" : entry.existed ? "restore" : "remove", + path: actionPath(input.options.scope, entry.path), + targetScope: input.options.scope, + summary: undoSummary(input.options.scope, entry), + requiresApproval: !entry.path.startsWith(".opcore/"), + outsideOpcore: !entry.path.startsWith(".opcore/") + })), + warnings: [], + nextActions: input.options.approved && !input.options.dryRun + ? [undoAppliedNextAction(input.options.command)] + : [undoPreviewNextAction(input.options)], + undoAvailable: true, + ...initContextPayload(input.context) + }; +} + +function undoSummary( + scope: ParsedInitArgs["scope"], + entry: ReturnType["entries"][number] +): string { + const path = actionPath(scope, entry.path); + if (entry.kind === "append_managed_line") { + return `Remove managed ${entry.line} gitignore entry from ${entry.path}.`; + } + return entry.existed + ? `Restore ${path} from Opcore init backup.` + : `Remove ${path} created by Opcore init.`; +} + +export function undoAppliedNextAction(command: OpcoreSetupCommand): string { + return command === "uninstall" + ? "Opcore setup metadata was restored or removed; rerun opcore install to recreate setup." + : "Opcore init metadata was restored or removed; rerun opcore init to recreate setup."; +} + +function undoPreviewNextAction(options: ParsedInitArgs): string { + if (options.command === "uninstall") { + return `Run ${options.scope === "global" + ? "opcore uninstall --global --yes" + : "opcore uninstall --yes"} to restore or remove recorded setup files.`; + } + return `Run ${options.scope === "global" + ? "opcore init --global --undo --approve" + : "opcore init --undo --approve"} to restore or remove recorded setup files.`; +} diff --git a/packages/opcore/src/init-wizard-flow.ts b/packages/opcore/src/init-wizard-flow.ts new file mode 100644 index 0000000..798514e --- /dev/null +++ b/packages/opcore/src/init-wizard-flow.ts @@ -0,0 +1,122 @@ +import type { + CommandRouterResult, + OpcoreInitPlanPayload +} from "@the-open-engine/opcore-contracts"; +import type { InstallWizardChoices } from "./install-wizard.js"; +import { applyInit } from "./init-apply.js"; +import { appliedInitPayload } from "./init-payloads.js"; +import { planInit, type InitPlanInput } from "./init-plan.js"; +import { scopeRoot } from "./init-prompts.js"; +import { createInitRouterResult } from "./init-result.js"; +import { elapsedMs, nowMs, withContext } from "./init-timing.js"; +import type { + PlannedInit, + WizardSession +} from "./init-types.js"; +import { + createInstallWizardGroups, + installWizardPlanView +} from "./init-wizard-plan.js"; +import { repoDisplayLabel } from "./init-wizard-render.js"; + +export async function runInstallWizardFlow(session: WizardSession): Promise { + await session.wizard.coverage(session.context.scan); + if (!await chooseScope(session)) return declinedInstallWizardResult(session); + const planFor = createInstallPlanCache(session); + const probe = planFor({ agentSkill: true, writeGateHooks: true, activePreCommitHook: true }); + session.context.interaction = { tty: true, promptState: "requested" }; + const promptStartedAt = nowMs(); + const outcome = await session.wizard.planApproval({ + groups: createInstallWizardGroups( + session.options.scope, session.git, session.repoRoot, probe.payload.actions + ), + initial: { + agentSkill: session.options.agentSkill, + writeGateHooks: session.options.writeGateHooks, + activePreCommitHook: session.options.activePreCommitHook + }, + planView: (choices) => installWizardPlanView(planFor(choices).payload.actions) + }); + session.timing.promptMs += elapsedMs(promptStartedAt); + session.options = { ...session.options, ...outcome.choices }; + if (!outcome.confirmed) return declinedInstallWizardResult(session); + return applyWizardPlan(session); +} + +async function chooseScope(session: WizardSession): Promise { + if (!session.git || session.options.scopeExplicit || session.options.repoExplicit) return true; + const startedAt = nowMs(); + const scope = await session.wizard.selectScope(repoDisplayLabel(session.repoRoot, session.homeRoot)); + session.timing.promptMs += elapsedMs(startedAt); + if (scope === null) return false; + session.options = { ...session.options, scope, scopeExplicit: true }; + return true; +} + +async function applyWizardPlan(session: WizardSession): Promise { + session.context.interaction = { tty: true, promptState: "approved" }; + const planStartedAt = nowMs(); + const planned = planInit(toPlanInput(session)); + session.timing.planMs += elapsedMs(planStartedAt); + const root = scopeRoot(session.repoRoot, session.homeRoot, session.options.scope); + const applyStartedAt = nowMs(); + applyInit(root, session.options.scope, planned.writes); + session.timing.applyMs = elapsedMs(applyStartedAt); + await session.wizard.applyCascade( + planned.payload.actions.map((action) => action.path), + session.timing.applyMs + ); + const undo = session.options.scope === "global" + ? "opcore uninstall --global --yes" + : "opcore uninstall"; + session.wizard.doneCard(planned.payload.actions.length, session.options.scope, undo); + const payload = withContext( + appliedInitPayload(planned.payload, root, session.options.scope, session.command), + session.context, + session.timing + ); + return createInitRouterResult({ + argv: session.argv, json: false, status: "ok", + message: `opcore ${session.command} applied`, + canonicalCommand: ["opcore", session.command], opcoreInit: payload + }); +} + +function declinedInstallWizardResult(session: WizardSession): CommandRouterResult { + session.wizard.cancelled(); + session.context.interaction = { tty: true, promptState: "declined" }; + const startedAt = nowMs(); + const planned = planInit(toPlanInput(session)); + session.timing.planMs += elapsedMs(startedAt); + const payload: OpcoreInitPlanPayload = { + ...withContext(planned.payload, session.context, session.timing), + nextActions: [`No files written. Rerun opcore ${session.command} when ready.`] + }; + return createInitRouterResult({ + argv: session.argv, json: false, status: "ok", + message: `opcore ${session.command} declined`, + canonicalCommand: ["opcore", session.command], opcoreInit: payload + }); +} + +function createInstallPlanCache(session: WizardSession): (choices: InstallWizardChoices) => PlannedInit { + const cache = new Map(); + return (choices) => { + const key = `${choices.agentSkill}|${choices.writeGateHooks}|${choices.activePreCommitHook}`; + const cached = cache.get(key); + if (cached) return cached; + const planned = planInit(toPlanInput(session, { ...session.options, ...choices })); + cache.set(key, planned); + return planned; + }; +} + +function toPlanInput( + session: WizardSession, + options = session.options +): InitPlanInput { + return { + repoRoot: session.repoRoot, requestedPath: session.requestedPath, + git: session.git, homeRoot: session.homeRoot, options, context: session.context + }; +} diff --git a/packages/opcore/src/init-wizard-plan.ts b/packages/opcore/src/init-wizard-plan.ts new file mode 100644 index 0000000..a424884 --- /dev/null +++ b/packages/opcore/src/init-wizard-plan.ts @@ -0,0 +1,81 @@ +import type { OpcoreInitAction } from "@the-open-engine/opcore-contracts"; +import type { + InstallWizardFileRow, + InstallWizardGroup, + InstallWizardGroupKey, + InstallWizardPlanView +} from "./install-wizard.js"; +import { + ACTIVE_PRE_COMMIT_HOOK_PATH, + AGENT_GATE_HOOK_PATH, + CLAUDE_SETTINGS_PATH, + CODEX_HOOKS_PATH, + GITIGNORE_PATH +} from "./init-constants.js"; +import { isLinkedGitWorktree } from "./init-paths.js"; +import type { InitScope } from "./init-types.js"; + +export function createInstallWizardGroups( + scope: InitScope, + git: boolean, + repoRoot: string, + actions: readonly OpcoreInitAction[] +): InstallWizardGroup[] { + const groups: InstallWizardGroup[] = [ + { key: "skill", label: "agent skill", available: true }, + { key: "hooks", label: "write-gate hooks", available: true } + ]; + if (scope === "global") return groups; + if (actions.some((action) => action.path === ACTIVE_PRE_COMMIT_HOOK_PATH)) { + groups.push({ key: "precommit", label: "pre-commit hook", available: true }); + return groups; + } + groups.push({ + key: "precommit", + label: "pre-commit hook", + available: false, + unavailableNote: unavailablePreCommitNote(git, repoRoot) + }); + return groups; +} + +function unavailablePreCommitNote(git: boolean, repoRoot: string): string { + if (!git) return "no git repo"; + return isLinkedGitWorktree(repoRoot) ? "linked worktree — skipped" : "existing hook kept"; +} + +export function installWizardPlanView(actions: readonly OpcoreInitAction[]): InstallWizardPlanView { + const baseRows: InstallWizardFileRow[] = []; + const groupRows: Record = { + skill: [], hooks: [], precommit: [] + }; + for (const action of actions) { + const row: InstallWizardFileRow = { + path: action.path, + mark: actionMark(action), + outsideOpcore: action.outsideOpcore + }; + const group = installWizardGroupForPath(action.path); + if (group) groupRows[group].push(row); + else baseRows.push(row); + } + return { + baseRows, + groupRows, + totalWrites: actions.length, + outsideWrites: actions.filter((action) => action.outsideOpcore).length + }; +} + +function actionMark(action: OpcoreInitAction): InstallWizardFileRow["mark"] { + if (action.kind === "upsert_block" || action.kind === "wire_harness") return "~"; + return action.path === GITIGNORE_PATH ? "»" : "+"; +} + +function installWizardGroupForPath(path: string): InstallWizardGroupKey | undefined { + if (path.endsWith("skills/opcore/SKILL.md")) return "skill"; + if (path.endsWith(AGENT_GATE_HOOK_PATH) || + path.endsWith(CLAUDE_SETTINGS_PATH) || + path.endsWith(CODEX_HOOKS_PATH)) return "hooks"; + return path.endsWith(ACTIVE_PRE_COMMIT_HOOK_PATH) ? "precommit" : undefined; +} diff --git a/packages/opcore/src/init-wizard-render.ts b/packages/opcore/src/init-wizard-render.ts new file mode 100644 index 0000000..f2c8174 --- /dev/null +++ b/packages/opcore/src/init-wizard-render.ts @@ -0,0 +1,74 @@ +import { sep } from "node:path"; +import { + createInstallWizardRenderer, + type InstallWizardRenderer +} from "./install-wizard.js"; +import { isInteractiveRuntime } from "./init-prompts.js"; +import { elapsedMs, nowMs, writeProgress, type InitScanProgress } from "./init-timing.js"; +import type { + OpcoreInitRuntime, + OpcoreSetupCommand, + ParsedInitArgs +} from "./init-types.js"; + +export function createInstallWizard( + json: boolean, + options: ParsedInitArgs, + runtime: OpcoreInitRuntime, + command: OpcoreSetupCommand +): InstallWizardRenderer | undefined { + if (command !== "install" || json || options.approved || options.dryRun || options.undo) return undefined; + if (!isInteractiveRuntime(runtime) || runtime.stderrIsTTY !== true) return undefined; + if (typeof runtime.readKey !== "function" || typeof runtime.writeStderr !== "function") return undefined; + const writeStderr = runtime.writeStderr; + return createInstallWizardRenderer( + { + write: (text) => writeProgress(writeStderr, text), + readKey: runtime.readKey, + color: runtime.stderrColor === true, + motion: runtime.initWizardMotion !== false + }, + runtime.stderrTrueColor === true + ); +} + +export function startWizardScanProgress( + wizard: InstallWizardRenderer, + runtime: OpcoreInitRuntime, + repoLabel: string +): InitScanProgress { + wizard.hideCursor(); + wizard.header(repoLabel); + const startedAt = nowMs(); + let finished = false; + let frame = 0; + wizard.scanFrame(frame, 0); + const intervalMs = runtime.initWizardMotion === false ? 0 : 120; + const timer = intervalMs > 0 + ? (setInterval(() => { + if (finished) return; + frame += 1; + wizard.scanFrame(frame, elapsedMs(startedAt)); + }, intervalMs) as ReturnType & { unref?: () => void }) + : undefined; + timer?.unref?.(); + return { + complete: (scanMs, totalFiles) => { + if (finished) return; + finished = true; + if (timer) clearInterval(timer); + wizard.scanDone(scanMs, totalFiles); + }, + fail: (scanMs) => { + if (finished) return; + finished = true; + if (timer) clearInterval(timer); + wizard.scanFailed(scanMs); + } + }; +} + +export function repoDisplayLabel(root: string, homeRoot: string): string { + if (root === homeRoot) return "~"; + return root.startsWith(`${homeRoot}${sep}`) ? `~${root.slice(homeRoot.length)}` : root; +} diff --git a/packages/opcore/src/init-write.ts b/packages/opcore/src/init-write.ts new file mode 100644 index 0000000..1461bb5 --- /dev/null +++ b/packages/opcore/src/init-write.ts @@ -0,0 +1,83 @@ +import { + appendFileSync, + chmodSync, + mkdirSync, + readFileSync, + rmSync, + writeFileSync +} from "node:fs"; +import { dirname } from "node:path"; +import { appendManagedGitignoreLine, removeManagedGitignoreLine } from "./init-gitignore.js"; +import { readOptionalRepoFile } from "./init-files.js"; +import { + assertExistingRepoPath, + assertMutationPath, + repoPathExists +} from "./init-paths.js"; +import type { + PlannedWrite, + UndoEntry +} from "./init-types.js"; + +export function priorEntry(root: string, path: string, write: PlannedWrite | undefined): UndoEntry { + if (write?.kind === "append_managed_line") { + const existing = readOptionalRepoFile(root, path); + return { + kind: "append_managed_line", path, existed: existing !== undefined, + line: write.line, appended: appendManagedGitignoreLine(existing) + }; + } + if (!repoPathExists(root, path)) return { kind: "restore_file", path, existed: false }; + return { + kind: "restore_file", + path, + existed: true, + content: readFileSync(assertExistingRepoPath( + root, path, "Existing Opcore init target", "file" + ), "utf8") + }; +} + +export function writeScopedFile(root: string, write: PlannedWrite): void { + const absolute = assertMutationPath(root, write.path, "Opcore init write target"); + mkdirSync(dirname(absolute), { recursive: true }); + if (write.kind === "append_managed_line") { + appendFileSync(absolute, appendManagedGitignoreLine(readOptionalRepoFile(root, write.path))); + return; + } + writeFileSync(absolute, write.content, "utf8"); + if (write.executable) { + assertMutationPath(root, write.path, "Opcore init chmod target"); + chmodSync(absolute, 0o755); + } +} + +export function restoreUndoEntry(root: string, entry: UndoEntry): void { + const absolute = assertMutationPath(root, entry.path, "Opcore init undo target"); + if (entry.kind === "append_managed_line") { + restoreManagedLine(root, absolute, entry); + return; + } + if (!entry.existed) { + rmSync(absolute, { force: true }); + return; + } + if (entry.content === undefined) throw new Error(`Undo entry for ${entry.path} is missing content`); + mkdirSync(dirname(absolute), { recursive: true }); + writeFileSync(absolute, entry.content, "utf8"); +} + +function restoreManagedLine( + root: string, + absolute: string, + entry: Extract +): void { + if (!repoPathExists(root, entry.path)) return; + const removal = removeManagedGitignoreLine(readFileSync(absolute, "utf8"), entry); + if (!removal.removed) return; + if (!entry.existed && removal.content.length === 0) { + rmSync(absolute, { force: true }); + return; + } + writeFileSync(absolute, removal.content, "utf8"); +} diff --git a/packages/opcore/src/init.ts b/packages/opcore/src/init.ts index 63ce789..6592811 100644 --- a/packages/opcore/src/init.ts +++ b/packages/opcore/src/init.ts @@ -1,2300 +1,7 @@ -import type { - CommandRouterResult, - OpcoreInitAction, - OpcoreInitInteraction, - OpcoreInitLanguageSetting, - OpcoreInitPythonEnvironment, - OpcoreInitPlanPayload, - OpcoreInitScanSummary, - OpcoreInitSettings, - OpcoreInitTiming, - OpcoreRepoStatePayload, - ParsedCommandArgv, - ValidationResult -} from "@the-open-engine/opcore-contracts"; -import { createCommandRouterResult } from "@the-open-engine/opcore-contracts"; -import { - appendFileSync, - chmodSync, - lstatSync, - mkdirSync, - readFileSync, - realpathSync, - readdirSync, - rmSync, - statSync, - writeFileSync -} from "node:fs"; -import { homedir } from "node:os"; -import { dirname, isAbsolute, relative, resolve, sep } from "node:path"; -import { opcoreAgentGateHookScriptContent } from "./agent-gate.js"; -import { - createInstallWizardRenderer, - type InstallWizardChoices, - type InstallWizardFileRow, - type InstallWizardGroup, - type InstallWizardGroupKey, - type InstallWizardPlanView, - type InstallWizardRenderer -} from "./install-wizard.js"; -import { createOpcoreScanAnalysis, type OpcoreScanAnalysis } from "./scan.js"; -import { failedValidationCheckIds } from "./scan-presentation.js"; -import { scanValidationDiagnosticTotal } from "./scan-validation-preview.js"; -import { commonSkippedPathSegments, resolveRepo, type RepoResolution } from "./status.js"; - -declare const process: { - cwd(): string; -}; - -const helpArgs = new Set(["--help", "-h", "help"]); -export const AGENT_FILE_CANDIDATES = [ - "AGENTS.md", - "CLAUDE.md", - "GEMINI.md", - ".github/copilot-instructions.md", - ".codex/AGENTS.md", - ".opencode/AGENTS.md" -] as const; - -const beginMarker = ""; -const endMarker = ""; -const configPath = ".opcore/config"; -const undoPath = ".opcore/init-undo.json"; -const hookPath = ".opcore/hooks/pre-commit-opcore-check.sh"; -const agentGateHookPath = ".opcore/hooks/opcore-agent-gate.mjs"; -const repoAgentSkillPath = ".agents/skills/opcore/SKILL.md"; -const claudeAgentSkillPath = ".claude/skills/opcore/SKILL.md"; -const agentSkillPaths = [repoAgentSkillPath, claudeAgentSkillPath] as const; -const claudeSettingsPath = ".claude/settings.json"; -const codexHooksPath = ".codex/hooks.json"; -const activePreCommitHookPath = ".git/hooks/pre-commit"; -const failClosedHookActivationCommand = "cp .opcore/hooks/pre-commit-opcore-check.sh .git/hooks/pre-commit"; -const gitignorePath = ".gitignore"; -const opcoreIgnoreLine = ".opcore/"; -const defaultInitProgressIntervalMs = 5000; -type OpcoreSetupCommand = "init" | "install" | "uninstall"; -const allowedUndoPaths = new Set([ - configPath, - undoPath, - hookPath, - agentGateHookPath, - repoAgentSkillPath, - claudeAgentSkillPath, - claudeSettingsPath, - codexHooksPath, - activePreCommitHookPath, - gitignorePath, - ...AGENT_FILE_CANDIDATES -]); -const globalUndoPath = ".opcore/init-undo.json"; -const allowedGlobalUndoPaths = new Set([ - globalUndoPath, - agentGateHookPath, - repoAgentSkillPath, - claudeAgentSkillPath, - claudeSettingsPath, - codexHooksPath -]); -const rustActiveValidationKinds = new Set([".rs", ".inc", "Cargo.toml"]); - -interface ParsedInitArgs { - command: OpcoreSetupCommand; - repo: string; - repoExplicit: boolean; - scope: "repo" | "global"; - scopeExplicit: boolean; - approved: boolean; - dryRun: boolean; - failClosedHook: boolean; - agentSkill: boolean; - writeGateHooks: boolean; - activePreCommitHook: boolean; - undo: boolean; -} - -export interface OpcoreInitRuntime { - stdinIsTTY?: boolean; - stdoutIsTTY?: boolean; - stderrIsTTY?: boolean; - stderrColor?: boolean; - stderrTrueColor?: boolean; - homeDir?: string; - writeStderr?: (text: string) => void; - scanAnalysis?: (resolution: RepoResolution) => Promise; - initProgressIntervalMs?: number; - readLine?: (prompt: string) => Promise; - readKey?: () => Promise; - initWizardMotion?: boolean; -} - -interface InitContext { - scan: OpcoreInitScanSummary; - settings: OpcoreInitSettings; - interaction: OpcoreInitInteraction; - timings: OpcoreInitTiming; -} - -interface TimingState { - startedAt: number; - scanMs: number; - planMs: number; - promptMs: number; - applyMs: number; -} - -interface PlannedInit { - payload: OpcoreInitPlanPayload; - writes: readonly PlannedWrite[]; -} - -type PlannedWrite = PlannedFileWrite | PlannedManagedLineAppend; - -interface PlannedFileWrite { - kind: "write"; - path: string; - targetScope: "repo" | "global"; - content: string; - executable?: boolean; -} - -interface PlannedManagedLineAppend { - kind: "append_managed_line"; - path: string; - targetScope: "repo"; - line: string; -} - -interface UndoMetadata { - schemaVersion: 1; - kind: "opcore_init_undo" | "opcore_global_init_undo"; - repoRoot?: string; - homeRoot?: string; - entries: readonly UndoEntry[]; -} - -type UndoEntry = FileUndoEntry | ManagedLineUndoEntry; - -interface FileUndoEntry { - kind?: "restore_file"; - path: string; - existed: boolean; - content?: string; -} - -interface ManagedLineUndoEntry { - kind: "append_managed_line"; - path: string; - existed: boolean; - line: string; - appended?: string; -} - -export async function routeOpcoreInit( - argv: readonly string[], - parsed: ParsedCommandArgv, - runtime: OpcoreInitRuntime = {} -): Promise { - return routeOpcoreSetup(argv, parsed, runtime, "init"); -} - -export async function routeOpcoreInstall( - argv: readonly string[], - parsed: ParsedCommandArgv, - runtime: OpcoreInitRuntime = {} -): Promise { - return routeOpcoreSetup(argv, parsed, runtime, "install"); -} - -export async function routeOpcoreUninstall( - argv: readonly string[], - parsed: ParsedCommandArgv, - runtime: OpcoreInitRuntime = {} -): Promise { - return routeOpcoreSetup(argv, parsed, runtime, "uninstall"); -} - -async function routeOpcoreSetup( - argv: readonly string[], - parsed: ParsedCommandArgv, - runtime: OpcoreInitRuntime, - command: OpcoreSetupCommand -): Promise { - const rest = parsed.args.slice(1); - if (rest.some((arg) => helpArgs.has(arg))) { - return createInitRouterResult(argv, parsed.json, "ok", opcoreSetupHelpMessage(command), ["opcore", command, "help"]); - } - - const parsedInit = parseOpcoreInitArgs(rest, command); - if (!parsedInit.ok) { - return createInitRouterResult(argv, parsed.json, "error", parsedInit.message); - } - const resolution = resolveRepo(parsedInit.args.repo, `opcore ${command}`); - if (!resolution.ok) { - return createInitRouterResult(argv, parsed.json, "error", resolution.message); - } - - const wizard = createInstallWizard(parsed.json, parsedInit.args, runtime, command); - try { - const timing: TimingState = { - startedAt: nowMs(), - scanMs: 0, - planMs: 0, - promptMs: 0, - applyMs: 0 - }; - const scanStartedAt = nowMs(); - const progress = wizard - ? startWizardScanProgress(wizard, runtime, repoDisplayLabel(resolution.resolution.root, initHomeRoot(runtime))) - : startInitScanProgress(parsed.json, runtime, command); - const scanAnalysis = runtime.scanAnalysis ?? createOpcoreScanAnalysis; - let analysis: OpcoreScanAnalysis; - try { - analysis = await scanAnalysis(resolution.resolution); - timing.scanMs = elapsedMs(scanStartedAt); - progress?.complete(timing.scanMs, analysis.repoState.coverage.totalFiles); - } catch (error) { - timing.scanMs = elapsedMs(scanStartedAt); - progress?.fail(timing.scanMs); - throw error; - } - const context: InitContext = { - scan: createInitScanSummary(analysis.repoState, analysis.validationResult), - settings: createInitSettings(analysis.repoState), - interaction: { - tty: isInteractiveRuntime(runtime), - promptState: "not_requested" - }, - timings: finalizeTimings(timing) - }; - - if (parsedInit.args.undo) { - return routeOpcoreInitUndo( - argv, - parsed.json, - resolution.resolution.root, - resolution.resolution.requestedPath, - initHomeRoot(runtime), - parsedInit.args, - context, - timing, - command - ); - } - - if (wizard) { - return await runInstallWizardFlow( - argv, - resolution.resolution.root, - resolution.resolution.requestedPath, - resolution.resolution.git, - initHomeRoot(runtime), - parsedInit.args, - context, - timing, - wizard, - command - ); - } - - return await routeOpcoreInitPlanOrApply( - argv, - parsed.json, - resolution.resolution.root, - resolution.resolution.requestedPath, - resolution.resolution.git, - initHomeRoot(runtime), - parsedInit.args, - context, - timing, - runtime, - command - ); - } catch (error) { - return createInitRouterResult(argv, parsed.json, "error", `opcore ${command} failed: ${errorMessage(error)}`); - } finally { - wizard?.showCursor(); - } -} - -function createInstallWizard( - json: boolean, - options: ParsedInitArgs, - runtime: OpcoreInitRuntime, - command: OpcoreSetupCommand -): InstallWizardRenderer | undefined { - if (command !== "install" || json || options.approved || options.dryRun || options.undo) return undefined; - if (!isInteractiveRuntime(runtime) || runtime.stderrIsTTY !== true) return undefined; - if (typeof runtime.readKey !== "function" || typeof runtime.writeStderr !== "function") return undefined; - const writeStderr = runtime.writeStderr; - const readKey = runtime.readKey; - return createInstallWizardRenderer( - { - write: (text) => writeProgress(writeStderr, text), - readKey, - color: runtime.stderrColor === true, - motion: runtime.initWizardMotion !== false - }, - runtime.stderrTrueColor === true - ); -} - -function startWizardScanProgress( - wizard: InstallWizardRenderer, - runtime: OpcoreInitRuntime, - repoLabel: string -): { complete(scanMs: number, totalFiles?: number): void; fail(scanMs: number): void } { - wizard.hideCursor(); - wizard.header(repoLabel); - const startedAt = nowMs(); - let finished = false; - let frame = 0; - wizard.scanFrame(frame, 0); - const intervalMs = runtime.initWizardMotion === false ? 0 : 120; - const timer = intervalMs > 0 - ? (setInterval(() => { - if (finished) return; - frame += 1; - wizard.scanFrame(frame, elapsedMs(startedAt)); - }, intervalMs) as ReturnType & { unref?: () => void }) - : undefined; - timer?.unref?.(); - return { - complete: (scanMs: number, totalFiles?: number) => { - if (finished) return; - finished = true; - if (timer) clearInterval(timer); - wizard.scanDone(scanMs, totalFiles); - }, - fail: (scanMs: number) => { - if (finished) return; - finished = true; - if (timer) clearInterval(timer); - wizard.scanFailed(scanMs); - } - }; -} - -function repoDisplayLabel(root: string, homeRoot: string): string { - if (root === homeRoot) return "~"; - return root.startsWith(`${homeRoot}${sep}`) ? `~${root.slice(homeRoot.length)}` : root; -} - -function routeOpcoreInitUndo( - argv: readonly string[], - json: boolean, - repoRoot: string, - requestedPath: string, - homeRoot: string, - options: ParsedInitArgs, - context: InitContext, - timing: TimingState, - command: OpcoreSetupCommand -): CommandRouterResult { - const planStartedAt = nowMs(); - const undo = planUndo(repoRoot, requestedPath, homeRoot, options, context); - timing.planMs = elapsedMs(planStartedAt); - const approved = options.approved && !options.dryRun; - if (approved) { - const applyStartedAt = nowMs(); - applyUndo(scopeRoot(repoRoot, homeRoot, options.scope), options.scope, undo); - timing.applyMs = elapsedMs(applyStartedAt); - } - const payload = withContext( - approved ? appliedUndoPayload(undo, scopeRoot(repoRoot, homeRoot, options.scope), options.scope, command) : undo, - context, - timing - ); - return createInitRouterResult(argv, json, "ok", formatSetupPlan(payload, approved, command), ["opcore", command], payload); -} - -async function routeOpcoreInitPlanOrApply( - argv: readonly string[], - json: boolean, - repoRoot: string, - requestedPath: string, - git: boolean, - homeRoot: string, - options: ParsedInitArgs, - context: InitContext, - timing: TimingState, - runtime: OpcoreInitRuntime, - command: OpcoreSetupCommand -): Promise { - if (shouldPromptForScope(json, options, git, runtime)) { - const promptStartedAt = nowMs(); - const scopeAnswer = await runtime.readLine("Install the Opcore write gate for THIS repo, or GLOBALLY for all repos? [repo/global] "); - timing.promptMs += elapsedMs(promptStartedAt); - options = { - ...options, - scope: parseScopeAnswer(scopeAnswer), - scopeExplicit: true - }; - } - const planStartedAt = nowMs(); - const planned = planInit(repoRoot, requestedPath, git, homeRoot, options, context); - timing.planMs = elapsedMs(planStartedAt); - let payload = withContext(planned.payload, context, timing); - let approved = options.approved && !options.dryRun; - let prompted = false; - - if (shouldPromptForApproval(json, options, runtime)) { - prompted = true; - context.interaction = { tty: true, promptState: "requested" }; - payload = withContext(payload, context, timing); - const promptStartedAt = nowMs(); - const answer = await runtime.readLine(`${formatSetupPlan(payload, false, command)}\nApply setup? ${approvalPromptSuffix(command)} `); - timing.promptMs += elapsedMs(promptStartedAt); - if (isApprovedAnswer(answer, command)) { - approved = true; - context.interaction = { tty: true, promptState: "approved" }; - } else { - context.interaction = { tty: true, promptState: "declined" }; - payload = { - ...payload, - nextActions: [`No files written. Rerun opcore ${command === "uninstall" ? "uninstall" : command} when ready.`] - }; - } - } - - if (approved) { - const applyStartedAt = nowMs(); - applyInit(scopeRoot(repoRoot, homeRoot, options.scope), options.scope, planned.writes); - timing.applyMs = elapsedMs(applyStartedAt); - } - payload = approved ? appliedInitPayload(payload, scopeRoot(repoRoot, homeRoot, options.scope), options.scope, command) : payload; - payload = withContext(payload, context, timing); - const message = prompted ? formatInteractiveOutcome(payload, command) : formatSetupPlan(payload, approved, command); - return createInitRouterResult(argv, json, "ok", message, ["opcore", command], payload); -} - -async function runInstallWizardFlow( - argv: readonly string[], - repoRoot: string, - requestedPath: string, - git: boolean, - homeRoot: string, - options: ParsedInitArgs, - context: InitContext, - timing: TimingState, - wizard: InstallWizardRenderer, - command: OpcoreSetupCommand -): Promise { - await wizard.coverage(context.scan); - - if (git && !options.scopeExplicit && !options.repoExplicit) { - const promptStartedAt = nowMs(); - const scope = await wizard.selectScope(repoDisplayLabel(repoRoot, homeRoot)); - timing.promptMs += elapsedMs(promptStartedAt); - if (scope === null) { - return declinedInstallWizardResult(argv, repoRoot, requestedPath, git, homeRoot, options, context, timing, wizard, command); - } - options = { ...options, scope, scopeExplicit: true }; - } - - const planFor = createInstallPlanCache(repoRoot, requestedPath, git, homeRoot, options, context); - const probe = planFor({ agentSkill: true, writeGateHooks: true, activePreCommitHook: true }); - const model = { - groups: createInstallWizardGroups(options.scope, git, repoRoot, probe.payload.actions), - initial: { - agentSkill: options.agentSkill, - writeGateHooks: options.writeGateHooks, - activePreCommitHook: options.activePreCommitHook - }, - planView: (choices: InstallWizardChoices) => installWizardPlanView(planFor(choices).payload.actions) - }; - context.interaction = { tty: true, promptState: "requested" }; - const promptStartedAt = nowMs(); - const outcome = await wizard.planApproval(model); - timing.promptMs += elapsedMs(promptStartedAt); - options = { ...options, ...outcome.choices }; - if (!outcome.confirmed) { - return declinedInstallWizardResult(argv, repoRoot, requestedPath, git, homeRoot, options, context, timing, wizard, command); - } - - context.interaction = { tty: true, promptState: "approved" }; - const planStartedAt = nowMs(); - const planned = planInit(repoRoot, requestedPath, git, homeRoot, options, context); - timing.planMs += elapsedMs(planStartedAt); - const root = scopeRoot(repoRoot, homeRoot, options.scope); - const applyStartedAt = nowMs(); - applyInit(root, options.scope, planned.writes); - timing.applyMs = elapsedMs(applyStartedAt); - await wizard.applyCascade(planned.payload.actions.map((action) => action.path), timing.applyMs); - const undoCommand = options.scope === "global" ? "opcore uninstall --global --yes" : "opcore uninstall"; - wizard.doneCard(planned.payload.actions.length, options.scope, undoCommand); - const payload = withContext(appliedInitPayload(planned.payload, root, options.scope, command), context, timing); - return createInitRouterResult(argv, false, "ok", `opcore ${command} applied`, ["opcore", command], payload); -} - -function declinedInstallWizardResult( - argv: readonly string[], - repoRoot: string, - requestedPath: string, - git: boolean, - homeRoot: string, - options: ParsedInitArgs, - context: InitContext, - timing: TimingState, - wizard: InstallWizardRenderer, - command: OpcoreSetupCommand -): CommandRouterResult { - wizard.cancelled(); - context.interaction = { tty: true, promptState: "declined" }; - const planStartedAt = nowMs(); - const planned = planInit(repoRoot, requestedPath, git, homeRoot, options, context); - timing.planMs += elapsedMs(planStartedAt); - const payload: OpcoreInitPlanPayload = { - ...withContext(planned.payload, context, timing), - nextActions: [`No files written. Rerun opcore ${command} when ready.`] - }; - return createInitRouterResult(argv, false, "ok", `opcore ${command} declined`, ["opcore", command], payload); -} - -function createInstallPlanCache( - repoRoot: string, - requestedPath: string, - git: boolean, - homeRoot: string, - options: ParsedInitArgs, - context: InitContext -): (choices: InstallWizardChoices) => PlannedInit { - const cache = new Map(); - return (choices) => { - const key = `${choices.agentSkill}|${choices.writeGateHooks}|${choices.activePreCommitHook}`; - const cached = cache.get(key); - if (cached) return cached; - const planned = planInit(repoRoot, requestedPath, git, homeRoot, { ...options, ...choices }, context); - cache.set(key, planned); - return planned; - }; -} - -function createInstallWizardGroups( - scope: ParsedInitArgs["scope"], - git: boolean, - repoRoot: string, - probeActions: readonly OpcoreInitAction[] -): InstallWizardGroup[] { - const groups: InstallWizardGroup[] = [ - { key: "skill", label: "agent skill", available: true }, - { key: "hooks", label: "write-gate hooks", available: true } - ]; - if (scope === "global") return groups; - const preCommitPlanned = probeActions.some((action) => action.path === activePreCommitHookPath); - if (preCommitPlanned) { - groups.push({ key: "precommit", label: "pre-commit hook", available: true }); - } else { - groups.push({ - key: "precommit", - label: "pre-commit hook", - available: false, - unavailableNote: !git - ? "no git repo" - : isLinkedGitWorktree(repoRoot) - ? "linked worktree — skipped" - : "existing hook kept" - }); - } - return groups; -} - -function installWizardPlanView(actions: readonly OpcoreInitAction[]): InstallWizardPlanView { - const baseRows: InstallWizardFileRow[] = []; - const groupRows: Record = { skill: [], hooks: [], precommit: [] }; - for (const action of actions) { - const row: InstallWizardFileRow = { - path: action.path, - mark: action.kind === "upsert_block" || action.kind === "wire_harness" - ? "~" - : action.path === gitignorePath - ? "»" - : "+", - outsideOpcore: action.outsideOpcore - }; - const group = installWizardGroupForPath(action.path); - if (group) groupRows[group].push(row); - else baseRows.push(row); - } - return { - baseRows, - groupRows, - totalWrites: actions.length, - outsideWrites: actions.filter((action) => action.outsideOpcore).length - }; -} - -function installWizardGroupForPath(path: string): InstallWizardGroupKey | undefined { - if (path.endsWith("skills/opcore/SKILL.md")) return "skill"; - if (path.endsWith(agentGateHookPath) || path.endsWith(claudeSettingsPath) || path.endsWith(codexHooksPath)) return "hooks"; - if (path.endsWith(activePreCommitHookPath)) return "precommit"; - return undefined; -} - -function createInitRouterResult( - argv: readonly string[], - json: boolean, - status: "ok" | "error", - message: string, - canonicalCommand: readonly string[] = ["opcore", "init"], - opcoreInit?: OpcoreInitPlanPayload -): CommandRouterResult { - return createCommandRouterResult({ - bin: "opcore", - argv, - canonicalCommand, - owner: "runtime", - status, - json, - message, - opcoreInit - }); -} - -function createInitScanSummary(repoState: OpcoreRepoStatePayload, validationResult: ValidationResult): OpcoreInitScanSummary { - const failedChecks = failedValidationCheckIds(validationResult); - return { - totalFiles: repoState.coverage.totalFiles, - graphSupportedFiles: repoState.coverage.graph.supportedFiles, - validationSupportedFiles: repoState.coverage.validation.supportedFiles, - validationRetainedFiles: repoState.coverage.validation.retainedFiles, - unsupportedFiles: repoState.coverage.unsupported.totalFiles, - languages: repoState.coverage.languages, - unsupportedStacks: repoState.coverage.unsupported.stacks, - degradedRustTools: repoState.validation.degradedToolchains, - diagnosticCount: scanValidationDiagnosticTotal(validationResult), - validationStatus: validationResult.status, - failedChecks, - graphState: repoState.graph.state, - activationLevel: repoState.activation.level - }; -} - -function createInitSettings(repoState: OpcoreRepoStatePayload): OpcoreInitSettings { - const unsupportedLanguages = new Set(repoState.coverage.unsupported.stacks.map((stack) => stack.language)); - const degradedRustTools = repoState.validation.degradedToolchains.filter((tool) => tool.adapter === "rust").map((tool) => tool.tool); - const degradedPythonTools = repoState.validation.degradedToolchains.filter((tool) => tool.adapter === "python").map((tool) => tool.tool); - const rustHasActiveValidationInput = repoState.coverage.validation.extensions.some((entry) => - rustActiveValidationKinds.has(entry.extension) - ); - const pythonProject = pythonEnvironmentFromContexts(repoState.validation.pythonProjectContexts ?? []); - return { - languages: repoState.coverage.languages.map((language): OpcoreInitLanguageSetting => { - const rustRetainedOnly = - language.language === "Rust" && - !rustHasActiveValidationInput && - repoState.coverage.validation.retainedFiles > 0; - const rustDegraded = language.language === "Rust" && degradedRustTools.length > 0 && language.validationSupported && !rustRetainedOnly; - const pythonDegraded = language.language === "Python" && degradedPythonTools.length > 0 && language.validationSupported; - const unsupported = unsupportedLanguages.has(language.language) && !language.validationSupported; - const validation = unsupported - ? "unsupported" - : rustRetainedOnly - ? "retained" - : rustDegraded || pythonDegraded - ? "degraded" - : language.validationSupported - ? "supported" - : "unsupported"; - const state = validation === "unsupported" - ? "unsupported" - : validation === "retained" - ? "retained" - : validation === "degraded" - ? "degraded" - : "supported"; - return { - language: language.language, - files: language.files, - state, - graph: language.graphSupported ? "supported" : "unsupported", - validation, - checks: checksForLanguage(language.language, validation), - notes: notesForLanguage( - language.language, - validation, - language.language === "Python" ? degradedPythonTools : degradedRustTools, - language.language === "Python" ? pythonProject : undefined - ) - }; - }), - ...(hasPythonEnvironmentSignals(pythonProject) ? { python: pythonProject } : {}) - }; -} - -function checksForLanguage(language: string, validation: OpcoreInitLanguageSetting["validation"]): string[] { - if (validation === "unsupported" || validation === "retained") return []; - if (language === "TypeScript" || language === "JavaScript") { - return [ - "typescript.syntax", - "typescript.types", - "typescript.import-graph", - "typescript.dead-code", - "typescript.function-metrics", - "typescript.relevant-tests", - "typescript.file-length" - ]; - } - if (language === "Rust") { - return [ - "rust.source-hygiene", - "rust.fmt", - "rust.cargo-check", - "rust.clippy", - "rust.rustdoc", - "rust.import-graph", - "rust.dead-code", - "rust.unused-deps", - "rust.file-length", - "rust.function-metrics" - ]; - } - if (language === "Python") { - return [ - "python.syntax", - "python.source-hygiene", - "python.types", - "python.import-graph", - "python.dead-code", - "python.relevant-tests", - "python.pytest" - ]; - } - return []; -} - -function notesForLanguage( - language: string, - validation: OpcoreInitLanguageSetting["validation"], - degradedTools: readonly string[], - pythonProject?: OpcoreInitPythonEnvironment -): string[] { - if (validation === "unsupported") return ["Unsupported stack counted without fabricated checks."]; - if (validation === "retained") return ["Retained for compatibility; no active checks configured."]; - const notes: string[] = []; - if (validation === "degraded") notes.push(`${language} validation tools degraded: ${degradedTools.join(", ")}.`); - if (language === "Python" && pythonProject !== undefined) { - notes.push(...pythonProject.notes); - } - return notes; -} - -function pythonEnvironmentFromContexts( - contexts: NonNullable -): OpcoreInitPythonEnvironment { - const managerKind = { - pip: "requirements", - uv: "uv", - poetry: "poetry", - pdm: "pyproject", - pipenv: "pipfile" - } as const; - const managers = new Map(); - const environments = new Map(); - for (const context of contexts) { - for (const manager of context.managers) { - const path = manager.lockFiles[0] ?? manager.configFiles[0]; - if (path !== undefined) { - const value = { kind: managerKind[manager.kind], path }; - managers.set(`${value.kind}\0${value.path}`, value); - } - } - if (context.interpreter?.source === "project_local_environment") { - const executable = context.interpreter.executable.replaceAll("\\", "/"); - const suffix = executable.match(/\/(?:bin\/python[^/]*|Scripts\/python\.exe|python\.exe)$/u)?.[0]; - if (suffix !== undefined) { - const environmentRoot = executable.slice(0, -suffix.length); - const path = relative(context.repositoryRoot, environmentRoot).replaceAll("\\", "/"); - if (path.length > 0 && !path.startsWith("..")) environments.set(path, { kind: "venv", path }); - } - } - } - const projectRoots = [...new Set(contexts.map((context) => context.projectRoot))].sort(); - const outcomes = [...new Set(contexts.map((context) => context.outcome))].sort(); - const evidenceFiles = [...new Set(contexts.flatMap((context) => context.evidence.map((entry) => entry.path)))].sort(); - return { - dependencyManagers: [...managers.values()].sort((left, right) => left.path.localeCompare(right.path)), - virtualEnvironments: [...environments.values()].sort((left, right) => left.path.localeCompare(right.path)), - notes: contexts.length === 0 - ? [] - : [ - `Canonical Python project contexts: ${projectRoots.join(", ") || "."}.`, - `Canonical Python project evidence: ${evidenceFiles.join(", ")}.`, - `Python context outcomes: ${outcomes.join(", ")}.` - ], - contexts - }; -} - -function hasPythonEnvironmentSignals(environment: OpcoreInitPythonEnvironment): boolean { - return (environment.contexts?.length ?? 0) > 0; -} - -function withContext(payload: OpcoreInitPlanPayload, context: InitContext, timing: TimingState): OpcoreInitPlanPayload { - return { - ...payload, - scan: context.scan, - settings: context.settings, - interaction: context.interaction, - timings: finalizeTimings(timing) - }; -} - -function shouldPromptForApproval(json: boolean, options: ParsedInitArgs, runtime: OpcoreInitRuntime): runtime is OpcoreInitRuntime & { - readLine: (prompt: string) => Promise; -} { - return ( - !json && - !options.approved && - !options.dryRun && - !options.undo && - isInteractiveRuntime(runtime) && - typeof runtime.readLine === "function" - ); -} - -function shouldPromptForScope( - json: boolean, - options: ParsedInitArgs, - git: boolean, - runtime: OpcoreInitRuntime -): runtime is OpcoreInitRuntime & { readLine: (prompt: string) => Promise } { - return ( - git && - !json && - !options.approved && - !options.dryRun && - !options.undo && - !options.scopeExplicit && - !options.repoExplicit && - isInteractiveRuntime(runtime) && - typeof runtime.readLine === "function" - ); -} - -function parseScopeAnswer(answer: string | undefined): ParsedInitArgs["scope"] { - const normalized = (answer ?? "").trim().toLowerCase(); - return normalized === "g" || normalized === "global" ? "global" : "repo"; -} - -function initHomeRoot(runtime: OpcoreInitRuntime): string { - return realpathSync(resolve(runtime.homeDir ?? homedir())); -} - -function scopeRoot(repoRoot: string, homeRoot: string, scope: ParsedInitArgs["scope"]): string { - return scope === "global" ? homeRoot : repoRoot; -} - -function isInteractiveRuntime(runtime: OpcoreInitRuntime): boolean { - return runtime.stdinIsTTY === true && runtime.stdoutIsTTY === true; -} - -function isExplicitYes(answer: string): boolean { - const normalized = answer.trim().toLowerCase(); - return normalized === "y" || normalized === "yes"; -} - -function isApprovedAnswer(answer: string | undefined, command: OpcoreSetupCommand): boolean { - const normalized = (answer ?? "").trim().toLowerCase(); - if (command === "install") return normalized === "" || normalized === "y" || normalized === "yes"; - return isExplicitYes(answer ?? ""); -} - -function approvalPromptSuffix(command: OpcoreSetupCommand): string { - return command === "install" ? "[Y/n]" : "[y/N]"; -} - -function startInitScanProgress( - json: boolean, - runtime: OpcoreInitRuntime, - command: OpcoreSetupCommand -): { complete(scanMs: number, totalFiles?: number): void; fail(scanMs: number): void } | undefined { - if (json || runtime.stderrIsTTY !== true || typeof runtime.writeStderr !== "function") return undefined; - const startedAt = nowMs(); - const intervalMs = normalizeProgressIntervalMs(runtime.initProgressIntervalMs); - let finished = false; - const write = (text: string) => writeProgress(runtime.writeStderr, text); - const writeProgressLine = (text: string) => write(`\r\x1b[2K${text}`); - write(`Opcore ${command}: scanning repository before setup...`); - const timer = setInterval(() => { - if (finished) return; - const elapsedSeconds = Math.max(1, Math.floor(elapsedMs(startedAt) / 1000)); - writeProgressLine(`Opcore ${command}: still scanning repository before setup (${elapsedSeconds}s elapsed)...`); - }, intervalMs) as ReturnType & { unref?: () => void }; - timer.unref?.(); - return { - complete: (scanMs: number) => { - if (finished) return; - finished = true; - clearInterval(timer); - writeProgressLine(`Opcore ${command}: scan complete in ${scanMs}ms.\n`); - }, - fail: (scanMs: number) => { - if (finished) return; - finished = true; - clearInterval(timer); - writeProgressLine(`Opcore ${command}: scan failed after ${scanMs}ms.\n`); - } - }; -} - -function normalizeProgressIntervalMs(value: number | undefined): number { - return typeof value === "number" && Number.isFinite(value) && value > 0 - ? Math.max(1, Math.floor(value)) - : defaultInitProgressIntervalMs; -} - -function writeProgress(writeStderr: ((text: string) => void) | undefined, text: string): void { - try { - writeStderr?.(text); - } catch { - // Progress output must never change init scan/apply semantics. - } -} - -function nowMs(): number { - return Date.now(); -} - -function elapsedMs(startedAt: number): number { - return Math.max(0, Date.now() - startedAt); -} - -function finalizeTimings(timing: TimingState): OpcoreInitTiming { - return { - scanMs: timing.scanMs, - planMs: timing.planMs, - promptMs: timing.promptMs, - applyMs: timing.applyMs, - totalMs: elapsedMs(timing.startedAt), - firstOutputMs: timing.scanMs - }; -} - -function parseOpcoreInitArgs( - args: readonly string[], - command: OpcoreSetupCommand -): { ok: true; args: ParsedInitArgs } | { ok: false; message: string } { - const parsed: ParsedInitArgs = { - command, - repo: process.cwd(), - repoExplicit: false, - scope: "repo", - scopeExplicit: false, - approved: false, - dryRun: false, - failClosedHook: false, - agentSkill: command === "install", - writeGateHooks: true, - activePreCommitHook: command === "install", - undo: command === "uninstall" - }; - for (let index = 0; index < args.length; index += 1) { - const arg = args[index]; - if (arg === "--repo") { - const value = args[index + 1]; - if (!value || value.startsWith("--")) return { ok: false, message: `opcore ${command}: --repo requires a path` }; - parsed.repo = value; - parsed.repoExplicit = true; - parsed.scope = "repo"; - parsed.scopeExplicit = true; - index += 1; - continue; - } - if (arg.startsWith("--repo=")) { - const value = arg.slice("--repo=".length); - if (!value) return { ok: false, message: `opcore ${command}: --repo requires a path` }; - parsed.repo = value; - parsed.repoExplicit = true; - parsed.scope = "repo"; - parsed.scopeExplicit = true; - continue; - } - if (arg === "--global") { - parsed.scope = "global"; - parsed.scopeExplicit = true; - continue; - } - if (arg === "--local") { - parsed.scope = "repo"; - parsed.scopeExplicit = true; - continue; - } - if (arg === "--approve" || arg === "--yes") { - parsed.approved = true; - continue; - } - if (arg === "--dry-run") { - parsed.dryRun = true; - continue; - } - if (arg === "--fail-closed-hook") { - parsed.failClosedHook = true; - continue; - } - if (arg === "--no-pre-commit" && command === "install") { - parsed.activePreCommitHook = false; - continue; - } - if (arg === "--no-skill" && command === "install") { - parsed.agentSkill = false; - continue; - } - if (arg === "--undo" && command !== "install") { - parsed.undo = true; - continue; - } - return { ok: false, message: `opcore ${command}: unsupported argument ${arg}` }; - } - return { ok: true, args: parsed }; -} - -function planInit( - repoRoot: string, - requestedPath: string, - git: boolean, - homeRoot: string, - options: ParsedInitArgs, - context: InitContext -): PlannedInit { - if (options.scope === "global") return planGlobalInit(repoRoot, requestedPath, homeRoot, options, context); - const agentFiles = detectAgentFiles(repoRoot); - const linkedGitWorktree = git && isLinkedGitWorktree(repoRoot); - const activePreCommitWritePlanned = options.activePreCommitHook && - git && - !linkedGitWorktree && - !repoPathExists(repoRoot, activePreCommitHookPath); - const config = createConfig( - repoRoot, - options.failClosedHook, - activePreCommitWritePlanned, - options.writeGateHooks, - context.scan, - context.settings - ); - const writes: PlannedWrite[] = [ - { - kind: "write", - path: configPath, - targetScope: "repo", - content: `${JSON.stringify(config, null, 2)}\n` - }, - ...agentFiles.map((path) => ({ - kind: "write" as const, - path, - targetScope: "repo" as const, - content: upsertOpcoreBlock(readOptionalRepoFile(repoRoot, path)) - })) - ]; - if (options.writeGateHooks) { - writes.push( - { - kind: "write", - path: agentGateHookPath, - targetScope: "repo", - content: opcoreAgentGateHookScriptContent(), - executable: true - }, - { - kind: "write", - path: claudeSettingsPath, - targetScope: "repo", - content: `${JSON.stringify(mergeClaudeSettings(readJsonObjectIfExists(repoRoot, claudeSettingsPath), "repo"), null, 2)}\n` - }, - { - kind: "write", - path: codexHooksPath, - targetScope: "repo", - content: `${JSON.stringify(mergeCodexHooks(readJsonObjectIfExists(repoRoot, codexHooksPath), "repo"), null, 2)}\n` - } - ); - } - if (options.agentSkill) { - writes.push(...agentSkillPaths.map((path) => ({ - kind: "write" as const, - path, - targetScope: "repo" as const, - content: opcoreAgentSkillContent() - }))); - } - if (git) { - const gitignore = readOptionalRepoFile(repoRoot, gitignorePath); - if (!gitignoreIgnoresOpcore(gitignore ?? "")) { - writes.push({ - kind: "append_managed_line", - path: gitignorePath, - targetScope: "repo", - line: opcoreIgnoreLine - }); - } - if (activePreCommitWritePlanned) { - writes.push({ - kind: "write", - path: activePreCommitHookPath, - targetScope: "repo", - content: activePreCommitHookContent(), - executable: true - }); - } - } - if (options.failClosedHook) { - writes.push({ - kind: "write", - path: hookPath, - targetScope: "repo", - content: failClosedHookContent(), - executable: true - }); - } - const actions = createInitActions( - options.scope, - agentFiles, - options, - writes.some((write) => write.path === gitignorePath), - activePreCommitWritePlanned - ); - return { - writes, - payload: { - schemaVersion: 1, - mode: "plan", - approved: false, - repo: { - root: repoRoot, - requestedPath - }, - options: { - scope: options.scope, - failClosedHook: options.failClosedHook, - dryRun: options.dryRun - }, - agentFiles, - actions, - warnings: initWarnings( - context.scan, - git, - options.failClosedHook, - options.scope, - activePreCommitWritePlanned, - options.activePreCommitHook && git, - linkedGitWorktree - ), - nextActions: initNextActions(options), - undoAvailable: repoPathExists(repoRoot, undoPath), - scan: context.scan, - settings: context.settings, - interaction: context.interaction, - timings: context.timings - } - }; -} - -function planGlobalInit( - repoRoot: string, - requestedPath: string, - homeRoot: string, - options: ParsedInitArgs, - context: InitContext -): PlannedInit { - const writes: PlannedWrite[] = [ - ...(options.writeGateHooks - ? [{ - kind: "write" as const, - path: agentGateHookPath, - targetScope: "global" as const, - content: opcoreAgentGateHookScriptContent(), - executable: true - }] - : []), - ...(options.agentSkill ? agentSkillPaths.map((path) => ({ - kind: "write" as const, - path, - targetScope: "global" as const, - content: opcoreAgentSkillContent() - })) : []), - ...(options.writeGateHooks - ? [ - { - kind: "write" as const, - path: claudeSettingsPath, - targetScope: "global" as const, - content: `${JSON.stringify(mergeClaudeSettings(readJsonObjectIfExists(homeRoot, claudeSettingsPath), "global"), null, 2)}\n` - }, - { - kind: "write" as const, - path: codexHooksPath, - targetScope: "global" as const, - content: `${JSON.stringify(mergeCodexHooks(readJsonObjectIfExists(homeRoot, codexHooksPath), "global"), null, 2)}\n` - } - ] - : []) - ]; - return { - writes, - payload: { - schemaVersion: 1, - mode: "plan", - approved: false, - repo: { - root: repoRoot, - requestedPath - }, - options: { - scope: options.scope, - failClosedHook: options.failClosedHook, - dryRun: options.dryRun - }, - agentFiles: [], - actions: createInitActions(options.scope, [], options, false, false), - warnings: initWarnings(context.scan, true, false, options.scope, false, false, false), - nextActions: initNextActions(options), - undoAvailable: repoPathExists(homeRoot, globalUndoPath), - scan: context.scan, - settings: context.settings, - interaction: context.interaction, - timings: context.timings - } - }; -} - -function appliedInitPayload( - payload: OpcoreInitPlanPayload, - root: string, - scope: ParsedInitArgs["scope"], - command: OpcoreSetupCommand -): OpcoreInitPlanPayload { - return { - ...payload, - mode: "apply", - approved: true, - nextActions: appliedInitNextActions(payload, command), - undoAvailable: repoPathExists(root, undoPathForScope(scope)) - }; -} - -function initNextActions(options: ParsedInitArgs): string[] { - const approveFlag = options.command === "install" ? "--yes" : "--approve"; - const scopedCommand = options.scope === "global" - ? `opcore ${options.command} --global ${approveFlag}` - : `opcore ${options.command} ${approveFlag}`; - const actions = options.dryRun - ? [`Run ${scopedCommand} to apply this plan.`] - : [`Review this plan, then run ${scopedCommand} to write setup.`]; - if (options.scope === "repo") { - actions.push("Claude Code and Codex write-gate hooks are installed by Opcore setup; review Codex project hook trust with /hooks if Codex asks."); - } else { - actions.push("Global Claude Code and Codex write-gate hooks are installed by Opcore setup; review Codex hook trust with /hooks if Codex asks."); - } - if (options.failClosedHook) actions.push(failClosedHookManualInstallAction()); - return actions; -} - -function appliedInitNextActions(payload: OpcoreInitPlanPayload, command: OpcoreSetupCommand): string[] { - const undoCommand = command === "install" - ? payload.options.scope === "global" ? "opcore uninstall --global --yes" : "opcore uninstall --yes" - : payload.options.scope === "global" ? "opcore init --global --undo --approve" : "opcore init --undo --approve"; - const actions = [`Run ${undoCommand} to restore or remove recorded setup files.`]; - if (payload.options.scope === "repo") { - actions.push("Claude Code write calls are blocked on non-ok receipts. Codex uses a PreToolUse guardrail and may require hook trust review."); - } else { - actions.push("Global Claude Code write calls are blocked on non-ok receipts. Codex uses a PreToolUse guardrail and may require hook trust review."); - } - if (payload.options.failClosedHook) actions.push(failClosedHookManualInstallAction()); - return actions; -} - -function failClosedHookManualInstallAction(): string { - return `Manual install required before the fail-closed hook is active: ${failClosedHookActivationCommand}`; -} - -function planUndo( - repoRoot: string, - requestedPath: string, - homeRoot: string, - options: ParsedInitArgs, - context: InitContext -): OpcoreInitPlanPayload { - const root = scopeRoot(repoRoot, homeRoot, options.scope); - const metadata = readUndoMetadata(root, options.scope); - return { - schemaVersion: 1, - mode: "undo", - approved: options.approved && !options.dryRun, - repo: { - root: repoRoot, - requestedPath - }, - options: { - scope: options.scope, - failClosedHook: options.failClosedHook, - dryRun: options.dryRun - }, - agentFiles: metadata.entries - .map((entry) => entry.path) - .filter((path) => AGENT_FILE_CANDIDATES.includes(path as (typeof AGENT_FILE_CANDIDATES)[number])), - actions: metadata.entries.map((entry) => ({ - kind: entry.kind === "append_managed_line" ? "remove" : entry.existed ? "restore" : "remove", - path: actionPath(options.scope, entry.path), - targetScope: options.scope, - summary: entry.kind === "append_managed_line" - ? `Remove managed ${entry.line} gitignore entry from ${entry.path}.` - : entry.existed - ? `Restore ${actionPath(options.scope, entry.path)} from Opcore init backup.` - : `Remove ${actionPath(options.scope, entry.path)} created by Opcore init.`, - requiresApproval: !entry.path.startsWith(".opcore/"), - outsideOpcore: !entry.path.startsWith(".opcore/") - })), - warnings: [], - nextActions: options.approved && !options.dryRun - ? [undoAppliedNextAction(options.command)] - : [undoPreviewNextAction(options)], - undoAvailable: true, - scan: context.scan, - settings: context.settings, - interaction: context.interaction, - timings: context.timings - }; -} - -function appliedUndoPayload( - payload: OpcoreInitPlanPayload, - root: string, - scope: ParsedInitArgs["scope"], - command: OpcoreSetupCommand -): OpcoreInitPlanPayload { - return { - ...payload, - approved: true, - nextActions: [undoAppliedNextAction(command)], - undoAvailable: repoPathExists(root, undoPathForScope(scope)) - }; -} - -function undoAppliedNextAction(command: OpcoreSetupCommand): string { - if (command === "uninstall") return "Opcore setup metadata was restored or removed; rerun opcore install to recreate setup."; - return "Opcore init metadata was restored or removed; rerun opcore init to recreate setup."; -} - -function undoPreviewNextAction(options: ParsedInitArgs): string { - if (options.command === "uninstall") { - return `Run ${options.scope === "global" ? "opcore uninstall --global --yes" : "opcore uninstall --yes"} to restore or remove recorded setup files.`; - } - return `Run ${options.scope === "global" ? "opcore init --global --undo --approve" : "opcore init --undo --approve"} to restore or remove recorded setup files.`; -} - -function applyInit(root: string, scope: ParsedInitArgs["scope"], writes: readonly PlannedWrite[]): void { - const scopedWrites = writes.filter((write) => write.targetScope === scope); - const previousMetadata = readUndoMetadataIfExists(root, scope); - const touchedPaths = uniqueStrings([ - ...(previousMetadata?.entries.map((entry) => entry.path) ?? []), - ...scopedWrites.map((write) => write.path), - undoPathForScope(scope) - ]); - for (const path of touchedPaths) assertMutationPath(root, path, `Opcore ${scope} init target`); - const metadata: UndoMetadata = { - schemaVersion: 1, - kind: scope === "global" ? "opcore_global_init_undo" : "opcore_init_undo", - ...(scope === "global" ? { homeRoot: root } : { repoRoot: root }), - entries: touchedPaths.map((path) => { - const previousEntry = previousMetadata?.entries.find((entry) => entry.path === path); - if (previousEntry) return previousEntry; - return priorEntry(root, path, scopedWrites.find((write) => write.path === path)); - }) - }; - for (const write of scopedWrites) writeScopedFile(root, write); - writeScopedFile(root, { - kind: "write", - path: undoPathForScope(scope), - targetScope: scope, - content: `${JSON.stringify(metadata, null, 2)}\n` - }); -} - -function applyUndo(root: string, scope: ParsedInitArgs["scope"], payload: OpcoreInitPlanPayload): void { - const metadata = readUndoMetadata(root, scope); - const scopedUndoPath = undoPathForScope(scope); - for (const entry of metadata.entries) assertMutationPath(root, entry.path, "Opcore init undo target"); - for (const entry of metadata.entries.filter((entry) => entry.path !== scopedUndoPath)) restoreUndoEntry(root, entry); - const undoEntry = metadata.entries.find((entry) => entry.path === scopedUndoPath); - if (undoEntry) restoreUndoEntry(root, undoEntry); - else rmSync(resolveScopedPath(root, scopedUndoPath), { force: true }); - removeEmptyOpcoreHookDir(root); - void payload; -} - -function createInitActions( - scope: ParsedInitArgs["scope"], - agentFiles: readonly string[], - options: ParsedInitArgs, - gitignoreWritePlanned: boolean, - activePreCommitWritePlanned: boolean -): OpcoreInitAction[] { - const skillActions: OpcoreInitAction[] = options.agentSkill - ? agentSkillPaths.map((path) => ({ - kind: "write" as const, - path: actionPath(scope, path), - targetScope: scope, - summary: "Install the Opcore agent skill.", - requiresApproval: true, - outsideOpcore: true - })) - : []; - if (scope === "global") { - return [ - ...(options.writeGateHooks - ? [{ - kind: "create_hook" as const, - path: actionPath(scope, agentGateHookPath), - targetScope: scope, - summary: "Install the global Opcore write-gate adapter script.", - requiresApproval: false, - outsideOpcore: false - }] - : []), - ...skillActions, - ...(options.writeGateHooks - ? [ - { - kind: "wire_harness" as const, - path: actionPath(scope, claudeSettingsPath), - targetScope: scope, - summary: "Merge the Opcore Claude Code PreToolUse write gate.", - requiresApproval: true, - outsideOpcore: true - }, - { - kind: "wire_harness" as const, - path: actionPath(scope, codexHooksPath), - targetScope: scope, - summary: "Merge the Opcore Codex PreToolUse write gate guardrail.", - requiresApproval: true, - outsideOpcore: true - } - ] - : []) - ]; - } - const actions: OpcoreInitAction[] = [ - { - kind: "write", - path: configPath, - targetScope: scope, - summary: "Write additive Opcore init config.", - requiresApproval: false, - outsideOpcore: false - }, - ...agentFiles.map((path) => ({ - kind: "upsert_block" as const, - path, - targetScope: scope, - summary: "Add or update delimited Opcore agent guidance.", - requiresApproval: true, - outsideOpcore: true - })), - ...skillActions, - ...(options.writeGateHooks - ? [ - { - kind: "create_hook" as const, - path: agentGateHookPath, - targetScope: scope, - summary: "Install the repo-local Opcore write-gate adapter script.", - requiresApproval: false, - outsideOpcore: false - }, - { - kind: "wire_harness" as const, - path: claudeSettingsPath, - targetScope: scope, - summary: "Merge the Opcore Claude Code PreToolUse write gate.", - requiresApproval: true, - outsideOpcore: true - }, - { - kind: "wire_harness" as const, - path: codexHooksPath, - targetScope: scope, - summary: "Merge the Opcore Codex PreToolUse write gate guardrail.", - requiresApproval: true, - outsideOpcore: true - } - ] - : []) - ]; - if (gitignoreWritePlanned) { - actions.push({ - kind: "write", - path: gitignorePath, - targetScope: scope, - summary: "Append managed .opcore/ gitignore entry.", - requiresApproval: true, - outsideOpcore: true - }); - } - if (activePreCommitWritePlanned) { - actions.push({ - kind: "create_hook", - path: activePreCommitHookPath, - targetScope: scope, - summary: "Install active Git pre-commit hook that runs `opcore check --changed`.", - requiresApproval: true, - outsideOpcore: true - }); - } - if (options.failClosedHook) { - actions.push({ - kind: "create_hook", - path: hookPath, - targetScope: scope, - summary: `Manual install required: create fail-closed pre-commit hook script; activate with \`${failClosedHookActivationCommand}\`.`, - requiresApproval: false, - outsideOpcore: false - }); - } - return actions; -} - -function detectAgentFiles(repoRoot: string): string[] { - const existing = AGENT_FILE_CANDIDATES.filter((path) => repoPathExists(repoRoot, path)); - for (const path of existing) assertExistingRepoPath(repoRoot, path, "Existing agent guidance file", "file"); - return existing.length > 0 ? [...existing] : ["AGENTS.md"]; -} - -function upsertOpcoreBlock(existing: string | undefined): string { - const block = guidanceBlock(); - if (existing === undefined || existing.length === 0) return `${block}\n`; - const begin = existing.indexOf(beginMarker); - const end = existing.indexOf(endMarker); - if ((begin === -1) !== (end === -1) || (begin !== -1 && end < begin)) { - throw new Error("existing Opcore init guidance markers are unbalanced"); - } - if (begin !== -1) { - const replacementEnd = end + endMarker.length; - return `${trimRightPreserve(existing.slice(0, begin))}\n\n${block}\n${trimLeftPreserve(existing.slice(replacementEnd))}`.replace(/\n{3,}/g, "\n\n"); - } - return `${trimRightPreserve(existing)}\n\n${block}\n`; -} - -function guidanceBlock(): string { - return [ - beginMarker, - "## Opcore", - "", - "- Run `opcore check --changed` before finalizing edits.", - "- Preserve existing repo lint/test/CI/pre-commit guardrails.", - "- Treat unsupported stacks and degraded tools honestly.", - "- For Python repos, require one configured per-project type authority; treat absent, conflicting, unavailable, or deferred authority and missing ruff/pytest as degraded coverage, not a pass.", - "- Do not rely on ACE, Rox, CRG, CIX, or ASP host authority for direct Opcore.", - endMarker - ].join("\n"); -} - -function createConfig( - repoRoot: string, - failClosedHook: boolean, - activePreCommitHook: boolean, - writeGateHooks: boolean, - scan: OpcoreInitScanSummary, - settings: OpcoreInitSettings -): Record { - const existing = readJsonObject(repoRoot, configPath); - const existingHooks = isPlainObject(existing.hooks) ? existing.hooks : {}; - const existingGuidance = isPlainObject(existing.guidance) ? existing.guidance : {}; - const existingOnboarding = isPlainObject(existing.onboarding) ? existing.onboarding : {}; - const onboardingScan = isPlainObject(existingOnboarding.scan) ? existingOnboarding.scan : scan; - const onboardingLanguages = Array.isArray(existingOnboarding.languages) ? existingOnboarding.languages : settings.languages; - return { - ...existing, - schemaVersion: 1, - kind: "opcore_init_config", - onboarding: { - ...existingOnboarding, - scan: onboardingScan, - languages: onboardingLanguages, - timingPayload: true - }, - guidance: { - ...existingGuidance, - checkCommand: "opcore check --changed", - preserveExistingGuardrails: true, - treatUnsupportedCoverageHonestly: true, - directProductAuthority: "opcore" - }, - hooks: { - ...existingHooks, - failClosedPreCommit: existingHooks.failClosedPreCommit === true || failClosedHook || activePreCommitHook, - activePreCommit: existingHooks.activePreCommit === true || activePreCommitHook, - writeGate: existingHooks.writeGate === true || writeGateHooks, - harnesses: writeGateHooks - ? ["claude-code", "codex"] - : Array.isArray(existingHooks.harnesses) ? existingHooks.harnesses : [] - } - }; -} - -function initWarnings( - scan: OpcoreInitScanSummary, - git: boolean, - failClosedHook: boolean, - scope: ParsedInitArgs["scope"], - activePreCommitHook: boolean, - activePreCommitRequested: boolean, - linkedGitWorktree: boolean -): string[] { - const warnings: string[] = []; - if (scan.unsupportedStacks.length > 0) { - warnings.push(`Unsupported stacks: ${scan.unsupportedStacks.map((stack) => `${stack.language} (${stack.count})`).join(", ")}`); - } - if (scan.degradedRustTools.length > 0) { - warnings.push(`Degraded validation tools: ${scan.degradedRustTools.map((tool) => tool.tool).join(", ")}`); - } - if (!git) { - warnings.push("No Git repository detected; .opcore/ ignore entry not written."); - } - warnings.push("Do not weaken existing lint, test, CI, pre-commit, or agent guardrails."); - warnings.push( - scope === "global" - ? "Global write-gate hooks apply across repos; undo removes only Opcore-recorded global hook entries." - : "Repo write-gate hooks are additive; Codex project hooks may require trust review before they run." - ); - warnings.push( - failClosedHook - ? `Fail-closed hook script is opt-in. Manual install required: ${failClosedHookActivationCommand}` - : activePreCommitHook && git - ? "Git pre-commit hook will run opcore check --changed when no existing .git/hooks/pre-commit is present." - : linkedGitWorktree - ? "Linked Git worktree detected; Opcore will not install .git/hooks/pre-commit from this checkout." - : activePreCommitRequested - ? "Existing .git/hooks/pre-commit detected; Opcore will not overwrite it." - : "Fail-closed hooks are opt-in and are not created unless --fail-closed-hook is approved." - ); - return warnings; -} - -function failClosedHookContent(): string { - return [ - "#!/usr/bin/env sh", - "# Manual install required.", - "# This script is not active until installed.", - `# Activation command: ${failClosedHookActivationCommand}`, - "set -eu", - "opcore check --changed", - "" - ].join("\n"); -} - -function activePreCommitHookContent(): string { - return [ - "#!/usr/bin/env sh", - "# Installed by opcore install. Remove with opcore uninstall.", - "set -eu", - "opcore check --changed", - "" - ].join("\n"); -} - -function opcoreAgentSkillContent(): string { - return [ - "---", - "name: opcore", - "description: Use when working in a repository that has installed Opcore robustness checks.", - "---", - "", - "# Opcore", - "", - "Use Opcore as the repository-local robustness gate for coding-agent edits.", - "", - "- Run `opcore status` to inspect activation and coverage before broad work.", - "- Run `opcore check --changed` before finalizing source edits.", - "- Treat unsupported stacks and degraded tools honestly; do not report them as clean coverage.", - "- Preserve existing lint, test, CI, pre-commit, and agent guardrails.", - "- The installed write gate is a hook guardrail for supported edit tools, not host authority.", - "" - ].join("\n"); -} - -function mergeClaudeSettings(existing: Record, scope: ParsedInitArgs["scope"]): Record { - return mergePreToolUseHook(existing, { - matcher: "Edit|MultiEdit|Write", - command: agentGateCommand("claude", scope), - statusMessage: "Running Opcore write gate" - }); -} - -function mergeCodexHooks(existing: Record, scope: ParsedInitArgs["scope"]): Record { - return mergePreToolUseHook(existing, { - matcher: "apply_patch|Edit|Write", - command: agentGateCommand("codex", scope), - statusMessage: "Running Opcore write gate" - }); -} - -function mergePreToolUseHook( - existing: Record, - hook: { matcher: string; command: string; statusMessage: string } -): Record { - const hooks = isPlainObject(existing.hooks) ? existing.hooks : {}; - const preToolUse = Array.isArray(hooks.PreToolUse) ? [...hooks.PreToolUse] : []; - const groupIndex = preToolUse.findIndex((entry) => isPlainObject(entry) && entry.matcher === hook.matcher); - const hookEntry = { - type: "command", - command: hook.command, - timeout: 30, - statusMessage: hook.statusMessage - }; - if (groupIndex >= 0) { - const group = preToolUse[groupIndex]; - if (isPlainObject(group)) { - const groupHooks = Array.isArray(group.hooks) ? [...group.hooks] : []; - const alreadyPresent = groupHooks.some( - (entry) => isPlainObject(entry) && typeof entry.command === "string" && entry.command.includes("opcore-agent-gate.mjs") - ); - preToolUse[groupIndex] = { - ...group, - hooks: alreadyPresent ? groupHooks : [...groupHooks, hookEntry] - }; - } - } else { - preToolUse.push({ - matcher: hook.matcher, - hooks: [hookEntry] - }); - } - return { - ...existing, - hooks: { - ...hooks, - PreToolUse: preToolUse - } - }; -} - -function agentGateCommand(harness: "claude" | "codex", scope: ParsedInitArgs["scope"]): string { - const hook = scope === "global" - ? "$HOME/.opcore/hooks/opcore-agent-gate.mjs" - : '"$(git rev-parse --show-toplevel)/.opcore/hooks/opcore-agent-gate.mjs"'; - const repo = scope === "global" ? "" : ' --repo "$(git rev-parse --show-toplevel)"'; - return `node ${hook} --harness ${harness}${repo}`; -} - -function priorEntry(repoRoot: string, path: string, write: PlannedWrite | undefined): UndoEntry { - if (write?.kind === "append_managed_line") { - const existing = readOptionalRepoFile(repoRoot, path); - return { - kind: "append_managed_line", - path, - existed: existing !== undefined, - line: write.line, - appended: appendManagedGitignoreLine(existing) - }; - } - if (!repoPathExists(repoRoot, path)) return { kind: "restore_file", path, existed: false }; - return { - kind: "restore_file", - path, - existed: true, - content: readFileSync(assertExistingRepoPath(repoRoot, path, "Existing Opcore init target", "file"), "utf8") - }; -} - -function writeRepoFile(repoRoot: string, write: PlannedWrite): void { - const absolute = assertRepoMutationPath(repoRoot, write.path, "Opcore init write target"); - mkdirSync(dirname(absolute), { recursive: true }); - if (write.kind === "append_managed_line") { - const existing = readOptionalRepoFile(repoRoot, write.path); - appendFileSync(absolute, appendManagedGitignoreLine(existing)); - return; - } - writeFileSync(absolute, write.content, "utf8"); - if (write.executable) { - assertRepoMutationPath(repoRoot, write.path, "Opcore init chmod target"); - chmodSync(absolute, 0o755); - } -} - -function writeScopedFile(root: string, write: PlannedWrite): void { - writeRepoFile(root, write); -} - -function restoreUndoEntry(repoRoot: string, entry: UndoEntry): void { - const absolute = assertRepoMutationPath(repoRoot, entry.path, "Opcore init undo target"); - if (entry.kind === "append_managed_line") { - if (!repoPathExists(repoRoot, entry.path)) return; - const removal = removeManagedGitignoreLine(readFileSync(absolute, "utf8"), entry); - if (!removal.removed) return; - if (!entry.existed && removal.content.length === 0) { - rmSync(absolute, { force: true }); - return; - } - writeFileSync(absolute, removal.content, "utf8"); - return; - } - if (!entry.existed) { - rmSync(absolute, { force: true }); - return; - } - if (entry.content === undefined) { - throw new Error(`Undo entry for ${entry.path} is missing content`); - } - mkdirSync(dirname(absolute), { recursive: true }); - writeFileSync(absolute, entry.content, "utf8"); -} - -function readUndoMetadata(root: string, scope: ParsedInitArgs["scope"]): UndoMetadata { - const scopedUndoPath = undoPathForScope(scope); - const raw = readFileSync(assertExistingRepoPath(root, scopedUndoPath, "Opcore init undo metadata", "file"), "utf8"); - const parsed = JSON.parse(raw) as unknown; - const expectedKind = scope === "global" ? "opcore_global_init_undo" : "opcore_init_undo"; - if (!isPlainObject(parsed) || parsed.schemaVersion !== 1 || parsed.kind !== expectedKind || !Array.isArray(parsed.entries)) { - throw new Error(".opcore/init-undo.json is not valid Opcore init undo metadata"); - } - const recordedRoot = scope === "global" ? parsed.homeRoot : parsed.repoRoot; - if (typeof recordedRoot !== "string" || resolve(recordedRoot) !== resolve(root)) { - throw new Error(".opcore/init-undo.json repoRoot does not match this repository"); - } - const allowedPaths = scope === "global" ? allowedGlobalUndoPaths : allowedUndoPaths; - const seenPaths = new Set(); - const entries: UndoEntry[] = []; - for (const entry of parsed.entries) { - if (!isPlainObject(entry) || typeof entry.path !== "string" || typeof entry.existed !== "boolean") { - throw new Error(".opcore/init-undo.json contains an invalid entry"); - } - if (!allowedPaths.has(entry.path)) { - throw new Error(`.opcore/init-undo.json contains unsupported path: ${entry.path}`); - } - if (seenPaths.has(entry.path)) { - throw new Error(`.opcore/init-undo.json contains duplicate path: ${entry.path}`); - } - seenPaths.add(entry.path); - const kind = typeof entry.kind === "string" ? entry.kind : "restore_file"; - if (kind === "append_managed_line") { - if (entry.path !== gitignorePath) { - throw new Error(`.opcore/init-undo.json append-managed-line entry targets unsupported path: ${entry.path}`); - } - if (typeof entry.line !== "string" || entry.line !== opcoreIgnoreLine) { - throw new Error(`.opcore/init-undo.json append-managed-line entry for ${entry.path} has invalid line`); - } - if ( - "appended" in entry && - entry.appended !== undefined && - entry.appended !== `${opcoreIgnoreLine}\n` && - entry.appended !== `\n${opcoreIgnoreLine}\n` - ) { - throw new Error(`.opcore/init-undo.json append-managed-line entry for ${entry.path} has invalid appended text`); - } - resolveRepoPath(root, entry.path); - entries.push({ - kind: "append_managed_line", - path: entry.path, - existed: entry.existed, - line: entry.line, - ...(typeof entry.appended === "string" ? { appended: entry.appended } : {}) - }); - continue; - } - if (entry.path === gitignorePath) { - throw new Error(".opcore/init-undo.json .gitignore entry must use managed-line undo metadata"); - } - if (kind !== "restore_file") { - throw new Error(`.opcore/init-undo.json contains unsupported entry kind: ${kind}`); - } - if (entry.existed && typeof entry.content !== "string") { - throw new Error(`.opcore/init-undo.json restore entry for ${entry.path} is missing string content`); - } - if (!entry.existed && "content" in entry && entry.content !== undefined && typeof entry.content !== "string") { - throw new Error(`.opcore/init-undo.json remove entry for ${entry.path} has invalid content`); - } - resolveRepoPath(root, entry.path); - entries.push({ - kind: "restore_file", - path: entry.path, - existed: entry.existed, - ...(typeof entry.content === "string" ? { content: entry.content } : {}) - }); - } - return { - schemaVersion: 1, - kind: expectedKind, - ...(scope === "global" ? { homeRoot: recordedRoot } : { repoRoot: recordedRoot }), - entries - }; -} - -function readUndoMetadataIfExists(root: string, scope: ParsedInitArgs["scope"]): UndoMetadata | undefined { - if (!repoPathExists(root, undoPathForScope(scope))) return undefined; - return readUndoMetadata(root, scope); -} - -function readJsonObject(repoRoot: string, path: string): Record { - const content = readOptionalRepoFile(repoRoot, path); - if (content === undefined) return {}; - const parsed = JSON.parse(content) as unknown; - if (!isPlainObject(parsed)) throw new Error(`${path} must contain a JSON object`); - return parsed; -} - -function readJsonObjectIfExists(repoRoot: string, path: string): Record { - if (!repoPathExists(repoRoot, path)) return {}; - return readJsonObject(repoRoot, path); -} - -function readOptionalRepoFile(repoRoot: string, path: string): string | undefined { - if (!repoPathExists(repoRoot, path)) return undefined; - return readFileSync(assertExistingRepoPath(repoRoot, path, "Existing repo file", "file"), "utf8"); -} - -function gitignoreIgnoresOpcore(content: string): boolean { - let ignored = false; - for (const rawLine of content.split(/\r\n|\n|\r/u)) { - const line = rawLine.trim(); - if (line.length === 0 || line.startsWith("#")) continue; - const negated = line.startsWith("!"); - const pattern = negated ? line.slice(1).trim() : line; - if (isOpcoreGitignorePattern(pattern)) { - ignored = !negated; - } - } - return ignored; -} - -function isOpcoreGitignorePattern(pattern: string): boolean { - return pattern === ".opcore" || - pattern === ".opcore/" || - pattern === "/.opcore" || - pattern === "/.opcore/" || - pattern === ".opcore/**" || - pattern === "/.opcore/**"; -} - -function appendManagedGitignoreLine(existing: string | undefined): string { - if (existing === undefined || existing.length === 0) return `${opcoreIgnoreLine}\n`; - return `${existing.endsWith("\n") || existing.endsWith("\r") ? "" : "\n"}${opcoreIgnoreLine}\n`; -} - -function removeManagedGitignoreLine(current: string, entry: ManagedLineUndoEntry): { content: string; removed: boolean } { - if (entry.appended !== undefined && current.endsWith(entry.appended)) { - return { content: current.slice(0, -entry.appended.length), removed: true }; - } - const chunks = current.match(/[^\r\n]*(?:\r\n|\n|\r|$)/gu) ?? []; - const meaningfulChunks = chunks.filter((chunk) => chunk.length > 0); - for (let index = 0; index < meaningfulChunks.length; index += 1) { - const chunk = meaningfulChunks[index]; - const chunkLine = chunk.replace(/(?:\r\n|\n|\r)$/u, ""); - if (chunkLine === entry.line) { - meaningfulChunks.splice(index, 1); - return { content: meaningfulChunks.join(""), removed: true }; - } - } - return { content: current, removed: false }; -} - -function resolveRepoPath(repoRoot: string, path: string): string { - if (path.length === 0 || path.includes("\0")) throw new Error(`Invalid repo path: ${path}`); - const absolute = resolve(repoRoot, path); - const normalized = relative(repoRoot, absolute); - if (normalized === "" || normalized.startsWith("..") || normalized.split(sep).includes("..")) { - throw new Error(`Repo-relative path escapes repository: ${path}`); - } - return absolute; -} - -function resolveScopedPath(root: string, path: string): string { - return resolveRepoPath(root, path); -} - -function assertMutationPath(root: string, path: string, label: string): string { - return assertRepoMutationPath(root, path, label); -} - -function undoPathForScope(_scope: ParsedInitArgs["scope"]): string { - return undoPath; -} - -function actionPath(scope: ParsedInitArgs["scope"], path: string): string { - return scope === "global" ? `~/${path}` : path; -} - -function assertRepoMutationPath(repoRoot: string, path: string, label: string): string { - const absolute = resolveRepoPath(repoRoot, path); - assertExistingAncestorInsideRepo(repoRoot, absolute, path, label); - if (lstatIfExists(absolute)) assertExistingRepoPath(repoRoot, path, label, "file"); - return absolute; -} - -function assertExistingRepoPath(repoRoot: string, path: string, label: string, expected: "file" | "directory"): string { - const absolute = resolveRepoPath(repoRoot, path); - assertExistingAncestorInsideRepo(repoRoot, absolute, path, label); - return assertExistingAbsolutePath(repoRoot, absolute, path, label, expected); -} - -function assertExistingAbsolutePath( - repoRoot: string, - absolute: string, - displayPath: string, - label: string, - expected: "file" | "directory" -): string { - const lstat = lstatIfExists(absolute); - if (!lstat) throw new Error(`${label} does not exist: ${displayPath}`); - if (lstat.isSymbolicLink()) throw new Error(`${label} must not be a symlink: ${displayPath}`); - let realPath: string; - try { - realPath = realpathSync(absolute); - } catch (error) { - throw new Error(`${label} symlink cannot be resolved for ${displayPath}: ${errorMessage(error)}`); - } - if (!isInsideRepo(repoRoot, realPath)) { - throw new Error(`${label} resolves outside repository through a symlink: ${displayPath}`); - } - const stat = statSync(absolute); - if (expected === "file" && !stat.isFile()) throw new Error(`${label} is not a file: ${displayPath}`); - if (expected === "directory" && !stat.isDirectory()) throw new Error(`${label} is not a directory: ${displayPath}`); - return absolute; -} - -function assertExistingAncestorInsideRepo(repoRoot: string, absolutePath: string, path: string, label: string): void { - const relativeParent = relative(resolve(repoRoot), dirname(absolutePath)); - if (relativeParent === "") return; - if (relativeParent.startsWith("..") || isAbsolute(relativeParent)) { - throw new Error(`${label} parent cannot be resolved inside repository: ${path}`); - } - - let current = resolve(repoRoot); - for (const segment of relativeParent.split(sep)) { - if (!segment) continue; - current = resolve(current, segment); - if (!lstatIfExists(current)) return; - assertExistingAbsolutePath(repoRoot, current, repoRelativePath(repoRoot, current), `${label} parent`, "directory"); - } -} - -function repoPathExists(repoRoot: string, path: string): boolean { - return lstatIfExists(resolveRepoPath(repoRoot, path)) !== undefined; -} - -function isLinkedGitWorktree(repoRoot: string): boolean { - const gitPath = lstatIfExists(resolveRepoPath(repoRoot, ".git")); - return gitPath?.isFile() === true; -} - -function lstatIfExists(path: string): ReturnType | undefined { - try { - return lstatSync(path); - } catch (error) { - if (errorCode(error) === "ENOENT") return undefined; - throw error; - } -} - -function isInsideRepo(repoRoot: string, path: string): boolean { - const normalized = relative(resolve(repoRoot), resolve(path)); - return normalized === "" || (!normalized.startsWith("..") && !isAbsolute(normalized)); -} - -function repoRelativePath(repoRoot: string, absolutePath: string): string { - const normalized = relative(resolve(repoRoot), resolve(absolutePath)); - return normalized === "" ? "." : normalized; -} - -function isPlainObject(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -function trimRightPreserve(text: string): string { - return text.replace(/\s+$/u, ""); -} - -function trimLeftPreserve(text: string): string { - return text.replace(/^\s+/u, ""); -} - -function removeEmptyOpcoreHookDir(repoRoot: string): void { - const hooksDir = resolveRepoPath(repoRoot, ".opcore/hooks"); - if (!lstatIfExists(hooksDir)) return; - assertExistingRepoPath(repoRoot, ".opcore/hooks", "Opcore hooks directory", "directory"); - if (readdirSync(hooksDir).length === 0) { - rmSync(hooksDir, { recursive: true, force: true }); - } -} - -function uniqueStrings(values: readonly string[]): string[] { - return [...new Set(values)]; -} - -function formatSetupPlan(payload: OpcoreInitPlanPayload, applied: boolean, command: OpcoreSetupCommand): string { - if (command === "install") return formatInstallPlan(payload, applied); - if (command === "uninstall") return formatUninstallPlan(payload, applied); - return formatInitPlan(payload, applied); -} - -function formatInstallPlan(payload: OpcoreInitPlanPayload, applied: boolean): string { - const skillEnabled = payload.actions.some((action) => action.path.endsWith("/skills/opcore/SKILL.md")); - const hooksEnabled = payload.actions.some((action) => action.path.endsWith(claudeSettingsPath)); - const activePreCommitEnabled = payload.actions.some((action) => action.path === activePreCommitHookPath); - const scanSummary = `Analyzed ${payload.scan.totalFiles} files; validation=${payload.scan.validationStatus}; diagnostics=${payload.scan.diagnosticCount}.`; - return [ - "Opcore install:", - ` ${scanSummary}`, - "Setup choices:", - `${skillEnabled ? "[x]" : "[ ]"} Install Opcore agent skill`, - `${hooksEnabled ? "[x]" : "[ ]"} Install Claude Code and Codex write-gate hooks`, - `${activePreCommitEnabled ? "[x]" : "[ ]"} Install Git pre-commit hook`, - "", - formatInitPlan(payload, applied, "--yes") - ].join("\n"); -} - -function formatUninstallPlan(payload: OpcoreInitPlanPayload, applied: boolean): string { - return [ - "Opcore uninstall:", - " Restore or remove only files recorded in .opcore/init-undo.json.", - "", - formatInitPlan(payload, applied, "--yes") - ].join("\n"); -} - -function formatInitPlan(payload: OpcoreInitPlanPayload, applied: boolean, approvalFlag?: string): string { - const heading = payload.mode === "undo" ? "Undo:" : "Setup:"; - const requiredApprovalFlag = approvalFlag ?? (payload.mode === "undo" ? "--undo --approve" : "--approve"); - const actionLines = payload.actions.map((action) => `- ${action.kind} ${action.path}: ${action.summary}`); - const approvalLine = payload.interaction.promptState === "requested" - ? "Approval: awaiting TTY response." - : payload.interaction.promptState === "declined" - ? "Approval: declined; no files written." - : applied - ? "Approval: applied." - : payload.mode === "undo" - ? `Approval: required; rerun with ${requiredApprovalFlag} to restore/remove recorded files.` - : `Approval: required; rerun with ${requiredApprovalFlag} to write this setup.`; - const languages = payload.scan.languages.length === 0 - ? "none" - : payload.scan.languages.map((entry) => `${entry.language} ${entry.files}`).join(", "); - const unsupported = payload.scan.unsupportedStacks.length === 0 - ? "none" - : payload.scan.unsupportedStacks.map((stack) => `${stack.language} ${stack.count}`).join(", "); - const degradedValidationTools = payload.scan.degradedRustTools.length === 0 - ? "none" - : payload.scan.degradedRustTools.map((tool) => `${tool.adapter}:${tool.tool}`).join(", "); - const warningLines = payload.warnings.length === 0 - ? [" none"] - : payload.warnings.map((warning) => ` ${warning}`); - const pythonDependencyManagers = payload.settings.python?.dependencyManagers.length - ? payload.settings.python.dependencyManagers.map((manager) => `${manager.kind}:${manager.path}`).join(", ") - : "none"; - const pythonVirtualEnvironments = payload.settings.python?.virtualEnvironments.length - ? payload.settings.python.virtualEnvironments.map((environment) => environment.path).join(", ") - : "none"; - return [ - "Coverage:", - ` files=${payload.scan.totalFiles}`, - ` graph-supported=${payload.scan.graphSupportedFiles}`, - ` validation-supported=${payload.scan.validationSupportedFiles}`, - ` validation-retained=${payload.scan.validationRetainedFiles}`, - ` unsupported=${unsupported}`, - ` languages=${languages}`, - ` degraded-validation-tools=${degradedValidationTools}`, - ` python-dependency-managers=${pythonDependencyManagers}`, - ` python-virtualenvs=${pythonVirtualEnvironments}`, - "Findings:", - ` diagnostics=${payload.scan.diagnosticCount}`, - ` validation=${payload.scan.validationStatus}`, - ` failed-checks=${payload.scan.failedChecks.length === 0 ? "none" : payload.scan.failedChecks.join(", ")}`, - ` graph=${payload.scan.graphState}`, - ` activation=${payload.scan.activationLevel}`, - "Warnings:", - ...warningLines, - heading, - `Repo: ${payload.repo.root}`, - `Scope: ${payload.options.scope}`, - `Mode: ${payload.mode}`, - `Approved: ${payload.approved ? "yes" : "no"}`, - "Actions:", - ...actionLines, - approvalLine, - "Timing:", - ` first-output-ms=${payload.timings.firstOutputMs} scan-ms=${payload.timings.scanMs} total-ms=${payload.timings.totalMs}` - ].join("\n"); -} - -function formatInteractiveOutcome(payload: OpcoreInitPlanPayload, command: OpcoreSetupCommand): string { - return payload.approved - ? `opcore ${command} applied\nApproval: applied.` - : `opcore ${command} declined\nApproval: declined; no files written.`; -} - -function opcoreSetupHelpMessage(command: OpcoreSetupCommand): string { - if (command === "install") return opcoreInstallHelpMessage(); - if (command === "uninstall") return opcoreUninstallHelpMessage(); - return opcoreInitHelpMessage(); -} - -function opcoreInitHelpMessage(): string { - return [ - "Usage:", - " opcore init [--repo ] [--local|--global] [--approve] [--json]", - " opcore init --undo --approve [--repo ] [--local|--global] [--json]", - "Flags:", - " --repo Repository root to set up.", - " --local Force repo-scoped setup.", - " --global Install the write gate in user-level agent settings.", - " --approve Apply the proposed additive setup.", - " --undo Revert files recorded in .opcore/init-undo.json.", - " --fail-closed-hook Add the optional fail-closed pre-commit hook.", - " --json Emit structured JSON.", - "Defaults:", - " Without --approve, init is plan-only outside an interactive approval prompt.", - " Inside a Git repo on a TTY, init asks whether to install for this repo or globally.", - "Examples:", - " opcore init --repo . --json", - " opcore init --repo . --approve", - " opcore init --global --approve", - "Exit codes: 0 planned or applied, 1 setup error, 64 unsupported." - ].join("\n"); -} - -function opcoreInstallHelpMessage(): string { - return [ - "Usage:", - " opcore install [--repo ] [--local|--global] [--yes] [--json]", - "Flags:", - " --repo Repository root to set up.", - " --local Force repo-scoped setup.", - " --global Install user-level agent skills and write-gate hooks.", - " --yes Apply the proposed setup without prompting.", - " --no-skill Do not install the Opcore agent skill.", - " --no-pre-commit Do not install the repo Git pre-commit hook.", - " --json Emit structured JSON.", - "Defaults:", - " install scans first, then applies on --yes or an interactive default-yes approval prompt.", - "Examples:", - " opcore install", - " opcore install --repo . --yes", - "Exit codes: 0 planned or applied, 1 setup error, 64 unsupported." - ].join("\n"); -} - -function opcoreUninstallHelpMessage(): string { - return [ - "Usage:", - " opcore uninstall [--repo ] [--local|--global] [--yes] [--json]", - "Flags:", - " --repo Repository root to restore/remove recorded setup from.", - " --local Force repo-scoped uninstall.", - " --global Restore/remove user-level recorded setup.", - " --yes Apply the uninstall without prompting.", - " --json Emit structured JSON.", - "Defaults:", - " uninstall restores or removes only files recorded in .opcore/init-undo.json.", - "Examples:", - " opcore uninstall --repo . --yes", - " opcore uninstall --global --yes", - "Exit codes: 0 planned or applied, 1 setup error, 64 unsupported." - ].join("\n"); -} - -function errorMessage(error: unknown): string { - return error instanceof Error ? error.message : String(error); -} - -function errorCode(error: unknown): string | undefined { - return typeof error === "object" && error !== null && "code" in error ? String((error as { code?: unknown }).code) : undefined; -} +export { AGENT_FILE_CANDIDATES } from "./init-constants.js"; +export { + routeOpcoreInit, + routeOpcoreInstall, + routeOpcoreUninstall +} from "./init-router.js"; +export type { OpcoreInitRuntime } from "./init-types.js"; diff --git a/packages/opcore/src/repo-paths.ts b/packages/opcore/src/repo-paths.ts new file mode 100644 index 0000000..f7ce66b --- /dev/null +++ b/packages/opcore/src/repo-paths.ts @@ -0,0 +1,26 @@ +import { isAbsolute, relative, resolve, sep } from "node:path"; + +export function resolveRepoPath(root: string, path: string): string { + if (path.length === 0 || path.includes("\0")) { + throw new Error(`Invalid repo path: ${path}`); + } + const absolute = resolve(root, path); + const normalized = relative(root, absolute); + if ( + normalized === "" || + normalized.startsWith("..") || + normalized.split(sep).includes("..") + ) { + throw new Error(`Repo-relative path escapes repository: ${path}`); + } + return absolute; +} + +export function isMissingPathError(error: unknown): boolean { + return ( + typeof error === "object" && + error !== null && + "code" in error && + (error.code === "ENOENT" || error.code === "ENOTDIR") + ); +} diff --git a/packages/opcore/src/scan.ts b/packages/opcore/src/scan.ts index 0d42333..6238d1e 100644 --- a/packages/opcore/src/scan.ts +++ b/packages/opcore/src/scan.ts @@ -31,6 +31,7 @@ import { opcorePublicValidationRuntimePolicy } from "./validation-composition.js"; import { validationChecksForRepoPolicyAndCoverage } from "./repo-validation-policy.js"; +import { isMissingPathError, resolveRepoPath } from "./repo-paths.js"; const skippedPathSegments = new Set(commonSkippedPathSegments); const cloneDiagnosticsCommand = "opcore check --all --checks clone.duplication --json"; @@ -184,7 +185,7 @@ function createReadOnlyWorkspace(repoRoot: string): ValidationWorkspace { content: await readFile(resolveRepoPath(root, path), "utf8") }; } catch (error) { - if (isMissingFileError(error)) return { status: "missing" }; + if (isMissingPathError(error)) return { status: "missing" }; throw error; } }, @@ -252,24 +253,6 @@ function formatScanMessage(repoState: OpcoreRepoStatePayload, validationResult: ].join("\n"); } -function resolveRepoPath(root: string, path: string): string { - const absolute = resolve(root, path); - const normalized = relative(root, absolute); - if (normalized === "" || normalized.startsWith("..") || normalized.split(sep).includes("..")) { - throw new Error(`Repo-relative path escapes repository: ${path}`); - } - return absolute; -} - function hasSkippedSegment(path: string): boolean { return path.split(/[\\/]+/).some((segment) => skippedPathSegments.has(segment)); } - -function isMissingFileError(error: unknown): boolean { - return ( - typeof error === "object" && - error !== null && - "code" in error && - ((error as { code?: unknown }).code === "ENOENT" || (error as { code?: unknown }).code === "ENOTDIR") - ); -} diff --git a/packages/opcore/src/source-policy.ts b/packages/opcore/src/source-policy.ts index 0ad4da5..7290a72 100644 --- a/packages/opcore/src/source-policy.ts +++ b/packages/opcore/src/source-policy.ts @@ -5,7 +5,6 @@ export const commonSkippedPathSegments = [ "vendor", "dist", "target", - ".ace", ".agents", ".asp", ".claude", @@ -13,7 +12,5 @@ export const commonSkippedPathSegments = [ ".gemini", ".lattice", ".opencode", - ".opcore", - ".rox-cache", - ".robustness-engine-cache" + ".opcore" ] as const; diff --git a/packages/validation-docs/src/check-definition.ts b/packages/validation-docs/src/check-definition.ts new file mode 100644 index 0000000..d96b4f1 --- /dev/null +++ b/packages/validation-docs/src/check-definition.ts @@ -0,0 +1,33 @@ +import type { ValidationDiagnostic } from "@the-open-engine/opcore-contracts"; +import type { + ValidationCheckContext, + ValidationCheckDefinition, + ValidationCheckResult +} from "@the-open-engine/opcore-validation"; +import { + docsCheckAdapter, + docsCheckOwner, + optInDocsDefaultScopes +} from "./check-constants.js"; + +type DocsCheckRunner = ( + context: ValidationCheckContext +) => Promise | ValidationCheckResult; + +export function docsCheck( + id: string, + defaultSeverity: ValidationDiagnostic["severity"], + supportedScopes: readonly ValidationCheckDefinition["supportedScopes"][number][], + run: DocsCheckRunner +): ValidationCheckDefinition { + return { + id, + owner: docsCheckOwner, + adapter: docsCheckAdapter, + defaultSeverity, + supportedScopes, + defaultScopes: optInDocsDefaultScopes, + requiresGraph: false, + run + }; +} diff --git a/packages/validation-docs/src/check-results.ts b/packages/validation-docs/src/check-results.ts new file mode 100644 index 0000000..c027d1d --- /dev/null +++ b/packages/validation-docs/src/check-results.ts @@ -0,0 +1,17 @@ +import type { ValidationCheckResult } from "@the-open-engine/opcore-validation"; + +export function skippedDocsResult(message: string): ValidationCheckResult { + return { + status: "skipped", + diagnostics: [], + failureMessage: message + }; +} + +export function skippedHistoryUnavailableResult(message: string): ValidationCheckResult { + return skippedDocsResult( + message.startsWith("Git history is unavailable") + ? message + : `Git history is unavailable: ${message}` + ); +} diff --git a/packages/validation-docs/src/checks.ts b/packages/validation-docs/src/checks.ts index ae1382c..763f5d2 100644 --- a/packages/validation-docs/src/checks.ts +++ b/packages/validation-docs/src/checks.ts @@ -1,497 +1,20 @@ -import type { GraphFactEdge, GraphFactNode, RequiredContextDocPolicy, ValidationDiagnostic } from "@the-open-engine/opcore-contracts"; -import type { ValidationCheckContext, ValidationCheckDefinition, ValidationCheckResult } from "@the-open-engine/opcore-validation"; -import { countPhysicalLines, graphFactNodePath, graphFactPathFromEndpoint, splitPhysicalLines } from "@the-open-engine/opcore-validation"; -import { - defaultDocsHistoryThresholds, - defaultDocsHubCoverageThresholds, - docsCheckAdapter, - docsCheckOwner, - optInDocsDefaultScopes, - repoWideDocsValidationScopes, - supportedDocsValidationScopes -} from "./check-constants.js"; -import { - DOCS_CODE_BLOCKS_CHECK_ID, - DOCS_CONTENT_QUALITY_CHECK_ID, - DOCS_DRY_CHECK_ID, - DOCS_EXISTENCE_CHECK_ID, - DOCS_FRESHNESS_CHECK_ID, - DOCS_HUB_COVERAGE_CHECK_ID, - DOCS_LENGTH_CHECK_ID, - DOCS_RULES_WHY_CHECK_ID, - DOCS_SUBTREE_COVERAGE_CHECK_ID, - DOCS_STALENESS_CHECK_ID -} from "./check-ids.js"; -import { diagnostic, sortDiagnostics } from "./diagnostics.js"; -import { assertGitHistoryAvailable, latestCommitIso } from "./history.js"; -import { runDocsFreshnessCheck } from "./freshness.js"; -import { - isDocsPath, - materializeDocsSnapshot, - pathBasename, - type DocsDocument, - type DocsPolicyOptions -} from "./snapshot.js"; - -export interface DocsHistoryOptions { - now?: string | Date; - maxStaleDays?: number; -} - -export interface DocsHubCoverageOptions { - minFanIn?: number; - minFanOut?: number; - requireExplicitMention?: boolean; -} - -export interface DocsSubtreeCoverageOptions { - minLoc?: number; -} - -export interface CreateDocsValidationChecksOptions extends DocsPolicyOptions { - history?: DocsHistoryOptions; - hubCoverage?: DocsHubCoverageOptions; - subtreeCoverage?: DocsSubtreeCoverageOptions; -} - -type DocsCheckRunner = (context: ValidationCheckContext) => Promise | ValidationCheckResult; - -export function createDocsExistenceCheck(options: DocsPolicyOptions = {}): ValidationCheckDefinition { - return docsCheck(DOCS_EXISTENCE_CHECK_ID, "error", repoWideDocsValidationScopes, async (context) => { - const snapshot = await materializeDocsSnapshot(context, options); - const diagnostics = snapshot.requiredLocations - .filter((location) => location.found.length === 0) - .map((location) => - diagnostic({ - path: location.root === "." ? undefined : location.root, - code: "DOCS_REQUIRED_CONTEXT_DOC_MISSING", - message: `Required context doc is missing at ${location.root}: expected ${snapshot.policy.filenames.join(" or ")}.` - }) - ); - return { diagnostics }; - }); -} - -export function createDocsStalenessCheck(options: CreateDocsValidationChecksOptions = {}): ValidationCheckDefinition { - return docsCheck(DOCS_STALENESS_CHECK_ID, "warning", supportedDocsValidationScopes, async (context) => { - const skipped = await skippedHistoryResult(context, options); - if (skipped !== undefined) return skipped; - const snapshot = await materializeDocsSnapshot(context, options); - if (snapshot.docs.length === 0) return skippedDocsResult("No documentation files were selected."); - const repoRoot = context.request.repo.repoRoot; - if (repoRoot === undefined) return skippedHistoryUnavailableResult("Git history is unavailable: request.repo.repoRoot is missing."); - const now = historyNow(options.history); - const maxStaleDays = options.history?.maxStaleDays ?? defaultDocsHistoryThresholds.maxStaleDays; - const diagnostics: ValidationDiagnostic[] = []; - - for (const doc of snapshot.docs) { - const committed = latestCommitIso(repoRoot, doc.path); - if (!committed.ok) return skippedHistoryUnavailableResult(committed.message); - if (committed.value === undefined) continue; - const ageDays = elapsedDays(committed.value, now); - if (ageDays > maxStaleDays) { - diagnostics.push( - diagnostic({ - severity: "warning", - path: doc.path, - code: "DOCS_STALE", - message: `Documentation was last committed ${Math.floor(ageDays)} days ago, over the ${maxStaleDays}-day staleness threshold.` - }) - ); - } - } - return { diagnostics: sortDiagnostics(diagnostics) }; - }); -} - -export function createDocsFreshnessCheck(options: CreateDocsValidationChecksOptions = {}): ValidationCheckDefinition { - return docsCheck(DOCS_FRESHNESS_CHECK_ID, "warning", supportedDocsValidationScopes, (context) => - runDocsFreshnessCheck(context, options) - ); -} - -export function createDocsLengthCheck(options: DocsPolicyOptions = {}): ValidationCheckDefinition { - return docsCheck(DOCS_LENGTH_CHECK_ID, "error", supportedDocsValidationScopes, async (context) => { - const snapshot = await materializeDocsSnapshot(context, options); - if (snapshot.docs.length === 0) return skippedDocsResult("No documentation files were selected."); - return { - diagnostics: sortDiagnostics( - snapshot.docs.flatMap((doc) => lengthDiagnostics(doc, snapshot.policy)) - ) - }; - }); -} - -export function createDocsDryCheck(options: DocsPolicyOptions = {}): ValidationCheckDefinition { - return docsCheck(DOCS_DRY_CHECK_ID, "warning", supportedDocsValidationScopes, async (context) => { - const snapshot = await materializeDocsSnapshot(context, options); - if (snapshot.docs.length === 0) return skippedDocsResult("No documentation files were selected."); - const seen = new Map(); - const diagnostics: ValidationDiagnostic[] = []; - for (const doc of snapshot.docs) { - for (const paragraph of normalizedParagraphs(doc.content)) { - const existing = seen.get(paragraph); - if (existing === undefined) { - seen.set(paragraph, doc.path); - continue; - } - if (existing === doc.path) continue; - diagnostics.push( - diagnostic({ - severity: "warning", - path: doc.path, - code: "DOCS_DRY_DUPLICATE_PARAGRAPH", - message: `Documentation repeats a long paragraph already present in ${existing}.` - }) - ); - break; - } - } - return { diagnostics: sortDiagnostics(diagnostics) }; - }); -} - -export function createDocsContentQualityCheck(options: DocsPolicyOptions = {}): ValidationCheckDefinition { - return docsCheck(DOCS_CONTENT_QUALITY_CHECK_ID, "error", supportedDocsValidationScopes, async (context) => { - const snapshot = await materializeDocsSnapshot(context, options); - if (snapshot.docs.length === 0) return skippedDocsResult("No documentation files were selected."); - return { diagnostics: sortDiagnostics(snapshot.docs.flatMap(contentQualityDiagnostics)) }; - }); -} - -export function createDocsCodeBlocksCheck(options: DocsPolicyOptions = {}): ValidationCheckDefinition { - return docsCheck(DOCS_CODE_BLOCKS_CHECK_ID, "error", supportedDocsValidationScopes, async (context) => { - const snapshot = await materializeDocsSnapshot(context, options); - if (snapshot.docs.length === 0) return skippedDocsResult("No documentation files were selected."); - return { diagnostics: sortDiagnostics(snapshot.docs.flatMap(codeBlockDiagnostics)) }; - }); -} - -export function createDocsRulesWhyCheck(options: DocsPolicyOptions = {}): ValidationCheckDefinition { - return docsCheck(DOCS_RULES_WHY_CHECK_ID, "error", supportedDocsValidationScopes, async (context) => { - const snapshot = await materializeDocsSnapshot(context, options); - if (snapshot.docs.length === 0) return skippedDocsResult("No documentation files were selected."); - return { diagnostics: sortDiagnostics(snapshot.docs.flatMap(ruleWhyDiagnostics)) }; - }); -} - -export function createDocsHubCoverageCheck(options: CreateDocsValidationChecksOptions = {}): ValidationCheckDefinition { - return { - ...docsCheck(DOCS_HUB_COVERAGE_CHECK_ID, "warning", repoWideDocsValidationScopes, async (context) => { - const snapshot = await materializeDocsSnapshot(context, options); - if (snapshot.docs.length === 0) return skippedDocsResult("No documentation files were selected."); - const [nodeResult, importsFrom] = await Promise.all([ - context.graph.facts({ kind: "nodes", nodeKinds: ["File", "file", "Module"] }), - context.graph.importsFrom() - ]); - const hubs = hubPaths(nodeResult.nodes, importsFrom, snapshot.policy, options.hubCoverage); - const documentedHubs = hubs.filter((hub) => - !docsMentionPath(snapshot.docs, hub.path, options.hubCoverage?.requireExplicitMention === true) - ); - return { - diagnostics: documentedHubs.map((hub) => - diagnostic({ - severity: "warning", - category: "graph", - code: "DOCS_HUB_UNDOCUMENTED", - message: `Graph hub ${hub.path} has ${hub.fanIn} incoming and ${hub.fanOut} outgoing IMPORTS_FROM edges but is not mentioned in discovered context docs.` - }) - ) - }; - }), - requiresGraph: true, - graphRequirements: () => [ - { - operation: "factQuery", - selector: { - kind: "nodes", - nodeKinds: ["File", "file", "Module"] - } - }, - { - operation: "factQuery", - selector: { - kind: "edges", - edgeKinds: ["IMPORTS_FROM"] - } - } - ] - }; -} - -export function createDocsSubtreeCoverageCheck(options: CreateDocsValidationChecksOptions = {}): ValidationCheckDefinition { - return docsCheck(DOCS_SUBTREE_COVERAGE_CHECK_ID, "warning", repoWideDocsValidationScopes, async (context) => { - const snapshot = await materializeDocsSnapshot(context, options); - if (snapshot.docs.length === 0) return skippedDocsResult("No documentation files were selected."); - const minLoc = options.subtreeCoverage?.minLoc ?? 10_000; - const subtrees = await sourceSubtreeLoc(context); - return { - diagnostics: sortDiagnostics( - [...subtrees] - .filter(([, loc]) => loc >= minLoc) - .filter(([subtree]) => !docsMentionPath(snapshot.docs, subtree, true)) - .map(([subtree, loc]) => - diagnostic({ - severity: "warning", - path: subtree, - code: "DOCS_SUBTREE_UNDOCUMENTED", - message: `Source subtree ${subtree} has ${loc} lines but is not explicitly mentioned in discovered context docs.` - }) - ) - ) - }; - }); -} - -function docsCheck( - id: string, - defaultSeverity: ValidationDiagnostic["severity"], - supportedScopes: readonly ValidationCheckDefinition["supportedScopes"][number][], - run: DocsCheckRunner -): ValidationCheckDefinition { - return { - id, - owner: docsCheckOwner, - adapter: docsCheckAdapter, - defaultSeverity, - supportedScopes, - defaultScopes: optInDocsDefaultScopes, - requiresGraph: false, - run - }; -} - -async function skippedHistoryResult( - context: ValidationCheckContext, - options: DocsPolicyOptions -): Promise { - const snapshot = await materializeDocsSnapshot(context, options); - if (snapshot.hasOverlays) { - return skippedDocsResult("Documentation Git-history checks require committed state and skip when overlays are present."); - } - const repoRoot = context.request.repo.repoRoot; - if (repoRoot === undefined) { - return skippedHistoryUnavailableResult("Git history is unavailable: request.repo.repoRoot is missing."); - } - const available = assertGitHistoryAvailable(repoRoot); - return available.ok ? undefined : skippedHistoryUnavailableResult(available.message); -} - -function skippedDocsResult(message: string): ValidationCheckResult { - return { - status: "skipped", - diagnostics: [], - failureMessage: message - }; -} - -function skippedHistoryUnavailableResult(message: string): ValidationCheckResult { - return skippedDocsResult(message.startsWith("Git history is unavailable") ? message : `Git history is unavailable: ${message}`); -} - -function lengthDiagnostics(doc: DocsDocument, policy: RequiredContextDocPolicy): readonly ValidationDiagnostic[] { - const diagnostics: ValidationDiagnostic[] = []; - if (doc.content.trim().length < policy.minimumContentLength) { - diagnostics.push( - diagnostic({ - path: doc.path, - code: "DOCS_TOO_SHORT", - message: `Documentation content is shorter than the ${policy.minimumContentLength}-character require-context-doc policy minimum.` - }) - ); - } - const lines = splitPhysicalLines(doc.content); - const totalLines = countPhysicalLines(doc.content); - if (policy.maxLines !== undefined && totalLines > policy.maxLines) { - diagnostics.push( - diagnostic({ - path: doc.path, - code: "DOCS_TOO_LONG", - message: `Documentation has ${totalLines} lines; max is ${policy.maxLines}.` - }) - ); - } - if (policy.maxSectionLines !== undefined) { - const section = longestSectionLineCount(lines); - if (section > policy.maxSectionLines) { - diagnostics.push( - diagnostic({ - path: doc.path, - code: "DOCS_SECTION_TOO_LONG", - message: `Documentation section has ${section} lines; max is ${policy.maxSectionLines}.` - }) - ); - } - } - return diagnostics; -} - -function historyNow(options: DocsHistoryOptions | undefined): Date { - if (options?.now instanceof Date) return options.now; - if (typeof options?.now === "string") return new Date(options.now); - return new Date(); -} - -function elapsedDays(iso: string, now: Date): number { - return (now.getTime() - Date.parse(iso)) / (24 * 60 * 60 * 1000); -} - -function normalizedParagraphs(content: string): readonly string[] { - return content - .split(/\n\s*\n/u) - .map((paragraph) => paragraph.replace(/\s+/gu, " ").trim()) - .filter((paragraph) => paragraph.length >= 80 && !paragraph.startsWith("```")); -} - -function longestSectionLineCount(lines: readonly string[]): number { - let current = 0; - let longest = 0; - for (const line of lines) { - if (/^\s*#{1,6}\s+/u.test(line)) { - longest = Math.max(longest, current); - current = 1; - continue; - } - current += 1; - } - return Math.max(longest, current); -} - -function contentQualityDiagnostics(doc: DocsDocument): readonly ValidationDiagnostic[] { - const diagnostics: ValidationDiagnostic[] = []; - const lines = doc.content.split(/\r?\n/u); - lines.forEach((line, index) => { - if (/\b(TODO|TBD|FIXME|placeholder|lorem ipsum)\b/iu.test(line)) { - diagnostics.push( - diagnostic({ - path: doc.path, - code: "DOCS_CONTENT_PLACEHOLDER", - message: `Documentation contains placeholder text on line ${index + 1}.` - }) - ); - } - if (/^(<{7}|={7}|>{7})/u.test(line)) { - diagnostics.push( - diagnostic({ - path: doc.path, - code: "DOCS_CONTENT_CONFLICT_MARKER", - message: `Documentation contains an unresolved conflict marker on line ${index + 1}.` - }) - ); - } - }); - return diagnostics; -} - -function codeBlockDiagnostics(doc: DocsDocument): readonly ValidationDiagnostic[] { - if (!isMarkdownLike(doc.path)) return []; - let openFenceLine: number | undefined; - const diagnostics: ValidationDiagnostic[] = []; - doc.content.split(/\r?\n/u).forEach((line, index) => { - if (!line.trimStart().startsWith("```")) return; - if (openFenceLine === undefined) { - openFenceLine = index + 1; - } else { - openFenceLine = undefined; - } - }); - if (openFenceLine !== undefined) { - diagnostics.push( - diagnostic({ - path: doc.path, - code: "DOCS_CODE_BLOCK_UNCLOSED", - message: `Markdown code block opened on line ${openFenceLine} is not closed.` - }) - ); - } - return diagnostics; -} - -function ruleWhyDiagnostics(doc: DocsDocument): readonly ValidationDiagnostic[] { - if (!doc.requiredContext) return []; - const diagnostics: ValidationDiagnostic[] = []; - doc.content.split(/\r?\n/u).forEach((line, index) => { - const trimmed = line.trim(); - if (!/^(?:[-*]\s*)?(ALWAYS|NEVER|MUST|SHOULD)\b/u.test(trimmed)) return; - if (/\bWHY:\b/u.test(trimmed) || /\bbecause\b/iu.test(trimmed)) return; - diagnostics.push( - diagnostic({ - path: doc.path, - code: "DOCS_RULE_WITHOUT_WHY", - message: `Context-doc rule on line ${index + 1} is missing a WHY rationale.` - }) - ); - }); - return diagnostics; -} - -function hubPaths( - nodes: readonly GraphFactNode[], - edges: readonly GraphFactEdge[], - policy: RequiredContextDocPolicy, - options: DocsHubCoverageOptions | undefined -): readonly { path: string; fanIn: number; fanOut: number }[] { - const nodePaths = new Set(nodes.map(nodePath).filter((path): path is string => path !== undefined && !isDocsPath(path, policy))); - const incoming = new Map(); - const outgoing = new Map(); - for (const edge of edges) { - if (edge.kind !== "IMPORTS_FROM") continue; - const sourcePath = endpointPath(edge.from); - const targetPath = endpointPath(edge.to); - if (sourcePath !== undefined && nodePaths.has(sourcePath)) { - outgoing.set(sourcePath, (outgoing.get(sourcePath) ?? 0) + 1); - } - if (targetPath !== undefined && nodePaths.has(targetPath)) { - incoming.set(targetPath, (incoming.get(targetPath) ?? 0) + 1); - } - } - const minFanIn = options?.minFanIn ?? defaultDocsHubCoverageThresholds.minFanIn; - const minFanOut = options?.minFanOut; - return [...nodePaths] - .map((path) => ({ path, fanIn: incoming.get(path) ?? 0, fanOut: outgoing.get(path) ?? 0 })) - .filter((hub) => hub.fanIn >= minFanIn || (minFanOut !== undefined && hub.fanOut >= minFanOut)) - .sort((left, right) => right.fanIn - left.fanIn || right.fanOut - left.fanOut || left.path.localeCompare(right.path)); -} - -function docsMentionPath(docs: readonly DocsDocument[], path: string, requireExplicit = false): boolean { - const basename = pathBasename(path).toLowerCase(); - const normalizedPath = path.toLowerCase(); - return docs.some((doc) => { - const content = doc.content.toLowerCase(); - return content.includes(normalizedPath) || (!requireExplicit && content.includes(basename)); - }); -} - -async function sourceSubtreeLoc(context: ValidationCheckContext): Promise> { - const totals = new Map(); - const visibleFiles = await context.fileView.listVisibleFiles(); - for (const path of visibleFiles) { - if (isDocsPath(path) || !isSourceLikePath(path)) continue; - const result = await context.fileView.readAfter(path); - if (result.status !== "found") continue; - const subtree = firstPathSegment(path); - totals.set(subtree, (totals.get(subtree) ?? 0) + countPhysicalLines(result.content)); - } - return totals; -} - -function isSourceLikePath(path: string): boolean { - return /\.(?:[cm]?[jt]sx?|pyi?|rs|toml)$/iu.test(path); -} - -function firstPathSegment(path: string): string { - return path.split("/")[0] ?? path; -} - -function nodePath(node: GraphFactNode): string | undefined { - return graphFactNodePath(node); -} - -function endpointPath(endpoint: string): string | undefined { - return graphFactPathFromEndpoint(endpoint); -} - -function isMarkdownLike(path: string): boolean { - const lower = path.toLowerCase(); - return lower.endsWith(".md") || lower.endsWith(".mdx") || lower.endsWith(".rst") || lower.endsWith(".adoc"); -} +export { createDocsExistenceCheck } from "./existence-check.js"; +export { createDocsStalenessCheck } from "./staleness-check.js"; +export { createDocsFreshnessCheck } from "./freshness.js"; +export { + createDocsCodeBlocksCheck, + createDocsContentQualityCheck, + createDocsDryCheck, + createDocsLengthCheck, + createDocsRulesWhyCheck +} from "./content-checks.js"; +export { + createDocsHubCoverageCheck, + createDocsSubtreeCoverageCheck +} from "./coverage-checks.js"; +export type { + CreateDocsValidationChecksOptions, + DocsHistoryOptions, + DocsHubCoverageOptions, + DocsSubtreeCoverageOptions +} from "./options.js"; diff --git a/packages/validation-docs/src/code-blocks-check.ts b/packages/validation-docs/src/code-blocks-check.ts new file mode 100644 index 0000000..b0ac2c0 --- /dev/null +++ b/packages/validation-docs/src/code-blocks-check.ts @@ -0,0 +1,49 @@ +import type { ValidationDiagnostic } from "@the-open-engine/opcore-contracts"; +import type { ValidationCheckDefinition } from "@the-open-engine/opcore-validation"; +import { DOCS_CODE_BLOCKS_CHECK_ID } from "./check-ids.js"; +import { diagnostic } from "./diagnostics.js"; +import { createDocsDocumentCheck } from "./document-check.js"; +import { + type DocsDocument, + type DocsPolicyOptions +} from "./snapshot.js"; + +export function createDocsCodeBlocksCheck( + options: DocsPolicyOptions = {} +): ValidationCheckDefinition { + return createDocsDocumentCheck( + DOCS_CODE_BLOCKS_CHECK_ID, + "error", + options, + codeBlockDiagnostics + ); +} + +function codeBlockDiagnostics( + doc: DocsDocument +): readonly ValidationDiagnostic[] { + if (!isMarkdownLike(doc.path)) return []; + let openFenceLine: number | undefined; + doc.content.split(/\r?\n/u).forEach((line, index) => { + if (!line.trimStart().startsWith("```")) return; + openFenceLine = openFenceLine === undefined ? index + 1 : undefined; + }); + return openFenceLine === undefined + ? [] + : [ + diagnostic({ + path: doc.path, + code: "DOCS_CODE_BLOCK_UNCLOSED", + message: + `Markdown code block opened on line ${openFenceLine} ` + + "is not closed." + }) + ]; +} + +function isMarkdownLike(path: string): boolean { + const lower = path.toLowerCase(); + return [".md", ".mdx", ".rst", ".adoc"].some((extension) => + lower.endsWith(extension) + ); +} diff --git a/packages/validation-docs/src/content-checks.ts b/packages/validation-docs/src/content-checks.ts new file mode 100644 index 0000000..cfb5e8c --- /dev/null +++ b/packages/validation-docs/src/content-checks.ts @@ -0,0 +1,5 @@ +export { createDocsCodeBlocksCheck } from "./code-blocks-check.js"; +export { createDocsContentQualityCheck } from "./content-quality-check.js"; +export { createDocsDryCheck } from "./dry-check.js"; +export { createDocsLengthCheck } from "./length-check.js"; +export { createDocsRulesWhyCheck } from "./rules-why-check.js"; diff --git a/packages/validation-docs/src/content-quality-check.ts b/packages/validation-docs/src/content-quality-check.ts new file mode 100644 index 0000000..972fb28 --- /dev/null +++ b/packages/validation-docs/src/content-quality-check.ts @@ -0,0 +1,49 @@ +import type { ValidationDiagnostic } from "@the-open-engine/opcore-contracts"; +import type { ValidationCheckDefinition } from "@the-open-engine/opcore-validation"; +import { DOCS_CONTENT_QUALITY_CHECK_ID } from "./check-ids.js"; +import { diagnostic } from "./diagnostics.js"; +import { createDocsDocumentCheck } from "./document-check.js"; +import { + type DocsDocument, + type DocsPolicyOptions +} from "./snapshot.js"; + +export function createDocsContentQualityCheck( + options: DocsPolicyOptions = {} +): ValidationCheckDefinition { + return createDocsDocumentCheck( + DOCS_CONTENT_QUALITY_CHECK_ID, + "error", + options, + contentQualityDiagnostics + ); +} + +function contentQualityDiagnostics( + doc: DocsDocument +): readonly ValidationDiagnostic[] { + const diagnostics: ValidationDiagnostic[] = []; + doc.content.split(/\r?\n/u).forEach((line, index) => { + if (/\b(TODO|TBD|FIXME|placeholder|lorem ipsum)\b/iu.test(line)) { + diagnostics.push( + diagnostic({ + path: doc.path, + code: "DOCS_CONTENT_PLACEHOLDER", + message: `Documentation contains placeholder text on line ${index + 1}.` + }) + ); + } + if (/^(<{7}|={7}|>{7})/u.test(line)) { + diagnostics.push( + diagnostic({ + path: doc.path, + code: "DOCS_CONTENT_CONFLICT_MARKER", + message: + "Documentation contains an unresolved conflict marker on line " + + `${index + 1}.` + }) + ); + } + }); + return diagnostics; +} diff --git a/packages/validation-docs/src/coverage-checks.ts b/packages/validation-docs/src/coverage-checks.ts new file mode 100644 index 0000000..6f8716a --- /dev/null +++ b/packages/validation-docs/src/coverage-checks.ts @@ -0,0 +1,220 @@ +import type { + GraphFactEdge, + GraphFactNode, + RequiredContextDocPolicy +} from "@the-open-engine/opcore-contracts"; +import type { + ValidationCheckContext, + ValidationCheckDefinition +} from "@the-open-engine/opcore-validation"; +import { + countPhysicalLines, + graphFactNodePath, + graphFactPathFromEndpoint +} from "@the-open-engine/opcore-validation"; +import { + defaultDocsHubCoverageThresholds, + repoWideDocsValidationScopes +} from "./check-constants.js"; +import { + DOCS_HUB_COVERAGE_CHECK_ID, + DOCS_SUBTREE_COVERAGE_CHECK_ID +} from "./check-ids.js"; +import { docsCheck } from "./check-definition.js"; +import { withSelectedDocs } from "./document-check.js"; +import { diagnostic } from "./diagnostics.js"; +import type { + CreateDocsValidationChecksOptions, + DocsHubCoverageOptions +} from "./options.js"; +import { + isDocsPath, + pathBasename, + type DocsDocument +} from "./snapshot.js"; + +export function createDocsHubCoverageCheck( + options: CreateDocsValidationChecksOptions = {} +): ValidationCheckDefinition { + return { + ...docsCheck( + DOCS_HUB_COVERAGE_CHECK_ID, + "warning", + repoWideDocsValidationScopes, + async (context) => { + return withSelectedDocs(context, options, async (snapshot) => { + const [nodeResult, importsFrom] = await Promise.all([ + context.graph.facts({ + kind: "nodes", + nodeKinds: ["File", "file", "Module"] + }), + context.graph.importsFrom() + ]); + const hubs = hubPaths( + nodeResult.nodes, + importsFrom, + snapshot.policy, + options.hubCoverage + ); + return hubs + .filter( + (hub) => + !docsMentionPath( + snapshot.docs, + hub.path, + options.hubCoverage?.requireExplicitMention === true + ) + ) + .map((hub) => + diagnostic({ + severity: "warning", + category: "graph", + code: "DOCS_HUB_UNDOCUMENTED", + message: + `Graph hub ${hub.path} has ${hub.fanIn} incoming and ` + + `${hub.fanOut} outgoing IMPORTS_FROM edges but is not ` + + "mentioned in discovered context docs." + }) + ); + }); + } + ), + requiresGraph: true, + graphRequirements: () => [ + { + operation: "factQuery", + selector: { + kind: "nodes", + nodeKinds: ["File", "file", "Module"] + } + }, + { + operation: "factQuery", + selector: { + kind: "edges", + edgeKinds: ["IMPORTS_FROM"] + } + } + ] + }; +} + +export function createDocsSubtreeCoverageCheck( + options: CreateDocsValidationChecksOptions = {} +): ValidationCheckDefinition { + return docsCheck( + DOCS_SUBTREE_COVERAGE_CHECK_ID, + "warning", + repoWideDocsValidationScopes, + async (context) => { + return withSelectedDocs(context, options, async (snapshot) => { + const configuredMinLoc = options.subtreeCoverage?.minLoc; + const minLoc = configuredMinLoc === undefined ? 10_000 : configuredMinLoc; + const subtrees = await sourceSubtreeLoc(context); + return [...subtrees] + .filter(([, loc]) => loc >= minLoc) + .filter(([subtree]) => !docsMentionPath(snapshot.docs, subtree, true)) + .map(([subtree, loc]) => + diagnostic({ + severity: "warning", + path: subtree, + code: "DOCS_SUBTREE_UNDOCUMENTED", + message: + `Source subtree ${subtree} has ${loc} lines but is not ` + + "explicitly mentioned in discovered context docs." + }) + ); + }); + } + ); +} + +function hubPaths( + nodes: readonly GraphFactNode[], + edges: readonly GraphFactEdge[], + policy: RequiredContextDocPolicy, + options: DocsHubCoverageOptions | undefined +): readonly { path: string; fanIn: number; fanOut: number }[] { + const nodePaths = new Set( + nodes + .map(graphFactNodePath) + .filter( + (path): path is string => + path !== undefined && !isDocsPath(path, policy) + ) + ); + const incoming = new Map(); + const outgoing = new Map(); + for (const edge of edges) { + if (edge.kind !== "IMPORTS_FROM") continue; + incrementPathCount(outgoing, graphFactPathFromEndpoint(edge.from), nodePaths); + incrementPathCount(incoming, graphFactPathFromEndpoint(edge.to), nodePaths); + } + const minFanIn = + options?.minFanIn ?? defaultDocsHubCoverageThresholds.minFanIn; + const minFanOut = options?.minFanOut; + return [...nodePaths] + .map((path) => ({ + path, + fanIn: incoming.get(path) ?? 0, + fanOut: outgoing.get(path) ?? 0 + })) + .filter( + (hub) => + hub.fanIn >= minFanIn || + (minFanOut !== undefined && hub.fanOut >= minFanOut) + ) + .sort( + (left, right) => + right.fanIn - left.fanIn || + right.fanOut - left.fanOut || + left.path.localeCompare(right.path) + ); +} + +function incrementPathCount( + counts: Map, + path: string | undefined, + allowed: ReadonlySet +): void { + if (path === undefined || !allowed.has(path)) return; + counts.set(path, (counts.get(path) ?? 0) + 1); +} + +function docsMentionPath( + docs: readonly DocsDocument[], + path: string, + requireExplicit = false +): boolean { + const basename = pathBasename(path).toLowerCase(); + const normalizedPath = path.toLowerCase(); + return docs.some((doc) => { + const content = doc.content.toLowerCase(); + return ( + content.includes(normalizedPath) || + (!requireExplicit && content.includes(basename)) + ); + }); +} + +async function sourceSubtreeLoc( + context: ValidationCheckContext +): Promise> { + const totals = new Map(); + const visibleFiles = await context.fileView.listVisibleFiles(); + for (const path of visibleFiles) { + if (isDocsPath(path) || !isSourceLikePath(path)) continue; + const result = await context.fileView.readAfter(path); + if (result.status !== "found") continue; + const subtree = path.split("/")[0] ?? path; + totals.set( + subtree, + (totals.get(subtree) ?? 0) + countPhysicalLines(result.content) + ); + } + return totals; +} + +function isSourceLikePath(path: string): boolean { + return /\.(?:[cm]?[jt]sx?|pyi?|rs|toml)$/iu.test(path); +} diff --git a/packages/validation-docs/src/document-check.ts b/packages/validation-docs/src/document-check.ts new file mode 100644 index 0000000..8e40d0c --- /dev/null +++ b/packages/validation-docs/src/document-check.ts @@ -0,0 +1,57 @@ +import type { ValidationDiagnostic } from "@the-open-engine/opcore-contracts"; +import type { + ValidationCheckContext, + ValidationCheckDefinition, + ValidationCheckResult +} from "@the-open-engine/opcore-validation"; +import { supportedDocsValidationScopes } from "./check-constants.js"; +import { docsCheck } from "./check-definition.js"; +import { skippedDocsResult } from "./check-results.js"; +import { sortDiagnostics } from "./diagnostics.js"; +import { + materializeDocsSnapshot, + type DocsDocument, + type DocsPolicyOptions, + type DocsSnapshot +} from "./snapshot.js"; + +type SnapshotCollector = ( + snapshot: DocsSnapshot +) => Promise | readonly ValidationDiagnostic[]; + +export function createDocsDocumentCheck( + id: string, + severity: ValidationDiagnostic["severity"], + options: DocsPolicyOptions, + collect: (doc: DocsDocument, snapshot: DocsSnapshot) => readonly ValidationDiagnostic[] +): ValidationCheckDefinition { + return createDocsSnapshotCheck(id, severity, options, (snapshot) => + snapshot.docs.flatMap((doc) => collect(doc, snapshot)) + ); +} + +export function createDocsSnapshotCheck( + id: string, + severity: ValidationDiagnostic["severity"], + options: DocsPolicyOptions, + collect: SnapshotCollector +): ValidationCheckDefinition { + return docsCheck( + id, + severity, + supportedDocsValidationScopes, + (context) => withSelectedDocs(context, options, collect) + ); +} + +export async function withSelectedDocs( + context: ValidationCheckContext, + options: DocsPolicyOptions, + collect: SnapshotCollector +): Promise { + const snapshot = await materializeDocsSnapshot(context, options); + if (snapshot.docs.length === 0) { + return skippedDocsResult("No documentation files were selected."); + } + return { diagnostics: sortDiagnostics(await collect(snapshot)) }; +} diff --git a/packages/validation-docs/src/dry-check.ts b/packages/validation-docs/src/dry-check.ts new file mode 100644 index 0000000..528507a --- /dev/null +++ b/packages/validation-docs/src/dry-check.ts @@ -0,0 +1,64 @@ +import type { ValidationDiagnostic } from "@the-open-engine/opcore-contracts"; +import type { ValidationCheckDefinition } from "@the-open-engine/opcore-validation"; +import { DOCS_DRY_CHECK_ID } from "./check-ids.js"; +import { diagnostic } from "./diagnostics.js"; +import { createDocsSnapshotCheck } from "./document-check.js"; +import { + type DocsDocument, + type DocsPolicyOptions +} from "./snapshot.js"; + +export function createDocsDryCheck( + options: DocsPolicyOptions = {} +): ValidationCheckDefinition { + return createDocsSnapshotCheck( + DOCS_DRY_CHECK_ID, + "warning", + options, + (snapshot) => duplicateParagraphDiagnostics(snapshot.docs) + ); +} + +function duplicateParagraphDiagnostics( + docs: readonly DocsDocument[] +): readonly ValidationDiagnostic[] { + const seen = new Map(); + const diagnostics: ValidationDiagnostic[] = []; + for (const doc of docs) { + const duplicate = firstDuplicateParagraph(doc, seen); + if (duplicate === undefined) continue; + diagnostics.push( + diagnostic({ + severity: "warning", + path: doc.path, + code: "DOCS_DRY_DUPLICATE_PARAGRAPH", + message: + "Documentation repeats a long paragraph already present in " + + `${duplicate}.` + }) + ); + } + return diagnostics; +} + +function firstDuplicateParagraph( + doc: DocsDocument, + seen: Map +): string | undefined { + for (const paragraph of normalizedParagraphs(doc.content)) { + const existing = seen.get(paragraph); + if (existing !== undefined && existing !== doc.path) return existing; + seen.set(paragraph, doc.path); + } + return undefined; +} + +function normalizedParagraphs(content: string): readonly string[] { + return content + .split(/\n\s*\n/u) + .map((paragraph) => paragraph.replace(/\s+/gu, " ").trim()) + .filter( + (paragraph) => + paragraph.length >= 80 && !paragraph.startsWith("```") + ); +} diff --git a/packages/validation-docs/src/existence-check.ts b/packages/validation-docs/src/existence-check.ts new file mode 100644 index 0000000..228c852 --- /dev/null +++ b/packages/validation-docs/src/existence-check.ts @@ -0,0 +1,34 @@ +import type { ValidationCheckDefinition } from "@the-open-engine/opcore-validation"; +import { repoWideDocsValidationScopes } from "./check-constants.js"; +import { DOCS_EXISTENCE_CHECK_ID } from "./check-ids.js"; +import { docsCheck } from "./check-definition.js"; +import { diagnostic } from "./diagnostics.js"; +import { + materializeDocsSnapshot, + type DocsPolicyOptions +} from "./snapshot.js"; + +export function createDocsExistenceCheck( + options: DocsPolicyOptions = {} +): ValidationCheckDefinition { + return docsCheck( + DOCS_EXISTENCE_CHECK_ID, + "error", + repoWideDocsValidationScopes, + async (context) => { + const snapshot = await materializeDocsSnapshot(context, options); + const diagnostics = snapshot.requiredLocations + .filter((location) => location.found.length === 0) + .map((location) => + diagnostic({ + path: location.root === "." ? undefined : location.root, + code: "DOCS_REQUIRED_CONTEXT_DOC_MISSING", + message: + `Required context doc is missing at ${location.root}: expected ` + + `${snapshot.policy.filenames.join(" or ")}.` + }) + ); + return { diagnostics }; + } + ); +} diff --git a/packages/validation-docs/src/freshness.ts b/packages/validation-docs/src/freshness.ts index 47d4c17..c9b50fb 100644 --- a/packages/validation-docs/src/freshness.ts +++ b/packages/validation-docs/src/freshness.ts @@ -1,7 +1,19 @@ import type { ValidationDiagnostic } from "@the-open-engine/opcore-contracts"; -import type { ValidationCheckContext, ValidationCheckResult } from "@the-open-engine/opcore-validation"; +import type { + ValidationCheckContext, + ValidationCheckDefinition, + ValidationCheckResult +} from "@the-open-engine/opcore-validation"; +import { supportedDocsValidationScopes } from "./check-constants.js"; +import { DOCS_FRESHNESS_CHECK_ID } from "./check-ids.js"; +import { docsCheck } from "./check-definition.js"; +import { + skippedDocsResult, + skippedHistoryUnavailableResult +} from "./check-results.js"; import { diagnostic, sortDiagnostics } from "./diagnostics.js"; import { assertGitHistoryAvailable, latestCommitIso } from "./history.js"; +import type { CreateDocsValidationChecksOptions } from "./options.js"; import { isDocsPath, materializeDocsSnapshot, @@ -16,6 +28,17 @@ type FreshnessHistoryOutcome = | { status: "skipped"; result: ValidationCheckResult }; type LatestCommittedPath = { path: string; iso: string }; +export function createDocsFreshnessCheck( + options: CreateDocsValidationChecksOptions = {} +): ValidationCheckDefinition { + return docsCheck( + DOCS_FRESHNESS_CHECK_ID, + "warning", + supportedDocsValidationScopes, + (context) => runDocsFreshnessCheck(context, options) + ); +} + export async function runDocsFreshnessCheck( context: ValidationCheckContext, options: DocsPolicyOptions = {} @@ -116,18 +139,6 @@ function freshnessSkippedOutcome(message: string): FreshnessHistoryOutcome { return { status: "skipped", result: skippedHistoryUnavailableResult(message) }; } -function skippedDocsResult(message: string): ValidationCheckResult { - return { - status: "skipped", - diagnostics: [], - failureMessage: message - }; -} - -function skippedHistoryUnavailableResult(message: string): ValidationCheckResult { - return skippedDocsResult(message.startsWith("Git history is unavailable") ? message : `Git history is unavailable: ${message}`); -} - function latestCommittedPath( repoRoot: string, paths: readonly string[] diff --git a/packages/validation-docs/src/length-check.ts b/packages/validation-docs/src/length-check.ts new file mode 100644 index 0000000..509a7b5 --- /dev/null +++ b/packages/validation-docs/src/length-check.ts @@ -0,0 +1,86 @@ +import type { + RequiredContextDocPolicy, + ValidationDiagnostic +} from "@the-open-engine/opcore-contracts"; +import type { ValidationCheckDefinition } from "@the-open-engine/opcore-validation"; +import { + countPhysicalLines, + splitPhysicalLines +} from "@the-open-engine/opcore-validation"; +import { DOCS_LENGTH_CHECK_ID } from "./check-ids.js"; +import { diagnostic } from "./diagnostics.js"; +import { createDocsDocumentCheck } from "./document-check.js"; +import { + type DocsDocument, + type DocsPolicyOptions +} from "./snapshot.js"; + +export function createDocsLengthCheck( + options: DocsPolicyOptions = {} +): ValidationCheckDefinition { + return createDocsDocumentCheck( + DOCS_LENGTH_CHECK_ID, + "error", + options, + (doc, snapshot) => lengthDiagnostics(doc, snapshot.policy) + ); +} + +function lengthDiagnostics( + doc: DocsDocument, + policy: RequiredContextDocPolicy +): readonly ValidationDiagnostic[] { + const diagnostics: ValidationDiagnostic[] = []; + if (doc.content.trim().length < policy.minimumContentLength) { + diagnostics.push( + diagnostic({ + path: doc.path, + code: "DOCS_TOO_SHORT", + message: + "Documentation content is shorter than the " + + `${policy.minimumContentLength}-character require-context-doc policy minimum.` + }) + ); + } + const lines = splitPhysicalLines(doc.content); + const totalLines = countPhysicalLines(doc.content); + if (policy.maxLines !== undefined && totalLines > policy.maxLines) { + diagnostics.push( + diagnostic({ + path: doc.path, + code: "DOCS_TOO_LONG", + message: `Documentation has ${totalLines} lines; max is ${policy.maxLines}.` + }) + ); + } + const sectionLines = longestSectionLineCount(lines); + if ( + policy.maxSectionLines !== undefined && + sectionLines > policy.maxSectionLines + ) { + diagnostics.push( + diagnostic({ + path: doc.path, + code: "DOCS_SECTION_TOO_LONG", + message: + `Documentation section has ${sectionLines} lines; ` + + `max is ${policy.maxSectionLines}.` + }) + ); + } + return diagnostics; +} + +function longestSectionLineCount(lines: readonly string[]): number { + let current = 0; + let longest = 0; + for (const line of lines) { + if (/^\s*#{1,6}\s+/u.test(line)) { + longest = Math.max(longest, current); + current = 1; + } else { + current += 1; + } + } + return Math.max(longest, current); +} diff --git a/packages/validation-docs/src/options.ts b/packages/validation-docs/src/options.ts new file mode 100644 index 0000000..cd38a1c --- /dev/null +++ b/packages/validation-docs/src/options.ts @@ -0,0 +1,22 @@ +import type { DocsPolicyOptions } from "./snapshot.js"; + +export interface DocsHistoryOptions { + now?: string | Date; + maxStaleDays?: number; +} + +export interface DocsHubCoverageOptions { + minFanIn?: number; + minFanOut?: number; + requireExplicitMention?: boolean; +} + +export interface DocsSubtreeCoverageOptions { + minLoc?: number; +} + +export interface CreateDocsValidationChecksOptions extends DocsPolicyOptions { + history?: DocsHistoryOptions; + hubCoverage?: DocsHubCoverageOptions; + subtreeCoverage?: DocsSubtreeCoverageOptions; +} diff --git a/packages/validation-docs/src/rules-why-check.ts b/packages/validation-docs/src/rules-why-check.ts new file mode 100644 index 0000000..b943fac --- /dev/null +++ b/packages/validation-docs/src/rules-why-check.ts @@ -0,0 +1,42 @@ +import type { ValidationDiagnostic } from "@the-open-engine/opcore-contracts"; +import type { ValidationCheckDefinition } from "@the-open-engine/opcore-validation"; +import { DOCS_RULES_WHY_CHECK_ID } from "./check-ids.js"; +import { diagnostic } from "./diagnostics.js"; +import { createDocsDocumentCheck } from "./document-check.js"; +import { + type DocsDocument, + type DocsPolicyOptions +} from "./snapshot.js"; + +export function createDocsRulesWhyCheck( + options: DocsPolicyOptions = {} +): ValidationCheckDefinition { + return createDocsDocumentCheck( + DOCS_RULES_WHY_CHECK_ID, + "error", + options, + ruleWhyDiagnostics + ); +} + +function ruleWhyDiagnostics( + doc: DocsDocument +): readonly ValidationDiagnostic[] { + if (!doc.requiredContext) return []; + const diagnostics: ValidationDiagnostic[] = []; + doc.content.split(/\r?\n/u).forEach((line, index) => { + const trimmed = line.trim(); + if (!/^(?:[-*]\s*)?(ALWAYS|NEVER|MUST|SHOULD)\b/u.test(trimmed)) return; + if (/\bWHY:/u.test(trimmed) || /\bbecause\b/iu.test(trimmed)) return; + diagnostics.push( + diagnostic({ + path: doc.path, + code: "DOCS_RULE_WITHOUT_WHY", + message: + `Context-doc rule on line ${index + 1} ` + + "is missing a WHY rationale." + }) + ); + }); + return diagnostics; +} diff --git a/packages/validation-docs/src/staleness-check.ts b/packages/validation-docs/src/staleness-check.ts new file mode 100644 index 0000000..fc6d31a --- /dev/null +++ b/packages/validation-docs/src/staleness-check.ts @@ -0,0 +1,109 @@ +import type { ValidationDiagnostic } from "@the-open-engine/opcore-contracts"; +import type { + ValidationCheckContext, + ValidationCheckDefinition, + ValidationCheckResult +} from "@the-open-engine/opcore-validation"; +import { + defaultDocsHistoryThresholds, + supportedDocsValidationScopes +} from "./check-constants.js"; +import { DOCS_STALENESS_CHECK_ID } from "./check-ids.js"; +import { docsCheck } from "./check-definition.js"; +import { + skippedDocsResult, + skippedHistoryUnavailableResult +} from "./check-results.js"; +import { diagnostic, sortDiagnostics } from "./diagnostics.js"; +import { assertGitHistoryAvailable, latestCommitIso } from "./history.js"; +import type { + CreateDocsValidationChecksOptions, + DocsHistoryOptions +} from "./options.js"; +import { materializeDocsSnapshot } from "./snapshot.js"; + +export function createDocsStalenessCheck( + options: CreateDocsValidationChecksOptions = {} +): ValidationCheckDefinition { + return docsCheck( + DOCS_STALENESS_CHECK_ID, + "warning", + supportedDocsValidationScopes, + async (context) => runStalenessCheck(context, options) + ); +} + +async function runStalenessCheck( + context: ValidationCheckContext, + options: CreateDocsValidationChecksOptions +): Promise { + const skipped = await skippedHistoryResult(context, options); + if (skipped !== undefined) return skipped; + const snapshot = await materializeDocsSnapshot(context, options); + if (snapshot.docs.length === 0) { + return skippedDocsResult("No documentation files were selected."); + } + const repoRoot = context.request.repo.repoRoot; + if (repoRoot === undefined) { + return skippedHistoryUnavailableResult( + "Git history is unavailable: request.repo.repoRoot is missing." + ); + } + const now = historyNow(options.history); + const maxStaleDays = + options.history?.maxStaleDays ?? + defaultDocsHistoryThresholds.maxStaleDays; + const diagnostics: ValidationDiagnostic[] = []; + for (const doc of snapshot.docs) { + const committed = latestCommitIso(repoRoot, doc.path); + if (!committed.ok) { + return skippedHistoryUnavailableResult(committed.message); + } + if (committed.value === undefined) continue; + const ageDays = elapsedDays(committed.value, now); + if (ageDays <= maxStaleDays) continue; + diagnostics.push( + diagnostic({ + severity: "warning", + path: doc.path, + code: "DOCS_STALE", + message: + `Documentation was last committed ${Math.floor(ageDays)} days ago, ` + + `over the ${maxStaleDays}-day staleness threshold.` + }) + ); + } + return { diagnostics: sortDiagnostics(diagnostics) }; +} + +async function skippedHistoryResult( + context: ValidationCheckContext, + options: CreateDocsValidationChecksOptions +): Promise { + const snapshot = await materializeDocsSnapshot(context, options); + if (snapshot.hasOverlays) { + return skippedDocsResult( + "Documentation Git-history checks require committed state and skip when overlays are present." + ); + } + const repoRoot = context.request.repo.repoRoot; + if (repoRoot === undefined) { + return skippedHistoryUnavailableResult( + "Git history is unavailable: request.repo.repoRoot is missing." + ); + } + const available = assertGitHistoryAvailable(repoRoot); + return available.ok + ? undefined + : skippedHistoryUnavailableResult(available.message); +} + +function historyNow(options: DocsHistoryOptions | undefined): Date { + if (options?.now instanceof Date) return options.now; + if (typeof options?.now === "string") return new Date(options.now); + return new Date(); +} + +function elapsedDays(iso: string, now: Date): number { + return (now.getTime() - Date.parse(iso)) / (24 * 60 * 60 * 1000); +} diff --git a/packages/validation-python/src/project-workspace.ts b/packages/validation-python/src/project-workspace.ts index 4e80bb0..0d10241 100644 --- a/packages/validation-python/src/project-workspace.ts +++ b/packages/validation-python/src/project-workspace.ts @@ -186,8 +186,8 @@ function repoRelativeOrUndefined(repoRoot: string, absolute: string): string | u function skipPath(path: string): boolean { const skipped = new Set([ - ".git", "node_modules", "target", "dist", ".ace", ".agents", ".claude", ".codex", ".gemini", - ".opencode", ".rox-cache", ".robustness-engine-cache", ".venv", "venv", "env", "__pycache__", + ".git", "node_modules", "target", "dist", ".agents", ".claude", ".codex", ".gemini", + ".opencode", ".venv", "venv", "env", "__pycache__", ".eggs", "build", ".tox", ".mypy_cache", ".pytest_cache", ".ruff_cache", "site-packages" ]); return path.split("/").some((segment) => skipped.has(segment) || segment.endsWith(".egg-info") || segment.endsWith(".dist-info")); diff --git a/packages/validation-rust/src/materialize.ts b/packages/validation-rust/src/materialize.ts index 3875c56..f9f46c2 100644 --- a/packages/validation-rust/src/materialize.ts +++ b/packages/validation-rust/src/materialize.ts @@ -20,10 +20,7 @@ const excludedParts = new Set([ ".git", "node_modules", "dist", - "target", - ".ace", - ".ro" + "x-cache", - ".robustness-engine" + "-cache" + "target" ]); export async function materializeRustWorkspace( @@ -48,7 +45,7 @@ async function createMaterializedRustWorkspace( context: ValidationCheckContext, options: { env?: Record } ): Promise { - const tempRoot = mkdtempSync(join(tmpdir(), "lattice-validation-rust-")); + const tempRoot = mkdtempSync(join(tmpdir(), "opcore-validation-rust-")); const root = join(tempRoot, "repo"); mkdirSync(root, { recursive: true }); const repoRoot = context.request.repo.repoRoot; diff --git a/packages/validation-rust/src/unused-deps-check.ts b/packages/validation-rust/src/unused-deps-check.ts index 57b7de3..4d4619f 100644 --- a/packages/validation-rust/src/unused-deps-check.ts +++ b/packages/validation-rust/src/unused-deps-check.ts @@ -16,74 +16,37 @@ import { runTool } from "./process.js"; import { skippedRustInputResult } from "./source-files.js"; import { toolAvailable } from "./toolchain.js"; -export function createUnusedDepsCheck(options: { env?: Record; timeoutMs?: number } = {}): ValidationCheckDefinition { - return { - id: RUST_UNUSED_DEPS_CHECK_ID, - owner: rustCheckOwner, - adapter: rustCheckAdapter, - defaultSeverity: "error", - supportedScopes: supportedRustValidationScopes, - run: async (context) => { - const skipped = skippedRustInputResult(context); - if (skipped !== undefined) return skipped; - if (!toolAvailable("cargo-udeps", { env: options.env })) { - return { - status: "unsupported_request", - diagnostics: [], - failureMessage: "cargo-udeps is unavailable" - }; - } - const materialized = await materializeRustWorkspace(context, { env: options.env }); - const metadata = loadCargoMetadata(materialized.root, { - ...options, - cargoTargetCacheKey: materialized.cargoTargetCacheKey - }); - if (!metadata.ok) return metadataFailureResult(metadata); - const packageScope = resolveCargoPackageScope(metadata.metadata, context.scope); - if (!packageScope.ok) return metadataFailureResult(packageScope); - const result = runTool("cargo", unusedDepsArgs(packageScope.member, options), { - cwd: materialized.root, - cargoTargetCacheKey: materialized.cargoTargetCacheKey, - env: options.env, - timeoutMs: options.timeoutMs, - allowedExitCodes: [0, 1, 101] - }); - const infrastructureFailure = commandInfrastructureFailure(result); - if (infrastructureFailure !== undefined) return infrastructureFailure; - const unsupportedFailure = requiredToolUnsupportedFailure(result, "udeps") ?? requiredToolUnsupportedFailure(result, "cargo-udeps"); - if (unsupportedFailure !== undefined) return unsupportedFailure; - if (result.status !== 0) { - const diagnostics = parseUnusedDependencyDiagnostics(result.stderr || result.stdout, packageScope.member); - if (diagnostics.length > 0) return { diagnostics }; - const toolchainFailure = cargoUdepsToolchainFailure(result); - if (toolchainFailure !== undefined) return toolchainFailure; - return singleStderrPolicyFailure({ - path: packageScope.member?.manifestPath ?? "Cargo.toml", - code: "RUST_UNUSED_DEPS", - stderr: result.stderr || result.stdout, - fallback: "Unused Rust dependencies found" - }); - } - return { diagnostics: [] }; - } - }; +interface UnusedDepsCheckOptions { + env?: Record; + timeoutMs?: number; } -function unusedDepsArgs(member: CargoMetadataPackage | undefined, options: { env?: Record; timeoutMs?: number }): readonly string[] { - const args = member === undefined - ? ["udeps", "--workspace", "--all-targets", "--all-features"] - : ["udeps", "-p", member.name, "--all-targets", "--all-features"]; - return nightlyCargoUdepsAvailable(options) ? ["+nightly", ...args] : args; -} +const defaultNightlyToolchain = "nightly"; +const nightlyToolchainEnvName = "OPCORE_RUST_NIGHTLY_TOOLCHAIN"; -function nightlyCargoUdepsAvailable(options: { env?: Record; timeoutMs?: number }): boolean { - return runTool("cargo", ["+nightly", "udeps", "--version"], { +function nightlyCargoUdepsAvailable(options: UnusedDepsCheckOptions): boolean { + return runTool("cargo", [nightlyToolchainSelector(options), "udeps", "--version"], { env: options.env, timeoutMs: options.timeoutMs, allowedExitCodes: [0] }).ok; } +function nightlyToolchainSelector(options: UnusedDepsCheckOptions): string { + const configured = (options.env ?? process.env)[nightlyToolchainEnvName]?.trim(); + return `+${configured || defaultNightlyToolchain}`; +} + +function unusedDepsArgs( + member: CargoMetadataPackage | undefined, + options: UnusedDepsCheckOptions +): readonly string[] { + const args = member === undefined + ? ["udeps", "--workspace", "--all-targets", "--all-features"] + : ["udeps", "-p", member.name, "--all-targets", "--all-features"]; + return nightlyCargoUdepsAvailable(options) ? [nightlyToolchainSelector(options), ...args] : args; +} + function cargoUdepsToolchainFailure(result: ReturnType): ValidationCheckResult | undefined { const output = [result.stderr, result.stdout, result.failureMessage].filter(Boolean).join("\n"); if (!isNightlyRustToolchainFailure(output)) return undefined; @@ -102,7 +65,10 @@ function isNightlyRustToolchainFailure(output: string): boolean { ].some((pattern) => pattern.test(output)); } -function parseUnusedDependencyDiagnostics(output: string, member: CargoMetadataPackage | undefined): readonly ValidationDiagnostic[] { +function parseUnusedDependencyDiagnostics( + output: string, + member: CargoMetadataPackage | undefined +): readonly ValidationDiagnostic[] { const names = new Set(); for (const line of output.split(/\r?\n/)) { const trimmed = line.trim(); @@ -122,3 +88,68 @@ function parseUnusedDependencyDiagnostics(output: string, member: CargoMetadataP ) ); } + +async function runUnusedDepsCheck( + context: Parameters[0], + options: UnusedDepsCheckOptions +): Promise { + const skipped = skippedRustInputResult(context); + if (skipped !== undefined) return skipped; + if (!toolAvailable("cargo-udeps", { env: options.env })) { + return { + status: "unsupported_request", + diagnostics: [], + failureMessage: "cargo-udeps is unavailable" + }; + } + const materialized = await materializeRustWorkspace(context, { env: options.env }); + const metadata = loadCargoMetadata(materialized.root, { + ...options, + cargoTargetCacheKey: materialized.cargoTargetCacheKey + }); + if (!metadata.ok) return metadataFailureResult(metadata); + const packageScope = resolveCargoPackageScope(metadata.metadata, context.scope); + if (!packageScope.ok) return metadataFailureResult(packageScope); + const result = runTool("cargo", unusedDepsArgs(packageScope.member, options), { + cwd: materialized.root, + cargoTargetCacheKey: materialized.cargoTargetCacheKey, + env: options.env, + timeoutMs: options.timeoutMs, + allowedExitCodes: [0, 1, 101] + }); + const infrastructureFailure = commandInfrastructureFailure(result); + if (infrastructureFailure !== undefined) return infrastructureFailure; + const unsupportedFailure = + requiredToolUnsupportedFailure(result, "udeps") ?? + requiredToolUnsupportedFailure(result, "cargo-udeps"); + if (unsupportedFailure !== undefined) return unsupportedFailure; + return unusedDepsResult(result, packageScope.member); +} + +function unusedDepsResult( + result: ReturnType, + member: CargoMetadataPackage | undefined +): ValidationCheckResult { + if (result.status === 0) return { diagnostics: [] }; + const diagnostics = parseUnusedDependencyDiagnostics(result.stderr || result.stdout, member); + if (diagnostics.length > 0) return { diagnostics }; + const toolchainFailure = cargoUdepsToolchainFailure(result); + if (toolchainFailure !== undefined) return toolchainFailure; + return singleStderrPolicyFailure({ + path: member?.manifestPath ?? "Cargo.toml", + code: "RUST_UNUSED_DEPS", + stderr: result.stderr || result.stdout, + fallback: "Unused Rust dependencies found" + }); +} + +export function createUnusedDepsCheck(options: UnusedDepsCheckOptions = {}): ValidationCheckDefinition { + return { + id: RUST_UNUSED_DEPS_CHECK_ID, + owner: rustCheckOwner, + adapter: rustCheckAdapter, + defaultSeverity: "error", + supportedScopes: supportedRustValidationScopes, + run: (context) => runUnusedDepsCheck(context, options) + }; +} diff --git a/packages/validation-typescript/src/dead-code-roots.ts b/packages/validation-typescript/src/dead-code-roots.ts index 21c0105..9de29b9 100644 --- a/packages/validation-typescript/src/dead-code-roots.ts +++ b/packages/validation-typescript/src/dead-code-roots.ts @@ -9,6 +9,7 @@ import { import ts from "typescript"; import type { TypeScriptDeadCodeOptions } from "./dead-code-entrypoints.js"; import { isTypeScriptSourcePath, type TypeScriptMaterializedSourceSet } from "./source-files.js"; +import { isConventionalTypeScriptTestPath } from "./test-paths.js"; interface TypeScriptOutputMapping { readonly rootDir: string; @@ -19,7 +20,6 @@ interface PackageEntrypointResolution { readonly context: ValidationCheckContext; readonly packageRoot: string; readonly target: string; - readonly sourcePaths: ReadonlySet; readonly outputMapping: TypeScriptOutputMapping | undefined; } @@ -31,7 +31,7 @@ export async function discoverTypeScriptDeadCodeRoots( ): Promise { if (options.entrypoints !== undefined) return []; const sourcePaths = knownSourcePaths(sourceSet, nodes); - const testRoots = sourcePaths.filter((path) => isConventionalTestPath(path)); + const testRoots = sourcePaths.filter((path) => isConventionalTypeScriptTestPath(path)); const graphTestRoots = nodes.filter(isGraphTestNode).map(graphFactNodePath).filter(isDefined); const packageRoots = await discoverPackageEntrypoints(context, sourcePaths); return uniqueSortedStrings([...testRoots, ...graphTestRoots, ...packageRoots]); @@ -51,7 +51,6 @@ async function discoverPackageEntrypoints( context: ValidationCheckContext, sourcePaths: readonly string[] ): Promise { - const sourcePathSet = new Set(sourcePaths); const entrypoints: string[] = []; for (const packageRoot of candidatePackageRoots(sourcePaths)) { const manifest = await readPackageManifest(context, packageRoot); @@ -62,7 +61,6 @@ async function discoverPackageEntrypoints( context, packageRoot, target, - sourcePaths: sourcePathSet, outputMapping }); if (entrypoint !== undefined) entrypoints.push(entrypoint); @@ -133,7 +131,7 @@ async function resolvePackageEntrypoint(resolution: PackageEntrypointResolution) if (targetPath === undefined) return undefined; const mappedSource = await resolveOutputSource(resolution.context, targetPath, resolution.outputMapping); if (mappedSource !== undefined) return mappedSource; - if (!resolution.sourcePaths.has(targetPath) || !isTypeScriptSourcePath(targetPath)) return undefined; + if (!isTypeScriptSourcePath(targetPath)) return undefined; return (await resolution.context.fileView.exists(targetPath)) ? targetPath : undefined; } @@ -203,10 +201,6 @@ function isGraphTestNode(node: GraphFactNode): boolean { return node.kind === "Test" || node.attributes?.isTest === true; } -function isConventionalTestPath(path: string): boolean { - return /(?:^|\/)__tests__\//u.test(path) || /\.(?:test|spec)\.[cm]?[jt]sx?$/u.test(path); -} - function isDefined(value: T | undefined): value is T { return value !== undefined; } diff --git a/packages/validation-typescript/src/dead-code-run.ts b/packages/validation-typescript/src/dead-code-run.ts index 2c29d13..8682465 100644 --- a/packages/validation-typescript/src/dead-code-run.ts +++ b/packages/validation-typescript/src/dead-code-run.ts @@ -139,7 +139,9 @@ function deadCodeDiagnostics(state: DeadCodeState): readonly ValidationDiagnosti !hasReachableSymbol(node, state.reachability.reachableSymbolAliases) ); const typeExports = partitionTypeExportsByReferenceSupport({ - nodes: typeCoveredExports.filter((node) => !nodeHasPath(node, state.missingGraphImportTargets)), + nodes: typeCoveredExports + .filter((node) => !hasReachableSymbol(node, state.reachability.reachableSymbolAliases)) + .filter((node) => !nodeHasPath(node, state.missingGraphImportTargets)), typeReferences: state.typeReferences, incomingTypeReferences: new Set(state.typeReferences.map((edge) => edge.to)), importsFrom: state.importsFrom, diff --git a/packages/validation-typescript/src/diagnostics.ts b/packages/validation-typescript/src/diagnostics.ts index 6f95af8..c6c60a0 100644 --- a/packages/validation-typescript/src/diagnostics.ts +++ b/packages/validation-typescript/src/diagnostics.ts @@ -32,13 +32,6 @@ export function sortValidationDiagnostics(diagnostics: readonly ValidationDiagno return [...diagnostics].sort(compareDiagnostics); } -export function scriptKindForPath(path: string): ts.ScriptKind { - if (path.endsWith(".tsx")) return ts.ScriptKind.TSX; - if (path.endsWith(".jsx")) return ts.ScriptKind.JSX; - if (path.endsWith(".js")) return ts.ScriptKind.JS; - return ts.ScriptKind.TS; -} - function compareDiagnostics(left: ValidationDiagnostic, right: ValidationDiagnostic): number { return ( (left.path ?? "").localeCompare(right.path ?? "") || diff --git a/packages/validation-typescript/src/function-metrics-check.ts b/packages/validation-typescript/src/function-metrics-check.ts index 415bf59..abe9ced 100644 --- a/packages/validation-typescript/src/function-metrics-check.ts +++ b/packages/validation-typescript/src/function-metrics-check.ts @@ -8,7 +8,8 @@ import { typeScriptCheckOwner, supportedTypeScriptValidationScopes } from "./check-constants.js"; -import { scriptKindForPath, sortValidationDiagnostics } from "./diagnostics.js"; +import { sortValidationDiagnostics } from "./diagnostics.js"; +import { scriptKindForPath } from "./script-kind.js"; import { materializeTypeScriptSources } from "./source-files.js"; export interface TypeScriptFunctionMetricThresholds { diff --git a/packages/validation-typescript/src/graph-requirements.ts b/packages/validation-typescript/src/graph-requirements.ts index d55e1d6..06675d7 100644 --- a/packages/validation-typescript/src/graph-requirements.ts +++ b/packages/validation-typescript/src/graph-requirements.ts @@ -42,7 +42,7 @@ export async function deadCodeGraphRequirements( export async function relevantTestsGraphRequirements( context: ValidationCheckContext ): Promise { - return edgeAndScopedFileRequirements(context, ["TESTED_BY"]); + return edgeAndScopedFileRequirements(context, ["IMPORTS_FROM", "TESTED_BY"]); } async function edgeAndScopedFileRequirements( diff --git a/packages/validation-typescript/src/import-graph-check.ts b/packages/validation-typescript/src/import-graph-check.ts index 85ef1a0..d17d6a6 100644 --- a/packages/validation-typescript/src/import-graph-check.ts +++ b/packages/validation-typescript/src/import-graph-check.ts @@ -41,6 +41,7 @@ async function retainedMissingEdgeDiagnostics( ) return []; throw error; } + // IMPORTS_FROM represents compiler dependencies, so type-only imports still require graph parity. return relativeImports .filter((relativeImport) => !edges.some((edge) => matchesDirectedFileEdge(edge, relativeImport.fromPath, relativeImport.resolvedPath))) .map((relativeImport): ValidationDiagnostic => ({ @@ -66,7 +67,12 @@ interface ImportGraphEdge { } function cycleDiagnostics(relativeImports: readonly TypeScriptRelativeImport[]): readonly ValidationDiagnostic[] { - return findCycles(relativeImports.map((relativeImport) => ({ from: relativeImport.fromPath, to: relativeImport.resolvedPath }))).map( + const runtimeImports = relativeImports.filter((relativeImport) => relativeImport.kind === "runtime"); + const edges = runtimeImports.map((relativeImport) => ({ + from: relativeImport.fromPath, + to: relativeImport.resolvedPath + })); + return findCycles(edges).map( (cycle): ValidationDiagnostic => ({ category: "graph", severity: "warning", diff --git a/packages/validation-typescript/src/import-layer-rules-check.ts b/packages/validation-typescript/src/import-layer-rules-check.ts index c13a512..61d2e2d 100644 --- a/packages/validation-typescript/src/import-layer-rules-check.ts +++ b/packages/validation-typescript/src/import-layer-rules-check.ts @@ -1,9 +1,8 @@ import type { ValidationDiagnostic } from "@the-open-engine/opcore-contracts"; import type { ValidationCheckDefinition } from "@the-open-engine/opcore-validation"; -import ts from "typescript"; import { TYPE_SCRIPT_IMPORT_LAYER_RULES_CHECK_ID } from "./check-ids.js"; import { typeScriptCheckAdapter, typeScriptCheckOwner, supportedTypeScriptValidationScopes } from "./check-constants.js"; -import { scriptKindForPath, sortValidationDiagnostics } from "./diagnostics.js"; +import { sortValidationDiagnostics } from "./diagnostics.js"; import { materializeTypeScriptSources } from "./source-files.js"; export interface TypeScriptImportLayerRule { @@ -31,10 +30,9 @@ export function createImportLayerRulesCheck(options: TypeScriptImportLayerRulesO const rules = options.layerRules ?? []; if (rules.length === 0) return { diagnostics: [] }; const sourceSet = await materializeTypeScriptSources(context); - const typeOnly = typeOnlyImportSpecifiers(sourceSet.files); const diagnostics: ValidationDiagnostic[] = []; for (const relativeImport of sourceSet.relativeImports) { - if (options.ignoreTypeOnlyImports === true && typeOnly.get(relativeImport.fromPath)?.has(relativeImport.specifier)) continue; + if (options.ignoreTypeOnlyImports === true && relativeImport.kind === "type_only") continue; for (const rule of rules) { if (!layerRuleMatches(rule, relativeImport.fromPath, relativeImport.resolvedPath)) continue; diagnostics.push({ @@ -51,26 +49,6 @@ export function createImportLayerRulesCheck(options: TypeScriptImportLayerRulesO }; } -function typeOnlyImportSpecifiers( - files: readonly { path: string; content: string }[] -): ReadonlyMap> { - const byPath = new Map>(); - for (const file of files) { - const sourceFile = ts.createSourceFile(file.path, file.content, ts.ScriptTarget.Latest, true, scriptKindForPath(file.path)); - const specifiers = new Set(); - sourceFile.forEachChild((node) => { - if (ts.isImportDeclaration(node) && node.importClause?.isTypeOnly === true && ts.isStringLiteral(node.moduleSpecifier)) { - specifiers.add(node.moduleSpecifier.text); - } - if (ts.isExportDeclaration(node) && node.isTypeOnly === true && node.moduleSpecifier !== undefined && ts.isStringLiteral(node.moduleSpecifier)) { - specifiers.add(node.moduleSpecifier.text); - } - }); - if (specifiers.size > 0) byPath.set(file.path, specifiers); - } - return byPath; -} - function layerRuleMatches(rule: TypeScriptImportLayerRule, fromPath: string, toPath: string): boolean { if (!patternMatches(rule.from, fromPath)) return false; if (rule.fromNot?.some((pattern) => patternMatches(pattern, fromPath)) === true) return false; diff --git a/packages/validation-typescript/src/lint-plugin-cache.ts b/packages/validation-typescript/src/lint-plugin-cache.ts index 782b3ca..704de05 100644 --- a/packages/validation-typescript/src/lint-plugin-cache.ts +++ b/packages/validation-typescript/src/lint-plugin-cache.ts @@ -6,7 +6,6 @@ const skippedDependencyDirs = new Set([ "node_modules", "dist", "target", - ".ace", ".agents", ".claude", ".codex", diff --git a/packages/validation-typescript/src/module-dependencies.ts b/packages/validation-typescript/src/module-dependencies.ts new file mode 100644 index 0000000..c71beb0 --- /dev/null +++ b/packages/validation-typescript/src/module-dependencies.ts @@ -0,0 +1,83 @@ +import ts from "typescript"; +import { scriptKindForPath } from "./script-kind.js"; + +export type TypeScriptImportKind = "runtime" | "type_only"; + +export interface TypeScriptModuleDependency { + specifier: string; + kind: TypeScriptImportKind; +} + +export function moduleDependencies(path: string, content: string): readonly TypeScriptModuleDependency[] { + const sourceFile = ts.createSourceFile(path, content, ts.ScriptTarget.Latest, true, scriptKindForPath(path)); + const collected = new Map(); + for (const reference of sourceFile.referencedFiles) { + recordDependency(collected, { specifier: reference.fileName, kind: "type_only" }); + } + const visit = (node: ts.Node): void => { + const dependency = dependencyForNode(node); + if (dependency !== undefined) recordDependency(collected, dependency); + ts.forEachChild(node, visit); + }; + sourceFile.forEachChild(visit); + return [...collected.entries()] + .map(([specifier, kind]) => ({ specifier, kind })) + .sort((left, right) => left.specifier.localeCompare(right.specifier)); +} + +function recordDependency( + collected: Map, + dependency: TypeScriptModuleDependency +): void { + const existing = collected.get(dependency.specifier); + if (existing === "runtime" || existing === dependency.kind) return; + collected.set(dependency.specifier, dependency.kind); +} + +function dependencyForNode(node: ts.Node): TypeScriptModuleDependency | undefined { + if (ts.isImportDeclaration(node)) { + return dependency(node.moduleSpecifier, importDeclarationKind(node)); + } + if (ts.isExportDeclaration(node) && node.moduleSpecifier !== undefined) { + return dependency(node.moduleSpecifier, exportDeclarationKind(node)); + } + if (ts.isImportEqualsDeclaration(node) && ts.isExternalModuleReference(node.moduleReference)) { + return dependency(node.moduleReference.expression, node.isTypeOnly ? "type_only" : "runtime"); + } + if (ts.isImportTypeNode(node) && ts.isLiteralTypeNode(node.argument)) { + return dependency(node.argument.literal, "type_only"); + } + return callDependency(node); +} + +function importDeclarationKind(node: ts.ImportDeclaration): TypeScriptImportKind { + const clause = node.importClause; + if (clause?.isTypeOnly === true) return "type_only"; + if (clause === undefined || clause.name !== undefined || clause.namedBindings === undefined) return "runtime"; + if (!ts.isNamedImports(clause.namedBindings)) return "runtime"; + const elements = clause.namedBindings.elements; + return elements.length > 0 && elements.every((element) => element.isTypeOnly) ? "type_only" : "runtime"; +} + +function exportDeclarationKind(node: ts.ExportDeclaration): TypeScriptImportKind { + if (node.isTypeOnly) return "type_only"; + if (node.exportClause === undefined || !ts.isNamedExports(node.exportClause)) return "runtime"; + const elements = node.exportClause.elements; + return elements.length > 0 && elements.every((element) => element.isTypeOnly) ? "type_only" : "runtime"; +} + +function callDependency(node: ts.Node): TypeScriptModuleDependency | undefined { + if (!ts.isCallExpression(node)) return undefined; + const isLoader = + node.expression.kind === ts.SyntaxKind.ImportKeyword || + (ts.isIdentifier(node.expression) && node.expression.text === "require"); + return isLoader ? dependency(node.arguments[0], "runtime") : undefined; +} + +function dependency( + expression: ts.Expression | undefined, + kind: TypeScriptImportKind +): TypeScriptModuleDependency | undefined { + if (expression === undefined || !ts.isStringLiteralLike(expression)) return undefined; + return { specifier: expression.text, kind }; +} diff --git a/packages/validation-typescript/src/relevant-tests-check.ts b/packages/validation-typescript/src/relevant-tests-check.ts index 9107dd5..1e94cbd 100644 --- a/packages/validation-typescript/src/relevant-tests-check.ts +++ b/packages/validation-typescript/src/relevant-tests-check.ts @@ -1,9 +1,11 @@ -import type { GraphFactEdge, ValidationDiagnostic } from "@the-open-engine/opcore-contracts"; +import type { ValidationDiagnostic } from "@the-open-engine/opcore-contracts"; import type { ValidationCheckDefinition } from "@the-open-engine/opcore-validation"; import { TYPE_SCRIPT_RELEVANT_TESTS_CHECK_ID } from "./check-ids.js"; import { typeScriptCheckAdapter, typeScriptCheckOwner, supportedTypeScriptValidationScopes } from "./check-constants.js"; import { relevantTestsGraphRequirements } from "./graph-requirements.js"; -import { materializeTypeScriptSources, toFileNodeId } from "./source-files.js"; +import { createRelevantTestEvidence } from "./relevant-tests-evidence.js"; +import { materializeTypeScriptSources } from "./source-files.js"; +import { isConventionalTypeScriptTestPath } from "./test-paths.js"; export function createRelevantTestsCheck(): ValidationCheckDefinition { return { @@ -15,51 +17,24 @@ export function createRelevantTestsCheck(): ValidationCheckDefinition { requiresGraph: true, graphRequirements: relevantTestsGraphRequirements, run: async (context) => { - const [sourceSet, testedBy] = await Promise.all([materializeTypeScriptSources(context), context.graph.testedBy()]); - const diagnostics = sourceSet.rootPaths.map((path): ValidationDiagnostic => { - const evidence = testedBy.filter((edge) => edgeReferencesFile(edge, path)); - if (evidence.length > 0) { - return { - category: "test", - severity: "info", - path, - code: "TS_RELEVANT_TESTS_FOUND", - message: `TESTED_BY graph evidence exists for ${path}: ${evidence.map(testEndpoint).sort().join(", ")}` - }; - } - return { + const [sourceSet, importsFrom, testedBy] = await Promise.all([ + materializeTypeScriptSources(context), + context.graph.importsFrom(), + context.graph.testedBy() + ]); + const relevantTestEvidence = createRelevantTestEvidence(importsFrom, testedBy); + const sourcePaths = sourceSet.rootPaths.filter((path) => !isConventionalTypeScriptTestPath(path)); + const diagnostics = sourcePaths.flatMap((path): ValidationDiagnostic[] => { + if (relevantTestEvidence(path).length > 0) return []; + return [{ category: "test", severity: "info", path, code: "TS_RELEVANT_TESTS_ABSENT", message: `No TESTED_BY graph evidence found for ${path}.` - }; + }]; }); return { diagnostics }; } }; } - -function edgeReferencesFile(edge: GraphFactEdge, path: string): boolean { - return endpointReferencesFile(edge.from, path) || endpointReferencesFile(edge.to, path); -} - -function testEndpoint(edge: GraphFactEdge): string { - return endpointFilePath(edge.to) ?? edge.to; -} - -function endpointAliases(path: string): ReadonlySet { - return new Set([path, toFileNodeId(path)]); -} - -function endpointReferencesFile(endpoint: string, path: string): boolean { - const aliases = endpointAliases(path); - if (aliases.has(endpoint)) return true; - const endpointPath = endpointFilePath(endpoint); - return endpointPath !== undefined && aliases.has(endpointPath); -} - -function endpointFilePath(endpoint: string): string | undefined { - const match = /^[^:]+:([^#]+)(?:#.*)?$/.exec(endpoint); - return match?.[1]; -} diff --git a/packages/validation-typescript/src/relevant-tests-evidence.ts b/packages/validation-typescript/src/relevant-tests-evidence.ts new file mode 100644 index 0000000..586c5cb --- /dev/null +++ b/packages/validation-typescript/src/relevant-tests-evidence.ts @@ -0,0 +1,74 @@ +import type { GraphFactEdge } from "@the-open-engine/opcore-contracts"; +import { graphFactPathFromEndpoint } from "@the-open-engine/opcore-validation"; + +const maxRelevantTestTraversalFiles = 10_000; + +export function createRelevantTestEvidence( + importsFrom: readonly GraphFactEdge[], + testedBy: readonly GraphFactEdge[] +): (path: string) => readonly string[] { + const reverseImporters = collectReverseImporters(importsFrom); + return (path) => relevantTestEvidence(path, testedBy, reverseImporters); +} + +function relevantTestEvidence( + path: string, + testedBy: readonly GraphFactEdge[], + reverseImporters: ReadonlyMap +): readonly string[] { + const direct = directTestEndpoints(path, testedBy); + if (direct.length > 0) return direct; + + const discovered = new Set([path]); + const pending: string[] = []; + const initialImporters = reverseImporters.get(path); + if (initialImporters !== undefined) appendUnseenPaths(pending, discovered, initialImporters); + const evidence = new Set(); + for (let cursor = 0; cursor < pending.length; cursor += 1) { + const importer = pending[cursor]; + if (importer === undefined) continue; + for (const endpoint of directTestEndpoints(importer, testedBy)) evidence.add(`${importer} -> ${endpoint}`); + const nextImporters = reverseImporters.get(importer); + if (nextImporters !== undefined) appendUnseenPaths(pending, discovered, nextImporters); + } + return [...evidence].sort(); +} + +function appendUnseenPaths(pending: string[], discovered: Set, paths: readonly string[]): void { + for (const path of paths) { + if (discovered.size >= maxRelevantTestTraversalFiles) return; + if (discovered.has(path)) continue; + discovered.add(path); + pending.push(path); + } +} + +function collectReverseImporters(edges: readonly GraphFactEdge[]): ReadonlyMap { + const collected = new Map>(); + for (const edge of edges) { + if (edge.kind !== "IMPORTS_FROM") continue; + const importer = graphFactPathFromEndpoint(edge.from); + const imported = graphFactPathFromEndpoint(edge.to); + if (importer === undefined || imported === undefined) continue; + const importers = collected.get(imported) ?? new Set(); + importers.add(importer); + collected.set(imported, importers); + } + return new Map( + [...collected.entries()] + .sort(([left], [right]) => left.localeCompare(right)) + .map(([path, importers]) => [path, [...importers].sort()]) + ); +} + +function directTestEndpoints(path: string, testedBy: readonly GraphFactEdge[]): readonly string[] { + const endpoints = new Set(); + for (const edge of testedBy) { + const fromPath = graphFactPathFromEndpoint(edge.from); + const toPath = graphFactPathFromEndpoint(edge.to); + const referencesPath = edge.from === path || edge.from === `file:${path}` || fromPath === path || + edge.to === path || edge.to === `file:${path}` || toPath === path; + if (referencesPath) endpoints.add(toPath ?? edge.to); + } + return [...endpoints].sort(); +} diff --git a/packages/validation-typescript/src/script-kind.ts b/packages/validation-typescript/src/script-kind.ts new file mode 100644 index 0000000..2d27650 --- /dev/null +++ b/packages/validation-typescript/src/script-kind.ts @@ -0,0 +1,8 @@ +import ts from "typescript"; + +export function scriptKindForPath(path: string): ts.ScriptKind { + if (path.endsWith(".tsx")) return ts.ScriptKind.TSX; + if (path.endsWith(".jsx")) return ts.ScriptKind.JSX; + if (path.endsWith(".js")) return ts.ScriptKind.JS; + return ts.ScriptKind.TS; +} diff --git a/packages/validation-typescript/src/source-files.ts b/packages/validation-typescript/src/source-files.ts index d086ad3..3f64dcb 100644 --- a/packages/validation-typescript/src/source-files.ts +++ b/packages/validation-typescript/src/source-files.ts @@ -1,6 +1,7 @@ import type { ValidationCheckContext, ValidationFileView } from "@the-open-engine/opcore-validation"; import { joinRepoRelativePaths, normalizeValidationFileViewPath, uniqueSortedStrings } from "@the-open-engine/opcore-validation"; import ts from "typescript"; +import { moduleDependencies, type TypeScriptImportKind } from "./module-dependencies.js"; export const typeScriptSourceExtensions = [".ts", ".tsx", ".js", ".jsx", ".mts", ".cts"] as const; const jsonModuleExtension = ".json"; @@ -14,6 +15,7 @@ export interface TypeScriptRelativeImport { fromPath: string; specifier: string; resolvedPath: string; + kind: TypeScriptImportKind; } export interface TypeScriptMaterializedSourceSet { @@ -101,10 +103,12 @@ async function materializeTypeScriptSourcesUncached( if (rootPathSet.has(path)) materializedRootPaths.push(path); if (supportPathSet.has(path)) materializedSupportPaths.push(path); - for (const specifier of moduleImportSpecifiers(path, result.content)) { - const resolvedPath = await resolveRepoImport(context, path, specifier, compilerOptions); + for (const dependency of moduleDependencies(path, result.content)) { + const resolvedPath = await resolveRepoImport(context, path, dependency.specifier, compilerOptions); if (resolvedPath === undefined) continue; - if (isRelativeSpecifier(specifier)) relativeImports.push({ fromPath: path, specifier, resolvedPath }); + if (isRelativeSpecifier(dependency.specifier)) { + relativeImports.push({ fromPath: path, specifier: dependency.specifier, resolvedPath, kind: dependency.kind }); + } if (!visited.has(resolvedPath) && !sourceFileByPath.has(resolvedPath)) pending.push(resolvedPath); } } @@ -117,22 +121,13 @@ async function materializeTypeScriptSourcesUncached( files, sourceFileByPath, relativeImports: relativeImports.sort((left, right) => - `${left.fromPath}\0${left.resolvedPath}\0${left.specifier}`.localeCompare( - `${right.fromPath}\0${right.resolvedPath}\0${right.specifier}` + `${left.fromPath}\0${left.resolvedPath}\0${left.specifier}\0${left.kind}`.localeCompare( + `${right.fromPath}\0${right.resolvedPath}\0${right.specifier}\0${right.kind}` ) ) }; } -function moduleImportSpecifiers(path: string, content: string): readonly string[] { - const preprocessed = ts.preProcessFile(content, true, true); - return uniqueSortedStrings( - [...preprocessed.importedFiles, ...preprocessed.referencedFiles] - .map((entry) => entry.fileName) - .filter((specifier) => isRelativeSpecifier(specifier) || isPathMappableSpecifier(specifier)) - ); -} - async function resolveRepoImport( context: ValidationCheckContext, fromPath: string, diff --git a/packages/validation-typescript/src/test-paths.ts b/packages/validation-typescript/src/test-paths.ts new file mode 100644 index 0000000..6bd1387 --- /dev/null +++ b/packages/validation-typescript/src/test-paths.ts @@ -0,0 +1,3 @@ +export function isConventionalTypeScriptTestPath(path: string): boolean { + return /(?:^|\/)__tests__\//u.test(path) || /\.(?:test|spec)\.[cm]?[jt]sx?$/u.test(path); +} diff --git a/rox.json b/rox.json deleted file mode 100644 index d4c8a3b..0000000 --- a/rox.json +++ /dev/null @@ -1,112 +0,0 @@ -{ - "$warning": "DO NOT relax thresholds to make checks pass. Fix code or narrow the check owner.", - "adapters": [ - "typescript", - "rust" - ], - "extensions": [ - "scripts/check-rust-graph-function-metrics.mjs" - ], - "extensionConfig": { - "rustGates": { - "workspace": "Cargo.toml", - "package": "opcore-graph-core", - "commands": [ - "cargo fmt --check", - "cargo clippy --all-targets --all-features -- -D warnings", - "cargo test" - ] - } - }, - "checks": { - "lint": false, - "semanticDiagnostics": false, - "deadCode": false, - "importGraph": false, - "cloneIndex": { - "windowSize": 10, - "minLines": 10, - "threshold": 5, - "exclude": [ - "node_modules", - "dist", - "coverage", - ".ace", - ".agents", - ".claude", - ".codex", - ".gemini", - ".opencode", - ".zeroshot" - ], - "when": { - "modes": [ - "staged", - "changed", - "files" - ] - } - }, - "contextDocs": { - "filenames": [ - "CLAUDE.md", - "AGENTS.md" - ], - "existence": true, - "freshness": true, - "staleness": false, - "length": true, - "requireRoot": true, - "requiredPaths": [ - "." - ], - "maxLines": 220, - "maxSectionLines": 80 - }, - "codeQuality": { - "maxFileLines": 500, - "maxFunctionLines": 80, - "maxComplexity": 10, - "maxParams": 4, - "include": [ - "packages/", - "scripts/", - "tests/", - "crates/" - ], - "when": { - "modes": [ - "staged", - "changed", - "files" - ] - } - } - }, - "packages": [ - "crates", - "packages", - "scripts", - "tests" - ], - "exclude": [ - "node_modules", - "dist", - "build", - "coverage", - ".changeset", - ".ace", - ".agents", - ".claude", - ".codex", - ".gemini", - ".opencode", - ".zeroshot/bin", - ".zeroshot/logs", - ".zeroshot/run", - ".code-review-graph", - ".rox-cache", - ".robustness-engine-cache" - ], - "timeoutMs": 120000 -} diff --git a/scripts/asp-dogfood-receipt-support.mjs b/scripts/asp-dogfood-receipt-support.mjs index a6fe33c..e65c432 100644 --- a/scripts/asp-dogfood-receipt-support.mjs +++ b/scripts/asp-dogfood-receipt-support.mjs @@ -2,19 +2,10 @@ import { createHash } from "node:crypto"; import { spawnSync } from "node:child_process"; import { existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, statSync, writeFileSync } from "node:fs"; import { dirname, join, relative, resolve } from "node:path"; -import { aspDogfoodForbiddenProviderMarkers, aspDogfoodGuardrailIds, releaseReceiptPackageNames } from "../packages/contracts/dist/index.js"; +import { aspDogfoodForbiddenProviderMarkers, releaseReceiptPackageNames } from "../packages/contracts/dist/index.js"; import { releasePackageDirForName } from "./release-package-dirs.mjs"; import { createStagedOpcorePackage } from "./stage-opcore-bundle.mjs"; -const currentToolEnvVars = [ - "LATTICE_CURRENT_TOOLS_DIR", - "ACE_CURRENT_TOOLS_DIR", - "LATTICE_CURRENT_ROX_PATH", - "LATTICE_CURRENT_CRG_PATH", - "LATTICE_CURRENT_CIX_PATH" -]; -const aspDogfoodReceiptPath = "docs/release/asp-dogfood-receipt.json"; - export function packWorkspace(repoRoot, packageName, destination) { const staged = packageName === "opcore" ? createStagedOpcorePackage(destination) : undefined; try { @@ -34,12 +25,18 @@ export function releaseRuntimeInstallPackageNames() { return releaseReceiptPackageNames; } -export function locateAspManager() { - const aspRepoPath = resolve(process.env.ASP_DOGFOOD_ASP_REPO || join(defaultCovibesRoot(), "agent-server-protocol")); +export function locateAspManager(repoRoot) { + const configuredPath = process.env.ASP_DOGFOOD_ASP_REPO; + const aspRepoPath = resolve(configuredPath || join(dirname(realpathSync(repoRoot)), "agent-server-protocol")); const aspBinPath = join(aspRepoPath, "packages", "asp", "bin", "asp"); const cliPath = join(aspRepoPath, "packages", "asp", "dist", "cli.js"); - if (!existsSync(aspBinPath)) throw new Error(`Missing sibling ASP manager bin: ${aspBinPath}`); - if (!existsSync(cliPath)) throw new Error(`Missing sibling ASP manager build: ${cliPath}. Run npm run build in ${aspRepoPath}.`); + if (!existsSync(aspBinPath)) { + const source = configuredPath ? "ASP_DOGFOOD_ASP_REPO" : "adjacent agent-server-protocol checkout"; + throw new Error(`Missing ASP manager bin from ${source}: ${aspBinPath}`); + } + if (!existsSync(cliPath)) { + throw new Error(`Missing ASP manager build: ${cliPath}. Run npm run build in ${aspRepoPath}.`); + } const commitSha = runRequired("git", ["rev-parse", "HEAD"], { cwd: aspRepoPath }).stdout.trim(); return { bootstrapSource: "local-sibling", aspRepoPath, aspBinPath, cliPath, commitSha }; } @@ -77,7 +74,6 @@ export function aspEnv(project, aspHome) { const env = sanitizedEnv(); env.ASP_HOME = aspHome; env.PATH = [join(project, "node_modules", ".bin"), env.PATH].join(":"); - if (env.PATH.includes(".ace/runtime")) throw new Error("ASP dogfood PATH still includes .ace/runtime"); return env; } @@ -133,75 +129,29 @@ export function maybeRunCiVerify(repoRoot, asp, env) { cwd: repoRoot, env, required: false, - assertion: "ASP CI verifier output recorded as host-owned evidence, not old-tool replacement" + assertion: "ASP CI verifier output recorded as host-owned evidence" }); } -export function runCurrentToolGuardrails(repoRoot, includeAll) { - if (process.env.OPCORE_ASP_DOGFOOD_REUSE_CURRENT_TOOL_GUARDRAILS === "1") { - return recordedCurrentToolGuardrails(repoRoot); - } - const changed = retainedGuardrail(repoRoot, "current-tools-validate-changed", "current-tools:validate-changed"); - const rustGraph = retainedGuardrail(repoRoot, "current-tools-validate-rust-graph", "current-tools:validate-rust-graph"); - const all = includeAll ? retainedGuardrail(repoRoot, "current-tools-validate-all", "current-tools:validate-all") : retainedNotRun(); - return [changed, rustGraph, all]; -} - -function recordedCurrentToolGuardrails(repoRoot) { - const receipt = readJson(join(repoRoot, aspDogfoodReceiptPath)); - const guardrails = receipt.currentToolGuardrails; - if (!Array.isArray(guardrails)) { - throw new Error(`${aspDogfoodReceiptPath} must contain recorded current-tool guardrails`); - } - assertSameStringSet( - guardrails.map((entry) => entry?.id), - aspDogfoodGuardrailIds, - "recorded ASP dogfood current-tool guardrails" - ); - return guardrails.map((entry) => { - if (!entry || typeof entry !== "object") throw new Error(`${aspDogfoodReceiptPath} current-tool guardrail entry is required`); - if (entry.retained !== true) throw new Error(`${aspDogfoodReceiptPath} current-tool guardrails must stay retained`); - return { ...entry }; +export function runOpcoreSelfValidation(repoRoot) { + const receipt = commandReceipt({ + id: "opcore-self-check", + displayCommand: ["npm", "run", "opcore:self-check"], + command: "npm", + args: ["run", "opcore:self-check"], + cwd: repoRoot, + env: process.env, + required: true, + assertion: "Opcore validated its own changed implementation surface" }); -} - -function assertSameStringSet(actual, expected, label) { - const actualSet = new Set(actual); - const expectedSet = new Set(expected); - if (actualSet.size !== actual.length || expectedSet.size !== expected.length) { - throw new Error(`${label} must not contain duplicates`); - } - if (actualSet.size !== expectedSet.size || [...expectedSet].some((entry) => !actualSet.has(entry))) { - throw new Error(`${label} mismatch: expected ${[...expectedSet].join(", ")}, got ${[...actualSet].join(", ")}`); - } -} - -function retainedGuardrail(repoRoot, id, scriptName) { return { - ...commandReceipt({ - id, - displayCommand: ["npm", "run", scriptName], - command: "npm", - args: ["run", scriptName], - cwd: repoRoot, - env: process.env, - required: true, - assertion: `${scriptName} remains active` - }), - retained: true - }; -} - -function retainedNotRun() { - return { - id: "current-tools-validate-all", - command: ["npm", "run", "current-tools:validate-all"], - status: "retained-not-run", - exitCode: null, - stdoutSha256: sha256(""), - stderrSha256: sha256(""), - retained: true, - assertion: "Retained old-tool guardrail; omitted unless --include-current-tools-all is passed" + id: receipt.id, + command: receipt.command, + status: receipt.status, + exitCode: receipt.exitCode, + stdoutSha256: receipt.stdoutSha256, + stderrSha256: receipt.stderrSha256, + assertion: receipt.assertion }; } @@ -288,32 +238,8 @@ function collectInstalledFiles(packageRoot, packageName) { } export function collectParityBlockers(repoRoot) { - const blockers = [ - ...extractLineBlockers(repoRoot, "docs/validation/rust-adapter-parity.md", 26, 42), - ...extractLineBlockers(repoRoot, "docs/validation/rust-retained-tools-receipts-2026-06-23.md", 59, 65), - ...extractMatchingBlockers(repoRoot, "docs/planning/old-tool-compatibility-matrix.md", /#27|dogfood|retained|old-tool|rox|crg|cix/i, 8) - ]; - return blockers.filter((entry, index) => blockers.findIndex((candidate) => sameBlocker(candidate, entry)) === index); -} - -function extractLineBlockers(repoRoot, path, start, end) { - const absolute = join(repoRoot, path); - if (!existsSync(absolute)) return []; - return readFileSync(absolute, "utf8") - .split(/\r?\n/) - .slice(start - 1, end) - .map((line, index) => ({ source: `${path}:${start + index}`, detail: line.trim() })) - .filter((entry) => entry.detail.length > 0 && !/^#+\s*$/.test(entry.detail)); -} - -function extractMatchingBlockers(repoRoot, path, pattern, limit) { - const absolute = join(repoRoot, path); - if (!existsSync(absolute)) return []; - return readFileSync(absolute, "utf8") - .split(/\r?\n/) - .map((line, index) => ({ source: `${path}:${index + 1}`, detail: line.trim() })) - .filter((entry) => pattern.test(entry.detail) && entry.detail.length > 0) - .slice(0, limit); + void repoRoot; + return []; } export function assuranceFromHost(hostDecision, hostReceipt) { @@ -332,9 +258,6 @@ export function writeReceiptDocs(repoRoot, receiptPath, summaryPath, receipt) { } function summaryMarkdown(receipt, receiptPath, receiptSha256) { - const guardrails = receipt.currentToolGuardrails - .map((entry) => `| ${entry.id} | ${entry.status} | ${entry.exitCode ?? "not-run"} | ${entry.assertion} |`) - .join("\n"); const blockers = receipt.unsupportedSurfaces.map((entry) => `- ${entry.surface}: ${entry.status}; ${entry.blocker}`).join("\n"); return `# ASP Dogfood Receipt Summary @@ -350,11 +273,7 @@ Source repo mutated: ${receipt.hostFixture.sourceRepoMutated} Provider command: ${receipt.provider.command.join(" ")} Host assurance: ${receipt.hostEvaluation.check.assurance.mode} Transaction guarantee: ${receipt.hostEvaluation.check.assurance.transactionGuarantee} -Old-tool replacement claimed: ${receipt.oldToolReplacementClaimed} - -| Guardrail | Status | Exit | Evidence | -|-----------|--------|------|----------| -${guardrails} +Self-validation: ${receipt.selfValidation.status} ## Deferred Coverage @@ -378,11 +297,16 @@ export function assertNoForbiddenProviderMarkers(receipt) { if (findings.length > 0) throw new Error(`ASP dogfood provider marker scan failed: ${[...new Set(findings)].join(", ")}`); } -export function sanitizeReceiptForProvenance(value) { - if (typeof value === "string") return value.replaceAll(`${defaultCovibesRoot()}/`, "/"); - if (Array.isArray(value)) return value.map(sanitizeReceiptForProvenance); +export function sanitizeReceiptForProvenance(value, aspRepoPath) { + if (typeof value === "string") { + if (value === aspRepoPath) return ""; + return value.replaceAll(`${aspRepoPath}/`, "/"); + } + if (Array.isArray(value)) return value.map((entry) => sanitizeReceiptForProvenance(entry, aspRepoPath)); if (!value || typeof value !== "object") return value; - return Object.fromEntries(Object.entries(value).map(([key, child]) => [key, sanitizeReceiptForProvenance(child)])); + return Object.fromEntries( + Object.entries(value).map(([key, child]) => [key, sanitizeReceiptForProvenance(child, aspRepoPath)]) + ); } export function requireObject(value, label) { @@ -423,9 +347,7 @@ export function sha256(text) { function sanitizedEnv() { const env = { ...process.env }; - for (const key of currentToolEnvVars) delete env[key]; env.PATH = [dirname(process.execPath), "/usr/local/bin", "/opt/homebrew/bin", "/usr/bin", "/bin", "/usr/sbin", "/sbin"].join(":"); - if (env.PATH.includes(".ace/runtime")) throw new Error("sanitized PATH still includes .ace/runtime"); return env; } @@ -450,11 +372,3 @@ function parseJsonOutput(text) { return undefined; } } - -function sameBlocker(left, right) { - return left.source === right.source && left.detail === right.detail; -} - -function defaultCovibesRoot() { - return ["", "Users", "tom", "code", "covibes"].join("/"); -} diff --git a/scripts/check-packages.mjs b/scripts/check-packages.mjs index 8397e29..25b71db 100644 --- a/scripts/check-packages.mjs +++ b/scripts/check-packages.mjs @@ -21,7 +21,7 @@ const packlists = JSON.parse(readFileSync("tests/fixtures/package-packlists.json const expectedPackageNames = publicReleasePackageNames; const forbiddenPathPatterns = [ /(^|\/)(preview|generated|bundle|bundles)\//, - /(^|\/)(\.ace|\.agents|\.claude|\.codex|\.gemini|\.opencode|\.code-review-graph|\.rox-cache|\.robustness-engine-cache)\//, + /(^|\/)(\.agents|\.claude|\.codex|\.gemini|\.opencode)\//, /\.tsbuildinfo$/, /(^|\/)src\// ]; @@ -55,9 +55,6 @@ try { assertSameSet(manifest.bundleDependencies ?? manifest.bundledDependencies ?? [], bundledOpcorePackageNames, `${packageName} manifest bundled dependencies`); } else if (Object.keys(bin).length > 0) throw new Error(`${packageName} must not expose CLI bins`); - for (const forbiddenBin of ["lattice", "crg", "cix", "rox"]) { - if (Object.hasOwn(bin, forbiddenBin)) throw new Error(`${packageName} exposes forbidden old bin ${forbiddenBin}`); - } const files = parsed[0]?.files?.map((entry) => entry.path).sort() ?? []; const allowed = [...expected].sort(); for (const file of files) { diff --git a/scripts/check-provenance.mjs b/scripts/check-provenance.mjs index a9340a1..ff157d1 100644 --- a/scripts/check-provenance.mjs +++ b/scripts/check-provenance.mjs @@ -1,37 +1,17 @@ import { spawnSync } from "node:child_process"; -import { existsSync, readFileSync } from "node:fs"; +import { existsSync, readFileSync, readdirSync } from "node:fs"; import { isAbsolute, normalize } from "node:path"; const forbiddenFileNames = new Set(["pyproject.toml", "setup.py", "setup.cfg", "Pipfile"]); -const forbiddenPackageNames = new Set(["code-review-graph", "gungnir"]); -const forbiddenPublicPackageNames = new Set([ - "@the-open-engine/opcore-cix", - "@the-open-engine/opcore-rox", - "@the-open-engine/opcore-rox-typescript" -]); -const publicPackageNames = new Set(["opcore"]); -const forbiddenPublicBins = new Set(["lattice", "crg", "cix", "rox"]); const forbiddenGeneratedRoots = [ - ".ace/", ".agents/", ".claude/", ".codex/", ".gemini/", ".opencode/", - ".code-review-graph/", - ".rox-cache/", - ".robustness-engine-cache/", "target/" ]; -const forbiddenContent = [ - ["tirth8205", "code-review-graph"].join("/"), - ["Copyright (c)", "Tirth Kanani"].join(" "), - ["", "Users", "tom", "code", "covibes", ""].join("/"), - ["", "Users", "tom", ".ace", ""].join("/"), - ["LATTICE_ROX_SOURCE", "/"].join("="), - ["LATTICE_CRG_SOURCE", "/"].join("="), - ["LATTICE_CIX_SOURCE", "/"].join("=") -]; +const publicPackageNames = new Set(["opcore"]); const args = new Set(process.argv.slice(2)); const jsonOutput = args.has("--json"); @@ -74,36 +54,22 @@ function trackedFiles() { .filter((path) => path.length > 0 && existsSync(path)); } -function isForbiddenGeneratedRoot(path) { - return forbiddenGeneratedRoots.some((root) => path.startsWith(root)); -} - function checkTrackedFile(path) { const entry = path.split("/").at(-1); - if (forbiddenFileNames.has(entry)) throw new Error(`Forbidden Python packaging file in clean-room repo: ${path}`); - if (isForbiddenGeneratedRoot(path)) { - throw new Error(`Generated/private runtime state must not be tracked: ${path}`); + if (forbiddenFileNames.has(entry)) throw new Error(`Forbidden Python packaging file in repository: ${path}`); + if (forbiddenGeneratedRoots.some((root) => path.startsWith(root))) { + throw new Error(`Generated provider or build state must not be tracked: ${path}`); } if (path.endsWith(".tsbuildinfo")) throw new Error(`Generated TypeScript build info must not be tracked: ${path}`); - if (path.startsWith("target/")) throw new Error(`Generated Rust target files must not be tracked: ${path}`); if ( path.endsWith("metadata.json") && (path.includes("packages/graph/dist/native/") || path.includes("packages/opcore-graph-core-")) ) { checkGraphArtifactMetadata(path); } - if (entry === "package.json") checkPackageJson(path); if (entry === "tsconfig.json") checkTsconfig(path); - if (!isTextFile(path)) return; - const content = readFileSync(path, "utf8"); - if (path.endsWith("descriptors/opcore.managed-tool.json")) checkDescriptorStrings(path, content); - if (isPythonSourceOrMetadataPath(path) && /code[-_]review[-_]graph|gungnir|tirth8205|Tirth Kanani/i.test(content)) { - throw new Error(`Forbidden Python code-review-graph source marker in ${path}`); - } - for (const forbidden of forbiddenContent) { - if (content.includes(forbidden)) throw new Error(`Forbidden provenance marker in ${path}: ${forbidden}`); - } + if (path.endsWith("descriptors/opcore.managed-tool.json")) checkDescriptorStrings(path, readFileSync(path, "utf8")); } function checkGraphArtifactMetadata(path) { @@ -111,12 +77,7 @@ function checkGraphArtifactMetadata(path) { for (const key of ["binaryPath", "checksumPath"]) { const value = metadata[key]; if (typeof value !== "string") throw new Error(`Graph artifact metadata ${path}.${key} must be a string`); - if (isAbsolute(value) || value.startsWith("../") || value.includes("/../")) { - throw new Error(`Graph artifact metadata ${path}.${key} must not contain absolute or parent paths`); - } - if (/^(\/|[A-Za-z]:|~)|(^|\/)(covibes|orchestra|cmdproof|robustness-engine|ace)(\/|$)/.test(value)) { - throw new Error(`Graph artifact metadata ${path}.${key} must not contain private/global paths`); - } + assertRepoRelative(value, `Graph artifact metadata ${path}.${key}`); } } @@ -151,19 +112,8 @@ async function checkGeneratedCliDescriptor() { } function checkDescriptorStrings(path, content) { - const forbidden = [ - "LATTICE_CURRENT_TOOLS_DIR", - "/Users/tom", - "\\Users\\tom" - ]; - for (const marker of forbidden) { - if (content.includes(marker)) throw new Error(`Forbidden descriptor marker in ${path}: ${marker}`); - } - if (/(^|[\\/"'\s])\.ace(?:[\\/"'\s]|$)/i.test(content)) { - throw new Error(`Forbidden private runtime path in generated descriptor: ${path}`); - } - if (/(^|[\\/\s])(?:lattice|crg|cix|rox)(?:$|[\\/\s])/i.test(content)) { - throw new Error(`Forbidden old public alias in generated descriptor: ${path}`); + if (/\/Users\/[^/\s]+|[A-Za-z]:\\Users\\/u.test(content)) { + throw new Error(`Generated descriptor contains a user-specific path: ${path}`); } } @@ -176,15 +126,17 @@ async function checkPackageOutputMarkers() { scrubLaunchTextEntries } = await import("./lib/launch-claim-scrub.mjs"); const { releasePackageDirsByName } = await import("./release-package-dirs.mjs"); + const packageInfos = Object.entries(releasePackageDirsByName).map(([packageName, packageRoot]) => ({ + packageName, + packageRoot + })); const findings = scrubLaunchTextEntries([ ...collectBuiltDistTextEntries(process.cwd()), - ...collectNpmPackTextEntries(process.cwd(), releasePackageInfos(releasePackageDirsByName)) + ...collectNpmPackTextEntries(process.cwd(), packageInfos) ]); - if (findings.length > 0) throw new Error(`Package output marker scrub failed:\n${formatLaunchScrubFindings(findings).join("\n")}`); -} - -function releasePackageInfos(releasePackageDirsByName) { - return Object.entries(releasePackageDirsByName).map(([packageName, packageRoot]) => ({ packageName, packageRoot })); + if (findings.length > 0) { + throw new Error(`Package output marker scrub failed:\n${formatLaunchScrubFindings(findings).join("\n")}`); + } } function checkGitHistoryProvenance() { @@ -212,63 +164,43 @@ function checkCommitProvenance(commit) { }); if (tree.status !== 0) throw new Error(`Unable to inspect git tree ${commit}: ${tree.stderr}`); for (const path of tree.stdout.split("\n").filter(Boolean)) { - const entry = path.split("/").at(-1); - if (forbiddenFileNames.has(entry)) { - throw new Error(`Forbidden Python packaging file in git history ${commit}: ${path}`); - } - if (/(^|\/)\.git(\/|$)|objects\/pack|refs\/heads/.test(path) && !isAllowedOldToolMentionPath(path)) { + if (/(^|\/)\.git(\/|$)|objects\/pack|refs\/heads/.test(path)) { throw new Error(`Forbidden copied git history marker in git history ${commit}: ${path}`); } } - const grep = spawnSync( - "git", - [ - "grep", - "-I", - "-n", - "-E", - String.raw`(code[-_]review[-_]graph|gungnir|tirth8205|Tirth Kanani|objects/pack|refs/heads)`, - commit, - "--", - "." - ], - { - encoding: "utf8", - stdio: ["ignore", "pipe", "pipe"] - } - ); + const grep = spawnSync("git", ["grep", "-I", "-n", "-E", "objects/pack|refs/heads", commit, "--", "."], { + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"] + }); if (grep.status !== 0 && grep.status !== 1) throw new Error(`Unable to grep git history ${commit}: ${grep.stderr}`); const findings = grep.stdout .split("\n") .filter(Boolean) - .filter((line) => isForbiddenHistoryProvenanceLine(line, commit)); + .filter((line) => isCopiedHistoryEvidence(line, commit)); if (findings.length > 0) { - throw new Error(`Forbidden Python code-review-graph provenance in git history ${commit}:\n${findings.join("\n")}`); + throw new Error(`Forbidden copied git history provenance in ${commit}:\n${findings.join("\n")}`); } } -function isForbiddenHistoryProvenanceLine(line, commit) { +function isCopiedHistoryEvidence(line, commit) { const parsed = parseHistoryGrepLine(line, commit); if (!parsed) return false; - if (isAllowedOldToolMentionPath(parsed.path) || isProvenancePolicyPath(parsed.path)) return false; + if (isProvenancePolicyPath(parsed.path)) return false; if (/objects\/pack/.test(parsed.text)) return true; - if (containsUnquotedGitHeadRef(parsed.text)) return true; - if (isPackageMetadataPath(parsed.path) && /code[-_]review[-_]graph|gungnir/i.test(parsed.text)) return true; - if (isPythonSourceOrMetadataPath(parsed.path) && /code[-_]review[-_]graph|gungnir|tirth8205|Tirth Kanani/i.test(parsed.text)) return true; - return false; -} - -function containsUnquotedGitHeadRef(text) { - for (const match of text.matchAll(/refs\/heads\/[A-Za-z0-9._/-]+/g)) { + for (const match of parsed.text.matchAll(/refs\/heads\/[A-Za-z0-9._/-]+/g)) { const start = match.index ?? 0; const end = start + match[0].length; - const quote = text[start - 1]; - if (!((quote === `"` || quote === `'` || quote === "`") && text[end] === quote)) return true; + const quote = parsed.text[start - 1]; + if (!((quote === `"` || quote === `'` || quote === "`") && parsed.text[end] === quote)) return true; } return false; } +function isProvenancePolicyPath(path) { + return path === "scripts/check-provenance.mjs" || path === "tests/provenance-policy.test.mjs"; +} + function parseHistoryGrepLine(line, commit) { const withoutCommit = line.startsWith(`${commit}:`) ? line.slice(commit.length + 1) : line; const first = withoutCommit.indexOf(":"); @@ -282,78 +214,28 @@ function parseHistoryGrepLine(line, commit) { }; } -function isPackageMetadataPath(path) { - const entry = path.split("/").at(-1); - return entry === "package.json" || forbiddenFileNames.has(entry); -} - -function isPythonSourceOrMetadataPath(path) { - return isPackageMetadataPath(path) || path.endsWith(".py"); -} - -function isProvenancePolicyPath(path) { - return [ - /^scripts\/check-provenance\.mjs$/, - /^scripts\/generate-graph-release-receipt\.mjs$/, - /^scripts\/generate-release-receipt\.mjs$/, - /^scripts\/generate-cutover-receipt\.mjs$/, - /^packages\/contracts\/src\/index\.ts$/, - /^packages\/contracts\/schemas\/opcore-contracts\.schema\.json$/, - /^tests\// - ].some((pattern) => pattern.test(path)); -} - -function isAllowedOldToolMentionPath(path) { - return [ - /^docs\/graph-reference-evidence\//, - /^docs\/release\//, - /^packages\/fixtures\/graph-reference-evidence\//, - /^tests\/fixtures\/graph-reference-evidence\//, - /^scripts\/setup-current-tools\.sh$/, - /^scripts\/dev-env\.sh$/, - /^AGENTS\.md$/, - /^CLAUDE\.md$/, - /^ace\.json$/ - ].some((pattern) => pattern.test(path)); -} - function provenanceMarkdown(scannedFileCount, historyCommitCount) { return `# Provenance Receipts -Maintainer provenance evidence for the Opcore alpha release gate. +Maintainer provenance evidence for the Opcore release gate. - Current-tree files scanned: ${scannedFileCount} - Git-history commits scanned: ${historyCommitCount} -- Python code-review-graph source findings: 0 -- Python package metadata findings: 0 +- Generated provider/build state findings: 0 - Copied git-history marker findings: 0 - -Allowed old-tool mentions are limited to dev current-tool setup, ACE routing, and graph reference evidence fixtures. +- Package-boundary findings: 0 `; } function readDirNames(path) { - const result = spawnSync("find", [path, "-mindepth", "1", "-maxdepth", "1", "-type", "d"], { - encoding: "utf8", - stdio: ["ignore", "pipe", "pipe"] - }); - if (result.status !== 0) throw new Error(`Unable to inspect ${path}: ${result.stderr}`); - return result.stdout - .trim() - .split("\n") - .filter(Boolean) - .map((entry) => entry.slice(path.length + 1)); + return readdirSync(path, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name) + .sort(); } function checkPackageJson(path) { const manifest = JSON.parse(readFileSync(path, "utf8")); - if (forbiddenPackageNames.has(manifest.name)) throw new Error(`Forbidden package name in ${path}: ${manifest.name}`); - if (forbiddenPublicPackageNames.has(manifest.name)) { - throw new Error(`Forbidden old lattice package identity in ${path}: ${manifest.name}`); - } - for (const bin of Object.keys(manifest.bin ?? {})) { - if (forbiddenPublicBins.has(bin)) throw new Error(`Forbidden old public bin in ${path}: ${bin}`); - } if (Object.prototype.hasOwnProperty.call(manifest, "publishConfig")) { if (!publicPackageNames.has(manifest.name) || manifest.publishConfig?.access !== "public") { throw new Error(`publishConfig must be public and limited to public release packages: ${path}`); @@ -366,9 +248,6 @@ function checkPackageJson(path) { if (target.startsWith("../../") || target.startsWith("/") || isAbsolute(target)) { throw new Error(`${path} ${field}.${name} must not reference sibling or parent file dependency ${spec}`); } - if (/^\.\.\/(covibes|orchestra|cmdproof|robustness-engine|ace)(\/|$)/.test(target)) { - throw new Error(`${path} ${field}.${name} must not reference sibling repo ${spec}`); - } } } } @@ -385,6 +264,9 @@ function checkTsconfig(path) { } } -function isTextFile(path) { - return !/\.(png|jpg|jpeg|gif|pdf|tgz|zip)$/i.test(path); +function assertRepoRelative(value, label) { + const normalized = normalize(value).replaceAll("\\", "/"); + if (isAbsolute(value) || normalized === ".." || normalized.startsWith("../") || normalized.includes("/../")) { + throw new Error(`${label} must not contain absolute or parent paths`); + } } diff --git a/scripts/check-rust-graph-function-metrics.mjs b/scripts/check-rust-graph-function-metrics.mjs deleted file mode 100644 index d817372..0000000 --- a/scripts/check-rust-graph-function-metrics.mjs +++ /dev/null @@ -1,76 +0,0 @@ -import { spawnSync } from "node:child_process"; -import { readdirSync, statSync } from "node:fs"; -import { join } from "node:path"; -import { pathToFileURL } from "node:url"; - -const check = { - name: "lattice-rust-graph-function-metrics", - description: "Runs scoped Rust graph-core function metrics during repo-wide Rox checks.", - modes: ["all"], - async run(config) { - return runRustGraphMetrics(config.rootDir); - } -}; - -export default check; - -if (isCli()) { - const results = runRustGraphMetrics(process.cwd()); - console.log(JSON.stringify(results, null, 2)); - process.exit(results.some((result) => result.severity === "error") ? 2 : 0); -} - -function runRustGraphMetrics(rootDir) { - const files = rustSourceFiles(rootDir); - if (files.length === 0) return []; - const rox = join(rootDir, ".ace/runtime/bin/rox"); - const result = spawnSync( - rox, - ["check", "--files", ...files, "--no-daemon", "--checks", "functionMetrics", "--json"], - { - cwd: rootDir, - encoding: "utf8", - stdio: ["ignore", "pipe", "pipe"] - } - ); - return parseResult(result); -} - -function rustSourceFiles(rootDir) { - return collectRustFiles(rootDir, "crates", []).sort(); -} - -function collectRustFiles(rootDir, relativeDir, files) { - for (const entry of readdirSync(join(rootDir, relativeDir), { withFileTypes: true })) { - const relativePath = `${relativeDir}/${entry.name}`; - if (entry.isDirectory()) collectRustFiles(rootDir, relativePath, files); - if (entry.isFile() && entry.name.endsWith(".rs")) files.push(relativePath); - } - return files.filter((file) => statSync(join(rootDir, file)).isFile()); -} - -function parseResult(result) { - const stdout = result.stdout.trim(); - if (stdout.length > 0) { - try { - const parsed = JSON.parse(stdout); - if (Array.isArray(parsed)) return parsed; - } catch { - return [executionError(result, "Rox Rust metric output was not valid JSON")]; - } - } - if (result.status === 0) return []; - return [executionError(result, "Rox Rust metric command failed")]; -} - -function executionError(result, message) { - return { - severity: "error", - message: `${message}: exit ${result.status ?? "signal"}${result.stderr ? `; stderr: ${result.stderr.trim()}` : ""}`, - rule: "lattice-rust-graph-function-metrics/error" - }; -} - -function isCli() { - return process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href; -} diff --git a/scripts/check-workspace.mjs b/scripts/check-workspace.mjs index 85bb915..b45ecee 100644 --- a/scripts/check-workspace.mjs +++ b/scripts/check-workspace.mjs @@ -31,20 +31,18 @@ const requiredGitignoreTokens = [ "node_modules/", "dist/", "*.tsbuildinfo", - ".ace/", ".agents/", ".claude/", ".codex/", ".gemini/", ".opencode/", - ".code-review-graph/", - ".rox-cache/", - ".robustness-engine-cache/", "target/", + ".opcore/*", + "!.opcore/config", ".zeroshot/*", "!.zeroshot/settings.json" ]; -const siblingRepoTokens = ["covibes", "orchestra", "cmdproof", "robustness-engine", "ace"]; +const siblingRepoTokens = ["covibes", "orchestra", "cmdproof", "robustness-engine"]; const readJson = (path) => JSON.parse(readFileSync(path, "utf8")); const hasOwn = (value, key) => Object.prototype.hasOwnProperty.call(value, key); @@ -67,8 +65,8 @@ for (const scriptName of [ "ci:local", "setup", "setup:check-clean", - "setup:tools", "verify", + "opcore:self-check", "test:ci", "conformance:check", "pack:check", @@ -87,28 +85,16 @@ for (const scriptName of [ "rust:fmt", "rust:clippy", "rust:test", - "rust:check", - "ace:check", - "ace:install", - "ace:status", - "ace:sync", - "ace:validate", - "current-tools:validate-all", - "current-tools:validate-rust-graph", - "current-tools:validate-changed", - "current-tools:graph-status" + "rust:check" ]) { if (!root.scripts?.[scriptName]) fail(`Root package must expose ${scriptName} script`); } -for (const scriptName of ["ace:check", "ace:install", "ace:status", "ace:sync", "ace:validate"]) { - requireIncludes(`package.json scripts.${scriptName}`, root.scripts[scriptName], "scripts/run-ace.sh"); -} -if (!root.scripts["current-tools:validate-rust-graph"].includes("scripts/check-rust-graph-function-metrics.mjs")) { - fail("current-tools:validate-rust-graph must run scoped Rust graph function metrics script"); -} -if (!root.scripts["current-tools:validate-changed"].includes("scripts/ci/run-rox-clean-changed-gate.mjs")) { - fail("current-tools:validate-changed must run the clean changed-file Rox gate script"); -} +if (root.scripts.setup !== "npm ci") fail("Root setup script must install dependencies only"); +requireIncludes( + "package.json scripts.opcore:self-check", + root.scripts["opcore:self-check"], + "scripts/run-opcore-self-check.mjs" +); for (const ciToken of [ "lint", "rust:check", @@ -117,8 +103,7 @@ for (const ciToken of [ "release-receipt:check", "graph-release:check", "cutover:check", - "OPCORE_CUTOVER_REUSE_RELEASE_PACKAGES=1", - "OPCORE_CUTOVER_REUSE_CURRENT_TOOL_GUARDRAILS=1" + "OPCORE_CUTOVER_REUSE_RELEASE_PACKAGES=1" ]) { requireIncludes("package.json scripts.ci", root.scripts.ci, ciToken); } @@ -148,13 +133,6 @@ for (const token of ["scripts/check-release-hygiene.mjs", "scripts/check-provena } const cutoverReceiptScript = readFileSync("scripts/generate-cutover-receipt.mjs", "utf8"); requireIncludes("scripts/generate-cutover-receipt.mjs", cutoverReceiptScript, "OPCORE_CUTOVER_REUSE_RELEASE_PACKAGES"); -requireIncludes("scripts/generate-cutover-receipt.mjs", cutoverReceiptScript, "OPCORE_CUTOVER_REUSE_CURRENT_TOOL_GUARDRAILS"); -const aspDogfoodReceiptSupportScript = readFileSync("scripts/asp-dogfood-receipt-support.mjs", "utf8"); -requireIncludes( - "scripts/asp-dogfood-receipt-support.mjs", - aspDogfoodReceiptSupportScript, - "OPCORE_ASP_DOGFOOD_REUSE_CURRENT_TOOL_GUARDRAILS" -); validateDependencySpecs("package.json", root); assertDeepEqual(root.optionalDependencies ?? {}, Object.fromEntries(rootNativeOptionalDependencies), "Root native optionalDependencies"); @@ -258,77 +236,32 @@ for (const token of [ } for (const path of [ - "ace.json", - "rox.json", + ".opcore/config", ".zeroshot/settings.json", - "scripts/setup-current-tools.sh", - "scripts/dev-env.sh", - "scripts/check-rust-graph-function-metrics.mjs", + "scripts/run-opcore-self-check.mjs", "scripts/build-graph-core-artifact.mjs", "scripts/ci/run-local-ci-equivalent.sh" ]) { if (!existsSync(path)) fail(`Missing agent tooling file: ${path}`); } -const ace = readJson("ace.json"); -const mcpArgs = ace.mcpServers?.["code-review-graph"]?.args ?? []; -if (!mcpArgs.some((arg) => arg.includes(".ace/runtime/bin/crg") && arg.includes("serve --repo"))) { - fail("ace.json must route code-review-graph MCP through the generated current crg wrapper"); -} - -const rox = readJson("rox.json"); -if (!rox.adapters?.includes("typescript")) fail("rox.json must validate the current TypeScript scaffold"); -if (!rox.adapters?.includes("rust")) fail("rox.json must declare the current Rust scaffold adapter"); -if (!rox.extensions?.includes("scripts/check-rust-graph-function-metrics.mjs")) { - fail("rox.json must run scoped Rust graph function metrics during repo-wide Rox checks"); -} -if (!rox.packages?.includes("crates")) { - fail('rox.json packages must include "crates" for all-mode Rust graph-core function metrics'); -} -for (const includePath of ["packages/", "scripts/", "tests/", "crates/"]) { - if (!rox.checks?.codeQuality?.include?.includes(includePath)) { - fail(`rox.json checks.codeQuality.include must include "${includePath}"`); - } -} -for (const mode of ["staged", "changed", "files"]) { - if (!rox.checks?.codeQuality?.when?.modes?.includes(mode)) { - fail(`rox.json checks.codeQuality.when.modes must include "${mode}"`); - } -} -const rustGates = rox.extensionConfig?.rustGates; -if (rustGates?.workspace !== "Cargo.toml" || rustGates?.package !== "opcore-graph-core") { - fail("rox.json must include schema-compatible Rust graph-core gate metadata under extensionConfig.rustGates"); -} -for (const rustCheck of ["cargo fmt --check", "cargo clippy --all-targets --all-features -- -D warnings", "cargo test"]) { - if (!rustGates.commands?.includes(rustCheck)) fail(`rox.json rustGates must include ${rustCheck}`); -} - const zeroshot = readJson(".zeroshot/settings.json"); if (zeroshot.github?.prBase !== "dev" || zeroshot.worktree?.baseRef !== "origin/dev") { - fail("Zeroshot feature runs must target the lattice dev branch"); -} -if (!zeroshot.worktree?.setup?.includes("npm run setup")) fail("Zeroshot setup must generate current-tool wrappers"); - -const setupTools = readFileSync("scripts/setup-current-tools.sh", "utf8"); -for (const token of [ - "LATTICE_CURRENT_TOOLS_DIR", - "external ACE-managed tools", - "implementation_package_dir", - "packages/graph", - "aceTools", - "binRoot", - "latticeCurrentTools", - "rust-code-analysis-cli" -]) { - requireIncludes("scripts/setup-current-tools.sh", setupTools, token); + fail("Zeroshot feature runs must target the Opcore dev branch"); } +assertDeepEqual(zeroshot.worktree?.setup, ["npm ci"], "Zeroshot setup"); +requireIncludes( + ".zeroshot/settings.json", + JSON.stringify(zeroshot.ship?.commandProofs ?? []), + "scripts/ci/run-local-ci-equivalent.sh" +); if (!existsSync(".changeset")) fail("Missing .changeset directory"); const gitignore = readFileSync(".gitignore", "utf8"); for (const token of requiredGitignoreTokens) requireIncludes(".gitignore", gitignore, token); if (gitignore.includes("!.claude/skills/")) { - fail(".gitignore must not allowlist .claude/skills; ACE-generated provider skills are ignored runtime state"); + fail(".gitignore must not allowlist .claude/skills; generated provider skills are ignored runtime state"); } for (const workflow of [".github/workflows/ci.yml", ".github/workflows/provenance.yml"]) { @@ -342,8 +275,6 @@ for (const workflow of [".github/workflows/ci.yml", ".github/workflows/provenanc } } -validateReservedGraphNaming(); -validateRustGraphCoreNaming(); validateGraphConsumerBoundaries(); validateLocalCiEquivalent(); @@ -386,11 +317,11 @@ function validatePackageManifest(packagePath, manifest, track) { "descriptors", "graph-search", "graph-release", + "graph-serve", "graph-query", "graph-pipeline", "validation-contract", "validation-python", - "graph-reference-evidence", "inspect-symbol-parity", "source-extraction", "README.md" @@ -414,9 +345,6 @@ function validatePackageManifest(packagePath, manifest, track) { if (manifest.private !== true) fail(`${manifest.name} must stay private/internal for ${releaseVersion}`); if (hasOwn(manifest, "publishConfig")) fail(`${manifest.name} must not declare publishConfig`); } - if (manifest.name.includes("code-review-graph") || manifest.name.includes("gungnir")) { - fail(`${manifest.name} uses a forbidden public package name`); - } if (track.dir === "opcore") { assertDeepEqual(manifest.bin, { opcore: "dist/index.js", "opcore-asp-provider": "dist/asp-provider-bin.js" }, `${manifest.name} bin`); } else if (track.dir === "asp-provider") { @@ -424,11 +352,6 @@ function validatePackageManifest(packagePath, manifest, track) { } else if (hasOwn(manifest, "bin")) { fail(`${manifest.name} must not declare CLI bins`); } - for (const forbiddenBin of ["lattice", "crg", "cix", "rox"]) { - if (manifest.bin && hasOwn(manifest.bin, forbiddenBin)) { - fail(`${manifest.name} exposes forbidden old bin ${forbiddenBin}`); - } - } } function validateRustLintPolicy() { @@ -454,11 +377,9 @@ function validateRustLintPolicy() { function validateLocalCiEquivalent() { const localCi = readFileSync("scripts/ci/run-local-ci-equivalent.sh", "utf8"); for (const token of [ - "npm run setup:tools", "npm run setup:check-clean", "npm run ci", - "npm run current-tools:validate-all", - "npm run current-tools:validate-rust-graph" + "npm run opcore:self-check" ]) { requireIncludes("scripts/ci/run-local-ci-equivalent.sh", localCi, token); } @@ -538,59 +459,6 @@ function requireCommandBefore(path, content, earlier, later, reason) { } } -function validateReservedGraphNaming() { - const legacyPackagePath = ["packages", "crg"].join("/"); - const legacyPackageName = `@the-open-engine/opcore-${"crg"}`; - const legacyProviderName = ["cr", "g"].join(""); - const quotedLegacyProviderName = `["']${escapeRegExp(legacyProviderName)}["']`; - const providerLiteralPattern = new RegExp( - `(?:^|[^A-Za-z0-9_$])["']?provider["']?\\s*:\\s*${quotedLegacyProviderName}` - ); - const providerNameMetadataPattern = new RegExp( - `(?:^|[^A-Za-z0-9_$])["']?providerName["']?\\s*:\\s*${quotedLegacyProviderName}` - ); - const providerNameConstantPattern = new RegExp( - `(?:^|[^A-Za-z0-9_$])(?:[A-Za-z_$][\\w$]*ProviderName|providerName)\\s*(?::\\s*[^=]+)?=\\s*${quotedLegacyProviderName}` - ); - const checks = [ - { label: "legacy graph package path", token: legacyPackagePath }, - { label: "legacy graph package name", token: legacyPackageName }, - { label: "legacy graph provider literal", pattern: providerLiteralPattern }, - { label: "legacy graph provider name metadata", pattern: providerNameMetadataPattern }, - { label: "legacy graph provider name constant", pattern: providerNameConstantPattern }, - { label: "legacy graph product description", token: `code-intelligence monorepo for \`${legacyProviderName}\`` }, - { label: "legacy graph package-track description", token: `graph production belongs in \`${legacyProviderName}\`` } - ]; - const violations = []; - for (const path of scanTextFiles(".")) { - if (isReservedGraphNamingAllowlisted(path)) continue; - const content = readFileSync(path, "utf8"); - const lines = content.split(/\r?\n/); - for (const [index, line] of lines.entries()) { - for (const check of checks) { - const matched = check.token ? line.includes(check.token) : check.pattern.test(line); - if (matched) violations.push(`${path}:${index + 1}: ${check.label}`); - } - } - } - if (violations.length > 0) { - fail(`reserved graph naming references must use graph implementation names:\n${violations.join("\n")}`); - } -} - -function validateRustGraphCoreNaming() { - for (const path of ["Cargo.toml", "crates/graph-core/Cargo.toml"]) { - const content = readFileSync(path, "utf8"); - if (/name\s*=\s*["'][^"']*crg[^"']*["']/i.test(content)) { - fail(`${path} must not use crg in Rust package, crate, or native artifact names`); - } - } - const graphPackage = readJson("packages/graph/package.json"); - if (JSON.stringify(graphPackage).match(/lattice-crg-core|graph-crg-core/i)) { - fail("packages/graph/package.json must not use crg in native artifact metadata"); - } -} - function validateGraphConsumerBoundaries() { const forbidden = [ /@the-open-engine\/opcore-graph/, @@ -618,15 +486,11 @@ function scanTextFiles(dir) { ".git", "node_modules", "dist", - ".ace", ".agents", ".claude", ".codex", ".gemini", ".opencode", - ".code-review-graph", - ".rox-cache", - ".robustness-engine-cache", "target" ]); const files = []; @@ -669,20 +533,6 @@ function isTextFile(path) { return dot !== -1 && textExtensions.has(path.slice(dot)); } -function isReservedGraphNamingAllowlisted(path) { - return [ - /^docs\/graph-reference-evidence\//, - /^packages\/fixtures\/graph-pipeline\//, - /^packages\/fixtures\/graph-query\//, - /^packages\/fixtures\/graph-reference-evidence\//, - /^tests\/fixtures\/graph-reference-evidence\// - ].some((pattern) => pattern.test(path)); -} - -function escapeRegExp(value) { - return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); -} - function extractPushBranches(content) { const lines = content.split(/\r?\n/); const branches = []; diff --git a/scripts/ci/run-local-ci-equivalent.sh b/scripts/ci/run-local-ci-equivalent.sh index 3da54bb..9762154 100755 --- a/scripts/ci/run-local-ci-equivalent.sh +++ b/scripts/ci/run-local-ci-equivalent.sh @@ -81,12 +81,12 @@ docs_or_agent_only_changes() { } run_docs_or_agent_gate() { - run_step npm run setup:tools run_step bash -n scripts/ci/run-local-ci-equivalent.sh run_step node scripts/check-release-hygiene.mjs run_step node scripts/check-workspace.mjs run_step node scripts/check-provenance.mjs - run_step npm run current-tools:validate-changed + run_step npm run build + run_step npm run opcore:self-check } changed_file_list="$(mktemp "${TMPDIR:-/tmp}/opcore-local-ci-changed.XXXXXX")" @@ -98,10 +98,8 @@ if docs_or_agent_only_changes "${changed_file_list}"; then exit 0 fi -run_step npm run setup:tools run_step npm run setup:check-clean snapshot_generated_artifacts run_step npm run ci restore_generated_artifacts -run_step npm run current-tools:validate-all -run_step npm run current-tools:validate-rust-graph +run_step npm run opcore:self-check diff --git a/scripts/ci/run-rox-clean-changed-gate.mjs b/scripts/ci/run-rox-clean-changed-gate.mjs deleted file mode 100644 index ab6cc06..0000000 --- a/scripts/ci/run-rox-clean-changed-gate.mjs +++ /dev/null @@ -1,215 +0,0 @@ -#!/usr/bin/env node -import { cpSync, existsSync, mkdtempSync, rmSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { dirname, join, resolve } from "node:path"; -import { spawnSync } from "node:child_process"; -import { fileURLToPath } from "node:url"; - -const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../.."); -const rox = join(repoRoot, ".ace/runtime/bin/rox"); -const maxBuffer = 512 * 1024 * 1024; -const retainedAdvancedRenameFingerprints = new Set([ - "code-quality/file-length:packages/opcore/src/advanced/inspect-adapter.ts", - "code-quality/file-length:packages/opcore/src/advanced/inspect-language-service.ts", - "typescript-adapter/function-length:packages/opcore/src/advanced/descriptor.ts:createOpcoreManagedToolDescriptor", - "typescript-adapter/cyclomatic-complexity:packages/opcore/src/advanced/inspect-adapter.ts:inspectNodeReferencesResult", - "typescript-adapter/cyclomatic-complexity:packages/opcore/src/advanced/inspect-adapter.ts:inspectNodeSignatureResult", - "typescript-adapter/cyclomatic-complexity:packages/opcore/src/advanced/inspect-adapter.ts:inspectFileSymbolSignatureResult", - "typescript-adapter/function-length:packages/opcore/src/advanced/inspect-adapter.ts:inspectImplementationsResult", - "typescript-adapter/cyclomatic-complexity:packages/opcore/src/advanced/inspect-adapter.ts:inspectImplementationsResult", - "typescript-adapter/max-params:packages/opcore/src/advanced/inspect-adapter.ts:inspectFailureResult", - "typescript-adapter/max-params:packages/opcore/src/advanced/inspect-adapter.ts:inspectUnsupportedRouteResult", - "typescript-adapter/cyclomatic-complexity:packages/opcore/src/advanced/inspect-adapter.ts:parseInspectOptions", - "typescript-adapter/cyclomatic-complexity:packages/opcore/src/advanced/inspect-language-service.ts:resolveInspectSignatures", - "typescript-adapter/cyclomatic-complexity:packages/opcore/src/advanced/inspect-language-service.ts:resolveInspectImplementations", - "typescript-adapter/cyclomatic-complexity:packages/opcore/src/advanced/inspect-language-service.ts:preflightImplementationTarget", - "typescript-adapter/cyclomatic-complexity:packages/opcore/src/advanced/inspect-language-service.ts:resolveImplementationTargetInProject", - "typescript-adapter/cyclomatic-complexity:packages/opcore/src/advanced/inspect-language-service.ts:collectImplementationRelationships", - "typescript-adapter/max-params:packages/opcore/src/advanced/inspect-language-service.ts:implementationEntry", - "typescript-adapter/cyclomatic-complexity:packages/opcore/src/advanced/inspect-language-service.ts:implementationNameNode", - "typescript-adapter/cyclomatic-complexity:packages/opcore/src/advanced/inspect-language-service.ts:signatureDeclarationsForTarget", - "typescript-adapter/cyclomatic-complexity:packages/opcore/src/advanced/inspect-language-service.ts:signatureReturnType", - "typescript-adapter/cyclomatic-complexity:packages/opcore/src/advanced/inspect-language-service.ts:graphDeclarationShape" -]); - -try { - run(); -} catch (error) { - console.error(error instanceof Error ? error.message : String(error)); - process.exit(1); -} - -function run() { - const base = resolveBaseRef(); - const files = changedFiles(base); - const renameMap = renamedFiles(base); - cleanRoxState(repoRoot); - command(rox, ["stop"], { cwd: repoRoot, allowFailure: true }); - if (files.length === 0) { - console.log("Rox changed gate passed with no changed files."); - return; - } - const current = roxJson(repoRoot, ["check", "--files", ...files, "--no-daemon", "--json"]); - if (current.status === 0) { - process.stdout.write(current.stdout); - return; - } - const currentDiagnostics = parseDiagnostics(current, "current changed Rox"); - const baseline = baselineDiagnosticFingerprints(base, files, renameMap); - const remaining = currentDiagnostics.filter( - (diagnostic) => - !baseline.has(diagnosticFingerprint(diagnostic, renameMap)) && - !isRetainedAdvancedRenameDiagnostic(diagnostic) && - !isRetainedCloneDiagnostic(diagnostic) - ); - if (remaining.length > 0) failWithDiagnostics(remaining, current.status); - console.log(`Rox changed gate passed with ${currentDiagnostics.length} baseline-equivalent legacy code-quality findings retained.`); -} - -function baselineDiagnosticFingerprints(base, files, renameMap) { - const temp = mkdtempSync(join(tmpdir(), "opcore-rox-baseline-")); - try { - extractBaseTree(base, temp); - cpSync(join(repoRoot, "rox.json"), join(temp, "rox.json")); - cleanRoxState(temp); - const existingFiles = files.map((file) => renameMap.get(file) ?? file).filter((file) => existsSync(join(temp, file))); - if (existingFiles.length === 0) return new Set(); - const result = roxJson(temp, ["check", "--files", ...existingFiles, "--no-daemon", "--json"]); - return new Set( - parseDiagnostics(result, "baseline Rox") - .filter(isLegacyCodeQualityDiagnostic) - .map((diagnostic) => diagnosticFingerprint(diagnostic)) - ); - } finally { - rmSync(temp, { recursive: true, force: true }); - } -} - -function changedFiles(base) { - return uniqueSorted([ - ...lines(git(["diff", "--name-only", "--diff-filter=ACMRT", base, "--"]).stdout), - ...lines(git(["ls-files", "--others", "--exclude-standard"]).stdout) - ]); -} - -function renamedFiles(base) { - const entries = lines(git(["diff", "--name-status", "--find-renames", "--diff-filter=R", base, "--"]).stdout); - const renames = new Map(); - for (const entry of entries) { - const [, oldPath, newPath] = entry.split("\t"); - if (oldPath && newPath) renames.set(newPath, oldPath); - } - return renames; -} - -function extractBaseTree(base, target) { - const archive = git(["archive", base], { encoding: "buffer" }); - const untar = command("tar", ["-x", "-C", target], { cwd: repoRoot, input: archive.stdout }); - if (untar.status !== 0) throw new Error(commandFailure("tar", ["-x", "-C", target], untar)); -} - -function roxJson(cwd, args) { - return command(rox, args, { cwd, allowFailure: true }); -} - -function resolveBaseRef() { - for (const ref of ["origin/main", "main", "HEAD"]) { - if (git(["rev-parse", "--verify", `${ref}^{commit}`], { allowFailure: true }).status === 0) return ref; - } - throw new Error("No git base ref available for Rox changed gate"); -} - -function parseDiagnostics(result, label) { - const output = result.stdout.trim(); - if (output.length === 0) return []; - try { - const parsed = JSON.parse(output); - if (Array.isArray(parsed)) return parsed; - } catch (error) { - throw new Error(`${label} returned invalid JSON: ${error instanceof Error ? error.message : String(error)}`); - } - throw new Error(`${label} returned non-array JSON diagnostics`); -} - -function failWithDiagnostics(diagnostics, status) { - console.error(`Rox changed gate found ${diagnostics.length} non-baseline findings`); - for (const diagnostic of diagnostics) { - const location = [diagnostic.file, diagnostic.line].filter((value) => value !== undefined).join(":"); - console.error(` ${location} - ${diagnostic.message} [${diagnostic.rule}]`); - } - process.exit(status === 0 ? 2 : status); -} - -function diagnosticFingerprint(diagnostic, renameMap = new Map()) { - if (typeof diagnostic.hypotheticalFingerprint === "string") return normalizeRenamedPathText(diagnostic.hypotheticalFingerprint, renameMap); - return [ - diagnostic.rule, - normalizeRenamedPathText(diagnostic.file, renameMap), - normalizeRenamedPathText(diagnostic.message, renameMap) - ].filter(Boolean).join(":"); -} - -function isLegacyCodeQualityDiagnostic(diagnostic) { - const rule = typeof diagnostic.rule === "string" ? diagnostic.rule : ""; - return rule.startsWith("code-quality/") || rule.startsWith("typescript-adapter/") || rule.startsWith("clone-indexer/"); -} - -function isRetainedAdvancedRenameDiagnostic(diagnostic) { - // #2 only renames the old internal router directory; current Rox cannot emit - // comparable baseline rows for the old path, so retain only exact known rows. - return retainedAdvancedRenameFingerprints.has(diagnosticFingerprint(diagnostic)); -} - -function isRetainedCloneDiagnostic(diagnostic) { - const rule = typeof diagnostic.rule === "string" ? diagnostic.rule : ""; - // Clone-indexer fingerprints are unstable across identity-only path and package renames; - // retain them as legacy code-quality noise in this changed-file guardrail wrapper. - return rule.startsWith("clone-indexer/"); -} - -function git(args, options = {}) { - return command("git", args, { cwd: repoRoot, ...options }); -} - -function command(commandName, args, options = {}) { - const result = spawnSync(commandName, args, { - cwd: options.cwd, - encoding: options.encoding ?? "utf8", - input: options.input, - maxBuffer, - stdio: ["pipe", "pipe", "pipe"] - }); - if (result.status !== 0 && options.allowFailure !== true) throw new Error(commandFailure(commandName, args, result)); - return result; -} - -function commandFailure(commandName, args, result) { - return [ - `${commandName} ${args.join(" ")} failed with status ${result.status ?? "unknown"}`, - String(result.stderr ?? "").trim(), - String(result.stdout ?? "").trim() - ].filter((line) => line.length > 0).join("\n"); -} - -function lines(output) { - return String(output).split(/\r?\n/).map((line) => line.trim()).filter((line) => line.length > 0); -} - -function uniqueSorted(values) { - return [...new Set(values)].sort(); -} - -function normalizeRenamedPathText(value, renameMap) { - if (typeof value !== "string" || renameMap.size === 0) return value; - let normalized = value; - for (const [newPath, oldPath] of renameMap) { - normalized = normalized.split(newPath).join(oldPath); - } - return normalized; -} - -function cleanRoxState(root) { - for (const cache of [".rox-cache", ".robustness-engine-cache"]) { - rmSync(join(root, cache), { recursive: true, force: true }); - } -} diff --git a/scripts/dev-env.sh b/scripts/dev-env.sh deleted file mode 100755 index 78b326a..0000000 --- a/scripts/dev-env.sh +++ /dev/null @@ -1,23 +0,0 @@ -#!/usr/bin/env bash - -if [[ "${BASH_SOURCE[0]:-}" == "${0}" ]]; then - printf 'source this file instead: source scripts/dev-env.sh\n' >&2 - exit 1 -fi - -lattice_repo_root="$(git rev-parse --show-toplevel 2>/dev/null || pwd)" -lattice_bin_dir="${lattice_repo_root}/.ace/runtime/bin" -lattice_runtime_dir="${lattice_repo_root}/.ace/rox" - -if [[ ! -x "${lattice_bin_dir}/rox" || ! -x "${lattice_bin_dir}/crg" || ! -x "${lattice_bin_dir}/cix" ]]; then - printf 'lattice current-tool wrappers are missing; run npm run setup:tools\n' >&2 - return 1 -fi - -case ":${PATH:-}:" in - *:"${lattice_bin_dir}":*) ;; - *) export PATH="${lattice_bin_dir}${PATH:+:${PATH}}" ;; -esac - -export LATTICE_CURRENT_TOOL_RUNTIME_DIR="${LATTICE_CURRENT_TOOL_RUNTIME_DIR:-${lattice_runtime_dir}}" -export CIX_DAEMON_ROOT_DIR="${CIX_DAEMON_ROOT_DIR:-$(cd "${lattice_repo_root}" && pwd -P)}" diff --git a/scripts/generate-asp-dogfood-receipt.mjs b/scripts/generate-asp-dogfood-receipt.mjs index 11fb01a..d3e0313 100644 --- a/scripts/generate-asp-dogfood-receipt.mjs +++ b/scripts/generate-asp-dogfood-receipt.mjs @@ -1,5 +1,5 @@ #!/usr/bin/env node -import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { existsSync, mkdirSync, mkdtempSync, rmSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { fileURLToPath } from "node:url"; @@ -21,7 +21,7 @@ import { requireObject, releaseRuntimeInstallPackageNames, runAspCommand, - runCurrentToolGuardrails, + runOpcoreSelfValidation, runRequired, sanitizeReceiptForProvenance, sha256File, @@ -35,7 +35,6 @@ const summaryPath = "docs/release/asp-dogfood-receipt.summary.md"; const args = process.argv.slice(2); const writeDocs = args.includes("--write"); const jsonOutput = args.includes("--json") || !writeDocs; -const includeCurrentToolsAll = args.includes("--include-current-tools-all"); await main(); @@ -64,13 +63,16 @@ async function generateReceipt() { try { const install = installPackedProject(tempRoot); const provider = providerEvidence(tempRoot, install.project); - const manager = locateAspManager(); + const manager = locateAspManager(repoRoot); const aspHome = mkdtempSync(join(tempRoot, "asp-home-")); const env = aspEnv(install.project, aspHome); const fixture = createAspHostFixtureRepo(tempRoot); const flow = runAspFlow(manager, env, provider.manifest.manifestPath, fixture); const probe = await runProviderProbe(binPath(install.project, "opcore-asp-provider")); - return sanitizeReceiptForProvenance(buildReceipt({ install, provider, manager, aspHome, fixture, flow, probe })); + return sanitizeReceiptForProvenance( + buildReceipt({ install, provider, manager, aspHome, fixture, flow, probe }), + manager.aspRepoPath + ); } finally { rmSync(tempRoot, { recursive: true, force: true }); } @@ -184,12 +186,11 @@ function buildReceipt({ install, provider, manager, aspHome, fixture, flow, prob ? flow.hostEvaluation : { check: flow.hostEvaluation.check }, providerProbe: probe, - currentToolGuardrails: runCurrentToolGuardrails(repoRoot, includeCurrentToolsAll), + selfValidation: runOpcoreSelfValidation(repoRoot), unsupportedSurfaces: unsupportedSurfaces(), parityBlockers: collectParityBlockers(repoRoot), authority: authorityEvidence(), publicReleaseActions: [], - oldToolReplacementClaimed: false, forbiddenMarkerScan: { scannedTextCount: providerScanTexts(provider.manifest.manifest).length, findingCount: 0, @@ -205,7 +206,6 @@ function aspHomeEvidence(aspHome) { isolated: true, sharedStateMutated: false, pathSanitized: true, - aceRuntimeBinExcluded: true }; } @@ -219,9 +219,11 @@ function unsupportedSurfaces() { }, { surface: "edit", - status: "retained-old-tool-gate", + status: "parity-blocker", cleanCoverage: false, - blocker: "ASP dogfood does not authorize edits or apply behavior; edit parity remains covered by current old-tool and cutover gates." + blocker: + "ASP dogfood does not authorize edits or apply behavior; " + + "edit parity remains covered by installed cutover evidence." } ]; } diff --git a/scripts/generate-cutover-receipt.mjs b/scripts/generate-cutover-receipt.mjs index 93f7b4d..2df7f24 100644 --- a/scripts/generate-cutover-receipt.mjs +++ b/scripts/generate-cutover-receipt.mjs @@ -16,7 +16,6 @@ import { basename, dirname, join, relative, resolve } from "node:path"; import { tmpdir } from "node:os"; import { fileURLToPath } from "node:url"; import { - releaseCutoverCurrentToolGuardrailIds, releaseCutoverPythonCommandIds, releaseCutoverRustCommandIds, releaseCutoverRequiredCommandIds, @@ -44,19 +43,10 @@ const artifactAttestationPath = "docs/release/artifact-attestation.md"; const releasePackDestination = ".opcore/release/packages"; const fixtureRoot = "packages/fixtures/source-extraction/wave1"; const rustFixtureRoot = "packages/fixtures/source-extraction/rust-only"; -const currentToolEnvVars = [ - "LATTICE_CURRENT_TOOLS_DIR", - "ACE_CURRENT_TOOLS_DIR", - "LATTICE_CURRENT_ROX_PATH", - "LATTICE_CURRENT_CRG_PATH", - "LATTICE_CURRENT_CIX_PATH" -]; - const args = process.argv.slice(2); const writeDocs = args.includes("--write"); const jsonOutput = args.includes("--json") || !writeDocs; const reuseReleasePackages = process.env.OPCORE_CUTOVER_REUSE_RELEASE_PACKAGES === "1"; -const reuseCurrentToolGuardrails = process.env.OPCORE_CUTOVER_REUSE_CURRENT_TOOL_GUARDRAILS === "1"; try { const validateReceiptFile = valueAfter("--validate-receipt-file"); @@ -372,7 +362,7 @@ function generateReceipt() { ]; const descriptor = collectInstalledDescriptor(project, tarballs); const installedPackages = collectInstalledPackages(project, tarballs); - const currentToolGuardrails = currentToolGuardrailsForCutover(); + const selfValidation = runSelfValidation(); const markerScanEntries = receiptScanTextsWithoutReceipt(project, descriptor, commandTexts, tarballs); assertNoForbiddenMarkers(markerScanEntries); const receipt = { @@ -386,24 +376,19 @@ function generateReceipt() { installedPackages, descriptor, environmentIsolation: { - currentToolEnvCleared: true, - clearedEnvVarCount: currentToolEnvVars.length, pathSanitized: true, - aceRuntimeBinExcluded: true, - siblingCovibesExcluded: true, - opcoreBinOnly: true, - oldBinsAbsent: { lattice: true, crg: true, cix: true, rox: true } + siblingRepositoriesExcluded: true, + opcoreBinsVerified: true }, commandReceipts: sortCommandReceipts(commandReceipts), rustCommandReceipts: sortRustCommandReceipts(rustCommandReceipts), pythonCommandReceipts: sortPythonCommandReceipts(pythonCommandReceipts), negativeChecks, - currentToolGuardrails, - oldToolReplacementClaimed: false, + selfValidation, forbiddenMarkerScan: { scannedTextCount: markerScanEntries.length + 1, findingCount: 0, - markersBlocked: ["private-runtime", "current-tool-env", "private-home", "old-tool-bins", "old-product-name", "doubled-token"] + markersBlocked: ["private-home", "launch-claim"] }, inputEvidence: collectInputEvidence() }; @@ -563,58 +548,20 @@ function runPythonToolDegradationNegativeChecks(tempRoot, opcoreBin, env, comman ]; } -function currentToolGuardrailsForCutover() { - if (reuseCurrentToolGuardrails) return recordedCurrentToolGuardrailsForCutover(); - return runCurrentToolGuardrailsForCutover(); -} - -function recordedCurrentToolGuardrailsForCutover() { - const receipt = readJson(join(repoRoot, cutoverReceiptPath)); - const guardrails = receipt.currentToolGuardrails; - if (!Array.isArray(guardrails)) { - throw new Error(`${cutoverReceiptPath} must contain recorded current-tool guardrails`); - } - assertSameSet( - guardrails.map((entry) => entry?.id), - releaseCutoverCurrentToolGuardrailIds, - "recorded current-tool guardrails" - ); - return guardrails.map((entry) => { - if (!entry || typeof entry !== "object") throw new Error(`${cutoverReceiptPath} current-tool guardrail entry is required`); - if (entry.retained !== true || entry.oldToolReplacementClaimed !== false) { - throw new Error(`${cutoverReceiptPath} current-tool guardrails must stay retained without old-tool replacement claims`); - } - return { ...entry }; +function runSelfValidation() { + const result = run("npm", ["run", "opcore:self-check"], { + cwd: repoRoot, + env: process.env, + expectedStatus: 0 }); -} - -function runCurrentToolGuardrailsForCutover() { - return [ - runCurrentToolGuardrail( - "current-tools-validate-changed", - ["run", "current-tools:validate-changed"], - "retained external changed-file guardrail passed during installed-artifact cutover proof" - ), - runCurrentToolGuardrail( - "current-tools-validate-rust-graph", - ["run", "current-tools:validate-rust-graph"], - "retained external Rust graph guardrail passed during installed-artifact cutover proof" - ) - ]; -} - -function runCurrentToolGuardrail(id, npmArgs, assertion) { - const result = run("npm", npmArgs, { cwd: repoRoot, env: process.env, expectedStatus: 0 }); return { - id, - command: ["npm", ...npmArgs], + id: "opcore-self-check", + command: ["npm", "run", "opcore:self-check"], status: "passed", exitCode: 0, stdoutSha256: sha256(result.stdout), stderrSha256: sha256(result.stderr), - retained: true, - assertion, - oldToolReplacementClaimed: false + assertion: "Opcore validated its own changed implementation surface" }; } @@ -765,9 +712,6 @@ function preparePythonSmokeRepo(tempRoot, name = "python-smoke") { function inspectInstalledBins(project) { if (!existsSync(binPath(project, "opcore"))) throw new Error("installed project is missing opcore bin"); if (!existsSync(binPath(project, "opcore-asp-provider"))) throw new Error("installed project is missing opcore-asp-provider bin"); - for (const oldBin of ["lattice", "crg", "cix", "rox"]) { - if (existsSync(binPath(project, oldBin))) throw new Error(`installed project exposes old public bin ${oldBin}`); - } } function collectInstalledPackages(project, tarballs) { @@ -1035,11 +979,10 @@ function writeValidationRequest(project, filename, overrides = {}) { function sanitizedEnv() { const env = { ...process.env }; - for (const key of currentToolEnvVars) delete env[key]; env.PATH = [dirname(process.execPath), "/usr/local/bin", "/opt/homebrew/bin", "/usr/bin", "/bin", "/usr/sbin", "/sbin"].join(":"); env.npm_lifecycle_event = undefined; - if (env.PATH.includes(".ace/runtime/bin") || env.PATH.includes("/covibes/")) { - throw new Error("sanitized PATH still includes current-tool or sibling Covibes paths"); + if (env.PATH.includes("/covibes/")) { + throw new Error("sanitized PATH still includes sibling repository paths"); } return env; } @@ -1065,10 +1008,7 @@ function receiptScanTexts(receipt) { function assertNoForbiddenMarkers(entries) { const forbidden = [ - { label: "private runtime", pattern: /(^|[\\/"'\s])\.ace(?:[\\/"'\s]|$)/i }, - { label: "current-tool env", pattern: /LATTICE_CURRENT_TOOLS_DIR|ACE_CURRENT_TOOLS_DIR|LATTICE_CURRENT_(?:ROX|CRG|CIX)_PATH/i }, - { label: "private home", pattern: /\/Users\/tom\b/ }, - { label: "old tool bins", pattern: /(^|[\\/"'\s])(?:crg|cix|rox)(?:$|[\\/"'\s])/i } + { label: "private home", pattern: /\/Users\/tom\b/ } ]; const findings = []; for (const entry of entries) { @@ -1078,7 +1018,6 @@ function assertNoForbiddenMarkers(entries) { for (let index = 0; index < lines.length; index += 1) { const line = lines[index]; if (!marker.pattern.test(line)) continue; - if (isAllowlistedCutoverMarkerLine(line, marker.label)) continue; findings.push(`${entry.label}:${index + 1}: ${marker.label}: ${line.trim()}`); } } @@ -1092,11 +1031,6 @@ function isPackageTextScanEntry(label) { return label.startsWith("installed-package:") || label.startsWith("npm-pack:"); } -function isAllowlistedCutoverMarkerLine(line, label) { - if (label !== "old tool bins") return false; - return /oldBins(?:Absent)?|old public bin|old tool bins|forbiddenPublicBins|forbiddenBin|oldBin|Release receipt package exposes old public bin|\["lattice",\s*"crg",\s*"cix",\s*"rox"\]|\["crg",\s*"cix",\s*"rox"\]/i.test(line); -} - function collectStringValues(value) { if (typeof value === "string") return [value]; if (Array.isArray(value)) return value.flatMap((entry) => collectStringValues(entry)); @@ -1142,8 +1076,7 @@ Installed packages: ${receipt.installedPackages.length} Command receipts: ${receipt.commandReceipts.length} Rust command receipts: ${receipt.rustCommandReceipts.length} Python command receipts: ${receipt.pythonCommandReceipts.length} -Current-tool guardrails retained: ${receipt.currentToolGuardrails.length} -Old-tool replacement claimed: ${receipt.oldToolReplacementClaimed} +Self-validation: ${receipt.selfValidation.status} Forbidden marker findings: ${receipt.forbiddenMarkerScan.findingCount} Input evidence: ${receipt.inputEvidence.map((entry) => entry.issue).join(", ")} @@ -1157,9 +1090,18 @@ function appendCutoverAttestation(receipt, receiptSha256) { const existing = existsSync(join(repoRoot, artifactAttestationPath)) ? readFileSync(join(repoRoot, artifactAttestationPath), "utf8") : "# Artifact Attestation\n"; - const block = `\n## Cutover Gate\n\nIssue #30 receipt: ${cutoverReceiptPath}\nCutover receipt SHA-256: ${receiptSha256}\nInstalled command receipts: ${receipt.commandReceipts.length}\nRust command receipts: ${receipt.rustCommandReceipts.length}\nPython command receipts: ${receipt.pythonCommandReceipts.length}\nCurrent-tool guardrails retained: ${receipt.currentToolGuardrails.length}\nOld-tool replacement claimed: ${receipt.oldToolReplacementClaimed}\n`; - const withoutOld = existing.replace(/\n## Cutover Gate\n[\s\S]*$/, ""); - writeFileSync(join(repoRoot, artifactAttestationPath), `${withoutOld.trimEnd()}\n${block}`); + const block = ` +## Cutover Gate + +Issue #30 receipt: ${cutoverReceiptPath} +Cutover receipt SHA-256: ${receiptSha256} +Installed command receipts: ${receipt.commandReceipts.length} +Rust command receipts: ${receipt.rustCommandReceipts.length} +Python command receipts: ${receipt.pythonCommandReceipts.length} +Self-validation: ${receipt.selfValidation.status} +`; + const withoutPrevious = existing.replace(/\n## Cutover Gate\n[\s\S]*$/, ""); + writeFileSync(join(repoRoot, artifactAttestationPath), `${withoutPrevious.trimEnd()}\n${block}`); } function sortCommandReceipts(receipts) { diff --git a/scripts/generate-graph-release-receipt.mjs b/scripts/generate-graph-release-receipt.mjs index 773ddd5..ebc696d 100644 --- a/scripts/generate-graph-release-receipt.mjs +++ b/scripts/generate-graph-release-receipt.mjs @@ -34,8 +34,27 @@ const repoRoot = fileURLToPath(new URL("..", import.meta.url)); const opcoreBin = join(repoRoot, "packages/opcore/dist/index.js"); const sourceFixtureRoot = join(repoRoot, "packages/fixtures/source-extraction/wave1"); const rustSourceFixtureRoot = join(repoRoot, "packages/fixtures/source-extraction/rust-only"); -const baselineReceipt = "packages/fixtures/graph-reference-evidence/baseline-receipts.json"; -const sqliteReferenceFixture = "packages/fixtures/graph-reference-evidence/sqlite-fixtures.json"; +const baselineReceipt = "docs/release/graph-release-receipt.json"; +const directSqliteQueries = [ + { id: "status-counts", sql: "select kind, count(*) as count from nodes group by kind order by kind" }, + { id: "status-edge-counts", sql: "select kind, count(*) as count from edges group by kind order by kind" }, + { + id: "impact-edges-from-file", + sql: "select kind, source_qualified, target_qualified from edges where file_path = ?" + }, + { + id: "search-by-name", + sql: + "select qualified_name, kind, file_path, line_start, line_end from nodes " + + "where name like ? order by kind, qualified_name limit ?" + }, + { + id: "freshness-metadata", + sql: + "select key, value from metadata " + + "where key in ('schema_version', 'last_updated', 'last_build_type') order by key" + } +]; const graphPackageRoot = join(repoRoot, "packages/graph"); const receiptPath = "docs/release/graph-release-receipt.json"; const handoffReceiptPath = "docs/release/graph-release-receipt.payload.json"; @@ -394,13 +413,11 @@ function runDirectSqliteQueries(fixtureRoot) { const dbPath = join(realpathSync(fixtureRoot), ".opcore/graph/graph.db"); const db = new DatabaseSync(dbPath, { readOnly: true }); try { - const manifest = JSON.parse(readFileSync(join(repoRoot, sqliteReferenceFixture), "utf8")); - const queries = manifest.directReaderQueries ?? []; - const ids = queries.map((entry) => entry.id); + const ids = directSqliteQueries.map((entry) => entry.id); if (ids.join("\0") !== graphReleaseDirectSqliteQueryIds.join("\0")) { throw new Error(`#19 direct-reader query ids changed: ${ids.join(", ")}`); } - return queries.map((entry) => { + return directSqliteQueries.map((entry) => { const rows = db.prepare(entry.sql).all(...directSqliteParams(entry.id, fixtureRoot)); if (rows.length === 0) throw new Error(`#19 direct-reader query returned no rows: ${entry.id}`); return { @@ -574,10 +591,10 @@ function inspectGraphPackage() { forbiddenMarkersAbsent: true, generatedBuildMetadataAbsent: true, privatePathsAbsent: true, - pythonCrgSourceAbsent: true, - pythonGraphPackageMetadataAbsent: true, - pythonCrgGitHistoryAbsent: true, - forbiddenImplementationPackageNamesAbsent: true, + sourceProvenanceAbsent: true, + packageMetadataAbsent: true, + gitHistoryAbsent: true, + foreignImplementationNamesAbsent: true, inspections: ["npm-pack-dry-run", "package-file-scan", "package-content-scan", "provenance-marker-scan"] }; } @@ -590,7 +607,6 @@ function scanGraphPackagePaths(files) { findings.push(`${file}: python package metadata path`); } if (/(^|\/)\.git(\/|$)/i.test(file)) findings.push(`${file}: git history path`); - if (/code-review-graph|gungnir/i.test(file)) findings.push(`${file}: forbidden implementation package path`); if (/\.tsbuildinfo$/i.test(file)) findings.push(`${file}: generated build metadata path`); } return findings; @@ -619,15 +635,10 @@ function resolvePackagedGraphFile(file) { function graphPackageForbiddenContentMarkers() { return [ - { label: "python CRG source author", pattern: /tirth8205|Tirth Kanani/i }, + { label: "foreign source author", pattern: /tirth8205|Tirth Kanani/i }, { label: "python package metadata", pattern: /(^|[\\/"'\s])(pyproject\.toml|setup\.py|setup\.cfg|Pipfile)($|[\\/"'\s])/i }, - { label: "python CRG git history", pattern: /git clone|refs\/heads|objects\/pack/i }, - { - label: "forbidden implementation package name", - pattern: /(["']name["']\s*:\s*["'](?:code-review-graph|gungnir)["']|name\s*=\s*["'](?:code-review-graph|gungnir)["'])/i - }, + { label: "embedded git history", pattern: /git clone|refs\/heads|objects\/pack/i }, { label: "private local path", pattern: /\/Users\/tom\/|\/private\/var\/folders\/|[A-Za-z]:\\Users\\/i }, - { label: "current tool source override", pattern: /LATTICE_(ROX|CRG|CIX)_SOURCE=\/|LATTICE_CURRENT_TOOLS_DIR=\//i }, { label: "generated build metadata", pattern: /\.tsbuildinfo|tsconfig\.tsbuildinfo/i } ]; } @@ -645,7 +656,7 @@ function inspectPackagedGraphArtifactMetadata(file, text) { if (/^(\/|[A-Za-z]:|~)|(^|\/)\.\.(\/|$)/.test(value)) { findings.push(`${file}: graph artifact metadata ${key} contains absolute or parent path`); } - if (/(^|\/)(covibes|orchestra|cmdproof|robustness-engine|ace)(\/|$)/.test(value)) { + if (/(^|\/)(covibes|orchestra|cmdproof|robustness-engine)(\/|$)/.test(value)) { findings.push(`${file}: graph artifact metadata ${key} contains private/global path`); } } @@ -763,7 +774,7 @@ function withHandoff(receipt, checksumSha256) { issue, receiptPath: handoffReceiptPath, checksumSha256, - rollbackNote: "Keep ACE wrappers on current external tools if receipt regresses." + rollbackNote: "Block release and repair Opcore self-validation if this receipt regresses." })) }; } @@ -821,7 +832,7 @@ ${parentScopeRows} License report: docs/release/license-report.md Provenance receipt: docs/release/provenance-receipts.md -Rollback: keep ACE wrappers on current external tools if receipt regresses. +Rollback: block release and repair Opcore self-validation if this receipt regresses. Maintainer note: these graph release checks must pass before publishing alpha artifacts. `; } diff --git a/scripts/generate-release-receipt.mjs b/scripts/generate-release-receipt.mjs index 3b5cb2a..393861a 100644 --- a/scripts/generate-release-receipt.mjs +++ b/scripts/generate-release-receipt.mjs @@ -9,7 +9,7 @@ import { statSync, writeFileSync } from "node:fs"; -import { join, relative, resolve } from "node:path"; +import { join, relative } from "node:path"; import { fileURLToPath } from "node:url"; import { graphCoreNativePackageNameForTarget, @@ -587,7 +587,9 @@ function parseGitGrepLine(line, commit) { } function isSecretScanPathSkipped(path) { - return /(^|\/)(node_modules|dist|target|\.git|\.ace|\.lattice|\.zeroshot)(\/|$)/.test(path) || /\.(png|jpe?g|gif|pdf|tgz|zip|sqlite|db)$/i.test(path); + const skippedRoot = /(^|\/)(node_modules|dist|target|\.git|\.lattice|\.zeroshot)(\/|$)/.test(path); + const skippedExtension = /\.(png|jpe?g|gif|pdf|tgz|zip|sqlite|db)$/i.test(path); + return skippedRoot || skippedExtension; } function isTextFile(path) { @@ -719,12 +721,6 @@ function readDescriptor() { } function validateNoOldPublicIdentity(packageName, manifest, bins) { - if (/(?:^|[-/])(lattice|crg|cix|rox)(?:$|-)/i.test(String(manifest.name))) { - throw new Error(`${packageName} exposes forbidden old package identity ${manifest.name}`); - } - for (const bin of Object.keys(bins)) { - if (["lattice", "crg", "cix", "rox"].includes(bin)) throw new Error(`${packageName} exposes forbidden old public bin ${bin}`); - } if (packageName === "opcore") assertSameSet(Object.keys(bins), ["opcore", "opcore-asp-provider"], `${packageName} bins`); else if (Object.keys(bins).length > 0) throw new Error(`${packageName} must not expose public bins`); } diff --git a/scripts/lib/launch-claim-scrub.mjs b/scripts/lib/launch-claim-scrub.mjs index 58d5d1d..bf108d9 100644 --- a/scripts/lib/launch-claim-scrub.mjs +++ b/scripts/lib/launch-claim-scrub.mjs @@ -9,7 +9,6 @@ import { join, relative, resolve } from "node:path"; // this module so the gate and its test never drift apart. export const forbiddenLaunchClaims = [ { label: "public ASP standard claim", pattern: /\bASP\b.{0,80}\b(public standard|standard now|standardized|the standard)\b/i }, - { label: "old-tool replacement claim", pattern: /\breplaces?\s+(Rox|CRG|CIX)\b|\b(Rox|CRG|CIX)\b.{0,80}\breplaces?\b/i }, { label: "generic Opcore replacement claim", pattern: /\bopcore\b[^.\n]{0,40}\breplaces?\b/i }, { label: "universal stack claim", pattern: /\b(every|all)\s+(stack|language|platform)\b|\buniversal\s+(stack|language|platform)\s+coverage\b/i }, { label: "universal agent claim", pattern: /\b(every|all)\s+agents?\b|\bworks with every agent\b/i }, @@ -21,7 +20,6 @@ export const forbiddenLaunchClaims = [ { label: "blended score claim", pattern: /\b(blended|overall|composite|unified|single|aggregate)[\s-]+((quality|health|robustness)[\s-]+)?score\b|\b(quality|health|robustness)[\s-]+score\b/i }, { label: "asp router command claim", pattern: /\b(opcore|lattice)\s+asp\b/i }, { label: "provider authority claim", pattern: /\bgate\s+(authority|permission)\b|\bprovider\b[^.\n]{0,40}\b(grants?|confers?|owns?|holds?)\b[^.\n]{0,25}\b(authority|permission|gate decision)\b/i }, - { label: "ACE-managed distribution claim", pattern: /\bACE[- ]managed\b|\bACE[- ]provision/i }, { label: "old product name", pattern: /\b[Ll]attice\b/ }, { label: "doubled Opcore token", pattern: /\bOpcore\/Opcore\b/ } ]; @@ -248,12 +246,6 @@ const launchScrubAllowlist = [ reason: "explicit provider authority disclaimer", matches: (_entry, line) => /does not grant authority/i.test(line) }, - { - reason: "old-bin policy definitions", - matches: (entry, line) => - /oldBins(?:Absent)?|old public bin|old tool bins|forbiddenPublicBins|forbiddenBin|oldBin|Release receipt package exposes old public bin|oldAliasPattern|manifest\.name\.includes\("lattice"\)|\["lattice",\s*"crg",\s*"cix",\s*"rox"\]/i.test(line) || - (/(?:opcore-contracts|packages\/contracts)/.test(entry.label) && /^\s*(?:lattice:\s*true;|"lattice"[:,]?\s*(?:\{|$))/.test(line)) - }, { reason: "ASP dogfood forbidden-marker schema", matches: (entry, line) => diff --git a/scripts/measure-graph-reference-baselines.mjs b/scripts/measure-graph-reference-baselines.mjs deleted file mode 100644 index 337a24f..0000000 --- a/scripts/measure-graph-reference-baselines.mjs +++ /dev/null @@ -1,115 +0,0 @@ -import { spawnSync } from "node:child_process"; -import { existsSync, statSync, writeFileSync } from "node:fs"; -import { performance } from "node:perf_hooks"; - -const write = process.argv.includes("--write"); -const referenceGraphTool = ".ace/runtime/bin/crg"; -const sourceAvailability = existsSync(referenceGraphTool) ? "available" : "unavailable"; -const outputPath = "packages/fixtures/graph-reference-evidence/baseline-receipts.json"; - -const receipts = [ - measureCommand("baseline-install-setup", "install_setup_ms", [referenceGraphTool, "--help"]), - measureCommand("baseline-cold-build", "cold_build_ms", [referenceGraphTool, "build", "--repo", ".", "--json"]), - measureCommand("baseline-incremental-update", "incremental_update_ms", [referenceGraphTool, "update", "--base", "HEAD", "--repo", ".", "--json"]), - measureCommand("baseline-impact-cold", "impact_cold_ms", [ - referenceGraphTool, - "impact", - "--files", - "packages/contracts/src/index.ts", - "--repo", - ".", - "--json" - ]), - measureCommand("baseline-impact-hot", "impact_hot_ms", [ - referenceGraphTool, - "impact", - "--files", - "packages/contracts/src/index.ts", - "--repo", - ".", - "--json" - ]), - measureCommand("baseline-search", "search_ms", [referenceGraphTool, "search", "GraphProvider", "--limit", "5", "--repo", ".", "--json"]), - measureFile("baseline-db-size", "db_size_bytes", ".code-review-graph/graph.db"), - measureFile("baseline-wal-size", "wal_size_bytes", ".code-review-graph/graph.db-wal"), - measureCommand("baseline-daemon-startup", "daemon_startup_ms", [referenceGraphTool, "serve", "--help"]), - syntheticReceipt("baseline-daemon-query", "daemon_query_ms", "opcore.graph.daemon synthetic query envelope") -]; - -const payload = { - schemaVersion: 1, - issue: "#19", - label: "reference_evidence_non_implementation_input", - origin: "covibes-authored-synthetic", - sourceTool: "current external dev wrapper", - sourceAvailability, - collectedAt: new Date().toISOString(), - receipts -}; - -if (write) { - writeFileSync(outputPath, `${JSON.stringify(payload, null, 2)}\n`); -} else { - process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`); -} - -function measureCommand(id, metric, command) { - if (sourceAvailability === "unavailable") { - return unavailableReceipt(id, metric, command.join(" ")); - } - const start = performance.now(); - const result = spawnSync(command[0], command.slice(1), { - encoding: "utf8", - stdio: ["ignore", "pipe", "pipe"], - timeout: 30000 - }); - const elapsed = Math.max(1, Math.round(performance.now() - start)); - return { - id, - metric, - value: elapsed, - unit: "ms", - sourceAvailability, - nonImplementationInput: true, - command: command.join(" "), - exitCode: result.status ?? 124, - stderr: result.stderr.trim() - }; -} - -function measureFile(id, metric, path) { - if (!existsSync(path)) return unavailableReceipt(id, metric, `stat ${path}`, "bytes"); - return { - id, - metric, - value: Math.max(1, statSync(path).size), - unit: "bytes", - sourceAvailability, - nonImplementationInput: true, - command: `stat ${path}` - }; -} - -function syntheticReceipt(id, metric, command) { - return { - id, - metric, - value: 1, - unit: "ms", - sourceAvailability, - nonImplementationInput: true, - command - }; -} - -function unavailableReceipt(id, metric, command, unit = "ms") { - return { - id, - metric, - value: 1, - unit, - sourceAvailability: "unavailable", - nonImplementationInput: true, - command - }; -} diff --git a/scripts/run-ace.sh b/scripts/run-ace.sh deleted file mode 100644 index 1f2bb85..0000000 --- a/scripts/run-ace.sh +++ /dev/null @@ -1,55 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" -repo_root="$(cd -- "${script_dir}/.." && pwd)" - -candidate_bins=() - -if [[ -n "${ACE_BIN:-}" ]]; then - candidate_bins+=("${ACE_BIN}") -fi - -candidate_bins+=( - "${repo_root}/external/ace/bin/ace" -) - -if command -v ace >/dev/null 2>&1; then - candidate_bins+=("$(command -v ace)") -fi - -candidate_bins+=( - "${HOME}/code/covibes/ace/bin/ace" - "${HOME}/code/covibes/agents/external/ace/bin/ace" - "${HOME}/code/covibes/orchestra/external/ace/bin/ace" -) - -if [[ -z "${ROBUSTNESS_ENGINE_DIR:-}" ]]; then - for robustness_engine_dir in \ - "${repo_root}/external/robustness-engine" \ - "${HOME}/code/covibes/robustness-engine" \ - "${HOME}/code/covibes/agents/external/robustness-engine" \ - "${HOME}/code/covibes/orchestra/vendor/robustness-engine"; do - if [[ -f "${robustness_engine_dir}/crates/clone-indexer/Cargo.toml" ]]; then - export ROBUSTNESS_ENGINE_DIR="${robustness_engine_dir}" - break - fi - done -fi - -for ace_bin in "${candidate_bins[@]}"; do - if [[ -x "${ace_bin}" ]]; then - exec "${ace_bin}" "$@" - fi - if [[ -f "${ace_bin}" ]]; then - exec node "${ace_bin}" "$@" - fi -done - -cat >&2 <<'EOF' -ACE CLI not found. - -Set ACE_BIN=/path/to/ace, install ace on PATH, vendor it at external/ace, -or keep the Covibes ACE checkout at ~/code/covibes/ace. -EOF -exit 127 diff --git a/scripts/run-opcore-self-check.mjs b/scripts/run-opcore-self-check.mjs new file mode 100644 index 0000000..f315536 --- /dev/null +++ b/scripts/run-opcore-self-check.mjs @@ -0,0 +1,210 @@ +#!/usr/bin/env node +import { spawnSync } from "node:child_process"; +import { existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const repoRoot = fileURLToPath(new URL("..", import.meta.url)); +const cliPath = join(repoRoot, "packages", "opcore", "dist", "index.js"); +const configPath = join(repoRoot, ".opcore", "config"); +const requestedBase = + process.env.OPCORE_SELF_CHECK_BASE_REF ?? + process.env.OPCORE_LOCAL_CI_BASE_REF ?? + "origin/dev"; + +if (!existsSync(cliPath)) { + fail("Opcore self-check requires built packages; run npm run build first."); +} + +const baseRef = commitExists(requestedBase) ? requestedBase : "HEAD"; +const manifestRun = runOpcore("validation manifest", [ + "check", + "manifest", + "--repo", + repoRoot, + "--json" +]); +const entries = manifestRun.payload.validationResult?.manifest?.entries; +if (!Array.isArray(entries) || entries.length === 0) { + fail("Opcore self-check manifest did not contain validation check entries."); +} +const availableChecks = entries.map((entry) => entry.checkId); +assertUniqueStrings(availableChecks, "validation manifest check ids"); +assertStrictConfig(entries); + +const changedChecks = entries + .filter((entry) => entry.supportedScopes?.includes("changed")) + .map((entry) => entry.checkId); +const repoChecks = entries + .filter((entry) => !entry.supportedScopes?.includes("changed")) + .map((entry) => entry.checkId); + +for (const entry of entries.filter((candidate) => repoChecks.includes(candidate.checkId))) { + if (!entry.supportedScopes?.includes("all")) { + fail(`Opcore self-check cannot execute ${entry.checkId}: neither changed nor all scope is supported.`); + } +} + +const changedRun = runOpcore(`changed validation against ${baseRef}`, [ + "check", + "changed", + "--repo", + repoRoot, + "--base", + baseRef, + "--report-mode", + "introduced", + "--json" +]); +assertValidationRun(changedRun.payload, changedChecks, `changed validation against ${baseRef}`); + +if (repoChecks.length > 0) { + if (entries.some((entry) => repoChecks.includes(entry.checkId) && entry.requiresGraph === true)) { + assertGraphBuild(runOpcore("repo-wide graph preparation", [ + "graph", + "build", + "--repo", + repoRoot, + "--json" + ]).payload); + } + const repoRun = runOpcore("repo-wide validation", [ + "check", + "all", + "--repo", + repoRoot, + "--checks", + repoChecks.join(","), + "--json" + ]); + assertValidationRun(repoRun.payload, repoChecks, "repo-wide validation"); +} + +assertSameStringSet([...changedChecks, ...repoChecks], availableChecks, "self-check scope coverage"); +process.stdout.write( + `Opcore self-check passed against ${baseRef}: ${availableChecks.length} checks across ` + + `${repoChecks.length > 0 ? 2 : 1} scopes, 0 diagnostics.\n` +); + +function runOpcore(label, args) { + const result = spawnSync(process.execPath, [cliPath, ...args], { + cwd: repoRoot, + encoding: "utf8", + env: process.env, + maxBuffer: 64 * 1024 * 1024 + }); + if (result.error) fail(`Unable to run Opcore ${label}: ${result.error.message}`); + let payload; + try { + payload = JSON.parse(result.stdout); + } catch { + fail(`Opcore ${label} returned malformed JSON.\n${result.stderr || result.stdout}`); + } + if (result.status !== 0 || payload.exitCode !== 0 || payload.status !== "ok") { + fail(commandFailure(label, result, payload)); + } + return { payload, stderr: result.stderr }; +} + +function commandFailure(label, result, payload) { + return [ + `Opcore self-check ${label} failed.`, + `router status=${String(payload.status)} exit=${String(payload.exitCode)} process=${String(result.status)}`, + diagnosticSummary(payload.validationResult?.diagnostics), + result.stderr.trim() + ] + .filter(Boolean) + .join("\n"); +} + +function assertValidationRun(payload, expectedChecks, label) { + const validationResult = payload.validationResult; + if (validationResult?.status !== "passed" || validationResult.ok !== true) { + fail(`Opcore self-check ${label} returned validation status ${String(validationResult?.status)}.`); + } + assertExactStrings(validationResult.manifest?.checks, expectedChecks, `${label} manifest`); + const diagnostics = validationResult.diagnostics ?? []; + if (diagnostics.length > 0) { + fail(`Opcore self-check ${label} returned diagnostics.\n${diagnosticSummary(diagnostics)}`); + } +} + +function assertGraphBuild(payload) { + if ( + payload.providerStatus?.state !== "available" || + payload.graphPipeline?.summary?.operation !== "build" + ) { + fail("Opcore self-check repo-wide graph preparation did not produce an available build."); + } +} + +function assertStrictConfig(entries) { + let config; + try { + config = JSON.parse(readFileSync(configPath, "utf8")); + } catch (error) { + fail(`Opcore self-check could not read strict policy: ${errorMessage(error)}`); + } + const checks = config?.validation?.checks; + assertExactStrings(checks?.defaults, entries.map((entry) => entry.checkId), "validation.checks.defaults"); + assertExactStrings(checks?.disabled, [], "validation.checks.disabled"); + assertExactStrings( + config?.validation?.adapters, + [...new Set(entries.map((entry) => entry.adapter))], + "validation.adapters" + ); +} + +function assertExactStrings(actual, expected, label) { + if (!Array.isArray(actual) || actual.some((value) => typeof value !== "string")) { + fail(`Opcore self-check ${label} must be a string array.`); + } + if (actual.length !== expected.length || actual.some((value, index) => value !== expected[index])) { + fail( + `Opcore self-check ${label} mismatch.\n` + + `expected=${JSON.stringify(expected)}\nactual=${JSON.stringify(actual)}` + ); + } +} + +function assertUniqueStrings(values, label) { + if (new Set(values).size !== values.length) { + fail(`Opcore self-check ${label} must be unique.`); + } +} + +function assertSameStringSet(actual, expected, label) { + assertUniqueStrings(actual, label); + const actualSorted = [...actual].sort(); + const expectedSorted = [...expected].sort(); + assertExactStrings(actualSorted, expectedSorted, label); +} + +function diagnosticSummary(diagnostics = []) { + return diagnostics + .slice(0, 20) + .map((diagnostic) => { + const location = diagnostic.path ? `${diagnostic.path}: ` : ""; + const code = diagnostic.code ? ` [${diagnostic.code}]` : ""; + return `${location}${diagnostic.message ?? "validation diagnostic"}${code}`; + }) + .join("\n"); +} + +function commitExists(ref) { + const probe = spawnSync("git", ["rev-parse", "--verify", `${ref}^{commit}`], { + cwd: repoRoot, + encoding: "utf8", + stdio: ["ignore", "ignore", "ignore"] + }); + return probe.status === 0; +} + +function errorMessage(error) { + return error instanceof Error ? error.message : String(error); +} + +function fail(message) { + process.stderr.write(`${message}\n`); + process.exit(1); +} diff --git a/scripts/run-test-ci.mjs b/scripts/run-test-ci.mjs index 582224c..0bab42e 100644 --- a/scripts/run-test-ci.mjs +++ b/scripts/run-test-ci.mjs @@ -6,7 +6,7 @@ import { join } from "node:path"; const nativePackagingTest = "tests/native-packaging-policy.test.mjs"; const pythonValidationTest = "tests/validation-python.test.mjs"; const testFiles = readdirSync("tests") - .filter((file) => file.endsWith(".test.mjs")) + .filter((file) => file.endsWith(".test.mjs") || file.endsWith(".test.ts")) .map((file) => join("tests", file)) .sort(); const parallelSafeTests = testFiles.filter((file) => file !== nativePackagingTest && file !== pythonValidationTest); diff --git a/scripts/setup-current-tools.sh b/scripts/setup-current-tools.sh deleted file mode 100755 index d1fd4a9..0000000 --- a/scripts/setup-current-tools.sh +++ /dev/null @@ -1,319 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -# Current-tool invariant: wrappers execute external ACE-managed tools, never lattice packages under development. -repo_root="$(git rev-parse --show-toplevel 2>/dev/null || pwd)" -cd "${repo_root}" -repo_root_physical="$(pwd -P)" -runtime_root="${repo_root}/.ace/runtime" -bin_dir="${runtime_root}/bin" -tooling_json="${runtime_root}/tooling.json" -run_dir="${repo_root}/.ace/run" -rox_runtime_dir="${repo_root}/.ace/rox" -idle_ms="${LATTICE_TOOL_DAEMON_IDLE_TIMEOUT_MS:-1800000}" - -mkdir -p "${bin_dir}" "${run_dir}" "${rox_runtime_dir}" - -fail() { - printf 'error: %s\n' "$*" >&2 - exit 1 -} - -shell_quote() { - printf '%q' "$1" -} - -canonical_path() { - local path="$1" - node -e ' -const fs = require("node:fs"); - -try { - process.stdout.write(fs.realpathSync.native(process.argv[1])); -} catch { - process.exit(1); -} -' "${path}" -} - -canonical_dir() { - local path="$1" - (cd "${path}" 2>/dev/null && pwd -P) -} - -is_inside_repo() { - local path="$1" - local real_path - - real_path="$(canonical_path "${path}" 2>/dev/null || true)" - [[ -n "${real_path}" ]] || return 1 - case "${real_path}" in - "${repo_root_physical}"|"${repo_root_physical}"/*) return 0 ;; - *) return 1 ;; - esac -} - -implementation_package_dir() { - local tool_name="$1" - case "${tool_name}" in - crg) printf 'packages/graph' ;; - *) printf 'packages/%s' "${tool_name}" ;; - esac -} - -validate_source_path() { - local tool_name="$1" - local path="$2" - local implementation_path - - [[ -n "${path}" ]] || return 1 - [[ -x "${path}" ]] || fail "${tool_name} source is not executable: ${path}" - if is_inside_repo "${path}"; then - implementation_path="$(implementation_package_dir "${tool_name}")" - fail "${tool_name} source resolved inside lattice (${path}); use current external tools, not ${implementation_path}" - fi -} - -candidate_dirs=() -append_dir() { - local dir="$1" - [[ -n "${dir}" ]] || return 0 - candidate_dirs+=("${dir}") -} - -append_dir "${LATTICE_CURRENT_TOOLS_DIR:-}" -append_dir "${ACE_CURRENT_TOOLS_DIR:-}" -append_dir "${repo_root}/../covibes/.ace/runtime/bin" -append_dir "${repo_root}/../cmdproof/.ace/runtime/bin" -append_dir "${repo_root}/../robustness-engine/.ace/runtime/bin" -append_dir "${repo_root}/../orchestra/.ace/runtime/bin" -append_dir "${HOME}/code/covibes/cmdproof/.ace/runtime/bin" -append_dir "${HOME}/code/covibes/robustness-engine/.ace/runtime/bin" -append_dir "${HOME}/code/covibes/orchestra/.ace/runtime/bin" - -native_path_dirs=() -append_native_dir() { - local dir="$1" - [[ -n "${dir}" && -d "${dir}" ]] || return 0 - native_path_dirs+=("${dir}") -} - -resolve_tool() { - local tool_name="$1" - local upper_name - local env_var - local env_value - local dir - local candidate - local path_dir - local path_entries - - upper_name="$(printf '%s' "${tool_name}" | tr '[:lower:]' '[:upper:]')" - env_var="LATTICE_CURRENT_${upper_name}_PATH" - env_value="${!env_var:-}" - if [[ -n "${env_value}" ]]; then - validate_source_path "${tool_name}" "${env_value}" - canonical_path "${env_value}" - return 0 - fi - - for dir in "${candidate_dirs[@]}"; do - [[ -d "${dir}" ]] || continue - candidate="${dir}/${tool_name}" - if [[ -x "${candidate}" ]]; then - validate_source_path "${tool_name}" "${candidate}" - canonical_path "${candidate}" - return 0 - fi - done - - IFS=':' read -r -a path_entries <<< "${PATH:-}" - for path_dir in "${path_entries[@]}"; do - [[ -n "${path_dir}" ]] || path_dir="." - dir="$(canonical_dir "${path_dir}" 2>/dev/null || true)" - [[ -n "${dir}" ]] || continue - candidate="${dir}/${tool_name}" - if [[ -x "${candidate}" ]]; then - if is_inside_repo "${candidate}"; then - continue - fi - validate_source_path "${tool_name}" "${candidate}" - canonical_path "${candidate}" - return 0 - fi - done - - fail "could not resolve current external ${tool_name}; set LATTICE_CURRENT_TOOLS_DIR or LATTICE_CURRENT_${upper_name}_PATH" -} - -discover_native_tool_dir() { - local tool_name="$1" - local command_name="$2" - local dir - local candidate - - for dir in "${candidate_dirs[@]}"; do - [[ -d "${dir}" ]] || continue - candidate="${dir}/../native-tools/rox/${tool_name}/bin/${command_name}" - if [[ -x "${candidate}" ]] && "${candidate}" --help 2>/dev/null | grep -q 'Analyze source code'; then - canonical_dir "${candidate%/*}" - return 0 - fi - done - - while IFS= read -r candidate; do - if [[ -x "${candidate}" ]] && "${candidate}" --help 2>/dev/null | grep -q 'Analyze source code'; then - canonical_dir "${candidate%/*}" - return 0 - fi - done < <(find "${HOME}/.cache/ace/native-tools/rox/${tool_name}" -path "*/install/bin/${command_name}" -type f -perm -111 -print 2>/dev/null || true) -} - -write_wrapper() { - local tool_name="$1" - local source_path="$2" - local wrapper_path="${bin_dir}/${tool_name}" - local maybe_cix_root="" - local native_path="" - - if [[ "${tool_name}" == "cix" ]]; then - maybe_cix_root='export CIX_DAEMON_ROOT_DIR="${CIX_DAEMON_ROOT_DIR:-$repo_root_physical}"' - fi - if [[ "${tool_name}" == "rox" && "${#native_path_dirs[@]}" -gt 0 ]]; then - native_path="$(IFS=:; printf '%s' "${native_path_dirs[*]}")" - fi - - cat > "${wrapper_path}" < `.ace/runtime/bin/${name}`; - -const currentTool = (name, sourcePath) => ({ - available: true, - mode: "external-current-tool", - sourcePath, - wrapperPath: `${process.env.LATTICE_BIN_DIR}/${name}` -}); - -const aceTool = (name, sourcePath, options) => ({ - executablePath: relativeToolPath(name), - sourcePath, - version: null, - ready: true, - reason: null, - launcher: { - sourceStrategy: { - kind: "direct-path", - path: relativeToolPath(name) - }, - environment: { - prependPath: [".ace/runtime/bin", ...options.prependPath], - unset: [], - xdgRuntimeDir: options.xdgRuntimeDir - }, - healthProbe: { - args: ["--version"], - parser: "first-non-empty-output-line" - }, - daemonPolicy: { - retryWithoutDaemonOnVersionSkew: options.retryWithoutDaemonOnVersionSkew - } - }, - nativeDependencies: {} -}); - -const metadata = { - schemaVersion: 1, - packagingMode: "current_external_tools", - runtimeRoot: ".ace/runtime", - generatedAt: new Date().toISOString(), - generatedBy: { - name: "lattice-current-tool-setup", - version: "0.2.1" - }, - tooling: { - aceTools: { - binRoot: ".ace/runtime/bin", - tools: { - rox: aceTool("rox", process.env.LATTICE_ROX_SOURCE, { - xdgRuntimeDir: ".ace/rox", - retryWithoutDaemonOnVersionSkew: true, - prependPath: (process.env.LATTICE_ROX_NATIVE_PATH || "").split(/\s+/).filter(Boolean) - }), - crg: aceTool("crg", process.env.LATTICE_CRG_SOURCE, { - xdgRuntimeDir: null, - retryWithoutDaemonOnVersionSkew: false, - prependPath: [] - }), - cix: aceTool("cix", process.env.LATTICE_CIX_SOURCE, { - xdgRuntimeDir: ".ace/rox", - retryWithoutDaemonOnVersionSkew: false, - prependPath: [] - }) - } - } - }, - latticeCurrentTools: { - version: 1, - owner: "lattice-current-tool-setup", - root: process.env.LATTICE_REPO_ROOT, - runtimeRoot: process.env.LATTICE_RUNTIME_ROOT, - bin: process.env.LATTICE_BIN_DIR, - run: process.env.LATTICE_RUN_DIR, - roxRuntime: process.env.LATTICE_ROX_RUNTIME_DIR, - idleTimeoutMs: Number(process.env.LATTICE_IDLE_MS), - invariant: "wrappers execute current external ACE-managed tools, never lattice packages under development", - tools: { - rox: currentTool("rox", process.env.LATTICE_ROX_SOURCE), - crg: currentTool("crg", process.env.LATTICE_CRG_SOURCE), - cix: currentTool("cix", process.env.LATTICE_CIX_SOURCE) - } - } -}; - -fs.writeFileSync(process.env.LATTICE_TOOLING_JSON, `${JSON.stringify(metadata, null, 2)}\n`); -NODE - -printf 'current tool wrappers ready at %s\n' "${bin_dir}" diff --git a/tests/asp-dogfood-receipt.test.mjs b/tests/asp-dogfood-receipt.test.mjs index 4b20b01..f552926 100644 --- a/tests/asp-dogfood-receipt.test.mjs +++ b/tests/asp-dogfood-receipt.test.mjs @@ -5,7 +5,11 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; -import { createAspHostFixtureRepo, runCurrentToolGuardrails } from "../scripts/asp-dogfood-receipt-support.mjs"; +import { + createAspHostFixtureRepo, + locateAspManager, + sanitizeReceiptForProvenance +} from "../scripts/asp-dogfood-receipt-support.mjs"; import { validateAspDogfoodReceipt } from "../packages/contracts/dist/index.js"; import { invalidAspDogfoodCases, validAspDogfoodReceipt } from "./helpers/asp-dogfood-fixture.mjs"; @@ -24,7 +28,7 @@ describe("ASP dogfood receipt", () => { stdio: ["ignore", "pipe", "pipe"] }); assert.equal(result.status, 0, result.stderr); - assert.equal(JSON.parse(result.stdout).oldToolReplacementClaimed, false); + assert.equal(JSON.parse(result.stdout).selfValidation.status, "passed"); } finally { rmSync(temp, { recursive: true, force: true }); } @@ -43,22 +47,6 @@ describe("ASP dogfood receipt", () => { assert.equal(validateAspDogfoodReceipt(receipt).issue, "#120"); }); - it("can reuse recorded retained current-tool guardrails for receipt refreshes", () => { - const temp = mkdtempSync(join(tmpdir(), "lattice-asp-dogfood-guardrails-test-")); - const previous = process.env.OPCORE_ASP_DOGFOOD_REUSE_CURRENT_TOOL_GUARDRAILS; - try { - mkdirSync(join(temp, "docs", "release"), { recursive: true }); - const receipt = validAspDogfoodReceipt(); - writeFileSync(join(temp, "docs", "release", "asp-dogfood-receipt.json"), `${JSON.stringify(receipt, null, 2)}\n`); - process.env.OPCORE_ASP_DOGFOOD_REUSE_CURRENT_TOOL_GUARDRAILS = "1"; - assert.deepEqual(runCurrentToolGuardrails(temp, true), receipt.currentToolGuardrails); - } finally { - if (previous === undefined) delete process.env.OPCORE_ASP_DOGFOOD_REUSE_CURRENT_TOOL_GUARDRAILS; - else process.env.OPCORE_ASP_DOGFOOD_REUSE_CURRENT_TOOL_GUARDRAILS = previous; - rmSync(temp, { recursive: true, force: true }); - } - }); - it("creates an isolated changed fixture repo for clean-tree host dogfood", () => { const temp = mkdtempSync(join(tmpdir(), "lattice-asp-dogfood-fixture-test-")); try { @@ -78,4 +66,44 @@ describe("ASP dogfood receipt", () => { rmSync(temp, { recursive: true, force: true }); } }); + + it("locates an explicit ASP manager checkout and redacts its root", () => { + const temp = mkdtempSync(join(tmpdir(), "opcore-asp-manager-test-")); + const previous = process.env.ASP_DOGFOOD_ASP_REPO; + try { + const bin = join(temp, "packages", "asp", "bin", "asp"); + const cli = join(temp, "packages", "asp", "dist", "cli.js"); + mkdirSync(dirname(bin), { recursive: true }); + mkdirSync(dirname(cli), { recursive: true }); + writeFileSync(bin, "#!/usr/bin/env node\n"); + writeFileSync(cli, "export {};\n"); + runGit(temp, ["init", "-q"]); + runGit(temp, ["config", "user.name", "Opcore Test"]); + runGit(temp, ["config", "user.email", "opcore@example.invalid"]); + runGit(temp, ["add", "."]); + runGit(temp, ["commit", "-qm", "fixture"]); + + process.env.ASP_DOGFOOD_ASP_REPO = temp; + const manager = locateAspManager(repoRoot); + assert.equal(manager.aspRepoPath, temp); + assert.equal(manager.aspBinPath, bin); + assert.deepEqual( + sanitizeReceiptForProvenance({ root: temp, bin }, manager.aspRepoPath), + { root: "", bin: "/packages/asp/bin/asp" } + ); + } finally { + if (previous === undefined) delete process.env.ASP_DOGFOOD_ASP_REPO; + else process.env.ASP_DOGFOOD_ASP_REPO = previous; + rmSync(temp, { recursive: true, force: true }); + } + }); }); + +function runGit(cwd, args) { + const result = spawnSync("git", args, { + cwd, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"] + }); + assert.equal(result.status, 0, result.stderr); +} diff --git a/tests/asp-provider.test.mjs b/tests/asp-provider.test.mjs index 9c875d6..a96cbe0 100644 --- a/tests/asp-provider.test.mjs +++ b/tests/asp-provider.test.mjs @@ -874,7 +874,6 @@ describe("Opcore ASP provider", () => { assert.equal(manifest.noGateGrant, true); assert.match(manifest.checksums["dist/index.js"].sha256, /^[a-f0-9]{64}$/); assertNoForbiddenKeys(manifest); - assert.doesNotMatch(JSON.stringify(manifest), /\.ace\/runtime|\b(?:rox|crg|cix)\b|LATTICE_CURRENT_TOOLS_DIR/i); }); it("ships a canonical ASP server manifest with read-only access expectations", () => { @@ -928,7 +927,6 @@ describe("Opcore ASP provider", () => { }); assertNoForbiddenKeys(manifest); assert.doesNotMatch(JSON.stringify(manifest.provenance), /\b(?:trust|authority|gate|apply|decision|verdict|assurance)\b/i); - assert.doesNotMatch(JSON.stringify(manifest), /\.ace\/runtime|\b(?:rox|crg|cix)\b|LATTICE_CURRENT_TOOLS_DIR/i); }); it("removes stale legacy generated manifests before packaging", () => { @@ -995,7 +993,7 @@ describe("Opcore ASP provider claim scrub", () => { assert.match(readme, /\bwrite\b[^.\n]*\bfalse\b/i, "README must state write is false"); assert.match(readme, /\bnetwork\b[^.\n]*\bfalse\b/i, "README must state network is false"); assert.match(readme, /degraded|unsupported/i, "README must describe degraded/unsupported coverage honesty"); - assert.match(readme, /does \*\*not\*\* use ACE|not use ACE as a carrier/i, "README must disclaim ACE as carrier/provisioner"); + assert.match(readme, /does not invoke external development toolchains/i); }); it("rejects forbidden marketing tokens in package.json metadata", () => { diff --git a/tests/cli-canonical-surface.test.mjs b/tests/cli-canonical-surface.test.mjs index 35b385c..d578d75 100644 --- a/tests/cli-canonical-surface.test.mjs +++ b/tests/cli-canonical-surface.test.mjs @@ -1,7 +1,7 @@ import { describe, it } from "node:test"; import assert from "node:assert/strict"; import { spawnSync } from "node:child_process"; -import { cpSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs"; +import { cpSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; @@ -135,14 +135,12 @@ describe("canonical CLI surface", () => { assert.equal((await run(["graph", "inspect", "--json"], 64)).status, "unsupported"); }); - it("rejects direct old entrypoint execution through the router", async () => { - for (const bin of ["lattice", "crg", "cix", "rox"]) { - const result = await run(["status", "--json"], 64, bin); - assert.equal(result.status, "unsupported"); - assert.deepEqual(result.canonicalCommand, ["opcore", "unsupported"]); - assert.equal(Object.hasOwn(result, "alias"), false); - assert.equal(Object.hasOwn(result, removedLegacyCommandField), false); - } + it("rejects non-Opcore entrypoint execution through the router", async () => { + const result = await run(["status", "--json"], 64, "other-tool"); + assert.equal(result.status, "unsupported"); + assert.deepEqual(result.canonicalCommand, ["opcore", "unsupported"]); + assert.equal(Object.hasOwn(result, "alias"), false); + assert.equal(Object.hasOwn(result, removedLegacyCommandField), false); }); it("keeps opcore status on validationStatus without repoState", async () => { diff --git a/tests/command-router.test.mjs b/tests/command-router.test.mjs index 137ee23..5045e20 100644 --- a/tests/command-router.test.mjs +++ b/tests/command-router.test.mjs @@ -1,8 +1,6 @@ import { describe, it } from "node:test"; import assert from "node:assert/strict"; -import { spawnSync } from "node:child_process"; -import { DatabaseSync } from "node:sqlite"; -import { cpSync, existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { cpSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; @@ -100,16 +98,14 @@ describe("Opcore command router", () => { }); it("rejects non-Opcore entrypoints without alias metadata", async () => { - for (const bin of ["lattice", "crg", "cix", "rox"]) { - const routed = await routeCommand(["status", "--json"], bin); - assert.equal(routed.status, "unsupported"); - assert.equal(routed.exitCode, 64); - assert.deepEqual(routed.canonicalCommand, ["opcore", "unsupported"]); - assert.equal(routed.owner, "runtime"); - assert.equal(Object.hasOwn(routed, "alias"), false); - assert.equal(Object.hasOwn(routed, removedLegacyCommandField), false); - assertCommandTiming(routed); - } + const routed = await routeCommand(["status", "--json"], "other-tool"); + assert.equal(routed.status, "unsupported"); + assert.equal(routed.exitCode, 64); + assert.deepEqual(routed.canonicalCommand, ["opcore", "unsupported"]); + assert.equal(routed.owner, "runtime"); + assert.equal(Object.hasOwn(routed, "alias"), false); + assert.equal(Object.hasOwn(routed, removedLegacyCommandField), false); + assertCommandTiming(routed); }); it("routes canonical graph status, query, and search through the graph adapter", async () => { @@ -380,7 +376,7 @@ describe("Opcore command router", () => { assert.equal(helpJson.exitCode, 0); assert.equal(doctorJson.runtimeInfo.packageName, "opcore"); assert.equal(doctorJson.opcoreDoctor.schemaVersion, 1); - assert.equal(doctorJson.opcoreDoctor.config.state, "missing"); + assert.equal(doctorJson.opcoreDoctor.config.state, "found"); assert.equal(doctorJson.opcoreDoctor.checks.count > 0, true); assert.match(doctorJson.opcoreDoctor.generatedState.guidance, /\.opcore/); assertCommandTiming(statusJson); @@ -502,16 +498,3 @@ async function withFixtureCopy(runFixture) { function skipGeneratedStore(source) { return !source.includes(`${join(".opcore", "graph")}`); } - -function corruptSnapshotSchema(fixtureRoot) { - const db = new DatabaseSync(join(fixtureRoot, ".opcore/graph/graph.db")); - try { - const metadata = JSON.parse(db.prepare("select value from metadata where key = 'lattice_snapshot_metadata'").get().value); - metadata.schemaVersion = 2; - const value = JSON.stringify(metadata); - db.prepare("update metadata set value = ? where key = 'lattice_snapshot_metadata'").run(value); - db.prepare("update lattice_store set value = ? where key = 'metadata_json'").run(value); - } finally { - db.close(); - } -} diff --git a/tests/conformance.test.mjs b/tests/conformance.test.mjs index 3c8d1c7..9bbd970 100644 --- a/tests/conformance.test.mjs +++ b/tests/conformance.test.mjs @@ -29,11 +29,6 @@ const expectedIds = [ "inspect-symbol-parity-v1", "validation-contract-v1", "installed-artifact-smoke-v1", - "graph-reference-evidence-manifest-v1", - "graph-reference-evidence-sqlite-fixtures-v1", - "graph-reference-evidence-daemon-socket-fixtures-v1", - "graph-reference-evidence-golden-corpus-v1", - "graph-reference-evidence-baseline-receipts-v1", "graph-release-readiness-v1" ]; @@ -92,9 +87,7 @@ describe("conformance fixture metadata", () => { ? "#17" : fixture.id === "descriptor-discovery-v1" || fixture.id === "installed-artifact-smoke-v1" ? "#28" - : fixture.id.startsWith("graph-reference-evidence-") - ? "#19" - : "#3" + : "#3" ); assert.equal(fixture.schemaVersion, 1); assert.notEqual(fixture.status, "placeholder"); @@ -205,7 +198,7 @@ describe("conformance fixture metadata", () => { ["#13", "#14", "#15", "#16"] ); const text = JSON.stringify(descriptor); - assert.doesNotMatch(text, /(^|[\\/"'\s])\.ace(?:[\\/"'\s]|$)|LATTICE_CURRENT_TOOLS_DIR|\/Users\/tom|(^|[\\/\s])(?:lattice|crg|cix|rox)(?:$|[\\/\s])/i); + assert.doesNotMatch(text, /\/Users\/tom/i); }); it("describes canonical router metadata for descriptor planning", () => { @@ -344,7 +337,7 @@ describe("conformance fixture metadata", () => { assert.deepEqual(graphServe.protocols, ["opcore.graph.daemon", "jsonrpc-2.0"]); assert.deepEqual(graphServe.operations, ["ping", "status", "query", "search", "shutdown"]); assert.ok(graphServe.failureStates.includes("schema_mismatch")); - assert.equal(graphServe.dataFile, "packages/fixtures/graph-reference-evidence/daemon-socket-fixtures.json"); + assert.equal(graphServe.dataFile, "packages/fixtures/graph-serve/serve-fixtures.json"); assert.ok(serveFixture.envelopes.some((entry) => entry.id === "serve-jsonl-ping")); assert.ok(serveFixture.envelopes.some((entry) => entry.id === "mcp-initialize")); }); @@ -411,21 +404,6 @@ describe("conformance fixture metadata", () => { assert.deepEqual(validation.degradedTools, ["mypy", "pyright", "ruff", "pytest"]); }); - it("includes concrete source-free #19 reference evidence data files", () => { - for (const id of expectedIds.filter((entry) => entry.startsWith("graph-reference-evidence-"))) { - const fixture = fixtureById(id); - assert.equal(fixture.issue, "#19"); - assert.equal(fixture.packageTrack, "fixtures"); - assert.equal(fixture.containsSourceCode, false); - assert.equal(fixture.origin, "covibes-authored-synthetic"); - assert.ok(fixture.dataFile?.startsWith("packages/fixtures/graph-reference-evidence/"), `bad dataFile ${fixture.dataFile}`); - const url = new URL(`../${fixture.dataFile}`, import.meta.url); - const content = readFileSync(url, "utf8"); - assert.ok(JSON.parse(content)); - assert.doesNotMatch(content, /tirth8205|pyproject\.toml|setup\.py|setup\.cfg|Pipfile|git clone/i); - } - }); - it("describes #17 graph release readiness metadata", () => { const graphRelease = fixtureById("graph-release-readiness-v1").graphRelease; const receipt = JSON.parse(readFileSync(graphRelease.receipt, "utf8")); diff --git a/tests/contracts.test.mjs b/tests/contracts.test.mjs index 7a55c32..df774e7 100644 --- a/tests/contracts.test.mjs +++ b/tests/contracts.test.mjs @@ -17,7 +17,6 @@ import { createCommandRouterResult, editRefusalCategories, aspDogfoodForbiddenProviderMarkers, - aspDogfoodGuardrailIds, graphReleaseBenchmarkMetrics, graphCoreNativePackageNameForTarget, graphCoreNativeSupportedTargets, @@ -31,14 +30,13 @@ import { opcoreMeasureLatencyFindingStatuses, opcoreMeasureLatencyStatuses, releaseReceiptCommandGroups, - releaseCutoverCurrentToolGuardrailIds, releaseCutoverNegativeCheckIds, releaseCutoverPythonCommandIds, releaseCutoverRustCommandIds, releaseReceiptPackageNames, releaseReceiptReportIds, requiredContextDocPolicy, - rustOldRoxComparisonSurfaceIds, + graphReleaseSurfaceClassifications, graphDaemonOperations, graphExtractionDiagnosticCategories, graphFactQueryKinds, @@ -48,7 +46,6 @@ import { graphSnapshotMetadataKeys, inspectFailureCategories, providerFailureCategories, - graphReferenceEvidenceClassifications, parseCommandArgv, requiredGraphEdgeKinds, requiredGraphNodeKinds, @@ -76,13 +73,11 @@ import { validateGraphProviderCapabilityHandshake, validateGraphReleaseReceipt, validateAspDogfoodReceipt, - validateRustOldRoxComparisonReceipt, validateReleaseCutoverReceipt, validateReleaseReceipt, validateGraphSearchRequest, validateGraphSearchResult, validateGraphServeTransportStatus, - validateGraphReferenceEvidenceManifest, validateInspectRouteResult, validateManagedToolDescriptor, validateLatencyBudget, @@ -301,18 +296,6 @@ describe("Opcore shared contracts", () => { "python-relevant-tests-no-pytest", "python-toolchain-degraded-no-tools" ]); - assert.deepEqual(releaseCutoverCurrentToolGuardrailIds, [ - "current-tools-validate-changed", - "current-tools-validate-rust-graph" - ]); - assert.deepEqual(rustOldRoxComparisonSurfaceIds, [ - "rust.rustdoc", - "rust.import-graph", - "rust.dead-code", - "rust.unused-deps", - "rust.function-metrics", - "current-tools:validate-rust-graph" - ]); assert.deepEqual(graphReleaseBenchmarkMetrics, [ "install_setup_ms", "cold_build_ms", @@ -338,7 +321,7 @@ describe("Opcore shared contracts", () => { "children_of", "file_summary" ]); - assert.deepEqual(graphReferenceEvidenceClassifications, ["required", "supporting", "optional", "deferred"]); + assert.deepEqual(graphReleaseSurfaceClassifications, ["required", "supporting", "optional", "deferred"]); assert.deepEqual(graphReleaseDeferredChildren, ["#13", "#14", "#15", "#16"]); assert.deepEqual(graphReleaseOptionalAnalysisSurfaces, expectedOptionalAnalysisSurfaces()); }); @@ -2301,7 +2284,7 @@ describe("Opcore shared contracts", () => { assert.deepEqual(surfaceContracts(descriptor.optionalSurfaces), expectedOptionalAnalysisSurfaces()); }); - it("rejects managed-tool descriptors with old aliases or unsafe package paths", () => { + it("rejects managed-tool descriptors with noncanonical entrypoints or unsafe package paths", () => { assert.throws( () => validateManagedToolDescriptor({ @@ -2309,11 +2292,11 @@ describe("Opcore shared contracts", () => { entrypoints: [ { ...validManagedToolDescriptor().entrypoints[0], - bin: ["c", "r", "g"].join("") + bin: "unexpected" } ] }), - /old public aliases/ + /entrypoint bins/ ); assert.throws( () => @@ -2322,7 +2305,7 @@ describe("Opcore shared contracts", () => { artifacts: [ { ...validManagedToolDescriptor().artifacts[0], - path: "/tmp/lattice" + path: "/tmp/tool" } ] }), @@ -2348,7 +2331,7 @@ describe("Opcore shared contracts", () => { artifacts: [ { ...validManagedToolDescriptor().artifacts[0], - path: ".ace/runtime/bin/lattice" + path: ".agents/runtime/bin/tool" } ] }), @@ -2361,7 +2344,7 @@ describe("Opcore shared contracts", () => { artifacts: [ { ...validManagedToolDescriptor().artifacts[0], - path: ".ace" + path: ".agents" } ] }), @@ -2374,7 +2357,7 @@ describe("Opcore shared contracts", () => { artifacts: [ { ...validManagedToolDescriptor().artifacts[0], - path: "dist/.ace" + path: "dist/.agents" } ] }), @@ -2387,12 +2370,12 @@ describe("Opcore shared contracts", () => { provenanceHooks: [ { id: "private-runtime-wrapper", - command: [".ace\\runtime\\bin\\lattice", "status"], + command: [".agents\\runtime\\bin\\tool", "status"], expectedExitCode: 0 } ] }), - /current-tool runtime paths/ + /private runtime paths/ ); assert.throws( () => @@ -2401,7 +2384,7 @@ describe("Opcore shared contracts", () => { artifacts: [ { ...validManagedToolDescriptor().artifacts[0], - path: "~/lattice" + path: "~/tool" } ] }), @@ -2685,69 +2668,6 @@ describe("Opcore shared contracts", () => { ); }); - it("accepts graph reference evidence manifests and rejects invalid coverage", () => { - const manifest = validGraphReferenceEvidenceManifest(); - assert.equal(validateGraphReferenceEvidenceManifest(manifest).issue, "#19"); - assert.deepEqual(surfaceContracts(manifest.optionalAnalysisSurfaces), expectedOptionalAnalysisSurfaces()); - assert.throws( - () => - validateGraphReferenceEvidenceManifest({ - ...manifest, - commandSurfaces: [ - { - ...manifest.commandSurfaces[0], - classification: "release_blocking" - } - ] - }), - /Unknown graph reference evidence surface classification/ - ); - assert.throws( - () => - validateGraphReferenceEvidenceManifest({ - ...manifest, - commandSurfaces: [ - { - ...manifest.commandSurfaces[0], - fixtures: [] - } - ] - }), - /required surface must include fixture coverage/ - ); - assert.throws( - () => - validateGraphReferenceEvidenceManifest({ - ...manifest, - optionalAnalysisSurfaces: manifest.optionalAnalysisSurfaces.map((surface) => - surface.id === "flows" ? { ...surface, issue: "#13" } : surface - ) - }), - /optional analysis surfaces must match staged graph release surfaces/ - ); - assert.throws( - () => - validateGraphReferenceEvidenceManifest({ - ...manifest, - optionalAnalysisSurfaces: manifest.optionalAnalysisSurfaces.map((surface) => - surface.id === "flows" ? { ...surface, classification: "required" } : surface - ) - }), - /optional analysis surfaces must not mark staged graph release surfaces as required/ - ); - assert.throws( - () => - validateGraphReferenceEvidenceManifest({ - ...manifest, - provenance: { - ...manifest.provenance, - containsPythonCrgSource: true - } - }), - /must not contain Python CRG source/ - ); - }); - it("accepts graph release receipts and rejects incomplete or tainted release evidence", () => { const receipt = validGraphReleaseReceipt(); assert.equal(validateGraphReleaseReceipt(receipt).issue, "#17"); @@ -2809,6 +2729,10 @@ describe("Opcore shared contracts", () => { }), /Graph release serve transport ids must exactly match/ ); + }); + + it("rejects tainted graph release transport, package, and handoff evidence", () => { + const receipt = validGraphReleaseReceipt(); assert.throws( () => validateGraphReleaseReceipt({ @@ -2942,13 +2866,13 @@ describe("Opcore shared contracts", () => { entry.packageName === "opcore" ? { ...entry, - bins: { ...entry.bins, crg: "dist/index.js" }, - manifest: { ...entry.manifest, bins: { ...entry.manifest.bins, crg: "dist/index.js" } } + bins: { ...entry.bins, unexpected: "dist/index.js" }, + manifest: { ...entry.manifest, bins: { ...entry.manifest.bins, unexpected: "dist/index.js" } } } : entry ) }), - /old public bin/ + /Opcore package bins/ ); assert.throws( () => @@ -3001,11 +2925,7 @@ describe("Opcore shared contracts", () => { receipt.negativeChecks.map((entry) => entry.id), releaseCutoverNegativeCheckIds ); - assert.deepEqual( - receipt.currentToolGuardrails.map((entry) => entry.id), - releaseCutoverCurrentToolGuardrailIds - ); - assert.equal(receipt.oldToolReplacementClaimed, false); + assert.equal(receipt.selfValidation.status, "passed"); assert.throws( () => validateReleaseCutoverReceipt({ @@ -3054,6 +2974,10 @@ describe("Opcore shared contracts", () => { }), /canonical .* bin/ ); + }); + + it("rejects incomplete cutover language and command evidence", () => { + const receipt = validReleaseCutoverReceipt(); assert.throws( () => validateReleaseCutoverReceipt({ @@ -3103,7 +3027,11 @@ describe("Opcore shared contracts", () => { ...receipt, pythonCommandReceipts: receipt.pythonCommandReceipts.map((entry) => entry.id === "graph-python-search" - ? { ...entry, command: ["lattice", "graph", "search", "Greeting"], canonicalCommand: ["lattice", "graph", "search", "Greeting"] } + ? { + ...entry, + command: ["node", "graph", "search", "Greeting"], + canonicalCommand: ["node", "graph", "search", "Greeting"] + } : entry ) }), @@ -3121,6 +3049,10 @@ describe("Opcore shared contracts", () => { }), /graph-python-query evidence/ ); + }); + + it("rejects incomplete cutover policy and self-validation evidence", () => { + const receipt = validReleaseCutoverReceipt(); assert.throws( () => validateReleaseCutoverReceipt({ @@ -3154,7 +3086,7 @@ describe("Opcore shared contracts", () => { ...receipt, negativeChecks: receipt.negativeChecks.map((entry) => entry.id === "missing-required-graph-check" - ? { ...entry, command: ["lattice", "check", "files", "src/index.ts", "--graph-mode", "required"] } + ? { ...entry, command: ["node", "check", "files", "src/index.ts", "--graph-mode", "required"] } : entry ) }), @@ -3164,40 +3096,9 @@ describe("Opcore shared contracts", () => { () => validateReleaseCutoverReceipt({ ...receipt, - currentToolGuardrails: receipt.currentToolGuardrails.filter((entry) => entry.id !== "current-tools-validate-changed") + selfValidation: { ...receipt.selfValidation, status: "failed", exitCode: 1 } }), - /current-tool guardrails/ - ); - assert.throws( - () => - validateReleaseCutoverReceipt({ - ...receipt, - currentToolGuardrails: receipt.currentToolGuardrails.map((entry) => - entry.id === "current-tools-validate-changed" - ? { ...entry, status: "retained-not-run", exitCode: null } - : entry - ) - }), - /status must be passed/ - ); - assert.throws( - () => - validateReleaseCutoverReceipt({ - ...receipt, - environmentIsolation: { - ...receipt.environmentIsolation, - oldBinsAbsent: { crg: true, cix: true, rox: true } - } - }), - /old public bins/ - ); - assert.throws( - () => - validateReleaseCutoverReceipt({ - ...receipt, - oldToolReplacementClaimed: true - }), - /old-tool replacement/ + /self-validation status must be passed/ ); assert.throws( () => @@ -3209,84 +3110,14 @@ describe("Opcore shared contracts", () => { ); }); - it("accepts old-Rox comparison receipts and rejects replacement overclaims", () => { - const receipt = validRustOldRoxComparisonReceipt(); - assert.equal(validateRustOldRoxComparisonReceipt(receipt).oldToolReplacementClaimed, false); - assert.deepEqual( - receipt.surfaces.map((entry) => entry.id), - rustOldRoxComparisonSurfaceIds - ); - assert.equal(receipt.surfaces.every((entry) => ["retained", "deferred"].includes(entry.replacementStatus)), true); - assert.throws( - () => - validateRustOldRoxComparisonReceipt({ - ...receipt, - surfaces: receipt.surfaces.filter((entry) => entry.id !== "rust.dead-code") - }), - /old-Rox comparison surfaces/ - ); - assert.throws( - () => - validateRustOldRoxComparisonReceipt({ - ...receipt, - oldToolReplacementClaimed: true - }), - /must not claim old-tool replacement/ - ); - assert.throws( - () => - validateRustOldRoxComparisonReceipt({ - ...receipt, - publicReleaseActions: ["publish"] - }), - /public release actions/ - ); - assert.throws( - () => - validateRustOldRoxComparisonReceipt({ - ...receipt, - surfaces: receipt.surfaces.map((entry) => - entry.id === "rust.function-metrics" ? { ...entry, replacementStatus: "replaced" } : entry - ) - }), - /replacementStatus/ - ); - assert.throws( - () => - validateRustOldRoxComparisonReceipt({ - ...receipt, - surfaces: receipt.surfaces.map((entry) => - entry.id === "rust.import-graph" ? { ...entry, graphEvidenceExists: true, graphEvidence: [] } : entry - ) - }), - /graph evidence/ - ); - const artifact = JSON.parse( - readFileSync(new URL("../docs/validation/rust-old-rox-comparison-receipt-2026-06-27.json", import.meta.url), "utf8") - ); - assert.equal(validateRustOldRoxComparisonReceipt(artifact).oldToolReplacementClaimed, false); - assert.deepEqual( - artifact.surfaces.map((entry) => entry.id), - rustOldRoxComparisonSurfaceIds - ); - }); - - it("accepts ASP dogfood receipts and rejects authority, entrypoint, parity, and guardrail overclaims", () => { + it("accepts ASP dogfood receipts and rejects authority, entrypoint, and parity overclaims", () => { const receipt = validAspDogfoodReceipt(); assert.equal(validateAspDogfoodReceipt(receipt).issue, "#120"); assert.equal(receipt.bootstrapSource, "local-sibling"); assert.deepEqual(receipt.provider.command, ["opcore-asp-provider", "--stdio"]); assert.equal(receipt.hostFixture.sourceRepoMutated, false); assert.deepEqual(receipt.hostFixture.changedPaths, ["src/dogfood.ts"]); - assert.deepEqual(receipt.currentToolGuardrails.map((entry) => entry.id), aspDogfoodGuardrailIds); - assert.throws( - () => - validateAspDogfoodReceipt({ - ...receipt, - currentToolGuardrails: receipt.currentToolGuardrails.filter((entry) => entry.id !== "current-tools-validate-rust-graph") - }), - /guardrail ids/ - ); + assert.equal(receipt.selfValidation.status, "passed"); assert.throws( () => validateAspDogfoodReceipt({ @@ -3303,12 +3134,9 @@ describe("Opcore shared contracts", () => { () => validateAspDogfoodReceipt({ ...receipt, - provider: { - ...receipt.provider, - binPath: ".ace/runtime/bin/opcore-asp-provider" - } + provider: { ...receipt.provider, binPath: "private/runtime/opcore-asp-provider" } }), - /node_modules\/\.bin\/opcore-asp-provider|forbidden marker/ + /node_modules\/\.bin\/opcore-asp-provider/ ); assert.throws( () => @@ -3352,6 +3180,10 @@ describe("Opcore shared contracts", () => { }), /host-owned field|host-owned decision|hostOwnedFieldLeak/ ); + }); + + it("rejects ASP dogfood parity and host-authority overclaims", () => { + const receipt = validAspDogfoodReceipt(); assert.throws( () => validateAspDogfoodReceipt({ @@ -4879,130 +4711,6 @@ function validOpcoreDoctor() { }; } -function validGraphReferenceEvidenceManifest() { - return { - schemaVersion: 1, - issue: "#19", - origin: "covibes-authored-synthetic", - fixtureRefs: [ - "packages/fixtures/graph-reference-evidence/sqlite-fixtures.json", - "packages/fixtures/graph-reference-evidence/daemon-socket-fixtures.json", - "packages/fixtures/graph-reference-evidence/golden-corpus.json", - "packages/fixtures/graph-reference-evidence/baseline-receipts.json" - ], - commandSurfaces: [ - { - id: "graph-reference-status", - classification: "required", - referenceTool: "current external graph dev wrapper", - referenceCommand: ["status"], - canonicalCommand: ["opcore", "graph", "status"], - flags: ["--repo", "--json"], - positionals: [], - fixtures: ["status-json"], - exitSemantics: { - success: 0, - failure: "nonzero" - } - } - ], - jsonOutputSurfaces: [ - { - id: "status-json", - command: "status", - classification: "required", - requiredFields: ["status", "summary"], - fixtures: ["status-json"], - exitSemantics: { - success: 0, - failure: "nonzero" - } - } - ], - sqliteFixtures: [ - { - id: "sqlite-required-views", - classification: "required", - fixture: "packages/fixtures/graph-reference-evidence/sqlite-fixtures.json", - tables: ["metadata", "nodes", "edges"], - indexes: ["idx_nodes_file"], - metadataKeys: ["schema_version"], - nodeKinds: ["File", "Function", "Test", "Module", "Struct", "Enum", "Trait", "Impl", "Method", "TypeAlias", "Const", "Static", "Macro"], - edgeKinds: ["CALLS", "CONTAINS", "IMPORTS_FROM", "TESTED_BY", "IMPLEMENTS", "DEPENDS_ON", "INHERITS"], - directReaderQueries: ["status-counts"], - fixtures: ["sqlite-fixtures"] - } - ], - daemonFixtures: [ - { - id: "daemon-hot-query", - classification: "required", - fixture: "packages/fixtures/graph-reference-evidence/daemon-socket-fixtures.json", - protocol: "opcore.graph.daemon", - envelopes: ["ping-request", "success-response"], - fixtures: ["daemon-fixtures"] - } - ], - baselineReceipts: [ - { - id: "install-setup", - metric: "install_setup_ms", - classification: "required", - receipt: "packages/fixtures/graph-reference-evidence/baseline-receipts.json", - label: "reference_evidence_non_implementation_input", - sourceAvailability: "unavailable", - nonImplementationInput: true, - fixtures: ["baseline-receipts"] - } - ], - optionalAnalysisSurfaces: [ - { - issue: "#13", - id: "coverage", - classification: "deferred", - status: "deferred", - fixtures: ["coverage-deferred-marker"] - }, - { - issue: "#14", - id: "flows", - classification: "optional", - status: "deferred", - fixtures: ["sqlite-fixtures"] - }, - { - issue: "#15", - id: "communities", - classification: "optional", - status: "deferred", - fixtures: ["sqlite-fixtures"] - }, - { - issue: "#16", - id: "read_only_suggestions", - classification: "supporting", - status: "deferred", - fixtures: ["read-only-refactor-baseline"] - } - ], - goldenCorpus: { - id: "graph-reference-evidence-golden-corpus-v1", - classification: "required", - fixture: "packages/fixtures/graph-reference-evidence/golden-corpus.json", - covers: ["parser", "store", "query", "search", "freshness", "status"], - fixtures: ["golden-corpus"] - }, - provenance: { - containsPythonCrgSource: false, - containsPackageMetadata: false, - containsGitHistory: false, - referenceReceiptsAreImplementationInput: false, - implementationPackageNames: ["@the-open-engine/opcore-graph"], - allowedMentionPaths: ["docs/graph-reference-evidence/", "packages/fixtures/graph-reference-evidence/"] - } - }; -} - function validGraphReleaseReceipt() { const commandCoverage = graphReleaseCoreCommandIds.map((id) => { const command = id.replace("opcore-graph-", ""); @@ -5100,7 +4808,7 @@ function validGraphReleaseReceipt() { value: 1, unit: metric.endsWith("_bytes") ? "bytes" : "ms", baselineIssue: "#19", - baselineReceipt: "packages/fixtures/graph-reference-evidence/baseline-receipts.json", + baselineReceipt: "docs/release/graph-release-receipt.json", comparison: "recorded" })), packageInspection: { @@ -5111,10 +4819,10 @@ function validGraphReleaseReceipt() { forbiddenMarkersAbsent: true, generatedBuildMetadataAbsent: true, privatePathsAbsent: true, - pythonCrgSourceAbsent: true, - pythonGraphPackageMetadataAbsent: true, - pythonCrgGitHistoryAbsent: true, - forbiddenImplementationPackageNamesAbsent: true, + sourceProvenanceAbsent: true, + packageMetadataAbsent: true, + gitHistoryAbsent: true, + foreignImplementationNamesAbsent: true, inspections: ["npm-pack-dry-run"] }, supportedNativeTargets: graphCoreNativeSupportedTargets, @@ -5197,7 +4905,7 @@ function validGraphReleaseReceipt() { issue, receiptPath: "docs/release/graph-release-receipt.payload.json", checksumSha256: "b".repeat(64), - rollbackNote: "Keep ACE wrappers on current external tools if receipt regresses." + rollbackNote: "Block release and repair Opcore self-validation if this receipt regresses." })) }; } @@ -5569,18 +5277,9 @@ function validReleaseCutoverReceipt() { resolvedChecksums: descriptor.resolvedChecksums }, environmentIsolation: { - currentToolEnvCleared: true, - clearedEnvVarCount: 5, pathSanitized: true, - aceRuntimeBinExcluded: true, - siblingCovibesExcluded: true, - opcoreBinOnly: true, - oldBinsAbsent: { - lattice: true, - crg: true, - cix: true, - rox: true - } + siblingRepositoriesExcluded: true, + opcoreBinsVerified: true }, commandReceipts, rustCommandReceipts, @@ -5629,35 +5328,19 @@ function validReleaseCutoverReceipt() { assertion: "missing Python toolchain stayed degraded" } ], - currentToolGuardrails: [ - { - id: "current-tools-validate-changed", - command: ["npm", "run", "current-tools:validate-changed"], - status: "passed", - exitCode: 0, - stdoutSha256: "7".repeat(64), - stderrSha256: "8".repeat(64), - retained: true, - assertion: "retained changed-file guardrail", - oldToolReplacementClaimed: false - }, - { - id: "current-tools-validate-rust-graph", - command: ["npm", "run", "current-tools:validate-rust-graph"], - status: "passed", - exitCode: 0, - stdoutSha256: "7".repeat(64), - stderrSha256: "8".repeat(64), - retained: true, - assertion: "retained Rust graph guardrail", - oldToolReplacementClaimed: false - } - ], - oldToolReplacementClaimed: false, + selfValidation: { + id: "opcore-self-check", + command: ["npm", "run", "opcore:self-check"], + status: "passed", + exitCode: 0, + stdoutSha256: "7".repeat(64), + stderrSha256: "8".repeat(64), + assertion: "Opcore self-validation passed" + }, forbiddenMarkerScan: { scannedTextCount: 12, findingCount: 0, - markersBlocked: ["private-runtime", "current-tool-env", "private-home", "old-tool-bins"] + markersBlocked: ["private-home", "launch-claim"] }, inputEvidence: [ { @@ -5692,72 +5375,6 @@ function pythonCutoverEvidence(id) { }[id]; } -function validRustOldRoxComparisonReceipt() { - const surface = (id, graphEvidenceExists, graphEvidence, stillUniquelyProvidedByCurrentTools, replacementStatus = "retained") => ({ - id, - graphEvidenceExists, - graphEvidence, - stillUniquelyProvidedByCurrentTools, - replacementStatus - }); - return { - schemaVersion: 1, - issue: "#29", - origin: "covibes-authored-old-rox-comparison", - generatedAt: "2026-06-27T00:00:00.000Z", - privateRepo: true, - oldToolReplacementClaimed: false, - publicReleaseActions: [], - surfaces: [ - surface( - "rust.rustdoc", - false, - ["No graph fact replaces rustdoc diagnostics."], - ["rustdoc diagnostics and broken intra-doc link policy remain current-tool evidence."] - ), - surface( - "rust.import-graph", - true, - ["Rust graph emits IMPORTS_FROM and DEPENDS_ON edges for module files."], - ["Rox/current tooling still uniquely provides rustdoc and cargo-depgraph-enriched import checks."], - "deferred" - ), - surface( - "rust.dead-code", - true, - ["Rust graph emits exported symbol metadata and graph-backed dead public export signals."], - ["Cargo dead_code diagnostics and retained Rox gate behavior still uniquely cover compiler reachability."] - ), - surface( - "rust.unused-deps", - false, - ["No graph fact replaces cargo-udeps unused dependency analysis."], - ["cargo-udeps/Rox unused dependency detection remains current-tool evidence."] - ), - surface( - "rust.function-metrics", - true, - ["Rust graph emits symbol spans and signatures for functions and methods."], - ["rust-code-analysis complexity and parameter thresholds remain current-tool evidence."] - ), - surface( - "current-tools:validate-rust-graph", - false, - ["Graph receipts do not replace the aggregate current-tools Rust graph gate."], - ["npm run current-tools:validate-rust-graph remains the retained aggregate guardrail."] - ) - ], - guardrails: [ - { - id: "current-tools:validate-rust-graph", - command: ["npm", "run", "current-tools:validate-rust-graph"], - replacementStatus: "retained", - oldToolReplacementClaimed: false - } - ] - }; -} - function validAspDogfoodReceipt() { const cutover = validReleaseCutoverReceipt(); const aspRepo = covibesPath("agent-server-protocol"); @@ -5833,8 +5450,7 @@ function validAspDogfoodReceipt() { temp: true, isolated: true, sharedStateMutated: false, - pathSanitized: true, - aceRuntimeBinExcluded: true + pathSanitized: true }, hostFixture: { repo: hostFixtureRepo, @@ -5892,32 +5508,18 @@ function validAspDogfoodReceipt() { diagnosticsCount: 0, hostOwnedFieldLeak: false }, - currentToolGuardrails: [ - { ...command("current-tools-validate-changed", ["npm", "run", "current-tools:validate-changed"]), retained: true }, - { ...command("current-tools-validate-rust-graph", ["npm", "run", "current-tools:validate-rust-graph"]), retained: true }, - { - id: "current-tools-validate-all", - command: ["npm", "run", "current-tools:validate-all"], - status: "retained-not-run", - exitCode: null, - stdoutSha256: "0".repeat(64), - stderrSha256: "0".repeat(64), - retained: true, - assertion: "retained by default" - } - ], + selfValidation: cutover.selfValidation, unsupportedSurfaces: [ { surface: "inspect", status: "parity-blocker", cleanCoverage: false, blocker: "inspect not mapped into ASP #120" }, - { surface: "edit", status: "retained-old-tool-gate", cleanCoverage: false, blocker: "edit not mapped into ASP #120" } + { surface: "edit", status: "parity-blocker", cleanCoverage: false, blocker: "edit not mapped into ASP #120" } ], - parityBlockers: [{ source: "docs/planning/old-tool-compatibility-matrix.md:1", detail: "old-tool guardrails retained" }], + parityBlockers: [], authority: { hostOwnsDecisions: true, providerOutputIsHostDecision: false, localAuthorityOverride: { present: false, sharedAuthorityWeakened: false } }, publicReleaseActions: [], - oldToolReplacementClaimed: false, forbiddenMarkerScan: { scannedTextCount: 2, findingCount: 0, diff --git a/tests/current-tools.test.mjs b/tests/current-tools.test.mjs deleted file mode 100644 index 48f1e67..0000000 --- a/tests/current-tools.test.mjs +++ /dev/null @@ -1,199 +0,0 @@ -import { describe, it } from "node:test"; -import assert from "node:assert/strict"; -import { - chmodSync, - cpSync, - existsSync, - mkdtempSync, - readFileSync, - realpathSync, - rmSync, - symlinkSync, - writeFileSync -} from "node:fs"; -import { tmpdir } from "node:os"; -import { dirname, join, relative, resolve } from "node:path"; -import { fileURLToPath } from "node:url"; -import { spawnSync } from "node:child_process"; - -const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); -const copiedRepoSkips = new Set([ - ".git", - "node_modules", - "dist", - ".ace", - ".agents", - ".claude", - ".codex", - ".gemini", - ".opencode", - ".code-review-graph", - ".rox-cache", - ".robustness-engine-cache", - "target" -]); - -describe("current tool setup", () => { - it("generates wrappers and tooling manifest from external tools", () => { - const repo = tempRepo(); - const tools = fakeTools(); - const result = run(repo, "bash", ["scripts/setup-current-tools.sh"], { - env: { LATTICE_CURRENT_TOOLS_DIR: tools } - }); - assert.match(result.stdout, /current tool wrappers ready/); - - for (const tool of ["rox", "crg", "cix"]) { - const wrapper = join(repo, ".ace/runtime/bin", tool); - assert.equal(existsSync(wrapper), true); - assert.match(readFileSync(wrapper, "utf8"), new RegExp(`${tools}/${tool}`)); - } - - const tooling = JSON.parse(readFileSync(join(repo, ".ace/runtime/tooling.json"), "utf8")); - assert.equal(tooling.tooling.aceTools.binRoot, ".ace/runtime/bin"); - assert.deepEqual(Object.keys(tooling.tooling.aceTools.tools).sort(), ["cix", "crg", "rox"]); - }); - - it("rejects lattice-internal tool sources", () => { - const repo = tempRepo(); - const tools = fakeTools(); - const internalRox = join(repo, "packages/graph/rox"); - writeFileSync(internalRox, "#!/usr/bin/env bash\nexit 0\n"); - chmodSync(internalRox, 0o755); - - const result = run(repo, "bash", ["scripts/setup-current-tools.sh"], { - expectFailure: true, - env: { - LATTICE_CURRENT_ROX_PATH: internalRox, - LATTICE_CURRENT_CRG_PATH: join(tools, "crg"), - LATTICE_CURRENT_CIX_PATH: join(tools, "cix") - } - }); - assert.match(stderrAndStdout(result), /source resolved inside lattice/); - }); - - it("rejects symlinks that resolve to lattice-internal tool sources", () => { - const repo = tempRepo(); - const tools = fakeTools(); - const internalRox = join(repo, "packages/graph/rox"); - const symlinkDir = mkdtempSync(join(tmpdir(), "lattice-current-tools-link-")); - const symlinkedRox = join(symlinkDir, "rox"); - writeFileSync(internalRox, "#!/usr/bin/env bash\nexit 0\n"); - chmodSync(internalRox, 0o755); - symlinkSync(internalRox, symlinkedRox); - - const result = run(repo, "bash", ["scripts/setup-current-tools.sh"], { - expectFailure: true, - env: { - LATTICE_CURRENT_ROX_PATH: symlinkedRox, - LATTICE_CURRENT_CRG_PATH: join(tools, "crg"), - LATTICE_CURRENT_CIX_PATH: join(tools, "cix") - } - }); - assert.match(stderrAndStdout(result), /source resolved inside lattice/); - }); -}); - -describe("dev-env current-tool wrappers", () => { - for (const tool of ["rox", "crg", "cix"]) { - it(`fails closed when ${tool} wrapper is missing`, () => { - const repo = tempRepo(); - run(repo, "bash", ["scripts/setup-current-tools.sh"], { - env: { LATTICE_CURRENT_TOOLS_DIR: fakeTools() } - }); - rmSync(join(repo, ".ace/runtime/bin", tool)); - - const result = run(repo, "bash", ["-lc", devEnvProbeScript()], { - expectFailure: true - }); - - assert.match(result.stderr, /run npm run setup:tools/); - assert.match(result.stdout, /status=1/); - assert.match(result.stdout, /PATH=\/usr\/bin:\/bin/); - assert.match(result.stdout, /runtime=/); - assert.match(result.stdout, /cixroot=/); - }); - } - - it("exports current-tool environment when all wrappers are present", () => { - const repo = tempRepo(); - run(repo, "bash", ["scripts/setup-current-tools.sh"], { - env: { LATTICE_CURRENT_TOOLS_DIR: fakeTools() } - }); - - const result = run(repo, "bash", ["-lc", devEnvProbeScript()]); - const output = parseProbeOutput(result.stdout); - const realRepo = realpathSync(repo); - - assert.equal(output.status, "0"); - assert.equal(output.PATH, `${join(realRepo, ".ace/runtime/bin")}:/usr/bin:/bin`); - assert.equal(output.runtime, join(realRepo, ".ace/rox")); - assert.equal(output.cixroot, realRepo); - }); -}); - -function tempRepo() { - const tempRoot = mkdtempSync(join(tmpdir(), "lattice-tools-")); - const repo = join(tempRoot, "repo"); - cpSync(repoRoot, repo, { - recursive: true, - filter(source) { - const rel = relative(repoRoot, source); - if (rel === "") return true; - return !rel.split(/[\\/]/).some((segment) => copiedRepoSkips.has(segment)); - } - }); - return repo; -} - -function fakeTools() { - const dir = mkdtempSync(join(tmpdir(), "lattice-current-tools-")); - for (const tool of ["rox", "crg", "cix"]) { - const path = join(dir, tool); - writeFileSync(path, `#!/usr/bin/env bash\nprintf '${tool} fake\\n'\n`); - chmodSync(path, 0o755); - } - return dir; -} - -function run(cwd, command, args, options = {}) { - const result = spawnSync(command, args, { - cwd, - encoding: "utf8", - env: { ...process.env, ...(options.env ?? {}) }, - stdio: ["ignore", "pipe", "pipe"] - }); - if (options.expectFailure) { - assert.notEqual(result.status, 0, `${command} ${args.join(" ")} should fail`); - return result; - } - assert.equal(result.status, 0, `${command} ${args.join(" ")} failed\n${stderrAndStdout(result)}`); - return result; -} - -function stderrAndStdout(result) { - return `${result.stderr}\n${result.stdout}`; -} - -function devEnvProbeScript() { - return [ - "set +e", - "PATH=/usr/bin:/bin", - "unset LATTICE_CURRENT_TOOL_RUNTIME_DIR CIX_DAEMON_ROOT_DIR", - "source scripts/dev-env.sh", - "status=$?", - 'printf "status=%s\\nPATH=%s\\nruntime=%s\\ncixroot=%s\\n" "$status" "$PATH" "${LATTICE_CURRENT_TOOL_RUNTIME_DIR-}" "${CIX_DAEMON_ROOT_DIR-}"', - 'exit "$status"' - ].join("\n"); -} - -function parseProbeOutput(stdout) { - return Object.fromEntries( - stdout - .trimEnd() - .split("\n") - .map((line) => { - const separator = line.indexOf("="); - return [line.slice(0, separator), line.slice(separator + 1)]; - }) - ); -} diff --git a/tests/cutover-release.test.mjs b/tests/cutover-release.test.mjs index 1f44a23..2a25f73 100644 --- a/tests/cutover-release.test.mjs +++ b/tests/cutover-release.test.mjs @@ -8,7 +8,6 @@ import { fileURLToPath } from "node:url"; import { graphCoreNativePackageNameForTarget, graphCoreNativeSupportedTargets, - releaseCutoverCurrentToolGuardrailIds, releaseCutoverNegativeCheckIds, releaseCutoverPythonCommandIds, releaseCutoverRustCommandIds, @@ -23,15 +22,17 @@ const receiptGatesRunSeparately = process.env.OPCORE_CI_RECEIPT_GATES_RUN_SEPARA const separateReceiptGateSkip = receiptGatesRunSeparately ? "covered by root CI receipt gate" : false; describe("cutover release receipt", () => { - it("proves installed Opcore artifacts without current-tool fallback", { timeout: 180000, skip: separateReceiptGateSkip }, () => { - withReleaseDocsLock(() => { + it( + "proves installed Opcore artifacts with native self-validation", + { timeout: 180000, skip: separateReceiptGateSkip }, + () => { + withReleaseDocsLock(() => { run(["scripts/generate-release-receipt.mjs", "--inspect-packages-only", "--json"]); const result = withCompleteNativeArtifactFixtures(() => run(["scripts/generate-cutover-receipt.mjs", "--json"], { env: { ...process.env, - OPCORE_CUTOVER_REUSE_RELEASE_PACKAGES: "1", - OPCORE_CUTOVER_REUSE_CURRENT_TOOL_GUARDRAILS: "1" + OPCORE_CUTOVER_REUSE_RELEASE_PACKAGES: "1" } }) ); @@ -61,11 +62,9 @@ describe("cutover release receipt", () => { target ); } - assert.equal(receipt.environmentIsolation.currentToolEnvCleared, true); - assert.equal(receipt.environmentIsolation.aceRuntimeBinExcluded, true); - assert.equal(receipt.environmentIsolation.siblingCovibesExcluded, true); - assert.equal(receipt.environmentIsolation.opcoreBinOnly, true); - assert.deepEqual(receipt.environmentIsolation.oldBinsAbsent, { lattice: true, crg: true, cix: true, rox: true }); + assert.equal(receipt.environmentIsolation.pathSanitized, true); + assert.equal(receipt.environmentIsolation.siblingRepositoriesExcluded, true); + assert.equal(receipt.environmentIsolation.opcoreBinsVerified, true); assert.equal(receipt.commandReceipts.every((entry) => entry.command[0] === "opcore" || entry.command[0] === "opcore"), true); assert.deepEqual( receipt.commandReceipts.filter((entry) => entry.status === "not_implemented").map((entry) => entry.id), @@ -81,20 +80,16 @@ describe("cutover release receipt", () => { releaseCutoverPythonCommandIds ); assert.equal(receipt.pythonCommandReceipts.every((entry) => entry.status === "ok"), true); - assert.deepEqual( - receipt.currentToolGuardrails.map((entry) => entry.id), - releaseCutoverCurrentToolGuardrailIds - ); - assert.deepEqual(receipt.currentToolGuardrails, recordedReceipt.currentToolGuardrails); - assert.equal(receipt.currentToolGuardrails.every((entry) => entry.retained === true && entry.oldToolReplacementClaimed === false), true); - assert.equal(receipt.oldToolReplacementClaimed, false); + assert.deepEqual(receipt.selfValidation, recordedReceipt.selfValidation); + assert.equal(receipt.selfValidation.status, "passed"); for (const id of ["inspect-symbols", "inspect-definition", "inspect-references", "inspect-signature", "inspect-implementations", "inspect-search"]) { assert.equal(receipt.commandReceipts.find((entry) => entry.id === id)?.owner, "inspect", id); } assert.equal(receipt.forbiddenMarkerScan.findingCount, 0); assert.deepEqual(receipt.inputEvidence.map((entry) => entry.issue).sort(), ["#17", "#29", "#58"]); - }); - }); + }); + } + ); it("rejects cutover receipts with advertised placeholder command evidence", () => { const temp = mkdtempSync(join(tmpdir(), "opcore-cutover-negative-")); @@ -136,13 +131,9 @@ describe("cutover release receipt", () => { })) }, environmentIsolation: { - currentToolEnvCleared: true, - clearedEnvVarCount: 5, pathSanitized: true, - aceRuntimeBinExcluded: true, - siblingCovibesExcluded: true, - opcoreBinOnly: true, - oldBinsAbsent: { lattice: true, crg: true, cix: true, rox: true } + siblingRepositoriesExcluded: true, + opcoreBinsVerified: true }, commandReceipts: [ { @@ -169,21 +160,15 @@ describe("cutover release receipt", () => { exitCode: 0, assertion: `${id} rejected the unsafe path` })), - currentToolGuardrails: releaseCutoverCurrentToolGuardrailIds.map((id) => ({ - id, - command: - id === "current-tools-validate-changed" - ? ["npm", "run", "current-tools:validate-changed"] - : ["npm", "run", "current-tools:validate-rust-graph"], + selfValidation: { + id: "opcore-self-check", + command: ["npm", "run", "opcore:self-check"], status: "passed", exitCode: 0, stdoutSha256: "5".repeat(64), stderrSha256: "6".repeat(64), - retained: true, - assertion: `${id} remains retained`, - oldToolReplacementClaimed: false - })), - oldToolReplacementClaimed: false, + assertion: "Opcore self-validation passed" + }, forbiddenMarkerScan: { scannedTextCount: 1, findingCount: 0, markersBlocked: ["private-runtime"] }, inputEvidence: [ { issue: "#17", path: "docs/release/graph-release-receipt.json", checksumSha256: "1".repeat(64) }, diff --git a/tests/edit-tree-policy-parity.test.mjs b/tests/edit-tree-policy-parity.test.mjs index 47310b2..268f70a 100644 --- a/tests/edit-tree-policy-parity.test.mjs +++ b/tests/edit-tree-policy-parity.test.mjs @@ -15,8 +15,8 @@ test("tree planning refuses duplicate paths, private roots, unsafe paths, and sy const workspace = await createNodeEditWorkspace({ repoRoot: root }); const cases = [ ["conflict", { files: [{ path: "src/a.ts", content: "one\n" }, { path: "src/a.ts", content: "two\n" }] }], - ["unsupported_change", { files: [{ path: ".ace/runtime/state.json", content: "{}\n" }] }], - ["unsupported_change", { files: [{ path: ".rox-cache/state.json", content: "{}\n" }] }], + ["unsupported_change", { files: [{ path: ".agents/runtime/state.json", content: "{}\n" }] }], + ["unsupported_change", { files: [{ path: ".codex/runtime/state.json", content: "{}\n" }] }], ["unsupported_change", { files: [{ path: "node_modules/pkg/index.js", content: "module.exports = {}\n" }] }], ["unsupported_change", { files: [{ path: "target/debug/out.txt", content: "out\n" }] }], ["parent_directory", { files: [{ path: "../escape.ts", content: "x\n" }] }], diff --git a/tests/fixtures/package-packlists.json b/tests/fixtures/package-packlists.json index df6cd14..f714c0b 100644 --- a/tests/fixtures/package-packlists.json +++ b/tests/fixtures/package-packlists.json @@ -39,6 +39,9 @@ "dist/advanced/inspect-language-service.d.ts", "dist/advanced/inspect-language-service.d.ts.map", "dist/advanced/inspect-language-service.js", + "dist/advanced/inspect-typescript-project.d.ts", + "dist/advanced/inspect-typescript-project.d.ts.map", + "dist/advanced/inspect-typescript-project.js", "dist/advanced/manifest.d.ts", "dist/advanced/manifest.d.ts.map", "dist/advanced/manifest.js", @@ -70,6 +73,132 @@ "dist/index.d.ts", "dist/index.d.ts.map", "dist/index.js", + "dist/init-action-helpers.d.ts", + "dist/init-action-helpers.d.ts.map", + "dist/init-action-helpers.js", + "dist/init-actions-global.d.ts", + "dist/init-actions-global.d.ts.map", + "dist/init-actions-global.js", + "dist/init-actions-repo.d.ts", + "dist/init-actions-repo.d.ts.map", + "dist/init-actions-repo.js", + "dist/init-actions.d.ts", + "dist/init-actions.d.ts.map", + "dist/init-actions.js", + "dist/init-apply.d.ts", + "dist/init-apply.d.ts.map", + "dist/init-apply.js", + "dist/init-args.d.ts", + "dist/init-args.d.ts.map", + "dist/init-args.js", + "dist/init-config.d.ts", + "dist/init-config.d.ts.map", + "dist/init-config.js", + "dist/init-constants.d.ts", + "dist/init-constants.d.ts.map", + "dist/init-constants.js", + "dist/init-context-payload.d.ts", + "dist/init-context-payload.d.ts.map", + "dist/init-context-payload.js", + "dist/init-data.d.ts", + "dist/init-data.d.ts.map", + "dist/init-data.js", + "dist/init-files.d.ts", + "dist/init-files.d.ts.map", + "dist/init-files.js", + "dist/init-format-summary.d.ts", + "dist/init-format-summary.d.ts.map", + "dist/init-format-summary.js", + "dist/init-format.d.ts", + "dist/init-format.d.ts.map", + "dist/init-format.js", + "dist/init-gitignore.d.ts", + "dist/init-gitignore.d.ts.map", + "dist/init-gitignore.js", + "dist/init-guidance.d.ts", + "dist/init-guidance.d.ts.map", + "dist/init-guidance.js", + "dist/init-help.d.ts", + "dist/init-help.d.ts.map", + "dist/init-help.js", + "dist/init-hooks.d.ts", + "dist/init-hooks.d.ts.map", + "dist/init-hooks.js", + "dist/init-language-settings.d.ts", + "dist/init-language-settings.d.ts.map", + "dist/init-language-settings.js", + "dist/init-messages.d.ts", + "dist/init-messages.d.ts.map", + "dist/init-messages.js", + "dist/init-paths.d.ts", + "dist/init-paths.d.ts.map", + "dist/init-paths.js", + "dist/init-payloads.d.ts", + "dist/init-payloads.d.ts.map", + "dist/init-payloads.js", + "dist/init-plan-flow.d.ts", + "dist/init-plan-flow.d.ts.map", + "dist/init-plan-flow.js", + "dist/init-plan-global.d.ts", + "dist/init-plan-global.d.ts.map", + "dist/init-plan-global.js", + "dist/init-plan-repo.d.ts", + "dist/init-plan-repo.d.ts.map", + "dist/init-plan-repo.js", + "dist/init-plan-writes.d.ts", + "dist/init-plan-writes.d.ts.map", + "dist/init-plan-writes.js", + "dist/init-plan.d.ts", + "dist/init-plan.d.ts.map", + "dist/init-plan.js", + "dist/init-prompts.d.ts", + "dist/init-prompts.d.ts.map", + "dist/init-prompts.js", + "dist/init-python-settings.d.ts", + "dist/init-python-settings.d.ts.map", + "dist/init-python-settings.js", + "dist/init-result.d.ts", + "dist/init-result.d.ts.map", + "dist/init-result.js", + "dist/init-router.d.ts", + "dist/init-router.d.ts.map", + "dist/init-router.js", + "dist/init-session.d.ts", + "dist/init-session.d.ts.map", + "dist/init-session.js", + "dist/init-settings.d.ts", + "dist/init-settings.d.ts.map", + "dist/init-settings.js", + "dist/init-timing.d.ts", + "dist/init-timing.d.ts.map", + "dist/init-timing.js", + "dist/init-types.d.ts", + "dist/init-types.d.ts.map", + "dist/init-types.js", + "dist/init-undo-entry.d.ts", + "dist/init-undo-entry.d.ts.map", + "dist/init-undo-entry.js", + "dist/init-undo-flow.d.ts", + "dist/init-undo-flow.d.ts.map", + "dist/init-undo-flow.js", + "dist/init-undo-metadata.d.ts", + "dist/init-undo-metadata.d.ts.map", + "dist/init-undo-metadata.js", + "dist/init-undo-plan.d.ts", + "dist/init-undo-plan.d.ts.map", + "dist/init-undo-plan.js", + "dist/init-wizard-flow.d.ts", + "dist/init-wizard-flow.d.ts.map", + "dist/init-wizard-flow.js", + "dist/init-wizard-plan.d.ts", + "dist/init-wizard-plan.d.ts.map", + "dist/init-wizard-plan.js", + "dist/init-wizard-render.d.ts", + "dist/init-wizard-render.d.ts.map", + "dist/init-wizard-render.js", + "dist/init-write.d.ts", + "dist/init-write.d.ts.map", + "dist/init-write.js", "dist/init.d.ts", "dist/init.d.ts.map", "dist/init.js", @@ -94,6 +223,9 @@ "dist/repo-check-packs.d.ts", "dist/repo-check-packs.d.ts.map", "dist/repo-check-packs.js", + "dist/repo-paths.d.ts", + "dist/repo-paths.d.ts.map", + "dist/repo-paths.js", "dist/repo-validation-config.d.ts", "dist/repo-validation-config.d.ts.map", "dist/repo-validation-config.js", @@ -198,9 +330,327 @@ "node_modules/@the-open-engine/opcore-asp-provider/dist/workspace.js", "node_modules/@the-open-engine/opcore-asp-provider/package.json", "node_modules/@the-open-engine/opcore-contracts/README.md", + "node_modules/@the-open-engine/opcore-contracts/dist/clone/validators.d.ts", + "node_modules/@the-open-engine/opcore-contracts/dist/clone/validators.d.ts.map", + "node_modules/@the-open-engine/opcore-contracts/dist/clone/validators.js", + "node_modules/@the-open-engine/opcore-contracts/dist/command/adapter-validator.d.ts", + "node_modules/@the-open-engine/opcore-contracts/dist/command/adapter-validator.d.ts.map", + "node_modules/@the-open-engine/opcore-contracts/dist/command/adapter-validator.js", + "node_modules/@the-open-engine/opcore-contracts/dist/command/contracts.d.ts", + "node_modules/@the-open-engine/opcore-contracts/dist/command/contracts.d.ts.map", + "node_modules/@the-open-engine/opcore-contracts/dist/command/contracts.js", + "node_modules/@the-open-engine/opcore-contracts/dist/command/helper-validators.d.ts", + "node_modules/@the-open-engine/opcore-contracts/dist/command/helper-validators.d.ts.map", + "node_modules/@the-open-engine/opcore-contracts/dist/command/helper-validators.js", + "node_modules/@the-open-engine/opcore-contracts/dist/command/manifest.d.ts", + "node_modules/@the-open-engine/opcore-contracts/dist/command/manifest.d.ts.map", + "node_modules/@the-open-engine/opcore-contracts/dist/command/manifest.js", + "node_modules/@the-open-engine/opcore-contracts/dist/command/router-01.d.ts", + "node_modules/@the-open-engine/opcore-contracts/dist/command/router-01.d.ts.map", + "node_modules/@the-open-engine/opcore-contracts/dist/command/router-01.js", + "node_modules/@the-open-engine/opcore-contracts/dist/command/router-02.d.ts", + "node_modules/@the-open-engine/opcore-contracts/dist/command/router-02.d.ts.map", + "node_modules/@the-open-engine/opcore-contracts/dist/command/router-02.js", + "node_modules/@the-open-engine/opcore-contracts/dist/command/router-contracts.d.ts", + "node_modules/@the-open-engine/opcore-contracts/dist/command/router-contracts.d.ts.map", + "node_modules/@the-open-engine/opcore-contracts/dist/command/router-contracts.js", + "node_modules/@the-open-engine/opcore-contracts/dist/command/validators.d.ts", + "node_modules/@the-open-engine/opcore-contracts/dist/command/validators.d.ts.map", + "node_modules/@the-open-engine/opcore-contracts/dist/command/validators.js", + "node_modules/@the-open-engine/opcore-contracts/dist/command/vocabulary.d.ts", + "node_modules/@the-open-engine/opcore-contracts/dist/command/vocabulary.d.ts.map", + "node_modules/@the-open-engine/opcore-contracts/dist/command/vocabulary.js", + "node_modules/@the-open-engine/opcore-contracts/dist/edit/contracts.d.ts", + "node_modules/@the-open-engine/opcore-contracts/dist/edit/contracts.d.ts.map", + "node_modules/@the-open-engine/opcore-contracts/dist/edit/contracts.js", + "node_modules/@the-open-engine/opcore-contracts/dist/edit/refusal-validator.d.ts", + "node_modules/@the-open-engine/opcore-contracts/dist/edit/refusal-validator.d.ts.map", + "node_modules/@the-open-engine/opcore-contracts/dist/edit/refusal-validator.js", + "node_modules/@the-open-engine/opcore-contracts/dist/edit/validators.d.ts", + "node_modules/@the-open-engine/opcore-contracts/dist/edit/validators.d.ts.map", + "node_modules/@the-open-engine/opcore-contracts/dist/edit/validators.js", + "node_modules/@the-open-engine/opcore-contracts/dist/edit/vocabulary.d.ts", + "node_modules/@the-open-engine/opcore-contracts/dist/edit/vocabulary.d.ts.map", + "node_modules/@the-open-engine/opcore-contracts/dist/edit/vocabulary.js", + "node_modules/@the-open-engine/opcore-contracts/dist/graph/daemon-validators-01.d.ts", + "node_modules/@the-open-engine/opcore-contracts/dist/graph/daemon-validators-01.d.ts.map", + "node_modules/@the-open-engine/opcore-contracts/dist/graph/daemon-validators-01.js", + "node_modules/@the-open-engine/opcore-contracts/dist/graph/daemon-validators-02.d.ts", + "node_modules/@the-open-engine/opcore-contracts/dist/graph/daemon-validators-02.d.ts.map", + "node_modules/@the-open-engine/opcore-contracts/dist/graph/daemon-validators-02.js", + "node_modules/@the-open-engine/opcore-contracts/dist/graph/helper-validators.d.ts", + "node_modules/@the-open-engine/opcore-contracts/dist/graph/helper-validators.d.ts.map", + "node_modules/@the-open-engine/opcore-contracts/dist/graph/helper-validators.js", + "node_modules/@the-open-engine/opcore-contracts/dist/graph/payload-validators.d.ts", + "node_modules/@the-open-engine/opcore-contracts/dist/graph/payload-validators.d.ts.map", + "node_modules/@the-open-engine/opcore-contracts/dist/graph/payload-validators.js", + "node_modules/@the-open-engine/opcore-contracts/dist/graph/pipeline-contracts.d.ts", + "node_modules/@the-open-engine/opcore-contracts/dist/graph/pipeline-contracts.d.ts.map", + "node_modules/@the-open-engine/opcore-contracts/dist/graph/pipeline-contracts.js", + "node_modules/@the-open-engine/opcore-contracts/dist/graph/protocol-validators.d.ts", + "node_modules/@the-open-engine/opcore-contracts/dist/graph/protocol-validators.d.ts.map", + "node_modules/@the-open-engine/opcore-contracts/dist/graph/protocol-validators.js", + "node_modules/@the-open-engine/opcore-contracts/dist/graph/provider-contracts-01.d.ts", + "node_modules/@the-open-engine/opcore-contracts/dist/graph/provider-contracts-01.d.ts.map", + "node_modules/@the-open-engine/opcore-contracts/dist/graph/provider-contracts-01.js", + "node_modules/@the-open-engine/opcore-contracts/dist/graph/provider-contracts-02.d.ts", + "node_modules/@the-open-engine/opcore-contracts/dist/graph/provider-contracts-02.d.ts.map", + "node_modules/@the-open-engine/opcore-contracts/dist/graph/provider-contracts-02.js", + "node_modules/@the-open-engine/opcore-contracts/dist/graph/provider-validators.d.ts", + "node_modules/@the-open-engine/opcore-contracts/dist/graph/provider-validators.d.ts.map", + "node_modules/@the-open-engine/opcore-contracts/dist/graph/provider-validators.js", + "node_modules/@the-open-engine/opcore-contracts/dist/graph/query-contracts-01.d.ts", + "node_modules/@the-open-engine/opcore-contracts/dist/graph/query-contracts-01.d.ts.map", + "node_modules/@the-open-engine/opcore-contracts/dist/graph/query-contracts-01.js", + "node_modules/@the-open-engine/opcore-contracts/dist/graph/query-contracts-02.d.ts", + "node_modules/@the-open-engine/opcore-contracts/dist/graph/query-contracts-02.d.ts.map", + "node_modules/@the-open-engine/opcore-contracts/dist/graph/query-contracts-02.js", + "node_modules/@the-open-engine/opcore-contracts/dist/graph/query-validators.d.ts", + "node_modules/@the-open-engine/opcore-contracts/dist/graph/query-validators.d.ts.map", + "node_modules/@the-open-engine/opcore-contracts/dist/graph/query-validators.js", + "node_modules/@the-open-engine/opcore-contracts/dist/graph/search-contracts.d.ts", + "node_modules/@the-open-engine/opcore-contracts/dist/graph/search-contracts.d.ts.map", + "node_modules/@the-open-engine/opcore-contracts/dist/graph/search-contracts.js", + "node_modules/@the-open-engine/opcore-contracts/dist/graph/search-validators.d.ts", + "node_modules/@the-open-engine/opcore-contracts/dist/graph/search-validators.d.ts.map", + "node_modules/@the-open-engine/opcore-contracts/dist/graph/search-validators.js", + "node_modules/@the-open-engine/opcore-contracts/dist/graph/vocabulary-01.d.ts", + "node_modules/@the-open-engine/opcore-contracts/dist/graph/vocabulary-01.d.ts.map", + "node_modules/@the-open-engine/opcore-contracts/dist/graph/vocabulary-01.js", + "node_modules/@the-open-engine/opcore-contracts/dist/graph/vocabulary-02.d.ts", + "node_modules/@the-open-engine/opcore-contracts/dist/graph/vocabulary-02.d.ts.map", + "node_modules/@the-open-engine/opcore-contracts/dist/graph/vocabulary-02.js", "node_modules/@the-open-engine/opcore-contracts/dist/index.d.ts", "node_modules/@the-open-engine/opcore-contracts/dist/index.d.ts.map", "node_modules/@the-open-engine/opcore-contracts/dist/index.js", + "node_modules/@the-open-engine/opcore-contracts/dist/inspect/contracts-01.d.ts", + "node_modules/@the-open-engine/opcore-contracts/dist/inspect/contracts-01.d.ts.map", + "node_modules/@the-open-engine/opcore-contracts/dist/inspect/contracts-01.js", + "node_modules/@the-open-engine/opcore-contracts/dist/inspect/contracts-02.d.ts", + "node_modules/@the-open-engine/opcore-contracts/dist/inspect/contracts-02.d.ts.map", + "node_modules/@the-open-engine/opcore-contracts/dist/inspect/contracts-02.js", + "node_modules/@the-open-engine/opcore-contracts/dist/inspect/helper-validators-01.d.ts", + "node_modules/@the-open-engine/opcore-contracts/dist/inspect/helper-validators-01.d.ts.map", + "node_modules/@the-open-engine/opcore-contracts/dist/inspect/helper-validators-01.js", + "node_modules/@the-open-engine/opcore-contracts/dist/inspect/helper-validators-02.d.ts", + "node_modules/@the-open-engine/opcore-contracts/dist/inspect/helper-validators-02.d.ts.map", + "node_modules/@the-open-engine/opcore-contracts/dist/inspect/helper-validators-02.js", + "node_modules/@the-open-engine/opcore-contracts/dist/inspect/validators.d.ts", + "node_modules/@the-open-engine/opcore-contracts/dist/inspect/validators.d.ts.map", + "node_modules/@the-open-engine/opcore-contracts/dist/inspect/validators.js", + "node_modules/@the-open-engine/opcore-contracts/dist/inspect/warm-contracts.d.ts", + "node_modules/@the-open-engine/opcore-contracts/dist/inspect/warm-contracts.d.ts.map", + "node_modules/@the-open-engine/opcore-contracts/dist/inspect/warm-contracts.js", + "node_modules/@the-open-engine/opcore-contracts/dist/managed/contracts.d.ts", + "node_modules/@the-open-engine/opcore-contracts/dist/managed/contracts.d.ts.map", + "node_modules/@the-open-engine/opcore-contracts/dist/managed/contracts.js", + "node_modules/@the-open-engine/opcore-contracts/dist/managed/helper-validators.d.ts", + "node_modules/@the-open-engine/opcore-contracts/dist/managed/helper-validators.d.ts.map", + "node_modules/@the-open-engine/opcore-contracts/dist/managed/helper-validators.js", + "node_modules/@the-open-engine/opcore-contracts/dist/managed/validators-01.d.ts", + "node_modules/@the-open-engine/opcore-contracts/dist/managed/validators-01.d.ts.map", + "node_modules/@the-open-engine/opcore-contracts/dist/managed/validators-01.js", + "node_modules/@the-open-engine/opcore-contracts/dist/managed/validators-02.d.ts", + "node_modules/@the-open-engine/opcore-contracts/dist/managed/validators-02.d.ts.map", + "node_modules/@the-open-engine/opcore-contracts/dist/managed/validators-02.js", + "node_modules/@the-open-engine/opcore-contracts/dist/managed/validators-03.d.ts", + "node_modules/@the-open-engine/opcore-contracts/dist/managed/validators-03.d.ts.map", + "node_modules/@the-open-engine/opcore-contracts/dist/managed/validators-03.js", + "node_modules/@the-open-engine/opcore-contracts/dist/product/init-contracts.d.ts", + "node_modules/@the-open-engine/opcore-contracts/dist/product/init-contracts.d.ts.map", + "node_modules/@the-open-engine/opcore-contracts/dist/product/init-contracts.js", + "node_modules/@the-open-engine/opcore-contracts/dist/product/init-validators-01.d.ts", + "node_modules/@the-open-engine/opcore-contracts/dist/product/init-validators-01.d.ts.map", + "node_modules/@the-open-engine/opcore-contracts/dist/product/init-validators-01.js", + "node_modules/@the-open-engine/opcore-contracts/dist/product/init-validators-02.d.ts", + "node_modules/@the-open-engine/opcore-contracts/dist/product/init-validators-02.d.ts.map", + "node_modules/@the-open-engine/opcore-contracts/dist/product/init-validators-02.js", + "node_modules/@the-open-engine/opcore-contracts/dist/product/latency-contracts.d.ts", + "node_modules/@the-open-engine/opcore-contracts/dist/product/latency-contracts.d.ts.map", + "node_modules/@the-open-engine/opcore-contracts/dist/product/latency-contracts.js", + "node_modules/@the-open-engine/opcore-contracts/dist/product/metrics-contracts-01.d.ts", + "node_modules/@the-open-engine/opcore-contracts/dist/product/metrics-contracts-01.d.ts.map", + "node_modules/@the-open-engine/opcore-contracts/dist/product/metrics-contracts-01.js", + "node_modules/@the-open-engine/opcore-contracts/dist/product/metrics-contracts-02.d.ts", + "node_modules/@the-open-engine/opcore-contracts/dist/product/metrics-contracts-02.d.ts.map", + "node_modules/@the-open-engine/opcore-contracts/dist/product/metrics-contracts-02.js", + "node_modules/@the-open-engine/opcore-contracts/dist/product/metrics-coverage-validators.d.ts", + "node_modules/@the-open-engine/opcore-contracts/dist/product/metrics-coverage-validators.d.ts.map", + "node_modules/@the-open-engine/opcore-contracts/dist/product/metrics-coverage-validators.js", + "node_modules/@the-open-engine/opcore-contracts/dist/product/metrics-validators-01.d.ts", + "node_modules/@the-open-engine/opcore-contracts/dist/product/metrics-validators-01.d.ts.map", + "node_modules/@the-open-engine/opcore-contracts/dist/product/metrics-validators-01.js", + "node_modules/@the-open-engine/opcore-contracts/dist/product/metrics-validators-02.d.ts", + "node_modules/@the-open-engine/opcore-contracts/dist/product/metrics-validators-02.d.ts.map", + "node_modules/@the-open-engine/opcore-contracts/dist/product/metrics-validators-02.js", + "node_modules/@the-open-engine/opcore-contracts/dist/product/metrics-validators-03.d.ts", + "node_modules/@the-open-engine/opcore-contracts/dist/product/metrics-validators-03.d.ts.map", + "node_modules/@the-open-engine/opcore-contracts/dist/product/metrics-validators-03.js", + "node_modules/@the-open-engine/opcore-contracts/dist/product/metrics-validators-04.d.ts", + "node_modules/@the-open-engine/opcore-contracts/dist/product/metrics-validators-04.d.ts.map", + "node_modules/@the-open-engine/opcore-contracts/dist/product/metrics-validators-04.js", + "node_modules/@the-open-engine/opcore-contracts/dist/product/metrics-validators-05.d.ts", + "node_modules/@the-open-engine/opcore-contracts/dist/product/metrics-validators-05.d.ts.map", + "node_modules/@the-open-engine/opcore-contracts/dist/product/metrics-validators-05.js", + "node_modules/@the-open-engine/opcore-contracts/dist/product/status-contracts.d.ts", + "node_modules/@the-open-engine/opcore-contracts/dist/product/status-contracts.d.ts.map", + "node_modules/@the-open-engine/opcore-contracts/dist/product/status-contracts.js", + "node_modules/@the-open-engine/opcore-contracts/dist/product/status-validators.d.ts", + "node_modules/@the-open-engine/opcore-contracts/dist/product/status-validators.d.ts.map", + "node_modules/@the-open-engine/opcore-contracts/dist/product/status-validators.js", + "node_modules/@the-open-engine/opcore-contracts/dist/release/asp-contracts-01.d.ts", + "node_modules/@the-open-engine/opcore-contracts/dist/release/asp-contracts-01.d.ts.map", + "node_modules/@the-open-engine/opcore-contracts/dist/release/asp-contracts-01.js", + "node_modules/@the-open-engine/opcore-contracts/dist/release/asp-validators-01.d.ts", + "node_modules/@the-open-engine/opcore-contracts/dist/release/asp-validators-01.d.ts.map", + "node_modules/@the-open-engine/opcore-contracts/dist/release/asp-validators-01.js", + "node_modules/@the-open-engine/opcore-contracts/dist/release/asp-validators-02.d.ts", + "node_modules/@the-open-engine/opcore-contracts/dist/release/asp-validators-02.d.ts.map", + "node_modules/@the-open-engine/opcore-contracts/dist/release/asp-validators-02.js", + "node_modules/@the-open-engine/opcore-contracts/dist/release/cutover-contracts.d.ts", + "node_modules/@the-open-engine/opcore-contracts/dist/release/cutover-contracts.d.ts.map", + "node_modules/@the-open-engine/opcore-contracts/dist/release/cutover-contracts.js", + "node_modules/@the-open-engine/opcore-contracts/dist/release/cutover-validators-01.d.ts", + "node_modules/@the-open-engine/opcore-contracts/dist/release/cutover-validators-01.d.ts.map", + "node_modules/@the-open-engine/opcore-contracts/dist/release/cutover-validators-01.js", + "node_modules/@the-open-engine/opcore-contracts/dist/release/cutover-validators-02.d.ts", + "node_modules/@the-open-engine/opcore-contracts/dist/release/cutover-validators-02.d.ts.map", + "node_modules/@the-open-engine/opcore-contracts/dist/release/cutover-validators-02.js", + "node_modules/@the-open-engine/opcore-contracts/dist/release/graph-contracts.d.ts", + "node_modules/@the-open-engine/opcore-contracts/dist/release/graph-contracts.d.ts.map", + "node_modules/@the-open-engine/opcore-contracts/dist/release/graph-contracts.js", + "node_modules/@the-open-engine/opcore-contracts/dist/release/graph-optional-validators.d.ts", + "node_modules/@the-open-engine/opcore-contracts/dist/release/graph-optional-validators.d.ts.map", + "node_modules/@the-open-engine/opcore-contracts/dist/release/graph-optional-validators.js", + "node_modules/@the-open-engine/opcore-contracts/dist/release/graph-validators-01.d.ts", + "node_modules/@the-open-engine/opcore-contracts/dist/release/graph-validators-01.d.ts.map", + "node_modules/@the-open-engine/opcore-contracts/dist/release/graph-validators-01.js", + "node_modules/@the-open-engine/opcore-contracts/dist/release/graph-validators-02.d.ts", + "node_modules/@the-open-engine/opcore-contracts/dist/release/graph-validators-02.d.ts.map", + "node_modules/@the-open-engine/opcore-contracts/dist/release/graph-validators-02.js", + "node_modules/@the-open-engine/opcore-contracts/dist/release/graph-validators-03.d.ts", + "node_modules/@the-open-engine/opcore-contracts/dist/release/graph-validators-03.d.ts.map", + "node_modules/@the-open-engine/opcore-contracts/dist/release/graph-validators-03.js", + "node_modules/@the-open-engine/opcore-contracts/dist/release/graph-vocabulary-01.d.ts", + "node_modules/@the-open-engine/opcore-contracts/dist/release/graph-vocabulary-01.d.ts.map", + "node_modules/@the-open-engine/opcore-contracts/dist/release/graph-vocabulary-01.js", + "node_modules/@the-open-engine/opcore-contracts/dist/release/graph-vocabulary-02.d.ts", + "node_modules/@the-open-engine/opcore-contracts/dist/release/graph-vocabulary-02.d.ts.map", + "node_modules/@the-open-engine/opcore-contracts/dist/release/graph-vocabulary-02.js", + "node_modules/@the-open-engine/opcore-contracts/dist/release/public-validators.d.ts", + "node_modules/@the-open-engine/opcore-contracts/dist/release/public-validators.d.ts.map", + "node_modules/@the-open-engine/opcore-contracts/dist/release/public-validators.js", + "node_modules/@the-open-engine/opcore-contracts/dist/release/receipt-contracts-01.d.ts", + "node_modules/@the-open-engine/opcore-contracts/dist/release/receipt-contracts-01.d.ts.map", + "node_modules/@the-open-engine/opcore-contracts/dist/release/receipt-contracts-01.js", + "node_modules/@the-open-engine/opcore-contracts/dist/release/receipt-contracts-02.d.ts", + "node_modules/@the-open-engine/opcore-contracts/dist/release/receipt-contracts-02.d.ts.map", + "node_modules/@the-open-engine/opcore-contracts/dist/release/receipt-contracts-02.js", + "node_modules/@the-open-engine/opcore-contracts/dist/release/receipt-validators-01.d.ts", + "node_modules/@the-open-engine/opcore-contracts/dist/release/receipt-validators-01.d.ts.map", + "node_modules/@the-open-engine/opcore-contracts/dist/release/receipt-validators-01.js", + "node_modules/@the-open-engine/opcore-contracts/dist/release/receipt-validators-02.d.ts", + "node_modules/@the-open-engine/opcore-contracts/dist/release/receipt-validators-02.d.ts.map", + "node_modules/@the-open-engine/opcore-contracts/dist/release/receipt-validators-02.js", + "node_modules/@the-open-engine/opcore-contracts/dist/release/receipt-validators-03.d.ts", + "node_modules/@the-open-engine/opcore-contracts/dist/release/receipt-validators-03.d.ts.map", + "node_modules/@the-open-engine/opcore-contracts/dist/release/receipt-validators-03.js", + "node_modules/@the-open-engine/opcore-contracts/dist/release/vocabulary-01.d.ts", + "node_modules/@the-open-engine/opcore-contracts/dist/release/vocabulary-01.d.ts.map", + "node_modules/@the-open-engine/opcore-contracts/dist/release/vocabulary-01.js", + "node_modules/@the-open-engine/opcore-contracts/dist/release/vocabulary-02.d.ts", + "node_modules/@the-open-engine/opcore-contracts/dist/release/vocabulary-02.d.ts.map", + "node_modules/@the-open-engine/opcore-contracts/dist/release/vocabulary-02.js", + "node_modules/@the-open-engine/opcore-contracts/dist/release/vocabulary-03.d.ts", + "node_modules/@the-open-engine/opcore-contracts/dist/release/vocabulary-03.d.ts.map", + "node_modules/@the-open-engine/opcore-contracts/dist/release/vocabulary-03.js", + "node_modules/@the-open-engine/opcore-contracts/dist/release/vocabulary-04.d.ts", + "node_modules/@the-open-engine/opcore-contracts/dist/release/vocabulary-04.d.ts.map", + "node_modules/@the-open-engine/opcore-contracts/dist/release/vocabulary-04.js", + "node_modules/@the-open-engine/opcore-contracts/dist/shared/json.d.ts", + "node_modules/@the-open-engine/opcore-contracts/dist/shared/json.d.ts.map", + "node_modules/@the-open-engine/opcore-contracts/dist/shared/json.js", + "node_modules/@the-open-engine/opcore-contracts/dist/shared/path-validators.d.ts", + "node_modules/@the-open-engine/opcore-contracts/dist/shared/path-validators.d.ts.map", + "node_modules/@the-open-engine/opcore-contracts/dist/shared/path-validators.js", + "node_modules/@the-open-engine/opcore-contracts/dist/shared/primitives.d.ts", + "node_modules/@the-open-engine/opcore-contracts/dist/shared/primitives.d.ts.map", + "node_modules/@the-open-engine/opcore-contracts/dist/shared/primitives.js", + "node_modules/@the-open-engine/opcore-contracts/dist/shared/validators-01.d.ts", + "node_modules/@the-open-engine/opcore-contracts/dist/shared/validators-01.d.ts.map", + "node_modules/@the-open-engine/opcore-contracts/dist/shared/validators-01.js", + "node_modules/@the-open-engine/opcore-contracts/dist/shared/validators-02.d.ts", + "node_modules/@the-open-engine/opcore-contracts/dist/shared/validators-02.d.ts.map", + "node_modules/@the-open-engine/opcore-contracts/dist/shared/validators-02.js", + "node_modules/@the-open-engine/opcore-contracts/dist/validation/capability-contracts.d.ts", + "node_modules/@the-open-engine/opcore-contracts/dist/validation/capability-contracts.d.ts.map", + "node_modules/@the-open-engine/opcore-contracts/dist/validation/capability-contracts.js", + "node_modules/@the-open-engine/opcore-contracts/dist/validation/diagnostic-contracts.d.ts", + "node_modules/@the-open-engine/opcore-contracts/dist/validation/diagnostic-contracts.d.ts.map", + "node_modules/@the-open-engine/opcore-contracts/dist/validation/diagnostic-contracts.js", + "node_modules/@the-open-engine/opcore-contracts/dist/validation/prewrite-status-validators-01.d.ts", + "node_modules/@the-open-engine/opcore-contracts/dist/validation/prewrite-status-validators-01.d.ts.map", + "node_modules/@the-open-engine/opcore-contracts/dist/validation/prewrite-status-validators-01.js", + "node_modules/@the-open-engine/opcore-contracts/dist/validation/prewrite-status-validators-02.d.ts", + "node_modules/@the-open-engine/opcore-contracts/dist/validation/prewrite-status-validators-02.d.ts.map", + "node_modules/@the-open-engine/opcore-contracts/dist/validation/prewrite-status-validators-02.js", + "node_modules/@the-open-engine/opcore-contracts/dist/validation/python-project-contracts-01.d.ts", + "node_modules/@the-open-engine/opcore-contracts/dist/validation/python-project-contracts-01.d.ts.map", + "node_modules/@the-open-engine/opcore-contracts/dist/validation/python-project-contracts-01.js", + "node_modules/@the-open-engine/opcore-contracts/dist/validation/python-project-contracts-02.d.ts", + "node_modules/@the-open-engine/opcore-contracts/dist/validation/python-project-contracts-02.d.ts.map", + "node_modules/@the-open-engine/opcore-contracts/dist/validation/python-project-contracts-02.js", + "node_modules/@the-open-engine/opcore-contracts/dist/validation/python-project-validators-01.d.ts", + "node_modules/@the-open-engine/opcore-contracts/dist/validation/python-project-validators-01.d.ts.map", + "node_modules/@the-open-engine/opcore-contracts/dist/validation/python-project-validators-01.js", + "node_modules/@the-open-engine/opcore-contracts/dist/validation/python-project-validators-02.d.ts", + "node_modules/@the-open-engine/opcore-contracts/dist/validation/python-project-validators-02.d.ts.map", + "node_modules/@the-open-engine/opcore-contracts/dist/validation/python-project-validators-02.js", + "node_modules/@the-open-engine/opcore-contracts/dist/validation/python-pytest-validators-01.d.ts", + "node_modules/@the-open-engine/opcore-contracts/dist/validation/python-pytest-validators-01.d.ts.map", + "node_modules/@the-open-engine/opcore-contracts/dist/validation/python-pytest-validators-01.js", + "node_modules/@the-open-engine/opcore-contracts/dist/validation/python-pytest-validators-02.d.ts", + "node_modules/@the-open-engine/opcore-contracts/dist/validation/python-pytest-validators-02.d.ts.map", + "node_modules/@the-open-engine/opcore-contracts/dist/validation/python-pytest-validators-02.js", + "node_modules/@the-open-engine/opcore-contracts/dist/validation/python-ruff-validators-01.d.ts", + "node_modules/@the-open-engine/opcore-contracts/dist/validation/python-ruff-validators-01.d.ts.map", + "node_modules/@the-open-engine/opcore-contracts/dist/validation/python-ruff-validators-01.js", + "node_modules/@the-open-engine/opcore-contracts/dist/validation/python-ruff-validators-02.d.ts", + "node_modules/@the-open-engine/opcore-contracts/dist/validation/python-ruff-validators-02.d.ts.map", + "node_modules/@the-open-engine/opcore-contracts/dist/validation/python-ruff-validators-02.js", + "node_modules/@the-open-engine/opcore-contracts/dist/validation/python-ruff-validators-03.d.ts", + "node_modules/@the-open-engine/opcore-contracts/dist/validation/python-ruff-validators-03.d.ts.map", + "node_modules/@the-open-engine/opcore-contracts/dist/validation/python-ruff-validators-03.js", + "node_modules/@the-open-engine/opcore-contracts/dist/validation/python-types-validators.d.ts", + "node_modules/@the-open-engine/opcore-contracts/dist/validation/python-types-validators.d.ts.map", + "node_modules/@the-open-engine/opcore-contracts/dist/validation/python-types-validators.js", + "node_modules/@the-open-engine/opcore-contracts/dist/validation/python-validator-primitives.d.ts", + "node_modules/@the-open-engine/opcore-contracts/dist/validation/python-validator-primitives.d.ts.map", + "node_modules/@the-open-engine/opcore-contracts/dist/validation/python-validator-primitives.js", + "node_modules/@the-open-engine/opcore-contracts/dist/validation/request-contracts.d.ts", + "node_modules/@the-open-engine/opcore-contracts/dist/validation/request-contracts.d.ts.map", + "node_modules/@the-open-engine/opcore-contracts/dist/validation/request-contracts.js", + "node_modules/@the-open-engine/opcore-contracts/dist/validation/request-validators-01.d.ts", + "node_modules/@the-open-engine/opcore-contracts/dist/validation/request-validators-01.d.ts.map", + "node_modules/@the-open-engine/opcore-contracts/dist/validation/request-validators-01.js", + "node_modules/@the-open-engine/opcore-contracts/dist/validation/request-validators-02.d.ts", + "node_modules/@the-open-engine/opcore-contracts/dist/validation/request-validators-02.d.ts.map", + "node_modules/@the-open-engine/opcore-contracts/dist/validation/request-validators-02.js", + "node_modules/@the-open-engine/opcore-contracts/dist/validation/result-validator.d.ts", + "node_modules/@the-open-engine/opcore-contracts/dist/validation/result-validator.d.ts.map", + "node_modules/@the-open-engine/opcore-contracts/dist/validation/result-validator.js", + "node_modules/@the-open-engine/opcore-contracts/dist/validation/status-contracts.d.ts", + "node_modules/@the-open-engine/opcore-contracts/dist/validation/status-contracts.d.ts.map", + "node_modules/@the-open-engine/opcore-contracts/dist/validation/status-contracts.js", + "node_modules/@the-open-engine/opcore-contracts/dist/validation/vocabulary-01.d.ts", + "node_modules/@the-open-engine/opcore-contracts/dist/validation/vocabulary-01.d.ts.map", + "node_modules/@the-open-engine/opcore-contracts/dist/validation/vocabulary-01.js", + "node_modules/@the-open-engine/opcore-contracts/dist/validation/vocabulary-02.d.ts", + "node_modules/@the-open-engine/opcore-contracts/dist/validation/vocabulary-02.d.ts.map", + "node_modules/@the-open-engine/opcore-contracts/dist/validation/vocabulary-02.js", + "node_modules/@the-open-engine/opcore-contracts/dist/validation/vocabulary-03.d.ts", + "node_modules/@the-open-engine/opcore-contracts/dist/validation/vocabulary-03.d.ts.map", + "node_modules/@the-open-engine/opcore-contracts/dist/validation/vocabulary-03.js", "node_modules/@the-open-engine/opcore-contracts/package.json", "node_modules/@the-open-engine/opcore-contracts/schemas/opcore-contracts.schema.json", "node_modules/@the-open-engine/opcore-edit/README.md", @@ -261,6 +711,30 @@ "node_modules/@the-open-engine/opcore-edit/dist/tree-planner.d.ts", "node_modules/@the-open-engine/opcore-edit/dist/tree-planner.d.ts.map", "node_modules/@the-open-engine/opcore-edit/dist/tree-planner.js", + "node_modules/@the-open-engine/opcore-edit/dist/typescript-project/filesystem-discovery.d.ts", + "node_modules/@the-open-engine/opcore-edit/dist/typescript-project/filesystem-discovery.d.ts.map", + "node_modules/@the-open-engine/opcore-edit/dist/typescript-project/filesystem-discovery.js", + "node_modules/@the-open-engine/opcore-edit/dist/typescript-project/import-resolution.d.ts", + "node_modules/@the-open-engine/opcore-edit/dist/typescript-project/import-resolution.d.ts.map", + "node_modules/@the-open-engine/opcore-edit/dist/typescript-project/import-resolution.js", + "node_modules/@the-open-engine/opcore-edit/dist/typescript-project/index.d.ts", + "node_modules/@the-open-engine/opcore-edit/dist/typescript-project/index.d.ts.map", + "node_modules/@the-open-engine/opcore-edit/dist/typescript-project/index.js", + "node_modules/@the-open-engine/opcore-edit/dist/typescript-project/path-policy.d.ts", + "node_modules/@the-open-engine/opcore-edit/dist/typescript-project/path-policy.d.ts.map", + "node_modules/@the-open-engine/opcore-edit/dist/typescript-project/path-policy.js", + "node_modules/@the-open-engine/opcore-edit/dist/typescript-project/project-service.d.ts", + "node_modules/@the-open-engine/opcore-edit/dist/typescript-project/project-service.d.ts.map", + "node_modules/@the-open-engine/opcore-edit/dist/typescript-project/project-service.js", + "node_modules/@the-open-engine/opcore-edit/dist/typescript-project/source-discovery.d.ts", + "node_modules/@the-open-engine/opcore-edit/dist/typescript-project/source-discovery.d.ts.map", + "node_modules/@the-open-engine/opcore-edit/dist/typescript-project/source-discovery.js", + "node_modules/@the-open-engine/opcore-edit/dist/typescript-project/tsconfig.d.ts", + "node_modules/@the-open-engine/opcore-edit/dist/typescript-project/tsconfig.d.ts.map", + "node_modules/@the-open-engine/opcore-edit/dist/typescript-project/tsconfig.js", + "node_modules/@the-open-engine/opcore-edit/dist/typescript-project/types.d.ts", + "node_modules/@the-open-engine/opcore-edit/dist/typescript-project/types.d.ts.map", + "node_modules/@the-open-engine/opcore-edit/dist/typescript-project/types.js", "node_modules/@the-open-engine/opcore-edit/dist/validated-apply.d.ts", "node_modules/@the-open-engine/opcore-edit/dist/validated-apply.d.ts.map", "node_modules/@the-open-engine/opcore-edit/dist/validated-apply.js", @@ -333,15 +807,42 @@ "node_modules/@the-open-engine/opcore-validation-docs/dist/check-constants.d.ts", "node_modules/@the-open-engine/opcore-validation-docs/dist/check-constants.d.ts.map", "node_modules/@the-open-engine/opcore-validation-docs/dist/check-constants.js", + "node_modules/@the-open-engine/opcore-validation-docs/dist/check-definition.d.ts", + "node_modules/@the-open-engine/opcore-validation-docs/dist/check-definition.d.ts.map", + "node_modules/@the-open-engine/opcore-validation-docs/dist/check-definition.js", "node_modules/@the-open-engine/opcore-validation-docs/dist/check-ids.d.ts", "node_modules/@the-open-engine/opcore-validation-docs/dist/check-ids.d.ts.map", "node_modules/@the-open-engine/opcore-validation-docs/dist/check-ids.js", + "node_modules/@the-open-engine/opcore-validation-docs/dist/check-results.d.ts", + "node_modules/@the-open-engine/opcore-validation-docs/dist/check-results.d.ts.map", + "node_modules/@the-open-engine/opcore-validation-docs/dist/check-results.js", "node_modules/@the-open-engine/opcore-validation-docs/dist/checks.d.ts", "node_modules/@the-open-engine/opcore-validation-docs/dist/checks.d.ts.map", "node_modules/@the-open-engine/opcore-validation-docs/dist/checks.js", + "node_modules/@the-open-engine/opcore-validation-docs/dist/code-blocks-check.d.ts", + "node_modules/@the-open-engine/opcore-validation-docs/dist/code-blocks-check.d.ts.map", + "node_modules/@the-open-engine/opcore-validation-docs/dist/code-blocks-check.js", + "node_modules/@the-open-engine/opcore-validation-docs/dist/content-checks.d.ts", + "node_modules/@the-open-engine/opcore-validation-docs/dist/content-checks.d.ts.map", + "node_modules/@the-open-engine/opcore-validation-docs/dist/content-checks.js", + "node_modules/@the-open-engine/opcore-validation-docs/dist/content-quality-check.d.ts", + "node_modules/@the-open-engine/opcore-validation-docs/dist/content-quality-check.d.ts.map", + "node_modules/@the-open-engine/opcore-validation-docs/dist/content-quality-check.js", + "node_modules/@the-open-engine/opcore-validation-docs/dist/coverage-checks.d.ts", + "node_modules/@the-open-engine/opcore-validation-docs/dist/coverage-checks.d.ts.map", + "node_modules/@the-open-engine/opcore-validation-docs/dist/coverage-checks.js", "node_modules/@the-open-engine/opcore-validation-docs/dist/diagnostics.d.ts", "node_modules/@the-open-engine/opcore-validation-docs/dist/diagnostics.d.ts.map", "node_modules/@the-open-engine/opcore-validation-docs/dist/diagnostics.js", + "node_modules/@the-open-engine/opcore-validation-docs/dist/document-check.d.ts", + "node_modules/@the-open-engine/opcore-validation-docs/dist/document-check.d.ts.map", + "node_modules/@the-open-engine/opcore-validation-docs/dist/document-check.js", + "node_modules/@the-open-engine/opcore-validation-docs/dist/dry-check.d.ts", + "node_modules/@the-open-engine/opcore-validation-docs/dist/dry-check.d.ts.map", + "node_modules/@the-open-engine/opcore-validation-docs/dist/dry-check.js", + "node_modules/@the-open-engine/opcore-validation-docs/dist/existence-check.d.ts", + "node_modules/@the-open-engine/opcore-validation-docs/dist/existence-check.d.ts.map", + "node_modules/@the-open-engine/opcore-validation-docs/dist/existence-check.js", "node_modules/@the-open-engine/opcore-validation-docs/dist/freshness.d.ts", "node_modules/@the-open-engine/opcore-validation-docs/dist/freshness.d.ts.map", "node_modules/@the-open-engine/opcore-validation-docs/dist/freshness.js", @@ -351,9 +852,21 @@ "node_modules/@the-open-engine/opcore-validation-docs/dist/index.d.ts", "node_modules/@the-open-engine/opcore-validation-docs/dist/index.d.ts.map", "node_modules/@the-open-engine/opcore-validation-docs/dist/index.js", + "node_modules/@the-open-engine/opcore-validation-docs/dist/length-check.d.ts", + "node_modules/@the-open-engine/opcore-validation-docs/dist/length-check.d.ts.map", + "node_modules/@the-open-engine/opcore-validation-docs/dist/length-check.js", + "node_modules/@the-open-engine/opcore-validation-docs/dist/options.d.ts", + "node_modules/@the-open-engine/opcore-validation-docs/dist/options.d.ts.map", + "node_modules/@the-open-engine/opcore-validation-docs/dist/options.js", + "node_modules/@the-open-engine/opcore-validation-docs/dist/rules-why-check.d.ts", + "node_modules/@the-open-engine/opcore-validation-docs/dist/rules-why-check.d.ts.map", + "node_modules/@the-open-engine/opcore-validation-docs/dist/rules-why-check.js", "node_modules/@the-open-engine/opcore-validation-docs/dist/snapshot.d.ts", "node_modules/@the-open-engine/opcore-validation-docs/dist/snapshot.d.ts.map", "node_modules/@the-open-engine/opcore-validation-docs/dist/snapshot.js", + "node_modules/@the-open-engine/opcore-validation-docs/dist/staleness-check.d.ts", + "node_modules/@the-open-engine/opcore-validation-docs/dist/staleness-check.d.ts.map", + "node_modules/@the-open-engine/opcore-validation-docs/dist/staleness-check.js", "node_modules/@the-open-engine/opcore-validation-docs/package.json", "node_modules/@the-open-engine/opcore-validation-policy/README.md", "node_modules/@the-open-engine/opcore-validation-policy/dist/check-packs.d.ts", @@ -433,15 +946,6 @@ "node_modules/@the-open-engine/opcore-validation-python/dist/process.d.ts", "node_modules/@the-open-engine/opcore-validation-python/dist/process.d.ts.map", "node_modules/@the-open-engine/opcore-validation-python/dist/process.js", - "node_modules/@the-open-engine/opcore-validation-python/dist/python-check-result.d.ts", - "node_modules/@the-open-engine/opcore-validation-python/dist/python-check-result.d.ts.map", - "node_modules/@the-open-engine/opcore-validation-python/dist/python-check-result.js", - "node_modules/@the-open-engine/opcore-validation-python/dist/python-context-result.d.ts", - "node_modules/@the-open-engine/opcore-validation-python/dist/python-context-result.d.ts.map", - "node_modules/@the-open-engine/opcore-validation-python/dist/python-context-result.js", - "node_modules/@the-open-engine/opcore-validation-python/dist/python-execution-workspace.d.ts", - "node_modules/@the-open-engine/opcore-validation-python/dist/python-execution-workspace.d.ts.map", - "node_modules/@the-open-engine/opcore-validation-python/dist/python-execution-workspace.js", "node_modules/@the-open-engine/opcore-validation-python/dist/project-config-files.d.ts", "node_modules/@the-open-engine/opcore-validation-python/dist/project-config-files.d.ts.map", "node_modules/@the-open-engine/opcore-validation-python/dist/project-config-files.js", @@ -499,6 +1003,15 @@ "node_modules/@the-open-engine/opcore-validation-python/dist/pytest-workspace.d.ts", "node_modules/@the-open-engine/opcore-validation-python/dist/pytest-workspace.d.ts.map", "node_modules/@the-open-engine/opcore-validation-python/dist/pytest-workspace.js", + "node_modules/@the-open-engine/opcore-validation-python/dist/python-check-result.d.ts", + "node_modules/@the-open-engine/opcore-validation-python/dist/python-check-result.d.ts.map", + "node_modules/@the-open-engine/opcore-validation-python/dist/python-check-result.js", + "node_modules/@the-open-engine/opcore-validation-python/dist/python-context-result.d.ts", + "node_modules/@the-open-engine/opcore-validation-python/dist/python-context-result.d.ts.map", + "node_modules/@the-open-engine/opcore-validation-python/dist/python-context-result.js", + "node_modules/@the-open-engine/opcore-validation-python/dist/python-execution-workspace.d.ts", + "node_modules/@the-open-engine/opcore-validation-python/dist/python-execution-workspace.d.ts.map", + "node_modules/@the-open-engine/opcore-validation-python/dist/python-execution-workspace.js", "node_modules/@the-open-engine/opcore-validation-python/dist/relevant-tests-check.d.ts", "node_modules/@the-open-engine/opcore-validation-python/dist/relevant-tests-check.d.ts.map", "node_modules/@the-open-engine/opcore-validation-python/dist/relevant-tests-check.js", @@ -517,12 +1030,12 @@ "node_modules/@the-open-engine/opcore-validation-python/dist/ruff-config-proof.d.ts", "node_modules/@the-open-engine/opcore-validation-python/dist/ruff-config-proof.d.ts.map", "node_modules/@the-open-engine/opcore-validation-python/dist/ruff-config-proof.js", - "node_modules/@the-open-engine/opcore-validation-python/dist/ruff-execution.d.ts", - "node_modules/@the-open-engine/opcore-validation-python/dist/ruff-execution.d.ts.map", - "node_modules/@the-open-engine/opcore-validation-python/dist/ruff-execution.js", "node_modules/@the-open-engine/opcore-validation-python/dist/ruff-execution-workspace.d.ts", "node_modules/@the-open-engine/opcore-validation-python/dist/ruff-execution-workspace.d.ts.map", "node_modules/@the-open-engine/opcore-validation-python/dist/ruff-execution-workspace.js", + "node_modules/@the-open-engine/opcore-validation-python/dist/ruff-execution.d.ts", + "node_modules/@the-open-engine/opcore-validation-python/dist/ruff-execution.d.ts.map", + "node_modules/@the-open-engine/opcore-validation-python/dist/ruff-execution.js", "node_modules/@the-open-engine/opcore-validation-python/dist/ruff-format-check.d.ts", "node_modules/@the-open-engine/opcore-validation-python/dist/ruff-format-check.d.ts.map", "node_modules/@the-open-engine/opcore-validation-python/dist/ruff-format-check.js", @@ -716,15 +1229,27 @@ "node_modules/@the-open-engine/opcore-validation-typescript/dist/lint-rules.d.ts", "node_modules/@the-open-engine/opcore-validation-typescript/dist/lint-rules.d.ts.map", "node_modules/@the-open-engine/opcore-validation-typescript/dist/lint-rules.js", + "node_modules/@the-open-engine/opcore-validation-typescript/dist/module-dependencies.d.ts", + "node_modules/@the-open-engine/opcore-validation-typescript/dist/module-dependencies.d.ts.map", + "node_modules/@the-open-engine/opcore-validation-typescript/dist/module-dependencies.js", "node_modules/@the-open-engine/opcore-validation-typescript/dist/relevant-tests-check.d.ts", "node_modules/@the-open-engine/opcore-validation-typescript/dist/relevant-tests-check.d.ts.map", "node_modules/@the-open-engine/opcore-validation-typescript/dist/relevant-tests-check.js", + "node_modules/@the-open-engine/opcore-validation-typescript/dist/relevant-tests-evidence.d.ts", + "node_modules/@the-open-engine/opcore-validation-typescript/dist/relevant-tests-evidence.d.ts.map", + "node_modules/@the-open-engine/opcore-validation-typescript/dist/relevant-tests-evidence.js", + "node_modules/@the-open-engine/opcore-validation-typescript/dist/script-kind.d.ts", + "node_modules/@the-open-engine/opcore-validation-typescript/dist/script-kind.d.ts.map", + "node_modules/@the-open-engine/opcore-validation-typescript/dist/script-kind.js", "node_modules/@the-open-engine/opcore-validation-typescript/dist/source-files.d.ts", "node_modules/@the-open-engine/opcore-validation-typescript/dist/source-files.d.ts.map", "node_modules/@the-open-engine/opcore-validation-typescript/dist/source-files.js", "node_modules/@the-open-engine/opcore-validation-typescript/dist/syntax-check.d.ts", "node_modules/@the-open-engine/opcore-validation-typescript/dist/syntax-check.d.ts.map", "node_modules/@the-open-engine/opcore-validation-typescript/dist/syntax-check.js", + "node_modules/@the-open-engine/opcore-validation-typescript/dist/test-paths.d.ts", + "node_modules/@the-open-engine/opcore-validation-typescript/dist/test-paths.d.ts.map", + "node_modules/@the-open-engine/opcore-validation-typescript/dist/test-paths.js", "node_modules/@the-open-engine/opcore-validation-typescript/dist/type-check.d.ts", "node_modules/@the-open-engine/opcore-validation-typescript/dist/type-check.d.ts.map", "node_modules/@the-open-engine/opcore-validation-typescript/dist/type-check.js", @@ -935,13 +1460,6 @@ "node_modules/brace-expansion/dist/esm/index.js.map", "node_modules/brace-expansion/dist/esm/package.json", "node_modules/brace-expansion/package.json", - "node_modules/concat-map/.travis.yml", - "node_modules/concat-map/LICENSE", - "node_modules/concat-map/README.markdown", - "node_modules/concat-map/example/map.js", - "node_modules/concat-map/index.js", - "node_modules/concat-map/package.json", - "node_modules/concat-map/test/map.js", "node_modules/code-block-writer/LICENSE", "node_modules/code-block-writer/README.md", "node_modules/code-block-writer/esm/_dnt.test_shims.d.ts.map", @@ -971,6 +1489,13 @@ "node_modules/code-block-writer/script/utils/string_utils.d.ts.map", "node_modules/code-block-writer/script/utils/string_utils.js", "node_modules/code-block-writer/script/utils/string_utils.test.d.ts.map", + "node_modules/concat-map/.travis.yml", + "node_modules/concat-map/LICENSE", + "node_modules/concat-map/README.markdown", + "node_modules/concat-map/example/map.js", + "node_modules/concat-map/index.js", + "node_modules/concat-map/package.json", + "node_modules/concat-map/test/map.js", "node_modules/debug/LICENSE", "node_modules/debug/README.md", "node_modules/debug/package.json", diff --git a/tests/gate-negative-fixtures.test.mjs b/tests/gate-negative-fixtures.test.mjs index 6e6e276..f46d9bc 100644 --- a/tests/gate-negative-fixtures.test.mjs +++ b/tests/gate-negative-fixtures.test.mjs @@ -1,6 +1,6 @@ -import { describe, it } from "node:test"; +import { afterEach, describe, it } from "node:test"; import assert from "node:assert/strict"; -import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { cpSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join, relative, resolve } from "node:path"; import { fileURLToPath } from "node:url"; @@ -8,7 +8,6 @@ import { spawnSync } from "node:child_process"; import { graphCoreNativePackageNameForTarget, graphCoreNativeSupportedTargets, - releaseCutoverCurrentToolGuardrailIds, releaseCutoverNegativeCheckIds, releaseCutoverPythonCommandIds, releaseCutoverRequiredCommandIds, @@ -20,24 +19,28 @@ import { externalRuntimePackageDir } from "../scripts/stage-opcore-bundle.mjs"; const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); const releaseDocsLockTimeoutMs = 900000; +const tempRepoRoots = new Set(); const copiedRepoSkips = new Set([ ".git", "node_modules", "target", - ".ace", ".agents", ".claude", ".codex", ".gemini", ".opencode", ".lattice", - ".code-review-graph", - ".rox-cache", - ".robustness-engine-cache", ".receipt-test.lock" ]); describe("negative gate fixtures", () => { + afterEach(() => { + for (const tempRoot of tempRepoRoots) { + rmSync(tempRoot, { recursive: true, force: true }); + } + tempRepoRoots.clear(); + }); + it("rejects tracked TypeScript build info", () => { const repo = tempRepo(); writeFileSync(join(repo, "packages/contracts/tsconfig.tsbuildinfo"), "{}\n"); @@ -47,15 +50,6 @@ describe("negative gate fixtures", () => { assert.match(stderrAndStdout(result), /Generated TypeScript build info must not be checked in/); }); - it("rejects Python CRG provenance markers", () => { - const repo = tempRepo(); - writeFileSync(join(repo, "pyproject.toml"), "[project]\nname = \"code-review-graph\"\n"); - run(repo, "git", ["add", "pyproject.toml"]); - - const result = run(repo, "node", ["scripts/check-provenance.mjs"], { expectFailure: true }); - assert.match(stderrAndStdout(result), /Forbidden Python packaging file/); - }); - it("rejects provenance receipt checks without build artifacts", () => { const repo = tempRepo(); const workflowPath = join(repo, ".github/workflows/provenance.yml"); @@ -165,15 +159,15 @@ describe("negative gate fixtures", () => { assert.match(stderrAndStdout(result), /packed files mismatch|EXTRA\.md/); }); - it("rejects old public bins in release package inspection", () => { + it("rejects unexpected public bins in release package inspection", () => { const repo = tempRepo({ includeDist: true }); const manifestPath = join(repo, "packages/opcore/package.json"); const manifest = JSON.parse(readFileSync(manifestPath, "utf8")); - manifest.bin.crg = "dist/index.js"; + manifest.bin.unexpected = "dist/index.js"; writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`); const result = run(repo, "node", ["scripts/generate-release-receipt.mjs", "--inspect-packages-only"], { expectFailure: true }); - assert.match(stderrAndStdout(result), /forbidden old public bin crg/); + assert.match(stderrAndStdout(result), /Opcore package bins|unexpected/); }); it("rejects canonical ASP server manifest launch claim overreach", () => { @@ -236,15 +230,15 @@ describe("negative gate fixtures", () => { assert.match(stderrAndStdout(result), /packed files mismatch|checksum|sha256/); }); - it("rejects current-tool markers in cutover descriptor inspection", () => { + it("rejects private runtime paths in cutover descriptor inspection", () => { const repo = tempRepo({ includeDist: true }); const descriptorPath = join(repo, "packages/opcore/dist/descriptors/opcore.managed-tool.json"); const descriptor = JSON.parse(readFileSync(descriptorPath, "utf8")); - descriptor.artifacts[0].path = ".ace/runtime/bin/lattice"; + descriptor.artifacts[0].path = ".agents/runtime/bin/tool"; writeFileSync(descriptorPath, `${JSON.stringify(descriptor, null, 2)}\n`); const result = run(repo, "node", ["scripts/generate-cutover-receipt.mjs", "--inspect-descriptor-only"], { expectFailure: true }); - assert.match(stderrAndStdout(result), /private runtime|forbidden marker|\.ace/); + assert.match(stderrAndStdout(result), /private runtime|forbidden marker/); }); it("rejects cutover receipts with advertised not_implemented commands", () => { @@ -271,21 +265,6 @@ describe("negative gate fixtures", () => { assert.match(stderrAndStdout(result), /command receipts.*inspect-search/); }); - it("rejects old bin fallback in installed cutover projects", () => { - const repo = tempRepo({ includeDist: true }); - const project = join(repo, "tmp-installed-project"); - mkdirSync(join(project, "node_modules/.bin"), { recursive: true }); - writeFileSync(join(project, "node_modules/.bin/lattice"), "#!/bin/sh\n"); - writeFileSync(join(project, "node_modules/.bin/opcore"), "#!/bin/sh\n"); - writeFileSync(join(project, "node_modules/.bin/opcore-asp-provider"), "#!/bin/sh\n"); - writeFileSync(join(project, "node_modules/.bin/crg"), "#!/bin/sh\n"); - - const result = run(repo, "node", ["scripts/generate-cutover-receipt.mjs", "--inspect-installed-bin-dir", "tmp-installed-project"], { - expectFailure: true - }); - assert.match(stderrAndStdout(result), /old public bin.*lattice/); - }); - it("rejects sibling file dependencies", () => { const repo = tempRepo(); const manifestPath = join(repo, "packages/graph/package.json"); @@ -308,76 +287,6 @@ describe("negative gate fixtures", () => { assert.match(stderrAndStdout(result), /must not reference parent directories or absolute paths/); }); - it("rejects reserved graph implementation package paths", () => { - const repo = tempRepo(); - const readmePath = join(repo, "reserved-graph-name.md"); - writeFileSync(readmePath, `${["packages", "crg"].join("/")} is reserved for removed implementation references\n`); - - const result = run(repo, "node", ["scripts/check-workspace.mjs"], { expectFailure: true }); - assert.match(stderrAndStdout(result), /reserved graph naming references/); - }); - - it("rejects reserved graph implementation package names", () => { - const repo = tempRepo(); - const manifestPath = join(repo, "packages/graph/package.json"); - const manifest = JSON.parse(readFileSync(manifestPath, "utf8")); - manifest.description = `Reserved ${`@the-open-engine/opcore-${"crg"}`} implementation name`; - writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`); - - const result = run(repo, "node", ["scripts/check-workspace.mjs"], { expectFailure: true }); - assert.match(stderrAndStdout(result), /reserved graph naming references/); - }); - - it("rejects reserved graph provider literals", () => { - const repo = tempRepo(); - const sourcePath = join(repo, "packages/graph/src/reserved-provider.ts"); - writeFileSync(sourcePath, `export const status = { provider: ${JSON.stringify("crg")} };\n`); - - const result = run(repo, "node", ["scripts/check-workspace.mjs"], { expectFailure: true }); - assert.match(stderrAndStdout(result), /reserved graph naming references/); - }); - - it("rejects reserved graph providerName metadata", () => { - const repo = tempRepo(); - const sourcePath = join(repo, "packages/graph/src/reserved-provider-name-metadata.ts"); - writeFileSync(sourcePath, `export const status = { providerName: ${JSON.stringify("crg")} };\n`); - - const result = run(repo, "node", ["scripts/check-workspace.mjs"], { expectFailure: true }); - assert.match(stderrAndStdout(result), /reserved-provider-name-metadata\.ts/); - assert.match(stderrAndStdout(result), /legacy graph provider name metadata/); - }); - - it("rejects reserved graph provider name constants", () => { - const repo = tempRepo(); - const sourcePath = join(repo, "packages/graph/src/bad-provider-name.ts"); - const legacyGraphTool = "cr" + "g"; - writeFileSync(sourcePath, `export const crgProviderName = ${JSON.stringify(legacyGraphTool)};\n`); - - const result = run(repo, "node", ["scripts/check-workspace.mjs"], { expectFailure: true }); - assert.match(stderrAndStdout(result), /bad-provider-name\.ts/); - assert.match(stderrAndStdout(result), /legacy graph provider name constant/); - }); - - it("rejects stale CONTRIBUTING graph naming", () => { - const repo = tempRepo(); - const contributingPath = join(repo, "CONTRIBUTING.md"); - const legacyGraphTool = "cr" + "g"; - const content = readFileSync(contributingPath, "utf8") - .replace( - "Opcore is a public alpha for local code intelligence, edit planning, and pre-write validation for coding agents.", - `Opcore is a public alpha code-intelligence monorepo for \`${legacyGraphTool}\`, edit, and validation.` - ) - .replace( - "Graph extraction, persistence, query, search, and impact belong in `@the-open-engine/opcore-graph`.", - `Graph extraction, persistence, query, search, and impact graph production belongs in \`${legacyGraphTool}\`.` - ); - writeFileSync(contributingPath, content); - - const result = run(repo, "node", ["scripts/check-workspace.mjs"], { expectFailure: true }); - assert.match(stderrAndStdout(result), /CONTRIBUTING\.md/); - assert.match(stderrAndStdout(result), /reserved graph naming references/); - }); - it("rejects edit importing graph-core native artifact loaders", () => { const repo = tempRepo(); const sourcePath = join(repo, "packages/edit/src/bad-graph-loader.ts"); @@ -396,85 +305,6 @@ describe("negative gate fixtures", () => { assert.match(stderrAndStdout(result), /bad-graph-sqlite\.ts/); }); - it("rejects Cargo package names containing crg", () => { - const repo = tempRepo(); - const manifestPath = join(repo, "crates/graph-core/Cargo.toml"); - const manifest = readFileSync(manifestPath, "utf8").replace( - 'name = "opcore-graph-core"', - `name = "lattice-${"crg"}-core"` - ); - writeFileSync(manifestPath, manifest); - - const result = run(repo, "node", ["scripts/check-workspace.mjs"], { expectFailure: true }); - assert.match(stderrAndStdout(result), /must not use crg in Rust package/); - }); - - it("rejects Rox code-quality coverage without all Rust crate paths", () => { - const repo = tempRepo(); - const roxPath = join(repo, "rox.json"); - const rox = JSON.parse(readFileSync(roxPath, "utf8")); - rox.checks.codeQuality.include = rox.checks.codeQuality.include.filter((entry) => entry !== "crates/"); - writeFileSync(roxPath, `${JSON.stringify(rox, null, 2)}\n`); - - const result = run(repo, "node", ["scripts/check-workspace.mjs"], { expectFailure: true }); - assert.match(stderrAndStdout(result), /checks\.codeQuality\.include must include "crates\/"/); - }); - - it("rejects Rox code-quality coverage without existing TypeScript and script scopes", () => { - const repo = tempRepo(); - const roxPath = join(repo, "rox.json"); - const rox = JSON.parse(readFileSync(roxPath, "utf8")); - rox.checks.codeQuality.include = ["crates/"]; - writeFileSync(roxPath, `${JSON.stringify(rox, null, 2)}\n`); - - const result = run(repo, "node", ["scripts/check-workspace.mjs"], { expectFailure: true }); - assert.match(stderrAndStdout(result), /checks\.codeQuality\.include must include "packages\/"/); - }); - - it("rejects scoped Rust quality scripts that run against the whole repo", () => { - const repo = tempRepo(); - const manifestPath = join(repo, "package.json"); - const manifest = JSON.parse(readFileSync(manifestPath, "utf8")); - manifest.scripts["current-tools:validate-rust-graph"] = "./.ace/runtime/bin/rox check --all --no-daemon --checks functionMetrics"; - writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`); - - const result = run(repo, "node", ["scripts/check-workspace.mjs"], { expectFailure: true }); - assert.match(stderrAndStdout(result), /must run scoped Rust graph function metrics script/); - }); - - it("rejects repo-wide Rox without scoped Rust graph metrics", () => { - const repo = tempRepo(); - const roxPath = join(repo, "rox.json"); - const rox = JSON.parse(readFileSync(roxPath, "utf8")); - rox.extensions = rox.extensions.filter((entry) => entry !== "scripts/check-rust-graph-function-metrics.mjs"); - writeFileSync(roxPath, `${JSON.stringify(rox, null, 2)}\n`); - - const result = run(repo, "node", ["scripts/check-workspace.mjs"], { expectFailure: true }); - assert.match(stderrAndStdout(result), /must run scoped Rust graph function metrics/); - }); - - it("rejects Rox all-mode Rust metrics without crate package scope", () => { - const repo = tempRepo(); - const roxPath = join(repo, "rox.json"); - const rox = JSON.parse(readFileSync(roxPath, "utf8")); - rox.packages = rox.packages.filter((entry) => entry !== "crates"); - writeFileSync(roxPath, `${JSON.stringify(rox, null, 2)}\n`); - - const result = run(repo, "node", ["scripts/check-workspace.mjs"], { expectFailure: true }); - assert.match(stderrAndStdout(result), /packages must include "crates"/); - }); - - it("rejects Rox code-quality coverage without changed-file modes", () => { - const repo = tempRepo(); - const roxPath = join(repo, "rox.json"); - const rox = JSON.parse(readFileSync(roxPath, "utf8")); - rox.checks.codeQuality.when.modes = rox.checks.codeQuality.when.modes.filter((entry) => entry !== "changed"); - writeFileSync(roxPath, `${JSON.stringify(rox, null, 2)}\n`); - - const result = run(repo, "node", ["scripts/check-workspace.mjs"], { expectFailure: true }); - assert.match(stderrAndStdout(result), /checks\.codeQuality\.when\.modes must include "changed"/); - }); - it("rejects graph-core crates without workspace lint opt-in", () => { const repo = tempRepo(); const manifestPath = join(repo, "crates/graph-core/Cargo.toml"); @@ -497,7 +327,8 @@ describe("negative gate fixtures", () => { }); function tempRepo(options = {}) { - const tempRoot = mkdtempSync(join(tmpdir(), "lattice-gate-")); + const tempRoot = mkdtempSync(join(tmpdir(), "opcore-gate-")); + tempRepoRoots.add(tempRoot); const repo = join(tempRoot, "repo"); withReleaseDocsLock(() => { cpSync(repoRoot, repo, { @@ -650,11 +481,6 @@ function minimalCutoverReceipt(repo, commandOverrides = {}) { negativeChecks.map((entry) => entry.id), releaseCutoverNegativeCheckIds ); - const currentToolGuardrails = retainedCutoverGuardrails(); - assert.deepEqual( - currentToolGuardrails.map((entry) => entry.id), - releaseCutoverCurrentToolGuardrailIds - ); return { schemaVersion: 1, issue: "#30", @@ -689,24 +515,27 @@ function minimalCutoverReceipt(repo, commandOverrides = {}) { resolvedChecksums: descriptor.checksums.map((checksum) => ({ ...checksum, packageFile: true, value: "8".repeat(64) })) }, environmentIsolation: { - currentToolEnvCleared: true, - clearedEnvVarCount: 5, pathSanitized: true, - aceRuntimeBinExcluded: true, - siblingCovibesExcluded: true, - opcoreBinOnly: true, - oldBinsAbsent: { lattice: true, crg: true, cix: true, rox: true } + siblingRepositoriesExcluded: true, + opcoreBinsVerified: true }, commandReceipts, rustCommandReceipts, pythonCommandReceipts, negativeChecks, - currentToolGuardrails, - oldToolReplacementClaimed: false, + selfValidation: { + id: "opcore-self-check", + command: ["npm", "run", "opcore:self-check"], + status: "passed", + exitCode: 0, + stdoutSha256: "1".repeat(64), + stderrSha256: "2".repeat(64), + assertion: "Opcore self-validation passed" + }, forbiddenMarkerScan: { scannedTextCount: 1, findingCount: 0, - markersBlocked: ["private-runtime", "current-tool-env", "private-home", "old-tool-bins"] + markersBlocked: ["private-home", "launch-claim"] }, inputEvidence: [ { issue: "#17", path: "docs/release/graph-release-receipt.json", checksumSha256: "4".repeat(64) }, @@ -906,30 +735,3 @@ function commandReceipt(expectation, bin = expectation.command[0], assertionSuff assertion: `${expectation.id} ${assertionSuffix}` }; } - -function retainedCutoverGuardrails() { - return [ - { - id: "current-tools-validate-changed", - command: ["npm", "run", "current-tools:validate-changed"], - status: "passed", - exitCode: 0, - stdoutSha256: "1".repeat(64), - stderrSha256: "2".repeat(64), - retained: true, - assertion: "retained changed-file guardrail", - oldToolReplacementClaimed: false - }, - { - id: "current-tools-validate-rust-graph", - command: ["npm", "run", "current-tools:validate-rust-graph"], - status: "passed", - exitCode: 0, - stdoutSha256: "1".repeat(64), - stderrSha256: "2".repeat(64), - retained: true, - assertion: "retained Rust graph guardrail", - oldToolReplacementClaimed: false - } - ]; -} diff --git a/tests/graph-ephemeral-snapshot.test.mjs b/tests/graph-ephemeral-snapshot.test.mjs index a86190a..a819d15 100644 --- a/tests/graph-ephemeral-snapshot.test.mjs +++ b/tests/graph-ephemeral-snapshot.test.mjs @@ -68,6 +68,50 @@ describe("ephemeral graph snapshots", () => { assert.equal(existsSync(materializedRepo), false); }); + it("materializes root tsconfig so exact snapshots resolve source aliases", async () => { + const logicalRepo = { repoId: "target", repoRoot: "/target/repo" }; + const files = new Map([ + [ + "tsconfig.json", + JSON.stringify({ compilerOptions: { baseUrl: ".", paths: { "@example/pkg": ["src/index.ts"] } } }) + ], + ["src/index.ts", "export const publicValue = 1;\n"], + [ + "tests/package-contract.test.ts", + "import { publicValue } from '@example/pkg'; test('public value', () => publicValue);\n" + ] + ]); + const snapshot = await createEphemeralGraphSnapshot({ + logicalRepo, + sourceUniverse: { paths: [...files.keys()], complete: true }, + readFile: (path) => files.has(path) + ? { status: "found", content: files.get(path) } + : { status: "missing" } + }); + try { + assert.deepEqual(snapshot.materializedPaths, [ + "src/index.ts", + "tests/package-contract.test.ts", + "tsconfig.json" + ]); + const result = snapshot.factQuery({ + requestId: "exact-test-evidence", + repo: logicalRepo, + schemaVersion: 1, + mode: "required", + selector: { kind: "edges", edgeKinds: ["TESTED_BY"] } + }); + assert.equal(result.status.state, "available"); + assert.ok(result.edges.some((edge) => + edge.kind === "TESTED_BY" && + edge.from === "file:src/index.ts" && + edge.to === "file:tests/package-contract.test.ts" + )); + } finally { + snapshot.dispose(); + } + }); + it("rejects incomplete universes and removes materialized state after build errors", async () => { await assert.rejects(createEphemeralGraphSnapshotWithOperations({ logicalRepo: { repoId: "target" }, diff --git a/tests/graph-pipeline-cli.test.mjs b/tests/graph-pipeline-cli.test.mjs index 4a62369..d307a8a 100644 --- a/tests/graph-pipeline-cli.test.mjs +++ b/tests/graph-pipeline-cli.test.mjs @@ -303,15 +303,12 @@ describe("graph pipeline CLI", () => { ["src", "app.ts"], ["src", "tool.py"], ["node_modules/pkg", "index.ts"], - [".ace/runtime", "generated.ts"], [".agents/runtime", "generated.ts"], [".claude/runtime", "generated.ts"], [".codex/runtime", "generated.ts"], [".gemini/runtime", "generated.ts"], [".opcore/graph", "generated.ts"], [".opencode/runtime", "generated.ts"], - [".rox-cache", "generated.ts"], - [".robustness-engine-cache", "generated.ts"], [".pnpm/pkg", "index.ts"], ["vendor/pkg", "generated.ts"], ["dist", "generated.ts"], @@ -608,7 +605,7 @@ describe("graph pipeline CLI", () => { } }); - it("scopes watch from OPCORE_GRAPH_WATCH_PATHS only and ignores CRG_WATCH_PATHS", () => { + it("scopes watch from OPCORE_GRAPH_WATCH_PATHS", () => { const temp = mkdtempSync(join(tmpdir(), "lattice-watch-env-paths-")); try { mkdirSync(join(temp, "src"), { recursive: true }); @@ -622,13 +619,6 @@ describe("graph pipeline CLI", () => { assert.deepEqual(latticeScoped.graphPipeline.summary.watchPaths, ["src/a.ts"]); assert.deepEqual(latticeScoped.graphPipeline.summary.changedFiles, ["src/a.ts"]); - rmSync(join(temp, ".opcore"), { recursive: true, force: true }); - const crgIgnored = run(latticeBin, ["graph", "watch", "--repo", temp, "--once", "--json"], 0, { - env: { CRG_WATCH_PATHS: "src/a.ts" } - }); - assert.equal(crgIgnored.providerStatus.state, "available"); - assert.deepEqual(crgIgnored.graphPipeline.summary.watchPaths ?? [], []); - assert.deepEqual(crgIgnored.graphPipeline.summary.changedFiles, ["src/a.ts", "src/b.ts"]); } finally { rmSync(temp, { recursive: true, force: true }); } @@ -687,10 +677,7 @@ describe("graph pipeline CLI", () => { mkdirSync(join(temp, "src"), { recursive: true }); writeFileSync(join(temp, "src/a.ts"), "export const a = 1;\n"); writeFileSync(join(temp, "src/b.ts"), "export const b = 2;\n"); - const env = { - OPCORE_GRAPH_WATCH_PATHS: "src/a.ts", - CRG_WATCH_PATHS: "src/a.ts" - }; + const env = { OPCORE_GRAPH_WATCH_PATHS: "src/a.ts" }; const build = run(latticeBin, ["graph", "build", "--repo", temp, "--json"], 0, { env }); assert.equal(build.providerStatus.state, "available"); @@ -829,10 +816,7 @@ function createRuntimeIgnoredFiles(fixtureRoot) { for (const [directory, file] of [ ["ignored", "drop.ts"], ["node_modules/pkg", "index.ts"], - [".ace/runtime", "generated.ts"], [".opcore/graph", "generated.ts"], - [".rox-cache", "generated.ts"], - [".robustness-engine-cache", "generated.ts"], ["dist", "generated.ts"] ]) { mkdirSync(join(fixtureRoot, directory), { recursive: true }); diff --git a/tests/graph-reference-evidence.test.mjs b/tests/graph-reference-evidence.test.mjs deleted file mode 100644 index a98a87f..0000000 --- a/tests/graph-reference-evidence.test.mjs +++ /dev/null @@ -1,227 +0,0 @@ -import { describe, it } from "node:test"; -import assert from "node:assert/strict"; -import { existsSync, readFileSync } from "node:fs"; -import { validateGraphReferenceEvidenceManifest } from "../packages/contracts/dist/index.js"; - -const fixtureRoot = new URL("../packages/fixtures/graph-reference-evidence/", import.meta.url); -const manifest = readFixture("manifest.json"); -const sqliteFixtures = readFixture("sqlite-fixtures.json"); -const daemonFixtures = readFixture("daemon-socket-fixtures.json"); -const goldenCorpus = readFixture("golden-corpus.json"); -const baselineReceipts = readFixture("baseline-receipts.json"); - -describe("graph reference evidence fixtures", () => { - it("validates the reference evidence manifest through shared contracts", () => { - assert.equal(validateGraphReferenceEvidenceManifest(manifest).issue, "#19"); - assert.deepEqual(manifest.fixtureRefs, [ - "packages/fixtures/graph-reference-evidence/sqlite-fixtures.json", - "packages/fixtures/graph-reference-evidence/daemon-socket-fixtures.json", - "packages/fixtures/graph-reference-evidence/golden-corpus.json", - "packages/fixtures/graph-reference-evidence/baseline-receipts.json" - ]); - }); - - it("records neutral command evidence for canonical graph routes", () => { - assertCommand("graph-reference-build", ["build"], ["opcore", "graph", "build"]); - assertCommand("graph-reference-update", ["update"], ["opcore", "graph", "update"]); - assertCommand("graph-reference-watch", ["watch"], ["opcore", "graph", "watch"]); - assertCommand("graph-reference-status", ["status"], ["opcore", "graph", "status"]); - assertCommand("graph-reference-query", ["query"], ["opcore", "graph", "query"]); - assertCommand("graph-reference-impact", ["impact"], ["opcore", "graph", "impact"]); - assertCommand("graph-reference-search", ["search"], ["opcore", "graph", "search"]); - assertCommand("graph-reference-serve", ["serve"], ["opcore", "graph", "serve"]); - }); - - it("keeps SQLite and daemon evidence concrete", () => { - assert.deepEqual(sqliteFixtures.nodeKinds, [ - "File", - "Function", - "Test", - "Module", - "Struct", - "Enum", - "Trait", - "Impl", - "Method", - "TypeAlias", - "Const", - "Static", - "Macro" - ]); - assert.deepEqual(sqliteFixtures.edgeKinds, ["CALLS", "CONTAINS", "IMPORTS_FROM", "TESTED_BY", "IMPLEMENTS", "DEPENDS_ON", "INHERITS"]); - assert.deepEqual(sqliteFixtures.tables.map((entry) => entry.name), ["metadata", "nodes", "edges", "nodes_fts"]); - assert.deepEqual( - sqliteFixtures.indexes, - [ - "idx_nodes_file", - "idx_nodes_kind", - "idx_nodes_qualified", - "idx_edges_source", - "idx_edges_target", - "idx_edges_kind", - "idx_edges_file", - "idx_nodes_exported_name" - ] - ); - assert.equal(sqliteFixtures.tables.find((entry) => entry.name === "nodes").columns.includes("community_id"), false); - assert.equal(sqliteFixtures.tables.some((entry) => ["flows", "flow_memberships", "communities", "embeddings"].includes(entry.name)), false); - assert.deepEqual(sqliteFixtures.optionalAnalysisTables, [ - { - issue: "#14", - id: "flows", - classification: "optional", - tables: ["flows", "flow_memberships"], - indexes: ["idx_flows_criticality", "idx_flows_entry", "idx_flow_memberships_node"] - }, - { - issue: "#15", - id: "communities", - classification: "optional", - tables: ["communities"], - indexes: ["idx_nodes_community", "idx_communities_parent", "idx_communities_cohesion"] - }, - { - issue: "#16", - id: "embeddings", - classification: "supporting", - tables: ["embeddings"], - indexes: [] - } - ]); - const requiredStatusFields = manifest.jsonOutputSurfaces.find((entry) => entry.id === "status-json").requiredFields; - assert.equal(requiredStatusFields.includes("embeddings_count"), false); - const manifestSqlite = manifest.sqliteFixtures.find((entry) => entry.id === "sqlite-required-views"); - assert.deepEqual(manifestSqlite.tables, ["metadata", "nodes", "edges", "nodes_fts"]); - assert.equal(manifestSqlite.indexes.includes("idx_nodes_community"), false); - assert.deepEqual( - manifest.optionalAnalysisSurfaces.map(({ issue, id, classification, status }) => ({ issue, id, classification, status })), - [ - { issue: "#13", id: "coverage", classification: "deferred", status: "deferred" }, - { issue: "#14", id: "flows", classification: "optional", status: "deferred" }, - { issue: "#15", id: "communities", classification: "optional", status: "deferred" }, - { issue: "#16", id: "read_only_suggestions", classification: "supporting", status: "deferred" } - ] - ); - assert.equal(manifest.optionalAnalysisSurfaces.some((entry) => entry.classification === "required"), false); - assertDirectQuery("status-counts", "select kind, count(*) as count from nodes group by kind order by kind"); - assertDirectQuery("impact-edges-from-file", "select kind, source_qualified, target_qualified from edges where file_path = ?"); - for (const id of ["serve-jsonl-ping", "serve-jsonl-status", "serve-jsonl-query", "serve-jsonl-search", "serve-jsonl-shutdown"]) { - assert.ok(daemonFixtures.envelopes.find((entry) => entry.id === id), `missing daemon envelope ${id}`); - } - }); - - it("keeps the golden corpus synthetic and internally consistent", () => { - assert.equal(goldenCorpus.origin, "covibes-authored-synthetic"); - assert.equal(goldenCorpus.containsSourceCode, false); - const nodes = goldenCorpus.expectedFacts.parser.nodes; - const edges = goldenCorpus.expectedFacts.store.edges; - const nodeIds = new Set(nodes.map((node) => node.id)); - for (const edge of edges) { - assert.equal(nodeIds.has(edge.source), true, `missing edge source ${edge.source}`); - assert.equal(nodeIds.has(edge.target), true, `missing edge target ${edge.target}`); - } - assert.deepEqual(countBy(nodes, "kind"), goldenCorpus.expectedFacts.status.nodesByKind); - assert.deepEqual(countBy(edges, "kind"), goldenCorpus.expectedFacts.status.edgesByKind); - }); - - it("records Rust fixture coverage without old-tool replacement claims", () => { - const coveredRows = new Set(manifest.goldenCorpus.covers); - for (const row of [ - "rust-source-extraction-fixtures", - "rust-store-freshness-fts", - "rust-query-impact-search", - "mixed-rust-ts-source-fixture" - ]) { - assert.equal(coveredRows.has(row), true, `missing Rust coverage row ${row}`); - } - assert.ok(goldenCorpus.expectedFacts.status.languages.includes("rust")); - assert.ok(goldenCorpus.expectedFacts.parser.nodes.some((node) => node.id === "struct:src/lib.rs#Widget")); - assert.ok(goldenCorpus.expectedFacts.store.edges.some((edge) => edge.kind === "TESTED_BY")); - assert.equal(manifest.provenance.referenceReceiptsAreImplementationInput, false); - }); - - it("records baseline receipts as non-implementation reference evidence", () => { - assert.equal(baselineReceipts.label, "reference_evidence_non_implementation_input"); - assert.equal(baselineReceipts.sourceTool, "current external graph dev wrapper"); - assert.deepEqual(baselineReceipts.receipts.map((receipt) => receipt.metric), [ - "install_setup_ms", - "cold_build_ms", - "incremental_update_ms", - "impact_cold_ms", - "impact_hot_ms", - "search_ms", - "db_size_bytes", - "wal_size_bytes", - "daemon_startup_ms", - "daemon_query_ms" - ]); - for (const receipt of baselineReceipts.receipts) { - assert.equal(receipt.nonImplementationInput, true); - assert.equal(receipt.value > 0, true, `nonzero baseline ${receipt.metric}`); - } - }); - - it("records #4 CRG parity rows as non-implementation compatibility evidence", () => { - const coveredRows = new Set(manifest.goldenCorpus.covers); - for (const row of [ - "code-review-graph-cli-surface", - "crg-watch-roots-ignore-reconcile", - "crg-wal-health-checkpoint-pressure", - "crg-hot-query-socket", - "crg-mcp-tool-surface", - "crg-impact-query-review-search", - "lattice-native-graph-provider-surfaces", - "lattice-current-tools-graph-status", - "lattice-crg-reference-baseline-release-fixtures", - "covibes-crg-watch-ci-unit-gate", - "covibes-push-ready-crg-freshness-impact-gate", - "covibes-agent-guidance-crg-watch-and-reads", - "mcp-server-name-code-review-graph-compatibility" - ]) { - assert.equal(coveredRows.has(row), true, `missing #4 parity row ${row}`); - } - assert.equal(baselineReceipts.label, "reference_evidence_non_implementation_input"); - assert.equal(manifest.provenance.referenceReceiptsAreImplementationInput, false); - }); - - it("enforces provenance guardrails for reference data", () => { - assert.equal(manifest.provenance.containsPythonCrgSource, false); - assert.equal(manifest.provenance.containsPackageMetadata, false); - assert.equal(manifest.provenance.containsGitHistory, false); - assert.equal(manifest.provenance.referenceReceiptsAreImplementationInput, false); - assert.deepEqual(manifest.provenance.allowedMentionPaths, [ - "docs/graph-reference-evidence/", - "packages/fixtures/graph-reference-evidence/" - ]); - for (const file of ["manifest.json", "sqlite-fixtures.json", "daemon-socket-fixtures.json", "golden-corpus.json"]) { - assert.doesNotMatch(readFileSync(new URL(file, fixtureRoot), "utf8"), /tirth8205|pyproject\.toml|setup\.py|setup\.cfg|Pipfile|git clone/i); - } - }); -}); - -function readFixture(file) { - const url = new URL(file, fixtureRoot); - assert.equal(existsSync(url), true, `missing ${file}`); - return JSON.parse(readFileSync(url, "utf8")); -} - -function assertCommand(id, referenceCommand, canonicalCommand) { - const command = manifest.commandSurfaces.find((entry) => entry.id === id); - assert.ok(command, `missing command evidence ${id}`); - assert.equal(command.classification, "required"); - assert.deepEqual(command.referenceCommand, referenceCommand); - assert.deepEqual(command.canonicalCommand, canonicalCommand); -} - -function assertDirectQuery(id, sql) { - const query = sqliteFixtures.directReaderQueries.find((entry) => entry.id === id); - assert.ok(query, `missing direct query ${id}`); - assert.equal(query.sql, sql); -} - -function countBy(entries, key) { - return entries.reduce((counts, entry) => { - counts[entry[key]] = (counts[entry[key]] ?? 0) + 1; - return counts; - }, {}); -} diff --git a/tests/graph-release-readiness.test.mjs b/tests/graph-release-readiness.test.mjs index 81f07e8..408bb87 100644 --- a/tests/graph-release-readiness.test.mjs +++ b/tests/graph-release-readiness.test.mjs @@ -151,12 +151,12 @@ describe("graph release readiness receipt", () => { function tempGraphPackageInspectionRepo() { const tempRoot = mkdtempSync(join(tmpdir(), "opcore-graph-package-test-")); mkdirSync(join(tempRoot, "scripts"), { recursive: true }); - mkdirSync(join(tempRoot, "packages/contracts/dist"), { recursive: true }); + mkdirSync(join(tempRoot, "packages/contracts"), { recursive: true }); mkdirSync(join(tempRoot, "packages/graph"), { recursive: true }); cpSync(join(repoRoot, "package.json"), join(tempRoot, "package.json")); cpSync(join(repoRoot, ".npmrc"), join(tempRoot, ".npmrc")); cpSync(join(repoRoot, "scripts/generate-graph-release-receipt.mjs"), join(tempRoot, "scripts/generate-graph-release-receipt.mjs")); - cpSync(join(repoRoot, "packages/contracts/dist/index.js"), join(tempRoot, "packages/contracts/dist/index.js")); + cpSync(join(repoRoot, "packages/contracts/dist"), join(tempRoot, "packages/contracts/dist"), { recursive: true }); cpSync(join(repoRoot, "packages/graph/package.json"), join(tempRoot, "packages/graph/package.json")); cpSync(join(repoRoot, "packages/graph/README.md"), join(tempRoot, "packages/graph/README.md")); cpSync(join(repoRoot, "packages/graph/dist"), join(tempRoot, "packages/graph/dist"), { recursive: true }); diff --git a/tests/graph-store-conformance.test.mjs b/tests/graph-store-conformance.test.mjs index 4f8cd3d..be42f0c 100644 --- a/tests/graph-store-conformance.test.mjs +++ b/tests/graph-store-conformance.test.mjs @@ -70,7 +70,7 @@ describe("GraphProvider SQLite store conformance", () => { { kind: "IMPLEMENTS", count: 2 }, { kind: "IMPORTS_FROM", count: 11 }, { kind: "INHERITS", count: 2 }, - { kind: "TESTED_BY", count: 4 } + { kind: "TESTED_BY", count: 7 } ] ); assert.ok( @@ -462,7 +462,7 @@ describe("GraphProvider SQLite store conformance", () => { withFixtureCopy((fixtureRoot) => { mkdirSync(join(fixtureRoot, "ignored"), { recursive: true }); - writeFileSync(join(fixtureRoot, ".code-review-graphignore"), "ignored/\n"); + writeFileSync(join(fixtureRoot, ".gitignore"), "ignored/\n"); writeFileSync(join(fixtureRoot, "ignored/generated.ts"), "export const ignored = true;\n"); const build = graphProviderBuild({ repoRoot: fixtureRoot }); assert.equal(build.status.state, "available"); @@ -561,14 +561,14 @@ describe("GraphProvider SQLite store conformance", () => { assert.equal(build.status.state, "available"); assertStoreHasPath(build.status.dbPath, "src/generated.ts"); - writeFileSync(join(fixtureRoot, ".code-review-graphignore"), "src/generated.ts\n"); + writeFileSync(join(fixtureRoot, ".gitignore"), "src/generated.ts\n"); const update = graphProviderUpdate({ repoRoot: fixtureRoot }); assert.deepEqual(update.summary.deletedFiles, ["src/generated.ts"]); assertStoreMissingPath(update.status.dbPath, "src/generated.ts"); }); }); - it("excludes generated, private, dependency, gitignore, and code-review-graphignore paths", () => { + it("excludes generated, private, dependency, and gitignore paths", () => { withRobustnessFixtureCopy((fixtureRoot) => { createRuntimeIgnoredFiles(fixtureRoot); const build = graphProviderBuild({ repoRoot: fixtureRoot }); @@ -576,19 +576,16 @@ describe("GraphProvider SQLite store conformance", () => { assert.deepEqual(build.summary.changedFiles, ["ignored/keep.ts", "shared/util.ts", "src/app.ts"]); for (const excluded of [ "ignored/drop.ts", - "crg-ignored/drop.ts", + "policy-ignored/drop.ts", "node_modules/pkg/index.ts", ".pnpm/pkg/index.ts", "vendor/pkg/generated.ts", - ".ace/runtime/generated.ts", ".agents/runtime/generated.ts", ".claude/runtime/generated.ts", ".codex/runtime/generated.ts", ".gemini/runtime/generated.ts", ".opcore/graph/generated.ts", ".opencode/runtime/generated.ts", - ".rox-cache/generated.ts", - ".robustness-engine-cache/generated.ts", "dist/generated.ts" ]) { assertStoreMissingPath(build.status.dbPath, excluded); @@ -701,21 +698,19 @@ function withRobustnessFixtureCopy(run) { } function createRuntimeIgnoredFiles(fixtureRoot) { - writeFileSync(join(fixtureRoot, ".gitignore"), "ignored/drop.ts\n"); + writeFileSync(join(fixtureRoot, ".gitignore"), "ignored/drop.ts\npolicy-ignored/**\n"); for (const [directory, file] of [ ["ignored", "drop.ts"], + ["policy-ignored", "drop.ts"], ["node_modules/pkg", "index.ts"], [".pnpm/pkg", "index.ts"], ["vendor/pkg", "generated.ts"], - [".ace/runtime", "generated.ts"], [".agents/runtime", "generated.ts"], [".claude/runtime", "generated.ts"], [".codex/runtime", "generated.ts"], [".gemini/runtime", "generated.ts"], [".opcore/graph", "generated.ts"], [".opencode/runtime", "generated.ts"], - [".rox-cache", "generated.ts"], - [".robustness-engine-cache", "generated.ts"], ["dist", "generated.ts"] ]) { mkdirSync(join(fixtureRoot, directory), { recursive: true }); diff --git a/tests/helpers/asp-dogfood-fixture.mjs b/tests/helpers/asp-dogfood-fixture.mjs index d065bf9..ead6f01 100644 --- a/tests/helpers/asp-dogfood-fixture.mjs +++ b/tests/helpers/asp-dogfood-fixture.mjs @@ -19,12 +19,11 @@ export function validAspDogfoodReceipt() { repoEnrollment: repoEnrollmentFixture(), hostEvaluation: hostEvaluationFixture(hostDecision), providerProbe: providerProbeFixture(), - currentToolGuardrails: guardrailsFixture(), + selfValidation: selfValidationFixture(), unsupportedSurfaces: unsupportedSurfacesFixture(), - parityBlockers: [{ source: "docs/planning/old-tool-compatibility-matrix.md:1", detail: "old-tool guardrails retained" }], + parityBlockers: [], authority: authorityFixture(), publicReleaseActions: [], - oldToolReplacementClaimed: false, forbiddenMarkerScan: { scannedTextCount: 2, findingCount: 0, markersBlocked: aspDogfoodForbiddenProviderMarkers } }; } @@ -32,21 +31,27 @@ export function validAspDogfoodReceipt() { export function invalidAspDogfoodCases(receipt) { return [ ["opcore asp serve entrypoint", { ...receipt, provider: { ...receipt.provider, command: ["opcore", "asp", "serve"] } }, /provider command/], - ["ACE runtime provider entrypoint", { ...receipt, provider: { ...receipt.provider, binPath: ".ace/runtime/bin/opcore-asp-provider" } }, /node_modules\/\.bin\/opcore-asp-provider|forbidden marker/], + [ + "private provider entrypoint", + { + ...receipt, + provider: { ...receipt.provider, binPath: "private/runtime/opcore-asp-provider" } + }, + /node_modules\/\.bin\/opcore-asp-provider/ + ], ["failed ASP server add", failedManagerServerAdd(receipt), /manager server add status must be passed/], ["failed ASP repo enable", failedRepoEnable(receipt), /repo enable status must be passed/], ["failed ASP host check", failedHostCheck(receipt), /host check status must be passed/], ["missing host fixture evidence", missingHostFixture(receipt), /host fixture evidence/], ["host fixture mutates source repo", sourceMutatingHostFixture(receipt), /source repo/], ["failed provider probe", failedProviderProbe(receipt), /provider probe status must be passed/], - ["failed required old-tool guardrail", failedRequiredGuardrail(receipt), /required guardrail current-tools-validate-changed must pass/], + ["failed self-validation", failedSelfValidation(receipt), /self-validation status must be passed/], ["missing host receipt authority evidence", missingHostAuthority(receipt), /authorityEvidence/], ["provider output as host decision", providerDecisionLeak(receipt), /host-owned field|host-owned decision|hostOwnedFieldLeak/], - ["missing old-tool guardrail", missingGuardrail(receipt), /guardrail ids/], + ["missing self-validation", missingSelfValidation(receipt), /self-validation receipt is required/], ["unsupported inspect clean coverage", cleanInspectCoverage(receipt), /clean coverage/], ["silent local authority weakening", weakenedAuthority(receipt), /weaken shared authority/], - ["public publish action", { ...receipt, publicReleaseActions: [{ action: "publish" }] }, /public publish/], - ["old-tool replacement claim", { ...receipt, oldToolReplacementClaimed: true }, /old-tool replacement/] + ["public publish action", { ...receipt, publicReleaseActions: [{ action: "publish" }] }, /public publish/] ]; } @@ -113,8 +118,7 @@ function aspHomeFixture() { temp: true, isolated: true, sharedStateMutated: false, - pathSanitized: true, - aceRuntimeBinExcluded: true + pathSanitized: true }; } @@ -228,18 +232,22 @@ function assessmentFixture() { }; } -function guardrailsFixture() { - return [ - { ...command("current-tools-validate-changed", ["npm", "run", "current-tools:validate-changed"]), retained: true }, - { ...command("current-tools-validate-rust-graph", ["npm", "run", "current-tools:validate-rust-graph"]), retained: true }, - { id: "current-tools-validate-all", command: ["npm", "run", "current-tools:validate-all"], status: "retained-not-run", exitCode: null, stdoutSha256: "0".repeat(64), stderrSha256: "0".repeat(64), retained: true, assertion: "retained by default" } - ]; +function selfValidationFixture() { + return { + id: "opcore-self-check", + command: ["npm", "run", "opcore:self-check"], + status: "passed", + exitCode: 0, + stdoutSha256: "0".repeat(64), + stderrSha256: "0".repeat(64), + assertion: "Opcore self-validation passed" + }; } function unsupportedSurfacesFixture() { return [ { surface: "inspect", status: "parity-blocker", cleanCoverage: false, blocker: "inspect not mapped into ASP #120" }, - { surface: "edit", status: "retained-old-tool-gate", cleanCoverage: false, blocker: "edit not mapped into ASP #120" } + { surface: "edit", status: "parity-blocker", cleanCoverage: false, blocker: "edit not mapped into ASP #120" } ]; } @@ -300,13 +308,8 @@ function failedProviderProbe(receipt) { return { ...receipt, providerProbe: failedCommand(receipt.providerProbe) }; } -function failedRequiredGuardrail(receipt) { - return { - ...receipt, - currentToolGuardrails: receipt.currentToolGuardrails.map((entry) => - entry.id === "current-tools-validate-changed" ? failedCommand(entry) : entry - ) - }; +function failedSelfValidation(receipt) { + return { ...receipt, selfValidation: failedCommand(receipt.selfValidation) }; } function missingHostAuthority(receipt) { @@ -317,8 +320,10 @@ function providerDecisionLeak(receipt) { return { ...receipt, providerProbe: { ...receipt.providerProbe, assessment: { ...receipt.providerProbe.assessment, decision: "allow" }, hostOwnedFieldLeak: true } }; } -function missingGuardrail(receipt) { - return { ...receipt, currentToolGuardrails: receipt.currentToolGuardrails.filter((entry) => entry.id !== "current-tools-validate-changed") }; +function missingSelfValidation(receipt) { + const { selfValidation, ...withoutSelfValidation } = receipt; + void selfValidation; + return withoutSelfValidation; } function cleanInspectCoverage(receipt) { diff --git a/tests/helpers/validation-rust-fixtures.mjs b/tests/helpers/validation-rust-fixtures.mjs index 2d8dbd6..b3cfefa 100644 --- a/tests/helpers/validation-rust-fixtures.mjs +++ b/tests/helpers/validation-rust-fixtures.mjs @@ -113,9 +113,11 @@ export function fakeCargoScript({ return [ "#!/bin/sh", logLine, - 'if [ "$1" = "+nightly" ]; then', + 'case "$1" in', + "+nightly*)", " shift", - "fi", + " ;;", + "esac", 'if [ "$1" = "--version" ]; then', " printf '%s\\n' 'cargo 1.93.0'", " exit 0", @@ -175,10 +177,12 @@ export function writeFakeRustToolchain(bin, options = {}) { join(bin, "rust-code-analysis-cli"), fakeVersionedToolScript("rust-code-analysis-cli 0.0.25", options.rustCodeAnalysis) ); + const env = { ...process.env }; + delete env.OPCORE_RUST_NIGHTLY_TOOLCHAIN; return { bin, env: { - ...process.env, + ...env, PATH: bin } }; diff --git a/tests/import-boundaries.test.mjs b/tests/import-boundaries.test.mjs index 276fe7d..5485fdc 100644 --- a/tests/import-boundaries.test.mjs +++ b/tests/import-boundaries.test.mjs @@ -203,9 +203,7 @@ describe("package import boundaries", () => { /resolveGraphCoreArtifact/i, /native artifact loader/i, /raw sqlite/i, - /graph sqlite/i, - /graph-reference-evidence execution/i, - /\bcrg\s+(status|serve|query|refresh|build|inspect|impact|search)\b/i + /graph sqlite/i ]; for (const packageDir of ["edit", "validation"]) { for (const file of sourceFiles(`packages/${packageDir}/src`)) { diff --git a/tests/installed-bins.test.mjs b/tests/installed-bins.test.mjs index fbb11bc..dfa47db 100644 --- a/tests/installed-bins.test.mjs +++ b/tests/installed-bins.test.mjs @@ -16,7 +16,7 @@ import { releasePackageDirForName } from "../scripts/release-package-dirs.mjs"; import { createStagedOpcorePackage } from "../scripts/stage-opcore-bundle.mjs"; const removedLegacyCommandField = `legacy${"Command"}`; -const onboardingForbiddenOutput = /(^|[\\/"'\s])(?:lattice|crg|cix|rox)(?:$|[\\/"'\s])|\.ace\/runtime|LATTICE_CURRENT_TOOLS_DIR|\/Users\/tom|oldToolReplacementClaimed"?\s*:\s*true/i; +const onboardingForbiddenOutput = /(^|[\\/"'\s])lattice(?:$|[\\/"'\s])|\/Users\/tom/i; const currentTarget = `${process.platform}-${process.arch}`; const currentNativePackage = graphCoreNativePackageNamesByTarget[currentTarget]; @@ -45,7 +45,6 @@ describe("installed package bins", () => { assert.equal(existsSync(binPath(project, "opcore")), true); assert.equal(existsSync(binPath(project, "opcore-asp-provider")), true); assert.equal(existsSync(join(project, "node_modules", "opcore", "node_modules", "jsonc-parser", "package.json")), true); - for (const oldBin of ["lattice", "crg", "cix", "rox"]) assert.equal(existsSync(binPath(project, oldBin)), false, oldBin); assertAspProviderInitializeSmoke(project); assertSmoke(project, ["status", "--json"], 0); @@ -218,9 +217,6 @@ describe("installed package bins", () => { run("npm", ["install", "--ignore-scripts", "--no-audit", "--no-fund", ...tarballs], { cwd: project }); assert.equal(existsSync(binPath(project, "opcore")), true); - for (const forbiddenBin of ["lattice", "crg", "cix", "rox"]) { - assert.equal(existsSync(binPath(project, forbiddenBin)), false, forbiddenBin); - } const status = assertSmoke(project, ["status", "--json"], 0, "opcore"); assert.deepEqual(status.canonicalCommand, ["opcore", "status"]); const tryResult = assertSmoke(project, ["try", "--json"], 0, "opcore"); @@ -693,10 +689,7 @@ function assertManagedDescriptor(project) { ); assert.equal(existsSync(descriptorPath), true, descriptorPath); const descriptorText = readFileSync(descriptorPath, "utf8"); - assert.doesNotMatch( - descriptorText, - /(^|[\\/"'\s])\.ace(?:[\\/"'\s]|$)|LATTICE_CURRENT_TOOLS_DIR|\/Users\/tom|(^|[\\/\s])(?:lattice|crg|cix|rox)(?:$|[\\/\s])/i - ); + assert.doesNotMatch(descriptorText, /\/Users\/tom/i); const descriptor = validateManagedToolDescriptor(JSON.parse(descriptorText)); assert.deepEqual(descriptor.capabilities.validation.pythonProjectContext, { schemaId: "opcore.python.project-context.v1", @@ -778,7 +771,6 @@ function assertCliJson(command, args, expectedExitCode, cwd, options = {}) { ); assert.equal(Object.hasOwn(parsed, "alias"), false); assert.equal(Object.hasOwn(parsed, removedLegacyCommandField), false); - assert.notEqual(parsed.oldToolReplacementClaimed, true); assert.doesNotMatch(JSON.stringify(parsed), onboardingForbiddenOutput); if (options.telemetryPath) { writeLatencyRecord(options.telemetryPath, createLatencyRecord(command, parsed, options.fixture, cwd, durationMs)); diff --git a/tests/launch-claim-scrub.test.mjs b/tests/launch-claim-scrub.test.mjs index 7bc254f..9bab600 100644 --- a/tests/launch-claim-scrub.test.mjs +++ b/tests/launch-claim-scrub.test.mjs @@ -15,7 +15,6 @@ const repoRoot = fileURLToPath(new URL("..", import.meta.url)); // One positive sample per forbidden label. Each must be a phrase the alpha must never ship. const overclaimSamples = { "public ASP standard claim": "ASP is now the public standard for agent checks.", - "old-tool replacement claim": "Opcore replaces Rox, CRG, and CIX in your pipeline.", "generic Opcore replacement claim": "Opcore replaces your linters.", "universal stack claim": "Full coverage for every language and platform.", "universal agent claim": "Works with every agent on the market.", @@ -26,7 +25,6 @@ const overclaimSamples = { "blended score claim": "Get a single robustness score for the whole repo.", "asp router command claim": "Run opcore asp serve to start the host.", "provider authority claim": "The provider grants gate authority to allow merges.", - "ACE-managed distribution claim": "Distributed as an ACE-managed tool.", "old product name": "lattice validation complete.", "doubled Opcore token": "No Opcore/Opcore issue is open." }; @@ -79,7 +77,6 @@ test("scrub allowlists intentional internal transitional markers", () => { { label: "graph-package", text: "lattice-graph-core\n" }, { label: "daemon", text: "lattice.graph.daemon\n" }, { label: "generated-dist", text: "dist/lattice\n" }, - { label: "old-bin-policy", text: "oldBinsAbsent: { lattice: true, crg: true, cix: true, rox: true }\n" }, { label: "roadmap-policy", text: '- "Lattice" as product or launch branding.\n' } ]); @@ -94,8 +91,7 @@ test("honest launch wording passes the scrub", () => { "Opcore does not blend findings into an opaque score.", "Prefer concrete counts and file locations over scores.", "Providers assess; ASP hosts decide. Do not treat provider output as a gate decision.", - "Opcore is an independently installed ASP Core check provider; the host owns allow/deny decisions.", - "Retain your existing Rox, CRG, and CIX guardrails; this is additive." + "Opcore is an independently installed ASP Core check provider; the host owns allow/deny decisions." ].join("\n"); assert.deepEqual(scrubLaunchClaims(clean), []); }); diff --git a/tests/native-packaging-policy.test.mjs b/tests/native-packaging-policy.test.mjs index 65ab6a6..c6aafc3 100644 --- a/tests/native-packaging-policy.test.mjs +++ b/tests/native-packaging-policy.test.mjs @@ -90,6 +90,16 @@ describe("native graph-core packaging policy", () => { assert.match(triggerBlock, /merge_group:/); assert.match(triggerBlock, /dev/); assert.match(triggerBlock, /main/); + const checkJob = workflow.slice(workflow.indexOf(" check:"), workflow.indexOf("native-artifact:")); + assert.match(checkJob, /rustup toolchain install nightly-2026-07-27 --profile minimal/); + assert.match(checkJob, /cargo \+nightly-2026-07-27 install cargo-udeps --version 0\.1\.61 --locked/); + assert.match(checkJob, /test "\$\(cargo \+nightly-2026-07-27 udeps --version\)" = "cargo-udeps 0\.1\.61"/); + assert.match(checkJob, /cargo install rust-code-analysis-cli --version 0\.0\.25 --locked/); + assert.match( + checkJob, + /test "\$\(rust-code-analysis-cli --version\)" = "rust-code-analysis-cli 0\.0\.25"/ + ); + assert.match(checkJob, /OPCORE_RUST_NIGHTLY_TOOLCHAIN:\s*nightly-2026-07-27/); const nativeJob = workflow.slice(workflow.indexOf("native-artifact:"), workflow.indexOf("aggregate:")); for (const [target, expected] of Object.entries(nativeTargets)) { assert.match(nativeJob, new RegExp(`target: ${target}[\\s\\S]*?rust_target: ${expected.rustTarget}`)); diff --git a/tests/opcore-facade.test.mjs b/tests/opcore-facade.test.mjs index 2ca75c4..ca7cfff 100644 --- a/tests/opcore-facade.test.mjs +++ b/tests/opcore-facade.test.mjs @@ -17,6 +17,7 @@ import { compactScanValidationResult } from "../packages/opcore/dist/scan-valida const repoRoot = fileURLToPath(new URL("..", import.meta.url)); const opcoreBin = resolve(repoRoot, "packages/opcore/dist/index.js"); const sourceFixtureRoot = resolve(repoRoot, "packages/fixtures/source-extraction/wave1"); +const systemToolPath = "/usr/bin:/bin:/opt/homebrew/bin"; describe("opcore public facade", () => { it("runs zero-command scan with coverage-first output and only .opcore artifacts", () => { @@ -27,13 +28,12 @@ describe("opcore public facade", () => { assert.equal(human.stderr, ""); assert.equal(firstNonEmptyLine(human.stdout).startsWith("Coverage"), true); assert.equal(human.stdout.indexOf("Coverage") < human.stdout.indexOf("Findings"), true); - assert.doesNotMatch(human.stdout, /\blattice\b|\bcrg\b|\bcix\b|\brox\b|ASP setup|ACE setup|sibling checkout/i); + assert.doesNotMatch(human.stdout, /\blattice\b|ASP setup|sibling checkout/i); assert.equal(existsSync(join(fixtureRoot, ".opcore", "report.json")), true); assert.equal(existsSync(join(fixtureRoot, ".opcore", "history.jsonl")), true); assert.equal(existsSync(join(fixtureRoot, ".opcore", "telemetry.jsonl")), true); assert.deepEqual(readdirSync(join(fixtureRoot, ".opcore")).sort(), ["history.jsonl", "report.json", "telemetry.jsonl"]); assert.equal(existsSync(join(fixtureRoot, ".lattice")), false); - assert.equal(existsSync(join(fixtureRoot, ".ace")), false); assert.equal(existsSync(join(fixtureRoot, ".asp")), false); assert.deepEqual( collectRepoPaths(fixtureRoot).filter((path) => !before.includes(path) && path !== ".opcore" && !path.startsWith(".opcore/")), @@ -1093,7 +1093,7 @@ printf '%s\\n' '${JSON.stringify({ assert.match(agents, /opcore check --changed/); assert.match(agents, /preserve existing repo lint\/test\/CI\/pre-commit guardrails/i); assert.match(agents, /unsupported stacks and degraded tools/i); - assert.match(agents, /Do not rely on ACE, Rox, CRG, CIX, or ASP host authority/i); + assert.match(agents, /Use Opcore validation directly; ASP hosts retain their own decision authority/i); const undo = JSON.parse(readFileSync(join(temp, ".opcore", "init-undo.json"), "utf8")); assert.deepEqual( undo.entries.find((entry) => entry.path === ".gitignore"), @@ -1470,7 +1470,9 @@ printf '%s\\n' '${JSON.stringify({ if (fixture.gitInit) run("git", ["init"], temp, 0); for (const [path, content] of fixture.files) writeFixtureFile(temp, path, content); - const result = parseJson(runOpcore(["init", "--repo", temp, "--json"], temp, 0).stdout); + const result = parseJson( + runOpcore(["init", "--repo", temp, "--json"], temp, 0, { ...process.env, PATH: systemToolPath }).stdout + ); const languages = result.opcoreInit.settings.languages.map((entry) => entry.language).sort(); assert.equal(result.opcoreInit.scan.totalFiles, fixture.expect.totalFiles, fixture.name); @@ -2005,7 +2007,7 @@ printf '%s\\n' '${JSON.stringify({ assert.match(human, /Findings:\n(?:.*\n)* typescript\.type_errors:/); assert.match(human, /rust\.source_hygiene:/); assert.match(human, /coverage\.unsupported_stacks:/); - assert.doesNotMatch(human, /score|SAST|security scanner|AI authorship|Rox|CRG|CIX|ACE/i); + assert.doesNotMatch(human, /score|SAST|security scanner|AI authorship/i); } finally { for (const root of cleanupRoots) rmSync(root, { recursive: true, force: true }); rmSync(temp, { recursive: true, force: true }); diff --git a/tests/provenance-policy.test.mjs b/tests/provenance-policy.test.mjs index 09081fb..913f26e 100644 --- a/tests/provenance-policy.test.mjs +++ b/tests/provenance-policy.test.mjs @@ -36,7 +36,7 @@ describe("provenance history policy", () => { commitAll(repo); const result = run(repo, ["scripts/check-provenance.mjs"], { expectFailure: true }); - assert.match(`${result.stderr}\n${result.stdout}`, /Forbidden Python code-review-graph provenance|refs\/heads\/main/); + assert.match(`${result.stderr}\n${result.stdout}`, /Forbidden copied git history provenance|refs\/heads\/main/); } finally { rmSync(repo, { recursive: true, force: true }); } @@ -44,7 +44,7 @@ describe("provenance history policy", () => { }); function tempPolicyRepo() { - const repo = mkdtempSync(join(tmpdir(), "lattice-provenance-policy-")); + const repo = mkdtempSync(join(tmpdir(), "opcore-provenance-policy-")); mkdirSync(join(repo, "scripts"), { recursive: true }); cpSync(join(repoRoot, "scripts/check-provenance.mjs"), join(repo, "scripts/check-provenance.mjs")); const init = spawnSync("git", ["init", "--quiet"], { cwd: repo, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }); diff --git a/tests/scaffold.test.mjs b/tests/scaffold.test.mjs index a8a0724..ef82da5 100644 --- a/tests/scaffold.test.mjs +++ b/tests/scaffold.test.mjs @@ -3,8 +3,49 @@ import assert from "node:assert/strict"; import { existsSync, readFileSync } from "node:fs"; const readJson = (path) => JSON.parse(readFileSync(path, "utf8")); +const selfValidationCheckIds = [ + "typescript.syntax", + "typescript.types", + "typescript.lint", + "typescript.import-graph", + "typescript.dead-code", + "typescript.function-metrics", + "typescript.relevant-tests", + "typescript.file-length", + "rust.source-hygiene", + "rust.fmt", + "rust.cargo-check", + "rust.clippy", + "rust.rustdoc", + "rust.import-graph", + "rust.dead-code", + "rust.graph-signals", + "rust.unused-deps", + "rust.file-length", + "rust.function-metrics", + "python.syntax", + "python.source-hygiene", + "python.ruff-lint", + "python.ruff-format", + "python.types", + "python.import-graph", + "python.dead-code", + "python.relevant-tests", + "python.pytest", + "docs.existence", + "docs.staleness", + "docs.freshness", + "docs.length", + "docs.dry", + "docs.content-quality", + "docs.code-blocks", + "docs.rules-why", + "docs.hub-coverage", + "docs.subtree-coverage", + "clone.duplication" +]; -describe("lattice scaffold", () => { +describe("Opcore scaffold", () => { it("keeps opcore, graph, edit, and validation as separate package tracks", () => { const root = readJson("package.json"); assert.deepEqual(root.workspaces, [ @@ -57,45 +98,17 @@ describe("lattice scaffold", () => { ]); }); - it("does not publish as code-review-graph or gungnir", () => { - for (const name of [ - "contracts", - "opcore", - "graph", - "edit", - "validation", - "validation-policy", - "validation-clone", - "validation-docs", - "validation-python", - "validation-rust", - "validation-typescript", - "fixtures" - ]) { - const manifest = readJson(`packages/${name}/package.json`); - assert.equal(manifest.name.includes("code-review-graph"), false); - assert.equal(manifest.name.includes("gungnir"), false); - } - }); - - it("keeps agent tooling pointed at current external tools", () => { + it("uses Opcore for repository validation", () => { assert.equal(readFileSync("AGENTS.md", "utf8"), readFileSync("CLAUDE.md", "utf8")); - assert.equal(existsSync("ace.json"), true); - assert.equal(existsSync("rox.json"), true); + assert.equal(existsSync(".opcore/config"), true); assert.equal(existsSync(".zeroshot/settings.json"), true); - assert.equal(existsSync("scripts/setup-current-tools.sh"), true); + assert.equal(existsSync("scripts/run-opcore-self-check.mjs"), true); assert.equal(existsSync("scripts/ci/run-local-ci-equivalent.sh"), true); - const setupTools = readFileSync("scripts/setup-current-tools.sh", "utf8"); - assert.match(setupTools, /external ACE-managed tools/); - assert.match(setupTools, /implementation_package_dir/); - assert.match(setupTools, /use current external tools, not \$\{implementation_path\}/); - assert.match(setupTools, /aceTools/); - assert.match(setupTools, /binRoot/); - assert.match(setupTools, /latticeCurrentTools/); - - const ace = readJson("ace.json"); - assert.match(ace.mcpServers["code-review-graph"].args.join("\n"), /\.ace\/runtime\/bin\/crg/); + const root = readJson("package.json"); + assert.equal(root.scripts.setup, "npm ci"); + assert.match(root.scripts["opcore:self-check"], /scripts\/run-opcore-self-check\.mjs/); + assert.deepEqual(readJson(".zeroshot/settings.json").worktree.setup, ["npm ci"]); }); it("pins runtime CLI decision anchors", () => { @@ -132,3 +145,20 @@ describe("lattice scaffold", () => { } }); }); + +describe("Opcore self-validation policy", () => { + it("selects every registered check with strict complexity thresholds", () => { + const policy = readJson(".opcore/config").validation; + assert.deepEqual(policy.adapters, ["typescript", "rust", "python", "docs", "clone"]); + assert.deepEqual(policy.checks.defaults, selfValidationCheckIds); + assert.deepEqual(policy.checks.disabled, []); + assert.deepEqual(policy.checks.typescript, { + fileLength: { maxFileLines: 300 }, + functionMetrics: { maxFunctionLines: 80, maxComplexity: 10, maxParams: 4 } + }); + assert.deepEqual(policy.checks.rust, { + fileLength: { maxFileLines: 500 }, + functionMetrics: { maxFunctionLines: 80, maxComplexity: 10, maxParams: 4 } + }); + }); +}); diff --git a/tests/schema-contracts.test.mjs b/tests/schema-contracts.test.mjs index 6b80a46..73f81de 100644 --- a/tests/schema-contracts.test.mjs +++ b/tests/schema-contracts.test.mjs @@ -2182,13 +2182,6 @@ describe("Opcore JSON schema wire constraints", () => { }), false ); - assert.equal( - isValidDefinition("ManagedToolDescriptor", { - ...validManagedToolDescriptor(), - entrypoints: [{ ...validManagedToolDescriptor().entrypoints[0], bin: ["r", "o", "x"].join("") }] - }), - false - ); assert.equal( isValidDefinition("ManagedToolDescriptor", { ...validManagedToolDescriptor(), @@ -2210,40 +2203,6 @@ describe("Opcore JSON schema wire constraints", () => { }), false ); - assert.equal( - isValidDefinition("ManagedToolDescriptor", { - ...validManagedToolDescriptor(), - artifacts: [{ ...validManagedToolDescriptor().artifacts[0], path: ".ace\\runtime\\bin\\lattice" }] - }), - false - ); - assert.equal( - isValidDefinition("ManagedToolDescriptor", { - ...validManagedToolDescriptor(), - artifacts: [{ ...validManagedToolDescriptor().artifacts[0], path: ".ace" }] - }), - false - ); - assert.equal( - isValidDefinition("ManagedToolDescriptor", { - ...validManagedToolDescriptor(), - artifacts: [{ ...validManagedToolDescriptor().artifacts[0], path: "dist/.ace" }] - }), - false - ); - assert.equal( - isValidDefinition("ManagedToolDescriptor", { - ...validManagedToolDescriptor(), - provenanceHooks: [ - { - id: "private-runtime-wrapper", - command: [".ace\\runtime\\bin\\lattice", "status"], - expectedExitCode: 0 - } - ] - }), - false - ); assert.equal( isValidDefinition("ManagedToolDescriptor", { ...validManagedToolDescriptor(), @@ -2544,103 +2503,6 @@ describe("Opcore JSON schema wire constraints", () => { ); }); - it("accepts and rejects graph reference evidence manifest schemas", () => { - const manifest = validGraphReferenceEvidenceManifest(); - assert.equal(isValidDefinition("GraphReferenceEvidenceManifest", manifest), true); - assert.equal( - isValidDefinition("GraphReferenceEvidenceManifest", { - ...manifest, - issue: "#18" - }), - false - ); - assert.equal( - isValidDefinition("GraphReferenceEvidenceManifest", { - ...manifest, - commandSurfaces: [] - }), - false - ); - assert.equal( - isValidDefinition("GraphReferenceEvidenceManifest", { - ...manifest, - commandSurfaces: [ - { - ...manifest.commandSurfaces[0], - classification: "release_blocking" - } - ] - }), - false - ); - assert.equal( - isValidDefinition("GraphReferenceEvidenceManifest", { - ...manifest, - commandSurfaces: [ - { - ...manifest.commandSurfaces[0], - fixtures: [] - } - ] - }), - false - ); - assert.equal( - isValidDefinition("GraphReferenceEvidenceManifest", { - ...manifest, - optionalAnalysisSurfaces: [ - { - ...manifest.optionalAnalysisSurfaces[0], - fixtures: [] - } - ] - }), - false - ); - assert.equal( - isValidDefinition("GraphReferenceEvidenceManifest", { - ...manifest, - optionalAnalysisSurfaces: manifest.optionalAnalysisSurfaces.map((surface) => - surface.id === "flows" ? { ...surface, issue: "#13" } : surface - ) - }), - false - ); - assert.equal( - isValidDefinition("GraphReferenceEvidenceManifest", { - ...manifest, - optionalAnalysisSurfaces: manifest.optionalAnalysisSurfaces.map((surface) => - surface.id === "flows" ? { ...surface, classification: "required" } : surface - ) - }), - false - ); - assert.equal( - isValidDefinition("GraphReferenceEvidenceManifest", { - ...manifest, - optionalAnalysisSurfaces: manifest.optionalAnalysisSurfaces.map(({ issue, ...surface }) => surface) - }), - false - ); - assert.equal( - isValidDefinition("GraphReferenceEvidenceManifest", { - ...manifest, - optionalAnalysisSurfaces: manifest.optionalAnalysisSurfaces.map((surface) => ({ ...surface, fixtures: [] })) - }), - true - ); - assert.equal( - isValidDefinition("GraphReferenceEvidenceManifest", { - ...manifest, - provenance: { - ...manifest.provenance, - containsGitHistory: true - } - }), - false - ); - }); - it("accepts and rejects graph release receipt schemas", () => { const receipt = validGraphReleaseReceipt(); assert.equal(isValidDefinition("GraphReleaseReceipt", receipt), true); @@ -2715,6 +2577,10 @@ describe("Opcore JSON schema wire constraints", () => { }), false ); + }); + + it("rejects incomplete graph release query schemas", () => { + const receipt = validGraphReleaseReceipt(); assert.equal( isValidDefinition("GraphReleaseReceipt", { ...receipt, @@ -2722,6 +2588,10 @@ describe("Opcore JSON schema wire constraints", () => { }), false ); + }); + + it("rejects incomplete graph release ownership and optional-surface schemas", () => { + const receipt = validGraphReleaseReceipt(); assert.equal( isValidDefinition("GraphReleaseReceipt", { ...receipt, @@ -2768,6 +2638,10 @@ describe("Opcore JSON schema wire constraints", () => { }), false ); + }); + + it("rejects incomplete graph release transport, handoff, and package schemas", () => { + const receipt = validGraphReleaseReceipt(); assert.equal( isValidDefinition("GraphReleaseReceipt", { ...receipt, @@ -2871,17 +2745,6 @@ describe("Opcore JSON schema wire constraints", () => { }), false ); - assert.equal( - isValidDefinition("ReleaseReceipt", { - ...receipt, - packages: receipt.packages.map((entry) => - entry.packageName === "opcore" - ? { ...entry, bins: { ...entry.bins, rox: "dist/index.js" } } - : entry - ) - }), - false - ); assert.equal( isValidDefinition("ReleaseReceipt", { ...receipt, @@ -2941,6 +2804,10 @@ describe("Opcore JSON schema wire constraints", () => { }), false ); + }); + + it("rejects invalid cutover command and installed-package schemas", () => { + const receipt = validReleaseCutoverReceipt(); assert.equal( isValidDefinition("ReleaseCutoverReceipt", { ...receipt, @@ -2952,6 +2819,10 @@ describe("Opcore JSON schema wire constraints", () => { }), false ); + }); + + it("rejects incomplete cutover language and negative-check schemas", () => { + const receipt = validReleaseCutoverReceipt(); assert.equal( isValidDefinition("ReleaseCutoverReceipt", { ...receipt, @@ -3030,14 +2901,22 @@ describe("Opcore JSON schema wire constraints", () => { assert.equal( isValidDefinition("ReleaseCutoverReceipt", { ...receipt, - pythonCommandReceipts: receipt.pythonCommandReceipts.map((entry) => - entry.id === "graph-python-search" - ? { ...entry, command: ["lattice", "graph", "search", "Greeting"], canonicalCommand: ["lattice", "graph", "search", "Greeting"] } + pythonCommandReceipts: receipt.pythonCommandReceipts.map((entry) => + entry.id === "graph-python-search" + ? { + ...entry, + command: ["node", "graph", "search", "Greeting"], + canonicalCommand: ["node", "graph", "search", "Greeting"] + } : entry ) }), false ); + }); + + it("rejects invalid cutover self-validation and environment schemas", () => { + const receipt = validReleaseCutoverReceipt(); assert.equal( isValidDefinition("ReleaseCutoverReceipt", { ...receipt, @@ -3061,7 +2940,7 @@ describe("Opcore JSON schema wire constraints", () => { ...receipt, negativeChecks: receipt.negativeChecks.map((entry) => entry.id === "missing-required-graph-check" - ? { ...entry, command: ["lattice", "check", "files", "src/index.ts", "--graph-mode", "required"] } + ? { ...entry, command: ["node", "check", "files", "src/index.ts", "--graph-mode", "required"] } : entry ) }), @@ -3070,28 +2949,16 @@ describe("Opcore JSON schema wire constraints", () => { assert.equal( isValidDefinition("ReleaseCutoverReceipt", { ...receipt, - currentToolGuardrails: receipt.currentToolGuardrails.filter((entry) => entry.id !== "current-tools-validate-changed") + selfValidation: { ...receipt.selfValidation, status: "failed", exitCode: 1 } }), false ); - assert.equal( - isValidDefinition("ReleaseCutoverReceipt", { - ...receipt, - currentToolGuardrails: receipt.currentToolGuardrails.map((entry) => - entry.id === "current-tools-validate-changed" - ? { ...entry, status: "retained-not-run", exitCode: null } - : entry - ) - }), - false - ); - assert.equal(isValidDefinition("ReleaseCutoverReceipt", { ...receipt, oldToolReplacementClaimed: true }), false); assert.equal( isValidDefinition("ReleaseCutoverReceipt", { ...receipt, installedPackages: receipt.installedPackages.map((entry) => entry.packageName === "opcore" - ? { ...entry, installedManifest: { ...entry.installedManifest, bins: { lattice: "dist/index.js", crg: "dist/index.js" } } } + ? { ...entry, installedManifest: { ...entry.installedManifest, bins: { unexpected: "dist/index.js" } } } : entry ) }), @@ -3116,39 +2983,7 @@ describe("Opcore JSON schema wire constraints", () => { assert.equal( isValidDefinition("ReleaseCutoverReceipt", { ...receipt, - environmentIsolation: { ...receipt.environmentIsolation, opcoreBinOnly: false } - }), - false - ); - }); - - it("accepts and rejects old-Rox comparison receipt schemas", () => { - const receipt = validRustOldRoxComparisonReceipt(); - assert.equal(isValidDefinition("RustOldRoxComparisonReceipt", receipt), true); - assert.equal( - isValidDefinition("RustOldRoxComparisonReceipt", { - ...receipt, - surfaces: receipt.surfaces.filter((entry) => entry.id !== "rust.dead-code") - }), - false - ); - assert.equal(isValidDefinition("RustOldRoxComparisonReceipt", { ...receipt, oldToolReplacementClaimed: true }), false); - assert.equal(isValidDefinition("RustOldRoxComparisonReceipt", { ...receipt, publicReleaseActions: ["publish"] }), false); - assert.equal( - isValidDefinition("RustOldRoxComparisonReceipt", { - ...receipt, - surfaces: receipt.surfaces.map((entry) => - entry.id === "rust.function-metrics" ? { ...entry, replacementStatus: "replaced" } : entry - ) - }), - false - ); - assert.equal( - isValidDefinition("RustOldRoxComparisonReceipt", { - ...receipt, - surfaces: receipt.surfaces.map((entry) => - entry.id === "rust.import-graph" ? { ...entry, graphEvidenceExists: true, graphEvidence: [] } : entry - ) + environmentIsolation: { ...receipt.environmentIsolation, opcoreBinsVerified: false } }), false ); @@ -3166,6 +3001,10 @@ describe("Opcore JSON schema wire constraints", () => { }), false ); + }); + + it("rejects invalid ASP dogfood authority and parity schemas", () => { + const receipt = validAspDogfoodReceipt(); assert.equal( isValidDefinition("AspDogfoodReceipt", { ...receipt, @@ -3199,7 +3038,7 @@ describe("Opcore JSON schema wire constraints", () => { assert.equal( isValidDefinition("AspDogfoodReceipt", { ...receipt, - currentToolGuardrails: receipt.currentToolGuardrails.filter((entry) => entry.id !== "current-tools-validate-changed") + selfValidation: { ...receipt.selfValidation, status: "failed", exitCode: 1 } }), false ); @@ -4332,130 +4171,6 @@ function validCommandAdapterRequest() { }; } -function validGraphReferenceEvidenceManifest() { - return { - schemaVersion: 1, - issue: "#19", - origin: "covibes-authored-synthetic", - fixtureRefs: [ - "packages/fixtures/graph-reference-evidence/sqlite-fixtures.json", - "packages/fixtures/graph-reference-evidence/daemon-socket-fixtures.json", - "packages/fixtures/graph-reference-evidence/golden-corpus.json", - "packages/fixtures/graph-reference-evidence/baseline-receipts.json" - ], - commandSurfaces: [ - { - id: "graph-reference-status", - classification: "required", - referenceTool: "current external graph dev wrapper", - referenceCommand: ["status"], - canonicalCommand: ["opcore", "graph", "status"], - flags: ["--repo", "--json"], - positionals: [], - fixtures: ["status-json"], - exitSemantics: { - success: 0, - failure: "nonzero" - } - } - ], - jsonOutputSurfaces: [ - { - id: "status-json", - command: "status", - classification: "required", - requiredFields: ["status", "summary"], - fixtures: ["status-json"], - exitSemantics: { - success: 0, - failure: "nonzero" - } - } - ], - sqliteFixtures: [ - { - id: "sqlite-required-views", - classification: "required", - fixture: "packages/fixtures/graph-reference-evidence/sqlite-fixtures.json", - tables: ["metadata", "nodes", "edges"], - indexes: ["idx_nodes_file"], - metadataKeys: ["schema_version"], - nodeKinds: ["File", "Function", "Test", "Module", "Struct", "Enum", "Trait", "Impl", "Method", "TypeAlias", "Const", "Static", "Macro"], - edgeKinds: ["CALLS", "CONTAINS", "IMPORTS_FROM", "TESTED_BY", "IMPLEMENTS", "DEPENDS_ON", "INHERITS"], - directReaderQueries: ["status-counts"], - fixtures: ["sqlite-fixtures"] - } - ], - daemonFixtures: [ - { - id: "daemon-hot-query", - classification: "required", - fixture: "packages/fixtures/graph-reference-evidence/daemon-socket-fixtures.json", - protocol: "opcore.graph.daemon", - envelopes: ["ping-request", "success-response"], - fixtures: ["daemon-fixtures"] - } - ], - baselineReceipts: [ - { - id: "install-setup", - metric: "install_setup_ms", - classification: "required", - receipt: "packages/fixtures/graph-reference-evidence/baseline-receipts.json", - label: "reference_evidence_non_implementation_input", - sourceAvailability: "unavailable", - nonImplementationInput: true, - fixtures: ["baseline-receipts"] - } - ], - optionalAnalysisSurfaces: [ - { - issue: "#13", - id: "coverage", - classification: "deferred", - status: "deferred", - fixtures: ["coverage-deferred-marker"] - }, - { - issue: "#14", - id: "flows", - classification: "optional", - status: "deferred", - fixtures: ["sqlite-fixtures"] - }, - { - issue: "#15", - id: "communities", - classification: "optional", - status: "deferred", - fixtures: ["sqlite-fixtures"] - }, - { - issue: "#16", - id: "read_only_suggestions", - classification: "supporting", - status: "deferred", - fixtures: ["sqlite-fixtures"] - } - ], - goldenCorpus: { - id: "graph-reference-evidence-golden-corpus-v1", - classification: "required", - fixture: "packages/fixtures/graph-reference-evidence/golden-corpus.json", - covers: ["parser", "store", "query", "search", "freshness", "status"], - fixtures: ["golden-corpus"] - }, - provenance: { - containsPythonCrgSource: false, - containsPackageMetadata: false, - containsGitHistory: false, - referenceReceiptsAreImplementationInput: false, - implementationPackageNames: ["@the-open-engine/opcore-graph"], - allowedMentionPaths: ["docs/graph-reference-evidence/", "packages/fixtures/graph-reference-evidence/"] - } - }; -} - function validGraphReleaseReceipt() { const commandIds = [ "opcore-graph-build", @@ -4589,7 +4304,7 @@ function validGraphReleaseReceipt() { value: 1, unit: metric.endsWith("_bytes") ? "bytes" : "ms", baselineIssue: "#19", - baselineReceipt: "packages/fixtures/graph-reference-evidence/baseline-receipts.json", + baselineReceipt: "docs/release/graph-release-receipt.json", comparison: "recorded" })), packageInspection: { @@ -4600,10 +4315,10 @@ function validGraphReleaseReceipt() { forbiddenMarkersAbsent: true, generatedBuildMetadataAbsent: true, privatePathsAbsent: true, - pythonCrgSourceAbsent: true, - pythonGraphPackageMetadataAbsent: true, - pythonCrgGitHistoryAbsent: true, - forbiddenImplementationPackageNamesAbsent: true, + sourceProvenanceAbsent: true, + packageMetadataAbsent: true, + gitHistoryAbsent: true, + foreignImplementationNamesAbsent: true, inspections: ["npm-pack-dry-run"] }, supportedNativeTargets: graphCoreNativeSupportedTargets, @@ -4686,7 +4401,7 @@ function validGraphReleaseReceipt() { issue, receiptPath: "docs/release/graph-release-receipt.payload.json", checksumSha256: "b".repeat(64), - rollbackNote: "Keep ACE wrappers on current external tools if receipt regresses." + rollbackNote: "Block release and repair Opcore self-validation if this receipt regresses." })) }; } @@ -5065,18 +4780,9 @@ function validReleaseCutoverReceipt() { resolvedChecksums: descriptor.resolvedChecksums }, environmentIsolation: { - currentToolEnvCleared: true, - clearedEnvVarCount: 5, pathSanitized: true, - aceRuntimeBinExcluded: true, - siblingCovibesExcluded: true, - opcoreBinOnly: true, - oldBinsAbsent: { - lattice: true, - crg: true, - cix: true, - rox: true - } + siblingRepositoriesExcluded: true, + opcoreBinsVerified: true }, commandReceipts, rustCommandReceipts, @@ -5125,35 +4831,19 @@ function validReleaseCutoverReceipt() { assertion: "missing Python toolchain stayed degraded" } ], - currentToolGuardrails: [ - { - id: "current-tools-validate-changed", - command: ["npm", "run", "current-tools:validate-changed"], - status: "passed", - exitCode: 0, - stdoutSha256: "7".repeat(64), - stderrSha256: "8".repeat(64), - retained: true, - assertion: "retained changed-file guardrail", - oldToolReplacementClaimed: false - }, - { - id: "current-tools-validate-rust-graph", - command: ["npm", "run", "current-tools:validate-rust-graph"], - status: "passed", - exitCode: 0, - stdoutSha256: "7".repeat(64), - stderrSha256: "8".repeat(64), - retained: true, - assertion: "retained Rust graph guardrail", - oldToolReplacementClaimed: false - } - ], - oldToolReplacementClaimed: false, + selfValidation: { + id: "opcore-self-check", + command: ["npm", "run", "opcore:self-check"], + status: "passed", + exitCode: 0, + stdoutSha256: "7".repeat(64), + stderrSha256: "8".repeat(64), + assertion: "Opcore self-validation passed" + }, forbiddenMarkerScan: { scannedTextCount: 12, findingCount: 0, - markersBlocked: ["private-runtime", "current-tool-env", "private-home", "old-tool-bins"] + markersBlocked: ["private-home", "launch-claim"] }, inputEvidence: [ { @@ -5188,75 +4878,9 @@ function pythonCutoverEvidence(id) { }[id]; } -function validRustOldRoxComparisonReceipt() { - const surface = (id, graphEvidenceExists, graphEvidence, stillUniquelyProvidedByCurrentTools, replacementStatus = "retained") => ({ - id, - graphEvidenceExists, - graphEvidence, - stillUniquelyProvidedByCurrentTools, - replacementStatus - }); - return { - schemaVersion: 1, - issue: "#29", - origin: "covibes-authored-old-rox-comparison", - generatedAt: "2026-06-27T00:00:00.000Z", - privateRepo: true, - oldToolReplacementClaimed: false, - publicReleaseActions: [], - surfaces: [ - surface( - "rust.rustdoc", - false, - ["No graph fact replaces rustdoc diagnostics."], - ["rustdoc diagnostics and broken intra-doc link policy remain current-tool evidence."] - ), - surface( - "rust.import-graph", - true, - ["Rust graph emits IMPORTS_FROM and DEPENDS_ON edges for module files."], - ["Rox/current tooling still uniquely provides rustdoc and cargo-depgraph-enriched import checks."], - "deferred" - ), - surface( - "rust.dead-code", - true, - ["Rust graph emits exported symbol metadata and graph-backed dead public export signals."], - ["Cargo dead_code diagnostics and retained Rox gate behavior still uniquely cover compiler reachability."] - ), - surface( - "rust.unused-deps", - false, - ["No graph fact replaces cargo-udeps unused dependency analysis."], - ["cargo-udeps/Rox unused dependency detection remains current-tool evidence."] - ), - surface( - "rust.function-metrics", - true, - ["Rust graph emits symbol spans and signatures for functions and methods."], - ["rust-code-analysis complexity and parameter thresholds remain current-tool evidence."] - ), - surface( - "current-tools:validate-rust-graph", - false, - ["Graph receipts do not replace the aggregate current-tools Rust graph gate."], - ["npm run current-tools:validate-rust-graph remains the retained aggregate guardrail."] - ) - ], - guardrails: [ - { - id: "current-tools:validate-rust-graph", - command: ["npm", "run", "current-tools:validate-rust-graph"], - replacementStatus: "retained", - oldToolReplacementClaimed: false - } - ] - }; -} - function validAspDogfoodReceipt() { const cutover = validReleaseCutoverReceipt(); - const markers = ["opcore asp serve", "opcore asp", "dist/bin/lattice", ".ace/runtime"]; + const markers = ["opcore asp serve", "opcore asp"]; const aspRepo = covibesPath("agent-server-protocol"); const hostFixtureRepo = "/tmp/opcore-asp-dogfood/asp-host-fixture"; const command = (id, commandParts, output = {}) => ({ @@ -5320,8 +4944,7 @@ function validAspDogfoodReceipt() { temp: true, isolated: true, sharedStateMutated: false, - pathSanitized: true, - aceRuntimeBinExcluded: true + pathSanitized: true }, hostFixture: { repo: hostFixtureRepo, @@ -5379,32 +5002,18 @@ function validAspDogfoodReceipt() { diagnosticsCount: 0, hostOwnedFieldLeak: false }, - currentToolGuardrails: [ - { ...command("current-tools-validate-changed", ["npm", "run", "current-tools:validate-changed"]), retained: true }, - { ...command("current-tools-validate-rust-graph", ["npm", "run", "current-tools:validate-rust-graph"]), retained: true }, - { - id: "current-tools-validate-all", - command: ["npm", "run", "current-tools:validate-all"], - status: "retained-not-run", - exitCode: null, - stdoutSha256: "0".repeat(64), - stderrSha256: "0".repeat(64), - retained: true, - assertion: "retained by default" - } - ], + selfValidation: cutover.selfValidation, unsupportedSurfaces: [ { surface: "inspect", status: "parity-blocker", cleanCoverage: false, blocker: "inspect not mapped into ASP #120" }, - { surface: "edit", status: "retained-old-tool-gate", cleanCoverage: false, blocker: "edit not mapped into ASP #120" } + { surface: "edit", status: "parity-blocker", cleanCoverage: false, blocker: "edit not mapped into ASP #120" } ], - parityBlockers: [{ source: "docs/planning/old-tool-compatibility-matrix.md:1", detail: "old-tool guardrails retained" }], + parityBlockers: [], authority: { hostOwnsDecisions: true, providerOutputIsHostDecision: false, localAuthorityOverride: { present: false, sharedAuthorityWeakened: false } }, publicReleaseActions: [], - oldToolReplacementClaimed: false, forbiddenMarkerScan: { scannedTextCount: 2, findingCount: 0, diff --git a/tests/source-package-contracts.test.ts b/tests/source-package-contracts.test.ts new file mode 100644 index 0000000..29b8481 --- /dev/null +++ b/tests/source-package-contracts.test.ts @@ -0,0 +1,19 @@ +import { commandRouterManifest } from "@the-open-engine/opcore-contracts"; +import { createEditCommandAdapter } from "@the-open-engine/opcore-edit"; +import { createEphemeralGraphSnapshot } from "@the-open-engine/opcore-graph"; +import { createDocsValidationChecks } from "@the-open-engine/opcore-validation-docs"; +import { createTypeScriptValidationChecks } from "@the-open-engine/opcore-validation-typescript"; +import { routeOpcoreCommand } from "opcore"; + +assertRuntimeExport("commandRouterManifest", commandRouterManifest, "object"); +assertRuntimeExport("createEditCommandAdapter", createEditCommandAdapter, "function"); +assertRuntimeExport("createEphemeralGraphSnapshot", createEphemeralGraphSnapshot, "function"); +assertRuntimeExport("createDocsValidationChecks", createDocsValidationChecks, "function"); +assertRuntimeExport("createTypeScriptValidationChecks", createTypeScriptValidationChecks, "function"); +assertRuntimeExport("routeOpcoreCommand", routeOpcoreCommand, "function"); + +function assertRuntimeExport(name: string, value: unknown, expectedType: "function" | "object"): void { + if (typeof value !== expectedType) { + throw new Error(`${name} must be a runtime ${expectedType} export`); + } +} diff --git a/tests/validation-cli.test.mjs b/tests/validation-cli.test.mjs index dd72d01..afb2710 100644 --- a/tests/validation-cli.test.mjs +++ b/tests/validation-cli.test.mjs @@ -1,11 +1,10 @@ -import { describe, it } from "node:test"; +import { it } from "node:test"; import assert from "node:assert/strict"; import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import { spawnSync } from "node:child_process"; -import { routeCommand } from "../packages/opcore/dist/advanced/index.js"; import { routeOpcoreCommand } from "../packages/opcore/dist/index.js"; import { fakeCargoScript, writeFakeRustToolchain } from "./helpers/validation-rust-fixtures.mjs"; @@ -61,7 +60,6 @@ const cloneCheckIds = ["clone.duplication"]; const typeScriptExecutableDefaultCheckIds = typeScriptCheckIds.filter((checkId) => checkId !== "typescript.lint"); const pythonExecutableDefaultCheckIds = pythonCheckIds.filter((checkId) => checkId !== "python.pytest"); const executableDefaultCheckIds = [...typeScriptExecutableDefaultCheckIds, ...rustCheckIds, ...pythonExecutableDefaultCheckIds, ...cloneCheckIds]; -const defaultCheckIds = [...typeScriptCheckIds, ...rustCheckIds, ...pythonCheckIds, ...docsCheckIds, ...cloneCheckIds]; const availableCheckIds = [ ...typeScriptCheckIds, ...rustCheckIds, @@ -72,8 +70,9 @@ const availableCheckIds = [ ...docsCheckIds, ...cloneCheckIds ]; +const repoWideDocsCheckIds = ["docs.existence", "docs.hub-coverage", "docs.subtree-coverage"]; +const rootFileConfiguredCheckIds = availableCheckIds.filter((checkId) => !repoWideDocsCheckIds.includes(checkId)); -describe("validation CLI", () => { it("keeps opcore status separate from validation execution results", async () => { const temp = mkdtempSync(join(tmpdir(), "opcore-validation-status-")); try { @@ -147,7 +146,7 @@ describe("validation CLI", () => { const result = run(args, [0, 1]); assert.equal(result.owner, "validation"); assert.equal(result.exitCode === 0 || result.exitCode === 1, true); - assert.deepEqual(result.validationResult.manifest.checks, executableDefaultCheckIds); + assert.deepEqual(result.validationResult.manifest.checks, rootFileConfiguredCheckIds); assert.equal(Object.hasOwn(result.validationResult.manifest, "entries"), false); assert.equal(Object.hasOwn(result.validationResult.manifest, "runs"), false); assert.equal(Object.hasOwn(result.validationResult.manifest, "skippedChecks"), false); @@ -321,111 +320,7 @@ describe("validation CLI", () => { it("normalizes repo validation config", async () => { const temp = mkdtempSync(join(tmpdir(), "opcore-validation-config-")); try { - writeRepoConfigObject(temp, { - schemaVersion: 1, - kind: "opcore_init_config", - onboarding: { - scan: { - totalFiles: 1 - } - }, - validation: { - adapters: ["typescript", "rust", "docs", "clone"], - timeoutMs: 120000, - pathPolicy: { - include: ["packages/", "scripts/"], - exclude: ["dist/**", ".ace"] - }, - checks: { - packs: ["./checks/policy.cjs"], - disabled: ["typescript.types"], - defaults: ["docs.existence", "docs.freshness"], - typescript: { - fileLength: { - maxFileLines: 600 - }, - functionMetrics: { - maxFunctionLines: 120, - maxComplexity: 10, - maxParams: 4 - }, - lint: { - repoPlugin: "./eslint-local-rules/index.js", - cacheDependencyGlobs: ["CLAUDE.md", "**/CLAUDE.md"] - }, - importGraph: { - ignoreTypeOnlyImports: true, - layerRules: [ - { - name: "no-client-to-server", - from: "%/client/src/%", - to: "%/server/%" - } - ] - }, - deadCode: { - entrypoints: ["scripts/build-package.mjs"] - } - }, - rust: { - fileLength: { - maxFileLines: 500 - }, - functionMetrics: { - maxFunctionLines: 80, - maxComplexity: 10, - maxParams: 4 - }, - commandGates: [ - { - id: "rust-gate.test", - command: "cargo", - args: ["test"], - cwd: ".", - timeoutMs: 120000 - } - ] - }, - docs: { - enabled: { - existence: true, - freshness: true, - staleness: false, - length: true, - hubCoverage: true, - subtreeCoverage: true - }, - policy: { - filenames: ["CLAUDE.md", "AGENTS.md"], - requiredPaths: ["."], - requireRoot: true, - minimumContentLength: 1, - maxLines: 220, - maxSectionLines: 80 - }, - history: { - maxStaleDays: 90 - }, - hubCoverage: { - minFanIn: 5, - minFanOut: 5, - requireExplicitMention: true - }, - subtreeCoverage: { - minLoc: 20000 - } - }, - clone: { - windowSize: 16, - minLines: 16, - threshold: 5, - partitions: [["server", "shared"], ["client"], ["platform-cli"]], - exclude: ["docs/**"], - modes: ["staged", "changed", "files"] - } - } - } - }); + writeRepoConfigObject(temp, normalizedRepoConfigFixture()); const { readOpcoreRepoConfig } = await import("../packages/opcore/dist/repo-validation-config.js"); const config = readOpcoreRepoConfig(temp); @@ -434,7 +329,7 @@ describe("validation CLI", () => { assert.equal(config.validation.timeoutMs, 120000); assert.deepEqual(config.validation.pathPolicy, { include: ["packages/", "scripts/"], - exclude: ["dist/**", ".ace"] + exclude: ["dist/**", ".agents"] }); assert.deepEqual(config.validation.checks.packs, ["./checks/policy.cjs"]); assert.deepEqual(config.validation.checks.disabled, ["typescript.types"]); @@ -519,14 +414,14 @@ describe("validation CLI", () => { const { pathPolicyIncludes } = await import("../packages/opcore/dist/path-policy.js"); const policy = { include: ["packages/", "scripts/"], - exclude: ["dist/**", ".ace", ".agents", "packages/generated/**"] + exclude: ["dist/**", ".agents", ".codex", "packages/generated/**"] }; assert.equal(pathPolicyIncludes("packages/opcore/src/index.ts", policy), true); assert.equal(pathPolicyIncludes("scripts/build.mjs", policy), true); assert.equal(pathPolicyIncludes("docs/notes.ts", policy), false); assert.equal(pathPolicyIncludes("dist/index.js", policy), false); - assert.equal(pathPolicyIncludes(".ace/runtime/tool.json", policy), false); + assert.equal(pathPolicyIncludes(".codex/runtime/tool.json", policy), false); assert.equal(pathPolicyIncludes(".agents/skills/opcore/SKILL.md", policy), false); assert.equal(pathPolicyIncludes("packages/generated/output.ts", policy), false); assert.equal(pathPolicyIncludes("../outside.ts", policy), false); @@ -541,7 +436,7 @@ describe("validation CLI", () => { scopeFiles: ["packages/src/index.ts", "docs/notes.ts", "dist/index.js"], listVisibleFiles: async () => { listVisibleFileCalls += 1; - return ["packages/src/index.ts", "scripts/build.mjs", "docs/notes.ts", ".ace/runtime.json"]; + return ["packages/src/index.ts", "scripts/build.mjs", "docs/notes.ts", ".codex/runtime.json"]; }, overlays: [ { path: "packages/src/index.ts", action: "write", content: "export const value = 1;\n" }, @@ -558,7 +453,7 @@ describe("validation CLI", () => { const filtered = withFilteredFileView(context, { include: ["packages/", "scripts/"], - exclude: ["dist/**", ".ace", ".agents"] + exclude: ["dist/**", ".codex", ".agents"] }); assert.equal(listVisibleFileCalls, 0); @@ -1441,7 +1336,6 @@ describe("validation CLI", () => { rmSync(temp, { recursive: true, force: true }); } }); -}); function validRequest(repoRootPath) { return { @@ -1453,6 +1347,116 @@ function validRequest(repoRootPath) { }; } +function normalizedRepoConfigFixture() { + return { + schemaVersion: 1, + kind: "opcore_init_config", + onboarding: { scan: { totalFiles: 1 } }, + validation: { + adapters: ["typescript", "rust", "docs", "clone"], + timeoutMs: 120000, + pathPolicy: { + include: ["packages/", "scripts/"], + exclude: ["dist/**", ".agents"] + }, + checks: normalizedChecksFixture() + } + }; +} + +function normalizedChecksFixture() { + return { + packs: ["./checks/policy.cjs"], + disabled: ["typescript.types"], + defaults: ["docs.existence", "docs.freshness"], + typescript: normalizedTypeScriptChecksFixture(), + rust: normalizedRustChecksFixture(), + docs: normalizedDocsChecksFixture(), + clone: { + windowSize: 16, + minLines: 16, + threshold: 5, + partitions: [["server", "shared"], ["client"], ["platform-cli"]], + exclude: ["docs/**"], + modes: ["staged", "changed", "files"] + } + }; +} + +function normalizedTypeScriptChecksFixture() { + return { + fileLength: { maxFileLines: 600 }, + functionMetrics: { + maxFunctionLines: 120, + maxComplexity: 10, + maxParams: 4 + }, + lint: { + repoPlugin: "./eslint-local-rules/index.js", + cacheDependencyGlobs: ["CLAUDE.md", "**/CLAUDE.md"] + }, + importGraph: { + ignoreTypeOnlyImports: true, + layerRules: [ + { + name: "no-client-to-server", + from: "%/client/src/%", + to: "%/server/%" + } + ] + }, + deadCode: { entrypoints: ["scripts/build-package.mjs"] } + }; +} + +function normalizedRustChecksFixture() { + return { + fileLength: { maxFileLines: 500 }, + functionMetrics: { + maxFunctionLines: 80, + maxComplexity: 10, + maxParams: 4 + }, + commandGates: [ + { + id: "rust-gate.test", + command: "cargo", + args: ["test"], + cwd: ".", + timeoutMs: 120000 + } + ] + }; +} + +function normalizedDocsChecksFixture() { + return { + enabled: { + existence: true, + freshness: true, + staleness: false, + length: true, + hubCoverage: true, + subtreeCoverage: true + }, + policy: { + filenames: ["CLAUDE.md", "AGENTS.md"], + requiredPaths: ["."], + requireRoot: true, + minimumContentLength: 1, + maxLines: 220, + maxSectionLines: 80 + }, + history: { maxStaleDays: 90 }, + hubCoverage: { + minFanIn: 5, + minFanOut: 5, + requireExplicitMention: true + }, + subtreeCoverage: { minLoc: 20000 } + }; +} + function writeRepoConfig(repoRootPath, packs) { writeRepoConfigObject(repoRootPath, { schemaVersion: 1, diff --git a/tests/validation-docs.test.mjs b/tests/validation-docs.test.mjs index 361a0c4..0a38cd3 100644 --- a/tests/validation-docs.test.mjs +++ b/tests/validation-docs.test.mjs @@ -139,6 +139,22 @@ const expectedDocsCheckIds = [ assert.equal(result.diagnostics.every((diagnostic) => ["AGENTS.md", "CLAUDE.md", "docs/guide.md"].includes(diagnostic.path)), true); }); + it("accepts an explicit WHY rationale in a context-doc rule", async () => { + const result = await runner({ + files: { + "AGENTS.md": validGuidance("rule rationale") + } + }).runValidation( + request({ + checks: [DOCS_RULES_WHY_CHECK_ID], + scope: { kind: "files", files: ["AGENTS.md"] } + }) + ); + + assert.equal(result.status, "passed"); + assert.deepEqual(result.diagnostics, []); + }); + it("applies docs policy maximum line and section limits", async () => { const result = await runner({ files: { diff --git a/tests/validation-rust.test.mjs b/tests/validation-rust.test.mjs index da6c596..14ac086 100644 --- a/tests/validation-rust.test.mjs +++ b/tests/validation-rust.test.mjs @@ -4,7 +4,6 @@ import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, wr import { tmpdir } from "node:os"; import { join } from "node:path"; import { - calculateValidationFileChecksum, createNodeValidationWorkspace, createValidationCheckRegistry, createValidationRunner @@ -1567,10 +1566,10 @@ const rustCheckIds = [ const workspaceRoots = readFileSync(logPath, "utf8") .split(/\r?\n/) .map((line) => /\tCWD=([^\t]+)$/.exec(line)?.[1]) - .filter((path) => path?.includes("lattice-validation-rust-")); + .filter((path) => path?.includes("opcore-validation-rust-")); assert.equal(workspaceRoots.length > 5, true); assert.equal(new Set(workspaceRoots).size, 1, workspaceRoots.join("\n")); - assert.match(workspaceRoots[0], /lattice-validation-rust-[^/]+\/repo$/); + assert.match(workspaceRoots[0], /opcore-validation-rust-[^/]+\/repo$/); assert.equal(existsSync(workspaceRoots[0]), false); } finally { rmSync(temp, { recursive: true, force: true }); @@ -1760,6 +1759,33 @@ const rustCheckIds = [ } }); + it("runs cargo-udeps through the configured pinned nightly toolchain", async () => { + const temp = mkdtempSync(join(tmpdir(), "opcore-validation-rust-udeps-pinned-nightly-run-")); + try { + const logPath = join(temp, "cargo.log"); + const { env } = writeFakeRustToolchain(join(temp, "bin"), { + cargo: { + logPath + } + }); + const result = await runner({ + files: rustCrate(), + env: { + ...env, + OPCORE_RUST_NIGHTLY_TOOLCHAIN: "nightly-2026-07-27" + } + }).runValidation(request({ checks: [RUST_UNUSED_DEPS_CHECK_ID] })); + + assert.equal(result.status, "passed", JSON.stringify(result, null, 2)); + assert.match( + readFileSync(logPath, "utf8"), + /\+nightly-2026-07-27 udeps --workspace --all-targets --all-features/ + ); + } finally { + rmSync(temp, { recursive: true, force: true }); + } + }); + it("classifies cargo-udeps nightly toolchain failures as unsupported instead of unused dependencies", async () => { const temp = mkdtempSync(join(tmpdir(), "lattice-validation-rust-udeps-nightly-")); try { diff --git a/tests/validation-typescript.test.mjs b/tests/validation-typescript.test.mjs index 21ff5cc..cc30e0b 100644 --- a/tests/validation-typescript.test.mjs +++ b/tests/validation-typescript.test.mjs @@ -1212,6 +1212,64 @@ describe("validation-typescript adapter", () => { assert.deepEqual(result.diagnostics, []); }); + it("does not report a runtime cycle for pure type-only imports", async () => { + const result = await runner({ + files: { + "src/a.ts": "import type { B } from './b';\nexport interface A { child?: B }\n", + "src/b.ts": "import type { A } from './a';\nexport interface B { parent?: A }\n", + "src/index.ts": "import type { A } from './a';\nexport type Value = A;\n" + } + }).runValidation( + request({ + checks: [TYPE_SCRIPT_IMPORT_GRAPH_CHECK_ID] + }) + ); + + assert.equal(result.status, "passed", JSON.stringify(result.diagnostics, null, 2)); + assert.deepEqual(result.diagnostics, []); + }); + + it("reports runtime cycles when a declaration mixes type and value imports", async () => { + const result = await runner({ + files: { + "src/a.ts": "import { type B, b } from './b';\nexport type A = B;\nexport const a = b;\n", + "src/b.ts": "import { a } from './a';\nexport interface B { value: number }\nexport const b = a;\n", + "src/index.ts": "import { a } from './a';\nexport const value = a;\n" + } + }).runValidation( + request({ + checks: [TYPE_SCRIPT_IMPORT_GRAPH_CHECK_ID] + }) + ); + + assert.equal(result.status, "passed", JSON.stringify(result.diagnostics, null, 2)); + assert.deepEqual(result.diagnostics.map((diagnostic) => diagnostic.code), ["TS_IMPORT_GRAPH_CYCLE"]); + assert.equal(result.diagnostics[0].message, "TypeScript import cycle detected: src/a.ts -> src/b.ts -> src/a.ts"); + }); + + it("retains missing graph-edge diagnostics for type-only imports", async () => { + const result = await runner({ + files: { + "src/index.ts": "import type { Value } from './types';\nexport type Result = Value;\n", + "src/types.ts": "export interface Value { id: string }\n" + }, + graphProviderClient: graphClient({ + factQuery: (query) => availableFactResult(query, [], []) + }) + }).runValidation( + request({ + checks: [TYPE_SCRIPT_IMPORT_GRAPH_CHECK_ID] + }) + ); + + assert.equal(result.status, "passed"); + assert.deepEqual(result.diagnostics.map((diagnostic) => diagnostic.code), ["TS_IMPORT_GRAPH_MISSING_EDGE"]); + assert.equal( + result.diagnostics[0].message, + "Missing IMPORTS_FROM graph edge for src/index.ts -> src/types.ts" + ); + }); + it("reports configured TypeScript import layer rule violations", async () => { const result = await runner({ checks: createTypeScriptValidationChecks({ @@ -1870,105 +1928,46 @@ describe("validation-typescript adapter", () => { ); }); - it("reports unreferenced source files and unused exported types from graph facts", async () => { + it("does not report entrypoint-reachable exported types as unsupported", async () => { const nodes = [ + fileNode("src/index.ts"), + fileNode("src/types.ts"), { - id: "file:src/index.ts", - kind: "File", - path: "src/index.ts", - attributes: { - language: "typescript" - } - }, - { - id: "file:src/used.ts", - kind: "File", - path: "src/used.ts", - attributes: { - language: "typescript" - } - }, - { - id: "file:src/orphan.ts", - kind: "File", - path: "src/orphan.ts", - attributes: { - language: "typescript" - } - }, - { - id: "type:src/orphan.ts#ReferencedShape", - kind: "Type", - path: "src/orphan.ts", - name: "ReferencedShape", - attributes: { - exported: true, - exportKind: "named", - exportName: "ReferencedShape" - } - }, - { - id: "type:src/orphan.ts#LocalExtension", - kind: "Type", - path: "src/orphan.ts", - name: "LocalExtension", - attributes: { - exported: false - } - }, - { - id: "type:src/orphan.ts#UnusedShape", + id: "type:src/types.ts#Shape", kind: "Type", - path: "src/orphan.ts", - name: "UnusedShape", - attributes: { - exported: true, - exportKind: "named", - exportName: "UnusedShape" - } - }, - { - id: "variable:src/used.ts#used", - kind: "Variable", - path: "src/used.ts", - name: "used", - attributes: { - exported: false - } + path: "src/types.ts", + name: "Shape", + attributes: { exported: true, exportKind: "named", exportName: "Shape" } } ]; const edges = [ - { - kind: "IMPORTS_FROM", - from: "file:src/index.ts", - to: "file:src/used.ts" - }, - { - kind: "CONTAINS", - from: "file:src/used.ts", - to: "variable:src/used.ts#used" - }, - { - kind: "CONTAINS", - from: "file:src/orphan.ts", - to: "type:src/orphan.ts#ReferencedShape" - }, - { - kind: "CONTAINS", - from: "file:src/orphan.ts", - to: "type:src/orphan.ts#LocalExtension" - }, - { - kind: "CONTAINS", - from: "file:src/orphan.ts", - to: "type:src/orphan.ts#UnusedShape" - }, - { - kind: "INHERITS", - from: "type:src/orphan.ts#LocalExtension", - to: "type:src/orphan.ts#ReferencedShape" - } + importEdge("src/index.ts", "src/types.ts"), + containsEdge("src/types.ts", "type:src/types.ts#Shape") ]; + const result = await runner({ + files: { + "package.json": JSON.stringify({ main: "./src/index.ts" }), + "src/index.ts": "export type { Shape } from './types';\n", + "src/types.ts": "export interface Shape { width: number }\n" + }, + graphProviderClient: graphClient({ + status: (validationRequest) => ({ + ...availableStatus(validationRequest.graph.mode, validationRequest.repo), + handshake: graphHandshake() + }), + factQuery: (query) => availableFactResult(query, nodes, edges) + }) + }).runValidation(request({ + checks: [TYPE_SCRIPT_DEAD_CODE_CHECK_ID], + scope: { kind: "files", files: ["src/types.ts"] } + })); + + assert.equal(result.status, "passed"); + assert.deepEqual(result.diagnostics, []); + }); + + it("reports unreferenced source files and unused exported types from graph facts", async () => { + const { nodes, edges } = unusedTypeGraphFacts(); const result = await runner({ files: { @@ -2502,46 +2501,9 @@ describe("validation-typescript adapter", () => { it("does not report used direct re-exported callables as dead exports with the real graph provider", () => { const repo = mkdtempSync(join(tmpdir(), "lattice-dead-code-reexport-")); try { - mkdirSync(join(repo, "src"), { recursive: true }); - writeFileSync( - join(repo, "tsconfig.json"), - `${JSON.stringify( - { - compilerOptions: { - target: "ES2022", - module: "ESNext", - moduleResolution: "Bundler", - strict: true - }, - include: ["src/**/*.ts"] - }, - null, - 2 - )}\n` - ); - writeFileSync(join(repo, "src/source.ts"), "export function add() { return 1; }\n"); - writeFileSync(join(repo, "src/barrel.ts"), "export { add as addFromBarrel } from './source';\n"); - writeFileSync( - join(repo, "src/index.ts"), - "import { addFromBarrel } from './barrel';\nfunction run() { return addFromBarrel(); }\nrun();\n" - ); - - const graphBuild = spawnSync(process.execPath, ["packages/opcore/dist/advanced/index.js", "graph", "build", "--repo", repo, "--json"], { - cwd: process.cwd(), - encoding: "utf8" - }); - assert.equal(graphBuild.status, 0, graphBuild.stderr || graphBuild.stdout); - - const edgeQuery = spawnSync( - process.execPath, - ["packages/opcore/dist/advanced/index.js", "graph", "query", "--repo", repo, "--kind", "edges", "--json"], - { - cwd: process.cwd(), - encoding: "utf8" - } - ); - assert.equal(edgeQuery.status, 0, edgeQuery.stderr || edgeQuery.stdout); - const edges = JSON.parse(edgeQuery.stdout).graphQuery.edges; + writeDirectReexportFixture(repo); + runOpcoreJson(repo, ["graph", "build"]); + const edges = runOpcoreJson(repo, ["graph", "query", "--kind", "edges"]).graphQuery.edges; assert.equal( edges.some( (edge) => @@ -2550,16 +2512,8 @@ describe("validation-typescript adapter", () => { true ); - const nodeQuery = spawnSync( - process.execPath, - ["packages/opcore/dist/advanced/index.js", "graph", "query", "--repo", repo, "--kind", "nodes", "--json"], - { - cwd: process.cwd(), - encoding: "utf8" - } - ); - assert.equal(nodeQuery.status, 0, nodeQuery.stderr || nodeQuery.stdout); - const fileNode = JSON.parse(nodeQuery.stdout).graphQuery.nodes.find((node) => node.id === "file:src/barrel.ts"); + const nodes = runOpcoreJson(repo, ["graph", "query", "--kind", "nodes"]).graphQuery.nodes; + const fileNode = nodes.find((node) => node.id === "file:src/barrel.ts"); assert.deepEqual(fileNode?.attributes?.exports, [ { kind: "named", @@ -2571,30 +2525,17 @@ describe("validation-typescript adapter", () => { } ]); - const check = spawnSync( - process.execPath, - [ - "packages/opcore/dist/advanced/index.js", - "check", - "files", - "src/source.ts", - "src/barrel.ts", - "src/index.ts", - "--repo", - repo, - "--checks", - TYPE_SCRIPT_DEAD_CODE_CHECK_ID, - "--graph-mode", - "required", - "--json" - ], - { - cwd: process.cwd(), - encoding: "utf8" - } - ); - assert.equal(check.status, 0, check.stderr || check.stdout); - const payload = JSON.parse(check.stdout); + const payload = runOpcoreJson(repo, [ + "check", + "files", + "src/source.ts", + "src/barrel.ts", + "src/index.ts", + "--checks", + TYPE_SCRIPT_DEAD_CODE_CHECK_ID, + "--graph-mode", + "required" + ]); assert.deepEqual( payload.validationResult.diagnostics.map((diagnostic) => diagnostic.code), ["TS_DEAD_CODE_UNUSED_FILE"] @@ -2608,7 +2549,7 @@ describe("validation-typescript adapter", () => { } }); - it("finds relevant tests from symbol TESTED_BY endpoints", async () => { + it("finds direct relevant tests from symbol TESTED_BY endpoints", async () => { const result = await runner({ files: { "src/index.ts": "export const value = 1;" @@ -2636,7 +2577,131 @@ describe("validation-typescript adapter", () => { ); assert.equal(result.status, "passed"); - assert.deepEqual(result.diagnostics.map((diagnostic) => diagnostic.code), ["TS_RELEVANT_TESTS_FOUND"]); + assert.deepEqual(result.diagnostics, []); + }); + + it("inherits relevant-test evidence through directed reverse importers", async () => { + const edges = [ + importEdge("src/index.ts", "src/implementation.ts"), + { + kind: "TESTED_BY", + from: "function:src/index.ts#value", + to: "test:src/index.test.ts#covers value" + } + ]; + const result = await runner({ + files: { + "src/implementation.ts": "export const value = 1;", + "src/index.ts": "export { value } from './implementation';" + }, + graphProviderClient: graphClient({ + factQuery: (query) => availableFactResult(query, [], query.selector.kind === "edges" ? edges : []) + }) + }).runValidation( + request({ + checks: [TYPE_SCRIPT_RELEVANT_TESTS_CHECK_ID], + scope: { kind: "files", files: ["src/implementation.ts"] } + }) + ); + + assert.equal(result.status, "passed"); + assert.deepEqual(result.diagnostics, []); + }); + + it("does not inherit relevant-test evidence from unrelated tested files", async () => { + const edges = [ + importEdge("src/index.ts", "src/other.ts"), + { + kind: "TESTED_BY", + from: "file:src/index.ts", + to: "test:src/index.test.ts#covers index" + } + ]; + const result = await runner({ + files: { + "src/implementation.ts": "export const value = 1;", + "src/index.ts": "export { other } from './other';", + "src/other.ts": "export const other = 2;" + }, + graphProviderClient: graphClient({ + factQuery: (query) => availableFactResult(query, [], query.selector.kind === "edges" ? edges : []) + }) + }).runValidation( + request({ + checks: [TYPE_SCRIPT_RELEVANT_TESTS_CHECK_ID], + scope: { kind: "files", files: ["src/implementation.ts"] } + }) + ); + + assert.equal(result.status, "passed"); + assert.deepEqual(result.diagnostics.map((diagnostic) => diagnostic.code), ["TS_RELEVANT_TESTS_ABSENT"]); + }); + + it("traverses cyclic reverse importers once while finding relevant tests", async () => { + const edges = [ + importEdge("src/index.ts", "src/implementation.ts"), + importEdge("src/helper.ts", "src/index.ts"), + importEdge("src/index.ts", "src/helper.ts"), + { + kind: "TESTED_BY", + from: "function:src/helper.ts#exercise", + to: "test:src/helper.test.ts#covers exercise" + } + ]; + const result = await runner({ + files: { + "src/implementation.ts": "export const value = 1;", + "src/index.ts": "export { value } from './implementation';\nexport { exercise } from './helper';", + "src/helper.ts": "export { value } from './index';\nexport const exercise = true;" + }, + graphProviderClient: graphClient({ + factQuery: (query) => availableFactResult(query, [], query.selector.kind === "edges" ? edges : []) + }) + }).runValidation( + request({ + checks: [TYPE_SCRIPT_RELEVANT_TESTS_CHECK_ID], + scope: { kind: "files", files: ["src/implementation.ts"] } + }) + ); + + assert.equal(result.status, "passed"); + assert.deepEqual(result.diagnostics, []); + }); + + it("fails closed when required relevant-test graph queries fail", async () => { + const result = await runner({ + graphProviderClient: graphClient({ + status: (validationRequest) => availableStatus(validationRequest.graph.mode, validationRequest.repo), + factQuery: () => ({ + status: graphFailure("error", "query_failed", "required") + }) + }) + }).runValidation( + request({ + checks: [TYPE_SCRIPT_RELEVANT_TESTS_CHECK_ID], + graph: { + mode: "required", + provider: "opcore-graph" + } + }) + ); + + assert.equal(result.status, "provider_failure"); + assert.equal(result.failure.category, "provider_failure"); + }); + + it("does not require relevant-test evidence for test files", async () => { + const result = await runner({ + files: { + "src/index.test.ts": "test('works', () => {});\n" + } + }).runValidation(request({ + checks: [TYPE_SCRIPT_RELEVANT_TESTS_CHECK_ID], + scope: { kind: "files", files: ["src/index.test.ts"] } + })); + + assert.equal(result.status, "skipped"); + assert.deepEqual(result.diagnostics, []); }); it("maps graph query failures to runner provider_failure", async () => { @@ -2725,6 +2790,90 @@ describe("validation-typescript adapter", () => { }); }); +function unusedTypeGraphFacts() { + const nodes = [ + fileNode("src/index.ts"), + fileNode("src/used.ts"), + fileNode("src/orphan.ts"), + { + id: "type:src/orphan.ts#ReferencedShape", + kind: "Type", + path: "src/orphan.ts", + name: "ReferencedShape", + attributes: { exported: true, exportKind: "named", exportName: "ReferencedShape" } + }, + { + id: "type:src/orphan.ts#LocalExtension", + kind: "Type", + path: "src/orphan.ts", + name: "LocalExtension", + attributes: { exported: false } + }, + { + id: "type:src/orphan.ts#UnusedShape", + kind: "Type", + path: "src/orphan.ts", + name: "UnusedShape", + attributes: { exported: true, exportKind: "named", exportName: "UnusedShape" } + }, + { + id: "variable:src/used.ts#used", + kind: "Variable", + path: "src/used.ts", + name: "used", + attributes: { exported: false } + } + ]; + const edges = [ + importEdge("src/index.ts", "src/used.ts"), + containsEdge("src/used.ts", "variable:src/used.ts#used"), + containsEdge("src/orphan.ts", "type:src/orphan.ts#ReferencedShape"), + containsEdge("src/orphan.ts", "type:src/orphan.ts#LocalExtension"), + containsEdge("src/orphan.ts", "type:src/orphan.ts#UnusedShape"), + { + kind: "INHERITS", + from: "type:src/orphan.ts#LocalExtension", + to: "type:src/orphan.ts#ReferencedShape" + } + ]; + return { nodes, edges }; +} + +function writeDirectReexportFixture(repo) { + mkdirSync(join(repo, "src"), { recursive: true }); + writeFileSync( + join(repo, "tsconfig.json"), + `${JSON.stringify({ + compilerOptions: { + target: "ES2022", + module: "ESNext", + moduleResolution: "Bundler", + strict: true + }, + include: ["src/**/*.ts"] + })}\n` + ); + writeFileSync(join(repo, "src/source.ts"), "export function add() { return 1; }\n"); + writeFileSync(join(repo, "src/barrel.ts"), "export { add as addFromBarrel } from './source';\n"); + writeFileSync( + join(repo, "src/index.ts"), + "import { addFromBarrel } from './barrel';\nfunction run() { return addFromBarrel(); }\nrun();\n" + ); +} + +function runOpcoreJson(repo, args) { + const result = spawnSync( + process.execPath, + ["packages/opcore/dist/advanced/index.js", ...args, "--repo", repo, "--json"], + { + cwd: process.cwd(), + encoding: "utf8" + } + ); + assert.equal(result.status, 0, result.stderr || result.stdout); + return JSON.parse(result.stdout); +} + function runner(options = {}) { return createValidationRunner({ workspace: workspace(options), diff --git a/tsconfig.json b/tsconfig.json index 8d6139d..94d7fac 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,4 +1,28 @@ { + "extends": "./tsconfig.base.json", + "compilerOptions": { + "baseUrl": ".", + "paths": { + "@the-open-engine/opcore-contracts": [ + "packages/contracts/src/index.ts" + ], + "@the-open-engine/opcore-edit": [ + "packages/edit/src/index.ts" + ], + "@the-open-engine/opcore-graph": [ + "packages/graph/src/index.ts" + ], + "@the-open-engine/opcore-validation-docs": [ + "packages/validation-docs/src/index.ts" + ], + "@the-open-engine/opcore-validation-typescript": [ + "packages/validation-typescript/src/index.ts" + ], + "opcore": [ + "packages/opcore/src/index.ts" + ] + } + }, "files": [], "references": [ {