diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 52b5ebe..2a6a55b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -54,6 +54,11 @@ jobs: node-version: 22 cache: npm + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + - name: Setup Rust uses: dtolnay/rust-toolchain@stable with: @@ -65,9 +70,25 @@ jobs: - name: Install run: npm ci + - name: Install authoritative mypy + run: python -m pip install --disable-pip-version-check mypy==2.3.0 + + - name: Install authoritative Pyright + run: npm install --prefix "${RUNNER_TEMP}/opcore-pyright" --no-package-lock --ignore-scripts pyright@1.1.411 + - name: Check run: npm run ci + - name: Prove authoritative mypy after-state execution + run: npm run python:mypy-authority-proof + env: + OPCORE_REAL_MYPY: ${{ env.pythonLocation }}/bin/mypy + + - name: Prove authoritative Pyright after-state execution + run: npm run python:pyright-authority-proof + env: + OPCORE_REAL_PYRIGHT: ${{ runner.temp }}/opcore-pyright/node_modules/.bin/pyright + native-artifact: name: native-artifact (${{ matrix.target }}) needs: check diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b80f9f8..ec5a34a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -26,7 +26,7 @@ jobs: (github.event_name == 'workflow_run' && github.event.workflow_run.conclusion == 'success') runs-on: ubuntu-latest env: - RELEASE_VERSION: "0.2.0" + RELEASE_VERSION: "0.2.1" NPM_TAG: latest steps: - name: Checkout @@ -97,7 +97,7 @@ jobs: if: github.event_name == 'workflow_run' run: npm run release:publish -- --version "$RELEASE_VERSION" --tag "$NPM_TAG" env: - OPCORE_CONFIRM_PUBLISH: "0.2.0" + OPCORE_CONFIRM_PUBLISH: "0.2.1" OPCORE_REQUIRE_ALL_NATIVE_PACKAGES: "1" NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/AGENTS.md b/AGENTS.md index 5a317ac..0e75742 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -80,15 +80,19 @@ Opcore is the code-intelligence and robustness monorepo for graph context, edit - `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. - 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, then runs `tests/native-packaging-policy.test.mjs` separately with receipt gates skipped - WHY: the native packaging policy test intentionally mutates native package artifacts while exercising aggregate dry-run failures, so folding it back into parallel `node --test tests/*.test.mjs` reintroduces a race. +- `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. - 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. -- 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.0` 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. +- 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. - #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. +- #244 `python.syntax` resolves one concrete interpreter through the Python project resolver, executes that exact interpreter with an isolated versioned JSON compile protocol over sorted `.py`/`.pyi` after-state content, and reports ranged/provenanced compiler diagnostics plus fine-grained outcomes. Parser-success overrides and hand-written grammar heuristics are forbidden; malformed output, nonzero exits, signals, and timeouts fail closed - WHY: syntax truth, target-version truth, and tool provenance must come from the same compiler invocation without mutating the source repo. +- #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. +- #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. - #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. @@ -123,6 +127,9 @@ Opcore is the code-intelligence and robustness monorepo for graph context, edit - ALWAYS emit boolean `attributes.exported` on supported TS/JS graph symbol nodes and file-node `attributes.exports[]` for default/re-export/barrel forms - WHY: dead-export metrics must distinguish unsupported export coverage from zero dead exports. - ALWAYS make validation checks read file content through `ValidationCheckContext.fileView` - WHY: validation overlays are hypothetical and checks must see the same before/after file state without mutating disk. - ALWAYS make validation checks consume GraphProvider facts through `ValidationCheckContext.graph` and injected `ValidationGraphProviderClient` sessions - WHY: validation must depend only on public contracts, not graph package internals, CLI execution, or store layouts. +- ALWAYS bind every hypothetical graph-backed check to the disposable exact-state graph session owned by its `ValidationFileView`, and share one session per state across all selected checks - WHY: request-scoped or persistent sessions make graph facts disagree with the contents read by the check; optional graph policy must not hide exact-state failures. +- ALWAYS make Python import-dependent validation consume an injected `PythonImportAnalyzer` backed by graph-core and derive closure from its directed after-state edges - WHY: validation-owned parsing or module candidates create a second semantic owner and cannot represent overlays consistently. +- ALWAYS preserve validation diagnostic source ranges, tool provenance, and fine-grained check outcomes across contracts, manifests, sorting/fingerprints, reporting, and ASP mapping when an adapter supplies them - WHY: transport layers must not collapse actionable compiler evidence or turn tool failures into clean passes. - Native repo validation policy lives under `.opcore/config` `validation`; active checks, thresholds, path policy, docs policy, clone policy, TypeScript policy, Rust command gates, and check packs must be parsed and composed through `packages/validation-policy`. - 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. @@ -173,6 +180,7 @@ Opcore is the code-intelligence and robustness monorepo for graph context, edit - #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. +- 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`. diff --git a/CLAUDE.md b/CLAUDE.md index 5a317ac..0e75742 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -80,15 +80,19 @@ Opcore is the code-intelligence and robustness monorepo for graph context, edit - `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. - 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, then runs `tests/native-packaging-policy.test.mjs` separately with receipt gates skipped - WHY: the native packaging policy test intentionally mutates native package artifacts while exercising aggregate dry-run failures, so folding it back into parallel `node --test tests/*.test.mjs` reintroduces a race. +- `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. - 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. -- 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.0` 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. +- 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. - #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. +- #244 `python.syntax` resolves one concrete interpreter through the Python project resolver, executes that exact interpreter with an isolated versioned JSON compile protocol over sorted `.py`/`.pyi` after-state content, and reports ranged/provenanced compiler diagnostics plus fine-grained outcomes. Parser-success overrides and hand-written grammar heuristics are forbidden; malformed output, nonzero exits, signals, and timeouts fail closed - WHY: syntax truth, target-version truth, and tool provenance must come from the same compiler invocation without mutating the source repo. +- #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. +- #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. - #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. @@ -123,6 +127,9 @@ Opcore is the code-intelligence and robustness monorepo for graph context, edit - ALWAYS emit boolean `attributes.exported` on supported TS/JS graph symbol nodes and file-node `attributes.exports[]` for default/re-export/barrel forms - WHY: dead-export metrics must distinguish unsupported export coverage from zero dead exports. - ALWAYS make validation checks read file content through `ValidationCheckContext.fileView` - WHY: validation overlays are hypothetical and checks must see the same before/after file state without mutating disk. - ALWAYS make validation checks consume GraphProvider facts through `ValidationCheckContext.graph` and injected `ValidationGraphProviderClient` sessions - WHY: validation must depend only on public contracts, not graph package internals, CLI execution, or store layouts. +- ALWAYS bind every hypothetical graph-backed check to the disposable exact-state graph session owned by its `ValidationFileView`, and share one session per state across all selected checks - WHY: request-scoped or persistent sessions make graph facts disagree with the contents read by the check; optional graph policy must not hide exact-state failures. +- ALWAYS make Python import-dependent validation consume an injected `PythonImportAnalyzer` backed by graph-core and derive closure from its directed after-state edges - WHY: validation-owned parsing or module candidates create a second semantic owner and cannot represent overlays consistently. +- ALWAYS preserve validation diagnostic source ranges, tool provenance, and fine-grained check outcomes across contracts, manifests, sorting/fingerprints, reporting, and ASP mapping when an adapter supplies them - WHY: transport layers must not collapse actionable compiler evidence or turn tool failures into clean passes. - Native repo validation policy lives under `.opcore/config` `validation`; active checks, thresholds, path policy, docs policy, clone policy, TypeScript policy, Rust command gates, and check packs must be parsed and composed through `packages/validation-policy`. - 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. @@ -173,6 +180,7 @@ Opcore is the code-intelligence and robustness monorepo for graph context, edit - #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. +- 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`. diff --git a/Cargo.lock b/Cargo.lock index d74fdcd..16a1e2b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -472,7 +472,7 @@ checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" [[package]] name = "opcore-graph-core" -version = "0.2.0" +version = "0.2.1" dependencies = [ "ctrlc", "fs2", diff --git a/Cargo.toml b/Cargo.toml index a2c9a46..e6258af 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ members = ["crates/graph-core"] resolver = "2" [workspace.package] -version = "0.2.0" +version = "0.2.1" edition = "2021" license = "MIT" repository = "https://github.com/the-open-engine/opcore" diff --git a/docs/architecture/runtime-cli-ard.md b/docs/architecture/runtime-cli-ard.md index 9fc27f2..0c534c3 100644 --- a/docs/architecture/runtime-cli-ard.md +++ b/docs/architecture/runtime-cli-ard.md @@ -58,6 +58,8 @@ Canonical `opcore graph`, `opcore edit`, `opcore check`, and `opcore validate` c #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. + #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. @@ -72,9 +74,9 @@ 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, and graph JSON output. It must not duplicate graph-core internals or depend on the aggregate CLI. | +| `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/validation` | Mechanical checks, public check/validate command adapters, validation manifests, failure policy, hypothetical validation, graph-aware rule orchestration, and `check`/`validate` reporting. It consumes contracts and adapters, not edit 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. | @@ -118,10 +120,16 @@ Before replacing current external tool behavior, add golden/reference fixtures a #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. -Rust checks read candidate files through `ValidationCheckContext.fileView`. Checks that need Cargo or native tools materialize a temporary workspace from after-state content, apply write/delete overlays there, and clean up without mutating the real worktree. `.rs`, `.inc`, and `Cargo.toml` are adapter-owned inputs. Cargo.lock-only changes remain retained compatibility until a later decision expands Rust adapter ownership. +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. `opcore status --json` and `opcore doctor --json` expose Rust adapter status, toolchain availability, degraded checks, retained compatibility notes, `currentUsage` metadata for Opcore/Orchestra/CoVibes/gateway consumers, and temporary workspace requirements so #21 can decide daemon/cache/check UX without rediscovering Rust coverage. Missing `cargo`, `rustfmt`, or `clippy` makes the Rust foundation unavailable; missing `rustdoc` is reported as degraded retained compatibility instead of disabling the core `rust.fmt`, `rust.cargo-check`, and `rust.clippy` foundation. +## Python Syntax Validation + +#244 makes `python.syntax` compiler-truthful. The adapter resolves one concrete project interpreter once, retains its executable, working directory, version, resolution source, and declared-target compatibility, then invokes that exact executable with isolated no-bytecode flags. A versioned JSON stdin/stdout protocol compiles sorted `.py` and `.pyi` after-state content with `compile(..., "exec", dont_inherit=True)` without importing, executing, or materializing source files. + +Python syntax diagnostics carry normalized workspace-relative paths, stable compiler-error codes, 1-based ranges when supplied by the interpreter, and interpreter provenance. Validation run summaries preserve fine-grained `passed`, `findings`, `tool_unavailable`, `invalid_config`, `timeout`, `unsupported_target`, and `tool_failure` outcomes while retaining aggregate validation statuses. Protocol/schema mismatch, unknown nonzero exit, timeout, signal, spawn failure, and mismatched file/provenance output fail closed. Hand-written delimiter, quote, comment, or compound-header grammar checks must not override compiler success; any future supplemental Python policy requires a separate check ID and category. + ## Clone Validation Adapter #151 adds `@the-open-engine/opcore-validation-clone` as the duplicate-code validation adapter package. The stable check id is `clone.duplication`; it requiresGraph:false, reads candidate after-state through `ValidationCheckContext.fileView.readAfter`, and emits line-free duplicate-code diagnostics with stable clone-class identity. The adapter receives its native invocation function from Opcore validation composition, so it does not import graph-core loaders, raw SQLite, graph internals, or the aggregate CLI. diff --git a/docs/concepts.md b/docs/concepts.md index 7c563b3..b602ae6 100644 --- a/docs/concepts.md +++ b/docs/concepts.md @@ -4,7 +4,7 @@ Opcore starts by counting the repository. TypeScript, JavaScript, Rust sources, `Cargo.toml`, retained Rust metadata, and Python `.py`/`.pyi` files are recognized for alpha checks. Unsupported extensions are still counted with examples so reports do not imply broader coverage than exists. -Python coverage is experimental and degraded-honest. Graph-backed structure, untested modules, dead exports, syntax, source-hygiene, import graph, and relevant-test signals may be reported for `.py`/`.pyi` files. `python.types` depends on mypy or pyright; missing tools produce degraded optional-tool evidence instead of fake pass/fail findings. +Python coverage is experimental and degraded-honest. Graph-backed structure, untested modules, dead exports, syntax, source-hygiene, import graph, and relevant-test signals may be reported for `.py`/`.pyi` files. `python.types` executes exactly one configured mypy or Pyright authority per project over the same hypothetical after-state. Missing, conflicting, or unavailable authority evidence is degraded instead of becoming a fake pass/fail finding. ## Findings diff --git a/docs/examples.md b/docs/examples.md index 9ebddf2..9ff1ecc 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -92,7 +92,7 @@ For Python-only validation in the same scan, check, measure loop: opcore check --changed --checks python.syntax,python.source-hygiene --json ``` -`python.types` is optional-tool evidence; missing mypy or pyright is reported as degraded instead of inventing a finding. +`python.types` is configured-authority evidence. Exactly one configured mypy or Pyright runs per project; absent, conflicting, or unavailable authority is degraded instead of inventing a finding. ## Read Deltas @@ -108,5 +108,5 @@ Coverage: Signals: typescript.type_errors: 2 baseline=-1 previous=+0 python-measure-delta: python.dead-code previous=-1 baseline=+0 - python.types: degraded requiredTool=mypy|pyright + python.types: degraded requiredTool=configured-authority ``` diff --git a/docs/planning/opcore-alpha-roadmap.md b/docs/planning/opcore-alpha-roadmap.md index c800510..8f17f8e 100644 --- a/docs/planning/opcore-alpha-roadmap.md +++ b/docs/planning/opcore-alpha-roadmap.md @@ -68,7 +68,7 @@ Ship only signals the engine can defend: - Rust oversized files. - Rust module cycles, orphans, and unresolved module evidence. - Rust cargo, fmt, clippy, rustdoc, and optional-tool evidence when available, with honest degraded status when tools are missing. -- Python `.py`/`.pyi` graph-backed structure, untested modules, dead exports, syntax, source-hygiene, import graph, relevant-test signals, and optional `python.types` via mypy or pyright with degraded status when tools are missing. +- 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. 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. diff --git a/docs/quickstart.md b/docs/quickstart.md index 69c20f3..513f81e 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -79,7 +79,7 @@ Approved repo install writes additive `.opcore/config`, one delimited guidance b ## Coverage -Opcore is deep for TypeScript/JavaScript graph-backed signals and useful for Rust validation signals. Python is experimental and degraded-honest: `.py`/`.pyi` graph-backed structure, untested modules, dead exports, syntax, and source-hygiene are reported when available; `python.types` depends on mypy or pyright and reports missing tools as degraded. Other non-TS/JS/Rust/Python languages are counted and reported as unsupported; they do not get fake findings or fake ratings. +Opcore is deep for TypeScript/JavaScript graph-backed signals and useful for Rust validation signals. Python is experimental and degraded-honest: `.py`/`.pyi` graph-backed structure, untested modules, dead exports, syntax, and source-hygiene are reported when available; `python.types` runs exactly one configured mypy or Pyright authority per project and degrades when authority is absent, conflicting, or unavailable. Other non-TS/JS/Rust/Python languages are counted and reported as unsupported; they do not get fake findings or fake ratings. ## Demo Loop diff --git a/docs/release/artifact-attestation.md b/docs/release/artifact-attestation.md index 6c74fda..54b479d 100644 --- a/docs/release/artifact-attestation.md +++ b/docs/release/artifact-attestation.md @@ -12,16 +12,16 @@ Graph input evidence: docs/release/graph-release-receipt.json | Platform | Binary | Binary SHA-256 | Checksum File | |----------|--------|----------------|---------------| -| darwin-arm64 | node_modules/@the-open-engine/opcore-graph-core-darwin-arm64/opcore-graph-core | c222610c492e628fa48ff50be22310e5bf8ebcd62bffbde1903d1fa91c45f6cb | node_modules/@the-open-engine/opcore-graph-core-darwin-arm64/opcore-graph-core.sha256 | +| darwin-arm64 | node_modules/@the-open-engine/opcore-graph-core-darwin-arm64/opcore-graph-core | 72acf10de0ece619c3896943addfb0cc1b09dab22000b48a653b033224217bc8 | node_modules/@the-open-engine/opcore-graph-core-darwin-arm64/opcore-graph-core.sha256 | | darwin-x64 | node_modules/@the-open-engine/opcore-graph-core-darwin-x64/opcore-graph-core | 1b72afa74d48b087c967cb01b39630ed3fa58b5a2895037a778eb5612ef73059 | node_modules/@the-open-engine/opcore-graph-core-darwin-x64/opcore-graph-core.sha256 | -| linux-x64 | node_modules/@the-open-engine/opcore-graph-core-linux-x64/opcore-graph-core | 70cb6d213d74594f337a67522c879b77767872dc4601063686f1abe70886127b | node_modules/@the-open-engine/opcore-graph-core-linux-x64/opcore-graph-core.sha256 | +| linux-x64 | node_modules/@the-open-engine/opcore-graph-core-linux-x64/opcore-graph-core | c3a771435a7a8172a9e5f0bb20b0d111453efc70901e95308094664f77e7bdf1 | node_modules/@the-open-engine/opcore-graph-core-linux-x64/opcore-graph-core.sha256 | No package publishing happens in this gate. ## Cutover Gate Issue #30 receipt: docs/release/cutover-receipt.json -Cutover receipt SHA-256: c8d33f671a1f6801a6b46362c4f2bd1f152dfbd13e161ef83c007b194723adf8 +Cutover receipt SHA-256: c72be0d79199505770c631b08201be09f8602e239e80c1ffa974e7f24d1cf32b Installed command receipts: 28 Rust command receipts: 7 Python command receipts: 8 diff --git a/docs/release/cutover-receipt.json b/docs/release/cutover-receipt.json index d48ab3f..06eea20 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-09T19:56:41.104Z", - "commitSha": "1b782d7c86dae055a0225e0905101beafaaa2fce", + "generatedAt": "2026-07-18T06:36:28.550Z", + "commitSha": "2d4e920e0b08a989953486b261327ed7496c328b", "privateRepo": true, "packageNames": [ "opcore" @@ -11,14 +11,14 @@ "installedPackages": [ { "packageName": "opcore", - "version": "0.2.0", + "version": "0.2.1", "tarball": { - "filename": "opcore-0.2.0.tgz", - "sha256": "a5bc2d71695c2b3e8320f9dce88c7907d34e40af32eaa00793ae185386abd753" + "filename": "opcore-0.2.1.tgz", + "sha256": "f3c4478eca2a9a1118b10bdabdfa009dfed0bdd8903312f06c42a8b60515b128" }, "installedManifest": { "path": "node_modules/opcore/package.json", - "sha256": "5fab044eaea9e581534d0c30d86882872c24f6efcee65ea20e2654e61a78e052", + "sha256": "edb01642dcba2b610b0a478969c6270cb33c0a80ea7f2f3d3f9d1a0e6f22ba69", "bins": { "opcore": "dist/index.js", "opcore-asp-provider": "dist/asp-provider-bin.js" @@ -103,11 +103,11 @@ }, { "path": "node_modules/opcore/dist/advanced/descriptor.d.ts.map", - "sha256": "2c3dd8668eaaeeff97c01b82bd108592c251fb06c9c8b22e76c60501ddddf47f" + "sha256": "f6f8bde44c7749a1804a7e120bcbaea063207816547358be1a54c1a2638e5140" }, { "path": "node_modules/opcore/dist/advanced/descriptor.js", - "sha256": "003d89c29ee2acfb11b39174243e691ab17f3bb7b917538d40610583f39e858f" + "sha256": "480f90a655a812ec4df9e923ccfc94f90c53ef6acd38df0ff2b4560f4e873937" }, { "path": "node_modules/opcore/dist/advanced/edit-composition.d.ts", @@ -191,19 +191,19 @@ }, { "path": "node_modules/opcore/dist/advanced/router.js", - "sha256": "93b002be1d7b4c518048e340142f299fa2c298eba0fd1ca1bd6fc6d94020455f" + "sha256": "a9bcfb28395c82204fa94728e0f390e6fc9bcfe80d0b90d08b581ff7eede7296" }, { "path": "node_modules/opcore/dist/advanced/validation-composition.d.ts", - "sha256": "6bbc30379a53b3f430c2f093ebf43656675b0056d915474084473696abcfa55d" + "sha256": "b7c28a6e21cbd3be1460bfed8f7df125eca47bbc4c1b4ddbefc311ab108ac370" }, { "path": "node_modules/opcore/dist/advanced/validation-composition.d.ts.map", - "sha256": "704d4f05c65dbfe5e5cac484eec22168461d3765bbb2eb14de1ca1f7383f3647" + "sha256": "ad06345d267c837f10334addb62d30389670129b49c9dcfbdd07b1ecb6398f87" }, { "path": "node_modules/opcore/dist/advanced/validation-composition.js", - "sha256": "c32d9335a79d67102f1b4927a2bc9782f110f356bb56c64eb382814296d97909" + "sha256": "c054f94d89a88cb0bb4ae8ef0440f766cb1f17c81a72d03ae156e4a7c907696b" }, { "path": "node_modules/opcore/dist/agent-gate.d.ts", @@ -215,7 +215,7 @@ }, { "path": "node_modules/opcore/dist/agent-gate.js", - "sha256": "9680e602fbd53a2f3559efb999ac90331fae58740c11a615f4bdac71364bb5cd" + "sha256": "d1d75fed60cf2322aa98b03330676efd91e42b51fe3c3f85fc6e965d570a7faf" }, { "path": "node_modules/opcore/dist/asp-provider-bin.d.ts", @@ -255,19 +255,19 @@ }, { "path": "node_modules/opcore/dist/descriptors/opcore.managed-tool.json", - "sha256": "282c6a8f4f4adf097eacc40d818f32340acc0b6fb4b0e14845e3327525806d12" + "sha256": "8ae0a226d8f320ec3e785d0bf0ec892ed4f51b7ad7161d6cdd9fc0018eb73dd1" }, { "path": "node_modules/opcore/dist/doctor.d.ts", - "sha256": "3682d90196966687aebb2911228cbd74961339f63fb55fc446446683a5423bb1" + "sha256": "e0e695b52c3f6f214073b5f5b7ff4b50eb1451b0a3600f751eccf095354f8b4a" }, { "path": "node_modules/opcore/dist/doctor.d.ts.map", - "sha256": "371928698ae373c30adbe5e0ff280e80554203a028cb432429e6289be87f0b28" + "sha256": "110d344818ad031b1de8ccd69380671881cf80ba310df8b9f2ae85110eabaf8c" }, { "path": "node_modules/opcore/dist/doctor.js", - "sha256": "c6471b0399c50f9359441a5d6e188e89adb90b413c63e7426ec3d1de5083a09f" + "sha256": "a5f8c2f24d4ac799a96b73bb0c43d52d1f287d969ee0081f25682b7b5a23c825" }, { "path": "node_modules/opcore/dist/graph-provider-client.d.ts", @@ -299,11 +299,11 @@ }, { "path": "node_modules/opcore/dist/init.d.ts.map", - "sha256": "53ea761059c9c5229c93e436c5c7a3a09adcee5b8b67dc97e98fa7b1030c872d" + "sha256": "3c09fd18f4ceef3e826270f94e7ecbf19147a27b985edfc33ce16d8630da33b7" }, { "path": "node_modules/opcore/dist/init.js", - "sha256": "0a6c42fdc8626a3437f70df2c2a0438496e0416fb618be7ec6df384aa123174e" + "sha256": "b0d1a394fd7f0cfd142a9cf5f0245becc6fb3baa687e6dbacc83046ff05dc7e7" }, { "path": "node_modules/opcore/dist/install-wizard-screen.d.ts", @@ -335,11 +335,11 @@ }, { "path": "node_modules/opcore/dist/json-output.d.ts.map", - "sha256": "b9e2ad348b1f51119646a0263e5fe6a977387760c4ca15075961e23523b4655b" + "sha256": "eb4235489b2f7e6a7e8759f3b4cf37f95e9becd4c3c5eac55908be35f71e7eb0" }, { "path": "node_modules/opcore/dist/json-output.js", - "sha256": "b9142f8dcc7934d5d0cd45254e8bbb1444e686cc325ed11274fadcbe2107753a" + "sha256": "87f9c35eaafe9c3f917f18c3fb083acf5ac3a7a2cef42b78e8904bd4cfef9852" }, { "path": "node_modules/opcore/dist/path-policy.d.ts", @@ -359,11 +359,11 @@ }, { "path": "node_modules/opcore/dist/plate.d.ts.map", - "sha256": "30c67ebc6ad37c6f894fba08284b64701a9bcad11f28f6226f3bff79714c7d0e" + "sha256": "7ede2eb7d2f7d0915e59552b7564308aa51668ffeb777fba351d47e4a6c93ab8" }, { "path": "node_modules/opcore/dist/plate.js", - "sha256": "171709c203fd8098e75489b89efc06cabcf5c1d9bce019327428b80b51c182cb" + "sha256": "4f4e9c880d2f790b95e011fbf5e0d174897f7333f2d3744b0f4153acdfed59ec" }, { "path": "node_modules/opcore/dist/postinstall.d.ts", @@ -407,11 +407,11 @@ }, { "path": "node_modules/opcore/dist/repo-validation-policy.d.ts.map", - "sha256": "5e58f52cca307534138c84375ee0a076de4489a6dbf5c01641746d9d66e6ea21" + "sha256": "c9368195820c37f409e97d82c82c2d72c0d344209edd7d7149054fd48043d36e" }, { "path": "node_modules/opcore/dist/repo-validation-policy.js", - "sha256": "77e8e92458e7a034a83294f0547ea703c2791f09ed8f47ebe1f80350ada40c48" + "sha256": "433cc1c929e6ed9485581f5227bf082643b69df6726c927a66837859175916b4" }, { "path": "node_modules/opcore/dist/reporting.d.ts", @@ -419,11 +419,11 @@ }, { "path": "node_modules/opcore/dist/reporting.d.ts.map", - "sha256": "97f88c34271c557b3bae8fc2938787e903f04142f6f99368e71d1dcb078356fb" + "sha256": "f9ff1b0f1cab4481adaf18822d3fe23c949d00fc209f65e333da1967a381e227" }, { "path": "node_modules/opcore/dist/reporting.js", - "sha256": "ba177be4c0154c511b8c7583f6bde2560225732e01f470fb28934eab296aae58" + "sha256": "2d879ead468ed0c95cd6d7c7a62d8fe0e11290af300c137d5ff3459659abaec6" }, { "path": "node_modules/opcore/dist/router.d.ts", @@ -447,7 +447,7 @@ }, { "path": "node_modules/opcore/dist/runtime-info.js", - "sha256": "6544ee4e7f4e21634e857d3787292fe1c464874a796ce078912974a34093601e" + "sha256": "e5c1a2442dd664ff300c814e6a9a022db0e69cbc56de082cd4b96c23f5830102" }, { "path": "node_modules/opcore/dist/scan-presentation.d.ts", @@ -461,17 +461,29 @@ "path": "node_modules/opcore/dist/scan-presentation.js", "sha256": "2f2d36a9771fc52a81de35a1b6eaadf3aab0d9360016027a545700481e74c58e" }, + { + "path": "node_modules/opcore/dist/scan-validation-preview.d.ts", + "sha256": "2c3bae9f9ba156b403139c7512cf1fd7a48f2cf6af3fda0fefe7a5b7c4db5f95" + }, + { + "path": "node_modules/opcore/dist/scan-validation-preview.d.ts.map", + "sha256": "02167d28fac42267008a6f930f73b5b264e5e740d46e141cb76494797e786859" + }, + { + "path": "node_modules/opcore/dist/scan-validation-preview.js", + "sha256": "db805b4b0f167972cfe227b30b1a78d10947d7916881a8e9b1bd0a0446001b6e" + }, { "path": "node_modules/opcore/dist/scan.d.ts", "sha256": "001a6436517486fa89279ffcdbf8a9efc49153846e4ed266085373c86ae6085d" }, { "path": "node_modules/opcore/dist/scan.d.ts.map", - "sha256": "0f8bbab295c697937cb14c710fd8e2646125f66ddd5eadde7abdfe2af2eb1925" + "sha256": "b557fe25131a1bd32d5d13afff82fb120acd6bdd76d1984a6e21450d439b2cbd" }, { "path": "node_modules/opcore/dist/scan.js", - "sha256": "3c5645c4e787e2bec50c5df02c13d644fb4ae3b1cec2cc266a3e833c0174b747" + "sha256": "3a6339c11c285479d1ec6b6679148acf0376a19de964a031093a48bd4fbed5b8" }, { "path": "node_modules/opcore/dist/serve-telemetry.d.ts", @@ -479,11 +491,11 @@ }, { "path": "node_modules/opcore/dist/serve-telemetry.d.ts.map", - "sha256": "ac09b75b18ec8bcb77e526b8006dbf9621fd0f80ee5a35120626b60b140f4e38" + "sha256": "b559fec0e608d6edf953cc96e6efdd52a8e9541fb1374b195451ea137fb8aeb7" }, { "path": "node_modules/opcore/dist/serve-telemetry.js", - "sha256": "329276ae5ddb3c4dca476bfa7b65972e911e3fba47d1722e498c2e05ebce2efd" + "sha256": "e89bde128ece2b24435949300735fa9509663cf90fcbed7aee77bf1a8372e445" }, { "path": "node_modules/opcore/dist/source-policy.d.ts", @@ -497,17 +509,125 @@ "path": "node_modules/opcore/dist/source-policy.js", "sha256": "f66c2416b961cdc546a6813f521fe98876f195b21efb9053a8206962a7c4873e" }, + { + "path": "node_modules/opcore/dist/status-args.d.ts", + "sha256": "c37150dd81a27c3b467768f05340ba1add06b1ee710fd9a90a9f43f472111f7d" + }, + { + "path": "node_modules/opcore/dist/status-args.d.ts.map", + "sha256": "254cc4296034dd8a6d9ca5c2d015548261b3a7281f46666b6f453097a769296b" + }, + { + "path": "node_modules/opcore/dist/status-args.js", + "sha256": "b5fbd17de9354244531f0f32077b53a098dee13cb8267ca6a8fb955927576dfc" + }, + { + "path": "node_modules/opcore/dist/status-census.d.ts", + "sha256": "05537e054ab1fa5458602bc46daa2e4be3494167c0e4f6ca2cd503e2da4bbc31" + }, + { + "path": "node_modules/opcore/dist/status-census.d.ts.map", + "sha256": "5047b434f3f4c52510ea22f9c411d0faeef191fa4cc2b1e96cbed413f9876665" + }, + { + "path": "node_modules/opcore/dist/status-census.js", + "sha256": "123b2a45eb3f55054a15d283f6861f287f6f3bfb8b2508a7c64a93814dfd01e7" + }, + { + "path": "node_modules/opcore/dist/status-coverage.d.ts", + "sha256": "4486c6a3ddcf105cafae65f3c088c9880de6c6bce5ad4f6cb1c6b41533920712" + }, + { + "path": "node_modules/opcore/dist/status-coverage.d.ts.map", + "sha256": "17216d69340310c2d3a7ac21c84e0853239772dcc775936297f00e01ab6e090e" + }, + { + "path": "node_modules/opcore/dist/status-coverage.js", + "sha256": "7cd86efcdf5701b09213b2fb4f6d39bf5366cea2433ceb9fc368c4ed742aaf3c" + }, + { + "path": "node_modules/opcore/dist/status-errors.d.ts", + "sha256": "0989055632155c2d1cb8f220460faf8d456e050175c9676333e0a17c050f1276" + }, + { + "path": "node_modules/opcore/dist/status-errors.d.ts.map", + "sha256": "9d88f3d368b8f219d625119b2ceaf37e6a44d20a186afb75dfaf36360dd87d94" + }, + { + "path": "node_modules/opcore/dist/status-errors.js", + "sha256": "7af42b50678124f52e42a85f8eeeb7a412ae760b5c9b0eeefb030ad43b98a918" + }, + { + "path": "node_modules/opcore/dist/status-format.d.ts", + "sha256": "869069805cf642dd1047f99b0a6700fb4ea0b9a9f391b3025a1d79f65cd3ca52" + }, + { + "path": "node_modules/opcore/dist/status-format.d.ts.map", + "sha256": "cef7d16f483e248711a1e143ea50530a3b6f6f15451e24dd58369e53062c180a" + }, + { + "path": "node_modules/opcore/dist/status-format.js", + "sha256": "033a906b9214d409de3738f29d5e79829907c61913ed9615fb6b6e0a9f1e5b19" + }, + { + "path": "node_modules/opcore/dist/status-git.d.ts", + "sha256": "357570dac2ee8d3c7b7928eb6761b53cc7af18b611805f240e71759934499514" + }, + { + "path": "node_modules/opcore/dist/status-git.d.ts.map", + "sha256": "be5cd6b4c4bfd9dc6be1d3d36b9af69b39e31949fd64e808d6ca1a37712fb3c2" + }, + { + "path": "node_modules/opcore/dist/status-git.js", + "sha256": "65dc77c9b30ad4fdb9f58c9626658b5f30d36f4a352949862ed4c622b188a1e8" + }, + { + "path": "node_modules/opcore/dist/status-repo.d.ts", + "sha256": "d29082c4bbd69b60d6f6bbec2513cb92e80eca9d6fc847361093e5a603fb31df" + }, + { + "path": "node_modules/opcore/dist/status-repo.d.ts.map", + "sha256": "c1408257edf48d6f72d0e6f020596e08ea11ba61ddc0c7bd1b238a897d9a084d" + }, + { + "path": "node_modules/opcore/dist/status-repo.js", + "sha256": "6aca952268966feee3c6d9f8573d695583f1212dd840299c018c1cea9714e1a1" + }, + { + "path": "node_modules/opcore/dist/status-state.d.ts", + "sha256": "7e64830a05486f4fa47ede48f977b694d6597eee7c75924e82129ac82dd39fb2" + }, + { + "path": "node_modules/opcore/dist/status-state.d.ts.map", + "sha256": "f82dec0779e45cc9b95d605dfbaf3ca353dcf0f5a663dfb9a3127b386f6eaaf0" + }, + { + "path": "node_modules/opcore/dist/status-state.js", + "sha256": "b4532b946efb07e53a00da79a0747d2db7907141b12b2a485a5faa47b82c08c2" + }, + { + "path": "node_modules/opcore/dist/status-validation.d.ts", + "sha256": "c7500ca6edb66a79564611c3b80dc381cc5ebc46889209ddce428698b77a5f65" + }, + { + "path": "node_modules/opcore/dist/status-validation.d.ts.map", + "sha256": "da0652f715ccd0963f66ac5e3fcb097ad014c6583c638b76f9e2c15dc0e32fd8" + }, + { + "path": "node_modules/opcore/dist/status-validation.js", + "sha256": "c067d6433a5e1ea96df0ffcbfdb4f1aa82264505c8aed395239cabe63e4ec5ac" + }, { "path": "node_modules/opcore/dist/status.d.ts", - "sha256": "630e572644b2ba0adcc7a686bffbace425edafbad65ead07b032c7de300a4138" + "sha256": "285bf1aea593f19cfb4d599403b68d14261d9fa2cbac820029f6d8b79e5634d0" }, { "path": "node_modules/opcore/dist/status.d.ts.map", - "sha256": "35c5f3632bab32a270f3fb320174687f5022521f5799182f4b9a2f372048b479" + "sha256": "edee605a70067c3f86854e859a7a56b32f117a5286fd5f8e4cdb450bfa483d01" }, { "path": "node_modules/opcore/dist/status.js", - "sha256": "24a720b005b89cb3874d3b263e470a7dae79a5f9df639002d71a046b01ccdf79" + "sha256": "1565b4d3046870482e883f453f47b857293e2c71188dfc40927810950ebe03c2" }, { "path": "node_modules/opcore/dist/stream-output.d.ts", @@ -531,7 +651,7 @@ }, { "path": "node_modules/opcore/dist/timing.js", - "sha256": "1dccdf3020bd780bd708bf7c9399d049fd108c6ee2313b464722b8481f0cb455" + "sha256": "d1ad05598b681b94220206fb71683e988256d655699f4034a6e9a974d1b1ce89" }, { "path": "node_modules/opcore/dist/try.d.ts", @@ -543,19 +663,31 @@ }, { "path": "node_modules/opcore/dist/try.js", - "sha256": "2637bbc01aa43a8c350825130b438c25ab8567e4f679dc9b6d03b19f0cf1fb3b" + "sha256": "6a9fe9016a8a0486b95a2da1b81594574aca71d47b9ba2bc7f7b5bd8a94f3a94" }, { "path": "node_modules/opcore/dist/validation-composition.d.ts", - "sha256": "946b4e5eb0942b01d215d62ae37781cfdac2a654564f05b5833ac9f1330d1c19" + "sha256": "64becc3c5f557aba3672e006b6e060dac62f6f88fe2371ac62fc2947cf1cd14e" }, { "path": "node_modules/opcore/dist/validation-composition.d.ts.map", - "sha256": "11cdfe386566bda11ef511278e7a7a3f84b4612bb95a824b69137074d847d72d" + "sha256": "8e5cd3d5b27a4d3c0b2ecfd730f29fd8ead5e23b3d89dde15960ece728281a95" }, { "path": "node_modules/opcore/dist/validation-composition.js", - "sha256": "c6b6742af803a6bef6d341d96c6834e92bbf5dbe0075b11a0f64dbcde663c60e" + "sha256": "6dbde57aa4eb9db2a859279648a1785428b27329d8e222a70428172a62eaa912" + }, + { + "path": "node_modules/opcore/dist/validation-graph-session.d.ts", + "sha256": "9b551cc2fe31f0d5e1edbe0e178f72b4bdf7fbd8b8e6066eabf101934da961cd" + }, + { + "path": "node_modules/opcore/dist/validation-graph-session.d.ts.map", + "sha256": "9cdc692f8a71879a053e5a573b6224e403387b978e3762c739ce2f0349803eb4" + }, + { + "path": "node_modules/opcore/dist/validation-graph-session.js", + "sha256": "d9911b967cc2077b2fd04cd10b32bdbd9deb5a178287f7b1619df8f23e9657c6" }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-asp-provider/dist/digests.d.ts", @@ -595,23 +727,23 @@ }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-asp-provider/dist/manifest.d.ts", - "sha256": "e6ecc8b970946f0145fd6b401eaf1ae997f6e89106cf72581264730ebaa057f9" + "sha256": "36528eed3cb4ece8046e30b8d4aeed04a22257efd096cd7084349a8f46c5eb7a" }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-asp-provider/dist/manifest.d.ts.map", - "sha256": "465d82d7f00c72c34fbfaae0898338d45cfa6e1bdbc14b88ccfe116296348c0e" + "sha256": "4a16dc896c8c353281704337cf0943f4482e305ce3586265fe0c7eed896062b1" }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-asp-provider/dist/manifest.js", - "sha256": "bb87042bfa5d7ff128aa65aaf99b1cc4cca50a8c84c5a410010d9bbd5ea88f2c" + "sha256": "e0e5f59958f93c5a2240166f1f597d5f805bbc77caac20726cdf37ab03a18ee5" }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-asp-provider/dist/manifests/asp-server.json", - "sha256": "d2bfbb77446043049298806ae25c323c0a6a25e88e19dee2ed916be87d5f5a21" + "sha256": "16c7b6a7cdd033e02a116a741b0adef5bbc50a136e2e704d381c0f3c74466ffa" }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-asp-provider/dist/manifests/opcore-asp-provider.provisional.json", - "sha256": "14a16543f63db919a49fbdbc65838d7be31c7d5a4466260bfa738fd47bd4edad" + "sha256": "b86bd23bfe0c084a6256abb8390526bfd42d8e79b6fe0cb232b8f5af8678e63e" }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-asp-provider/dist/mapping.d.ts", @@ -619,15 +751,15 @@ }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-asp-provider/dist/mapping.d.ts.map", - "sha256": "084f9b1db094ca5ec80808615925e20dbfd5949297e491f1000015939ee7c1c1" + "sha256": "81081b573cd9498756c5048399ce6cc60189d6a4ba7aac8c58d7a87559f0b077" }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-asp-provider/dist/mapping.js", - "sha256": "35f3b59a6ff21add4f7b988b5123c1013ecc7cb8334883eabdc227a04bf086db" + "sha256": "bbc7e9b39d9dfd0b309442584fbd7895ee1f5942878e1fd160ae7dc507c6daed" }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-asp-provider/dist/protocol.d.ts", - "sha256": "1e5000f0f6e4c3e59ab5afc8ae9ef46254f40800146e9e373c344c348807cd16" + "sha256": "86a389c3e61f9ffdb4cf32b481556f5f09186fbfe9d347beedc7e35340d31f45" }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-asp-provider/dist/protocol.d.ts.map", @@ -635,35 +767,35 @@ }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-asp-provider/dist/protocol.js", - "sha256": "2ceb72c6a3d6f5b9f56034d13f2f2e3320abdabc59c71a32d68f4e9e8444f2bb" + "sha256": "863150fb890f32510ed3e2c21b33d3c0b45b76e75209780ecaa4897031f8c0ec" }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-asp-provider/dist/validation-composition.d.ts", - "sha256": "dfb16a3ddcf5151528baa307e2a8dc99e38855a88441475c4f45c173261eaf4a" + "sha256": "adab11e92e36d1f34d9b37757181b1527fbe9a571ba683c1aae62d8faf78b9d0" }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-asp-provider/dist/validation-composition.d.ts.map", - "sha256": "b4153ae4c4eddf4c3839b18d07ae3b046ed6109a4750bd1b099f82b61923110f" + "sha256": "3124e89e18f9c5370e5f1fe2e81b8a45a11fa1433c8ae4019e265100fe9bad01" }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-asp-provider/dist/validation-composition.js", - "sha256": "9d6a9f87a17e277fa50e111847bcfc5a771b61ea570af9d1bf1e84f14a47a234" + "sha256": "197af4d677cee31ead9c7803788bf39f82f9e3e46db382dc927f4701ec62003b" }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-asp-provider/dist/workspace.d.ts", - "sha256": "9abb275428987b638ba0a3e9608a610e839b28bd27f867202fb2ff6d2ec842a8" + "sha256": "c949b26981f8359f2c5bd2aa9e829e7e5ffded44cdd9f658318c8db2cdd92f83" }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-asp-provider/dist/workspace.d.ts.map", - "sha256": "2164ba8cd7de722a72bdb18799e1efb5b5ba9468d3db8ebc8d2c367de9a075b3" + "sha256": "5213ff20f72018f49fcffcde821aec8ed2e7f816d7aa4e81af09702420301c52" }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-asp-provider/dist/workspace.js", - "sha256": "6b9f58762c89aa777afa8b21bb268a152b5f6b55b17135a216ae0fe1d596d5c7" + "sha256": "f3fdd265fd83a2f2a782c53ec615dffd931e16dcc7d2a0effbac6055813c4406" }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-asp-provider/package.json", - "sha256": "ba728204f8e89065d3b9abba93eeaba2f082a1f07264b9f44d8b5c45752e3f5d" + "sha256": "4b66b0c6276587ad7ac582ec8f0de28d5f58f8f82a33b26078cd02b182ba42c7" }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-asp-provider/README.md", @@ -671,27 +803,27 @@ }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-contracts/dist/index.d.ts", - "sha256": "1ba55d9f57b0497441ff0d7f25ff152511b067d09d45c20e1439e3575b70588d" + "sha256": "04c3529642808722491a610d95594d0a9d0f5c7bd480c86aec30c10da7ec4e60" }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-contracts/dist/index.d.ts.map", - "sha256": "f83ea7cfa867c601f24eba2c35156f27eb41cc773dd5b7f4cf65840a300e65df" + "sha256": "19ef90f6f5171dbfd0fa0131642a15bdf0b2b8b9060d2876d1c7312edb036790" }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-contracts/dist/index.js", - "sha256": "daea1b815da9ceb5eaa920ee2e0458b14832f6e15d4c48ac0ec1764396dc35cf" + "sha256": "c9d59ede565c92e4488c185223c96575ff8fa3adf9e34b99959e4d41a4949a25" }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-contracts/package.json", - "sha256": "e5134405c8d528576e2aa59b5d275ee98b7d0a46b03e0ecba23cef2a7dbb2965" + "sha256": "dbcd86152fa6b835e2ed95b52ae8a2799f5886e1e0cce1401065f40b56c5f974" }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-contracts/README.md", - "sha256": "ff0c2161661dd8799a8d5bc86be4cbd421a312a9b132708e6eed7b69521486fc" + "sha256": "8dcf8ca4dab2cc1031754c39a32957c34b059246c3e7219eb905d259f04d2a99" }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-contracts/schemas/opcore-contracts.schema.json", - "sha256": "d2671c5673015a044d52a5eafd6060e590ac99c2b5b3516eaf166841a7aa92ff" + "sha256": "0db053e2dec21f3143dcff59c7aa26ad5a8c4f9eadb5e34f110dcfffcb32eba7" }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-edit/dist/atomic-writer.d.ts", @@ -971,7 +1103,7 @@ }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-edit/package.json", - "sha256": "a18853317e723a5f985a6f102cfeb571840e751cbdba8671fba5a83e4cd18788" + "sha256": "875efba3f41217621e0929123296fe63b39358a5debb874cfc943544d449a9fd" }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-edit/README.md", @@ -979,19 +1111,19 @@ }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-graph-core-darwin-arm64/metadata.json", - "sha256": "4f2817e526b57f3036a7595cbc2a8ad94836f32a87f315f866080b0d9855c266" + "sha256": "9c3fdfc4b574e6cb2bc6786a5c720e6fb1f1cbe0895906ab0713d614439ec4fa" }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-graph-core-darwin-arm64/opcore-graph-core", - "sha256": "c222610c492e628fa48ff50be22310e5bf8ebcd62bffbde1903d1fa91c45f6cb" + "sha256": "72acf10de0ece619c3896943addfb0cc1b09dab22000b48a653b033224217bc8" }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-graph-core-darwin-arm64/opcore-graph-core.sha256", - "sha256": "794844f23f2c3ddd415b3a58d07b41a6a1c4ad643be0b6ae2a6585d0e4e057fe" + "sha256": "8c604dbcec287dd6a46a5949872bcc69ecb7d0274377da97c43a89b8f0907fca" }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-graph-core-darwin-arm64/package.json", - "sha256": "413b1b234d6ed3190302cc7852bcdc87c6e0bbc3320b37e3530b8db16ba3a9f7" + "sha256": "918a9996ec1dce568bb84089dab7ed9c806716dcb59884b7b7efa0643dac641d" }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-graph-core-darwin-arm64/README.md", @@ -999,7 +1131,7 @@ }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-graph-core-darwin-x64/metadata.json", - "sha256": "1892fae547da3aca3feb60bfba1be52b9dcfd4f5dfc9e4da0cb923cfd2a4792a" + "sha256": "b0995ddb2769a57cd3a07510efe90ed1788e5f55f48f10b0e46ac3c81f53e175" }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-graph-core-darwin-x64/opcore-graph-core", @@ -1011,7 +1143,7 @@ }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-graph-core-darwin-x64/package.json", - "sha256": "11fed3cf21851828ffb752fb1c82014038278812f5aab76d097438bb65ce4ed7" + "sha256": "d344b80abb7a1390700df6e1fc60411043c964fcf4b4006e334f1ebc87cfce50" }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-graph-core-darwin-x64/README.md", @@ -1019,19 +1151,19 @@ }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-graph-core-linux-x64/metadata.json", - "sha256": "d851e010080be2505824d89a49cbe2b1bd6709854d380bd3f39e8c58c2114d90" + "sha256": "2d6abe4b7651c879a1db63932f73c2c6af83bcf0db44f7d87bbde651d129f9e0" }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-graph-core-linux-x64/opcore-graph-core", - "sha256": "70cb6d213d74594f337a67522c879b77767872dc4601063686f1abe70886127b" + "sha256": "c3a771435a7a8172a9e5f0bb20b0d111453efc70901e95308094664f77e7bdf1" }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-graph-core-linux-x64/opcore-graph-core.sha256", - "sha256": "c2910f2077d0e824fa838a3565114bab7c63910dcee51221e64d37c0aee20b19" + "sha256": "4f1fe963a88779474c8c8c0ab20b84dc16ed604ad7573a7dadb17795c8ef37c9" }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-graph-core-linux-x64/package.json", - "sha256": "6ed19877da5a0147fabb063f8c1b295afae11aa6cc20f77d7dd51189c62c606c" + "sha256": "e908f78f4461ebf6f40bdcc4ad89928105922dbafba4f1af8f99a985da720e66" }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-graph-core-linux-x64/README.md", @@ -1047,19 +1179,31 @@ }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-graph/dist/artifact.js", - "sha256": "f64ad1f3f71300e1be44e1ceae53d5c46e320b36c4a1b190afc4f63e4f1cd460" + "sha256": "5abfeabfd74285cae12451274165a85f6b201f413742fc1ca7518f2f6aa6a5f1" + }, + { + "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-graph/dist/ephemeral-snapshot.d.ts", + "sha256": "5c6ab4465728ebbc2b9b1e0a3d366b093649b6c95e080750fb73922129b6cf6c" + }, + { + "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-graph/dist/ephemeral-snapshot.d.ts.map", + "sha256": "9011ab4daac15aec2772fa811403c7a1bcf094eae30f4d11bbc6390e92053ef8" + }, + { + "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-graph/dist/ephemeral-snapshot.js", + "sha256": "c96d85a3cf72e7278ad9ff83aa6be7bf358325d77b24ae0f8e3f666875fd5457" }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-graph/dist/index.d.ts", - "sha256": "e89f47cd370466224b7c62d969699e451e9f219d8f5fe6c01b0357c9f7f25a1f" + "sha256": "1c9e14e7608de61665ac89608859009957fc1f1a2f80015c5351b58edde49620" }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-graph/dist/index.d.ts.map", - "sha256": "3bf9c2d7897b1a2f3691207858e99628e215d356401ec09e233a53ca0bb2f89a" + "sha256": "37feacb128f2f9cb15559734f6f8e26024cc8606ab02d452b92b34c013e25264" }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-graph/dist/index.js", - "sha256": "27679634cc923e8a1c58fd614929fb17f0ae7293f4aa4f894c4bf154ce4a2049" + "sha256": "18341eecdd40b5635072e0344a66a0236bd88223c80206fc877c3be7b7de3a84" }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-graph/dist/native-targets.d.ts", @@ -1073,6 +1217,18 @@ "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-graph/dist/native-targets.js", "sha256": "ff335ba36ca639198eaa10f9ae2049ef21f47b87993879ef36754430a538d1f3" }, + { + "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-graph/dist/python-import-analysis.d.ts", + "sha256": "13b6c7ab139fb5514e89395d91982fe5d8d4805512f52b51618d6e7d2b277710" + }, + { + "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-graph/dist/python-import-analysis.d.ts.map", + "sha256": "15f8cca8751709dd96bd5d89467b3c781b136c739dbc0405440faf4dd0d52a94" + }, + { + "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-graph/dist/python-import-analysis.js", + "sha256": "0dc2b3b7333ea7ed1de4cd4be86e0e6332509d9b35d9c7501b62ba30916ee58e" + }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-graph/dist/serve.d.ts", "sha256": "c527c374aa9602bd754530fd98f09ea320b6d250c8fd8b897cd4d9fd92f321ec" @@ -1083,7 +1239,7 @@ }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-graph/dist/serve.js", - "sha256": "7027ed4c132b806daf8a75b9d9a831a0229ae7af8b15b057f32d5fe010a331d6" + "sha256": "6daa833d2db61f939908925cee5b30b9ebc2d3d83e55e32af4d4ceae727aebb3" }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-graph/dist/sidecar.d.ts", @@ -1099,11 +1255,11 @@ }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-graph/package.json", - "sha256": "18db8e6a9dd9321c303aa97112fd04d188da49a6c11dc05a5b86c6d2f2120f50" + "sha256": "2f323a750c079766738276d976272139494f008eb3683be21839676200de1422" }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-graph/README.md", - "sha256": "5e94c0258d169215b1809351adb32c40fff0c7ad5bbef5d76f4957e1053247df" + "sha256": "fc38126e4b469e448f8e31a544b8830940f480d5035f936301c73c40eb232bab" }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-clone/dist/check-constants.d.ts", @@ -1135,11 +1291,11 @@ }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-clone/dist/clone-check.d.ts.map", - "sha256": "e0c16562b2e7473c54e5f670cc491b49dda68ae0e4a2d719715ead2495606545" + "sha256": "9deabb3d3f1ed2d8dcdae235e28ebf9451d9152f92cb2e4e9bcb46569aaf3ba6" }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-clone/dist/clone-check.js", - "sha256": "888bf572e64692e7e5bf138a982beb34d446c1286261d98f1d7b78ba36503bdf" + "sha256": "5fbc7d759d90dee7ae3ce09d9d512419572ca60c8327d24ae45ab4418d238266" }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-clone/dist/index.d.ts", @@ -1155,19 +1311,19 @@ }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-clone/dist/source-files.d.ts", - "sha256": "31108024cfc4c98e57c005b441cefb0efc4fa57f71716ec0cba7294a766e5055" + "sha256": "7f5076c49dd7ad1f67fd5386d9346477a0f7cb4c90a6906068cafed3aad41fd2" }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-clone/dist/source-files.d.ts.map", - "sha256": "d5a8bfe0037baed21f4a99d7327874be12a906c2bd1ee6610d69753802e27dac" + "sha256": "fecbc930875e23d79519760968b1de49af666ee6e8f97d0ccba2cce14be85e8a" }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-clone/dist/source-files.js", - "sha256": "2bcc354144ceace8cd24b8128ac1b09938b69b319e7b0edf0ffee565ccc68464" + "sha256": "8803dcd94443891ddef56fd075218c9b0ab33c0c95d4fbc1c1c31bea924808a3" }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-clone/package.json", - "sha256": "9c30eb9ae94e778bd50991b21e4aff79aa19355e31e8da2c3539e040b23703b6" + "sha256": "3039def3542087a86f7cd0eecaf0621c6cbb104acb6658882f2c1d78fee4cc18" }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-clone/README.md", @@ -1207,7 +1363,7 @@ }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-docs/dist/checks.js", - "sha256": "c73c4811f7f89104e67de4e77317a7cc0bd3273f043a157cdc25d40b352804a8" + "sha256": "62d2c9669880109dfbbc702a2c602114cfa5d9436b66afca6ca99c9e69069a78" }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-docs/dist/diagnostics.d.ts", @@ -1271,7 +1427,7 @@ }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-docs/package.json", - "sha256": "a2ddbb4e695320847b766079999b400dc78bda0e6c86358114e266e2893c5ba9" + "sha256": "b455bd9bcb4e76080f6a8f083107d2834648504ec3b350c0d7cb88b838e3f8de" }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-docs/README.md", @@ -1291,15 +1447,15 @@ }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-policy/dist/config.d.ts", - "sha256": "1a854d537c8bda980e43284c00547f48cebd614ac769b29459d0110449e86bc4" + "sha256": "1fcf0d723be1625064c258f95f4765fe18715bf015036f0f2c2afc76ec8990f3" }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-policy/dist/config.d.ts.map", - "sha256": "29772ffdcd1ecf74cb62add861cde62395bcfa5449d68b4b59e25a2a4de93dc6" + "sha256": "7b1334280f472480f6381e08b61fbbb8803e29f36fb8bcac82c44e735eed8671" }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-policy/dist/config.js", - "sha256": "462f6660b89b8746c43ef1febea9ca93d95c9ca2c107b5d7b37e079f8c57be95" + "sha256": "5fcdcf4d6b10afb31ae650aa0a221d564cb2df967a4a6e1fc472cbbf5df6641f" }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-policy/dist/factory.d.ts", @@ -1307,11 +1463,11 @@ }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-policy/dist/factory.d.ts.map", - "sha256": "56ffe581ff277dad5e1b3abe98c6fc9d80b54087c19b849d1a9fdfa7f4157376" + "sha256": "03192673e6cba4665c2b6beaa4613bc81996273f044bfef56cd1760f9f3f75d6" }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-policy/dist/factory.js", - "sha256": "ed5ef6e0e8ed52d9536456d74e22129c67dd591cab8a5f9c4eec62ed8dcae49e" + "sha256": "070be0a2f4a0c50271424eff9fff29768440d2f151f04de582fe57a083895493" }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-policy/dist/index.d.ts", @@ -1331,19 +1487,19 @@ }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-policy/dist/path-policy.d.ts.map", - "sha256": "1066912a1a38ea4abf3715ab172b6683baddb2448240f1672700e7e847001e56" + "sha256": "7c166de0d1ed35c3e5af44ecda59dae13bccbd819de6ab24051365543dd5f2a4" }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-policy/dist/path-policy.js", - "sha256": "31484fff8c0f1fc4e93e210e81a0bec1f6b983979aad2c8407dccd8ad2e4f10c" + "sha256": "7070df9ae8d8d2fee063b0b94d1179c12bfb9acd7727a64d7f07fbfac1d2d0af" }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-policy/dist/types.d.ts", - "sha256": "4309bd29df742b2a421f7cd937afc5115cfe853ffc66c3899c5387209ed1d266" + "sha256": "d9cfe8f139d1a82ccdfccf32fc0fa7d5e232f591080d1c62cb5c7fc0a6b292a5" }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-policy/dist/types.d.ts.map", - "sha256": "38a1dd507d017067ecc50f5ab2bafcf4f0d6bfa6f681aedd55fcc672cf42feb8" + "sha256": "f09fda981c69c43465d568af363ae268c51b887cc2024a255b06531573315013" }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-policy/dist/types.js", @@ -1351,11 +1507,11 @@ }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-policy/package.json", - "sha256": "e8993c4f7e5f348d285add17b4d6cb9e9479e8e67254151e4d2b88fcf4f083f7" + "sha256": "4926a5c4bd8dc9a3d9c0133464d2729259b633b84e0232cfb2d236592af4b7c4" }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-policy/README.md", - "sha256": "b586e1798ab5f5246413792e789e9cb3d526a5381f69438227cb4d5f8c9c338d" + "sha256": "db1d86dd49022c5020c1b3351ec74094703b020e94a21bd6f1f9e3b8b45eead1" }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/check-constants.d.ts", @@ -1381,101 +1537,353 @@ "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/check-ids.js", "sha256": "5e1aef29466da1e49a13fa0f04c63eede39d6da3f686f946ad5c11afd90c8b35" }, + { + "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/compiler-protocol.d.ts", + "sha256": "dfcf46db0e17aa824f52b5246b6edfccccdd9d8640a2a2a4c47179b20b52d387" + }, + { + "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/compiler-protocol.d.ts.map", + "sha256": "91b0a9f442b63206c449fd0748f520297bd81acb2384dfd5ead668580e5f7b11" + }, + { + "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/compiler-protocol.js", + "sha256": "864c2130fffc299048891201e2c0f60a494919cd9cc690121aa44d93f65603c4" + }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/dead-code-check.d.ts", - "sha256": "34df57eb88a9380f3f2f34aa7dba98665d4bb12923ccb5ddddb4bbd7b7211b91" + "sha256": "4e49a666d9b0f5439cf3c0b23b32d3354579e03582fd23b7b24dd7b795f81e6a" }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/dead-code-check.d.ts.map", - "sha256": "db554a4663b73e78d3baedbb7b1191e54f5294c973c855a255a6bcab4d53309e" + "sha256": "acd1eeca0321d9931b02087b24ce2a24d90e1521b1e1cd24e53bfb5be326a503" }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/dead-code-check.js", - "sha256": "6142775234a5e993b57330f1624ec54a1f9622c3b4838194ceb005db204b3712" + "sha256": "8b562e495d6572ecd60b7d96d8ab701567755e6cc66e42b343f042bd8d50e498" }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/diagnostics.d.ts", - "sha256": "4aea379e9306f4ffed62e6e8c2f77ed13d40b007731b3fefe5d1057453023cd2" + "sha256": "dd6d64c1d8c960ddb34e0263a549242cd383808bbbd4b454ad2d6418c493cd60" }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/diagnostics.d.ts.map", - "sha256": "fbaf59fda1b55b679e541d824552ca60f2f394952d6b1b58f8a023b35c18e991" + "sha256": "7b70efdaae0765948964a2284097f12009d3e3cc222cf3e439b600cc921305b9" }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/diagnostics.js", - "sha256": "ac25956f727f13def2665600003fb2f3d7a931de81eb410569c5d6c98dad80fc" + "sha256": "c1440578fa60c6b583ca6fa5a5788c124295ec46db8a416f64da399e3df93c31" + }, + { + "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/environment-resolution.d.ts", + "sha256": "d93493b07da4e4a2325a0838e19077a9ae534487821573b78540b03780858a84" + }, + { + "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/environment-resolution.d.ts.map", + "sha256": "7667de8584b4fb91bbcba8722e793f13ce8f95be2c02f8565703bbeba702701f" + }, + { + "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/environment-resolution.js", + "sha256": "46f548c9c8ab37ef21f734b8016dd95491bd749b5ea2e5ba7e4ee62768898cc7" }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/graph-requirements.d.ts", - "sha256": "d8da29d17f5d8acc6212929f7f4dbb704ae2d95f515b0449fedde75d3ce2fb14" + "sha256": "a0b872e2bc804abdc7fdce3999d6a6ab70526bcec3c56177e01184349969ecc0" }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/graph-requirements.d.ts.map", - "sha256": "a78fda7d7fdd26c7d201166d6a55fd0f606cf8a8b43d74c5036e072e3b53c476" + "sha256": "7eafffe21e4ffa14cc10305236ad44efae187d6e11edc7e206993b8a11418906" }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/graph-requirements.js", - "sha256": "84241e83aef79b8d14db29436aa2ff7f018d3d80349f7f149a21a5aae8af3ad2" + "sha256": "a3d0e1c7147825f1596a92fe0f9995a5f1b1b0f7d61599558e6cc73a8ff9f9b4" + }, + { + "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/import-analysis.d.ts", + "sha256": "e9aa92949ad91d0232932bfbe31b008238916eb0e039525cf960b2f962d198e5" + }, + { + "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/import-analysis.d.ts.map", + "sha256": "3ad7742cc9e223e7b3146ea6379710da4774b12fcd0f5a0dce0f5ab69bbd28a4" + }, + { + "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/import-analysis.js", + "sha256": "68545da94e13538e18187609e4c0f8edc555cb336179662e961185f194a87246" }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/import-graph-check.d.ts", - "sha256": "47fd892c611fc011d4ebee8874bee1a600a6594d1d0f3c385822d98878abca66" + "sha256": "0aaafac30dc23c0b06e34a99fabda61708754ff17909bcb5d84e9141a7fc9542" }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/import-graph-check.d.ts.map", - "sha256": "670808449bee91e95f2f6e0b3802130aa906e81a20140b19bc084b5ca0f1af2c" + "sha256": "854d1ede9b21e92df6af01852a3b0a53b91bfe4fe42c7b1b2fd9076d945d3da6" }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/import-graph-check.js", - "sha256": "e9da561e7b46c0eba3b0ff6b0201bb428190b1166b6df0eba56dc08a2c689601" + "sha256": "64831783675029ed0b53873e0e7932b8f22e0a8fce1712015a14ebaa56f5bf77" }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/index.d.ts", - "sha256": "0137b25dd97051f8e0e47130f15a592b4c80486778036d8c86c3aa6e5b47d20a" + "sha256": "2297dce38a1d39b7bffd466a31060066ddb79c4ccfc5752d4ab191a29770575f" }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/index.d.ts.map", - "sha256": "3935bab306ce9fff81db08c269e0a177ae4a606ca4312b606523cf3870be47de" + "sha256": "b688ca7b040abb33d556b247d611a127b4e75207665abf4408f63bd2c33caefd" }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/index.js", - "sha256": "c5d64a3d50a4913a1c6848bcfd15629af4b3682e13f6d78b352ace17d081f458" + "sha256": "fffab3bfa9eb10a5ca8675a896a85c8c7a7f946d771be5caa306ffc5205e4a08" + }, + { + "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/ini-config.d.ts", + "sha256": "45c1e5cc4ab2c164034fde8d6d0d57bf7f9b53ff131310c4f33e269bd088ca90" + }, + { + "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/ini-config.d.ts.map", + "sha256": "b42173c7be789fa5118d3839ba700002ace02dc2ea3c04eceaa5c053b03d4fad" + }, + { + "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/mypy-config-values.d.ts", + "sha256": "67d28edcf2f8e696a577c9afd417d3fd41b41de46388f70cda8498315e92d8bc" + }, + { + "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/mypy-config-values.d.ts.map", + "sha256": "d46a75aea2718631d0d15606f3412a20d4b7b2100bd5bfefb3cce51c17362f45" + }, + { + "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/mypy-config-values.js", + "sha256": "e888c65e57b9ece50c4eb63cb1733fe21d2db59d592c4c4fede07bac48bf1525" + }, + { + "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/mypy-config.d.ts", + "sha256": "c6878315a44b4c9350e18cf654766b03e52a207efd9a3be2193462e4f5244a59" + }, + { + "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/mypy-config.d.ts.map", + "sha256": "f21a7cfcec00fdc9c7a1896b7a84a6db3347ba5fd6f365d95943c0a44eb75c29" + }, + { + "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/mypy-config.js", + "sha256": "becc09812ea0b6b18fcc856a63636bd8031ff3e97f842dafbefc4690528e3284" + }, + { + "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/mypy-output.d.ts", + "sha256": "93779d394e9ab4e421bbb65e71a8a0febb36c9273e870b1ba0b924841d5b4570" + }, + { + "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/mypy-output.d.ts.map", + "sha256": "19e564c54a9323e2d9af705db59b2c6f212014d960171d124d767ab6f2b257b7" + }, + { + "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/mypy-output.js", + "sha256": "5f909b72a6dacde78a1463532e9ce8d90adad4bb6e27dbab85aba39f9d677cc5" + }, + { + "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/mypy-result.d.ts", + "sha256": "780c01f2ac1840eb22413d3602bd3c4b4544d6675e3fa8c5045189e973c1537c" + }, + { + "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/mypy-result.d.ts.map", + "sha256": "cb0df9a6382f423211c459a8b75627d79d190da6278dc569dfefba276074751b" + }, + { + "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/mypy-result.js", + "sha256": "1a3968656321679f0589e5080e3f99b1b42efc7468a660966cf70bf18310b79c" + }, + { + "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/mypy-runner-types.d.ts", + "sha256": "72e5d82733184915f1503f6b8758eaa55d50b2fa1b1903376dcfd98e7255ef4a" + }, + { + "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/mypy-runner-types.d.ts.map", + "sha256": "0668d61033e066ffc6071c1ce16575d4a194d2ba27c9e2f5dab212fe27abd2fa" + }, + { + "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/mypy-runner-types.js", + "sha256": "8e609bb71c20b858c77f0e9f90bb1319db8477b13f9f965f1a1e18524bf50881" + }, + { + "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/mypy-runner.d.ts", + "sha256": "4b98fe5f7dee0ff6e4359496aef6ba87620cf877937550460d2c3ec09a9ced46" + }, + { + "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/mypy-runner.d.ts.map", + "sha256": "7aa57b4f44e4d077aaf142257061417601faa9ba7b6d6b118dca471cb4f2dd19" + }, + { + "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/mypy-runner.js", + "sha256": "1f1867f9124d7c7e07665e19cba3a49a882b8b1c18189f60dec63a2e5a7cfdd8" }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/process.d.ts", - "sha256": "44e3995c0a52d6cbbeb21256ac192527729f71e15a719bd69bf5d9237b9a9ec0" + "sha256": "e4f9b2956b4dd6c50206eefa8440ed0e2f1e5d9cab455439478da4753f99287f" }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/process.d.ts.map", - "sha256": "1444854cbb8477da9b102d6952bbf3e39e2861b4a7447ac320c499500d0ad394" + "sha256": "69efd4c6155f1dd23c0d04aeaf60d4e28affe4ba12db983285685cca7b17cee5" }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/process.js", - "sha256": "71692327f2dd7435308bba7b42b3e447525b4e24172172c465b5ff9d7235013b" + "sha256": "7cb40898ec8dae8c7beab0f4170278b10edcd159095eef964941e03597cd5976" + }, + { + "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/project-config-files.d.ts", + "sha256": "497823250ecde82b9e744adf7689819179e161f1f1bbdcd831b925c4236e3f55" + }, + { + "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/project-config-files.d.ts.map", + "sha256": "754de277453cf78a68991966b34b4e63f6b298376bee40281e04f86e4a17e880" + }, + { + "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/project-config-files.js", + "sha256": "f9712fcf234ac93a6490d2b97281608c2333b68b45f9c5c27d93aade61105b51" + }, + { + "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/project-context.d.ts", + "sha256": "773241c9fd9b80e352d7ce53ac908d3ccd021dbae17bd0f3da5346aed08b7575" + }, + { + "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/project-context.d.ts.map", + "sha256": "c8a313303530b707f6c47e77ab091d0a459ef7f3c17c4e83229871710319c6d1" + }, + { + "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/project-context.js", + "sha256": "766dc99fd6999254f0970f27c5e6f553c67eed34f911adc9e849def397606162" + }, + { + "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/project-discovery.d.ts", + "sha256": "19be0d790c04c88ab9fbdbb14296de66a21de7efff8b862858a5d7276014a113" + }, + { + "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/project-discovery.d.ts.map", + "sha256": "0ce82019183909e4da7696b706fff0af47a3fe7300f7a682aea8f2ffcf3f6b66" + }, + { + "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/project-discovery.js", + "sha256": "b5fd185ca40cfcddb5975c5e1c0f77ca558d57de19e262046af1870e7e6dd8bb" + }, + { + "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/project-fingerprint.d.ts", + "sha256": "19857728534749c259fdbe4eacde12c64bf360f35de92c3e9d1edb2bcd1dce8a" + }, + { + "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/project-fingerprint.d.ts.map", + "sha256": "5997e1912967bec2a25301f337112b6f720309babacb323e56c9baa4846fda37" + }, + { + "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-workspace.d.ts", + "sha256": "84c0b4e365d65451ed5300b2164c92b2116f60eef38521a542a20a0587d024ee" + }, + { + "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/project-workspace.d.ts.map", + "sha256": "811647d3ceb411ca07805a0d306d2117d37744a69d80d17036db3e51f7d2f7cb" + }, + { + "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/project-workspace.js", + "sha256": "e15bdae3b1ac852273ed31bbc4bf4436d8c9c309e8b7ff1743563a4dfd7e3308" + }, + { + "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/protocol-validation.d.ts", + "sha256": "f23a9267309e488acd5f4dd414e87ecf83e5b74185b90e2261d28d18b8864387" + }, + { + "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/protocol-validation.d.ts.map", + "sha256": "b0880cd8840d769691a56b94b6ebda50e367d8bc5536598a0708c1f1eceba593" + }, + { + "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/protocol-validation.js", + "sha256": "d7d413f3b7004d35316b34e26c5a347e38dff4211a2fdbd0ff6ddadd979c17f1" + }, + { + "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/pyright-config-values.d.ts", + "sha256": "a54b0ff30ef91d7fc439f63dea3868b322e629b19c8e618ce4adabf77e4028ca" + }, + { + "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/pyright-config-values.d.ts.map", + "sha256": "b4443b8d0a64fa92e4f04586172d6296d2624901b9c93147be8a5d2ef2652828" + }, + { + "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/pyright-config-values.js", + "sha256": "68777231edeefff7a1ec350e0e19e0cc4b4183df2ff61209da490fad21846d4b" + }, + { + "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/pyright-config.d.ts", + "sha256": "146893682e6a065a90829b3f75f24f86cf0b3ef86bec1abce1f71771a7a3a4fc" + }, + { + "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/pyright-config.d.ts.map", + "sha256": "9b1a1034206b7010300e2d8a74ae0e215fd9819606dd9f7db535e117d2b4339a" + }, + { + "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/pyright-config.js", + "sha256": "ca521f29754a386d0f29f37765b5565bdfc84a1120c5fcc60e7c9c2f4ae62b35" + }, + { + "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/pyright-output.d.ts", + "sha256": "847955ba128f4f512c9c89f294d8f8b87102a95d7397a93632edf2e2fbe1b9aa" + }, + { + "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/pyright-output.d.ts.map", + "sha256": "1a327b881d7e329834b6e4bddd3cd1e09ad3ebb0bf4bbfc1d8c1a317f1e5295b" + }, + { + "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/pyright-output.js", + "sha256": "2c44255b254476dcad1f76cbae9f19a86fdbf3bd32a2939b04d421506c5e8862" + }, + { + "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/pyright-runner.d.ts", + "sha256": "c9ddd2e7efc5c63013c8dcfa8c2542797d22fc50075e1d58e1850c0cd81c3b95" + }, + { + "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/pyright-runner.d.ts.map", + "sha256": "38f61a8c393d4e97445a94153defbe97a45de8db8b9f9e82df88d7d79e04f23a" + }, + { + "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/relevant-tests-check.d.ts", - "sha256": "d14d753ed8d6253ad516d3b9f14c2cd6e9bcc61e02000cdd5d5a7dcc9ffe7f31" + "sha256": "997ccad55a27d3b8db02837b0cb3ab87aae977490a5a6010fb972c8767f1447c" }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/relevant-tests-check.d.ts.map", - "sha256": "d8ea11cedf3576573cab857702b69d48be0bb01fc555e42dd216f7f2db60b844" + "sha256": "a2912525c19bed28411c25aa84627c70c35c86948bc8f517862cfad948b122a5" }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/relevant-tests-check.js", - "sha256": "9d8de8dadac7d737659e6e2a23604cdfdbaffbc153cf89887e0e117fa004ff49" + "sha256": "79568d4598b33b78ed388499b11038e3004518454d675fd4e95adc72462640a0" + }, + { + "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/source-closure.d.ts", + "sha256": "f33274f9aef8c4947f35dff25c5fc758d07fbf36ab75e2c4a4eba1dfb4252c63" + }, + { + "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/source-closure.d.ts.map", + "sha256": "71ed3366c6f58aec9fae8bbd6723bb5f908ca2ffb2e284accee7f35ab26418f8" + }, + { + "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/source-closure.js", + "sha256": "eae99dc9a3c633b4f42194cad429b6d32a23efc99b7eff1fd23ffca019b18171" }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/source-files.d.ts", - "sha256": "2edc85c1f35143928de9d8a205864d6145fc7ea5250b2187cd9db2c318ec1f5c" + "sha256": "dfce5a4343e78ec47df186161a9dab524f58e694cb4cfe57dfca42037da78d60" }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/source-files.d.ts.map", - "sha256": "b62b5246da79d6436b5eab0b6e3e39740370c6dbc5d30a6bccaf74d9c9d9a414" + "sha256": "ce06e10a525f1505d57fd2bee542a2c4981a0b838da66f3dc97644935d41d310" }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/source-files.js", - "sha256": "423e58a08992f25e915876c5fd63dc1992456c819e1902f6d2350aded8024d34" + "sha256": "0eebb0d5e1dccc7fef2b361c8bf7e9bc8e33ba951c04ef2996ea861c62c20350" }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/source-hygiene-check.d.ts", @@ -1489,49 +1897,169 @@ "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/source-hygiene-check.js", "sha256": "6d627426dbb01e242e7259ab353a366e3a979ae7fec503305584b380e8f0dd68" }, + { + "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/source-types.d.ts", + "sha256": "936f7ac1e09f675979270813162658eafe32e83cb7027df8c2bf33b69cff0f09" + }, + { + "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/source-types.d.ts.map", + "sha256": "0fa14033fe5b2eda30e761a9b2c2f56765e1d1c0d5fa176fd9b0362f50964090" + }, + { + "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/source-types.js", + "sha256": "8e609bb71c20b858c77f0e9f90bb1319db8477b13f9f965f1a1e18524bf50881" + }, + { + "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/static-config.d.ts", + "sha256": "ae01347dc436be0d276a667c405c25e524edbd683857ce56305abc62fd86a490" + }, + { + "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/static-config.d.ts.map", + "sha256": "5a073d331beda8a776bd23f5b34373d1aae6b5faed2916348bd2804257c9a432" + }, + { + "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/static-config.js", + "sha256": "97460315c77b2fb5dcc1e01a123ff57db2a939cd7aefa82acfccf1ce9f486e7b" + }, + { + "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/strict-json.d.ts", + "sha256": "ecdd84663e4e8d85d2e89dc4d61b1ee5469ef5877240512a36c7f397b99a558c" + }, + { + "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/strict-json.d.ts.map", + "sha256": "697259614288ed4c647e4c9d114a81f73e14ac8b7f379e8210ef8ce0059b61c2" + }, + { + "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/strict-json.js", + "sha256": "5b2211e2536950de5dd8afdbf78df02c0ea11d491a2699d84b45206447308e21" + }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/syntax-check.d.ts", - "sha256": "c1ab74c11eebed90404117aab83d3c1141f456084878a8111a18fa12ba128693" + "sha256": "be0032532ed9c24fcd157555cc4c2527c30d90492f6fc7aa47478a1ed1c3429e" }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/syntax-check.d.ts.map", - "sha256": "55239332533dbbf75285fe0a9e3a3f0b9f6e580713bd9f8f1774f1ac3f73363b" + "sha256": "dc9907f32137fd34f8d60a5f236307e4b905a2d58d3adbb42bc28fdabc515402" }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/syntax-check.js", - "sha256": "cd8234189b59fb58d693af5ee50a3ef672f638d824fc3d0def21b0d0a15ecd90" + "sha256": "f5f3822c3b62e8be40c8dd142aedee02ccf5222863daac785dae9d743e47acaa" + }, + { + "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/toml-config.d.ts", + "sha256": "0fbae3066821105ae6a91b3f565b24f504034d7dd82298a7f2cedd6794f57dfc" + }, + { + "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/toml-config.d.ts.map", + "sha256": "1e8c37755f0476d72cc5d781acdaf0bfc549780ed48ccafb63ca78466e443c98" + }, + { + "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/toml-config.js", + "sha256": "b9c41257cf0cf6c28e09b585a43ed1524664106443a23ece041734ff72bb6ecc" }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/toolchain.d.ts", - "sha256": "85cb743c9984c5f61d09c4c3baf4304d648dc53eada22547be65d052927fcc47" + "sha256": "632e02df935c1f62fc0fa93eafa0707352173be6f759b46d62c37915912e7a6c" }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/toolchain.d.ts.map", - "sha256": "2a247bf45fe5db852df0622ac78e4b496d919faf1c8e50287daf6dd3eee0216a" + "sha256": "b1f1418bdf728706d1a0699f14803288290c8b1c097038a3e0cbb666aac52a1f" }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/toolchain.js", - "sha256": "86266fa88a2904ca4995b0c5749ae2a8c6e86ae41b3b66ecabd62dc0b97a72b1" + "sha256": "2a6f80bda3f4013d958b448306503713ac8db21ce506d3d46910a723cd254ceb" + }, + { + "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/type-authority.d.ts", + "sha256": "bcaeabf12a4c924104ca7fbe07f758099fb8357449606839b9a76d66c7979d51" + }, + { + "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/type-authority.d.ts.map", + "sha256": "b19b69c7e0e8a6894a245d3650591dbf3e1a287c0a4552c02be7b2c58ec8b978" + }, + { + "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/type-authority.js", + "sha256": "08b5b1bd0770a59239d93e893803fbf7a5a1735224d1c0c1b4ae6ca58111dafc" + }, + { + "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/type-capability-run.d.ts", + "sha256": "c82b36087b1339e130b5da9a37a6fdbec38dada50da513d745750617488fbfbb" + }, + { + "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/type-capability-run.d.ts.map", + "sha256": "2ebe915fa8fdc02113bab3616b18b802ea623e0fdd1ab9267fec57e7551fa556" + }, + { + "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/type-capability-run.js", + "sha256": "23088d94769aca2dba534aa2e17b0ee105c45c1971274e7f3d69db81c7cf7014" }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/type-check.d.ts", - "sha256": "96dbbac01080902b2ba17086310e539ae34161868781997174ecae34dc567023" + "sha256": "5130d0d88b397df251900fda7c992cf8e7e4af18bae9a4c2e2b151eb34602402" }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/type-check.d.ts.map", - "sha256": "3ceb9e6d5277c5e1a05ac006b404c5179d0c92bd33b8c9cccc411f9bad77ab34" + "sha256": "871a330554c156f9055b550c356041e5b4cf64e9ad054fc99c520fe22aea0fa7" }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/type-check.js", - "sha256": "6b24f8cf55935dd1b5b91f366df734831dd46b2be1d5b60cc258f04d8f877ad2" + "sha256": "ff167e49fe942cb9addfc83a421533d69d0d7667a9684bc3e7a276115307f544" + }, + { + "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/type-result.d.ts", + "sha256": "75274ccf3c59f113e77ab67ca8347020a960275f90e7c1154652d411b4f16357" + }, + { + "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/type-result.d.ts.map", + "sha256": "f03c987bae4fa9cc6a0207ee0a9be3493ba8170f7a69fc6b05e3cd2c7f34faa7" + }, + { + "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/type-result.js", + "sha256": "dd0f6587ad116b71589aee2f16a69be7aea4590a935c60d9d2c5f4666885692d" + }, + { + "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/type-runner-runtime.d.ts", + "sha256": "29e046656843be57a082d0375d491bea58f2de2e31bb8bc46912820c4e7aaa3c" + }, + { + "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/type-runner-runtime.d.ts.map", + "sha256": "346a94cc921488a0eaab7d7f4f3d9ea9c28e0998b2a6f3fe16d3b15479cce8a7" + }, + { + "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/type-runner-runtime.js", + "sha256": "2be5a9bf187a4497ab3970ff33e5c91b2df72f400113411169b29e95a7d8b22e" + }, + { + "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/type-runner-types.d.ts", + "sha256": "1e64ae862d330656cffa2fe63062f270b9ba2608e963fd7220840d65f2bf8f2d" + }, + { + "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/type-runner-types.d.ts.map", + "sha256": "f316ed6daad0c7598e1975863c1c6e1e4768325922b46da54912d50a4424d25c" + }, + { + "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/type-runner-types.js", + "sha256": "8e609bb71c20b858c77f0e9f90bb1319db8477b13f9f965f1a1e18524bf50881" + }, + { + "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/version-constraint.d.ts", + "sha256": "e5078e030b02bb3c63c98322be79e4d92d8f7d3973759cf8cb49806f22c16869" + }, + { + "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/version-constraint.d.ts.map", + "sha256": "e65c78e207981500af822bd9b24cc4689653407f88ddb3deff49dccc0e66c5e8" + }, + { + "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/dist/version-constraint.js", + "sha256": "d37a8fee635def42b5965aba9422b7e1b3bd5894dca46dce5da0355ec18dae1f" }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/package.json", - "sha256": "40518168600f66737d3aad515b563f646c52c33c9060eae37cc5c86c1518fe82" + "sha256": "fa954be8fc1ed25fa8dbf3370eb858838a1bcd428c81f954ecd225d5f08ae33a" }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-python/README.md", - "sha256": "ee51384001fcb696fa474c3685bccefa12c3589db77a9b89bf600ce500d411e4" + "sha256": "a9ebd507b25b6c88e0d8bcf236744a2245bbb256387e1741a071f067f41be27a" }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-rust/dist/cargo-check.d.ts", @@ -1543,7 +2071,7 @@ }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-rust/dist/cargo-check.js", - "sha256": "7b933fafa83476485f0db670563714fa0abb7389a295bf4bb11761042ce5dfe2" + "sha256": "1bf0606a02ea90db233c3892bd12c7e7bb8cb3100b9458878b59ccfccddfdad3" }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-rust/dist/cargo-metadata.d.ts", @@ -1635,11 +2163,11 @@ }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-rust/dist/fmt-check.d.ts.map", - "sha256": "adaee3e2e96fd426606692faa4031a8a31a34614430bcf5fc533d9234bd02dc6" + "sha256": "07ddcbf7ed05ff13ca60e9adf658d6719b4a77b605c334991a33723025af01f1" }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-rust/dist/fmt-check.js", - "sha256": "72d4f0f8589091501201ddfaf7f8e3e68a62ac04361c97995e42b1651250db4e" + "sha256": "6fe929808bff975507c24a013617479a97269853ea4538acea0b10fd9c67c64a" }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-rust/dist/function-metrics-check.d.ts", @@ -1651,7 +2179,7 @@ }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-rust/dist/function-metrics-check.js", - "sha256": "8a29031f9d9576e939cbc371e75f6fc6871e4211bf51061e8943e1cf09e443be" + "sha256": "8fd78c03dd738a05e2e15d0ae5f2be7b21873081d6606e88cb28fae8a68cbe25" }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-rust/dist/graph-signals-check.d.ts", @@ -1671,11 +2199,11 @@ }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-rust/dist/import-graph-check.d.ts.map", - "sha256": "3f831fbfadbbdedf758ed9ae9fbb2a9c1c5089aeafdc7790a867dfdece2ee805" + "sha256": "ceb6a25723c43d9cebe3a995584e53af81c47bca5b26dfcaa4a5f9af78927aa0" }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-rust/dist/import-graph-check.js", - "sha256": "bc58f565d76e3e0d082f011ffa049a1c84aa147fd7b74a9df78b5bddc78b2939" + "sha256": "7611cc809fdba0f668409a56ac3dbbf891b7e322aa8796412aaaa5f6e1aa024e" }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-rust/dist/index.d.ts", @@ -1691,15 +2219,15 @@ }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-rust/dist/materialize.d.ts", - "sha256": "c93f17013f8875e941707b5165b7fd98065c464105aedb229df23ab60eccc65d" + "sha256": "a5966afaf0c7f7ea651e8cf4fd38a650d3ea1cc3e8a85312b4878371418a3f6c" }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-rust/dist/materialize.d.ts.map", - "sha256": "e6cef15530ff2a9d0f86c96c68172af03a266032024f1c1a465e8a8b834eb5dc" + "sha256": "a4a3f8ba61e38e2895d598060aab9152e569f22aa2201471cfc43ba9dd9097ec" }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-rust/dist/materialize.js", - "sha256": "e9c96369e7d85c91049af0f8e4989648b896e1910956c10042d2a1f485f92ff0" + "sha256": "b941f8d84b7638fd3c2081013349e4dfafb4d1dc5510b057be6d837c9820ad64" }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-rust/dist/process.d.ts", @@ -1767,19 +2295,19 @@ }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-rust/dist/unused-deps-check.d.ts.map", - "sha256": "e26cfeb15b4767a1fe6e2eeaf9f5f4dc8fdd33f586b70a47ee7f02da718c4e77" + "sha256": "cc509179c6daac6d8fe4b1ff797851c43da016bb8fb1f1dea0b6d9dffa8a65b5" }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-rust/dist/unused-deps-check.js", - "sha256": "cc052f05a1cf2b4d04e1ad72997341b23203e834b7f17c3b17009d48db17fee5" + "sha256": "ad2a98aa16d1dc962a2c49e50e13e5d42c7eedaba4e9e73201e365268842e48e" }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-rust/package.json", - "sha256": "8bdb2eb2f1093771cd42f132451787b69418eaf43a60665a21a75b958d8d9b55" + "sha256": "c0af3467eaf7e07795da6fea7973b058209bc56b89c105260cb418893ba5f757" }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-rust/README.md", - "sha256": "d99c1c5d4a95aac6e9d50cd1169a97f909667bb1a91551f514530d773aaacbb5" + "sha256": "d338d2577bf37bc2de09ae9457760fd811044b76cadd1f016923d8d4a8eda4e5" }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-typescript/dist/check-constants.d.ts", @@ -1829,29 +2357,77 @@ "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-typescript/dist/compiler-options.js", "sha256": "54a52c5b5c8dd9fede4fc9a36a2a933f8daca64a0cc5af63b46b4db9e1e20978" }, + { + "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-typescript/dist/dead-code-analysis.d.ts", + "sha256": "74a4bffb568ccbda0769f13683e97178ff517d51235b31c8e13d33434e7009fb" + }, + { + "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-typescript/dist/dead-code-analysis.d.ts.map", + "sha256": "8635013b60fead71ad9fc1c53d342ebc5357ca5a03668695cf8079cb16fed721" + }, + { + "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-typescript/dist/dead-code-analysis.js", + "sha256": "eb4f0e6f33819932fc6fb9a31142843ec23ab85df1cc141eeb2b126cfda12b96" + }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-typescript/dist/dead-code-check.d.ts", - "sha256": "33fc2715978e0f15ab0251b418494ce31b05e19dda00143c173e969ce9b5a0cb" + "sha256": "7f2b7812c27899affe140183df5544f58b58ddfc80a222e9dc6bc412fe6bf009" }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-typescript/dist/dead-code-check.d.ts.map", - "sha256": "3d9c85d9276ba3f0b6e56cdb01c735b658b5723be7a0780a427a1bd739c43be8" + "sha256": "36e20e31cfdedf96187fc430eca9bcdd8098a0a54e793e222b914349f9999685" }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-typescript/dist/dead-code-check.js", - "sha256": "28870cabe777f105bc611f28b111b6d3d5c7f550538082f9c6a5d7d0b3be14ae" + "sha256": "4e9555dd04eb4939d92b5a369d49907ceddc3443b4a2440005f97c7f5fbec8b0" }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-typescript/dist/dead-code-entrypoints.d.ts", - "sha256": "23ee339b7302fab33763542f05d1caa63358f6b02c78e8cfe1567536461078ac" + "sha256": "c8be332d3e2a2e7ef093c475c4f76a4dd1ab9b4c7a91b687a0f44b87c8832a9b" }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-typescript/dist/dead-code-entrypoints.d.ts.map", - "sha256": "a6f55de1bf53c474c08789c874b24749f4726518547023637fc2dbf34e96f49d" + "sha256": "cfc318e7d98af23edaf7ffa367871f28fe52cbea85d0f671e5733059b84d4070" }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-typescript/dist/dead-code-entrypoints.js", - "sha256": "55b230dff5ee38450426b6e11755ba0d34b1c60c183c26f2b37969805de38084" + "sha256": "7d76a8272e4cbb2e9c1ab940cb027157497e259cbf3ffbc87c49b7b402e8895a" + }, + { + "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-typescript/dist/dead-code-import-evidence.d.ts", + "sha256": "788038511bdab47dab5f4048f9f10ce7a50f09f7a52414bcfd8774579365351c" + }, + { + "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-typescript/dist/dead-code-import-evidence.d.ts.map", + "sha256": "ad57566b285418092e78284b798f7d6914d536b35c08e806945047ce65fcf727" + }, + { + "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-typescript/dist/dead-code-import-evidence.js", + "sha256": "3434fe99b03aea4716d5520a47f486ddc9409fbeb4185d0209f0434fdb0ebaec" + }, + { + "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-typescript/dist/dead-code-roots.d.ts", + "sha256": "2ca1f3c06a3087a46497179e1d94157b5755951aaf538a76c725266838e770d6" + }, + { + "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-typescript/dist/dead-code-roots.d.ts.map", + "sha256": "0e3be2baca4800a542eba2d9bb4406ee21ab55e15f07e4dba025001bd5fc0efd" + }, + { + "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-typescript/dist/dead-code-roots.js", + "sha256": "5dd5413a35e9cfc67153d11448b98ba10279a54413622b923684eb36acf7c6b7" + }, + { + "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-typescript/dist/dead-code-run.d.ts", + "sha256": "18f53da3e0b92984c2eb6fe83a18a84cbd2b7ac2d29c47a3368e9347b08679a0" + }, + { + "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-typescript/dist/dead-code-run.d.ts.map", + "sha256": "5aa355900f7702edce5c2548d1aea96d729495afb66012d703ce0ca75d764795" + }, + { + "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-typescript/dist/dead-code-run.js", + "sha256": "f5b9fcc3b636c6a2d210a4a28c62c0703407be841494c34a02e29a221805c61b" }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-typescript/dist/diagnostics.d.ts", @@ -1907,11 +2483,11 @@ }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-typescript/dist/import-graph-check.d.ts.map", - "sha256": "da4d9918b3855e0e041455ac494baedc0de7d661c5dd6d082ba905374ff5be48" + "sha256": "1160711ec9f733712ce4ad119ad6a395d4c686346c3c8ed1d4421a152c7d99b3" }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-typescript/dist/import-graph-check.js", - "sha256": "3627de054d1610dcafc2ab7e9429bb14d9e7136dd53529a7f4e6ae186e71b34c" + "sha256": "0eaf60ed0d088d916db1da49bbbc6b5b47c4b312318bdb078933ffac743fea0f" }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-typescript/dist/import-layer-rules-check.d.ts", @@ -2047,7 +2623,7 @@ }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-typescript/package.json", - "sha256": "ba5f1d71d79e96c0ddde98bb3f4fc9cb77ec8cf654efa1d8ba6f841ea5ad100e" + "sha256": "a4b2dbf3d36fe2d73a6a13fba16ceaedeff3ef016ff39384d668cc4089819c00" }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation-typescript/README.md", @@ -2055,27 +2631,27 @@ }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation/dist/aggregation.d.ts", - "sha256": "884ae8ae64eed5311a5e4caf9d20b8915d872f0e8306af2b405fb081f6ce5713" + "sha256": "c198134934a3d1aea46884baf71cf992215b76e0c60aaa4312f5c90ccc936913" }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation/dist/aggregation.d.ts.map", - "sha256": "7affab90bd4256f0c25c898950447a896c3efb2a1a7b8b170e65b031cf5772af" + "sha256": "a214a46a188428f7435d6e5c1e5df5ef9ad46f3d8d998671f73709eb085f589b" }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation/dist/aggregation.js", - "sha256": "2dd558ebb1214f741197d49b02469b5e485dae9ba68c485c149459a4c7a54ac9" + "sha256": "eccddc1c56476df4006b8caf30057c27117851f9c0cd170847678bfc51245204" }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation/dist/command-adapter.d.ts", - "sha256": "f79f351dcbe889809ebecc53e63898081350efa710cdd2ee07be380e95377979" + "sha256": "6b741e89673c3e5c1e0ceaf6a48a1e9b1615f32c30f119a16ba7d66136c9298c" }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation/dist/command-adapter.d.ts.map", - "sha256": "29c5fac909e58105db3a1dbf9156294011b5e72d6df928455e75971d4a1750a3" + "sha256": "886e5ff3a6b1c23477e7cb5de2d81e6eebfdbdf7c094537b91f7f75d72dd8559" }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation/dist/command-adapter.js", - "sha256": "13b7537b92efced5a058b91e23069a9aa595e529859bd94b0d6cdee0dfbd1e62" + "sha256": "912810eba24593a0f5439347b62dc67a1ed1aec62f3b7cba960e273523f9e87d" }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation/dist/command-options.d.ts", @@ -2091,51 +2667,51 @@ }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation/dist/graph-client.d.ts", - "sha256": "fc70491c7e5b91bb99fecfdd69d0bbfeda92515fa88b0009168b4bd86dd06aea" + "sha256": "e5e92fcce8b64cc7178471b8e29b479cbe4186d7f3c79f86d53e1305d6b2e1b0" }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation/dist/graph-client.d.ts.map", - "sha256": "f5a320b38324a09dc212d128a9037f3b582d0d9bba178af3cc4d968f0f3259a2" + "sha256": "14ac7b6c9bcfa73332208829c83648ba505a22852e717f84d217cd08d8ec19aa" }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation/dist/graph-client.js", - "sha256": "b38e7a5f9ef103f292b120cd58c10263f000ea4d374db98671fdd61758561fa2" + "sha256": "1d2680378eb294fc1c3757ed53f5bd04caf405ef658b09376ee9eba669f58534" }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation/dist/index.d.ts", - "sha256": "1a0148eedba73edcad32445cc2d22cb30e57c30a48a25e8f9ad64e1613a67d94" + "sha256": "bc75b1f3bf4314947a927194796300622deb83ff3bd76421e953a95a3a9f8ef6" }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation/dist/index.d.ts.map", - "sha256": "5ecfdbf1f3811eef9485b295d320476060809711a19c88aa2326ba61b6b13d0a" + "sha256": "3aceb683dafa32feeeed2b44f04d9bbc2ffec4e2a629c4f265a0d0936aec0740" }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation/dist/index.js", - "sha256": "902c7971953585232247be2d7560990e82345b615305db7086931775ae34e86d" + "sha256": "17111b0a6c8b69c0dab4cb6e63c642b7b2553488593484ae45bc20d90f5670cb" }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation/dist/overlays.d.ts", - "sha256": "969263ec3d7666496c4d1eb4aea9179187c3bf2c15b3d6b04eaf868a39526850" + "sha256": "318440b1d75ea5429fea3a907672c77fe47db910954e8b3bd2277a3ad41aacb2" }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation/dist/overlays.d.ts.map", - "sha256": "95f8d4ecccbc8aee28fdbc83ef2003a1db5e6b10ac59d484cde9f453dc199457" + "sha256": "2fbbe2a1a7cef35c51fedc389cfc3d3c7ba093afa82cbfe54c08c6b133dbe563" }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation/dist/overlays.js", - "sha256": "d2e24bea81b76cf1393a058cdde3cedc2d511b50a57781ab77aa19ee11a77d8c" + "sha256": "f29dc429cb6d4660a09d064b5498b846265660d333c699a2c22871a373eaa5c2" }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation/dist/registry.d.ts", - "sha256": "88c2372f5ff4ad93f2fec58e4aa89961c658a3909c75a82edf6ca9f493f918ff" + "sha256": "0480cdb8df7b95f68f14f2eba16d273e9fb517cf7e0de0eaa01fedecdadf4563" }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation/dist/registry.d.ts.map", - "sha256": "fd685022dcfa05393272faf4e055b6891efe8e279dbe3ab653660a4d9417d4a2" + "sha256": "1e95cbafb5c73ffef6860fd36bd1f0e1784742c0ecbaf5fa8d4904f25429fa84" }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation/dist/registry.js", - "sha256": "051ac54be33f943b13f995d120e8a463517ad3d595f497b9428e53a35c0e1c32" + "sha256": "7d5b44429cdc512d8dbcdd5fc49169acd855175656ad915864c31b6173c3b5a4" }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation/dist/request.d.ts", @@ -2149,25 +2725,37 @@ "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation/dist/request.js", "sha256": "7516e304b41f559e91f979508873ee5768609a13c2eafc25d90a2cfb514f6147" }, + { + "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation/dist/resources.d.ts", + "sha256": "7a5463cdab8ec7887bd29df7a0787c73b32b561de3b1592f243bb4f5ed6dd628" + }, + { + "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation/dist/resources.d.ts.map", + "sha256": "c763df60a9984366f23b09f0703ad1ed4306d39454ea1da963c3ee4d0a7508b5" + }, + { + "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation/dist/resources.js", + "sha256": "944d64469db2dc471ce5966ceb694cad88b2047a7ff2e0c7b4df23650151f9c0" + }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation/dist/runner.d.ts", - "sha256": "a03e7c9a29849c305485632c6e7bff9d01c740f253056186b0491cbca9a2f942" + "sha256": "f06d5cf2455cddddd4f00663481bb64439323ea4b59eec1121efb07fcc370b54" }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation/dist/runner.d.ts.map", - "sha256": "c5f4ae00fd78edbdc32fa0c3932d68655511bf91dc2eb671a28393b17c827cf2" + "sha256": "ffdf7e8ee511ef92e1162c871e3c4d0b8463b976accda851193539afa4d186b3" }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation/dist/runner.js", - "sha256": "d51446de7dc92e8b2dd822e572b812f36150d86ba09a66379be052f64a93a77b" + "sha256": "76395cb451d9989cfa710fbc9ca7f577e17dd6a754fb4fb61c7d097859ecb928" }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation/dist/scope.d.ts", - "sha256": "940662ee92fa675292cd9a577add8c136bd974d5108835eb9d11a2efb5f4e031" + "sha256": "271316618f0137c42b9ff9df67afb95ff1402278e19b0521c165c9f78ca0ea94" }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation/dist/scope.d.ts.map", - "sha256": "8104af5a03d79dae6ae3d2ff412f9a825c0aaaa02e6459a46cb74d3863019f86" + "sha256": "7c9303ecbf758eb82d42dbbdc17815545f588a42d4e9c5d26ee66b388d2e557e" }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation/dist/scope.js", @@ -2203,19 +2791,19 @@ }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation/dist/workspace.d.ts.map", - "sha256": "5c4a47876082def2655e90311636d93736bc67e6848a17b84b2bd4b733e857f1" + "sha256": "27be94b7d35ca1ee7c48cecb78d5786a2b9bdf0dbca2c2d41c4adee2ee2f1f0c" }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation/dist/workspace.js", - "sha256": "18c4d3423334afef397601b51f1271649ddded0351c616e83cafccbc0e80330b" + "sha256": "8e6e073709c93fe408483b28ad4c26570a3ec9a0620ce624af8e49132a9c3eac" }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation/package.json", - "sha256": "a1e4240c72670dc9504762f87efe5836b74203eca5999a29a2e0a0a84d70f483" + "sha256": "40378082e76ec28a4029542ebe5203047ef3131f96e28914dace1ddbca4ffcd4" }, { "path": "node_modules/opcore/node_modules/@the-open-engine/opcore-validation/README.md", - "sha256": "909bff57bc02d41b6337815917fb2293192054aafed1f40e3be564ab837f4d00" + "sha256": "f5053adb36f6b6154e277b92c105e6c769a163b1ffdd9734a15f82dce0fc9fee" }, { "path": "node_modules/opcore/node_modules/@ts-morph/common/dist/ts-morph-common.js", @@ -2981,6 +3569,34 @@ "path": "node_modules/opcore/node_modules/code-block-writer/script/utils/string_utils.test.d.ts.map", "sha256": "302b292e97710af74f26aecf8ce50e5c2458386f7cfc40e6693c608de9b7ceb1" }, + { + "path": "node_modules/opcore/node_modules/concat-map/.travis.yml", + "sha256": "dc3b4379f4671f3771341b866a8b9d7ff954a65e9152056b8166747dfb343d19" + }, + { + "path": "node_modules/opcore/node_modules/concat-map/example/map.js", + "sha256": "ffd5fbdaf966b799cd9d046624be96b48e206920e58ad52d2ed1f2bdd10e93d0" + }, + { + "path": "node_modules/opcore/node_modules/concat-map/index.js", + "sha256": "091b65d778337599d0140b35d53c038603d1732d27c33bfe39e03871a96926b2" + }, + { + "path": "node_modules/opcore/node_modules/concat-map/LICENSE", + "sha256": "435a6722c786b0a56fbe7387028f1d9d3f3a2d0fb615bb8fee118727c3f59b7b" + }, + { + "path": "node_modules/opcore/node_modules/concat-map/package.json", + "sha256": "4fe5e8089b65ebfd55bb86f465ba896ba6a8e24b7b61de1e0f420f590616b118" + }, + { + "path": "node_modules/opcore/node_modules/concat-map/README.markdown", + "sha256": "2bc76dd6de6869fb5335e04c066edd5ab9d99a2cf7958a25e3c7c8c48768b5b4" + }, + { + "path": "node_modules/opcore/node_modules/concat-map/test/map.js", + "sha256": "e2a41fad6a88afcd0958cb9f93217ac2d62670d2a116ab7deb2b790e8f9bc90a" + }, { "path": "node_modules/opcore/node_modules/debug/LICENSE", "sha256": "3a61c6c96caf5c1d9b623fb9b04c822b783dfcb78aa7e49c76a3f643e6ed7f95" @@ -3073,6 +3689,82 @@ "path": "node_modules/opcore/node_modules/fdir/README.md", "sha256": "99ba9a269bf78c24621c0eaaee2978fd2deea1680c147ede89eb2f900ea1d562" }, + { + "path": "node_modules/opcore/node_modules/jsonc-parser/CHANGELOG.md", + "sha256": "e8e1c80842a20b52dc69cba3a92ab4822a12625dd242b45300c50164432b231e" + }, + { + "path": "node_modules/opcore/node_modules/jsonc-parser/lib/esm/impl/edit.js", + "sha256": "7e7bf98e740f560026df079056d3b0f5f34628124a8d34e2765fb101dde12911" + }, + { + "path": "node_modules/opcore/node_modules/jsonc-parser/lib/esm/impl/format.js", + "sha256": "e57dee56700a844b18dea11548f9ba42b17bc857bde5bcd39554af06ca95bb1f" + }, + { + "path": "node_modules/opcore/node_modules/jsonc-parser/lib/esm/impl/parser.js", + "sha256": "74fae1bbebf290a04e46c5457ac5d97988146a5503ea35d9f0c4c3768acd5a26" + }, + { + "path": "node_modules/opcore/node_modules/jsonc-parser/lib/esm/impl/scanner.js", + "sha256": "e737e1774f87ca88d85d73e3bafff688b78cac06b81c53b96c54da8b3ca86cd9" + }, + { + "path": "node_modules/opcore/node_modules/jsonc-parser/lib/esm/impl/string-intern.js", + "sha256": "3a0aad3f56c3e0728812c7584568c0ff200bce1ca58cf40e946f01fdb54c5752" + }, + { + "path": "node_modules/opcore/node_modules/jsonc-parser/lib/esm/main.d.ts", + "sha256": "f1d603af05e59e26aae3d9fa7bb0138e744bfbfc9f4793ddeaabe5c85da1d30f" + }, + { + "path": "node_modules/opcore/node_modules/jsonc-parser/lib/esm/main.js", + "sha256": "72ff182105e158a1505ca3ecbf3f30359abb138a3980edaef1db7f4010ffb7dc" + }, + { + "path": "node_modules/opcore/node_modules/jsonc-parser/lib/umd/impl/edit.js", + "sha256": "9ecd115039cf7a478f223e4a9649170c7f5e8fd8016df1b267723b57553f9483" + }, + { + "path": "node_modules/opcore/node_modules/jsonc-parser/lib/umd/impl/format.js", + "sha256": "f9e468e436bf1014a4395b1ac70374edb918739ba90b19e6e6a22ca0214564a9" + }, + { + "path": "node_modules/opcore/node_modules/jsonc-parser/lib/umd/impl/parser.js", + "sha256": "a35db1bfb9631dcba643fa42972e9ebe2a318b4de3cac239d29081577a43d688" + }, + { + "path": "node_modules/opcore/node_modules/jsonc-parser/lib/umd/impl/scanner.js", + "sha256": "581d82c4f1aa84b0605369c01f414fb0164ee462e03daa298cb960076a2a9e82" + }, + { + "path": "node_modules/opcore/node_modules/jsonc-parser/lib/umd/impl/string-intern.js", + "sha256": "e590d27909a7c132382fbf9b8c4668054ab425734c1e0789753063356826c30f" + }, + { + "path": "node_modules/opcore/node_modules/jsonc-parser/lib/umd/main.d.ts", + "sha256": "f1d603af05e59e26aae3d9fa7bb0138e744bfbfc9f4793ddeaabe5c85da1d30f" + }, + { + "path": "node_modules/opcore/node_modules/jsonc-parser/lib/umd/main.js", + "sha256": "6dc132e2e792a9616688ff93d10dd671c1fbcfa3301d76bff3e81193d049337d" + }, + { + "path": "node_modules/opcore/node_modules/jsonc-parser/LICENSE.md", + "sha256": "c3a24c02d678e5f0711623dcf3ab3f243b273c2633f638a1fa8e35fb7c7f8e4d" + }, + { + "path": "node_modules/opcore/node_modules/jsonc-parser/package.json", + "sha256": "a7a9192caaac00d9330592f4fd572908bee037518128a0fc3db96d2cd652e2b5" + }, + { + "path": "node_modules/opcore/node_modules/jsonc-parser/README.md", + "sha256": "dbd5441ae6226fb80684fd71dc400e27c65bfb6e74e78e1c66e9b620bd82cd82" + }, + { + "path": "node_modules/opcore/node_modules/jsonc-parser/SECURITY.md", + "sha256": "830e94b83c3b6f45ceee5609de6249b2125927f95532e68427f09c99eed5e663" + }, { "path": "node_modules/opcore/node_modules/minimatch/dist/commonjs/assert-valid-pattern.d.ts", "sha256": "ff8872a24ed233ed9b80a521c1c5214ee996f4d649f537a1e8d12196d9f83d77" @@ -3629,6 +4321,94 @@ "path": "node_modules/opcore/node_modules/semver/README.md", "sha256": "f1a789dcec285150be24db2ea04dd3175031554fa9834ec92fab83fb5e025a57" }, + { + "path": "node_modules/opcore/node_modules/smol-toml/dist/date.d.ts", + "sha256": "c7ad3531c131f2b5b246b2d208b75c5403475ad155b7661fa2d673104b806791" + }, + { + "path": "node_modules/opcore/node_modules/smol-toml/dist/date.js", + "sha256": "fcc8fcce1a3e8660ac572fb827c20e49d269ad50686c085bb72c3c50771323d5" + }, + { + "path": "node_modules/opcore/node_modules/smol-toml/dist/error.d.ts", + "sha256": "c5ada15420e52bcc8a451a7c4f2daff318e6d0bf5f8c691b25158555a4508f0e" + }, + { + "path": "node_modules/opcore/node_modules/smol-toml/dist/error.js", + "sha256": "28dbfc2871c0707f97cebb5a8be7e343a9cee58250b5814813d748b90d9e0d3b" + }, + { + "path": "node_modules/opcore/node_modules/smol-toml/dist/extract.d.ts", + "sha256": "00c1a5d214514abf62695a8b92ca78b839e64b921f083bf58e1d6faff010df33" + }, + { + "path": "node_modules/opcore/node_modules/smol-toml/dist/extract.js", + "sha256": "74845a2bd9d0c585f7eb14d35ea234c3eb8fb86fd1efb3fb0bebd5c434ceb46e" + }, + { + "path": "node_modules/opcore/node_modules/smol-toml/dist/index.cjs", + "sha256": "db5bf42d36ba6c950c9bd651026be9a771e55a2c82f8b64866de8928d04e1fd1" + }, + { + "path": "node_modules/opcore/node_modules/smol-toml/dist/index.d.ts", + "sha256": "4ef8ce05fffa05c691083bc96f9e65bac35f79e0b4517c426a30d1123b09ba1e" + }, + { + "path": "node_modules/opcore/node_modules/smol-toml/dist/index.js", + "sha256": "ded3ee91f3ce1ef746f6b57459b0c68475658eacb8e88fd18336a5408de009a1" + }, + { + "path": "node_modules/opcore/node_modules/smol-toml/dist/parse.d.ts", + "sha256": "92f6be0d7e2937e9c13fec8bb38b0e1186db1cd654af86a8bbb2f78d40ba8b9d" + }, + { + "path": "node_modules/opcore/node_modules/smol-toml/dist/parse.js", + "sha256": "1c82702f0b4244a4d10ef43806a581a80277ff239d27bfd98ae5662c47c706d5" + }, + { + "path": "node_modules/opcore/node_modules/smol-toml/dist/primitive.d.ts", + "sha256": "e280a713f408497aacba06c0b058e5ceef3418819ff901fa145d065ef5fcc669" + }, + { + "path": "node_modules/opcore/node_modules/smol-toml/dist/primitive.js", + "sha256": "c979c3a950cc1757acf7e5359655cfe4192ae98711e7391bc9c86c8562946bd5" + }, + { + "path": "node_modules/opcore/node_modules/smol-toml/dist/stringify.d.ts", + "sha256": "c9a75924aa75b4e60542ff06437b3abd9679ee9f740245080fbe528f05fb8763" + }, + { + "path": "node_modules/opcore/node_modules/smol-toml/dist/stringify.js", + "sha256": "de9dafe2c331dc0b43da17de75a4f1337ddf262a5b21f7936c35c860917ca581" + }, + { + "path": "node_modules/opcore/node_modules/smol-toml/dist/struct.d.ts", + "sha256": "a7c3aa15e05ec77962b7ac9e93546a3ad616adb862a3e787aa9a78edc5ac278f" + }, + { + "path": "node_modules/opcore/node_modules/smol-toml/dist/struct.js", + "sha256": "690c2ec5c8efba9376d096196df93a387a3fa2e356e2dc9ba0564cf75564ea78" + }, + { + "path": "node_modules/opcore/node_modules/smol-toml/dist/util.d.ts", + "sha256": "8f3f902140fe673679da77cbfbb44dd113d1e4217fd05c4f6e5d9bee8b395b4c" + }, + { + "path": "node_modules/opcore/node_modules/smol-toml/dist/util.js", + "sha256": "9b411141923c467991e486390bd3716eda3e24c563e818302fbeb1f97db0b07f" + }, + { + "path": "node_modules/opcore/node_modules/smol-toml/LICENSE", + "sha256": "fa5659948374d4f555594f47f6da073b40dc503e921aeeece30df4362b3051a5" + }, + { + "path": "node_modules/opcore/node_modules/smol-toml/package.json", + "sha256": "27c8961976d85fc0571c03e40cbb376faef5fbf89ecbd1c17491757ca94ed129" + }, + { + "path": "node_modules/opcore/node_modules/smol-toml/README.md", + "sha256": "ed92f933ed41b95dcd949799c156a142973e296b183683e52a468a76df9f5f63" + }, { "path": "node_modules/opcore/node_modules/tinyglobby/dist/index.cjs", "sha256": "ee408bbc9665cba5800a6b2a0c89dd3df9820baa30d4ff3ba2f2435f16ce9f94" @@ -4239,11 +5019,11 @@ }, { "path": "node_modules/opcore/package.json", - "sha256": "5fab044eaea9e581534d0c30d86882872c24f6efcee65ea20e2654e61a78e052" + "sha256": "edb01642dcba2b610b0a478969c6270cb33c0a80ea7f2f3d3f9d1a0e6f22ba69" }, { "path": "node_modules/opcore/README.md", - "sha256": "0c580644b5814abfb09cbf0664429a088e68b8843c238b50d804f9b558b6c71d" + "sha256": "d063f0014075761e11fd5d3757081daef1262e74a13e20b28cefa73aeb95b2fa" } ] } @@ -4251,7 +5031,7 @@ "descriptor": { "path": "node_modules/opcore/dist/descriptors/opcore.managed-tool.json", "packageName": "opcore", - "checksumSha256": "282c6a8f4f4adf097eacc40d818f32340acc0b6fb4b0e14845e3327525806d12", + "checksumSha256": "8ae0a226d8f320ec3e785d0bf0ec892ed4f51b7ad7161d6cdd9fc0018eb73dd1", "descriptor": { "schemaVersion": 1, "descriptorKind": "aggregate_opcore", @@ -4259,12 +5039,12 @@ "name": "opcore", "releaseLine": "opcore", "packageName": "opcore", - "version": "0.2.0" + "version": "0.2.1" }, "packageIdentity": { "packageName": "opcore", "artifactName": "opcore", - "version": "0.2.0" + "version": "0.2.1" }, "entrypoints": [ { @@ -4588,6 +5368,17 @@ "status", "doctor" ], + "pythonProjectContext": { + "schemaId": "opcore.python.project-context.v1", + "outcomes": [ + "resolved", + "degraded", + "unsupported", + "ambiguous" + ], + "readOnly": true, + "installs": false + }, "writeGate": { "initScopes": [ "repo", @@ -4923,7 +5714,7 @@ "artifactRef": "graph-core-binary-darwin-arm64", "required": true, "packageFile": true, - "value": "c222610c492e628fa48ff50be22310e5bf8ebcd62bffbde1903d1fa91c45f6cb" + "value": "72acf10de0ece619c3896943addfb0cc1b09dab22000b48a653b033224217bc8" }, { "id": "graph-core-binary-sha256-darwin-x64", @@ -4943,7 +5734,7 @@ "artifactRef": "graph-core-binary-linux-x64", "required": true, "packageFile": true, - "value": "70cb6d213d74594f337a67522c879b77767872dc4601063686f1abe70886127b" + "value": "c3a771435a7a8172a9e5f0bb20b0d111453efc70901e95308094664f77e7bdf1" } ] }, @@ -4976,7 +5767,7 @@ "status": "ok", "exitCode": 0, "binPath": "node_modules/.bin/opcore", - "stdoutSha256": "41337f7b3572012682479bfb08be724d5d76767b5996e9de9c15e5225f3f843d", + "stdoutSha256": "36113707a91309f5c999cd90122e3da19faa21c67ca2112acce48e2f6be0c46c", "stderrSha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "assertion": "opcore scan wrote read-only report artifacts" }, @@ -4994,7 +5785,7 @@ "status": "ok", "exitCode": 0, "binPath": "node_modules/.bin/opcore", - "stdoutSha256": "5c943f364e19196b05dc2856fd7d5874ba39276d95461c69fa2b7e7538f66e1e", + "stdoutSha256": "423b6b401ac37b261a29e4d87ab163b372e2b4b52b3a4edb4944d931e3a694e8", "stderrSha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "assertion": "opcore status returned repoState" }, @@ -5026,7 +5817,7 @@ "status": "ok", "exitCode": 0, "binPath": "node_modules/.bin/opcore", - "stdoutSha256": "73c668aeda69a8cfb4a4295561a5ae59926899a1c6ceb2cff0caf00d714c6ba2", + "stdoutSha256": "f5131c4730d2d9b7d81fe551589c6d13c21d761467f5a8ff155d762f6901a96c", "stderrSha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "assertion": "opcore check changed defaulted base to HEAD" }, @@ -5044,7 +5835,7 @@ "status": "ok", "exitCode": 0, "binPath": "node_modules/.bin/opcore", - "stdoutSha256": "41b33dd5d67a2897c93ea297d598b4a250481eab0183297b36c945436ba3a5ef", + "stdoutSha256": "c4a21817782a216e4e03ca17b8209e9b154edb1af4a0f181ea18fbe5d5eaf5f2", "stderrSha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "assertion": "opcore measure returned read-only report deltas" }, @@ -5062,7 +5853,7 @@ "status": "ok", "exitCode": 0, "binPath": "node_modules/.bin/opcore", - "stdoutSha256": "9e813807167750470d6a4dc2e38d35517b7ef7d3ae365909261f1c9069b262ae", + "stdoutSha256": "bfa8573238f3fb5eca7b4bca7cf7ed0c0de2f7f451f7e2d6e43e4021b071c06e", "stderrSha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "assertion": "opcore try generated local sample repos without publishing" }, @@ -5080,7 +5871,7 @@ "status": "ok", "exitCode": 0, "binPath": "node_modules/.bin/opcore", - "stdoutSha256": "078702482929911cbc3dfb2ce8c65b4e005627b8f289dd694d841293dfc504c3", + "stdoutSha256": "6e3f62a9c661ea71fa4da1491ca21a38fe00b342a420f0b04e651990afe2878a", "stderrSha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "assertion": "runtime status reports validation readiness" }, @@ -5098,7 +5889,7 @@ "status": "ok", "exitCode": 0, "binPath": "node_modules/.bin/opcore", - "stdoutSha256": "ec352a7acf1ab30c6591c769820c6fd12c0afe3e2a5947ad6778f0748af793ae", + "stdoutSha256": "1145413cee682702c85758a9c6b9f6066c49065e9e67b49b68240b06e6170197", "stderrSha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "assertion": "runtime doctor reports validation readiness" }, @@ -5118,7 +5909,7 @@ "status": "ok", "exitCode": 0, "binPath": "node_modules/.bin/opcore", - "stdoutSha256": "03369a97227407b1594f64b1af9755be7508736059d7b4bfbe0b44b6d3ed0c90", + "stdoutSha256": "b22a7a73954479eaaf805bbb8d340f95dfda68553e613f73f6d510333885896a", "stderrSha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "assertion": "graph build completed with native artifact" }, @@ -5138,7 +5929,7 @@ "status": "ok", "exitCode": 0, "binPath": "node_modules/.bin/opcore", - "stdoutSha256": "354a3832e752a4719e4f4db5b7f25231edeee864d7dea642a0b3273c5aa5d748", + "stdoutSha256": "324e6d40a039c2aeeac7908ba5abdc46ddd31c849d8aba6a651b885b9f976368", "stderrSha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "assertion": "graph status available after build" }, @@ -5158,7 +5949,7 @@ "status": "ok", "exitCode": 0, "binPath": "node_modules/.bin/opcore", - "stdoutSha256": "96da979d76d0a0c103a64d101d1c81e604657a4b998c660553ae08f5ab74c7e9", + "stdoutSha256": "f1ce978b3c92240ac499db40a704a6a57db662a0dc8e577696c265fa9a1848cb", "stderrSha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "assertion": "graph query returned facts" }, @@ -5182,7 +5973,7 @@ "status": "ok", "exitCode": 0, "binPath": "node_modules/.bin/opcore", - "stdoutSha256": "44a9bf55a39121f6a0288b58da211855ff0bdf46bc5effffeadb9c73c5d75335", + "stdoutSha256": "70f01ffe2aa7ecfcfe2f23e92c9c0b662c2b6e4dbac1f81b579b3852c8950171", "stderrSha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "assertion": "graph impact returned file impact" }, @@ -5206,7 +5997,7 @@ "status": "ok", "exitCode": 0, "binPath": "node_modules/.bin/opcore", - "stdoutSha256": "4490dfb1c7106f04d22c0aa377fc0065c6d44013c4cec12116951a8fe0d1283e", + "stdoutSha256": "1448277aafc98d14b7e01279b841143ba0f12fbfd60eb81766e7f0479e01e619", "stderrSha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "assertion": "graph review-context returned related facts" }, @@ -5230,7 +6021,7 @@ "status": "ok", "exitCode": 0, "binPath": "node_modules/.bin/opcore", - "stdoutSha256": "2da8572d979e01a2b298fa009ecbd1fa26565d94804701a2b3a8badca0142427", + "stdoutSha256": "d41bb844d1cd4569c7dcd96dbdac4608f468cc58e82a12b1114c6f482b4856c0", "stderrSha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "assertion": "graph detect-changes returned typed change data" }, @@ -5256,7 +6047,7 @@ "status": "ok", "exitCode": 0, "binPath": "node_modules/.bin/opcore", - "stdoutSha256": "44d5187c4d9893e8d3e824e383bf5e10b3589253fac92584a2cd9a17410dfff6", + "stdoutSha256": "2762b69f03689f5b98c1534dd7365bcac5e0f0057299da6d1d71fd2f0f2e7681", "stderrSha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "assertion": "graph search returned ranked results" }, @@ -5276,7 +6067,7 @@ "status": "ok", "exitCode": 0, "binPath": "node_modules/.bin/opcore", - "stdoutSha256": "c030ce3c628845d73a71ffb6c9a9fdf4197480d8fa54bcbe412676d9edd41981", + "stdoutSha256": "8e1b81680031ec996c1911c294f1717201865971eb19aa2f13015f2567a0a6a2", "stderrSha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "assertion": "graph serve status route is ready" }, @@ -5302,7 +6093,7 @@ "status": "ok", "exitCode": 0, "binPath": "node_modules/.bin/opcore", - "stdoutSha256": "80d48d50eb0f0e7833fee98436a458c3fd35cd5665386fcc4d5ca0ec128581a9", + "stdoutSha256": "57a75201b8e0c6e880d685f4acff2bc4c30e1eecb383d7078baf7b2856e555a9", "stderrSha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "assertion": "inspect symbols returned graph symbols" }, @@ -5324,7 +6115,7 @@ "status": "ok", "exitCode": 0, "binPath": "node_modules/.bin/opcore", - "stdoutSha256": "338a912f2a2c49d79cbbca2a03aa8e391c6ecc8a9175512e6c336ac7c6bb6d2f", + "stdoutSha256": "07b9447821670d4cda06f1f9b3e035c51ce4a3a21f60ce97eaf2e851f7b9f478", "stderrSha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "assertion": "inspect definition returned a symbol" }, @@ -5350,7 +6141,7 @@ "status": "ok", "exitCode": 0, "binPath": "node_modules/.bin/opcore", - "stdoutSha256": "b689dbf929aade444c497ca817c5f5daceb4e579f1bb9ee0061e50b8d954d69e", + "stdoutSha256": "9920c09424d083fd9811fb017a4f76df18ec1af1e039f87bc5d82b66d3f3cca8", "stderrSha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "assertion": "inspect references returned callers" }, @@ -5372,7 +6163,7 @@ "status": "ok", "exitCode": 0, "binPath": "node_modules/.bin/opcore", - "stdoutSha256": "813e7be48307400f8396a11031506b00f5eae559de13a4d8e1bc00fdd00cc4d5", + "stdoutSha256": "04f90b01005ea95d988cfa410408eb0374213a38539132c5345352625fa7c0cc", "stderrSha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "assertion": "inspect signature returned read-only language-service signatures" }, @@ -5394,7 +6185,7 @@ "status": "ok", "exitCode": 0, "binPath": "node_modules/.bin/opcore", - "stdoutSha256": "099522e9936cf4c9b969ae47688ce14f51600093759b2b414d8fc6d40f421416", + "stdoutSha256": "fbe1a9195487b304c47693aba906639989ed2a642c0d9dcb0a2339c590ce287f", "stderrSha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "assertion": "inspect implementations returned implementation evidence" }, @@ -5420,7 +6211,7 @@ "status": "ok", "exitCode": 0, "binPath": "node_modules/.bin/opcore", - "stdoutSha256": "8e4db2bf4f9de6d19e583ca4e4853763666f3f648e8ae9e5bdcb0677c7b70ff4", + "stdoutSha256": "9ff50bf0cc2eb9c9ddaa20e3018a4c2b87ae09583b87db535738dbb16971bcee", "stderrSha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "assertion": "inspect search returned graph search results" }, @@ -5452,7 +6243,7 @@ "status": "ok", "exitCode": 0, "binPath": "node_modules/.bin/opcore", - "stdoutSha256": "09d9f9386e563650600a9e30977f329842a1e7752b45f09466a2ce2e363072cf", + "stdoutSha256": "6dd2f83ee9d36f94410e1260b05bf0064657d75e46711d44834c9a598cd24e8f", "stderrSha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "assertion": "safe edit preview produced a plan without writing" }, @@ -5486,7 +6277,7 @@ "status": "ok", "exitCode": 0, "binPath": "node_modules/.bin/opcore", - "stdoutSha256": "699df5dd4b9647efd72a3221138e86102ec10ca24dc1429a6a0c8b9414529009", + "stdoutSha256": "c408d7cb1c30e5fb770edc0e3731e5effd723f84fd147c85e9f4d8f2c7c97255", "stderrSha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "assertion": "safe edit apply wrote after validation" }, @@ -5520,7 +6311,7 @@ "status": "error", "exitCode": 1, "binPath": "node_modules/.bin/opcore", - "stdoutSha256": "88e7b5fa71fec820782e3bbfe503b4a03b9af498f99f54718fddf633fa04baf9", + "stdoutSha256": "74011e1a54fc7a299458e645697258a3ab77f175fe1bbacd0b053287eec246f4", "stderrSha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "assertion": "validation-refused edit left file unchanged" }, @@ -5546,7 +6337,7 @@ "status": "ok", "exitCode": 0, "binPath": "node_modules/.bin/opcore", - "stdoutSha256": "b260022a56253abac4231d175100dfee00c236aef10314d37d3836a643f4d148", + "stdoutSha256": "9ac0f91133a7bf56a2c999ddf84a7e2c69ac18694e7388f4cef7fafd97113062", "stderrSha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "assertion": "check files passed syntax and type checks" }, @@ -5570,7 +6361,7 @@ "status": "ok", "exitCode": 0, "binPath": "node_modules/.bin/opcore", - "stdoutSha256": "285148d8623839d77b3682f464a18226b5afb5aed7dadcedeaa13732ddd6c62d", + "stdoutSha256": "41f83a81b6fd39cd5e9ed539e232df040508fe8f8595d4cd3b42863711111620", "stderrSha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "assertion": "validate request passed" }, @@ -5598,7 +6389,7 @@ "status": "ok", "exitCode": 0, "binPath": "node_modules/.bin/opcore", - "stdoutSha256": "1a0c5ee938ca984b700e9fc46945c44a4ad03d9b71b478a5d3d60ae207ae1ff4", + "stdoutSha256": "d7a5f71ea1b84d12bd34a88586b2b3565b88cdcfa4f3574d6f3ff890182ca1b1", "stderrSha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "assertion": "pre-write pass receipt was ok" }, @@ -5626,7 +6417,7 @@ "status": "error", "exitCode": 1, "binPath": "node_modules/.bin/opcore", - "stdoutSha256": "ca60fc72d02d3a8face821e34cae5b5e6fc0578eb823d2444199aa0499e60786", + "stdoutSha256": "0a8bb14c58e704b6145597c78a67a5d0a1a85e160b19866051fb0f1f33b65e82", "stderrSha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "assertion": "pre-write failure receipt failed closed" } @@ -5648,7 +6439,7 @@ "status": "ok", "exitCode": 0, "binPath": "node_modules/.bin/opcore", - "stdoutSha256": "b0b8119c4f07a6558e7b0462b0f4ea392c4de72ff3f48ede1adf1705a8586cd1", + "stdoutSha256": "35c4f156114926183039c3486e2aea9d62c6dd22ff30103dd88bd78dd91fa292", "stderrSha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "assertion": "Rust graph build completed with installed native artifact" }, @@ -5668,7 +6459,7 @@ "status": "ok", "exitCode": 0, "binPath": "node_modules/.bin/opcore", - "stdoutSha256": "7c96f49247b5ffe4f47a0b610d3984c940ee961ca3441759b8fdd65cd62893c1", + "stdoutSha256": "870318ab1d0a49d120b45f2d061b52b75a21761b8eb1c91408c66b769f085296", "stderrSha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "assertion": "Rust graph status available after build" }, @@ -5688,7 +6479,7 @@ "status": "ok", "exitCode": 0, "binPath": "node_modules/.bin/opcore", - "stdoutSha256": "e02b850c019a72bda14d61c760aa7f8dc5a778f8e644d476b6106878475ac7d8", + "stdoutSha256": "b427e613f1c30404883435b27864b1f370a319ff9ee836a92ef7414e1842bbc6", "stderrSha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "assertion": "Rust graph query returned Rust facts" }, @@ -5712,7 +6503,7 @@ "status": "ok", "exitCode": 0, "binPath": "node_modules/.bin/opcore", - "stdoutSha256": "5438fdd3ce137bec38fa9e9a06a808d529562bed8e1abba1826e6c24ba69f363", + "stdoutSha256": "cdd0f55fb1fee6a2fbba25a66712af47e17b3f984d20651c5c52e876e04cf76f", "stderrSha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "assertion": "Rust graph impact returned related Rust facts" }, @@ -5736,7 +6527,7 @@ "status": "ok", "exitCode": 0, "binPath": "node_modules/.bin/opcore", - "stdoutSha256": "24942b934479a140115ebd872c40a72fbaa46ab7cb029d999738986aee1de863", + "stdoutSha256": "671aca39436cb322d14cdcd7c332e2bf4e4aa8d206b282b8e17a23116a3e4fd7", "stderrSha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "assertion": "Rust graph review-context returned related Rust facts" }, @@ -5760,7 +6551,7 @@ "status": "ok", "exitCode": 0, "binPath": "node_modules/.bin/opcore", - "stdoutSha256": "9bf6903ecaec198c6a968db2de8c574f6c86f3155ea664456d5d42631be07f13", + "stdoutSha256": "0b14409a5abd2020928b38ec3c70f4b287a1bd5dea2856605c5e604973c41deb", "stderrSha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "assertion": "Rust graph detect-changes returned typed Rust change data" }, @@ -5786,7 +6577,7 @@ "status": "ok", "exitCode": 0, "binPath": "node_modules/.bin/opcore", - "stdoutSha256": "39af3aec9fb95a7a718786ef35cb5e81db45d8fb1c1682bb99164fb1ab82c74c", + "stdoutSha256": "9c1145f77611b954bc8e0cfc0d68551e2a211486e78e90d3cb301ddb60860b48", "stderrSha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "assertion": "Rust graph search returned ranked Rust symbols" } @@ -5811,7 +6602,7 @@ "status": "ok", "exitCode": 0, "binPath": "node_modules/.bin/opcore", - "stdoutSha256": "d9ec83e3a0b23dbe9b1bb9403d66cc25f82927693e02b0fa9b3cef88c84057e3", + "stdoutSha256": "1cbeccedf0104e8677d2d48708d488f422ef4e9d82407659ce81bc0b578cd860", "stderrSha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "assertion": "opcore scan returned Python repoState and validation evidence from installed artifacts" }, @@ -5833,7 +6624,7 @@ "status": "ok", "exitCode": 0, "binPath": "node_modules/.bin/opcore", - "stdoutSha256": "96b69e3b4e73e3ef0f45d9906320ba33337bb42afaaed497ea2680f7955ff83d", + "stdoutSha256": "60d6826a0650af05780b4f1b7e55317b8090cc441ba3417186b101fa20f63085", "stderrSha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "assertion": "opcore status returned Python repoState from installed artifacts" }, @@ -5869,7 +6660,7 @@ "status": "ok", "exitCode": 0, "binPath": "node_modules/.bin/opcore", - "stdoutSha256": "f59698b35bd10d7196d4ff1ee01abd4466a4bd47d7757096ad4a6972774637d7", + "stdoutSha256": "7c1b37f8d613cc3bef3911fcdd881fb29b177de77516890953d68788b56c580b", "stderrSha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "assertion": "opcore check changed validated Python syntax and hygiene from installed artifacts" }, @@ -5890,7 +6681,7 @@ "status": "ok", "exitCode": 0, "binPath": "node_modules/.bin/opcore", - "stdoutSha256": "2a09527b9969cfa92499f40390d9664fcb4ec55178729f5e456be25cd55c0ffc", + "stdoutSha256": "b8e91f03cbe6fe815fbb6246dec11ed7738630ef91d83caae60fba3e6a92e63a", "stderrSha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "assertion": "opcore measure returned Python metric deltas from installed artifacts" }, @@ -5913,7 +6704,7 @@ "status": "ok", "exitCode": 0, "binPath": "node_modules/.bin/opcore", - "stdoutSha256": "1e50cdfcc035362c299c6d9ef72a0cedbfb9ed3f29cfa005827242594c17e3a2", + "stdoutSha256": "a55bd3df8241031cd60a4728aa37d5acb0a65f0b1a88f8edeb823c85dbc6d0e1", "stderrSha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "assertion": "Python graph build completed with installed native artifact" }, @@ -5936,7 +6727,7 @@ "status": "ok", "exitCode": 0, "binPath": "node_modules/.bin/opcore", - "stdoutSha256": "3531f969e335c1ef0bdd99ca366947646af90efdd07656ee7894993c24172b49", + "stdoutSha256": "3a249ceca524bf44091ce805fd452861139d0b70048fb20e27c422f73c48801f", "stderrSha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "assertion": "Python graph status available after installed-artifact build" }, @@ -5961,7 +6752,7 @@ "status": "ok", "exitCode": 0, "binPath": "node_modules/.bin/opcore", - "stdoutSha256": "37924cb204d4760c1a0d6b3a951c5ef5f008841176c4d4e5712c2e35a99ba3e3", + "stdoutSha256": "9a79b95d07b8f6e931e04c27ce154e2aac787044ef49d53fa28bc0e1ed75974a", "stderrSha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "assertion": "Python graph query returned installed-artifact Python facts" }, @@ -5991,7 +6782,7 @@ "status": "ok", "exitCode": 0, "binPath": "node_modules/.bin/opcore", - "stdoutSha256": "a17f7f2c6fb164d7b5a2da2fbcf7bf07386ad53176be78da3133c97c115f9606", + "stdoutSha256": "bd422996a6afe5fa4eb1da11a715457e950d272caf83bc2829ba9211ecd23bef", "stderrSha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "assertion": "Python graph search returned ranked installed-artifact Python symbols" } @@ -6115,7 +6906,7 @@ ], "oldToolReplacementClaimed": false, "forbiddenMarkerScan": { - "scannedTextCount": 1719, + "scannedTextCount": 2005, "findingCount": 0, "markersBlocked": [ "private-runtime", @@ -6130,12 +6921,12 @@ { "issue": "#17", "path": "docs/release/graph-release-receipt.json", - "checksumSha256": "016134bf577c850906fcf7d28307a501024b01679d2ef63ae451655a368e5949" + "checksumSha256": "fee81c57608f2c63ddbfc23d53490760d03a5df49fe69e3849860db0599e852f" }, { "issue": "#29", "path": "docs/release/release-receipt.json", - "checksumSha256": "9d22e13b6150e3cf433c56b4b86848d8b5d47a3839a5e478130fab2b477bcdd0" + "checksumSha256": "de47ce42ec42b04508b016d74f09cc7dc14eb08ee37d918f226490430eed4ff1" }, { "issue": "#58", diff --git a/docs/release/cutover-receipt.summary.md b/docs/release/cutover-receipt.summary.md index b8bf31c..400f54c 100644 --- a/docs/release/cutover-receipt.summary.md +++ b/docs/release/cutover-receipt.summary.md @@ -3,7 +3,7 @@ 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: c8d33f671a1f6801a6b46362c4f2bd1f152dfbd13e161ef83c007b194723adf8 +Machine receipt SHA-256: c72be0d79199505770c631b08201be09f8602e239e80c1ffa974e7f24d1cf32b Installed packages: 1 Command receipts: 28 diff --git a/docs/release/graph-release-handoff.md b/docs/release/graph-release-handoff.md index 83c01bc..1e16cde 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: 016134bf577c850906fcf7d28307a501024b01679d2ef63ae451655a368e5949 +Full receipt SHA-256: fee81c57608f2c63ddbfc23d53490760d03a5df49fe69e3849860db0599e852f | Issue | Checksummed Receipt Path | SHA-256 | |-------|--------------------------|---------| -| #7 | docs/release/graph-release-receipt.payload.json | c0f51e13590149072812e3a606200a9788e07f130224064fe111c2914e1c6473 | -| #28 | docs/release/graph-release-receipt.payload.json | c0f51e13590149072812e3a606200a9788e07f130224064fe111c2914e1c6473 | -| #29 | docs/release/graph-release-receipt.payload.json | c0f51e13590149072812e3a606200a9788e07f130224064fe111c2914e1c6473 | +| #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 | ## Parent #4 Graph Scope diff --git a/docs/release/graph-release-receipt.json b/docs/release/graph-release-receipt.json index c80b57f..efc66de 100644 --- a/docs/release/graph-release-receipt.json +++ b/docs/release/graph-release-receipt.json @@ -2,28 +2,28 @@ "schemaVersion": 1, "issue": "#17", "origin": "covibes-authored-synthetic", - "generatedAt": "2026-07-09T19:53:26.833Z", - "commitSha": "1b782d7c86dae055a0225e0905101beafaaa2fce", + "generatedAt": "2026-07-18T06:29:58.350Z", + "commitSha": "2d4e920e0b08a989953486b261327ed7496c328b", "graphPackageVersions": [ { "packageName": "@the-open-engine/opcore-graph", - "version": "0.2.0" + "version": "0.2.1" }, { "packageName": "@the-open-engine/opcore-contracts", - "version": "0.2.0" + "version": "0.2.1" }, { "packageName": "@the-open-engine/opcore-graph-core-darwin-arm64", - "version": "0.2.0" + "version": "0.2.1" }, { "packageName": "@the-open-engine/opcore-graph-core-darwin-x64", - "version": "0.2.0" + "version": "0.2.1" }, { "packageName": "@the-open-engine/opcore-graph-core-linux-x64", - "version": "0.2.0" + "version": "0.2.1" } ], "graphProviderSchemaVersion": 1, @@ -59,7 +59,7 @@ "status": "passed", "exitCode": 0, "fixture": "packages/fixtures/source-extraction/wave1", - "durationMs": 672 + "durationMs": 997 }, { "id": "opcore-graph-update", @@ -76,7 +76,7 @@ "status": "passed", "exitCode": 0, "fixture": "packages/fixtures/source-extraction/wave1", - "durationMs": 340 + "durationMs": 681 }, { "id": "opcore-graph-watch", @@ -93,7 +93,7 @@ "status": "passed", "exitCode": 0, "fixture": "packages/fixtures/source-extraction/wave1", - "durationMs": 321 + "durationMs": 662 }, { "id": "opcore-graph-status", @@ -110,7 +110,7 @@ "status": "passed", "exitCode": 0, "fixture": "packages/fixtures/source-extraction/wave1", - "durationMs": 316 + "durationMs": 743 }, { "id": "opcore-graph-query", @@ -127,7 +127,7 @@ "status": "passed", "exitCode": 0, "fixture": "packages/fixtures/source-extraction/wave1", - "durationMs": 324 + "durationMs": 691 }, { "id": "opcore-graph-impact", @@ -144,7 +144,7 @@ "status": "passed", "exitCode": 0, "fixture": "packages/fixtures/source-extraction/wave1", - "durationMs": 322 + "durationMs": 734 }, { "id": "opcore-graph-search", @@ -161,7 +161,7 @@ "status": "passed", "exitCode": 0, "fixture": "packages/fixtures/source-extraction/wave1", - "durationMs": 325 + "durationMs": 734 }, { "id": "opcore-graph-serve", @@ -178,7 +178,7 @@ "status": "passed", "exitCode": 0, "fixture": "packages/fixtures/source-extraction/wave1", - "durationMs": 322 + "durationMs": 745 } ], "rustCommandCoverage": [ @@ -197,7 +197,7 @@ "status": "passed", "exitCode": 0, "fixture": "packages/fixtures/source-extraction/rust-only", - "durationMs": 332 + "durationMs": 743 }, { "id": "opcore-graph-rust-update", @@ -214,7 +214,7 @@ "status": "passed", "exitCode": 0, "fixture": "packages/fixtures/source-extraction/rust-only", - "durationMs": 329 + "durationMs": 723 }, { "id": "opcore-graph-rust-watch", @@ -231,7 +231,7 @@ "status": "passed", "exitCode": 0, "fixture": "packages/fixtures/source-extraction/rust-only", - "durationMs": 323 + "durationMs": 733 }, { "id": "opcore-graph-rust-status", @@ -248,7 +248,7 @@ "status": "passed", "exitCode": 0, "fixture": "packages/fixtures/source-extraction/rust-only", - "durationMs": 317 + "durationMs": 735 }, { "id": "opcore-graph-rust-query", @@ -265,7 +265,7 @@ "status": "passed", "exitCode": 0, "fixture": "packages/fixtures/source-extraction/rust-only", - "durationMs": 317 + "durationMs": 784 }, { "id": "opcore-graph-rust-impact", @@ -282,7 +282,7 @@ "status": "passed", "exitCode": 0, "fixture": "packages/fixtures/source-extraction/rust-only", - "durationMs": 324 + "durationMs": 691 }, { "id": "opcore-graph-rust-search", @@ -299,7 +299,7 @@ "status": "passed", "exitCode": 0, "fixture": "packages/fixtures/source-extraction/rust-only", - "durationMs": 326 + "durationMs": 724 }, { "id": "opcore-graph-rust-serve", @@ -316,7 +316,7 @@ "status": "passed", "exitCode": 0, "fixture": "packages/fixtures/source-extraction/rust-only", - "durationMs": 315 + "durationMs": 719 } ], "directSqliteQueries": [ @@ -396,7 +396,7 @@ "benchmarks": [ { "metric": "install_setup_ms", - "value": 3, + "value": 6, "unit": "ms", "baselineIssue": "#19", "baselineReceipt": "packages/fixtures/graph-reference-evidence/baseline-receipts.json", @@ -404,7 +404,7 @@ }, { "metric": "cold_build_ms", - "value": 672, + "value": 997, "unit": "ms", "baselineIssue": "#19", "baselineReceipt": "packages/fixtures/graph-reference-evidence/baseline-receipts.json", @@ -412,7 +412,7 @@ }, { "metric": "incremental_update_ms", - "value": 340, + "value": 681, "unit": "ms", "baselineIssue": "#19", "baselineReceipt": "packages/fixtures/graph-reference-evidence/baseline-receipts.json", @@ -420,7 +420,7 @@ }, { "metric": "impact_cold_ms", - "value": 322, + "value": 734, "unit": "ms", "baselineIssue": "#19", "baselineReceipt": "packages/fixtures/graph-reference-evidence/baseline-receipts.json", @@ -428,7 +428,7 @@ }, { "metric": "impact_hot_ms", - "value": 321, + "value": 756, "unit": "ms", "baselineIssue": "#19", "baselineReceipt": "packages/fixtures/graph-reference-evidence/baseline-receipts.json", @@ -436,7 +436,7 @@ }, { "metric": "search_ms", - "value": 325, + "value": 734, "unit": "ms", "baselineIssue": "#19", "baselineReceipt": "packages/fixtures/graph-reference-evidence/baseline-receipts.json", @@ -444,7 +444,7 @@ }, { "metric": "daemon_startup_ms", - "value": 542, + "value": 1130, "unit": "ms", "baselineIssue": "#19", "baselineReceipt": "packages/fixtures/graph-reference-evidence/baseline-receipts.json", @@ -452,7 +452,7 @@ }, { "metric": "daemon_query_ms", - "value": 542, + "value": 1130, "unit": "ms", "baselineIssue": "#19", "baselineReceipt": "packages/fixtures/graph-reference-evidence/baseline-receipts.json", @@ -477,19 +477,25 @@ ], "packageInspection": { "packageName": "@the-open-engine/opcore-graph", - "tarballName": "the-open-engine-opcore-graph-0.2.0.tgz", - "fileCount": 17, + "tarballName": "the-open-engine-opcore-graph-0.2.1.tgz", + "fileCount": 23, "files": [ "README.md", "dist/artifact.d.ts", "dist/artifact.d.ts.map", "dist/artifact.js", + "dist/ephemeral-snapshot.d.ts", + "dist/ephemeral-snapshot.d.ts.map", + "dist/ephemeral-snapshot.js", "dist/index.d.ts", "dist/index.d.ts.map", "dist/index.js", "dist/native-targets.d.ts", "dist/native-targets.d.ts.map", "dist/native-targets.js", + "dist/python-import-analysis.d.ts", + "dist/python-import-analysis.d.ts.map", + "dist/python-import-analysis.js", "dist/serve.d.ts", "dist/serve.d.ts.map", "dist/serve.js", @@ -523,19 +529,19 @@ "targetPlatform": "darwin-arm64", "metadata": { "artifactName": "opcore-graph-core", - "artifactVersion": "0.2.0", + "artifactVersion": "0.2.1", "targetPlatform": "darwin-arm64", "binaryPath": "opcore-graph-core", "checksumPath": "opcore-graph-core.sha256", - "checksumSha256": "c222610c492e628fa48ff50be22310e5bf8ebcd62bffbde1903d1fa91c45f6cb", + "checksumSha256": "72acf10de0ece619c3896943addfb0cc1b09dab22000b48a653b033224217bc8", "buildProfile": "release" }, "binaryPath": "opcore-graph-core", "checksumPath": "opcore-graph-core.sha256", "metadataPath": "metadata.json", - "binarySha256": "c222610c492e628fa48ff50be22310e5bf8ebcd62bffbde1903d1fa91c45f6cb", - "checksumFileSha256": "794844f23f2c3ddd415b3a58d07b41a6a1c4ad643be0b6ae2a6585d0e4e057fe", - "metadataSha256": "4f2817e526b57f3036a7595cbc2a8ad94836f32a87f315f866080b0d9855c266", + "binarySha256": "72acf10de0ece619c3896943addfb0cc1b09dab22000b48a653b033224217bc8", + "checksumFileSha256": "8c604dbcec287dd6a46a5949872bcc69ecb7d0274377da97c43a89b8f0907fca", + "metadataSha256": "9c3fdfc4b574e6cb2bc6786a5c720e6fb1f1cbe0895906ab0713d614439ec4fa", "packageFiles": [ "package.json", "README.md", @@ -549,7 +555,7 @@ "targetPlatform": "darwin-x64", "metadata": { "artifactName": "opcore-graph-core", - "artifactVersion": "0.2.0", + "artifactVersion": "0.2.1", "targetPlatform": "darwin-x64", "binaryPath": "opcore-graph-core", "checksumPath": "opcore-graph-core.sha256", @@ -561,7 +567,7 @@ "metadataPath": "metadata.json", "binarySha256": "1b72afa74d48b087c967cb01b39630ed3fa58b5a2895037a778eb5612ef73059", "checksumFileSha256": "78e1e2efd92e4c3f184b19263b8e8c161fda919c180638906808afdf3b2d312d", - "metadataSha256": "1892fae547da3aca3feb60bfba1be52b9dcfd4f5dfc9e4da0cb923cfd2a4792a", + "metadataSha256": "b0995ddb2769a57cd3a07510efe90ed1788e5f55f48f10b0e46ac3c81f53e175", "packageFiles": [ "package.json", "README.md", @@ -575,19 +581,19 @@ "targetPlatform": "linux-x64", "metadata": { "artifactName": "opcore-graph-core", - "artifactVersion": "0.2.0", + "artifactVersion": "0.2.1", "targetPlatform": "linux-x64", "binaryPath": "opcore-graph-core", "checksumPath": "opcore-graph-core.sha256", - "checksumSha256": "70cb6d213d74594f337a67522c879b77767872dc4601063686f1abe70886127b", + "checksumSha256": "c3a771435a7a8172a9e5f0bb20b0d111453efc70901e95308094664f77e7bdf1", "buildProfile": "release" }, "binaryPath": "opcore-graph-core", "checksumPath": "opcore-graph-core.sha256", "metadataPath": "metadata.json", - "binarySha256": "70cb6d213d74594f337a67522c879b77767872dc4601063686f1abe70886127b", - "checksumFileSha256": "c2910f2077d0e824fa838a3565114bab7c63910dcee51221e64d37c0aee20b19", - "metadataSha256": "d851e010080be2505824d89a49cbe2b1bd6709854d380bd3f39e8c58c2114d90", + "binarySha256": "c3a771435a7a8172a9e5f0bb20b0d111453efc70901e95308094664f77e7bdf1", + "checksumFileSha256": "4f1fe963a88779474c8c8c0ab20b84dc16ed604ad7573a7dadb17795c8ef37c9", + "metadataSha256": "2d6abe4b7651c879a1db63932f73c2c6af83bcf0db44f7d87bbde651d129f9e0", "packageFiles": [ "package.json", "README.md", @@ -645,11 +651,11 @@ ], "graphArtifact": { "artifactName": "opcore-graph-core", - "artifactVersion": "0.2.0", + "artifactVersion": "0.2.1", "targetPlatform": "darwin-arm64", "binaryPath": "opcore-graph-core", "checksumPath": "opcore-graph-core.sha256", - "checksumSha256": "c222610c492e628fa48ff50be22310e5bf8ebcd62bffbde1903d1fa91c45f6cb", + "checksumSha256": "72acf10de0ece619c3896943addfb0cc1b09dab22000b48a653b033224217bc8", "buildProfile": "release" }, "optionalSurfaces": [ @@ -682,19 +688,19 @@ { "issue": "#7", "receiptPath": "docs/release/graph-release-receipt.payload.json", - "checksumSha256": "c0f51e13590149072812e3a606200a9788e07f130224064fe111c2914e1c6473", + "checksumSha256": "2ae48d67a1a908c5418f274c8d6d07843c131c9fc7f11ee1d0bd1f89fbcc628b", "rollbackNote": "Keep ACE wrappers on current external tools if receipt regresses." }, { "issue": "#28", "receiptPath": "docs/release/graph-release-receipt.payload.json", - "checksumSha256": "c0f51e13590149072812e3a606200a9788e07f130224064fe111c2914e1c6473", + "checksumSha256": "2ae48d67a1a908c5418f274c8d6d07843c131c9fc7f11ee1d0bd1f89fbcc628b", "rollbackNote": "Keep ACE wrappers on current external tools if receipt regresses." }, { "issue": "#29", "receiptPath": "docs/release/graph-release-receipt.payload.json", - "checksumSha256": "c0f51e13590149072812e3a606200a9788e07f130224064fe111c2914e1c6473", + "checksumSha256": "2ae48d67a1a908c5418f274c8d6d07843c131c9fc7f11ee1d0bd1f89fbcc628b", "rollbackNote": "Keep ACE wrappers on current external tools if receipt regresses." } ] diff --git a/docs/release/graph-release-receipt.payload.json b/docs/release/graph-release-receipt.payload.json index 719f57a..92f69ab 100644 --- a/docs/release/graph-release-receipt.payload.json +++ b/docs/release/graph-release-receipt.payload.json @@ -2,28 +2,28 @@ "schemaVersion": 1, "issue": "#17", "origin": "covibes-authored-synthetic", - "generatedAt": "2026-07-09T19:53:26.833Z", - "commitSha": "1b782d7c86dae055a0225e0905101beafaaa2fce", + "generatedAt": "2026-07-18T06:29:58.350Z", + "commitSha": "2d4e920e0b08a989953486b261327ed7496c328b", "graphPackageVersions": [ { "packageName": "@the-open-engine/opcore-graph", - "version": "0.2.0" + "version": "0.2.1" }, { "packageName": "@the-open-engine/opcore-contracts", - "version": "0.2.0" + "version": "0.2.1" }, { "packageName": "@the-open-engine/opcore-graph-core-darwin-arm64", - "version": "0.2.0" + "version": "0.2.1" }, { "packageName": "@the-open-engine/opcore-graph-core-darwin-x64", - "version": "0.2.0" + "version": "0.2.1" }, { "packageName": "@the-open-engine/opcore-graph-core-linux-x64", - "version": "0.2.0" + "version": "0.2.1" } ], "graphProviderSchemaVersion": 1, @@ -59,7 +59,7 @@ "status": "passed", "exitCode": 0, "fixture": "packages/fixtures/source-extraction/wave1", - "durationMs": 672 + "durationMs": 997 }, { "id": "opcore-graph-update", @@ -76,7 +76,7 @@ "status": "passed", "exitCode": 0, "fixture": "packages/fixtures/source-extraction/wave1", - "durationMs": 340 + "durationMs": 681 }, { "id": "opcore-graph-watch", @@ -93,7 +93,7 @@ "status": "passed", "exitCode": 0, "fixture": "packages/fixtures/source-extraction/wave1", - "durationMs": 321 + "durationMs": 662 }, { "id": "opcore-graph-status", @@ -110,7 +110,7 @@ "status": "passed", "exitCode": 0, "fixture": "packages/fixtures/source-extraction/wave1", - "durationMs": 316 + "durationMs": 743 }, { "id": "opcore-graph-query", @@ -127,7 +127,7 @@ "status": "passed", "exitCode": 0, "fixture": "packages/fixtures/source-extraction/wave1", - "durationMs": 324 + "durationMs": 691 }, { "id": "opcore-graph-impact", @@ -144,7 +144,7 @@ "status": "passed", "exitCode": 0, "fixture": "packages/fixtures/source-extraction/wave1", - "durationMs": 322 + "durationMs": 734 }, { "id": "opcore-graph-search", @@ -161,7 +161,7 @@ "status": "passed", "exitCode": 0, "fixture": "packages/fixtures/source-extraction/wave1", - "durationMs": 325 + "durationMs": 734 }, { "id": "opcore-graph-serve", @@ -178,7 +178,7 @@ "status": "passed", "exitCode": 0, "fixture": "packages/fixtures/source-extraction/wave1", - "durationMs": 322 + "durationMs": 745 } ], "rustCommandCoverage": [ @@ -197,7 +197,7 @@ "status": "passed", "exitCode": 0, "fixture": "packages/fixtures/source-extraction/rust-only", - "durationMs": 332 + "durationMs": 743 }, { "id": "opcore-graph-rust-update", @@ -214,7 +214,7 @@ "status": "passed", "exitCode": 0, "fixture": "packages/fixtures/source-extraction/rust-only", - "durationMs": 329 + "durationMs": 723 }, { "id": "opcore-graph-rust-watch", @@ -231,7 +231,7 @@ "status": "passed", "exitCode": 0, "fixture": "packages/fixtures/source-extraction/rust-only", - "durationMs": 323 + "durationMs": 733 }, { "id": "opcore-graph-rust-status", @@ -248,7 +248,7 @@ "status": "passed", "exitCode": 0, "fixture": "packages/fixtures/source-extraction/rust-only", - "durationMs": 317 + "durationMs": 735 }, { "id": "opcore-graph-rust-query", @@ -265,7 +265,7 @@ "status": "passed", "exitCode": 0, "fixture": "packages/fixtures/source-extraction/rust-only", - "durationMs": 317 + "durationMs": 784 }, { "id": "opcore-graph-rust-impact", @@ -282,7 +282,7 @@ "status": "passed", "exitCode": 0, "fixture": "packages/fixtures/source-extraction/rust-only", - "durationMs": 324 + "durationMs": 691 }, { "id": "opcore-graph-rust-search", @@ -299,7 +299,7 @@ "status": "passed", "exitCode": 0, "fixture": "packages/fixtures/source-extraction/rust-only", - "durationMs": 326 + "durationMs": 724 }, { "id": "opcore-graph-rust-serve", @@ -316,7 +316,7 @@ "status": "passed", "exitCode": 0, "fixture": "packages/fixtures/source-extraction/rust-only", - "durationMs": 315 + "durationMs": 719 } ], "directSqliteQueries": [ @@ -396,7 +396,7 @@ "benchmarks": [ { "metric": "install_setup_ms", - "value": 3, + "value": 6, "unit": "ms", "baselineIssue": "#19", "baselineReceipt": "packages/fixtures/graph-reference-evidence/baseline-receipts.json", @@ -404,7 +404,7 @@ }, { "metric": "cold_build_ms", - "value": 672, + "value": 997, "unit": "ms", "baselineIssue": "#19", "baselineReceipt": "packages/fixtures/graph-reference-evidence/baseline-receipts.json", @@ -412,7 +412,7 @@ }, { "metric": "incremental_update_ms", - "value": 340, + "value": 681, "unit": "ms", "baselineIssue": "#19", "baselineReceipt": "packages/fixtures/graph-reference-evidence/baseline-receipts.json", @@ -420,7 +420,7 @@ }, { "metric": "impact_cold_ms", - "value": 322, + "value": 734, "unit": "ms", "baselineIssue": "#19", "baselineReceipt": "packages/fixtures/graph-reference-evidence/baseline-receipts.json", @@ -428,7 +428,7 @@ }, { "metric": "impact_hot_ms", - "value": 321, + "value": 756, "unit": "ms", "baselineIssue": "#19", "baselineReceipt": "packages/fixtures/graph-reference-evidence/baseline-receipts.json", @@ -436,7 +436,7 @@ }, { "metric": "search_ms", - "value": 325, + "value": 734, "unit": "ms", "baselineIssue": "#19", "baselineReceipt": "packages/fixtures/graph-reference-evidence/baseline-receipts.json", @@ -444,7 +444,7 @@ }, { "metric": "daemon_startup_ms", - "value": 542, + "value": 1130, "unit": "ms", "baselineIssue": "#19", "baselineReceipt": "packages/fixtures/graph-reference-evidence/baseline-receipts.json", @@ -452,7 +452,7 @@ }, { "metric": "daemon_query_ms", - "value": 542, + "value": 1130, "unit": "ms", "baselineIssue": "#19", "baselineReceipt": "packages/fixtures/graph-reference-evidence/baseline-receipts.json", @@ -477,19 +477,25 @@ ], "packageInspection": { "packageName": "@the-open-engine/opcore-graph", - "tarballName": "the-open-engine-opcore-graph-0.2.0.tgz", - "fileCount": 17, + "tarballName": "the-open-engine-opcore-graph-0.2.1.tgz", + "fileCount": 23, "files": [ "README.md", "dist/artifact.d.ts", "dist/artifact.d.ts.map", "dist/artifact.js", + "dist/ephemeral-snapshot.d.ts", + "dist/ephemeral-snapshot.d.ts.map", + "dist/ephemeral-snapshot.js", "dist/index.d.ts", "dist/index.d.ts.map", "dist/index.js", "dist/native-targets.d.ts", "dist/native-targets.d.ts.map", "dist/native-targets.js", + "dist/python-import-analysis.d.ts", + "dist/python-import-analysis.d.ts.map", + "dist/python-import-analysis.js", "dist/serve.d.ts", "dist/serve.d.ts.map", "dist/serve.js", @@ -523,19 +529,19 @@ "targetPlatform": "darwin-arm64", "metadata": { "artifactName": "opcore-graph-core", - "artifactVersion": "0.2.0", + "artifactVersion": "0.2.1", "targetPlatform": "darwin-arm64", "binaryPath": "opcore-graph-core", "checksumPath": "opcore-graph-core.sha256", - "checksumSha256": "c222610c492e628fa48ff50be22310e5bf8ebcd62bffbde1903d1fa91c45f6cb", + "checksumSha256": "72acf10de0ece619c3896943addfb0cc1b09dab22000b48a653b033224217bc8", "buildProfile": "release" }, "binaryPath": "opcore-graph-core", "checksumPath": "opcore-graph-core.sha256", "metadataPath": "metadata.json", - "binarySha256": "c222610c492e628fa48ff50be22310e5bf8ebcd62bffbde1903d1fa91c45f6cb", - "checksumFileSha256": "794844f23f2c3ddd415b3a58d07b41a6a1c4ad643be0b6ae2a6585d0e4e057fe", - "metadataSha256": "4f2817e526b57f3036a7595cbc2a8ad94836f32a87f315f866080b0d9855c266", + "binarySha256": "72acf10de0ece619c3896943addfb0cc1b09dab22000b48a653b033224217bc8", + "checksumFileSha256": "8c604dbcec287dd6a46a5949872bcc69ecb7d0274377da97c43a89b8f0907fca", + "metadataSha256": "9c3fdfc4b574e6cb2bc6786a5c720e6fb1f1cbe0895906ab0713d614439ec4fa", "packageFiles": [ "package.json", "README.md", @@ -549,7 +555,7 @@ "targetPlatform": "darwin-x64", "metadata": { "artifactName": "opcore-graph-core", - "artifactVersion": "0.2.0", + "artifactVersion": "0.2.1", "targetPlatform": "darwin-x64", "binaryPath": "opcore-graph-core", "checksumPath": "opcore-graph-core.sha256", @@ -561,7 +567,7 @@ "metadataPath": "metadata.json", "binarySha256": "1b72afa74d48b087c967cb01b39630ed3fa58b5a2895037a778eb5612ef73059", "checksumFileSha256": "78e1e2efd92e4c3f184b19263b8e8c161fda919c180638906808afdf3b2d312d", - "metadataSha256": "1892fae547da3aca3feb60bfba1be52b9dcfd4f5dfc9e4da0cb923cfd2a4792a", + "metadataSha256": "b0995ddb2769a57cd3a07510efe90ed1788e5f55f48f10b0e46ac3c81f53e175", "packageFiles": [ "package.json", "README.md", @@ -575,19 +581,19 @@ "targetPlatform": "linux-x64", "metadata": { "artifactName": "opcore-graph-core", - "artifactVersion": "0.2.0", + "artifactVersion": "0.2.1", "targetPlatform": "linux-x64", "binaryPath": "opcore-graph-core", "checksumPath": "opcore-graph-core.sha256", - "checksumSha256": "70cb6d213d74594f337a67522c879b77767872dc4601063686f1abe70886127b", + "checksumSha256": "c3a771435a7a8172a9e5f0bb20b0d111453efc70901e95308094664f77e7bdf1", "buildProfile": "release" }, "binaryPath": "opcore-graph-core", "checksumPath": "opcore-graph-core.sha256", "metadataPath": "metadata.json", - "binarySha256": "70cb6d213d74594f337a67522c879b77767872dc4601063686f1abe70886127b", - "checksumFileSha256": "c2910f2077d0e824fa838a3565114bab7c63910dcee51221e64d37c0aee20b19", - "metadataSha256": "d851e010080be2505824d89a49cbe2b1bd6709854d380bd3f39e8c58c2114d90", + "binarySha256": "c3a771435a7a8172a9e5f0bb20b0d111453efc70901e95308094664f77e7bdf1", + "checksumFileSha256": "4f1fe963a88779474c8c8c0ab20b84dc16ed604ad7573a7dadb17795c8ef37c9", + "metadataSha256": "2d6abe4b7651c879a1db63932f73c2c6af83bcf0db44f7d87bbde651d129f9e0", "packageFiles": [ "package.json", "README.md", @@ -645,11 +651,11 @@ ], "graphArtifact": { "artifactName": "opcore-graph-core", - "artifactVersion": "0.2.0", + "artifactVersion": "0.2.1", "targetPlatform": "darwin-arm64", "binaryPath": "opcore-graph-core", "checksumPath": "opcore-graph-core.sha256", - "checksumSha256": "c222610c492e628fa48ff50be22310e5bf8ebcd62bffbde1903d1fa91c45f6cb", + "checksumSha256": "72acf10de0ece619c3896943addfb0cc1b09dab22000b48a653b033224217bc8", "buildProfile": "release" }, "optionalSurfaces": [ diff --git a/docs/release/license-report.md b/docs/release/license-report.md index ae4e749..152cfa2 100644 --- a/docs/release/license-report.md +++ b/docs/release/license-report.md @@ -2,10 +2,10 @@ Maintainer license evidence for the Opcore alpha release gate. -Root package: opcore@0.2.0 +Root package: opcore@0.2.1 Lockfile version: 3 -Third-party packages: 118 -Bundled runtime packages: 35 +Third-party packages: 119 +Bundled runtime packages: 38 Workspace packages: 16 ## Third-Party Inventory @@ -97,6 +97,7 @@ Workspace packages: 16 | json-buffer | 3.0.1 | MIT | node_modules/json-buffer | | json-schema-traverse | 0.4.1 | MIT | node_modules/json-schema-traverse | | json-stable-stringify-without-jsonify | 1.0.1 | MIT | node_modules/json-stable-stringify-without-jsonify | +| jsonc-parser | 3.3.1 | MIT | node_modules/jsonc-parser | | keyv | 4.5.4 | MIT | node_modules/keyv | | levn | 0.4.1 | MIT | node_modules/levn | | locate-path | 6.0.0 | MIT | node_modules/locate-path | @@ -140,27 +141,27 @@ Workspace packages: 16 | BSD-2-Clause | 6 | | BSD-3-Clause | 1 | | ISC | 8 | -| MIT | 87 | +| MIT | 88 | | Python-2.0 | 1 | ## Bundled Runtime Dependencies | Package | Version | License | Source | |---------|---------|---------|--------| -| @the-open-engine/opcore-asp-provider | 0.2.0 | MIT | packages/asp-provider/package.json | -| @the-open-engine/opcore-contracts | 0.2.0 | MIT | packages/contracts/package.json | -| @the-open-engine/opcore-edit | 0.2.0 | MIT | packages/edit/package.json | -| @the-open-engine/opcore-graph | 0.2.0 | MIT | packages/graph/package.json | -| @the-open-engine/opcore-graph-core-darwin-arm64 | 0.2.0 | MIT | packages/opcore-graph-core-darwin-arm64/package.json | -| @the-open-engine/opcore-graph-core-darwin-x64 | 0.2.0 | MIT | packages/opcore-graph-core-darwin-x64/package.json | -| @the-open-engine/opcore-graph-core-linux-x64 | 0.2.0 | MIT | packages/opcore-graph-core-linux-x64/package.json | -| @the-open-engine/opcore-validation | 0.2.0 | MIT | packages/validation/package.json | -| @the-open-engine/opcore-validation-clone | 0.2.0 | MIT | packages/validation-clone/package.json | -| @the-open-engine/opcore-validation-docs | 0.2.0 | MIT | packages/validation-docs/package.json | -| @the-open-engine/opcore-validation-policy | 0.2.0 | MIT | packages/validation-policy/package.json | -| @the-open-engine/opcore-validation-python | 0.2.0 | MIT | packages/validation-python/package.json | -| @the-open-engine/opcore-validation-rust | 0.2.0 | MIT | packages/validation-rust/package.json | -| @the-open-engine/opcore-validation-typescript | 0.2.0 | MIT | packages/validation-typescript/package.json | +| @the-open-engine/opcore-asp-provider | 0.2.1 | MIT | packages/asp-provider/package.json | +| @the-open-engine/opcore-contracts | 0.2.1 | MIT | packages/contracts/package.json | +| @the-open-engine/opcore-edit | 0.2.1 | MIT | packages/edit/package.json | +| @the-open-engine/opcore-graph | 0.2.1 | MIT | packages/graph/package.json | +| @the-open-engine/opcore-graph-core-darwin-arm64 | 0.2.1 | MIT | packages/opcore-graph-core-darwin-arm64/package.json | +| @the-open-engine/opcore-graph-core-darwin-x64 | 0.2.1 | MIT | packages/opcore-graph-core-darwin-x64/package.json | +| @the-open-engine/opcore-graph-core-linux-x64 | 0.2.1 | MIT | packages/opcore-graph-core-linux-x64/package.json | +| @the-open-engine/opcore-validation | 0.2.1 | MIT | packages/validation/package.json | +| @the-open-engine/opcore-validation-clone | 0.2.1 | MIT | packages/validation-clone/package.json | +| @the-open-engine/opcore-validation-docs | 0.2.1 | MIT | packages/validation-docs/package.json | +| @the-open-engine/opcore-validation-policy | 0.2.1 | MIT | packages/validation-policy/package.json | +| @the-open-engine/opcore-validation-python | 0.2.1 | MIT | packages/validation-python/package.json | +| @the-open-engine/opcore-validation-rust | 0.2.1 | MIT | packages/validation-rust/package.json | +| @the-open-engine/opcore-validation-typescript | 0.2.1 | MIT | packages/validation-typescript/package.json | | @ts-morph/common | 0.29.0 | MIT | node_modules/@ts-morph/common | | @typescript-eslint/project-service | 8.62.0 | MIT | node_modules/@typescript-eslint/project-service | | @typescript-eslint/tsconfig-utils | 8.62.0 | MIT | node_modules/@typescript-eslint/tsconfig-utils | @@ -170,14 +171,17 @@ Workspace packages: 16 | balanced-match | 4.0.4 | MIT | node_modules/balanced-match | | brace-expansion | 5.0.6 | MIT | node_modules/brace-expansion | | code-block-writer | 13.0.3 | MIT | node_modules/code-block-writer | +| concat-map | 0.0.1 | MIT | node_modules/concat-map | | debug | 4.4.3 | MIT | node_modules/debug | | eslint-visitor-keys | 4.2.1 | Apache-2.0 | node_modules/eslint-visitor-keys | | fdir | 6.5.0 | MIT | node_modules/fdir | +| jsonc-parser | 3.3.1 | MIT | node_modules/jsonc-parser | | minimatch | 10.2.5 | BlueOak-1.0.0 | node_modules/minimatch | | ms | 2.1.3 | MIT | node_modules/ms | | path-browserify | 1.0.1 | MIT | node_modules/path-browserify | | picomatch | 4.0.4 | MIT | node_modules/picomatch | | semver | 7.8.5 | ISC | node_modules/semver | +| smol-toml | 1.6.0 | BSD-3-Clause | packages/opcore/node_modules/smol-toml | | tinyglobby | 0.2.17 | MIT | node_modules/tinyglobby | | ts-api-utils | 2.5.0 | MIT | node_modules/ts-api-utils | | ts-morph | 28.0.0 | MIT | node_modules/ts-morph | @@ -187,21 +191,21 @@ Workspace packages: 16 | Package | Version | License | Manifest | |---------|---------|---------|----------| -| @the-open-engine/opcore-asp-provider | 0.2.0 | MIT | packages/asp-provider/package.json | -| @the-open-engine/opcore-contracts | 0.2.0 | MIT | packages/contracts/package.json | -| @the-open-engine/opcore-edit | 0.2.0 | MIT | packages/edit/package.json | -| @the-open-engine/opcore-fixtures | 0.2.0 | MIT | packages/fixtures/package.json | -| @the-open-engine/opcore-graph | 0.2.0 | MIT | packages/graph/package.json | -| @the-open-engine/opcore-graph-core-darwin-arm64 | 0.2.0 | MIT | packages/opcore-graph-core-darwin-arm64/package.json | -| @the-open-engine/opcore-graph-core-darwin-x64 | 0.2.0 | MIT | packages/opcore-graph-core-darwin-x64/package.json | -| @the-open-engine/opcore-graph-core-linux-x64 | 0.2.0 | MIT | packages/opcore-graph-core-linux-x64/package.json | -| @the-open-engine/opcore-validation | 0.2.0 | MIT | packages/validation/package.json | -| @the-open-engine/opcore-validation-clone | 0.2.0 | MIT | packages/validation-clone/package.json | -| @the-open-engine/opcore-validation-docs | 0.2.0 | MIT | packages/validation-docs/package.json | -| @the-open-engine/opcore-validation-policy | 0.2.0 | MIT | packages/validation-policy/package.json | -| @the-open-engine/opcore-validation-python | 0.2.0 | MIT | packages/validation-python/package.json | -| @the-open-engine/opcore-validation-rust | 0.2.0 | MIT | packages/validation-rust/package.json | -| @the-open-engine/opcore-validation-typescript | 0.2.0 | MIT | packages/validation-typescript/package.json | -| opcore | 0.2.0 | MIT | packages/opcore/package.json | +| @the-open-engine/opcore-asp-provider | 0.2.1 | MIT | packages/asp-provider/package.json | +| @the-open-engine/opcore-contracts | 0.2.1 | MIT | packages/contracts/package.json | +| @the-open-engine/opcore-edit | 0.2.1 | MIT | packages/edit/package.json | +| @the-open-engine/opcore-fixtures | 0.2.1 | MIT | packages/fixtures/package.json | +| @the-open-engine/opcore-graph | 0.2.1 | MIT | packages/graph/package.json | +| @the-open-engine/opcore-graph-core-darwin-arm64 | 0.2.1 | MIT | packages/opcore-graph-core-darwin-arm64/package.json | +| @the-open-engine/opcore-graph-core-darwin-x64 | 0.2.1 | MIT | packages/opcore-graph-core-darwin-x64/package.json | +| @the-open-engine/opcore-graph-core-linux-x64 | 0.2.1 | MIT | packages/opcore-graph-core-linux-x64/package.json | +| @the-open-engine/opcore-validation | 0.2.1 | MIT | packages/validation/package.json | +| @the-open-engine/opcore-validation-clone | 0.2.1 | MIT | packages/validation-clone/package.json | +| @the-open-engine/opcore-validation-docs | 0.2.1 | MIT | packages/validation-docs/package.json | +| @the-open-engine/opcore-validation-policy | 0.2.1 | MIT | packages/validation-policy/package.json | +| @the-open-engine/opcore-validation-python | 0.2.1 | MIT | packages/validation-python/package.json | +| @the-open-engine/opcore-validation-rust | 0.2.1 | MIT | packages/validation-rust/package.json | +| @the-open-engine/opcore-validation-typescript | 0.2.1 | MIT | packages/validation-typescript/package.json | +| opcore | 0.2.1 | MIT | packages/opcore/package.json | No unresolved third-party license entries. diff --git a/docs/release/provenance-receipts.md b/docs/release/provenance-receipts.md index caf2abb..09a6af0 100644 --- a/docs/release/provenance-receipts.md +++ b/docs/release/provenance-receipts.md @@ -2,8 +2,8 @@ Maintainer provenance evidence for the Opcore alpha release gate. -- Current-tree files scanned: 551 -- Git-history commits scanned: 248 +- 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 diff --git a/docs/release/release-receipt.json b/docs/release/release-receipt.json index a889b7f..fdad311 100644 --- a/docs/release/release-receipt.json +++ b/docs/release/release-receipt.json @@ -2,8 +2,8 @@ "schemaVersion": 1, "issue": "#29", "origin": "covibes-authored-release-proof", - "generatedAt": "2026-07-09T19:55:10.363Z", - "commitSha": "1b782d7c86dae055a0225e0905101beafaaa2fce", + "generatedAt": "2026-07-18T06:34:06.264Z", + "commitSha": "2d4e920e0b08a989953486b261327ed7496c328b", "privateRepo": true, "packageNames": [ "opcore" @@ -21,10 +21,10 @@ { "packageName": "opcore", "packageRoot": "packages/opcore", - "version": "0.2.0", + "version": "0.2.1", "manifest": { "name": "opcore", - "version": "0.2.0", + "version": "0.2.1", "license": "MIT", "main": "dist/index.js", "types": "dist/index.d.ts", @@ -37,25 +37,28 @@ "opcore-asp-provider": "dist/asp-provider-bin.js" }, "dependencies": { - "@the-open-engine/opcore-asp-provider": "0.2.0", - "@the-open-engine/opcore-edit": "0.2.0", - "@the-open-engine/opcore-contracts": "0.2.0", - "@the-open-engine/opcore-graph": "0.2.0", - "@the-open-engine/opcore-validation": "0.2.0", - "@the-open-engine/opcore-validation-policy": "0.2.0", - "@the-open-engine/opcore-validation-clone": "0.2.0", - "@the-open-engine/opcore-validation-docs": "0.2.0", - "@the-open-engine/opcore-validation-python": "0.2.0", - "@the-open-engine/opcore-validation-rust": "0.2.0", - "@the-open-engine/opcore-validation-typescript": "0.2.0", + "@the-open-engine/opcore-asp-provider": "0.2.1", + "@the-open-engine/opcore-edit": "0.2.1", + "@the-open-engine/opcore-contracts": "0.2.1", + "@the-open-engine/opcore-graph": "0.2.1", + "@the-open-engine/opcore-validation": "0.2.1", + "@the-open-engine/opcore-validation-policy": "0.2.1", + "@the-open-engine/opcore-validation-clone": "0.2.1", + "@the-open-engine/opcore-validation-docs": "0.2.1", + "@the-open-engine/opcore-validation-python": "0.2.1", + "@the-open-engine/opcore-validation-rust": "0.2.1", + "@the-open-engine/opcore-validation-typescript": "0.2.1", "@typescript-eslint/typescript-estree": "^8.62.0", + "concat-map": "0.0.1", + "jsonc-parser": "3.3.1", + "smol-toml": "1.6.0", "ts-morph": "^28.0.0", "typescript": "^5.9.3" }, "optionalDependencies": { - "@the-open-engine/opcore-graph-core-darwin-arm64": "0.2.0", - "@the-open-engine/opcore-graph-core-darwin-x64": "0.2.0", - "@the-open-engine/opcore-graph-core-linux-x64": "0.2.0" + "@the-open-engine/opcore-graph-core-darwin-arm64": "0.2.1", + "@the-open-engine/opcore-graph-core-darwin-x64": "0.2.1", + "@the-open-engine/opcore-graph-core-linux-x64": "0.2.1" }, "bundledDependencies": [ "@the-open-engine/opcore-asp-provider", @@ -80,15 +83,18 @@ "@typescript-eslint/visitor-keys", "balanced-match", "brace-expansion", + "concat-map", "code-block-writer", "debug", "eslint-visitor-keys", "fdir", + "jsonc-parser", "minimatch", "ms", "path-browserify", "picomatch", "semver", + "smol-toml", "tinyglobby", "ts-api-utils", "ts-morph", @@ -96,11 +102,11 @@ ] }, "tarball": { - "filename": "opcore-0.2.0.tgz", - "path": ".opcore/release/packages/opcore-0.2.0.tgz", - "sha256": "a5bc2d71695c2b3e8320f9dce88c7907d34e40af32eaa00793ae185386abd753", - "integrity": "sha512-RcRPMPp74AUeNtjN9z9AYCp7F3dGL5pLKKxu8/eesHfyt9fQHhUlWZc2PRD7t9q9ZC47mLNwbKuHt5uhGtdEhw==", - "shasum": "a715348b7197beb87b770fd26018b53db62ea9f7" + "filename": "opcore-0.2.1.tgz", + "path": ".opcore/release/packages/opcore-0.2.1.tgz", + "sha256": "f3c4478eca2a9a1118b10bdabdfa009dfed0bdd8903312f06c42a8b60515b128", + "integrity": "sha512-vu3Hemw2S3heof0UqaE4k+jGS6aNpgcv3YufeyhGzA08CNYKruO91pNmsrYkczFxGHarYa23RLJlLfgF+t1hEQ==", + "shasum": "641cc277abd477d63f255e2b8989290cc73225dd" }, "files": [ "README.md", @@ -213,6 +219,9 @@ "dist/scan-presentation.d.ts", "dist/scan-presentation.d.ts.map", "dist/scan-presentation.js", + "dist/scan-validation-preview.d.ts", + "dist/scan-validation-preview.d.ts.map", + "dist/scan-validation-preview.js", "dist/scan.d.ts", "dist/scan.d.ts.map", "dist/scan.js", @@ -222,6 +231,33 @@ "dist/source-policy.d.ts", "dist/source-policy.d.ts.map", "dist/source-policy.js", + "dist/status-args.d.ts", + "dist/status-args.d.ts.map", + "dist/status-args.js", + "dist/status-census.d.ts", + "dist/status-census.d.ts.map", + "dist/status-census.js", + "dist/status-coverage.d.ts", + "dist/status-coverage.d.ts.map", + "dist/status-coverage.js", + "dist/status-errors.d.ts", + "dist/status-errors.d.ts.map", + "dist/status-errors.js", + "dist/status-format.d.ts", + "dist/status-format.d.ts.map", + "dist/status-format.js", + "dist/status-git.d.ts", + "dist/status-git.d.ts.map", + "dist/status-git.js", + "dist/status-repo.d.ts", + "dist/status-repo.d.ts.map", + "dist/status-repo.js", + "dist/status-state.d.ts", + "dist/status-state.d.ts.map", + "dist/status-state.js", + "dist/status-validation.d.ts", + "dist/status-validation.d.ts.map", + "dist/status-validation.js", "dist/status.d.ts", "dist/status.d.ts.map", "dist/status.js", @@ -237,6 +273,9 @@ "dist/validation-composition.d.ts", "dist/validation-composition.d.ts.map", "dist/validation-composition.js", + "dist/validation-graph-session.d.ts", + "dist/validation-graph-session.d.ts.map", + "dist/validation-graph-session.js", "node_modules/@the-open-engine/opcore-asp-provider/README.md", "node_modules/@the-open-engine/opcore-asp-provider/dist/digests.d.ts", "node_modules/@the-open-engine/opcore-asp-provider/dist/digests.d.ts.map", @@ -361,12 +400,18 @@ "node_modules/@the-open-engine/opcore-graph/dist/artifact.d.ts", "node_modules/@the-open-engine/opcore-graph/dist/artifact.d.ts.map", "node_modules/@the-open-engine/opcore-graph/dist/artifact.js", + "node_modules/@the-open-engine/opcore-graph/dist/ephemeral-snapshot.d.ts", + "node_modules/@the-open-engine/opcore-graph/dist/ephemeral-snapshot.d.ts.map", + "node_modules/@the-open-engine/opcore-graph/dist/ephemeral-snapshot.js", "node_modules/@the-open-engine/opcore-graph/dist/index.d.ts", "node_modules/@the-open-engine/opcore-graph/dist/index.d.ts.map", "node_modules/@the-open-engine/opcore-graph/dist/index.js", "node_modules/@the-open-engine/opcore-graph/dist/native-targets.d.ts", "node_modules/@the-open-engine/opcore-graph/dist/native-targets.d.ts.map", "node_modules/@the-open-engine/opcore-graph/dist/native-targets.js", + "node_modules/@the-open-engine/opcore-graph/dist/python-import-analysis.d.ts", + "node_modules/@the-open-engine/opcore-graph/dist/python-import-analysis.d.ts.map", + "node_modules/@the-open-engine/opcore-graph/dist/python-import-analysis.js", "node_modules/@the-open-engine/opcore-graph/dist/serve.d.ts", "node_modules/@the-open-engine/opcore-graph/dist/serve.d.ts.map", "node_modules/@the-open-engine/opcore-graph/dist/serve.js", @@ -444,42 +489,135 @@ "node_modules/@the-open-engine/opcore-validation-python/dist/check-ids.d.ts", "node_modules/@the-open-engine/opcore-validation-python/dist/check-ids.d.ts.map", "node_modules/@the-open-engine/opcore-validation-python/dist/check-ids.js", + "node_modules/@the-open-engine/opcore-validation-python/dist/compiler-protocol.d.ts", + "node_modules/@the-open-engine/opcore-validation-python/dist/compiler-protocol.d.ts.map", + "node_modules/@the-open-engine/opcore-validation-python/dist/compiler-protocol.js", "node_modules/@the-open-engine/opcore-validation-python/dist/dead-code-check.d.ts", "node_modules/@the-open-engine/opcore-validation-python/dist/dead-code-check.d.ts.map", "node_modules/@the-open-engine/opcore-validation-python/dist/dead-code-check.js", "node_modules/@the-open-engine/opcore-validation-python/dist/diagnostics.d.ts", "node_modules/@the-open-engine/opcore-validation-python/dist/diagnostics.d.ts.map", "node_modules/@the-open-engine/opcore-validation-python/dist/diagnostics.js", + "node_modules/@the-open-engine/opcore-validation-python/dist/environment-resolution.d.ts", + "node_modules/@the-open-engine/opcore-validation-python/dist/environment-resolution.d.ts.map", + "node_modules/@the-open-engine/opcore-validation-python/dist/environment-resolution.js", "node_modules/@the-open-engine/opcore-validation-python/dist/graph-requirements.d.ts", "node_modules/@the-open-engine/opcore-validation-python/dist/graph-requirements.d.ts.map", "node_modules/@the-open-engine/opcore-validation-python/dist/graph-requirements.js", + "node_modules/@the-open-engine/opcore-validation-python/dist/import-analysis.d.ts", + "node_modules/@the-open-engine/opcore-validation-python/dist/import-analysis.d.ts.map", + "node_modules/@the-open-engine/opcore-validation-python/dist/import-analysis.js", "node_modules/@the-open-engine/opcore-validation-python/dist/import-graph-check.d.ts", "node_modules/@the-open-engine/opcore-validation-python/dist/import-graph-check.d.ts.map", "node_modules/@the-open-engine/opcore-validation-python/dist/import-graph-check.js", "node_modules/@the-open-engine/opcore-validation-python/dist/index.d.ts", "node_modules/@the-open-engine/opcore-validation-python/dist/index.d.ts.map", "node_modules/@the-open-engine/opcore-validation-python/dist/index.js", + "node_modules/@the-open-engine/opcore-validation-python/dist/ini-config.d.ts", + "node_modules/@the-open-engine/opcore-validation-python/dist/ini-config.d.ts.map", + "node_modules/@the-open-engine/opcore-validation-python/dist/ini-config.js", + "node_modules/@the-open-engine/opcore-validation-python/dist/mypy-config-values.d.ts", + "node_modules/@the-open-engine/opcore-validation-python/dist/mypy-config-values.d.ts.map", + "node_modules/@the-open-engine/opcore-validation-python/dist/mypy-config-values.js", + "node_modules/@the-open-engine/opcore-validation-python/dist/mypy-config.d.ts", + "node_modules/@the-open-engine/opcore-validation-python/dist/mypy-config.d.ts.map", + "node_modules/@the-open-engine/opcore-validation-python/dist/mypy-config.js", + "node_modules/@the-open-engine/opcore-validation-python/dist/mypy-output.d.ts", + "node_modules/@the-open-engine/opcore-validation-python/dist/mypy-output.d.ts.map", + "node_modules/@the-open-engine/opcore-validation-python/dist/mypy-output.js", + "node_modules/@the-open-engine/opcore-validation-python/dist/mypy-result.d.ts", + "node_modules/@the-open-engine/opcore-validation-python/dist/mypy-result.d.ts.map", + "node_modules/@the-open-engine/opcore-validation-python/dist/mypy-result.js", + "node_modules/@the-open-engine/opcore-validation-python/dist/mypy-runner-types.d.ts", + "node_modules/@the-open-engine/opcore-validation-python/dist/mypy-runner-types.d.ts.map", + "node_modules/@the-open-engine/opcore-validation-python/dist/mypy-runner-types.js", + "node_modules/@the-open-engine/opcore-validation-python/dist/mypy-runner.d.ts", + "node_modules/@the-open-engine/opcore-validation-python/dist/mypy-runner.d.ts.map", + "node_modules/@the-open-engine/opcore-validation-python/dist/mypy-runner.js", "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/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", + "node_modules/@the-open-engine/opcore-validation-python/dist/project-context.d.ts", + "node_modules/@the-open-engine/opcore-validation-python/dist/project-context.d.ts.map", + "node_modules/@the-open-engine/opcore-validation-python/dist/project-context.js", + "node_modules/@the-open-engine/opcore-validation-python/dist/project-discovery.d.ts", + "node_modules/@the-open-engine/opcore-validation-python/dist/project-discovery.d.ts.map", + "node_modules/@the-open-engine/opcore-validation-python/dist/project-discovery.js", + "node_modules/@the-open-engine/opcore-validation-python/dist/project-fingerprint.d.ts", + "node_modules/@the-open-engine/opcore-validation-python/dist/project-fingerprint.d.ts.map", + "node_modules/@the-open-engine/opcore-validation-python/dist/project-fingerprint.js", + "node_modules/@the-open-engine/opcore-validation-python/dist/project-workspace.d.ts", + "node_modules/@the-open-engine/opcore-validation-python/dist/project-workspace.d.ts.map", + "node_modules/@the-open-engine/opcore-validation-python/dist/project-workspace.js", + "node_modules/@the-open-engine/opcore-validation-python/dist/protocol-validation.d.ts", + "node_modules/@the-open-engine/opcore-validation-python/dist/protocol-validation.d.ts.map", + "node_modules/@the-open-engine/opcore-validation-python/dist/protocol-validation.js", + "node_modules/@the-open-engine/opcore-validation-python/dist/pyright-config-values.d.ts", + "node_modules/@the-open-engine/opcore-validation-python/dist/pyright-config-values.d.ts.map", + "node_modules/@the-open-engine/opcore-validation-python/dist/pyright-config-values.js", + "node_modules/@the-open-engine/opcore-validation-python/dist/pyright-config.d.ts", + "node_modules/@the-open-engine/opcore-validation-python/dist/pyright-config.d.ts.map", + "node_modules/@the-open-engine/opcore-validation-python/dist/pyright-config.js", + "node_modules/@the-open-engine/opcore-validation-python/dist/pyright-output.d.ts", + "node_modules/@the-open-engine/opcore-validation-python/dist/pyright-output.d.ts.map", + "node_modules/@the-open-engine/opcore-validation-python/dist/pyright-output.js", + "node_modules/@the-open-engine/opcore-validation-python/dist/pyright-runner.d.ts", + "node_modules/@the-open-engine/opcore-validation-python/dist/pyright-runner.d.ts.map", + "node_modules/@the-open-engine/opcore-validation-python/dist/pyright-runner.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", + "node_modules/@the-open-engine/opcore-validation-python/dist/source-closure.d.ts", + "node_modules/@the-open-engine/opcore-validation-python/dist/source-closure.d.ts.map", + "node_modules/@the-open-engine/opcore-validation-python/dist/source-closure.js", "node_modules/@the-open-engine/opcore-validation-python/dist/source-files.d.ts", "node_modules/@the-open-engine/opcore-validation-python/dist/source-files.d.ts.map", "node_modules/@the-open-engine/opcore-validation-python/dist/source-files.js", "node_modules/@the-open-engine/opcore-validation-python/dist/source-hygiene-check.d.ts", "node_modules/@the-open-engine/opcore-validation-python/dist/source-hygiene-check.d.ts.map", "node_modules/@the-open-engine/opcore-validation-python/dist/source-hygiene-check.js", + "node_modules/@the-open-engine/opcore-validation-python/dist/source-types.d.ts", + "node_modules/@the-open-engine/opcore-validation-python/dist/source-types.d.ts.map", + "node_modules/@the-open-engine/opcore-validation-python/dist/source-types.js", + "node_modules/@the-open-engine/opcore-validation-python/dist/static-config.d.ts", + "node_modules/@the-open-engine/opcore-validation-python/dist/static-config.d.ts.map", + "node_modules/@the-open-engine/opcore-validation-python/dist/static-config.js", + "node_modules/@the-open-engine/opcore-validation-python/dist/strict-json.d.ts", + "node_modules/@the-open-engine/opcore-validation-python/dist/strict-json.d.ts.map", + "node_modules/@the-open-engine/opcore-validation-python/dist/strict-json.js", "node_modules/@the-open-engine/opcore-validation-python/dist/syntax-check.d.ts", "node_modules/@the-open-engine/opcore-validation-python/dist/syntax-check.d.ts.map", "node_modules/@the-open-engine/opcore-validation-python/dist/syntax-check.js", + "node_modules/@the-open-engine/opcore-validation-python/dist/toml-config.d.ts", + "node_modules/@the-open-engine/opcore-validation-python/dist/toml-config.d.ts.map", + "node_modules/@the-open-engine/opcore-validation-python/dist/toml-config.js", "node_modules/@the-open-engine/opcore-validation-python/dist/toolchain.d.ts", "node_modules/@the-open-engine/opcore-validation-python/dist/toolchain.d.ts.map", "node_modules/@the-open-engine/opcore-validation-python/dist/toolchain.js", + "node_modules/@the-open-engine/opcore-validation-python/dist/type-authority.d.ts", + "node_modules/@the-open-engine/opcore-validation-python/dist/type-authority.d.ts.map", + "node_modules/@the-open-engine/opcore-validation-python/dist/type-authority.js", + "node_modules/@the-open-engine/opcore-validation-python/dist/type-capability-run.d.ts", + "node_modules/@the-open-engine/opcore-validation-python/dist/type-capability-run.d.ts.map", + "node_modules/@the-open-engine/opcore-validation-python/dist/type-capability-run.js", "node_modules/@the-open-engine/opcore-validation-python/dist/type-check.d.ts", "node_modules/@the-open-engine/opcore-validation-python/dist/type-check.d.ts.map", "node_modules/@the-open-engine/opcore-validation-python/dist/type-check.js", + "node_modules/@the-open-engine/opcore-validation-python/dist/type-result.d.ts", + "node_modules/@the-open-engine/opcore-validation-python/dist/type-result.d.ts.map", + "node_modules/@the-open-engine/opcore-validation-python/dist/type-result.js", + "node_modules/@the-open-engine/opcore-validation-python/dist/type-runner-runtime.d.ts", + "node_modules/@the-open-engine/opcore-validation-python/dist/type-runner-runtime.d.ts.map", + "node_modules/@the-open-engine/opcore-validation-python/dist/type-runner-runtime.js", + "node_modules/@the-open-engine/opcore-validation-python/dist/type-runner-types.d.ts", + "node_modules/@the-open-engine/opcore-validation-python/dist/type-runner-types.d.ts.map", + "node_modules/@the-open-engine/opcore-validation-python/dist/type-runner-types.js", + "node_modules/@the-open-engine/opcore-validation-python/dist/version-constraint.d.ts", + "node_modules/@the-open-engine/opcore-validation-python/dist/version-constraint.d.ts.map", + "node_modules/@the-open-engine/opcore-validation-python/dist/version-constraint.js", "node_modules/@the-open-engine/opcore-validation-python/package.json", "node_modules/@the-open-engine/opcore-validation-rust/README.md", "node_modules/@the-open-engine/opcore-validation-rust/dist/cargo-check.d.ts", @@ -556,12 +694,24 @@ "node_modules/@the-open-engine/opcore-validation-typescript/dist/compiler-options.d.ts", "node_modules/@the-open-engine/opcore-validation-typescript/dist/compiler-options.d.ts.map", "node_modules/@the-open-engine/opcore-validation-typescript/dist/compiler-options.js", + "node_modules/@the-open-engine/opcore-validation-typescript/dist/dead-code-analysis.d.ts", + "node_modules/@the-open-engine/opcore-validation-typescript/dist/dead-code-analysis.d.ts.map", + "node_modules/@the-open-engine/opcore-validation-typescript/dist/dead-code-analysis.js", "node_modules/@the-open-engine/opcore-validation-typescript/dist/dead-code-check.d.ts", "node_modules/@the-open-engine/opcore-validation-typescript/dist/dead-code-check.d.ts.map", "node_modules/@the-open-engine/opcore-validation-typescript/dist/dead-code-check.js", "node_modules/@the-open-engine/opcore-validation-typescript/dist/dead-code-entrypoints.d.ts", "node_modules/@the-open-engine/opcore-validation-typescript/dist/dead-code-entrypoints.d.ts.map", "node_modules/@the-open-engine/opcore-validation-typescript/dist/dead-code-entrypoints.js", + "node_modules/@the-open-engine/opcore-validation-typescript/dist/dead-code-import-evidence.d.ts", + "node_modules/@the-open-engine/opcore-validation-typescript/dist/dead-code-import-evidence.d.ts.map", + "node_modules/@the-open-engine/opcore-validation-typescript/dist/dead-code-import-evidence.js", + "node_modules/@the-open-engine/opcore-validation-typescript/dist/dead-code-roots.d.ts", + "node_modules/@the-open-engine/opcore-validation-typescript/dist/dead-code-roots.d.ts.map", + "node_modules/@the-open-engine/opcore-validation-typescript/dist/dead-code-roots.js", + "node_modules/@the-open-engine/opcore-validation-typescript/dist/dead-code-run.d.ts", + "node_modules/@the-open-engine/opcore-validation-typescript/dist/dead-code-run.d.ts.map", + "node_modules/@the-open-engine/opcore-validation-typescript/dist/dead-code-run.js", "node_modules/@the-open-engine/opcore-validation-typescript/dist/diagnostics.d.ts", "node_modules/@the-open-engine/opcore-validation-typescript/dist/diagnostics.d.ts.map", "node_modules/@the-open-engine/opcore-validation-typescript/dist/diagnostics.js", @@ -636,6 +786,9 @@ "node_modules/@the-open-engine/opcore-validation/dist/request.d.ts", "node_modules/@the-open-engine/opcore-validation/dist/request.d.ts.map", "node_modules/@the-open-engine/opcore-validation/dist/request.js", + "node_modules/@the-open-engine/opcore-validation/dist/resources.d.ts", + "node_modules/@the-open-engine/opcore-validation/dist/resources.d.ts.map", + "node_modules/@the-open-engine/opcore-validation/dist/resources.js", "node_modules/@the-open-engine/opcore-validation/dist/runner.d.ts", "node_modules/@the-open-engine/opcore-validation/dist/runner.d.ts.map", "node_modules/@the-open-engine/opcore-validation/dist/runner.js", @@ -843,6 +996,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", @@ -866,6 +1026,25 @@ "node_modules/fdir/dist/index.d.mts", "node_modules/fdir/dist/index.mjs", "node_modules/fdir/package.json", + "node_modules/jsonc-parser/CHANGELOG.md", + "node_modules/jsonc-parser/LICENSE.md", + "node_modules/jsonc-parser/README.md", + "node_modules/jsonc-parser/SECURITY.md", + "node_modules/jsonc-parser/lib/esm/impl/edit.js", + "node_modules/jsonc-parser/lib/esm/impl/format.js", + "node_modules/jsonc-parser/lib/esm/impl/parser.js", + "node_modules/jsonc-parser/lib/esm/impl/scanner.js", + "node_modules/jsonc-parser/lib/esm/impl/string-intern.js", + "node_modules/jsonc-parser/lib/esm/main.d.ts", + "node_modules/jsonc-parser/lib/esm/main.js", + "node_modules/jsonc-parser/lib/umd/impl/edit.js", + "node_modules/jsonc-parser/lib/umd/impl/format.js", + "node_modules/jsonc-parser/lib/umd/impl/parser.js", + "node_modules/jsonc-parser/lib/umd/impl/scanner.js", + "node_modules/jsonc-parser/lib/umd/impl/string-intern.js", + "node_modules/jsonc-parser/lib/umd/main.d.ts", + "node_modules/jsonc-parser/lib/umd/main.js", + "node_modules/jsonc-parser/package.json", "node_modules/minimatch/LICENSE.md", "node_modules/minimatch/README.md", "node_modules/minimatch/dist/commonjs/assert-valid-pattern.d.ts", @@ -1005,6 +1184,28 @@ "node_modules/semver/ranges/subset.js", "node_modules/semver/ranges/to-comparators.js", "node_modules/semver/ranges/valid.js", + "node_modules/smol-toml/LICENSE", + "node_modules/smol-toml/README.md", + "node_modules/smol-toml/dist/date.d.ts", + "node_modules/smol-toml/dist/date.js", + "node_modules/smol-toml/dist/error.d.ts", + "node_modules/smol-toml/dist/error.js", + "node_modules/smol-toml/dist/extract.d.ts", + "node_modules/smol-toml/dist/extract.js", + "node_modules/smol-toml/dist/index.cjs", + "node_modules/smol-toml/dist/index.d.ts", + "node_modules/smol-toml/dist/index.js", + "node_modules/smol-toml/dist/parse.d.ts", + "node_modules/smol-toml/dist/parse.js", + "node_modules/smol-toml/dist/primitive.d.ts", + "node_modules/smol-toml/dist/primitive.js", + "node_modules/smol-toml/dist/stringify.d.ts", + "node_modules/smol-toml/dist/stringify.js", + "node_modules/smol-toml/dist/struct.d.ts", + "node_modules/smol-toml/dist/struct.js", + "node_modules/smol-toml/dist/util.d.ts", + "node_modules/smol-toml/dist/util.js", + "node_modules/smol-toml/package.json", "node_modules/tinyglobby/LICENSE", "node_modules/tinyglobby/README.md", "node_modules/tinyglobby/dist/index.cjs", @@ -1159,7 +1360,7 @@ "node_modules/typescript/package.json", "package.json" ], - "fileCount": 1055, + "fileCount": 1250, "expectedFiles": [ "README.md", "dist/advanced/asp-warm/asp-serve-cli.d.ts", @@ -1271,6 +1472,9 @@ "dist/scan-presentation.d.ts", "dist/scan-presentation.d.ts.map", "dist/scan-presentation.js", + "dist/scan-validation-preview.d.ts", + "dist/scan-validation-preview.d.ts.map", + "dist/scan-validation-preview.js", "dist/scan.d.ts", "dist/scan.d.ts.map", "dist/scan.js", @@ -1280,6 +1484,33 @@ "dist/source-policy.d.ts", "dist/source-policy.d.ts.map", "dist/source-policy.js", + "dist/status-args.d.ts", + "dist/status-args.d.ts.map", + "dist/status-args.js", + "dist/status-census.d.ts", + "dist/status-census.d.ts.map", + "dist/status-census.js", + "dist/status-coverage.d.ts", + "dist/status-coverage.d.ts.map", + "dist/status-coverage.js", + "dist/status-errors.d.ts", + "dist/status-errors.d.ts.map", + "dist/status-errors.js", + "dist/status-format.d.ts", + "dist/status-format.d.ts.map", + "dist/status-format.js", + "dist/status-git.d.ts", + "dist/status-git.d.ts.map", + "dist/status-git.js", + "dist/status-repo.d.ts", + "dist/status-repo.d.ts.map", + "dist/status-repo.js", + "dist/status-state.d.ts", + "dist/status-state.d.ts.map", + "dist/status-state.js", + "dist/status-validation.d.ts", + "dist/status-validation.d.ts.map", + "dist/status-validation.js", "dist/status.d.ts", "dist/status.d.ts.map", "dist/status.js", @@ -1295,6 +1526,9 @@ "dist/validation-composition.d.ts", "dist/validation-composition.d.ts.map", "dist/validation-composition.js", + "dist/validation-graph-session.d.ts", + "dist/validation-graph-session.d.ts.map", + "dist/validation-graph-session.js", "node_modules/@the-open-engine/opcore-asp-provider/README.md", "node_modules/@the-open-engine/opcore-asp-provider/dist/digests.d.ts", "node_modules/@the-open-engine/opcore-asp-provider/dist/digests.d.ts.map", @@ -1419,12 +1653,18 @@ "node_modules/@the-open-engine/opcore-graph/dist/artifact.d.ts", "node_modules/@the-open-engine/opcore-graph/dist/artifact.d.ts.map", "node_modules/@the-open-engine/opcore-graph/dist/artifact.js", + "node_modules/@the-open-engine/opcore-graph/dist/ephemeral-snapshot.d.ts", + "node_modules/@the-open-engine/opcore-graph/dist/ephemeral-snapshot.d.ts.map", + "node_modules/@the-open-engine/opcore-graph/dist/ephemeral-snapshot.js", "node_modules/@the-open-engine/opcore-graph/dist/index.d.ts", "node_modules/@the-open-engine/opcore-graph/dist/index.d.ts.map", "node_modules/@the-open-engine/opcore-graph/dist/index.js", "node_modules/@the-open-engine/opcore-graph/dist/native-targets.d.ts", "node_modules/@the-open-engine/opcore-graph/dist/native-targets.d.ts.map", "node_modules/@the-open-engine/opcore-graph/dist/native-targets.js", + "node_modules/@the-open-engine/opcore-graph/dist/python-import-analysis.d.ts", + "node_modules/@the-open-engine/opcore-graph/dist/python-import-analysis.d.ts.map", + "node_modules/@the-open-engine/opcore-graph/dist/python-import-analysis.js", "node_modules/@the-open-engine/opcore-graph/dist/serve.d.ts", "node_modules/@the-open-engine/opcore-graph/dist/serve.d.ts.map", "node_modules/@the-open-engine/opcore-graph/dist/serve.js", @@ -1502,42 +1742,135 @@ "node_modules/@the-open-engine/opcore-validation-python/dist/check-ids.d.ts", "node_modules/@the-open-engine/opcore-validation-python/dist/check-ids.d.ts.map", "node_modules/@the-open-engine/opcore-validation-python/dist/check-ids.js", + "node_modules/@the-open-engine/opcore-validation-python/dist/compiler-protocol.d.ts", + "node_modules/@the-open-engine/opcore-validation-python/dist/compiler-protocol.d.ts.map", + "node_modules/@the-open-engine/opcore-validation-python/dist/compiler-protocol.js", "node_modules/@the-open-engine/opcore-validation-python/dist/dead-code-check.d.ts", "node_modules/@the-open-engine/opcore-validation-python/dist/dead-code-check.d.ts.map", "node_modules/@the-open-engine/opcore-validation-python/dist/dead-code-check.js", "node_modules/@the-open-engine/opcore-validation-python/dist/diagnostics.d.ts", "node_modules/@the-open-engine/opcore-validation-python/dist/diagnostics.d.ts.map", "node_modules/@the-open-engine/opcore-validation-python/dist/diagnostics.js", + "node_modules/@the-open-engine/opcore-validation-python/dist/environment-resolution.d.ts", + "node_modules/@the-open-engine/opcore-validation-python/dist/environment-resolution.d.ts.map", + "node_modules/@the-open-engine/opcore-validation-python/dist/environment-resolution.js", "node_modules/@the-open-engine/opcore-validation-python/dist/graph-requirements.d.ts", "node_modules/@the-open-engine/opcore-validation-python/dist/graph-requirements.d.ts.map", "node_modules/@the-open-engine/opcore-validation-python/dist/graph-requirements.js", + "node_modules/@the-open-engine/opcore-validation-python/dist/import-analysis.d.ts", + "node_modules/@the-open-engine/opcore-validation-python/dist/import-analysis.d.ts.map", + "node_modules/@the-open-engine/opcore-validation-python/dist/import-analysis.js", "node_modules/@the-open-engine/opcore-validation-python/dist/import-graph-check.d.ts", "node_modules/@the-open-engine/opcore-validation-python/dist/import-graph-check.d.ts.map", "node_modules/@the-open-engine/opcore-validation-python/dist/import-graph-check.js", "node_modules/@the-open-engine/opcore-validation-python/dist/index.d.ts", "node_modules/@the-open-engine/opcore-validation-python/dist/index.d.ts.map", "node_modules/@the-open-engine/opcore-validation-python/dist/index.js", + "node_modules/@the-open-engine/opcore-validation-python/dist/ini-config.d.ts", + "node_modules/@the-open-engine/opcore-validation-python/dist/ini-config.d.ts.map", + "node_modules/@the-open-engine/opcore-validation-python/dist/ini-config.js", + "node_modules/@the-open-engine/opcore-validation-python/dist/mypy-config-values.d.ts", + "node_modules/@the-open-engine/opcore-validation-python/dist/mypy-config-values.d.ts.map", + "node_modules/@the-open-engine/opcore-validation-python/dist/mypy-config-values.js", + "node_modules/@the-open-engine/opcore-validation-python/dist/mypy-config.d.ts", + "node_modules/@the-open-engine/opcore-validation-python/dist/mypy-config.d.ts.map", + "node_modules/@the-open-engine/opcore-validation-python/dist/mypy-config.js", + "node_modules/@the-open-engine/opcore-validation-python/dist/mypy-output.d.ts", + "node_modules/@the-open-engine/opcore-validation-python/dist/mypy-output.d.ts.map", + "node_modules/@the-open-engine/opcore-validation-python/dist/mypy-output.js", + "node_modules/@the-open-engine/opcore-validation-python/dist/mypy-result.d.ts", + "node_modules/@the-open-engine/opcore-validation-python/dist/mypy-result.d.ts.map", + "node_modules/@the-open-engine/opcore-validation-python/dist/mypy-result.js", + "node_modules/@the-open-engine/opcore-validation-python/dist/mypy-runner-types.d.ts", + "node_modules/@the-open-engine/opcore-validation-python/dist/mypy-runner-types.d.ts.map", + "node_modules/@the-open-engine/opcore-validation-python/dist/mypy-runner-types.js", + "node_modules/@the-open-engine/opcore-validation-python/dist/mypy-runner.d.ts", + "node_modules/@the-open-engine/opcore-validation-python/dist/mypy-runner.d.ts.map", + "node_modules/@the-open-engine/opcore-validation-python/dist/mypy-runner.js", "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/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", + "node_modules/@the-open-engine/opcore-validation-python/dist/project-context.d.ts", + "node_modules/@the-open-engine/opcore-validation-python/dist/project-context.d.ts.map", + "node_modules/@the-open-engine/opcore-validation-python/dist/project-context.js", + "node_modules/@the-open-engine/opcore-validation-python/dist/project-discovery.d.ts", + "node_modules/@the-open-engine/opcore-validation-python/dist/project-discovery.d.ts.map", + "node_modules/@the-open-engine/opcore-validation-python/dist/project-discovery.js", + "node_modules/@the-open-engine/opcore-validation-python/dist/project-fingerprint.d.ts", + "node_modules/@the-open-engine/opcore-validation-python/dist/project-fingerprint.d.ts.map", + "node_modules/@the-open-engine/opcore-validation-python/dist/project-fingerprint.js", + "node_modules/@the-open-engine/opcore-validation-python/dist/project-workspace.d.ts", + "node_modules/@the-open-engine/opcore-validation-python/dist/project-workspace.d.ts.map", + "node_modules/@the-open-engine/opcore-validation-python/dist/project-workspace.js", + "node_modules/@the-open-engine/opcore-validation-python/dist/protocol-validation.d.ts", + "node_modules/@the-open-engine/opcore-validation-python/dist/protocol-validation.d.ts.map", + "node_modules/@the-open-engine/opcore-validation-python/dist/protocol-validation.js", + "node_modules/@the-open-engine/opcore-validation-python/dist/pyright-config-values.d.ts", + "node_modules/@the-open-engine/opcore-validation-python/dist/pyright-config-values.d.ts.map", + "node_modules/@the-open-engine/opcore-validation-python/dist/pyright-config-values.js", + "node_modules/@the-open-engine/opcore-validation-python/dist/pyright-config.d.ts", + "node_modules/@the-open-engine/opcore-validation-python/dist/pyright-config.d.ts.map", + "node_modules/@the-open-engine/opcore-validation-python/dist/pyright-config.js", + "node_modules/@the-open-engine/opcore-validation-python/dist/pyright-output.d.ts", + "node_modules/@the-open-engine/opcore-validation-python/dist/pyright-output.d.ts.map", + "node_modules/@the-open-engine/opcore-validation-python/dist/pyright-output.js", + "node_modules/@the-open-engine/opcore-validation-python/dist/pyright-runner.d.ts", + "node_modules/@the-open-engine/opcore-validation-python/dist/pyright-runner.d.ts.map", + "node_modules/@the-open-engine/opcore-validation-python/dist/pyright-runner.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", + "node_modules/@the-open-engine/opcore-validation-python/dist/source-closure.d.ts", + "node_modules/@the-open-engine/opcore-validation-python/dist/source-closure.d.ts.map", + "node_modules/@the-open-engine/opcore-validation-python/dist/source-closure.js", "node_modules/@the-open-engine/opcore-validation-python/dist/source-files.d.ts", "node_modules/@the-open-engine/opcore-validation-python/dist/source-files.d.ts.map", "node_modules/@the-open-engine/opcore-validation-python/dist/source-files.js", "node_modules/@the-open-engine/opcore-validation-python/dist/source-hygiene-check.d.ts", "node_modules/@the-open-engine/opcore-validation-python/dist/source-hygiene-check.d.ts.map", "node_modules/@the-open-engine/opcore-validation-python/dist/source-hygiene-check.js", + "node_modules/@the-open-engine/opcore-validation-python/dist/source-types.d.ts", + "node_modules/@the-open-engine/opcore-validation-python/dist/source-types.d.ts.map", + "node_modules/@the-open-engine/opcore-validation-python/dist/source-types.js", + "node_modules/@the-open-engine/opcore-validation-python/dist/static-config.d.ts", + "node_modules/@the-open-engine/opcore-validation-python/dist/static-config.d.ts.map", + "node_modules/@the-open-engine/opcore-validation-python/dist/static-config.js", + "node_modules/@the-open-engine/opcore-validation-python/dist/strict-json.d.ts", + "node_modules/@the-open-engine/opcore-validation-python/dist/strict-json.d.ts.map", + "node_modules/@the-open-engine/opcore-validation-python/dist/strict-json.js", "node_modules/@the-open-engine/opcore-validation-python/dist/syntax-check.d.ts", "node_modules/@the-open-engine/opcore-validation-python/dist/syntax-check.d.ts.map", "node_modules/@the-open-engine/opcore-validation-python/dist/syntax-check.js", + "node_modules/@the-open-engine/opcore-validation-python/dist/toml-config.d.ts", + "node_modules/@the-open-engine/opcore-validation-python/dist/toml-config.d.ts.map", + "node_modules/@the-open-engine/opcore-validation-python/dist/toml-config.js", "node_modules/@the-open-engine/opcore-validation-python/dist/toolchain.d.ts", "node_modules/@the-open-engine/opcore-validation-python/dist/toolchain.d.ts.map", "node_modules/@the-open-engine/opcore-validation-python/dist/toolchain.js", + "node_modules/@the-open-engine/opcore-validation-python/dist/type-authority.d.ts", + "node_modules/@the-open-engine/opcore-validation-python/dist/type-authority.d.ts.map", + "node_modules/@the-open-engine/opcore-validation-python/dist/type-authority.js", + "node_modules/@the-open-engine/opcore-validation-python/dist/type-capability-run.d.ts", + "node_modules/@the-open-engine/opcore-validation-python/dist/type-capability-run.d.ts.map", + "node_modules/@the-open-engine/opcore-validation-python/dist/type-capability-run.js", "node_modules/@the-open-engine/opcore-validation-python/dist/type-check.d.ts", "node_modules/@the-open-engine/opcore-validation-python/dist/type-check.d.ts.map", "node_modules/@the-open-engine/opcore-validation-python/dist/type-check.js", + "node_modules/@the-open-engine/opcore-validation-python/dist/type-result.d.ts", + "node_modules/@the-open-engine/opcore-validation-python/dist/type-result.d.ts.map", + "node_modules/@the-open-engine/opcore-validation-python/dist/type-result.js", + "node_modules/@the-open-engine/opcore-validation-python/dist/type-runner-runtime.d.ts", + "node_modules/@the-open-engine/opcore-validation-python/dist/type-runner-runtime.d.ts.map", + "node_modules/@the-open-engine/opcore-validation-python/dist/type-runner-runtime.js", + "node_modules/@the-open-engine/opcore-validation-python/dist/type-runner-types.d.ts", + "node_modules/@the-open-engine/opcore-validation-python/dist/type-runner-types.d.ts.map", + "node_modules/@the-open-engine/opcore-validation-python/dist/type-runner-types.js", + "node_modules/@the-open-engine/opcore-validation-python/dist/version-constraint.d.ts", + "node_modules/@the-open-engine/opcore-validation-python/dist/version-constraint.d.ts.map", + "node_modules/@the-open-engine/opcore-validation-python/dist/version-constraint.js", "node_modules/@the-open-engine/opcore-validation-python/package.json", "node_modules/@the-open-engine/opcore-validation-rust/README.md", "node_modules/@the-open-engine/opcore-validation-rust/dist/cargo-check.d.ts", @@ -1614,12 +1947,24 @@ "node_modules/@the-open-engine/opcore-validation-typescript/dist/compiler-options.d.ts", "node_modules/@the-open-engine/opcore-validation-typescript/dist/compiler-options.d.ts.map", "node_modules/@the-open-engine/opcore-validation-typescript/dist/compiler-options.js", + "node_modules/@the-open-engine/opcore-validation-typescript/dist/dead-code-analysis.d.ts", + "node_modules/@the-open-engine/opcore-validation-typescript/dist/dead-code-analysis.d.ts.map", + "node_modules/@the-open-engine/opcore-validation-typescript/dist/dead-code-analysis.js", "node_modules/@the-open-engine/opcore-validation-typescript/dist/dead-code-check.d.ts", "node_modules/@the-open-engine/opcore-validation-typescript/dist/dead-code-check.d.ts.map", "node_modules/@the-open-engine/opcore-validation-typescript/dist/dead-code-check.js", "node_modules/@the-open-engine/opcore-validation-typescript/dist/dead-code-entrypoints.d.ts", "node_modules/@the-open-engine/opcore-validation-typescript/dist/dead-code-entrypoints.d.ts.map", "node_modules/@the-open-engine/opcore-validation-typescript/dist/dead-code-entrypoints.js", + "node_modules/@the-open-engine/opcore-validation-typescript/dist/dead-code-import-evidence.d.ts", + "node_modules/@the-open-engine/opcore-validation-typescript/dist/dead-code-import-evidence.d.ts.map", + "node_modules/@the-open-engine/opcore-validation-typescript/dist/dead-code-import-evidence.js", + "node_modules/@the-open-engine/opcore-validation-typescript/dist/dead-code-roots.d.ts", + "node_modules/@the-open-engine/opcore-validation-typescript/dist/dead-code-roots.d.ts.map", + "node_modules/@the-open-engine/opcore-validation-typescript/dist/dead-code-roots.js", + "node_modules/@the-open-engine/opcore-validation-typescript/dist/dead-code-run.d.ts", + "node_modules/@the-open-engine/opcore-validation-typescript/dist/dead-code-run.d.ts.map", + "node_modules/@the-open-engine/opcore-validation-typescript/dist/dead-code-run.js", "node_modules/@the-open-engine/opcore-validation-typescript/dist/diagnostics.d.ts", "node_modules/@the-open-engine/opcore-validation-typescript/dist/diagnostics.d.ts.map", "node_modules/@the-open-engine/opcore-validation-typescript/dist/diagnostics.js", @@ -1694,6 +2039,9 @@ "node_modules/@the-open-engine/opcore-validation/dist/request.d.ts", "node_modules/@the-open-engine/opcore-validation/dist/request.d.ts.map", "node_modules/@the-open-engine/opcore-validation/dist/request.js", + "node_modules/@the-open-engine/opcore-validation/dist/resources.d.ts", + "node_modules/@the-open-engine/opcore-validation/dist/resources.d.ts.map", + "node_modules/@the-open-engine/opcore-validation/dist/resources.js", "node_modules/@the-open-engine/opcore-validation/dist/runner.d.ts", "node_modules/@the-open-engine/opcore-validation/dist/runner.d.ts.map", "node_modules/@the-open-engine/opcore-validation/dist/runner.js", @@ -1901,6 +2249,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", @@ -1924,6 +2279,25 @@ "node_modules/fdir/dist/index.d.mts", "node_modules/fdir/dist/index.mjs", "node_modules/fdir/package.json", + "node_modules/jsonc-parser/CHANGELOG.md", + "node_modules/jsonc-parser/LICENSE.md", + "node_modules/jsonc-parser/README.md", + "node_modules/jsonc-parser/SECURITY.md", + "node_modules/jsonc-parser/lib/esm/impl/edit.js", + "node_modules/jsonc-parser/lib/esm/impl/format.js", + "node_modules/jsonc-parser/lib/esm/impl/parser.js", + "node_modules/jsonc-parser/lib/esm/impl/scanner.js", + "node_modules/jsonc-parser/lib/esm/impl/string-intern.js", + "node_modules/jsonc-parser/lib/esm/main.d.ts", + "node_modules/jsonc-parser/lib/esm/main.js", + "node_modules/jsonc-parser/lib/umd/impl/edit.js", + "node_modules/jsonc-parser/lib/umd/impl/format.js", + "node_modules/jsonc-parser/lib/umd/impl/parser.js", + "node_modules/jsonc-parser/lib/umd/impl/scanner.js", + "node_modules/jsonc-parser/lib/umd/impl/string-intern.js", + "node_modules/jsonc-parser/lib/umd/main.d.ts", + "node_modules/jsonc-parser/lib/umd/main.js", + "node_modules/jsonc-parser/package.json", "node_modules/minimatch/LICENSE.md", "node_modules/minimatch/README.md", "node_modules/minimatch/dist/commonjs/assert-valid-pattern.d.ts", @@ -2063,6 +2437,28 @@ "node_modules/semver/ranges/subset.js", "node_modules/semver/ranges/to-comparators.js", "node_modules/semver/ranges/valid.js", + "node_modules/smol-toml/LICENSE", + "node_modules/smol-toml/README.md", + "node_modules/smol-toml/dist/date.d.ts", + "node_modules/smol-toml/dist/date.js", + "node_modules/smol-toml/dist/error.d.ts", + "node_modules/smol-toml/dist/error.js", + "node_modules/smol-toml/dist/extract.d.ts", + "node_modules/smol-toml/dist/extract.js", + "node_modules/smol-toml/dist/index.cjs", + "node_modules/smol-toml/dist/index.d.ts", + "node_modules/smol-toml/dist/index.js", + "node_modules/smol-toml/dist/parse.d.ts", + "node_modules/smol-toml/dist/parse.js", + "node_modules/smol-toml/dist/primitive.d.ts", + "node_modules/smol-toml/dist/primitive.js", + "node_modules/smol-toml/dist/stringify.d.ts", + "node_modules/smol-toml/dist/stringify.js", + "node_modules/smol-toml/dist/struct.d.ts", + "node_modules/smol-toml/dist/struct.js", + "node_modules/smol-toml/dist/util.d.ts", + "node_modules/smol-toml/dist/util.js", + "node_modules/smol-toml/package.json", "node_modules/tinyglobby/LICENSE", "node_modules/tinyglobby/README.md", "node_modules/tinyglobby/dist/index.cjs", @@ -2217,7 +2613,7 @@ "node_modules/typescript/package.json", "package.json" ], - "expectedFileCount": 1055, + "expectedFileCount": 1250, "bins": { "opcore": "dist/index.js", "opcore-asp-provider": "dist/asp-provider-bin.js" @@ -2318,19 +2714,19 @@ "targetPlatform": "darwin-arm64", "metadata": { "artifactName": "opcore-graph-core", - "artifactVersion": "0.2.0", + "artifactVersion": "0.2.1", "targetPlatform": "darwin-arm64", "binaryPath": "opcore-graph-core", "checksumPath": "opcore-graph-core.sha256", - "checksumSha256": "c222610c492e628fa48ff50be22310e5bf8ebcd62bffbde1903d1fa91c45f6cb", + "checksumSha256": "72acf10de0ece619c3896943addfb0cc1b09dab22000b48a653b033224217bc8", "buildProfile": "release" }, "binaryPath": "node_modules/@the-open-engine/opcore-graph-core-darwin-arm64/opcore-graph-core", "checksumPath": "node_modules/@the-open-engine/opcore-graph-core-darwin-arm64/opcore-graph-core.sha256", "metadataPath": "node_modules/@the-open-engine/opcore-graph-core-darwin-arm64/metadata.json", - "binarySha256": "c222610c492e628fa48ff50be22310e5bf8ebcd62bffbde1903d1fa91c45f6cb", - "checksumFileSha256": "794844f23f2c3ddd415b3a58d07b41a6a1c4ad643be0b6ae2a6585d0e4e057fe", - "metadataSha256": "4f2817e526b57f3036a7595cbc2a8ad94836f32a87f315f866080b0d9855c266", + "binarySha256": "72acf10de0ece619c3896943addfb0cc1b09dab22000b48a653b033224217bc8", + "checksumFileSha256": "8c604dbcec287dd6a46a5949872bcc69ecb7d0274377da97c43a89b8f0907fca", + "metadataSha256": "9c3fdfc4b574e6cb2bc6786a5c720e6fb1f1cbe0895906ab0713d614439ec4fa", "descriptorArtifactId": "graph-core-binary-darwin-arm64", "descriptorChecksumId": "graph-core-binary-sha256-darwin-arm64" }, @@ -2340,7 +2736,7 @@ "targetPlatform": "darwin-x64", "metadata": { "artifactName": "opcore-graph-core", - "artifactVersion": "0.2.0", + "artifactVersion": "0.2.1", "targetPlatform": "darwin-x64", "binaryPath": "opcore-graph-core", "checksumPath": "opcore-graph-core.sha256", @@ -2352,7 +2748,7 @@ "metadataPath": "node_modules/@the-open-engine/opcore-graph-core-darwin-x64/metadata.json", "binarySha256": "1b72afa74d48b087c967cb01b39630ed3fa58b5a2895037a778eb5612ef73059", "checksumFileSha256": "78e1e2efd92e4c3f184b19263b8e8c161fda919c180638906808afdf3b2d312d", - "metadataSha256": "1892fae547da3aca3feb60bfba1be52b9dcfd4f5dfc9e4da0cb923cfd2a4792a", + "metadataSha256": "b0995ddb2769a57cd3a07510efe90ed1788e5f55f48f10b0e46ac3c81f53e175", "descriptorArtifactId": "graph-core-binary-darwin-x64", "descriptorChecksumId": "graph-core-binary-sha256-darwin-x64" }, @@ -2362,19 +2758,19 @@ "targetPlatform": "linux-x64", "metadata": { "artifactName": "opcore-graph-core", - "artifactVersion": "0.2.0", + "artifactVersion": "0.2.1", "targetPlatform": "linux-x64", "binaryPath": "opcore-graph-core", "checksumPath": "opcore-graph-core.sha256", - "checksumSha256": "70cb6d213d74594f337a67522c879b77767872dc4601063686f1abe70886127b", + "checksumSha256": "c3a771435a7a8172a9e5f0bb20b0d111453efc70901e95308094664f77e7bdf1", "buildProfile": "release" }, "binaryPath": "node_modules/@the-open-engine/opcore-graph-core-linux-x64/opcore-graph-core", "checksumPath": "node_modules/@the-open-engine/opcore-graph-core-linux-x64/opcore-graph-core.sha256", "metadataPath": "node_modules/@the-open-engine/opcore-graph-core-linux-x64/metadata.json", - "binarySha256": "70cb6d213d74594f337a67522c879b77767872dc4601063686f1abe70886127b", - "checksumFileSha256": "c2910f2077d0e824fa838a3565114bab7c63910dcee51221e64d37c0aee20b19", - "metadataSha256": "d851e010080be2505824d89a49cbe2b1bd6709854d380bd3f39e8c58c2114d90", + "binarySha256": "c3a771435a7a8172a9e5f0bb20b0d111453efc70901e95308094664f77e7bdf1", + "checksumFileSha256": "4f1fe963a88779474c8c8c0ab20b84dc16ed604ad7573a7dadb17795c8ef37c9", + "metadataSha256": "2d6abe4b7651c879a1db63932f73c2c6af83bcf0db44f7d87bbde651d129f9e0", "descriptorArtifactId": "graph-core-binary-linux-x64", "descriptorChecksumId": "graph-core-binary-sha256-linux-x64" } @@ -2384,7 +2780,7 @@ "descriptor": { "path": "packages/opcore/dist/descriptors/opcore.managed-tool.json", "packageName": "opcore", - "checksumSha256": "282c6a8f4f4adf097eacc40d818f32340acc0b6fb4b0e14845e3327525806d12", + "checksumSha256": "8ae0a226d8f320ec3e785d0bf0ec892ed4f51b7ad7161d6cdd9fc0018eb73dd1", "descriptor": { "schemaVersion": 1, "descriptorKind": "aggregate_opcore", @@ -2392,12 +2788,12 @@ "name": "opcore", "releaseLine": "opcore", "packageName": "opcore", - "version": "0.2.0" + "version": "0.2.1" }, "packageIdentity": { "packageName": "opcore", "artifactName": "opcore", - "version": "0.2.0" + "version": "0.2.1" }, "entrypoints": [ { @@ -2721,6 +3117,17 @@ "status", "doctor" ], + "pythonProjectContext": { + "schemaId": "opcore.python.project-context.v1", + "outcomes": [ + "resolved", + "degraded", + "unsupported", + "ambiguous" + ], + "readOnly": true, + "installs": false + }, "writeGate": { "initScopes": [ "repo", @@ -3114,7 +3521,7 @@ "artifactRef": "graph-core-binary-darwin-arm64", "required": true, "packageFile": true, - "value": "c222610c492e628fa48ff50be22310e5bf8ebcd62bffbde1903d1fa91c45f6cb" + "value": "72acf10de0ece619c3896943addfb0cc1b09dab22000b48a653b033224217bc8" }, { "id": "graph-core-binary-sha256-darwin-x64", @@ -3134,7 +3541,7 @@ "artifactRef": "graph-core-binary-linux-x64", "required": true, "packageFile": true, - "value": "70cb6d213d74594f337a67522c879b77767872dc4601063686f1abe70886127b" + "value": "c3a771435a7a8172a9e5f0bb20b0d111453efc70901e95308094664f77e7bdf1" } ] }, @@ -3145,19 +3552,19 @@ "targetPlatform": "darwin-arm64", "metadata": { "artifactName": "opcore-graph-core", - "artifactVersion": "0.2.0", + "artifactVersion": "0.2.1", "targetPlatform": "darwin-arm64", "binaryPath": "opcore-graph-core", "checksumPath": "opcore-graph-core.sha256", - "checksumSha256": "c222610c492e628fa48ff50be22310e5bf8ebcd62bffbde1903d1fa91c45f6cb", + "checksumSha256": "72acf10de0ece619c3896943addfb0cc1b09dab22000b48a653b033224217bc8", "buildProfile": "release" }, "binaryPath": "node_modules/@the-open-engine/opcore-graph-core-darwin-arm64/opcore-graph-core", "checksumPath": "node_modules/@the-open-engine/opcore-graph-core-darwin-arm64/opcore-graph-core.sha256", "metadataPath": "node_modules/@the-open-engine/opcore-graph-core-darwin-arm64/metadata.json", - "binarySha256": "c222610c492e628fa48ff50be22310e5bf8ebcd62bffbde1903d1fa91c45f6cb", - "checksumFileSha256": "794844f23f2c3ddd415b3a58d07b41a6a1c4ad643be0b6ae2a6585d0e4e057fe", - "metadataSha256": "4f2817e526b57f3036a7595cbc2a8ad94836f32a87f315f866080b0d9855c266", + "binarySha256": "72acf10de0ece619c3896943addfb0cc1b09dab22000b48a653b033224217bc8", + "checksumFileSha256": "8c604dbcec287dd6a46a5949872bcc69ecb7d0274377da97c43a89b8f0907fca", + "metadataSha256": "9c3fdfc4b574e6cb2bc6786a5c720e6fb1f1cbe0895906ab0713d614439ec4fa", "descriptorArtifactId": "graph-core-binary-darwin-arm64", "descriptorChecksumId": "graph-core-binary-sha256-darwin-arm64" }, @@ -3167,7 +3574,7 @@ "targetPlatform": "darwin-x64", "metadata": { "artifactName": "opcore-graph-core", - "artifactVersion": "0.2.0", + "artifactVersion": "0.2.1", "targetPlatform": "darwin-x64", "binaryPath": "opcore-graph-core", "checksumPath": "opcore-graph-core.sha256", @@ -3179,7 +3586,7 @@ "metadataPath": "node_modules/@the-open-engine/opcore-graph-core-darwin-x64/metadata.json", "binarySha256": "1b72afa74d48b087c967cb01b39630ed3fa58b5a2895037a778eb5612ef73059", "checksumFileSha256": "78e1e2efd92e4c3f184b19263b8e8c161fda919c180638906808afdf3b2d312d", - "metadataSha256": "1892fae547da3aca3feb60bfba1be52b9dcfd4f5dfc9e4da0cb923cfd2a4792a", + "metadataSha256": "b0995ddb2769a57cd3a07510efe90ed1788e5f55f48f10b0e46ac3c81f53e175", "descriptorArtifactId": "graph-core-binary-darwin-x64", "descriptorChecksumId": "graph-core-binary-sha256-darwin-x64" }, @@ -3189,28 +3596,28 @@ "targetPlatform": "linux-x64", "metadata": { "artifactName": "opcore-graph-core", - "artifactVersion": "0.2.0", + "artifactVersion": "0.2.1", "targetPlatform": "linux-x64", "binaryPath": "opcore-graph-core", "checksumPath": "opcore-graph-core.sha256", - "checksumSha256": "70cb6d213d74594f337a67522c879b77767872dc4601063686f1abe70886127b", + "checksumSha256": "c3a771435a7a8172a9e5f0bb20b0d111453efc70901e95308094664f77e7bdf1", "buildProfile": "release" }, "binaryPath": "node_modules/@the-open-engine/opcore-graph-core-linux-x64/opcore-graph-core", "checksumPath": "node_modules/@the-open-engine/opcore-graph-core-linux-x64/opcore-graph-core.sha256", "metadataPath": "node_modules/@the-open-engine/opcore-graph-core-linux-x64/metadata.json", - "binarySha256": "70cb6d213d74594f337a67522c879b77767872dc4601063686f1abe70886127b", - "checksumFileSha256": "c2910f2077d0e824fa838a3565114bab7c63910dcee51221e64d37c0aee20b19", - "metadataSha256": "d851e010080be2505824d89a49cbe2b1bd6709854d380bd3f39e8c58c2114d90", + "binarySha256": "c3a771435a7a8172a9e5f0bb20b0d111453efc70901e95308094664f77e7bdf1", + "checksumFileSha256": "4f1fe963a88779474c8c8c0ab20b84dc16ed604ad7573a7dadb17795c8ef37c9", + "metadataSha256": "2d6abe4b7651c879a1db63932f73c2c6af83bcf0db44f7d87bbde651d129f9e0", "descriptorArtifactId": "graph-core-binary-linux-x64", "descriptorChecksumId": "graph-core-binary-sha256-linux-x64" } ], "license": { "reportPath": "docs/release/license-report.md", - "reportSha256": "f2d943a3e3c5caf7caf54988734554b9397aed2d7def5b08e4a9f11bdc8f433d", - "productionDependencyCount": 118, - "bundledDependencyCount": 35, + "reportSha256": "bad9cae3266e2ad7b866eba223f05cedfcd8332d2e3c44656381e70e335e8773", + "productionDependencyCount": 119, + "bundledDependencyCount": 38, "workspacePackageCount": 1, "unresolvedLicenseCount": 0, "packages": [ @@ -3809,6 +4216,13 @@ "source": "node_modules/json-stable-stringify-without-jsonify", "bundled": false }, + { + "name": "jsonc-parser", + "version": "3.3.1", + "license": "MIT", + "source": "node_modules/jsonc-parser", + "bundled": false + }, { "name": "keyv", "version": "4.5.4", @@ -4042,98 +4456,98 @@ }, { "name": "@the-open-engine/opcore-asp-provider", - "version": "0.2.0", + "version": "0.2.1", "license": "MIT", "source": "packages/asp-provider/package.json", "bundled": true }, { "name": "@the-open-engine/opcore-contracts", - "version": "0.2.0", + "version": "0.2.1", "license": "MIT", "source": "packages/contracts/package.json", "bundled": true }, { "name": "@the-open-engine/opcore-edit", - "version": "0.2.0", + "version": "0.2.1", "license": "MIT", "source": "packages/edit/package.json", "bundled": true }, { "name": "@the-open-engine/opcore-graph", - "version": "0.2.0", + "version": "0.2.1", "license": "MIT", "source": "packages/graph/package.json", "bundled": true }, { "name": "@the-open-engine/opcore-graph-core-darwin-arm64", - "version": "0.2.0", + "version": "0.2.1", "license": "MIT", "source": "packages/opcore-graph-core-darwin-arm64/package.json", "bundled": true }, { "name": "@the-open-engine/opcore-graph-core-darwin-x64", - "version": "0.2.0", + "version": "0.2.1", "license": "MIT", "source": "packages/opcore-graph-core-darwin-x64/package.json", "bundled": true }, { "name": "@the-open-engine/opcore-graph-core-linux-x64", - "version": "0.2.0", + "version": "0.2.1", "license": "MIT", "source": "packages/opcore-graph-core-linux-x64/package.json", "bundled": true }, { "name": "@the-open-engine/opcore-validation", - "version": "0.2.0", + "version": "0.2.1", "license": "MIT", "source": "packages/validation/package.json", "bundled": true }, { "name": "@the-open-engine/opcore-validation-clone", - "version": "0.2.0", + "version": "0.2.1", "license": "MIT", "source": "packages/validation-clone/package.json", "bundled": true }, { "name": "@the-open-engine/opcore-validation-docs", - "version": "0.2.0", + "version": "0.2.1", "license": "MIT", "source": "packages/validation-docs/package.json", "bundled": true }, { "name": "@the-open-engine/opcore-validation-policy", - "version": "0.2.0", + "version": "0.2.1", "license": "MIT", "source": "packages/validation-policy/package.json", "bundled": true }, { "name": "@the-open-engine/opcore-validation-python", - "version": "0.2.0", + "version": "0.2.1", "license": "MIT", "source": "packages/validation-python/package.json", "bundled": true }, { "name": "@the-open-engine/opcore-validation-rust", - "version": "0.2.0", + "version": "0.2.1", "license": "MIT", "source": "packages/validation-rust/package.json", "bundled": true }, { "name": "@the-open-engine/opcore-validation-typescript", - "version": "0.2.0", + "version": "0.2.1", "license": "MIT", "source": "packages/validation-typescript/package.json", "bundled": true @@ -4201,6 +4615,13 @@ "source": "node_modules/code-block-writer", "bundled": true }, + { + "name": "concat-map", + "version": "0.0.1", + "license": "MIT", + "source": "node_modules/concat-map", + "bundled": true + }, { "name": "debug", "version": "4.4.3", @@ -4222,6 +4643,13 @@ "source": "node_modules/fdir", "bundled": true }, + { + "name": "jsonc-parser", + "version": "3.3.1", + "license": "MIT", + "source": "node_modules/jsonc-parser", + "bundled": true + }, { "name": "minimatch", "version": "10.2.5", @@ -4257,6 +4685,13 @@ "source": "node_modules/semver", "bundled": true }, + { + "name": "smol-toml", + "version": "1.6.0", + "license": "BSD-3-Clause", + "source": "packages/opcore/node_modules/smol-toml", + "bundled": true + }, { "name": "tinyglobby", "version": "0.2.17", @@ -4289,17 +4724,17 @@ }, "provenance": { "reportPath": "docs/release/provenance-receipts.md", - "reportSha256": "c13858047a9e80843b15bb89f0a1b97e6c3a5b83e0d3d6950da8c16623966f9c", - "scannedFileCount": 551, - "historyCommitCount": 248, + "reportSha256": "0c0e0092c26d883fd6451f07d860ff8a3b9dd8f8179356b8659787f9da93597d", + "scannedFileCount": 695, + "historyCommitCount": 305, "findingCount": 0, "findings": [] }, "secretHistory": { "allowlistPath": "docs/release/secret-scan-allowlist.json", "allowlistSha256": "7060c267c912e3156b87c646e3bc970eac1ac8bdd964cf0a794b83e6f1250614", - "currentTreeScannedFileCount": 548, - "gitHistoryScannedCommitCount": 248, + "currentTreeScannedFileCount": 692, + "gitHistoryScannedCommitCount": 305, "findingCount": 0, "findings": [] }, @@ -4326,8 +4761,8 @@ "status": "passed", "exitCode": 0, "path": "docs/release/license-report.md", - "checksumSha256": "f2d943a3e3c5caf7caf54988734554b9397aed2d7def5b08e4a9f11bdc8f433d", - "summary": "118 production dependencies, 0 unresolved" + "checksumSha256": "bad9cae3266e2ad7b866eba223f05cedfcd8332d2e3c44656381e70e335e8773", + "summary": "119 production dependencies, 0 unresolved" }, { "id": "provenance", @@ -4339,8 +4774,8 @@ "status": "passed", "exitCode": 0, "path": "docs/release/provenance-receipts.md", - "checksumSha256": "c13858047a9e80843b15bb89f0a1b97e6c3a5b83e0d3d6950da8c16623966f9c", - "summary": "551 files, 248 commits scanned" + "checksumSha256": "0c0e0092c26d883fd6451f07d860ff8a3b9dd8f8179356b8659787f9da93597d", + "summary": "695 files, 305 commits scanned" }, { "id": "release-hygiene", @@ -4351,7 +4786,7 @@ "status": "passed", "exitCode": 0, "path": "docs/release/artifact-attestation.md", - "checksumSha256": "ae55ec7440ea15bc8477a81010381c8dcb8e540b2f4fdf276c5883acb76159e1", + "checksumSha256": "ab68c5abd9148dad24b344e54cd2a52ac19610a13ccf386b00fa853d8587547f", "summary": "release hygiene check passed" }, { @@ -4364,7 +4799,7 @@ "status": "passed", "exitCode": 0, "path": "docs/release/graph-release-receipt.json", - "checksumSha256": "016134bf577c850906fcf7d28307a501024b01679d2ef63ae451655a368e5949", + "checksumSha256": "fee81c57608f2c63ddbfc23d53490760d03a5df49fe69e3849860db0599e852f", "summary": "graph release receipt #17 validated as input evidence" }, { @@ -4378,12 +4813,12 @@ "exitCode": 0, "path": "docs/release/secret-scan-allowlist.json", "checksumSha256": "7060c267c912e3156b87c646e3bc970eac1ac8bdd964cf0a794b83e6f1250614", - "summary": "548 files, 248 commits scanned" + "summary": "692 files, 305 commits scanned" } ], "graphReleaseReceipt": { "path": "docs/release/graph-release-receipt.json", "issue": "#17", - "checksumSha256": "016134bf577c850906fcf7d28307a501024b01679d2ef63ae451655a368e5949" + "checksumSha256": "fee81c57608f2c63ddbfc23d53490760d03a5df49fe69e3849860db0599e852f" } } diff --git a/docs/release/release-receipt.summary.md b/docs/release/release-receipt.summary.md index 7bb8d81..3e0077b 100644 --- a/docs/release/release-receipt.summary.md +++ b/docs/release/release-receipt.summary.md @@ -3,7 +3,7 @@ Maintainer release receipt for the Opcore alpha package gate. Machine receipt: docs/release/release-receipt.json -Machine receipt SHA-256: 9d22e13b6150e3cf433c56b4b86848d8b5d47a3839a5e478130fab2b477bcdd0 +Machine receipt SHA-256: de47ce42ec42b04508b016d74f09cc7dc14eb08ee37d918f226490430eed4ff1 Canonical command groups: graph, inspect, edit, check, validate, status, doctor Native graph artifacts: 3 @@ -14,18 +14,18 @@ License unresolved count: 0 | Package | Tarball | SHA-256 | Files | |---------|---------|---------|-------| -| opcore | opcore-0.2.0.tgz | a5bc2d71695c2b3e8320f9dce88c7907d34e40af32eaa00793ae185386abd753 | 1055 | +| opcore | opcore-0.2.1.tgz | f3c4478eca2a9a1118b10bdabdfa009dfed0bdd8903312f06c42a8b60515b128 | 1250 | ## Reports | Report | Status | SHA-256 | Summary | |--------|--------|---------|---------| | package-inspection | passed | n/a | npm pack package inspection passed | -| license | passed | f2d943a3e3c5caf7caf54988734554b9397aed2d7def5b08e4a9f11bdc8f433d | 118 production dependencies, 0 unresolved | -| provenance | passed | c13858047a9e80843b15bb89f0a1b97e6c3a5b83e0d3d6950da8c16623966f9c | 551 files, 248 commits scanned | -| release-hygiene | passed | ae55ec7440ea15bc8477a81010381c8dcb8e540b2f4fdf276c5883acb76159e1 | release hygiene check passed | -| graph-release | passed | 016134bf577c850906fcf7d28307a501024b01679d2ef63ae451655a368e5949 | graph release receipt #17 validated as input evidence | -| secret-history | passed | 7060c267c912e3156b87c646e3bc970eac1ac8bdd964cf0a794b83e6f1250614 | 548 files, 248 commits scanned | +| license | passed | bad9cae3266e2ad7b866eba223f05cedfcd8332d2e3c44656381e70e335e8773 | 119 production dependencies, 0 unresolved | +| provenance | passed | 0c0e0092c26d883fd6451f07d860ff8a3b9dd8f8179356b8659787f9da93597d | 695 files, 305 commits scanned | +| release-hygiene | passed | ab68c5abd9148dad24b344e54cd2a52ac19610a13ccf386b00fa853d8587547f | release hygiene check passed | +| graph-release | passed | fee81c57608f2c63ddbfc23d53490760d03a5df49fe69e3849860db0599e852f | graph release receipt #17 validated as input evidence | +| secret-history | passed | 7060c267c912e3156b87c646e3bc970eac1ac8bdd964cf0a794b83e6f1250614 | 692 files, 305 commits scanned | Secret allowlist: docs/release/secret-scan-allowlist.json. Add entries only for reviewed false positives with path or commit scope, reviewer, reason, expiry, and optional fingerprint/kind narrowing. diff --git a/docs/release/v0.2.0.md b/docs/release/v0.2.0.md index 351ca89..5f913dc 100644 --- a/docs/release/v0.2.0.md +++ b/docs/release/v0.2.0.md @@ -5,7 +5,7 @@ Opcore 0.2.0 makes the default robustness loop lighter on large repositories and - Bounds product scan validation output to summaries, counts, failed check IDs, and diagnostic samples instead of retaining and serializing an unrestricted result payload. - Makes validation file views lazy so path-specific checks do not enumerate every visible file unless they need the complete file universe. - Sends committed clone-analysis inputs as path references and reserves content-bearing overlays for changed or hypothetical files. -- Replaces the Python syntax heuristic with parser-backed `ast.parse` validation, including structured locations and an honest unsupported result when no Python interpreter is available. +- Replaces Python grammar heuristics with exact-interpreter `compile()` validation over `.py`/`.pyi` after-state content, including structured ranges, interpreter provenance, and fail-closed tool outcomes. - Adds native validation-policy parity and tightens the package guardrails around it. ## Memory scope diff --git a/docs/release/v0.2.1.md b/docs/release/v0.2.1.md new file mode 100644 index 0000000..6b48dce --- /dev/null +++ b/docs/release/v0.2.1.md @@ -0,0 +1,29 @@ +Opcore 0.2.1 is an urgent macOS stability hotfix for Rust validation. + +Rust Cargo and native-tool checks previously created a fresh recursive copy of the target repository for every +selected check. On large repositories and concurrent agent runs, that produced a high-volume create/delete event +storm, left large temporary trees behind when processes were interrupted, and could drive macOS `fseventsd` memory +growth. + +This release: + +- materializes one Rust workspace per validation file-view state and materialization environment; +- shares that exact staged, tree, or hypothetical after-state across selected Rust checks; +- disposes run-scoped workspaces on normal, fail-fast, streaming, and failed exits; +- retains separate snapshots when Git/tool environments differ; and +- adds regression coverage for bounded materialization, cleanup, resource factory failures, and reverse-order + disposal. + +Install: + +```sh +npm install --global opcore@0.2.1 +``` + +Or run without installing: + +```sh +npx opcore@0.2.1 --version +``` + +Full comparison: https://github.com/the-open-engine/opcore/compare/v0.2.0...v0.2.1 diff --git a/docs/validation/rust-adapter-parity.md b/docs/validation/rust-adapter-parity.md index c179728..21b2dbf 100644 --- a/docs/validation/rust-adapter-parity.md +++ b/docs/validation/rust-adapter-parity.md @@ -73,7 +73,7 @@ Cargo.lock-only changes are retained compatibility too. Current native 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 temporary materialization for Cargo tools. +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: @@ -105,7 +105,8 @@ Expected native result: `rust.clippy` reports owned lint diagnostics when run be ## #21 Runtime Facts -- Temporary workspace materialization is required for Cargo-backed checks. +- 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. diff --git a/package-lock.json b/package-lock.json index 4c6985e..91e891a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "opcore", - "version": "0.2.0", + "version": "0.2.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "opcore", - "version": "0.2.0", + "version": "0.2.1", "workspaces": [ "packages/contracts", "packages/opcore", @@ -772,7 +772,6 @@ "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true, "license": "MIT" }, "node_modules/cross-spawn": { @@ -1248,6 +1247,12 @@ "dev": true, "license": "MIT" }, + "node_modules/jsonc-parser": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz", + "integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==", + "license": "MIT" + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -1656,17 +1661,17 @@ }, "packages/asp-provider": { "name": "@the-open-engine/opcore-asp-provider", - "version": "0.2.0", + "version": "0.2.1", "license": "MIT", "dependencies": { - "@the-open-engine/opcore-contracts": "0.2.0", - "@the-open-engine/opcore-graph": "0.2.0", - "@the-open-engine/opcore-validation": "0.2.0", - "@the-open-engine/opcore-validation-docs": "0.2.0", - "@the-open-engine/opcore-validation-policy": "0.2.0", - "@the-open-engine/opcore-validation-python": "0.2.0", - "@the-open-engine/opcore-validation-rust": "0.2.0", - "@the-open-engine/opcore-validation-typescript": "0.2.0" + "@the-open-engine/opcore-contracts": "0.2.1", + "@the-open-engine/opcore-graph": "0.2.1", + "@the-open-engine/opcore-validation": "0.2.1", + "@the-open-engine/opcore-validation-docs": "0.2.1", + "@the-open-engine/opcore-validation-policy": "0.2.1", + "@the-open-engine/opcore-validation-python": "0.2.1", + "@the-open-engine/opcore-validation-rust": "0.2.1", + "@the-open-engine/opcore-validation-typescript": "0.2.1" }, "bin": { "opcore-asp-provider": "dist/index.js" @@ -1677,7 +1682,7 @@ }, "packages/contracts": { "name": "@the-open-engine/opcore-contracts", - "version": "0.2.0", + "version": "0.2.1", "license": "MIT", "engines": { "node": ">=22" @@ -1685,10 +1690,10 @@ }, "packages/edit": { "name": "@the-open-engine/opcore-edit", - "version": "0.2.0", + "version": "0.2.1", "license": "MIT", "dependencies": { - "@the-open-engine/opcore-contracts": "0.2.0", + "@the-open-engine/opcore-contracts": "0.2.1", "ts-morph": "^28.0.0" }, "engines": { @@ -1697,7 +1702,7 @@ }, "packages/fixtures": { "name": "@the-open-engine/opcore-fixtures", - "version": "0.2.0", + "version": "0.2.1", "license": "MIT", "engines": { "node": ">=22" @@ -1705,22 +1710,22 @@ }, "packages/graph": { "name": "@the-open-engine/opcore-graph", - "version": "0.2.0", + "version": "0.2.1", "license": "MIT", "dependencies": { - "@the-open-engine/opcore-contracts": "0.2.0" + "@the-open-engine/opcore-contracts": "0.2.1" }, "engines": { "node": ">=22" }, "optionalDependencies": { - "@the-open-engine/opcore-graph-core-darwin-arm64": "0.2.0", - "@the-open-engine/opcore-graph-core-darwin-x64": "0.2.0", - "@the-open-engine/opcore-graph-core-linux-x64": "0.2.0" + "@the-open-engine/opcore-graph-core-darwin-arm64": "0.2.1", + "@the-open-engine/opcore-graph-core-darwin-x64": "0.2.1", + "@the-open-engine/opcore-graph-core-linux-x64": "0.2.1" } }, "packages/opcore": { - "version": "0.2.0", + "version": "0.2.1", "bundleDependencies": [ "@the-open-engine/opcore-asp-provider", "@the-open-engine/opcore-contracts", @@ -1744,15 +1749,18 @@ "@typescript-eslint/visitor-keys", "balanced-match", "brace-expansion", + "concat-map", "code-block-writer", "debug", "eslint-visitor-keys", "fdir", + "jsonc-parser", "minimatch", "ms", "path-browserify", "picomatch", "semver", + "smol-toml", "tinyglobby", "ts-api-utils", "ts-morph", @@ -1761,18 +1769,21 @@ "hasInstallScript": true, "license": "MIT", "dependencies": { - "@the-open-engine/opcore-asp-provider": "0.2.0", - "@the-open-engine/opcore-contracts": "0.2.0", - "@the-open-engine/opcore-edit": "0.2.0", - "@the-open-engine/opcore-graph": "0.2.0", - "@the-open-engine/opcore-validation": "0.2.0", - "@the-open-engine/opcore-validation-clone": "0.2.0", - "@the-open-engine/opcore-validation-docs": "0.2.0", - "@the-open-engine/opcore-validation-policy": "0.2.0", - "@the-open-engine/opcore-validation-python": "0.2.0", - "@the-open-engine/opcore-validation-rust": "0.2.0", - "@the-open-engine/opcore-validation-typescript": "0.2.0", + "@the-open-engine/opcore-asp-provider": "0.2.1", + "@the-open-engine/opcore-contracts": "0.2.1", + "@the-open-engine/opcore-edit": "0.2.1", + "@the-open-engine/opcore-graph": "0.2.1", + "@the-open-engine/opcore-validation": "0.2.1", + "@the-open-engine/opcore-validation-clone": "0.2.1", + "@the-open-engine/opcore-validation-docs": "0.2.1", + "@the-open-engine/opcore-validation-policy": "0.2.1", + "@the-open-engine/opcore-validation-python": "0.2.1", + "@the-open-engine/opcore-validation-rust": "0.2.1", + "@the-open-engine/opcore-validation-typescript": "0.2.1", "@typescript-eslint/typescript-estree": "^8.62.0", + "concat-map": "0.0.1", + "jsonc-parser": "3.3.1", + "smol-toml": "1.6.0", "ts-morph": "^28.0.0", "typescript": "^5.9.3" }, @@ -1784,14 +1795,14 @@ "node": ">=22" }, "optionalDependencies": { - "@the-open-engine/opcore-graph-core-darwin-arm64": "0.2.0", - "@the-open-engine/opcore-graph-core-darwin-x64": "0.2.0", - "@the-open-engine/opcore-graph-core-linux-x64": "0.2.0" + "@the-open-engine/opcore-graph-core-darwin-arm64": "0.2.1", + "@the-open-engine/opcore-graph-core-darwin-x64": "0.2.1", + "@the-open-engine/opcore-graph-core-linux-x64": "0.2.1" } }, "packages/opcore-graph-core-darwin-arm64": { "name": "@the-open-engine/opcore-graph-core-darwin-arm64", - "version": "0.2.0", + "version": "0.2.1", "cpu": [ "arm64" ], @@ -1803,7 +1814,7 @@ }, "packages/opcore-graph-core-darwin-x64": { "name": "@the-open-engine/opcore-graph-core-darwin-x64", - "version": "0.2.0", + "version": "0.2.1", "cpu": [ "x64" ], @@ -1815,7 +1826,7 @@ }, "packages/opcore-graph-core-linux-x64": { "name": "@the-open-engine/opcore-graph-core-linux-x64", - "version": "0.2.0", + "version": "0.2.1", "cpu": [ "x64" ], @@ -1825,12 +1836,25 @@ "linux" ] }, + "packages/opcore/node_modules/smol-toml": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.6.0.tgz", + "integrity": "sha512-4zemZi0HvTnYwLfrpk/CF9LOd9Lt87kAt50GnqhMpyF9U3poDAP2+iukq2bZsO/ufegbYehBkqINbsWxj4l4cw==", + "inBundle": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">= 18" + }, + "funding": { + "url": "https://github.com/sponsors/cyyynthia" + } + }, "packages/validation": { "name": "@the-open-engine/opcore-validation", - "version": "0.2.0", + "version": "0.2.1", "license": "MIT", "dependencies": { - "@the-open-engine/opcore-contracts": "0.2.0" + "@the-open-engine/opcore-contracts": "0.2.1" }, "engines": { "node": ">=22" @@ -1838,11 +1862,11 @@ }, "packages/validation-clone": { "name": "@the-open-engine/opcore-validation-clone", - "version": "0.2.0", + "version": "0.2.1", "license": "MIT", "dependencies": { - "@the-open-engine/opcore-contracts": "0.2.0", - "@the-open-engine/opcore-validation": "0.2.0" + "@the-open-engine/opcore-contracts": "0.2.1", + "@the-open-engine/opcore-validation": "0.2.1" }, "engines": { "node": ">=22" @@ -1850,11 +1874,11 @@ }, "packages/validation-docs": { "name": "@the-open-engine/opcore-validation-docs", - "version": "0.2.0", + "version": "0.2.1", "license": "MIT", "dependencies": { - "@the-open-engine/opcore-contracts": "0.2.0", - "@the-open-engine/opcore-validation": "0.2.0" + "@the-open-engine/opcore-contracts": "0.2.1", + "@the-open-engine/opcore-validation": "0.2.1" }, "engines": { "node": ">=22" @@ -1862,16 +1886,16 @@ }, "packages/validation-policy": { "name": "@the-open-engine/opcore-validation-policy", - "version": "0.2.0", + "version": "0.2.1", "license": "MIT", "dependencies": { - "@the-open-engine/opcore-contracts": "0.2.0", - "@the-open-engine/opcore-validation": "0.2.0", - "@the-open-engine/opcore-validation-clone": "0.2.0", - "@the-open-engine/opcore-validation-docs": "0.2.0", - "@the-open-engine/opcore-validation-python": "0.2.0", - "@the-open-engine/opcore-validation-rust": "0.2.0", - "@the-open-engine/opcore-validation-typescript": "0.2.0" + "@the-open-engine/opcore-contracts": "0.2.1", + "@the-open-engine/opcore-validation": "0.2.1", + "@the-open-engine/opcore-validation-clone": "0.2.1", + "@the-open-engine/opcore-validation-docs": "0.2.1", + "@the-open-engine/opcore-validation-python": "0.2.1", + "@the-open-engine/opcore-validation-rust": "0.2.1", + "@the-open-engine/opcore-validation-typescript": "0.2.1" }, "engines": { "node": ">=22" @@ -1879,23 +1903,37 @@ }, "packages/validation-python": { "name": "@the-open-engine/opcore-validation-python", - "version": "0.2.0", + "version": "0.2.1", "license": "MIT", "dependencies": { - "@the-open-engine/opcore-contracts": "0.2.0", - "@the-open-engine/opcore-validation": "0.2.0" + "@the-open-engine/opcore-contracts": "0.2.1", + "@the-open-engine/opcore-validation": "0.2.1", + "jsonc-parser": "3.3.1", + "smol-toml": "1.6.0" }, "engines": { "node": ">=22" } }, + "packages/validation-python/node_modules/smol-toml": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.6.0.tgz", + "integrity": "sha512-4zemZi0HvTnYwLfrpk/CF9LOd9Lt87kAt50GnqhMpyF9U3poDAP2+iukq2bZsO/ufegbYehBkqINbsWxj4l4cw==", + "license": "BSD-3-Clause", + "engines": { + "node": ">= 18" + }, + "funding": { + "url": "https://github.com/sponsors/cyyynthia" + } + }, "packages/validation-rust": { "name": "@the-open-engine/opcore-validation-rust", - "version": "0.2.0", + "version": "0.2.1", "license": "MIT", "dependencies": { - "@the-open-engine/opcore-contracts": "0.2.0", - "@the-open-engine/opcore-validation": "0.2.0" + "@the-open-engine/opcore-contracts": "0.2.1", + "@the-open-engine/opcore-validation": "0.2.1" }, "engines": { "node": ">=22" @@ -1903,11 +1941,11 @@ }, "packages/validation-typescript": { "name": "@the-open-engine/opcore-validation-typescript", - "version": "0.2.0", + "version": "0.2.1", "license": "MIT", "dependencies": { - "@the-open-engine/opcore-contracts": "0.2.0", - "@the-open-engine/opcore-validation": "0.2.0", + "@the-open-engine/opcore-contracts": "0.2.1", + "@the-open-engine/opcore-validation": "0.2.1", "@typescript-eslint/typescript-estree": "^8.62.0", "typescript": "^5.9.3" }, diff --git a/package.json b/package.json index 3ce7140..184e75e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "opcore", - "version": "0.2.0", + "version": "0.2.1", "private": true, "description": "Local code intelligence, safe edits, and pre-write validation for coding agents.", "type": "module", @@ -43,6 +43,9 @@ "lint": "node scripts/check-workspace.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", + "python:mypy-authority-proof": "node scripts/check-python-mypy-authority.mjs", + "python:pyright-authority-proof": "node scripts/check-python-pyright-authority.mjs", "release:hygiene": "node scripts/check-release-hygiene.mjs", "rust:check": "npm run rust:fmt && npm run rust:clippy && npm run rust:test", "rust:clippy": "cargo clippy --all-targets --all-features -- -D warnings", diff --git a/packages/asp-provider/package.json b/packages/asp-provider/package.json index 0d00161..0c96770 100644 --- a/packages/asp-provider/package.json +++ b/packages/asp-provider/package.json @@ -1,6 +1,6 @@ { "name": "@the-open-engine/opcore-asp-provider", - "version": "0.2.0", + "version": "0.2.1", "description": "Standalone ASP Core check provider facade for Opcore validation.", "type": "module", "license": "MIT", @@ -30,14 +30,14 @@ "opcore-asp-provider": "dist/index.js" }, "dependencies": { - "@the-open-engine/opcore-contracts": "0.2.0", - "@the-open-engine/opcore-graph": "0.2.0", - "@the-open-engine/opcore-validation": "0.2.0", - "@the-open-engine/opcore-validation-policy": "0.2.0", - "@the-open-engine/opcore-validation-docs": "0.2.0", - "@the-open-engine/opcore-validation-python": "0.2.0", - "@the-open-engine/opcore-validation-rust": "0.2.0", - "@the-open-engine/opcore-validation-typescript": "0.2.0" + "@the-open-engine/opcore-contracts": "0.2.1", + "@the-open-engine/opcore-graph": "0.2.1", + "@the-open-engine/opcore-validation": "0.2.1", + "@the-open-engine/opcore-validation-policy": "0.2.1", + "@the-open-engine/opcore-validation-docs": "0.2.1", + "@the-open-engine/opcore-validation-python": "0.2.1", + "@the-open-engine/opcore-validation-rust": "0.2.1", + "@the-open-engine/opcore-validation-typescript": "0.2.1" }, "engines": { "node": ">=22" diff --git a/packages/asp-provider/src/manifest.ts b/packages/asp-provider/src/manifest.ts index 2953850..9c068df 100644 --- a/packages/asp-provider/src/manifest.ts +++ b/packages/asp-provider/src/manifest.ts @@ -68,6 +68,12 @@ export type OpcoreAspProviderManifest = { protocolVersion: string; capabilityFamilies: readonly ["check"]; checks: readonly string[]; + pythonProjectContext: { + schemaId: "opcore.python.project-context.v1"; + outcomes: readonly ["resolved", "degraded", "unsupported", "ambiguous"]; + readOnly: true; + installs: false; + }; executable: { packageName: string; bin: "opcore-asp-provider"; @@ -102,6 +108,12 @@ export function createOpcoreAspProviderManifest(options: { packageRoot?: string; protocolVersion: ASP_PROTOCOL_VERSION, capabilityFamilies: ["check"], checks: defaultAspProviderValidationCheckIds, + pythonProjectContext: { + schemaId: "opcore.python.project-context.v1", + outcomes: ["resolved", "degraded", "unsupported", "ambiguous"], + readOnly: true, + installs: false + }, executable: { packageName: OPCORE_PROVIDER_PACKAGE, bin: "opcore-asp-provider", diff --git a/packages/asp-provider/src/mapping.ts b/packages/asp-provider/src/mapping.ts index b0755df..fd28597 100644 --- a/packages/asp-provider/src/mapping.ts +++ b/packages/asp-provider/src/mapping.ts @@ -8,7 +8,11 @@ import type { ValidationRequest, ValidationResult } from "@the-open-engine/opcore-contracts"; -import { validateCommandTiming, validateRepoRelativePath } from "@the-open-engine/opcore-contracts"; +import { + validateCommandTiming, + validatePythonValidationCapabilityRun, + validateRepoRelativePath +} from "@the-open-engine/opcore-contracts"; import type { JsonRpcPeer } from "./json-rpc.js"; import { changesetDigest as computeChangesetDigest, diagnosticFingerprint, digestJson } from "./digests.js"; import type { @@ -61,7 +65,13 @@ export function initializeResult(params: InitializeParams): JsonObject { manifest: defaultAspProviderValidationManifest, scopes: ["changeset", "paths"], comparisons: [supportedComparison], - fixes: false + fixes: false, + pythonProjectContext: { + schemaId: "opcore.python.project-context.v1", + outcomes: ["resolved", "degraded", "unsupported", "ambiguous"], + readOnly: true, + installs: false + } } }, requestedPermissions: { @@ -119,7 +129,7 @@ export async function evaluateChangeset( }; }); validationResult = await timing.timeAsync("validation", () => - createAspProviderValidationRunner(workspace.workspace).runValidation(request) + createAspProviderValidationRunner(workspace.workspace, workspace.pythonWorkspace).runValidation(request) ); } catch (error) { const validationFailure = errorAssessment({ @@ -355,6 +365,8 @@ function mapDiagnostic(diagnostic: ValidationDiagnostic, changesetDigest: string const code = `${source}/${checkId}/${sanitizeCode(diagnostic.code ?? diagnostic.category)}`; const location: JsonObject = {}; if (diagnostic.path !== undefined) location.path = diagnostic.path; + const range = diagnosticRange(diagnostic); + if (range !== undefined) location.range = range; return { source, code, @@ -366,6 +378,7 @@ function mapDiagnostic(diagnostic: ValidationDiagnostic, changesetDigest: string source, code, path: diagnostic.path, + range, severity: diagnostic.severity, message: diagnostic.message, changesetDigest @@ -374,9 +387,26 @@ function mapDiagnostic(diagnostic: ValidationDiagnostic, changesetDigest: string }; } +function diagnosticRange(diagnostic: ValidationDiagnostic): JsonObject | undefined { + if (diagnostic.line === undefined) return undefined; + const start: JsonObject = { line: diagnostic.line }; + if (diagnostic.column !== undefined) start.column = diagnostic.column; + const range: JsonObject = { start }; + if (diagnostic.endLine !== undefined) { + const end: JsonObject = { line: diagnostic.endLine }; + if (diagnostic.endColumn !== undefined) end.column = diagnostic.endColumn; + range.end = end; + } + return range; +} + function checkIdForDiagnostic(diagnostic: ValidationDiagnostic): string { - if (diagnostic.category === "syntax") return "typescript.syntax"; - if (diagnostic.category === "types") return "typescript.types"; + if (diagnostic.category === "syntax") return diagnostic.path?.endsWith(".py") || diagnostic.path?.endsWith(".pyi") + ? "python.syntax" + : "typescript.syntax"; + if (diagnostic.category === "types") return diagnostic.path?.endsWith(".py") || diagnostic.path?.endsWith(".pyi") + ? "python.types" + : "typescript.types"; if (diagnostic.category === "test") return "typescript.relevant-tests"; if (diagnostic.category === "graph") return "typescript.import-graph"; if (diagnostic.code === "TS_FILE_LINES") return "typescript.file-length"; @@ -409,6 +439,19 @@ function validationCoverageDegradations(result: ValidationResult, selectedIds: r } { const degraded: ProviderCoverageDegradation[] = []; const unsupported: ProviderCoverageDegradation[] = []; + for (const context of result.pythonProjectContexts ?? []) { + if (context.outcome === "resolved") continue; + const reason = context.outcome === "unsupported" ? "unsupported" : "unavailable"; + const entry = { + source: providerSource, + reason, + requirement: `python-project-context:${context.target}`, + detail: context.reasons.map((item) => `${item.code}: ${item.message}`).join("; ") || + `Python project context ${context.outcome}` + }; + degraded.push(entry); + if (reason === "unsupported") unsupported.push(entry); + } for (const skipped of result.manifest?.skippedChecks ?? []) { const entry = { source: providerSource, @@ -518,13 +561,91 @@ function validationEvidence(result: ValidationResult): JsonObject[] { data.refusalCategory = result.refusal.category; data.refusalMessage = result.refusal.message; } - return [ + const evidence: JsonObject[] = [ { kind: "message", message: "Opcore validation result mapped to ASP Core check assessment.", data } ]; + for (const context of result.pythonProjectContexts ?? []) { + evidence.push({ + kind: "python_project_context", + message: `Canonical Python project context for ${context.target}.`, + data: { + schemaId: context.schemaId, + target: context.target, + projectRoot: context.projectRoot, + projectKey: context.projectKey, + contextFingerprint: context.contextFingerprint, + outcome: context.outcome, + interpreter: context.interpreter === undefined + ? null + : { + executable: context.interpreter.executable, + argv: [...context.interpreter.argv], + cwd: context.interpreter.cwd, + source: context.interpreter.source, + ...(context.interpreter.version === undefined ? {} : { version: context.interpreter.version }), + ...(context.interpreter.implementation === undefined ? {} : { implementation: context.interpreter.implementation }), + ...(context.interpreter.platform === undefined ? {} : { platform: context.interpreter.platform }), + ...(context.interpreter.architecture === undefined ? {} : { architecture: context.interpreter.architecture }), + ...(context.interpreter.abi === undefined ? {} : { abi: context.interpreter.abi }), + ...(context.interpreter.soabi === undefined ? {} : { soabi: context.interpreter.soabi }) + }, + tools: context.tools.map((tool) => ({ + tool: tool.tool, + available: tool.available, + executable: tool.executable, + argv: [...tool.argv], + cwd: tool.cwd, + source: tool.source, + ...(tool.configFile === undefined ? {} : { configFile: tool.configFile }), + ...(tool.version === undefined ? {} : { version: tool.version }) + })), + reasons: context.reasons.map((reason) => ({ code: reason.code, message: reason.message })) + } + }); + } + for (const run of result.pythonCapabilityRuns ?? []) { + validatePythonValidationCapabilityRun(run); + evidence.push({ + kind: "python_validation_capability_run", + message: `Python ${run.capability} capability evidence for ${run.projectRoot}.`, + data: { + schemaId: run.schemaId, + schemaVersion: run.schemaVersion, + capability: run.capability, + checkId: run.checkId, + projectKey: run.projectKey, + contextFingerprint: run.contextFingerprint, + projectRoot: run.projectRoot, + targets: [...run.targets], + selectedSourcePaths: [...run.selectedSourcePaths], + selectedConfigPaths: [...run.selectedConfigPaths], + afterStateManifestFingerprint: run.afterStateManifestFingerprint, + ...(run.authority === undefined ? {} : { checker: run.authority }), + ...(run.authoritySource === undefined ? {} : { checkerSource: run.authoritySource }), + status: run.status, + durationMs: run.durationMs, + diagnosticCount: run.diagnosticCount, + errorCount: run.errorCount, + warningCount: run.warningCount, + noteCount: run.noteCount, + ...(run.tool === undefined ? {} : { tool: { + name: run.tool.name, + executable: run.tool.executable, + argv: [...run.tool.argv], + cwd: run.tool.cwd, + source: run.tool.source, + ...(run.tool.version === undefined ? {} : { version: run.tool.version }), + ...(run.tool.configFile === undefined ? {} : { configFile: run.tool.configFile }) + } }), + ...(run.execution === undefined ? {} : { execution: { ...run.execution } }) + } + }); + } + return evidence; } function providerMetadata(): Assessment["provider"] { diff --git a/packages/asp-provider/src/node-shims.d.ts b/packages/asp-provider/src/node-shims.d.ts index 8901582..ec8cefc 100644 --- a/packages/asp-provider/src/node-shims.d.ts +++ b/packages/asp-provider/src/node-shims.d.ts @@ -13,6 +13,8 @@ declare module "node:fs" { } declare module "node:fs/promises" { + export function access(path: string): Promise; + export function realpath(path: string): Promise; export function readFile(path: string, encoding: "utf8"): Promise; export function chmod(path: string, mode: number): Promise; export function mkdir(path: string, options: { recursive: true }): Promise; diff --git a/packages/asp-provider/src/protocol.ts b/packages/asp-provider/src/protocol.ts index d5a7258..2838b4b 100644 --- a/packages/asp-provider/src/protocol.ts +++ b/packages/asp-provider/src/protocol.ts @@ -171,4 +171,4 @@ export type RpcThrowable = Error & { export const ASP_PROTOCOL_VERSION = "asp/0.1"; export const OPCORE_PROVIDER_ID = "opcore"; export const OPCORE_PROVIDER_PACKAGE = "opcore"; -export const OPCORE_PROVIDER_VERSION = "0.2.0"; +export const OPCORE_PROVIDER_VERSION = "0.2.1"; diff --git a/packages/asp-provider/src/validation-composition.ts b/packages/asp-provider/src/validation-composition.ts index d5d707c..b9cc202 100644 --- a/packages/asp-provider/src/validation-composition.ts +++ b/packages/asp-provider/src/validation-composition.ts @@ -23,13 +23,15 @@ import { GRAPH_SCHEMA_VERSION, validateProviderStatus } from "@the-open-engine/o import { createValidationCheckManifest, createValidationRunner, + createStateAwareValidationGraphSessionFactory, + createValidationExactGraphSnapshotFactory, type ValidationCheckDefinition, type ValidationGraphProviderClient, type ValidationWorkspace } from "@the-open-engine/opcore-validation"; import { createBuiltInValidationChecks, - validationChecksForRepoPolicy + parseOpcoreRepoConfig } from "@the-open-engine/opcore-validation-policy"; import { graphProviderDetectChanges, @@ -37,25 +39,34 @@ import { graphProviderNamedQuery, graphProviderQuery, graphProviderReviewContext, - graphProviderStatus + graphProviderStatus, + createEphemeralGraphSnapshot, + graphPythonImportAnalyzer } from "@the-open-engine/opcore-graph"; +import type { PythonProjectWorkspace } from "@the-open-engine/opcore-validation-python"; -const aspProviderPolicyOptions = { clone: false } as const; +const aspProviderPolicyOptions = { clone: false, pythonImportAnalyzer: graphPythonImportAnalyzer } as const; export const defaultAspProviderValidationChecks = createBuiltInValidationChecks(undefined, aspProviderPolicyOptions); export const defaultAspProviderValidationCheckIds = defaultAspProviderValidationChecks.map((check) => check.id); export const defaultAspProviderValidationManifest = createValidationCheckManifest(defaultAspProviderValidationChecks); -export function createAspProviderValidationRunner(workspace: ValidationWorkspace): { +export function createAspProviderValidationRunner(workspace: ValidationWorkspace, pythonWorkspace: PythonProjectWorkspace): { runValidation(request: ValidationRequest): Promise; } { return { - runValidation(request) { + async runValidation(request) { + const config = await readHostConfig(workspace, request); + const graphProviderClient = createAspValidationGraphProviderClient(); return createValidationRunner({ workspace, - checks: validationChecksForAspRequest(request), - graphProviderClient: createAspValidationGraphProviderClient() + checks: createBuiltInValidationChecks(config, { ...aspProviderPolicyOptions, pythonWorkspace }), + graphProviderClient, + graphSessionFactory: createStateAwareValidationGraphSessionFactory({ + persistentClient: graphProviderClient, + exactSnapshotFactory: createValidationExactGraphSnapshotFactory(createEphemeralGraphSnapshot) + }) }).runValidation(request); } }; @@ -67,10 +78,12 @@ export function selectedValidationChecks(checkIds?: readonly string[]): readonly return defaultAspProviderValidationChecks.filter((check) => requested.has(check.id)); } -function validationChecksForAspRequest(request: ValidationRequest): readonly ValidationCheckDefinition[] { - const repoRoot = request.repo.repoRoot; - if (repoRoot === undefined) return defaultAspProviderValidationChecks; - return validationChecksForRepoPolicy(repoRoot, aspProviderPolicyOptions); +async function readHostConfig(workspace: ValidationWorkspace, request: ValidationRequest) { + const overlay = request.overlays.find((entry) => entry.path === ".opcore/config"); + if (overlay?.action === "write") return parseOpcoreRepoConfig(overlay.content); + if (overlay?.action === "delete") return parseOpcoreRepoConfig(undefined); + const result = await workspace.readFile(".opcore/config"); + return parseOpcoreRepoConfig(result.status === "found" ? result.content : undefined); } function createAspValidationGraphProviderClient(): ValidationGraphProviderClient { diff --git a/packages/asp-provider/src/workspace.ts b/packages/asp-provider/src/workspace.ts index f672cc5..cf87e8a 100644 --- a/packages/asp-provider/src/workspace.ts +++ b/packages/asp-provider/src/workspace.ts @@ -1,10 +1,14 @@ +import { access, realpath } from "node:fs/promises"; +import { resolve } from "node:path"; import type { ValidationWorkspace, ValidationWorkspaceFileSet, ValidationWorkspaceReadFileResult } from "@the-open-engine/opcore-validation"; import { calculateValidationFileChecksum } from "@the-open-engine/opcore-validation"; +import type { PythonProjectWorkspace } from "@the-open-engine/opcore-validation-python"; import type { JsonRpcPeer } from "./json-rpc.js"; import type { Baseline, ChangeSet, EncodedBlob, InitializedParams, InitializeParams, InlineBlob, JsonObject } from "./protocol.js"; export type AspHostValidationWorkspace = { workspace: ValidationWorkspace; + pythonWorkspace: PythonProjectWorkspace; readBlobText(blobId: string): Promise; readInlineOrBlobText(blob: string | InlineBlob | undefined, label: string): Promise; checksumForBlob(blobId: string): Promise; @@ -27,6 +31,7 @@ export function createAspHostValidationWorkspace( const entryByPath = new Map(); const blobTextById = new Map(); const readSet = new Set(); + let fullTreeTruncated = false; async function listTree(paths?: readonly string[]): Promise { const params: JsonObject = { @@ -39,6 +44,7 @@ export function createAspHostValidationWorkspace( truncated?: boolean; }; const entries = (result.entries ?? []).map(normalizeTreeEntry).filter((entry): entry is TreeEntry => entry !== undefined); + if (paths === undefined) fullTreeTruncated = result.truncated === true; for (const entry of entries) entryByPath.set(entry.path, entry); return entries; } @@ -70,16 +76,19 @@ export function createAspHostValidationWorkspace( async function listRepoFiles(): Promise { const entries = await listTree(); return { - files: entries.map((entry) => ({ path: entry.path, status: "unchanged" as const })) + files: entries.map((entry) => ({ path: entry.path, status: "unchanged" as const })), + ...(fullTreeTruncated ? { truncated: true, message: "Host workspace/listTree returned a truncated file universe" } : {}) }; } const workspace: ValidationWorkspace = { readFile, + listFiles: async () => listRepoFiles(), listRepoFiles, listPackageFiles: async (_packageName, packageRoot) => { const files = await listRepoFiles(); return { + ...files, files: files.files.filter((file) => { const path = typeof file === "string" ? file : file.path; return path === packageRoot || path.startsWith(`${packageRoot}/`); @@ -88,8 +97,25 @@ export function createAspHostValidationWorkspace( } }; + const pythonWorkspace: PythonProjectWorkspace = { + read: async (path) => { + const result = await readFile(path); + return result.status === "found" ? result.content : undefined; + }, + list: async () => (await listTree()).filter(isFileLikeTreeEntry).map((entry) => entry.path).sort(), + exists: async (path) => (await readFile(path)).status === "found", + realpath: async (path) => { + let entry = entryByPath.get(path); + if (entry === undefined) entry = (await listTree([path])).find((candidate) => candidate.path === path); + if (entry === undefined || entry.kind === undefined) return { path, symlink: false, unavailable: true }; + return entry.kind === "file" ? { path, symlink: false } : { path, symlink: true }; + }, + executableExists: async (path) => providerExecutableExists(initialize, path) + }; + return { workspace, + pythonWorkspace, readBlobText, readInlineOrBlobText: async (blob, label) => { if (typeof blob === "string") return readBlobText(blob); @@ -106,11 +132,24 @@ function readGlobs(initialized: InitializedParams): readonly string[] { return Array.isArray(read) ? [...read] : ["**/*"]; } +async function providerExecutableExists(initialize: InitializeParams, path: string): Promise { + const root = initialize.workspace?.root; + if (root === undefined) return false; + try { + const [hostRoot, providerCwd] = await Promise.all([realpath(resolve(root)), realpath(process.cwd())]); + if (hostRoot !== providerCwd) return false; + if (!path.includes("/") && !path.includes("\\")) return true; + await access(path); + return true; + } catch { + return false; + } +} + function normalizeTreeEntry(value: unknown): TreeEntry | undefined { if (!value || typeof value !== "object") return undefined; const entry = value as { path?: unknown; blobId?: unknown; kind?: unknown }; if (typeof entry.path !== "string" || typeof entry.blobId !== "string") return undefined; - if (entry.kind !== undefined && entry.kind !== "file") return undefined; return { path: entry.path, blobId: entry.blobId, @@ -118,6 +157,10 @@ function normalizeTreeEntry(value: unknown): TreeEntry | undefined { }; } +function isFileLikeTreeEntry(entry: TreeEntry): boolean { + return entry.kind === undefined || entry.kind === "file" || entry.kind === "symlink"; +} + function decodeBlob(blob: EncodedBlob): string { if (typeof blob.content === "string") return blob.content; if (typeof blob.bytes !== "string") throw new Error(`Host blob ${blob.id} is missing bytes`); diff --git a/packages/contracts/README.md b/packages/contracts/README.md index 1dadf8c..9183b56 100644 --- a/packages/contracts/README.md +++ b/packages/contracts/README.md @@ -9,3 +9,7 @@ Public TypeScript contracts and JSON schema for Opcore package and CLI surfaces. ## Context Docs `requiredContextDocPolicy` is the shared `require-context-doc` policy for agent guidance filenames, required locations, and minimum content length. + +## Python Capability Evidence + +`PythonValidationCapabilityRun` (`opcore.python.validation-capability-run`, version 1) is the portable, source-free receipt for one Python capability attempt in one canonical project. Tool executable locations use `repo:`, `project:`, `path:`, or `external:` locators so host and materialization roots never enter receipts. An `invalid_config` run has no authority when conflicting evidence prevents selection; after selection it may carry unexecuted static-config evidence or exited tool evidence. `ValidationResult.pythonCapabilityRuns` retains separate project/context/after-state/authority runs through runner events, introduced mode, scan previews, and ASP evidence mapping. diff --git a/packages/contracts/package.json b/packages/contracts/package.json index 8e098f0..5715e46 100644 --- a/packages/contracts/package.json +++ b/packages/contracts/package.json @@ -1,6 +1,6 @@ { "name": "@the-open-engine/opcore-contracts", - "version": "0.2.0", + "version": "0.2.1", "description": "Public contracts and JSON schema for Opcore graph, edit, validation, and CLI routes.", "type": "module", "license": "MIT", diff --git a/packages/contracts/schemas/opcore-contracts.schema.json b/packages/contracts/schemas/opcore-contracts.schema.json index 93a2197..c32a92c 100644 --- a/packages/contracts/schemas/opcore-contracts.schema.json +++ b/packages/contracts/schemas/opcore-contracts.schema.json @@ -2383,6 +2383,389 @@ } } }, + "PythonProjectContext": { + "type": "object", + "additionalProperties": false, + "required": [ + "schemaId", "schemaVersion", "target", "repositoryRoot", "projectRoot", "projectBoundary", + "sourceRoots", "layout", "evidence", "targetRuntime", "managers", "tools", "projectKey", + "contextFingerprint", "outcome", "reasons" + ], + "properties": { + "schemaId": { "const": "opcore.python.project-context.v1" }, + "schemaVersion": { "const": 1 }, + "target": { "allOf": [{ "$ref": "#/$defs/RepoRelativePath" }, { "pattern": "\\.pyi?$" }] }, + "repositoryRoot": { "type": "string", "minLength": 1 }, + "projectRoot": { "$ref": "#/$defs/PythonProjectRoot" }, + "projectBoundary": { "$ref": "#/$defs/PythonProjectRoot" }, + "sourceRoots": { + "type": "array", "minItems": 1, "uniqueItems": true, + "items": { "$ref": "#/$defs/PythonProjectRoot" } + }, + "layout": { + "type": "object", "additionalProperties": false, "required": ["kinds", "paths"], + "properties": { + "kinds": { + "type": "array", "minItems": 1, "uniqueItems": true, + "items": { "enum": ["flat", "src", "namespace", "stub", "package"] } + }, + "paths": { + "type": "array", "minItems": 1, "uniqueItems": true, + "items": { "$ref": "#/$defs/PythonProjectRoot" } + } + } + }, + "evidence": { + "type": "array", + "items": { + "type": "object", "additionalProperties": false, "required": ["path", "role"], + "properties": { + "path": { "$ref": "#/$defs/RepoRelativePath" }, + "role": { "enum": ["boundary", "config", "lock", "requirements", "build", "layout"] } + } + } + }, + "targetRuntime": { + "type": "object", "additionalProperties": false, "required": ["conflicts"], + "properties": { + "requiresPython": { "type": "string", "minLength": 1 }, + "version": { "type": "string", "minLength": 1 }, + "platform": { "type": "string", "minLength": 1 }, + "implementation": { "type": "string", "minLength": 1 }, + "conflicts": { "type": "array", "items": { "type": "string", "minLength": 1 } } + } + }, + "managers": { + "type": "array", + "items": { + "type": "object", "additionalProperties": false, "required": ["kind", "configFiles", "lockFiles"], + "properties": { + "kind": { "enum": ["pip", "uv", "poetry", "pdm", "pipenv"] }, + "configFiles": { "type": "array", "items": { "$ref": "#/$defs/RepoRelativePath" } }, + "lockFiles": { "type": "array", "items": { "$ref": "#/$defs/RepoRelativePath" } } + } + } + }, + "buildSystem": { + "type": "object", + "additionalProperties": false, + "required": ["configFile", "requires"], + "properties": { + "configFile": { "$ref": "#/$defs/RepoRelativePath" }, + "backend": { "type": "string", "minLength": 1 }, + "requires": { + "type": "array", + "items": { "type": "string", "minLength": 1 } + } + } + }, + "interpreter": { "$ref": "#/$defs/PythonInterpreterProvenance" }, + "tools": { + "type": "array", + "items": { + "allOf": [ + { "$ref": "#/$defs/PythonExecutableProvenance" }, + { + "type": "object", "required": ["tool", "available"], + "properties": { + "tool": { "enum": ["mypy", "pyright", "ruff", "pytest", "build"] }, + "available": { "type": "boolean" } + } + }, + { + "if": { "properties": { "available": { "const": true } }, "required": ["available"] }, + "then": { "required": ["version"] } + } + ] + } + }, + "projectKey": { "type": "string", "pattern": "^sha256:[a-f0-9]{64}$" }, + "contextFingerprint": { "type": "string", "pattern": "^sha256:[a-f0-9]{64}$" }, + "outcome": { "enum": ["resolved", "degraded", "unsupported", "ambiguous"] }, + "reasons": { + "type": "array", + "items": { + "type": "object", "additionalProperties": false, "required": ["code", "message"], + "properties": { + "code": { + "enum": [ + "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" + ] + }, + "message": { "type": "string", "minLength": 1 }, + "path": { "$ref": "#/$defs/RepoRelativePath" }, + "tool": { "type": "string", "minLength": 1 } + } + } + } + }, + "allOf": [ + { + "if": { "properties": { "outcome": { "const": "resolved" } }, "required": ["outcome"] }, + "then": { "properties": { "reasons": { "maxItems": 0 } } }, + "else": { "properties": { "reasons": { "minItems": 1 } } } + } + ] + }, + "PythonProjectRoot": { + "anyOf": [{ "const": "." }, { "$ref": "#/$defs/RepoRelativePath" }] + }, + "PythonExecutableProvenance": { + "type": "object", + "additionalProperties": false, + "required": ["executable", "argv", "cwd", "source"], + "properties": { + "executable": { "type": "string", "minLength": 1 }, + "argv": { "type": "array", "minItems": 1, "items": { "type": "string", "minLength": 1 } }, + "cwd": { "type": "string", "minLength": 1 }, + "source": { "enum": ["explicit_override", "active_environment", "project_local_environment", "manager_environment", "path"] }, + "version": { "type": "string", "pattern": "^[0-9]+(?:\\.[0-9]+)+(?:[-+._A-Za-z0-9]*)?$" }, + "configFile": { "$ref": "#/$defs/RepoRelativePath" }, + "tool": { "enum": ["mypy", "pyright", "ruff", "pytest", "build"] }, + "available": { "type": "boolean" }, + "implementation": { "type": "string", "minLength": 1 }, + "platform": { "type": "string", "minLength": 1 }, + "architecture": { "type": "string", "minLength": 1 }, + "abi": { "type": "string", "minLength": 1 }, + "soabi": { "type": "string", "minLength": 1 } + } + }, + "PythonInterpreterProvenance": { + "allOf": [ + { "$ref": "#/$defs/PythonExecutableProvenance" }, + { + "type": "object", + "required": ["version", "implementation", "platform", "architecture", "abi", "soabi"], + "properties": { + "version": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+(?:(?:a|b|rc)[0-9]+)?(?:\\+[A-Za-z0-9]+(?:[._-][A-Za-z0-9]+)*)?$" + } + } + } + ] + }, + "PythonValidationCapabilityRun": { + "type": "object", + "additionalProperties": false, + "required": [ + "schemaId", "schemaVersion", "capability", "checkId", "projectKey", "contextFingerprint", + "projectRoot", "targets", "selectedSourcePaths", "selectedConfigPaths", "afterStateManifestFingerprint", + "status", "durationMs", "diagnosticCount", "errorCount", + "warningCount", "noteCount" + ], + "properties": { + "schemaId": { "const": "opcore.python.validation-capability-run" }, + "schemaVersion": { "const": 1 }, + "capability": { "const": "types" }, + "checkId": { "const": "python.types" }, + "projectKey": { "type": "string", "pattern": "^sha256:[a-f0-9]{64}$" }, + "contextFingerprint": { "type": "string", "pattern": "^sha256:[a-f0-9]{64}$" }, + "projectRoot": { "$ref": "#/$defs/PythonProjectRoot" }, + "targets": { + "type": "array", "minItems": 1, "uniqueItems": true, + "items": { "allOf": [{ "$ref": "#/$defs/RepoRelativePath" }, { "pattern": "\\.pyi?$" }] } + }, + "selectedSourcePaths": { + "type": "array", "minItems": 1, "uniqueItems": true, + "items": { "allOf": [{ "$ref": "#/$defs/RepoRelativePath" }, { "pattern": "\\.pyi?$" }] } + }, + "selectedConfigPaths": { + "type": "array", "uniqueItems": true, "items": { "$ref": "#/$defs/RepoRelativePath" } + }, + "afterStateManifestFingerprint": { "type": "string", "pattern": "^sha256:[a-f0-9]{64}$" }, + "authority": { "enum": ["mypy", "pyright"] }, + "authoritySource": { "enum": ["explicit", "project_config"] }, + "status": { + "enum": ["passed", "findings", "tool_unavailable", "invalid_config", "timeout", "unsupported_target", "tool_failure"] + }, + "tool": { + "type": "object", "additionalProperties": false, + "required": ["name", "executable", "argv", "cwd", "source"], + "properties": { + "name": { "enum": ["mypy", "pyright"] }, + "executable": { + "type": "string", + "pattern": "^(?:(?:repo|project):(?!.*(?:^|/)\\.\\.(?:/|$))[A-Za-z0-9._+@-]+(?:/[A-Za-z0-9._+@-]+)*|(?:path|external):[A-Za-z0-9_.+-]+)$" + }, + "argv": { + "type": "array", + "minItems": 1, + "items": { + "type": "string", + "minLength": 1, + "pattern": "^(?!(?:/|\\\\|[A-Za-z]:[\\\\/]))(?!.*[\\s(\\\"'=](?:/|\\\\|[A-Za-z]:[\\\\/]))(?!.*file://).+$" + } + }, + "cwd": { "$ref": "#/$defs/PythonProjectRoot" }, + "source": { "enum": ["explicit_override", "active_environment", "project_local_environment", "manager_environment", "path"] }, + "version": { "type": "string", "pattern": "^[0-9]+\\.[0-9][-+._A-Za-z0-9]*$" }, + "configFile": { "$ref": "#/$defs/RepoRelativePath" } + } + }, + "execution": { + "type": "object", "additionalProperties": false, "required": ["termination"], + "properties": { + "termination": { "enum": ["exited", "timeout", "signal", "spawn_error"] }, + "exitCode": { "type": "integer", "minimum": 0 }, + "signal": { "type": "string", "minLength": 1 }, + "failureSummary": { + "type": "string", + "minLength": 1, + "maxLength": 1024, + "pattern": "^(?!(?:/|\\\\|[A-Za-z]:[\\\\/]))(?!.*[\\s(\\\"'=](?:/|\\\\|[A-Za-z]:[\\\\/]))(?!.*file://).+$" + } + }, + "allOf": [ + { + "if": { "properties": { "termination": { "const": "exited" } }, "required": ["termination"] }, + "then": { "required": ["exitCode"] }, + "else": { "not": { "required": ["exitCode"] } } + }, + { + "if": { "properties": { "termination": { "const": "signal" } }, "required": ["termination"] }, + "then": { "required": ["signal"] }, + "else": { "not": { "required": ["signal"] } } + }, + { + "if": { "properties": { "termination": { "enum": ["timeout", "signal", "spawn_error"] } }, "required": ["termination"] }, + "then": { "required": ["failureSummary"] } + } + ] + }, + "durationMs": { "type": "integer", "minimum": 0 }, + "diagnosticCount": { "type": "integer", "minimum": 0 }, + "errorCount": { "type": "integer", "minimum": 0 }, + "warningCount": { "type": "integer", "minimum": 0 }, + "noteCount": { "type": "integer", "minimum": 0 } + }, + "allOf": [ + { + "if": { "required": ["authority"] }, + "then": { "required": ["authoritySource"] } + }, + { + "if": { "required": ["authoritySource"] }, + "then": { "required": ["authority"] } + }, + { + "if": { "required": ["tool"] }, + "then": { "required": ["authority", "authoritySource"] } + }, + { + "if": { + "properties": { "status": { "enum": ["invalid_config", "unsupported_target"] } }, + "required": ["status"] + }, + "else": { "required": ["authority", "authoritySource"] } + }, + { + "if": { "properties": { "status": { "enum": ["passed", "findings"] } }, "required": ["status"] }, + "then": { "required": ["tool", "execution"] } + }, + { + "if": { "properties": { "status": { "const": "timeout" } }, "required": ["status"] }, + "then": { + "required": ["tool", "execution"], + "properties": { + "execution": { + "properties": { "termination": { "const": "timeout" } }, + "required": ["termination", "failureSummary"] + } + } + } + }, + { + "if": { "properties": { "status": { "const": "passed" } }, "required": ["status"] }, + "then": { + "properties": { + "errorCount": { "const": 0 }, + "execution": { + "properties": { "termination": { "const": "exited" }, "exitCode": { "const": 0 } }, + "required": ["termination", "exitCode"] + } + } + } + }, + { + "if": { "properties": { "status": { "const": "findings" } }, "required": ["status"] }, + "then": { + "anyOf": [ + { "properties": { "errorCount": { "minimum": 1 } }, "required": ["errorCount"] }, + { "properties": { "warningCount": { "minimum": 1 } }, "required": ["warningCount"] } + ], + "properties": { + "diagnosticCount": { "minimum": 1 }, + "execution": { + "properties": { "termination": { "const": "exited" }, "exitCode": { "const": 1 } }, + "required": ["termination", "exitCode"] + } + } + } + }, + { + "if": { "properties": { "status": { "const": "unsupported_target" } }, "required": ["status"] }, + "then": { "not": { "required": ["execution"] } } + }, + { + "if": { "properties": { "status": { "const": "invalid_config" } }, "required": ["status"] }, + "then": { + "anyOf": [ + { + "not": { + "anyOf": [ + { "required": ["authority"] }, + { "required": ["authoritySource"] }, + { "required": ["tool"] }, + { "required": ["execution"] } + ] + } + }, + { + "required": ["authority", "authoritySource"], + "not": { "required": ["execution"] } + }, + { + "required": ["authority", "authoritySource", "tool", "execution"], + "properties": { + "execution": { + "properties": { "termination": { "const": "exited" } }, + "required": ["termination", "exitCode", "failureSummary"] + } + } + } + ] + } + }, + { + "if": { "properties": { "status": { "const": "tool_unavailable" } }, "required": ["status"] }, + "then": { "required": ["tool"], "not": { "required": ["execution"] } } + }, + { + "if": { "properties": { "status": { "const": "tool_failure" } }, "required": ["status"] }, + "then": { + "required": ["tool", "execution"], + "properties": { + "execution": { + "properties": { "termination": { "enum": ["exited", "signal", "spawn_error"] } }, + "required": ["termination", "failureSummary"] + } + } + } + }, + { + "if": { "properties": { "authority": { "const": "mypy" } }, "required": ["authority", "tool"] }, + "then": { "properties": { "tool": { "properties": { "name": { "const": "mypy" } } } } } + }, + { + "if": { "properties": { "authority": { "const": "pyright" } }, "required": ["authority", "tool"] }, + "then": { "properties": { "tool": { "properties": { "name": { "const": "pyright" } } } } } + } + ] + }, "ValidationResult": { "type": "object", "additionalProperties": false, @@ -2411,6 +2794,7 @@ "type": "array", "items": { "type": "object", + "additionalProperties": false, "required": [ "category", "message", @@ -2447,13 +2831,92 @@ "code": { "type": "string", "minLength": 1 + }, + "line": { + "type": "integer", + "minimum": 1 + }, + "column": { + "type": "integer", + "minimum": 1 + }, + "endLine": { + "type": "integer", + "minimum": 1 + }, + "endColumn": { + "type": "integer", + "minimum": 1 + }, + "tool": { + "type": "object", + "additionalProperties": false, + "required": [ + "name", + "command" + ], + "properties": { + "name": { + "type": "string", + "minLength": 1 + }, + "command": { + "type": "string", + "minLength": 1 + }, + "version": { + "type": "string", + "minLength": 1 + }, + "source": { + "type": "string", + "minLength": 1 + }, + "cwd": { + "type": "string", + "minLength": 1 + } + } } - } + }, + "allOf": [ + { + "if": { + "required": ["column"] + }, + "then": { + "required": ["line"] + } + }, + { + "if": { + "anyOf": [ + { "required": ["endLine"] }, + { "required": ["endColumn"] } + ] + }, + "then": { + "required": ["line"] + } + }, + { + "if": { + "required": ["endColumn"] + }, + "then": { + "required": ["endLine"] + } + } + ] } }, "graphStatus": { "$ref": "#/$defs/GraphProviderStatus" }, + "pythonCapabilityRuns": { + "type": "array", + "items": { "$ref": "#/$defs/PythonValidationCapabilityRun" } + }, "failure": { "type": "object", "additionalProperties": false, @@ -2515,6 +2978,10 @@ } } }, + "pythonProjectContexts": { + "type": "array", + "items": { "$ref": "#/$defs/PythonProjectContext" } + }, "manifest": { "type": "object", "additionalProperties": false, @@ -2617,6 +3084,17 @@ "skipped" ] }, + "outcome": { + "enum": [ + "passed", + "findings", + "tool_unavailable", + "invalid_config", + "timeout", + "unsupported_target", + "tool_failure" + ] + }, "durationMs": { "type": "number", "minimum": 0 @@ -2648,6 +3126,38 @@ "failureMessage" ] } + }, + { + "if": { + "properties": { "outcome": { "const": "passed" } }, + "required": ["outcome"] + }, + "then": { "properties": { "status": { "const": "passed" } } } + }, + { + "if": { + "properties": { "outcome": { "const": "findings" } }, + "required": ["outcome"] + }, + "then": { "properties": { "status": { "const": "policy_failure" } } } + }, + { + "if": { + "properties": { + "outcome": { + "enum": ["tool_unavailable", "invalid_config", "unsupported_target"] + } + }, + "required": ["outcome"] + }, + "then": { "properties": { "status": { "const": "unsupported_request" } } } + }, + { + "if": { + "properties": { "outcome": { "enum": ["timeout", "tool_failure"] } }, + "required": ["outcome"] + }, + "then": { "properties": { "status": { "const": "infrastructure_failure" } } } } ] } @@ -4209,6 +4719,7 @@ "graphModes", "hypothetical", "statusSurfaces", + "pythonProjectContext", "writeGate", "checkIds" ], @@ -4397,6 +4908,47 @@ ] } }, + "pythonProjectContext": { + "type": "object", + "additionalProperties": false, + "required": [ + "schemaId", + "outcomes", + "readOnly", + "installs" + ], + "properties": { + "schemaId": { + "const": "opcore.python.project-context.v1" + }, + "outcomes": { + "type": "array", + "minItems": 4, + "maxItems": 4, + "uniqueItems": true, + "items": { + "enum": [ + "resolved", + "degraded", + "unsupported", + "ambiguous" + ] + }, + "allOf": [ + { "contains": { "const": "resolved" } }, + { "contains": { "const": "degraded" } }, + { "contains": { "const": "unsupported" } }, + { "contains": { "const": "ambiguous" } } + ] + }, + "readOnly": { + "const": true + }, + "installs": { + "const": false + } + } + }, "writeGate": { "type": "object", "additionalProperties": false, @@ -6101,6 +6653,10 @@ } } }, + "pythonProjectContexts": { + "type": "array", + "items": { "$ref": "#/$defs/PythonProjectContext" } + }, "degradedToolchains": { "type": "array", "items": { @@ -6641,6 +7197,10 @@ }, "policy": { "$ref": "#/$defs/OpcoreValidationPolicySummary" + }, + "pythonProjectContexts": { + "type": "array", + "items": { "$ref": "#/$defs/PythonProjectContext" } } } }, @@ -7514,6 +8074,10 @@ "pattern": "^[a-z][a-z0-9]*(?:[._:-][a-z0-9]+)*$" } }, + "contexts": { + "type": "array", + "items": { "$ref": "#/$defs/PythonProjectContext" } + }, "notes": { "type": "array", "items": { diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index d386e00..2ad9bb5 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -163,6 +163,29 @@ export const validationCheckRunStatuses = [ ] 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 validationSkippedCheckReasons = [ "graph_unavailable", "unsupported_scope", @@ -1139,12 +1162,194 @@ export interface ValidationRequest { 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 PythonValidationCapabilityRun { + 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 { @@ -1159,6 +1364,7 @@ export interface ValidationCheckManifestEntry { export interface ValidationCheckRunSummary { checkId: string; status: ValidationCheckRunStatus; + outcome?: ValidationCheckOutcome; durationMs?: number; diagnosticCount?: number; failureMessage?: string; @@ -1188,6 +1394,8 @@ export interface ValidationResult { failure?: ValidationFailure; refusal?: EditRefusal; manifest?: ValidationResultManifest; + pythonProjectContexts?: readonly PythonProjectContext[]; + pythonCapabilityRuns?: readonly PythonValidationCapabilityRun[]; } export interface RequiredContextDocPolicy { @@ -1399,6 +1607,12 @@ export interface ManagedToolDescriptorCapabilities { 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")[]; @@ -2142,6 +2356,7 @@ export interface OpcoreRepoStatePayload { tool: string; failureMessage?: string; }[]; + pythonProjectContexts?: readonly PythonProjectContext[]; }; activation: { ready: boolean; @@ -2267,6 +2482,7 @@ export interface OpcoreInitPythonEnvironment { path: string; }[]; notes: readonly string[]; + contexts?: readonly PythonProjectContext[]; } export interface OpcoreInitSettings { @@ -2361,6 +2577,7 @@ export interface OpcoreMetricReport { diagnosticCount: number; checkCount: number; policy?: OpcoreValidationPolicySummary; + pythonProjectContexts?: readonly PythonProjectContext[]; }; signals: readonly OpcoreMetricSignal[]; degradations: readonly OpcoreMetricDegradation[]; @@ -3954,6 +4171,20 @@ function validateManagedToolValidationCapabilities(validation: ManagedToolDescri 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"); } @@ -4379,6 +4610,9 @@ export function validateOpcoreRepoStatePayload(payload: OpcoreRepoStatePayload): 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"); @@ -4639,6 +4873,7 @@ function validateOpcoreInitPythonEnvironment(environment: OpcoreInitPythonEnviro validateRepoRelativePath(virtualEnvironment.path); } validateStringArray(environment.notes, "Opcore init Python environment notes", { allowEmpty: true }); + if (environment.contexts !== undefined) validatePythonProjectContexts(environment.contexts); return environment; } @@ -4918,6 +5153,9 @@ export function validateOpcoreMetricReport(report: OpcoreMetricReport): OpcoreMe 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"); } @@ -6439,9 +6677,451 @@ export function validateValidationResultPayload(result: ValidationResult): Valid 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 || 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: PythonValidationCapabilityRun): 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: PythonValidationCapabilityRun): 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: PythonValidationCapabilityRun): 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: PythonValidationCapabilityRun): 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: PythonValidationCapabilityRun): 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: PythonValidationCapabilityRun): 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: PythonValidationCapabilityRun): 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: PythonValidationCapabilityRun): 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: PythonValidationCapabilityRun): 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: PythonValidationCapabilityRun): void { + if (run.execution !== undefined) throw new Error(`${run.status} Python validation capability run must not include execution evidence`); +} + +function validateUnavailablePythonCapability(run: PythonValidationCapabilityRun): 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: PythonValidationCapabilityRun): 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 validatePythonValidationCapabilityTool( + tool: PythonValidationCapabilityToolProvenance, + run: PythonValidationCapabilityRun +): 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 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"); @@ -6778,9 +7458,40 @@ function validateValidationDiagnostic(diagnostic: ValidationDiagnostic): Validat 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"); @@ -6899,6 +7610,10 @@ function validateValidationCheckRunSummary(run: ValidationCheckRunSummary): Vali 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"); @@ -6911,6 +7626,22 @@ function validateValidationCheckRunSummary(run: ValidationCheckRunSummary): Vali 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 validateValidationSkippedCheck(skippedCheck: ValidationSkippedCheck): ValidationSkippedCheck { if (!skippedCheck || typeof skippedCheck !== "object") { throw new Error("Validation skipped check is required"); diff --git a/packages/edit/package.json b/packages/edit/package.json index 65feb8b..2a9e305 100644 --- a/packages/edit/package.json +++ b/packages/edit/package.json @@ -1,6 +1,6 @@ { "name": "@the-open-engine/opcore-edit", - "version": "0.2.0", + "version": "0.2.1", "description": "Deterministic edit planning and validation-gated apply for coding agents.", "type": "module", "license": "MIT", @@ -25,7 +25,7 @@ "developer-tools" ], "dependencies": { - "@the-open-engine/opcore-contracts": "0.2.0", + "@the-open-engine/opcore-contracts": "0.2.1", "ts-morph": "^28.0.0" }, "engines": { diff --git a/packages/fixtures/descriptors/opcore.managed-tool.json b/packages/fixtures/descriptors/opcore.managed-tool.json index 6609dd6..1861faa 100644 --- a/packages/fixtures/descriptors/opcore.managed-tool.json +++ b/packages/fixtures/descriptors/opcore.managed-tool.json @@ -5,12 +5,12 @@ "name": "opcore", "releaseLine": "opcore", "packageName": "opcore", - "version": "0.2.0" + "version": "0.2.1" }, "packageIdentity": { "packageName": "opcore", "artifactName": "opcore", - "version": "0.2.0" + "version": "0.2.1" }, "entrypoints": [ { @@ -334,6 +334,17 @@ "status", "doctor" ], + "pythonProjectContext": { + "schemaId": "opcore.python.project-context.v1", + "outcomes": [ + "resolved", + "degraded", + "unsupported", + "ambiguous" + ], + "readOnly": true, + "installs": false + }, "writeGate": { "initScopes": [ "repo", diff --git a/packages/fixtures/graph-release/release-readiness-fixture.json b/packages/fixtures/graph-release/release-readiness-fixture.json index 418af30..f7bbbee 100644 --- a/packages/fixtures/graph-release/release-readiness-fixture.json +++ b/packages/fixtures/graph-release/release-readiness-fixture.json @@ -7,23 +7,23 @@ "graphPackageVersions": [ { "packageName": "@the-open-engine/opcore-graph", - "version": "0.2.0" + "version": "0.2.1" }, { "packageName": "@the-open-engine/opcore-contracts", - "version": "0.2.0" + "version": "0.2.1" }, { "packageName": "@the-open-engine/opcore-graph-core-darwin-arm64", - "version": "0.2.0" + "version": "0.2.1" }, { "packageName": "@the-open-engine/opcore-graph-core-darwin-x64", - "version": "0.2.0" + "version": "0.2.1" }, { "packageName": "@the-open-engine/opcore-graph-core-linux-x64", - "version": "0.2.0" + "version": "0.2.1" } ], "graphProviderSchemaVersion": 1, @@ -477,7 +477,7 @@ ], "packageInspection": { "packageName": "@the-open-engine/opcore-graph", - "tarballName": "the-open-engine-opcore-graph-0.2.0.tgz", + "tarballName": "the-open-engine-opcore-graph-0.2.1.tgz", "fileCount": 17, "files": [ "package.json", @@ -560,7 +560,7 @@ ], "graphArtifact": { "artifactName": "opcore-graph-core", - "artifactVersion": "0.2.0", + "artifactVersion": "0.2.1", "targetPlatform": "darwin-arm64", "binaryPath": "opcore-graph-core", "checksumPath": "opcore-graph-core.sha256", @@ -624,7 +624,7 @@ "targetPlatform": "darwin-arm64", "metadata": { "artifactName": "opcore-graph-core", - "artifactVersion": "0.2.0", + "artifactVersion": "0.2.1", "targetPlatform": "darwin-arm64", "binaryPath": "opcore-graph-core", "checksumPath": "opcore-graph-core.sha256", @@ -650,7 +650,7 @@ "targetPlatform": "darwin-x64", "metadata": { "artifactName": "opcore-graph-core", - "artifactVersion": "0.2.0", + "artifactVersion": "0.2.1", "targetPlatform": "darwin-x64", "binaryPath": "opcore-graph-core", "checksumPath": "opcore-graph-core.sha256", @@ -676,7 +676,7 @@ "targetPlatform": "linux-x64", "metadata": { "artifactName": "opcore-graph-core", - "artifactVersion": "0.2.0", + "artifactVersion": "0.2.1", "targetPlatform": "linux-x64", "binaryPath": "opcore-graph-core", "checksumPath": "opcore-graph-core.sha256", diff --git a/packages/fixtures/package.json b/packages/fixtures/package.json index fc5e6b3..1d0e1a4 100644 --- a/packages/fixtures/package.json +++ b/packages/fixtures/package.json @@ -1,6 +1,6 @@ { "name": "@the-open-engine/opcore-fixtures", - "version": "0.2.0", + "version": "0.2.1", "private": true, "description": "Internal fixtures and conformance inputs for Opcore tests and examples.", "type": "module", diff --git a/packages/fixtures/source-extraction/python/python.expected.json b/packages/fixtures/source-extraction/python/python.expected.json index 6f4bea0..9495ecd 100644 --- a/packages/fixtures/source-extraction/python/python.expected.json +++ b/packages/fixtures/source-extraction/python/python.expected.json @@ -20,13 +20,31 @@ "class:src/pkg/base.py#BaseModel", "class:src/pkg/models.py#PublicModel", "class:tests/test_models.py#TestPublicModel", + "file:src/cases/alias_case.py", + "file:src/cases/conditional_case.py", + "file:src/cases/docstring_case.py", + "file:src/cases/multiline_case.py", + "file:src/cases/namespace_case.py", + "file:src/cases/package_case.py", + "file:src/cases/star_case.py", + "file:src/cases/stub_case.py", + "file:src/cases/submodule_case.py", + "file:src/namespace_pkg/tool.py", "file:src/pkg/__init__.py", + "file:src/pkg/alias_target.py", "file:src/pkg/async_tools.py", "file:src/pkg/base.py", "file:src/pkg/broken.py", + "file:src/pkg/conditional_target.py", + "file:src/pkg/ghost.py", "file:src/pkg/helpers.py", "file:src/pkg/models.py", + "file:src/pkg/multiline.py", + "file:src/pkg/relative_case.py", + "file:src/pkg/relative_target.py", + "file:src/pkg/star_target.py", "file:src/pkg/stubs.pyi", + "file:src/pkg/submodule.py", "file:src/pkg/uses_stub.py", "file:tests/test_models.py", "function:src/pkg/async_tools.py#load_name", @@ -41,17 +59,52 @@ "function:src/pkg/uses_stub.py#call_stub", "function:tests/test_models.py#TestPublicModel.test_render", "function:tests/test_models.py#test_make_model", + "module:src/cases/alias_case.py#src.cases.alias_case", + "module:src/cases/conditional_case.py#src.cases.conditional_case", + "module:src/cases/docstring_case.py#src.cases.docstring_case", + "module:src/cases/multiline_case.py#src.cases.multiline_case", + "module:src/cases/namespace_case.py#src.cases.namespace_case", + "module:src/cases/package_case.py#src.cases.package_case", + "module:src/cases/star_case.py#src.cases.star_case", + "module:src/cases/stub_case.py#src.cases.stub_case", + "module:src/cases/submodule_case.py#src.cases.submodule_case", + "module:src/namespace_pkg/tool.py#src.namespace_pkg.tool", "module:src/pkg/__init__.py#src.pkg", + "module:src/pkg/alias_target.py#src.pkg.alias_target", "module:src/pkg/async_tools.py#src.pkg.async_tools", "module:src/pkg/base.py#src.pkg.base", "module:src/pkg/broken.py#src.pkg.broken", + "module:src/pkg/conditional_target.py#src.pkg.conditional_target", + "module:src/pkg/ghost.py#src.pkg.ghost", "module:src/pkg/helpers.py#src.pkg.helpers", "module:src/pkg/models.py#src.pkg.models", + "module:src/pkg/multiline.py#src.pkg.multiline", + "module:src/pkg/relative_case.py#src.pkg.relative_case", + "module:src/pkg/relative_target.py#src.pkg.relative_target", + "module:src/pkg/star_target.py#src.pkg.star_target", "module:src/pkg/stubs.pyi#src.pkg.stubs", + "module:src/pkg/submodule.py#src.pkg.submodule", "module:src/pkg/uses_stub.py#src.pkg.uses_stub", "module:tests/test_models.py#tests.test_models", + "variable:src/cases/alias_case.py#VALUE", + "variable:src/cases/conditional_case.py#VALUE", + "variable:src/cases/docstring_case.py#VALUE", + "variable:src/cases/multiline_case.py#VALUE", + "variable:src/cases/namespace_case.py#VALUE", + "variable:src/cases/package_case.py#VALUE", + "variable:src/cases/stub_case.py#VALUE", + "variable:src/cases/submodule_case.py#VALUE", + "variable:src/namespace_pkg/tool.py#VALUE", "variable:src/pkg/__init__.py#PACKAGE_VALUE", - "variable:src/pkg/models.py#_private" + "variable:src/pkg/alias_target.py#VALUE", + "variable:src/pkg/conditional_target.py#VALUE", + "variable:src/pkg/ghost.py#VALUE", + "variable:src/pkg/models.py#_private", + "variable:src/pkg/multiline.py#VALUE", + "variable:src/pkg/relative_case.py#VALUE", + "variable:src/pkg/relative_target.py#VALUE", + "variable:src/pkg/star_target.py#STAR_VALUE", + "variable:src/pkg/submodule.py#VALUE" ], "edgeTriples": [ [ @@ -104,6 +157,11 @@ "function:tests/test_models.py#TestPublicModel.test_render", "class:src/pkg/models.py#PublicModel" ], + [ + "CALLS", + "variable:src/cases/stub_case.py#VALUE", + "function:src/pkg/stubs.pyi#stubbed" + ], [ "CALLS", "variable:src/pkg/__init__.py#PACKAGE_VALUE", @@ -124,11 +182,66 @@ "class:tests/test_models.py#TestPublicModel", "function:tests/test_models.py#TestPublicModel.test_render" ], + [ + "CONTAINS", + "file:src/cases/alias_case.py", + "module:src/cases/alias_case.py#src.cases.alias_case" + ], + [ + "CONTAINS", + "file:src/cases/conditional_case.py", + "module:src/cases/conditional_case.py#src.cases.conditional_case" + ], + [ + "CONTAINS", + "file:src/cases/docstring_case.py", + "module:src/cases/docstring_case.py#src.cases.docstring_case" + ], + [ + "CONTAINS", + "file:src/cases/multiline_case.py", + "module:src/cases/multiline_case.py#src.cases.multiline_case" + ], + [ + "CONTAINS", + "file:src/cases/namespace_case.py", + "module:src/cases/namespace_case.py#src.cases.namespace_case" + ], + [ + "CONTAINS", + "file:src/cases/package_case.py", + "module:src/cases/package_case.py#src.cases.package_case" + ], + [ + "CONTAINS", + "file:src/cases/star_case.py", + "module:src/cases/star_case.py#src.cases.star_case" + ], + [ + "CONTAINS", + "file:src/cases/stub_case.py", + "module:src/cases/stub_case.py#src.cases.stub_case" + ], + [ + "CONTAINS", + "file:src/cases/submodule_case.py", + "module:src/cases/submodule_case.py#src.cases.submodule_case" + ], + [ + "CONTAINS", + "file:src/namespace_pkg/tool.py", + "module:src/namespace_pkg/tool.py#src.namespace_pkg.tool" + ], [ "CONTAINS", "file:src/pkg/__init__.py", "module:src/pkg/__init__.py#src.pkg" ], + [ + "CONTAINS", + "file:src/pkg/alias_target.py", + "module:src/pkg/alias_target.py#src.pkg.alias_target" + ], [ "CONTAINS", "file:src/pkg/async_tools.py", @@ -144,6 +257,16 @@ "file:src/pkg/broken.py", "module:src/pkg/broken.py#src.pkg.broken" ], + [ + "CONTAINS", + "file:src/pkg/conditional_target.py", + "module:src/pkg/conditional_target.py#src.pkg.conditional_target" + ], + [ + "CONTAINS", + "file:src/pkg/ghost.py", + "module:src/pkg/ghost.py#src.pkg.ghost" + ], [ "CONTAINS", "file:src/pkg/helpers.py", @@ -154,11 +277,36 @@ "file:src/pkg/models.py", "module:src/pkg/models.py#src.pkg.models" ], + [ + "CONTAINS", + "file:src/pkg/multiline.py", + "module:src/pkg/multiline.py#src.pkg.multiline" + ], + [ + "CONTAINS", + "file:src/pkg/relative_case.py", + "module:src/pkg/relative_case.py#src.pkg.relative_case" + ], + [ + "CONTAINS", + "file:src/pkg/relative_target.py", + "module:src/pkg/relative_target.py#src.pkg.relative_target" + ], + [ + "CONTAINS", + "file:src/pkg/star_target.py", + "module:src/pkg/star_target.py#src.pkg.star_target" + ], [ "CONTAINS", "file:src/pkg/stubs.pyi", "module:src/pkg/stubs.pyi#src.pkg.stubs" ], + [ + "CONTAINS", + "file:src/pkg/submodule.py", + "module:src/pkg/submodule.py#src.pkg.submodule" + ], [ "CONTAINS", "file:src/pkg/uses_stub.py", @@ -174,11 +322,61 @@ "function:src/pkg/async_tools.py#load_name", "function:src/pkg/async_tools.py#load_name.inner" ], + [ + "CONTAINS", + "module:src/cases/alias_case.py#src.cases.alias_case", + "variable:src/cases/alias_case.py#VALUE" + ], + [ + "CONTAINS", + "module:src/cases/conditional_case.py#src.cases.conditional_case", + "variable:src/cases/conditional_case.py#VALUE" + ], + [ + "CONTAINS", + "module:src/cases/docstring_case.py#src.cases.docstring_case", + "variable:src/cases/docstring_case.py#VALUE" + ], + [ + "CONTAINS", + "module:src/cases/multiline_case.py#src.cases.multiline_case", + "variable:src/cases/multiline_case.py#VALUE" + ], + [ + "CONTAINS", + "module:src/cases/namespace_case.py#src.cases.namespace_case", + "variable:src/cases/namespace_case.py#VALUE" + ], + [ + "CONTAINS", + "module:src/cases/package_case.py#src.cases.package_case", + "variable:src/cases/package_case.py#VALUE" + ], + [ + "CONTAINS", + "module:src/cases/stub_case.py#src.cases.stub_case", + "variable:src/cases/stub_case.py#VALUE" + ], + [ + "CONTAINS", + "module:src/cases/submodule_case.py#src.cases.submodule_case", + "variable:src/cases/submodule_case.py#VALUE" + ], + [ + "CONTAINS", + "module:src/namespace_pkg/tool.py#src.namespace_pkg.tool", + "variable:src/namespace_pkg/tool.py#VALUE" + ], [ "CONTAINS", "module:src/pkg/__init__.py#src.pkg", "variable:src/pkg/__init__.py#PACKAGE_VALUE" ], + [ + "CONTAINS", + "module:src/pkg/alias_target.py#src.pkg.alias_target", + "variable:src/pkg/alias_target.py#VALUE" + ], [ "CONTAINS", "module:src/pkg/async_tools.py#src.pkg.async_tools", @@ -194,6 +392,16 @@ "module:src/pkg/base.py#src.pkg.base", "class:src/pkg/base.py#BaseModel" ], + [ + "CONTAINS", + "module:src/pkg/conditional_target.py#src.pkg.conditional_target", + "variable:src/pkg/conditional_target.py#VALUE" + ], + [ + "CONTAINS", + "module:src/pkg/ghost.py#src.pkg.ghost", + "variable:src/pkg/ghost.py#VALUE" + ], [ "CONTAINS", "module:src/pkg/helpers.py#src.pkg.helpers", @@ -219,11 +427,36 @@ "module:src/pkg/models.py#src.pkg.models", "variable:src/pkg/models.py#_private" ], + [ + "CONTAINS", + "module:src/pkg/multiline.py#src.pkg.multiline", + "variable:src/pkg/multiline.py#VALUE" + ], + [ + "CONTAINS", + "module:src/pkg/relative_case.py#src.pkg.relative_case", + "variable:src/pkg/relative_case.py#VALUE" + ], + [ + "CONTAINS", + "module:src/pkg/relative_target.py#src.pkg.relative_target", + "variable:src/pkg/relative_target.py#VALUE" + ], + [ + "CONTAINS", + "module:src/pkg/star_target.py#src.pkg.star_target", + "variable:src/pkg/star_target.py#STAR_VALUE" + ], [ "CONTAINS", "module:src/pkg/stubs.pyi#src.pkg.stubs", "function:src/pkg/stubs.pyi#stubbed" ], + [ + "CONTAINS", + "module:src/pkg/submodule.py#src.pkg.submodule", + "variable:src/pkg/submodule.py#VALUE" + ], [ "CONTAINS", "module:src/pkg/uses_stub.py#src.pkg.uses_stub", @@ -239,6 +472,56 @@ "module:tests/test_models.py#tests.test_models", "function:tests/test_models.py#test_make_model" ], + [ + "DEPENDS_ON", + "file:src/cases/alias_case.py", + "file:src/pkg/alias_target.py" + ], + [ + "DEPENDS_ON", + "file:src/cases/conditional_case.py", + "file:src/pkg/conditional_target.py" + ], + [ + "DEPENDS_ON", + "file:src/cases/docstring_case.py", + "file:src/pkg/__init__.py" + ], + [ + "DEPENDS_ON", + "file:src/cases/multiline_case.py", + "file:src/pkg/multiline.py" + ], + [ + "DEPENDS_ON", + "file:src/cases/multiline_case.py", + "file:src/pkg/relative_target.py" + ], + [ + "DEPENDS_ON", + "file:src/cases/namespace_case.py", + "file:src/namespace_pkg/tool.py" + ], + [ + "DEPENDS_ON", + "file:src/cases/package_case.py", + "file:src/pkg/__init__.py" + ], + [ + "DEPENDS_ON", + "file:src/cases/star_case.py", + "file:src/pkg/star_target.py" + ], + [ + "DEPENDS_ON", + "file:src/cases/stub_case.py", + "file:src/pkg/stubs.pyi" + ], + [ + "DEPENDS_ON", + "file:src/cases/submodule_case.py", + "file:src/pkg/submodule.py" + ], [ "DEPENDS_ON", "file:src/pkg/__init__.py", @@ -259,6 +542,11 @@ "file:src/pkg/models.py", "file:src/pkg/helpers.py" ], + [ + "DEPENDS_ON", + "file:src/pkg/relative_case.py", + "file:src/pkg/relative_target.py" + ], [ "DEPENDS_ON", "file:src/pkg/uses_stub.py", @@ -274,6 +562,56 @@ "file:tests/test_models.py", "file:src/pkg/models.py" ], + [ + "IMPORTS_FROM", + "file:src/cases/alias_case.py", + "file:src/pkg/alias_target.py" + ], + [ + "IMPORTS_FROM", + "file:src/cases/conditional_case.py", + "file:src/pkg/conditional_target.py" + ], + [ + "IMPORTS_FROM", + "file:src/cases/docstring_case.py", + "file:src/pkg/__init__.py" + ], + [ + "IMPORTS_FROM", + "file:src/cases/multiline_case.py", + "file:src/pkg/multiline.py" + ], + [ + "IMPORTS_FROM", + "file:src/cases/multiline_case.py", + "file:src/pkg/relative_target.py" + ], + [ + "IMPORTS_FROM", + "file:src/cases/namespace_case.py", + "file:src/namespace_pkg/tool.py" + ], + [ + "IMPORTS_FROM", + "file:src/cases/package_case.py", + "file:src/pkg/__init__.py" + ], + [ + "IMPORTS_FROM", + "file:src/cases/star_case.py", + "file:src/pkg/star_target.py" + ], + [ + "IMPORTS_FROM", + "file:src/cases/stub_case.py", + "file:src/pkg/stubs.pyi" + ], + [ + "IMPORTS_FROM", + "file:src/cases/submodule_case.py", + "file:src/pkg/submodule.py" + ], [ "IMPORTS_FROM", "file:src/pkg/__init__.py", @@ -294,6 +632,11 @@ "file:src/pkg/models.py", "file:src/pkg/helpers.py" ], + [ + "IMPORTS_FROM", + "file:src/pkg/relative_case.py", + "file:src/pkg/relative_target.py" + ], [ "IMPORTS_FROM", "file:src/pkg/uses_stub.py", @@ -330,6 +673,80 @@ "function:tests/test_models.py#test_make_model" ] ], + "pythonImportEdges": [ + { + "fromPath": "src/cases/alias_case.py", + "toPath": "src/pkg/alias_target.py" + }, + { + "fromPath": "src/cases/conditional_case.py", + "toPath": "src/pkg/conditional_target.py" + }, + { + "fromPath": "src/cases/docstring_case.py", + "toPath": "src/pkg/__init__.py" + }, + { + "fromPath": "src/cases/multiline_case.py", + "toPath": "src/pkg/multiline.py" + }, + { + "fromPath": "src/cases/multiline_case.py", + "toPath": "src/pkg/relative_target.py" + }, + { + "fromPath": "src/cases/namespace_case.py", + "toPath": "src/namespace_pkg/tool.py" + }, + { + "fromPath": "src/cases/package_case.py", + "toPath": "src/pkg/__init__.py" + }, + { + "fromPath": "src/cases/star_case.py", + "toPath": "src/pkg/star_target.py" + }, + { + "fromPath": "src/cases/stub_case.py", + "toPath": "src/pkg/stubs.pyi" + }, + { + "fromPath": "src/cases/submodule_case.py", + "toPath": "src/pkg/submodule.py" + }, + { + "fromPath": "src/pkg/__init__.py", + "toPath": "src/pkg/models.py" + }, + { + "fromPath": "src/pkg/async_tools.py", + "toPath": "src/pkg/helpers.py" + }, + { + "fromPath": "src/pkg/models.py", + "toPath": "src/pkg/base.py" + }, + { + "fromPath": "src/pkg/models.py", + "toPath": "src/pkg/helpers.py" + }, + { + "fromPath": "src/pkg/relative_case.py", + "toPath": "src/pkg/relative_target.py" + }, + { + "fromPath": "src/pkg/uses_stub.py", + "toPath": "src/pkg/stubs.pyi" + }, + { + "fromPath": "tests/test_models.py", + "toPath": "src/pkg/__init__.py" + }, + { + "fromPath": "tests/test_models.py", + "toPath": "src/pkg/models.py" + } + ], "nodeAttributes": { "class:src/pkg/base.py#BaseModel": { "decorators": [], @@ -457,9 +874,42 @@ "exported": false, "isTest": true }, + "module:src/cases/alias_case.py#src.cases.alias_case": { + "dottedName": "src.cases.alias_case" + }, + "module:src/cases/conditional_case.py#src.cases.conditional_case": { + "dottedName": "src.cases.conditional_case" + }, + "module:src/cases/docstring_case.py#src.cases.docstring_case": { + "dottedName": "src.cases.docstring_case" + }, + "module:src/cases/multiline_case.py#src.cases.multiline_case": { + "dottedName": "src.cases.multiline_case" + }, + "module:src/cases/namespace_case.py#src.cases.namespace_case": { + "dottedName": "src.cases.namespace_case" + }, + "module:src/cases/package_case.py#src.cases.package_case": { + "dottedName": "src.cases.package_case" + }, + "module:src/cases/star_case.py#src.cases.star_case": { + "dottedName": "src.cases.star_case" + }, + "module:src/cases/stub_case.py#src.cases.stub_case": { + "dottedName": "src.cases.stub_case" + }, + "module:src/cases/submodule_case.py#src.cases.submodule_case": { + "dottedName": "src.cases.submodule_case" + }, + "module:src/namespace_pkg/tool.py#src.namespace_pkg.tool": { + "dottedName": "src.namespace_pkg.tool" + }, "module:src/pkg/__init__.py#src.pkg": { "dottedName": "src.pkg" }, + "module:src/pkg/alias_target.py#src.pkg.alias_target": { + "dottedName": "src.pkg.alias_target" + }, "module:src/pkg/async_tools.py#src.pkg.async_tools": { "dottedName": "src.pkg.async_tools" }, @@ -469,33 +919,246 @@ "module:src/pkg/broken.py#src.pkg.broken": { "dottedName": "src.pkg.broken" }, + "module:src/pkg/conditional_target.py#src.pkg.conditional_target": { + "dottedName": "src.pkg.conditional_target" + }, + "module:src/pkg/ghost.py#src.pkg.ghost": { + "dottedName": "src.pkg.ghost" + }, "module:src/pkg/helpers.py#src.pkg.helpers": { "dottedName": "src.pkg.helpers" }, "module:src/pkg/models.py#src.pkg.models": { "dottedName": "src.pkg.models" }, + "module:src/pkg/multiline.py#src.pkg.multiline": { + "dottedName": "src.pkg.multiline" + }, + "module:src/pkg/relative_case.py#src.pkg.relative_case": { + "dottedName": "src.pkg.relative_case" + }, + "module:src/pkg/relative_target.py#src.pkg.relative_target": { + "dottedName": "src.pkg.relative_target" + }, + "module:src/pkg/star_target.py#src.pkg.star_target": { + "dottedName": "src.pkg.star_target" + }, "module:src/pkg/stubs.pyi#src.pkg.stubs": { "dottedName": "src.pkg.stubs" }, + "module:src/pkg/submodule.py#src.pkg.submodule": { + "dottedName": "src.pkg.submodule" + }, "module:src/pkg/uses_stub.py#src.pkg.uses_stub": { "dottedName": "src.pkg.uses_stub" }, "module:tests/test_models.py#tests.test_models": { "dottedName": "tests.test_models" }, + "variable:src/cases/alias_case.py#VALUE": { + "exportKind": "named", + "exportName": "VALUE", + "exportPolicy": "underscore_convention", + "exported": true + }, + "variable:src/cases/conditional_case.py#VALUE": { + "exportKind": "named", + "exportName": "VALUE", + "exportPolicy": "underscore_convention", + "exported": true + }, + "variable:src/cases/docstring_case.py#VALUE": { + "exportKind": "named", + "exportName": "VALUE", + "exportPolicy": "underscore_convention", + "exported": true + }, + "variable:src/cases/multiline_case.py#VALUE": { + "exportKind": "named", + "exportName": "VALUE", + "exportPolicy": "underscore_convention", + "exported": true + }, + "variable:src/cases/namespace_case.py#VALUE": { + "exportKind": "named", + "exportName": "VALUE", + "exportPolicy": "underscore_convention", + "exported": true + }, + "variable:src/cases/package_case.py#VALUE": { + "exportKind": "named", + "exportName": "VALUE", + "exportPolicy": "underscore_convention", + "exported": true + }, + "variable:src/cases/stub_case.py#VALUE": { + "exportKind": "named", + "exportName": "VALUE", + "exportPolicy": "underscore_convention", + "exported": true + }, + "variable:src/cases/submodule_case.py#VALUE": { + "exportKind": "named", + "exportName": "VALUE", + "exportPolicy": "underscore_convention", + "exported": true + }, + "variable:src/namespace_pkg/tool.py#VALUE": { + "exportKind": "named", + "exportName": "VALUE", + "exportPolicy": "underscore_convention", + "exported": true + }, "variable:src/pkg/__init__.py#PACKAGE_VALUE": { "exportKind": "named", "exportName": "PACKAGE_VALUE", "exportPolicy": "__all__", "exported": true }, + "variable:src/pkg/alias_target.py#VALUE": { + "exportKind": "named", + "exportName": "VALUE", + "exportPolicy": "underscore_convention", + "exported": true + }, + "variable:src/pkg/conditional_target.py#VALUE": { + "exportKind": "named", + "exportName": "VALUE", + "exportPolicy": "underscore_convention", + "exported": true + }, + "variable:src/pkg/ghost.py#VALUE": { + "exportKind": "named", + "exportName": "VALUE", + "exportPolicy": "underscore_convention", + "exported": true + }, "variable:src/pkg/models.py#_private": { "exportPolicy": "__all__", "exported": false + }, + "variable:src/pkg/multiline.py#VALUE": { + "exportKind": "named", + "exportName": "VALUE", + "exportPolicy": "underscore_convention", + "exported": true + }, + "variable:src/pkg/relative_case.py#VALUE": { + "exportKind": "named", + "exportName": "VALUE", + "exportPolicy": "underscore_convention", + "exported": true + }, + "variable:src/pkg/relative_target.py#VALUE": { + "exportKind": "named", + "exportName": "VALUE", + "exportPolicy": "underscore_convention", + "exported": true + }, + "variable:src/pkg/star_target.py#STAR_VALUE": { + "exportKind": "named", + "exportName": "STAR_VALUE", + "exportPolicy": "underscore_convention", + "exported": true + }, + "variable:src/pkg/submodule.py#VALUE": { + "exportKind": "named", + "exportName": "VALUE", + "exportPolicy": "underscore_convention", + "exported": true } }, "fileExports": { + "file:src/cases/alias_case.py": [ + { + "exported": "VALUE", + "kind": "named", + "local": "VALUE", + "policy": "underscore_convention", + "source": null, + "supportedSymbol": true + } + ], + "file:src/cases/conditional_case.py": [ + { + "exported": "VALUE", + "kind": "named", + "local": "VALUE", + "policy": "underscore_convention", + "source": null, + "supportedSymbol": true + } + ], + "file:src/cases/docstring_case.py": [ + { + "exported": "VALUE", + "kind": "named", + "local": "VALUE", + "policy": "underscore_convention", + "source": null, + "supportedSymbol": true + } + ], + "file:src/cases/multiline_case.py": [ + { + "exported": "VALUE", + "kind": "named", + "local": "VALUE", + "policy": "underscore_convention", + "source": null, + "supportedSymbol": true + } + ], + "file:src/cases/namespace_case.py": [ + { + "exported": "VALUE", + "kind": "named", + "local": "VALUE", + "policy": "underscore_convention", + "source": null, + "supportedSymbol": true + } + ], + "file:src/cases/package_case.py": [ + { + "exported": "VALUE", + "kind": "named", + "local": "VALUE", + "policy": "underscore_convention", + "source": null, + "supportedSymbol": true + } + ], + "file:src/cases/stub_case.py": [ + { + "exported": "VALUE", + "kind": "named", + "local": "VALUE", + "policy": "underscore_convention", + "source": null, + "supportedSymbol": true + } + ], + "file:src/cases/submodule_case.py": [ + { + "exported": "VALUE", + "kind": "named", + "local": "VALUE", + "policy": "underscore_convention", + "source": null, + "supportedSymbol": true + } + ], + "file:src/namespace_pkg/tool.py": [ + { + "exported": "VALUE", + "kind": "named", + "local": "VALUE", + "policy": "underscore_convention", + "source": null, + "supportedSymbol": true + } + ], "file:src/pkg/__init__.py": [ { "exported": "PACKAGE_VALUE", @@ -514,6 +1177,16 @@ "supportedSymbol": false } ], + "file:src/pkg/alias_target.py": [ + { + "exported": "VALUE", + "kind": "named", + "local": "VALUE", + "policy": "underscore_convention", + "source": null, + "supportedSymbol": true + } + ], "file:src/pkg/async_tools.py": [ { "exported": "trace", @@ -542,6 +1215,26 @@ "supportedSymbol": true } ], + "file:src/pkg/conditional_target.py": [ + { + "exported": "VALUE", + "kind": "named", + "local": "VALUE", + "policy": "underscore_convention", + "source": null, + "supportedSymbol": true + } + ], + "file:src/pkg/ghost.py": [ + { + "exported": "VALUE", + "kind": "named", + "local": "VALUE", + "policy": "underscore_convention", + "source": null, + "supportedSymbol": true + } + ], "file:src/pkg/helpers.py": [ { "exported": "build_name", @@ -570,6 +1263,46 @@ "supportedSymbol": true } ], + "file:src/pkg/multiline.py": [ + { + "exported": "VALUE", + "kind": "named", + "local": "VALUE", + "policy": "underscore_convention", + "source": null, + "supportedSymbol": true + } + ], + "file:src/pkg/relative_case.py": [ + { + "exported": "VALUE", + "kind": "named", + "local": "VALUE", + "policy": "underscore_convention", + "source": null, + "supportedSymbol": true + } + ], + "file:src/pkg/relative_target.py": [ + { + "exported": "VALUE", + "kind": "named", + "local": "VALUE", + "policy": "underscore_convention", + "source": null, + "supportedSymbol": true + } + ], + "file:src/pkg/star_target.py": [ + { + "exported": "STAR_VALUE", + "kind": "named", + "local": "STAR_VALUE", + "policy": "underscore_convention", + "source": null, + "supportedSymbol": true + } + ], "file:src/pkg/stubs.pyi": [ { "exported": "stubbed", @@ -580,6 +1313,16 @@ "supportedSymbol": true } ], + "file:src/pkg/submodule.py": [ + { + "exported": "VALUE", + "kind": "named", + "local": "VALUE", + "policy": "underscore_convention", + "source": null, + "supportedSymbol": true + } + ], "file:src/pkg/uses_stub.py": [ { "exported": "call_stub", diff --git a/packages/fixtures/source-extraction/python/src/cases/alias_case.py b/packages/fixtures/source-extraction/python/src/cases/alias_case.py new file mode 100644 index 0000000..fa234d1 --- /dev/null +++ b/packages/fixtures/source-extraction/python/src/cases/alias_case.py @@ -0,0 +1,3 @@ +import pkg.alias_target as aliased_target + +VALUE = aliased_target.VALUE diff --git a/packages/fixtures/source-extraction/python/src/cases/conditional_case.py b/packages/fixtures/source-extraction/python/src/cases/conditional_case.py new file mode 100644 index 0000000..d5e5e0f --- /dev/null +++ b/packages/fixtures/source-extraction/python/src/cases/conditional_case.py @@ -0,0 +1,6 @@ +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from pkg import conditional_target + +VALUE = True diff --git a/packages/fixtures/source-extraction/python/src/cases/docstring_case.py b/packages/fixtures/source-extraction/python/src/cases/docstring_case.py new file mode 100644 index 0000000..f072645 --- /dev/null +++ b/packages/fixtures/source-extraction/python/src/cases/docstring_case.py @@ -0,0 +1,8 @@ +""" +This example is documentation, not syntax: +from pkg import ghost +""" + +import pkg + +VALUE = pkg.PACKAGE_VALUE diff --git a/packages/fixtures/source-extraction/python/src/cases/multiline_case.py b/packages/fixtures/source-extraction/python/src/cases/multiline_case.py new file mode 100644 index 0000000..b92e3f6 --- /dev/null +++ b/packages/fixtures/source-extraction/python/src/cases/multiline_case.py @@ -0,0 +1,6 @@ +from pkg import ( + multiline, + relative_target as renamed_target, +) + +VALUE = multiline.VALUE + renamed_target.VALUE diff --git a/packages/fixtures/source-extraction/python/src/cases/namespace_case.py b/packages/fixtures/source-extraction/python/src/cases/namespace_case.py new file mode 100644 index 0000000..29cd242 --- /dev/null +++ b/packages/fixtures/source-extraction/python/src/cases/namespace_case.py @@ -0,0 +1,3 @@ +from namespace_pkg import tool + +VALUE = tool.VALUE diff --git a/packages/fixtures/source-extraction/python/src/cases/package_case.py b/packages/fixtures/source-extraction/python/src/cases/package_case.py new file mode 100644 index 0000000..6754f11 --- /dev/null +++ b/packages/fixtures/source-extraction/python/src/cases/package_case.py @@ -0,0 +1,3 @@ +import pkg + +VALUE = pkg.PACKAGE_VALUE diff --git a/packages/fixtures/source-extraction/python/src/cases/star_case.py b/packages/fixtures/source-extraction/python/src/cases/star_case.py new file mode 100644 index 0000000..f8c1d4a --- /dev/null +++ b/packages/fixtures/source-extraction/python/src/cases/star_case.py @@ -0,0 +1 @@ +from pkg.star_target import * diff --git a/packages/fixtures/source-extraction/python/src/cases/stub_case.py b/packages/fixtures/source-extraction/python/src/cases/stub_case.py new file mode 100644 index 0000000..2f4efcf --- /dev/null +++ b/packages/fixtures/source-extraction/python/src/cases/stub_case.py @@ -0,0 +1,3 @@ +from pkg import stubs + +VALUE = stubs.stubbed() diff --git a/packages/fixtures/source-extraction/python/src/cases/submodule_case.py b/packages/fixtures/source-extraction/python/src/cases/submodule_case.py new file mode 100644 index 0000000..e62ec9c --- /dev/null +++ b/packages/fixtures/source-extraction/python/src/cases/submodule_case.py @@ -0,0 +1,3 @@ +from pkg import submodule + +VALUE = submodule.VALUE diff --git a/packages/fixtures/source-extraction/python/src/namespace_pkg/tool.py b/packages/fixtures/source-extraction/python/src/namespace_pkg/tool.py new file mode 100644 index 0000000..b15b1b0 --- /dev/null +++ b/packages/fixtures/source-extraction/python/src/namespace_pkg/tool.py @@ -0,0 +1 @@ +VALUE = 1 diff --git a/packages/fixtures/source-extraction/python/src/pkg/alias_target.py b/packages/fixtures/source-extraction/python/src/pkg/alias_target.py new file mode 100644 index 0000000..b15b1b0 --- /dev/null +++ b/packages/fixtures/source-extraction/python/src/pkg/alias_target.py @@ -0,0 +1 @@ +VALUE = 1 diff --git a/packages/fixtures/source-extraction/python/src/pkg/conditional_target.py b/packages/fixtures/source-extraction/python/src/pkg/conditional_target.py new file mode 100644 index 0000000..b15b1b0 --- /dev/null +++ b/packages/fixtures/source-extraction/python/src/pkg/conditional_target.py @@ -0,0 +1 @@ +VALUE = 1 diff --git a/packages/fixtures/source-extraction/python/src/pkg/ghost.py b/packages/fixtures/source-extraction/python/src/pkg/ghost.py new file mode 100644 index 0000000..b15b1b0 --- /dev/null +++ b/packages/fixtures/source-extraction/python/src/pkg/ghost.py @@ -0,0 +1 @@ +VALUE = 1 diff --git a/packages/fixtures/source-extraction/python/src/pkg/models.py b/packages/fixtures/source-extraction/python/src/pkg/models.py index eb133f1..2224b6b 100644 --- a/packages/fixtures/source-extraction/python/src/pkg/models.py +++ b/packages/fixtures/source-extraction/python/src/pkg/models.py @@ -1,6 +1,9 @@ +import sys + from .base import BaseModel from .helpers import build_name -from .missing import MissingLocal +if sys.version_info < (0, 0): + from .missing import MissingLocal _private = 1 __all__ = ["PublicModel", "make_model"] diff --git a/packages/fixtures/source-extraction/python/src/pkg/multiline.py b/packages/fixtures/source-extraction/python/src/pkg/multiline.py new file mode 100644 index 0000000..b15b1b0 --- /dev/null +++ b/packages/fixtures/source-extraction/python/src/pkg/multiline.py @@ -0,0 +1 @@ +VALUE = 1 diff --git a/packages/fixtures/source-extraction/python/src/pkg/relative_case.py b/packages/fixtures/source-extraction/python/src/pkg/relative_case.py new file mode 100644 index 0000000..64e7691 --- /dev/null +++ b/packages/fixtures/source-extraction/python/src/pkg/relative_case.py @@ -0,0 +1,3 @@ +from . import relative_target + +VALUE = relative_target.VALUE diff --git a/packages/fixtures/source-extraction/python/src/pkg/relative_target.py b/packages/fixtures/source-extraction/python/src/pkg/relative_target.py new file mode 100644 index 0000000..b15b1b0 --- /dev/null +++ b/packages/fixtures/source-extraction/python/src/pkg/relative_target.py @@ -0,0 +1 @@ +VALUE = 1 diff --git a/packages/fixtures/source-extraction/python/src/pkg/star_target.py b/packages/fixtures/source-extraction/python/src/pkg/star_target.py new file mode 100644 index 0000000..a50cc06 --- /dev/null +++ b/packages/fixtures/source-extraction/python/src/pkg/star_target.py @@ -0,0 +1 @@ +STAR_VALUE = 1 diff --git a/packages/fixtures/source-extraction/python/src/pkg/submodule.py b/packages/fixtures/source-extraction/python/src/pkg/submodule.py new file mode 100644 index 0000000..b15b1b0 --- /dev/null +++ b/packages/fixtures/source-extraction/python/src/pkg/submodule.py @@ -0,0 +1 @@ +VALUE = 1 diff --git a/packages/fixtures/src/index.ts b/packages/fixtures/src/index.ts index 645ae94..206bfca 100644 --- a/packages/fixtures/src/index.ts +++ b/packages/fixtures/src/index.ts @@ -166,6 +166,11 @@ export interface SyntheticFixtureMetadata { nodeKinds: readonly string[]; edgeKinds: readonly string[]; diagnostics: readonly string[]; + importResolution?: { + owner: "graph-core"; + expectedEdgeField: "pythonImportEdges"; + cases: readonly string[]; + }; }; graphPipeline?: { operations: readonly string[]; @@ -215,7 +220,7 @@ export interface SyntheticFixtureMetadata { }; validationPython?: { fixtureRoot: string; - scenarios: readonly ("clean" | "failing" | "degraded-tools")[]; + scenarios: readonly ("clean" | "failing" | "degraded-tools" | "mypy-authority" | "pyright-authority")[]; checks: readonly string[]; degradedTools: readonly string[]; }; @@ -568,7 +573,23 @@ export const conformanceFixtureMetadata = [ languages: ["py", "pyi"], nodeKinds: ["File", "Module", "Class", "Function", "Variable"], edgeKinds: ["CONTAINS", "IMPORTS_FROM", "DEPENDS_ON", "CALLS", "TESTED_BY", "INHERITS"], - diagnostics: ["parse_error", "unresolved_import"] + diagnostics: ["parse_error", "unresolved_import"], + importResolution: { + owner: "graph-core", + expectedEdgeField: "pythonImportEdges", + cases: [ + "multiline-parenthesized", + "alias", + "conditional", + "submodule", + "star", + "relative", + "package-initializer", + "stub", + "namespace", + "src-layout" + ] + } } }, { @@ -581,7 +602,7 @@ export const conformanceFixtureMetadata = [ status: "validation_python", validationPython: { fixtureRoot: "packages/fixtures/validation-python", - scenarios: ["clean", "failing", "degraded-tools"], + scenarios: ["clean", "failing", "degraded-tools", "mypy-authority", "pyright-authority"], checks: [ "python.syntax", "python.source-hygiene", diff --git a/packages/fixtures/validation-contract/validation-fixtures.json b/packages/fixtures/validation-contract/validation-fixtures.json index 7ad8aae..c994a77 100644 --- a/packages/fixtures/validation-contract/validation-fixtures.json +++ b/packages/fixtures/validation-contract/validation-fixtures.json @@ -273,5 +273,69 @@ "invalid_payload", "skipped", "refused" + ], + "pythonCapabilityRuns": [ + { + "schemaId": "opcore.python.validation-capability-run", + "schemaVersion": 1, + "capability": "types", + "checkId": "python.types", + "projectKey": "sha256:1111111111111111111111111111111111111111111111111111111111111111", + "contextFingerprint": "sha256:2222222222222222222222222222222222222222222222222222222222222222", + "projectRoot": "services/api", + "targets": ["services/api/src/app.py"], + "selectedSourcePaths": ["services/api/src/app.py"], + "selectedConfigPaths": ["services/api/pyproject.toml"], + "afterStateManifestFingerprint": "sha256:3333333333333333333333333333333333333333333333333333333333333333", + "authority": "mypy", + "authoritySource": "project_config", + "status": "passed", + "tool": { + "name": "mypy", + "executable": "repo:services/api/.venv/bin/mypy", + "argv": ["repo:services/api/.venv/bin/mypy", "--output=json", "src/app.py"], + "cwd": "services/api", + "source": "project_local_environment", + "version": "2.3.0", + "configFile": "services/api/pyproject.toml" + }, + "execution": { "termination": "exited", "exitCode": 0 }, + "durationMs": 12, + "diagnosticCount": 0, + "errorCount": 0, + "warningCount": 0, + "noteCount": 0 + }, + { + "schemaId": "opcore.python.validation-capability-run", + "schemaVersion": 1, + "capability": "types", + "checkId": "python.types", + "projectKey": "sha256:4444444444444444444444444444444444444444444444444444444444444444", + "contextFingerprint": "sha256:5555555555555555555555555555555555555555555555555555555555555555", + "projectRoot": "services/worker", + "targets": ["services/worker/src/job.py"], + "selectedSourcePaths": ["services/worker/src/job.py"], + "selectedConfigPaths": ["services/worker/pyrightconfig.json"], + "afterStateManifestFingerprint": "sha256:6666666666666666666666666666666666666666666666666666666666666666", + "authority": "pyright", + "authoritySource": "project_config", + "status": "passed", + "tool": { + "name": "pyright", + "executable": "repo:services/worker/node_modules/.bin/pyright", + "argv": ["repo:services/worker/node_modules/.bin/pyright", "--outputjson", "--project", "pyrightconfig.json"], + "cwd": "services/worker", + "source": "project_local_environment", + "version": "1.1.411", + "configFile": "services/worker/pyrightconfig.json" + }, + "execution": { "termination": "exited", "exitCode": 0 }, + "durationMs": 9, + "diagnosticCount": 2, + "errorCount": 0, + "warningCount": 1, + "noteCount": 1 + } ] } diff --git a/packages/fixtures/validation-python/README.md b/packages/fixtures/validation-python/README.md index 7636764..50c138b 100644 --- a/packages/fixtures/validation-python/README.md +++ b/packages/fixtures/validation-python/README.md @@ -1,7 +1,5 @@ # Python validation fixtures -Synthetic fixtures for Python validation conformance: +`mypy-authority/` is the real-tool proof repository for `python.types`. It combines strict mypy configuration, a configured plugin, a stub-only dependency, and a namespace package. `scripts/check-python-mypy-authority.mjs` runs pinned mypy over both clean and hypothetical/materialized after-states and verifies portable manifest identity plus cleanup. -- `clean`: syntax/source-hygiene pass fixture. -- `failing`: syntax, source-hygiene, and import-graph diagnostics. -- `degraded-tools`: type-check degradation when optional Python type tools are absent. +`pyright-authority/` is the real-tool Pyright proof repository. It covers JSONC, recursive extends, include/exclude/ignore, extra paths, strict execution environments, namespace/src/stub layouts, hypothetical/materialized equivalence, portable receipts, packed Opcore execution, and cleanup with pinned Pyright 1.1.411. diff --git a/packages/fixtures/validation-python/compiler-truth/invalid/misplaced-future.py.fixture b/packages/fixtures/validation-python/compiler-truth/invalid/misplaced-future.py.fixture new file mode 100644 index 0000000..6bdf514 --- /dev/null +++ b/packages/fixtures/validation-python/compiler-truth/invalid/misplaced-future.py.fixture @@ -0,0 +1,2 @@ +value = 1 +from __future__ import annotations diff --git a/packages/fixtures/validation-python/compiler-truth/invalid/module-return.py.fixture b/packages/fixtures/validation-python/compiler-truth/invalid/module-return.py.fixture new file mode 100644 index 0000000..a4325f6 --- /dev/null +++ b/packages/fixtures/validation-python/compiler-truth/invalid/module-return.py.fixture @@ -0,0 +1 @@ +return 1 diff --git a/packages/fixtures/validation-python/compiler-truth/pkg/valid.py.fixture b/packages/fixtures/validation-python/compiler-truth/pkg/valid.py.fixture new file mode 100644 index 0000000..0047958 --- /dev/null +++ b/packages/fixtures/validation-python/compiler-truth/pkg/valid.py.fixture @@ -0,0 +1,72 @@ +def decorator(value): + return value + + +@decorator +async def async_api( + value: int, +) -> int: + async with ( + value, + ): + return value + + +class Example( + object, +): + pass + + +if ( + True +): + value = 1 # type: int +elif ( + False +): + value = 2 +else: + value = 3 + +for ( + item +) in ( + [value] +): + value = item + +while ( + False +): + break + +with ( + open(__file__) as handle, +): + text = handle.read() + +match ( + value +): + case ( + 1 | 2 + ): + matched = True + case _: + matched = False + +values = [ + item + for item in range(3) + if item > 0 +] +nested = f"{f'{value}'}" +continued = value + \ + 1 +first = 1; second = 2 +python_looking_text = """ +if True + this is not parsed as Python +def incomplete( +""" diff --git a/packages/fixtures/validation-python/compiler-truth/pkg/valid.pyi.fixture b/packages/fixtures/validation-python/compiler-truth/pkg/valid.pyi.fixture new file mode 100644 index 0000000..cf76197 --- /dev/null +++ b/packages/fixtures/validation-python/compiler-truth/pkg/valid.pyi.fixture @@ -0,0 +1,18 @@ +from collections.abc import Callable +from typing import Protocol, overload + + +class SupportsCall(Protocol): + def __call__( + self, + value: int, + /, + *, + transform: Callable[[int], str] = ..., + ) -> str: ... + + +@overload +def convert(value: int) -> str: ... +@overload +def convert(value: str) -> bytes: ... diff --git a/packages/fixtures/validation-python/mypy-authority/pyproject.toml.fixture b/packages/fixtures/validation-python/mypy-authority/pyproject.toml.fixture new file mode 100644 index 0000000..1ed7896 --- /dev/null +++ b/packages/fixtures/validation-python/mypy-authority/pyproject.toml.fixture @@ -0,0 +1,12 @@ +[project] +name = "opcore-mypy-authority-fixture" +version = "0.0.0" +requires-python = ">=3.11" + +[tool.mypy] +strict = true +plugins = ["acme.mypy_plugin"] +mypy_path = ["src", "stubs"] +namespace_packages = true +explicit_package_bases = true +show_error_codes = true diff --git a/packages/fixtures/validation-python/mypy-authority/src/acme/__init__.py b/packages/fixtures/validation-python/mypy-authority/src/acme/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/packages/fixtures/validation-python/mypy-authority/src/acme/__init__.py @@ -0,0 +1 @@ + diff --git a/packages/fixtures/validation-python/mypy-authority/src/acme/app.py b/packages/fixtures/validation-python/mypy-authority/src/acme/app.py new file mode 100644 index 0000000..1c36718 --- /dev/null +++ b/packages/fixtures/validation-python/mypy-authority/src/acme/app.py @@ -0,0 +1,7 @@ +from external import WidgetId + +from .widget import render + + +def run(value: WidgetId) -> str: + return render(value) diff --git a/packages/fixtures/validation-python/mypy-authority/src/acme/mypy_plugin.py b/packages/fixtures/validation-python/mypy-authority/src/acme/mypy_plugin.py new file mode 100644 index 0000000..4eb56c3 --- /dev/null +++ b/packages/fixtures/validation-python/mypy-authority/src/acme/mypy_plugin.py @@ -0,0 +1,11 @@ +from mypy.plugin import Plugin + +from .plugin_support import PLUGIN_MARKER + + +class OpcoreFixturePlugin(Plugin): + marker = PLUGIN_MARKER + + +def plugin(version: str) -> type[Plugin]: + return OpcoreFixturePlugin diff --git a/packages/fixtures/validation-python/mypy-authority/src/acme/plugin_support.py b/packages/fixtures/validation-python/mypy-authority/src/acme/plugin_support.py new file mode 100644 index 0000000..b59935e --- /dev/null +++ b/packages/fixtures/validation-python/mypy-authority/src/acme/plugin_support.py @@ -0,0 +1 @@ +PLUGIN_MARKER: str = "opcore-fixture" diff --git a/packages/fixtures/validation-python/mypy-authority/src/acme/widget.py b/packages/fixtures/validation-python/mypy-authority/src/acme/widget.py new file mode 100644 index 0000000..091c18f --- /dev/null +++ b/packages/fixtures/validation-python/mypy-authority/src/acme/widget.py @@ -0,0 +1,5 @@ +from external import WidgetId + + +def render(value: WidgetId) -> str: + return f"widget:{int(value)}" diff --git a/packages/fixtures/validation-python/mypy-authority/stubs/external/__init__.pyi b/packages/fixtures/validation-python/mypy-authority/stubs/external/__init__.pyi new file mode 100644 index 0000000..c384759 --- /dev/null +++ b/packages/fixtures/validation-python/mypy-authority/stubs/external/__init__.pyi @@ -0,0 +1,3 @@ +from typing import NewType + +WidgetId = NewType("WidgetId", int) diff --git a/packages/fixtures/validation-python/project-context/flatpkg/__init__.py.fixture b/packages/fixtures/validation-python/project-context/flatpkg/__init__.py.fixture new file mode 100644 index 0000000..b15b1b0 --- /dev/null +++ b/packages/fixtures/validation-python/project-context/flatpkg/__init__.py.fixture @@ -0,0 +1 @@ +VALUE = 1 diff --git a/packages/fixtures/validation-python/project-context/requirements.txt.fixture b/packages/fixtures/validation-python/project-context/requirements.txt.fixture new file mode 100644 index 0000000..69e74b3 --- /dev/null +++ b/packages/fixtures/validation-python/project-context/requirements.txt.fixture @@ -0,0 +1 @@ +typing-extensions>=4 diff --git a/packages/fixtures/validation-python/project-context/resolver-matrix.json b/packages/fixtures/validation-python/project-context/resolver-matrix.json new file mode 100644 index 0000000..c69f4de --- /dev/null +++ b/packages/fixtures/validation-python/project-context/resolver-matrix.json @@ -0,0 +1,348 @@ +{ + "schemaVersion": 1, + "rows": [ + { + "id": "real-posix-flat-package", + "execution": "real", + "target": "flatpkg/__init__.py", + "expected": { + "target": "flatpkg/__init__.py", + "repositoryRoot": "", + "projectRoot": ".", + "projectBoundary": ".", + "sourceRoots": ["."], + "layout": { "kinds": ["flat", "package"], "paths": ["."] }, + "evidence": [ + { "path": "requirements.txt", "role": "boundary" }, + { "path": "requirements.txt", "role": "requirements" } + ], + "targetRuntime": { "conflicts": [] }, + "managers": [ + { "kind": "pip", "configFiles": ["requirements.txt"], "lockFiles": [] } + ], + "interpreter": { + "executable": "", + "argv": [""], + "cwd": "", + "source": "explicit_override", + "version": "", + "implementation": "CPython", + "platform": "", + "architecture": "" + }, + "tools": [ + { "tool": "mypy", "available": false, "executable": "/opcore-missing-tools/mypy", "argv": ["/opcore-missing-tools/mypy"], "cwd": "", "source": "explicit_override" }, + { "tool": "pyright", "available": false, "executable": "/opcore-missing-tools/pyright", "argv": ["/opcore-missing-tools/pyright"], "cwd": "", "source": "explicit_override" }, + { "tool": "pytest", "available": false, "executable": "/opcore-missing-tools/pytest", "argv": ["/opcore-missing-tools/pytest"], "cwd": "", "source": "explicit_override" }, + { "tool": "ruff", "available": false, "executable": "/opcore-missing-tools/ruff", "argv": ["/opcore-missing-tools/ruff"], "cwd": "", "source": "explicit_override" } + ], + "projectKey": "", + "contextFingerprint": "", + "outcome": "degraded", + "reasons": [ + { "code": "tool_unavailable", "tool": "mypy" }, + { "code": "tool_unavailable", "tool": "pyright" }, + { "code": "tool_unavailable", "tool": "pytest" }, + { "code": "tool_unavailable", "tool": "ruff" } + ] + } + }, + { + "id": "simulated-posix-nested-uv-namespace", + "execution": "simulated", + "platform": "linux", + "target": "services/api/src/acme/api.py", + "expected": { + "target": "services/api/src/acme/api.py", + "repositoryRoot": "/fixture", + "projectRoot": "services/api", + "projectBoundary": "services/api", + "sourceRoots": ["services/api/src"], + "layout": { "kinds": ["namespace", "src"], "paths": ["services/api/src"] }, + "evidence": [ + { "path": "services/api/mypy.ini", "role": "config" }, + { "path": "services/api/pyproject.toml", "role": "boundary" }, + { "path": "services/api/pyproject.toml", "role": "build" }, + { "path": "services/api/pyrightconfig.json", "role": "config" }, + { "path": "services/api/pytest.ini", "role": "config" }, + { "path": "services/api/ruff.toml", "role": "config" }, + { "path": "services/api/src", "role": "layout" }, + { "path": "services/api/uv.lock", "role": "boundary" }, + { "path": "services/api/uv.lock", "role": "lock" } + ], + "targetRuntime": { "requiresPython": ">=3.12", "conflicts": [] }, + "managers": [ + { "kind": "uv", "configFiles": ["services/api/pyproject.toml"], "lockFiles": ["services/api/uv.lock"] } + ], + "buildSystem": { + "configFile": "services/api/pyproject.toml", + "backend": "hatchling.build", + "requires": ["hatch-vcs>=0.4", "hatchling>=1"] + }, + "interpreter": { + "executable": "/fixture/services/api/.venv/bin/python", + "argv": ["/fixture/services/api/.venv/bin/python"], + "cwd": "/fixture/services/api", + "source": "project_local_environment", + "version": "3.12.4", + "implementation": "CPython", + "platform": "linux", + "architecture": "x86_64", + "abi": "cpython-312", + "soabi": "cpython-312-x86_64-linux-gnu" + }, + "tools": [ + { "tool": "build", "available": true, "executable": "/fixture/services/api/.venv/bin/python", "argv": ["/fixture/services/api/.venv/bin/python", "-m", "build"], "cwd": "/fixture/services/api", "source": "project_local_environment", "version": "1.0.0", "configFile": "services/api/pyproject.toml" }, + { "tool": "mypy", "available": true, "executable": "/fixture/services/api/.venv/bin/mypy", "argv": ["/fixture/services/api/.venv/bin/mypy"], "cwd": "/fixture/services/api", "source": "project_local_environment", "version": "1.0.0", "configFile": "services/api/mypy.ini" }, + { "tool": "pyright", "available": true, "executable": "/fixture/services/api/.venv/bin/pyright", "argv": ["/fixture/services/api/.venv/bin/pyright"], "cwd": "/fixture/services/api", "source": "project_local_environment", "version": "1.0.0", "configFile": "services/api/pyrightconfig.json" }, + { "tool": "pytest", "available": true, "executable": "/fixture/services/api/.venv/bin/pytest", "argv": ["/fixture/services/api/.venv/bin/pytest"], "cwd": "/fixture/services/api", "source": "project_local_environment", "version": "1.0.0", "configFile": "services/api/pytest.ini" }, + { "tool": "ruff", "available": true, "executable": "/fixture/services/api/.venv/bin/ruff", "argv": ["/fixture/services/api/.venv/bin/ruff"], "cwd": "/fixture/services/api", "source": "project_local_environment", "version": "1.0.0", "configFile": "services/api/ruff.toml" } + ], + "projectKey": "", + "contextFingerprint": "", + "outcome": "resolved", + "reasons": [] + } + }, + { + "id": "simulated-posix-mixed-flat-src-root-target", + "execution": "simulated", + "platform": "linux", + "target": "services/mixed/script.py", + "expected": { + "target": "services/mixed/script.py", + "repositoryRoot": "/fixture", + "projectRoot": "services/mixed", + "projectBoundary": "services/mixed", + "sourceRoots": ["services/mixed"], + "layout": { "kinds": ["flat"], "paths": ["services/mixed"] }, + "evidence": [ + { "path": "services/mixed/pyproject.toml", "role": "boundary" }, + { "path": "services/mixed/pyproject.toml", "role": "config" }, + { "path": "services/mixed", "role": "layout" } + ], + "targetRuntime": { "requiresPython": ">=3.12", "conflicts": [] }, + "managers": [], + "interpreter": { + "executable": "/fixture/services/mixed/.venv/bin/python", + "argv": ["/fixture/services/mixed/.venv/bin/python"], + "cwd": "/fixture/services/mixed", + "source": "project_local_environment", + "version": "3.12.4", + "implementation": "CPython", + "platform": "linux", + "architecture": "x86_64", + "abi": "cpython-312", + "soabi": "cpython-312-x86_64-linux-gnu" + }, + "tools": [ + { "tool": "mypy", "available": true, "executable": "/fixture/services/mixed/.venv/bin/mypy", "argv": ["/fixture/services/mixed/.venv/bin/mypy"], "cwd": "/fixture/services/mixed", "source": "project_local_environment", "version": "1.0.0" }, + { "tool": "pyright", "available": true, "executable": "/fixture/services/mixed/.venv/bin/pyright", "argv": ["/fixture/services/mixed/.venv/bin/pyright"], "cwd": "/fixture/services/mixed", "source": "project_local_environment", "version": "1.0.0" }, + { "tool": "pytest", "available": true, "executable": "/fixture/services/mixed/.venv/bin/pytest", "argv": ["/fixture/services/mixed/.venv/bin/pytest"], "cwd": "/fixture/services/mixed", "source": "project_local_environment", "version": "1.0.0" }, + { "tool": "ruff", "available": true, "executable": "/fixture/services/mixed/.venv/bin/ruff", "argv": ["/fixture/services/mixed/.venv/bin/ruff"], "cwd": "/fixture/services/mixed", "source": "project_local_environment", "version": "1.0.0" } + ], + "projectKey": "", + "contextFingerprint": "", + "outcome": "resolved", + "reasons": [] + } + }, + { + "id": "simulated-posix-poetry-flat-package", + "execution": "simulated", + "platform": "linux", + "target": "services/poetry/pkg/__init__.py", + "expected": { + "target": "services/poetry/pkg/__init__.py", + "repositoryRoot": "/fixture", + "projectRoot": "services/poetry", + "projectBoundary": "services/poetry", + "sourceRoots": ["services/poetry"], + "layout": { "kinds": ["flat", "package"], "paths": ["services/poetry"] }, + "evidence": [ + { "path": "services/poetry/poetry.lock", "role": "boundary" }, + { "path": "services/poetry/poetry.lock", "role": "lock" }, + { "path": "services/poetry/pyproject.toml", "role": "boundary" }, + { "path": "services/poetry/pyproject.toml", "role": "config" }, + { "path": "services/poetry", "role": "layout" } + ], + "targetRuntime": { "requiresPython": ">=3.12", "conflicts": [] }, + "managers": [ + { "kind": "poetry", "configFiles": ["services/poetry/pyproject.toml"], "lockFiles": ["services/poetry/poetry.lock"] } + ], + "interpreter": { "executable": "/fixture/services/poetry/.venv/bin/python", "argv": ["/fixture/services/poetry/.venv/bin/python"], "cwd": "/fixture/services/poetry", "source": "project_local_environment", "version": "3.12.4", "implementation": "CPython", "platform": "linux", "architecture": "x86_64", "abi": "cpython-312", "soabi": "cpython-312-x86_64-linux-gnu" }, + "tools": [ + { "tool": "mypy", "available": true, "executable": "/fixture/services/poetry/.venv/bin/mypy", "argv": ["/fixture/services/poetry/.venv/bin/mypy"], "cwd": "/fixture/services/poetry", "source": "project_local_environment", "version": "1.0.0" }, + { "tool": "pyright", "available": true, "executable": "/fixture/services/poetry/.venv/bin/pyright", "argv": ["/fixture/services/poetry/.venv/bin/pyright"], "cwd": "/fixture/services/poetry", "source": "project_local_environment", "version": "1.0.0" }, + { "tool": "pytest", "available": true, "executable": "/fixture/services/poetry/.venv/bin/pytest", "argv": ["/fixture/services/poetry/.venv/bin/pytest"], "cwd": "/fixture/services/poetry", "source": "project_local_environment", "version": "1.0.0" }, + { "tool": "ruff", "available": true, "executable": "/fixture/services/poetry/.venv/bin/ruff", "argv": ["/fixture/services/poetry/.venv/bin/ruff"], "cwd": "/fixture/services/poetry", "source": "project_local_environment", "version": "1.0.0" } + ], + "projectKey": "", "contextFingerprint": "", "outcome": "resolved", "reasons": [] + } + }, + { + "id": "simulated-posix-pdm-src-namespace", + "execution": "simulated", + "platform": "linux", + "target": "services/pdm/src/pdm_pkg/app.py", + "expected": { + "target": "services/pdm/src/pdm_pkg/app.py", + "repositoryRoot": "/fixture", + "projectRoot": "services/pdm", + "projectBoundary": "services/pdm", + "sourceRoots": ["services/pdm/src"], + "layout": { "kinds": ["namespace", "src"], "paths": ["services/pdm/src"] }, + "evidence": [ + { "path": "services/pdm/pdm.lock", "role": "boundary" }, + { "path": "services/pdm/pdm.lock", "role": "lock" }, + { "path": "services/pdm/pyproject.toml", "role": "boundary" }, + { "path": "services/pdm/pyproject.toml", "role": "config" }, + { "path": "services/pdm/src", "role": "layout" } + ], + "targetRuntime": { "requiresPython": ">=3.12", "conflicts": [] }, + "managers": [ + { "kind": "pdm", "configFiles": ["services/pdm/pyproject.toml"], "lockFiles": ["services/pdm/pdm.lock"] } + ], + "interpreter": { "executable": "/fixture/services/pdm/.venv/bin/python", "argv": ["/fixture/services/pdm/.venv/bin/python"], "cwd": "/fixture/services/pdm", "source": "project_local_environment", "version": "3.12.4", "implementation": "CPython", "platform": "linux", "architecture": "x86_64", "abi": "cpython-312", "soabi": "cpython-312-x86_64-linux-gnu" }, + "tools": [ + { "tool": "mypy", "available": true, "executable": "/fixture/services/pdm/.venv/bin/mypy", "argv": ["/fixture/services/pdm/.venv/bin/mypy"], "cwd": "/fixture/services/pdm", "source": "project_local_environment", "version": "1.0.0" }, + { "tool": "pyright", "available": true, "executable": "/fixture/services/pdm/.venv/bin/pyright", "argv": ["/fixture/services/pdm/.venv/bin/pyright"], "cwd": "/fixture/services/pdm", "source": "project_local_environment", "version": "1.0.0" }, + { "tool": "pytest", "available": true, "executable": "/fixture/services/pdm/.venv/bin/pytest", "argv": ["/fixture/services/pdm/.venv/bin/pytest"], "cwd": "/fixture/services/pdm", "source": "project_local_environment", "version": "1.0.0" }, + { "tool": "ruff", "available": true, "executable": "/fixture/services/pdm/.venv/bin/ruff", "argv": ["/fixture/services/pdm/.venv/bin/ruff"], "cwd": "/fixture/services/pdm", "source": "project_local_environment", "version": "1.0.0" } + ], + "projectKey": "", "contextFingerprint": "", "outcome": "resolved", "reasons": [] + } + }, + { + "id": "simulated-windows-pipenv-stub-environment-root-python", + "execution": "simulated", + "platform": "win32", + "windowsInterpreterLayout": "environment-root", + "target": "services/stubs/src/sdk/__init__.pyi", + "expected": { + "target": "services/stubs/src/sdk/__init__.pyi", + "repositoryRoot": "C:\\fixture", + "projectRoot": "services/stubs", + "projectBoundary": "services/stubs", + "sourceRoots": ["services/stubs/src"], + "layout": { "kinds": ["package", "src", "stub"], "paths": ["services/stubs/src"] }, + "evidence": [ + { "path": "services/stubs/Pipfile.lock", "role": "boundary" }, + { "path": "services/stubs/Pipfile.lock", "role": "lock" }, + { "path": "services/stubs/Pipfile", "role": "boundary" }, + { "path": "services/stubs/Pipfile", "role": "config" }, + { "path": "services/stubs/src", "role": "layout" } + ], + "targetRuntime": { "version": "3.12", "conflicts": [] }, + "managers": [ + { "kind": "pipenv", "configFiles": ["services/stubs/Pipfile"], "lockFiles": ["services/stubs/Pipfile.lock"] } + ], + "interpreter": { "executable": "C:\\fixture\\services\\stubs\\.venv\\python.exe", "argv": ["C:\\fixture\\services\\stubs\\.venv\\python.exe"], "cwd": "C:\\fixture\\services\\stubs", "source": "project_local_environment", "version": "3.12.4", "implementation": "CPython", "platform": "win32", "architecture": "AMD64", "abi": "cpython-312", "soabi": "cp312-win_amd64" }, + "tools": [ + { "tool": "mypy", "available": true, "executable": "C:\\fixture\\services\\stubs\\.venv\\Scripts\\mypy.exe", "argv": ["C:\\fixture\\services\\stubs\\.venv\\Scripts\\mypy.exe"], "cwd": "C:\\fixture\\services\\stubs", "source": "project_local_environment", "version": "1.0.0" }, + { "tool": "pyright", "available": true, "executable": "C:\\fixture\\services\\stubs\\.venv\\Scripts\\pyright.exe", "argv": ["C:\\fixture\\services\\stubs\\.venv\\Scripts\\pyright.exe"], "cwd": "C:\\fixture\\services\\stubs", "source": "project_local_environment", "version": "1.0.0" }, + { "tool": "pytest", "available": true, "executable": "C:\\fixture\\services\\stubs\\.venv\\Scripts\\pytest.exe", "argv": ["C:\\fixture\\services\\stubs\\.venv\\Scripts\\pytest.exe"], "cwd": "C:\\fixture\\services\\stubs", "source": "project_local_environment", "version": "1.0.0" }, + { "tool": "ruff", "available": true, "executable": "C:\\fixture\\services\\stubs\\.venv\\Scripts\\ruff.exe", "argv": ["C:\\fixture\\services\\stubs\\.venv\\Scripts\\ruff.exe"], "cwd": "C:\\fixture\\services\\stubs", "source": "project_local_environment", "version": "1.0.0" } + ], + "projectKey": "", "contextFingerprint": "", "outcome": "resolved", "reasons": [] + } + }, + { + "id": "simulated-posix-invalid-toml", + "execution": "simulated", + "platform": "linux", + "target": "services/invalid/app.py", + "expected": { + "target": "services/invalid/app.py", + "repositoryRoot": "/fixture", + "projectRoot": "services/invalid", + "projectBoundary": "services/invalid", + "sourceRoots": ["services/invalid"], + "layout": { "kinds": ["flat"], "paths": ["services/invalid"] }, + "evidence": [ + { "path": "services/invalid/pyproject.toml", "role": "boundary" }, + { "path": "services/invalid/pyproject.toml", "role": "config" }, + { "path": "services/invalid", "role": "layout" } + ], + "targetRuntime": { "conflicts": [] }, + "managers": [], + "interpreter": { "executable": "/fixture/services/invalid/.venv/bin/python", "argv": ["/fixture/services/invalid/.venv/bin/python"], "cwd": "/fixture/services/invalid", "source": "project_local_environment", "version": "3.12.4" }, + "tools": [ + { "tool": "mypy", "available": true, "argv": ["/fixture/services/invalid/.venv/bin/mypy"], "cwd": "/fixture/services/invalid", "source": "project_local_environment" }, + { "tool": "pyright", "available": true, "argv": ["/fixture/services/invalid/.venv/bin/pyright"], "cwd": "/fixture/services/invalid", "source": "project_local_environment" }, + { "tool": "pytest", "available": true, "argv": ["/fixture/services/invalid/.venv/bin/pytest"], "cwd": "/fixture/services/invalid", "source": "project_local_environment" }, + { "tool": "ruff", "available": true, "argv": ["/fixture/services/invalid/.venv/bin/ruff"], "cwd": "/fixture/services/invalid", "source": "project_local_environment" } + ], + "projectKey": "", "contextFingerprint": "", "outcome": "degraded", + "reasons": [{ "code": "invalid_config", "path": "services/invalid/pyproject.toml" }] + } + }, + { + "id": "simulated-posix-conflicting-managers", + "execution": "simulated", + "platform": "linux", + "target": "services/conflict/app.py", + "expected": { + "target": "services/conflict/app.py", + "repositoryRoot": "/fixture", + "projectRoot": "services/conflict", + "projectBoundary": "services/conflict", + "sourceRoots": ["services/conflict"], + "layout": { "kinds": ["flat"], "paths": ["services/conflict"] }, + "evidence": [ + { "path": "services/conflict/poetry.lock", "role": "boundary" }, + { "path": "services/conflict/poetry.lock", "role": "lock" }, + { "path": "services/conflict/pyproject.toml", "role": "boundary" }, + { "path": "services/conflict/pyproject.toml", "role": "config" }, + { "path": "services/conflict/uv.lock", "role": "boundary" }, + { "path": "services/conflict/uv.lock", "role": "lock" }, + { "path": "services/conflict", "role": "layout" } + ], + "targetRuntime": { "requiresPython": ">=3.12", "conflicts": [] }, + "managers": [ + { "kind": "poetry", "configFiles": [], "lockFiles": ["services/conflict/poetry.lock"] }, + { "kind": "uv", "configFiles": ["services/conflict/pyproject.toml"], "lockFiles": ["services/conflict/uv.lock"] } + ], + "interpreter": { "executable": "/fixture/services/conflict/.venv/bin/python", "argv": ["/fixture/services/conflict/.venv/bin/python"], "cwd": "/fixture/services/conflict", "source": "project_local_environment", "version": "3.12.4" }, + "tools": [ + { "tool": "mypy", "available": true, "argv": ["/fixture/services/conflict/.venv/bin/mypy"], "cwd": "/fixture/services/conflict", "source": "project_local_environment" }, + { "tool": "pyright", "available": true, "argv": ["/fixture/services/conflict/.venv/bin/pyright"], "cwd": "/fixture/services/conflict", "source": "project_local_environment" }, + { "tool": "pytest", "available": true, "argv": ["/fixture/services/conflict/.venv/bin/pytest"], "cwd": "/fixture/services/conflict", "source": "project_local_environment" }, + { "tool": "ruff", "available": true, "argv": ["/fixture/services/conflict/.venv/bin/ruff"], "cwd": "/fixture/services/conflict", "source": "project_local_environment" } + ], + "projectKey": "", "contextFingerprint": "", "outcome": "ambiguous", + "reasons": [{ "code": "conflicting_managers" }] + } + }, + { + "id": "simulated-posix-incompatible-interpreter", + "execution": "simulated", + "platform": "linux", + "target": "services/incompatible/app.py", + "expected": { + "target": "services/incompatible/app.py", + "repositoryRoot": "/fixture", + "projectRoot": "services/incompatible", + "projectBoundary": "services/incompatible", + "sourceRoots": ["services/incompatible"], + "layout": { "kinds": ["flat"], "paths": ["services/incompatible"] }, + "evidence": [ + { "path": "services/incompatible/pyproject.toml", "role": "boundary" }, + { "path": "services/incompatible/pyproject.toml", "role": "config" }, + { "path": "services/incompatible", "role": "layout" } + ], + "targetRuntime": { "requiresPython": ">=3.13", "conflicts": [] }, + "managers": [], + "interpreter": { "executable": "/fixture/services/incompatible/.venv/bin/python", "argv": ["/fixture/services/incompatible/.venv/bin/python"], "cwd": "/fixture/services/incompatible", "source": "project_local_environment", "version": "3.12.4" }, + "tools": [ + { "tool": "mypy", "available": true, "argv": ["/fixture/services/incompatible/.venv/bin/mypy"], "cwd": "/fixture/services/incompatible", "source": "project_local_environment" }, + { "tool": "pyright", "available": true, "argv": ["/fixture/services/incompatible/.venv/bin/pyright"], "cwd": "/fixture/services/incompatible", "source": "project_local_environment" }, + { "tool": "pytest", "available": true, "argv": ["/fixture/services/incompatible/.venv/bin/pytest"], "cwd": "/fixture/services/incompatible", "source": "project_local_environment" }, + { "tool": "ruff", "available": true, "argv": ["/fixture/services/incompatible/.venv/bin/ruff"], "cwd": "/fixture/services/incompatible", "source": "project_local_environment" } + ], + "projectKey": "", "contextFingerprint": "", "outcome": "unsupported", + "reasons": [{ "code": "incompatible_interpreter", "tool": "python" }] + } + } + ] +} diff --git a/packages/fixtures/validation-python/project-context/services/api/mypy.ini.fixture b/packages/fixtures/validation-python/project-context/services/api/mypy.ini.fixture new file mode 100644 index 0000000..010ed68 --- /dev/null +++ b/packages/fixtures/validation-python/project-context/services/api/mypy.ini.fixture @@ -0,0 +1,2 @@ +[mypy] +python_version = 3.12 diff --git a/packages/fixtures/validation-python/project-context/services/api/pyproject.toml.fixture b/packages/fixtures/validation-python/project-context/services/api/pyproject.toml.fixture new file mode 100644 index 0000000..dec0a5c --- /dev/null +++ b/packages/fixtures/validation-python/project-context/services/api/pyproject.toml.fixture @@ -0,0 +1,13 @@ +[project] +name = "fixture-api" +requires-python = ">=3.12" + +[tool.uv] +package = true + +[build-system] +requires = [ + "hatchling>=1", + "hatch-vcs>=0.4", +] +build-backend = "hatchling.build" diff --git a/packages/fixtures/validation-python/project-context/services/api/pyrightconfig.json.fixture b/packages/fixtures/validation-python/project-context/services/api/pyrightconfig.json.fixture new file mode 100644 index 0000000..92e4ec8 --- /dev/null +++ b/packages/fixtures/validation-python/project-context/services/api/pyrightconfig.json.fixture @@ -0,0 +1 @@ +{"pythonVersion":"3.12"} diff --git a/packages/fixtures/validation-python/project-context/services/api/pytest.ini.fixture b/packages/fixtures/validation-python/project-context/services/api/pytest.ini.fixture new file mode 100644 index 0000000..5ee6477 --- /dev/null +++ b/packages/fixtures/validation-python/project-context/services/api/pytest.ini.fixture @@ -0,0 +1,2 @@ +[pytest] +testpaths = tests diff --git a/packages/fixtures/validation-python/project-context/services/api/ruff.toml.fixture b/packages/fixtures/validation-python/project-context/services/api/ruff.toml.fixture new file mode 100644 index 0000000..8b96752 --- /dev/null +++ b/packages/fixtures/validation-python/project-context/services/api/ruff.toml.fixture @@ -0,0 +1 @@ +target-version = "py312" diff --git a/packages/fixtures/validation-python/project-context/services/api/src/acme/api.py.fixture b/packages/fixtures/validation-python/project-context/services/api/src/acme/api.py.fixture new file mode 100644 index 0000000..605bacb --- /dev/null +++ b/packages/fixtures/validation-python/project-context/services/api/src/acme/api.py.fixture @@ -0,0 +1,2 @@ +def handler() -> str: + return "ok" diff --git a/packages/fixtures/validation-python/project-context/services/api/uv.lock.fixture b/packages/fixtures/validation-python/project-context/services/api/uv.lock.fixture new file mode 100644 index 0000000..d9914df --- /dev/null +++ b/packages/fixtures/validation-python/project-context/services/api/uv.lock.fixture @@ -0,0 +1 @@ +version = 1 diff --git a/packages/fixtures/validation-python/project-context/services/conflict/app.py.fixture b/packages/fixtures/validation-python/project-context/services/conflict/app.py.fixture new file mode 100644 index 0000000..b15b1b0 --- /dev/null +++ b/packages/fixtures/validation-python/project-context/services/conflict/app.py.fixture @@ -0,0 +1 @@ +VALUE = 1 diff --git a/packages/fixtures/validation-python/project-context/services/conflict/poetry.lock.fixture b/packages/fixtures/validation-python/project-context/services/conflict/poetry.lock.fixture new file mode 100644 index 0000000..c4293b3 --- /dev/null +++ b/packages/fixtures/validation-python/project-context/services/conflict/poetry.lock.fixture @@ -0,0 +1 @@ +package = [] diff --git a/packages/fixtures/validation-python/project-context/services/conflict/pyproject.toml.fixture b/packages/fixtures/validation-python/project-context/services/conflict/pyproject.toml.fixture new file mode 100644 index 0000000..d2d4134 --- /dev/null +++ b/packages/fixtures/validation-python/project-context/services/conflict/pyproject.toml.fixture @@ -0,0 +1,6 @@ +[project] +name = "fixture-conflict" +requires-python = ">=3.12" + +[tool.uv] +package = true diff --git a/packages/fixtures/validation-python/project-context/services/conflict/uv.lock.fixture b/packages/fixtures/validation-python/project-context/services/conflict/uv.lock.fixture new file mode 100644 index 0000000..d9914df --- /dev/null +++ b/packages/fixtures/validation-python/project-context/services/conflict/uv.lock.fixture @@ -0,0 +1 @@ +version = 1 diff --git a/packages/fixtures/validation-python/project-context/services/incompatible/app.py.fixture b/packages/fixtures/validation-python/project-context/services/incompatible/app.py.fixture new file mode 100644 index 0000000..b15b1b0 --- /dev/null +++ b/packages/fixtures/validation-python/project-context/services/incompatible/app.py.fixture @@ -0,0 +1 @@ +VALUE = 1 diff --git a/packages/fixtures/validation-python/project-context/services/incompatible/pyproject.toml.fixture b/packages/fixtures/validation-python/project-context/services/incompatible/pyproject.toml.fixture new file mode 100644 index 0000000..6d743a4 --- /dev/null +++ b/packages/fixtures/validation-python/project-context/services/incompatible/pyproject.toml.fixture @@ -0,0 +1,3 @@ +[project] +name = "fixture-incompatible" +requires-python = ">=3.13" diff --git a/packages/fixtures/validation-python/project-context/services/invalid/app.py.fixture b/packages/fixtures/validation-python/project-context/services/invalid/app.py.fixture new file mode 100644 index 0000000..b15b1b0 --- /dev/null +++ b/packages/fixtures/validation-python/project-context/services/invalid/app.py.fixture @@ -0,0 +1 @@ +VALUE = 1 diff --git a/packages/fixtures/validation-python/project-context/services/invalid/pyproject.toml.fixture b/packages/fixtures/validation-python/project-context/services/invalid/pyproject.toml.fixture new file mode 100644 index 0000000..f97156f --- /dev/null +++ b/packages/fixtures/validation-python/project-context/services/invalid/pyproject.toml.fixture @@ -0,0 +1,2 @@ +[project +name = "fixture-invalid" diff --git a/packages/fixtures/validation-python/project-context/services/mixed/helper.py.fixture b/packages/fixtures/validation-python/project-context/services/mixed/helper.py.fixture new file mode 100644 index 0000000..d3b6e65 --- /dev/null +++ b/packages/fixtures/validation-python/project-context/services/mixed/helper.py.fixture @@ -0,0 +1 @@ +VALUE: int = 1 diff --git a/packages/fixtures/validation-python/project-context/services/mixed/pyproject.toml.fixture b/packages/fixtures/validation-python/project-context/services/mixed/pyproject.toml.fixture new file mode 100644 index 0000000..06918e8 --- /dev/null +++ b/packages/fixtures/validation-python/project-context/services/mixed/pyproject.toml.fixture @@ -0,0 +1,3 @@ +[project] +name = "mixed" +requires-python = ">=3.12" diff --git a/packages/fixtures/validation-python/project-context/services/mixed/script.py.fixture b/packages/fixtures/validation-python/project-context/services/mixed/script.py.fixture new file mode 100644 index 0000000..cbb703d --- /dev/null +++ b/packages/fixtures/validation-python/project-context/services/mixed/script.py.fixture @@ -0,0 +1,3 @@ +import helper + +VALUE = helper.VALUE diff --git a/packages/fixtures/validation-python/project-context/services/mixed/src/pkg/__init__.py.fixture b/packages/fixtures/validation-python/project-context/services/mixed/src/pkg/__init__.py.fixture new file mode 100644 index 0000000..c8fcecd --- /dev/null +++ b/packages/fixtures/validation-python/project-context/services/mixed/src/pkg/__init__.py.fixture @@ -0,0 +1 @@ +VALUE = 2 diff --git a/packages/fixtures/validation-python/project-context/services/pdm/pdm.lock.fixture b/packages/fixtures/validation-python/project-context/services/pdm/pdm.lock.fixture new file mode 100644 index 0000000..b61538e --- /dev/null +++ b/packages/fixtures/validation-python/project-context/services/pdm/pdm.lock.fixture @@ -0,0 +1,2 @@ +[metadata] +lock_version = "4.5" diff --git a/packages/fixtures/validation-python/project-context/services/pdm/pyproject.toml.fixture b/packages/fixtures/validation-python/project-context/services/pdm/pyproject.toml.fixture new file mode 100644 index 0000000..2ae8ac4 --- /dev/null +++ b/packages/fixtures/validation-python/project-context/services/pdm/pyproject.toml.fixture @@ -0,0 +1,6 @@ +[project] +name = "fixture-pdm" +requires-python = ">=3.12" + +[tool.pdm] +distribution = true diff --git a/packages/fixtures/validation-python/project-context/services/pdm/src/pdm_pkg/app.py.fixture b/packages/fixtures/validation-python/project-context/services/pdm/src/pdm_pkg/app.py.fixture new file mode 100644 index 0000000..b15b1b0 --- /dev/null +++ b/packages/fixtures/validation-python/project-context/services/pdm/src/pdm_pkg/app.py.fixture @@ -0,0 +1 @@ +VALUE = 1 diff --git a/packages/fixtures/validation-python/project-context/services/poetry/pkg/__init__.py.fixture b/packages/fixtures/validation-python/project-context/services/poetry/pkg/__init__.py.fixture new file mode 100644 index 0000000..b15b1b0 --- /dev/null +++ b/packages/fixtures/validation-python/project-context/services/poetry/pkg/__init__.py.fixture @@ -0,0 +1 @@ +VALUE = 1 diff --git a/packages/fixtures/validation-python/project-context/services/poetry/poetry.lock.fixture b/packages/fixtures/validation-python/project-context/services/poetry/poetry.lock.fixture new file mode 100644 index 0000000..c4293b3 --- /dev/null +++ b/packages/fixtures/validation-python/project-context/services/poetry/poetry.lock.fixture @@ -0,0 +1 @@ +package = [] diff --git a/packages/fixtures/validation-python/project-context/services/poetry/pyproject.toml.fixture b/packages/fixtures/validation-python/project-context/services/poetry/pyproject.toml.fixture new file mode 100644 index 0000000..9ae2c56 --- /dev/null +++ b/packages/fixtures/validation-python/project-context/services/poetry/pyproject.toml.fixture @@ -0,0 +1,6 @@ +[tool.poetry] +name = "fixture-poetry" +version = "0.1.0" + +[tool.poetry.dependencies] +python = ">=3.12" diff --git a/packages/fixtures/validation-python/project-context/services/stubs/Pipfile.fixture b/packages/fixtures/validation-python/project-context/services/stubs/Pipfile.fixture new file mode 100644 index 0000000..980eed7 --- /dev/null +++ b/packages/fixtures/validation-python/project-context/services/stubs/Pipfile.fixture @@ -0,0 +1,5 @@ +[[source]] +url = "https://pypi.org/simple" + +[requires] +python_version = "3.12" diff --git a/packages/fixtures/validation-python/project-context/services/stubs/Pipfile.lock.fixture b/packages/fixtures/validation-python/project-context/services/stubs/Pipfile.lock.fixture new file mode 100644 index 0000000..a48e542 --- /dev/null +++ b/packages/fixtures/validation-python/project-context/services/stubs/Pipfile.lock.fixture @@ -0,0 +1 @@ +{"_meta":{"requires":{"python_version":"3.12"}},"default":{},"develop":{}} diff --git a/packages/fixtures/validation-python/project-context/services/stubs/src/sdk/__init__.pyi.fixture b/packages/fixtures/validation-python/project-context/services/stubs/src/sdk/__init__.pyi.fixture new file mode 100644 index 0000000..c816088 --- /dev/null +++ b/packages/fixtures/validation-python/project-context/services/stubs/src/sdk/__init__.pyi.fixture @@ -0,0 +1 @@ +def identity(value: str) -> str: ... diff --git a/packages/fixtures/validation-python/pyright-authority/configs/base.json b/packages/fixtures/validation-python/pyright-authority/configs/base.json new file mode 100644 index 0000000..5cbd0f8 --- /dev/null +++ b/packages/fixtures/validation-python/pyright-authority/configs/base.json @@ -0,0 +1,3 @@ +{ + "extends": "strict.json" +} diff --git a/packages/fixtures/validation-python/pyright-authority/configs/strict.json b/packages/fixtures/validation-python/pyright-authority/configs/strict.json new file mode 100644 index 0000000..006fe94 --- /dev/null +++ b/packages/fixtures/validation-python/pyright-authority/configs/strict.json @@ -0,0 +1,4 @@ +{ + "typeCheckingMode": "strict", + "reportMissingTypeStubs": "none" +} diff --git a/packages/fixtures/validation-python/pyright-authority/pyrightconfig.json.fixture b/packages/fixtures/validation-python/pyright-authority/pyrightconfig.json.fixture new file mode 100644 index 0000000..2b425c0 --- /dev/null +++ b/packages/fixtures/validation-python/pyright-authority/pyrightconfig.json.fixture @@ -0,0 +1,14 @@ +{ + // Pyright JSONC and recursive extends are part of the authority proof. + "extends": "configs/base.json", + "include": ["src"], + "exclude": ["src/excluded"], + "ignore": ["src/ignored"], + "extraPaths": ["shared"], + "stubPath": "stubs", + "pythonVersion": "PYTHON_VERSION", + "pythonPlatform": "PYTHON_PLATFORM", + "executionEnvironments": [ + { "root": "src", "extraPaths": ["shared"] } + ], +} diff --git a/packages/fixtures/validation-python/pyright-authority/shared/helper.py.fixture b/packages/fixtures/validation-python/pyright-authority/shared/helper.py.fixture new file mode 100644 index 0000000..6f6f6d3 --- /dev/null +++ b/packages/fixtures/validation-python/pyright-authority/shared/helper.py.fixture @@ -0,0 +1 @@ +PREFIX: str = "rendered:" diff --git a/packages/fixtures/validation-python/pyright-authority/src/acme/__init__.py.fixture b/packages/fixtures/validation-python/pyright-authority/src/acme/__init__.py.fixture new file mode 100644 index 0000000..d9f9938 --- /dev/null +++ b/packages/fixtures/validation-python/pyright-authority/src/acme/__init__.py.fixture @@ -0,0 +1,3 @@ +from .app import render_value + +__all__ = ["render_value"] diff --git a/packages/fixtures/validation-python/pyright-authority/src/acme/app.py.fixture b/packages/fixtures/validation-python/pyright-authority/src/acme/app.py.fixture new file mode 100644 index 0000000..5f6402b --- /dev/null +++ b/packages/fixtures/validation-python/pyright-authority/src/acme/app.py.fixture @@ -0,0 +1,7 @@ +from external import render +from helper import PREFIX +from namespace_pkg.tool import normalize + + +def render_value(value: int) -> str: + return PREFIX + render(normalize(value)) diff --git a/packages/fixtures/validation-python/pyright-authority/src/excluded/broken.py.fixture b/packages/fixtures/validation-python/pyright-authority/src/excluded/broken.py.fixture new file mode 100644 index 0000000..406221b --- /dev/null +++ b/packages/fixtures/validation-python/pyright-authority/src/excluded/broken.py.fixture @@ -0,0 +1 @@ +VALUE: int = "excluded by pyrightconfig.json" diff --git a/packages/fixtures/validation-python/pyright-authority/src/ignored/broken.py.fixture b/packages/fixtures/validation-python/pyright-authority/src/ignored/broken.py.fixture new file mode 100644 index 0000000..ab60ec3 --- /dev/null +++ b/packages/fixtures/validation-python/pyright-authority/src/ignored/broken.py.fixture @@ -0,0 +1 @@ +VALUE: int = "ignored by pyrightconfig.json" diff --git a/packages/fixtures/validation-python/pyright-authority/src/namespace_pkg/tool.py.fixture b/packages/fixtures/validation-python/pyright-authority/src/namespace_pkg/tool.py.fixture new file mode 100644 index 0000000..ca2195b --- /dev/null +++ b/packages/fixtures/validation-python/pyright-authority/src/namespace_pkg/tool.py.fixture @@ -0,0 +1,2 @@ +def normalize(value: int) -> int: + return value diff --git a/packages/fixtures/validation-python/pyright-authority/stubs/external/__init__.pyi.fixture b/packages/fixtures/validation-python/pyright-authority/stubs/external/__init__.pyi.fixture new file mode 100644 index 0000000..bb83027 --- /dev/null +++ b/packages/fixtures/validation-python/pyright-authority/stubs/external/__init__.pyi.fixture @@ -0,0 +1 @@ +def render(value: int) -> str: ... diff --git a/packages/graph/README.md b/packages/graph/README.md index 58388e6..91ceead 100644 --- a/packages/graph/README.md +++ b/packages/graph/README.md @@ -1,3 +1,7 @@ # @the-open-engine/opcore-graph Opcore graph provider package for repository graph extraction, query, search, and impact surfaces. + +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. diff --git a/packages/graph/package.json b/packages/graph/package.json index 3e546d8..370be10 100644 --- a/packages/graph/package.json +++ b/packages/graph/package.json @@ -1,6 +1,6 @@ { "name": "@the-open-engine/opcore-graph", - "version": "0.2.0", + "version": "0.2.1", "description": "Local Rust-backed graph provider for repository search, impact, and agent context.", "type": "module", "license": "MIT", @@ -26,12 +26,12 @@ "developer-tools" ], "dependencies": { - "@the-open-engine/opcore-contracts": "0.2.0" + "@the-open-engine/opcore-contracts": "0.2.1" }, "optionalDependencies": { - "@the-open-engine/opcore-graph-core-darwin-arm64": "0.2.0", - "@the-open-engine/opcore-graph-core-darwin-x64": "0.2.0", - "@the-open-engine/opcore-graph-core-linux-x64": "0.2.0" + "@the-open-engine/opcore-graph-core-darwin-arm64": "0.2.1", + "@the-open-engine/opcore-graph-core-darwin-x64": "0.2.1", + "@the-open-engine/opcore-graph-core-linux-x64": "0.2.1" }, "engines": { "node": ">=22" diff --git a/packages/graph/src/artifact.ts b/packages/graph/src/artifact.ts index 2f1f207..5e69954 100644 --- a/packages/graph/src/artifact.ts +++ b/packages/graph/src/artifact.ts @@ -150,9 +150,9 @@ function resolveExistingGraphCoreArtifact( function unsupportedPlatformMessage(target: string): string { const supported = graphCoreSupportedTargets.join(", "); if (target.startsWith("win32-")) { - return `Unsupported graph-core platform ${target}. Opcore 0.2.0 supports ${supported}; Windows is not supported.`; + return `Unsupported graph-core platform ${target}. Opcore 0.2.1 supports ${supported}; Windows is not supported.`; } - return `Unsupported graph-core platform ${target}. Opcore 0.2.0 supports ${supported}.`; + return `Unsupported graph-core platform ${target}. Opcore 0.2.1 supports ${supported}.`; } export function providerFailureStatus( diff --git a/packages/graph/src/ephemeral-snapshot.ts b/packages/graph/src/ephemeral-snapshot.ts new file mode 100644 index 0000000..0e284f5 --- /dev/null +++ b/packages/graph/src/ephemeral-snapshot.ts @@ -0,0 +1,219 @@ +import type { + GraphDetectChangesRequest, + GraphDetectChangesResult, + GraphFactQueryRequest, + GraphFactQueryResult, + GraphImpactRequest, + GraphImpactResult, + GraphNamedQueryRequest, + GraphNamedQueryResult, + GraphPipelineResult, + GraphProviderMode, + GraphProviderStatus, + GraphReviewContextRequest, + GraphReviewContextResult, + RepoIdentity +} from "@the-open-engine/opcore-contracts"; +import { validateRepoRelativePath } from "@the-open-engine/opcore-contracts"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; + +export interface EphemeralGraphSourceUniverse { + paths: readonly string[]; + complete: boolean; + message?: string; +} + +export interface EphemeralGraphSourceReadResult { + status: "found" | "missing" | "deleted"; + content?: string; +} + +export interface EphemeralGraphSnapshotLimits { + maxFiles?: number; + maxDepth?: number; + maxBytes?: number; +} + +export interface CreateEphemeralGraphSnapshotOptions { + logicalRepo: RepoIdentity; + sourceUniverse: EphemeralGraphSourceUniverse; + readFile: (path: string) => EphemeralGraphSourceReadResult | Promise; + limits?: EphemeralGraphSnapshotLimits; +} + +export interface EphemeralGraphOperations { + build(repo: RepoIdentity): GraphPipelineResult; + factQuery(repo: RepoIdentity, request: GraphFactQueryRequest): GraphFactQueryResult; + namedQuery?(repo: RepoIdentity, request: GraphNamedQueryRequest): GraphNamedQueryResult; + impact?(repo: RepoIdentity, request: GraphImpactRequest): GraphImpactResult; + reviewContext?(repo: RepoIdentity, request: GraphReviewContextRequest): GraphReviewContextResult; + detectChanges?(repo: RepoIdentity, request: GraphDetectChangesRequest): GraphDetectChangesResult; +} + +export interface EphemeralGraphSnapshot { + readonly logicalRepo: RepoIdentity; + readonly materializedPaths: readonly string[]; + status(mode: GraphProviderMode): GraphProviderStatus; + factQuery(request: GraphFactQueryRequest): GraphFactQueryResult; + namedQuery(request: GraphNamedQueryRequest): GraphNamedQueryResult; + impact(request: GraphImpactRequest): GraphImpactResult; + reviewContext(request: GraphReviewContextRequest): GraphReviewContextResult; + detectChanges(request: GraphDetectChangesRequest): GraphDetectChangesResult; + dispose(): void; +} + +const defaultLimits: Required = { + maxFiles: 50_000, + maxDepth: 64, + maxBytes: 256 * 1024 * 1024 +}; + +export async function createEphemeralGraphSnapshotWithOperations( + options: CreateEphemeralGraphSnapshotOptions, + operations: EphemeralGraphOperations +): 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); + let disposed = false; + try { + const build = operations.build(materialized.repo); + if (build.status.state !== "available") throw snapshotOperationFailure("build", build.status); + return bindSnapshot(options.logicalRepo, materialized, build.status, operations, () => { + if (disposed) return; + disposed = true; + rmSync(materialized.tempRoot, { recursive: true, force: true }); + }); + } catch (error) { + rmSync(materialized.tempRoot, { recursive: true, force: true }); + throw error; + } +} + +interface MaterializedSnapshotSources { + tempRoot: string; + repo: RepoIdentity; + paths: readonly string[]; +} + +async function materializeSnapshotSources( + paths: readonly string[], + readFile: CreateEphemeralGraphSnapshotOptions["readFile"], + limits: Required +): Promise { + const tempRoot = mkdtempSync(join(tmpdir(), "opcore-graph-snapshot-")); + const repoRoot = join(tempRoot, "repo"); + try { + mkdirSync(repoRoot, { recursive: true }); + let byteCount = 0; + const materializedPaths: string[] = []; + for (const path of paths) { + const source = await readFile(path); + if (source.status === "missing" || source.status === "deleted") continue; + if (source.status !== "found" || typeof source.content !== "string") { + throw new Error(`Ephemeral graph source reader returned an invalid result for ${path}`); + } + byteCount += new TextEncoder().encode(source.content).byteLength; + if (byteCount > limits.maxBytes) { + throw new Error(`Ephemeral graph snapshot exceeds maxBytes (${limits.maxBytes})`); + } + writeSourceFile(repoRoot, path, source.content); + materializedPaths.push(path); + } + return { tempRoot, repo: { repoRoot }, paths: materializedPaths }; + } catch (error) { + rmSync(tempRoot, { recursive: true, force: true }); + throw error; + } +} + +function bindSnapshot( + logicalRepo: RepoIdentity, + materialized: MaterializedSnapshotSources, + status: GraphProviderStatus, + operations: EphemeralGraphOperations, + dispose: () => void +): EphemeralGraphSnapshot { + const { repo } = materialized; + return { + logicalRepo, + materializedPaths: materialized.paths, + status: (mode) => bindStatus(status, mode, logicalRepo), + factQuery: (request) => bindResult(operations.factQuery(repo, request), request, logicalRepo), + namedQuery: (request) => bindResult(requireOperation(operations.namedQuery, "namedQuery")(repo, request), request, logicalRepo), + impact: (request) => bindResult(requireOperation(operations.impact, "impact")(repo, request), request, logicalRepo), + reviewContext: (request) => bindResult(requireOperation(operations.reviewContext, "reviewContext")(repo, request), request, logicalRepo), + detectChanges: (request) => bindResult(requireOperation(operations.detectChanges, "detectChanges")(repo, request), request, logicalRepo), + dispose + }; +} + +function normalizeUniverse(universe: EphemeralGraphSourceUniverse): readonly string[] { + if (!universe || !Array.isArray(universe.paths)) throw new Error("Ephemeral graph source universe is invalid"); + if (!universe.complete) throw new Error(universe.message ?? "Ephemeral graph source universe is incomplete"); + const seen = new Set(); + for (const candidate of universe.paths) { + const path = validateRepoRelativePath(candidate); + if (seen.has(path)) throw new Error(`Ephemeral graph source universe contains duplicate path: ${path}`); + seen.add(path); + } + return [...seen].sort(); +} + +function normalizeLimits(limits: EphemeralGraphSnapshotLimits | undefined): Required { + const normalized = { ...defaultLimits, ...limits }; + for (const [name, value] of Object.entries(normalized)) { + if (!Number.isSafeInteger(value) || value <= 0) throw new Error(`Ephemeral graph snapshot ${name} must be a positive integer`); + } + return normalized; +} + +function enforcePathLimits(paths: readonly string[], limits: Required): void { + if (paths.length > limits.maxFiles) throw new Error(`Ephemeral graph snapshot exceeds maxFiles (${limits.maxFiles})`); + const tooDeep = paths.find((path) => path.split("/").length > limits.maxDepth); + if (tooDeep !== undefined) throw new Error(`Ephemeral graph snapshot path exceeds maxDepth (${limits.maxDepth}): ${tooDeep}`); +} + +function writeSourceFile(repoRoot: string, path: string, content: string): void { + const absolutePath = resolve(repoRoot, validateRepoRelativePath(path)); + if (absolutePath !== repoRoot && !absolutePath.startsWith(`${repoRoot}/`)) { + throw new Error(`Ephemeral graph source path escapes snapshot root: ${path}`); + } + mkdirSync(dirname(absolutePath), { recursive: true }); + writeFileSync(absolutePath, content); +} + +function isSupportedGraphSourcePath(path: string): boolean { + return /\.(?:tsx?|[cm]ts|jsx?|pyi?|rs)$/u.test(path); +} + +function bindStatus(status: GraphProviderStatus, mode: GraphProviderMode, logicalRepo: RepoIdentity): GraphProviderStatus { + return { ...status, mode, ...(status.state === "available" || status.state === "stale" ? { repo: logicalRepo } : {}) } as GraphProviderStatus; +} + +function bindResult( + result: Result, + request: { requestId?: string; mode: GraphProviderMode }, + logicalRepo: RepoIdentity +): Result { + return { + ...result, + requestId: request.requestId, + status: bindStatus(result.status, request.mode, logicalRepo), + ...(result.metadata === undefined ? {} : { metadata: { ...result.metadata, repo: logicalRepo } }) + } as Result; +} + +function snapshotOperationFailure(operation: string, status: GraphProviderStatus): Error { + const message = "failure" in status ? status.failure.message : status.message; + return new Error(`Ephemeral graph snapshot ${operation} failed (${status.state}): ${message ?? "no failure message"}`); +} + +function requireOperation(operation: Operation | undefined, name: string): Operation { + if (operation === undefined) throw new Error(`Ephemeral graph snapshot operation is unavailable: ${name}`); + return operation; +} diff --git a/packages/graph/src/index.ts b/packages/graph/src/index.ts index c83b174..5cba8c8 100644 --- a/packages/graph/src/index.ts +++ b/packages/graph/src/index.ts @@ -20,8 +20,18 @@ import type { } from "@the-open-engine/opcore-contracts"; import { createCommandRouterResult, graphNamedQueryKinds } from "@the-open-engine/opcore-contracts"; import { providerFailureStatus } from "./artifact.js"; +import { + analyzePythonImportsWithGraph, + type PythonImportAnalysisEdge, + type PythonImportAnalysisFile +} from "./python-import-analysis.js"; import { graphServeRouterResult, isServeTransportArgv, runGraphServeCli } from "./serve.js"; import { invokeGraphCoreSidecar } from "./sidecar.js"; +import { + createEphemeralGraphSnapshotWithOperations, + type CreateEphemeralGraphSnapshotOptions, + type EphemeralGraphSnapshot +} from "./ephemeral-snapshot.js"; declare const process: { argv: string[]; @@ -41,10 +51,42 @@ export { export { invokeGraphCoreSidecar } from "./sidecar.js"; export { graphServeRouterResult, isServeTransportArgv, runGraphServeCli } from "./serve.js"; export type { GraphServeFrameTimingEvent, GraphServeTelemetry } from "./serve.js"; +export type { PythonImportAnalysisEdge, PythonImportAnalysisFile } from "./python-import-analysis.js"; +export type { + CreateEphemeralGraphSnapshotOptions, + EphemeralGraphSnapshot, + EphemeralGraphSnapshotLimits, + EphemeralGraphSourceReadResult, + EphemeralGraphSourceUniverse +} from "./ephemeral-snapshot.js"; export const graphProviderName = "opcore-graph"; export const graphProviderSchemaVersion = 1; +export function analyzePythonImports( + files: readonly PythonImportAnalysisFile[] +): Promise { + return analyzePythonImportsWithGraph(files, { + build: (repo) => graphProviderBuild(repo), + query: (repo) => graphProviderQuery(repo, { kind: "edges", edgeKinds: ["IMPORTS_FROM"] }) + }); +} + +export const graphPythonImportAnalyzer = { analyze: analyzePythonImports } as const; + +export function createEphemeralGraphSnapshot( + options: CreateEphemeralGraphSnapshotOptions +): Promise { + return createEphemeralGraphSnapshotWithOperations(options, { + build: (repo) => graphProviderBuild(repo), + factQuery: (repo, request) => graphProviderQuery(repo, request.selector), + namedQuery: (repo, request) => graphProviderNamedQuery(repo, request), + impact: (repo, request) => graphProviderImpact(repo, request), + reviewContext: (repo, request) => graphProviderReviewContext(repo, request), + detectChanges: (repo, request) => graphProviderDetectChanges(repo, request) + }); +} + export function graphProviderStatus( repo: RepoIdentity | string = { repoRoot: process.cwd() }, options: { paths?: readonly string[]; watchPaths?: readonly string[] } = {} diff --git a/packages/graph/src/node-shims.d.ts b/packages/graph/src/node-shims.d.ts index 4d0a98b..3216679 100644 --- a/packages/graph/src/node-shims.d.ts +++ b/packages/graph/src/node-shims.d.ts @@ -62,6 +62,7 @@ declare module "node:crypto" { declare module "node:fs" { export function closeSync(fd: number): void; export function existsSync(path: string): boolean; + export const mkdirSync: (path: string, options?: { recursive?: boolean }) => void; export function mkdtempSync(prefix: string): string; export function openSync(path: string, flags: string): number; export function readFileSync(path: string): Uint8Array; @@ -69,6 +70,7 @@ declare module "node:fs" { export function readSync(fd: number, buffer: Uint8Array, offset: number, length: number, position: number): number; export function rmSync(path: string, options?: { recursive?: boolean; force?: boolean }): void; export function statSync(path: string): { size: number; isFile: () => boolean }; + export const writeFileSync: (path: string, content: string) => void; } declare module "node:os" { diff --git a/packages/graph/src/python-import-analysis.ts b/packages/graph/src/python-import-analysis.ts new file mode 100644 index 0000000..cc029e4 --- /dev/null +++ b/packages/graph/src/python-import-analysis.ts @@ -0,0 +1,100 @@ +import type { GraphFactEdge, GraphFactQueryResult, GraphPipelineResult, RepoIdentity } from "@the-open-engine/opcore-contracts"; +import { validateRepoRelativePath } from "@the-open-engine/opcore-contracts"; +import { createEphemeralGraphSnapshotWithOperations } from "./ephemeral-snapshot.js"; + +export interface PythonImportAnalysisFile { + path: string; + content: string; +} + +export interface PythonImportAnalysisEdge { + fromPath: string; + toPath: string; +} + +export interface PythonImportAnalysisGraph { + build(repo: RepoIdentity): GraphPipelineResult; + query(repo: RepoIdentity): GraphFactQueryResult; +} + +export async function analyzePythonImportsWithGraph( + files: readonly PythonImportAnalysisFile[], + graph: PythonImportAnalysisGraph +): Promise { + const normalizedFiles = normalizeSuppliedFiles(files); + const fileByPath = new Map(normalizedFiles.map((file) => [file.path, file])); + const snapshot = await createEphemeralGraphSnapshotWithOperations({ + logicalRepo: { repoRoot: "." }, + sourceUniverse: { paths: normalizedFiles.map((file) => file.path), complete: true }, + readFile: (path) => { + const file = fileByPath.get(path); + return file === undefined ? { status: "missing" } : { status: "found", content: file.content }; + } + }, { + build: graph.build, + factQuery: (repo) => graph.query(repo) + }); + try { + const query = snapshot.factQuery({ + requestId: "python-import-analysis", + repo: { repoRoot: "." }, + schemaVersion: 1, + mode: "required", + selector: { kind: "edges", edgeKinds: ["IMPORTS_FROM"] } + }); + if (query.status.state !== "available" || !("edges" in query)) throw graphAnalysisFailure("query", query.status); + return importEdges(query.edges, new Set(normalizedFiles.map((file) => file.path))); + } finally { + snapshot.dispose(); + } +} + +function normalizeSuppliedFiles(files: readonly PythonImportAnalysisFile[]): readonly PythonImportAnalysisFile[] { + if (!Array.isArray(files)) throw new Error("Python import analysis files must be an array"); + const byPath = new Map(); + for (const [index, candidate] of files.entries()) { + if (candidate === null || typeof candidate !== "object") { + throw new Error(`Python import analysis file at index ${index} is malformed`); + } + if (typeof candidate.path !== "string" || typeof candidate.content !== "string") { + throw new Error(`Python import analysis file at index ${index} requires string path and content`); + } + const path = validateRepoRelativePath(candidate.path); + if (!isPythonSourcePath(path)) throw new Error(`Python import analysis accepts only .py/.pyi files: ${path}`); + if (byPath.has(path)) throw new Error(`Python import analysis received duplicate path: ${path}`); + byPath.set(path, { path, content: candidate.content }); + } + return [...byPath.values()].sort((left, right) => left.path.localeCompare(right.path)); +} + +function importEdges( + edges: readonly GraphFactEdge[], + suppliedPaths: ReadonlySet +): readonly PythonImportAnalysisEdge[] { + const byKey = new Map(); + for (const edge of edges) { + if (edge.kind !== "IMPORTS_FROM") continue; + const fromPath = fileEndpointPath(edge.from); + const toPath = fileEndpointPath(edge.to); + if (!suppliedPaths.has(fromPath) || !suppliedPaths.has(toPath)) { + throw new Error(`Graph-core Python import analysis returned an edge outside supplied files: ${fromPath} -> ${toPath}`); + } + byKey.set(`${fromPath}\0${toPath}`, { fromPath, toPath }); + } + return [...byKey.values()].sort((left, right) => + `${left.fromPath}\0${left.toPath}`.localeCompare(`${right.fromPath}\0${right.toPath}`) + ); +} + +function fileEndpointPath(endpoint: string): string { + if (!endpoint.startsWith("file:")) throw new Error(`Graph-core Python import analysis returned a non-file endpoint: ${endpoint}`); + return validateRepoRelativePath(endpoint.slice("file:".length)); +} + +function graphAnalysisFailure(operation: "build" | "query", status: { state: string; message?: string }): Error { + return new Error(`Graph-core Python import analysis ${operation} failed (${status.state}): ${status.message ?? "no failure message"}`); +} + +function isPythonSourcePath(path: string): boolean { + return path.endsWith(".py") || path.endsWith(".pyi"); +} diff --git a/packages/graph/src/serve.ts b/packages/graph/src/serve.ts index 2a69f6a..e3c6bf5 100644 --- a/packages/graph/src/serve.ts +++ b/packages/graph/src/serve.ts @@ -386,7 +386,7 @@ function parseMcpFrame(request: JsonRpcRequest, repo: RepoIdentity): ParsedServe protocolVersion: "2024-11-05", serverInfo: { name: "opcore-graph", - version: "0.2.0" + version: "0.2.1" }, capabilities: graphServeCapabilities } diff --git a/packages/opcore-graph-core-darwin-arm64/metadata.json b/packages/opcore-graph-core-darwin-arm64/metadata.json index 65279a0..33ee278 100644 --- a/packages/opcore-graph-core-darwin-arm64/metadata.json +++ b/packages/opcore-graph-core-darwin-arm64/metadata.json @@ -1,9 +1,9 @@ { "artifactName": "opcore-graph-core", - "artifactVersion": "0.2.0", + "artifactVersion": "0.2.1", "targetPlatform": "darwin-arm64", "binaryPath": "opcore-graph-core", "checksumPath": "opcore-graph-core.sha256", - "checksumSha256": "3fb851c8e057f085c0dd1fb15a7e3a9796a6176974a8fb6e1f34a5222b1271ae", + "checksumSha256": "72acf10de0ece619c3896943addfb0cc1b09dab22000b48a653b033224217bc8", "buildProfile": "release" } diff --git a/packages/opcore-graph-core-darwin-arm64/opcore-graph-core b/packages/opcore-graph-core-darwin-arm64/opcore-graph-core index d94c85e..1d77324 100755 Binary files a/packages/opcore-graph-core-darwin-arm64/opcore-graph-core and b/packages/opcore-graph-core-darwin-arm64/opcore-graph-core differ diff --git a/packages/opcore-graph-core-darwin-arm64/opcore-graph-core.sha256 b/packages/opcore-graph-core-darwin-arm64/opcore-graph-core.sha256 index c00a462..87e7702 100644 --- a/packages/opcore-graph-core-darwin-arm64/opcore-graph-core.sha256 +++ b/packages/opcore-graph-core-darwin-arm64/opcore-graph-core.sha256 @@ -1 +1 @@ -3fb851c8e057f085c0dd1fb15a7e3a9796a6176974a8fb6e1f34a5222b1271ae opcore-graph-core +72acf10de0ece619c3896943addfb0cc1b09dab22000b48a653b033224217bc8 opcore-graph-core diff --git a/packages/opcore-graph-core-darwin-arm64/package.json b/packages/opcore-graph-core-darwin-arm64/package.json index 4cef383..9c489a2 100644 --- a/packages/opcore-graph-core-darwin-arm64/package.json +++ b/packages/opcore-graph-core-darwin-arm64/package.json @@ -1,6 +1,6 @@ { "name": "@the-open-engine/opcore-graph-core-darwin-arm64", - "version": "0.2.0", + "version": "0.2.1", "description": "Opcore graph-core native binary for macOS arm64.", "type": "module", "license": "MIT", diff --git a/packages/opcore-graph-core-darwin-x64/metadata.json b/packages/opcore-graph-core-darwin-x64/metadata.json index 9224889..234760b 100644 --- a/packages/opcore-graph-core-darwin-x64/metadata.json +++ b/packages/opcore-graph-core-darwin-x64/metadata.json @@ -1,6 +1,6 @@ { "artifactName": "opcore-graph-core", - "artifactVersion": "0.2.0", + "artifactVersion": "0.2.1", "targetPlatform": "darwin-x64", "binaryPath": "opcore-graph-core", "checksumPath": "opcore-graph-core.sha256", diff --git a/packages/opcore-graph-core-darwin-x64/package.json b/packages/opcore-graph-core-darwin-x64/package.json index 0f4c1a7..a587412 100644 --- a/packages/opcore-graph-core-darwin-x64/package.json +++ b/packages/opcore-graph-core-darwin-x64/package.json @@ -1,6 +1,6 @@ { "name": "@the-open-engine/opcore-graph-core-darwin-x64", - "version": "0.2.0", + "version": "0.2.1", "description": "Opcore graph-core native binary for macOS x64.", "type": "module", "license": "MIT", diff --git a/packages/opcore-graph-core-linux-x64/metadata.json b/packages/opcore-graph-core-linux-x64/metadata.json index 57958d3..fb3e3eb 100644 --- a/packages/opcore-graph-core-linux-x64/metadata.json +++ b/packages/opcore-graph-core-linux-x64/metadata.json @@ -1,6 +1,6 @@ { "artifactName": "opcore-graph-core", - "artifactVersion": "0.2.0", + "artifactVersion": "0.2.1", "targetPlatform": "linux-x64", "binaryPath": "opcore-graph-core", "checksumPath": "opcore-graph-core.sha256", diff --git a/packages/opcore-graph-core-linux-x64/package.json b/packages/opcore-graph-core-linux-x64/package.json index 07a076f..2bb6cb4 100644 --- a/packages/opcore-graph-core-linux-x64/package.json +++ b/packages/opcore-graph-core-linux-x64/package.json @@ -1,6 +1,6 @@ { "name": "@the-open-engine/opcore-graph-core-linux-x64", - "version": "0.2.0", + "version": "0.2.1", "description": "Opcore graph-core native binary for Linux x64.", "type": "module", "license": "MIT", diff --git a/packages/opcore/README.md b/packages/opcore/README.md index ad424f7..948afc2 100644 --- a/packages/opcore/README.md +++ b/packages/opcore/README.md @@ -48,7 +48,7 @@ The command validates changed source files with stable JSON and agent-friendly e - TypeScript and JavaScript: deep graph-backed and validation signals for syntax, types, imports, relevant tests, dead exports, and graph structure when facts are available. - Rust: useful validation and toolchain signals for source hygiene, oversized files, module evidence, cargo, fmt, clippy, rustdoc, and optional-tool evidence when available. -- Python: experimental degraded-honest validation for graph-backed `.py`/`.pyi` structure, untested modules, dead exports, syntax, and source-hygiene; `python.types` depends on mypy or pyright and reports missing tools as degraded. +- Python: experimental degraded-honest validation for graph-backed `.py`/`.pyi` structure, untested modules, dead exports, syntax, and source-hygiene; `python.types` runs one configured mypy or Pyright authority per project and reports absent/conflicting/unavailable authority as degraded. - Other non-TS/JS/Rust/Python languages: counted and reported as unsupported; Opcore does not invent findings or ratings for files it cannot assess. Metric output is named evidence and deltas. diff --git a/packages/opcore/package.json b/packages/opcore/package.json index 997132f..5988a6b 100644 --- a/packages/opcore/package.json +++ b/packages/opcore/package.json @@ -1,6 +1,6 @@ { "name": "opcore", - "version": "0.2.0", + "version": "0.2.1", "description": "Local quality gates and pre-write validation for AI coding agents.", "type": "module", "license": "MIT", @@ -76,40 +76,46 @@ "@typescript-eslint/visitor-keys", "balanced-match", "brace-expansion", + "concat-map", "code-block-writer", "debug", "eslint-visitor-keys", "fdir", + "jsonc-parser", "minimatch", "ms", "path-browserify", "picomatch", "semver", + "smol-toml", "tinyglobby", "ts-api-utils", "ts-morph", "typescript" ], "dependencies": { - "@the-open-engine/opcore-asp-provider": "0.2.0", - "@the-open-engine/opcore-edit": "0.2.0", - "@the-open-engine/opcore-contracts": "0.2.0", - "@the-open-engine/opcore-graph": "0.2.0", - "@the-open-engine/opcore-validation": "0.2.0", - "@the-open-engine/opcore-validation-policy": "0.2.0", - "@the-open-engine/opcore-validation-clone": "0.2.0", - "@the-open-engine/opcore-validation-docs": "0.2.0", - "@the-open-engine/opcore-validation-python": "0.2.0", - "@the-open-engine/opcore-validation-rust": "0.2.0", - "@the-open-engine/opcore-validation-typescript": "0.2.0", + "@the-open-engine/opcore-asp-provider": "0.2.1", + "@the-open-engine/opcore-edit": "0.2.1", + "@the-open-engine/opcore-contracts": "0.2.1", + "@the-open-engine/opcore-graph": "0.2.1", + "@the-open-engine/opcore-validation": "0.2.1", + "@the-open-engine/opcore-validation-policy": "0.2.1", + "@the-open-engine/opcore-validation-clone": "0.2.1", + "@the-open-engine/opcore-validation-docs": "0.2.1", + "@the-open-engine/opcore-validation-python": "0.2.1", + "@the-open-engine/opcore-validation-rust": "0.2.1", + "@the-open-engine/opcore-validation-typescript": "0.2.1", "@typescript-eslint/typescript-estree": "^8.62.0", + "concat-map": "0.0.1", + "jsonc-parser": "3.3.1", + "smol-toml": "1.6.0", "ts-morph": "^28.0.0", "typescript": "^5.9.3" }, "optionalDependencies": { - "@the-open-engine/opcore-graph-core-darwin-arm64": "0.2.0", - "@the-open-engine/opcore-graph-core-darwin-x64": "0.2.0", - "@the-open-engine/opcore-graph-core-linux-x64": "0.2.0" + "@the-open-engine/opcore-graph-core-darwin-arm64": "0.2.1", + "@the-open-engine/opcore-graph-core-darwin-x64": "0.2.1", + "@the-open-engine/opcore-graph-core-linux-x64": "0.2.1" }, "engines": { "node": ">=22" diff --git a/packages/opcore/src/advanced/descriptor.ts b/packages/opcore/src/advanced/descriptor.ts index c527cc8..8063429 100644 --- a/packages/opcore/src/advanced/descriptor.ts +++ b/packages/opcore/src/advanced/descriptor.ts @@ -140,6 +140,12 @@ export function createOpcoreManagedToolDescriptor(options: OpcoreManagedToolDesc graphModes: graphProviderModes, hypothetical: true, statusSurfaces: ["status", "doctor"], + pythonProjectContext: { + schemaId: "opcore.python.project-context.v1", + outcomes: ["resolved", "degraded", "unsupported", "ambiguous"], + readOnly: true, + installs: false + }, writeGate: { initScopes: ["repo", "global"], harnesses: ["claude-code", "codex"], diff --git a/packages/opcore/src/advanced/router.ts b/packages/opcore/src/advanced/router.ts index 6a3c434..6501fa4 100644 --- a/packages/opcore/src/advanced/router.ts +++ b/packages/opcore/src/advanced/router.ts @@ -143,12 +143,12 @@ async function routeLattice( }); } -function routeRuntimeCommand( +async function routeRuntimeCommand( argv: readonly string[], parsed: ParsedCommandArgv, command: RuntimeCommand, rest: readonly string[] -): CommandRouterResult { +): Promise { if (command === "doctor") { return routeOpcoreDoctor(argv, { args: [command, ...rest], json: parsed.json }); } diff --git a/packages/opcore/src/advanced/validation-composition.ts b/packages/opcore/src/advanced/validation-composition.ts index 81802f5..78d1089 100644 --- a/packages/opcore/src/advanced/validation-composition.ts +++ b/packages/opcore/src/advanced/validation-composition.ts @@ -1,6 +1,7 @@ import type { CommandAdapter, GraphProviderMode, + PythonProjectContext, ValidationRequest, ValidationResult, ValidationStatusPayload @@ -12,7 +13,8 @@ import { createValidationRunner, createValidationStatusPayload, type ValidationCommandAdapterOptions, - type ValidationGraphProviderClient + type ValidationGraphProviderClient, + type ValidationGraphSessionFactory } from "@the-open-engine/opcore-validation"; import { createPythonValidationAdapterStatus, createPythonValidationChecks } from "@the-open-engine/opcore-validation-python"; import { createRustValidationAdapterStatus, createRustValidationChecks } from "@the-open-engine/opcore-validation-rust"; @@ -29,6 +31,7 @@ import { validationChecksForRepoPolicy } from "../repo-validation-policy.js"; import { commonSkippedPathSegments } from "../source-policy.js"; +import { createOpcoreGraphSessionFactory } from "../validation-graph-session.js"; export const checkCommandAdapter = createCliCheckCommandAdapter(); export const validateCommandAdapter = createCliValidateCommandAdapter(); @@ -54,13 +57,15 @@ export function createCliValidateCommandAdapter( export const editValidationRunner = { runValidation(request: ValidationRequest): Promise { const repoRoot = request.repo.repoRoot ?? process.cwd(); + const graphProviderClient = createCliValidationGraphProviderClient(); return createValidationRunner({ workspace: createNodeValidationWorkspace({ repoRoot, skippedPathSegments: commonSkippedPathSegments }), checks: validationChecksForRepoPolicy(repoRoot), - graphProviderClient: createCliValidationGraphProviderClient() + graphProviderClient, + graphSessionFactory: createCliValidationGraphSessionFactory(graphProviderClient) }).runValidation(request); } }; @@ -72,20 +77,26 @@ declare const process: { export function createDefaultValidationStatusPayload(options: { repoRoot: string; graphMode?: GraphProviderMode; + pythonProjectContexts?: readonly PythonProjectContext[]; }): ValidationStatusPayload { const graphMode = options.graphMode ?? "optional"; return createValidationStatusPayload({ checks: validationChecksForRepoPolicy(options.repoRoot), - adapters: [createRustValidationAdapterStatus(), createPythonValidationAdapterStatus({ repoRoot: options.repoRoot })], + adapters: [ + createRustValidationAdapterStatus(), + createPythonValidationAdapterStatus({ repoRoot: options.repoRoot, contexts: options.pythonProjectContexts }) + ], graphMode, graphStatus: cliGraphStatus({ repoRoot: options.repoRoot }, graphMode) }); } function defaultValidationAdapterOptions(repoRoot = process.cwd()): ValidationCommandAdapterOptions { + const graphProviderClient = createCliValidationGraphProviderClient(); return { checksFactory: validationChecksForRepoPolicy, - graphProviderClient: createCliValidationGraphProviderClient(), + graphProviderClient, + graphSessionFactory: createCliValidationGraphSessionFactory(graphProviderClient), defaultRepoRoot: repoRoot, workspaceFactory: (repoRoot) => createNodeValidationWorkspace({ @@ -95,6 +106,12 @@ function defaultValidationAdapterOptions(repoRoot = process.cwd()): ValidationCo }; } +export function createCliValidationGraphSessionFactory( + persistentClient: ValidationGraphProviderClient = createCliValidationGraphProviderClient() +): ValidationGraphSessionFactory { + return createOpcoreGraphSessionFactory(persistentClient); +} + function createCliValidationGraphProviderClient(): ValidationGraphProviderClient { return { status: (request) => cliGraphStatus(request.repo, request.graph.mode), diff --git a/packages/opcore/src/agent-gate.ts b/packages/opcore/src/agent-gate.ts index c42810f..64a7072 100644 --- a/packages/opcore/src/agent-gate.ts +++ b/packages/opcore/src/agent-gate.ts @@ -1,7 +1,7 @@ import { spawnSync } from "node:child_process"; import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; -import { dirname, isAbsolute, relative, resolve, sep } from "node:path"; +import { delimiter, dirname, isAbsolute, join, relative, resolve, sep } from "node:path"; import { createNodeEditWorkspace, createPatchEditPlan, @@ -240,12 +240,15 @@ async function patchOverlays(repoRoot: string, input: Record): } function runPreWriteValidation(request: ValidationRequest): PreWriteValidationReceipt { + const repoRoot = request.repo.repoRoot; + if (repoRoot === undefined) throw new Error("pre-write validation requires repoRoot"); const tempDir = mkdtempSync(resolve(tmpdir(), "opcore-agent-gate-")); const requestPath = resolve(tempDir, "validation-request.json"); try { writeFileSync(requestPath, `${JSON.stringify(request)}\n`, "utf8"); const result = spawnSync("opcore", ["validate", "pre-write", "--request-file", requestPath, "--timeout-ms", String(validationTimeoutMs), "--json"], { - cwd: request.repo.repoRoot, + cwd: repoRoot, + env: validationSpawnEnv(repoRoot), encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }); @@ -258,8 +261,17 @@ function runPreWriteValidation(request: ValidationRequest): PreWriteValidationRe } } -function parseValidationOutput(stdout: string): { receipt?: PreWriteValidationReceipt } | undefined { - const trimmed = stdout.trim(); +function validationSpawnEnv(repoRoot: string): Record { + const env = { ...(process.env ?? {}) }; + const pathKey = Object.keys(env).find((key) => key.toLowerCase() === "path") ?? "PATH"; + const currentPath = env[pathKey] ?? ""; + const localBin = join(repoRoot, "node_modules", ".bin"); + env[pathKey] = currentPath.length > 0 ? `${localBin}${delimiter}${currentPath}` : localBin; + return env; +} + +function parseValidationOutput(stdout: string | null | undefined): { receipt?: PreWriteValidationReceipt } | undefined { + const trimmed = (stdout ?? "").trim(); if (trimmed.length === 0) return undefined; const parsed = JSON.parse(trimmed) as unknown; if (!isRecord(parsed) || !isRecord(parsed.receipt)) return undefined; diff --git a/packages/opcore/src/doctor.ts b/packages/opcore/src/doctor.ts index ea7fdf5..a34295c 100644 --- a/packages/opcore/src/doctor.ts +++ b/packages/opcore/src/doctor.ts @@ -4,6 +4,7 @@ import { existsSync, readFileSync } from "node:fs"; import { join } from "node:path"; import { readOpcoreRuntimeInfo } from "./runtime-info.js"; import { resolveRepo, validationPolicySummary } from "./status.js"; +import { createRepoState } from "./status-state.js"; import { createDefaultValidationStatusPayload } from "./validation-composition.js"; declare const process: { @@ -17,7 +18,7 @@ const generatedStateIgnores = [ ".robustness-engine-cache/" ]; -export function routeOpcoreDoctor(argv: readonly string[], parsed: ParsedCommandArgv): CommandRouterResult { +export async function routeOpcoreDoctor(argv: readonly string[], parsed: ParsedCommandArgv): Promise { const rest = parsed.args.slice(1); if (rest.some((arg) => helpArgs.has(arg))) { return createCommandRouterResult({ @@ -55,9 +56,11 @@ export function routeOpcoreDoctor(argv: readonly string[], parsed: ParsedCommand }); } + const repoState = await createRepoState(resolution.resolution); const validationStatus = createDefaultValidationStatusPayload({ repoRoot: resolution.resolution.root, - graphMode: "optional" + graphMode: "optional", + pythonProjectContexts: repoState.validation.pythonProjectContexts }); const runtimeInfo = readOpcoreRuntimeInfo(); const policy = validationPolicySummary(resolution.resolution.root, validationStatus.adapterRegistry.checkIds); diff --git a/packages/opcore/src/init.ts b/packages/opcore/src/init.ts index f688561..0674bd2 100644 --- a/packages/opcore/src/init.ts +++ b/packages/opcore/src/init.ts @@ -96,19 +96,6 @@ const allowedGlobalUndoPaths = new Set([ codexHooksPath ]); const rustActiveValidationKinds = new Set([".rs", ".inc", "Cargo.toml"]); -const pythonProjectFileNames = new Set(["pyproject.toml", "Pipfile", "poetry.lock", "uv.lock"]); -const pythonVirtualEnvDirs = new Set([".venv", "venv", "env"]); -const pythonDiscoverySkipDirs = new Set([ - ...commonSkippedPathSegments, - "__pycache__", - ".eggs", - "build", - ".tox", - ".mypy_cache", - ".pytest_cache", - ".ruff_cache", - "site-packages" -]); interface ParsedInitArgs { command: OpcoreSetupCommand; @@ -272,7 +259,7 @@ async function routeOpcoreSetup( } const context: InitContext = { scan: createInitScanSummary(analysis.repoState, analysis.validationResult), - settings: createInitSettings(analysis.repoState, resolution.resolution.root), + settings: createInitSettings(analysis.repoState), interaction: { tty: isInteractiveRuntime(runtime), promptState: "not_requested" @@ -682,14 +669,14 @@ function createInitScanSummary(repoState: OpcoreRepoStatePayload, validationResu }; } -function createInitSettings(repoState: OpcoreRepoStatePayload, repoRoot: string): OpcoreInitSettings { +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 = detectPythonProjectSignals(repoRoot); + const pythonProject = pythonEnvironmentFromContexts(repoState.validation.pythonProjectContexts ?? []); return { languages: repoState.coverage.languages.map((language): OpcoreInitLanguageSetting => { const rustRetainedOnly = @@ -790,118 +777,55 @@ function notesForLanguage( return notes; } -function detectPythonProjectSignals(repoRoot: string): OpcoreInitPythonEnvironment { - const files: string[] = []; - const virtualEnvironmentPaths: string[] = []; - const stack = [""]; - while (stack.length > 0) { - const relativeDir = stack.pop(); - if (relativeDir === undefined) continue; - let entries: ReturnType; - try { - entries = readdirSync(relativeDir.length > 0 ? resolveRepoPath(repoRoot, relativeDir) : repoRoot, { withFileTypes: true }); - } catch { - continue; +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); + } } - for (const entry of entries) { - const relativePath = relativeDir.length > 0 ? `${relativeDir}/${entry.name}` : entry.name; - const entryStat = lstatIfExists(resolveRepoPath(repoRoot, relativePath)); - if (!entryStat || entryStat.isSymbolicLink()) continue; - if (entry.isDirectory()) { - if (pythonVirtualEnvDirs.has(entry.name)) { - virtualEnvironmentPaths.push(relativePath); - continue; - } - if (pythonDiscoverySkipDirs.has(entry.name) || entry.name.endsWith(".egg-info") || entry.name.endsWith(".dist-info")) { - continue; - } - stack.push(relativePath); - continue; + 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 }); } - if (entry.isFile() && isPythonProjectFile(entry.name)) files.push(relativePath); } } - const uniqueFiles = uniqueStrings(files).sort(); - const dependencyManagers = pythonDependencyManagers(uniqueFiles); - const virtualEnvironments = uniqueStrings(virtualEnvironmentPaths) - .sort() - .map((path) => ({ kind: "venv" as const, 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, - virtualEnvironments, - notes: pythonEnvironmentNotes(uniqueFiles, dependencyManagers, virtualEnvironments) + 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 isPythonProjectFile(name: string): boolean { - return pythonProjectFileNames.has(name) || /^requirements.*\.txt$/u.test(name); -} - -function pythonDependencyManagers(files: readonly string[]): OpcoreInitPythonEnvironment["dependencyManagers"] { - const managers: OpcoreInitPythonEnvironment["dependencyManagers"][number][] = []; - const seen = new Set(); - for (const file of files) { - const name = file.split("/").at(-1) ?? file; - const kind = pythonDependencyManagerKind(name); - if (kind === undefined) continue; - const key = `${kind}\0${file}`; - if (seen.has(key)) continue; - seen.add(key); - managers.push({ kind, path: file }); - } - return managers.sort((left, right) => left.path.localeCompare(right.path) || left.kind.localeCompare(right.kind)); -} - -function pythonDependencyManagerKind( - name: string -): OpcoreInitPythonEnvironment["dependencyManagers"][number]["kind"] | undefined { - if (name === "pyproject.toml") return "pyproject"; - if (/^requirements.*\.txt$/u.test(name)) return "requirements"; - if (name === "Pipfile") return "pipfile"; - if (name === "poetry.lock") return "poetry"; - if (name === "uv.lock") return "uv"; - return undefined; -} - -function pythonEnvironmentNotes( - files: readonly string[], - dependencyManagers: OpcoreInitPythonEnvironment["dependencyManagers"], - virtualEnvironments: OpcoreInitPythonEnvironment["virtualEnvironments"] -): string[] { - const notes: string[] = []; - if (files.length > 0) { - notes.push(`Detected Python project files: ${formatExamples(files)}.`); - } - if (dependencyManagers.length > 0) { - notes.push(`Detected Python dependency managers: ${formatManagerKinds(dependencyManagers)}.`); - } - if (virtualEnvironments.length > 0) { - notes.push(`Detected Python virtualenv directories: ${formatExamples(virtualEnvironments.map((entry) => entry.path))}.`); - } - if (files.length > 0 || dependencyManagers.length > 0 || virtualEnvironments.length > 0) { - notes.push("Python graph and validation coverage is reported with missing tools as degraded coverage."); - } - return notes; -} - -function formatManagerKinds(dependencyManagers: OpcoreInitPythonEnvironment["dependencyManagers"]): string { - const labels = new Map([ - ["pyproject", "pyproject"], - ["requirements", "pip requirements"], - ["pipfile", "Pipenv"], - ["poetry", "Poetry"], - ["uv", "uv"] - ]); - return [...new Set(dependencyManagers.map((entry) => labels.get(entry.kind) ?? entry.kind))].sort().join(", "); -} - function hasPythonEnvironmentSignals(environment: OpcoreInitPythonEnvironment): boolean { - return environment.dependencyManagers.length > 0 || environment.virtualEnvironments.length > 0 || environment.notes.length > 0; -} - -function formatExamples(values: readonly string[]): string { - const visible = values.slice(0, 5).join(", "); - return values.length > 5 ? `${visible}, +${values.length - 5} more` : visible; + return (environment.contexts?.length ?? 0) > 0; } function withContext(payload: OpcoreInitPlanPayload, context: InitContext, timing: TimingState): OpcoreInitPlanPayload { @@ -1657,7 +1581,7 @@ function guidanceBlock(): string { "- 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, treat missing mypy/pyright/ruff/pytest as degraded coverage, not a pass.", + "- 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"); diff --git a/packages/opcore/src/node-shims.d.ts b/packages/opcore/src/node-shims.d.ts index dfda9b1..d98c4fd 100644 --- a/packages/opcore/src/node-shims.d.ts +++ b/packages/opcore/src/node-shims.d.ts @@ -84,6 +84,7 @@ declare module "node:module" { } declare module "node:path" { export function basename(path: string): string; + export const delimiter: string; export function dirname(path: string): string; export function extname(path: string): string; export function isAbsolute(path: string): boolean; diff --git a/packages/opcore/src/repo-validation-policy.ts b/packages/opcore/src/repo-validation-policy.ts index 2af7376..ee2622b 100644 --- a/packages/opcore/src/repo-validation-policy.ts +++ b/packages/opcore/src/repo-validation-policy.ts @@ -3,9 +3,12 @@ import { validationChecksForRepoPolicy as validationChecksForSharedRepoPolicy, validationChecksForRepoPolicyAndCoverage as validationChecksForSharedRepoPolicyAndCoverage } from "@the-open-engine/opcore-validation-policy"; +import { graphPythonImportAnalyzer } from "@the-open-engine/opcore-graph"; import { invokeCloneAnalysis } from "./clone-invoker.js"; +import { createNodePythonProjectWorkspace } from "@the-open-engine/opcore-validation-python"; const opcoreValidationPolicyOptions = { + pythonImportAnalyzer: graphPythonImportAnalyzer, clone: { invoke: invokeCloneAnalysis } @@ -14,9 +17,15 @@ const opcoreValidationPolicyOptions = { export const defaultValidationChecks = createBuiltInValidationChecks(undefined, opcoreValidationPolicyOptions); export function validationChecksForRepoPolicy(repoRoot: string) { - return validationChecksForSharedRepoPolicy(repoRoot, opcoreValidationPolicyOptions); + return validationChecksForSharedRepoPolicy(repoRoot, { + ...opcoreValidationPolicyOptions, + pythonWorkspace: createNodePythonProjectWorkspace(repoRoot) + }); } export function validationChecksForRepoPolicyAndCoverage(repoRoot: string, adapters: ReadonlySet) { - return validationChecksForSharedRepoPolicyAndCoverage(repoRoot, adapters, opcoreValidationPolicyOptions); + return validationChecksForSharedRepoPolicyAndCoverage(repoRoot, adapters, { + ...opcoreValidationPolicyOptions, + pythonWorkspace: createNodePythonProjectWorkspace(repoRoot) + }); } diff --git a/packages/opcore/src/reporting.ts b/packages/opcore/src/reporting.ts index 75b2da9..8ea7808 100644 --- a/packages/opcore/src/reporting.ts +++ b/packages/opcore/src/reporting.ts @@ -283,7 +283,9 @@ export function createOpcoreMetricReport(input: CreateOpcoreMetricReportInput): ...(validationResult?.status ? { status: validationResult.status } : {}), diagnosticCount: validationResult?.diagnostics.length ?? 0, checkCount: validationResult?.manifest?.checks.length ?? input.repoState.validation.checkCount, - policy: input.repoState.validation.policy + policy: input.repoState.validation.policy, + pythonProjectContexts: + validationResult?.pythonProjectContexts ?? input.repoState.validation.pythonProjectContexts ?? [] }, signals: sortSignals(signals), degradations: sortDegradations(degradations), @@ -1007,17 +1009,15 @@ function addValidationDegradations( checkId: pythonDeadCodeCheckId }); } - if (diagnostic.code === "PYTHON_TYPES_UNSUPPORTED" || diagnostic.code === "PYTHON_TYPES_DEFERRED") { + if (isPythonTypeAvailabilityDiagnostic(diagnostic)) { degradations.push({ - id: diagnostic.code === "PYTHON_TYPES_UNSUPPORTED" ? "python.types.unavailable" : "python.types.deferred", - title: diagnostic.code === "PYTHON_TYPES_UNSUPPORTED" - ? "Python type metric unavailable" - : "Python type metric deferred", + id: "python.types.unavailable", + title: "Python type metric unavailable", source: "validation_diagnostic", severity: "warning", message: diagnostic.message, checkId: pythonTypesCheckId, - requiredTool: "mypy or pyright" + requiredTool: "configured Python type authority" }); } } @@ -1112,7 +1112,9 @@ function diagnosticEvidence(diagnostic: ValidationDiagnostic, checkId: string): path: diagnostic.path, message: diagnostic.message, checkId, - ...(diagnostic.code ? { code: diagnostic.code } : {}) + ...(diagnostic.code ? { code: diagnostic.code } : {}), + ...(diagnostic.line === undefined ? {} : { line: diagnostic.line }), + ...(diagnostic.column === undefined ? {} : { column: diagnostic.column }) } ]; } @@ -1144,7 +1146,12 @@ function isRustToolchainDiagnostic(diagnostic: ValidationDiagnostic): boolean { function isPythonTypeSignalDiagnostic(diagnostic: ValidationDiagnostic): boolean { if (diagnostic.category !== "types" || !isPythonPath(diagnostic.path)) return false; - return diagnostic.code !== "PYTHON_TYPES_UNSUPPORTED" && diagnostic.code !== "PYTHON_TYPES_DEFERRED"; + return !isPythonTypeAvailabilityDiagnostic(diagnostic); +} + +function isPythonTypeAvailabilityDiagnostic(diagnostic: ValidationDiagnostic): boolean { + return diagnostic.code === "PYTHON_TYPES_UNSUPPORTED_TARGET" || + diagnostic.code === "PYTHON_TYPES_TOOL_UNAVAILABLE"; } function graphBackedCheckId(checkId: string): boolean { diff --git a/packages/opcore/src/runtime-info.ts b/packages/opcore/src/runtime-info.ts index 1a95d55..5a54ba5 100644 --- a/packages/opcore/src/runtime-info.ts +++ b/packages/opcore/src/runtime-info.ts @@ -31,9 +31,9 @@ export function formatOpcoreVersion(runtime = readOpcoreRuntimeInfo()): string { function readPackageVersion(): string { try { const parsed = JSON.parse(readFileSync(packageJsonPath, "utf8")) as { version?: unknown }; - return typeof parsed.version === "string" && parsed.version.length > 0 ? parsed.version : "0.2.0"; + return typeof parsed.version === "string" && parsed.version.length > 0 ? parsed.version : "0.2.1"; } catch { - return "0.2.0"; + return "0.2.1"; } } diff --git a/packages/opcore/src/scan-validation-preview.ts b/packages/opcore/src/scan-validation-preview.ts index 79f0490..3f3c3e8 100644 --- a/packages/opcore/src/scan-validation-preview.ts +++ b/packages/opcore/src/scan-validation-preview.ts @@ -1,4 +1,7 @@ -import type { ValidationResult } from "@the-open-engine/opcore-contracts"; +import { + validatePythonValidationCapabilityRun, + type ValidationResult +} from "@the-open-engine/opcore-contracts"; export const scanDiagnosticPreviewLimit = 50; @@ -16,6 +19,14 @@ export function compactScanValidationResult( if (result.graphStatus !== undefined) compact.graphStatus = result.graphStatus; if (result.failure !== undefined) compact.failure = result.failure; if (result.refusal !== undefined) compact.refusal = result.refusal; + if (result.pythonProjectContexts !== undefined) { + compact.pythonProjectContexts = [...result.pythonProjectContexts]; + } + if (result.pythonCapabilityRuns !== undefined) { + compact.pythonCapabilityRuns = result.pythonCapabilityRuns.map((run) => + validatePythonValidationCapabilityRun(run) + ); + } if (result.manifest !== undefined) { compact.manifest = { schemaVersion: result.manifest.schemaVersion, diff --git a/packages/opcore/src/scan.ts b/packages/opcore/src/scan.ts index dbf8422..0d42333 100644 --- a/packages/opcore/src/scan.ts +++ b/packages/opcore/src/scan.ts @@ -92,7 +92,7 @@ export async function routeOpcoreScan( } export async function createOpcoreScanAnalysis(resolution: RepoResolution): Promise { - const repoState = createRepoState(resolution); + const repoState = await createRepoState(resolution); const graphMode: GraphProviderMode = repoState.graph.state === "available" ? "required" : "optional"; const validationRequest: ValidationRequest = { repo: { diff --git a/packages/opcore/src/serve-telemetry.ts b/packages/opcore/src/serve-telemetry.ts index 4d7a77c..ea26ca6 100644 --- a/packages/opcore/src/serve-telemetry.ts +++ b/packages/opcore/src/serve-telemetry.ts @@ -5,38 +5,39 @@ import { createRepoState, resolveRepo } from "./status.js"; import { createCommandLatencyRecord } from "./timing.js"; export function createGraphServeTelemetry(): GraphServeTelemetry { - const repoStateCache = new Map(); + const repoStateCache = new Map>(); return { recordFrameTiming(event): void { const repoRoot = event.repo.repoRoot; if (!repoRoot) return; - const repoState = cachedRepoState(repoStateCache, repoRoot); - if (!repoState) return; - const result: CommandRouterResult = { - schemaVersion: 1, - bin: "opcore", - argv: event.canonicalCommand, - canonicalCommand: event.canonicalCommand, - owner: event.owner, - status: event.status, - exitCode: event.exitCode, - message: `${event.canonicalCommand.join(" ")} frame timing`, - json: false, - timing: event.timing - }; - writeCommandLatencyTelemetry(repoState.repo.root, createCommandLatencyRecord(result, repoState)); + void cachedRepoState(repoStateCache, repoRoot).then((repoState) => { + if (!repoState) return; + const result: CommandRouterResult = { + schemaVersion: 1, + bin: "opcore", + argv: event.canonicalCommand, + canonicalCommand: event.canonicalCommand, + owner: event.owner, + status: event.status, + exitCode: event.exitCode, + message: `${event.canonicalCommand.join(" ")} frame timing`, + json: false, + timing: event.timing + }; + writeCommandLatencyTelemetry(repoState.repo.root, createCommandLatencyRecord(result, repoState)); + }); } }; } function cachedRepoState( - cache: Map, + cache: Map>, repoRoot: string -): OpcoreRepoStatePayload | undefined { +): Promise { const cached = cache.get(repoRoot); if (cached) return cached; const resolution = resolveRepo(repoRoot, "opcore graph serve telemetry"); - if (!resolution.ok) return undefined; + if (!resolution.ok) return Promise.resolve(undefined); const repoState = createRepoState(resolution.resolution); cache.set(repoRoot, repoState); return repoState; diff --git a/packages/opcore/src/status-state.ts b/packages/opcore/src/status-state.ts index 2d96e1b..6de0d0c 100644 --- a/packages/opcore/src/status-state.ts +++ b/packages/opcore/src/status-state.ts @@ -1,4 +1,9 @@ import type { GraphProviderStatus, OpcoreRepoStatePayload } from "@the-open-engine/opcore-contracts"; +import { + createNodePythonProjectWorkspace, + isPythonSourcePath, + resolvePythonProjectContexts +} from "@the-open-engine/opcore-validation-python"; import { existsSync } from "node:fs"; import { join } from "node:path"; import { @@ -18,16 +23,25 @@ interface StatusWarningOptions { traversalFailures: readonly CensusTraversalFailure[]; } -export function createRepoState(resolution: RepoResolution): OpcoreRepoStatePayload { +export async function createRepoState(resolution: RepoResolution): Promise { + const census = readRepoCensus(resolution); + const coverage = computeCoverage(census.files); + const pythonTargets = census.files.filter(isPythonSourcePath); + const pythonProjectContexts = pythonTargets.length === 0 + ? [] + : await resolvePythonProjectContexts({ + repoRoot: resolution.root, + targets: pythonTargets, + workspace: createNodePythonProjectWorkspace(resolution.root) + }); const validationStatus = createDefaultValidationStatusPayload({ repoRoot: resolution.root, - graphMode: "optional" + graphMode: "optional", + pythonProjectContexts }); - const census = readRepoCensus(resolution); - const coverage = computeCoverage(census.files); const graphStatus = validationStatus.graph.status; const policy = validationPolicySummary(resolution.root, validationStatus.adapterRegistry.checkIds); - const validation = validationSummary(validationStatus, coverage, policy); + const validation = validationSummary(validationStatus, coverage, policy, pythonProjectContexts); const blockers = statusBlockers(graphStatus, census.traversalFailures); const level = activationLevel(graphStatus, validation.ready, blockers); const warningOptions = { coverage, validation, graphStatus, traversalFailures: census.traversalFailures }; diff --git a/packages/opcore/src/status-validation.ts b/packages/opcore/src/status-validation.ts index 4f950dc..965f980 100644 --- a/packages/opcore/src/status-validation.ts +++ b/packages/opcore/src/status-validation.ts @@ -1,6 +1,7 @@ import type { OpcoreRepoStatePayload, OpcoreValidationPolicySummary, + PythonProjectContext, ValidationAdapterRuntimeStatus } from "@the-open-engine/opcore-contracts"; import { existsSync } from "node:fs"; @@ -12,7 +13,8 @@ import type { createDefaultValidationStatusPayload } from "./validation-composit export function validationSummary( validationStatus: ReturnType, coverage: OpcoreRepoStatePayload["coverage"], - policy: OpcoreValidationPolicySummary + policy: OpcoreValidationPolicySummary, + pythonProjectContexts: readonly PythonProjectContext[] = [] ): OpcoreRepoStatePayload["validation"] { const adapters = validationStatus.adapterRegistry.adapters ?? []; const degraded = adapters.flatMap((adapter) => degradedToolchains(adapter)); @@ -20,6 +22,7 @@ export function validationSummary( ready: validationStatus.ready, checkCount: validationStatus.adapterRegistry.checkIds.length, policy, + pythonProjectContexts, adapters: adapters.map((adapter) => ({ adapter: adapter.adapter, status: adapter.status, diff --git a/packages/opcore/src/status.ts b/packages/opcore/src/status.ts index 0c0c047..91cc2c7 100644 --- a/packages/opcore/src/status.ts +++ b/packages/opcore/src/status.ts @@ -21,7 +21,7 @@ export { resolveRepo, type RepoResolution } from "./status-repo.js"; export { createRepoState } from "./status-state.js"; export { validationPolicySummary } from "./status-validation.js"; -export function routeOpcoreStatus(argv: readonly string[], parsed: ParsedCommandArgv): CommandRouterResult { +export async function routeOpcoreStatus(argv: readonly string[], parsed: ParsedCommandArgv): Promise { const rest = parsed.args.slice(1); if (rest.some((arg) => isStatusHelpArg(arg))) return statusHelpResult(argv, parsed); const parsedStatus = parseOpcoreStatusArgs(rest); @@ -31,7 +31,7 @@ export function routeOpcoreStatus(argv: readonly string[], parsed: ParsedCommand let repoState: OpcoreRepoStatePayload; try { - repoState = createRepoState(resolution.resolution); + repoState = await createRepoState(resolution.resolution); } catch (error) { return statusErrorResult(argv, parsed, errorMessage(error)); } diff --git a/packages/opcore/src/timing.ts b/packages/opcore/src/timing.ts index f9b38ab..5d886a2 100644 --- a/packages/opcore/src/timing.ts +++ b/packages/opcore/src/timing.ts @@ -190,9 +190,9 @@ function readOpcoreVersion(): string { const parsed = JSON.parse(readFileSync(packageJsonPath, "utf8")) as { version?: unknown }; cachedOpcoreVersion = typeof parsed.version === "string" && parsed.version.length > 0 ? parsed.version - : "0.2.0"; + : "0.2.1"; } catch { - cachedOpcoreVersion = "0.2.0"; + cachedOpcoreVersion = "0.2.1"; } return cachedOpcoreVersion; } diff --git a/packages/opcore/src/try.ts b/packages/opcore/src/try.ts index 1de340d..07330d4 100644 --- a/packages/opcore/src/try.ts +++ b/packages/opcore/src/try.ts @@ -224,7 +224,7 @@ function writeRustCrate(repoRoot: string, crateName: string): void { [ "[package]", `name = "${crateName}"`, - "version = \"0.2.0\"", + "version = \"0.2.1\"", "edition = \"2021\"", "", "[lib]", diff --git a/packages/opcore/src/validation-composition.ts b/packages/opcore/src/validation-composition.ts index 351995c..31f000d 100644 --- a/packages/opcore/src/validation-composition.ts +++ b/packages/opcore/src/validation-composition.ts @@ -1,6 +1,7 @@ import type { CommandAdapter, GraphProviderMode, + PythonProjectContext, ValidationRequest, ValidationResult, ValidationStatusPayload @@ -12,6 +13,7 @@ import { createValidationStatusPayload, type ValidationCommandAdapterOptions, type ValidationGraphProviderClient, + type ValidationGraphSessionFactory, type ValidationRuntimePolicy } from "@the-open-engine/opcore-validation"; import { createPythonValidationAdapterStatus, createPythonValidationChecks } from "@the-open-engine/opcore-validation-python"; @@ -29,6 +31,7 @@ import { validationChecksForRepoPolicy } from "./repo-validation-policy.js"; import { commonSkippedPathSegments } from "./source-policy.js"; +import { createOpcoreGraphSessionFactory } from "./validation-graph-session.js"; declare const process: { cwd(): string; @@ -52,13 +55,15 @@ export function createOpcoreCheckCommandAdapter( export const opcoreValidationRunner = { runValidation(request: ValidationRequest): Promise { const repoRoot = request.repo.repoRoot ?? process.cwd(); + const graphProviderClient = createOpcoreValidationGraphProviderClient(); return createValidationRunner({ workspace: createNodeValidationWorkspace({ repoRoot, skippedPathSegments: commonSkippedPathSegments }), checks: validationChecksForRepoPolicy(repoRoot), - graphProviderClient: createOpcoreValidationGraphProviderClient(), + graphProviderClient, + graphSessionFactory: createOpcoreValidationGraphSessionFactory(graphProviderClient), runtime: opcorePublicValidationRuntimePolicy }).runValidation(request); } @@ -67,20 +72,29 @@ export const opcoreValidationRunner = { export function createDefaultValidationStatusPayload(options: { repoRoot: string; graphMode?: GraphProviderMode; + pythonProjectContexts?: readonly PythonProjectContext[]; }): ValidationStatusPayload { const graphMode = options.graphMode ?? "optional"; return createValidationStatusPayload({ checks: validationChecksForRepoPolicy(options.repoRoot), - adapters: [createRustValidationAdapterStatus(), createPythonValidationAdapterStatus({ repoRoot: options.repoRoot })], + adapters: [ + createRustValidationAdapterStatus(), + createPythonValidationAdapterStatus({ + repoRoot: options.repoRoot, + contexts: options.pythonProjectContexts + }) + ], graphMode, graphStatus: opcoreGraphStatus({ repoRoot: options.repoRoot }, graphMode) }); } function defaultValidationAdapterOptions(repoRoot = process.cwd()): ValidationCommandAdapterOptions { + const graphProviderClient = createOpcoreValidationGraphProviderClient(); return { checksFactory: validationChecksForRepoPolicy, - graphProviderClient: createOpcoreValidationGraphProviderClient(), + graphProviderClient, + graphSessionFactory: createOpcoreValidationGraphSessionFactory(graphProviderClient), runtime: opcorePublicValidationRuntimePolicy, defaultRepoRoot: repoRoot, workspaceFactory: (repoRoot) => @@ -91,6 +105,12 @@ function defaultValidationAdapterOptions(repoRoot = process.cwd()): ValidationCo }; } +export function createOpcoreValidationGraphSessionFactory( + persistentClient: ValidationGraphProviderClient = createOpcoreValidationGraphProviderClient() +): ValidationGraphSessionFactory { + return createOpcoreGraphSessionFactory(persistentClient); +} + export function createOpcoreValidationGraphProviderClient(): ValidationGraphProviderClient { return { status: (request) => opcoreGraphStatus(request.repo, request.graph.mode), diff --git a/packages/opcore/src/validation-graph-session.ts b/packages/opcore/src/validation-graph-session.ts new file mode 100644 index 0000000..69277ac --- /dev/null +++ b/packages/opcore/src/validation-graph-session.ts @@ -0,0 +1,16 @@ +import { createEphemeralGraphSnapshot } from "@the-open-engine/opcore-graph"; +import { + createStateAwareValidationGraphSessionFactory, + createValidationExactGraphSnapshotFactory, + type ValidationGraphProviderClient, + type ValidationGraphSessionFactory +} from "@the-open-engine/opcore-validation"; + +export function createOpcoreGraphSessionFactory( + persistentClient: ValidationGraphProviderClient +): ValidationGraphSessionFactory { + return createStateAwareValidationGraphSessionFactory({ + persistentClient, + exactSnapshotFactory: createValidationExactGraphSnapshotFactory(createEphemeralGraphSnapshot) + }); +} diff --git a/packages/validation-clone/package.json b/packages/validation-clone/package.json index 77753ba..2569236 100644 --- a/packages/validation-clone/package.json +++ b/packages/validation-clone/package.json @@ -1,6 +1,6 @@ { "name": "@the-open-engine/opcore-validation-clone", - "version": "0.2.0", + "version": "0.2.1", "description": "Internal Opcore clone-detection validation adapter for duplicate-code policy checks.", "type": "module", "license": "MIT", @@ -24,8 +24,8 @@ "developer-tools" ], "dependencies": { - "@the-open-engine/opcore-contracts": "0.2.0", - "@the-open-engine/opcore-validation": "0.2.0" + "@the-open-engine/opcore-contracts": "0.2.1", + "@the-open-engine/opcore-validation": "0.2.1" }, "engines": { "node": ">=22" diff --git a/packages/validation-docs/package.json b/packages/validation-docs/package.json index e3c0054..9fe108b 100644 --- a/packages/validation-docs/package.json +++ b/packages/validation-docs/package.json @@ -1,6 +1,6 @@ { "name": "@the-open-engine/opcore-validation-docs", - "version": "0.2.0", + "version": "0.2.1", "description": "Internal Opcore documentation and context-health validation adapter.", "type": "module", "license": "MIT", @@ -24,8 +24,8 @@ "developer-tools" ], "dependencies": { - "@the-open-engine/opcore-contracts": "0.2.0", - "@the-open-engine/opcore-validation": "0.2.0" + "@the-open-engine/opcore-contracts": "0.2.1", + "@the-open-engine/opcore-validation": "0.2.1" }, "engines": { "node": ">=22" diff --git a/packages/validation-policy/README.md b/packages/validation-policy/README.md index 94d9955..1a1a5a1 100644 --- a/packages/validation-policy/README.md +++ b/packages/validation-policy/README.md @@ -1,3 +1,5 @@ # Opcore Validation Policy Shared repo validation policy parsing and check composition for Opcore-owned validation entrypoints. + +Python import-dependent checks require `pythonImportAnalyzer` composition. Opcore and ASP inject the graph package adapter; validation-policy forwards the structural interface without importing graph implementation internals. diff --git a/packages/validation-policy/package.json b/packages/validation-policy/package.json index c8a793d..f4270bc 100644 --- a/packages/validation-policy/package.json +++ b/packages/validation-policy/package.json @@ -1,6 +1,6 @@ { "name": "@the-open-engine/opcore-validation-policy", - "version": "0.2.0", + "version": "0.2.1", "description": "Shared Opcore repo validation policy composition.", "type": "module", "license": "MIT", @@ -25,13 +25,13 @@ "developer-tools" ], "dependencies": { - "@the-open-engine/opcore-contracts": "0.2.0", - "@the-open-engine/opcore-validation": "0.2.0", - "@the-open-engine/opcore-validation-clone": "0.2.0", - "@the-open-engine/opcore-validation-docs": "0.2.0", - "@the-open-engine/opcore-validation-python": "0.2.0", - "@the-open-engine/opcore-validation-rust": "0.2.0", - "@the-open-engine/opcore-validation-typescript": "0.2.0" + "@the-open-engine/opcore-contracts": "0.2.1", + "@the-open-engine/opcore-validation": "0.2.1", + "@the-open-engine/opcore-validation-clone": "0.2.1", + "@the-open-engine/opcore-validation-docs": "0.2.1", + "@the-open-engine/opcore-validation-python": "0.2.1", + "@the-open-engine/opcore-validation-rust": "0.2.1", + "@the-open-engine/opcore-validation-typescript": "0.2.1" }, "engines": { "node": ">=22" diff --git a/packages/validation-policy/src/config.ts b/packages/validation-policy/src/config.ts index 2140ab1..8cd5ea6 100644 --- a/packages/validation-policy/src/config.ts +++ b/packages/validation-policy/src/config.ts @@ -7,6 +7,18 @@ export function readOpcoreRepoConfig(repoRoot: string): OpcoreRepoConfig { return parsed === undefined ? defaultRepoConfig() : { validation: readValidationConfig(parsed.validation) }; } +export function parseOpcoreRepoConfig(content: string | undefined): OpcoreRepoConfig { + if (content === undefined) return defaultRepoConfig(); + let parsed: unknown; + try { + parsed = JSON.parse(content); + } catch (error) { + throw new Error(`Invalid Opcore config ${configPath}: ${errorMessage(error)}`); + } + if (!isPlainObject(parsed)) throw new Error(`Invalid Opcore config ${configPath}: expected JSON object`); + return { validation: readValidationConfig(parsed.validation) }; +} + export function defaultRepoConfig(): OpcoreRepoConfig { return { validation: { checks: defaultChecksConfig() } }; } diff --git a/packages/validation-policy/src/factory.ts b/packages/validation-policy/src/factory.ts index 314a5e6..ac1ae36 100644 --- a/packages/validation-policy/src/factory.ts +++ b/packages/validation-policy/src/factory.ts @@ -3,7 +3,7 @@ import type { ValidationCheckDefinition } from "@the-open-engine/opcore-validati import { createValidationCheckRegistry } from "@the-open-engine/opcore-validation"; import { createCloneValidationChecks } from "@the-open-engine/opcore-validation-clone"; import { createDocsValidationChecks, type CreateDocsValidationChecksOptions } from "@the-open-engine/opcore-validation-docs"; -import { createPythonValidationChecks } from "@the-open-engine/opcore-validation-python"; +import { createNodePythonProjectWorkspace, createPythonValidationChecks } from "@the-open-engine/opcore-validation-python"; import { createRustValidationChecks } from "@the-open-engine/opcore-validation-rust"; import { createTypeScriptValidationChecks } from "@the-open-engine/opcore-validation-typescript"; import { readOpcoreRepoConfig } from "./config.js"; @@ -35,6 +35,7 @@ export function createBuiltInValidationChecks( repoRoot?: string ): readonly ValidationCheckDefinition[] { const checksConfig = config?.validation.checks; + const pythonWorkspace = options.pythonWorkspace ?? (repoRoot === undefined ? undefined : createNodePythonProjectWorkspace(repoRoot)); return [ ...createTypeScriptValidationChecks({ fileLength: checksConfig?.typescript?.fileLength, @@ -51,7 +52,10 @@ export function createBuiltInValidationChecks( deadCode: checksConfig?.typescript?.deadCode }), ...createRustValidationChecks(rustValidationOptions(config)), - ...createPythonValidationChecks(repoRoot !== undefined ? { repoRoot } : {}), + ...createPythonValidationChecks({ + ...(pythonWorkspace === undefined ? {} : { nodeWorkspace: pythonWorkspace }), + ...(options.pythonImportAnalyzer === undefined ? {} : { importAnalyzer: options.pythonImportAnalyzer }) + }), ...createDocsValidationChecks(docsValidationOptions(checksConfig?.docs)), ...cloneChecks(checksConfig?.clone, options) ]; @@ -77,11 +81,12 @@ function validationChecksForRepoConfig( const adapters = config.validation.adapters === undefined ? undefined : new Set(config.validation.adapters); const disabled = new Set(configuredDisabled); const defaults = new Set(configuredDefaults); + const filteredContexts = new WeakMap[0]>(); const checks = available .filter((check) => adapters === undefined || adapters.has(check.adapter)) .filter((check) => !disabled.has(check.id)) .map((check) => applyDefaultScopePolicy(check, defaults)) - .map((check) => applyPathPolicy(check, config.validation.pathPolicy)); + .map((check) => applyPathPolicy(check, config.validation.pathPolicy, filteredContexts)); createValidationCheckRegistry(checks); return checks; } @@ -148,13 +153,21 @@ function applyDefaultScopePolicy(check: ValidationCheckDefinition, defaults: Rea function applyPathPolicy( check: ValidationCheckDefinition, - pathPolicy: OpcorePathPolicy | undefined + pathPolicy: OpcorePathPolicy | undefined, + filteredContexts: WeakMap[0]> ): ValidationCheckDefinition { if (pathPolicy === undefined) return check; - const run: ValidationCheckDefinition["run"] = (context) => check.run(withFilteredFileView(context, pathPolicy)); + const filteredContext = (context: Parameters[0]) => { + const existing = filteredContexts.get(context.fileView); + if (existing !== undefined) return existing; + const filtered = withFilteredFileView(context, pathPolicy); + filteredContexts.set(context.fileView, filtered); + return filtered; + }; + const run: ValidationCheckDefinition["run"] = (context) => check.run(filteredContext(context)); if (check.graphRequirements === undefined) return { ...check, run }; const graphRequirements: NonNullable = (context) => - check.graphRequirements?.(withFilteredFileView(context, pathPolicy)) ?? []; + check.graphRequirements?.(filteredContext(context)) ?? []; return { ...check, graphRequirements, run }; } diff --git a/packages/validation-policy/src/path-policy.ts b/packages/validation-policy/src/path-policy.ts index 2703ae6..fa0a8ce 100644 --- a/packages/validation-policy/src/path-policy.ts +++ b/packages/validation-policy/src/path-policy.ts @@ -23,10 +23,19 @@ export function withFilteredFileView paths.filter((path) => pathPolicyIncludes(path, policy))); return filteredVisibleFilesPromise; }; + const listVisibleFileUniverse = async () => { + const universe = await fileView.listVisibleFileUniverse(); + return { ...universe, files: universe.files.filter((path) => pathPolicyIncludes(path, policy)) }; + }; const filteredFileView: ValidationFileView = { ...fileView, scopeFiles: fileView.scopeFiles.filter((path) => pathPolicyIncludes(path, policy)), listVisibleFiles, + listVisibleFileUniverse, + listCompleteVisibleFiles: async () => { + await fileView.listCompleteVisibleFiles(); + return (await listVisibleFileUniverse()).files; + }, overlays: filteredOverlays, readFile: (path, options) => pathPolicyIncludes(path, policy) ? fileView.readFile(path, options) : Promise.resolve(missingRead(path, options?.state ?? "after")), diff --git a/packages/validation-policy/src/types.ts b/packages/validation-policy/src/types.ts index ecf8dea..b9d8f7b 100644 --- a/packages/validation-policy/src/types.ts +++ b/packages/validation-policy/src/types.ts @@ -1,4 +1,5 @@ import type { ValidationCheckDefinition } from "@the-open-engine/opcore-validation"; +import type { PythonImportAnalyzer, PythonProjectWorkspace } from "@the-open-engine/opcore-validation-python"; import type { CloneNativeInvoker } from "@the-open-engine/opcore-validation-clone"; export const configPath = ".opcore/config"; @@ -112,6 +113,8 @@ export interface OpcoreCheckPack { } export interface OpcoreRepoValidationPolicyOptions { + pythonWorkspace?: PythonProjectWorkspace; + pythonImportAnalyzer?: PythonImportAnalyzer; clone?: false | { invoke?: CloneNativeInvoker["invoke"]; }; diff --git a/packages/validation-python/README.md b/packages/validation-python/README.md index 291e4cf..be6cc5b 100644 --- a/packages/validation-python/README.md +++ b/packages/validation-python/README.md @@ -1,3 +1,17 @@ # @the-open-engine/opcore-validation-python Internal Opcore validation adapter package for the gated Python language-support workstream. Public Python enablement remains blocked on installed-artifact receipts and maintainer approval. + +This package owns the canonical, read-only `opcore.python.project-context.v1` resolver. It resolves each `.py`/`.pyi` target to its nearest project boundary, layout/source roots, static manager/build/tool configuration, declared runtime, and exact interpreter/tool provenance. It never installs or synchronizes dependencies, creates environments, executes project imports/setup/package code, writes workspace files, or keeps a persistent cache. + +Consumers must inject a workspace view with read/list/exists/realpath operations. Missing realpath evidence is an ambiguous context, not an assumed non-symlink. Validation uses `ValidationFileView` after-state adapters so written and deleted config/source overlays share one identity; ASP uses host workspace callbacks only. Node status and installed-artifact surfaces use the read-only Node adapter. TOML decisions consume the parsed `smol-toml` AST, static build metadata is retained, and build availability is probed independently. All consumers reuse the resolver result instead of scanning project/config/environment filenames independently. + +Python import grammar and repo-module resolution belong exclusively to Rust graph-core. This package exposes only the structural `PythonImportAnalyzer` injection contract. Import-dependent checks enumerate every visible `.py`/`.pyi` after-state file through `ValidationFileView`, invoke the required analyzer once per file view, and derive target/transitive source closure from its directed edges. Missing, failed, or malformed analysis fails as infrastructure failure; validation never parses Python imports or treats unavailable analysis as an empty graph. + +Precedence is nearest project, explicit per-tool override, compatible active environment, project-local `.venv`/`venv`/`env`, safe installed manager evidence, then PATH. Conflicting same-root manager or target evidence is ambiguous. Missing tools and probe timeout/signal/spawn/exit/malformed-output failures remain typed and visible; unresolved required contexts cannot produce a clean Python check. + +Run `npm run python:resolver-matrix -- --json` for checked fixture evidence covering real POSIX execution and explicitly simulated POSIX/Windows layouts. + +`python.types` selects one authority per canonical project. An explicit `checker: "mypy" | "pyright"` may be used by embedded/test composition only when it does not conflict with discovered config; otherwise exactly one configured checker is required. Tool availability never selects an authority and there is no fallback. Mypy follows documented first-match config precedence and emits strict JSON-lines. Pyright preserves `pyrightconfig.json` precedence over `[tool.pyright]`, recursively materializes repo-confined extends/config/source/stub after-state, and emits complete `--outputjson`. Both execute only selected pre-provisioned provenance inside isolated temporary HOME/XDG/cache/temp state with bounded process-tree cleanup. Protocol, version, summary, path, range, exit, and stderr contradictions fail closed. Every attempt returns source-free `pythonCapabilityRuns` evidence keyed by project, context, and after-state manifest. + +Run `OPCORE_REAL_PYRIGHT=/path/to/pyright npm run python:pyright-authority-proof` with Pyright 1.1.411 for real-tool overlay/materialized equivalence, exit-3, packed-install, portability, immutability, and cleanup evidence. diff --git a/packages/validation-python/package.json b/packages/validation-python/package.json index 3c7777b..0341f8e 100644 --- a/packages/validation-python/package.json +++ b/packages/validation-python/package.json @@ -1,6 +1,6 @@ { "name": "@the-open-engine/opcore-validation-python", - "version": "0.2.0", + "version": "0.2.1", "description": "Internal Opcore Python validation adapter for gated language-support work.", "type": "module", "license": "MIT", @@ -24,8 +24,10 @@ "developer-tools" ], "dependencies": { - "@the-open-engine/opcore-contracts": "0.2.0", - "@the-open-engine/opcore-validation": "0.2.0" + "@the-open-engine/opcore-contracts": "0.2.1", + "@the-open-engine/opcore-validation": "0.2.1", + "jsonc-parser": "3.3.1", + "smol-toml": "1.6.0" }, "engines": { "node": ">=22" diff --git a/packages/validation-python/src/compiler-protocol.ts b/packages/validation-python/src/compiler-protocol.ts new file mode 100644 index 0000000..a8f19ec --- /dev/null +++ b/packages/validation-python/src/compiler-protocol.ts @@ -0,0 +1,227 @@ +import type { PythonInterpreterProvenance, ValidationDiagnosticToolProvenance } from "@the-open-engine/opcore-contracts"; +import { hasExactProtocolKeys, hasOnlyProtocolKeys, isProtocolRecord } from "./protocol-validation.js"; +import type { PythonMaterializedSourceFile } from "./source-files.js"; + +export const PYTHON_COMPILE_PROTOCOL = "opcore.python.compile.v1"; + +export const pythonCompileScript = String.raw` +import json +import platform +import sys + +PROTOCOL = "opcore.python.compile.v1" + +def positive(value): + return value if isinstance(value, int) and not isinstance(value, bool) and value > 0 else None + +def compiler_error(error, source): + if "\x00" in source: + kind = "null_byte" + message = "source code string cannot contain null bytes" + elif isinstance(error, TabError): + kind = "tab_error" + message = error.msg + elif isinstance(error, IndentationError): + kind = "indentation_error" + message = error.msg + elif isinstance(error, SyntaxError): + kind = "syntax_error" + message = error.msg + elif isinstance(error, RecursionError): + kind = "recursion_error" + message = str(error) or "maximum recursion depth exceeded during compilation" + else: + kind = "overflow_error" + message = str(error) or "compiler overflow" + result = {"kind": kind, "message": message} + for source_name, output_name in ( + ("lineno", "line"), + ("offset", "column"), + ("end_lineno", "endLine"), + ("end_offset", "endColumn"), + ): + value = positive(getattr(error, source_name, None)) + if value is not None: + result[output_name] = value + return result + +request = json.load(sys.stdin) +if not isinstance(request, dict) or request.get("protocol") != PROTOCOL or not isinstance(request.get("files"), list): + raise ValueError("invalid compiler request") + +results = [] +for file in request["files"]: + if not isinstance(file, dict) or set(file) != {"path", "content"}: + raise ValueError("invalid compiler file request") + path = file["path"] + source = file["content"] + if not isinstance(path, str) or not isinstance(source, str): + raise ValueError("invalid compiler file values") + try: + compile(source, path, "exec", dont_inherit=True) + results.append({"path": path, "status": "passed"}) + except (SyntaxError, ValueError, RecursionError, OverflowError) as error: + if isinstance(error, ValueError) and "\x00" not in source: + raise + results.append({"path": path, "status": "finding", "error": compiler_error(error, source)}) + +print(json.dumps({ + "protocol": PROTOCOL, + "interpreter": {"executable": sys.executable, "version": platform.python_version()}, + "results": results, +}, separators=(",", ":"), sort_keys=True)) +`; + +export type PythonCompilerErrorKind = + | "syntax_error" + | "indentation_error" + | "tab_error" + | "null_byte" + | "recursion_error" + | "overflow_error"; + +export interface PythonCompilerFinding { + path: string; + kind: PythonCompilerErrorKind; + message: string; + line?: number; + column?: number; + endLine?: number; + endColumn?: number; +} + +export type PythonCompilerProtocolResult = + | { status: "parsed"; findings: readonly PythonCompilerFinding[] } + | { status: "malformed"; message: string }; + +export function compilerRequest(files: readonly PythonMaterializedSourceFile[]): string { + return JSON.stringify({ + protocol: PYTHON_COMPILE_PROTOCOL, + files: files.map((file) => ({ path: file.path, content: file.content })) + }); +} + +export function parseCompilerResponse( + stdout: string, + files: readonly PythonMaterializedSourceFile[], + interpreter: PythonInterpreterProvenance +): PythonCompilerProtocolResult { + const parsed = parseJsonObject(stdout); + if (parsed === undefined || !hasExactProtocolKeys(parsed, ["protocol", "interpreter", "results"])) { + return malformed("Python compiler returned malformed protocol output"); + } + if (parsed.protocol !== PYTHON_COMPILE_PROTOCOL || !validInterpreter(parsed.interpreter, interpreter)) { + return malformed("Python compiler response provenance does not match the resolved interpreter"); + } + if (!Array.isArray(parsed.results) || parsed.results.length !== files.length) { + return malformed("Python compiler response must contain exactly one result per requested file"); + } + const findings: PythonCompilerFinding[] = []; + for (const [index, expected] of files.entries()) { + const result = parseFileResult(parsed.results[index], expected.path); + if (result.status === "malformed") return result; + if (result.finding !== undefined) findings.push(result.finding); + } + return { status: "parsed", findings }; +} + +export function compilerToolProvenance(interpreter: PythonInterpreterProvenance): ValidationDiagnosticToolProvenance { + return { + name: "python", + command: interpreter.argv.join(" "), + version: interpreter.version, + source: interpreter.source, + cwd: interpreter.cwd + }; +} + +function parseFileResult( + value: unknown, + expectedPath: string +): { status: "parsed"; finding?: PythonCompilerFinding } | { status: "malformed"; message: string } { + if (!isProtocolRecord(value) || value.path !== expectedPath) return malformed(`Python compiler omitted or reordered ${expectedPath}`); + if (value.status === "passed" && hasExactProtocolKeys(value, ["path", "status"])) return { status: "parsed" }; + if (value.status !== "finding" || !hasExactProtocolKeys(value, ["path", "status", "error"])) { + return malformed(`Python compiler returned an invalid result for ${expectedPath}`); + } + const error = parseCompilerError(value.error); + if (error === undefined) return malformed(`Python compiler returned an invalid diagnostic for ${expectedPath}`); + return { status: "parsed", finding: { path: expectedPath, ...error } }; +} + +function parseCompilerError(value: unknown): Omit | undefined { + if (!isProtocolRecord(value) || !hasOnlyProtocolKeys(value, ["kind", "message", "line", "column", "endLine", "endColumn"])) return undefined; + if (!isCompilerErrorKind(value.kind)) return undefined; + if (typeof value.message !== "string" || value.message.length === 0) return undefined; + const location = parseCompilerLocation(value); + if (location === undefined) return undefined; + return { kind: value.kind, message: value.message, ...location }; +} + +function parseCompilerLocation(value: Record): Omit | undefined { + const line = optionalPositiveInteger(value.line); + const column = optionalPositiveInteger(value.column); + const endLine = optionalPositiveInteger(value.endLine); + const endColumn = optionalPositiveInteger(value.endColumn); + if (![line, column, endLine, endColumn].every((field) => field.valid)) return undefined; + const location = compactCompilerLocation(line.value, column.value, endLine.value, endColumn.value); + return validCompilerLocationShape(location) ? location : undefined; +} + +function compactCompilerLocation( + line: number | undefined, + column: number | undefined, + endLine: number | undefined, + endColumn: number | undefined +): Omit { + return { + ...(line === undefined ? {} : { line }), + ...(column === undefined ? {} : { column }), + ...(endLine === undefined ? {} : { endLine }), + ...(endColumn === undefined ? {} : { endColumn }) + }; +} + +function validCompilerLocationShape(location: Omit): boolean { + if (location.column !== undefined && location.line === undefined) return false; + if (location.endLine !== undefined && location.line === undefined) return false; + if (location.endColumn !== undefined && location.endLine === undefined) return false; + return true; +} + +function validInterpreter(value: unknown, expected: PythonInterpreterProvenance): boolean { + return isProtocolRecord(value) && hasExactProtocolKeys(value, ["executable", "version"]) && + value.executable === expected.executable && value.version === expected.version; +} + +function parseJsonObject(stdout: string): Record | undefined { + try { + const value = JSON.parse(stdout.trim()); + return isProtocolRecord(value) ? value : undefined; + } catch { + return undefined; + } +} + +function malformed(message: string): { status: "malformed"; message: string } { + return { status: "malformed", message }; +} + +const compilerErrorKinds: readonly PythonCompilerErrorKind[] = [ + "syntax_error", + "indentation_error", + "tab_error", + "null_byte", + "recursion_error", + "overflow_error" +]; + +function optionalPositiveInteger(value: unknown): { valid: boolean; value?: number } { + if (value === undefined) return { valid: true }; + if (typeof value === "number" && Number.isInteger(value) && value > 0) return { valid: true, value }; + return { valid: false }; +} + +function isCompilerErrorKind(value: unknown): value is PythonCompilerErrorKind { + return typeof value === "string" && compilerErrorKinds.some((kind) => kind === value); +} diff --git a/packages/validation-python/src/dead-code-check.ts b/packages/validation-python/src/dead-code-check.ts index da2d478..fb976ea 100644 --- a/packages/validation-python/src/dead-code-check.ts +++ b/packages/validation-python/src/dead-code-check.ts @@ -10,10 +10,10 @@ import { } from "@the-open-engine/opcore-validation"; import { PYTHON_DEAD_CODE_CHECK_ID } from "./check-ids.js"; import { pythonCheckAdapter, pythonCheckOwner, supportedPythonValidationScopes } from "./check-constants.js"; -import { deadCodeGraphRequirements } from "./graph-requirements.js"; -import { materializePythonSources } from "./source-files.js"; +import { createDeadCodeGraphRequirements } from "./graph-requirements.js"; +import type { PythonSourceRootResolver } from "./source-files.js"; -export function createDeadCodeCheck(): ValidationCheckDefinition { +export function createDeadCodeCheck(resolveRoots: PythonSourceRootResolver): ValidationCheckDefinition { return { id: PYTHON_DEAD_CODE_CHECK_ID, owner: pythonCheckOwner, @@ -21,15 +21,15 @@ export function createDeadCodeCheck(): ValidationCheckDefinition { defaultSeverity: "warning", supportedScopes: supportedPythonValidationScopes, requiresGraph: true, - graphRequirements: deadCodeGraphRequirements, + graphRequirements: createDeadCodeGraphRequirements(resolveRoots), run: async (context) => { - const sourceSet = await materializePythonSources(context); + const rootPaths = await resolveRoots(context); const [symbolFacts, calls, fileNodes] = await Promise.all([ context.graph.facts({ kind: "symbols" }), context.graph.calls(), - context.graph.fileNodes(sourceSet.rootPaths) + context.graph.fileNodes(rootPaths) ]); - const scopedPaths = new Set(sourceSet.rootPaths); + const scopedPaths = new Set(rootPaths); const scopedSymbols = symbolFacts.nodes.filter((node) => isScopedSymbol(node, scopedPaths)); const unsupportedFileExports = fileNodes.flatMap(graphFactUnsupportedFileExportMetadata); if (!scopedSymbols.some(graphFactHasExportMetadata) && unsupportedFileExports.length === 0) { diff --git a/packages/validation-python/src/diagnostics.ts b/packages/validation-python/src/diagnostics.ts index b2e19d3..c9edd02 100644 --- a/packages/validation-python/src/diagnostics.ts +++ b/packages/validation-python/src/diagnostics.ts @@ -1,4 +1,8 @@ -import type { ValidationDiagnostic, ValidationDiagnosticCategory } from "@the-open-engine/opcore-contracts"; +import type { + ValidationDiagnostic, + ValidationDiagnosticCategory, + ValidationDiagnosticToolProvenance +} from "@the-open-engine/opcore-contracts"; export interface PythonDiagnosticArgs { category: ValidationDiagnosticCategory; @@ -6,15 +10,25 @@ export interface PythonDiagnosticArgs { path?: string; code: string; message: string; + line?: number; + column?: number; + endLine?: number; + endColumn?: number; + tool?: ValidationDiagnosticToolProvenance; } export function diagnostic(args: PythonDiagnosticArgs): ValidationDiagnostic { return { category: args.category, severity: args.severity ?? "error", - path: args.path, code: args.code, - message: args.message + message: args.message, + ...(args.path === undefined ? {} : { path: args.path }), + ...(args.line === undefined ? {} : { line: args.line }), + ...(args.column === undefined ? {} : { column: args.column }), + ...(args.endLine === undefined ? {} : { endLine: args.endLine }), + ...(args.endColumn === undefined ? {} : { endColumn: args.endColumn }), + ...(args.tool === undefined ? {} : { tool: args.tool }) }; } @@ -22,6 +36,10 @@ export function sortDiagnostics(diagnostics: readonly ValidationDiagnostic[]): r return [...diagnostics].sort( (left, right) => (left.path ?? "").localeCompare(right.path ?? "") || + (left.line ?? 0) - (right.line ?? 0) || + (left.column ?? 0) - (right.column ?? 0) || + (left.endLine ?? 0) - (right.endLine ?? 0) || + (left.endColumn ?? 0) - (right.endColumn ?? 0) || (left.code ?? "").localeCompare(right.code ?? "") || left.message.localeCompare(right.message) ); diff --git a/packages/validation-python/src/environment-resolution.ts b/packages/validation-python/src/environment-resolution.ts new file mode 100644 index 0000000..ef6c181 --- /dev/null +++ b/packages/validation-python/src/environment-resolution.ts @@ -0,0 +1,836 @@ +import type { + PythonInterpreterProvenance, + PythonProjectBuildSystem, + PythonProjectContextReason, + PythonProjectExecutableSource, + PythonProjectManagerEvidence, + PythonProjectTarget, + PythonProjectToolKind, + PythonProjectToolProvenance +} from "@the-open-engine/opcore-contracts"; +import { existsSync } from "node:fs"; +import { delimiter, dirname, isAbsolute, resolve } from "node:path"; +import { runTool, type PythonToolRunOptions, type PythonToolRunResult } from "./process.js"; +import type { PythonProjectWorkspace } from "./project-workspace.js"; +import { isSupportedPythonVersionConstraint, pythonVersionSatisfiesConstraint } from "./version-constraint.js"; + +export interface PythonProjectProcessProbe { + run(command: string, args: readonly string[], options: PythonToolRunOptions): PythonToolRunResult | Promise; + resolveExecutable?(command: string, options: { env: Readonly>; platform: string }): string | undefined; +} + +export interface PythonProjectEnvironmentOptions { + repoRoot: string; + projectRoot: string; + workspace: PythonProjectWorkspace; + platform?: string; + architecture?: string; + env?: Readonly>; + interpreterArgv?: readonly string[]; + toolArgv?: Partial>; + target: PythonProjectTarget; + managers: readonly PythonProjectManagerEvidence[]; + toolConfigs: Readonly>; + buildSystem?: PythonProjectBuildSystem; + processProbe?: PythonProjectProcessProbe; + timeoutMs?: number; +} + +export interface PythonProjectEnvironmentResolution { + interpreter?: PythonInterpreterProvenance; + tools: readonly PythonProjectToolProvenance[]; + reasons: readonly PythonProjectContextReason[]; + fingerprintInput: unknown; +} + +interface ExecutableCandidate { + argv: readonly string[]; + source: PythonProjectExecutableSource; +} + +interface ManagerExecutableCandidates { + interpreters: readonly ExecutableCandidate[]; + tools: Readonly>>; +} + +const toolConfigOptions: Readonly, readonly string[]>> = { + mypy: ["--config", "--config-file"], + pyright: ["--project", "-p"], + ruff: ["--config"], + pytest: ["--config-file", "-c"] +}; + +const interpreterProbeScript = [ + "import json, platform, sys, sysconfig", + "print(json.dumps({'protocol':'opcore.python.project-context.interpreter.v1','executable':sys.executable,'version':platform.python_version(),'implementation':platform.python_implementation(),'platform':sys.platform,'architecture':platform.machine(),'abi':getattr(sys.implementation,'cache_tag',None),'soabi':sysconfig.get_config_var('SOABI')}))" +].join("\n"); + +const buildProbeScript = [ + "import importlib.metadata, importlib.util, json", + "available = importlib.util.find_spec('build') is not None", + "version = None", + "if available:", + " try:", + " version = importlib.metadata.version('build')", + " except importlib.metadata.PackageNotFoundError:", + " pass", + "print(json.dumps({'protocol':'opcore.python.project-context.build.v1','available':available,'version':version}))" +].join("\n"); + +export async function resolvePythonProjectEnvironment( + options: PythonProjectEnvironmentOptions +): Promise { + const env = effectiveProcessEnvironment(options.env); + const effectiveOptions = { ...options, env }; + const platform = options.platform ?? process.platform; + if (!["linux", "darwin", "win32"].includes(platform)) { + return { + tools: [], + reasons: [{ code: "unsupported_platform", message: `Unsupported Python project platform: ${platform}` }], + fingerprintInput: { + platform, + architecture: options.architecture, + environment: sanitizedFingerprintEnvironment(env, options.projectRoot), + explicit: { interpreterArgv: options.interpreterArgv, toolArgv: options.toolArgv } + } + }; + } + const processProbe = options.processProbe ?? { run: runTool, resolveExecutable: resolvePathExecutable }; + const reasons: PythonProjectContextReason[] = []; + const managerCandidates = await resolveManagerExecutableCandidates(effectiveOptions, reasons); + const interpreter = await resolveInterpreter(effectiveOptions, processProbe, env, managerCandidates, reasons); + const tools = await resolveTools(effectiveOptions, processProbe, env, interpreter, managerCandidates, reasons); + return { + ...(interpreter === undefined ? {} : { interpreter }), + tools, + reasons, + fingerprintInput: { + platform: options.platform ?? process.platform, + architecture: options.architecture, + environment: sanitizedFingerprintEnvironment(env, options.projectRoot), + explicit: { interpreterArgv: options.interpreterArgv, toolArgv: options.toolArgv }, + interpreter, + tools, + failures: reasons.map((reason) => ({ code: reason.code, tool: reason.tool })) + } + }; +} + +async function resolveInterpreter( + options: PythonProjectEnvironmentOptions, + processProbe: PythonProjectProcessProbe, + env: Record, + managerCandidates: ManagerExecutableCandidates, + reasons: PythonProjectContextReason[] +): Promise { + const candidates = interpreterCandidates(options, managerCandidates, reasons); + if (options.interpreterArgv !== undefined && candidates.length === 0) return undefined; + let pathFailure: PythonProjectContextReason | undefined; + for (const candidate of candidates) { + if (!(await candidateAvailable(options.workspace, candidate.argv[0]))) continue; + const command = candidate.argv[0]; + const prefix = candidate.argv.slice(1); + const args = [...prefix, "-I", "-B", "-c", interpreterProbeScript]; + const result = await processProbe.run(command, args, { + cwd: projectCwd(options), env, timeoutMs: options.timeoutMs ?? 10000 + }); + if (!result.ok) { + const failure = probeFailureReason(result, "python"); + if (candidate.source !== "path") { + reasons.push(failure); + return undefined; + } + pathFailure = failure; + continue; + } + const metadata = parseInterpreterMetadata(result.stdout); + if (metadata === undefined) { + const failure = { + code: "malformed_probe_output" as const, + tool: "python", + message: "Python interpreter probe returned malformed metadata" + }; + if (candidate.source !== "path") { + reasons.push(failure); + return undefined; + } + pathFailure = failure; + continue; + } + const runnable = runnableInterpreterArgv(candidate, metadata.executable, processProbe, env, options.platform ?? process.platform); + const provenance: PythonInterpreterProvenance = { + executable: runnable[0], + argv: runnable, + cwd: projectCwd(options), + source: candidate.source, + version: metadata.version, + implementation: metadata.implementation, + platform: metadata.platform, + architecture: metadata.architecture, + abi: metadata.abi, + soabi: metadata.soabi + }; + const unsupported = unsupportedTargetConstraint(options.target); + if (unsupported !== undefined) { + reasons.push({ code: "unsupported_target", tool: "python", message: unsupported }); + return provenance; + } + const incompatibility = targetIncompatibility(provenance, options.target); + if (incompatibility !== undefined) { + reasons.push({ code: "incompatible_interpreter", tool: "python", message: incompatibility }); + return provenance; + } + return provenance; + } + if (pathFailure !== undefined) reasons.push(pathFailure); + reasons.push({ code: "interpreter_unavailable", tool: "python", message: `No Python interpreter is available for ${options.projectRoot}` }); + return undefined; +} + +function unsupportedTargetConstraint(target: PythonProjectTarget): string | undefined { + for (const constraint of [target.requiresPython, target.version]) { + if (constraint === undefined) continue; + if (!isSupportedPythonVersionConstraint(constraint)) { + return `Unsupported Python target constraint: ${constraint}`; + } + } + return undefined; +} + +async function resolveTools( + options: PythonProjectEnvironmentOptions, + processProbe: PythonProjectProcessProbe, + env: Record, + interpreter: PythonInterpreterProvenance | undefined, + managerCandidates: ManagerExecutableCandidates, + reasons: PythonProjectContextReason[] +): Promise { + const toolKinds: PythonProjectToolKind[] = ["mypy", "pyright", "ruff", "pytest"]; + const tools: PythonProjectToolProvenance[] = []; + for (const tool of toolKinds) { + const configFile = tool === "mypy" || tool === "pyright" || tool === "ruff" || tool === "pytest" + ? options.toolConfigs[tool] + : undefined; + const candidates = toolCandidates(tool, options, managerCandidates, reasons); + if (options.toolArgv?.[tool] !== undefined && candidates.length === 0) { + const override = options.toolArgv[tool]; + if (override === undefined) throw new Error(`Python ${tool} argv override is required`); + tools.push({ + tool, + available: false, + executable: override[0], + argv: [...override], + cwd: projectCwd(options), + source: "explicit_override", + ...(configFile === undefined ? {} : { configFile }) + }); + continue; + } + let resolvedTool: PythonProjectToolProvenance | undefined; + let failure: PythonProjectContextReason | undefined; + for (const candidate of candidates) { + if (!(await candidateAvailable(options.workspace, candidate.argv[0]))) continue; + const command = candidate.argv[0]; + const prefix = candidate.argv.slice(1); + const result = await processProbe.run(command, [...prefix, "--version"], { + cwd: projectCwd(options), env, timeoutMs: options.timeoutMs ?? 10000 + }); + if (!result.ok) { + failure = probeFailureReason(result, tool); + if (candidate.source !== "path") break; + continue; + } + const version = firstVersion(result.stdout, result.stderr); + if (version === undefined) { + failure = { + code: "malformed_probe_output", + tool, + message: `${tool} version probe returned malformed metadata` + }; + if (candidate.source !== "path") break; + continue; + } + const executable = candidate.source === "path" + ? processProbe.resolveExecutable?.(command, { env, platform: options.platform ?? process.platform }) ?? command + : command; + resolvedTool = { + tool, + available: true, + executable, + argv: [executable, ...prefix], + cwd: projectCwd(options), + source: candidate.source, + version, + ...(configFile === undefined ? {} : { configFile }) + }; + break; + } + if (resolvedTool !== undefined) { + tools.push(resolvedTool); + continue; + } + const fallback = candidates[0] ?? { argv: [tool], source: "path" as const }; + tools.push({ + tool, + available: false, + executable: fallback.argv[0], + argv: fallback.argv, + cwd: projectCwd(options), + source: fallback.source, + ...(configFile === undefined ? {} : { configFile }) + }); + reasons.push(failure ?? { code: "tool_unavailable", tool, message: `${tool} is unavailable for ${options.projectRoot}` }); + } + if (options.buildSystem !== undefined) { + tools.push(await resolveBuildTool(options, processProbe, env, interpreter, reasons)); + } + return tools.sort((left, right) => left.tool.localeCompare(right.tool)); +} + +function runnableInterpreterArgv( + candidate: ExecutableCandidate, + probedExecutable: string, + processProbe: PythonProjectProcessProbe, + env: Readonly>, + platform: string +): readonly string[] { + const [command, ...args] = candidate.argv; + if (args.length === 0) return [probedExecutable]; + const launcher = processProbe.resolveExecutable?.(command, { env, platform }) ?? + (isAbsolute(command) || /^[A-Za-z]:[\\/]/u.test(command) ? command : undefined); + return launcher === undefined ? [probedExecutable] : [launcher, ...args]; +} + +async function resolveBuildTool( + options: PythonProjectEnvironmentOptions, + processProbe: PythonProjectProcessProbe, + env: Record, + interpreter: PythonInterpreterProvenance | undefined, + reasons: PythonProjectContextReason[] +): Promise { + const buildSystem = options.buildSystem; + if (buildSystem === undefined) throw new Error("Python build tool resolution requires build-system metadata"); + const fallbackExecutable = interpreter?.executable ?? "python"; + const fallbackArgv = interpreter === undefined ? [fallbackExecutable, "-m", "build"] : [...interpreter.argv, "-m", "build"]; + if (interpreter === undefined) { + reasons.push({ code: "tool_unavailable", tool: "build", message: `build is unavailable without an interpreter for ${options.projectRoot}` }); + return { + tool: "build", + available: false, + executable: fallbackExecutable, + argv: fallbackArgv, + cwd: projectCwd(options), + source: "path", + configFile: buildSystem.configFile + }; + } + const result = await processProbe.run(interpreter.executable, [...interpreter.argv.slice(1), "-I", "-B", "-c", buildProbeScript], { + cwd: interpreter.cwd, + env, + timeoutMs: options.timeoutMs ?? 10000 + }); + if (!result.ok) { + reasons.push(probeFailureReason(result, "build")); + return { + tool: "build", + available: false, + executable: interpreter.executable, + argv: [...interpreter.argv, "-m", "build"], + cwd: interpreter.cwd, + source: interpreter.source, + configFile: buildSystem.configFile + }; + } + const metadata = parseBuildMetadata(result.stdout); + if (metadata === undefined) { + reasons.push({ code: "malformed_probe_output", tool: "build", message: "Python build probe returned malformed metadata" }); + } else if (!metadata.available) { + reasons.push({ code: "tool_unavailable", tool: "build", message: `build is unavailable for ${options.projectRoot}` }); + } + return { + tool: "build", + available: metadata?.available === true, + executable: interpreter.executable, + argv: [...interpreter.argv, "-m", "build"], + cwd: interpreter.cwd, + source: interpreter.source, + ...(metadata?.version === undefined ? {} : { version: metadata.version }), + configFile: buildSystem.configFile + }; +} + +function parseBuildMetadata(stdout: string): { available: boolean; version?: string } | undefined { + let value: unknown; + try { + value = JSON.parse(stdout.trim()); + } catch { + return undefined; + } + if (!value || typeof value !== "object") return undefined; + const record = value as Record; + if (record.protocol !== "opcore.python.project-context.build.v1" || typeof record.available !== "boolean") return undefined; + if (record.version !== null && record.version !== undefined && (typeof record.version !== "string" || record.version.length === 0)) { + return undefined; + } + if (record.available && typeof record.version !== "string") return undefined; + if (typeof record.version === "string" && !isExactToolVersion(record.version)) return undefined; + return { available: record.available, ...(typeof record.version === "string" ? { version: record.version } : {}) }; +} + +function resolvePathExecutable( + command: string, + options: { env: Readonly>; platform: string } +): string | undefined { + if (isAbsolute(command) || command.includes("/") || command.includes("\\")) return existsSync(command) ? command : undefined; + const path = options.env.PATH; + if (path === undefined) return undefined; + const extensions = options.platform === "win32" + ? (options.env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";").filter(Boolean) + : [""]; + for (const directory of path.split(delimiter).filter(Boolean)) { + for (const extension of extensions) { + const candidate = resolve(directory, options.platform === "win32" ? `${command}${extension}` : command); + if (existsSync(candidate)) return candidate; + } + } + return undefined; +} + +function interpreterCandidates( + options: PythonProjectEnvironmentOptions, + managerCandidates: ManagerExecutableCandidates, + reasons: PythonProjectContextReason[] +): readonly ExecutableCandidate[] { + if (options.interpreterArgv !== undefined) { + const override = validateOverride(options.interpreterArgv, "interpreter", reasons, options); + return override === undefined ? [] : [override]; + } + const candidates: ExecutableCandidate[] = []; + const active = options.env?.VIRTUAL_ENV; + if (active !== undefined && activeEnvironmentCompatible(active, options)) { + candidates.push(...environmentInterpreterExecutables(active, options.platform).map((executable) => ({ + argv: [executable], + source: "active_environment" as const + }))); + } + for (const directory of [".venv", "venv", "env"]) { + candidates.push(...projectEnvironmentInterpreterExecutables(options, directory).map((executable) => ({ + argv: [executable], + source: "project_local_environment" as const + }))); + } + candidates.push(...managerCandidates.interpreters); + candidates.push({ argv: [options.platform === "win32" ? "python" : "python3"], source: "path" }); + if (options.platform !== "win32") candidates.push({ argv: ["python"], source: "path" }); + return candidates; +} + +function toolCandidates( + tool: PythonProjectToolKind, + options: PythonProjectEnvironmentOptions, + managerCandidates: ManagerExecutableCandidates, + reasons: PythonProjectContextReason[] +): readonly ExecutableCandidate[] { + const override = options.toolArgv?.[tool]; + if (override !== undefined) { + const candidate = validateOverride(override, tool, reasons, options); + return candidate === undefined ? [] : [candidate]; + } + const candidates: ExecutableCandidate[] = []; + const active = options.env?.VIRTUAL_ENV; + if (active !== undefined && activeEnvironmentCompatible(active, options)) { + candidates.push({ argv: [environmentExecutable(active, tool, options.platform)], source: "active_environment" }); + } + for (const directory of [".venv", "venv", "env"]) { + candidates.push({ argv: [projectEnvironmentExecutable(options, directory, tool)], source: "project_local_environment" }); + } + if (tool === "pyright") { + candidates.push({ argv: [absoluteProjectPath(options, "node_modules/.bin/pyright")], source: "project_local_environment" }); + } + candidates.push(...managerCandidates.tools[tool] ?? []); + candidates.push({ argv: [tool], source: "path" }); + return candidates; +} + +async function resolveManagerExecutableCandidates( + options: PythonProjectEnvironmentOptions, + reasons: PythonProjectContextReason[] +): Promise { + const uv = options.env?.UV_PROJECT_ENVIRONMENT; + if (uv !== undefined && activeEnvironmentCompatible(uv, options)) { + return environmentCandidates(uv, options.platform); + } + if (options.managers.some((manager) => manager.kind === "pdm")) { + const pdmPath = options.projectRoot === "." ? ".pdm-python" : `${options.projectRoot}/.pdm-python`; + if (!(await options.workspace.exists(pdmPath))) return emptyManagerCandidates(); + const resolved = await options.workspace.realpath(pdmPath); + if (resolved.unavailable) { + reasons.push({ + code: "ambiguous_path", + path: pdmPath, + message: `PDM interpreter evidence realpath is unavailable: ${pdmPath}` + }); + return emptyManagerCandidates(); + } + if (resolved.symlink || resolved.path !== pdmPath) { + reasons.push({ + code: "symlink_refused", + path: pdmPath, + message: `Symlinked PDM interpreter evidence is ambiguous: ${pdmPath}` + }); + return emptyManagerCandidates(); + } + const content = (await options.workspace.read(pdmPath))?.trim(); + if (content !== undefined && content.length > 0 && !content.includes("\0")) { + return interpreterPathCandidates(content, options.platform); + } + } + return emptyManagerCandidates(); +} + +function environmentCandidates(environment: string, platform: string | undefined): ManagerExecutableCandidates { + return { + interpreters: environmentInterpreterExecutables(environment, platform).map((executable) => ({ + argv: [executable], + source: "manager_environment" as const + })), + tools: managerToolKinds().reduce>>((tools, tool) => { + tools[tool] = [{ argv: [environmentExecutable(environment, tool, platform)], source: "manager_environment" }]; + return tools; + }, {}) + }; +} + +function interpreterPathCandidates(interpreter: string, platform: string | undefined): ManagerExecutableCandidates { + return { + interpreters: [{ argv: [interpreter], source: "manager_environment" }], + tools: managerToolKinds().reduce>>((tools, tool) => { + tools[tool] = [{ argv: [toolBesideInterpreter(interpreter, tool, platform)], source: "manager_environment" }]; + return tools; + }, {}) + }; +} + +function emptyManagerCandidates(): ManagerExecutableCandidates { + return { interpreters: [], tools: {} }; +} + +function managerToolKinds(): readonly Exclude[] { + return ["mypy", "pyright", "ruff", "pytest"]; +} + +function toolBesideInterpreter(interpreter: string, tool: string, platform: string | undefined): string { + const normalized = interpreter.replaceAll("\\", "/"); + const directory = normalized.slice(0, normalized.lastIndexOf("/")); + if (platform === "win32") { + const scriptsDirectory = directory.toLowerCase().endsWith("/scripts") ? directory : `${directory}/Scripts`; + return `${scriptsDirectory}/${tool}.exe`.replaceAll("/", "\\"); + } + return `${directory}/${tool}`; +} + +function parseInterpreterMetadata(stdout: string): { + executable: string; version: string; implementation: string; platform: string; architecture: string; abi: string; soabi: string; +} | undefined { + let value: unknown; + try { + value = JSON.parse(stdout.trim()); + } catch { + return undefined; + } + if (!value || typeof value !== "object") return undefined; + const record = value as Record; + if (record.protocol !== "opcore.python.project-context.interpreter.v1") return undefined; + for (const key of ["executable", "version", "implementation", "platform", "architecture"] as const) { + if (typeof record[key] !== "string" || record[key].length === 0) return undefined; + } + if (!isAbsolute(record.executable as string) && !/^[A-Za-z]:[\\/]/u.test(record.executable as string)) return undefined; + if (!isExactPythonInterpreterVersion(record.version as string)) return undefined; + for (const key of ["abi", "soabi"] as const) { + if (typeof record[key] !== "string" || record[key].length === 0) return undefined; + } + return { + executable: record.executable as string, + version: record.version as string, + implementation: record.implementation as string, + platform: record.platform as string, + architecture: record.architecture as string, + abi: record.abi as string, + soabi: record.soabi as string + }; +} + +function targetIncompatibility(interpreter: PythonInterpreterProvenance, target: PythonProjectTarget): string | undefined { + if (target.implementation !== undefined && interpreter.implementation !== undefined && + target.implementation.toLowerCase() !== interpreter.implementation.toLowerCase()) { + return `Selected ${interpreter.implementation} does not satisfy target implementation ${target.implementation}`; + } + if (target.platform !== undefined && interpreter.platform !== undefined && !platformMatches(interpreter.platform, target.platform)) { + return `Selected platform ${interpreter.platform} does not satisfy target platform ${target.platform}`; + } + if (target.requiresPython !== undefined && interpreter.version !== undefined && + !pythonVersionSatisfiesConstraint(interpreter.version, target.requiresPython)) { + return `Selected Python ${interpreter.version} does not satisfy ${target.requiresPython}`; + } + if (target.version !== undefined && interpreter.version !== undefined && + !pythonVersionMatchesTarget(interpreter.version, target.version)) { + return `Selected Python ${interpreter.version} does not satisfy ${target.version}`; + } + return undefined; +} + +function pythonVersionMatchesTarget(version: string, target: string): boolean { + const normalized = target.trim(); + const constraint = /^\d+\.\d+$/u.test(normalized) ? `==${normalized}.*` : normalized; + return pythonVersionSatisfiesConstraint(version, constraint); +} + +function isExactPythonInterpreterVersion(value: string): boolean { + return /^\d+\.\d+\.\d+(?:(?:a|b|rc)\d+)?(?:\+[A-Za-z0-9]+(?:[._-][A-Za-z0-9]+)*)?$/u.test(value); +} + +function isExactToolVersion(value: string): boolean { + return /^\d+(?:\.\d+)+(?:[-+._A-Za-z0-9]*)?$/u.test(value); +} + +function platformMatches(actual: string, expected: string): boolean { + const normalized = expected.toLowerCase(); + return actual.toLowerCase() === normalized || (normalized === "linux" && actual.startsWith("linux")) || + (normalized === "windows" && actual === "win32") || (normalized === "darwin" && actual === "darwin"); +} + +function probeFailureReason(result: PythonToolRunResult, tool: string): PythonProjectContextReason { + if (result.termination === "timeout") return { code: "probe_timeout", tool, message: `${tool} probe timed out` }; + if (result.termination === "signal") return { code: "probe_signal", tool, message: `${tool} probe terminated with signal ${result.signal}` }; + if (result.termination === "spawn_error") { + return missingCommand(result.failureMessage) + ? { code: "tool_unavailable", tool, message: `${tool} is unavailable` } + : { code: "probe_spawn_failure", tool, message: `${tool} probe could not spawn` }; + } + return { code: "probe_exit_failure", tool, message: `${tool} probe exited with code ${result.exitCode}` }; +} + +function firstVersion(stdout: string, stderr: string): string | undefined { + const line = `${stdout}\n${stderr}`.split(/\r?\n/u).map((entry) => entry.trim()).find(Boolean); + const version = line === undefined ? undefined : /\b\d+(?:\.\d+)+(?:[-+._A-Za-z0-9]*)?/u.exec(line)?.[0]; + return version; +} + +function validateOverride( + argv: readonly string[], + label: "interpreter" | PythonProjectToolKind, + reasons: PythonProjectContextReason[], + options?: PythonProjectEnvironmentOptions +): ExecutableCandidate | undefined { + if (!Array.isArray(argv) || argv.length === 0 || argv.some((entry) => typeof entry !== "string" || entry.length === 0)) { + throw new Error(`Python ${label} argv override must be a non-empty string array`); + } + if (!safeOverrideArgv(argv, label, options)) { + reasons.push({ + code: "invalid_config", + tool: label === "interpreter" ? "python" : label, + message: `Unsafe Python ${label} argv override was refused without execution` + }); + return undefined; + } + return { argv: [...argv], source: "explicit_override" }; +} + +function activeEnvironmentCompatible(environment: string, options: PythonProjectEnvironmentOptions): boolean { + const caseFold = (value: string): string => options.platform === "win32" ? value.toLowerCase() : value; + const envPath = caseFold(normalizePath(environment)); + const root = caseFold(normalizePath(projectCwd(options))); + if (!envPath.startsWith(`${root}/`)) return false; + const relativeEnvironment = envPath.slice(root.length + 1); + return relativeEnvironment.length > 0 && !relativeEnvironment.includes("/"); +} + +function safeOverrideArgv( + argv: readonly string[], + label: "interpreter" | PythonProjectToolKind, + options?: PythonProjectEnvironmentOptions +): boolean { + const executable = executableBasename(argv[0]); + const prefix = argv.slice(1); + if (label === "interpreter") { + if (!/^python(?:\d+(?:\.\d+)*)?(?:\.exe)?$/u.test(executable)) return false; + return safePythonOptionPrefix(prefix); + } + if (label === "build" || executable !== label && executable !== `${label}.exe`) return false; + return safeToolOptionPrefix(label, prefix) && + (prefix.length === 0 || options !== undefined && safeToolConfigPaths(label, prefix, options)); +} + +function executableBasename(command: string): string { + const normalized = command.replaceAll("\\", "/"); + return normalized.slice(normalized.lastIndexOf("/") + 1).toLowerCase(); +} + +function safePythonOptionPrefix(args: readonly string[]): boolean { + const switches = new Set(["-B", "-E", "-I", "-O", "-OO", "-P", "-S", "-s", "-u", "-v", "-q", "-b", "-bb", "-d", "-x"]); + for (let index = 0; index < args.length; index += 1) { + const argument = args[index]; + if (switches.has(argument)) continue; + if (argument === "-X" && args[index + 1] === "dev") { + index += 1; + continue; + } + if (argument === "-Xdev") continue; + return false; + } + return true; +} + +function safeToolOptionPrefix(tool: Exclude, args: readonly string[]): boolean { + const options = toolConfigOptions[tool]; + for (let index = 0; index < args.length; index += 1) { + const argument = args[index]; + const exact = options.find((option) => argument === option); + if (exact !== undefined) { + const value = args[index + 1]; + if (value === undefined || value.length === 0 || value.startsWith("-")) return false; + index += 1; + continue; + } + if (options.some((option) => argument.startsWith(`${option}=`) && argument.length > option.length + 1)) continue; + return false; + } + return true; +} + +function safeToolConfigPaths( + tool: Exclude, + args: readonly string[], + options: PythonProjectEnvironmentOptions +): boolean { + const configFile = options.toolConfigs[tool]; + if (configFile === undefined) return false; + const expected = canonicalPath(options.repoRoot, configFile, options.platform); + const expectedDirectory = options.platform === "win32" ? windowsDirname(expected) : normalizePath(dirname(expected)); + const optionNames = toolConfigOptions[tool]; + for (let index = 0; index < args.length; index += 1) { + const argument = args[index]; + const exact = optionNames.find((option) => argument === option); + const value = exact === undefined + ? optionNames.map((option) => argument.startsWith(`${option}=`) ? argument.slice(option.length + 1) : undefined) + .find((candidate) => candidate !== undefined) + : args[index += 1]; + if (value === undefined) return false; + const actual = canonicalPath(projectCwd(options), value, options.platform); + if (actual !== expected && (tool !== "pyright" || actual !== expectedDirectory)) return false; + } + return true; +} + + +function canonicalPath(root: string, path: string, platform: string | undefined): string { + const resolved = platform === "win32" ? resolveWindowsPath(root, path) : resolve(root, path); + const normalized = normalizePath(resolved); + return platform === "win32" ? normalized.toLowerCase() : normalized; +} + + +function environmentExecutable(environment: string, tool: string, platform: string | undefined): string { + const suffix = platform === "win32" ? `Scripts/${tool}.exe` : `bin/${tool}`; + return joinAbsolute(environment, suffix, platform); +} + +function environmentInterpreterExecutables(environment: string, platform: string | undefined): readonly string[] { + if (platform === "win32") { + return [ + joinAbsolute(environment, "Scripts/python.exe", platform), + joinAbsolute(environment, "python.exe", platform) + ]; + } + return [joinAbsolute(environment, "bin/python", platform), joinAbsolute(environment, "bin/python3", platform)]; +} + +function projectEnvironmentInterpreterExecutables( + options: PythonProjectEnvironmentOptions, + directory: string +): readonly string[] { + const project = options.projectRoot === "." ? "" : `${options.projectRoot}/`; + const environment = joinAbsolute(options.repoRoot, `${project}${directory}`, options.platform); + return environmentInterpreterExecutables(environment, options.platform); +} + +function projectEnvironmentExecutable(options: PythonProjectEnvironmentOptions, directory: string, tool: string): string { + const suffix = options.platform === "win32" ? `${directory}/Scripts/${tool}.exe` : `${directory}/bin/${tool}`; + return absoluteProjectPath(options, suffix); +} + +function absoluteProjectPath(options: PythonProjectEnvironmentOptions, suffix: string): string { + const project = options.projectRoot === "." ? "" : `${options.projectRoot}/`; + return joinAbsolute(options.repoRoot, `${project}${suffix}`, options.platform); +} + +function projectCwd(options: PythonProjectEnvironmentOptions): string { + return options.projectRoot === "." ? options.repoRoot : joinAbsolute(options.repoRoot, options.projectRoot, options.platform); +} + +function resolveWindowsPath(root: string, path: string): string { + const normalizedPath = path.replaceAll("\\", "/"); + const normalizedRoot = root.replaceAll("\\", "/").replace(/\/+$/u, ""); + const absolute = /^[A-Za-z]:\//u.test(normalizedPath) || normalizedPath.startsWith("//"); + const combined = absolute ? normalizedPath : `${normalizedRoot}/${normalizedPath}`; + const drive = combined.match(/^[A-Za-z]:/u)?.[0] ?? ""; + const unc = drive.length === 0 && combined.startsWith("//"); + const rooted = drive.length > 0 || unc || combined.startsWith("/"); + const body = drive.length > 0 ? combined.slice(drive.length) : unc ? combined.slice(2) : combined; + const segments: string[] = []; + for (const segment of body.split("/")) { + if (segment.length === 0 || segment === ".") continue; + if (segment === "..") { + if (segments.length > 0) segments.pop(); + continue; + } + segments.push(segment); + } + const prefix = drive.length > 0 ? `${drive}/` : unc ? "//" : rooted ? "/" : ""; + return `${prefix}${segments.join("/")}`; +} + +function windowsDirname(path: string): string { + const normalized = normalizePath(path); + const separator = normalized.lastIndexOf("/"); + if (separator < 0) return "."; + if (separator === 2 && /^[A-Za-z]:\//u.test(normalized)) return normalized.slice(0, 3); + return separator === 0 ? "/" : normalized.slice(0, separator); +} + +function joinAbsolute(root: string, suffix: string, platform: string | undefined): string { + if (platform === "win32") return `${root.replace(/[\\/]+$/u, "")}\\${suffix.replaceAll("/", "\\")}`; + return resolve(root, suffix); +} + +async function candidateAvailable(workspace: PythonProjectWorkspace, command: string): Promise { + return workspace.executableExists(command); +} + +function effectiveProcessEnvironment(env: Readonly> | undefined): Record { + if (env === undefined) return { ...process.env }; + return { ...env }; +} + +function sanitizedFingerprintEnvironment( + env: Readonly> | undefined, + projectRoot: string +): Record { + const selected: Record = {}; + for (const key of ["VIRTUAL_ENV", "UV_PROJECT_ENVIRONMENT", "PDM_VENV_IN_PROJECT", "POETRY_ACTIVE", "PIPENV_ACTIVE"] as const) { + const value = env?.[key]; + if (value === undefined) continue; + selected[key] = key.endsWith("ACTIVE") || key === "PDM_VENV_IN_PROJECT" ? value === "1" || value === "true" : normalizePath(value); + } + selected.projectRoot = projectRoot; + selected.pathProvided = env?.PATH !== undefined; + return selected; +} + +function normalizePath(value: string): string { + return value.replaceAll("\\", "/").replace(/\/$/u, ""); +} + +function missingCommand(message: string | undefined): boolean { + return message?.includes("ENOENT") === true || message?.toLowerCase().includes("not found") === true; +} diff --git a/packages/validation-python/src/graph-requirements.ts b/packages/validation-python/src/graph-requirements.ts index 62441ee..ebb560a 100644 --- a/packages/validation-python/src/graph-requirements.ts +++ b/packages/validation-python/src/graph-requirements.ts @@ -1,53 +1,51 @@ import type { GraphEdgeKind } from "@the-open-engine/opcore-contracts"; import type { ValidationCheckContext, ValidationGraphQueryRequirement } from "@the-open-engine/opcore-validation"; -import { materializePythonSources, toFileNodeId } from "./source-files.js"; +import { toFileNodeId, type PythonSourceRootResolver, type PythonSourceSetResolver } from "./source-files.js"; -export async function importGraphRequirements( - context: ValidationCheckContext -): Promise { - return edgeAndScopedFileRequirements(context, ["IMPORTS_FROM"]); +export function createImportGraphRequirements(resolveSources: PythonSourceSetResolver) { + return async (context: ValidationCheckContext): Promise => + edgeAndScopedFileRequirements(context, ["IMPORTS_FROM"], (await resolveSources(context)).rootPaths); } -export async function deadCodeGraphRequirements( - context: ValidationCheckContext -): Promise { - const sourceSet = await materializePythonSources(context); - return [ - { - operation: "factQuery", - selector: { - kind: "edges", - edgeKinds: ["CALLS"] - } - }, - { - operation: "factQuery", - selector: { - kind: "nodes", - nodeKinds: ["File", "file"], - ids: sourceSet.rootPaths.map(toFileNodeId) +export function createDeadCodeGraphRequirements(resolveRoots: PythonSourceRootResolver) { + return async (context: ValidationCheckContext): Promise => { + const rootPaths = await resolveRoots(context); + return [ + { + operation: "factQuery", + selector: { + kind: "edges", + edgeKinds: ["CALLS"] + } + }, + { + operation: "factQuery", + selector: { + kind: "nodes", + nodeKinds: ["File", "file"], + ids: rootPaths.map(toFileNodeId) + } + }, + { + operation: "factQuery", + selector: { + kind: "symbols" + } } - }, - { - operation: "factQuery", - selector: { - kind: "symbols" - } - } - ]; + ]; + }; } -export async function relevantTestsGraphRequirements( - context: ValidationCheckContext -): Promise { - return edgeAndScopedFileRequirements(context, ["TESTED_BY"]); +export function createRelevantTestsGraphRequirements(resolveRoots: PythonSourceRootResolver) { + return async (context: ValidationCheckContext): Promise => + edgeAndScopedFileRequirements(context, ["TESTED_BY"], await resolveRoots(context)); } async function edgeAndScopedFileRequirements( context: ValidationCheckContext, - edgeKinds: readonly GraphEdgeKind[] + edgeKinds: readonly GraphEdgeKind[], + rootPaths: readonly string[] ): Promise { - const sourceSet = await materializePythonSources(context); return [ { operation: "factQuery", @@ -61,7 +59,7 @@ async function edgeAndScopedFileRequirements( selector: { kind: "nodes", nodeKinds: ["File", "file"], - ids: sourceSet.rootPaths.map(toFileNodeId) + ids: rootPaths.map(toFileNodeId) } } ]; diff --git a/packages/validation-python/src/import-analysis.ts b/packages/validation-python/src/import-analysis.ts new file mode 100644 index 0000000..ee00fab --- /dev/null +++ b/packages/validation-python/src/import-analysis.ts @@ -0,0 +1,76 @@ +import { normalizeValidationFileViewPath } from "@the-open-engine/opcore-validation"; + +export interface PythonImportSourceFile { + path: string; + content: string; +} + +export interface PythonImportEdge { + fromPath: string; + toPath: string; +} + +export interface GraphImportEdge { + from: string; + to: string; +} + +export interface PythonImportAnalyzer { + analyze(files: readonly PythonImportSourceFile[]): Promise; +} + +export function requirePythonImportAnalyzer(analyzer: PythonImportAnalyzer | undefined): PythonImportAnalyzer { + if (analyzer === undefined || typeof analyzer.analyze !== "function") { + throw new Error("A canonical Python import analyzer is required for Python import-dependent validation"); + } + return analyzer; +} + +export function validatePythonImportEdges( + value: unknown, + sourcePaths: ReadonlySet +): readonly PythonImportEdge[] { + if (!Array.isArray(value)) throw new Error("Python import analyzer returned malformed edges: expected an array"); + const byKey = new Map(); + for (const [index, candidate] of value.entries()) { + if (candidate === null || typeof candidate !== "object") { + throw new Error(`Python import analyzer returned malformed edge at index ${index}`); + } + const fromValue = Reflect.get(candidate, "fromPath"); + const toValue = Reflect.get(candidate, "toPath"); + if (typeof fromValue !== "string" || typeof toValue !== "string") { + throw new Error(`Python import analyzer returned malformed edge endpoints at index ${index}`); + } + const fromPath = normalizeValidationFileViewPath(fromValue); + const toPath = normalizeValidationFileViewPath(toValue); + if (!sourcePaths.has(fromPath) || !sourcePaths.has(toPath)) { + throw new Error(`Python import analyzer returned an edge outside the supplied after-state: ${fromPath} -> ${toPath}`); + } + byKey.set(`${fromPath}\0${toPath}`, { fromPath, toPath }); + } + return [...byKey.values()].sort(comparePythonImportEdges); +} + +export function pythonImportEdgesFromGraph( + edges: readonly GraphImportEdge[], + pythonPaths: ReadonlySet +): readonly PythonImportEdge[] { + const imports: PythonImportEdge[] = []; + for (const edge of edges) { + const fromPath = graphFileEndpointPath(edge.from); + const toPath = graphFileEndpointPath(edge.to); + if (fromPath !== undefined && toPath !== undefined && pythonPaths.has(fromPath) && pythonPaths.has(toPath)) { + imports.push({ fromPath, toPath }); + } + } + return imports; +} + +function graphFileEndpointPath(endpoint: string): string | undefined { + if (!endpoint.startsWith("file:")) return undefined; + return normalizeValidationFileViewPath(endpoint.slice("file:".length)); +} + +function comparePythonImportEdges(left: PythonImportEdge, right: PythonImportEdge): number { + return `${left.fromPath}\0${left.toPath}`.localeCompare(`${right.fromPath}\0${right.toPath}`); +} diff --git a/packages/validation-python/src/import-graph-check.ts b/packages/validation-python/src/import-graph-check.ts index 0508b7f..ea04931 100644 --- a/packages/validation-python/src/import-graph-check.ts +++ b/packages/validation-python/src/import-graph-check.ts @@ -2,10 +2,10 @@ import type { GraphFactEdge, ValidationDiagnostic } from "@the-open-engine/opcor import type { ValidationCheckDefinition } from "@the-open-engine/opcore-validation"; import { PYTHON_IMPORT_GRAPH_CHECK_ID } from "./check-ids.js"; import { pythonCheckAdapter, pythonCheckOwner, supportedPythonValidationScopes } from "./check-constants.js"; -import { importGraphRequirements } from "./graph-requirements.js"; -import { materializePythonSources, toFileNodeId } from "./source-files.js"; +import { createImportGraphRequirements } from "./graph-requirements.js"; +import { toFileNodeId, type PythonSourceSetResolver } from "./source-files.js"; -export function createImportGraphCheck(): ValidationCheckDefinition { +export function createImportGraphCheck(resolveSources: PythonSourceSetResolver): ValidationCheckDefinition { return { id: PYTHON_IMPORT_GRAPH_CHECK_ID, owner: pythonCheckOwner, @@ -13,17 +13,17 @@ export function createImportGraphCheck(): ValidationCheckDefinition { defaultSeverity: "warning", supportedScopes: supportedPythonValidationScopes, requiresGraph: true, - graphRequirements: importGraphRequirements, + graphRequirements: createImportGraphRequirements(resolveSources), run: async (context) => { - const [sourceSet, edges] = await Promise.all([materializePythonSources(context), context.graph.importsFrom()]); + const [sourceSet, edges] = await Promise.all([resolveSources(context), context.graph.importsFrom()]); const diagnostics = sourceSet.repoImports - .filter((repoImport) => !edges.some((edge) => matchesDirectedFileEdge(edge, repoImport.fromPath, repoImport.resolvedPath))) + .filter((repoImport) => !edges.some((edge) => matchesDirectedFileEdge(edge, repoImport.fromPath, repoImport.toPath))) .map((repoImport): ValidationDiagnostic => ({ category: "graph", severity: "warning", path: repoImport.fromPath, code: "PY_IMPORT_GRAPH_MISSING_EDGE", - message: `Missing IMPORTS_FROM graph edge for ${repoImport.fromPath} -> ${repoImport.resolvedPath}` + message: `Missing IMPORTS_FROM graph edge for ${repoImport.fromPath} -> ${repoImport.toPath}` })); return { diagnostics }; } diff --git a/packages/validation-python/src/index.ts b/packages/validation-python/src/index.ts index 1ad50be..1ff1cbc 100644 --- a/packages/validation-python/src/index.ts +++ b/packages/validation-python/src/index.ts @@ -1,10 +1,13 @@ -import type { ValidationCheckDefinition } from "@the-open-engine/opcore-validation"; +import type { ValidationCheckDefinition, ValidationCheckResult } from "@the-open-engine/opcore-validation"; +import type { ValidationDiagnostic } from "@the-open-engine/opcore-contracts"; import { createDeadCodeCheck } from "./dead-code-check.js"; import { createImportGraphCheck } from "./import-graph-check.js"; import { createRelevantTestsCheck } from "./relevant-tests-check.js"; import { createSourceHygieneCheck } from "./source-hygiene-check.js"; import { createSyntaxCheck, type PythonSyntaxCheckOptions } from "./syntax-check.js"; +import { createPythonProjectContextResolver, createPythonSourceRootResolver, createPythonSourceSetResolver, pythonInputSet } from "./source-files.js"; import { createTypeCheck, type PythonTypeCheckOptions } from "./type-check.js"; +import type { PythonImportAnalyzer } from "./import-analysis.js"; export { PYTHON_DEAD_CODE_CHECK_ID, @@ -18,28 +21,65 @@ export { } from "./check-ids.js"; export { validationPythonAdapterName } from "./check-constants.js"; export { isPythonSourcePath } from "./source-files.js"; -export { createPythonValidationAdapterStatus, probePythonToolchain, type PythonValidationToolchainOptions } from "./toolchain.js"; +export type { PythonImportAnalyzer, PythonImportEdge, PythonImportSourceFile } from "./import-analysis.js"; export { - resolvePythonTool, - findPythonConfigFile, - type PythonToolResolution, - type PythonToolResolutionSource, - type PythonToolResolverOptions -} from "./toolchain-resolver.js"; + resolvePythonProjectContext, + resolvePythonProjectContexts, + type ResolvePythonProjectContextsOptions +} from "./project-context.js"; +export { + createNodePythonProjectWorkspace, + createValidationFileViewPythonWorkspace, + type PythonProjectWorkspace, + type PythonProjectWorkspaceRealpath +} from "./project-workspace.js"; +export type { PythonProjectProcessProbe } from "./environment-resolution.js"; +export { createPythonValidationAdapterStatus, type PythonValidationToolchainOptions } from "./toolchain.js"; export { createSyntaxCheck, type PythonSyntaxCheckOptions } from "./syntax-check.js"; export { createTypeCheck, type PythonTypeCheckOptions } from "./type-check.js"; -export interface CreatePythonValidationChecksOptions extends PythonTypeCheckOptions, PythonSyntaxCheckOptions {} +export interface CreatePythonValidationChecksOptions extends PythonTypeCheckOptions, PythonSyntaxCheckOptions { + importAnalyzer?: PythonImportAnalyzer; +} export function createPythonValidationChecks( options: CreatePythonValidationChecksOptions = {} ): readonly ValidationCheckDefinition[] { + const resolveContexts = createPythonProjectContextResolver({ + ...(options.nodeWorkspace === undefined ? {} : { nodeWorkspace: options.nodeWorkspace }), + ...(options.env === undefined ? {} : { env: options.env }), + ...(options.interpreterArgv === undefined ? {} : { interpreterArgv: options.interpreterArgv }), + ...(options.toolArgv === undefined ? {} : { toolArgv: options.toolArgv }), + ...(options.platform === undefined ? {} : { platform: options.platform }), + ...(options.architecture === undefined ? {} : { architecture: options.architecture }), + ...(options.processProbe === undefined ? {} : { processProbe: options.processProbe }), + ...(options.timeoutMs === undefined ? {} : { timeoutMs: options.timeoutMs }) + }); + const resolveSources = createPythonSourceSetResolver(options.importAnalyzer, resolveContexts); + const resolveRoots = createPythonSourceRootResolver(); return [ - createSyntaxCheck(options), + createSyntaxCheck(options, resolveContexts), createSourceHygieneCheck(), - createTypeCheck(options), - createImportGraphCheck(), - createDeadCodeCheck(), - createRelevantTestsCheck() - ]; + createTypeCheck(options, resolveContexts, resolveSources), + createImportGraphCheck(resolveSources), + createDeadCodeCheck(resolveRoots), + createRelevantTestsCheck(resolveRoots) + ].map((check) => withPythonProjectContexts(check, resolveContexts)); +} + +function withPythonProjectContexts( + check: ValidationCheckDefinition, + resolveContexts: ReturnType +): ValidationCheckDefinition { + return { + ...check, + run: async (context) => { + const result = await check.run(context); + if (pythonInputSet(context).length === 0) return result; + const pythonProjectContexts = await resolveContexts(context); + if (result === undefined) return { diagnostics: [], pythonProjectContexts }; + if (Array.isArray(result)) return { diagnostics: result as readonly ValidationDiagnostic[], pythonProjectContexts }; + return { ...(result as ValidationCheckResult), pythonProjectContexts }; + } + }; } diff --git a/packages/validation-python/src/ini-config.ts b/packages/validation-python/src/ini-config.ts new file mode 100644 index 0000000..171553f --- /dev/null +++ b/packages/validation-python/src/ini-config.ts @@ -0,0 +1,77 @@ +export interface PythonIniDocument { + sections: ReadonlyMap>; +} + +interface MutableIniState { + sections: Map>; + section?: string; + option?: string; +} + +export function parsePythonIni(content: string): PythonIniDocument { + const state: MutableIniState = { sections: new Map() }; + for (const [index, rawLine] of content.replace(/^\uFEFF/u, "").split(/\r?\n/u).entries()) { + parseIniLine(state, rawLine, index + 1); + } + if (state.sections.size === 0) throw new Error("INI config has no sections"); + return { sections: state.sections }; +} + +export function pythonIniHasSection(document: PythonIniDocument | undefined, section: string): boolean { + return document?.sections.has(section.toLowerCase()) ?? false; +} + +export function pythonIniValue( + document: PythonIniDocument | undefined, + section: string, + option: string +): string | undefined { + return document?.sections.get(section.toLowerCase())?.get(option.toLowerCase()); +} + +function parseIniLine(state: MutableIniState, rawLine: string, lineNumber: number): void { + const trimmed = rawLine.trim(); + if (trimmed.length === 0 || trimmed.startsWith("#") || trimmed.startsWith(";")) return; + if (/^\s/u.test(rawLine)) { + appendContinuation(state, trimmed, lineNumber); + return; + } + const section = /^\[([^\[\]]+)\](?:\s*[#;].*)?$/u.exec(trimmed)?.[1]?.trim().toLowerCase(); + if (section !== undefined) { + if (section.length === 0 || state.sections.has(section)) throw new Error(`Invalid INI section at line ${lineNumber}`); + state.sections.set(section, new Map()); + state.section = section; + delete state.option; + return; + } + parseIniOption(state, rawLine, lineNumber); +} + +function parseIniOption(state: MutableIniState, rawLine: string, lineNumber: number): void { + if (state.section === undefined) throw new Error(`INI option precedes a section at line ${lineNumber}`); + const delimiter = firstIniDelimiter(rawLine); + const option = delimiter < 1 ? "" : rawLine.slice(0, delimiter).trim().toLowerCase(); + if (option.length === 0) throw new Error(`Invalid INI option at line ${lineNumber}`); + const section = state.sections.get(state.section); + if (section === undefined || section.has(option)) throw new Error(`Duplicate INI option at line ${lineNumber}`); + section.set(option, rawLine.slice(delimiter + 1).trim()); + state.option = option; +} + +function appendContinuation(state: MutableIniState, value: string, lineNumber: number): void { + if (state.section === undefined || state.option === undefined) { + throw new Error(`Unexpected INI continuation at line ${lineNumber}`); + } + const section = state.sections.get(state.section); + const previous = section?.get(state.option); + if (section === undefined || previous === undefined) throw new Error(`Invalid INI continuation at line ${lineNumber}`); + section.set(state.option, previous.length === 0 ? value : `${previous}\n${value}`); +} + +function firstIniDelimiter(line: string): number { + const equals = line.indexOf("="); + const colon = line.indexOf(":"); + if (equals < 0) return colon; + if (colon < 0) return equals; + return Math.min(equals, colon); +} diff --git a/packages/validation-python/src/mypy-config-values.ts b/packages/validation-python/src/mypy-config-values.ts new file mode 100644 index 0000000..6353f4a --- /dev/null +++ b/packages/validation-python/src/mypy-config-values.ts @@ -0,0 +1,242 @@ +import { normalizeValidationFileViewPath } from "@the-open-engine/opcore-validation"; +import { posix } from "node:path"; +import { parsePythonIni } from "./ini-config.js"; + +export interface MypyConfigValues { + plugins: readonly string[]; + mypyPaths: readonly string[]; + invalidMypyPaths: readonly string[]; + settings: ReadonlyMap; +} + +export type PythonTomlParser = (content: string) => Record; + +const outputBearingMypyOptions = new Set([ + "cache_dir", + "cache_map", + "junit_xml", + "line_checking_stats", + "mypyc_annotation_file", + "timing_stats" +]); + +const hostInputMypyOptions = new Set([ + "custom_typing_module", + "custom_typeshed_dir", + "python_executable", + "quickstart_file", + "shadow_file" +]); + +const isolatedMypyPathOptions = new Set(["files", "package_root"]); +const exactStateMypyPathOptions = new Set(["mypy_path", "plugins"]); +const nonPathMypyOptionsWithPathSuffix = new Set(["show_absolute_path"]); + +export function readMypyConfigValues( + configPath: string, + content: string, + parseToml: PythonTomlParser +): MypyConfigValues { + if (configPath.endsWith("pyproject.toml")) return tomlMypyValues(content, parseToml); + try { + const document = parsePythonIni(content); + const settings = document.sections.get("mypy") ?? new Map(); + return { + plugins: splitCommaLines(settings.get("plugins")), + ...parseMypyPaths(settings.get("mypy_path")), + settings + }; + } catch { + return emptyMypyConfigValues(); + } +} + +export function mypyConfigSafetyFailures( + configPath: string, + content: string, + projectRoot: string, + parseToml: PythonTomlParser +): readonly string[] { + const values = readMypyConfigValues(configPath, content, parseToml); + return uniqueSorted([ + ...values.invalidMypyPaths, + ...mypyConfigIsolationFailures(configPath, projectRoot, values, false) + ]); +} + +export function mypyConfigIsolationFailures( + configPath: string, + projectRoot: string, + config: MypyConfigValues, + includePluginResolution = true +): readonly string[] { + const failures: string[] = []; + for (const [option, value] of config.settings) { + if (outputBearingMypyOptions.has(option) || option.endsWith("_report")) { + failures.push(`${option} is output-bearing and cannot run in read-only validation`); + continue; + } + if (hostInputMypyOptions.has(option)) { + failures.push(`${option} can read or execute host paths outside the exact after-state`); + continue; + } + if (option === "install_types" && configuredBoolean(value)) { + failures.push("install_types can mutate the selected Python environment"); + continue; + } + if (isolatedMypyPathOptions.has(option)) { + for (const path of stringValues(value)) { + const failure = isolatedConfigPathFailure(option, path, projectRoot); + if (failure !== undefined) failures.push(failure); + } + continue; + } + if (!exactStateMypyPathOptions.has(option) && + !nonPathMypyOptionsWithPathSuffix.has(option) && + looksPathBearingMypyOption(option)) { + failures.push(`${option} is an unsupported path-bearing option in exact after-state validation`); + } + } + if (!includePluginResolution) { + for (const plugin of config.plugins) { + const value = pluginImportValue(plugin); + if (!value.endsWith(".py")) continue; + const failure = isolatedConfigPathFailure("plugins", value, posix.dirname(configPath)); + if (failure !== undefined) failures.push(failure); + } + } + return uniqueSorted(failures); +} + +export function mypyPathValidationFailures(value: unknown): readonly string[] { + return parseMypyPaths(value).invalidMypyPaths; +} + +export function isolatedConfigPathFailure(option: string, value: string, root: string): string | undefined { + if (value.startsWith("/") || value.startsWith("\\") || /^[A-Za-z]:[\\/]/u.test(value)) { + return `${option} path is absolute and cannot be isolated`; + } + if (value.startsWith("~")) return `${option} path uses unsupported home expansion`; + if (value.includes("$") || /%[^%]+%/u.test(value)) { + return `${option} path uses unsupported environment expansion`; + } + try { + normalizeMypySearchRoot(posix.join(root, value.replaceAll("\\", "/"))); + } catch { + return `${option} path escapes the repository`; + } + return undefined; +} + +export function pluginImportValue(configured: string): string { + const value = configured.trim(); + const colon = value.lastIndexOf(":"); + const separator = Math.max(value.lastIndexOf("/"), value.lastIndexOf("\\")); + const driveColon = colon === 1 && /^[A-Za-z]:[\\/]/u.test(value); + return colon > separator && !driveColon ? value.slice(0, colon).trim() : value; +} + +export function normalizeMypySearchRoot(path: string): string { + const normalized = posix.normalize(path); + if (normalized === ".") return "."; + return normalizeValidationFileViewPath(normalized.replace(/^\.\//u, "")); +} + +function tomlMypyValues(content: string, parseToml: PythonTomlParser): MypyConfigValues { + try { + const document = parseToml(content) as { tool?: { mypy?: Record } }; + const mypy = document.tool?.mypy ?? {}; + const settings = new Map(Object.entries(mypy).filter(([key]) => key !== "overrides")); + return { + plugins: stringValues(mypy.plugins), + ...parseMypyPaths(mypy.mypy_path), + settings + }; + } catch { + return emptyMypyConfigValues(); + } +} + +function emptyMypyConfigValues(): MypyConfigValues { + return { plugins: [], mypyPaths: [], invalidMypyPaths: [], settings: new Map() }; +} + +function configuredBoolean(value: unknown): boolean { + return value === true || typeof value === "string" && value.trim().toLowerCase() === "true"; +} + +function looksPathBearingMypyOption(option: string): boolean { + return ["_dir", "_executable", "_file", "_path", "_root"].some((suffix) => option.endsWith(suffix)); +} + +function stringValues(value: unknown): readonly string[] { + if (Array.isArray(value)) return value.filter((entry): entry is string => typeof entry === "string").flatMap(splitCommaLines); + if (typeof value !== "string") return []; + return splitCommaLines(value); +} + +function splitCommaLines(value: string | undefined): readonly string[] { + return value?.split(/[,\r\n]+/u).map(cleanConfigValue).filter(Boolean) ?? []; +} + +function parseMypyPaths(value: unknown): Pick { + if (value === undefined) return { mypyPaths: [], invalidMypyPaths: [] }; + const values = Array.isArray(value) ? value : [value]; + const invalidMypyPaths: string[] = []; + const mypyPaths: string[] = []; + for (const candidate of values) { + if (typeof candidate !== "string") { + invalidMypyPaths.push("mypy_path must contain only strings"); + continue; + } + for (const entry of splitMypyPathString(candidate)) { + const failure = invalidMypyPath(entry); + if (failure === undefined) mypyPaths.push(entry); + else invalidMypyPaths.push(failure); + } + } + return { mypyPaths: uniqueSorted(mypyPaths), invalidMypyPaths: uniqueSorted(invalidMypyPaths) }; +} + +function splitMypyPathString(value: string): readonly string[] { + const entries: string[] = []; + for (const line of value.split(/[,\r\n]+/u)) { + let start = 0; + for (let index = 0; index < line.length; index += 1) { + if (line[index] !== ":") continue; + const driveColon = index === start + 1 && /[A-Za-z]/u.test(line[start] ?? "") && /[\\/]/u.test(line[index + 1] ?? ""); + if (driveColon) continue; + entries.push(line.slice(start, index)); + start = index + 1; + } + entries.push(line.slice(start)); + } + return entries.map(cleanConfigValue).filter(Boolean); +} + +function invalidMypyPath(value: string): string | undefined { + if (value.startsWith("/") || value.startsWith("\\") || /^[A-Za-z]:[\\/]/u.test(value)) { + return "mypy_path entry is absolute and cannot be isolated"; + } + if (value.startsWith("~")) return "mypy_path entry uses unsupported home expansion"; + const expanded = value + .replaceAll("${MYPY_CONFIG_FILE_DIR}", ".") + .replaceAll("$MYPY_CONFIG_FILE_DIR", "."); + if (expanded.includes("$") || /%[^%]+%/u.test(expanded)) { + return "mypy_path entry uses unsupported environment expansion"; + } + try { + normalizeMypySearchRoot(expanded); + } catch { + return "mypy_path entry escapes the repository"; + } + return undefined; +} + +function cleanConfigValue(value: string): string { + return value.replace(/\s[#;].*$/u, "").trim(); +} + +function uniqueSorted(values: readonly string[]): readonly string[] { + return [...new Set(values)].sort(); +} diff --git a/packages/validation-python/src/mypy-config.ts b/packages/validation-python/src/mypy-config.ts new file mode 100644 index 0000000..e5f492a --- /dev/null +++ b/packages/validation-python/src/mypy-config.ts @@ -0,0 +1,105 @@ +import type { PythonProjectContext } from "@the-open-engine/opcore-contracts"; +import type { ValidationFileView } from "@the-open-engine/opcore-validation"; +import { posix } from "node:path"; +import { + isolatedConfigPathFailure, + mypyConfigIsolationFailures, + normalizeMypySearchRoot, + pluginImportValue, + readMypyConfigValues, + type MypyConfigValues +} from "./mypy-config-values.js"; +import { parsePythonToml } from "./toml-config.js"; + +export interface MypyConfigSemantics { + pluginPaths: readonly string[]; + moduleSearchRoots: readonly string[]; + invalidConfigMessages: readonly string[]; +} + +export async function resolveMypyConfigSemantics( + fileView: ValidationFileView, + project: PythonProjectContext, + configPaths: readonly string[] +): Promise { + const values: { configPath: string; values: MypyConfigValues }[] = []; + for (const configPath of configPaths) { + const state = await fileView.readAfter(configPath); + if (state.status === "found") { + values.push({ configPath, values: readMypyConfigValues(configPath, state.content, parsePythonToml) }); + } + } + const configuredRoots = values.flatMap(({ configPath, values: config }) => + config.mypyPaths.flatMap((value) => resolveMypyPath(value, configPath, project.projectRoot)) + ); + const invalidConfigMessages = values.flatMap(({ configPath, values: config }) => + [ + ...config.invalidMypyPaths, + ...mypyConfigIsolationFailures(configPath, project.projectRoot, config) + ].map((message) => `${configPath}: ${message}`) + ); + const moduleSearchRoots = uniqueSorted([...project.sourceRoots, project.projectRoot, ...configuredRoots]); + const visible = new Set(await fileView.listVisibleFiles()); + const pluginPaths: string[] = []; + for (const { configPath, values: config } of values) { + for (const plugin of config.plugins) { + const resolved = resolvePluginModule(plugin, configPath, moduleSearchRoots, visible); + if (resolved.failure === undefined) pluginPaths.push(...resolved.paths); + else invalidConfigMessages.push(`${configPath}: ${resolved.failure}`); + } + } + return { + pluginPaths: uniqueSorted(pluginPaths), + moduleSearchRoots, + invalidConfigMessages: uniqueSorted(invalidConfigMessages) + }; +} + +function resolveMypyPath(value: string, configPath: string, projectRoot: string): readonly string[] { + if (value.startsWith("/") || /^[A-Za-z]:[\\/]/u.test(value)) return []; + const configRoot = posix.dirname(configPath); + const expanded = value + .replaceAll("${MYPY_CONFIG_FILE_DIR}", configRoot) + .replaceAll("$MYPY_CONFIG_FILE_DIR", configRoot); + const candidate = expanded !== value ? expanded : joinRoot(projectRoot, expanded); + try { + return [normalizeMypySearchRoot(candidate)]; + } catch { + return []; + } +} + +function resolvePluginModule( + configured: string, + configPath: string, + roots: readonly string[], + visible: ReadonlySet +): { paths: readonly string[]; failure?: string } { + const value = pluginImportValue(configured); + if (value.length === 0) return { paths: [], failure: "plugins contains an empty plugin reference" }; + if (value.endsWith(".py")) { + const failure = isolatedConfigPathFailure("plugins", value, posix.dirname(configPath)); + if (failure !== undefined) return { paths: [], failure }; + const path = normalizeMypySearchRoot(posix.join(posix.dirname(configPath), value.replaceAll("\\", "/"))); + return visible.has(path) + ? { paths: [path] } + : { paths: [], failure: "plugins path is absent from the exact after-state" }; + } + if (!/^[A-Za-z_]\w*(?:\.[A-Za-z_]\w*)*$/u.test(value)) { + return { paths: [], failure: "plugins entry is not an isolated module or .py path" }; + } + const modulePath = value.replaceAll(".", "/"); + const candidates = roots.flatMap((root) => [joinRoot(root, `${modulePath}.py`), joinRoot(root, `${modulePath}/__init__.py`)]); + const paths = uniqueSorted(candidates.filter((path) => visible.has(path))); + if (paths.length === 0) return { paths: [], failure: "plugins module is absent from the exact after-state" }; + if (paths.length > 1) return { paths: [], failure: "plugins module resolves ambiguously in the exact after-state" }; + return { paths }; +} + +function joinRoot(root: string, path: string): string { + return normalizeMypySearchRoot(root === "." ? path : posix.join(root, path)); +} + +function uniqueSorted(values: readonly string[]): readonly string[] { + return [...new Set(values)].sort(); +} diff --git a/packages/validation-python/src/mypy-output.ts b/packages/validation-python/src/mypy-output.ts new file mode 100644 index 0000000..0ce4456 --- /dev/null +++ b/packages/validation-python/src/mypy-output.ts @@ -0,0 +1,120 @@ +import type { + PythonValidationCapabilityToolProvenance, + ValidationDiagnostic +} from "@the-open-engine/opcore-contracts"; +import { diagnostic, sortDiagnostics } from "./diagnostics.js"; +import { + repoRelativeMaterializedPath, + type MaterializedPythonTypeWorkspace +} from "./type-capability-run.js"; + +export type ParsedMypyStream = + | { ok: true; diagnostics: readonly ValidationDiagnostic[] } + | { ok: false; message: string }; + +export interface MypyJsonRecord { + file: string; + line: number; + column: number; + end_line?: number; + end_column?: number; + message: string; + hint?: string | null; + code?: string | null; + severity: "error" | "warning" | "note"; +} + +export function parseMypyJsonStream( + stdout: string, + workspace: MaterializedPythonTypeWorkspace, + tool: PythonValidationCapabilityToolProvenance +): ParsedMypyStream { + const diagnostics: ValidationDiagnostic[] = []; + const lines = stdout.split(/\r?\n/u).filter((line) => line.trim().length > 0); + for (let index = 0; index < lines.length; index += 1) { + const parsed = parseMypyJsonLine(lines[index], index + 1, workspace, tool); + if (typeof parsed === "string") return { ok: false, message: parsed }; + diagnostics.push(parsed); + } + return { ok: true, diagnostics: sortDiagnostics(diagnostics) }; +} + +export function validateMypyRecord(value: unknown, record: number): MypyJsonRecord | string { + if (value === null || typeof value !== "object" || Array.isArray(value)) { + return `mypy JSON record ${record} must be an object`; + } + const candidate = value as Record; + const allowed = new Set(["file", "line", "column", "end_line", "end_column", "message", "hint", "code", "severity"]); + if (Object.keys(candidate).some((key) => !allowed.has(key))) return `mypy JSON record ${record} has unknown fields`; + const failure = validateMypyLocation(candidate, record) ?? validateMypyText(candidate, record); + if (failure !== undefined) return failure; + return candidate as unknown as MypyJsonRecord; +} + +function parseMypyJsonLine( + line: string, + record: number, + workspace: MaterializedPythonTypeWorkspace, + tool: PythonValidationCapabilityToolProvenance +): ValidationDiagnostic | string { + let value: unknown; + try { + value = JSON.parse(line); + } catch { + return `mypy emitted malformed JSON record ${record}`; + } + const validated = validateMypyRecord(value, record); + if (typeof validated === "string") return validated; + try { + const path = repoRelativeMaterializedPath(validated.file, workspace.projectCwd, workspace.root); + return mypyDiagnostic(validated, path, tool); + } catch { + return `mypy JSON record ${record} has an out-of-repository file`; + } +} + +function mypyDiagnostic( + record: MypyJsonRecord, + path: string, + tool: PythonValidationCapabilityToolProvenance +): ValidationDiagnostic { + return diagnostic({ + category: "types", severity: record.severity === "note" ? "info" : record.severity, path, + code: record.code == null ? "MYPY_TYPE_ERROR" : `MYPY_${normalizeDiagnosticCode(record.code)}`, + message: record.hint == null ? record.message : `${record.message} (${record.hint})`, + line: record.line, column: record.column + 1, + ...(record.end_line === undefined ? {} : { endLine: record.end_line }), + ...(record.end_line === undefined || record.end_column === undefined ? {} : { endColumn: record.end_column + 1 }), + tool: { + name: tool.name, command: tool.argv.join(" "), source: tool.source, cwd: tool.cwd, + ...(tool.version === undefined ? {} : { version: tool.version }) + } + }); +} + +function validateMypyLocation(candidate: Record, record: number): string | undefined { + if (typeof candidate.file !== "string" || candidate.file.length === 0 || candidate.file.includes("\0")) return `mypy JSON record ${record} has invalid file`; + if (!Number.isInteger(candidate.line) || (candidate.line as number) < 1) return `mypy JSON record ${record} has invalid line`; + if (!Number.isInteger(candidate.column) || (candidate.column as number) < 0) return `mypy JSON record ${record} has invalid column`; + return validateMypyEndLocation(candidate, record); +} + +function validateMypyEndLocation(candidate: Record, record: number): string | undefined { + if (candidate.end_line !== undefined && (!Number.isInteger(candidate.end_line) || (candidate.end_line as number) < (candidate.line as number))) return `mypy JSON record ${record} has invalid end_line`; + if (candidate.end_column !== undefined && (!Number.isInteger(candidate.end_column) || (candidate.end_column as number) < 0)) return `mypy JSON record ${record} has invalid end_column`; + if (candidate.end_column !== undefined && candidate.end_line === undefined) return `mypy JSON record ${record} has end_column without end_line`; + if (candidate.end_line === candidate.line && candidate.end_column !== undefined && (candidate.end_column as number) < (candidate.column as number)) return `mypy JSON record ${record} has end location before its start location`; + return undefined; +} + +function validateMypyText(candidate: Record, record: number): string | undefined { + if (typeof candidate.message !== "string" || candidate.message.length === 0) return `mypy JSON record ${record} has invalid message`; + if (candidate.hint != null && typeof candidate.hint !== "string") return `mypy JSON record ${record} has invalid hint`; + if (candidate.code != null && typeof candidate.code !== "string") return `mypy JSON record ${record} has invalid code`; + if (candidate.severity !== "error" && candidate.severity !== "warning" && candidate.severity !== "note") return `mypy JSON record ${record} has unknown severity`; + return undefined; +} + +function normalizeDiagnosticCode(code: string): string { + return code.replace(/([a-z])([A-Z])/gu, "$1_$2").replace(/[^A-Za-z0-9]+/gu, "_").replace(/^_+|_+$/gu, "").toUpperCase(); +} diff --git a/packages/validation-python/src/mypy-result.ts b/packages/validation-python/src/mypy-result.ts new file mode 100644 index 0000000..9a295ba --- /dev/null +++ b/packages/validation-python/src/mypy-result.ts @@ -0,0 +1,85 @@ +import type { + PythonValidationCapabilityToolProvenance, + ValidationDiagnostic +} from "@the-open-engine/opcore-contracts"; +import type { + MypyCapabilityArgs, + MypyCapabilityResult, + MypyExecutionContext +} from "./mypy-runner-types.js"; +import type { PythonToolRunResult } from "./process.js"; +import { + completedTypeResult, + terminatedTypeResult, + typeInvalidConfigFailure, + typeMaterializationFailure, + typeProtocolFailure +} from "./type-result.js"; +import type { TypeCapabilityArgs, TypeExecutionContext } from "./type-runner-types.js"; + +export function completedMypyResult( + context: MypyExecutionContext, + exitCode: number, + diagnostics: readonly ValidationDiagnostic[], + durationMs: number +): MypyCapabilityResult { + const shared = sharedContext(context); + if (exitCode === 0 && diagnostics.length !== 0) { + return typeProtocolFailure(shared, durationMs, exitCode, "mypy exit 0 emitted diagnostics"); + } + if (exitCode === 1 && !diagnostics.some(isFindingDiagnostic)) { + return typeProtocolFailure(shared, durationMs, exitCode, "mypy exit 1 emitted no finding diagnostics"); + } + return completedTypeResult({ + context: shared, + status: exitCode === 0 ? "passed" : "findings", + exitCode, + diagnostics, + durationMs + }); +} + +export function terminatedMypyResult( + context: MypyExecutionContext, + result: Exclude, + durationMs: number +): MypyCapabilityResult { + return terminatedTypeResult(sharedContext(context), result, durationMs); +} + +export function materializationFailure( + args: MypyCapabilityArgs, + tool: PythonValidationCapabilityToolProvenance, + startedAt: number +): MypyCapabilityResult { + return typeMaterializationFailure(sharedArgs(args), tool, startedAt); +} + +export function protocolFailure( + context: Pick, + durationMs: number, + exitCode: number, + rawMessage: string +): MypyCapabilityResult { + return typeProtocolFailure({ args: sharedArgs(context.args), tool: context.tool }, durationMs, exitCode, rawMessage); +} + +export function invalidConfigFailure( + context: Pick, + durationMs: number, + exitCode: number +): MypyCapabilityResult { + return typeInvalidConfigFailure({ args: sharedArgs(context.args), tool: context.tool }, durationMs, exitCode); +} + +function sharedArgs(args: MypyCapabilityArgs): TypeCapabilityArgs { + return { ...args, authority: "mypy" }; +} + +function sharedContext(context: MypyExecutionContext): TypeExecutionContext { + return { ...context, args: sharedArgs(context.args) }; +} + +function isFindingDiagnostic(entry: ValidationDiagnostic): boolean { + return entry.severity === "error" || entry.severity === "warning"; +} diff --git a/packages/validation-python/src/mypy-runner-types.ts b/packages/validation-python/src/mypy-runner-types.ts new file mode 100644 index 0000000..5de62ec --- /dev/null +++ b/packages/validation-python/src/mypy-runner-types.ts @@ -0,0 +1,5 @@ +import type { TypeCapabilityArgs, TypeCapabilityResult, TypeExecutionContext } from "./type-runner-types.js"; + +export type MypyCapabilityResult = TypeCapabilityResult; +export type MypyCapabilityArgs = Omit; +export type MypyExecutionContext = Omit & { args: MypyCapabilityArgs }; diff --git a/packages/validation-python/src/mypy-runner.ts b/packages/validation-python/src/mypy-runner.ts new file mode 100644 index 0000000..6cefa69 --- /dev/null +++ b/packages/validation-python/src/mypy-runner.ts @@ -0,0 +1,187 @@ +import type { PythonProjectToolProvenance } from "@the-open-engine/opcore-contracts"; +import { delimiter, join } from "node:path"; +import { + completedMypyResult, + invalidConfigFailure, + materializationFailure, + protocolFailure, + terminatedMypyResult +} from "./mypy-result.js"; +import { parseMypyJsonStream, validateMypyRecord } from "./mypy-output.js"; +import type { + MypyCapabilityArgs, + MypyCapabilityResult, + MypyExecutionContext +} from "./mypy-runner-types.js"; +import { runTool, type PythonToolRunResult } from "./process.js"; +import { + isolatedMypyConfigPath, + relativePythonProjectPath, + repoRelativeMaterializedPath, + type MaterializedPythonTypeWorkspace, + type PythonTypeCapabilityPreparation +} from "./type-capability-run.js"; +import { isolatedTypeEnvironmentBase, withMaterializedTypeExecution } from "./type-runner-runtime.js"; +import type { TypeCapabilityArgs, TypeExecutionContext } from "./type-runner-types.js"; + +export type { MypyCapabilityArgs, MypyCapabilityResult, MypyExecutionContext } from "./mypy-runner-types.js"; + +export async function runMypyCapability(args: MypyCapabilityArgs): Promise { + const observedArgv = mypyArgv(args.checker, args.preparation); + return withMaterializedTypeExecution( + sharedTypeArgs(args), + observedArgv, + async (context) => executeMypyCapability(toMypyExecutionContext(context, args), observedArgv), + (tool, startedAt) => materializationFailure(args, tool, startedAt) + ); +} + +async function executeMypyCapability( + context: MypyExecutionContext, + observedArgv: readonly string[] +): Promise { + const result = await runTool(observedArgv[0], observedArgv.slice(1), { + cwd: context.workspace.projectCwd, + env: isolatedMypyEnvironment(context.args.env, context.args.checker, context.workspace), + timeoutMs: context.args.timeoutMs, + allowedExitCodes: [0, 1] + }); + return interpretMypyResult(context, result, Date.now() - context.startedAt); +} + +function interpretMypyResult( + context: MypyExecutionContext, + result: PythonToolRunResult, + durationMs: number +): MypyCapabilityResult { + if (result.termination !== "exited") return terminatedMypyResult(context, result, durationMs); + if (isSelectedConfigFailure(context, result.stderr)) { + return invalidConfigFailure(context, durationMs, result.exitCode); + } + if (!result.ok || (result.exitCode !== 0 && result.exitCode !== 1)) { + return protocolFailure(context, durationMs, result.exitCode, `mypy exited with unexpected code ${result.exitCode}`); + } + if (result.stderr.trim().length > 0) { + return protocolFailure(context, durationMs, result.exitCode, "mypy wrote non-JSON error output to stderr"); + } + const parsed = parseMypyJsonStream(result.stdout, context.workspace, context.tool); + if (!parsed.ok) return protocolFailure(context, durationMs, result.exitCode, parsed.message); + return completedMypyResult(context, result.exitCode, parsed.diagnostics, durationMs); +} + +function isSelectedConfigFailure(context: MypyExecutionContext, stderr: string): boolean { + const configFile = context.tool.configFile; + if (configFile === undefined) return false; + const lines = stderr.split(/\r?\n/u).map((line) => line.trim()).filter(Boolean); + return lines.length > 0 && lines.every((line) => configFailureLineMatches( + line, + configFile, + context.args.preparation.project.projectRoot, + context.workspace + )); +} + +function configFailureLineMatches( + line: string, + configFile: string, + projectRoot: string, + workspace: MaterializedPythonTypeWorkspace +): boolean { + try { + const value = JSON.parse(line) as unknown; + const validated = validateMypyRecord(value, 1); + return typeof validated !== "string" && selectedConfigPath(validated.file, configFile, workspace); + } catch { + // Mypy emits human-readable semantic config errors even in JSON output mode. + } + return configPathCandidates(configFile, projectRoot, workspace).some((path) => + line.startsWith(`${path}:`) && /^:\s+\[mypy(?:-[^\]]+)?\]:/u.test(line.slice(path.length)) + ); +} + +function selectedConfigPath( + path: string, + configFile: string, + workspace: MaterializedPythonTypeWorkspace +): boolean { + try { + return repoRelativeMaterializedPath(path, workspace.projectCwd, workspace.root) === configFile; + } catch { + return false; + } +} + +function configPathCandidates( + configFile: string, + projectRoot: string, + workspace: MaterializedPythonTypeWorkspace +): readonly string[] { + const relativeConfig = relativePythonProjectPath(configFile, projectRoot); + const candidates = [configFile, relativeConfig, join(workspace.root, configFile)]; + return [...new Set(candidates.flatMap((path) => [path, path.replaceAll("\\", "/"), path.replaceAll("/", "\\")]))]; +} + +function mypyArgv( + checker: PythonProjectToolProvenance, + preparation: PythonTypeCapabilityPreparation +): readonly string[] { + const prefix = withoutConfigArguments(checker.argv.slice(1)); + const selectedConfig = checker.configFile === undefined + ? isolatedMypyConfigPath + : relativePythonProjectPath(checker.configFile, preparation.project.projectRoot); + const targets = preparation.targets.map((path) => relativePythonProjectPath(path, preparation.project.projectRoot)); + return [ + checker.executable, + ...prefix, + "--config-file", + selectedConfig, + "--output=json", + "--no-color-output", + "--no-pretty", + "--no-error-summary", + "--no-incremental", + "--cache-dir=.opcore-mypy-cache", + ...targets + ]; +} + +function isolatedMypyEnvironment( + input: Record | undefined, + checker: PythonProjectToolProvenance, + workspace: MaterializedPythonTypeWorkspace +): Record { + return isolatedTypeEnvironmentBase({ + input, + executable: checker.executable, + workspace, + extra: { + MYPY_CACHE_DIR: join(workspace.projectCwd, ".opcore-mypy-cache"), + PYTHONPATH: workspace.pythonPathEntries.join(delimiter) + } + }); +} + +function sharedTypeArgs(args: MypyCapabilityArgs): TypeCapabilityArgs { + return { ...args, authority: "mypy" }; +} + +function toMypyExecutionContext( + context: TypeExecutionContext, + args: MypyCapabilityArgs +): MypyExecutionContext { + return { ...context, args }; +} + +function withoutConfigArguments(args: readonly string[]): readonly string[] { + const filtered: string[] = []; + for (let index = 0; index < args.length; index += 1) { + const argument = args[index]; + if (argument === "--config" || argument === "--config-file") { + index += 1; + continue; + } + if (argument.startsWith("--config=") || argument.startsWith("--config-file=")) continue; + filtered.push(argument); + } + return filtered; +} diff --git a/packages/validation-python/src/node-shims.d.ts b/packages/validation-python/src/node-shims.d.ts index a0d053b..a170f0e 100644 --- a/packages/validation-python/src/node-shims.d.ts +++ b/packages/validation-python/src/node-shims.d.ts @@ -1,4 +1,16 @@ declare module "node:child_process" { + import type { Readable, Writable } from "node:stream"; + + export interface ChildProcessWithoutNullStreams { + pid?: number; + stdin: Writable; + stdout: Readable; + stderr: Readable; + kill(signal?: string): boolean; + once(event: "error", listener: (error: Error) => void): this; + once(event: "exit" | "close", listener: (code: number | null, signal: string | null) => void): this; + } + export interface SpawnSyncReturns { status: number | null; signal: string | null; @@ -19,10 +31,31 @@ declare module "node:child_process" { timeout?: number; } ): SpawnSyncReturns; + + export function spawn( + command: string, + args?: readonly string[], + options?: { + cwd?: string; + env?: Record; + detached?: boolean; + stdio?: readonly string[]; + windowsHide?: boolean; + } + ): ChildProcessWithoutNullStreams; +} + +declare module "node:crypto" { + interface Hash { + update(data: string, inputEncoding?: BufferEncoding): Hash; + digest(encoding: "hex"): string; + } + export function createHash(algorithm: string): Hash; } declare module "node:fs" { export function existsSync(path: string): boolean; + export function realpathSync(path: string): string; export function mkdirSync(path: string, options?: { recursive?: boolean }): void; export function mkdtempSync(prefix: string): string; export function rmSync(path: string, options?: { recursive?: boolean; force?: boolean }): void; @@ -30,8 +63,14 @@ declare module "node:fs" { } declare module "node:fs/promises" { + export function access(path: string): Promise; export function copyFile(source: string, destination: string): Promise; + export function lstat(path: string): Promise<{ isFile(): boolean; isSymbolicLink(): boolean }>; export function mkdir(path: string, options?: { recursive?: boolean }): Promise; + export function readFile(path: string, encoding: "utf8"): Promise; + export function readdir(path: string, options: { recursive: true }): Promise; + export function realpath(path: string): Promise; + export function writeFile(path: string, data: string, encoding?: BufferEncoding): Promise; } declare module "node:os" { @@ -39,20 +78,51 @@ declare module "node:os" { } declare module "node:path" { + export const delimiter: string; export function dirname(path: string): string; export function join(...paths: string[]): string; + export function isAbsolute(path: string): boolean; export function relative(from: string, to: string): string; export function resolve(...paths: string[]): string; + export const posix: { + dirname(path: string): string; + join(...paths: string[]): string; + normalize(path: string): string; + }; export const sep: string; } +declare module "node:stream" { + export interface Readable { + on(event: "data", listener: (chunk: Buffer) => void): this; + } + + export interface Writable { + write(chunk: string, encoding?: BufferEncoding): boolean; + end(): void; + on(event: "error", listener: (error: Error) => void): this; + } +} + type BufferEncoding = "utf8"; interface Buffer { + readonly byteLength: number; toString(encoding?: BufferEncoding): string; } +declare namespace NodeJS { + type Signals = string; + + interface ErrnoException extends Error { + code?: string; + } +} + declare const process: { env: Record; + execPath: string; cwd(): string; + kill(pid: number, signal?: string): boolean; + platform: string; }; diff --git a/packages/validation-python/src/process.ts b/packages/validation-python/src/process.ts index b4e5b72..a0fe552 100644 --- a/packages/validation-python/src/process.ts +++ b/packages/validation-python/src/process.ts @@ -1,41 +1,233 @@ -import { spawnSync } from "node:child_process"; +import { spawn, spawnSync, type ChildProcessWithoutNullStreams } from "node:child_process"; -export interface PythonToolRunResult { - ok: boolean; +const defaultMaxOutputBytes = 1024 * 1024; + +interface PythonToolRunBase { + command: string; + args: readonly string[]; + cwd: string; + allowedExitCodes: readonly number[]; exitCode: number | null; + signal: string | null; stdout: string; stderr: string; failureMessage?: string; } +export interface PythonToolExitedResult extends PythonToolRunBase { + termination: "exited"; + ok: boolean; + exitCode: number; + signal: null; +} + +export interface PythonToolTimeoutResult extends PythonToolRunBase { + termination: "timeout"; + ok: false; +} + +export interface PythonToolSignalResult extends PythonToolRunBase { + termination: "signal"; + ok: false; + signal: string; +} + +export interface PythonToolSpawnErrorResult extends PythonToolRunBase { + termination: "spawn_error"; + ok: false; + failureMessage: string; +} + +export type PythonToolRunResult = + | PythonToolExitedResult + | PythonToolTimeoutResult + | PythonToolSignalResult + | PythonToolSpawnErrorResult; + export interface PythonToolRunOptions { cwd?: string; env?: Record; timeoutMs?: number; input?: string; + allowedExitCodes?: readonly number[]; + maxOutputBytes?: number; +} + +interface ProcessCapture { + stdout: string; + stderr: string; + inputFailure?: string; + outputFailure?: string; + spawnFailure?: string; + timedOut: boolean; } -export function runTool( +interface ProcessIdentity { + command: string; + args: readonly string[]; + cwd: string; + allowedExitCodes: readonly number[]; +} + +export async function runTool( command: string, args: readonly string[] = [], options: PythonToolRunOptions = {} -): PythonToolRunResult { - const result = spawnSync(command, args, { - cwd: options.cwd, - env: options.env, - encoding: "utf8", - stdio: [options.input !== undefined ? "pipe" : "ignore", "pipe", "pipe"], - timeout: options.timeoutMs ?? 10000, - input: options.input +): Promise { + const cwd = options.cwd ?? process.cwd(); + const allowedExitCodes = uniqueExitCodes(options.allowedExitCodes ?? [0]); + const identity = { command, args, cwd, allowedExitCodes }; + const capture: ProcessCapture = { stdout: "", stderr: "", timedOut: false }; + let child: ChildProcessWithoutNullStreams; + try { + child = spawn(command, args, { + cwd, + env: options.env, + detached: process.platform !== "win32", + stdio: ["pipe", "pipe", "pipe"], + windowsHide: true + }); + } catch (error) { + return spawnFailure(identity, errorMessage(error)); + } + captureProcessOutput(child, capture, options.maxOutputBytes ?? defaultMaxOutputBytes); + const timeoutMs = options.timeoutMs ?? 10000; + const completionPromise = waitForProcess(child, capture, timeoutMs); + writeProcessInput(child, options.input, capture); + const completion = await completionPromise; + const base = processBase(identity, completion, capture); + return classifyProcessResult(base, capture, timeoutMs); +} + +function waitForProcess( + child: ChildProcessWithoutNullStreams, + capture: ProcessCapture, + timeoutMs: number +): Promise<{ code: number | null; signal: NodeJS.Signals | null }> { + return new Promise((resolve) => { + const timer = setTimeout(() => { + capture.timedOut = true; + terminateProcessTree(child); + }, timeoutMs); + child.once("error", (error) => { + capture.spawnFailure = errorMessage(error); + terminateProcessTree(child); + }); + child.once("exit", () => { + clearTimeout(timer); + terminateProcessTree(child); + }); + child.once("close", (code, signal) => { + clearTimeout(timer); + resolve({ code, signal }); + }); + }); +} + +function captureProcessOutput(child: ChildProcessWithoutNullStreams, capture: ProcessCapture, limit: number): void { + let bytes = 0; + const append = (stream: "stdout" | "stderr", chunk: Buffer): void => { + bytes += chunk.byteLength; + if (bytes > limit) { + capture.outputFailure ??= `Python tool output exceeded ${limit} bytes`; + terminateProcessTree(child); + return; + } + capture[stream] += chunk.toString("utf8"); + }; + child.stdout.on("data", (chunk: Buffer) => append("stdout", chunk)); + child.stderr.on("data", (chunk: Buffer) => append("stderr", chunk)); +} + +function writeProcessInput( + child: ChildProcessWithoutNullStreams, + input: string | undefined, + capture: ProcessCapture +): void { + child.stdin.on("error", (error) => { + if (input === undefined) return; + capture.inputFailure ??= `Python tool stdin write failed: ${errorMessage(error)}`; + terminateProcessTree(child); }); - const failureMessage = - result.error?.message ?? - (result.signal !== null ? `${command} terminated with signal ${result.signal}` : undefined); + if (input !== undefined) child.stdin.write(input, "utf8"); + child.stdin.end(); +} + +function terminateProcessTree(child: ChildProcessWithoutNullStreams): void { + const pid = child.pid; + if (pid === undefined) return; + if (process.platform === "win32") { + spawnSync("taskkill", ["/pid", String(pid), "/t", "/f"], { stdio: "ignore", timeout: 1000 }); + child.kill("SIGKILL"); + return; + } + try { + process.kill(-pid, "SIGKILL"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ESRCH") child.kill("SIGKILL"); + } +} + +function processBase( + identity: ProcessIdentity, + completion: { code: number | null; signal: NodeJS.Signals | null }, + capture: ProcessCapture +): PythonToolRunBase { + return { + command: identity.command, + args: [...identity.args], + cwd: identity.cwd, + allowedExitCodes: identity.allowedExitCodes, + exitCode: completion.code, + signal: completion.signal, + stdout: capture.stdout, + stderr: capture.stderr + }; +} + +function classifyProcessResult( + base: PythonToolRunBase, + capture: ProcessCapture, + timeoutMs: number +): PythonToolRunResult { + if (capture.timedOut) return { ...base, termination: "timeout", ok: false, failureMessage: `${base.command} timed out after ${timeoutMs}ms` }; + const failure = capture.spawnFailure ?? capture.inputFailure ?? capture.outputFailure; + if (failure !== undefined || base.exitCode === null && base.signal === null) { + return { ...base, termination: "spawn_error", ok: false, failureMessage: failure ?? `${base.command} did not report an exit status` }; + } + if (base.signal !== null) { + return { ...base, termination: "signal", ok: false, signal: base.signal, failureMessage: `${base.command} terminated with signal ${base.signal}` }; + } + const exitCode = base.exitCode as number; + const ok = base.allowedExitCodes.includes(exitCode); return { - ok: result.status === 0 && failureMessage === undefined, - exitCode: result.status, - stdout: result.stdout ?? "", - stderr: result.stderr ?? "", - ...(failureMessage !== undefined ? { failureMessage } : {}) + ...base, + termination: "exited", + ok, + exitCode, + signal: null, + ...(ok ? {} : { failureMessage: `${base.command} exited with code ${exitCode}; expected ${base.allowedExitCodes.join(" or ")}` }) }; } + +function spawnFailure( + identity: ProcessIdentity, + failureMessage: string +): PythonToolSpawnErrorResult { + return { + command: identity.command, args: [...identity.args], cwd: identity.cwd, + allowedExitCodes: identity.allowedExitCodes, exitCode: null, signal: null, + stdout: "", stderr: "", termination: "spawn_error", ok: false, failureMessage + }; +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function uniqueExitCodes(exitCodes: readonly number[]): readonly number[] { + if (exitCodes.length === 0 || exitCodes.some((code) => !Number.isInteger(code) || code < 0)) { + throw new Error("Python tool allowedExitCodes must contain non-negative integers"); + } + return [...new Set(exitCodes)].sort((left, right) => left - right); +} diff --git a/packages/validation-python/src/project-config-files.ts b/packages/validation-python/src/project-config-files.ts new file mode 100644 index 0000000..ed16565 --- /dev/null +++ b/packages/validation-python/src/project-config-files.ts @@ -0,0 +1,14 @@ +export const pythonBoundaryFileNames = [ + "pyproject.toml", "Pipfile", "Pipfile.lock", "poetry.lock", "pdm.lock", "uv.lock", "setup.cfg", "setup.py" +] as const; + +const pythonToolConfigFileNames = [ + "pyrightconfig.json", "ruff.toml", ".ruff.toml", "mypy.ini", ".mypy.ini", "pytest.ini", "tox.ini" +] as const; + +export function isRelevantPythonConfig(path: string): boolean { + const name = path.slice(path.lastIndexOf("/") + 1); + return pythonBoundaryFileNames.includes(name as (typeof pythonBoundaryFileNames)[number]) || + /^requirements.*\.txt$/u.test(name) || + pythonToolConfigFileNames.includes(name as (typeof pythonToolConfigFileNames)[number]); +} diff --git a/packages/validation-python/src/project-context.ts b/packages/validation-python/src/project-context.ts new file mode 100644 index 0000000..d244440 --- /dev/null +++ b/packages/validation-python/src/project-context.ts @@ -0,0 +1,220 @@ +import type { + PythonProjectContext, + PythonProjectContextOutcome, + PythonProjectContextReason, + PythonProjectToolKind +} from "@the-open-engine/opcore-contracts"; +import { + PYTHON_PROJECT_CONTEXT_SCHEMA_ID, + validatePythonProjectContext, + validateRepoRelativePath +} from "@the-open-engine/opcore-contracts"; +import { discoverPythonProject } from "./project-discovery.js"; +import { + resolvePythonProjectEnvironment, + type PythonProjectProcessProbe +} from "./environment-resolution.js"; +import { + normalizePythonProjectFingerprintInput, + pythonProjectDigest +} from "./project-fingerprint.js"; +import type { PythonProjectWorkspace } from "./project-workspace.js"; +import { readPythonStaticProjectConfig } from "./static-config.js"; + +export interface ResolvePythonProjectContextsOptions { + repoRoot: string; + targets: readonly string[]; + workspace: PythonProjectWorkspace; + interpreterArgv?: readonly string[]; + toolArgv?: Partial>; + env?: Readonly>; + platform?: string; + architecture?: string; + processProbe?: PythonProjectProcessProbe; + timeoutMs?: number; +} + +export class PythonProjectContextResolutionError extends Error { + readonly code: "path_refused"; + + constructor(message: string) { + super(message); + this.name = "PythonProjectContextResolutionError"; + this.code = "path_refused"; + } +} + +export async function resolvePythonProjectContexts( + options: ResolvePythonProjectContextsOptions +): Promise { + const targets = normalizeTargets(options.targets); + const visibleFiles = [...new Set(await options.workspace.list())].sort(); + const projectInputs = new Map>(); + const contexts: PythonProjectContext[] = []; + for (const target of targets) { + contexts.push(await resolveTarget(options, target, visibleFiles, projectInputs)); + } + return contexts; +} + +export async function resolvePythonProjectContext( + options: Omit & { target: string } +): Promise { + const [context] = await resolvePythonProjectContexts({ ...options, targets: [options.target] }); + if (context === undefined) throw new Error("Python project context target is required"); + return context; +} + +async function resolveTarget( + options: ResolvePythonProjectContextsOptions, + target: string, + visibleFiles: readonly string[], + projectInputs: Map> +): Promise { + const discovery = await discoverPythonProject(options.workspace, target, visibleFiles); + let inputs: ResolvedProjectInputs; + if (discovery.reasons.some(isPathRefusalReason)) { + const config = await readPythonStaticProjectConfig(options.workspace, discovery.projectRoot, visibleFiles); + inputs = { config, environment: refusedEnvironment() }; + } else { + let inputPromise = projectInputs.get(discovery.projectRoot); + if (inputPromise === undefined) { + inputPromise = resolveProjectInputs(options, discovery.projectRoot, visibleFiles); + projectInputs.set(discovery.projectRoot, inputPromise); + } + inputs = await inputPromise; + } + const { config, environment } = inputs; + const reasons = deduplicateReasons([...discovery.reasons, ...config.reasons, ...environment.reasons]); + const evidence = mergeEvidence(discovery.evidence, config); + const projectKey = pythonProjectDigest({ schemaId: PYTHON_PROJECT_CONTEXT_SCHEMA_ID, projectRoot: discovery.projectRoot }); + const targetContent = discovery.reasons.some(isPathRefusalReason) + ? undefined + : await options.workspace.read(target); + const fingerprintInput = { + schemaId: PYTHON_PROJECT_CONTEXT_SCHEMA_ID, + target, + targetContent: targetContent === undefined ? null : pythonProjectDigest(targetContent), + projectRoot: discovery.projectRoot, + sourceRoots: discovery.sourceRoots, + layout: discovery.layout, + configContents: [...config.contents].map(([path, content]) => [path, pythonProjectDigest(content)]), + targetRuntime: config.target, + managers: config.managers, + ...(config.buildSystem === undefined ? {} : { buildSystem: config.buildSystem }), + explicit: { interpreterArgv: options.interpreterArgv, toolArgv: options.toolArgv }, + environment: environment.fingerprintInput + }; + const contextFingerprint = pythonProjectDigest(normalizePythonProjectFingerprintInput( + fingerprintInput, + options.repoRoot, + options.platform ?? process.platform + )); + return validatePythonProjectContext({ + schemaId: PYTHON_PROJECT_CONTEXT_SCHEMA_ID, + schemaVersion: 1, + target, + repositoryRoot: options.repoRoot, + projectRoot: discovery.projectRoot, + projectBoundary: discovery.projectBoundary, + sourceRoots: discovery.sourceRoots, + layout: discovery.layout, + evidence, + targetRuntime: config.target, + managers: config.managers, + ...(config.buildSystem === undefined ? {} : { buildSystem: config.buildSystem }), + ...(environment.interpreter === undefined ? {} : { interpreter: environment.interpreter }), + tools: environment.tools, + projectKey, + contextFingerprint, + outcome: contextOutcome(reasons), + reasons + }); +} + +interface ResolvedProjectInputs { + config: Awaited>; + environment: Awaited>; +} + +async function resolveProjectInputs( + options: ResolvePythonProjectContextsOptions, + projectRoot: string, + visibleFiles: readonly string[] +): Promise { + const config = await readPythonStaticProjectConfig(options.workspace, projectRoot, visibleFiles); + const environment = config.reasons.some(isPathRefusalReason) + ? refusedEnvironment() + : await resolvePythonProjectEnvironment({ + repoRoot: options.repoRoot, + projectRoot, + workspace: options.workspace, + target: config.target, + managers: config.managers, + toolConfigs: config.toolConfigs, + ...(config.buildSystem === undefined ? {} : { buildSystem: config.buildSystem }), + ...(options.interpreterArgv === undefined ? {} : { interpreterArgv: options.interpreterArgv }), + ...(options.toolArgv === undefined ? {} : { toolArgv: options.toolArgv }), + ...(options.env === undefined ? {} : { env: options.env }), + ...(options.platform === undefined ? {} : { platform: options.platform }), + ...(options.architecture === undefined ? {} : { architecture: options.architecture }), + ...(options.processProbe === undefined ? {} : { processProbe: options.processProbe }), + ...(options.timeoutMs === undefined ? {} : { timeoutMs: options.timeoutMs }) + }); + return { config, environment }; +} + +function refusedEnvironment(): Awaited> { + return { tools: [], reasons: [], fingerprintInput: { refused: true } }; +} + +function isPathRefusalReason(reason: PythonProjectContextReason): boolean { + return reason.code === "symlink_refused" || reason.code === "path_refused" || reason.code === "ambiguous_path"; +} + +function normalizeTargets(targets: readonly string[]): readonly string[] { + if (!Array.isArray(targets) || targets.length === 0) throw new Error("Python project context targets must be a non-empty array"); + return [...new Set(targets.map((target) => { + try { + const normalized = validateRepoRelativePath(target.replaceAll("\\", "/")); + if (!/\.pyi?$/u.test(normalized)) throw new Error(`Python project context target must be .py or .pyi: ${normalized}`); + return normalized; + } catch (error) { + throw new PythonProjectContextResolutionError(error instanceof Error ? error.message : String(error)); + } + }))].sort(); +} + +function contextOutcome(reasons: readonly PythonProjectContextReason[]): PythonProjectContextOutcome { + if (reasons.length === 0) return "resolved"; + if (reasons.some((reason) => ["conflicting_managers", "conflicting_targets", "symlink_refused", "path_refused", "ambiguous_path"].includes(reason.code))) { + return "ambiguous"; + } + if (reasons.some((reason) => ["interpreter_unavailable", "incompatible_interpreter", "unsupported_target", "unsupported_platform"].includes(reason.code))) { + return "unsupported"; + } + return "degraded"; +} + +function deduplicateReasons(reasons: readonly PythonProjectContextReason[]): readonly PythonProjectContextReason[] { + const byKey = new Map(); + for (const reason of reasons) byKey.set(`${reason.code}\0${reason.path ?? ""}\0${reason.tool ?? ""}\0${reason.message}`, reason); + return [...byKey.values()].sort((left, right) => + `${left.code}\0${left.path ?? ""}\0${left.tool ?? ""}`.localeCompare(`${right.code}\0${right.path ?? ""}\0${right.tool ?? ""}`) + ); +} + +function mergeEvidence( + discovered: PythonProjectContext["evidence"], + config: Awaited> +): PythonProjectContext["evidence"] { + const entries = [...discovered]; + for (const path of config.contents.keys()) { + const lock = /(?:\.lock|Pipfile\.lock)$/u.test(path); + const requirements = /requirements.*\.txt$/u.test(path); + const build = path.endsWith("pyproject.toml") && config.buildSystem?.configFile === path; + entries.push({ path, role: lock ? "lock" : requirements ? "requirements" : build ? "build" : "config" }); + } + const byKey = new Map(entries.map((entry) => [`${entry.path}\0${entry.role}`, entry])); + return [...byKey.values()].sort((left, right) => `${left.path}\0${left.role}`.localeCompare(`${right.path}\0${right.role}`)); +} diff --git a/packages/validation-python/src/project-discovery.ts b/packages/validation-python/src/project-discovery.ts new file mode 100644 index 0000000..e04e840 --- /dev/null +++ b/packages/validation-python/src/project-discovery.ts @@ -0,0 +1,130 @@ +import type { + PythonProjectContextReason, + PythonProjectFileEvidence, + PythonProjectLayoutEvidence +} from "@the-open-engine/opcore-contracts"; +import { pythonBoundaryFileNames } from "./project-config-files.js"; +import type { PythonProjectWorkspace } from "./project-workspace.js"; + +export interface PythonProjectDiscovery { + projectRoot: string; + projectBoundary: string; + sourceRoots: readonly string[]; + layout: PythonProjectLayoutEvidence; + evidence: readonly PythonProjectFileEvidence[]; + reasons: readonly PythonProjectContextReason[]; +} + +export async function discoverPythonProject( + workspace: PythonProjectWorkspace, + target: string, + visibleFiles: readonly string[] +): Promise { + const ancestors = ancestorDirectories(dirname(target)); + let projectRoot = "."; + let boundaryFiles: string[] = []; + for (const directory of ancestors) { + const direct = directChildren(directory, visibleFiles); + const markers = direct.filter(isBoundaryMarker); + if (markers.length === 0) continue; + projectRoot = directory; + boundaryFiles = markers; + break; + } + + const reasons: PythonProjectContextReason[] = []; + if (boundaryFiles.length === 0) { + reasons.push({ code: "missing_config", message: `No Python project boundary/config marker owns ${target}` }); + } + for (const path of [target, ...boundaryFiles]) { + const resolved = await workspace.realpath(path); + if (resolved.unavailable) { + reasons.push({ code: "ambiguous_path", path, message: `Python project path realpath evidence is unavailable: ${path}` }); + } else if (resolved.symlink || resolved.path !== path) { + reasons.push({ code: "symlink_refused", path, message: `Symlinked Python project path is ambiguous: ${path}` }); + } + } + const layout = await layoutEvidence(workspace, projectRoot, target); + const evidence: PythonProjectFileEvidence[] = [ + ...boundaryFiles.map((path) => ({ path, role: "boundary" as const })), + ...layout.paths.filter((path) => path !== ".").map((path) => ({ path, role: "layout" as const })) + ].sort(compareEvidence); + return { projectRoot, projectBoundary: projectRoot, sourceRoots: sourceRoots(projectRoot, target), layout, evidence, reasons }; +} + +async function layoutEvidence( + workspace: PythonProjectWorkspace, + projectRoot: string, + target: string +): Promise { + const roots = sourceRoots(projectRoot, target); + const kinds = new Set<"flat" | "src" | "namespace" | "stub" | "package">(); + if (roots.some((root) => basename(root) === "src")) kinds.add("src"); + else kinds.add("flat"); + if (target.endsWith(".pyi")) kinds.add("stub"); + const sourceRoot = roots.find((root) => target === root || target.startsWith(`${root}/`)) ?? projectRoot; + const packageDirs = packageDirectoriesWithin(sourceRoot, dirname(target)); + let packageMarker = false; + for (const directory of packageDirs) { + if (await workspace.exists(`${directory}/__init__.py`) || await workspace.exists(`${directory}/__init__.pyi`)) { + packageMarker = true; + break; + } + } + if (packageMarker) kinds.add("package"); + else if (packageDirs.length > 0) kinds.add("namespace"); + return { kinds: [...kinds].sort(), paths: roots }; +} + +function packageDirectoriesWithin(sourceRoot: string, targetDirectory: string): readonly string[] { + if (targetDirectory === sourceRoot) return []; + const prefix = sourceRoot === "." ? "" : `${sourceRoot}/`; + if (!targetDirectory.startsWith(prefix)) return []; + const relative = targetDirectory.slice(prefix.length); + if (relative.length === 0) return []; + const parts = relative.split("/").filter(Boolean); + const directories: string[] = []; + for (let length = parts.length; length > 0; length -= 1) { + const child = parts.slice(0, length).join("/"); + directories.push(sourceRoot === "." ? child : `${sourceRoot}/${child}`); + } + return directories; +} + +function sourceRoots(projectRoot: string, target: string): readonly string[] { + const src = projectRoot === "." ? "src" : `${projectRoot}/src`; + if (target.startsWith(`${src}/`)) return [src]; + return [projectRoot]; +} + +function isBoundaryMarker(path: string): boolean { + const name = basename(path); + return pythonBoundaryFileNames.includes(name as (typeof pythonBoundaryFileNames)[number]) || /^requirements.*\.txt$/u.test(name); +} + +function directChildren(root: string, files: readonly string[]): readonly string[] { + const prefix = root === "." ? "" : `${root}/`; + return files.filter((path) => path.startsWith(prefix) && !path.slice(prefix.length).includes("/")); +} + +function ancestorDirectories(start: string): readonly string[] { + if (start === ".") return ["."]; + const parts = start.split("/").filter(Boolean); + const values: string[] = []; + for (let length = parts.length; length > 0; length -= 1) values.push(parts.slice(0, length).join("/")); + values.push("."); + return values; +} + +function dirname(path: string): string { + const index = path.lastIndexOf("/"); + return index < 0 ? "." : path.slice(0, index); +} + +function basename(path: string): string { + return path.slice(path.lastIndexOf("/") + 1); +} + +function compareEvidence(left: PythonProjectFileEvidence, right: PythonProjectFileEvidence): number { + return `${left.path}\0${left.role}`.localeCompare(`${right.path}\0${right.role}`); +} diff --git a/packages/validation-python/src/project-fingerprint.ts b/packages/validation-python/src/project-fingerprint.ts new file mode 100644 index 0000000..9a97a38 --- /dev/null +++ b/packages/validation-python/src/project-fingerprint.ts @@ -0,0 +1,84 @@ +import { createHash } from "node:crypto"; + +export function pythonProjectDigest(value: unknown): string { + return `sha256:${createHash("sha256").update(stableJson(value), "utf8").digest("hex")}`; +} + +export function normalizePythonProjectFingerprintInput( + value: unknown, + repoRoot: string, + platform: string +): unknown { + const normalizedRoot = normalizeRoot(repoRoot, platform); + return normalizeFingerprintValue(value, normalizedRoot, platform); +} + +function stableJson(value: unknown): string { + return JSON.stringify(sortJson(value)); +} + +function sortJson(value: unknown): unknown { + if (Array.isArray(value)) return value.map(sortJson); + if (value === null || typeof value !== "object") return value; + return Object.fromEntries( + Object.entries(value as Record) + .filter(([, entry]) => entry !== undefined) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, entry]) => [key, sortJson(entry)]) + ); +} + +function normalizeFingerprintValue(value: unknown, repoRoot: string, platform: string): unknown { + if (Array.isArray(value)) { + return value.map((entry) => normalizeFingerprintValue(entry, repoRoot, platform)); + } + if (value === null || typeof value !== "object") { + return typeof value === "string" ? replaceRepoRoot(value, repoRoot, platform) : value; + } + return Object.fromEntries( + Object.entries(value as Record) + .map(([key, entry]) => [key, normalizeFingerprintValue(entry, repoRoot, platform)]) + ); +} + +function normalizeRoot(repoRoot: string, platform: string): string { + const normalized = normalizeSeparators(repoRoot, platform); + if (normalized.length > 1) { + let end = normalized.length; + while (end > 1 && normalized.charCodeAt(end - 1) === 47) end -= 1; + return end === normalized.length ? normalized : normalized.slice(0, end); + } + return normalized; +} + +function replaceRepoRoot(value: string, repoRoot: string, platform: string): string { + const normalized = normalizeSeparators(value, platform); + if (repoRoot.length === 0) return normalized; + if (repoRoot === "/") { + return normalized.startsWith("/") ? `$REPO${normalized}` : normalized; + } + const comparableValue = platform === "win32" ? normalized.toLowerCase() : normalized; + const comparableRoot = platform === "win32" ? repoRoot.toLowerCase() : repoRoot; + let cursor = 0; + let result = ""; + while (cursor < normalized.length) { + const index = comparableValue.indexOf(comparableRoot, cursor); + if (index < 0) return result + normalized.slice(cursor); + const preceding = index === 0 ? undefined : normalized[index - 1]; + const following = normalized[index + repoRoot.length]; + const startsPath = preceding === undefined || /[\s=,:;"'(\[]/u.test(preceding); + const endsRoot = following === undefined || following === "/"; + if (!startsPath || !endsRoot) { + result += normalized.slice(cursor, index + repoRoot.length); + cursor = index + repoRoot.length; + continue; + } + result += `${normalized.slice(cursor, index)}$REPO`; + cursor = index + repoRoot.length; + } + return result; +} + +function normalizeSeparators(value: string, platform: string): string { + return platform === "win32" ? value.replaceAll("\\", "/") : value; +} diff --git a/packages/validation-python/src/project-workspace.ts b/packages/validation-python/src/project-workspace.ts new file mode 100644 index 0000000..8d801c0 --- /dev/null +++ b/packages/validation-python/src/project-workspace.ts @@ -0,0 +1,189 @@ +import { access, lstat, readFile, readdir, realpath } from "node:fs/promises"; +import { realpathSync } from "node:fs"; +import { relative, resolve, sep } from "node:path"; +import { validateRepoRelativePath } from "@the-open-engine/opcore-contracts"; +import type { ValidationFileView } from "@the-open-engine/opcore-validation"; +import { isRelevantPythonConfig } from "./project-config-files.js"; + +export interface PythonProjectWorkspaceRealpath { + path: string; + symlink: boolean; + unavailable?: boolean; +} + +export interface PythonProjectWorkspace { + read(path: string): Promise; + list(): Promise; + exists(path: string): Promise; + realpath(path: string): Promise; + executableExists(path: string): Promise; +} + +export function createValidationFileViewPythonWorkspace( + fileView: ValidationFileView, + executableExists?: PythonProjectWorkspace["executableExists"], + fullWorkspace?: PythonProjectWorkspace +): PythonProjectWorkspace { + const executableAvailable = executableExists ?? fullWorkspace?.executableExists ?? nodeExecutableExists; + return { + read: async (path) => { + const result = await fileView.readAfter(validateRepoRelativePath(path)); + return result.status === "found" ? result.content : undefined; + }, + list: async () => { + const fileViewPaths = await fileView.listVisibleFiles(); + const fullWorkspacePaths = fullWorkspace === undefined ? [] : await fullWorkspace.list(); + const fileViewPathSet = new Set(fileViewPaths); + const fullWorkspacePathSet = new Set(fullWorkspacePaths); + const candidates = [...new Set([...fileViewPaths, ...fullWorkspacePaths])] + .filter(isPythonProjectWorkspaceInput) + .sort(); + const visible: string[] = []; + for (const path of candidates) { + if (fileView.defaultReadState === "after") { + const overlay = fileView.overlayFor(path); + if (overlay?.action === "delete") continue; + if (overlay?.action === "write" || fileViewPathSet.has(path) || fullWorkspacePathSet.has(path)) { + visible.push(path); + continue; + } + } + if (await fileView.exists(path)) visible.push(path); + } + return visible; + }, + exists: (path) => fileView.exists(validateRepoRelativePath(path)), + realpath: async (path) => { + if (path === ".") { + return fullWorkspace === undefined + ? { path: ".", symlink: false, unavailable: true } + : fullWorkspace.realpath(path); + } + const normalized = validateRepoRelativePath(path); + if (fullWorkspace === undefined) return { path: normalized, symlink: false, unavailable: true }; + const baseline = await fullWorkspace.realpath(normalized); + if (!fileView.hasOverlay(normalized)) return baseline; + if (baseline.symlink) return baseline; + if (baseline.unavailable && await fullWorkspace.exists(normalized)) return baseline; + return { path: normalized, symlink: false }; + }, + executableExists: executableAvailable + }; +} +function isPythonProjectWorkspaceInput(path: string): boolean { + return /\.pyi?$/u.test(path) || isRelevantPythonConfig(path); +} + +export function createNodePythonProjectWorkspace(repoRoot: string): PythonProjectWorkspace { + const canonicalRoot = realpathSync(resolve(repoRoot)); + let listed: Promise | undefined; + return { + read: async (path) => { + const absolute = resolveRepoPath(canonicalRoot, path); + try { + return await readFile(absolute, "utf8"); + } catch (error) { + if (isMissing(error)) return undefined; + throw error; + } + }, + list: () => { + listed ??= listNodeWorkspace(canonicalRoot); + return listed; + }, + exists: async (path) => { + try { + await access(resolveRepoPath(canonicalRoot, path)); + return true; + } catch (error) { + if (isMissing(error)) return false; + throw error; + } + }, + realpath: async (path) => { + if (path === ".") return { path: ".", symlink: false }; + const normalized = validateRepoRelativePath(path); + const absolute = resolveRepoPath(canonicalRoot, normalized); + try { + const info = await lstat(absolute); + const resolved = await realpath(absolute); + const relativePath = repoRelativeOrUndefined(canonicalRoot, resolved); + if (relativePath === undefined) return { path: normalized, symlink: true }; + return { path: relativePath, symlink: info.isSymbolicLink() || relativePath !== normalized }; + } catch (error) { + if (isMissing(error)) return { path: normalized, symlink: false }; + throw error; + } + }, + executableExists: nodeExecutableExists + }; +} + +async function listNodeWorkspace(repoRoot: string): Promise { + const entries = await readdir(repoRoot, { recursive: true }); + const paths: string[] = []; + for (const entry of entries) { + const normalized = String(entry).replaceAll("\\", "/"); + if (skipPath(normalized)) continue; + const absolute = resolve(repoRoot, normalized); + let info: Awaited>; + try { + info = await lstat(absolute); + } catch (error) { + if (isMissing(error)) continue; + throw error; + } + if (info.isFile() || info.isSymbolicLink()) paths.push(validateRepoRelativePath(normalized)); + } + return [...new Set(paths)].sort(); +} + +async function nodeExecutableExists(path: string): Promise { + if (!isPathLike(path)) return true; + try { + await access(path); + return true; + } catch (error) { + if (isMissing(error)) return false; + throw error; + } +} + +function resolveRepoPath(repoRoot: string, path: string): string { + const normalized = validateRepoRelativePath(path); + const absolute = resolve(repoRoot, normalized); + toRepoRelative(repoRoot, absolute); + return absolute; +} + +function toRepoRelative(repoRoot: string, absolute: string): string { + const value = repoRelativeOrUndefined(repoRoot, absolute); + if (value === undefined) { + throw new Error(`Python project workspace path escapes repository: ${absolute}`); + } + return value; +} + +function repoRelativeOrUndefined(repoRoot: string, absolute: string): string | undefined { + const value = relative(repoRoot, absolute); + if (value === "" || value === ".." || value.startsWith(`..${sep}`)) return undefined; + return validateRepoRelativePath(value.replaceAll("\\", "/")); +} + +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__", + ".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")); +} + +function isPathLike(command: string): boolean { + return command.includes("/") || command.includes("\\") || /^[A-Za-z]:/u.test(command); +} + +function isMissing(error: unknown): boolean { + const code = (error as { code?: unknown } | undefined)?.code; + return code === "ENOENT" || code === "ENOTDIR"; +} diff --git a/packages/validation-python/src/protocol-validation.ts b/packages/validation-python/src/protocol-validation.ts new file mode 100644 index 0000000..573a7df --- /dev/null +++ b/packages/validation-python/src/protocol-validation.ts @@ -0,0 +1,12 @@ +export function isProtocolRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +export function hasExactProtocolKeys(value: Record, keys: readonly string[]): boolean { + return hasOnlyProtocolKeys(value, keys) && Object.keys(value).length === keys.length; +} + +export function hasOnlyProtocolKeys(value: Record, keys: readonly string[]): boolean { + const allowed = new Set(keys); + return Object.keys(value).every((key) => allowed.has(key)); +} diff --git a/packages/validation-python/src/pyright-config-values.ts b/packages/validation-python/src/pyright-config-values.ts new file mode 100644 index 0000000..21ad5c2 --- /dev/null +++ b/packages/validation-python/src/pyright-config-values.ts @@ -0,0 +1,65 @@ +import { parse, parseTree, type ParseError } from "jsonc-parser"; +import { posix } from "node:path"; +import { parsePythonToml, tomlTableAt } from "./toml-config.js"; +import { duplicateJsonObjectKey } from "./strict-json.js"; + +export function isPyrightConfigPath(path: string): boolean { + return path.endsWith("pyrightconfig.json") || path.endsWith("pyproject.toml"); +} + +export function parsePyrightConfig(path: string, content: string): Record | string { + if (path.endsWith("pyproject.toml")) { + try { + const document = parsePythonToml(content); + const section = tomlTableAt(document, ["tool", "pyright"]); + return section ?? "pyproject.toml has no [tool.pyright] table"; + } catch { + return "Pyright TOML configuration is malformed"; + } + } + try { + const errors: ParseError[] = []; + const value = parse(content, errors, { allowTrailingComma: true, disallowComments: false }) as unknown; + if (errors.length > 0 || !isObjectTable(value)) return "Pyright JSONC configuration is malformed"; + const treeErrors: ParseError[] = []; + const tree = parseTree(content, treeErrors, { allowTrailingComma: true, disallowComments: false }); + if (tree === undefined || treeErrors.length > 0) return "Pyright JSONC configuration is malformed"; + const duplicate = duplicateJsonObjectKey(tree); + return duplicate === undefined ? value : `Pyright JSONC configuration has duplicate key ${duplicate}`; + } catch { + return "Pyright JSONC configuration is malformed or excessively nested"; + } +} + +export function resolveConfiguredPath( + configPath: string, + configured: string, + key: string +): { path: string } | string { + const value = configured.trim().replaceAll("\\", "/"); + if (value.length === 0 || value.includes("\0")) return `Pyright ${key} has an invalid path`; + if (isAbsolutePath(value)) return `Pyright ${key} must not use an absolute path`; + if (/[$%{}]/u.test(value) || value.includes("://")) return `Pyright ${key} must not use host environment or URI expansion`; + if (hasUnsafePathSegment(value)) return `Pyright ${key} must not use parent traversal`; + const base = posix.dirname(configPath); + const normalized = posix.normalize(posix.join(base, value)); + if (normalized === "." && key === "extends") { + return "Pyright extends must name a repo-relative configuration file"; + } + if (normalized === ".." || normalized.startsWith("../")) { + return `Pyright ${key} escapes the repository`; + } + return { path: normalized }; +} + +function isObjectTable(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function isAbsolutePath(value: string): boolean { + return value.startsWith("/") || /^[A-Za-z]:\//u.test(value) || value.startsWith("//") || value.startsWith("~"); +} + +function hasUnsafePathSegment(value: string): boolean { + return value.split("/").includes(".."); +} diff --git a/packages/validation-python/src/pyright-config.ts b/packages/validation-python/src/pyright-config.ts new file mode 100644 index 0000000..046ca10 --- /dev/null +++ b/packages/validation-python/src/pyright-config.ts @@ -0,0 +1,259 @@ +import type { PythonProjectContext } from "@the-open-engine/opcore-contracts"; +import type { ValidationFileView } from "@the-open-engine/opcore-validation"; +import { + isPyrightConfigPath, + parsePyrightConfig, + resolveConfiguredPath +} from "./pyright-config-values.js"; +import type { PythonProjectWorkspace } from "./project-workspace.js"; + +export interface PyrightConfigSemantics { + configPaths: readonly string[]; + moduleSearchRoots: readonly string[]; + invalidConfigMessages: readonly string[]; +} + +interface ConfigDocument { + path: string; + value: Record; +} + +interface ConfigClosure { + fileView: ValidationFileView; + workspace?: PythonProjectWorkspace; + visited: Set; + documents: ConfigDocument[]; + failures: string[]; +} + +interface RootCollection { + configPath: string; + value: Record; + roots: Set; + configuredRoots: Set; + failures: string[]; +} + +const pathArrayKeys = ["include", "exclude", "ignore", "extraPaths"] as const; +const pathScalarKeys = ["stubPath", "typeshedPath", "venvPath"] as const; + +export async function resolvePyrightConfigSemantics( + fileView: ValidationFileView, + project: PythonProjectContext, + selectedConfigPaths: readonly string[], + workspace?: PythonProjectWorkspace +): Promise { + const selected = selectedConfigPaths.filter(isPyrightConfigPath); + if (selected.length !== 1) { + return { + configPaths: uniqueSorted(selectedConfigPaths), + moduleSearchRoots: uniqueSorted([...project.sourceRoots, project.projectRoot]), + invalidConfigMessages: [selected.length === 0 + ? `Pyright authority for ${project.projectRoot} requires pyrightconfig.json or [tool.pyright] in pyproject.toml` + : `Pyright authority for ${project.projectRoot} resolved multiple project configurations`] + }; + } + + const documents: ConfigDocument[] = []; + const failures: string[] = []; + await readConfigClosure({ fileView, workspace, visited: new Set(), documents, failures }, selected[0], []); + const roots = new Set([...project.sourceRoots, project.projectRoot]); + const configuredRoots = new Set(); + for (const document of documents) collectSearchRoots(document, roots, configuredRoots, failures); + if (workspace !== undefined) { + await validateConfiguredRoots(workspace, project.projectRoot, configuredRoots, failures); + } + return { + configPaths: uniqueSorted(documents.map((document) => document.path)), + moduleSearchRoots: uniqueSorted([...roots]), + invalidConfigMessages: uniqueSorted(failures) + }; +} + +async function readConfigClosure( + closure: ConfigClosure, + path: string, + stack: readonly string[] +): Promise { + if (stack.includes(path)) { + closure.failures.push(`${path}: Pyright extends cycle: ${[...stack, path].join(" -> ")}`); + return; + } + if (closure.visited.has(path)) return; + closure.visited.add(path); + const parsed = await readConfigDocument(closure, path); + if (parsed === undefined) return; + closure.documents.push({ path, value: parsed }); + const extendedPath = resolveExtendedConfigPath(path, parsed, closure.failures); + if (extendedPath === undefined) return; + await readConfigClosure(closure, extendedPath, [...stack, path]); +} + +async function readConfigDocument( + closure: ConfigClosure, + path: string +): Promise | undefined> { + const state = await closure.fileView.readAfter(path); + if (state.status !== "found") { + closure.failures.push(`${path}: Pyright configuration is missing from the exact after-state`); + return undefined; + } + if (closure.workspace !== undefined && !await validateConfigRealpath(closure.workspace, path, closure.failures)) { + return undefined; + } + const parsed = parsePyrightConfig(path, state.content); + if (typeof parsed === "string") { + closure.failures.push(`${path}: ${parsed}`); + return undefined; + } + return parsed; +} + +async function validateConfigRealpath( + workspace: PythonProjectWorkspace, + path: string, + failures: string[] +): Promise { + const resolved = await workspace.realpath(path); + if (resolved.unavailable) failures.push(`${path}: Pyright configuration realpath evidence is unavailable`); + else if (resolved.symlink || resolved.path !== path) { + failures.push(`${path}: Symlinked or escaping Pyright configuration is refused`); + } + return !resolved.unavailable && !resolved.symlink && resolved.path === path; +} + +function resolveExtendedConfigPath( + path: string, + parsed: Record, + failures: string[] +): string | undefined { + const extend = parsed.extends; + if (extend === undefined) return; + if (typeof extend !== "string" || extend.trim().length === 0) { + failures.push(`${path}: Pyright extends must be a non-empty repo-relative path`); + return; + } + const resolved = resolveConfiguredPath(path, extend, "extends"); + if (typeof resolved === "string") { + failures.push(`${path}: ${resolved}`); + return; + } + return resolved.path; +} + +function collectSearchRoots( + document: ConfigDocument, + roots: Set, + configuredRoots: Set, + failures: string[] +): void { + const collection = { configPath: document.path, value: document.value, roots, configuredRoots, failures }; + collectPathArrayRoots(collection); + collectPathScalarRoots(collection); + validateVenvName(collection); + collectExecutionEnvironmentRoots(collection); +} + +function collectPathArrayRoots(collection: RootCollection): void { + for (const key of pathArrayKeys) { + const value = collection.value[key]; + if (value === undefined) continue; + if (!Array.isArray(value)) { + collection.failures.push(`${collection.configPath}: Pyright ${key} must be an array`); + continue; + } + for (const path of value) { + if (key === "extraPaths") addSearchRoot(collection, key, path); + else validateConfiguredPattern(collection, key, path); + } + } +} + +function collectPathScalarRoots(collection: RootCollection): void { + for (const key of pathScalarKeys) { + const value = collection.value[key]; + if (value !== undefined) addSearchRoot(collection, key, value); + } +} + +function validateVenvName(collection: RootCollection): void { + const venv = collection.value.venv; + if (venv !== undefined && (typeof venv !== "string" || !/^(?!\.{1,2}$)[A-Za-z0-9._-]+$/u.test(venv))) { + collection.failures.push(`${collection.configPath}: Pyright venv must be a safe environment name`); + } +} + +function collectExecutionEnvironmentRoots(collection: RootCollection): void { + const environments = collection.value.executionEnvironments; + if (environments === undefined) return; + if (!Array.isArray(environments)) { + collection.failures.push(`${collection.configPath}: Pyright executionEnvironments must be an array`); + return; + } + for (const [index, value] of environments.entries()) { + if (!isObjectTable(value)) { + collection.failures.push(`${collection.configPath}: Pyright executionEnvironments[${index}] must be an object`); + continue; + } + addSearchRoot(collection, `executionEnvironments[${index}].root`, value.root); + if (value.extraPaths !== undefined) { + if (!Array.isArray(value.extraPaths)) { + collection.failures.push(`${collection.configPath}: Pyright executionEnvironments[${index}].extraPaths must be an array`); + } else { + for (const path of value.extraPaths) { + addSearchRoot(collection, `executionEnvironments[${index}].extraPaths`, path); + } + } + } + } +} + +function addSearchRoot(collection: RootCollection, key: string, value: unknown): void { + if (typeof value !== "string" || value.trim().length === 0) { + collection.failures.push(`${collection.configPath}: Pyright ${key} must contain non-empty paths`); + return; + } + const resolved = resolveConfiguredPath(collection.configPath, value, key); + if (typeof resolved === "string") { + collection.failures.push(`${collection.configPath}: ${resolved}`); + return; + } + collection.roots.add(resolved.path); + collection.configuredRoots.add(resolved.path); +} + +function validateConfiguredPattern(collection: RootCollection, key: string, value: unknown): void { + if (typeof value !== "string" || value.trim().length === 0) { + collection.failures.push(`${collection.configPath}: Pyright ${key} must contain non-empty paths`); + return; + } + const resolved = resolveConfiguredPath(collection.configPath, value, key); + if (typeof resolved === "string") collection.failures.push(`${collection.configPath}: ${resolved}`); +} + +async function validateConfiguredRoots( + workspace: PythonProjectWorkspace, + projectRoot: string, + configuredRoots: ReadonlySet, + failures: string[] +): Promise { + for (const path of [...configuredRoots].sort()) { + // The canonical project root is already confined by project discovery. + // Host workspace APIs expose file identities, not directory realpath entries. + if (path === projectRoot) continue; + const resolved = await workspace.realpath(path); + // Configured roots are recreated from individually validated source files in + // the isolated workspace. ASP hosts need not expose directory identities. + if (!resolved.unavailable && (resolved.symlink || resolved.path !== path)) { + failures.push(`${path}: Symlinked or escaping Pyright configured path is refused`); + } + } +} + +function isObjectTable(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function uniqueSorted(values: readonly string[]): readonly string[] { + return [...new Set(values)].sort(); +} diff --git a/packages/validation-python/src/pyright-output.ts b/packages/validation-python/src/pyright-output.ts new file mode 100644 index 0000000..9aa0189 --- /dev/null +++ b/packages/validation-python/src/pyright-output.ts @@ -0,0 +1,260 @@ +import type { + PythonValidationCapabilityToolProvenance, + ValidationDiagnostic +} from "@the-open-engine/opcore-contracts"; +import { parse, parseTree, type ParseError } from "jsonc-parser"; +import { diagnostic, sortDiagnostics } from "./diagnostics.js"; +import { duplicateJsonObjectKey } from "./strict-json.js"; +import { + repoRelativeMaterializedPath, + type MaterializedPythonTypeWorkspace +} from "./type-capability-run.js"; + +export type ParsedPyrightOutput = + | { ok: true; diagnostics: readonly ValidationDiagnostic[]; summary: PyrightOutputSummary } + | { ok: false; message: string }; + +export interface PyrightOutputSummary { + filesAnalyzed: number; + errorCount: number; + warningCount: number; + informationCount: number; + timeInSec: number; +} + +interface PyrightPosition { + line: number; + character: number; +} + +interface PyrightDiagnosticRecord { + file: string; + severity: "error" | "warning" | "information"; + message: string; + rule?: string; + range: { start: PyrightPosition; end: PyrightPosition }; +} + +type PyrightDiagnosticIdentity = Omit; + +export function parsePyrightJsonOutput( + stdout: string, + workspace: MaterializedPythonTypeWorkspace, + tool: PythonValidationCapabilityToolProvenance +): ParsedPyrightOutput { + try { + const errors: ParseError[] = []; + const value = parse(stdout, errors, { allowTrailingComma: false, disallowComments: true }) as unknown; + if (errors.length > 0 || !isTable(value)) return { ok: false, message: "pyright emitted malformed or truncated JSON" }; + const treeErrors: ParseError[] = []; + const tree = parseTree(stdout, treeErrors, { allowTrailingComma: false, disallowComments: true }); + if (tree === undefined || treeErrors.length > 0) return { ok: false, message: "pyright emitted malformed or truncated JSON" }; + const duplicateKey = duplicateJsonObjectKey(tree); + if (duplicateKey !== undefined) return { ok: false, message: `pyright JSON has duplicate key ${duplicateKey}` }; + return validatePyrightPayload(value, workspace, tool); + } catch { + return { ok: false, message: "pyright emitted malformed or excessively nested JSON" }; + } +} + +function validatePyrightPayload( + value: Record, + workspace: MaterializedPythonTypeWorkspace, + tool: PythonValidationCapabilityToolProvenance +): ParsedPyrightOutput { + if (typeof value.version !== "string" || value.version.length === 0) { + return { ok: false, message: "pyright JSON has invalid version" }; + } + if (tool.version === undefined || value.version !== tool.version) { + return { ok: false, message: `pyright output version ${String(value.version)} does not match selected tool version ${tool.version ?? "unknown"}` }; + } + if (!Array.isArray(value.generalDiagnostics)) { + return { ok: false, message: "pyright JSON generalDiagnostics must be an array" }; + } + const summary = validateSummary(value.summary); + if (typeof summary === "string") return { ok: false, message: summary }; + const normalized = normalizePyrightDiagnostics(value.generalDiagnostics, workspace, tool); + if (!normalized.ok) return normalized; + if (!summaryMatchesDiagnostics(summary, normalized.severityCounts)) { + return { ok: false, message: "pyright JSON summary counts do not match generalDiagnostics" }; + } + return { ok: true, diagnostics: sortDiagnostics(normalized.diagnostics), summary }; +} + +interface NormalizedPyrightDiagnostics { + ok: true; + diagnostics: readonly ValidationDiagnostic[]; + severityCounts: Record; +} + +type NormalizedDiagnosticPath = + | { ok: true; path: string } + | { ok: false; message: string }; + +function normalizePyrightDiagnostics( + records: readonly unknown[], + workspace: MaterializedPythonTypeWorkspace, + tool: PythonValidationCapabilityToolProvenance +): NormalizedPyrightDiagnostics | { ok: false; message: string } { + const diagnostics: ValidationDiagnostic[] = []; + const identities = new Set(); + const diagnosticSeverities = new Map(); + const severityCounts = { error: 0, warning: 0, information: 0 }; + for (let index = 0; index < records.length; index += 1) { + const record = validateDiagnosticRecord(records[index], index + 1); + if (typeof record === "string") return { ok: false, message: record }; + const normalizedPath = normalizeDiagnosticPath(record, index + 1, workspace); + if (!normalizedPath.ok) return normalizedPath; + const { path } = normalizedPath; + const identity = JSON.stringify([path, record.severity, record.message, record.rule, record.range]); + if (identities.has(identity)) return { ok: false, message: `pyright JSON contains duplicate diagnostic ${index + 1}` }; + identities.add(identity); + const semanticIdentity = JSON.stringify([path, record.message, record.rule, record.range]); + const earlierSeverity = diagnosticSeverities.get(semanticIdentity); + if (earlierSeverity !== undefined && earlierSeverity !== record.severity) { + return { ok: false, message: `pyright JSON contains contradictory diagnostic ${index + 1}` }; + } + diagnosticSeverities.set(semanticIdentity, record.severity); + severityCounts[record.severity] += 1; + diagnostics.push(pyrightDiagnostic(record, path, tool)); + } + return { ok: true, diagnostics, severityCounts }; +} + +function normalizeDiagnosticPath( + record: PyrightDiagnosticRecord, + index: number, + workspace: MaterializedPythonTypeWorkspace +): NormalizedDiagnosticPath { + let path: string; + try { + path = repoRelativeMaterializedPath(record.file, workspace.projectCwd, workspace.root); + } catch { + return { ok: false, message: `pyright JSON diagnostic ${index} has an out-of-repository file` }; + } + if (!workspace.selectedSourcePaths.includes(path) && !workspace.selectedConfigPaths.includes(path)) { + return { ok: false, message: `pyright JSON diagnostic ${index} names a file outside the selected after-state closure` }; + } + const content = workspace.afterStateContentByPath.get(path); + return content === undefined || !rangeWithinContent(record.range, content) + ? { ok: false, message: `pyright JSON diagnostic ${index} has a range outside its after-state file` } + : { ok: true, path }; +} + +function summaryMatchesDiagnostics( + summary: PyrightOutputSummary, + counts: Record +): boolean { + return summary.errorCount === counts.error && summary.warningCount === counts.warning && + summary.informationCount === counts.information; +} + +function validateDiagnosticRecord(value: unknown, index: number): PyrightDiagnosticRecord | string { + if (!isTable(value)) return `pyright JSON diagnostic ${index} must be an object`; + const identity = validateDiagnosticIdentity(value, index); + if (typeof identity === "string") return identity; + const range = validateDiagnosticRange(value.range, index); + if (typeof range === "string") return range; + return { ...identity, range }; +} + +function validateDiagnosticIdentity( + value: Record, + index: number +): PyrightDiagnosticIdentity | string { + if (!isProtocolString(value.file)) { + return `pyright JSON diagnostic ${index} has invalid file`; + } + if (!isDiagnosticSeverity(value.severity)) { + return `pyright JSON diagnostic ${index} has invalid severity`; + } + if (!isProtocolString(value.message)) { + return `pyright JSON diagnostic ${index} has invalid message`; + } + if (value.rule !== undefined && !isProtocolString(value.rule)) { + return `pyright JSON diagnostic ${index} has invalid rule`; + } + return { + file: value.file, + severity: value.severity, + message: value.message, + ...(value.rule === undefined ? {} : { rule: value.rule }) + }; +} + +function isProtocolString(value: unknown): value is string { + return typeof value === "string" && value.length > 0 && !value.includes("\0"); +} + +function isDiagnosticSeverity(value: unknown): value is PyrightDiagnosticRecord["severity"] { + return value === "error" || value === "warning" || value === "information"; +} + +function validateDiagnosticRange( + value: unknown, + index: number +): PyrightDiagnosticRecord["range"] | string { + if (!isTable(value) || !isPosition(value.start) || !isPosition(value.end)) { + return `pyright JSON diagnostic ${index} has invalid range`; + } + if (value.end.line < value.start.line || value.end.line === value.start.line && value.end.character < value.start.character) { + return `pyright JSON diagnostic ${index} has a reversed range`; + } + return { start: value.start, end: value.end }; +} + +function validateSummary(value: unknown): PyrightOutputSummary | string { + if (!isTable(value)) return "pyright JSON summary must be an object"; + for (const key of ["filesAnalyzed", "errorCount", "warningCount", "informationCount"] as const) { + if (!Number.isInteger(value[key]) || (value[key] as number) < 0) return `pyright JSON summary has invalid ${key}`; + } + if (typeof value.timeInSec !== "number" || !Number.isFinite(value.timeInSec) || value.timeInSec < 0) { + return "pyright JSON summary has invalid timeInSec"; + } + return value as unknown as PyrightOutputSummary; +} + +function pyrightDiagnostic( + record: PyrightDiagnosticRecord, + path: string, + tool: PythonValidationCapabilityToolProvenance +): ValidationDiagnostic { + return diagnostic({ + category: "types", + severity: record.severity === "information" ? "info" : record.severity, + path, + code: record.rule === undefined ? "PYRIGHT_TYPE_ERROR" : `PYRIGHT_${normalizeDiagnosticCode(record.rule)}`, + message: record.message, + line: record.range.start.line + 1, + column: record.range.start.character + 1, + endLine: record.range.end.line + 1, + endColumn: record.range.end.character + 1, + tool: { + name: tool.name, + command: tool.argv.join(" "), + ...(tool.version === undefined ? {} : { version: tool.version }), + source: tool.source, + cwd: tool.cwd + } + }); +} + +function isPosition(value: unknown): value is PyrightPosition { + return isTable(value) && Number.isInteger(value.line) && (value.line as number) >= 0 && + Number.isInteger(value.character) && (value.character as number) >= 0; +} + +function rangeWithinContent(range: PyrightDiagnosticRecord["range"], content: string): boolean { + const lines = content.split("\n").map((line) => line.endsWith("\r") ? line.slice(0, -1) : line); + return range.start.line < lines.length && range.end.line < lines.length && + range.start.character <= lines[range.start.line].length && range.end.character <= lines[range.end.line].length; +} + +function normalizeDiagnosticCode(code: string): string { + return code.replace(/([a-z])([A-Z])/gu, "$1_$2").replace(/[^A-Za-z0-9]+/gu, "_") + .replace(/^_+|_+$/gu, "").toUpperCase(); +} + +function isTable(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} diff --git a/packages/validation-python/src/pyright-runner.ts b/packages/validation-python/src/pyright-runner.ts new file mode 100644 index 0000000..6404659 --- /dev/null +++ b/packages/validation-python/src/pyright-runner.ts @@ -0,0 +1,192 @@ +import type { + PythonProjectToolProvenance, + PythonValidationAuthoritySource +} from "@the-open-engine/opcore-contracts"; +import { join } from "node:path"; +import { parsePyrightJsonOutput } from "./pyright-output.js"; +import { runTool, type PythonToolExitedResult, type PythonToolRunResult } from "./process.js"; +import { + relativePythonProjectPath, + type MaterializedPythonTypeWorkspace, + type PythonTypeCapabilityPreparation +} from "./type-capability-run.js"; +import { + isolatedTypeEnvironmentBase, + portableTypeTool, + withMaterializedTypeExecution +} from "./type-runner-runtime.js"; +import { + completedTypeResult, + terminatedTypeResult, + typeInvalidConfigFailure, + typeMaterializationFailure, + typePreflightFailure, + typeProtocolFailure +} from "./type-result.js"; +import type { TypeCapabilityArgs, TypeCapabilityResult, TypeExecutionContext } from "./type-runner-types.js"; + +export interface PyrightCapabilityArgs { + preparation: PythonTypeCapabilityPreparation; + checker: PythonProjectToolProvenance; + authoritySource: PythonValidationAuthoritySource; + env?: Record; + timeoutMs: number; +} + +export type PyrightCapabilityResult = TypeCapabilityResult; + +export async function runPyrightCapability(args: PyrightCapabilityArgs): Promise { + const sharedArgs: TypeCapabilityArgs = { ...args, authority: "pyright" }; + const invocation = pyrightInvocation(args.checker, args.preparation); + if (!invocation.ok) { + const tool = portableTypeTool(sharedArgs, args.checker.argv); + return typePreflightFailure(sharedArgs, tool, invocation.message); + } + return withMaterializedTypeExecution( + sharedArgs, + invocation.argv, + async (context) => executePyrightCapability(context, invocation.argv), + (tool, startedAt) => typeMaterializationFailure(sharedArgs, tool, startedAt) + ); +} + +async function executePyrightCapability( + context: TypeExecutionContext, + observedArgv: readonly string[] +): Promise { + const result = await runTool(observedArgv[0], observedArgv.slice(1), { + cwd: context.workspace.projectCwd, + env: isolatedPyrightEnvironment(context.args.env, context.args.checker, context.workspace), + timeoutMs: context.args.timeoutMs, + allowedExitCodes: [0, 1, 2, 3, 4] + }); + return interpretPyrightResult(context, result, Date.now() - context.startedAt); +} + +function interpretPyrightResult( + context: TypeExecutionContext, + result: PythonToolRunResult, + durationMs: number +): PyrightCapabilityResult { + if (result.termination !== "exited") return terminatedTypeResult(context, result, durationMs); + return interpretExitedPyrightResult(context, result, durationMs); +} + +function interpretExitedPyrightResult( + context: TypeExecutionContext, + result: PythonToolExitedResult, + durationMs: number +): PyrightCapabilityResult { + if (result.exitCode === 2 || result.exitCode === 4 || ![0, 1, 3].includes(result.exitCode)) { + return typeProtocolFailure(context, durationMs, result.exitCode, `pyright exited with fatal code ${result.exitCode}`); + } + if (result.exitCode === 3) return interpretInvalidPyrightConfig(context, result, durationMs); + if (result.stderr.trim().length > 0) { + return typeProtocolFailure(context, durationMs, result.exitCode, "pyright wrote unclassified protocol or fatal output to stderr"); + } + const parsed = parsePyrightJsonOutput(result.stdout, context.workspace, context.tool); + if (!parsed.ok) return typeProtocolFailure(context, durationMs, result.exitCode, parsed.message); + const contradiction = completedPyrightContradiction(context, result, parsed); + if (contradiction !== undefined) return typeProtocolFailure(context, durationMs, result.exitCode, contradiction); + return completedTypeResult({ + context, + status: result.exitCode === 0 ? "passed" : "findings", + exitCode: result.exitCode, + diagnostics: parsed.diagnostics, + durationMs + }); +} + +function interpretInvalidPyrightConfig( + context: TypeExecutionContext, + result: PythonToolExitedResult, + durationMs: number +): PyrightCapabilityResult { + if (result.stdout.trim().length === 0) return typeInvalidConfigFailure(context, durationMs, result.exitCode); + const parsed = parsePyrightJsonOutput(result.stdout, context.workspace, context.tool); + if (!parsed.ok) return typeProtocolFailure(context, durationMs, result.exitCode, parsed.message); + return typeInvalidConfigFailure(context, durationMs, result.exitCode, parsed.diagnostics); +} + +function completedPyrightContradiction( + context: TypeExecutionContext, + result: PythonToolExitedResult, + parsed: Extract, { ok: true }> +): string | undefined { + if (parsed.diagnostics.some((diagnostic) => diagnostic.path === undefined || !context.workspace.selectedSourcePaths.includes(diagnostic.path))) { + return "pyright type diagnostics escaped the selected after-state source closure"; + } + if (parsed.summary.filesAnalyzed === 0) return "pyright analyzed zero files"; + const hasErrors = parsed.summary.errorCount > 0; + const hasFindings = hasErrors || parsed.summary.warningCount > 0; + if (result.exitCode === 0 && hasErrors) return "pyright exit 0 contradicted error diagnostics"; + if (result.exitCode === 1 && !hasFindings) return "pyright exit 1 emitted no finding diagnostics"; + return undefined; +} + +type PyrightInvocation = + | { ok: true; argv: readonly string[] } + | { ok: false; message: string }; + +function pyrightInvocation( + checker: PythonProjectToolProvenance, + preparation: PythonTypeCapabilityPreparation +): PyrightInvocation { + const configFile = checker.configFile; + if (configFile === undefined || !preparation.selectedConfigPaths.includes(configFile)) { + return { ok: false, message: "Selected Pyright authority requires an exact after-state project configuration" }; + } + const interpreter = preparation.project.interpreter; + if (interpreter === undefined) { + const reason = preparation.project.reasons.find((entry) => entry.tool === "python")?.message; + return { + ok: false, + message: reason === undefined + ? "Selected Pyright authority requires canonical interpreter provenance" + : `Selected Pyright authority requires canonical interpreter provenance: ${reason}` + }; + } + return { + ok: true, + argv: [ + checker.executable, + ...withoutConflictingPyrightArguments(checker), + "--outputjson", + "--project", + relativePythonProjectPath(configFile, preparation.project.projectRoot), + "--pythonpath", + interpreter.executable + ] + }; +} + +function withoutConflictingPyrightArguments(checker: PythonProjectToolProvenance): readonly string[] { + const args = checker.argv.slice(1); + return args.slice(0, launcherPrefixLength(checker.executable, args)); +} + +function launcherPrefixLength(executable: string, args: readonly string[]): number { + const name = executable.replaceAll("\\", "/").split("/").at(-1)?.toLowerCase() ?? ""; + if ((name === "node" || name === "node.exe") && /\.(?:c?js|mjs)$/u.test(args[0] ?? "")) return 1; + if ((name.startsWith("python") || name === "py") && args[0] === "-m" && args[1] === "pyright") return 2; + return 0; +} + +function isolatedPyrightEnvironment( + input: Record | undefined, + checker: PythonProjectToolProvenance, + workspace: MaterializedPythonTypeWorkspace +): Record { + return isolatedTypeEnvironmentBase({ + input, + executable: checker.executable, + workspace, + includeProcessExecPath: true, + extra: { + NODE_OPTIONS: "", + NODE_PATH: "", + PYRIGHT_PYTHON_CACHE_DIR: join(workspace.runtimeRoot, "pyright-cache"), + PYTHONPATH: "" + } + }); +} diff --git a/packages/validation-python/src/relevant-tests-check.ts b/packages/validation-python/src/relevant-tests-check.ts index 46da5c4..2a648a5 100644 --- a/packages/validation-python/src/relevant-tests-check.ts +++ b/packages/validation-python/src/relevant-tests-check.ts @@ -2,10 +2,10 @@ import type { GraphFactEdge, ValidationDiagnostic } from "@the-open-engine/opcor import type { ValidationCheckDefinition } from "@the-open-engine/opcore-validation"; import { PYTHON_RELEVANT_TESTS_CHECK_ID } from "./check-ids.js"; import { pythonCheckAdapter, pythonCheckOwner, supportedPythonValidationScopes } from "./check-constants.js"; -import { relevantTestsGraphRequirements } from "./graph-requirements.js"; -import { materializePythonSources, toFileNodeId } from "./source-files.js"; +import { createRelevantTestsGraphRequirements } from "./graph-requirements.js"; +import { toFileNodeId, type PythonSourceRootResolver } from "./source-files.js"; -export function createRelevantTestsCheck(): ValidationCheckDefinition { +export function createRelevantTestsCheck(resolveRoots: PythonSourceRootResolver): ValidationCheckDefinition { return { id: PYTHON_RELEVANT_TESTS_CHECK_ID, owner: pythonCheckOwner, @@ -13,10 +13,10 @@ export function createRelevantTestsCheck(): ValidationCheckDefinition { defaultSeverity: "info", supportedScopes: supportedPythonValidationScopes, requiresGraph: true, - graphRequirements: relevantTestsGraphRequirements, + graphRequirements: createRelevantTestsGraphRequirements(resolveRoots), run: async (context) => { - const [sourceSet, testedBy] = await Promise.all([materializePythonSources(context), context.graph.testedBy()]); - const diagnostics = sourceSet.rootPaths.map((path): ValidationDiagnostic => { + const [rootPaths, testedBy] = await Promise.all([resolveRoots(context), context.graph.testedBy()]); + const diagnostics = rootPaths.map((path): ValidationDiagnostic => { const evidence = testedBy.filter((edge) => edgeReferencesFile(edge, path)); if (evidence.length > 0) { return { diff --git a/packages/validation-python/src/smol-toml-shims.d.ts b/packages/validation-python/src/smol-toml-shims.d.ts new file mode 100644 index 0000000..d43a629 --- /dev/null +++ b/packages/validation-python/src/smol-toml-shims.d.ts @@ -0,0 +1,10 @@ +declare module "smol-toml" { + export type TomlTable = Record; + + export interface ParseOptions { + maxDepth?: number; + integersAsBigInt?: boolean | "bigint"; + } + + export function parse(toml: string, options?: ParseOptions): TomlTable; +} diff --git a/packages/validation-python/src/source-closure.ts b/packages/validation-python/src/source-closure.ts new file mode 100644 index 0000000..b9b1884 --- /dev/null +++ b/packages/validation-python/src/source-closure.ts @@ -0,0 +1,104 @@ +import type { PythonProjectContext } from "@the-open-engine/opcore-contracts"; +import type { ValidationCheckContext } from "@the-open-engine/opcore-validation"; +import type { PythonImportEdge } from "./import-analysis.js"; +import type { + PythonMaterializedSourceFile, + PythonProjectContextResolver +} from "./source-types.js"; + +interface PythonSourceClosureArgs { + context: ValidationCheckContext, + rootPaths: readonly string[], + edges: readonly PythonImportEdge[], + sourceByPath: ReadonlyMap, + resolveContexts: PythonProjectContextResolver +} + +export async function expandPythonSourceClosure({ + context, + rootPaths, + edges, + sourceByPath, + resolveContexts +}: PythonSourceClosureArgs): Promise { + const projectContexts = new Map(); + let selected = transitiveSourcePaths(rootPaths, edges); + while (true) { + const unresolvedTargets = selected.filter((path) => !projectContexts.has(path)); + if (unresolvedTargets.length > 0) { + for (const projectContext of await resolveContexts(context, unresolvedTargets)) { + projectContexts.set(projectContext.target, projectContext); + } + } + const expanded = transitiveSourcePaths( + includePackageInitializers(selected, sourceByPath, [...projectContexts.values()]), + edges + ); + if (expanded.length === selected.length && expanded.every((path, index) => path === selected[index])) return expanded; + selected = expanded; + } +} + +function includePackageInitializers( + selectedPaths: readonly string[], + sourceByPath: ReadonlyMap, + projectContexts: readonly PythonProjectContext[] +): readonly string[] { + const expanded = new Set(selectedPaths); + for (const path of selectedPaths) { + const sourceRoot = owningSourceRoot(path, projectContexts); + if (sourceRoot === undefined) continue; + let directory = path.slice(0, path.lastIndexOf("/")); + while (directory.length > 0 && directory !== sourceRoot && pathWithinRoot(directory, sourceRoot)) { + const initializers = [`${directory}/__init__.py`, `${directory}/__init__.pyi`] + .filter((candidate) => sourceByPath.has(candidate)); + if (initializers.length === 0) { + const separator = directory.lastIndexOf("/"); + if (separator < 0) break; + directory = directory.slice(0, separator); + continue; + } + // Package markers are structural type-checker inputs, not import expectations. + for (const initializer of initializers) expanded.add(initializer); + const separator = directory.lastIndexOf("/"); + if (separator < 0) break; + directory = directory.slice(0, separator); + } + } + return [...expanded].sort(); +} + +function owningSourceRoot(path: string, projectContexts: readonly PythonProjectContext[]): string | undefined { + return projectContexts + .flatMap((projectContext) => projectContext.sourceRoots) + .filter((sourceRoot) => pathWithinRoot(path, sourceRoot)) + .sort((left, right) => right.length - left.length || left.localeCompare(right))[0]; +} + +function pathWithinRoot(path: string, root: string): boolean { + return root === "." || path === root || path.startsWith(`${root}/`); +} + +function transitiveSourcePaths( + rootPaths: readonly string[], + edges: readonly PythonImportEdge[] +): readonly string[] { + const outgoing = new Map(); + for (const edge of edges) { + const targets = outgoing.get(edge.fromPath) ?? []; + targets.push(edge.toPath); + outgoing.set(edge.fromPath, targets); + } + const selected = new Set(rootPaths); + const pending = [...rootPaths]; + while (pending.length > 0) { + const path = pending.shift(); + if (path === undefined) continue; + for (const target of outgoing.get(path) ?? []) { + if (selected.has(target)) continue; + selected.add(target); + pending.push(target); + } + } + return [...selected].sort(); +} diff --git a/packages/validation-python/src/source-files.ts b/packages/validation-python/src/source-files.ts index c55afce..8831da7 100644 --- a/packages/validation-python/src/source-files.ts +++ b/packages/validation-python/src/source-files.ts @@ -1,34 +1,98 @@ +import type { PythonProjectContext } from "@the-open-engine/opcore-contracts"; import type { ValidationCheckContext, ValidationCheckResult, ValidationFileView } from "@the-open-engine/opcore-validation"; import { normalizeValidationFileViewPath } from "@the-open-engine/opcore-validation"; +import { + pythonImportEdgesFromGraph, + requirePythonImportAnalyzer, + validatePythonImportEdges, + type PythonImportAnalyzer, + type PythonImportEdge +} from "./import-analysis.js"; +import { resolvePythonProjectContexts, type ResolvePythonProjectContextsOptions } from "./project-context.js"; +import { createValidationFileViewPythonWorkspace, type PythonProjectWorkspace } from "./project-workspace.js"; +import { expandPythonSourceClosure } from "./source-closure.js"; +import type { + PythonMaterializedSourceFile, + PythonMaterializedSourceSet, + PythonProjectContextResolver, + PythonSourceRootResolver, + PythonSourceSetResolver +} from "./source-types.js"; export const pythonSourceExtensions = [".py", ".pyi"] as const; -export interface PythonMaterializedSourceFile { - path: string; - content: string; +export type { + PythonMaterializedSourceFile, + PythonMaterializedSourceSet, + PythonProjectContextResolver, + PythonSourceRootResolver, + PythonSourceSetResolver +} from "./source-types.js"; + +export function createPythonProjectContextResolver( + options: Omit & { + nodeWorkspace?: PythonProjectWorkspace; + } = {} +): PythonProjectContextResolver { + const cache = new WeakMap(); + return async (context, requestedTargets) => { + let cached = cache.get(context.fileView); + if (cached === undefined) { + cached = { contexts: new Map() }; + cache.set(context.fileView, cached); + } + const targets = requestedTargets === undefined + ? await (cached.inputTargets ??= readPythonAfterSources(context).then((sources) => sources.map((source) => source.path))) + : uniqueSorted(requestedTargets.map(normalizeValidationFileViewPath).filter(isPythonSourcePath)); + while (true) { + const missing = targets.filter((target) => !cached.contexts.has(target)); + if (missing.length === 0) return targets.map((target) => requiredProjectContext(cached.contexts, target)); + if (cached.pending !== undefined) { + await cached.pending; + continue; + } + const pending = resolvePythonProjectContexts({ + repoRoot: context.request.repo.repoRoot ?? process.cwd(), + targets: missing, + workspace: createValidationFileViewPythonWorkspace(context.fileView, undefined, options.nodeWorkspace), + ...withoutNodeWorkspace(options) + }).then((contexts) => { + for (const projectContext of contexts) cached.contexts.set(projectContext.target, projectContext); + }); + cached.pending = pending; + try { + await pending; + } finally { + delete cached.pending; + } + } + }; } -export interface PythonRepoImport { - fromPath: string; - specifier: string; - resolvedPath: string; +interface PythonProjectContextCache { + contexts: Map; + inputTargets?: Promise; + pending?: Promise; } -export interface PythonMaterializedSourceSet { - rootPaths: readonly string[]; - paths: readonly string[]; - files: readonly PythonMaterializedSourceFile[]; - sourceFileByPath: ReadonlyMap; - repoImports: readonly PythonRepoImport[]; +function requiredProjectContext( + contexts: ReadonlyMap, + target: string +): PythonProjectContext { + const projectContext = contexts.get(target); + if (projectContext === undefined) throw new Error(`Python project context resolution omitted target ${target}`); + return projectContext; } -interface ParsedPythonImport { - specifier: string; - member?: string; +function withoutNodeWorkspace( + options: Omit & { + nodeWorkspace?: PythonProjectWorkspace; + } +): Omit { + const { nodeWorkspace: _nodeWorkspace, ...resolverOptions } = options; + return resolverOptions; } -const sourceSetCache = new WeakMap>(); - export function isPythonSourcePath(path: string): boolean { return pythonSourceExtensions.some((extension) => path.endsWith(extension)); } @@ -54,6 +118,17 @@ export async function readPythonAfterSources(context: ValidationCheckContext): P return files.sort((left, right) => left.path.localeCompare(right.path)); } +export function createPythonSourceRootResolver(): PythonSourceRootResolver { + const cache = new WeakMap>(); + return (context) => { + const existing = cache.get(context.fileView); + if (existing !== undefined) return existing; + const promise = readPythonAfterSources(context).then((sources) => sources.map((source) => source.path)); + cache.set(context.fileView, promise); + return promise; + }; +} + export function skippedPythonInputResult(context: ValidationCheckContext): ValidationCheckResult | undefined { if (pythonInputSet(context).length > 0) return undefined; return { @@ -63,163 +138,79 @@ export function skippedPythonInputResult(context: ValidationCheckContext): Valid }; } -export async function materializePythonSources(context: ValidationCheckContext): Promise { - const cached = sourceSetCache.get(context.fileView); - if (cached !== undefined) return cached; - const promise = materializePythonSourcesUncached(context); - sourceSetCache.set(context.fileView, promise); - return promise; +export function createPythonSourceSetResolver( + importAnalyzer: PythonImportAnalyzer | undefined, + resolveContexts: PythonProjectContextResolver +): PythonSourceSetResolver { + const cache = new WeakMap>(); + return (context) => { + const existing = cache.get(context.fileView); + if (existing !== undefined) return existing; + const promise = materializePythonSourcesUncached(context, importAnalyzer, resolveContexts); + cache.set(context.fileView, promise); + return promise; + }; } -async function materializePythonSourcesUncached(context: ValidationCheckContext): Promise { - const initialPaths = pythonInputSet(context); - const rootPaths: string[] = []; - const pending = [...initialPaths]; - const visited = new Set(); - const sourceFileByPath = new Map(); - const repoImports: PythonRepoImport[] = []; - - while (pending.length > 0) { - const path = pending.shift(); - if (path === undefined || visited.has(path)) continue; - visited.add(path); - +async function materializePythonSourcesUncached( + context: ValidationCheckContext, + importAnalyzer: PythonImportAnalyzer | undefined, + resolveContexts: PythonProjectContextResolver +): Promise { + const visiblePaths = uniqueSorted( + (await context.fileView.listVisibleFiles()) + .map(normalizeValidationFileViewPath) + .filter(isPythonSourcePath) + ); + const allSources: PythonMaterializedSourceFile[] = []; + for (const path of visiblePaths) { const result = await context.fileView.readAfter(path); - if (result.status !== "found") continue; - const sourceFile = { path, content: result.content }; - sourceFileByPath.set(path, sourceFile); - if (initialPaths.includes(path)) rootPaths.push(path); - - for (const parsedImport of parsePythonImports(result.content)) { - const resolvedPath = await resolvePythonImport(context, path, parsedImport); - if (resolvedPath === undefined) continue; - repoImports.push({ fromPath: path, specifier: parsedImport.specifier, resolvedPath }); - if (!visited.has(resolvedPath) && !sourceFileByPath.has(resolvedPath)) pending.push(resolvedPath); - } + if (result.status === "found") allSources.push({ path, content: result.content }); } - const files = [...sourceFileByPath.values()].sort((left, right) => left.path.localeCompare(right.path)); + const allSourceByPath = new Map(allSources.map((source) => [source.path, source])); + const rootPaths = pythonInputSet(context).filter((path) => allSourceByPath.has(path)); + if (rootPaths.length === 0) return emptySourceSet(); + + const repoImports = validatePythonImportEdges( + context.graph?.identity.kind === "exact" + ? pythonImportEdgesFromGraph(await context.graph.importsFrom(), new Set(allSourceByPath.keys())) + : await requirePythonImportAnalyzer(importAnalyzer).analyze(allSources), + new Set(allSourceByPath.keys()) + ); + const selectedPaths = await expandPythonSourceClosure({ + context, + rootPaths, + edges: repoImports, + sourceByPath: allSourceByPath, + resolveContexts + }); + const files = selectedPaths + .map((path) => allSourceByPath.get(path)) + .filter((file): file is PythonMaterializedSourceFile => file !== undefined); + const sourceFileByPath = new Map(files.map((file) => [file.path, file])); + const selectedPathSet = new Set(selectedPaths); return { - rootPaths: uniqueSorted(rootPaths), - paths: files.map((file) => file.path), + rootPaths, + paths: selectedPaths, + allPaths: [...allSourceByPath.keys()].sort(), files, sourceFileByPath, - repoImports: uniqueRepoImports(repoImports) + repoImports: repoImports.filter((edge) => selectedPathSet.has(edge.fromPath) && selectedPathSet.has(edge.toPath)), + allRepoImports: repoImports }; } -function parsePythonImports(content: string): readonly ParsedPythonImport[] { - const imports: ParsedPythonImport[] = []; - for (const rawLine of content.split(/\r?\n/u)) { - const line = stripInlineComment(rawLine).trim(); - if (line.length === 0) continue; - const importMatch = /^import\s+(.+)$/u.exec(line); - if (importMatch !== null) { - for (const entry of importMatch[1].split(",")) { - const specifier = entry.trim().split(/\s+as\s+/u)[0]?.trim(); - if (isImportableModuleSpecifier(specifier)) imports.push({ specifier }); - } - continue; - } - const fromMatch = /^from\s+([.\w]+)\s+import\s+(.+)$/u.exec(line); - if (fromMatch === null) continue; - const moduleSpecifier = fromMatch[1]; - const importedNames = fromMatch[2].split(",").map((entry) => entry.trim().split(/\s+as\s+/u)[0]?.trim() ?? ""); - if (/^\.+$/u.test(moduleSpecifier)) { - for (const name of importedNames) { - if (/^[A-Za-z_]\w*$/u.test(name)) imports.push({ specifier: `${moduleSpecifier}${name}` }); - } - } else if (isImportableModuleSpecifier(moduleSpecifier)) { - imports.push({ specifier: moduleSpecifier }); - for (const name of importedNames) { - if (/^[A-Za-z_]\w*$/u.test(name)) imports.push({ specifier: `${moduleSpecifier}.${name}`, member: name }); - } - } - } - return imports; -} - -async function resolvePythonImport( - context: ValidationCheckContext, - fromPath: string, - parsedImport: ParsedPythonImport -): Promise { - const moduleBase = moduleBasePath(fromPath, parsedImport.specifier); - if (moduleBase === undefined) return undefined; - return resolveModulePath(context, moduleBase); -} - -async function resolveModulePath(context: ValidationCheckContext, moduleBase: string): Promise { - for (const candidate of moduleCandidates(moduleBase)) { - let normalized: string; - try { - normalized = normalizeValidationFileViewPath(candidate); - } catch { - continue; - } - if (!isPythonSourcePath(normalized)) continue; - if (await context.fileView.exists(normalized)) return normalized; - } - return undefined; -} - -function moduleBasePath(fromPath: string, specifier: string): string | undefined { - const leadingDots = /^\.*/u.exec(specifier)?.[0].length ?? 0; - const moduleSpecifier = specifier.slice(leadingDots); - const moduleParts = moduleSpecifier.length === 0 ? [] : moduleSpecifier.split("."); - if (leadingDots === 0) return moduleParts.join("/"); - - const baseParts = fromPath.split("/"); - baseParts.pop(); - for (let index = 1; index < leadingDots; index += 1) { - if (baseParts.length === 0) return undefined; - baseParts.pop(); - } - return [...baseParts, ...moduleParts].filter((part) => part.length > 0).join("/"); -} - -function moduleCandidates(moduleBase: string): readonly string[] { - if (moduleBase.length === 0) return []; - return [`${moduleBase}.py`, `${moduleBase}.pyi`, `${moduleBase}/__init__.py`, `${moduleBase}/__init__.pyi`]; -} - -function stripInlineComment(line: string): string { - let quote: "'" | "\"" | undefined; - let escaped = false; - for (let index = 0; index < line.length; index += 1) { - const char = line[index]; - if (escaped) { - escaped = false; - continue; - } - if (quote !== undefined && char === "\\") { - escaped = true; - continue; - } - if (char === "'" || char === "\"") { - if (quote === undefined) quote = char; - else if (quote === char) quote = undefined; - continue; - } - if (quote === undefined && char === "#") return line.slice(0, index); - } - return line; -} - -function isImportableModuleSpecifier(value: string | undefined): value is string { - return value !== undefined && /^\.?[A-Za-z_]\w*(?:\.[A-Za-z_]\w*)*$/u.test(value); -} - -function uniqueRepoImports(imports: readonly PythonRepoImport[]): readonly PythonRepoImport[] { - const byKey = new Map(); - for (const repoImport of imports) { - byKey.set(`${repoImport.fromPath}\0${repoImport.specifier}\0${repoImport.resolvedPath}`, repoImport); - } - return [...byKey.values()].sort((left, right) => - `${left.fromPath}\0${left.resolvedPath}\0${left.specifier}`.localeCompare( - `${right.fromPath}\0${right.resolvedPath}\0${right.specifier}` - ) - ); +function emptySourceSet(): PythonMaterializedSourceSet { + return { + rootPaths: [], + paths: [], + allPaths: [], + files: [], + sourceFileByPath: new Map(), + repoImports: [], + allRepoImports: [] + }; } export function uniqueSorted(values: readonly string[]): readonly string[] { diff --git a/packages/validation-python/src/source-types.ts b/packages/validation-python/src/source-types.ts new file mode 100644 index 0000000..12f6ab3 --- /dev/null +++ b/packages/validation-python/src/source-types.ts @@ -0,0 +1,31 @@ +import type { PythonProjectContext } from "@the-open-engine/opcore-contracts"; +import type { ValidationCheckContext } from "@the-open-engine/opcore-validation"; +import type { + PythonImportEdge, + PythonImportSourceFile +} from "./import-analysis.js"; + +export type PythonMaterializedSourceFile = PythonImportSourceFile; + +export interface PythonMaterializedSourceSet { + rootPaths: readonly string[]; + paths: readonly string[]; + allPaths: readonly string[]; + files: readonly PythonMaterializedSourceFile[]; + sourceFileByPath: ReadonlyMap; + repoImports: readonly PythonImportEdge[]; + allRepoImports: readonly PythonImportEdge[]; +} + +export type PythonProjectContextResolver = ( + context: ValidationCheckContext, + targets?: readonly string[] +) => Promise; + +export type PythonSourceRootResolver = ( + context: ValidationCheckContext +) => Promise; + +export type PythonSourceSetResolver = ( + context: ValidationCheckContext +) => Promise; diff --git a/packages/validation-python/src/static-config.ts b/packages/validation-python/src/static-config.ts new file mode 100644 index 0000000..fa49b15 --- /dev/null +++ b/packages/validation-python/src/static-config.ts @@ -0,0 +1,482 @@ +import type { + PythonProjectBuildSystem, + PythonProjectContextReason, + PythonProjectManagerEvidence, + PythonProjectTarget +} from "@the-open-engine/opcore-contracts"; +import { parse as parseJsonc, type ParseError } from "jsonc-parser"; +import { isRelevantPythonConfig } from "./project-config-files.js"; +import type { PythonProjectWorkspace } from "./project-workspace.js"; +import { parsePyrightConfig, resolveConfiguredPath } from "./pyright-config-values.js"; +import { pythonVersionSatisfiesConstraint } from "./version-constraint.js"; +import { parsePythonIni, pythonIniHasSection, pythonIniValue, type PythonIniDocument } from "./ini-config.js"; +import { mypyConfigSafetyFailures, mypyPathValidationFailures } from "./mypy-config-values.js"; +import { + parsePythonToml, + tomlStringArrayAt, + tomlStringAt, + tomlTableAt, + tomlValueAt, + type TomlTable +} from "./toml-config.js"; + +export const pythonToolConfigPrecedence = { + pyright: ["pyrightconfig.json", "pyproject.toml"], + ruff: ["ruff.toml", ".ruff.toml", "pyproject.toml"], + mypy: ["mypy.ini", ".mypy.ini", "pyproject.toml", "setup.cfg", "tox.ini"], + pytest: ["pytest.ini", "pyproject.toml", "tox.ini", "setup.cfg"] +} as const; + +export interface PythonStaticProjectConfig { + contents: ReadonlyMap; + managers: readonly PythonProjectManagerEvidence[]; + target: PythonProjectTarget; + toolConfigs: Readonly>; + buildSystem?: PythonProjectBuildSystem; + reasons: readonly PythonProjectContextReason[]; +} + +interface PythonToolConfigInputs { + projectRoot: string; + direct: readonly string[]; + contents: ReadonlyMap; + tomlDocuments: ReadonlyMap; + iniDocuments: ReadonlyMap; +} + +export async function readPythonStaticProjectConfig( + workspace: PythonProjectWorkspace, + projectRoot: string, + visibleFiles: readonly string[] +): Promise { + const direct = directChildren(projectRoot, visibleFiles); + const relevant = direct.filter(isRelevantPythonConfig); + const contents = new Map(); + const jsonDocuments = new Map(); + const tomlDocuments = new Map(); + const iniDocuments = new Map(); + const reasons: PythonProjectContextReason[] = []; + for (const path of relevant) { + const resolved = await workspace.realpath(path); + if (resolved.unavailable) { + reasons.push({ code: "ambiguous_path", path, message: `Python config realpath evidence is unavailable: ${path}` }); + continue; + } + if (resolved.symlink || resolved.path !== path) { + reasons.push({ code: "symlink_refused", path, message: `Symlinked Python config path is ambiguous: ${path}` }); + continue; + } + const value = await workspace.read(path); + if (value === undefined) continue; + contents.set(path, value); + if (path.endsWith(".json") || basename(path) === "Pipfile.lock") { + try { + const errors: ParseError[] = []; + const parsed = basename(path) === "pyrightconfig.json" + ? parseJsonc(value, errors, { allowTrailingComma: true, disallowComments: false }) as unknown + : JSON.parse(value) as unknown; + if (errors.length > 0) throw new Error("JSONC parse failure"); + if (!isTable(parsed)) throw new Error("JSON config root must be an object"); + jsonDocuments.set(path, parsed); + } catch { + reasons.push({ code: "invalid_config", path, message: `Python project config is malformed: ${path}` }); + } + } + if (isTomlConfig(path)) { + try { + tomlDocuments.set(path, parsePythonToml(value)); + } catch { + reasons.push({ code: "invalid_config", path, message: `Python project config is malformed: ${path}` }); + } + } + if (path.endsWith(".ini") || path.endsWith(".cfg")) { + try { + iniDocuments.set(path, parsePythonIni(value)); + } catch { + if (!isDedicatedMypyConfig(path)) { + reasons.push({ + code: "invalid_config", + path, + message: `Python project config is malformed: ${path}` + }); + } + } + } + } + + const managers = managerEvidence(projectRoot, direct, tomlDocuments); + if (managers.length > 1) { + reasons.push({ + code: "conflicting_managers", + message: `Conflicting Python dependency managers at ${projectRoot}: ${managers.map((entry) => entry.kind).join(", ")}` + }); + } + const toolConfigs = selectToolConfigs({ projectRoot, direct, contents, tomlDocuments, iniDocuments }, reasons); + const pyrightTarget = toolConfigs.pyright === undefined + ? undefined + : await readSelectedPyrightTarget(workspace, toolConfigs.pyright, contents, reasons); + const target = targetEvidence(projectRoot, contents, tomlDocuments, pyrightTarget, toolConfigs.pyright, reasons); + const pyproject = joinRoot(projectRoot, "pyproject.toml"); + const buildSystem = parseBuildSystem(pyproject, tomlDocuments.get(pyproject)); + return { contents, managers, target, toolConfigs, ...(buildSystem === undefined ? {} : { buildSystem }), reasons }; +} + +function managerEvidence( + projectRoot: string, + direct: readonly string[], + tomlDocuments: ReadonlyMap +): readonly PythonProjectManagerEvidence[] { + const names = new Set(direct.map(basename)); + const pyprojectPath = joinRoot(projectRoot, "pyproject.toml"); + const pyprojectDocument = tomlDocuments.get(pyprojectPath); + const managers: PythonProjectManagerEvidence[] = []; + const requirements = direct.filter((path) => /^requirements.*\.txt$/u.test(basename(path))); + if (requirements.length > 0 || names.has("setup.py")) { + managers.push({ kind: "pip", configFiles: requirements.length > 0 ? requirements : [joinRoot(projectRoot, "setup.py")], lockFiles: [] }); + } + if (names.has("uv.lock") || tomlTableAt(pyprojectDocument, ["tool", "uv"]) !== undefined) { + managers.push({ kind: "uv", configFiles: tomlTableAt(pyprojectDocument, ["tool", "uv"]) === undefined ? [] : [pyprojectPath], lockFiles: names.has("uv.lock") ? [joinRoot(projectRoot, "uv.lock")] : [] }); + } + if (names.has("poetry.lock") || tomlTableAt(pyprojectDocument, ["tool", "poetry"]) !== undefined) { + managers.push({ kind: "poetry", configFiles: tomlTableAt(pyprojectDocument, ["tool", "poetry"]) === undefined ? [] : [pyprojectPath], lockFiles: names.has("poetry.lock") ? [joinRoot(projectRoot, "poetry.lock")] : [] }); + } + if (names.has("pdm.lock") || tomlTableAt(pyprojectDocument, ["tool", "pdm"]) !== undefined) { + managers.push({ kind: "pdm", configFiles: tomlTableAt(pyprojectDocument, ["tool", "pdm"]) === undefined ? [] : [pyprojectPath], lockFiles: names.has("pdm.lock") ? [joinRoot(projectRoot, "pdm.lock")] : [] }); + } + if (names.has("Pipfile") || names.has("Pipfile.lock")) { + managers.push({ kind: "pipenv", configFiles: names.has("Pipfile") ? [joinRoot(projectRoot, "Pipfile")] : [], lockFiles: names.has("Pipfile.lock") ? [joinRoot(projectRoot, "Pipfile.lock")] : [] }); + } + return managers.sort((left, right) => left.kind.localeCompare(right.kind)); +} + +function targetEvidence( + projectRoot: string, + contents: ReadonlyMap, + tomlDocuments: ReadonlyMap, + pyrightTarget: TomlTable | undefined, + selectedPyrightConfig: string | undefined, + reasons: PythonProjectContextReason[] +): PythonProjectTarget { + const declarations: { source: string; kind: "requires" | "version" | "platform" | "implementation"; value: string }[] = []; + for (const [path, content] of contents) { + const document = tomlDocuments.get(path); + const requires = tomlStringAt(document, ["project", "requires-python"]) ?? + tomlStringAt(document, ["tool", "poetry", "dependencies", "python"]) ?? + valueForNonTomlConfig(path, content, "requires-python") ?? + valueForNonTomlConfig(path, content, "python_requires"); + if (requires !== undefined) declarations.push({ source: path, kind: "requires", value: requires }); + const version = tomlStringAt(document, ["requires", "python_version"]) ?? + valueForNonTomlConfig(path, content, "python_version"); + if (version !== undefined) declarations.push({ source: path, kind: "version", value: version }); + const platform = valueForNonTomlConfig(path, content, "platform"); + if (platform !== undefined) declarations.push({ source: path, kind: "platform", value: platform }); + } + for (const [kind, key] of [ + ["version", "pythonVersion"], + ["platform", "pythonPlatform"], + ["implementation", "pythonImplementation"] + ] as const) { + const value = tomlStringAt(pyrightTarget, [key]); + if (value !== undefined && selectedPyrightConfig !== undefined) { + declarations.push({ source: selectedPyrightConfig, kind, value }); + } + } + const conflicts: string[] = []; + for (const kind of ["requires", "version", "platform", "implementation"] as const) { + const values = [...new Set(declarations.filter((entry) => entry.kind === kind).map((entry) => entry.value))]; + if (values.length > 1) conflicts.push(`${kind}:${values.join("|")}`); + } + if (conflicts.length > 0) { + reasons.push({ code: "conflicting_targets", message: `Conflicting Python targets at ${projectRoot}: ${conflicts.join(", ")}` }); + } + const requiresPython = declarations.find((entry) => entry.kind === "requires")?.value; + const version = declarations.find((entry) => entry.kind === "version")?.value; + const platform = declarations.find((entry) => entry.kind === "platform")?.value; + const implementation = declarations.find((entry) => entry.kind === "implementation")?.value; + if (requiresPython !== undefined && version !== undefined && !constraintAllowsVersion(requiresPython, version)) { + const conflict = `requires-version:${requiresPython}|${version}`; + conflicts.push(conflict); + reasons.push({ code: "conflicting_targets", message: `Conflicting Python targets at ${projectRoot}: ${conflict}` }); + } + return { + ...(requiresPython === undefined ? {} : { requiresPython }), + ...(version === undefined ? {} : { version }), + ...(platform === undefined ? {} : { platform }), + ...(implementation === undefined ? {} : { implementation }), + conflicts + }; +} + +async function readSelectedPyrightTarget( + workspace: PythonProjectWorkspace, + selectedConfig: string, + contents: Map, + reasons: PythonProjectContextReason[] +): Promise { + const documents: { path: string; value: TomlTable }[] = []; + await readSelectedPyrightConfigClosure(workspace, selectedConfig, contents, documents, reasons, new Set(), []); + if (documents.length === 0) return undefined; + const inherited: TomlTable = {}; + for (const document of [...documents].reverse()) { + for (const key of ["pythonVersion", "pythonPlatform", "pythonImplementation"] as const) { + const value = document.value[key]; + if (typeof value === "string" && value.trim().length > 0) inherited[key] = value.trim(); + } + } + return Object.keys(inherited).length === 0 ? undefined : inherited; +} + +async function readSelectedPyrightConfigClosure( + workspace: PythonProjectWorkspace, + path: string, + contents: Map, + documents: { path: string; value: TomlTable }[], + reasons: PythonProjectContextReason[], + visited: Set, + stack: readonly string[] +): Promise { + if (stack.includes(path)) { + reasons.push({ + code: "invalid_config", + path, + tool: "pyright", + message: `Invalid Pyright configuration: ${path} has an extends cycle: ${[...stack, path].join(" -> ")}` + }); + return; + } + if (visited.has(path)) return; + visited.add(path); + const resolved = await workspace.realpath(path); + if (resolved.unavailable) { + reasons.push({ code: "ambiguous_path", path, message: `Python config realpath evidence is unavailable: ${path}` }); + return; + } + if (resolved.symlink || resolved.path !== path) { + reasons.push({ code: "symlink_refused", path, message: `Symlinked Python config path is ambiguous: ${path}` }); + return; + } + const content = contents.get(path) ?? await workspace.read(path); + if (content === undefined) { + reasons.push({ + code: "invalid_config", + path, + tool: "pyright", + message: `Invalid Pyright configuration: ${path} is missing from the selected project` + }); + return; + } + contents.set(path, content); + const parsed = parsePyrightConfig(path, content); + if (typeof parsed === "string") { + reasons.push({ code: "invalid_config", path, tool: "pyright", message: `Invalid Pyright configuration: ${parsed}` }); + return; + } + documents.push({ path, value: parsed }); + if (typeof parsed.extends !== "string" || parsed.extends.trim().length === 0) { + if (parsed.extends !== undefined) { + reasons.push({ + code: "invalid_config", + path, + tool: "pyright", + message: "Invalid Pyright configuration: Pyright extends must be a non-empty repo-relative path" + }); + } + return; + } + const extended = resolveConfiguredPath(path, parsed.extends, "extends"); + if (typeof extended === "string") { + reasons.push({ code: "invalid_config", path, tool: "pyright", message: `Invalid Pyright configuration: ${extended}` }); + return; + } + await readSelectedPyrightConfigClosure( + workspace, + extended.path, + contents, + documents, + reasons, + visited, + [...stack, path] + ); +} + +function parseBuildSystem(path: string, document: TomlTable | undefined): PythonStaticProjectConfig["buildSystem"] { + const section = tomlTableAt(document, ["build-system"]); + if (section === undefined) return undefined; + const backend = tomlStringAt(section, ["build-backend"]); + const requires = tomlStringArrayAt(section, ["requires"]).sort(); + return { configFile: path, ...(backend === undefined ? {} : { backend }), requires }; +} + +function selectToolConfigs( + input: PythonToolConfigInputs, + reasons: PythonProjectContextReason[] +): PythonStaticProjectConfig["toolConfigs"] { + const tools = ["mypy", "pyright", "ruff", "pytest"] as const; + const selected: Partial> = {}; + addMissingMypySectionFailures(input, reasons); + addInvalidMypyPythonVersionFailures(input, reasons); + addInvalidMypyPathFailures(input, reasons); + addUnsafeMypyConfigFailures(input, reasons); + for (const tool of tools) { + const selectedPath = configuredToolPaths(input, tool)[0]; + if (selectedPath !== undefined) selected[tool] = selectedPath; + } + return selected as PythonStaticProjectConfig["toolConfigs"]; +} + +function addUnsafeMypyConfigFailures( + input: PythonToolConfigInputs, + reasons: PythonProjectContextReason[] +): void { + const path = configuredToolPaths(input, "mypy")[0]; + const content = path === undefined ? undefined : input.contents.get(path); + if (path === undefined || content === undefined) return; + for (const message of mypyConfigSafetyFailures(path, content, input.projectRoot, parsePythonToml)) { + if (message.startsWith("mypy_path ")) continue; + reasons.push({ code: "invalid_config", path, tool: "mypy", message: `Invalid mypy configuration: ${message}` }); + } +} + +function addInvalidMypyPathFailures( + input: PythonToolConfigInputs, + reasons: PythonProjectContextReason[] +): void { + const path = configuredToolPaths(input, "mypy")[0]; + if (path === undefined) return; + const value = path.endsWith("pyproject.toml") + ? tomlValueAt(input.tomlDocuments.get(path), ["tool", "mypy", "mypy_path"]) + : pythonIniValue(input.iniDocuments.get(path), "mypy", "mypy_path"); + for (const message of mypyPathValidationFailures(value)) { + reasons.push({ code: "invalid_config", path, tool: "mypy", message: `Invalid mypy configuration: ${message}` }); + } +} + +function addInvalidMypyPythonVersionFailures( + input: PythonToolConfigInputs, + reasons: PythonProjectContextReason[] +): void { + const path = configuredToolPaths(input, "mypy")[0]; + if (path === undefined) return; + const value = path.endsWith("pyproject.toml") + ? tomlValueAt(input.tomlDocuments.get(path), ["tool", "mypy", "python_version"]) + : pythonIniValue(input.iniDocuments.get(path), "mypy", "python_version"); + if (value === undefined || typeof value === "string" && /^\d+\.\d+$/u.test(value.trim())) return; + reasons.push({ + code: "invalid_config", + path, + tool: "mypy", + message: `Mypy configuration has an invalid python_version: ${path}` + }); +} + +function addMissingMypySectionFailures( + input: PythonToolConfigInputs, + reasons: PythonProjectContextReason[] +): void { + const path = configuredToolPaths(input, "mypy")[0]; + if (path === undefined || !isDedicatedMypyConfig(path)) return; + const document = input.iniDocuments.get(path); + if (document !== undefined && pythonIniHasSection(document, "mypy")) return; + reasons.push({ + code: "invalid_config", + path, + tool: "mypy", + message: document === undefined + ? `Python project config is malformed: ${path}` + : `Dedicated mypy config has no [mypy] section: ${path}` + }); +} + +function configuredToolPaths( + input: PythonToolConfigInputs, + tool: "mypy" | "pyright" | "ruff" | "pytest", +): readonly string[] { + const candidates = pythonToolConfigPrecedence[tool]; + const names = new Set(input.direct.map(basename)); + const section = { + mypy: "tool.mypy", + pyright: "tool.pyright", + ruff: "tool.ruff", + pytest: "tool.pytest.ini_options" + }[tool]; + return candidates.filter((candidate) => { + if (!names.has(candidate)) return false; + const path = joinRoot(input.projectRoot, candidate); + if (candidate === "pyproject.toml") { + return tomlTableAt(input.tomlDocuments.get(path), section.split(".")) !== undefined; + } + if (tool === "mypy") { + return isDedicatedMypyConfig(path) || pythonIniHasSection(input.iniDocuments.get(path), "mypy"); + } + if (tool === "pytest" && (candidate === "setup.cfg" || candidate === "tox.ini" || candidate === "pytest.ini")) { + return pythonIniHasSection(input.iniDocuments.get(path), "pytest") || pythonIniHasSection(input.iniDocuments.get(path), "tool:pytest"); + } + return true; + }).map((name) => joinRoot(input.projectRoot, name)); +} + +function isDedicatedMypyConfig(path: string): boolean { + const name = basename(path); + return name === "mypy.ini" || name === ".mypy.ini"; +} + +function isTomlConfig(path: string): boolean { + return path.endsWith(".toml") || ["Pipfile", "poetry.lock", "pdm.lock", "uv.lock"].includes(basename(path)); +} + +function directChildren(root: string, files: readonly string[]): readonly string[] { + const prefix = root === "." ? "" : `${root}/`; + return files.filter((path) => path.startsWith(prefix) && !path.slice(prefix.length).includes("/")); +} + +function valueForKey(content: string, key: string): string | undefined { + const escaped = key.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&"); + const match = new RegExp( + `^\\s*["']?${escaped}["']?\\s*(?:=|:)\\s*(?:"([^"]+)"|'([^']+)'|([^#;\\r\\n]+))`, + "mu" + ).exec(content); + return (match?.[1] ?? match?.[2] ?? match?.[3])?.trim(); +} + +function constraintAllowsVersion(constraint: string, version: string): boolean { + return pythonVersionSatisfiesConstraint(version, constraint); +} + +function valueForNonTomlConfig(path: string, content: string, key: string): string | undefined { + if (path.endsWith(".toml") || basename(path) === "Pipfile") return undefined; + if (path.endsWith(".json")) { + try { + const parsed = JSON.parse(content) as unknown; + return isTable(parsed) && typeof parsed[key] === "string" ? parsed[key].trim() : undefined; + } catch { + return undefined; + } + } + if (basename(path) === "setup.cfg" && key === "python_requires") { + return valueForIniOption(content, "options", key); + } + return valueForKey(content, key); +} + +function valueForIniOption(content: string, expectedSection: string, key: string): string | undefined { + try { + const value = pythonIniValue(parsePythonIni(content), expectedSection, key); + if (value === undefined) return undefined; + const match = /^(?:"([^"]*)"|'([^']*)'|([^#;]*))/u.exec(value); + return (match?.[1] ?? match?.[2] ?? match?.[3])?.trim(); + } catch { + return undefined; + } +} + +function isTable(value: unknown): value is TomlTable { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function joinRoot(root: string, name: string): string { + return root === "." ? name : `${root}/${name}`; +} + +function basename(path: string): string { + return path.slice(path.lastIndexOf("/") + 1); +} diff --git a/packages/validation-python/src/strict-json.ts b/packages/validation-python/src/strict-json.ts new file mode 100644 index 0000000..ac3496f --- /dev/null +++ b/packages/validation-python/src/strict-json.ts @@ -0,0 +1,28 @@ +import type { Node as JsonNode } from "jsonc-parser"; + +export function duplicateJsonObjectKey(node: JsonNode): string | undefined { + return node.type === "object" + ? duplicateObjectKey(node.children ?? []) + : duplicateNestedKey(node.children ?? []); +} + +function duplicateObjectKey(properties: readonly JsonNode[]): string | undefined { + const keys = new Set(); + for (const property of properties) { + const key = property.children?.[0]?.value; + if (typeof key === "string" && keys.has(key)) return key; + if (typeof key === "string") keys.add(key); + const value = property.children?.[1]; + const duplicate = duplicateNestedKey(value === undefined ? [] : [value]); + if (duplicate !== undefined) return duplicate; + } + return undefined; +} + +function duplicateNestedKey(children: readonly JsonNode[]): string | undefined { + for (const child of children) { + const duplicate = duplicateJsonObjectKey(child); + if (duplicate !== undefined) return duplicate; + } + return undefined; +} diff --git a/packages/validation-python/src/syntax-check.ts b/packages/validation-python/src/syntax-check.ts index 02b7e91..2401655 100644 --- a/packages/validation-python/src/syntax-check.ts +++ b/packages/validation-python/src/syntax-check.ts @@ -1,43 +1,34 @@ -import type { ValidationDiagnostic } from "@the-open-engine/opcore-contracts"; -import type { ValidationCheckDefinition } from "@the-open-engine/opcore-validation"; +import type { + ValidationCheckOutcome, + ValidationDiagnostic, + ValidationDiagnosticToolProvenance, + PythonInterpreterProvenance, + PythonProjectContext +} from "@the-open-engine/opcore-contracts"; +import type { ValidationCheckDefinition, ValidationCheckResult } from "@the-open-engine/opcore-validation"; import { PYTHON_SYNTAX_CHECK_ID } from "./check-ids.js"; import { pythonCheckAdapter, pythonCheckOwner, supportedPythonValidationScopes } from "./check-constants.js"; +import { + compilerRequest, + compilerToolProvenance, + parseCompilerResponse, + pythonCompileScript, + type PythonCompilerErrorKind, + type PythonCompilerFinding +} from "./compiler-protocol.js"; import { diagnostic, sortDiagnostics } from "./diagnostics.js"; import { runTool } from "./process.js"; -import { readPythonAfterSources, skippedPythonInputResult } from "./source-files.js"; -import { pythonAvailable, type PythonValidationToolchainOptions } from "./toolchain.js"; +import { readPythonAfterSources, skippedPythonInputResult, type PythonProjectContextResolver } from "./source-files.js"; +import { type PythonValidationToolchainOptions } from "./toolchain.js"; -const compoundStatementPattern = - /^(?:async\s+def|def|class|if|elif|else\b|for|while|try\b|except|finally\b|with|match|case)\b/u; - -const PY_AST_CHECK_SCRIPT = ` -import ast -import json -import sys - -source = sys.stdin.read() -try: - ast.parse(source) - print(json.dumps({"ok": True})) -except SyntaxError as error: - print(json.dumps({ - "ok": False, - "message": error.msg, - "line": error.lineno, - "column": error.offset - })) -except (ValueError, RecursionError) as error: - print(json.dumps({ - "ok": False, - "message": str(error), - "line": None, - "column": None - })) -`; - -export interface PythonSyntaxCheckOptions extends PythonValidationToolchainOptions {} +export interface PythonSyntaxCheckOptions extends Omit { + timeoutMs?: number; +} -export function createSyntaxCheck(options: PythonSyntaxCheckOptions = {}): ValidationCheckDefinition { +export function createSyntaxCheck( + options: PythonSyntaxCheckOptions = {}, + resolveContexts?: PythonProjectContextResolver +): ValidationCheckDefinition { return { id: PYTHON_SYNTAX_CHECK_ID, owner: pythonCheckOwner, @@ -48,218 +39,162 @@ export function createSyntaxCheck(options: PythonSyntaxCheckOptions = {}): Valid const skipped = skippedPythonInputResult(context); if (skipped !== undefined) return skipped; - const pythonCommand = options.pythonCommand ?? "python3"; - const parserAvailable = pythonAvailable({ env: options.env, pythonCommand }); - if (!parserAvailable) { - return { - status: "unsupported_request", - diagnostics: [ - diagnostic({ - category: "syntax", - severity: "info", - code: "PY_SYNTAX_PARSER_UNAVAILABLE", - message: - "Python syntax validation requires a Python interpreter (python3); none is available, so results are reported as unsupported instead of a false pass." - }) - ] - }; - } - + const sources = await readPythonAfterSources(context); + if (sources.length === 0) return { diagnostics: [] }; + if (resolveContexts === undefined) return missingContextResult(sources.map((source) => source.path)); + const resolvedContexts = await resolveContexts(context); + const missing = sources.map((source) => source.path).filter((path) => !resolvedContexts.some((candidate) => candidate.target === path)); + if (resolvedContexts.length === 0 || missing.length > 0) return missingContextResult(missing); + const unresolved = resolvedContexts.find((candidate) => candidate.interpreter === undefined || hasInterpreterFailure(candidate)); + if (unresolved !== undefined) return resolutionFailure(unresolved); + const contexts = groupProjectContexts(resolvedContexts); const diagnostics: ValidationDiagnostic[] = []; - for (const source of await readPythonAfterSources(context)) { - const parserDiagnostics = parseWithPython(source.path, source.content, pythonCommand, options.env); - diagnostics.push(...parserDiagnostics); - if (parserDiagnostics.length === 0) diagnostics.push(...heuristicSyntaxDiagnostics(source.path, source.content)); + for (const project of contexts) { + if (project.interpreter === undefined) return resolutionFailure(project); + const selected = sources.filter((source) => project.targets.includes(source.path)); + const result = await compileSources(project.interpreter, selected, options); + diagnostics.push(...(result.diagnostics ?? [])); + if (result.outcome !== "passed" && result.outcome !== "findings") return result; } - return { diagnostics: sortDiagnostics(diagnostics) }; + return { outcome: diagnostics.length === 0 ? "passed" : "findings", diagnostics: sortDiagnostics(diagnostics) }; } }; } -function parseWithPython( - path: string, - content: string, - pythonCommand: string, - env: Record | undefined -): readonly ValidationDiagnostic[] { - const result = runTool(pythonCommand, ["-c", PY_AST_CHECK_SCRIPT], { - input: content, - env, - timeoutMs: 10000 - }); +function missingContextResult(missing: readonly string[]): ValidationCheckResult { + const suffix = missing.length === 0 ? "" : `: ${missing.join(", ")}`; + const message = `Canonical Python project context resolution returned no context for selected source${suffix}`; + return { + outcome: "tool_failure", + failureMessage: message, + diagnostics: [diagnostic({ category: "infrastructure", code: "PY_SYNTAX_CONTEXT_MISSING", message })] + }; +} - let parsed: { ok: boolean; message?: string; line?: number | null; column?: number | null } | undefined; - try { - parsed = JSON.parse(result.stdout.trim()); - } catch { - parsed = undefined; - } +type PythonSyntaxProjectGroup = PythonProjectContext & { targets: readonly string[] }; - if (parsed === undefined) { - return [ - diagnostic({ - category: "syntax", - path, - code: "PY_SYNTAX_PARSE_ERROR", - message: "Unable to parse Python source with the configured Python interpreter." - }) - ]; +function groupProjectContexts(contexts: readonly PythonProjectContext[]): readonly PythonSyntaxProjectGroup[] { + const groups = new Map(); + for (const context of contexts) { + const group = groups.get(context.projectKey) ?? { context, targets: [] }; + group.targets.push(context.target); + groups.set(context.projectKey, group); } - - if (parsed.ok) return []; - - return [ - diagnostic({ - category: "syntax", - path, - code: "PY_SYNTAX_PARSE_ERROR", - message: buildParseErrorMessage(parsed) - }) - ]; + return [...groups.values()] + .map(({ context, targets }) => ({ ...context, targets: [...new Set(targets)].sort() })) + .sort((left, right) => left.projectRoot.localeCompare(right.projectRoot)); } -function buildParseErrorMessage(parsed: { message?: string; line?: number | null; column?: number | null }): string { - const reason = parsed.message ?? "invalid syntax"; - if (parsed.line !== null && parsed.line !== undefined && parsed.column !== null && parsed.column !== undefined) { - return `${reason} (line ${parsed.line}, column ${parsed.column})`; +async function compileSources( + interpreter: PythonInterpreterProvenance, + sources: Awaited>, + options: PythonSyntaxCheckOptions +): Promise { + const result = await runTool(interpreter.executable, [...interpreter.argv.slice(1), "-I", "-B", "-c", pythonCompileScript], { + cwd: interpreter.cwd, + env: options.env, + timeoutMs: options.timeoutMs ?? 10000, + input: compilerRequest(sources) + }); + if (!result.ok) { + const outcome = result.termination === "timeout" ? "timeout" : "tool_failure"; + return checkFailure(outcome, result.failureMessage ?? "Python compiler invocation failed", compilerToolProvenance(interpreter)); + } + const response = parseCompilerResponse(result.stdout, sources, interpreter); + if (response.status === "malformed") { + return checkFailure("tool_failure", response.message, compilerToolProvenance(interpreter)); } - return reason; + const diagnostics = response.findings.map((finding) => findingDiagnostic(finding, interpreter)); + return { + outcome: diagnostics.length === 0 ? "passed" : "findings", + diagnostics: sortDiagnostics(diagnostics) + }; } -function heuristicSyntaxDiagnostics(path: string, content: string): readonly ValidationDiagnostic[] { - const diagnostics: ValidationDiagnostic[] = []; - diagnostics.push(...missingColonDiagnostics(path, content)); - diagnostics.push(...delimiterDiagnostics(path, content)); - return diagnostics; +function resolutionFailure(context: PythonProjectContext): ValidationCheckResult { + const primary = context.reasons.find((reason) => reason.tool === "python") ?? context.reasons[0]; + const outcome = primary?.code === "probe_timeout" ? "timeout" : + primary?.code === "invalid_config" || context.outcome === "ambiguous" ? "invalid_config" : + primary?.code === "incompatible_interpreter" || primary?.code === "unsupported_target" || primary?.code === "unsupported_platform" + ? "unsupported_target" : + primary?.code === "interpreter_unavailable" || primary?.code === "tool_unavailable" ? "tool_unavailable" : "tool_failure"; + const message = primary?.message ?? `Python project context is unresolved for ${context.target}`; + const code = resolutionFailureCode(outcome); + const unsupported = outcome === "tool_unavailable" || outcome === "invalid_config" || outcome === "unsupported_target"; + return { + outcome, + failureMessage: message, + diagnostics: [ + diagnostic({ + category: "infrastructure", + severity: unsupported ? "info" : "error", + code, + message, + path: context.target, + ...(context.interpreter === undefined ? {} : { tool: compilerToolProvenance(context.interpreter) }) + }) + ] + }; } -function missingColonDiagnostics(path: string, content: string): readonly ValidationDiagnostic[] { - const diagnostics: ValidationDiagnostic[] = []; - for (const rawLine of content.split(/\r?\n/u)) { - const line = stripInlineComment(rawLine).trim(); - if (line.length === 0 || line.endsWith(":") || line.endsWith("\\")) continue; - if (!compoundStatementPattern.test(line)) continue; - diagnostics.push( +function checkFailure( + outcome: Extract, + message: string, + tool: ValidationDiagnosticToolProvenance +): ValidationCheckResult { + return { + outcome, + failureMessage: message, + diagnostics: [ diagnostic({ - category: "syntax", - path, - code: "PY_SYNTAX_MISSING_COLON", - message: "Python compound statements must end with a colon." + category: "infrastructure", + code: outcome === "timeout" ? "PY_SYNTAX_COMPILER_TIMEOUT" : "PY_SYNTAX_COMPILER_FAILURE", + message, + tool }) - ); - } - return diagnostics; + ] + }; } -function delimiterDiagnostics(path: string, content: string): readonly ValidationDiagnostic[] { - const diagnostics: ValidationDiagnostic[] = []; - const stack: string[] = []; - let quote: "'" | "\"" | undefined; - let tripleQuote: string | undefined; - let escaped = false; - - for (let index = 0; index < content.length; index += 1) { - const char = content[index]; - const nextThree = content.slice(index, index + 3); - if (tripleQuote !== undefined) { - if (nextThree === tripleQuote) { - tripleQuote = undefined; - index += 2; - } - continue; - } - if (escaped) { - escaped = false; - continue; - } - if (quote !== undefined) { - if (char === "\\") { - escaped = true; - continue; - } - if (char === quote) quote = undefined; - if (char === "\n") quote = undefined; - continue; - } - if (nextThree === "'''" || nextThree === "\"\"\"") { - tripleQuote = nextThree; - index += 2; - continue; - } - if (char === "'" || char === "\"") { - quote = char; - continue; - } - if (char === "#") { - while (index < content.length && content[index] !== "\n") index += 1; - continue; - } - if (char === "(" || char === "[" || char === "{") { - stack.push(char); - continue; - } - if (char === ")" || char === "]" || char === "}") { - const open = stack.pop(); - if (open === undefined || matchingClose(open) !== char) { - diagnostics.push( - diagnostic({ - category: "syntax", - path, - code: "PY_SYNTAX_MISMATCHED_DELIMITER", - message: "Python delimiters are mismatched." - }) - ); - break; - } - } - } +function findingDiagnostic(finding: PythonCompilerFinding, interpreter: PythonInterpreterProvenance): ValidationDiagnostic { + return diagnostic({ + category: "syntax", + path: finding.path, + code: compilerFindingCode(finding.kind), + message: finding.message, + line: finding.line, + column: finding.column, + endLine: finding.endLine, + endColumn: finding.endColumn, + tool: compilerToolProvenance(interpreter) + }); +} - if (tripleQuote !== undefined || quote !== undefined) { - diagnostics.push( - diagnostic({ - category: "syntax", - path, - code: "PY_SYNTAX_UNTERMINATED_STRING", - message: "Python string literal is unterminated." - }) - ); - } - if (stack.length > 0) { - diagnostics.push( - diagnostic({ - category: "syntax", - path, - code: "PY_SYNTAX_UNCLOSED_DELIMITER", - message: "Python delimiters are not closed." - }) - ); - } - return diagnostics; +function compilerFindingCode(kind: PythonCompilerErrorKind): string { + const codes: Record = { + syntax_error: "PY_SYNTAX_ERROR", + indentation_error: "PY_INDENTATION_ERROR", + tab_error: "PY_TAB_ERROR", + null_byte: "PY_NULL_BYTE", + recursion_error: "PY_COMPILER_RECURSION_ERROR", + overflow_error: "PY_COMPILER_OVERFLOW_ERROR" + }; + return codes[kind]; } -function matchingClose(open: string): string { - return open === "(" ? ")" : open === "[" ? "]" : "}"; +function resolutionFailureCode(outcome: Exclude): string { + const codes = { + tool_unavailable: "PY_SYNTAX_TOOL_UNAVAILABLE", + invalid_config: "PY_SYNTAX_INVALID_CONFIG", + timeout: "PY_SYNTAX_INTERPRETER_TIMEOUT", + unsupported_target: "PY_SYNTAX_UNSUPPORTED_TARGET", + tool_failure: "PY_SYNTAX_INTERPRETER_FAILURE" + } as const; + return codes[outcome]; } -function stripInlineComment(line: string): string { - let quote: "'" | "\"" | undefined; - let escaped = false; - for (let index = 0; index < line.length; index += 1) { - const char = line[index]; - if (escaped) { - escaped = false; - continue; - } - if (quote !== undefined && char === "\\") { - escaped = true; - continue; - } - if (char === "'" || char === "\"") { - if (quote === undefined) quote = char; - else if (quote === char) quote = undefined; - continue; - } - if (quote === undefined && char === "#") return line.slice(0, index); - } - return line; +function hasInterpreterFailure(context: PythonProjectContext): boolean { + return context.outcome === "ambiguous" || context.outcome === "unsupported" || context.reasons.some((reason) => reason.tool === "python" || + reason.code === "invalid_config" || + ["incompatible_interpreter", "unsupported_target", "unsupported_platform", "symlink_refused", "path_refused", "ambiguous_path"].includes(reason.code)); } diff --git a/packages/validation-python/src/toml-config.ts b/packages/validation-python/src/toml-config.ts new file mode 100644 index 0000000..ee1527b --- /dev/null +++ b/packages/validation-python/src/toml-config.ts @@ -0,0 +1,38 @@ +import { parse as parseToml } from "smol-toml"; + +export type TomlTable = Record; + +export function parsePythonToml(content: string): TomlTable { + const parsed = parseToml(content); + if (!isTomlTable(parsed)) throw new Error("TOML config root must be a table"); + return parsed; +} + +export function tomlValueAt(value: unknown, path: readonly string[]): unknown { + let current = value; + for (const segment of path) { + if (!isTomlTable(current)) return undefined; + current = current[segment]; + } + return current; +} + +export function tomlTableAt(value: unknown, path: readonly string[]): TomlTable | undefined { + const selected = tomlValueAt(value, path); + return isTomlTable(selected) ? selected : undefined; +} + +export function tomlStringAt(value: unknown, path: readonly string[]): string | undefined { + const selected = tomlValueAt(value, path); + return typeof selected === "string" && selected.trim().length > 0 ? selected.trim() : undefined; +} + +export function tomlStringArrayAt(value: unknown, path: readonly string[]): string[] { + const selected = tomlValueAt(value, path); + if (!Array.isArray(selected)) return []; + return selected.filter((entry): entry is string => typeof entry === "string" && entry.length > 0); +} + +function isTomlTable(value: unknown): value is TomlTable { + return value !== null && typeof value === "object" && !Array.isArray(value); +} diff --git a/packages/validation-python/src/toolchain-resolver.ts b/packages/validation-python/src/toolchain-resolver.ts deleted file mode 100644 index 8ed7522..0000000 --- a/packages/validation-python/src/toolchain-resolver.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { existsSync } from "node:fs"; -import { join } from "node:path"; -import { runTool } from "./process.js"; - -export type PythonToolResolutionSource = "virtualenv-env" | "repo-venv" | "node-modules" | "path"; - -export interface PythonToolResolution { - tool: string; - available: boolean; - command: string; - args: readonly string[]; - cwd: string; - source: PythonToolResolutionSource; - version?: string; - configFile?: string; - failureMessage?: string; -} - -export interface PythonToolResolverOptions { - repoRoot: string; - env?: Record; - pythonCommand?: string; -} - -const pythonVirtualEnvDirs = [".venv", "venv", "env"] as const; - -const pythonConfigFileCandidates: Record = { - mypy: ["mypy.ini", "setup.cfg", "tox.ini", "pyproject.toml"], - pyright: ["pyrightconfig.json", "pyproject.toml"], - ruff: ["ruff.toml", ".ruff.toml", "pyproject.toml"], - pytest: ["pytest.ini", "tox.ini", "setup.cfg", "pyproject.toml"] -}; - -interface PythonToolCandidate { - command: string; - source: PythonToolResolutionSource; -} - -export function resolvePythonTool( - tool: string, - fallbackCommand: string, - versionArgs: readonly string[], - options: PythonToolResolverOptions -): PythonToolResolution { - const repoRoot = options.repoRoot; - const binaryName = tool === "python" ? "python" : tool; - const configFile = findPythonConfigFile(repoRoot, tool); - const candidates: PythonToolCandidate[] = []; - - const virtualEnv = options.env?.VIRTUAL_ENV; - if (virtualEnv) { - candidates.push({ command: join(virtualEnv, "bin", binaryName), source: "virtualenv-env" }); - } - for (const dir of pythonVirtualEnvDirs) { - candidates.push({ command: join(repoRoot, dir, "bin", binaryName), source: "repo-venv" }); - } - candidates.push({ command: join(repoRoot, "node_modules", ".bin", binaryName), source: "node-modules" }); - candidates.push({ command: fallbackCommand, source: "path" }); - - for (const candidate of candidates) { - if (candidate.source !== "path" && !existsSync(candidate.command)) continue; - const result = runTool(candidate.command, versionArgs, { env: options.env, cwd: repoRoot }); - if (result.ok) { - const version = (result.stdout || result.stderr).trim().split(/\r?\n/, 1)[0]; - return { - tool, - available: true, - command: candidate.command, - args: versionArgs, - cwd: repoRoot, - source: candidate.source, - ...(version.length > 0 ? { version } : {}), - ...(configFile !== undefined ? { configFile } : {}) - }; - } - } - - const failureResult = runTool(fallbackCommand, versionArgs, { env: options.env, cwd: repoRoot }); - return { - tool, - available: false, - command: fallbackCommand, - args: versionArgs, - cwd: repoRoot, - source: "path", - ...(configFile !== undefined ? { configFile } : {}), - failureMessage: failureResult.failureMessage ?? `${tool} unavailable` - }; -} - -export function findPythonConfigFile(repoRoot: string, tool: string): string | undefined { - const candidates = pythonConfigFileCandidates[tool] ?? ["pyproject.toml"]; - for (const name of candidates) { - const candidatePath = join(repoRoot, name); - if (existsSync(candidatePath)) return candidatePath; - } - return undefined; -} diff --git a/packages/validation-python/src/toolchain.ts b/packages/validation-python/src/toolchain.ts index 4cb07a7..da6e241 100644 --- a/packages/validation-python/src/toolchain.ts +++ b/packages/validation-python/src/toolchain.ts @@ -1,91 +1,154 @@ import type { + PythonProjectContext, + PythonProjectToolKind, ValidationAdapterDegradedCheckStatus, ValidationAdapterRuntimeStatus, ValidationAdapterToolchainStatus } from "@the-open-engine/opcore-contracts"; import { PYTHON_SYNTAX_CHECK_ID, PYTHON_TYPES_CHECK_ID, pythonValidationCheckIds } from "./check-ids.js"; import { validationPythonAdapterName } from "./check-constants.js"; -import { resolvePythonTool, type PythonToolResolution } from "./toolchain-resolver.js"; +import type { PythonProjectProcessProbe } from "./environment-resolution.js"; +import type { PythonProjectWorkspace } from "./project-workspace.js"; +import { selectPythonTypeAuthority } from "./type-authority.js"; export interface PythonValidationToolchainOptions { repoRoot?: string; env?: Record; - pythonCommand?: string; + interpreterArgv?: readonly string[]; + toolArgv?: Partial>; + platform?: string; + architecture?: string; + processProbe?: PythonProjectProcessProbe; + timeoutMs?: number; + contexts?: readonly PythonProjectContext[]; + nodeWorkspace?: PythonProjectWorkspace; } export function createPythonValidationAdapterStatus( options: PythonValidationToolchainOptions = {} ): ValidationAdapterRuntimeStatus { - const checkIds = [...pythonValidationCheckIds]; - const toolchain = probePythonToolchain(options); - const missing = new Set(toolchain.filter((tool) => !tool.available).map((tool) => tool.tool)); - const degradedChecks = createPythonDegradedChecks(missing); + const toolchain = options.contexts === undefined ? unresolvedContextToolchain() : toolchainFromContexts(options.contexts); + const degradedChecks = pythonTypeDegradedChecks(options.contexts); return { adapter: validationPythonAdapterName, - status: degradedChecks.length > 0 ? "degraded" : "available", - checkIds, + status: degradedChecks.length > 0 || options.contexts?.some((context) => context.outcome !== "resolved") + ? "degraded" + : "available", + checkIds: [...pythonValidationCheckIds], toolchain, degradedChecks, tempWorkspaceRequired: false }; } -export function probePythonToolchain( - options: PythonValidationToolchainOptions = {} -): readonly ValidationAdapterToolchainStatus[] { - const resolverOptions = { - repoRoot: options.repoRoot ?? process.cwd(), - env: options.env, - pythonCommand: options.pythonCommand - }; +function pythonTypeDegradedChecks( + contexts: readonly PythonProjectContext[] | undefined +): readonly ValidationAdapterDegradedCheckStatus[] { + if (contexts === undefined) return [contextRequiredDegradation()]; + const typeGaps = contexts.flatMap(pythonTypeGap); + const syntaxUnavailable = contexts.some((context) => context.interpreter === undefined); return [ - toToolchainStatus(resolvePythonTool("python", options.pythonCommand ?? "python3", ["--version"], resolverOptions)), - toToolchainStatus(resolvePythonTool("mypy", "mypy", ["--version"], resolverOptions)), - toToolchainStatus(resolvePythonTool("pyright", "pyright", ["--version"], resolverOptions)), - toToolchainStatus(resolvePythonTool("ruff", "ruff", ["--version"], resolverOptions)), - toToolchainStatus(resolvePythonTool("pytest", "pytest", ["--version"], resolverOptions)) + ...(typeGaps.length === 0 ? [] : [typeGapDegradation(typeGaps)]), + ...(syntaxUnavailable ? [syntaxToolDegradation()] : []) ]; } -export function pythonAvailable(options: PythonValidationToolchainOptions = {}): boolean { - return resolvePythonTool("python", options.pythonCommand ?? "python3", ["--version"], { - repoRoot: options.repoRoot ?? process.cwd(), - env: options.env - }).available; +interface PythonTypeGap { + projectRoot: string; + reason: string; + requiredTool: string; + followUpIssue?: string; } -export function createPythonDegradedChecks(missing: ReadonlySet): readonly ValidationAdapterDegradedCheckStatus[] { - const degraded: ValidationAdapterDegradedCheckStatus[] = []; - if (missing.has("mypy") && missing.has("pyright")) { - degraded.push({ - checkId: PYTHON_TYPES_CHECK_ID, - status: "unsupported_request", - reason: "optional_tool_unavailable", - requiredTool: "mypy or pyright", - message: "Neither mypy nor pyright is available; Python type validation is reported as degraded instead of passing silently." - }); +function pythonTypeGap(context: PythonProjectContext): readonly PythonTypeGap[] { + const selection = selectPythonTypeAuthority(context); + if (selection.status === "invalid_config") { + return [{ projectRoot: context.projectRoot, reason: selection.message ?? "Python type authority configuration is invalid", requiredTool: "one non-conflicting configured authority" }]; + } + if (selection.status === "unsupported_target") { + return [{ projectRoot: context.projectRoot, reason: selection.message ?? "Python type authority is not configured", requiredTool: "configured Python type authority" }]; } - if (missing.has("python")) { - degraded.push({ - checkId: PYTHON_SYNTAX_CHECK_ID, - status: "unsupported_request", - reason: "required_tool_unavailable", - requiredTool: "python3", - message: "No Python interpreter (python3) is available; python.syntax cannot be parser-validated, so results are reported as unsupported instead of a false pass." - }); + if (selection.tool === undefined || !selection.tool.available) { + const authority = selection.authority ?? "Python type"; + return [{ projectRoot: context.projectRoot, reason: `Configured ${authority} authority is unavailable`, requiredTool: authority }]; } - return degraded; + return []; +} + +function typeGapDegradation(gaps: readonly PythonTypeGap[]): ValidationAdapterDegradedCheckStatus { + const requiredTools = [...new Set(gaps.map((gap) => gap.requiredTool))]; + const followUps = [...new Set(gaps.flatMap((gap) => gap.followUpIssue === undefined ? [] : [gap.followUpIssue]))]; + return { + checkId: PYTHON_TYPES_CHECK_ID, + status: "unsupported_request", + reason: "configured_authority_unavailable", + requiredTool: requiredTools.length === 1 ? requiredTools[0] : "per-project configured authority", + message: gaps.map((gap) => `${gap.projectRoot}: ${gap.reason}`).join("; "), + ...(followUps.length === 1 ? { followUpIssue: followUps[0] } : {}) + }; +} + +function contextRequiredDegradation(): ValidationAdapterDegradedCheckStatus { + return { + checkId: PYTHON_TYPES_CHECK_ID, + status: "unsupported_request", + reason: "canonical_context_required", + requiredTool: "configured Python type authority", + message: "Canonical Python project contexts are required before reporting python.types authority status." + }; } -function toToolchainStatus(resolution: PythonToolResolution): ValidationAdapterToolchainStatus { +function syntaxToolDegradation(): ValidationAdapterDegradedCheckStatus { return { - tool: resolution.tool, - available: resolution.available, - command: [resolution.command, ...resolution.args].join(" "), - cwd: resolution.cwd, - source: resolution.source, - ...(resolution.version !== undefined ? { version: resolution.version } : {}), - ...(resolution.configFile !== undefined ? { configFile: resolution.configFile } : {}), - ...(resolution.failureMessage !== undefined ? { failureMessage: resolution.failureMessage } : {}) + checkId: PYTHON_SYNTAX_CHECK_ID, + status: "unsupported_request", + reason: "required_tool_unavailable", + requiredTool: "python", + message: "No compatible canonical project interpreter is available; python.syntax cannot compile the selected after-state." }; } + +function toolchainFromContexts(contexts: readonly PythonProjectContext[]): readonly ValidationAdapterToolchainStatus[] { + const entries: ValidationAdapterToolchainStatus[] = []; + for (const context of contexts) { + if (context.interpreter !== undefined) { + entries.push({ + tool: "python", + available: !context.reasons.some((reason) => reason.tool === "python"), + command: context.interpreter.argv.join(" "), + cwd: context.interpreter.cwd, + source: context.interpreter.source, + ...(context.interpreter.version === undefined ? {} : { version: context.interpreter.version }) + }); + } else { + entries.push({ tool: "python", available: false, failureMessage: reasonMessage(context, "python") }); + } + for (const tool of context.tools) { + entries.push({ + tool: tool.tool, + available: tool.available, + command: tool.argv.join(" "), + cwd: tool.cwd, + source: tool.source, + ...(tool.version === undefined ? {} : { version: tool.version }), + ...(tool.configFile === undefined ? {} : { configFile: tool.configFile }), + ...(tool.available ? {} : { failureMessage: reasonMessage(context, tool.tool) }) + }); + } + } + const byIdentity = new Map(); + for (const entry of entries) byIdentity.set(`${entry.tool}\0${entry.command ?? ""}\0${entry.cwd ?? ""}`, entry); + return [...byIdentity.values()].sort((left, right) => `${left.tool}\0${left.command ?? ""}`.localeCompare(`${right.tool}\0${right.command ?? ""}`)); +} + +function unresolvedContextToolchain(): readonly ValidationAdapterToolchainStatus[] { + return ["python", "mypy", "pyright", "ruff", "pytest"].map((tool) => ({ + tool, + available: false, + failureMessage: "Canonical Python project contexts are required before reporting toolchain availability" + })); +} + +function reasonMessage(context: PythonProjectContext, tool: string): string { + return context.reasons.find((reason) => reason.tool === tool)?.message ?? `${tool} unavailable for ${context.projectRoot}`; +} diff --git a/packages/validation-python/src/type-authority.ts b/packages/validation-python/src/type-authority.ts new file mode 100644 index 0000000..de7e341 --- /dev/null +++ b/packages/validation-python/src/type-authority.ts @@ -0,0 +1,107 @@ +import type { + PythonProjectContext, + PythonProjectToolProvenance, + PythonValidationAuthority, + PythonValidationAuthoritySource +} from "@the-open-engine/opcore-contracts"; + +export interface PythonTypeAuthoritySelection { + status: "selected" | "invalid_config" | "unsupported_target"; + authority?: PythonValidationAuthority; + source?: PythonValidationAuthoritySource; + tool?: PythonProjectToolProvenance; + configPaths: readonly string[]; + message?: string; +} + +interface AuthorityFacts { + context: PythonProjectContext; + explicit?: PythonValidationAuthority; + mypy?: PythonProjectToolProvenance; + pyright?: PythonProjectToolProvenance; + configured: readonly PythonValidationAuthority[]; + configPaths: readonly string[]; +} + +export function selectPythonTypeAuthority( + context: PythonProjectContext, + explicit?: PythonValidationAuthority +): PythonTypeAuthoritySelection { + const facts = authorityFacts(context, explicit); + return invalidExplicit(facts) ?? invalidConfig(facts) ?? selectedAuthority(facts); +} + +function authorityFacts(context: PythonProjectContext, explicit?: PythonValidationAuthority): AuthorityFacts { + const mypy = context.tools.find((tool) => tool.tool === "mypy"); + const pyright = context.tools.find((tool) => tool.tool === "pyright"); + const configured = [ + ...(mypy?.configFile === undefined ? [] : ["mypy" as const]), + ...(pyright?.configFile === undefined ? [] : ["pyright" as const]) + ]; + const reasonPaths = context.reasons + .filter((reason) => reason.code === "invalid_config" && (reason.tool === "mypy" || reason.tool === "pyright")) + .flatMap((reason) => reason.path === undefined ? [] : [reason.path]); + const configPaths = [mypy?.configFile, pyright?.configFile, ...reasonPaths] + .filter((path): path is string => path !== undefined); + return { context, explicit, mypy, pyright, configured, configPaths: [...new Set(configPaths)].sort() }; +} + +function invalidExplicit(facts: AuthorityFacts): PythonTypeAuthoritySelection | undefined { + if (facts.explicit === undefined || facts.explicit === "mypy" || facts.explicit === "pyright") return undefined; + return { + status: "invalid_config", + configPaths: facts.configPaths, + message: `Unknown explicit Python type authority: ${String(facts.explicit)}` + }; +} + +function invalidConfig(facts: AuthorityFacts): PythonTypeAuthoritySelection | undefined { + const malformed = facts.context.reasons.filter((reason) => + reason.code === "invalid_config" && (reason.tool === "mypy" || reason.tool === "pyright" || reason.tool === undefined) + ); + if (malformed.length > 0) return invalidSelection(facts, malformed.map((reason) => reason.message).join("; ")); + if (facts.configured.length > 1) { + return invalidSelection(facts, `Python project ${facts.context.projectRoot} configures both mypy and pyright`); + } + const configured = facts.configured[0]; + if (facts.explicit !== undefined && configured !== undefined && facts.explicit !== configured) { + return invalidSelection( + facts, + `Explicit Python type authority ${facts.explicit} conflicts with configured ${configured} authority for ${facts.context.projectRoot}` + ); + } + return undefined; +} + +function invalidSelection(facts: AuthorityFacts, message: string): PythonTypeAuthoritySelection { + return { + status: "invalid_config", + configPaths: facts.configPaths, + message + }; +} + +function selectedAuthority(facts: AuthorityFacts): PythonTypeAuthoritySelection { + const authority = facts.explicit ?? facts.configured[0]; + if (authority === undefined) { + return { + status: "unsupported_target", + configPaths: facts.configPaths, + message: `Python project ${facts.context.projectRoot} has no explicit or configured type-checker authority` + }; + } + return { + status: "selected", + ...authorityEvidence(facts, authority, facts.explicit === undefined ? "project_config" : "explicit"), + configPaths: facts.configPaths + }; +} + +function authorityEvidence( + facts: AuthorityFacts, + authority: PythonValidationAuthority, + source: PythonValidationAuthoritySource +): Pick { + const tool = authority === "mypy" ? facts.mypy : facts.pyright; + return { authority, source, ...(tool === undefined ? {} : { tool }) }; +} diff --git a/packages/validation-python/src/type-capability-run.ts b/packages/validation-python/src/type-capability-run.ts new file mode 100644 index 0000000..41a1dc6 --- /dev/null +++ b/packages/validation-python/src/type-capability-run.ts @@ -0,0 +1,254 @@ +import type { + PythonProjectContext, + PythonProjectToolProvenance, + PythonValidationAuthority, + PythonValidationAuthoritySource, + PythonValidationCapabilityRun, + PythonValidationCapabilityRunStatus, + PythonValidationCapabilityToolProvenance +} from "@the-open-engine/opcore-contracts"; +import { normalizeValidationFileViewPath, type ValidationFileReadStatus, type ValidationFileView } from "@the-open-engine/opcore-validation"; +import { createHash } from "node:crypto"; +import { mkdtempSync, rmSync } from "node:fs"; +import { mkdir, realpath, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, isAbsolute, join, posix, relative, resolve, sep } from "node:path"; + +export const isolatedMypyConfigPath = ".opcore-mypy-isolated.ini"; + +interface ManifestRecord { + path: string; + status: ValidationFileReadStatus; + checksum?: string; + content?: string; +} + +export interface PythonTypeCapabilityPreparation { + project: PythonProjectContext; + targets: readonly string[]; + selectedSourcePaths: readonly string[]; + selectedConfigPaths: readonly string[]; + moduleSearchRoots: readonly string[]; + afterStateManifestFingerprint: string; + records: readonly ManifestRecord[]; +} + +export interface MaterializedPythonTypeWorkspace { + root: string; + runtimeRoot: string; + projectCwd: string; + pythonPathEntries: readonly string[]; + selectedSourcePaths: readonly string[]; + selectedConfigPaths: readonly string[]; + afterStateContentByPath: ReadonlyMap; + cleanup(): void; +} + +export async function preparePythonTypeCapability(args: { + fileView: ValidationFileView; + project: PythonProjectContext; + targets: readonly string[]; + sourcePaths: readonly string[]; + configPaths: readonly string[]; + moduleSearchRoots?: readonly string[]; +}): Promise { + const targets = uniqueSorted(args.targets); + const selectedSourcePaths = uniqueSorted(args.sourcePaths); + const selectedConfigPaths = uniqueSorted(args.configPaths); + const manifestConfigPaths = authorityCandidatePaths(args.project.projectRoot); + const records: ManifestRecord[] = []; + for (const path of uniqueSorted([ + ...selectedSourcePaths, + ...selectedConfigPaths, + ...manifestConfigPaths + ])) { + const state = await args.fileView.readAfter(path); + records.push({ + path, + status: state.status, + ...(state.status === "found" ? { checksum: state.checksum, content: state.content } : {}) + }); + } + const portableRecords = records.map(({ content: _content, ...record }) => record); + const canonical = JSON.stringify({ + projectKey: args.project.projectKey, + contextFingerprint: args.project.contextFingerprint, + projectRoot: args.project.projectRoot, + targets, + records: portableRecords + }); + return { + project: args.project, + targets, + selectedSourcePaths, + selectedConfigPaths, + moduleSearchRoots: uniqueSorted(args.moduleSearchRoots ?? args.project.sourceRoots), + afterStateManifestFingerprint: `sha256:${createHash("sha256").update(canonical, "utf8").digest("hex")}`, + records + }; +} + +function authorityCandidatePaths(projectRoot: string): readonly string[] { + const names = [".mypy.ini", "mypy.ini", "pyproject.toml", "pyrightconfig.json", "setup.cfg", "tox.ini"]; + return names.map((name) => projectRoot === "." ? name : `${projectRoot}/${name}`).sort(); +} + +export async function materializePythonTypeCapability( + preparation: PythonTypeCapabilityPreparation +): Promise { + const tempRoot = mkdtempSync(join(tmpdir(), "opcore-python-types-workspace-")); + const rawRoot = join(tempRoot, "repo"); + try { + await mkdir(rawRoot, { recursive: true }); + const root = await realpath(rawRoot); + const runtimeRoot = await realpath(tempRoot); + for (const record of preparation.records) { + if (record.status !== "found" || record.content === undefined) continue; + const absolute = resolveRepoPath(root, record.path); + await mkdir(dirname(absolute), { recursive: true }); + await writeFile(absolute, record.content, "utf8"); + } + const projectCwd = preparation.project.projectRoot === "." + ? root + : resolveRepoPath(root, preparation.project.projectRoot); + await mkdir(projectCwd, { recursive: true }); + await writeFile(join(projectCwd, isolatedMypyConfigPath), "[mypy]\n", "utf8"); + for (const name of ["home", "xdg-config", "xdg-cache", "tmp", "pyright-cache"]) { + await mkdir(join(tempRoot, name), { recursive: true }); + } + const pythonPathEntries = preparation.moduleSearchRoots.map((path) => + path === "." ? root : resolveRepoPath(root, path) + ); + return { + root, + runtimeRoot, + projectCwd, + pythonPathEntries, + selectedSourcePaths: preparation.selectedSourcePaths, + selectedConfigPaths: preparation.selectedConfigPaths, + afterStateContentByPath: new Map(preparation.records.flatMap((record) => + record.status === "found" && record.content !== undefined ? [[record.path, record.content] as const] : [] + )), + cleanup: () => rmSync(tempRoot, { recursive: true, force: true }) + }; + } catch (error) { + rmSync(tempRoot, { recursive: true, force: true }); + throw error; + } +} + +export function createPythonTypeCapabilityRun(args: { + preparation: PythonTypeCapabilityPreparation; + authority?: PythonValidationAuthority; + authoritySource?: PythonValidationAuthoritySource; + status: PythonValidationCapabilityRunStatus; + durationMs: number; + counts?: { diagnosticCount: number; errorCount: number; warningCount: number; noteCount: number }; + tool?: PythonValidationCapabilityToolProvenance; + execution?: PythonValidationCapabilityRun["execution"]; +}): PythonValidationCapabilityRun { + const counts = args.counts ?? { diagnosticCount: 0, errorCount: 0, warningCount: 0, noteCount: 0 }; + return { + schemaId: "opcore.python.validation-capability-run", + schemaVersion: 1, + capability: "types", + checkId: "python.types", + projectKey: args.preparation.project.projectKey, + contextFingerprint: args.preparation.project.contextFingerprint, + projectRoot: args.preparation.project.projectRoot, + targets: args.preparation.targets, + selectedSourcePaths: args.preparation.selectedSourcePaths, + selectedConfigPaths: args.preparation.selectedConfigPaths, + afterStateManifestFingerprint: args.preparation.afterStateManifestFingerprint, + ...(args.authority === undefined ? {} : { authority: args.authority }), + ...(args.authoritySource === undefined ? {} : { authoritySource: args.authoritySource }), + status: args.status, + ...(args.tool === undefined ? {} : { tool: args.tool }), + ...(args.execution === undefined ? {} : { execution: args.execution }), + durationMs: Math.max(0, Math.trunc(args.durationMs)), + ...counts + }; +} + +export function portablePythonValidationTool(args: { + checker: PythonProjectToolProvenance; + preparation: PythonTypeCapabilityPreparation; + authority: PythonValidationAuthority; + argv?: readonly string[]; +}): PythonValidationCapabilityToolProvenance { + const executable = portableExecutableLocator( + args.checker.executable, + args.preparation.project.repositoryRoot + ); + const observedArgv = args.argv ?? args.checker.argv; + const argv = observedArgv.map((argument, index) => index === 0 + ? executable + : portableArgument(argument, args.preparation.project.repositoryRoot)); + return { + name: args.authority, + executable, + argv, + cwd: args.preparation.project.projectRoot, + source: args.checker.source, + ...(args.checker.version === undefined ? {} : { version: args.checker.version }), + ...(args.checker.configFile === undefined ? {} : { configFile: args.checker.configFile }) + }; +} + +function portableArgument(argument: string, repositoryRoot: string): string { + const assignment = /^(.*?=)(.*)$/u.exec(argument); + if (assignment !== null && isHostAbsolutePath(assignment[2])) { + return `${assignment[1]}${portableExecutableLocator(assignment[2], repositoryRoot)}`; + } + return isHostAbsolutePath(argument) ? portableExecutableLocator(argument, repositoryRoot) : argument; +} + +function portableExecutableLocator(executable: string, repositoryRoot: string): string { + if (!isHostAbsolutePath(executable)) { + if (!executable.includes("/") && !executable.includes("\\")) return `path:${executable}`; + const normalized = posix.normalize(executable.replaceAll("\\", "/").replace(/^\.\//u, "")); + if (normalized !== ".." && !normalized.startsWith("../") && normalized !== ".") return `project:${normalized}`; + return `external:${portableBasename(executable)}`; + } + const repositoryRelative = relative(repositoryRoot, executable).replaceAll("\\", "/"); + if (repositoryRelative.length > 0 && repositoryRelative !== ".." && !repositoryRelative.startsWith("../") && !isAbsolute(repositoryRelative)) { + return `repo:${repositoryRelative}`; + } + return `external:${portableBasename(executable)}`; +} + +function portableBasename(path: string): string { + const value = path.replaceAll("\\", "/").split("/").filter(Boolean).at(-1) ?? "tool"; + return /^[A-Za-z0-9_.+-]+$/u.test(value) ? value : "tool"; +} + +function isHostAbsolutePath(value: string): boolean { + return isAbsolute(value) || /^[A-Za-z]:[\\/]/u.test(value) || /^\\\\/u.test(value); +} + +export function relativePythonProjectPath(path: string, projectRoot: string): string { + if (projectRoot === ".") return path; + return path === projectRoot ? "." : path.slice(`${projectRoot}/`.length); +} + +export function repoRelativeMaterializedPath(path: string, projectCwd: string, workspaceRoot: string): string { + const absolute = resolve(projectCwd, path); + const repoPath = relative(workspaceRoot, absolute).replaceAll("\\", "/"); + if (repoPath.length === 0 || repoPath.startsWith("..") || repoPath.split("/").includes("..")) { + throw new Error("mypy diagnostic path is outside the materialized repository"); + } + return normalizeValidationFileViewPath(repoPath); +} + +function resolveRepoPath(root: string, path: string): string { + const absolute = resolve(root, path); + const relativePath = relative(root, absolute); + if (relativePath === "" || relativePath.startsWith("..") || relativePath.split(sep).includes("..")) { + throw new Error(`Repo-relative path escapes materialized Python workspace: ${path}`); + } + return absolute; +} + +function uniqueSorted(values: readonly string[]): readonly string[] { + return [...new Set(values)].sort(); +} diff --git a/packages/validation-python/src/type-check.ts b/packages/validation-python/src/type-check.ts index 7cd1bbf..6b29ecc 100644 --- a/packages/validation-python/src/type-check.ts +++ b/packages/validation-python/src/type-check.ts @@ -1,202 +1,421 @@ -import type { ValidationCheckDefinition } from "@the-open-engine/opcore-validation"; -import type { ValidationDiagnostic } from "@the-open-engine/opcore-contracts"; -import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; -import { mkdir, copyFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { dirname, join, relative, resolve, sep } from "node:path"; +import type { + PythonProjectContext, + PythonProjectToolProvenance, + PythonValidationAuthority, + PythonValidationAuthoritySource, + PythonValidationCapabilityRun, + PythonValidationCapabilityRunStatus, + PythonValidationCapabilityToolProvenance, + ValidationCheckOutcome, + ValidationDiagnostic +} from "@the-open-engine/opcore-contracts"; +import type { + ValidationCheckContext, + ValidationCheckDefinition, + ValidationCheckResult +} from "@the-open-engine/opcore-validation"; import { PYTHON_TYPES_CHECK_ID } from "./check-ids.js"; import { pythonCheckAdapter, pythonCheckOwner, supportedPythonValidationScopes } from "./check-constants.js"; import { diagnostic, sortDiagnostics } from "./diagnostics.js"; -import { runTool } from "./process.js"; -import { materializePythonSources, type PythonMaterializedSourceSet } from "./source-files.js"; -import { type PythonValidationToolchainOptions } from "./toolchain.js"; -import { resolvePythonTool, type PythonToolResolution } from "./toolchain-resolver.js"; +import { runMypyCapability } from "./mypy-runner.js"; +import { resolveMypyConfigSemantics, type MypyConfigSemantics } from "./mypy-config.js"; +import { resolvePyrightConfigSemantics, type PyrightConfigSemantics } from "./pyright-config.js"; +import { runPyrightCapability } from "./pyright-runner.js"; +import { + pythonInputSet, + skippedPythonInputResult, + type PythonMaterializedSourceSet, + type PythonProjectContextResolver, + type PythonSourceSetResolver +} from "./source-files.js"; +import { selectPythonTypeAuthority, type PythonTypeAuthoritySelection } from "./type-authority.js"; +import { + createPythonTypeCapabilityRun, + portablePythonValidationTool, + preparePythonTypeCapability, + type PythonTypeCapabilityPreparation +} from "./type-capability-run.js"; +import type { PythonValidationToolchainOptions } from "./toolchain.js"; -export interface PythonTypeCheckOptions extends PythonValidationToolchainOptions { +export interface PythonTypeCheckOptions extends Omit { timeoutMs?: number; + checker?: PythonValidationAuthority; } -interface MaterializedPythonTypeWorkspace { - root: string; - cleanup: () => void; +interface PythonProjectGroup { + context: PythonProjectContext; + targets: readonly string[]; } -const pythonToolConfigFiles = [ - "mypy.ini", - "setup.cfg", - "tox.ini", - "pyproject.toml", - "pyrightconfig.json" -] as const; +interface ProjectAttempt { + run?: PythonValidationCapabilityRun; + diagnostics: readonly ValidationDiagnostic[]; + outcome: ValidationCheckOutcome; + failureMessage?: string; +} -export function createTypeCheck(options: PythonTypeCheckOptions = {}): ValidationCheckDefinition { +export function createTypeCheck( + options: PythonTypeCheckOptions = {}, + resolveContexts?: PythonProjectContextResolver, + resolveSources?: PythonSourceSetResolver +): ValidationCheckDefinition { return { id: PYTHON_TYPES_CHECK_ID, owner: pythonCheckOwner, adapter: pythonCheckAdapter, defaultSeverity: "warning", supportedScopes: supportedPythonValidationScopes, - run: async (context) => { - const sourceSet = await materializePythonSources(context); - if (sourceSet.files.length === 0) return { diagnostics: [] }; - const repoRoot = context.request.repo.repoRoot ?? process.cwd(); - const checker = selectTypeChecker({ ...options, repoRoot }); - if (checker === undefined) { - return { - status: "unsupported_request", - diagnostics: [ - { - category: "types", - severity: "info", - code: "PYTHON_TYPES_UNSUPPORTED", - message: "Python type validation requires mypy or pyright; neither tool is available." - } - ] - }; + run: async (validation) => { + const skipped = skippedPythonInputResult(validation); + if (skipped !== undefined) return skipped; + if (resolveContexts === undefined) return missingContextResult(pythonInputSet(validation)); + if (resolveSources === undefined) throw new Error("A shared Python source-set resolver is required for Python type validation"); + const sourceSet = await resolveSources(validation); + if (sourceSet.rootPaths.length === 0) { + return { status: "skipped", diagnostics: [], failureMessage: "No Python after-state source files were selected." }; } - - const workspace = await materializePythonTypeWorkspace(repoRoot, sourceSet); - try { - const result = runTool(checker.command, checkerArgs(checker, sourceSet), { - cwd: workspace.root, - env: options.env, - timeoutMs: options.timeoutMs ?? 30000 - }); - if (result.failureMessage !== undefined || result.exitCode === null) { - return unsupportedToolFailure(checker, result.failureMessage ?? `${checker.tool} invocation failed`); - } - return { - diagnostics: sortDiagnostics(parseTypeCheckerDiagnostics(checker, result.stdout, result.stderr, workspace.root)) - }; - } finally { - workspace.cleanup(); + // Pyright selects files from configuration rather than only the import closure. + // Resolve every visible source so parent projects cannot materialize sources + // owned by another project that has its own authority run in this request. + const contexts = await resolveContexts(validation, sourceSet.allPaths); + const contextByTarget = new Map(contexts.map((context) => [context.target, context])); + const missing = sourceSet.rootPaths.filter((path) => !contextByTarget.has(path)); + if (missing.length > 0) return missingContextResult(missing); + const projects = groupProjectContexts(sourceSet.rootPaths, contextByTarget); + const activeProjectKeys = new Set(projects.map((project) => project.context.projectKey)); + const attempts: ProjectAttempt[] = []; + for (const project of projects) { + attempts.push(await attemptProject(validation, project, sourceSet, contextByTarget, activeProjectKeys, options)); } + const diagnostics = sortDiagnostics(attempts.flatMap((attempt) => attempt.diagnostics)); + const runs = attempts.flatMap((attempt) => attempt.run === undefined ? [] : [attempt.run]); + const outcome = aggregateOutcome(attempts.map((attempt) => attempt.outcome)); + return { + outcome, + diagnostics, + pythonCapabilityRuns: runs, + ...(outcome === "passed" || outcome === "findings" ? {} : { + failureMessage: attempts.find((attempt) => attempt.outcome === outcome)?.failureMessage ?? + `Python type authority failed with ${outcome}` + }) + }; } }; } -function selectTypeChecker(options: Required> & PythonValidationToolchainOptions): PythonToolResolution | undefined { - const resolverOptions = { repoRoot: options.repoRoot, env: options.env, pythonCommand: options.pythonCommand }; - const mypy = resolvePythonTool("mypy", "mypy", ["--version"], resolverOptions); - const pyright = resolvePythonTool("pyright", "pyright", ["--version"], resolverOptions); - if (!mypy.available && !pyright.available) return undefined; - if (pyright.available && !mypy.available) return pyright; - if (mypy.available && !pyright.available) return mypy; - if (pyright.configFile?.endsWith("pyrightconfig.json") && !mypy.configFile?.endsWith("mypy.ini")) return pyright; - return mypy; -} - -function checkerArgs(checker: PythonToolResolution, sourceSet: PythonMaterializedSourceSet): readonly string[] { - if (checker.tool === "pyright") return [...sourceSet.rootPaths]; - return [...sourceSet.rootPaths]; -} - -async function materializePythonTypeWorkspace( - repoRoot: string, - sourceSet: PythonMaterializedSourceSet -): Promise { - const tempRoot = mkdtempSync(join(tmpdir(), "opcore-python-types-")); - const root = join(tempRoot, "repo"); - try { - await mkdir(root, { recursive: true }); - await copyPythonConfigFiles(repoRoot, root); - for (const source of sourceSet.files) { - const absolutePath = resolveRepoPath(root, source.path); - await mkdir(dirname(absolutePath), { recursive: true }); - writeFileSync(absolutePath, source.content); +function projectSourcePaths(args: { + basePaths: readonly string[]; + availablePaths: readonly string[]; + edges: readonly { fromPath: string; toPath: string }[]; + additionalRoots: readonly string[]; + projectRoot: string; +}): readonly string[] { + const outgoing = new Map(); + for (const edge of args.edges) { + const values = outgoing.get(edge.fromPath) ?? []; + values.push(edge.toPath); + outgoing.set(edge.fromPath, values); + } + const selected = new Set([...args.basePaths, ...args.additionalRoots]); + const pending = [...selected]; + const initializers = args.availablePaths.filter((path) => /(?:^|\/)__init__\.pyi?$/u.test(path)); + while (pending.length > 0) { + const path = pending.shift(); + if (path === undefined) continue; + for (const dependency of outgoing.get(path) ?? []) { + if (selected.has(dependency)) continue; + selected.add(dependency); + pending.push(dependency); } - return { root, cleanup: () => rmSync(tempRoot, { recursive: true, force: true }) }; - } catch (error) { - rmSync(tempRoot, { recursive: true, force: true }); - throw error; + for (const initializer of initializersForPath(path, initializers, args.projectRoot)) { + if (selected.has(initializer)) continue; + selected.add(initializer); + pending.push(initializer); + } + } + return [...selected].sort(); +} + +function initializersForPath( + path: string, + initializers: readonly string[], + projectRoot: string +): readonly string[] { + const sourceRoot = inferredSourceRoot(path, projectRoot); + return initializers.filter((initializer) => { + const directory = initializer.slice(0, initializer.lastIndexOf("/")); + return directory.length > 0 && directory !== sourceRoot && + (sourceRoot === "." || directory.startsWith(`${sourceRoot}/`)) && path.startsWith(`${directory}/`); + }); +} + +function inferredSourceRoot(path: string, projectRoot: string): string { + const src = /^(.*?\bsrc)(?:\/|$)/u.exec(path)?.[1]; + if (src !== undefined) return src; + return projectRoot; +} + +async function attemptProject( + validation: ValidationCheckContext, + project: PythonProjectGroup, + sourceSet: PythonMaterializedSourceSet, + contextByTarget: ReadonlyMap, + activeProjectKeys: ReadonlySet, + options: PythonTypeCheckOptions +): Promise { + const selection = selectPythonTypeAuthority(project.context, options.checker); + const mypySemantics = await selectedMypySemantics(validation, project, selection); + const pyrightSemantics = await selectedPyrightSemantics(validation, project, selection, options); + const eligiblePaths = sourceSet.allPaths.filter((path) => { + const projectKey = contextByTarget.get(path)?.projectKey; + if (projectKey === project.context.projectKey || projectKey === undefined) return true; + return selection.authority === "pyright" ? false : !activeProjectKeys.has(projectKey); + }); + const eligiblePathSet = new Set(eligiblePaths); + const sourcePaths = selection.authority === "pyright" + ? pyrightProjectSourcePaths(eligiblePaths, project.context.projectRoot, pyrightSemantics.moduleSearchRoots) + : projectSourcePaths({ + basePaths: project.targets, + availablePaths: eligiblePaths, + edges: sourceSet.allRepoImports.filter((edge) => eligiblePathSet.has(edge.fromPath) && eligiblePathSet.has(edge.toPath)), + additionalRoots: mypySemantics.pluginPaths.filter((path) => eligiblePathSet.has(path)), + projectRoot: project.context.projectRoot + }); + const pyrightIsolationFailures = selection.authority === "pyright" + ? await pyrightSourceIsolationFailures(sourcePaths, options) + : []; + const preparation = await preparePythonTypeCapability({ + fileView: validation.fileView, + project: project.context, + targets: project.targets, + sourcePaths, + configPaths: selection.authority === "pyright" ? pyrightSemantics.configPaths : selection.configPaths, + moduleSearchRoots: selection.authority === "pyright" ? pyrightSemantics.moduleSearchRoots : mypySemantics.moduleSearchRoots + }); + const blocked = blockedProjectAttempt(project, preparation, selection, [ + ...mypySemantics.invalidConfigMessages, + ...pyrightSemantics.invalidConfigMessages, + ...pyrightIsolationFailures + ]); + if (blocked !== undefined) return blocked; + if (selection.tool === undefined || selection.authority === undefined) { + throw new Error("Selected Python type authority omitted canonical tool provenance"); + } + const shared = { + preparation, + checker: selection.tool, + authoritySource: selection.source ?? "project_config", + ...(options.env === undefined ? {} : { env: options.env }), + timeoutMs: options.timeoutMs ?? 30000 + }; + const executed = selection.authority === "pyright" + ? await runPyrightCapability(shared) + : await runMypyCapability(shared); + return { ...executed, outcome: executed.run.status }; +} + +async function pyrightSourceIsolationFailures( + paths: readonly string[], + options: PythonTypeCheckOptions +): Promise { + if (options.nodeWorkspace === undefined) return []; + const failures: string[] = []; + for (const path of paths) { + const resolved = await options.nodeWorkspace.realpath(path); + if (resolved.unavailable) failures.push(`${path}: Pyright source realpath evidence is unavailable`); + else if (resolved.symlink || resolved.path !== path) failures.push(`${path}: Symlinked or escaping Pyright source is refused`); + } + return failures; +} + +function pyrightProjectSourcePaths( + availablePaths: readonly string[], + projectRoot: string, + roots: readonly string[] +): readonly string[] { + const effectiveRoots = uniqueSorted([projectRoot, ...roots]); + return availablePaths.filter((path) => effectiveRoots.some((root) => + root === "." || path === root || path.startsWith(`${root}/`) + )).sort(); +} + +async function selectedMypySemantics( + validation: ValidationCheckContext, + project: PythonProjectGroup, + selection: PythonTypeAuthoritySelection +): Promise { + if (selection.status !== "selected" || selection.authority !== "mypy") { + return { pluginPaths: [], moduleSearchRoots: project.context.sourceRoots, invalidConfigMessages: [] }; } + return resolveMypyConfigSemantics(validation.fileView, project.context, selection.configPaths); } -async function copyPythonConfigFiles(repoRoot: string, root: string): Promise { - for (const file of pythonToolConfigFiles) { - const source = join(repoRoot, file); - if (!existsSync(source)) continue; - await copyFile(source, join(root, file)); +async function selectedPyrightSemantics( + validation: ValidationCheckContext, + project: PythonProjectGroup, + selection: PythonTypeAuthoritySelection, + options: PythonTypeCheckOptions +): Promise { + if (selection.status !== "selected" || selection.authority !== "pyright") { + return { configPaths: [], moduleSearchRoots: project.context.sourceRoots, invalidConfigMessages: [] }; } + return resolvePyrightConfigSemantics(validation.fileView, project.context, selection.configPaths, options.nodeWorkspace); } -function resolveRepoPath(root: string, path: string): string { - const absolutePath = resolve(root, path); - const relativePath = relative(root, absolutePath); - if (relativePath === "" || relativePath.startsWith("..") || relativePath.split(sep).includes("..")) { - throw new Error(`Repo-relative path escapes materialized Python workspace: ${path}`); +function blockedProjectAttempt( + project: PythonProjectGroup, + preparation: PythonTypeCapabilityPreparation, + selection: PythonTypeAuthoritySelection, + invalidConfigMessages: readonly string[] +): ProjectAttempt | undefined { + if (project.context.outcome === "ambiguous") { + return nonExecutableAttempt(preparation, selection, { status: "invalid_config", message: projectFailure(project, "ambiguous"), code: "PYTHON_TYPES_INVALID_CONFIG" }); + } + if (selection.status === "invalid_config") { + return nonExecutableAttempt(preparation, selection, { + status: "invalid_config", + message: selection.message ?? projectFailure(project, "invalid_config"), + code: "PYTHON_TYPES_INVALID_CONFIG" + }); + } + if (invalidConfigMessages.length > 0) { + return nonExecutableAttempt(preparation, selection, { + status: "invalid_config", + message: invalidConfigMessages.join("; "), + code: "PYTHON_TYPES_INVALID_CONFIG" + }); + } + if (project.context.outcome === "unsupported") { + return nonExecutableAttempt(preparation, selection, { status: "unsupported_target", message: projectFailure(project, "unsupported"), code: "PYTHON_TYPES_UNSUPPORTED_TARGET" }); + } + if (selection.status !== "selected") { + return nonExecutableAttempt(preparation, selection, { + status: selection.status, + message: selection.message ?? projectFailure(project, selection.status), + code: "PYTHON_TYPES_UNSUPPORTED_TARGET" + }); } - return absolutePath; + if (selection.tool === undefined || !selection.tool.available) { + const message = `Selected ${selection.authority ?? "Python type"} authority is unavailable for ${project.context.projectRoot}`; + return nonExecutableAttempt(preparation, selection, { status: "tool_unavailable", message, code: "PYTHON_TYPES_TOOL_UNAVAILABLE" }); + } + return undefined; +} + +function projectFailure(project: PythonProjectGroup, state: string): string { + return project.context.reasons[0]?.message ?? `Canonical Python project is ${state} for ${project.context.projectRoot}`; } -function unsupportedToolFailure(checker: PythonToolResolution, message: string) { +function nonExecutableAttempt( + preparation: PythonTypeCapabilityPreparation, + selection: PythonTypeAuthoritySelection, + failure: { + status: Extract; + message: string; + code: string; + } +): ProjectAttempt { + const authority = evidenceAuthority(selection); + const authoritySource = evidenceAuthoritySource(selection); + const tool = selection.tool === undefined || authority === undefined + ? undefined + : portableNonExecutedTool(selection.tool, preparation, authority); + const run = createPythonTypeCapabilityRun({ + preparation, + ...(authority === undefined ? {} : { authority }), + ...(authoritySource === undefined ? {} : { authoritySource }), + status: failure.status, + durationMs: 0, + counts: { diagnosticCount: 1, errorCount: 0, warningCount: 0, noteCount: 1 }, + ...(tool === undefined ? {} : { tool }) + }); return { - status: "unsupported_request" as const, - diagnostics: [ - diagnostic({ - category: "types", - severity: "info", - code: "PYTHON_TYPES_TOOL_FAILED", - message: `${checker.tool} could not run: ${message}` - }) - ] + run, + outcome: failure.status, + failureMessage: failure.message, + diagnostics: [diagnostic({ + category: failure.status === "invalid_config" ? "infrastructure" : "types", + severity: "info", + code: failure.code, + message: failure.message, + path: preparation.project.target, + ...(tool === undefined ? {} : { tool: { + name: tool.name, + command: tool.argv.join(" "), + ...(tool.version === undefined ? {} : { version: tool.version }), + source: tool.source, + cwd: tool.cwd + } }) + })] }; } -function parseTypeCheckerDiagnostics( - checker: PythonToolResolution, - stdout: string, - stderr: string, - workspaceRoot: string -): readonly ValidationDiagnostic[] { - const text = [stdout, stderr].filter((part) => part.trim().length > 0).join("\n"); - const diagnostics: ValidationDiagnostic[] = []; - for (const line of text.split(/\r?\n/u)) { - const parsed = checker.tool === "pyright" ? parsePyrightLine(line, workspaceRoot) : parseMypyLine(line, workspaceRoot); - if (parsed !== undefined) diagnostics.push(parsed); +function portableNonExecutedTool( + checker: PythonProjectToolProvenance, + preparation: PythonTypeCapabilityPreparation, + authority: PythonValidationAuthority +): PythonValidationCapabilityToolProvenance { + return portablePythonValidationTool({ checker, preparation, authority }); +} + +function evidenceAuthority(selection: PythonTypeAuthoritySelection): PythonValidationAuthority | undefined { + return selection.authority; +} + +function evidenceAuthoritySource(selection: PythonTypeAuthoritySelection): PythonValidationAuthoritySource | undefined { + return selection.source; +} + +function groupProjectContexts( + rootPaths: readonly string[], + contextByTarget: ReadonlyMap +): readonly PythonProjectGroup[] { + const groups = new Map(); + for (const path of rootPaths) { + const context = contextByTarget.get(path); + if (context === undefined) continue; + const group = groups.get(context.projectKey) ?? { context, targets: [] }; + if (contextPriority(context) > contextPriority(group.context)) group.context = context; + group.targets.push(path); + groups.set(context.projectKey, group); } - return diagnostics; -} - -function parseMypyLine(line: string, workspaceRoot: string): ValidationDiagnostic | undefined { - const match = /^(?.+?):(?\d+)(?::(?\d+))?:\s+(?error|warning|note):\s+(?.+?)(?:\s+\[(?[^\]]+)\])?$/u.exec(line.trim()); - if (match?.groups === undefined) return undefined; - const severity = match.groups.severity === "error" ? "error" : "warning"; - const code = match.groups.code !== undefined ? `MYPY_${normalizeDiagnosticCode(match.groups.code)}` : "MYPY_TYPE_ERROR"; - return diagnostic({ - category: "types", - severity, - path: repoRelativeDiagnosticPath(match.groups.path, workspaceRoot), - code, - message: withLocation(match.groups.message, match.groups.line, match.groups.column) - }); + return [...groups.values()] + .map((group) => ({ context: group.context, targets: [...new Set(group.targets)].sort() })) + .sort((left, right) => + left.context.projectRoot.localeCompare(right.context.projectRoot) || + left.context.projectKey.localeCompare(right.context.projectKey) + ); } -function parsePyrightLine(line: string, workspaceRoot: string): ValidationDiagnostic | undefined { - const match = /^\s*(?.+?):(?\d+):(?\d+)\s+-\s+(?error|warning|information):\s+(?.+?)(?:\s+\((?[^)]+)\))?$/u.exec(line.trim()); - if (match?.groups === undefined) return undefined; - const severity = match.groups.severity === "error" ? "error" : match.groups.severity === "warning" ? "warning" : "info"; - const code = match.groups.code !== undefined ? `PYRIGHT_${normalizeDiagnosticCode(match.groups.code)}` : "PYRIGHT_TYPE_ERROR"; - return diagnostic({ - category: "types", - severity, - path: repoRelativeDiagnosticPath(match.groups.path, workspaceRoot), - code, - message: withLocation(match.groups.message, match.groups.line, match.groups.column) - }); +function contextPriority(context: PythonProjectContext): number { + if (context.outcome === "ambiguous" || context.reasons.some((reason) => reason.code === "invalid_config")) return 3; + if (context.outcome === "unsupported") return 2; + if (context.outcome === "degraded") return 1; + return 0; } -function repoRelativeDiagnosticPath(path: string, workspaceRoot: string): string { - const absolute = resolve(workspaceRoot, path); - const relativePath = relative(workspaceRoot, absolute).replaceAll("\\", "/"); - return relativePath.length > 0 && !relativePath.startsWith("..") ? relativePath : path.replaceAll("\\", "/"); +function aggregateOutcome(outcomes: readonly ValidationCheckOutcome[]): ValidationCheckOutcome { + const priority: readonly ValidationCheckOutcome[] = [ + "invalid_config", "timeout", "tool_failure", "tool_unavailable", "unsupported_target", "findings", "passed" + ]; + return priority.find((candidate) => outcomes.includes(candidate)) ?? "passed"; } -function withLocation(message: string, line: string | undefined, column: string | undefined): string { - if (line === undefined) return message; - return column === undefined ? `${message} (line ${line})` : `${message} (line ${line}, column ${column})`; +function uniqueSorted(values: readonly string[]): readonly string[] { + return [...new Set(values)].sort(); } -function normalizeDiagnosticCode(code: string): string { - return code.replace(/([a-z])([A-Z])/g, "$1_$2").replace(/[^A-Za-z0-9]+/g, "_").replace(/^_+|_+$/g, "").toUpperCase(); +function missingContextResult(missing: readonly string[]): ValidationCheckResult { + const suffix = missing.length === 0 ? "" : `: ${missing.join(", ")}`; + const message = `Canonical Python project context resolution returned no context for selected source${suffix}`; + return { + outcome: "tool_failure", + failureMessage: message, + diagnostics: [{ + category: "infrastructure", + severity: "error", + code: "PYTHON_CONTEXT_MISSING", + message + }] + }; } diff --git a/packages/validation-python/src/type-result.ts b/packages/validation-python/src/type-result.ts new file mode 100644 index 0000000..7338077 --- /dev/null +++ b/packages/validation-python/src/type-result.ts @@ -0,0 +1,232 @@ +import type { + PythonValidationAuthority, + PythonValidationCapabilityExecution, + PythonValidationCapabilityRunStatus, + PythonValidationCapabilityToolProvenance, + ValidationDiagnostic +} from "@the-open-engine/opcore-contracts"; +import { diagnostic } from "./diagnostics.js"; +import type { PythonToolRunResult } from "./process.js"; +import { createPythonTypeCapabilityRun } from "./type-capability-run.js"; +import type { TypeCapabilityArgs, TypeCapabilityResult, TypeExecutionContext } from "./type-runner-types.js"; + +interface CompletedTypeResultArgs { + context: TypeExecutionContext; + status: Extract; + exitCode: number; + diagnostics: readonly ValidationDiagnostic[]; + durationMs: number; +} + +export function completedTypeResult(args: CompletedTypeResultArgs): TypeCapabilityResult { + return { + run: createPythonTypeCapabilityRun({ + preparation: args.context.args.preparation, + authority: args.context.args.authority, + authoritySource: args.context.args.authoritySource, + status: args.status, + durationMs: args.durationMs, + counts: diagnosticCounts(args.diagnostics), + tool: args.context.tool, + execution: { termination: "exited", exitCode: args.exitCode } + }), + diagnostics: args.diagnostics + }; +} + +export function terminatedTypeResult( + context: TypeExecutionContext, + result: Exclude, + durationMs: number +): TypeCapabilityResult { + const status = result.termination === "timeout" ? "timeout" : "tool_failure"; + const failureMessage = portableFailure( + result.failureMessage ?? `${context.args.authority} terminated with ${result.termination}`, + context + ); + return failedResult({ + args: context.args, + tool: context.tool, + status, + durationMs, + execution: nonExitedExecution(result.termination, result.signal, failureMessage), + failureMessage + }); +} + +export function typeMaterializationFailure( + args: TypeCapabilityArgs, + tool: PythonValidationCapabilityToolProvenance, + startedAt: number +): TypeCapabilityResult { + const failureMessage = `${args.authority} exact after-state materialization failed`; + return failedResult({ + args, + tool, + status: "tool_failure", + durationMs: Date.now() - startedAt, + execution: { termination: "spawn_error", failureSummary: failureMessage }, + failureMessage + }); +} + +export function typePreflightFailure( + args: TypeCapabilityArgs, + tool: PythonValidationCapabilityToolProvenance, + rawMessage: string +): TypeCapabilityResult { + const failureMessage = boundedFailure(rawMessage, args.authority); + const failureDiagnostic = diagnostic({ + category: "infrastructure", + severity: "error", + code: "PYTHON_TYPES_INVALID_CONFIG", + message: `${args.authority} could not produce authoritative type evidence: ${failureMessage}`, + ...(tool.configFile === undefined ? {} : { path: tool.configFile }), + tool: diagnosticTool(tool) + }); + return { + run: createPythonTypeCapabilityRun({ + preparation: args.preparation, + authority: args.authority, + authoritySource: args.authoritySource, + status: "invalid_config", + durationMs: 0, + counts: diagnosticCounts([failureDiagnostic]), + tool + }), + diagnostics: [failureDiagnostic], + failureMessage + }; +} + +export function typeProtocolFailure( + context: Pick, + durationMs: number, + exitCode: number, + rawMessage: string +): TypeCapabilityResult { + const failureMessage = boundedFailure(rawMessage, context.args.authority); + return failedResult({ + args: context.args, + tool: context.tool, + status: "tool_failure", + durationMs, + execution: { termination: "exited", exitCode, failureSummary: failureMessage }, + failureMessage + }); +} + +export function typeInvalidConfigFailure( + context: Pick, + durationMs: number, + exitCode: number, + diagnostics: readonly ValidationDiagnostic[] = [] +): TypeCapabilityResult { + const configFile = context.tool.configFile ?? `selected ${context.args.authority} configuration`; + const failureMessage = `${context.args.authority} rejected selected configuration: ${configFile}`; + const failureDiagnostic = diagnostic({ + category: "infrastructure", + severity: "error", + code: "PYTHON_TYPES_INVALID_CONFIG", + message: `${context.args.authority} could not produce authoritative type evidence: ${failureMessage}`, + ...(context.tool.configFile === undefined ? {} : { path: context.tool.configFile }), + tool: diagnosticTool(context.tool) + }); + const allDiagnostics = diagnostics.length === 0 ? [failureDiagnostic] : [...diagnostics, failureDiagnostic]; + return { + run: createPythonTypeCapabilityRun({ + preparation: context.args.preparation, + authority: context.args.authority, + authoritySource: context.args.authoritySource, + status: "invalid_config", + durationMs, + counts: diagnosticCounts(allDiagnostics), + tool: context.tool, + execution: { termination: "exited", exitCode, failureSummary: failureMessage } + }), + diagnostics: allDiagnostics, + failureMessage + }; +} + +export function diagnosticCounts(diagnostics: readonly ValidationDiagnostic[]) { + return { + diagnosticCount: diagnostics.length, + errorCount: diagnostics.filter((entry) => entry.severity === "error").length, + warningCount: diagnostics.filter((entry) => entry.severity === "warning").length, + noteCount: diagnostics.filter((entry) => entry.severity === "info").length + }; +} + +interface FailedResultArgs { + args: TypeCapabilityArgs; + tool: PythonValidationCapabilityToolProvenance; + status: Extract; + durationMs: number; + execution: PythonValidationCapabilityExecution; + failureMessage: string; +} + +function failedResult(input: FailedResultArgs): TypeCapabilityResult { + return { + run: createPythonTypeCapabilityRun({ + preparation: input.args.preparation, authority: input.args.authority, authoritySource: input.args.authoritySource, + status: input.status, durationMs: input.durationMs, + counts: { diagnosticCount: 1, errorCount: 1, warningCount: 0, noteCount: 0 }, + tool: input.tool, + execution: input.execution + }), + diagnostics: [toolFailureDiagnostic(input.status, input.failureMessage, input.tool, input.args.authority)], + failureMessage: input.failureMessage + }; +} + +function nonExitedExecution( + termination: "timeout" | "signal" | "spawn_error", + signal: string | null, + failureSummary: string +): PythonValidationCapabilityExecution { + return { termination, ...(termination === "signal" && signal !== null ? { signal } : {}), failureSummary }; +} + +function toolFailureDiagnostic( + status: "timeout" | "tool_failure", + message: string, + tool: PythonValidationCapabilityToolProvenance, + authority: PythonValidationAuthority +): ValidationDiagnostic { + return diagnostic({ + category: "infrastructure", + code: status === "timeout" ? "PYTHON_TYPES_TOOL_TIMEOUT" : "PYTHON_TYPES_TOOL_FAILED", + message: `${authority} could not produce authoritative type evidence: ${message}`, + tool: diagnosticTool(tool) + }); +} + +function diagnosticTool(tool: PythonValidationCapabilityToolProvenance) { + return { + name: tool.name, + command: tool.argv.join(" "), + ...(tool.version === undefined ? {} : { version: tool.version }), + source: tool.source, + cwd: tool.cwd + }; +} + +function boundedFailure(message: string, authority: PythonValidationAuthority): string { + const normalized = message.replace(/[\r\n\t]+/gu, " ").replace(/\s+/gu, " ").trim(); + return (normalized.length === 0 ? `${authority} failed without a message` : normalized).slice(0, 1024); +} + +function portableFailure(message: string, context: TypeExecutionContext): string { + let portable = message; + for (const path of [ + context.args.checker.executable, + context.workspace.projectCwd, + context.workspace.root, + context.workspace.runtimeRoot + ].sort((left, right) => right.length - left.length)) { + if (path.length > 0) portable = portable.replaceAll(path, context.tool.executable); + } + return boundedFailure(portable, context.args.authority); +} diff --git a/packages/validation-python/src/type-runner-runtime.ts b/packages/validation-python/src/type-runner-runtime.ts new file mode 100644 index 0000000..7662a64 --- /dev/null +++ b/packages/validation-python/src/type-runner-runtime.ts @@ -0,0 +1,94 @@ +import type { PythonValidationCapabilityToolProvenance } from "@the-open-engine/opcore-contracts"; +import { delimiter, dirname, isAbsolute, join } from "node:path"; +import { + materializePythonTypeCapability, + portablePythonValidationTool, + type MaterializedPythonTypeWorkspace +} from "./type-capability-run.js"; +import type { TypeCapabilityArgs, TypeExecutionContext } from "./type-runner-types.js"; + +export async function withMaterializedTypeExecution( + args: TypeCapabilityArgs, + observedArgv: readonly string[], + execute: (context: TypeExecutionContext) => Promise, + onMaterializationFailure: ( + tool: PythonValidationCapabilityToolProvenance, + startedAt: number + ) => TResult +): Promise { + const startedAt = Date.now(); + const tool = portableTypeTool(args, observedArgv); + let workspace: MaterializedPythonTypeWorkspace; + try { + workspace = await materializePythonTypeCapability(args.preparation); + } catch { + return onMaterializationFailure(tool, startedAt); + } + try { + return await execute({ args, workspace, tool, startedAt }); + } finally { + workspace.cleanup(); + } +} + +export function isolatedTypeEnvironmentBase(args: { + input: Record | undefined; + executable: string; + workspace: MaterializedPythonTypeWorkspace; + includeProcessExecPath?: boolean; + extra?: Record; +}): Record { + const source = args.input ?? process.env; + return { + PATH: isolatedExecutablePath(args.executable, source, args.includeProcessExecPath === true), + PWD: args.workspace.projectCwd, + HOME: join(args.workspace.runtimeRoot, "home"), + XDG_CONFIG_HOME: join(args.workspace.runtimeRoot, "xdg-config"), + XDG_CACHE_HOME: join(args.workspace.runtimeRoot, "xdg-cache"), + TMPDIR: join(args.workspace.runtimeRoot, "tmp"), + TEMP: join(args.workspace.runtimeRoot, "tmp"), + TMP: join(args.workspace.runtimeRoot, "tmp"), + PYTHONNOUSERSITE: "1", + PYTHONDONTWRITEBYTECODE: "1", + PYTHONUTF8: "1", + LC_ALL: "C", + LANG: "C", + ...(args.extra ?? {}), + ...(process.platform !== "win32" ? {} : windowsEnvironment(source)) + }; +} + +export function portableTypeTool( + args: TypeCapabilityArgs, + observedArgv: readonly string[] +): PythonValidationCapabilityToolProvenance { + return portablePythonValidationTool({ + checker: args.checker, + argv: observedArgv, + preparation: args.preparation, + authority: args.authority + }); +} + +function isolatedExecutablePath( + executable: string, + source: Readonly>, + includeProcessExecPath: boolean +): string { + const system = process.platform === "win32" ? source.SystemRoot ?? source.SYSTEMROOT : undefined; + const systemPaths = system === undefined ? ["/usr/bin", "/bin"] : [join(system, "System32"), system]; + return [...new Set([ + isAbsolute(executable) ? dirname(executable) : undefined, + ...(includeProcessExecPath ? [dirname(process.execPath)] : []), + ...systemPaths + ])].filter((path): path is string => path !== undefined).join(delimiter); +} + +function windowsEnvironment(source: Readonly>): Record { + const values: Record = {}; + for (const key of ["SystemRoot", "SYSTEMROOT", "WINDIR", "COMSPEC", "PATHEXT"] as const) { + const value = source[key]; + if (value !== undefined) values[key] = value; + } + return values; +} diff --git a/packages/validation-python/src/type-runner-types.ts b/packages/validation-python/src/type-runner-types.ts new file mode 100644 index 0000000..eccf06b --- /dev/null +++ b/packages/validation-python/src/type-runner-types.ts @@ -0,0 +1,34 @@ +import type { + PythonProjectToolProvenance, + PythonValidationAuthority, + PythonValidationAuthoritySource, + PythonValidationCapabilityRun, + PythonValidationCapabilityToolProvenance, + ValidationDiagnostic +} from "@the-open-engine/opcore-contracts"; +import type { + MaterializedPythonTypeWorkspace, + PythonTypeCapabilityPreparation +} from "./type-capability-run.js"; + +export interface TypeCapabilityResult { + run: PythonValidationCapabilityRun; + diagnostics: readonly ValidationDiagnostic[]; + failureMessage?: string; +} + +export interface TypeCapabilityArgs { + preparation: PythonTypeCapabilityPreparation; + checker: PythonProjectToolProvenance; + authority: PythonValidationAuthority; + authoritySource: PythonValidationAuthoritySource; + env?: Record; + timeoutMs: number; +} + +export interface TypeExecutionContext { + args: TypeCapabilityArgs; + workspace: MaterializedPythonTypeWorkspace; + tool: PythonValidationCapabilityToolProvenance; + startedAt: number; +} diff --git a/packages/validation-python/src/version-constraint.ts b/packages/validation-python/src/version-constraint.ts new file mode 100644 index 0000000..4325b3b --- /dev/null +++ b/packages/validation-python/src/version-constraint.ts @@ -0,0 +1,103 @@ +interface ParsedPythonPrerelease { + kind: "a" | "b" | "rc"; + number: number; +} + +interface ParsedPythonVersion { + release: readonly [number, number, number]; + precision: number; + prerelease?: ParsedPythonPrerelease; +} + +interface ParsedPythonConstraintClause extends ParsedPythonVersion { + operator: "===" | "==" | "!=" | ">=" | "<=" | ">" | "<" | "~="; + wildcard: boolean; +} + +export function isSupportedPythonVersionConstraint(constraint: string): boolean { + const clauses = constraint.split(",").map((part) => part.trim()).filter(Boolean); + return clauses.length > 0 && clauses.every((clause) => parseConstraintClause(clause) !== undefined); +} + +export function pythonVersionSatisfiesConstraint(version: string, constraint: string): boolean { + const current = parsePythonVersion(version); + if (current === undefined) return false; + const clauses = constraint.split(",").map((part) => part.trim()).filter(Boolean); + if (clauses.length === 0) return false; + const targets = clauses.map(parseConstraintClause); + if (targets.some((target) => target === undefined)) return false; + const parsedTargets = targets as ParsedPythonConstraintClause[]; + if (current.prerelease !== undefined && !parsedTargets.some((target) => target.prerelease !== undefined)) return false; + return parsedTargets.every((target) => { + const comparison = compareVersion(current, target); + switch (target.operator) { + case ">=": return comparison >= 0; + case ">": return comparison > 0; + case "<=": return comparison <= 0; + case "<": return comparison < 0; + case "!=": return !equalVersion(current, target); + case "~=": { + const compatiblePrefixLength = target.precision - 1; + return comparison >= 0 && releasePrefixMatches(current.release, target.release, compatiblePrefixLength); + } + case "===": return comparison === 0; + case "==": return equalVersion(current, target); + } + }); +} + +function parseConstraintClause(value: string): ParsedPythonConstraintClause | undefined { + const match = /^(?===|==|!=|>=|<=|>|<|~=)?\s*(?\d+(?:\.\d+){0,2}(?:(?:a|b|rc)\d+)?)(?\.\*)?$/u.exec(value); + if (match?.groups === undefined) return undefined; + const parsed = parsePythonVersion(match.groups.version); + if (parsed === undefined) return undefined; + const operator = (match.groups.operator ?? "==") as ParsedPythonConstraintClause["operator"]; + const wildcard = match.groups.wildcard !== undefined; + if (wildcard && operator !== "==" && operator !== "!=") return undefined; + if (operator === "~=" && parsed.precision < 2) return undefined; + return { ...parsed, operator, wildcard }; +} + +function parsePythonVersion(value: string): ParsedPythonVersion | undefined { + const match = /^(?\d+)(?:\.(?\d+))?(?:\.(?\d+))?(?:(?a|b|rc)(?\d+))?(?:\+[A-Za-z0-9]+(?:[._-][A-Za-z0-9]+)*)?$/u.exec(value.trim()); + if (match?.groups === undefined) return undefined; + const prereleaseKind = match.groups.prereleaseKind as ParsedPythonPrerelease["kind"] | undefined; + return { + release: [Number(match.groups.major), Number(match.groups.minor ?? 0), Number(match.groups.patch ?? 0)], + precision: match.groups.minor === undefined ? 1 : match.groups.patch === undefined ? 2 : 3, + ...(prereleaseKind === undefined + ? {} + : { prerelease: { kind: prereleaseKind, number: Number(match.groups.prereleaseNumber) } }) + }; +} + +function equalVersion(current: ParsedPythonVersion, target: ParsedPythonConstraintClause): boolean { + if (target.wildcard) return releasePrefixMatches(current.release, target.release, target.precision); + return compareVersion(current, target) === 0; +} + +function releasePrefixMatches(left: readonly number[], right: readonly number[], length: number): boolean { + for (let index = 0; index < length; index += 1) { + if (left[index] !== right[index]) return false; + } + return true; +} + +function compareVersion(left: ParsedPythonVersion, right: ParsedPythonVersion): number { + const releaseComparison = compareRelease(left.release, right.release); + if (releaseComparison !== 0) return releaseComparison; + if (left.prerelease === undefined || right.prerelease === undefined) { + if (left.prerelease === undefined && right.prerelease === undefined) return 0; + return left.prerelease === undefined ? 1 : -1; + } + const rank = { a: 0, b: 1, rc: 2 } as const; + const kindComparison = rank[left.prerelease.kind] - rank[right.prerelease.kind]; + return kindComparison !== 0 ? kindComparison : left.prerelease.number - right.prerelease.number; +} + +function compareRelease(left: readonly number[], right: readonly number[]): number { + for (let index = 0; index < 3; index += 1) { + if (left[index] !== right[index]) return (left[index] ?? 0) - (right[index] ?? 0); + } + return 0; +} diff --git a/packages/validation-rust/README.md b/packages/validation-rust/README.md index 8f786aa..5e2bf7b 100644 --- a/packages/validation-rust/README.md +++ b/packages/validation-rust/README.md @@ -1,3 +1,6 @@ # @the-open-engine/opcore-validation-rust Opcore Rust validation adapter package for Cargo, lint, import, dead-code, and metric checks. + +Cargo and native-tool checks share one environment-keyed temporary workspace per validation file-view state. The +validation runner disposes the workspace after every normal, fail-fast, streaming, or failed run. diff --git a/packages/validation-rust/package.json b/packages/validation-rust/package.json index 7a627c0..f536609 100644 --- a/packages/validation-rust/package.json +++ b/packages/validation-rust/package.json @@ -1,6 +1,6 @@ { "name": "@the-open-engine/opcore-validation-rust", - "version": "0.2.0", + "version": "0.2.1", "description": "Rust cargo, lint, import, dead-code, and function-metric validation checks for Opcore.", "type": "module", "license": "MIT", @@ -26,8 +26,8 @@ "developer-tools" ], "dependencies": { - "@the-open-engine/opcore-contracts": "0.2.0", - "@the-open-engine/opcore-validation": "0.2.0" + "@the-open-engine/opcore-contracts": "0.2.1", + "@the-open-engine/opcore-validation": "0.2.1" }, "engines": { "node": ">=22" diff --git a/packages/validation-rust/src/cargo-check.ts b/packages/validation-rust/src/cargo-check.ts index f42764f..151579b 100644 --- a/packages/validation-rust/src/cargo-check.ts +++ b/packages/validation-rust/src/cargo-check.ts @@ -169,30 +169,26 @@ async function runCargoTool( const skipped = skippedRustInputResult(context); if (skipped !== undefined) return skipped; const materialized = await materializeRustWorkspace(context, { env: args.options.env }); - try { - const metadata = loadCargoMetadata(materialized.root, { - ...args.options, - cargoTargetCacheKey: materialized.cargoTargetCacheKey - }); - if (!metadata.ok) return metadataFailureResult(metadata); - const packageScope = resolveCargoPackageScope(metadata.metadata, context.scope); - if (!packageScope.ok) return metadataFailureResult(packageScope); - const commandArgs = scopedCargoArgs(args.args, packageScope.member); - const result = runTool("cargo", commandArgs, { - cwd: materialized.root, - cargoTargetCacheKey: materialized.cargoTargetCacheKey, - env: args.options.env, - timeoutMs: args.options.timeoutMs, - allowedExitCodes: [0, 101] - }); - return ( - commandInfrastructureFailure(result) ?? - cargoDiagnosticsResult(result.stdout, materialized.root, args) ?? - cargoCommandFailureResult(result, args) ?? { diagnostics: [] } - ); - } finally { - materialized.cleanup(); - } + const metadata = loadCargoMetadata(materialized.root, { + ...args.options, + cargoTargetCacheKey: materialized.cargoTargetCacheKey + }); + if (!metadata.ok) return metadataFailureResult(metadata); + const packageScope = resolveCargoPackageScope(metadata.metadata, context.scope); + if (!packageScope.ok) return metadataFailureResult(packageScope); + const commandArgs = scopedCargoArgs(args.args, packageScope.member); + const result = runTool("cargo", commandArgs, { + cwd: materialized.root, + cargoTargetCacheKey: materialized.cargoTargetCacheKey, + env: args.options.env, + timeoutMs: args.options.timeoutMs, + allowedExitCodes: [0, 101] + }); + return ( + commandInfrastructureFailure(result) ?? + cargoDiagnosticsResult(result.stdout, materialized.root, args) ?? + cargoCommandFailureResult(result, args) ?? { diagnostics: [] } + ); } function cargoDiagnosticsResult( @@ -257,35 +253,31 @@ async function runCachedCargoCheck( options: RustCommandCheckOptions ): Promise { const materialized = await materializeRustWorkspace(context, { env: options.env }); - try { - const metadata = loadCargoMetadata(materialized.root, { - ...options, - cargoTargetCacheKey: materialized.cargoTargetCacheKey - }); - if (!metadata.ok) return { ok: false, result: metadataFailureResult(metadata) }; - const packageScope = resolveCargoPackageScope(metadata.metadata, context.scope); - if (!packageScope.ok) return { ok: false, result: metadataFailureResult(packageScope) }; - const commandArgs = scopedCargoArgs(cargoCheckArgs, packageScope.member); - const result = runTool("cargo", commandArgs, { - cwd: materialized.root, - cargoTargetCacheKey: materialized.cargoTargetCacheKey, - env: options.env, - timeoutMs: options.timeoutMs, - allowedExitCodes: [0, 101] - }); - return { - ok: true, - execution: { - args: commandArgs, - metadata: metadata.metadata, - member: packageScope.member, - result, - workspaceRoot: materialized.root - } - }; - } finally { - materialized.cleanup(); - } + const metadata = loadCargoMetadata(materialized.root, { + ...options, + cargoTargetCacheKey: materialized.cargoTargetCacheKey + }); + if (!metadata.ok) return { ok: false, result: metadataFailureResult(metadata) }; + const packageScope = resolveCargoPackageScope(metadata.metadata, context.scope); + if (!packageScope.ok) return { ok: false, result: metadataFailureResult(packageScope) }; + const commandArgs = scopedCargoArgs(cargoCheckArgs, packageScope.member); + const result = runTool("cargo", commandArgs, { + cwd: materialized.root, + cargoTargetCacheKey: materialized.cargoTargetCacheKey, + env: options.env, + timeoutMs: options.timeoutMs, + allowedExitCodes: [0, 101] + }); + return { + ok: true, + execution: { + args: commandArgs, + metadata: metadata.metadata, + member: packageScope.member, + result, + workspaceRoot: materialized.root + } + }; } function cargoExecutionResult( diff --git a/packages/validation-rust/src/fmt-check.ts b/packages/validation-rust/src/fmt-check.ts index 4c995e6..a5a898d 100644 --- a/packages/validation-rust/src/fmt-check.ts +++ b/packages/validation-rust/src/fmt-check.ts @@ -28,12 +28,8 @@ export function createFmtCheck(options: { env?: Record { const thresholds = options.thresholds ?? defaultRustFunctionMetricThresholds; const materialized = await materializeRustWorkspace(context, { env: options.env }); - try { - const diagnostics: ValidationDiagnostic[] = []; - for (const path of rustInputSet(context).ownedPaths.filter(isRustSourcePath)) { - if ((await context.fileView.readAfter(path)).status !== "found") continue; - const absolutePath = resolveRepoPath(materialized.root, path); - const result = runTool("rust-code-analysis-cli", ["-p", absolutePath, "-m", "-O", "json"], { - cwd: materialized.root, - env: options.env, - timeoutMs: options.timeoutMs - }); - if (!result.ok) { - return { - status: "infrastructure_failure", - diagnostics: [], - failureMessage: result.failureMessage ?? "rust-code-analysis-cli failed" - }; + const diagnostics: ValidationDiagnostic[] = []; + for (const path of rustInputSet(context).ownedPaths.filter(isRustSourcePath)) { + if ((await context.fileView.readAfter(path)).status !== "found") continue; + const absolutePath = resolveRepoPath(materialized.root, path); + const result = runTool("rust-code-analysis-cli", ["-p", absolutePath, "-m", "-O", "json"], { + cwd: materialized.root, + env: options.env, + timeoutMs: options.timeoutMs + }); + if (!result.ok) { + return { + status: "infrastructure_failure", + diagnostics: [], + failureMessage: result.failureMessage ?? "rust-code-analysis-cli failed" + }; + } + const value = parseFunctionMetricJson(result.stdout); + for (const fn of functionMetricEntries(value, materialized.root, path)) { + if (fn.lines > thresholds.maxFunctionLines) { + diagnostics.push( + diagnostic({ + category: "policy", + path: fn.path, + code: "RUST_FUNCTION_LINES", + message: `Rust function ${fn.name} has ${fn.lines} lines; max is ${thresholds.maxFunctionLines}.` + }) + ); + } + if (fn.complexity > thresholds.maxComplexity) { + diagnostics.push( + diagnostic({ + category: "policy", + path: fn.path, + code: "RUST_FUNCTION_COMPLEXITY", + message: `Rust function ${fn.name} has cyclomatic complexity ${fn.complexity}; max is ${thresholds.maxComplexity}.` + }) + ); } - const value = parseFunctionMetricJson(result.stdout); - for (const fn of functionMetricEntries(value, materialized.root, path)) { - if (fn.lines > thresholds.maxFunctionLines) { - diagnostics.push( - diagnostic({ - category: "policy", - path: fn.path, - code: "RUST_FUNCTION_LINES", - message: `Rust function ${fn.name} has ${fn.lines} lines; max is ${thresholds.maxFunctionLines}.` - }) - ); - } - if (fn.complexity > thresholds.maxComplexity) { - diagnostics.push( - diagnostic({ - category: "policy", - path: fn.path, - code: "RUST_FUNCTION_COMPLEXITY", - message: `Rust function ${fn.name} has cyclomatic complexity ${fn.complexity}; max is ${thresholds.maxComplexity}.` - }) - ); - } - if (fn.params > thresholds.maxParams) { - diagnostics.push( - diagnostic({ - category: "policy", - path: fn.path, - code: "RUST_FUNCTION_PARAMS", - message: `Rust function ${fn.name} has ${fn.params} parameters; max is ${thresholds.maxParams}.` - }) - ); - } + if (fn.params > thresholds.maxParams) { + diagnostics.push( + diagnostic({ + category: "policy", + path: fn.path, + code: "RUST_FUNCTION_PARAMS", + message: `Rust function ${fn.name} has ${fn.params} parameters; max is ${thresholds.maxParams}.` + }) + ); } } - return { diagnostics: sortDiagnostics(diagnostics) }; - } finally { - materialized.cleanup(); } + return { diagnostics: sortDiagnostics(diagnostics) }; } interface FunctionMetricEntry { diff --git a/packages/validation-rust/src/import-graph-check.ts b/packages/validation-rust/src/import-graph-check.ts index ebb9e9f..62c3e60 100644 --- a/packages/validation-rust/src/import-graph-check.ts +++ b/packages/validation-rust/src/import-graph-check.ts @@ -117,15 +117,11 @@ export function createImportGraphCheck(options: RustImportGraphCheckOptions = {} const skipped = skippedRustInputResult(context); if (skipped !== undefined) return skipped; const materialized = await materializeRustWorkspace(context, { env: options.env }); - try { - const metadata = loadCargoMetadata(materialized.root, options); - if (!metadata.ok) return metadataFailureResult(metadata); - const packageScope = resolveCargoPackageScope(metadata.metadata, context.scope); - if (!packageScope.ok) return metadataFailureResult(packageScope); - return await runImportGraph(context, metadata.metadata, packageScope.member); - } finally { - materialized.cleanup(); - } + const metadata = loadCargoMetadata(materialized.root, options); + if (!metadata.ok) return metadataFailureResult(metadata); + const packageScope = resolveCargoPackageScope(metadata.metadata, context.scope); + if (!packageScope.ok) return metadataFailureResult(packageScope); + return runImportGraph(context, metadata.metadata, packageScope.member); } }; } diff --git a/packages/validation-rust/src/materialize.ts b/packages/validation-rust/src/materialize.ts index c648d37..3875c56 100644 --- a/packages/validation-rust/src/materialize.ts +++ b/packages/validation-rust/src/materialize.ts @@ -11,9 +11,11 @@ export interface MaterializedRustWorkspace { cargoTargetCacheKey: string; tempRoot: string; root: string; - cleanup: () => void; + dispose: () => void; } +const materializedRustWorkspaceResourceKey = "opcore.validation-rust.materialized-workspace"; + const excludedParts = new Set([ ".git", "node_modules", @@ -27,6 +29,24 @@ const excludedParts = new Set([ export async function materializeRustWorkspace( context: ValidationCheckContext, options: { env?: Record } = {} +): Promise { + const resourceKey = `${materializedRustWorkspaceResourceKey}:${environmentKey(options.env ?? process.env)}`; + return context.resources.getOrCreate(resourceKey, () => + createMaterializedRustWorkspace(context, options) + ); +} + +function environmentKey(env: Record): string { + return JSON.stringify( + Object.entries(env) + .filter((entry): entry is [string, string] => entry[1] !== undefined) + .sort(([left], [right]) => left.localeCompare(right)) + ); +} + +async function createMaterializedRustWorkspace( + context: ValidationCheckContext, + options: { env?: Record } ): Promise { const tempRoot = mkdtempSync(join(tmpdir(), "lattice-validation-rust-")); const root = join(tempRoot, "repo"); @@ -47,7 +67,7 @@ export async function materializeRustWorkspace( cargoTargetCacheKey: cargoTargetCacheKeyForContext(context), tempRoot, root, - cleanup: () => rmSync(tempRoot, { recursive: true, force: true }) + dispose: () => rmSync(tempRoot, { recursive: true, force: true }) }; } catch (error) { rmSync(tempRoot, { recursive: true, force: true }); diff --git a/packages/validation-rust/src/unused-deps-check.ts b/packages/validation-rust/src/unused-deps-check.ts index 1a06585..57b7de3 100644 --- a/packages/validation-rust/src/unused-deps-check.ts +++ b/packages/validation-rust/src/unused-deps-check.ts @@ -34,41 +34,37 @@ export function createUnusedDepsCheck(options: { env?: Record 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" }); - 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: [] }; - } finally { - materialized.cleanup(); } + return { diagnostics: [] }; } }; } diff --git a/packages/validation-typescript/package.json b/packages/validation-typescript/package.json index 870e2cf..372a584 100644 --- a/packages/validation-typescript/package.json +++ b/packages/validation-typescript/package.json @@ -1,6 +1,6 @@ { "name": "@the-open-engine/opcore-validation-typescript", - "version": "0.2.0", + "version": "0.2.1", "description": "TypeScript syntax, type, and graph-aware validation checks for Opcore.", "type": "module", "license": "MIT", @@ -25,8 +25,8 @@ "developer-tools" ], "dependencies": { - "@the-open-engine/opcore-contracts": "0.2.0", - "@the-open-engine/opcore-validation": "0.2.0", + "@the-open-engine/opcore-contracts": "0.2.1", + "@the-open-engine/opcore-validation": "0.2.1", "@typescript-eslint/typescript-estree": "^8.62.0", "typescript": "^5.9.3" }, diff --git a/packages/validation-typescript/src/import-graph-check.ts b/packages/validation-typescript/src/import-graph-check.ts index 023be7d..85ef1a0 100644 --- a/packages/validation-typescript/src/import-graph-check.ts +++ b/packages/validation-typescript/src/import-graph-check.ts @@ -13,6 +13,7 @@ export function createImportGraphCheck(): ValidationCheckDefinition { defaultSeverity: "warning", supportedScopes: supportedTypeScriptValidationScopes, requiresGraph: false, + graphUsage: "optional", run: async (context) => { const sourceSet = await materializeTypeScriptSources(context); const diagnostics = [ @@ -33,7 +34,11 @@ async function retainedMissingEdgeDiagnostics( try { edges = await context.graph.importsFrom(); } catch (error) { - if (error instanceof ValidationGraphProviderError && context.request.graph.mode !== "required") return []; + if ( + error instanceof ValidationGraphProviderError && + context.request.graph.mode !== "required" && + context.graph.identity.kind !== "exact" + ) return []; throw error; } return relativeImports diff --git a/packages/validation/README.md b/packages/validation/README.md index 8e4e893..3c381c0 100644 --- a/packages/validation/README.md +++ b/packages/validation/README.md @@ -1,3 +1,5 @@ # @the-open-engine/opcore-validation Opcore validation runner package for scopes, overlays, checks, and command adapters. + +Hypothetical graph-backed checks use one disposable exact-state graph session per `ValidationFileView`. Introduced reporting owns separate before/after sessions, shares each session across checks, fails closed on exact-state provider errors, and rejects incomplete visible-file universes. diff --git a/packages/validation/package.json b/packages/validation/package.json index f05fddb..ebb43ce 100644 --- a/packages/validation/package.json +++ b/packages/validation/package.json @@ -1,6 +1,6 @@ { "name": "@the-open-engine/opcore-validation", - "version": "0.2.0", + "version": "0.2.1", "description": "Validation runner, scopes, overlays, and pre-write checks for coding agents.", "type": "module", "license": "MIT", @@ -25,7 +25,7 @@ "developer-tools" ], "dependencies": { - "@the-open-engine/opcore-contracts": "0.2.0" + "@the-open-engine/opcore-contracts": "0.2.1" }, "engines": { "node": ">=22" diff --git a/packages/validation/src/aggregation.ts b/packages/validation/src/aggregation.ts index 83829b5..57de785 100644 --- a/packages/validation/src/aggregation.ts +++ b/packages/validation/src/aggregation.ts @@ -9,7 +9,9 @@ import type { ValidationResult, ValidationResultManifest, ValidationResultStatus, - ValidationSkippedCheck + ValidationSkippedCheck, + PythonProjectContext, + PythonValidationCapabilityRun } from "@the-open-engine/opcore-contracts"; import { GRAPH_SCHEMA_VERSION, validateValidationResultPayload } from "@the-open-engine/opcore-contracts"; @@ -28,6 +30,8 @@ export interface AggregateValidationResultsArgs extends CreateValidationManifest status?: ValidationResultStatus; failure?: ValidationFailure; refusal?: EditRefusal; + pythonProjectContexts?: readonly PythonProjectContext[]; + pythonCapabilityRuns?: readonly PythonValidationCapabilityRun[]; } const failureStatusPriority: readonly ValidationCheckRunStatus[] = [ @@ -61,11 +65,37 @@ export function aggregateValidationResults(args: AggregateValidationResultsArgs) }; if (args.graphStatus !== undefined) result.graphStatus = args.graphStatus; if (args.refusal !== undefined) result.refusal = args.refusal; + if (args.pythonProjectContexts !== undefined) result.pythonProjectContexts = deduplicatePythonProjectContexts(args.pythonProjectContexts); + if (args.pythonCapabilityRuns !== undefined) result.pythonCapabilityRuns = deduplicatePythonCapabilityRuns(args.pythonCapabilityRuns); const failure = args.failure ?? failureForStatus(status); if (failure !== undefined) result.failure = failure; return validateValidationResultPayload(result); } +function deduplicatePythonCapabilityRuns( + runs: readonly PythonValidationCapabilityRun[] +): readonly PythonValidationCapabilityRun[] { + const exact = new Map(); + for (const run of runs) exact.set(JSON.stringify(run), run); + return [...exact.values()].sort(comparePythonCapabilityRuns); +} + +function comparePythonCapabilityRuns( + left: PythonValidationCapabilityRun, + right: PythonValidationCapabilityRun +): number { + return left.projectKey.localeCompare(right.projectKey) || + left.contextFingerprint.localeCompare(right.contextFingerprint) || + left.afterStateManifestFingerprint.localeCompare(right.afterStateManifestFingerprint) || + (left.authority ?? "").localeCompare(right.authority ?? ""); +} + +function deduplicatePythonProjectContexts(contexts: readonly PythonProjectContext[]): readonly PythonProjectContext[] { + const byTarget = new Map(); + for (const context of contexts) byTarget.set(context.target, context); + return [...byTarget.values()].sort((left, right) => left.target.localeCompare(right.target)); +} + function deriveStatus( diagnostics: readonly ValidationDiagnostic[], runs: readonly ValidationCheckRunSummary[], @@ -112,6 +142,10 @@ function sortDiagnostics(diagnostics: readonly ValidationDiagnostic[]): readonly return [...diagnostics].sort((left, right) => [ (left.path ?? "").localeCompare(right.path ?? ""), + (left.line ?? 0) - (right.line ?? 0), + (left.column ?? 0) - (right.column ?? 0), + (left.endLine ?? 0) - (right.endLine ?? 0), + (left.endColumn ?? 0) - (right.endColumn ?? 0), left.category.localeCompare(right.category), left.severity.localeCompare(right.severity), (left.code ?? "").localeCompare(right.code ?? ""), diff --git a/packages/validation/src/command-adapter.ts b/packages/validation/src/command-adapter.ts index 99f4bff..8e615e7 100644 --- a/packages/validation/src/command-adapter.ts +++ b/packages/validation/src/command-adapter.ts @@ -28,7 +28,7 @@ import { type ValidationClock } from "./runner.js"; import { createNodeValidationWorkspace } from "./workspace.js"; -import type { ValidationGraphProviderClient } from "./graph-client.js"; +import type { ValidationGraphProviderClient, ValidationGraphSessionFactory } from "./graph-client.js"; import type { ValidationWorkspace } from "./scope.js"; import type { ValidationRuntimePolicy } from "./registry.js"; import { readFile } from "node:fs/promises"; @@ -45,6 +45,7 @@ export interface ValidationCommandAdapterOptions { workspace?: ValidationWorkspace; workspaceFactory?: (repoRoot: string) => ValidationWorkspace; graphProviderClient?: ValidationGraphProviderClient; + graphSessionFactory?: ValidationGraphSessionFactory; clock?: ValidationClock; defaultRepoRoot?: string; runtime?: ValidationRuntimePolicy; @@ -149,6 +150,7 @@ async function runRequest( workspace: workspaceForRequest(request, parsed, options), registry: registryForOptions(options, repoRootForRequest(request, parsed, options)), graphProviderClient: options.graphProviderClient, + graphSessionFactory: options.graphSessionFactory, clock: options.clock, runtime: options.runtime }; diff --git a/packages/validation/src/graph-client.ts b/packages/validation/src/graph-client.ts index efbb301..8b48cf1 100644 --- a/packages/validation/src/graph-client.ts +++ b/packages/validation/src/graph-client.ts @@ -41,6 +41,7 @@ import { validateRepoRelativePath } from "@the-open-engine/opcore-contracts"; import { defaultValidationGraphProvider, missingGraphStatus } from "./request.js"; +import type { ValidationFileView } from "./overlays.js"; type MaybePromise = T | Promise; @@ -129,8 +130,10 @@ export interface ValidationGraphFactView { } export interface ValidationGraphQuerySession extends ValidationGraphFactView { + readonly identity: ValidationGraphSessionIdentity; readonly status: GraphProviderStatus; readonly queryCapable: boolean; + dispose: () => MaybePromise; preload: (requirements: readonly ValidationGraphQueryRequirement[]) => Promise; facts: (selector: GraphFactQuerySelector) => Promise; namedQuery: (query: ValidationGraphNamedQueryInput) => Promise; @@ -140,16 +143,112 @@ export interface ValidationGraphQuerySession extends ValidationGraphFactView { factView: () => ValidationGraphFactView; } +export type ValidationGraphSessionIdentity = + | { kind: "persistent" } + | { kind: "exact"; state: "before" | "after" }; + export interface CreateValidationGraphQuerySessionArgs { request: ValidationRequest; client?: ValidationGraphProviderClient; status?: GraphProviderStatus; + fileView?: ValidationFileView; + identity?: ValidationGraphSessionIdentity; } export type ValidationGraphSessionFactory = ( args: CreateValidationGraphQuerySessionArgs ) => MaybePromise; +export interface ValidationExactGraphSnapshot { + status: GraphProviderStatus; + client: ValidationGraphProviderClient; + dispose: () => MaybePromise; +} + +export type ValidationExactGraphSnapshotFactory = (args: { + request: ValidationRequest; + fileView: ValidationFileView; + state: "before" | "after"; +}) => MaybePromise; + +export interface ValidationEphemeralGraphSnapshot { + status: (mode: ValidationRequest["graph"]["mode"]) => GraphProviderStatus; + factQuery: ValidationGraphProviderClient["factQuery"]; + namedQuery: ValidationGraphProviderClient["namedQuery"]; + impact: ValidationGraphProviderClient["impact"]; + reviewContext: ValidationGraphProviderClient["reviewContext"]; + detectChanges: ValidationGraphProviderClient["detectChanges"]; + dispose: () => MaybePromise; +} + +export type ValidationEphemeralGraphSnapshotProvider = (args: { + logicalRepo: RepoIdentity; + sourceUniverse: { paths: readonly string[]; complete: boolean; message?: string }; + readFile: (path: string) => MaybePromise<{ status: "found" | "missing" | "deleted"; content?: string }>; +}) => MaybePromise; + +export function createValidationExactGraphSnapshotFactory( + provider: ValidationEphemeralGraphSnapshotProvider +): ValidationExactGraphSnapshotFactory { + return async ({ request, fileView }) => { + const universe = await fileView.listVisibleFileUniverse(); + const snapshot = await provider({ + logicalRepo: request.repo, + sourceUniverse: { paths: universe.files, complete: universe.complete, message: universe.message }, + readFile: (path) => fileView.readFile(path) + }); + const client: ValidationGraphProviderClient = { + status: (candidate) => snapshot.status(candidate.graph.mode), + factQuery: snapshot.factQuery, + namedQuery: snapshot.namedQuery, + impact: snapshot.impact, + reviewContext: snapshot.reviewContext, + detectChanges: snapshot.detectChanges + }; + return { + status: snapshot.status(request.graph.mode), + client, + dispose: snapshot.dispose + }; + }; +} + +export function createStateAwareValidationGraphSessionFactory(options: { + persistentClient?: ValidationGraphProviderClient; + exactSnapshotFactory: ValidationExactGraphSnapshotFactory; +}): ValidationGraphSessionFactory { + return async (args) => { + if (args.identity?.kind !== "exact") { + return createValidationGraphQuerySession({ ...args, client: args.client ?? options.persistentClient }); + } + if (args.fileView === undefined) throw new Error("Exact validation graph session requires its ValidationFileView"); + const snapshot = await options.exactSnapshotFactory({ + request: args.request, + fileView: args.fileView, + state: args.identity.state + }); + try { + const session = await createValidationGraphQuerySession({ + ...args, + client: snapshot.client, + status: snapshot.status + }); + let disposed = false; + return { + ...session, + dispose: async () => { + if (disposed) return; + disposed = true; + await snapshot.dispose(); + } + }; + } catch (error) { + await snapshot.dispose(); + throw error; + } + }; +} + export class ValidationGraphProviderError extends Error { readonly operation: ValidationGraphOperation; readonly status: GraphProviderNonAvailableStatus; @@ -376,8 +475,10 @@ export async function createValidationGraphQuerySession( } return { + identity: args.identity ?? { kind: "persistent" }, status, queryCapable, + dispose: () => {}, preload, facts, namedQuery, diff --git a/packages/validation/src/index.ts b/packages/validation/src/index.ts index e8fdc69..af4024c 100644 --- a/packages/validation/src/index.ts +++ b/packages/validation/src/index.ts @@ -7,7 +7,9 @@ import type { ValidationFailure, ValidationResult, ValidationResultStatus, - ValidationSkippedCheck + ValidationSkippedCheck, + PythonProjectContext, + PythonValidationCapabilityRun } from "@the-open-engine/opcore-contracts"; import { GRAPH_SCHEMA_VERSION, validateValidationResultPayload } from "@the-open-engine/opcore-contracts"; import { createCheckCommandAdapter, createValidateCommandAdapter } from "./command-adapter.js"; @@ -24,6 +26,8 @@ export interface CreateValidationResultSkeletonArgs { entries?: readonly ValidationCheckManifestEntry[]; runs?: readonly ValidationCheckRunSummary[]; skippedChecks?: readonly ValidationSkippedCheck[]; + pythonProjectContexts?: readonly PythonProjectContext[]; + pythonCapabilityRuns?: readonly PythonValidationCapabilityRun[]; } export function createValidationResultSkeleton(args: CreateValidationResultSkeletonArgs = {}): ValidationResult { @@ -36,6 +40,8 @@ export function createValidationResultSkeleton(args: CreateValidationResultSkele if (args.graphStatus !== undefined) result.graphStatus = args.graphStatus; if (args.failure !== undefined) result.failure = args.failure; if (args.refusal !== undefined) result.refusal = args.refusal; + if (args.pythonProjectContexts !== undefined) result.pythonProjectContexts = args.pythonProjectContexts; + if (args.pythonCapabilityRuns !== undefined) result.pythonCapabilityRuns = args.pythonCapabilityRuns; if (args.checks !== undefined) { result.manifest = { schemaVersion: GRAPH_SCHEMA_VERSION, @@ -86,6 +92,8 @@ export { } from "./request.js"; export { createValidationGraphQuerySession, + createStateAwareValidationGraphSessionFactory, + createValidationExactGraphSnapshotFactory, resolveValidationGraphProviderStatus, ValidationGraphProviderError, ValidationGraphRequirementError, @@ -104,7 +112,12 @@ export { type ValidationGraphQuerySession, type ValidationGraphReviewContextInput, type ValidationGraphReviewContextRequirement, - type ValidationGraphSessionFactory + type ValidationGraphSessionFactory, + type ValidationGraphSessionIdentity, + type ValidationExactGraphSnapshot, + type ValidationExactGraphSnapshotFactory, + type ValidationEphemeralGraphSnapshot, + type ValidationEphemeralGraphSnapshotProvider } from "./graph-client.js"; export { createValidationCheckManifest, @@ -121,6 +134,7 @@ export { } from "./registry.js"; export { ValidationOverlayConflictError, + ValidationFileUniverseError, calculateValidationFileChecksum, createValidationFileView, findValidationOverlayEntry, @@ -137,6 +151,7 @@ export { type ValidationFileReadStatus, type ValidationFileSourceMetadata, type ValidationFileView, + type ValidationVisibleFileUniverse, type ValidationOverlayEntry } from "./overlays.js"; export { @@ -182,3 +197,8 @@ export { type ValidationClock, type ValidationRunner } from "./runner.js"; +export { + createValidationRunResources, + type ValidationRunResource, + type ValidationRunResources +} from "./resources.js"; diff --git a/packages/validation/src/node-shims.d.ts b/packages/validation/src/node-shims.d.ts index fe72b6e..0dff37e 100644 --- a/packages/validation/src/node-shims.d.ts +++ b/packages/validation/src/node-shims.d.ts @@ -29,6 +29,16 @@ declare module "node:fs/promises" { export function readFile(path: string, encoding: "utf8"): Promise; } +declare module "node:fs" { + export interface Dirent { + name: string; + isDirectory(): boolean; + isFile(): boolean; + isSymbolicLink(): boolean; + } + export function readdirSync(path: string, options: { withFileTypes: true }): Dirent[]; +} + declare module "node:path" { export const sep: string; export function relative(from: string, to: string): string; diff --git a/packages/validation/src/overlays.ts b/packages/validation/src/overlays.ts index eada86e..907f1f3 100644 --- a/packages/validation/src/overlays.ts +++ b/packages/validation/src/overlays.ts @@ -83,6 +83,8 @@ export interface ValidationFileView { readonly scopeFiles: readonly string[]; readonly defaultReadState: ValidationFileReadState; listVisibleFiles: () => Promise; + listVisibleFileUniverse: () => Promise; + listCompleteVisibleFiles: () => Promise; readFile: (path: string, options?: ValidationFileReadOptions) => Promise; readBefore: (path: string) => Promise; readAfter: (path: string) => Promise; @@ -91,6 +93,19 @@ export interface ValidationFileView { overlayFor: (path: string) => ValidationOverlayEntry | undefined; } +export interface ValidationVisibleFileUniverse { + files: readonly string[]; + complete: boolean; + message?: string; +} + +export class ValidationFileUniverseError extends Error { + constructor(message: string) { + super(message); + this.name = "ValidationFileUniverseError"; + } +} + export class ValidationOverlayConflictError extends Error { readonly path: string; readonly expected: string; @@ -135,10 +150,18 @@ export async function createValidationFileView(args: CreateValidationFileViewArg const overlayByPath = new Map(overlays.map((overlay) => [overlay.path, overlay])); const scopeFiles = uniqueSorted(args.scope.files.map(normalizeValidationFileViewPath)); const defaultReadState = args.defaultReadState ?? "after"; - let visibleFilesPromise: Promise | undefined; + let visibleFileUniversePromise: Promise | undefined; + const listVisibleFileUniverse = (): Promise => { + visibleFileUniversePromise ??= resolveVisibleFileUniverse(args.workspace, args.scope, defaultReadState, scopeFiles, overlays); + return visibleFileUniversePromise; + }; const listVisibleFiles = (): Promise => { - visibleFilesPromise ??= resolveVisibleFiles(args.workspace, args.scope, defaultReadState, scopeFiles, overlays); - return visibleFilesPromise; + return listVisibleFileUniverse().then((universe) => universe.files); + }; + const listCompleteVisibleFiles = async (): Promise => { + const universe = await listVisibleFileUniverse(); + if (!universe.complete) throw new ValidationFileUniverseError(universe.message ?? "Complete validation file listing is unavailable"); + return universe.files; }; const readBefore = (path: string): Promise => readWorkspacePath(args.workspace, args.scope, normalizeValidationFileViewPath(path), "before"); @@ -165,6 +188,8 @@ export async function createValidationFileView(args: CreateValidationFileViewArg scopeFiles, defaultReadState, listVisibleFiles, + listVisibleFileUniverse, + listCompleteVisibleFiles, readFile: (path, options = {}) => options.state === "before" || (options.state === undefined && defaultReadState === "before") ? readBefore(path) : readAfter(path), readBefore, @@ -293,18 +318,31 @@ function validateWorkspaceReader(workspace: ValidationWorkspace): void { } } -async function resolveVisibleFiles( +async function resolveVisibleFileUniverse( workspace: ValidationWorkspace, scope: ResolvedValidationScope, state: ValidationFileReadState, scopeFiles: readonly string[], overlays: readonly ValidationOverlayEntry[] -): Promise { +): Promise { const overlayFiles = overlays.map((overlay) => overlay.path); - if (workspace.listFiles === undefined) return uniqueSorted([...scopeFiles, ...overlayFiles]); + const fallbackFiles = uniqueSorted([...scopeFiles, ...overlayFiles]); + if (workspace.listFiles === undefined) { + return { files: fallbackFiles, complete: false, message: "Validation workspace cannot list the complete visible file universe" }; + } const fileSet = await workspace.listFiles({ scope, state }); - if (fileSet.unavailable) return uniqueSorted([...scopeFiles, ...overlayFiles]); - return uniqueSorted([...fileSetPaths(fileSet), ...scopeFiles, ...overlayFiles]); + if (fileSet.unavailable) { + return { + files: fallbackFiles, + complete: false, + message: fileSet.message ?? "Validation workspace file listing is unavailable" + }; + } + const files = uniqueSorted([...fileSetPaths(fileSet), ...scopeFiles, ...overlayFiles]); + if (fileSet.truncated) { + return { files, complete: false, message: fileSet.message ?? "Validation workspace file listing was truncated" }; + } + return { files, complete: true }; } function fileSetPaths(fileSet: ValidationWorkspaceFileSet): readonly string[] { diff --git a/packages/validation/src/registry.ts b/packages/validation/src/registry.ts index 45f2562..853df18 100644 --- a/packages/validation/src/registry.ts +++ b/packages/validation/src/registry.ts @@ -1,7 +1,10 @@ import type { ValidationCheckManifestEntry, + ValidationCheckOutcome, ValidationCheckRunStatus, ValidationDiagnostic, + PythonValidationCapabilityRun, + PythonProjectContext, GraphProviderStatus, ValidationRequest, ValidationScopeKind @@ -9,6 +12,7 @@ import type { import { validationCheckIdPattern, validationScopeKinds } from "@the-open-engine/opcore-contracts"; import type { ValidationGraphQueryRequirement, ValidationGraphQuerySession } from "./graph-client.js"; import type { ValidationFileView } from "./overlays.js"; +import type { ValidationRunResources } from "./resources.js"; import type { ResolvedValidationScope } from "./scope.js"; const validationCheckIdRegex = new RegExp(validationCheckIdPattern); @@ -20,6 +24,7 @@ export interface ValidationCheckContext { graphStatus: GraphProviderStatus; graph: ValidationGraphQuerySession; fileView: ValidationFileView; + resources: ValidationRunResources; runtime: ValidationRuntimePolicy; } @@ -32,7 +37,10 @@ export interface ValidationRuntimePolicy { export interface ValidationCheckResult { diagnostics?: readonly ValidationDiagnostic[]; status?: ValidationCheckRunStatus; + outcome?: ValidationCheckOutcome; failureMessage?: string; + pythonProjectContexts?: readonly PythonProjectContext[]; + pythonCapabilityRuns?: readonly PythonValidationCapabilityRun[]; } export interface ValidationCheckDefinition { @@ -43,6 +51,7 @@ export interface ValidationCheckDefinition { supportedScopes: readonly ValidationScopeKind[]; defaultScopes?: readonly ValidationScopeKind[]; requiresGraph?: boolean; + graphUsage?: "none" | "optional" | "required"; graphRequirements?: ( context: ValidationCheckContext ) => readonly ValidationGraphQueryRequirement[] | Promise; @@ -172,6 +181,9 @@ function validateValidationCheckDefinition(definition: ValidationCheckDefinition if (definition.requiresGraph !== undefined && typeof definition.requiresGraph !== "boolean") { throw new ValidationCheckRegistryError("Validation check requiresGraph must be boolean"); } + if (definition.graphUsage !== undefined && !["none", "optional", "required"].includes(definition.graphUsage)) { + throw new ValidationCheckRegistryError("Validation check graphUsage must be none, optional, or required"); + } if (definition.graphRequirements !== undefined && typeof definition.graphRequirements !== "function") { throw new ValidationCheckRegistryError("Validation check graphRequirements must be a function"); } diff --git a/packages/validation/src/resources.ts b/packages/validation/src/resources.ts new file mode 100644 index 0000000..ee38a1e --- /dev/null +++ b/packages/validation/src/resources.ts @@ -0,0 +1,51 @@ +export interface ValidationRunResource { + dispose: () => void | Promise; +} + +export interface ValidationRunResources { + getOrCreate: ( + key: PropertyKey, + create: () => T | Promise + ) => Promise; + dispose: () => Promise; +} + +export function createValidationRunResources(): ValidationRunResources { + const entries = new Map>(); + let disposed = false; + + return { + async getOrCreate( + key: PropertyKey, + create: () => T | Promise + ): Promise { + if (disposed) throw new Error("Validation run resources are already disposed"); + const existing = entries.get(key); + if (existing !== undefined) return existing as Promise; + + const pending = Promise.resolve().then(create); + entries.set(key, pending); + try { + return await pending; + } catch (error) { + if (entries.get(key) === pending) entries.delete(key); + throw error; + } + }, + async dispose(): Promise { + if (disposed) return; + disposed = true; + let firstError: unknown; + for (const pending of [...entries.values()].reverse()) { + try { + const resource = await pending; + await resource.dispose(); + } catch (error) { + firstError ??= error; + } + } + entries.clear(); + if (firstError !== undefined) throw firstError; + } + }; +} diff --git a/packages/validation/src/runner.ts b/packages/validation/src/runner.ts index c5c4b9f..2d48b02 100644 --- a/packages/validation/src/runner.ts +++ b/packages/validation/src/runner.ts @@ -1,5 +1,6 @@ import type { GraphProviderStatus, + ValidationCheckOutcome, ValidationCheckRunStatus, ValidationCheckRunSummary, ValidationDiagnostic, @@ -7,6 +8,8 @@ import type { ValidationFailureCategory, ValidationRequest, ValidationResult, + PythonProjectContext, + PythonValidationCapabilityRun, ValidationScopeKind, ValidationSkippedCheck } from "@the-open-engine/opcore-contracts"; @@ -32,6 +35,7 @@ import { type ValidationRuntimePolicy } from "./registry.js"; import { defaultValidationGraphProvider, missingGraphStatus, normalizeValidationRequest } from "./request.js"; +import { createValidationRunResources, type ValidationRunResources } from "./resources.js"; import { resolveValidationScope, ValidationScopeResolutionError, @@ -64,6 +68,7 @@ export interface ValidationCheckCompleteEvent { diagnostics: readonly ValidationDiagnostic[]; run?: ValidationCheckRunSummary; skippedCheck?: ValidationSkippedCheck; + pythonCapabilityRuns?: readonly PythonValidationCapabilityRun[]; } export type ValidationCheckCompleteHandler = (event: ValidationCheckCompleteEvent) => void | Promise; @@ -92,6 +97,8 @@ interface CheckExecution { diagnostics: ValidationDiagnostic[]; diagnosticsByCheck: Map; skippedChecks: ValidationSkippedCheck[]; + pythonProjectContexts: PythonProjectContext[]; + pythonCapabilityRuns: PythonValidationCapabilityRun[]; failureResult?: ValidationResult; } @@ -100,6 +107,7 @@ interface ExecuteChecksArgs { scope: ResolvedValidationScope; graph: ValidationGraphQuerySession; fileView: ValidationFileView; + resources: ValidationRunResources; selectedChecks: readonly ValidationCheckDefinition[]; totalStartedAt: number; clock: ValidationClock; @@ -111,14 +119,16 @@ interface ExecuteChecksArgs { interface PreparedValidationArgs { request: ValidationRequest; scope: ResolvedValidationScope; - graph: ValidationGraphQuerySession; selectedChecks: readonly ValidationCheckDefinition[]; totalStartedAt: number; options: RunnerRuntimeOptions; } -interface ExecuteValidationRequestArgs extends PreparedValidationArgs { - defaultReadState?: "before"; +interface ValidationExecutionState { + request: ValidationRequest; + fileView: ValidationFileView; + graph: ValidationGraphQuerySession; + resources: ValidationRunResources; } interface SingleCheckOutcome { @@ -127,6 +137,8 @@ interface SingleCheckOutcome { failureMessage?: string; failureStatus?: Extract; providerError?: ValidationGraphProviderError; + pythonProjectContexts: readonly PythonProjectContext[]; + pythonCapabilityRuns: readonly PythonValidationCapabilityRun[]; } export function createValidationRunner(options: CreateValidationRunnerOptions): ValidationRunner { @@ -177,14 +189,10 @@ async function runPreparedValidation( } const unsupportedResult = unsupportedScopeResult(selectedChecks, scope.kind, totalStartedAt, options.clock); if (unsupportedResult !== undefined) return unsupportedResult; - const graph = await resolveGraphSession(request, selectedChecks, options); - const graphFailure = requiredGraphFailureResult(request, graph.status, selectedChecks, totalStartedAt, options.clock); - if (graphFailure !== undefined) return graphFailure; if (request.reportMode === "introduced") { return await runIntroducedValidation({ request, scope, - graph, selectedChecks, totalStartedAt, options @@ -193,7 +201,6 @@ async function runPreparedValidation( return await runCurrentValidation({ request, scope, - graph, selectedChecks, totalStartedAt, options @@ -204,63 +211,78 @@ async function runPreparedValidation( } async function runCurrentValidation(args: PreparedValidationArgs): Promise { - const execution = await executeValidationRequest(args); - if (execution.failureResult !== undefined) return execution.failureResult; - return finalValidationResult(args.selectedChecks, execution, args.graph.status, args.totalStartedAt, args.options.clock); + const state = await acquireValidationState(args, args.request, "after", exactGraphRequired(args)); + try { + const graphFailure = requiredGraphFailureResult(state.graph, args.request, args.selectedChecks, args.totalStartedAt, args.options.clock); + if (graphFailure !== undefined) return graphFailure; + const execution = await executeValidationState(args, state, args.selectedChecks, args.options); + if (execution.failureResult !== undefined) return execution.failureResult; + return finalValidationResult(args.selectedChecks, execution, state.graph.status, args.totalStartedAt, args.options.clock); + } finally { + await disposeValidationState(state); + } } async function runIntroducedValidation(args: PreparedValidationArgs): Promise { - if (args.options.failFast || args.options.onCheckComplete !== undefined) { - return runIntroducedValidationIncremental(args); - } const beforeRequest: ValidationRequest = { ...args.request, overlays: [] }; - const beforeExecution = await executeValidationRequest({ - ...args, - request: beforeRequest, - defaultReadState: "before" - }); - if (beforeExecution.failureResult !== undefined) return beforeExecution.failureResult; - const execution = await executeValidationRequest(args); - if (execution.failureResult !== undefined) return execution.failureResult; - return finalValidationResult( - args.selectedChecks, - introducedExecution(beforeExecution, execution), - args.graph.status, - args.totalStartedAt, - args.options.clock - ); + const exact = exactGraphRequired(args); + const beforeState = await acquireValidationState(args, beforeRequest, "before", exact); + try { + const beforeFailure = requiredGraphFailureResult(beforeState.graph, beforeRequest, args.selectedChecks, args.totalStartedAt, args.options.clock); + if (beforeFailure !== undefined) return beforeFailure; + const afterState = await acquireValidationState(args, args.request, "after", exact); + try { + const afterFailure = requiredGraphFailureResult(afterState.graph, args.request, args.selectedChecks, args.totalStartedAt, args.options.clock); + if (afterFailure !== undefined) return afterFailure; + if (args.options.failFast || args.options.onCheckComplete !== undefined) { + return runIntroducedValidationIncremental(args, beforeState, afterState); + } + const beforeExecution = await executeValidationState(args, beforeState, args.selectedChecks, args.options); + const execution = await executeValidationState(args, afterState, args.selectedChecks, args.options); + if (execution.failureResult !== undefined) return execution.failureResult; + if (beforeExecution.failureResult !== undefined) { + return introducedBaselineFailureResult(args, beforeExecution.failureResult, execution, afterState.graph.status); + } + return finalValidationResult( + args.selectedChecks, + introducedExecution(beforeExecution, execution), + afterState.graph.status, + args.totalStartedAt, + args.options.clock + ); + } finally { + await disposeValidationState(afterState); + } + } finally { + await disposeValidationState(beforeState); + } } -async function runIntroducedValidationIncremental(args: PreparedValidationArgs): Promise { +async function runIntroducedValidationIncremental( + args: PreparedValidationArgs, + beforeState: ValidationExecutionState, + afterState: ValidationExecutionState +): Promise { const execution: CheckExecution = { runs: [], diagnostics: [], diagnosticsByCheck: new Map(), - skippedChecks: [] + skippedChecks: [], + pythonProjectContexts: [], + pythonCapabilityRuns: [] }; const quietOptions = withoutCheckCompleteOptions(args.options); for (const check of args.selectedChecks) { const selectedChecks = [check]; - const beforeExecution = await executeValidationRequest({ - ...args, - request: { - ...args.request, - overlays: [] - }, - selectedChecks, - defaultReadState: "before", - options: quietOptions - }); - if (beforeExecution.failureResult !== undefined) return beforeExecution.failureResult; - const afterExecution = await executeValidationRequest({ - ...args, - selectedChecks, - options: quietOptions - }); + const beforeExecution = await executeValidationState(args, beforeState, selectedChecks, quietOptions); + const afterExecution = await executeValidationState(args, afterState, selectedChecks, quietOptions); if (afterExecution.failureResult !== undefined) return afterExecution.failureResult; + if (beforeExecution.failureResult !== undefined) { + return introducedBaselineFailureResult(args, beforeExecution.failureResult, afterExecution, afterState.graph.status); + } const introduced = introducedExecution(beforeExecution, afterExecution); mergeCheckExecution(execution, introduced); await emitIntroducedCheckComplete(args, check, introduced); @@ -268,7 +290,32 @@ async function runIntroducedValidationIncremental(args: PreparedValidationArgs): break; } } - return finalValidationResult(args.selectedChecks, execution, args.graph.status, args.totalStartedAt, args.options.clock); + return finalValidationResult(args.selectedChecks, execution, afterState.graph.status, args.totalStartedAt, args.options.clock); +} + +function introducedBaselineFailureResult( + args: PreparedValidationArgs, + beforeFailure: ValidationResult, + afterExecution: CheckExecution, + graphStatus: GraphProviderStatus +): ValidationResult { + const failure: ValidationFailure = beforeFailure.failure ?? { + category: "infrastructure_failure", + message: "Introduced validation baseline could not execute" + }; + const status = failure.category; + return aggregateForChecks(args.selectedChecks, { + runs: afterExecution.runs, + skippedChecks: afterExecution.skippedChecks, + diagnostics: afterExecution.diagnostics, + pythonProjectContexts: afterExecution.pythonProjectContexts, + pythonCapabilityRuns: afterExecution.pythonCapabilityRuns, + generatedAt: args.options.clock.isoNow(), + durationMs: elapsed(args.totalStartedAt, args.options.clock.nowMs()), + graphStatus, + status, + failure + }); } function withoutCheckCompleteOptions(options: RunnerRuntimeOptions): RunnerRuntimeOptions { @@ -283,6 +330,8 @@ function mergeCheckExecution(target: CheckExecution, source: CheckExecution): vo target.runs.push(...source.runs); target.diagnostics.push(...source.diagnostics); target.skippedChecks.push(...source.skippedChecks); + mergePythonProjectContexts(target.pythonProjectContexts, source.pythonProjectContexts); + mergePythonCapabilityRuns(target.pythonCapabilityRuns, source.pythonCapabilityRuns); for (const [checkId, diagnostics] of source.diagnosticsByCheck) { target.diagnosticsByCheck.set(checkId, diagnostics); } @@ -302,7 +351,8 @@ async function emitIntroducedCheckComplete( checkId: check.id, status: run.status, diagnostics: execution.diagnosticsByCheck.get(check.id) ?? [], - run + run, + ...(execution.pythonCapabilityRuns.length === 0 ? {} : { pythonCapabilityRuns: execution.pythonCapabilityRuns }) }); return; } @@ -319,24 +369,54 @@ async function emitIntroducedCheckComplete( } } -async function executeValidationRequest(args: ExecuteValidationRequestArgs): Promise { +async function acquireValidationState( + args: PreparedValidationArgs, + request: ValidationRequest, + defaultReadState: "before" | "after", + exact: boolean +): Promise { const fileView = await createValidationFileView({ - request: args.request, + request, scope: args.scope, workspace: args.options.workspace, - ...(args.defaultReadState === undefined ? {} : { defaultReadState: args.defaultReadState }) + defaultReadState }); + const graph = await resolveGraphSession( + request, + args.selectedChecks, + args.options, + fileView, + exact ? { kind: "exact", state: defaultReadState } : { kind: "persistent" } + ); + return { request, fileView, graph, resources: createValidationRunResources() }; +} + +async function disposeValidationState(state: ValidationExecutionState): Promise { + try { + await state.resources.dispose(); + } finally { + await state.graph.dispose(); + } +} + +async function executeValidationState( + args: PreparedValidationArgs, + state: ValidationExecutionState, + selectedChecks: readonly ValidationCheckDefinition[], + options: RunnerRuntimeOptions +): Promise { return executeSelectedChecks({ - request: args.request, + request: state.request, scope: args.scope, - graph: args.graph, - fileView, - selectedChecks: args.selectedChecks, + graph: state.graph, + fileView: state.fileView, + resources: state.resources, + selectedChecks, totalStartedAt: args.totalStartedAt, - clock: args.options.clock, - runtime: args.options.runtime, - failFast: args.options.failFast, - onCheckComplete: args.options.onCheckComplete + clock: options.clock, + runtime: options.runtime, + failFast: options.failFast, + onCheckComplete: options.onCheckComplete }); } @@ -360,13 +440,14 @@ function unsupportedScopeResult( } function requiredGraphFailureResult( + graph: ValidationGraphQuerySession, request: ValidationRequest, - graphStatus: GraphProviderStatus, selectedChecks: readonly ValidationCheckDefinition[], totalStartedAt: number, clock: ValidationClock ): ValidationResult | undefined { - if (request.graph.mode !== "required" || graphStatus.state === "available") return undefined; + const graphStatus = graph.status; + if (graphStatus.state === "available" || (request.graph.mode !== "required" && graph.identity.kind !== "exact")) return undefined; return aggregateForChecks(selectedChecks, { generatedAt: clock.isoNow(), durationMs: elapsed(totalStartedAt, clock.nowMs()), @@ -385,7 +466,9 @@ async function executeSelectedChecks(args: ExecuteChecksArgs): Promise diagnostic.severity === "error") ? "policy_failure" : "passed"); const failureStatus = failureStatusForCheckRun(status); const failureMessage = normalized.failureMessage ?? (failureStatus === undefined ? undefined : `Validation check returned ${status}`); return { diagnostics, + pythonProjectContexts: normalized.pythonProjectContexts ?? [], + pythonCapabilityRuns: normalized.pythonCapabilityRuns ?? [], failureMessage, failureStatus, run: { checkId: check.id, status, + ...(normalized.outcome === undefined ? {} : { outcome: normalized.outcome }), durationMs: elapsed(checkStartedAt, args.clock.nowMs()), diagnosticCount: diagnostics.length, ...(failureMessage === undefined ? {} : { failureMessage }) @@ -589,6 +685,8 @@ async function runSingleCheck(check: ValidationCheckDefinition, args: ExecuteChe if (error instanceof ValidationGraphProviderError) { return { diagnostics: [], + pythonProjectContexts: [], + pythonCapabilityRuns: [], providerError: error, run: { checkId: check.id, @@ -601,6 +699,8 @@ async function runSingleCheck(check: ValidationCheckDefinition, args: ExecuteChe } return { diagnostics: [], + pythonProjectContexts: [], + pythonCapabilityRuns: [], failureMessage: errorMessage(error), failureStatus: "infrastructure_failure", run: { @@ -621,6 +721,7 @@ function checkContext(args: ExecuteChecksArgs) { graphStatus: args.graph.status, graph: args.graph, fileView: args.fileView, + resources: args.resources, runtime: args.runtime }; } @@ -656,6 +757,8 @@ function checkRunFailureResult( runs: execution.runs, skippedChecks: execution.skippedChecks, diagnostics: execution.diagnostics, + pythonProjectContexts: execution.pythonProjectContexts, + pythonCapabilityRuns: execution.pythonCapabilityRuns, generatedAt: args.clock.isoNow(), durationMs: elapsed(args.totalStartedAt, args.clock.nowMs()), graphStatus: args.graph.status, @@ -678,6 +781,8 @@ function checkProviderFailureResult( runs: execution.runs, skippedChecks: execution.skippedChecks, diagnostics: execution.diagnostics, + pythonProjectContexts: execution.pythonProjectContexts, + pythonCapabilityRuns: execution.pythonCapabilityRuns, generatedAt: args.clock.isoNow(), durationMs: elapsed(args.totalStartedAt, args.clock.nowMs()), graphStatus: error.status, @@ -700,6 +805,8 @@ function graphRequirementFailureResult( runs: execution.runs, skippedChecks: execution.skippedChecks, diagnostics: execution.diagnostics, + pythonProjectContexts: execution.pythonProjectContexts, + pythonCapabilityRuns: execution.pythonCapabilityRuns, generatedAt: args.clock.isoNow(), durationMs: elapsed(args.totalStartedAt, args.clock.nowMs()), graphStatus: args.graph.status, @@ -723,6 +830,8 @@ function finalValidationResult( runs: execution.runs, skippedChecks: execution.skippedChecks, diagnostics: execution.diagnostics, + pythonProjectContexts: execution.pythonProjectContexts, + pythonCapabilityRuns: execution.pythonCapabilityRuns, generatedAt: clock.isoNow(), durationMs: elapsed(totalStartedAt, clock.nowMs()), graphStatus @@ -786,30 +895,93 @@ function overlayConflictResult( async function resolveGraphSession( request: ValidationRequest, selectedChecks: readonly ValidationCheckDefinition[], - options: RunnerRuntimeOptions + options: RunnerRuntimeOptions, + fileView: ValidationFileView, + identity: ValidationGraphQuerySession["identity"] ): Promise { + if (identity.kind === "exact" && options.graphSessionFactory === undefined) { + return createValidationGraphQuerySession({ + request, + fileView, + identity, + status: missingGraphStatus(request.graph.mode, request.graph.provider ?? defaultValidationGraphProvider) + }); + } const factory = options.graphSessionFactory ?? createValidationGraphQuerySession; - const graph = await factory({ request, client: options.graphProviderClient }); - const hasGraphRequiredChecks = selectedChecks.some((check) => check.requiresGraph === true); + const graph = await factory({ request, client: options.graphProviderClient, fileView, identity }); + const hasGraphRequiredChecks = selectedChecks.some(checkUsesGraph); if (hasGraphRequiredChecks && !graph.queryCapable && graph.status.state === "available") { + await graph.dispose(); return createValidationGraphQuerySession({ request, + fileView, + identity, status: missingGraphStatus(request.graph.mode, request.graph.provider ?? defaultValidationGraphProvider) }); } return graph; } +function exactGraphRequired(args: PreparedValidationArgs): boolean { + return ( + (args.request.overlays.length > 0 || args.request.reportMode === "introduced") && + args.selectedChecks.some(checkUsesGraph) + ); +} + +function checkUsesGraph(check: ValidationCheckDefinition): boolean { + return check.graphUsage === "optional" || check.graphUsage === "required" || check.requiresGraph === true; +} + function normalizeCheckResult(result: ValidationCheckResult | readonly ValidationDiagnostic[] | void): ValidationCheckResult { if (result === undefined) return { diagnostics: [] }; - if (Array.isArray(result)) return { diagnostics: result as readonly ValidationDiagnostic[] }; + if (isValidationDiagnosticArray(result)) return { diagnostics: result }; return { - diagnostics: (result as ValidationCheckResult).diagnostics ?? [], - status: (result as ValidationCheckResult).status, - failureMessage: (result as ValidationCheckResult).failureMessage + diagnostics: result.diagnostics ?? [], + status: result.status, + outcome: result.outcome, + failureMessage: result.failureMessage, + pythonProjectContexts: result.pythonProjectContexts, + pythonCapabilityRuns: result.pythonCapabilityRuns }; } +function mergePythonProjectContexts(target: PythonProjectContext[], source: readonly PythonProjectContext[]): void { + const byTarget = new Map(target.map((context) => [context.target, context])); + for (const context of source) byTarget.set(context.target, context); + target.splice(0, target.length, ...[...byTarget.values()].sort((left, right) => left.target.localeCompare(right.target))); +} + +function mergePythonCapabilityRuns( + target: PythonValidationCapabilityRun[], + source: readonly PythonValidationCapabilityRun[] +): void { + const exact = new Map(target.map((run) => [JSON.stringify(run), run])); + for (const run of source) exact.set(JSON.stringify(run), run); + target.splice(0, target.length, ...[...exact.values()].sort((left, right) => + left.projectKey.localeCompare(right.projectKey) || + left.contextFingerprint.localeCompare(right.contextFingerprint) || + left.afterStateManifestFingerprint.localeCompare(right.afterStateManifestFingerprint) || + (left.authority ?? "").localeCompare(right.authority ?? "") + )); +} + +function isValidationDiagnosticArray( + result: ValidationCheckResult | readonly ValidationDiagnostic[] +): result is readonly ValidationDiagnostic[] { + return Array.isArray(result); +} + +function statusForOutcome(outcome: ValidationCheckOutcome | undefined): ValidationCheckRunStatus | undefined { + if (outcome === undefined) return undefined; + if (outcome === "passed") return "passed"; + if (outcome === "findings") return "policy_failure"; + if (outcome === "tool_unavailable" || outcome === "invalid_config" || outcome === "unsupported_target") { + return "unsupported_request"; + } + return "infrastructure_failure"; +} + function failureStatusForCheckRun( status: ValidationCheckRunStatus ): Extract | undefined { diff --git a/packages/validation/src/scope.ts b/packages/validation/src/scope.ts index a33fd3a..b34c5a3 100644 --- a/packages/validation/src/scope.ts +++ b/packages/validation/src/scope.ts @@ -19,6 +19,7 @@ export interface ValidationWorkspaceFile { export interface ValidationWorkspaceFileSet { files: readonly (string | ValidationWorkspaceFile)[]; unavailable?: boolean; + truncated?: boolean; message?: string; cause?: string; } diff --git a/packages/validation/src/workspace.ts b/packages/validation/src/workspace.ts index 350897c..d5cfb9c 100644 --- a/packages/validation/src/workspace.ts +++ b/packages/validation/src/workspace.ts @@ -8,6 +8,7 @@ import type { } from "./scope.js"; import { validateRepoRelativePath } from "@the-open-engine/opcore-contracts"; import { spawnSync } from "node:child_process"; +import { readdirSync } from "node:fs"; import { readFile } from "node:fs/promises"; import { relative, resolve, sep } from "node:path"; @@ -23,7 +24,7 @@ export function createNodeValidationWorkspace(options: CreateNodeValidationWorks const skippedPathSegments = new Set(options.skippedPathSegments ?? []); return { readFile: (path, context) => readWorkspaceFile(repoRoot, path, context), - listFiles: (context) => filterFileSet(listWorkspaceFiles(repoRoot, context), skippedPathSegments), + listFiles: (context) => filterFileSet(listWorkspaceFiles(repoRoot, context, skippedPathSegments), skippedPathSegments), listChangedFiles: (baseRef) => filterFileSet(listChangedFiles(repoRoot, baseRef), skippedPathSegments), listTreeFiles: (treeRef, changedFrom) => filterFileSet(listTreeFiles(repoRoot, treeRef, changedFrom), skippedPathSegments), listStagedFiles: () => @@ -133,7 +134,11 @@ function listTreeFiles(repoRoot: string, treeRef: string, changedFrom: string): return listDiffFiles(repoRoot, ["diff", "--name-status", "-z", "--find-renames", base.treeSha, tree.treeSha, "--"]); } -function listWorkspaceFiles(repoRoot: string, context: ValidationWorkspaceListFilesContext): ValidationWorkspaceFileSet { +function listWorkspaceFiles( + repoRoot: string, + context: ValidationWorkspaceListFilesContext, + skippedPathSegments: ReadonlySet +): ValidationWorkspaceFileSet { const state = context.state ?? "after"; const beforeRef = beforeStateTreeRef(repoRoot, { scope: context.scope, state }); if (beforeRef !== undefined) return mergeFileSets(listTreeSnapshotFiles(repoRoot, beforeRef), listRepoFiles(repoRoot)); @@ -141,9 +146,40 @@ function listWorkspaceFiles(repoRoot: string, context: ValidationWorkspaceListFi return mergeFileSets(listTreeSnapshotFiles(repoRoot, context.scope.treeRef), listRepoFiles(repoRoot)); } if (context.scope.kind === "package" && context.scope.packageRoot !== undefined) { - return filterPackageFileSet(listRepoFiles(repoRoot), context.scope.packageRoot); + const files = repoFilesOrDiskFiles(repoRoot, skippedPathSegments); + return filterPackageFileSet(files, context.scope.packageRoot); + } + return repoFilesOrDiskFiles(repoRoot, skippedPathSegments); +} + +function repoFilesOrDiskFiles(repoRoot: string, skippedPathSegments: ReadonlySet): ValidationWorkspaceFileSet { + const tracked = listRepoFiles(repoRoot); + return tracked.unavailable ? listDiskFiles(repoRoot, skippedPathSegments) : tracked; +} + +function listDiskFiles(repoRoot: string, skippedPathSegments: ReadonlySet): ValidationWorkspaceFileSet { + const files: string[] = []; + try { + const visit = (absoluteDirectory: string, relativeDirectory: string): void => { + for (const entry of readdirSync(absoluteDirectory, { withFileTypes: true })) { + if (skippedPathSegments.has(entry.name)) continue; + const relativePath = relativeDirectory.length === 0 ? entry.name : `${relativeDirectory}/${entry.name}`; + const absolutePath = resolve(absoluteDirectory, entry.name); + if (entry.isDirectory()) visit(absolutePath, relativePath); + else if (entry.isFile()) files.push(validateRepoRelativePath(relativePath)); + else if (entry.isSymbolicLink()) throw new Error(`Disk validation listing does not follow symlink: ${relativePath}`); + } + }; + visit(repoRoot, ""); + return { files: files.sort() }; + } catch (error) { + return { + files: [], + unavailable: true, + message: "Disk validation file listing is unavailable", + cause: error instanceof Error ? error.message : String(error) + }; } - return listRepoFiles(repoRoot); } function listTreeSnapshotFiles(repoRoot: string, treeRef: string): ValidationWorkspaceFileSet { @@ -191,6 +227,7 @@ function filterPackageFileSet(fileSet: ValidationWorkspaceFileSet, packageRoot: const root = validateRepoRelativePath(packageRoot); const prefix = `${root}/`; return { + ...fileSet, files: fileSet.files.filter((file) => { const path = typeof file === "string" ? file : file.path; return path === root || path.startsWith(prefix); diff --git a/scripts/asp-dogfood-provider-probe.mjs b/scripts/asp-dogfood-provider-probe.mjs index 69d146b..196c55d 100644 --- a/scripts/asp-dogfood-provider-probe.mjs +++ b/scripts/asp-dogfood-provider-probe.mjs @@ -34,7 +34,7 @@ async function probeProvider(providerBin, probeRoot) { function initialize(peer, probeRoot, baseline) { return peer.request("initialize", { protocolVersion: "asp/0.1", - host: { name: "asp-dogfood-receipt", version: "0.2.0-test" }, + host: { name: "asp-dogfood-receipt", version: "0.2.1-test" }, hostCapabilities: { readBlob: true, listTree: true, putBlob: false }, workspace: { root: probeRoot, baseline }, assuranceMode: "advisory" diff --git a/scripts/check-python-mypy-authority.mjs b/scripts/check-python-mypy-authority.mjs new file mode 100644 index 0000000..02bef42 --- /dev/null +++ b/scripts/check-python-mypy-authority.mjs @@ -0,0 +1,232 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs"; +import { relative, resolve } from "node:path"; +import { tmpdir } from "node:os"; +import { + createNodePythonProjectWorkspace, + createPythonValidationChecks, + PYTHON_TYPES_CHECK_ID +} from "../packages/validation-python/dist/index.js"; +import { validatePythonValidationCapabilityRun } from "../packages/contracts/dist/index.js"; +import { createValidationRunner } from "../packages/validation/dist/index.js"; + +const fixtureRoot = resolve("packages/fixtures/validation-python/mypy-authority"); +const executable = process.env.OPCORE_REAL_MYPY; +if (executable === undefined || executable.length === 0) { + throw new Error("OPCORE_REAL_MYPY must name the pinned mypy 2.3.0 executable"); +} +const versionProbe = spawnSync(executable, ["--version"], { encoding: "utf8" }); +if (versionProbe.status !== 0) throw new Error(versionProbe.stderr || "mypy --version failed"); +const version = /mypy\s+(?:version:\s*)?(\d+(?:\.\d+)+)/iu.exec(`${versionProbe.stdout}\n${versionProbe.stderr}`)?.[1]; +assert.equal(version, "2.3.0", "authoritative proof must use mypy 2.3.0"); + +const files = fixtureFiles(fixtureRoot); +const sourcePaths = Object.keys(files) + .filter((path) => /\.pyi?$/u.test(path) && !/(?:mypy_plugin|plugin_support)\.py$/u.test(path)) + .sort(); +const originalApp = files["src/acme/app.py"]; +const failingApp = originalApp.replace("-> str:\n return render(value)", "-> int:\n return render(value)"); +const tempBefore = mypyTempWorkspaces(); + +const clean = await validate(files, []); +assertPortableResult(clean); +assert.equal(clean.status, "passed", JSON.stringify(clean, null, 2)); +assert.equal(clean.pythonCapabilityRuns.length, 1); +assert.equal(clean.pythonCapabilityRuns[0].status, "passed"); +assert.equal(clean.pythonCapabilityRuns[0].tool.version, "2.3.0"); +assert.equal(clean.pythonCapabilityRuns[0].tool.configFile, "pyproject.toml"); +assert.equal(clean.pythonCapabilityRuns[0].selectedSourcePaths.includes("src/acme/mypy_plugin.py"), true); +assert.equal(clean.pythonCapabilityRuns[0].selectedSourcePaths.includes("src/acme/plugin_support.py"), true); + +const overlay = await validate(files, [{ path: "src/acme/app.py", action: "write", content: failingApp }]); +assertPortableResult(overlay); +assert.equal(overlay.status, "policy_failure", JSON.stringify(overlay, null, 2)); +assert.equal(overlay.pythonCapabilityRuns[0].status, "findings"); +assert.equal(overlay.diagnostics.some((entry) => entry.code === "MYPY_RETURN_VALUE"), true); + +const materialized = await validate({ ...files, "src/acme/app.py": failingApp }, []); +assertPortableResult(materialized); +assert.equal(materialized.status, "policy_failure", JSON.stringify(materialized, null, 2)); +assert.equal( + overlay.pythonCapabilityRuns[0].afterStateManifestFingerprint, + materialized.pythonCapabilityRuns[0].afterStateManifestFingerprint +); +assert.deepEqual( + overlay.diagnostics.map(normalizedDiagnostic), + materialized.diagnostics.map(normalizedDiagnostic) +); +const invalidConfig = await validate({ + ...files, + "pyproject.toml": files["pyproject.toml"].replace("strict = true", "strict = \"garbage\"") +}, []); +assertPortableResult(invalidConfig); +assert.equal(invalidConfig.status, "unsupported_request", JSON.stringify(invalidConfig, null, 2)); +assert.equal(invalidConfig.pythonCapabilityRuns[0].status, "invalid_config"); +assert.equal(invalidConfig.pythonCapabilityRuns[0].execution.termination, "exited"); +assert.equal(invalidConfig.pythonCapabilityRuns[0].execution.exitCode, 0); +assert.equal(invalidConfig.diagnostics[0].code, "PYTHON_TYPES_INVALID_CONFIG"); + +const attackRoot = mkdtempSync(resolve(tmpdir(), "opcore-mypy-authority-isolation-")); +let outputPathStatus; +let hostPluginStatus; +try { + const outputVictim = resolve(attackRoot, "output-victim.py"); + const hostPlugin = resolve(attackRoot, "host-plugin.py"); + const pluginMarker = resolve(attackRoot, "host-plugin-executed"); + writeFileSync(outputVictim, "SOURCE_UNCHANGED\n", "utf8"); + writeFileSync(hostPlugin, [ + "from pathlib import Path", + `Path(${JSON.stringify(pluginMarker)}).write_text('executed', encoding='utf8')`, + "" + ].join("\n"), "utf8"); + + const outputPath = await validate({ + ...files, + "pyproject.toml": `${files["pyproject.toml"]}\njunit_xml = ${JSON.stringify(outputVictim)}\n` + }, []); + assertPortableResult(outputPath); + assert.equal(outputPath.status, "unsupported_request", JSON.stringify(outputPath, null, 2)); + assert.equal(outputPath.pythonCapabilityRuns[0].status, "invalid_config"); + assert.equal(outputPath.pythonCapabilityRuns[0].execution, undefined); + assert.equal(readFileSync(outputVictim, "utf8"), "SOURCE_UNCHANGED\n"); + outputPathStatus = outputPath.pythonCapabilityRuns[0].status; + + const hostPluginConfig = files["pyproject.toml"].replace( + 'plugins = ["acme.mypy_plugin"]', + `plugins = [${JSON.stringify(hostPlugin)}]` + ); + const hostPluginResult = await validate({ ...files, "pyproject.toml": hostPluginConfig }, []); + assertPortableResult(hostPluginResult); + assert.equal(hostPluginResult.status, "unsupported_request", JSON.stringify(hostPluginResult, null, 2)); + assert.equal(hostPluginResult.pythonCapabilityRuns[0].status, "invalid_config"); + assert.equal(hostPluginResult.pythonCapabilityRuns[0].execution, undefined); + assert.equal(existsSync(pluginMarker), false); + hostPluginStatus = hostPluginResult.pythonCapabilityRuns[0].status; +} finally { + rmSync(attackRoot, { recursive: true, force: true }); +} +assert.equal(readFileSync(resolve(fixtureRoot, "src/acme/app.py"), "utf8"), originalApp); +assert.equal(existsSync(resolve(fixtureRoot, ".mypy_cache")), false); +assert.equal(existsSync(resolve(fixtureRoot, "__pycache__")), false); +assert.deepEqual(mypyTempWorkspaces(), tempBefore); + +const proof = { + schemaId: "opcore.python.mypy-authority-proof", + schemaVersion: 1, + mypyVersion: version, + executable: clean.pythonCapabilityRuns[0].tool.executable, + clean: receiptSummary(clean), + overlay: receiptSummary(overlay), + materialized: receiptSummary(materialized), + invalidConfig: receiptSummary(invalidConfig), + outputPathStatus, + hostPluginStatus, + sourceUnchanged: true, + persistentCacheAbsent: true, + temporaryWorkspacesCleaned: true +}; +const serializedProof = JSON.stringify(proof); +assert.equal(serializedProof.includes(fixtureRoot), false, "proof receipt must not contain the checkout root"); +assert.equal(serializedProof.includes("opcore-python-types-workspace-"), false, "proof receipt must not contain materialization roots"); +process.stdout.write(`${JSON.stringify(proof, null, 2)}\n`); + +async function validate(fileMap, overlays) { + const workspace = validationWorkspace(fileMap); + const checks = createPythonValidationChecks({ + repoRoot: fixtureRoot, + checker: "mypy", + toolArgv: { mypy: [executable] }, + nodeWorkspace: createNodePythonProjectWorkspace(fixtureRoot), + importAnalyzer: { + analyze: async () => [{ + fromPath: "src/acme/mypy_plugin.py", + toPath: "src/acme/plugin_support.py" + }] + }, + env: process.env + }); + return createValidationRunner({ workspace, checks }).runValidation({ + requestId: "python-mypy-authority-proof", + repo: { repoRoot: fixtureRoot }, + scope: { kind: "files", files: sourcePaths }, + graph: { mode: "optional", provider: "opcore-graph" }, + overlays, + checks: [PYTHON_TYPES_CHECK_ID] + }); +} + +function validationWorkspace(fileMap) { + return { + readFile: (path) => Object.hasOwn(fileMap, path) + ? { status: "found", content: fileMap[path] } + : { status: "missing" }, + listFiles: () => ({ files: Object.keys(fileMap).sort() }), + listChangedFiles: () => ({ files: Object.keys(fileMap).sort() }), + listStagedFiles: () => ({ files: Object.keys(fileMap).sort() }), + listRepoFiles: () => ({ files: Object.keys(fileMap).sort() }), + listPackageFiles: () => ({ files: Object.keys(fileMap).sort() }) + }; +} + +function fixtureFiles(root) { + const files = {}; + for (const absolute of walk(root)) { + const fixturePath = relative(root, absolute).replaceAll("\\", "/"); + const path = fixturePath.endsWith(".fixture") + ? fixturePath.slice(0, -".fixture".length) + : fixturePath; + files[path] = readFileSync(absolute, "utf8"); + } + return files; +} + +function walk(directory) { + return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { + const path = resolve(directory, entry.name); + return entry.isDirectory() ? walk(path) : [path]; + }); +} + +function mypyTempWorkspaces() { + return readdirSync(tmpdir()).filter((name) => name.startsWith("opcore-python-types-workspace-")).sort(); +} + +function normalizedDiagnostic(entry) { + return { + category: entry.category, + severity: entry.severity, + code: entry.code, + message: entry.message, + path: entry.path, + line: entry.line, + column: entry.column + }; +} + +function receiptSummary(result) { + const run = result.pythonCapabilityRuns[0]; + return { + status: run.status, + authority: run.authority, + authoritySource: run.authoritySource, + projectKey: run.projectKey, + contextFingerprint: run.contextFingerprint, + afterStateManifestFingerprint: run.afterStateManifestFingerprint, + argv: run.tool.argv, + cwd: run.tool.cwd, + toolSource: run.tool.source, + toolVersion: run.tool.version, + configFile: run.tool.configFile, + execution: run.execution, + diagnosticCount: run.diagnosticCount + }; +} + +function assertPortableResult(result) { + assert.equal(result.pythonCapabilityRuns.length, 1, JSON.stringify(result, null, 2)); + const run = validatePythonValidationCapabilityRun(result.pythonCapabilityRuns[0]); + const serialized = JSON.stringify(run); + assert.equal(serialized.includes(fixtureRoot), false, "capability run must not contain the checkout root"); + assert.equal(serialized.includes("opcore-python-types-workspace-"), false, "capability run must not contain materialization roots"); +} diff --git a/scripts/check-python-pyright-authority.mjs b/scripts/check-python-pyright-authority.mjs new file mode 100644 index 0000000..6cf70fa --- /dev/null +++ b/scripts/check-python-pyright-authority.mjs @@ -0,0 +1,283 @@ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { spawnSync } from "node:child_process"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs"; +import { dirname, join, relative, resolve } from "node:path"; +import { tmpdir } from "node:os"; +import { + createNodePythonProjectWorkspace, + createPythonValidationChecks, + PYTHON_TYPES_CHECK_ID +} from "../packages/validation-python/dist/index.js"; +import { validatePythonValidationCapabilityRun } from "../packages/contracts/dist/index.js"; +import { createValidationRunner } from "../packages/validation/dist/index.js"; +import { createStagedOpcorePackage } from "./stage-opcore-bundle.mjs"; + +const fixtureRoot = resolve("packages/fixtures/validation-python/pyright-authority"); +const executable = process.env.OPCORE_REAL_PYRIGHT; +if (executable === undefined || executable.length === 0) { + throw new Error("OPCORE_REAL_PYRIGHT must name the pinned Pyright 1.1.411 executable"); +} +const versionProbe = run(executable, ["--version"]); +const version = /pyright\s+(\d+(?:\.\d+)+)/iu.exec(`${versionProbe.stdout}\n${versionProbe.stderr}`)?.[1]; +assert.equal(version, "1.1.411", "authoritative proof must use Pyright 1.1.411"); + +const pythonProbe = run("python3", ["-c", "import json,sys; print(json.dumps({'version':f'{sys.version_info.major}.{sys.version_info.minor}','platform':sys.platform}))"]); +const python = JSON.parse(pythonProbe.stdout.trim()); +const pyrightPlatform = python.platform === "win32" ? "Windows" : python.platform === "darwin" ? "Darwin" : "Linux"; +const files = fixtureFiles(fixtureRoot); +files["pyrightconfig.json"] = files["pyrightconfig.json"] + .replace("PYTHON_VERSION", python.version) + .replace("PYTHON_PLATFORM", pyrightPlatform); +const sourcePaths = Object.keys(files).filter((path) => /\.pyi?$/u.test(path)).sort(); +const originalApp = files["src/acme/app.py"]; +const failingApp = originalApp.replace("def render_value(value: int) -> str:", "def render_value(value: int) -> int:"); +const fixtureHashBefore = treeHash(fixtureRoot); +const tempBefore = pyrightTempWorkspaces(); + +const clean = await validate(files, []); +assertPortableResult(clean); +assert.equal(clean.status, "passed", JSON.stringify(clean, null, 2)); +assert.equal(clean.pythonCapabilityRuns[0].status, "passed"); +assert.equal(clean.pythonCapabilityRuns[0].tool.version, "1.1.411"); +assert.equal(clean.pythonCapabilityRuns[0].tool.configFile, "pyrightconfig.json"); +assert.deepEqual(clean.pythonCapabilityRuns[0].selectedConfigPaths, [ + "configs/base.json", "configs/strict.json", "pyrightconfig.json" +]); +assert.equal(clean.pythonCapabilityRuns[0].selectedSourcePaths.includes("stubs/external/__init__.pyi"), true); +for (const path of ["shared/helper.py", "src/namespace_pkg/tool.py", "src/excluded/broken.py", "src/ignored/broken.py"]) { + assert.equal(clean.pythonCapabilityRuns[0].selectedSourcePaths.includes(path), true, `${path} must be in the exact after-state manifest`); +} +assert.equal(clean.diagnostics.some((entry) => entry.path === "src/excluded/broken.py"), false, "Pyright exclude configuration was not honored"); +assert.equal(clean.diagnostics.some((entry) => entry.path === "src/ignored/broken.py"), false, "Pyright ignore configuration was not honored"); + +const overlay = await validate(files, [{ path: "src/acme/app.py", action: "write", content: failingApp }]); +assertPortableResult(overlay); +assert.equal(overlay.status, "policy_failure", JSON.stringify(overlay, null, 2)); +assert.equal(overlay.pythonCapabilityRuns[0].status, "findings"); +assert.equal(overlay.diagnostics.some((entry) => entry.code === "PYRIGHT_REPORT_RETURN_TYPE"), true); + +const materialized = await validate({ ...files, "src/acme/app.py": failingApp }, []); +assertPortableResult(materialized); +assert.equal(materialized.status, "policy_failure", JSON.stringify(materialized, null, 2)); +assert.equal( + overlay.pythonCapabilityRuns[0].afterStateManifestFingerprint, + materialized.pythonCapabilityRuns[0].afterStateManifestFingerprint +); +assert.deepEqual(overlay.diagnostics.map(normalizedDiagnostic), materialized.diagnostics.map(normalizedDiagnostic)); + +const configExit3 = proveRealConfigExit3(); +const packedInstall = provePackedInstall(files); +assert.equal(treeHash(fixtureRoot), fixtureHashBefore, "Pyright proof changed fixture inputs"); +assert.equal(readFileSync(fixtureDiskPath("src/acme/app.py"), "utf8"), originalApp); +assert.equal(existsSync(resolve(fixtureRoot, ".pyright")), false); +assert.equal(existsSync(resolve(fixtureRoot, "__pycache__")), false); +assert.deepEqual(pyrightTempWorkspaces(), tempBefore); + +const proof = { + schemaId: "opcore.python.pyright-authority-proof", + schemaVersion: 1, + pyrightVersion: version, + executable: clean.pythonCapabilityRuns[0].tool.executable, + clean: receiptSummary(clean), + overlay: receiptSummary(overlay), + materialized: receiptSummary(materialized), + configExit3, + packedInstall, + sourceUnchanged: true, + persistentCacheAbsent: true, + temporaryWorkspacesCleaned: true +}; +const serializedProof = JSON.stringify(proof); +assert.equal(serializedProof.includes(fixtureRoot), false, "proof receipt must not contain the checkout root"); +assert.equal(serializedProof.includes("opcore-python-types-workspace-"), false, "proof receipt must not contain materialization roots"); +process.stdout.write(`${JSON.stringify(proof, null, 2)}\n`); + +async function validate(fileMap, overlays) { + const checks = createPythonValidationChecks({ + repoRoot: fixtureRoot, + checker: "pyright", + toolArgv: { pyright: [executable] }, + nodeWorkspace: createNodePythonProjectWorkspace(fixtureRoot), + importAnalyzer: { analyze: async () => [] }, + env: process.env + }); + return createValidationRunner({ workspace: validationWorkspace(fileMap), checks }).runValidation({ + requestId: "python-pyright-authority-proof", + repo: { repoRoot: fixtureRoot }, + scope: { kind: "files", files: sourcePaths }, + graph: { mode: "optional", provider: "opcore-graph" }, + overlays, + checks: [PYTHON_TYPES_CHECK_ID] + }); +} + +function proveRealConfigExit3() { + const root = mkdtempSync(join(tmpdir(), "opcore-pyright-config-exit-")); + try { + writeFileSync(join(root, "pyrightconfig.json"), "{ malformed\n", "utf8"); + const result = spawnSync(executable, ["--outputjson", "--project", "pyrightconfig.json"], { + cwd: root, encoding: "utf8", env: isolatedProofEnvironment(root) + }); + assert.equal(result.status, 3, `expected Pyright config exit 3: ${result.stderr}`); + const output = JSON.parse(result.stdout); + assert.equal(output.version, "1.1.411"); + assert.deepEqual(output.generalDiagnostics, []); + return { status: "invalid_config", termination: "exited", exitCode: 3, version: output.version }; + } finally { + rmSync(root, { recursive: true, force: true }); + } +} + +function provePackedInstall(fileMap) { + const root = mkdtempSync(join(tmpdir(), "opcore-pyright-packed-proof-")); + const staged = createStagedOpcorePackage(root); + try { + const packed = run("npm", ["pack", "--json", "--pack-destination", root], { cwd: staged.packageDir }); + const tarball = join(root, JSON.parse(packed.stdout)[0].filename); + const project = join(root, "project"); + mkdirSync(project, { recursive: true }); + run("npm", ["init", "-y"], { cwd: project }); + run("npm", ["install", "--ignore-scripts", "--no-audit", "--no-fund", tarball], { cwd: project }); + writeFixture(project, fileMap); + const sourceHash = treeHash(project, (path) => !path.startsWith("node_modules/") && !path.startsWith(".opcore/")); + const cli = join(project, "node_modules/.bin/opcore"); + const result = spawnSync(cli, [ + "check", "files", "--files", "src/acme/app.py", "--checks", "python.types", "--json" + ], { + cwd: project, + encoding: "utf8", + env: { ...process.env, PATH: `${dirname(executable)}${process.platform === "win32" ? ";" : ":"}${process.env.PATH ?? ""}` }, + timeout: 60_000 + }); + assert.equal(result.status, 0, `packed Opcore Pyright execution failed\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`); + const payload = JSON.parse(result.stdout); + const runReceipt = payload.validationResult.pythonCapabilityRuns.find((entry) => entry.authority === "pyright"); + validatePythonValidationCapabilityRun(runReceipt); + assert.equal(runReceipt.status, "passed"); + assert.equal(runReceipt.tool.version, "1.1.411"); + assert.equal(treeHash(project, (path) => !path.startsWith("node_modules/") && !path.startsWith(".opcore/")), sourceHash); + return { status: runReceipt.status, authority: runReceipt.authority, toolVersion: runReceipt.tool.version }; + } finally { + staged.cleanup(); + rmSync(root, { recursive: true, force: true }); + } +} + +function validationWorkspace(fileMap) { + return { + readFile: (path) => Object.hasOwn(fileMap, path) ? { status: "found", content: fileMap[path] } : { status: "missing" }, + listFiles: () => ({ files: Object.keys(fileMap).sort() }), + listChangedFiles: () => ({ files: Object.keys(fileMap).sort() }), + listStagedFiles: () => ({ files: Object.keys(fileMap).sort() }), + listRepoFiles: () => ({ files: Object.keys(fileMap).sort() }), + listPackageFiles: () => ({ files: Object.keys(fileMap).sort() }) + }; +} + +function fixtureFiles(root) { + const files = {}; + for (const absolute of walk(root)) { + const fixturePath = relative(root, absolute).replaceAll("\\", "/"); + const path = fixturePath.endsWith(".fixture") ? fixturePath.slice(0, -".fixture".length) : fixturePath; + files[path] = readFileSync(absolute, "utf8"); + } + return files; +} + +function fixtureDiskPath(path) { + const direct = resolve(fixtureRoot, path); + if (existsSync(direct)) return direct; + const fixture = resolve(fixtureRoot, `${path}.fixture`); + if (existsSync(fixture)) return fixture; + throw new Error(`fixture path missing from proof repository: ${path}`); +} + +function writeFixture(root, files) { + for (const [path, content] of Object.entries(files)) { + const absolute = join(root, path); + mkdirSync(dirname(absolute), { recursive: true }); + writeFileSync(absolute, content, "utf8"); + } +} + +function walk(directory) { + return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { + const path = resolve(directory, entry.name); + return entry.isDirectory() ? walk(path) : [path]; + }); +} + +function treeHash(root, include = () => true) { + const hash = createHash("sha256"); + for (const absolute of walk(root)) { + const path = relative(root, absolute).replaceAll("\\", "/"); + if (!include(path)) continue; + hash.update(path).update("\0").update(readFileSync(absolute)).update("\0"); + } + return hash.digest("hex"); +} + +function pyrightTempWorkspaces() { + return readdirSync(tmpdir()).filter((name) => name.startsWith("opcore-python-types-workspace-")).sort(); +} + +function normalizedDiagnostic(entry) { + return { + category: entry.category, severity: entry.severity, code: entry.code, message: entry.message, + path: entry.path, line: entry.line, column: entry.column, endLine: entry.endLine, endColumn: entry.endColumn + }; +} + +function receiptSummary(result) { + const runReceipt = result.pythonCapabilityRuns[0]; + return { + status: runReceipt.status, + authority: runReceipt.authority, + authoritySource: runReceipt.authoritySource, + projectKey: runReceipt.projectKey, + contextFingerprint: runReceipt.contextFingerprint, + afterStateManifestFingerprint: runReceipt.afterStateManifestFingerprint, + selectedSourcePaths: runReceipt.selectedSourcePaths, + selectedConfigPaths: runReceipt.selectedConfigPaths, + argv: runReceipt.tool.argv, + cwd: runReceipt.tool.cwd, + toolSource: runReceipt.tool.source, + toolVersion: runReceipt.tool.version, + configFile: runReceipt.tool.configFile, + execution: runReceipt.execution, + diagnosticCount: runReceipt.diagnosticCount + }; +} + +function assertPortableResult(result) { + assert.equal(result.pythonCapabilityRuns.length, 1, JSON.stringify(result, null, 2)); + const runReceipt = validatePythonValidationCapabilityRun(result.pythonCapabilityRuns[0]); + const serialized = JSON.stringify(runReceipt); + assert.equal(serialized.includes(fixtureRoot), false, "capability run must not contain checkout root"); + assert.equal(serialized.includes("opcore-python-types-workspace-"), false, "capability run must not contain materialization roots"); +} + +function isolatedProofEnvironment(root) { + return { + PATH: `${dirname(executable)}${process.platform === "win32" ? ";" : ":"}${dirname(process.execPath)}`, + HOME: root, + XDG_CONFIG_HOME: root, + XDG_CACHE_HOME: root, + TMPDIR: root, + PYTHONPATH: "", + PYTHONNOUSERSITE: "1", + NODE_PATH: "", + NODE_OPTIONS: "", + LC_ALL: "C", + LANG: "C" + }; +} + +function run(command, args, options = {}) { + const result = spawnSync(command, args, { encoding: "utf8", timeout: 120_000, ...options }); + if (result.status !== 0) { + throw new Error(`${command} ${args.join(" ")} failed code=${result.status} signal=${result.signal}\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`); + } + return result; +} diff --git a/scripts/check-python-resolver-matrix.mjs b/scripts/check-python-resolver-matrix.mjs new file mode 100644 index 0000000..d900d3d --- /dev/null +++ b/scripts/check-python-resolver-matrix.mjs @@ -0,0 +1,333 @@ +#!/usr/bin/env node +import { execFileSync } from "node:child_process"; +import { chmod, mkdir, mkdtemp, readFile, readdir, rm, symlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { isDeepStrictEqual } from "node:util"; +import { + createNodePythonProjectWorkspace, + createPythonValidationChecks, + PYTHON_SYNTAX_CHECK_ID, + PYTHON_TYPES_CHECK_ID, + resolvePythonProjectContext +} from "../packages/validation-python/dist/index.js"; +import { + createNodeValidationWorkspace, + createValidationRunner +} from "../packages/validation/dist/index.js"; + +const fixtureRoot = new URL("../packages/fixtures/validation-python/project-context/", import.meta.url); +const matrix = JSON.parse(await readFile(new URL("resolver-matrix.json", fixtureRoot), "utf8")); +const files = await loadFixtureFiles(fixtureRoot); +const tempRoot = await mkdtemp(join(tmpdir(), "opcore-python-resolver-matrix-")); + +try { + await materialize(tempRoot, files); + const rows = []; + for (const specification of matrix.rows) { + const context = await resolveSpecification(specification); + const repeated = await resolveSpecification(specification); + const actual = summarize(context); + const fingerprintStable = context.projectKey === repeated.projectKey && + context.contextFingerprint === repeated.contextFingerprint; + const invariants = contextInvariants(context, fingerprintStable); + rows.push({ + id: specification.id, + execution: specification.execution, + target: specification.target, + expected: specification.expected, + actual, + matchesExpected: matchesExpected(specification.expected, actual), + invariants, + provenance: { + hostPlatform: process.platform, + hostArchitecture: process.arch, + interpreter: context.interpreter ?? null, + tools: context.tools, + projectKey: context.projectKey, + contextFingerprint: context.contextFingerprint + } + }); + } + const validation = await verifyNestedValidationExecution(); + const output = { + schemaVersion: 1, + contract: "opcore.python.project-context.v1", + fixture: "packages/fixtures/validation-python/project-context/resolver-matrix.json", + rows, + validation, + allMatched: rows.every((row) => row.matchesExpected && Object.values(row.invariants).every(Boolean)) && + validation.matchesExpected + }; + process.stdout.write(`${JSON.stringify(output, null, process.argv.includes("--json") ? 0 : 2)}\n`); + if (!output.allMatched) process.exitCode = 1; +} finally { + await rm(tempRoot, { recursive: true, force: true }); +} + +function resolveSpecification(specification) { + return specification.execution === "real" + ? resolveReal(specification) + : resolveSimulated(specification); +} + +async function resolveReal(specification) { + const missingTools = Object.fromEntries( + ["mypy", "pyright", "ruff", "pytest"].map((tool) => [tool, [`/opcore-missing-tools/${tool}`]]) + ); + return resolvePythonProjectContext({ + repoRoot: tempRoot, + target: specification.target, + workspace: createNodePythonProjectWorkspace(tempRoot), + interpreterArgv: [process.env.OPCORE_PYTHON ?? "python3"], + toolArgv: missingTools + }); +} + +async function resolveSimulated(specification) { + const windows = specification.platform === "win32"; + const repoRoot = windows ? "C:\\fixture" : "/fixture"; + const projectRoot = specification.expected.projectRoot; + const projectPath = projectRoot === "." ? "" : `${projectRoot}/`; + const environmentRoot = windows + ? `C:\\fixture\\${projectPath.replaceAll("/", "\\")}\.venv`.replace("\\\\.venv", "\\.venv") + : `/fixture/${projectPath}.venv`.replace("//", "/"); + const interpreter = windows + ? specification.windowsInterpreterLayout === "environment-root" + ? `${environmentRoot}\\python.exe` + : `${environmentRoot}\\Scripts\\python.exe` + : `${environmentRoot}/bin/python`; + const workspace = inMemoryWorkspace(files, (path) => { + if (path === interpreter) return true; + if (windows) return path.startsWith(`${environmentRoot}\\Scripts\\`) && path.endsWith(".exe"); + return path.startsWith(`${environmentRoot}/bin/`); + }); + return resolvePythonProjectContext({ + repoRoot, + target: specification.target, + workspace, + platform: windows ? "win32" : "linux", + architecture: windows ? "x64" : "x86_64", + processProbe: simulatedProbe({ + interpreter, + windows, + version: specification.interpreterVersion ?? "3.12.4" + }) + }); +} + +function simulatedProbe({ interpreter, windows, version }) { + return { + run(command, args, options) { + const script = args[args.indexOf("-c") + 1] ?? ""; + const buildProbe = script.includes("opcore.python.project-context.build.v1"); + const interpreterProbe = args.includes("-c") && !buildProbe; + const stdout = buildProbe + ? JSON.stringify({ protocol: "opcore.python.project-context.build.v1", available: true, version: "1.0.0" }) + : interpreterProbe + ? JSON.stringify({ + protocol: "opcore.python.project-context.interpreter.v1", + executable: interpreter, + version, + implementation: "CPython", + platform: windows ? "win32" : "linux", + architecture: windows ? "AMD64" : "x86_64", + abi: `cpython-${version.split(".").slice(0, 2).join("")}`, + soabi: windows ? "cp312-win_amd64" : "cpython-312-x86_64-linux-gnu" + }) + : `${command} 1.0.0`; + return { + command, + args, + cwd: options.cwd, + allowedExitCodes: [0], + exitCode: 0, + signal: null, + stdout, + stderr: "", + termination: "exited", + ok: true + }; + } + }; +} + +function summarize(context) { + return { + target: context.target, + repositoryRoot: context.repositoryRoot, + projectRoot: context.projectRoot, + projectBoundary: context.projectBoundary, + sourceRoots: context.sourceRoots, + layout: context.layout, + evidence: context.evidence, + targetRuntime: context.targetRuntime, + managers: context.managers, + ...(context.buildSystem === undefined ? {} : { buildSystem: context.buildSystem }), + interpreter: context.interpreter ?? null, + tools: context.tools, + projectKey: context.projectKey, + contextFingerprint: context.contextFingerprint, + outcome: context.outcome, + reasons: context.reasons.map((reason) => ({ + code: reason.code, + ...(reason.path === undefined ? {} : { path: reason.path }), + ...(reason.tool === undefined ? {} : { tool: reason.tool }) + })) + }; +} + +function contextInvariants(context, fingerprintStable) { + return { + projectKeySha256: /^sha256:[a-f0-9]{64}$/u.test(context.projectKey), + contextFingerprintSha256: /^sha256:[a-f0-9]{64}$/u.test(context.contextFingerprint), + fingerprintStable, + interpreterArgvExact: context.interpreter === undefined || context.interpreter.argv[0] === context.interpreter.executable, + interpreterCwdExact: context.interpreter === undefined || context.interpreter.cwd === projectCwd(context), + toolArgvExact: context.tools.every((tool) => tool.argv[0] === tool.executable), + toolCwdExact: context.tools.every((tool) => tool.cwd === projectCwd(context)) + }; +} + +function projectCwd(context) { + if (context.projectRoot === ".") return context.repositoryRoot; + return context.repositoryRoot.includes("\\") + ? `${context.repositoryRoot}\\${context.projectRoot.replaceAll("/", "\\")}` + : `${context.repositoryRoot}/${context.projectRoot}`; +} + +function matchesExpected(expected, actual) { + if (typeof expected === "string" && expected.startsWith("<") && expected.endsWith(">")) { + if (expected === "") return typeof actual === "string" && /^sha256:[a-f0-9]{64}$/u.test(actual); + if (expected === "") return typeof actual === "string" && (/^\//u.test(actual) || /^[A-Za-z]:[\\/]/u.test(actual)); + if (expected === "") return typeof actual === "string" && /^\d+(?:\.\d+)+/u.test(actual); + if (expected === "") return actual === process.platform; + if (expected === "") return actual === process.arch; + } + if (typeof expected === "string" && expected.includes("")) { + return actual === expected.replaceAll("", tempRoot); + } + if (Array.isArray(expected)) { + return Array.isArray(actual) && expected.length === actual.length && + expected.every((entry, index) => matchesExpected(entry, actual[index])); + } + if (expected && typeof expected === "object") { + return actual && typeof actual === "object" && !Array.isArray(actual) && + Object.entries(expected).every(([key, value]) => matchesExpected(value, actual[key])); + } + return isDeepStrictEqual(expected, actual); +} + +async function verifyNestedValidationExecution() { + if (process.platform === "win32") { + return { execution: "skipped", reason: "POSIX wrapper row is not applicable on Windows", matchesExpected: true }; + } + const environmentRoot = join(tempRoot, "services/api/.venv/bin"); + const interpreter = join(environmentRoot, "python"); + const mypy = join(environmentRoot, "mypy"); + const logPath = join(tempRoot, "python-execution.log"); + const hostPython = process.env.OPCORE_PYTHON ?? "python3"; + const hostPythonExecutable = execFileSync(hostPython, ["-I", "-B", "-c", "import sys; print(sys.executable)"], { + encoding: "utf8" + }).trim(); + const hostPythonVersion = execFileSync(hostPython, ["-I", "-B", "-c", "import platform; print(platform.python_version())"], { + encoding: "utf8" + }).trim(); + const targetPythonVersion = hostPythonVersion.split(".").slice(0, 2).join("."); + const pyprojectPath = join(tempRoot, "services/api/pyproject.toml"); + const pyproject = await readFile(pyprojectPath, "utf8"); + await writeFile( + pyprojectPath, + pyproject.replace(/requires-python\s*=\s*"[^"]+"/u, `requires-python = ">=${targetPythonVersion}"`), + "utf8" + ); + await writeFile( + join(tempRoot, "services/api/pyrightconfig.json"), + `${JSON.stringify({ pythonVersion: targetPythonVersion })}\n`, + "utf8" + ); + await mkdir(environmentRoot, { recursive: true }); + await symlink(hostPythonExecutable, interpreter); + await writeFile(mypy, toolWrapper({ logPath }), "utf8"); + await chmod(mypy, 0o755); + + const target = "services/api/src/acme/api.py"; + const result = await createValidationRunner({ + workspace: createNodeValidationWorkspace({ repoRoot: tempRoot }), + checks: createPythonValidationChecks({ + env: { ...process.env }, + nodeWorkspace: createNodePythonProjectWorkspace(tempRoot) + }) + }).runValidation({ + requestId: "python-resolver-matrix-nested-execution", + repo: { repoRoot: tempRoot }, + scope: { kind: "files", files: [target] }, + graph: { mode: "optional", provider: "opcore-graph" }, + checks: [PYTHON_SYNTAX_CHECK_ID, PYTHON_TYPES_CHECK_ID], + overlays: [] + }); + const log = await readFile(logPath, "utf8").catch((error) => { + if (error?.code === "ENOENT") return ""; + throw error; + }); + const lines = log.split(/\r?\n/u).filter((line) => line.trim().length > 0).map((line) => JSON.parse(line)); + const types = lines.find((line) => line.kind === "types"); + const expectedProjectCwd = join(tempRoot, "services/api"); + const context = result.pythonProjectContexts?.find((candidate) => candidate.target === target); + const syntaxRun = result.manifest?.runs?.find((run) => run.checkId === PYTHON_SYNTAX_CHECK_ID); + const typeRun = result.manifest?.runs?.find((run) => run.checkId === PYTHON_TYPES_CHECK_ID); + const matches = result.status === "passed" && + syntaxRun?.status === "passed" && typeRun?.status === "passed" && + context?.interpreter?.executable === hostPythonExecutable && context?.interpreter?.cwd === expectedProjectCwd && + types?.executable === mypy && types?.cwd.endsWith("/repo/services/api") && + types?.argv.includes("src/acme/api.py"); + return { + execution: "real", + hostPlatform: process.platform, + hostArchitecture: process.arch, + hostInterpreter: hostPython, + hostInterpreterVersion: hostPythonVersion, + target, + projectRoot: "services/api", + selectedInterpreter: context?.interpreter ?? null, + selectedTypeTool: mypy, + validationStatus: result.status, + checkRuns: result.manifest?.runs ?? [], + pythonProjectContexts: result.pythonProjectContexts ?? [], + processExecutions: lines, + matchesExpected: matches + }; +} + +function toolWrapper({ logPath }) { + return `#!/bin/sh\nif [ "$1" = "--version" ]; then printf '%s\\n' 'mypy 1.0.0'; exit 0; fi\n'${process.execPath}' -e 'const fs=require("fs"); fs.appendFileSync(process.argv[1], JSON.stringify({kind:"types",executable:process.argv[2],cwd:process.cwd(),argv:process.argv.slice(3)})+"\\n")' '${logPath}' "$0" "$@"\nexit 0\n`; +} + +function inMemoryWorkspace(contents, executableExists) { + const paths = [...contents.keys()].sort(); + return { + read: async (path) => contents.get(path), + list: async () => paths, + exists: async (path) => contents.has(path), + realpath: async (path) => ({ path, symlink: false }), + executableExists: async (path) => executableExists(path) + }; +} + +async function loadFixtureFiles(root) { + const contents = new Map(); + for (const entry of await readdir(root, { recursive: true })) { + const path = String(entry).replaceAll("\\", "/"); + if (!path.endsWith(".fixture")) continue; + contents.set(path.slice(0, -".fixture".length), await readFile(new URL(path, root), "utf8")); + } + return contents; +} + +async function materialize(root, contents) { + for (const [path, content] of contents) { + const absolute = join(root, path); + await mkdir(dirname(absolute), { recursive: true }); + await writeFile(absolute, content, "utf8"); + } +} diff --git a/scripts/check-workspace.mjs b/scripts/check-workspace.mjs index 6f98350..85bb915 100644 --- a/scripts/check-workspace.mjs +++ b/scripts/check-workspace.mjs @@ -18,7 +18,7 @@ const packageTracks = [ { dir: "asp-provider", name: "@the-open-engine/opcore-asp-provider", bin: "opcore-asp-provider" }, { dir: "fixtures", name: "@the-open-engine/opcore-fixtures" } ]; -const releaseVersion = "0.2.0"; +const releaseVersion = "0.2.1"; const packageNames = new Set(packageTracks.map((entry) => entry.name)); const publicPackageNames = new Set(["opcore"]); const rootNativeOptionalDependencies = new Map([ diff --git a/scripts/generate-cutover-receipt.mjs b/scripts/generate-cutover-receipt.mjs index 5e2724c..57f1388 100644 --- a/scripts/generate-cutover-receipt.mjs +++ b/scripts/generate-cutover-receipt.mjs @@ -490,8 +490,10 @@ function runPythonToolDegradationNegativeChecks(tempRoot, opcoreBin, env, comman throw new Error("python-types-degraded-no-tools did not return unsupported_request validation evidence"); } const diagnosticCodes = parsed.validationResult.diagnostics?.map((diagnostic) => diagnostic.code) ?? []; - if (!diagnosticCodes.includes("PYTHON_TYPES_UNSUPPORTED")) { - throw new Error("python-types-degraded-no-tools did not report PYTHON_TYPES_UNSUPPORTED"); + if (!diagnosticCodes.includes("PYTHON_TYPES_UNSUPPORTED_TARGET")) { + throw new Error( + `python-types-degraded-no-tools did not report PYTHON_TYPES_UNSUPPORTED_TARGET: ${diagnosticCodes.join(", ")}` + ); } const sourceHygieneResult = run(opcoreBin, ["check", "files", "src/acme/app.py", "--checks", "python.source-hygiene", "--json"], { cwd: degradedProject, @@ -949,8 +951,8 @@ function assertPythonCommandPayload(id, parsed) { throw new Error("opcore-python-scan must return repoState and validationResult"); } assertPythonRepoState(id, parsed.repoState); - if (!text.includes("PYTHON_TYPES_UNSUPPORTED")) { - throw new Error("opcore-python-scan must record degraded Python type-tool evidence"); + if (!text.includes("PYTHON_TYPES_UNSUPPORTED_TARGET")) { + throw new Error("opcore-python-scan must record missing Python type-authority evidence"); } } if (id === "opcore-python-status") { diff --git a/scripts/generate-graph-release-receipt.mjs b/scripts/generate-graph-release-receipt.mjs index 7a43d3d..773ddd5 100644 --- a/scripts/generate-graph-release-receipt.mjs +++ b/scripts/generate-graph-release-receipt.mjs @@ -568,7 +568,7 @@ function inspectGraphPackage() { } return { packageName: "@the-open-engine/opcore-graph", - tarballName: parsed[0]?.filename ?? "covibes-opcore-graph-0.2.0.tgz", + tarballName: parsed[0]?.filename ?? "covibes-opcore-graph-0.2.1.tgz", fileCount: files.length, files, forbiddenMarkersAbsent: true, diff --git a/scripts/license-report.mjs b/scripts/license-report.mjs index f0b86b0..bf202a7 100644 --- a/scripts/license-report.mjs +++ b/scripts/license-report.mjs @@ -40,7 +40,7 @@ for (const [lockPath, entry] of Object.entries(packages)) { if (!lockPath.startsWith("packages/")) continue; const bundled = entry.bundleDependencies ?? entry.bundledDependencies ?? []; for (const name of bundled) { - const runtimeEntry = bundledPackageMetadata(name); + const runtimeEntry = bundledPackageMetadata(name, lockPath); bundledRuntime.push({ name, version: runtimeEntry.version, @@ -142,8 +142,11 @@ function formatPackage(entry) { return `${entry.name}@${entry.version}`; } -function bundledPackageMetadata(name) { - const runtimeEntry = packages[`node_modules/${name}`]; +function bundledPackageMetadata(name, ownerLockPath) { + const rootRuntimePath = `node_modules/${name}`; + const ownerRuntimePath = `${ownerLockPath}/node_modules/${name}`; + const runtimePath = packages[rootRuntimePath] === undefined ? ownerRuntimePath : rootRuntimePath; + const runtimeEntry = packages[runtimePath]; if (!runtimeEntry) { return { version: "workspace-bundled", license: "UNKNOWN" }; } @@ -159,6 +162,6 @@ function bundledPackageMetadata(name) { return { version: runtimeEntry.version ?? "unknown", license: runtimeEntry.license ?? "UNKNOWN", - source: `node_modules/${name}` + source: runtimePath }; } diff --git a/scripts/release-dry-run.mjs b/scripts/release-dry-run.mjs index c3433a2..3c8d581 100644 --- a/scripts/release-dry-run.mjs +++ b/scripts/release-dry-run.mjs @@ -12,7 +12,7 @@ import { import { publicReleasePackageNames, releasePackageDirForName } from "./release-package-dirs.mjs"; import { createStagedOpcorePackage, npmPackResultForPackage } from "./stage-opcore-bundle.mjs"; -const releaseVersion = "0.2.0"; +const releaseVersion = "0.2.1"; const publicPackages = publicReleasePackageNames; const cloneProtocolMarker = Buffer.from("opcore.clone.v1", "utf8"); const target = currentGraphCoreNativeTarget(); diff --git a/scripts/release-package-dirs.mjs b/scripts/release-package-dirs.mjs index 31d1298..d44d1c0 100644 --- a/scripts/release-package-dirs.mjs +++ b/scripts/release-package-dirs.mjs @@ -67,15 +67,18 @@ export const bundledExternalRuntimePackageNames = Object.freeze([ "@typescript-eslint/visitor-keys", "balanced-match", "brace-expansion", + "concat-map", "code-block-writer", "debug", "eslint-visitor-keys", "fdir", + "jsonc-parser", "minimatch", "ms", "path-browserify", "picomatch", "semver", + "smol-toml", "tinyglobby", "ts-api-utils", "ts-morph", diff --git a/scripts/release-publish.mjs b/scripts/release-publish.mjs index 9f46c70..585fadc 100644 --- a/scripts/release-publish.mjs +++ b/scripts/release-publish.mjs @@ -6,7 +6,7 @@ import { tmpdir } from "node:os"; import { publicReleasePackageNames, releasePackageDirForName } from "./release-package-dirs.mjs"; import { createStagedOpcorePackage } from "./stage-opcore-bundle.mjs"; -const releaseVersion = valueAfter("--version") ?? "0.2.0"; +const releaseVersion = valueAfter("--version") ?? "0.2.1"; const tag = valueAfter("--tag") ?? "latest"; const dryRun = process.argv.includes("--dry-run") || process.env.OPCORE_PUBLISH_DRY_RUN === "1"; const npmEnv = npmPublishEnv(); diff --git a/scripts/run-test-ci.mjs b/scripts/run-test-ci.mjs index 1d36a63..582224c 100644 --- a/scripts/run-test-ci.mjs +++ b/scripts/run-test-ci.mjs @@ -4,14 +4,16 @@ import { readdirSync } from "node:fs"; 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")) .map((file) => join("tests", file)) .sort(); -const parallelSafeTests = testFiles.filter((file) => file !== nativePackagingTest); +const parallelSafeTests = testFiles.filter((file) => file !== nativePackagingTest && file !== pythonValidationTest); const env = { ...process.env, OPCORE_CI_RECEIPT_GATES_RUN_SEPARATELY: "1" }; runTests("parallel-safe tests", parallelSafeTests); +runTests("Python validation", [pythonValidationTest]); runTests("native packaging policy", [nativePackagingTest]); function runTests(label, files) { diff --git a/scripts/setup-current-tools.sh b/scripts/setup-current-tools.sh index 8ad90cb..d1fd4a9 100755 --- a/scripts/setup-current-tools.sh +++ b/scripts/setup-current-tools.sh @@ -271,7 +271,7 @@ const metadata = { generatedAt: new Date().toISOString(), generatedBy: { name: "lattice-current-tool-setup", - version: "0.2.0" + version: "0.2.1" }, tooling: { aceTools: { diff --git a/scripts/stage-opcore-bundle.mjs b/scripts/stage-opcore-bundle.mjs index 780cb25..cd29a01 100644 --- a/scripts/stage-opcore-bundle.mjs +++ b/scripts/stage-opcore-bundle.mjs @@ -1,6 +1,6 @@ #!/usr/bin/env node import { spawnSync } from "node:child_process"; -import { cpSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join, relative } from "node:path"; import { fileURLToPath } from "node:url"; @@ -24,11 +24,25 @@ export function stageOpcoreBundle(options = {}) { } for (const packageName of bundledExternalRuntimePackageNames) { - const packageDir = join(repoRoot, "node_modules", ...packageName.split("/")); + const packageDir = externalRuntimePackageDir(packageName); stagePackage(packageName, packageDir, opcoreNodeModulesDir, { disableLifecycleScripts: true }); } } +export function externalRuntimePackageDir(packageName) { + const suffix = ["node_modules", ...packageName.split("/")]; + const candidates = [ + join(repoRoot, ...suffix), + join(repoRoot, "packages", "opcore", ...suffix), + join(repoRoot, "packages", "validation-python", ...suffix) + ]; + const packageDir = candidates.find((candidate) => existsSync(join(candidate, "package.json"))); + if (packageDir === undefined) { + throw new Error(`Bundled external runtime package is not installed: ${packageName}`); + } + return packageDir; +} + export function clearOpcoreBundle(options = {}) { const targetOpcorePackageDir = options.opcorePackageDir ?? opcorePackageDir; rmSync(join(targetOpcorePackageDir, "node_modules"), { recursive: true, force: true }); diff --git a/scripts/write-asp-provider-manifest.mjs b/scripts/write-asp-provider-manifest.mjs index 027864d..8b3e7b9 100644 --- a/scripts/write-asp-provider-manifest.mjs +++ b/scripts/write-asp-provider-manifest.mjs @@ -1,11 +1,11 @@ #!/usr/bin/env node import { chmod, mkdir, unlink, writeFile } from "node:fs/promises"; -import { dirname, join } from "node:path"; +import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { createOpcoreAspProviderManifest, createOpcoreAspServerManifest } from "../packages/asp-provider/dist/manifest.js"; const repoRoot = fileURLToPath(new URL("..", import.meta.url)); -const packageRoot = join(repoRoot, "packages/asp-provider"); +const packageRoot = requestedPackageRoot(process.argv.slice(2)) ?? join(repoRoot, "packages/asp-provider"); const canonicalManifestPath = join(packageRoot, "dist/manifests/asp-server.json"); const provisionalManifestPath = join(packageRoot, "dist/manifests/opcore-asp-provider.provisional.json"); const legacyProviderManifestFile = ["lattice", "asp", "provider.provisional.json"].join("-"); @@ -26,3 +26,11 @@ await writeFile(provisionalManifestPath, `${JSON.stringify(provisionalManifest, await chmod(join(packageRoot, "dist/index.js"), 0o755); process.stdout.write(`wrote ${canonicalManifestPath}\n`); process.stdout.write(`wrote ${provisionalManifestPath}\n`); + +function requestedPackageRoot(args) { + const index = args.indexOf("--package-root"); + if (index < 0) return undefined; + const value = args[index + 1]; + if (typeof value !== "string" || value.length === 0) throw new Error("--package-root requires a path"); + return resolve(value); +} diff --git a/tests/asp-provider.test.mjs b/tests/asp-provider.test.mjs index 71cf094..a7d1dc1 100644 --- a/tests/asp-provider.test.mjs +++ b/tests/asp-provider.test.mjs @@ -2,10 +2,11 @@ import { describe, it } from "node:test"; import assert from "node:assert/strict"; import { createHash } from "node:crypto"; import { spawn, spawnSync } from "node:child_process"; -import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { chmodSync, cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; +import { createAspHostValidationWorkspace } from "../packages/asp-provider/dist/workspace.js"; const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); const providerBin = join(repoRoot, "packages/asp-provider/dist/index.js"); @@ -49,6 +50,59 @@ const allCheckIds = [ ]; describe("Opcore ASP provider", () => { + it("preserves host listTree truncation for exact-state validation", async () => { + const baseline = { rev: "tree:truncated", stampedAt: "2026-07-16T00:00:00.000Z" }; + const host = createAspHostValidationWorkspace( + { request: async () => ({ entries: [{ path: "src/app.ts", blobId: "blob:app", kind: "file" }], truncated: true }) }, + { workspace: { baseline } }, + { baseline, grantedPermissions: { read: ["**/*"], write: false, network: false } }, + { baseline, changes: [] } + ); + + const listing = await host.workspace.listFiles({ + scope: { kind: "files", files: ["src/app.ts"], workspaceFiles: [{ path: "src/app.ts" }] }, + state: "after" + }); + assert.equal(listing.truncated, true); + assert.match(listing.message, /truncated/); + + const packageListing = await host.workspace.listPackageFiles("app", "src"); + assert.equal(packageListing.truncated, true); + assert.match(packageListing.message, /truncated/); + }); + + it("requires positive host file-kind evidence before treating realpaths as safe", async () => { + const baseline = { rev: "tree:realpath-evidence", stampedAt: "2026-07-15T00:00:00.000Z" }; + const workspaceFor = (entries) => createAspHostValidationWorkspace( + { + request: async (method) => { + assert.equal(method, "workspace/listTree"); + return { entries, truncated: false }; + } + }, + { workspace: { baseline } }, + { baseline, grantedPermissions: { read: ["**/*"], write: false, network: false } }, + { baseline, changes: [] } + ).pythonWorkspace; + + assert.deepEqual( + await workspaceFor([]).realpath("app.py"), + { path: "app.py", symlink: false, unavailable: true } + ); + assert.deepEqual( + await workspaceFor([{ path: "app.py", blobId: "blob:missing-kind" }]).realpath("app.py"), + { path: "app.py", symlink: false, unavailable: true } + ); + assert.deepEqual( + await workspaceFor([{ path: "app.py", blobId: "blob:file", kind: "file" }]).realpath("app.py"), + { path: "app.py", symlink: false } + ); + assert.deepEqual( + await workspaceFor([{ path: "app.py", blobId: "blob:symlink", kind: "symlink" }]).realpath("app.py"), + { path: "app.py", symlink: true } + ); + }); + it("handles initialize/initialized/evaluate over stdio without mutating host files", { timeout: 60000 }, async () => { assert.equal(existsSync(providerBin), true, "run npm run build before asp-provider tests"); const repo = mkdtempSync(join(tmpdir(), "opcore-asp-provider-repo-")); @@ -76,7 +130,7 @@ describe("Opcore ASP provider", () => { const init = await peer.request("initialize", { protocolVersion: "asp/0.1", - host: { name: "fake-host", version: "0.2.0-test" }, + host: { name: "fake-host", version: "0.2.1-test" }, hostCapabilities: { readBlob: true, listTree: true, putBlob: false }, workspace: { root: repo, baseline: host.baseline }, assuranceMode: "gated" @@ -124,7 +178,7 @@ describe("Opcore ASP provider", () => { assert.ok(assessment.diagnostics.every((diagnostic) => diagnostic.code.startsWith(`${diagnostic.source}/`))); assert.ok(assessment.diagnostics.every((diagnostic) => diagnostic.fingerprint.startsWith("sha256:"))); assert.ok(assessment.coverage.degraded.some((entry) => entry.reason === "unsupported" && /comparison/.test(entry.requirement))); - assert.ok(assessment.coverage.degraded.some((entry) => entry.reason === "unavailable" && entry.requirement === "typescript.import-graph")); + assert.ok(!assessment.coverage.degraded.some((entry) => entry.reason === "unavailable" && entry.requirement === "typescript.import-graph")); assert.equal(assessment.coverage.exhaustive, false); assertCommandTiming(assessment.timing, { expectedPhases: [ @@ -160,7 +214,7 @@ describe("Opcore ASP provider", () => { try { await peer.request("initialize", { protocolVersion: "asp/0.1", - host: { name: "fake-host", version: "0.2.0-test" }, + host: { name: "fake-host", version: "0.2.1-test" }, hostCapabilities: { readBlob: true, listTree: true, putBlob: false }, workspace: { root: repo, baseline: host.baseline }, assuranceMode: "gated" @@ -199,6 +253,47 @@ describe("Opcore ASP provider", () => { } }); + it("reports Python compiler unavailability without executing the provider process PATH", { timeout: 60000 }, async () => { + const repo = mkdtempSync(join(tmpdir(), "opcore-asp-provider-python-range-")); + try { + mkdirSync(join(repo, "pkg")); + writeFileSync(join(repo, "pkg/app.py"), "value = 1\n"); + const host = createHostWorkspace({ "pkg/app.py": "value = 1\n" }); + const peer = spawnProvider(host); + try { + await peer.request("initialize", { + protocolVersion: "asp/0.1", + host: { name: "fake-host", version: "0.2.1-test" }, + hostCapabilities: { readBlob: true, listTree: true, putBlob: false }, + workspace: { root: repo, baseline: host.baseline }, + assuranceMode: "gated" + }); + peer.notify("initialized", { + grantedPermissions: { read: ["**/*"], write: false, network: false }, + baseline: host.baseline + }); + const assessment = await peer.request("check/evaluate", { + callSite: "interactive", + changeset: host.changeset([host.modify("pkg/app.py", "if True\n value = 1\n")]), + comparison: "all", + checks: ["python.syntax"] + }); + const unavailable = assessment.diagnostics.find((entry) => entry.code.includes("/PY_SYNTAX_TOOL_UNAVAILABLE")); + assert.equal(unavailable?.code, "opcore/opcore.infrastructure/PY_SYNTAX_TOOL_UNAVAILABLE"); + assert.deepEqual(unavailable?.location, { path: "pkg/app.py" }); + assert.equal( + assessment.coverage.degraded.some((entry) => entry.requirement === "python.syntax"), + true + ); + assert.equal(readFileSync(join(repo, "pkg/app.py"), "utf8"), "value = 1\n"); + } finally { + peer.close(); + } + } finally { + rmSync(repo, { recursive: true, force: true }); + } + }); + it("honors repo validation policy thresholds during check evaluation", { timeout: 60000 }, async () => { assert.equal(existsSync(providerBin), true, "run npm run build before asp-provider tests"); const repo = mkdtempSync(join(tmpdir(), "opcore-asp-provider-policy-")); @@ -225,6 +320,7 @@ describe("Opcore ASP provider", () => { writeFileSync(join(repo, "src/length.ts"), "export const ok = 1;\n"); const host = createHostWorkspace({ + ".opcore/config": readFileSync(join(repo, ".opcore/config"), "utf8"), "tsconfig.json": "{}\n", "src/length.ts": "export const ok = 1;\n" }); @@ -232,7 +328,7 @@ describe("Opcore ASP provider", () => { try { await peer.request("initialize", { protocolVersion: "asp/0.1", - host: { name: "fake-host", version: "0.2.0-test" }, + host: { name: "fake-host", version: "0.2.1-test" }, hostCapabilities: { readBlob: true, listTree: true, putBlob: false }, workspace: { root: repo, baseline: host.baseline }, assuranceMode: "gated" @@ -266,6 +362,195 @@ describe("Opcore ASP provider", () => { } }); + it("resolves nested Python context exclusively from host after-state callbacks", { timeout: 60000 }, async () => { + assert.equal(existsSync(providerBin), true, "run npm run build before asp-provider tests"); + const repo = mkdtempSync(join(tmpdir(), "opcore-asp-provider-python-context-")); + try { + mkdirSync(join(repo, "services", "api", "src"), { recursive: true }); + const providerLocalPythonMarker = join(repo, "provider-local-python-ran"); + const providerLocalPython = join(repo, "services", "api", ".venv", "bin", "python"); + mkdirSync(dirname(providerLocalPython), { recursive: true }); + writeFileSync(providerLocalPython, `#!/bin/sh\n: > ${JSON.stringify(providerLocalPythonMarker)}\nexit 1\n`); + chmodSync(providerLocalPython, 0o755); + writeFileSync( + join(repo, "services", "api", "pyproject.toml"), + "[project]\nname='disk-must-not-win'\nrequires-python='>=99'\n" + ); + writeFileSync(join(repo, "services", "api", "src", "app.py"), "DISK = 'must-not-win'\n"); + const host = createHostWorkspace({ + "pyproject.toml": "[project]\nname='root'\nrequires-python='>=3.8'\n", + "root.py": "ROOT = 1\n", + "services/api/pyproject.toml": "[project]\nname='api'\nrequires-python='>=3.8'\n[tool.uv]\npackage=true\n", + "services/api/uv.lock": "version=1\n", + "services/api/src/app.py": "VALUE = 1\n" + }); + const peer = spawnProvider(host); + try { + await peer.request("initialize", { + protocolVersion: "asp/0.1", + host: { name: "fake-host", version: "0.1.0-test" }, + hostCapabilities: { readBlob: true, listTree: true, putBlob: false }, + workspace: { root: repo, baseline: host.baseline }, + assuranceMode: "gated" + }); + peer.notify("initialized", { + grantedPermissions: { read: ["**/*"], write: false, network: false }, + baseline: host.baseline + }); + const assessment = await peer.request("check/evaluate", { + callSite: "interactive", + changeset: host.changeset([ + host.modify("services/api/src/app.py", "VALUE = 2 # type: ignore\n") + ]), + comparison: "all", + checks: ["python.source-hygiene"] + }); + const contextEvidence = assessment.evidence.find((entry) => entry.kind === "python_project_context"); + assert.equal(contextEvidence.data.schemaId, "opcore.python.project-context.v1"); + assert.equal(contextEvidence.data.target, "services/api/src/app.py"); + assert.equal(contextEvidence.data.projectRoot, "services/api"); + assert.match(contextEvidence.data.projectKey, /^sha256:[a-f0-9]{64}$/); + assert.match(contextEvidence.data.contextFingerprint, /^sha256:[a-f0-9]{64}$/); + assert.equal(contextEvidence.data.outcome, "unsupported"); + assert.equal( + contextEvidence.data.reasons.some((reason) => reason.code === "tool_unavailable"), + true, + JSON.stringify(contextEvidence.data.reasons) + ); + assert.equal(JSON.stringify(contextEvidence).includes("disk-must-not-win"), false); + const typesAssessment = await peer.request("check/evaluate", { + callSite: "interactive", + changeset: host.changeset([ + host.modify("services/api/src/app.py", "VALUE = 2\n") + ]), + comparison: "all", + checks: ["python.types"] + }); + const capabilityEvidence = typesAssessment.evidence.find( + (entry) => entry.kind === "python_validation_capability_run" + ); + assert.equal(capabilityEvidence.data.schemaId, "opcore.python.validation-capability-run"); + assert.equal(Object.hasOwn(capabilityEvidence.data, "checker"), false); + assert.equal(Object.hasOwn(capabilityEvidence.data, "checkerSource"), false); + assert.equal(capabilityEvidence.data.status, "unsupported_target"); + assert.match(capabilityEvidence.data.afterStateManifestFingerprint, /^sha256:[a-f0-9]{64}$/); + assertNoForbiddenKeys(typesAssessment); + assert.equal(JSON.stringify(capabilityEvidence).includes("VALUE = 2"), false); + assert.equal(existsSync(providerLocalPythonMarker), false, "ASP provider must not execute package-local host paths"); + } finally { + peer.close(); + } + } finally { + rmSync(repo, { recursive: true, force: true }); + } + }); + + it("preserves selected Pyright capability receipts without ASP-side tool fallback", { timeout: 60000 }, async () => { + const repo = mkdtempSync(join(tmpdir(), "opcore-asp-provider-pyright-")); + try { + const bin = join(repo, "services/api/.venv/bin"); + mkdirSync(bin, { recursive: true }); + const pythonPath = join(bin, "python"); + const pythonScript = [ + "#!/bin/sh", + "case \"$4\" in", + " *opcore.python.project-context.interpreter.v1*)", + ` printf '%s\\n' '${JSON.stringify({ + protocol: "opcore.python.project-context.interpreter.v1", + executable: pythonPath, + version: "3.12.13", + implementation: "CPython", + platform: "darwin", + architecture: "arm64", + abi: "cpython-312", + soabi: "cpython-312-darwin" + })}'`, + " ;;", + " *) exit 9 ;;", + "esac", + "" + ].join("\n"); + const pyrightPath = join(bin, "pyright"); + const pyrightPayload = { + version: "1.1.411", + time: "0", + generalDiagnostics: [{ + file: "src/app.py", + severity: "warning", + message: "Unused import", + rule: "reportUnusedImport", + range: { start: { line: 0, character: 0 }, end: { line: 0, character: 1 } } + }], + summary: { filesAnalyzed: 1, errorCount: 0, warningCount: 1, informationCount: 0, timeInSec: 0 } + }; + const pyrightScript = [ + "#!/bin/sh", + "if [ \"$1\" = \"--version\" ]; then echo 'pyright 1.1.411'; exit 0; fi", + `printf '%s\\n' '${JSON.stringify(pyrightPayload)}'`, + "exit 0", + "" + ].join("\n"); + writeFileSync(pythonPath, pythonScript); + writeFileSync(pyrightPath, pyrightScript); + chmodSync(pythonPath, 0o755); + chmodSync(pyrightPath, 0o755); + + const host = createHostWorkspace({ + "services/api/pyproject.toml": "[project]\nname='api'\nrequires-python='>=3.8'\n", + "services/api/pyrightconfig.json": JSON.stringify({ + include: ["src/**/*.py"], + exclude: ["**/__pycache__"], + ignore: ["generated/**"], + extraPaths: [".", "src"], + stubPath: "stubs", + executionEnvironments: [{ root: "src", extraPaths: [".", "stubs"] }] + }) + "\n", + "services/api/src/app.py": "import unused\n", + "services/api/stubs/external/__init__.pyi": "VALUE: int\n", + "services/api/.venv/bin/python": pythonScript, + "services/api/.venv/bin/pyright": pyrightScript + }); + const peer = spawnProvider(host); + try { + await peer.request("initialize", { + protocolVersion: "asp/0.1", + host: { name: "fake-host", version: "0.1.0-test" }, + hostCapabilities: { readBlob: true, listTree: true, putBlob: false }, + workspace: { root: repo, baseline: host.baseline }, + assuranceMode: "gated" + }); + peer.notify("initialized", { + grantedPermissions: { read: ["**/*"], write: false, network: false }, + baseline: host.baseline + }); + const assessment = await peer.request("check/evaluate", { + callSite: "interactive", + changeset: host.changeset([host.modify("services/api/src/app.py", "import unused\nVALUE = 1\n")]), + comparison: "all", + checks: ["python.types"] + }, 30000); + const evidence = assessment.evidence.find((entry) => entry.kind === "python_validation_capability_run"); + assert.equal(evidence.data.checker, "pyright", JSON.stringify(assessment, null, 2)); + assert.equal(evidence.data.status, "unsupported_target", JSON.stringify(assessment, null, 2)); + assert.equal(assessment.diagnostics.some((entry) => /configured path realpath|invalid config/i.test(entry.message)), false); + assert.equal(evidence.data.tool.name, "pyright"); + assert.equal(evidence.data.tool.configFile, "services/api/pyrightconfig.json"); + assert.equal(Object.hasOwn(evidence.data, "execution"), false); + assert.deepEqual({ + diagnostics: evidence.data.diagnosticCount, + errors: evidence.data.errorCount, + warnings: evidence.data.warningCount, + notes: evidence.data.noteCount + }, { diagnostics: 1, errors: 0, warnings: 0, notes: 1 }); + assertNoForbiddenKeys(assessment); + } finally { + peer.close(); + } + } finally { + rmSync(repo, { recursive: true, force: true }); + } + }); + it("ships a provisional install manifest without authority semantics", () => { const manifestPath = join(repoRoot, "packages/asp-provider/dist/manifests/opcore-asp-provider.provisional.json"); assert.equal(existsSync(manifestPath), true, "run npm run build before asp-provider tests"); @@ -316,7 +601,7 @@ describe("Opcore ASP provider", () => { assert.equal(manifest.$schema, "https://covibes.dev/asp/schemas/server-manifest.schema.json"); assert.equal(manifest.manifestVersion, "asp-server/0.1"); - assert.deepEqual(manifest.server, { id: "opcore", name: "Opcore", version: "0.2.0" }); + assert.deepEqual(manifest.server, { id: "opcore", name: "Opcore", version: "0.2.1" }); assert.deepEqual(manifest.protocolVersions, ["asp/0.1"]); assert.deepEqual(manifest.capabilities, ["check"]); assert.deepEqual(manifest.entrypoint, { transport: "stdio", bin: "opcore-asp-provider", args: ["--stdio"] }); @@ -341,21 +626,32 @@ describe("Opcore ASP provider", () => { }); it("removes stale legacy generated manifests before packaging", () => { - const manifestDir = join(repoRoot, "packages/asp-provider/dist/manifests"); - const legacyManifestPath = join(manifestDir, ["lattice", "asp", "provider.provisional.json"].join("-")); - const provisionalManifestPath = join(manifestDir, "opcore-asp-provider.provisional.json"); - const canonicalManifestPath = join(manifestDir, "asp-server.json"); - writeFileSync(legacyManifestPath, "{}\n"); - - const result = spawnSync(process.execPath, ["scripts/write-asp-provider-manifest.mjs"], { - cwd: repoRoot, - encoding: "utf8" - }); + const temp = mkdtempSync(join(tmpdir(), "opcore-asp-manifest-")); + try { + const packageRoot = join(temp, "asp-provider"); + cpSync(join(repoRoot, "packages/asp-provider"), packageRoot, { + recursive: true, + filter: (source) => !source.split(/[\\/]/u).includes("node_modules") + }); + const manifestDir = join(packageRoot, "dist/manifests"); + const legacyManifestPath = join(manifestDir, ["lattice", "asp", "provider.provisional.json"].join("-")); + const provisionalManifestPath = join(manifestDir, "opcore-asp-provider.provisional.json"); + const canonicalManifestPath = join(manifestDir, "asp-server.json"); + writeFileSync(legacyManifestPath, "{}\n"); + + const result = spawnSync( + process.execPath, + ["scripts/write-asp-provider-manifest.mjs", "--package-root", packageRoot], + { cwd: repoRoot, encoding: "utf8" } + ); - assert.equal(result.status, 0, result.stderr); - assert.equal(existsSync(legacyManifestPath), false); - assert.equal(existsSync(provisionalManifestPath), true); - assert.equal(existsSync(canonicalManifestPath), true); + assert.equal(result.status, 0, result.stderr); + assert.equal(existsSync(legacyManifestPath), false); + assert.equal(existsSync(provisionalManifestPath), true); + assert.equal(existsSync(canonicalManifestPath), true); + } finally { + rmSync(temp, { recursive: true, force: true }); + } }); }); diff --git a/tests/asp-warm-serve-transport.test.mjs b/tests/asp-warm-serve-transport.test.mjs index a5cdaac..9c698f9 100644 --- a/tests/asp-warm-serve-transport.test.mjs +++ b/tests/asp-warm-serve-transport.test.mjs @@ -115,7 +115,7 @@ async function assertInspectCandidatesStayInsideFailure() { async function initializeWarmPeer(peer, repo, host) { const init = await peer.request("initialize", { protocolVersion: "asp/0.1", - host: { name: "fake-host", version: "0.2.0-test" }, + host: { name: "fake-host", version: "0.2.1-test" }, hostCapabilities: { readBlob: true, listTree: true, putBlob: false }, workspace: { root: repo, baseline: host.baseline }, assuranceMode: "gated" diff --git a/tests/conformance.test.mjs b/tests/conformance.test.mjs index b5a217b..ce1f3f7 100644 --- a/tests/conformance.test.mjs +++ b/tests/conformance.test.mjs @@ -252,6 +252,15 @@ describe("conformance fixture metadata", () => { assert.equal(data.graphModes.requiredFailures.length, 6); assert.equal(data.editHypotheticalOverlays.request.overlays[0].action, "delete"); assert.equal(data.editHypotheticalOverlays.request.overlays[1].action, "write"); + assert.equal(data.pythonCapabilityRuns[0].schemaId, "opcore.python.validation-capability-run"); + assert.equal(data.pythonCapabilityRuns[0].authority, "mypy"); + assert.equal(data.pythonCapabilityRuns[1].authority, "pyright"); + assert.equal(data.pythonCapabilityRuns[1].tool.version, "1.1.411"); + assert.deepEqual({ + errors: data.pythonCapabilityRuns[1].errorCount, + warnings: data.pythonCapabilityRuns[1].warningCount, + notes: data.pythonCapabilityRuns[1].noteCount + }, { errors: 0, warnings: 1, notes: 1 }); }); it("describes package-owned command adapter metadata for #37", () => { @@ -366,12 +375,28 @@ describe("conformance fixture metadata", () => { assert.deepEqual(sourceExtraction.nodeKinds, ["File", "Module", "Class", "Function", "Variable"]); assert.ok(sourceExtraction.edgeKinds.includes("TESTED_BY")); assert.deepEqual(sourceExtraction.diagnostics, ["parse_error", "unresolved_import"]); + assert.deepEqual(sourceExtraction.importResolution, { + owner: "graph-core", + expectedEdgeField: "pythonImportEdges", + cases: [ + "multiline-parenthesized", + "alias", + "conditional", + "submodule", + "star", + "relative", + "package-initializer", + "stub", + "namespace", + "src-layout" + ] + }); }); it("describes Python validation fixture metadata for #22", () => { const validation = fixtureById("validation-python-v1").validationPython; assert.equal(validation.fixtureRoot, "packages/fixtures/validation-python"); - assert.deepEqual(validation.scenarios, ["clean", "failing", "degraded-tools"]); + assert.deepEqual(validation.scenarios, ["clean", "failing", "degraded-tools", "mypy-authority", "pyright-authority"]); assert.deepEqual(validation.checks, [ "python.syntax", "python.source-hygiene", diff --git a/tests/contracts.test.mjs b/tests/contracts.test.mjs index e478c7d..eca2bb9 100644 --- a/tests/contracts.test.mjs +++ b/tests/contracts.test.mjs @@ -3,6 +3,8 @@ import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import { GRAPH_SCHEMA_VERSION, + PYTHON_PROJECT_CONTEXT_SCHEMA_ID, + PYTHON_VALIDATION_CAPABILITY_RUN_SCHEMA_ID, commandLatencyTelemetryArtifactPolicy, commandTimingDegradationReasons, commandTimingProcessStates, @@ -52,6 +54,7 @@ import { requiredGraphNodeKinds, routeCommandAdapter, validationCheckRunStatuses, + validationCheckOutcomes, validationFailureCategories, validationResultStatuses, validationSkippedCheckReasons, @@ -99,6 +102,8 @@ import { validateRequiredContextDocPolicy, validateValidationRequestPayload, validatePreWriteValidationReceipt, + validatePythonProjectContext, + validatePythonValidationCapabilityRun, validateValidationResultPayload, validateValidationStatusPayload } from "../packages/contracts/dist/index.js"; @@ -206,6 +211,15 @@ describe("Opcore shared contracts", () => { "unsupported_request", "skipped" ]); + assert.deepEqual(validationCheckOutcomes, [ + "passed", + "findings", + "tool_unavailable", + "invalid_config", + "timeout", + "unsupported_target", + "tool_failure" + ]); assert.deepEqual(validationSkippedCheckReasons, [ "graph_unavailable", "unsupported_scope", @@ -851,6 +865,169 @@ describe("Opcore shared contracts", () => { ); }); + it("validates canonical Python project contexts and rejects invalid outcomes and provenance", () => { + const context = validPythonProjectContext(); + assert.equal(validatePythonProjectContext(context).schemaId, PYTHON_PROJECT_CONTEXT_SCHEMA_ID); + assert.equal( + validateValidationResultPayload(validValidationResult({ pythonProjectContexts: [context] })).pythonProjectContexts[0].projectRoot, + "services/api" + ); + assert.throws(() => validatePythonProjectContext({ ...context, outcome: "ready" }), /outcome/); + assert.throws( + () => validatePythonProjectContext({ ...context, interpreter: { ...context.interpreter, source: "guessed" } }), + /source/ + ); + for (const field of ["implementation", "platform", "architecture", "abi", "soabi"]) { + assert.throws( + () => validatePythonProjectContext({ ...context, interpreter: { ...context.interpreter, [field]: 42 } }), + new RegExp(field) + ); + } + for (const field of ["abi", "soabi"]) { + const interpreter = { ...context.interpreter }; + delete interpreter[field]; + assert.throws(() => validatePythonProjectContext({ ...context, interpreter }), new RegExp(field)); + } + assert.throws( + () => validatePythonProjectContext({ ...context, buildSystem: { ...context.buildSystem, requires: [""] } }), + /buildSystem requires/ + ); + assert.throws( + () => validatePythonProjectContext({ ...context, interpreter: { ...context.interpreter, version: "garbage" } }), + /version/ + ); + assert.throws( + () => validatePythonProjectContext({ + ...context, + tools: [{ + tool: "mypy", + available: true, + executable: "/repo/.venv/bin/mypy", + argv: ["/repo/.venv/bin/mypy"], + cwd: "/repo", + source: "project_local_environment" + }] + }), + /version/ + ); + assert.throws(() => validatePythonProjectContext({ ...context, contextFingerprint: "secret" }), /fingerprint/i); + }); + + it("validates source-free Python capability runs and rejects malformed evidence", () => { + const run = validPythonCapabilityRun(); + assert.equal(validatePythonValidationCapabilityRun(run).schemaId, PYTHON_VALIDATION_CAPABILITY_RUN_SCHEMA_ID); + assert.equal( + validateValidationResultPayload(validValidationResult({ pythonCapabilityRuns: [run] })).pythonCapabilityRuns[0].status, + "passed" + ); + assert.equal(validatePythonValidationCapabilityRun({ + ...run, + authority: "pyright", + tool: { ...run.tool, name: "pyright" }, + diagnosticCount: 2, + warningCount: 1, + noteCount: 1 + }).status, "passed"); + assert.throws(() => validatePythonValidationCapabilityRun({ + ...run, + diagnosticCount: 1, + errorCount: 1 + }), /zero errors/); + const executedInvalidConfig = { + ...run, + status: "invalid_config", + diagnosticCount: 1, + errorCount: 1, + execution: { termination: "exited", exitCode: 0, failureSummary: "mypy rejected selected configuration: mypy.ini" } + }; + assert.equal(validatePythonValidationCapabilityRun(executedInvalidConfig).status, "invalid_config"); + assert.throws(() => validatePythonValidationCapabilityRun({ + ...run, + tool: { + ...run.tool, + executable: "/var/folders/private/opcore-python-types-workspace-secret/bin/mypy", + argv: ["/var/folders/private/opcore-python-types-workspace-secret/bin/mypy", "--output=json", "src/app.py"] + } + }), /portable executable/); + assert.throws(() => validatePythonValidationCapabilityRun({ + ...run, + tool: { ...run.tool, argv: [run.tool.executable, "--cache-dir=/tmp/secret", "src/app.py"] } + }), /portable argv/); + const authorityConflict = { ...run, status: "invalid_config", diagnosticCount: 1, noteCount: 1 }; + delete authorityConflict.authority; + delete authorityConflict.authoritySource; + delete authorityConflict.tool; + delete authorityConflict.execution; + assert.equal(validatePythonValidationCapabilityRun(authorityConflict).status, "invalid_config"); + assert.throws(() => validatePythonValidationCapabilityRun({ ...run, authority: "pytest" }), /authority/); + assert.throws(() => validatePythonValidationCapabilityRun({ ...run, targets: ["z.py", "a.py"] }), /sorted/); + assert.throws(() => validatePythonValidationCapabilityRun({ ...run, afterStateManifestFingerprint: "secret" }), /sha256/); + assert.throws(() => validatePythonValidationCapabilityRun({ ...run, content: "source text" }), /unexpected properties/); + assert.throws(() => validatePythonValidationCapabilityRun({ + ...run, + tool: { ...run.tool, name: "pyright" } + }), /match authority/); + assert.throws(() => validatePythonValidationCapabilityRun({ + ...run, + tool: { ...run.tool, version: "latest" } + }), /exact version/); + assert.throws(() => validatePythonValidationCapabilityRun({ + ...run, + status: "timeout", + execution: { termination: "signal", signal: "SIGTERM", failureSummary: "terminated" } + }), /timeout failure evidence/); + assert.throws(() => validatePythonValidationCapabilityRun({ + ...run, + status: "tool_failure", + execution: { termination: "exited", exitCode: 2 } + }), /failure evidence/); + assert.throws(() => validatePythonValidationCapabilityRun({ + ...executedInvalidConfig, + execution: { termination: "exited", exitCode: 0 } + }), /invalid-config.*failure evidence/i); + assert.throws(() => validatePythonValidationCapabilityRun({ + ...run, + status: "findings", + diagnosticCount: 1, + errorCount: 1, + execution: { termination: "exited", exitCode: 0 } + }), /exit 1/); + }); + + it("rejects schema-forbidden Python project-context properties recursively", () => { + const context = validPythonProjectContext(); + const invalidContexts = [ + { ...context, unexpected: true }, + { ...context, layout: { ...context.layout, unexpected: true } }, + { ...context, evidence: [{ ...context.evidence[0], unexpected: true }] }, + { ...context, targetRuntime: { ...context.targetRuntime, unexpected: true } }, + { ...context, managers: [{ ...context.managers[0], unexpected: true }] }, + { ...context, buildSystem: { ...context.buildSystem, unexpected: true } }, + { ...context, interpreter: { ...context.interpreter, unexpected: true } }, + { + ...context, + tools: [{ + tool: "mypy", + available: true, + executable: "/repo/services/api/.venv/bin/mypy", + argv: ["/repo/services/api/.venv/bin/mypy"], + cwd: "/repo/services/api", + source: "project_local_environment", + version: "1.8.0", + unexpected: true + }] + }, + { + ...context, + outcome: "degraded", + reasons: [{ code: "tool_unavailable", tool: "mypy", message: "mypy is unavailable", unexpected: true }] + } + ]; + for (const invalid of invalidContexts) { + assert.throws(() => validatePythonProjectContext(invalid), /unexpected|properties/i); + } + }); + it("validates validation result manifest metadata", () => { const manifest = { schemaVersion: GRAPH_SCHEMA_VERSION, @@ -879,6 +1056,7 @@ describe("Opcore shared contracts", () => { { checkId: "types", status: "passed", + outcome: "passed", durationMs: 7, diagnosticCount: 1 } @@ -960,12 +1138,24 @@ describe("Opcore shared contracts", () => { validValidationResult({ manifest: { ...manifest, - runs: [{ ...manifest.runs[0], status: "infrastructure_failure", failureMessage: "" }] + runs: [{ ...manifest.runs[0], status: "infrastructure_failure", outcome: "tool_failure", failureMessage: "" }] } }) ), /failureMessage/ ); + assert.throws( + () => + validateValidationResultPayload( + validValidationResult({ + manifest: { + ...manifest, + runs: [{ ...manifest.runs[0], status: "passed", outcome: "findings" }] + } + }) + ), + /outcome.*requires status/ + ); assert.throws( () => validateValidationResultPayload( @@ -985,6 +1175,54 @@ describe("Opcore shared contracts", () => { ); }); + it("validates diagnostic ranges, tool provenance, and fine-grained outcomes", () => { + const diagnostic = { + category: "syntax", + severity: "error", + code: "PY_SYNTAX_ERROR", + message: "expected ':'", + path: "pkg/app.py", + line: 3, + column: 8, + endLine: 3, + endColumn: 9, + tool: { + name: "python", + command: "/repo/.venv/bin/python", + version: "3.12.13", + source: "repo-venv", + cwd: "/repo" + } + }; + const result = validateValidationResultPayload( + validValidationResult({ + status: "policy_failure", + diagnostics: [diagnostic], + failure: { category: "policy_failure", message: "finding" }, + manifest: { + schemaVersion: GRAPH_SCHEMA_VERSION, + checks: ["python.syntax"], + generatedAt: "2026-07-14T00:00:00.000Z", + runs: [{ checkId: "python.syntax", status: "policy_failure", outcome: "findings", diagnosticCount: 1 }] + } + }) + ); + assert.equal(result.diagnostics[0].tool.command, "/repo/.venv/bin/python"); + assert.equal(result.manifest.runs[0].outcome, "findings"); + assert.throws( + () => validateValidationResultPayload(validValidationResult({ diagnostics: [{ ...diagnostic, line: 0 }] })), + /line/ + ); + assert.throws( + () => validateValidationResultPayload(validValidationResult({ diagnostics: [{ ...diagnostic, line: undefined }] })), + /requires line/ + ); + assert.throws( + () => validateValidationResultPayload(validValidationResult({ diagnostics: [{ ...diagnostic, tool: { name: "python", command: "" } }] })), + /tool command/ + ); + }); + it("rejects ambiguous repo identity and untyped provider failures", () => { assert.throws( () => @@ -1198,7 +1436,7 @@ describe("Opcore shared contracts", () => { runtimeInfo: validOpcoreRuntimeInfo(), opcoreDoctor: validOpcoreDoctor() }).opcoreDoctor.runtime.version, - "0.2.0" + "0.2.1" ); assert.equal(validateInspectRouteResult(validInspectRouteResult()).references[0].symbol.name, "GreetingModel"); assert.equal( @@ -3103,7 +3341,7 @@ function validCommandLatencyRecord(overrides = {}) { exitCode: 0, repo: validRepoShapeFingerprint(), timing: validCommandTiming(), - opcoreVersion: "0.2.0", + opcoreVersion: "0.2.1", ...overrides }; } @@ -3415,7 +3653,7 @@ function validManagedToolDescriptor(overrides = {}) { packageIdentity: { packageName: "opcore", artifactName: "opcore", - version: "0.2.0" + version: "0.2.1" }, entrypoints: [ { @@ -3488,6 +3726,12 @@ function validManagedToolDescriptor(overrides = {}) { graphModes: ["optional", "required"], hypothetical: true, statusSurfaces: ["status", "doctor"], + pythonProjectContext: { + schemaId: "opcore.python.project-context.v1", + outcomes: ["resolved", "degraded", "unsupported", "ambiguous"], + readOnly: true, + installs: false + }, writeGate: { initScopes: ["repo", "global"], harnesses: ["claude-code", "codex"], @@ -3550,6 +3794,80 @@ function validManagedToolDescriptor(overrides = {}) { }; } +function validPythonProjectContext(overrides = {}) { + return { + schemaId: "opcore.python.project-context.v1", + schemaVersion: 1, + target: "services/api/src/app.py", + repositoryRoot: "/repo", + projectRoot: "services/api", + projectBoundary: "services/api", + sourceRoots: ["services/api/src"], + layout: { kinds: ["src", "package"], paths: ["services/api/src"] }, + evidence: [{ path: "services/api/pyproject.toml", role: "boundary" }], + targetRuntime: { requiresPython: ">=3.12", conflicts: [] }, + managers: [{ kind: "uv", configFiles: ["services/api/pyproject.toml"], lockFiles: ["services/api/uv.lock"] }], + buildSystem: { + configFile: "services/api/pyproject.toml", + backend: "hatchling.build", + requires: ["hatchling>=1"] + }, + interpreter: { + executable: "/repo/services/api/.venv/bin/python", + argv: ["/repo/services/api/.venv/bin/python"], + cwd: "/repo/services/api", + source: "project_local_environment", + version: "3.12.4", + implementation: "CPython", + platform: "linux", + architecture: "x86_64", + abi: "cpython-312", + soabi: "cpython-312-x86_64-linux-gnu" + }, + tools: [], + projectKey: `sha256:${"1".repeat(64)}`, + contextFingerprint: `sha256:${"2".repeat(64)}`, + outcome: "resolved", + reasons: [], + ...overrides + }; +} + +function validPythonCapabilityRun(overrides = {}) { + return { + schemaId: "opcore.python.validation-capability-run", + schemaVersion: 1, + capability: "types", + checkId: "python.types", + projectKey: `sha256:${"1".repeat(64)}`, + contextFingerprint: `sha256:${"2".repeat(64)}`, + projectRoot: "services/api", + targets: ["services/api/src/app.py"], + selectedSourcePaths: ["services/api/src/app.py"], + selectedConfigPaths: ["services/api/pyproject.toml"], + afterStateManifestFingerprint: `sha256:${"3".repeat(64)}`, + authority: "mypy", + authoritySource: "project_config", + status: "passed", + tool: { + name: "mypy", + executable: "repo:services/api/.venv/bin/mypy", + argv: ["repo:services/api/.venv/bin/mypy", "--output=json", "src/app.py"], + cwd: "services/api", + source: "project_local_environment", + version: "2.3.0", + configFile: "services/api/pyproject.toml" + }, + execution: { termination: "exited", exitCode: 0 }, + durationMs: 12, + diagnosticCount: 0, + errorCount: 0, + warningCount: 0, + noteCount: 0, + ...overrides + }; +} + function descriptorNativeArtifacts() { return graphCoreNativeSupportedTargets.map((targetPlatform) => { const bundledPackageName = graphCoreNativePackageNameForTarget(targetPlatform); @@ -3644,7 +3962,7 @@ function surfaceContracts(surfaces) { function validArtifactMetadata() { return { artifactName: "opcore-graph-core", - artifactVersion: "0.2.0", + artifactVersion: "0.2.1", targetPlatform: "test", binaryPath: "dist/native/test/opcore-graph-core", checksumPath: "dist/native/test/opcore-graph-core.sha256", @@ -3658,7 +3976,7 @@ function validHandshake() { provider: "opcore-graph", graphSchemaVersion: 1, artifactName: "opcore-graph-core", - artifactVersion: "0.2.0", + artifactVersion: "0.2.1", targetPlatform: "test", supportedOperations: ["build", "update", "watch", "status", "query", "ping", "health", "shutdown"], nodeKinds: [ @@ -4236,7 +4554,7 @@ function validOpcoreRuntimeInfo() { return { schemaVersion: 1, packageName: "opcore", - version: "0.2.0", + version: "0.2.1", bin: "opcore", artifactSource: "source_checkout", packageRoot: "/repo/packages/opcore", @@ -4430,11 +4748,11 @@ function validGraphReleaseReceipt() { graphPackageVersions: [ { packageName: "@the-open-engine/opcore-graph", - version: "0.2.0" + version: "0.2.1" }, ...graphCoreNativeSupportedTargets.map((target) => ({ packageName: graphCoreNativePackageNameForTarget(target), - version: "0.2.0" + version: "0.2.1" })) ], graphProviderSchemaVersion: 1, @@ -4496,7 +4814,7 @@ function validGraphReleaseReceipt() { })), packageInspection: { packageName: "@the-open-engine/opcore-graph", - tarballName: "covibes-opcore-graph-0.2.0.tgz", + tarballName: "covibes-opcore-graph-0.2.1.tgz", fileCount: 1, files: ["dist/index.js"], forbiddenMarkersAbsent: true, @@ -4643,10 +4961,10 @@ function validReleaseReceipt() { { packageName, packageRoot: "packages/opcore", - version: "0.2.0", + version: "0.2.1", manifest: { name: packageName, - version: "0.2.0", + version: "0.2.1", license: "MIT", main: "dist/index.js", types: "dist/index.d.ts", @@ -4656,8 +4974,8 @@ function validReleaseReceipt() { bundledDependencies: [] }, tarball: { - filename: "opcore-0.2.0.tgz", - path: ".opcore/release/packages/opcore-0.2.0.tgz", + filename: "opcore-0.2.1.tgz", + path: ".opcore/release/packages/opcore-0.2.1.tgz", sha256: "c".repeat(64), integrity: "sha512-test", shasum: "d".repeat(40) @@ -5187,7 +5505,7 @@ function validAspDogfoodReceipt() { }; const manifest = { manifestVersion: "asp-server/0.1", - server: { id: "opcore", name: "Opcore", version: "0.2.0" }, + server: { id: "opcore", name: "Opcore", version: "0.2.1" }, protocolVersions: ["asp/0.1"], capabilities: ["check"], capabilityProfiles: ["core-check-provider", "opcore-core-check"], diff --git a/tests/cutover-release.test.mjs b/tests/cutover-release.test.mjs index c898c23..1f44a23 100644 --- a/tests/cutover-release.test.mjs +++ b/tests/cutover-release.test.mjs @@ -111,7 +111,7 @@ describe("cutover release receipt", () => { packageNames: releaseReceiptPackageNames, installedPackages: releaseReceiptPackageNames.map((packageName) => ({ packageName, - version: "0.2.0", + version: "0.2.1", tarball: { filename: `${packageName}.tgz`, sha256: "b".repeat(64) }, installedManifest: { path: `node_modules/${packageName}/package.json`, diff --git a/tests/edit-cli.test.mjs b/tests/edit-cli.test.mjs index 5183b00..67c8f9d 100644 --- a/tests/edit-cli.test.mjs +++ b/tests/edit-cli.test.mjs @@ -54,8 +54,8 @@ describe("opcore edit CLI", () => { assert.equal(routed.status, "error"); assert.equal(routed.exitCode, 1); assert.equal(routed.editResult.refusal.category, "validation_failed"); - assert.equal(routed.editResult.validation.status, "provider_failure"); - assert.equal(routed.editResult.validation.graphStatus.state, "stale"); + assert.equal(routed.editResult.validation.status, "policy_failure"); + assert.equal(routed.editResult.validation.graphStatus.state, "available"); assert.equal(routed.editResult.applied, false); assert.equal(readFileSync(join(repo, "src/a.ts"), "utf8"), "old\n"); }); diff --git a/tests/fixtures/latency/telemetry-over.jsonl b/tests/fixtures/latency/telemetry-over.jsonl index 3f3063f..9d000d7 100644 --- a/tests/fixtures/latency/telemetry-over.jsonl +++ b/tests/fixtures/latency/telemetry-over.jsonl @@ -1 +1 @@ -{"schemaVersion":1,"recordedAt":"2026-06-27T00:01:00.000Z","bin":"opcore","canonicalCommand":["opcore","check","changed","--report-mode","introduced","--base","HEAD","--checks","typescript.syntax"],"owner":"validation","status":"ok","exitCode":0,"repo":{"totalFiles":24,"languages":[{"language":"typescript","files":18},{"language":"json","files":6}],"graph":{"supportedFiles":18,"unsupportedFiles":6},"git":{"available":true,"clean":true}},"timing":{"durationMs":9000,"processState":"warm","phases":[{"phase":"validation","durationMs":8000},{"phase":"validation_typescript_syntax","durationMs":500}]},"opcoreVersion":"0.2.0"} +{"schemaVersion":1,"recordedAt":"2026-06-27T00:01:00.000Z","bin":"opcore","canonicalCommand":["opcore","check","changed","--report-mode","introduced","--base","HEAD","--checks","typescript.syntax"],"owner":"validation","status":"ok","exitCode":0,"repo":{"totalFiles":24,"languages":[{"language":"typescript","files":18},{"language":"json","files":6}],"graph":{"supportedFiles":18,"unsupportedFiles":6},"git":{"available":true,"clean":true}},"timing":{"durationMs":9000,"processState":"warm","phases":[{"phase":"validation","durationMs":8000},{"phase":"validation_typescript_syntax","durationMs":500}]},"opcoreVersion":"0.2.1"} diff --git a/tests/fixtures/latency/telemetry-pass.jsonl b/tests/fixtures/latency/telemetry-pass.jsonl index 4295200..6581946 100644 --- a/tests/fixtures/latency/telemetry-pass.jsonl +++ b/tests/fixtures/latency/telemetry-pass.jsonl @@ -1,7 +1,7 @@ -{"schemaVersion":1,"recordedAt":"2026-06-27T00:00:00.000Z","bin":"opcore","canonicalCommand":["opcore","check","changed","--report-mode","introduced","--base","HEAD","--checks","typescript.syntax"],"owner":"validation","status":"ok","exitCode":0,"repo":{"totalFiles":24,"languages":[{"language":"typescript","files":18},{"language":"json","files":6}],"graph":{"supportedFiles":18,"unsupportedFiles":6},"git":{"available":true,"clean":true}},"timing":{"durationMs":520,"processState":"warm","phases":[{"phase":"validation","durationMs":410},{"phase":"validation_typescript_syntax","durationMs":140}]},"opcoreVersion":"0.2.0"} -{"schemaVersion":1,"recordedAt":"2026-06-27T00:00:01.000Z","bin":"opcore","canonicalCommand":["opcore","graph","build","--repo","arg","--json"],"owner":"graph","status":"ok","exitCode":0,"repo":{"totalFiles":24,"languages":[{"language":"typescript","files":18},{"language":"json","files":6}],"graph":{"supportedFiles":18,"unsupportedFiles":6},"git":{"available":true,"clean":true}},"timing":{"durationMs":900,"processState":"cold","phases":[{"phase":"graph_extract","durationMs":520},{"phase":"graph_store","durationMs":180}]},"opcoreVersion":"0.2.0"} -{"schemaVersion":1,"recordedAt":"2026-06-27T00:00:02.000Z","bin":"opcore-asp-provider","canonicalCommand":["opcore-asp-provider","check","evaluate"],"owner":"validation","status":"ok","exitCode":0,"repo":{"totalFiles":24,"languages":[{"language":"typescript","files":18},{"language":"json","files":6}],"graph":{"supportedFiles":18,"unsupportedFiles":6},"git":{"available":true,"clean":true}},"timing":{"durationMs":640,"processState":"warm","phases":[{"phase":"validation","durationMs":510}]},"opcoreVersion":"0.2.0"} -{"schemaVersion":1,"recordedAt":"2026-06-27T00:00:03.000Z","bin":"opcore","canonicalCommand":["opcore","graph","serve","query"],"owner":"graph","status":"ok","exitCode":0,"repo":{"totalFiles":24,"languages":[{"language":"typescript","files":18},{"language":"json","files":6}],"graph":{"supportedFiles":18,"unsupportedFiles":6},"git":{"available":true,"clean":true}},"timing":{"durationMs":180,"processState":"warm","phases":[{"phase":"serve_query","durationMs":180}]},"opcoreVersion":"0.2.0"} -{"schemaVersion":1,"recordedAt":"2026-06-27T00:00:04.000Z","bin":"opcore","canonicalCommand":["opcore","graph","serve","search"],"owner":"graph","status":"ok","exitCode":0,"repo":{"totalFiles":24,"languages":[{"language":"typescript","files":18},{"language":"json","files":6}],"graph":{"supportedFiles":18,"unsupportedFiles":6},"git":{"available":true,"clean":true}},"timing":{"durationMs":220,"processState":"warm","phases":[{"phase":"serve_search","durationMs":220}]},"opcoreVersion":"0.2.0"} -{"schemaVersion":1,"recordedAt":"2026-06-27T00:00:05.000Z","bin":"opcore","canonicalCommand":["opcore","graph","serve","named-query"],"owner":"graph","status":"ok","exitCode":0,"repo":{"totalFiles":24,"languages":[{"language":"typescript","files":18},{"language":"json","files":6}],"graph":{"supportedFiles":18,"unsupportedFiles":6},"git":{"available":true,"clean":true}},"timing":{"durationMs":260,"processState":"warm","phases":[{"phase":"serve_named_query","durationMs":260}]},"opcoreVersion":"0.2.0"} -{"schemaVersion":1,"recordedAt":"2026-06-27T00:00:06.000Z","bin":"opcore","canonicalCommand":["opcore","inspect","signature","arg"],"owner":"inspect","status":"ok","exitCode":0,"repo":{"totalFiles":24,"languages":[{"language":"typescript","files":18},{"language":"json","files":6}],"graph":{"supportedFiles":18,"unsupportedFiles":6},"git":{"available":true,"clean":true}},"timing":{"durationMs":350,"processState":"warm","phases":[],"degradations":["no_source"]},"opcoreVersion":"0.2.0"} +{"schemaVersion":1,"recordedAt":"2026-06-27T00:00:00.000Z","bin":"opcore","canonicalCommand":["opcore","check","changed","--report-mode","introduced","--base","HEAD","--checks","typescript.syntax"],"owner":"validation","status":"ok","exitCode":0,"repo":{"totalFiles":24,"languages":[{"language":"typescript","files":18},{"language":"json","files":6}],"graph":{"supportedFiles":18,"unsupportedFiles":6},"git":{"available":true,"clean":true}},"timing":{"durationMs":520,"processState":"warm","phases":[{"phase":"validation","durationMs":410},{"phase":"validation_typescript_syntax","durationMs":140}]},"opcoreVersion":"0.2.1"} +{"schemaVersion":1,"recordedAt":"2026-06-27T00:00:01.000Z","bin":"opcore","canonicalCommand":["opcore","graph","build","--repo","arg","--json"],"owner":"graph","status":"ok","exitCode":0,"repo":{"totalFiles":24,"languages":[{"language":"typescript","files":18},{"language":"json","files":6}],"graph":{"supportedFiles":18,"unsupportedFiles":6},"git":{"available":true,"clean":true}},"timing":{"durationMs":900,"processState":"cold","phases":[{"phase":"graph_extract","durationMs":520},{"phase":"graph_store","durationMs":180}]},"opcoreVersion":"0.2.1"} +{"schemaVersion":1,"recordedAt":"2026-06-27T00:00:02.000Z","bin":"opcore-asp-provider","canonicalCommand":["opcore-asp-provider","check","evaluate"],"owner":"validation","status":"ok","exitCode":0,"repo":{"totalFiles":24,"languages":[{"language":"typescript","files":18},{"language":"json","files":6}],"graph":{"supportedFiles":18,"unsupportedFiles":6},"git":{"available":true,"clean":true}},"timing":{"durationMs":640,"processState":"warm","phases":[{"phase":"validation","durationMs":510}]},"opcoreVersion":"0.2.1"} +{"schemaVersion":1,"recordedAt":"2026-06-27T00:00:03.000Z","bin":"opcore","canonicalCommand":["opcore","graph","serve","query"],"owner":"graph","status":"ok","exitCode":0,"repo":{"totalFiles":24,"languages":[{"language":"typescript","files":18},{"language":"json","files":6}],"graph":{"supportedFiles":18,"unsupportedFiles":6},"git":{"available":true,"clean":true}},"timing":{"durationMs":180,"processState":"warm","phases":[{"phase":"serve_query","durationMs":180}]},"opcoreVersion":"0.2.1"} +{"schemaVersion":1,"recordedAt":"2026-06-27T00:00:04.000Z","bin":"opcore","canonicalCommand":["opcore","graph","serve","search"],"owner":"graph","status":"ok","exitCode":0,"repo":{"totalFiles":24,"languages":[{"language":"typescript","files":18},{"language":"json","files":6}],"graph":{"supportedFiles":18,"unsupportedFiles":6},"git":{"available":true,"clean":true}},"timing":{"durationMs":220,"processState":"warm","phases":[{"phase":"serve_search","durationMs":220}]},"opcoreVersion":"0.2.1"} +{"schemaVersion":1,"recordedAt":"2026-06-27T00:00:05.000Z","bin":"opcore","canonicalCommand":["opcore","graph","serve","named-query"],"owner":"graph","status":"ok","exitCode":0,"repo":{"totalFiles":24,"languages":[{"language":"typescript","files":18},{"language":"json","files":6}],"graph":{"supportedFiles":18,"unsupportedFiles":6},"git":{"available":true,"clean":true}},"timing":{"durationMs":260,"processState":"warm","phases":[{"phase":"serve_named_query","durationMs":260}]},"opcoreVersion":"0.2.1"} +{"schemaVersion":1,"recordedAt":"2026-06-27T00:00:06.000Z","bin":"opcore","canonicalCommand":["opcore","inspect","signature","arg"],"owner":"inspect","status":"ok","exitCode":0,"repo":{"totalFiles":24,"languages":[{"language":"typescript","files":18},{"language":"json","files":6}],"graph":{"supportedFiles":18,"unsupportedFiles":6},"git":{"available":true,"clean":true}},"timing":{"durationMs":350,"processState":"warm","phases":[],"degradations":["no_source"]},"opcoreVersion":"0.2.1"} diff --git a/tests/fixtures/package-packlists.json b/tests/fixtures/package-packlists.json index cc151c0..8859471 100644 --- a/tests/fixtures/package-packlists.json +++ b/tests/fixtures/package-packlists.json @@ -166,6 +166,9 @@ "dist/validation-composition.d.ts", "dist/validation-composition.d.ts.map", "dist/validation-composition.js", + "dist/validation-graph-session.d.ts", + "dist/validation-graph-session.d.ts.map", + "dist/validation-graph-session.js", "node_modules/@the-open-engine/opcore-asp-provider/README.md", "node_modules/@the-open-engine/opcore-asp-provider/dist/digests.d.ts", "node_modules/@the-open-engine/opcore-asp-provider/dist/digests.d.ts.map", @@ -290,12 +293,18 @@ "node_modules/@the-open-engine/opcore-graph/dist/artifact.d.ts", "node_modules/@the-open-engine/opcore-graph/dist/artifact.d.ts.map", "node_modules/@the-open-engine/opcore-graph/dist/artifact.js", + "node_modules/@the-open-engine/opcore-graph/dist/ephemeral-snapshot.d.ts", + "node_modules/@the-open-engine/opcore-graph/dist/ephemeral-snapshot.d.ts.map", + "node_modules/@the-open-engine/opcore-graph/dist/ephemeral-snapshot.js", "node_modules/@the-open-engine/opcore-graph/dist/index.d.ts", "node_modules/@the-open-engine/opcore-graph/dist/index.d.ts.map", "node_modules/@the-open-engine/opcore-graph/dist/index.js", "node_modules/@the-open-engine/opcore-graph/dist/native-targets.d.ts", "node_modules/@the-open-engine/opcore-graph/dist/native-targets.d.ts.map", "node_modules/@the-open-engine/opcore-graph/dist/native-targets.js", + "node_modules/@the-open-engine/opcore-graph/dist/python-import-analysis.d.ts", + "node_modules/@the-open-engine/opcore-graph/dist/python-import-analysis.d.ts.map", + "node_modules/@the-open-engine/opcore-graph/dist/python-import-analysis.js", "node_modules/@the-open-engine/opcore-graph/dist/serve.d.ts", "node_modules/@the-open-engine/opcore-graph/dist/serve.d.ts.map", "node_modules/@the-open-engine/opcore-graph/dist/serve.js", @@ -373,45 +382,135 @@ "node_modules/@the-open-engine/opcore-validation-python/dist/check-ids.d.ts", "node_modules/@the-open-engine/opcore-validation-python/dist/check-ids.d.ts.map", "node_modules/@the-open-engine/opcore-validation-python/dist/check-ids.js", + "node_modules/@the-open-engine/opcore-validation-python/dist/compiler-protocol.d.ts", + "node_modules/@the-open-engine/opcore-validation-python/dist/compiler-protocol.d.ts.map", + "node_modules/@the-open-engine/opcore-validation-python/dist/compiler-protocol.js", "node_modules/@the-open-engine/opcore-validation-python/dist/dead-code-check.d.ts", "node_modules/@the-open-engine/opcore-validation-python/dist/dead-code-check.d.ts.map", "node_modules/@the-open-engine/opcore-validation-python/dist/dead-code-check.js", "node_modules/@the-open-engine/opcore-validation-python/dist/diagnostics.d.ts", "node_modules/@the-open-engine/opcore-validation-python/dist/diagnostics.d.ts.map", "node_modules/@the-open-engine/opcore-validation-python/dist/diagnostics.js", + "node_modules/@the-open-engine/opcore-validation-python/dist/environment-resolution.d.ts", + "node_modules/@the-open-engine/opcore-validation-python/dist/environment-resolution.d.ts.map", + "node_modules/@the-open-engine/opcore-validation-python/dist/environment-resolution.js", "node_modules/@the-open-engine/opcore-validation-python/dist/graph-requirements.d.ts", "node_modules/@the-open-engine/opcore-validation-python/dist/graph-requirements.d.ts.map", "node_modules/@the-open-engine/opcore-validation-python/dist/graph-requirements.js", + "node_modules/@the-open-engine/opcore-validation-python/dist/import-analysis.d.ts", + "node_modules/@the-open-engine/opcore-validation-python/dist/import-analysis.d.ts.map", + "node_modules/@the-open-engine/opcore-validation-python/dist/import-analysis.js", "node_modules/@the-open-engine/opcore-validation-python/dist/import-graph-check.d.ts", "node_modules/@the-open-engine/opcore-validation-python/dist/import-graph-check.d.ts.map", "node_modules/@the-open-engine/opcore-validation-python/dist/import-graph-check.js", "node_modules/@the-open-engine/opcore-validation-python/dist/index.d.ts", "node_modules/@the-open-engine/opcore-validation-python/dist/index.d.ts.map", "node_modules/@the-open-engine/opcore-validation-python/dist/index.js", + "node_modules/@the-open-engine/opcore-validation-python/dist/ini-config.d.ts", + "node_modules/@the-open-engine/opcore-validation-python/dist/ini-config.d.ts.map", + "node_modules/@the-open-engine/opcore-validation-python/dist/ini-config.js", + "node_modules/@the-open-engine/opcore-validation-python/dist/mypy-config-values.d.ts", + "node_modules/@the-open-engine/opcore-validation-python/dist/mypy-config-values.d.ts.map", + "node_modules/@the-open-engine/opcore-validation-python/dist/mypy-config-values.js", + "node_modules/@the-open-engine/opcore-validation-python/dist/mypy-config.d.ts", + "node_modules/@the-open-engine/opcore-validation-python/dist/mypy-config.d.ts.map", + "node_modules/@the-open-engine/opcore-validation-python/dist/mypy-config.js", + "node_modules/@the-open-engine/opcore-validation-python/dist/mypy-output.d.ts", + "node_modules/@the-open-engine/opcore-validation-python/dist/mypy-output.d.ts.map", + "node_modules/@the-open-engine/opcore-validation-python/dist/mypy-output.js", + "node_modules/@the-open-engine/opcore-validation-python/dist/mypy-result.d.ts", + "node_modules/@the-open-engine/opcore-validation-python/dist/mypy-result.d.ts.map", + "node_modules/@the-open-engine/opcore-validation-python/dist/mypy-result.js", + "node_modules/@the-open-engine/opcore-validation-python/dist/mypy-runner-types.d.ts", + "node_modules/@the-open-engine/opcore-validation-python/dist/mypy-runner-types.d.ts.map", + "node_modules/@the-open-engine/opcore-validation-python/dist/mypy-runner-types.js", + "node_modules/@the-open-engine/opcore-validation-python/dist/mypy-runner.d.ts", + "node_modules/@the-open-engine/opcore-validation-python/dist/mypy-runner.d.ts.map", + "node_modules/@the-open-engine/opcore-validation-python/dist/mypy-runner.js", "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/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", + "node_modules/@the-open-engine/opcore-validation-python/dist/project-context.d.ts", + "node_modules/@the-open-engine/opcore-validation-python/dist/project-context.d.ts.map", + "node_modules/@the-open-engine/opcore-validation-python/dist/project-context.js", + "node_modules/@the-open-engine/opcore-validation-python/dist/project-discovery.d.ts", + "node_modules/@the-open-engine/opcore-validation-python/dist/project-discovery.d.ts.map", + "node_modules/@the-open-engine/opcore-validation-python/dist/project-discovery.js", + "node_modules/@the-open-engine/opcore-validation-python/dist/project-fingerprint.d.ts", + "node_modules/@the-open-engine/opcore-validation-python/dist/project-fingerprint.d.ts.map", + "node_modules/@the-open-engine/opcore-validation-python/dist/project-fingerprint.js", + "node_modules/@the-open-engine/opcore-validation-python/dist/project-workspace.d.ts", + "node_modules/@the-open-engine/opcore-validation-python/dist/project-workspace.d.ts.map", + "node_modules/@the-open-engine/opcore-validation-python/dist/project-workspace.js", + "node_modules/@the-open-engine/opcore-validation-python/dist/protocol-validation.d.ts", + "node_modules/@the-open-engine/opcore-validation-python/dist/protocol-validation.d.ts.map", + "node_modules/@the-open-engine/opcore-validation-python/dist/protocol-validation.js", + "node_modules/@the-open-engine/opcore-validation-python/dist/pyright-config-values.d.ts", + "node_modules/@the-open-engine/opcore-validation-python/dist/pyright-config-values.d.ts.map", + "node_modules/@the-open-engine/opcore-validation-python/dist/pyright-config-values.js", + "node_modules/@the-open-engine/opcore-validation-python/dist/pyright-config.d.ts", + "node_modules/@the-open-engine/opcore-validation-python/dist/pyright-config.d.ts.map", + "node_modules/@the-open-engine/opcore-validation-python/dist/pyright-config.js", + "node_modules/@the-open-engine/opcore-validation-python/dist/pyright-output.d.ts", + "node_modules/@the-open-engine/opcore-validation-python/dist/pyright-output.d.ts.map", + "node_modules/@the-open-engine/opcore-validation-python/dist/pyright-output.js", + "node_modules/@the-open-engine/opcore-validation-python/dist/pyright-runner.d.ts", + "node_modules/@the-open-engine/opcore-validation-python/dist/pyright-runner.d.ts.map", + "node_modules/@the-open-engine/opcore-validation-python/dist/pyright-runner.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", + "node_modules/@the-open-engine/opcore-validation-python/dist/source-closure.d.ts", + "node_modules/@the-open-engine/opcore-validation-python/dist/source-closure.d.ts.map", + "node_modules/@the-open-engine/opcore-validation-python/dist/source-closure.js", "node_modules/@the-open-engine/opcore-validation-python/dist/source-files.d.ts", "node_modules/@the-open-engine/opcore-validation-python/dist/source-files.d.ts.map", "node_modules/@the-open-engine/opcore-validation-python/dist/source-files.js", "node_modules/@the-open-engine/opcore-validation-python/dist/source-hygiene-check.d.ts", "node_modules/@the-open-engine/opcore-validation-python/dist/source-hygiene-check.d.ts.map", "node_modules/@the-open-engine/opcore-validation-python/dist/source-hygiene-check.js", + "node_modules/@the-open-engine/opcore-validation-python/dist/source-types.d.ts", + "node_modules/@the-open-engine/opcore-validation-python/dist/source-types.d.ts.map", + "node_modules/@the-open-engine/opcore-validation-python/dist/source-types.js", + "node_modules/@the-open-engine/opcore-validation-python/dist/static-config.d.ts", + "node_modules/@the-open-engine/opcore-validation-python/dist/static-config.d.ts.map", + "node_modules/@the-open-engine/opcore-validation-python/dist/static-config.js", + "node_modules/@the-open-engine/opcore-validation-python/dist/strict-json.d.ts", + "node_modules/@the-open-engine/opcore-validation-python/dist/strict-json.d.ts.map", + "node_modules/@the-open-engine/opcore-validation-python/dist/strict-json.js", "node_modules/@the-open-engine/opcore-validation-python/dist/syntax-check.d.ts", "node_modules/@the-open-engine/opcore-validation-python/dist/syntax-check.d.ts.map", "node_modules/@the-open-engine/opcore-validation-python/dist/syntax-check.js", + "node_modules/@the-open-engine/opcore-validation-python/dist/toml-config.d.ts", + "node_modules/@the-open-engine/opcore-validation-python/dist/toml-config.d.ts.map", + "node_modules/@the-open-engine/opcore-validation-python/dist/toml-config.js", "node_modules/@the-open-engine/opcore-validation-python/dist/toolchain.d.ts", "node_modules/@the-open-engine/opcore-validation-python/dist/toolchain.d.ts.map", "node_modules/@the-open-engine/opcore-validation-python/dist/toolchain.js", - "node_modules/@the-open-engine/opcore-validation-python/dist/toolchain-resolver.d.ts", - "node_modules/@the-open-engine/opcore-validation-python/dist/toolchain-resolver.d.ts.map", - "node_modules/@the-open-engine/opcore-validation-python/dist/toolchain-resolver.js", + "node_modules/@the-open-engine/opcore-validation-python/dist/type-authority.d.ts", + "node_modules/@the-open-engine/opcore-validation-python/dist/type-authority.d.ts.map", + "node_modules/@the-open-engine/opcore-validation-python/dist/type-authority.js", + "node_modules/@the-open-engine/opcore-validation-python/dist/type-capability-run.d.ts", + "node_modules/@the-open-engine/opcore-validation-python/dist/type-capability-run.d.ts.map", + "node_modules/@the-open-engine/opcore-validation-python/dist/type-capability-run.js", "node_modules/@the-open-engine/opcore-validation-python/dist/type-check.d.ts", "node_modules/@the-open-engine/opcore-validation-python/dist/type-check.d.ts.map", "node_modules/@the-open-engine/opcore-validation-python/dist/type-check.js", + "node_modules/@the-open-engine/opcore-validation-python/dist/type-result.d.ts", + "node_modules/@the-open-engine/opcore-validation-python/dist/type-result.d.ts.map", + "node_modules/@the-open-engine/opcore-validation-python/dist/type-result.js", + "node_modules/@the-open-engine/opcore-validation-python/dist/type-runner-runtime.d.ts", + "node_modules/@the-open-engine/opcore-validation-python/dist/type-runner-runtime.d.ts.map", + "node_modules/@the-open-engine/opcore-validation-python/dist/type-runner-runtime.js", + "node_modules/@the-open-engine/opcore-validation-python/dist/type-runner-types.d.ts", + "node_modules/@the-open-engine/opcore-validation-python/dist/type-runner-types.d.ts.map", + "node_modules/@the-open-engine/opcore-validation-python/dist/type-runner-types.js", + "node_modules/@the-open-engine/opcore-validation-python/dist/version-constraint.d.ts", + "node_modules/@the-open-engine/opcore-validation-python/dist/version-constraint.d.ts.map", + "node_modules/@the-open-engine/opcore-validation-python/dist/version-constraint.js", "node_modules/@the-open-engine/opcore-validation-python/package.json", "node_modules/@the-open-engine/opcore-validation-rust/README.md", "node_modules/@the-open-engine/opcore-validation-rust/dist/cargo-check.d.ts", @@ -580,6 +679,9 @@ "node_modules/@the-open-engine/opcore-validation/dist/request.d.ts", "node_modules/@the-open-engine/opcore-validation/dist/request.d.ts.map", "node_modules/@the-open-engine/opcore-validation/dist/request.js", + "node_modules/@the-open-engine/opcore-validation/dist/resources.d.ts", + "node_modules/@the-open-engine/opcore-validation/dist/resources.d.ts.map", + "node_modules/@the-open-engine/opcore-validation/dist/resources.js", "node_modules/@the-open-engine/opcore-validation/dist/runner.d.ts", "node_modules/@the-open-engine/opcore-validation/dist/runner.d.ts.map", "node_modules/@the-open-engine/opcore-validation/dist/runner.js", @@ -758,6 +860,13 @@ "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", @@ -810,6 +919,25 @@ "node_modules/fdir/dist/index.d.mts", "node_modules/fdir/dist/index.mjs", "node_modules/fdir/package.json", + "node_modules/jsonc-parser/CHANGELOG.md", + "node_modules/jsonc-parser/LICENSE.md", + "node_modules/jsonc-parser/README.md", + "node_modules/jsonc-parser/SECURITY.md", + "node_modules/jsonc-parser/lib/esm/impl/edit.js", + "node_modules/jsonc-parser/lib/esm/impl/format.js", + "node_modules/jsonc-parser/lib/esm/impl/parser.js", + "node_modules/jsonc-parser/lib/esm/impl/scanner.js", + "node_modules/jsonc-parser/lib/esm/impl/string-intern.js", + "node_modules/jsonc-parser/lib/esm/main.d.ts", + "node_modules/jsonc-parser/lib/esm/main.js", + "node_modules/jsonc-parser/lib/umd/impl/edit.js", + "node_modules/jsonc-parser/lib/umd/impl/format.js", + "node_modules/jsonc-parser/lib/umd/impl/parser.js", + "node_modules/jsonc-parser/lib/umd/impl/scanner.js", + "node_modules/jsonc-parser/lib/umd/impl/string-intern.js", + "node_modules/jsonc-parser/lib/umd/main.d.ts", + "node_modules/jsonc-parser/lib/umd/main.js", + "node_modules/jsonc-parser/package.json", "node_modules/minimatch/LICENSE.md", "node_modules/minimatch/README.md", "node_modules/minimatch/dist/commonjs/assert-valid-pattern.d.ts", @@ -949,6 +1077,28 @@ "node_modules/semver/ranges/subset.js", "node_modules/semver/ranges/to-comparators.js", "node_modules/semver/ranges/valid.js", + "node_modules/smol-toml/LICENSE", + "node_modules/smol-toml/README.md", + "node_modules/smol-toml/dist/date.d.ts", + "node_modules/smol-toml/dist/date.js", + "node_modules/smol-toml/dist/error.d.ts", + "node_modules/smol-toml/dist/error.js", + "node_modules/smol-toml/dist/extract.d.ts", + "node_modules/smol-toml/dist/extract.js", + "node_modules/smol-toml/dist/index.cjs", + "node_modules/smol-toml/dist/index.d.ts", + "node_modules/smol-toml/dist/index.js", + "node_modules/smol-toml/dist/parse.d.ts", + "node_modules/smol-toml/dist/parse.js", + "node_modules/smol-toml/dist/primitive.d.ts", + "node_modules/smol-toml/dist/primitive.js", + "node_modules/smol-toml/dist/stringify.d.ts", + "node_modules/smol-toml/dist/stringify.js", + "node_modules/smol-toml/dist/struct.d.ts", + "node_modules/smol-toml/dist/struct.js", + "node_modules/smol-toml/dist/util.d.ts", + "node_modules/smol-toml/dist/util.js", + "node_modules/smol-toml/package.json", "node_modules/tinyglobby/LICENSE", "node_modules/tinyglobby/README.md", "node_modules/tinyglobby/dist/index.cjs", diff --git a/tests/gate-negative-fixtures.test.mjs b/tests/gate-negative-fixtures.test.mjs index bed3f5f..6e6e276 100644 --- a/tests/gate-negative-fixtures.test.mjs +++ b/tests/gate-negative-fixtures.test.mjs @@ -16,6 +16,7 @@ import { releaseReceiptPackageNames } from "../packages/contracts/dist/index.js"; import { bundledExternalRuntimePackageNames } from "../scripts/release-package-dirs.mjs"; +import { externalRuntimePackageDir } from "../scripts/stage-opcore-bundle.mjs"; const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); const releaseDocsLockTimeoutMs = 900000; @@ -516,8 +517,7 @@ function tempRepo(options = {}) { function copyBundledExternalRuntimePackages(repo) { for (const packageName of bundledExternalRuntimePackageNames) { - const source = join(repoRoot, "node_modules", ...packageName.split("/")); - if (!existsSync(source)) throw new Error(`Missing external runtime package fixture source: node_modules/${packageName}`); + const source = externalRuntimePackageDir(packageName); const destination = join(repo, "node_modules", ...packageName.split("/")); mkdirSync(dirname(destination), { recursive: true }); cpSync(source, destination, { recursive: true }); @@ -665,9 +665,9 @@ function minimalCutoverReceipt(repo, commandOverrides = {}) { packageNames, installedPackages: packageNames.map((packageName) => ({ packageName, - version: "0.2.0", + version: "0.2.1", tarball: { - filename: `${packageName.replace("@the-open-engine/", "the-open-engine-").replace("/", "-")}-0.2.0.tgz`, + filename: `${packageName.replace("@the-open-engine/", "the-open-engine-").replace("/", "-")}-0.2.1.tgz`, sha256: "1".repeat(64) }, installedManifest: { diff --git a/tests/graph-core-artifact.test.mjs b/tests/graph-core-artifact.test.mjs index e1505bf..257cbd8 100644 --- a/tests/graph-core-artifact.test.mjs +++ b/tests/graph-core-artifact.test.mjs @@ -25,7 +25,7 @@ describe("graph-core native artifact", () => { assert.equal(result.ok, false); assert.equal(result.status.state, "required_missing"); assert.equal(result.status.failure.category, "provider_missing"); - assert.match(result.status.message, /Opcore 0\.2\.0 supports darwin-arm64, darwin-x64, linux-x64/); + assert.match(result.status.message, /Opcore 0\.2\.1 supports darwin-arm64, darwin-x64, linux-x64/); assert.match(result.status.message, /Windows is not supported/); assert.deepEqual(attempted, []); }); @@ -49,7 +49,7 @@ describe("graph-core native artifact", () => { mkdirSync(join(temp, "bin"), { recursive: true }); mkdirSync(join(temp, "packages", "graph"), { recursive: true }); mkdirSync(join(temp, "packages", "opcore-graph-core-linux-x64"), { recursive: true }); - writeFileSync(join(temp, "packages", "graph", "package.json"), `${JSON.stringify({ version: "0.2.0" })}\n`); + writeFileSync(join(temp, "packages", "graph", "package.json"), `${JSON.stringify({ version: "0.2.1" })}\n`); const cargoStub = join(temp, "bin", "cargo"); writeFileSync( cargoStub, diff --git a/tests/graph-ephemeral-snapshot.test.mjs b/tests/graph-ephemeral-snapshot.test.mjs new file mode 100644 index 0000000..a86190a --- /dev/null +++ b/tests/graph-ephemeral-snapshot.test.mjs @@ -0,0 +1,350 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs"; +import { createHash } from "node:crypto"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { createEphemeralGraphSnapshot, graphProviderBuild, graphProviderQuery } from "../packages/graph/dist/index.js"; +import { createEphemeralGraphSnapshotWithOperations } from "../packages/graph/dist/ephemeral-snapshot.js"; +import { + createStateAwareValidationGraphSessionFactory, + createValidationExactGraphSnapshotFactory, + createValidationRunner +} from "../packages/validation/dist/index.js"; + +describe("ephemeral graph snapshots", () => { + it("materializes supported sources, builds once, binds logical metadata, and disposes idempotently", async () => { + let builds = 0; + let materializedRepo; + const logicalRepo = { repoId: "target", repoRoot: "/target/repo" }; + const available = graphStatus(); + const snapshot = await createEphemeralGraphSnapshotWithOperations({ + logicalRepo, + sourceUniverse: { + paths: ["README.md", "src/a.py", "src/b.ts", "src/common.cts", "src/module.mts"], + complete: true + }, + readFile: (path) => ({ status: "found", content: `content:${path}` }) + }, { + build: (repo) => { + builds += 1; + materializedRepo = repo.repoRoot; + assert.equal(readFileSync(join(materializedRepo, "src/a.py"), "utf8"), "content:src/a.py"); + assert.equal(readFileSync(join(materializedRepo, "src/common.cts"), "utf8"), "content:src/common.cts"); + assert.equal(readFileSync(join(materializedRepo, "src/module.mts"), "utf8"), "content:src/module.mts"); + assert.equal(existsSync(join(materializedRepo, "README.md")), false); + return { status: available }; + }, + factQuery: (_repo, request) => ({ + requestId: request.requestId, + status: available, + metadata: { + schemaVersion: 1, + provider: "opcore-graph", + repo: { repoRoot: materializedRepo }, + generatedAt: "2026-07-16T00:00:00.000Z", + freshness: { generatedAt: "2026-07-16T00:00:00.000Z", ageMs: 0, stale: false }, + nodeKinds: [], + edgeKinds: [] + }, + nodes: [], + edges: [] + }) + }); + + const result = snapshot.factQuery({ + requestId: "exact-query", + repo: logicalRepo, + schemaVersion: 1, + mode: "optional", + selector: { kind: "nodes" } + }); + assert.equal(builds, 1); + assert.deepEqual(snapshot.materializedPaths, ["src/a.py", "src/b.ts", "src/common.cts", "src/module.mts"]); + assert.deepEqual(result.metadata.repo, logicalRepo); + assert.equal(result.status.mode, "optional"); + snapshot.dispose(); + snapshot.dispose(); + assert.equal(existsSync(materializedRepo), false); + }); + + it("rejects incomplete universes and removes materialized state after build errors", async () => { + await assert.rejects(createEphemeralGraphSnapshotWithOperations({ + logicalRepo: { repoId: "target" }, + sourceUniverse: { paths: ["src/a.py"], complete: false, message: "tree truncated" }, + readFile: () => ({ status: "found", content: "VALUE = 1\n" }) + }, operations()), /tree truncated/); + + let snapshotRepo; + await assert.rejects(createEphemeralGraphSnapshotWithOperations({ + logicalRepo: { repoId: "target" }, + sourceUniverse: { paths: ["src/a.py"], complete: true }, + readFile: () => ({ status: "found", content: "VALUE = 1\n" }) + }, operations({ + build: (repo) => { + snapshotRepo = repo.repoRoot; + throw new Error("forced build failure"); + } + })), /forced build failure/); + assert.equal(existsSync(snapshotRepo), false); + + for (const testCase of [ + { paths: ["a.py", "b.py"], limits: { maxFiles: 1 }, message: /maxFiles/ }, + { paths: ["a/b/c.py"], limits: { maxDepth: 2 }, message: /maxDepth/ }, + { paths: ["a.py"], limits: { maxBytes: 1 }, message: /maxBytes/ } + ]) { + await assert.rejects(createEphemeralGraphSnapshotWithOperations({ + logicalRepo: { repoId: "target" }, + sourceUniverse: { paths: testCase.paths, complete: true }, + readFile: () => ({ status: "found", content: "VALUE = 1\n" }), + limits: testCase.limits + }, operations()), testCase.message); + } + }); + + it("matches real on-disk Python import edges for add, remove, retarget, and rename-style states without touching the target", async () => { + const target = mkdtempSync(join(tmpdir(), "opcore-graph-snapshot-target-")); + try { + writeTree(target, new Map([ + ["pkg/__init__.py", ""], + ["pkg/app.py", "from pkg import old\n"], + ["pkg/old.py", "VALUE = 'old'\n"], + [".opcore/graph/sentinel", "persistent-graph\n"] + ])); + const targetBefore = targetEvidence(target); + const states = [ + new Map([ + ["pkg/__init__.py", ""], + ["pkg/app.py", "from pkg import new\n"], + ["pkg/old.py", "VALUE = 'old'\n"], + ["pkg/new.py", "VALUE = 'new'\n"] + ]), + new Map([ + ["pkg/__init__.py", ""], + ["pkg/app.py", "VALUE = 1\n"] + ]), + new Map([ + ["pkg/__init__.py", ""], + ["pkg/app.py", "from pkg import renamed\n"], + ["pkg/renamed.py", "VALUE = 'renamed'\n"] + ]) + ]; + + for (const state of states) { + assert.deepEqual(await ephemeralImportEdges(target, state), materializedImportEdges(state)); + } + assert.deepEqual(targetEvidence(target), targetBefore); + } finally { + rmSync(target, { recursive: true, force: true }); + } + }); + + it("produces the same introduced graph diagnostic as the equivalent materialized repository and preserves the persistent target graph", async () => { + const target = mkdtempSync(join(tmpdir(), "opcore-exact-validation-target-")); + const materialized = mkdtempSync(join(tmpdir(), "opcore-exact-validation-after-")); + try { + const beforeFiles = new Map([ + ["pkg/__init__.py", ""], + ["pkg/app.py", "from pkg import old\n"], + ["pkg/old.py", "VALUE = 'old'\n"] + ]); + const afterFiles = new Map([ + ["pkg/__init__.py", ""], + ["pkg/app.py", "from pkg import new\n"], + ["pkg/old.py", "VALUE = 'old'\n"], + ["pkg/new.py", "VALUE = 'new'\n"] + ]); + writeTree(target, beforeFiles); + writeTree(materialized, afterFiles); + assert.equal(graphProviderBuild({ repoRoot: target }).status.state, "available"); + assert.equal(graphProviderBuild({ repoRoot: materialized }).status.state, "available"); + const targetGraphBefore = directoryEvidence(join(target, ".opcore/graph")); + const targetSourceBefore = readFileSync(join(target, "pkg/app.py"), "utf8"); + const client = realGraphClient(); + const check = newImportCheck(); + const exact = await createValidationRunner({ + workspace: validationWorkspace(beforeFiles), + checks: [check], + graphProviderClient: client, + graphSessionFactory: createStateAwareValidationGraphSessionFactory({ + persistentClient: client, + exactSnapshotFactory: createValidationExactGraphSnapshotFactory(createEphemeralGraphSnapshot) + }) + }).runValidation(validationRequest(target, { + reportMode: "introduced", + overlays: [ + { path: "pkg/app.py", action: "write", content: afterFiles.get("pkg/app.py") }, + { path: "pkg/new.py", action: "write", content: afterFiles.get("pkg/new.py") } + ] + })); + const onDisk = await createValidationRunner({ + workspace: validationWorkspace(afterFiles), + checks: [check], + graphProviderClient: client + }).runValidation(validationRequest(materialized)); + + assert.equal(exact.status, "policy_failure", JSON.stringify(exact, null, 2)); + assert.deepEqual(exact.diagnostics, onDisk.diagnostics); + assert.equal(readFileSync(join(target, "pkg/app.py"), "utf8"), targetSourceBefore); + assert.deepEqual(directoryEvidence(join(target, ".opcore/graph")), targetGraphBefore); + } finally { + rmSync(target, { recursive: true, force: true }); + rmSync(materialized, { recursive: true, force: true }); + } + }); +}); + +function newImportCheck() { + return { + id: "python.new-import", + owner: "validation", + adapter: "python", + defaultSeverity: "error", + supportedScopes: ["files"], + requiresGraph: true, + graphRequirements: () => [{ operation: "factQuery", selector: { kind: "edges", edgeKinds: ["IMPORTS_FROM"] } }], + run: async (context) => ({ + diagnostics: (await context.graph.importsFrom()) + .filter((edge) => edge.from === "file:pkg/app.py" && edge.to === "file:pkg/new.py") + .map(() => ({ + category: "graph", + severity: "error", + path: "pkg/app.py", + code: "PY_NEW_IMPORT", + message: "pkg/app.py newly imports pkg/new.py" + })) + }) + }; +} + +function validationRequest(repoRoot, overrides = {}) { + return { + requestId: "exact-validation", + repo: { repoRoot }, + scope: { kind: "files", files: ["pkg/app.py"] }, + graph: { mode: "required", provider: "opcore-graph" }, + overlays: [], + checks: ["python.new-import"], + reportMode: "all", + ...overrides + }; +} + +function validationWorkspace(files) { + return { + readFile: (path) => files.has(path) ? { status: "found", content: files.get(path) } : { status: "missing" }, + listFiles: () => ({ files: [...files.keys()] }) + }; +} + +function realGraphClient() { + return { + status: (request) => ({ ...graphProviderBuildStatus(request.repo), mode: request.graph.mode }), + factQuery: (request) => graphProviderQuery(request.repo, request.selector), + namedQuery: () => { throw new Error("unused namedQuery"); }, + impact: () => { throw new Error("unused impact"); }, + reviewContext: () => { throw new Error("unused reviewContext"); }, + detectChanges: () => { throw new Error("unused detectChanges"); } + }; +} + +function graphProviderBuildStatus(repo) { + const query = graphProviderQuery(repo, { kind: "nodes", limit: 1 }); + return query.status; +} + +async function ephemeralImportEdges(target, files) { + const snapshot = await createEphemeralGraphSnapshot({ + logicalRepo: { repoRoot: target }, + sourceUniverse: { paths: [...files.keys()], complete: true }, + readFile: (path) => files.has(path) ? { status: "found", content: files.get(path) } : { status: "missing" } + }); + try { + const result = snapshot.factQuery({ + requestId: "ephemeral-imports", + repo: { repoRoot: target }, + schemaVersion: 1, + mode: "required", + selector: { kind: "edges", edgeKinds: ["IMPORTS_FROM"] } + }); + assert.equal(result.status.state, "available"); + return importEdgePairs(result.edges); + } finally { + snapshot.dispose(); + } +} + +function materializedImportEdges(files) { + const root = mkdtempSync(join(tmpdir(), "opcore-graph-snapshot-parity-")); + try { + writeTree(root, files); + assert.equal(graphProviderBuild({ repoRoot: root }).status.state, "available"); + const result = graphProviderQuery({ repoRoot: root }, { kind: "edges", edgeKinds: ["IMPORTS_FROM"] }); + assert.equal(result.status.state, "available"); + return importEdgePairs(result.edges); + } finally { + rmSync(root, { recursive: true, force: true }); + } +} + +function importEdgePairs(edges) { + return edges + .filter((edge) => edge.kind === "IMPORTS_FROM" && edge.from.startsWith("file:pkg/") && edge.to.startsWith("file:pkg/")) + .map((edge) => `${edge.from}->${edge.to}`) + .sort(); +} + +function writeTree(root, files) { + for (const [path, content] of files) { + const absolute = join(root, path); + mkdirSync(dirname(absolute), { recursive: true }); + writeFileSync(absolute, content); + } +} + +function targetEvidence(root) { + return { + app: readFileSync(join(root, "pkg/app.py"), "utf8"), + old: readFileSync(join(root, "pkg/old.py"), "utf8"), + graph: readFileSync(join(root, ".opcore/graph/sentinel"), "utf8") + }; +} + +function directoryEvidence(root) { + const evidence = {}; + for (const path of walk(root)) { + const relativePath = path.slice(root.length + 1); + evidence[relativePath] = createHash("sha256").update(readFileSync(path)).digest("hex"); + } + return evidence; +} + +function walk(root) { + const paths = []; + for (const entry of readdirSync(root, { withFileTypes: true })) { + const path = join(root, entry.name); + if (entry.isDirectory()) paths.push(...walk(path)); + else if (entry.isFile() && statSync(path).isFile()) paths.push(path); + } + return paths.sort(); +} + +function operations(overrides = {}) { + return { + build: () => ({ status: graphStatus() }), + factQuery: () => { throw new Error("unused query"); }, + ...overrides + }; +} + +function graphStatus() { + return { + state: "available", + mode: "required", + provider: "opcore-graph", + schemaVersion: 1, + freshness: { generatedAt: "2026-07-16T00:00:00.000Z", ageMs: 0, stale: false }, + nodes_by_kind: {}, + edges_by_kind: {} + }; +} diff --git a/tests/graph-python-import-analysis.test.mjs b/tests/graph-python-import-analysis.test.mjs new file mode 100644 index 0000000..6a9dd15 --- /dev/null +++ b/tests/graph-python-import-analysis.test.mjs @@ -0,0 +1,112 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { existsSync, readdirSync, readFileSync } from "node:fs"; +import { dirname, join, relative } from "node:path"; +import { fileURLToPath } from "node:url"; +import { analyzePythonImports } from "../packages/graph/dist/index.js"; +import { analyzePythonImportsWithGraph } from "../packages/graph/dist/python-import-analysis.js"; + +const repoRoot = fileURLToPath(new URL("..", import.meta.url)); +const fixtureRoot = join(repoRoot, "packages/fixtures/source-extraction/python"); +const expected = JSON.parse(readFileSync(join(fixtureRoot, "python.expected.json"), "utf8")); + +describe("graph Python import analysis adapter", () => { + it("returns canonical directed graph-core edges for supplied Python files", async () => { + const edges = await analyzePythonImports([ + { path: "src/app.py", content: "from pkg import (\n dep as renamed,\n)\n" }, + { path: "src/pkg/__init__.py", content: "" }, + { path: "src/pkg/dep.py", content: "VALUE = 1\n" } + ]); + + assert.deepEqual(edges, [{ fromPath: "src/app.py", toPath: "src/pkg/dep.py" }]); + }); + + it("matches the shared Python fixture edge matrix without mutating source files", async () => { + const beforeFiles = fixtureFiles(); + const edges = await analyzePythonImports( + [...beforeFiles].map(([path, content]) => ({ path, content })) + ); + + assert.deepEqual(edges, expected.pythonImportEdges); + assert.deepEqual(fixtureFiles(), beforeFiles); + }); + + it("rejects unsafe or malformed supplied-file paths before native analysis", async () => { + await assert.rejects( + analyzePythonImports([{ path: "../outside.py", content: "VALUE = 1\n" }]), + /repo-relative|traversal|invalid/i + ); + await assert.rejects( + analyzePythonImports([{ path: "src/app.ts", content: "export const value = 1;\n" }]), + /only \.py\/\.pyi/i + ); + }); + + it("fails loudly and removes its temporary state when graph analysis fails", async () => { + let analysisRoot; + await assert.rejects( + analyzePythonImportsWithGraph( + [{ path: "src/app.py", content: "VALUE = 1\n" }], + { + build(repo) { + analysisRoot = dirname(repo.repoRoot); + assert.equal(existsSync(analysisRoot), true); + throw new Error("native build unavailable"); + }, + query() { + throw new Error("query must not run"); + } + } + ), + /native build unavailable/ + ); + assert.equal(typeof analysisRoot, "string"); + assert.equal(existsSync(analysisRoot), false); + }); + + it("rejects unavailable and malformed graph query results instead of returning empty edges", async () => { + const available = { state: "available", mode: "required", provider: "opcore-graph", schemaVersion: 1 }; + const build = () => ({ status: available }); + await assert.rejects( + analyzePythonImportsWithGraph([{ path: "src/app.py", content: "VALUE = 1\n" }], { + build, + query: () => ({ + status: { + state: "required_missing", + mode: "required", + provider: "opcore-graph", + schemaVersion: 1, + message: "native query unavailable" + } + }) + }), + /query failed.*native query unavailable/i + ); + await assert.rejects( + analyzePythonImportsWithGraph([{ path: "src/app.py", content: "VALUE = 1\n" }], { + build, + query: () => ({ status: available }) + }), + /query failed.*available/i + ); + }); +}); + +function fixtureFiles() { + return new Map( + walkFiles(fixtureRoot) + .filter((path) => path.endsWith(".py") || path.endsWith(".pyi")) + .map((path) => [relative(fixtureRoot, path).replaceAll("\\", "/"), readFileSync(path, "utf8")]) + ); +} + +function walkFiles(root) { + const files = []; + for (const entry of readdirSync(root, { withFileTypes: true })) { + const path = join(root, entry.name); + if (entry.isDirectory() && entry.name !== ".opcore") files.push(...walkFiles(path)); + else if (entry.isFile()) files.push(path); + } + return files.sort(); +} + diff --git a/tests/graph-serve-transport.test.mjs b/tests/graph-serve-transport.test.mjs index 38c9d9e..f3e94d8 100644 --- a/tests/graph-serve-transport.test.mjs +++ b/tests/graph-serve-transport.test.mjs @@ -162,7 +162,7 @@ describe("graph serve stdio transport", () => { ok: true, artifact: { artifactName: "opcore-graph-core", - artifactVersion: "0.2.0", + artifactVersion: "0.2.1", targetPlatform: "test", binaryPath: "dist/native/test/opcore-graph-core", checksumPath: "dist/native/test/opcore-graph-core.sha256", diff --git a/tests/graph-sidecar-transport.test.mjs b/tests/graph-sidecar-transport.test.mjs index 86c0ca1..5032927 100644 --- a/tests/graph-sidecar-transport.test.mjs +++ b/tests/graph-sidecar-transport.test.mjs @@ -165,7 +165,7 @@ function buildResponse(repoRoot, changedFiles, requestId) { function testArtifact(temp, executablePath) { return { artifactName: "opcore-graph-core", - artifactVersion: "0.2.0", + artifactVersion: "0.2.1", targetPlatform: currentTarget, binaryPath: "opcore-graph-core", checksumPath: "opcore-graph-core.sha256", diff --git a/tests/graph-store-conformance.test.mjs b/tests/graph-store-conformance.test.mjs index 24dab7d..4f8cd3d 100644 --- a/tests/graph-store-conformance.test.mjs +++ b/tests/graph-store-conformance.test.mjs @@ -119,21 +119,8 @@ describe("GraphProvider SQLite store conformance", () => { const status = graphProviderStatus({ repoRoot: fixtureRoot }); assert.equal(status.state, "available"); assert.equal(status.dbPath, refresh.dbPath); - assert.deepEqual(status.nodes_by_kind, { - Class: 3, - File: 9, - Function: 12, - Module: 9, - Variable: 2 - }); - assert.deepEqual(status.edges_by_kind, { - CALLS: 11, - CONTAINS: 26, - DEPENDS_ON: 7, - IMPORTS_FROM: 7, - INHERITS: 1, - TESTED_BY: 3 - }); + assert.deepEqual(status.nodes_by_kind, expectedPythonNodeCounts()); + assert.deepEqual(status.edges_by_kind, expectedPythonEdgeCounts()); const rawStatus = rawGraphStatus(fixtureRoot); assert.equal(rawStatus.state, "available"); assert.deepEqual(rawStatus.nodes_by_kind, status.nodes_by_kind); @@ -150,28 +137,15 @@ describe("GraphProvider SQLite store conformance", () => { assert.equal(db.prepare("pragma user_version").get().user_version, 1); assert.deepEqual( plainRows(db.prepare("select kind, count(*) as count from nodes group by kind order by kind").all()), - [ - { kind: "Class", count: 3 }, - { kind: "File", count: 9 }, - { kind: "Function", count: 12 }, - { kind: "Module", count: 9 }, - { kind: "Variable", count: 2 } - ] + Object.entries(expectedPythonNodeCounts()).map(([kind, count]) => ({ kind, count })) ); assert.deepEqual( plainRows(db.prepare("select kind, count(*) as count from edges group by kind order by kind").all()), - [ - { kind: "CALLS", count: 11 }, - { kind: "CONTAINS", count: 26 }, - { kind: "DEPENDS_ON", count: 7 }, - { kind: "IMPORTS_FROM", count: 7 }, - { kind: "INHERITS", count: 1 }, - { kind: "TESTED_BY", count: 3 } - ] + Object.entries(expectedPythonEdgeCounts()).map(([kind, count]) => ({ kind, count })) ); assert.deepEqual( plainRows(db.prepare("select language, count(*) as count from file_hashes group by language").all()), - [{ language: "python", count: 9 }] + [{ language: "python", count: expectedPythonNodeCounts().File }] ); assert.deepEqual( Object.fromEntries( @@ -234,7 +208,13 @@ describe("GraphProvider SQLite store conformance", () => { { queryKind: "importers_of", target: "src/pkg/models.py", maxDepth: 2, limit: 100 } ); assert.equal(importers.status.state, "available"); - assert.deepEqual(paths(importers.nodes), ["src/pkg/__init__.py", "src/pkg/models.py", "tests/test_models.py"]); + assert.deepEqual(paths(importers.nodes), [ + "src/cases/docstring_case.py", + "src/cases/package_case.py", + "src/pkg/__init__.py", + "src/pkg/models.py", + "tests/test_models.py" + ]); const tests = graphProviderNamedQuery( { repoRoot: fixtureRoot }, @@ -759,6 +739,29 @@ function assertFreshMetadataTimestamp(value, minEpochMs) { assert.equal(parsed <= Date.now() + 5000, true, value); } +function expectedPythonNodeCounts() { + const kindByPrefix = { + class: "Class", + file: "File", + function: "Function", + module: "Module", + variable: "Variable" + }; + return countBy( + pythonExpected.nodeIds.map((id) => kindByPrefix[id.slice(0, id.indexOf(":"))]) + ); +} + +function expectedPythonEdgeCounts() { + return countBy(pythonExpected.edgeTriples.map(([kind]) => kind)); +} + +function countBy(values) { + const counts = new Map(); + for (const value of values) counts.set(value, (counts.get(value) ?? 0) + 1); + return Object.fromEntries([...counts].sort(([left], [right]) => left.localeCompare(right))); +} + function edgeTriples(edges) { return edges.map((edge) => [edge.kind, edge.from, edge.to]).sort(compareTuple); } diff --git a/tests/helpers/asp-dogfood-fixture.mjs b/tests/helpers/asp-dogfood-fixture.mjs index 3af469b..d065bf9 100644 --- a/tests/helpers/asp-dogfood-fixture.mjs +++ b/tests/helpers/asp-dogfood-fixture.mjs @@ -66,8 +66,8 @@ function installedPackagesFixture() { return releaseReceiptPackageNames .map((packageName) => ({ packageName, - version: "0.2.0", - tarball: { filename: `${packageName.split("/").pop()}-0.2.0.tgz`, sha256: "1".repeat(64) }, + version: "0.2.1", + tarball: { filename: `${packageName.split("/").pop()}-0.2.1.tgz`, sha256: "1".repeat(64) }, installedManifest: { path: `node_modules/${packageName}/package.json`, sha256: "2".repeat(64), bins: binsFor(packageName) }, installedFiles: installedFilesFor(packageName) })); @@ -144,7 +144,7 @@ function providerFixture() { function serverManifestFixture() { return { manifestVersion: "asp-server/0.1", - server: { id: "opcore", name: "Opcore", version: "0.2.0" }, + server: { id: "opcore", name: "Opcore", version: "0.2.1" }, protocolVersions: ["asp/0.1"], capabilities: ["check"], capabilityProfiles: ["core-check-provider", "opcore-core-check"], diff --git a/tests/helpers/validation-rust-fixtures.mjs b/tests/helpers/validation-rust-fixtures.mjs index 7da8c5f..2d8dbd6 100644 --- a/tests/helpers/validation-rust-fixtures.mjs +++ b/tests/helpers/validation-rust-fixtures.mjs @@ -45,7 +45,7 @@ export function request(overrides = {}) { export function rustCrate(overrides = {}) { return { "Cargo.toml": '[workspace]\nmembers = ["crates/app"]\nresolver = "2"\n', - "crates/app/Cargo.toml": '[package]\nname = "app"\nversion = "0.2.0"\nedition = "2021"\n', + "crates/app/Cargo.toml": '[package]\nname = "app"\nversion = "0.2.1"\nedition = "2021"\n', "crates/app/src/lib.rs": "pub fn answer() -> i32 { 42 }\n", ...overrides }; @@ -105,10 +105,11 @@ export function fakeCargoScript({ udepsVersionStatus = 0, metadata = defaultCargoMetadata(), logPath, + logCwd = false, logEnvKeys = [] } = {}) { const metadataJson = typeof metadata === "string" ? metadata : JSON.stringify(metadata); - const logLine = cargoInvocationLogLine(logPath, logEnvKeys); + const logLine = cargoInvocationLogLine(logPath, logEnvKeys, logCwd); return [ "#!/bin/sh", logLine, @@ -223,15 +224,16 @@ function shellEscapeSingleQuoted(value) { return String(value).replaceAll("'", "'\\''"); } -function cargoInvocationLogLine(logPath, logEnvKeys) { +function cargoInvocationLogLine(logPath, logEnvKeys, logCwd) { if (logPath === undefined) return ""; const escapedLogPath = shellEscapeSingleQuoted(logPath); - if (logEnvKeys.length === 0) return `printf '%s\\n' "$*" >> '${escapedLogPath}'`; + if (logEnvKeys.length === 0 && !logCwd) return `printf '%s\\n' "$*" >> '${escapedLogPath}'`; const envWrites = logEnvKeys.map((key) => { if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) throw new Error(`Invalid env key for fake cargo log: ${key}`); return `printf '\\t%s=%s' '${shellEscapeSingleQuoted(key)}' "$${key}" >> '${escapedLogPath}'`; }); - return [`printf '%s' "$*" >> '${escapedLogPath}'`, ...envWrites, `printf '\\n' >> '${escapedLogPath}'`].join("\n"); + const cwdWrite = logCwd ? [`printf '\\tCWD=%s' "$(pwd -P)" >> '${escapedLogPath}'`] : []; + return [`printf '%s' "$*" >> '${escapedLogPath}'`, ...envWrites, ...cwdWrite, `printf '\\n' >> '${escapedLogPath}'`].join("\n"); } function writeExecutable(path, content) { diff --git a/tests/import-boundaries.test.mjs b/tests/import-boundaries.test.mjs index 4ca5342..276fe7d 100644 --- a/tests/import-boundaries.test.mjs +++ b/tests/import-boundaries.test.mjs @@ -129,7 +129,7 @@ describe("package import boundaries", () => { } if (packageDir === "validation-python") { assert.equal( - ["@the-open-engine/opcore-contracts", "@the-open-engine/opcore-validation"].includes(source), + ["@the-open-engine/opcore-contracts", "@the-open-engine/opcore-validation", "jsonc-parser", "smol-toml"].includes(source), true, `${file} imports ${source}` ); diff --git a/tests/installed-bins.test.mjs b/tests/installed-bins.test.mjs index c334416..abef8f9 100644 --- a/tests/installed-bins.test.mjs +++ b/tests/installed-bins.test.mjs @@ -1,7 +1,7 @@ import { describe, it } from "node:test"; import assert from "node:assert/strict"; import { createHash } from "node:crypto"; -import { spawnSync } from "node:child_process"; +import { spawn, spawnSync } from "node:child_process"; import { existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; @@ -23,7 +23,7 @@ const currentNativePackage = graphCoreNativePackageNamesByTarget[currentTarget]; const packageNames = ["opcore"]; describe("installed package bins", () => { - it("installs the packed Opcore package and exposes only canonical Opcore bins", { timeout: 120000 }, () => { + it("installs the packed Opcore package and exposes only canonical Opcore bins", { timeout: 120000 }, async () => { assert.ok(currentNativePackage, `unsupported local graph-core target ${currentTarget}`); const temp = mkdtempSync(join(tmpdir(), "lattice-installed-bins-")); try { @@ -31,10 +31,20 @@ describe("installed package bins", () => { const project = join(temp, "project"); mkdirSync(project); run("npm", ["init", "-y"], { cwd: project }); - run("npm", ["install", "--ignore-scripts", ...tarballs], { cwd: project }); + run("npm", ["install", "--ignore-scripts", "--no-audit", "--no-fund", ...tarballs], { cwd: project }); + mkdirSync(join(project, "services", "api", "src"), { recursive: true }); + writeFileSync(join(project, "pyproject.toml"), "[project]\nname='root-fixture'\nrequires-python='>=3.8'\n"); + writeFileSync(join(project, "root.py"), "ROOT = 1\n"); + writeFileSync( + join(project, "services", "api", "pyproject.toml"), + "[project]\nname='api-fixture'\nrequires-python='>=3.8'\n[tool.uv]\npackage=true\n" + ); + writeFileSync(join(project, "services", "api", "uv.lock"), "version=1\n"); + writeFileSync(join(project, "services", "api", "src", "app.py"), "VALUE = 1\n"); 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); @@ -43,15 +53,46 @@ describe("installed package bins", () => { assert.deepEqual(opcoreStatus.canonicalCommand, ["opcore", "status"]); assert.equal(Object.hasOwn(opcoreStatus, "repoState"), true); assert.equal(Object.hasOwn(opcoreStatus, "validationResult"), false); + const nestedStatusContext = pythonContextFor(opcoreStatus.repoState.validation.pythonProjectContexts, "services/api/src/app.py"); + assert.equal(nestedStatusContext.projectRoot, "services/api"); + assert.deepEqual(nestedStatusContext.managers.map((manager) => manager.kind), ["uv"]); + const aspContext = await evaluateInstalledAspPythonContext(project); + assert.deepEqual(pythonContextIdentity(aspContext), pythonContextIdentity(nestedStatusContext)); const opcoreScan = assertSmoke(project, ["--json"], 0, "opcore"); assert.deepEqual(opcoreScan.canonicalCommand, ["opcore", "scan"]); assert.equal(Object.hasOwn(opcoreScan, "validationResult"), true); + assert.deepEqual( + pythonContextIdentity(pythonContextFor(opcoreScan.validationResult.pythonProjectContexts, "services/api/src/app.py")), + pythonContextIdentity(nestedStatusContext) + ); + const metricReport = JSON.parse(readFileSync(join(project, ".opcore", "report.json"), "utf8")); + assert.deepEqual( + pythonContextIdentity(pythonContextFor(metricReport.validation.pythonProjectContexts, "services/api/src/app.py")), + pythonContextIdentity(nestedStatusContext) + ); + const opcoreCheck = assertSmoke(project, [ + "check", + "files", + "--files", + "root.py,services/api/src/app.py", + "--checks", + "python.source-hygiene", + "--json" + ], 0, "opcore"); + assert.deepEqual( + pythonContextIdentity(pythonContextFor(opcoreCheck.validationResult.pythonProjectContexts, "services/api/src/app.py")), + pythonContextIdentity(nestedStatusContext) + ); const opcoreInit = assertSmoke(project, ["install", "--json"], 0, "opcore"); assert.deepEqual(opcoreInit.canonicalCommand, ["opcore", "install"]); assert.equal(opcoreInit.opcoreInit.mode, "plan"); assert.equal(Object.hasOwn(opcoreInit.opcoreInit, "scan"), true); assert.equal(Array.isArray(opcoreInit.opcoreInit.settings.languages), true); assert.equal(opcoreInit.opcoreInit.timings.scanMs >= 0, true); + assert.deepEqual( + pythonContextIdentity(pythonContextFor(opcoreInit.opcoreInit.settings.python.contexts, "services/api/src/app.py")), + pythonContextIdentity(nestedStatusContext) + ); assert.equal(existsSync(join(project, ".opcore", "config")), false); assert.equal(existsSync(join(project, "AGENTS.md")), false); const opcoreMeasure = assertSmoke(project, ["measure", "--json"], 0, "opcore"); @@ -420,6 +461,12 @@ function assertManagedDescriptor(project) { /(^|[\\/"'\s])\.ace(?:[\\/"'\s]|$)|LATTICE_CURRENT_TOOLS_DIR|\/Users\/tom|(^|[\\/\s])(?:lattice|crg|cix|rox)(?:$|[\\/\s])/i ); const descriptor = validateManagedToolDescriptor(JSON.parse(descriptorText)); + assert.deepEqual(descriptor.capabilities.validation.pythonProjectContext, { + schemaId: "opcore.python.project-context.v1", + outcomes: ["resolved", "degraded", "unsupported", "ambiguous"], + readOnly: true, + installs: false + }); assert.deepEqual( descriptor.commandGroups.map((group) => group.name), ["graph", "inspect", "edit", "check", "validate", "status", "doctor"] @@ -443,13 +490,37 @@ function assertManagedDescriptor(project) { } } +function pythonContextFor(contexts, target) { + const context = contexts?.find((candidate) => candidate.target === target); + assert.ok(context, `missing Python project context for ${target}`); + return context; +} + +function pythonContextIdentity(context) { + return { + target: context.target, + projectRoot: context.projectRoot, + projectKey: context.projectKey, + contextFingerprint: context.contextFingerprint, + outcome: context.outcome, + interpreter: context.interpreter?.argv ?? null, + tools: context.tools.map((tool) => ({ tool: tool.tool, argv: tool.argv, source: tool.source, available: tool.available })) + }; +} + function assertSmoke(project, args, expectedExitCode, bin = "opcore") { return assertCliJson(binPath(project, bin), args, expectedExitCode, project); } function assertCliJson(command, args, expectedExitCode, cwd, options = {}) { const startedAt = performance.now(); - const result = run(command, args, { cwd, env: options.env, expectedStatus: expectedExitCode }); + // Installed-artifact checks must not inherit development-only tools from the + // monorepo's node_modules/.bin through the test runner PATH. + const result = run(command, args, { + cwd, + env: options.env ?? sourceSafeOpcoreEnv(), + expectedStatus: expectedExitCode + }); const durationMs = nonNegativeDuration(performance.now() - startedAt); assert.doesNotMatch(result.stdout, onboardingForbiddenOutput); assert.doesNotMatch(result.stderr, onboardingForbiddenOutput); @@ -514,7 +585,7 @@ function assertAspProviderInitializeSmoke(project) { method: "initialize", params: { protocolVersion: "asp/0.1", - host: { name: "installed-bin-smoke", version: "0.2.0-test" }, + host: { name: "installed-bin-smoke", version: "0.2.1-test" }, hostCapabilities: { readBlob: true, listTree: true, putBlob: false }, workspace: { root: project, @@ -524,6 +595,7 @@ function assertAspProviderInitializeSmoke(project) { }; const result = spawnSync(binPath(project, "opcore-asp-provider"), ["--stdio"], { cwd: project, + env: sourceSafeOpcoreEnv(), input: `${JSON.stringify(request)}\n`, encoding: "utf8", stdio: ["pipe", "pipe", "pipe"] @@ -540,6 +612,191 @@ function assertAspProviderInitializeSmoke(project) { assert.deepEqual(response.result.requestedPermissions, { read: ["**/*"], write: false, network: false }); } +async function evaluateInstalledAspPythonContext(project) { + const files = { + "pyproject.toml": readFileSync(join(project, "pyproject.toml"), "utf8"), + "root.py": readFileSync(join(project, "root.py"), "utf8"), + "services/api/pyproject.toml": readFileSync(join(project, "services/api/pyproject.toml"), "utf8"), + "services/api/uv.lock": readFileSync(join(project, "services/api/uv.lock"), "utf8"), + "services/api/src/app.py": readFileSync(join(project, "services/api/src/app.py"), "utf8") + }; + const host = createInstalledAspHost(files); + const child = spawn(binPath(project, "opcore-asp-provider"), ["--stdio"], { + cwd: project, + env: sourceSafeOpcoreEnv(), + stdio: ["pipe", "pipe", "pipe"] + }); + const peer = new InstalledAspPeer(child, host).start(); + try { + const initialize = await peer.request("initialize", { + protocolVersion: "asp/0.1", + host: { name: "installed-bin-context", version: "0.1.0-test" }, + hostCapabilities: { readBlob: true, listTree: true, putBlob: false }, + workspace: { root: realpathSync(project), baseline: host.baseline }, + assuranceMode: "gated" + }); + peer.notify("initialized", { + grantedPermissions: initialize.requestedPermissions, + baseline: host.baseline + }); + const assessment = await peer.request("check/evaluate", { + callSite: "interactive", + changeset: host.changeset([ + host.modify("services/api/src/app.py", files["services/api/src/app.py"]) + ]), + comparison: "all", + checks: ["python.source-hygiene"] + }); + const evidence = assessment.evidence?.find((entry) => + entry.kind === "python_project_context" && entry.data?.target === "services/api/src/app.py" + ); + assert.ok(evidence, JSON.stringify(assessment, null, 2)); + const typesAssessment = await peer.request("check/evaluate", { + callSite: "interactive", + changeset: host.changeset([ + host.modify("services/api/src/app.py", files["services/api/src/app.py"]) + ]), + comparison: "all", + checks: ["python.types"] + }); + const capabilityEvidence = typesAssessment.evidence?.find( + (entry) => entry.kind === "python_validation_capability_run" + ); + assert.ok(capabilityEvidence, JSON.stringify(typesAssessment, null, 2)); + assert.equal(capabilityEvidence.data.schemaId, "opcore.python.validation-capability-run"); + assert.equal(capabilityEvidence.data.status, "unsupported_target"); + assert.equal(Object.hasOwn(capabilityEvidence.data, "checker"), false); + assert.equal(Object.hasOwn(capabilityEvidence.data, "checkerSource"), false); + assert.equal(Object.hasOwn(capabilityEvidence.data, "authority"), false); + assert.equal(JSON.stringify(capabilityEvidence).includes(files["services/api/src/app.py"]), false); + return evidence.data; + } finally { + peer.close(); + } +} + +class InstalledAspPeer { + constructor(child, host) { + this.child = child; + this.host = host; + this.nextId = 1; + this.pending = new Map(); + this.buffer = ""; + this.stderr = ""; + } + + start() { + this.child.stdout.setEncoding("utf8"); + this.child.stderr.setEncoding("utf8"); + this.child.stdout.on("data", (chunk) => this.onData(chunk)); + this.child.stderr.on("data", (chunk) => { this.stderr += chunk; }); + this.child.on("exit", (code, signal) => { + const error = new Error(`installed ASP provider exited code=${code} signal=${signal}\nstderr:\n${this.stderr}`); + for (const pending of this.pending.values()) pending.reject(error); + this.pending.clear(); + }); + return this; + } + + request(method, params = {}, timeoutMs = 30000) { + const id = this.nextId++; + this.write({ jsonrpc: "2.0", id, method, params }); + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this.pending.delete(id); + reject(new Error(`installed ASP request timed out: ${method}\nstderr:\n${this.stderr}`)); + }, timeoutMs); + this.pending.set(id, { + resolve(value) { clearTimeout(timer); resolve(value); }, + reject(error) { clearTimeout(timer); reject(error); } + }); + }); + } + + notify(method, params = {}) { + this.write({ jsonrpc: "2.0", method, params }); + } + + close() { + this.child.kill(); + } + + onData(chunk) { + this.buffer += chunk; + while (this.buffer.includes("\n")) { + const index = this.buffer.indexOf("\n"); + const line = this.buffer.slice(0, index).trim(); + this.buffer = this.buffer.slice(index + 1); + if (line.length > 0) this.handleMessage(JSON.parse(line)); + } + } + + handleMessage(message) { + if (Object.hasOwn(message, "id") && !message.method && (Object.hasOwn(message, "result") || Object.hasOwn(message, "error"))) { + const pending = this.pending.get(message.id); + if (!pending) return; + this.pending.delete(message.id); + if (message.error) { + const error = new Error(message.error.message); + error.rpc = message.error; + pending.reject(error); + } else { + pending.resolve(message.result); + } + return; + } + if (message.method === "workspace/listTree") { + this.write({ jsonrpc: "2.0", id: message.id, result: this.host.listTree(message.params) }); + return; + } + if (message.method === "workspace/readBlob") { + this.write({ jsonrpc: "2.0", id: message.id, result: this.host.readBlob(message.params) }); + return; + } + this.write({ jsonrpc: "2.0", id: message.id, error: { code: -32601, message: "method-not-found" } }); + } + + write(message) { + this.child.stdin.write(`${JSON.stringify(message)}\n`); + } +} + +function createInstalledAspHost(files) { + const baseline = { rev: "tree:installed-python-context", stampedAt: "2026-07-15T00:00:00.000Z" }; + const blobs = new Map(); + const entries = Object.entries(files).map(([path, content]) => { + const blobId = installedBlobId(content); + blobs.set(blobId, content); + return { path, blobId, kind: "file" }; + }); + const entryByPath = new Map(entries.map((entry) => [entry.path, entry])); + return { + baseline, + changeset: (changes) => ({ baseline, changes }), + modify(path, content) { + return { + kind: "modify", + path, + before: entryByPath.get(path).blobId, + after: { encoding: "utf-8", bytes: content } + }; + }, + listTree(params = {}) { + const paths = new Set(Array.isArray(params.paths) ? params.paths : []); + return { entries: entries.filter((entry) => paths.size === 0 || paths.has(entry.path)), truncated: false }; + }, + readBlob(params = {}) { + return { + blobs: (params.blobs ?? []).map((id) => ({ id, encoding: "utf-8", bytes: blobs.get(id) })) + }; + } + }; +} + +function installedBlobId(content) { + return `blob:sha256:${createHash("sha256").update(content, "utf8").digest("hex")}`; +} + function binPath(project, bin) { return join(project, "node_modules", ".bin", process.platform === "win32" ? `${bin}.cmd` : bin); } @@ -626,7 +883,7 @@ function createOnboardingFixture(root, id, definition) { } function rustCargoToml(name) { - return `[package]\nname = "${name}"\nversion = "0.2.0"\nedition = "2021"\n\n[lib]\npath = "src/lib.rs"\n`; + return `[package]\nname = "${name}"\nversion = "0.2.1"\nedition = "2021"\n\n[lib]\npath = "src/lib.rs"\n`; } function sourceSafeOpcoreEnv() { @@ -690,7 +947,7 @@ function assertFixtureInitHonesty(initPayload, fixture) { const python = initPayload.settings.languages.find((entry) => entry.language === "Python"); assert.equal(python.validation, "degraded"); assert.equal(python.state, "degraded"); - assert.equal(initPayload.scan.diagnosticCount, 2); + assert.equal(initPayload.scan.diagnosticCount, 1, "valid .pyi declarations must not add syntax diagnostics"); } } @@ -869,7 +1126,7 @@ function opcoreVersionForBin(command) { } current = dirname(current); } - return "0.2.0"; + return "0.2.1"; } function opcoreGitignoreLineCount(repoRoot) { diff --git a/tests/native-artifact-fixture.mjs b/tests/native-artifact-fixture.mjs index 1d6e779..fc154f6 100644 --- a/tests/native-artifact-fixture.mjs +++ b/tests/native-artifact-fixture.mjs @@ -35,7 +35,7 @@ export function withCompleteNativeArtifactFixtures(runWithArtifacts) { `${JSON.stringify( { artifactName: "opcore-graph-core", - artifactVersion: "0.2.0", + artifactVersion: "0.2.1", targetPlatform: target, binaryPath: binaryName, checksumPath: checksumName, diff --git a/tests/native-packaging-policy.test.mjs b/tests/native-packaging-policy.test.mjs index fae57fa..65ab6a6 100644 --- a/tests/native-packaging-policy.test.mjs +++ b/tests/native-packaging-policy.test.mjs @@ -115,7 +115,7 @@ describe("native graph-core packaging policy", () => { assert.match(workflow, /branches: \[main\]/); assert.match(workflow, /id-token: write/); assert.match(workflow, /NPM_TAG: latest/); - assert.match(workflow, /OPCORE_CONFIRM_PUBLISH:\s*"0\.2\.0"/); + assert.match(workflow, /OPCORE_CONFIRM_PUBLISH:\s*"0\.2\.1"/); assert.match(workflow, /OPCORE_REQUIRE_ALL_NATIVE_PACKAGES:\s*"1"/); assert.match(workflow, /run-id: \$\{\{ github\.event\.workflow_run\.id \}\}/); assert.match( diff --git a/tests/opcore-agent-gate.test.mjs b/tests/opcore-agent-gate.test.mjs index a45de62..537aa6c 100644 --- a/tests/opcore-agent-gate.test.mjs +++ b/tests/opcore-agent-gate.test.mjs @@ -86,6 +86,66 @@ describe("Opcore agent write gate adapter", () => { rmSync(temp, { recursive: true, force: true }); } }); + + it("resolves repo-local opcore from node_modules/.bin when the hook PATH is plain", () => { + const temp = mkdtempSync(join(tmpdir(), "opcore-agent-gate-local-bin-")); + try { + initTypeScriptFixture(temp); + createOpcoreShim(temp, "node_modules/.bin"); + runOpcore(["init", "--repo", temp, "--local", "--approve", "--json"], temp, 0); + const hookPath = join(temp, ".opcore", "hooks", "opcore-agent-gate.mjs"); + + const clean = runHook( + hookPath, + { + cwd: temp, + hook_event_name: "PreToolUse", + tool_name: "Write", + tool_input: { + file_path: join(temp, "src", "index.ts"), + content: "export const value: number = 2;\n" + }, + env: { ...process.env, PATH: "/usr/bin:/bin" }, + expectedStatus: 0 + } + ); + + assert.equal(clean.stderr, ""); + assert.equal(readFileSync(join(temp, "src", "index.ts"), "utf8"), "export const value: number = 1;\n"); + } finally { + rmSync(temp, { recursive: true, force: true }); + } + }); + + it("reports a missing opcore command without crashing on empty spawn output", () => { + const temp = mkdtempSync(join(tmpdir(), "opcore-agent-gate-missing-bin-")); + try { + initTypeScriptFixture(temp); + runOpcore(["init", "--repo", temp, "--local", "--approve", "--json"], temp, 0); + const hookPath = join(temp, ".opcore", "hooks", "opcore-agent-gate.mjs"); + + const blocked = runHook( + hookPath, + { + cwd: temp, + hook_event_name: "PreToolUse", + tool_name: "Write", + tool_input: { + file_path: join(temp, "src", "index.ts"), + content: "export const value: number = 2;\n" + }, + env: { ...process.env, PATH: "" }, + expectedStatus: 2 + } + ); + + assert.match(blocked.stderr, /validation command failed/); + assert.match(blocked.stderr, /ENOENT/); + assert.doesNotMatch(blocked.stderr, /Cannot read properties/); + } finally { + rmSync(temp, { recursive: true, force: true }); + } + }); }); function initTypeScriptFixture(root) { @@ -98,8 +158,8 @@ function initTypeScriptFixture(root) { writeFixtureFile(root, "src/index.ts", "export const value: number = 1;\n"); } -function createOpcoreShim(root) { - const shimDir = join(root, "bin"); +function createOpcoreShim(root, relativeDir = "bin") { + const shimDir = join(root, relativeDir); mkdirSync(shimDir, { recursive: true }); const shimPath = join(shimDir, "opcore"); writeFileSync(shimPath, `#!/usr/bin/env sh\nexec ${JSON.stringify(process.execPath)} ${JSON.stringify(opcoreBin)} "$@"\n`); diff --git a/tests/opcore-facade.test.mjs b/tests/opcore-facade.test.mjs index cfdbbc6..898d365 100644 --- a/tests/opcore-facade.test.mjs +++ b/tests/opcore-facade.test.mjs @@ -12,6 +12,7 @@ import { writeCommandLatencyTelemetry, writeOpcoreMetricArtifacts } from "../packages/opcore/dist/index.js"; +import { compactScanValidationResult } from "../packages/opcore/dist/scan-validation-preview.js"; const repoRoot = fileURLToPath(new URL("..", import.meta.url)); const opcoreBin = resolve(repoRoot, "packages/opcore/dist/index.js"); @@ -160,6 +161,83 @@ describe("opcore public facade", () => { } }); + it("preserves source-free Python capability runs when compacting scan diagnostics", () => { + const run = { + schemaId: "opcore.python.validation-capability-run", + schemaVersion: 1, + capability: "types", + checkId: "python.types", + projectKey: `sha256:${"1".repeat(64)}`, + contextFingerprint: `sha256:${"2".repeat(64)}`, + projectRoot: ".", + targets: ["pkg/app.py"], + selectedSourcePaths: ["pkg/app.py"], + selectedConfigPaths: ["mypy.ini"], + afterStateManifestFingerprint: `sha256:${"3".repeat(64)}`, + authority: "mypy", + authoritySource: "project_config", + status: "passed", + tool: { + name: "mypy", + executable: "external:mypy", + argv: ["external:mypy", "--output=json", "pkg/app.py"], + cwd: ".", + source: "project_local_environment", + version: "2.3.0", + configFile: "mypy.ini" + }, + execution: { termination: "exited", exitCode: 0 }, + durationMs: 1, + diagnosticCount: 0, + errorCount: 0, + warningCount: 0, + noteCount: 0 + }; + const pyrightRun = { + ...run, + projectKey: `sha256:${"4".repeat(64)}`, + contextFingerprint: `sha256:${"5".repeat(64)}`, + selectedConfigPaths: ["pyrightconfig.json"], + afterStateManifestFingerprint: `sha256:${"6".repeat(64)}`, + authority: "pyright", + tool: { + name: "pyright", + executable: "external:pyright", + argv: ["external:pyright", "--outputjson", "--project", "pyrightconfig.json"], + cwd: ".", + source: "project_local_environment", + version: "1.1.411", + configFile: "pyrightconfig.json" + }, + diagnosticCount: 2, + warningCount: 1, + noteCount: 1 + }; + const compact = compactScanValidationResult({ + ok: true, + status: "passed", + diagnostics: [{ category: "types", severity: "warning", message: "preview" }], + pythonCapabilityRuns: [run, pyrightRun] + }, 0); + + assert.deepEqual(compact.diagnostics, []); + assert.deepEqual(compact.pythonCapabilityRuns, [run, pyrightRun]); + assert.equal(JSON.stringify(compact.pythonCapabilityRuns).includes("source text"), false); + assert.throws(() => compactScanValidationResult({ + ok: true, + status: "passed", + diagnostics: [], + pythonCapabilityRuns: [{ + ...run, + tool: { + ...run.tool, + executable: "/var/folders/private/opcore-python-types-workspace-secret/bin/mypy", + argv: ["/var/folders/private/opcore-python-types-workspace-secret/bin/mypy", "pkg/app.py"] + } + }] + }), /portable executable/); + }); + it("excludes generated provider roots from status coverage and scan validation", () => { const temp = mkdtempSync(join(tmpdir(), "opcore-provider-root-skip-")); try { @@ -201,7 +279,7 @@ describe("opcore public facade", () => { try { const human = runOpcore(["--version"], temp, 0); assert.equal(human.stderr, ""); - assert.match(human.stdout, /^opcore 0\.2\.0\b/); + assert.match(human.stdout, /^opcore 0\.2\.1\b/); assert.equal(existsSync(join(temp, ".opcore")), false); const json = parseJson(runOpcore(["--version", "--json"], temp, 0).stdout); @@ -209,7 +287,7 @@ describe("opcore public facade", () => { assert.equal(json.owner, "runtime"); assert.equal(json.runtimeInfo.schemaVersion, 1); assert.equal(json.runtimeInfo.packageName, "opcore"); - assert.equal(json.runtimeInfo.version, "0.2.0"); + assert.equal(json.runtimeInfo.version, "0.2.1"); assert.equal(json.runtimeInfo.bin, "opcore"); assert.match(json.runtimeInfo.artifactSource, /^(source_checkout|installed_package|unknown)$/); assert.match(json.runtimeInfo.packageRoot, /packages\/opcore$/); @@ -383,7 +461,7 @@ describe("opcore public facade", () => { mkdirSync(join(temp, "src"), { recursive: true }); writeFileSync( join(temp, "Cargo.toml"), - "[package]\nname = \"opcore-rust-status\"\nversion = \"0.2.0\"\nedition = \"2021\"\n" + "[package]\nname = \"opcore-rust-status\"\nversion = \"0.2.1\"\nedition = \"2021\"\n" ); writeFileSync(join(temp, "src/lib.rs"), "pub fn value() -> u64 { 1 }\n"); @@ -1263,7 +1341,7 @@ describe("opcore public facade", () => { { name: "rust", files: [ - ["Cargo.toml", "[package]\nname = \"opcore_fixture\"\nversion = \"0.2.0\"\nedition = \"2021\"\n"], + ["Cargo.toml", "[package]\nname = \"opcore_fixture\"\nversion = \"0.2.1\"\nedition = \"2021\"\n"], ["src/lib.rs", "pub fn value() -> i32 { 1 }\n"] ], expect: { totalFiles: 2, languages: ["Rust"], unsupportedFiles: 0 } @@ -1272,7 +1350,7 @@ describe("opcore public facade", () => { name: "mixed", files: [ ["src/index.ts", "export const value = 1;\n"], - ["Cargo.toml", "[package]\nname = \"opcore_fixture\"\nversion = \"0.2.0\"\nedition = \"2021\"\n"], + ["Cargo.toml", "[package]\nname = \"opcore_fixture\"\nversion = \"0.2.1\"\nedition = \"2021\"\n"], ["src/lib.rs", "pub fn value() -> i32 { 1 }\n"] ], expect: { totalFiles: 3, languages: ["Rust", "TypeScript"], unsupportedFiles: 0 } @@ -1329,16 +1407,15 @@ describe("opcore public facade", () => { assert.match(result.opcoreInit.settings.languages[0].notes.join(" "), /pyproject\.toml/); assert.match(result.opcoreInit.settings.languages[0].notes.join(" "), /requirements\.txt/); assert.match(result.opcoreInit.settings.languages[0].notes.join(" "), /uv\.lock/); - assert.match(result.opcoreInit.settings.languages[0].notes.join(" "), /\.venv/); assert.deepEqual( result.opcoreInit.settings.python.dependencyManagers.map((entry) => [entry.kind, entry.path]), [ - ["pyproject", "pyproject.toml"], ["requirements", "requirements.txt"], ["uv", "uv.lock"] ] ); - assert.deepEqual(result.opcoreInit.settings.python.virtualEnvironments, [{ kind: "venv", path: ".venv" }]); + assert.deepEqual(result.opcoreInit.settings.python.virtualEnvironments, []); + assert.equal(result.opcoreInit.settings.python.contexts[0].outcome, "ambiguous"); } if (fixture.name === "mixed-cargo-lock-only") { const rust = result.opcoreInit.settings.languages.find((entry) => entry.language === "Rust"); @@ -2265,6 +2342,6 @@ function measureLatencyRecord(recordedAt, totalMs, validationMs) { { phase: "validation_typescript_syntax", durationMs: Math.max(0, totalMs - validationMs) } ] }, - opcoreVersion: "0.2.0" + opcoreVersion: "0.2.1" }; } diff --git a/tests/opcore-metrics.test.mjs b/tests/opcore-metrics.test.mjs index d0877cf..6cc8342 100644 --- a/tests/opcore-metrics.test.mjs +++ b/tests/opcore-metrics.test.mjs @@ -38,6 +38,8 @@ describe("Opcore metrics", () => { assert.equal(signals.get("rust.module_resolution").count, 1); assert.equal(signals.get("rust.toolchain_drift").count, 1); assert.equal(signals.get("python.syntax_errors").count, 1); + assert.equal(signals.get("python.syntax_errors").evidence[0].line, 3); + assert.equal(signals.get("python.syntax_errors").evidence[0].column, 8); assert.equal(signals.get("python.type_errors").count, 1); assert.equal(signals.get("python.untested_modules").count, 1); assert.equal(signals.get("python.dead_exports").evidence[0].path, "pkg/api.py"); @@ -413,7 +415,15 @@ function validationResult() { code: "RUST_FMT_DRIFT", message: "Rust formatting drift." }, - { category: "syntax", severity: "error", path: "pkg/bad.py", code: "PY_SYNTAX_MISSING_COLON", message: "Missing colon." }, + { + category: "syntax", + severity: "error", + path: "pkg/bad.py", + code: "PY_SYNTAX_ERROR", + message: "expected ':'", + line: 3, + column: 8 + }, { category: "types", severity: "error", path: "pkg/typed.py", code: "PY_TYPE_MISMATCH", message: "Type mismatch." }, { category: "test", @@ -452,8 +462,8 @@ function validationResult() { { category: "types", severity: "info", - code: "PYTHON_TYPES_UNSUPPORTED", - message: "Python type validation requires mypy or pyright; neither tool is available." + code: "PYTHON_TYPES_UNSUPPORTED_TARGET", + message: "Python type validation requires explicit or configured checker authority." } ], manifest: { @@ -740,6 +750,6 @@ function latencyRecord(index) { ], processState: index === 0 ? "cold" : "warm" }, - opcoreVersion: "0.2.0" + opcoreVersion: "0.2.1" }); } diff --git a/tests/package-identity.test.mjs b/tests/package-identity.test.mjs index d3232a0..b030566 100644 --- a/tests/package-identity.test.mjs +++ b/tests/package-identity.test.mjs @@ -48,15 +48,18 @@ const bundledExternalRuntimePackages = [ "@typescript-eslint/visitor-keys", "balanced-match", "brace-expansion", + "concat-map", "code-block-writer", "debug", "eslint-visitor-keys", "fdir", + "jsonc-parser", "minimatch", "ms", "path-browserify", "picomatch", "semver", + "smol-toml", "tinyglobby", "ts-api-utils", "ts-morph", diff --git a/tests/python-import-composition.test.mjs b/tests/python-import-composition.test.mjs new file mode 100644 index 0000000..5740c9b --- /dev/null +++ b/tests/python-import-composition.test.mjs @@ -0,0 +1,104 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { defaultAspProviderValidationChecks } from "../packages/asp-provider/dist/validation-composition.js"; +import { defaultValidationChecks as defaultOpcoreValidationChecks } from "../packages/opcore/dist/repo-validation-policy.js"; +import { createOpcoreValidationGraphSessionFactory } from "../packages/opcore/dist/validation-composition.js"; +import { createCliValidationGraphSessionFactory } from "../packages/opcore/dist/advanced/validation-composition.js"; +import { createBuiltInValidationChecks } from "../packages/validation-policy/dist/index.js"; +import { createValidationFileView } from "../packages/validation/dist/index.js"; + +describe("Python import analyzer composition", () => { + it("forwards the structural analyzer through validation-policy", async () => { + let calls = 0; + const checks = createBuiltInValidationChecks(undefined, { + clone: false, + pythonImportAnalyzer: { + async analyze() { + calls += 1; + return [{ fromPath: "src/app.py", toPath: "src/pkg/dep.py" }]; + } + } + }); + + await importGraphRequirements(checks); + assert.equal(calls, 1); + }); + + it("injects the graph-owned analyzer into Opcore and ASP compositions", async () => { + await importGraphRequirements(defaultOpcoreValidationChecks); + await importGraphRequirements(defaultAspProviderValidationChecks); + }); + + it("injects exact overlay graph snapshots into product and advanced validation composition", async () => { + const request = { + requestId: "composition-exact", + repo: { repoId: "composition-exact" }, + scope: { kind: "files", files: ["pkg/app.py"] }, + graph: { mode: "required", provider: "opcore-graph" }, + overlays: [{ path: "pkg/new.py", action: "write", content: "VALUE = 1\n" }], + checks: ["python.import-graph"] + }; + const files = new Map([ + ["pkg/__init__.py", ""], + ["pkg/app.py", "from pkg import new\n"] + ]); + const workspace = { + readFile: (path) => files.has(path) ? { status: "found", content: files.get(path) } : { status: "missing" }, + listFiles: () => ({ files: [...files.keys()] }) + }; + const scope = { kind: "files", files: ["pkg/app.py", "pkg/new.py"], workspaceFiles: [{ path: "pkg/app.py" }, { path: "pkg/new.py" }] }; + const fileView = await createValidationFileView({ request, scope, workspace }); + + for (const factory of [createOpcoreValidationGraphSessionFactory(), createCliValidationGraphSessionFactory()]) { + const session = await factory({ request, fileView, identity: { kind: "exact", state: "after" } }); + try { + assert.equal(session.identity.kind, "exact"); + assert.ok((await session.importsFrom()).some((edge) => edge.from === "file:pkg/app.py" && edge.to === "file:pkg/new.py")); + } finally { + await session.dispose(); + } + } + }); +}); + +async function importGraphRequirements(checks) { + const check = checks.find((candidate) => candidate.id === "python.import-graph"); + assert.ok(check?.graphRequirements); + const requirements = await check.graphRequirements(validationContext()); + assert.deepEqual(requirements.map((requirement) => requirement.selector.kind), ["edges", "nodes"]); +} + +function validationContext() { + const files = new Map([ + ["src/app.py", "from pkg import (\n dep,\n)\n"], + ["src/pkg/dep.py", "VALUE = 1\n"] + ]); + return { + request: { + requestId: "python-import-composition", + repo: { repoId: "python-import-composition" }, + scope: { kind: "files", files: ["src/app.py"] }, + graph: { mode: "optional", provider: "opcore-graph" }, + overlays: [], + checks: ["python.import-graph"] + }, + fileView: { + overlays: [], + scopeFiles: ["src/app.py"], + defaultReadState: "after", + listVisibleFiles: async () => [...files.keys()], + readFile: async (path) => files.has(path) + ? { status: "found", content: files.get(path) } + : { status: "missing" }, + readBefore: async (path) => files.has(path) + ? { status: "found", content: files.get(path) } + : { status: "missing" }, + readAfter: async (path) => files.has(path) + ? { status: "found", content: files.get(path) } + : { status: "missing" }, + exists: async (path) => files.has(path), + hasOverlay: () => false, + overlayFor: () => undefined + } + }; +} diff --git a/tests/schema-contracts.test.mjs b/tests/schema-contracts.test.mjs index f282d50..feecf91 100644 --- a/tests/schema-contracts.test.mjs +++ b/tests/schema-contracts.test.mjs @@ -155,6 +155,80 @@ function validationResultWith(overrides = {}) { }; } +function pythonProjectContextWith(overrides = {}) { + return { + schemaId: "opcore.python.project-context.v1", + schemaVersion: 1, + target: "services/api/src/app.py", + repositoryRoot: "/repo", + projectRoot: "services/api", + projectBoundary: "services/api", + sourceRoots: ["services/api/src"], + layout: { kinds: ["package", "src"], paths: ["services/api/src"] }, + evidence: [{ path: "services/api/pyproject.toml", role: "boundary" }], + targetRuntime: { requiresPython: ">=3.12", conflicts: [] }, + managers: [{ kind: "uv", configFiles: ["services/api/pyproject.toml"], lockFiles: ["services/api/uv.lock"] }], + buildSystem: { + configFile: "services/api/pyproject.toml", + backend: "hatchling.build", + requires: ["hatchling>=1"] + }, + interpreter: { + executable: "/repo/services/api/.venv/bin/python", + argv: ["/repo/services/api/.venv/bin/python"], + cwd: "/repo/services/api", + source: "project_local_environment", + version: "3.12.4", + implementation: "CPython", + platform: "linux", + architecture: "x86_64", + abi: "cpython-312", + soabi: "cpython-312-x86_64-linux-gnu" + }, + tools: [], + projectKey: `sha256:${"1".repeat(64)}`, + contextFingerprint: `sha256:${"2".repeat(64)}`, + outcome: "resolved", + reasons: [], + ...overrides + }; +} + +function pythonCapabilityRunWith(overrides = {}) { + return { + schemaId: "opcore.python.validation-capability-run", + schemaVersion: 1, + capability: "types", + checkId: "python.types", + projectKey: `sha256:${"1".repeat(64)}`, + contextFingerprint: `sha256:${"2".repeat(64)}`, + projectRoot: "services/api", + targets: ["services/api/src/app.py"], + selectedSourcePaths: ["services/api/src/app.py"], + selectedConfigPaths: ["services/api/pyproject.toml"], + afterStateManifestFingerprint: `sha256:${"3".repeat(64)}`, + authority: "mypy", + authoritySource: "project_config", + status: "passed", + tool: { + name: "mypy", + executable: "repo:services/api/.venv/bin/mypy", + argv: ["repo:services/api/.venv/bin/mypy", "--output=json", "src/app.py"], + cwd: "services/api", + source: "project_local_environment", + version: "2.3.0", + configFile: "services/api/pyproject.toml" + }, + execution: { termination: "exited", exitCode: 0 }, + durationMs: 12, + diagnosticCount: 0, + errorCount: 0, + warningCount: 0, + noteCount: 0, + ...overrides + }; +} + function preWriteValidationReceiptWith(overrides = {}) { return { schemaVersion: 1, @@ -702,6 +776,7 @@ describe("Opcore JSON schema wire constraints", () => { { checkId: "types", status: "passed", + outcome: "passed", durationMs: 7, diagnosticCount: 0 } @@ -746,6 +821,154 @@ describe("Opcore JSON schema wire constraints", () => { ), false ); + assert.equal( + isValidDefinition( + "ValidationResult", + validationResultWith({ + manifest: { + ...manifest, + runs: [{ ...manifest.runs[0], outcome: "findings" }] + } + }) + ), + false + ); + }); + + it("validates ValidationResult diagnostic ranges and tool provenance", () => { + const diagnostic = { + category: "syntax", + message: "expected ':'", + severity: "error", + code: "PY_SYNTAX_ERROR", + path: "pkg/app.py", + line: 2, + column: 8, + endLine: 2, + endColumn: 9, + tool: { + name: "python", + command: "/repo/.venv/bin/python", + version: "3.12.13", + source: "repo-venv", + cwd: "/repo" + } + }; + assert.equal(isValidDefinition("ValidationResult", validationResultWith({ diagnostics: [diagnostic] })), true); + assert.equal(isValidDefinition("ValidationResult", validationResultWith({ diagnostics: [{ ...diagnostic, line: 0 }] })), false); + assert.equal( + isValidDefinition("ValidationResult", validationResultWith({ diagnostics: [{ ...diagnostic, line: undefined }] })), + false + ); + assert.equal( + isValidDefinition("ValidationResult", validationResultWith({ diagnostics: [{ ...diagnostic, tool: { name: "python", command: "" } }] })), + false + ); + }); + + it("validates Python project-context wire identity and typed outcome vocabulary", () => { + const context = pythonProjectContextWith(); + assert.equal(isValidDefinition("PythonProjectContext", context), true); + assert.equal(isValidDefinition("ValidationResult", validationResultWith({ pythonProjectContexts: [context] })), true); + assert.equal(isValidDefinition("PythonProjectContext", { ...context, schemaId: "python.context.v0" }), false); + assert.equal(isValidDefinition("PythonProjectContext", { ...context, outcome: "ready" }), false); + assert.equal(isValidDefinition("PythonProjectContext", { ...context, contextFingerprint: "secret" }), false); + assert.equal(isValidDefinition("PythonProjectContext", { + ...context, + interpreter: { ...context.interpreter, version: "garbage" } + }), false); + for (const field of ["abi", "soabi"]) { + const interpreter = { ...context.interpreter }; + delete interpreter[field]; + assert.equal(isValidDefinition("PythonProjectContext", { ...context, interpreter }), false, field); + } + assert.equal(isValidDefinition("PythonProjectContext", { + ...context, + tools: [{ + tool: "mypy", + available: true, + executable: "/repo/.venv/bin/mypy", + argv: ["/repo/.venv/bin/mypy"], + cwd: "/repo", + source: "project_local_environment" + }] + }), false); + assert.equal(isValidDefinition("PythonProjectContext", { + ...context, + buildSystem: { ...context.buildSystem, requires: [""] } + }), false); + }); + + it("validates Python capability-run wire evidence", () => { + const run = pythonCapabilityRunWith(); + assert.equal(isValidDefinition("PythonValidationCapabilityRun", run), true); + assert.equal(isValidDefinition("PythonValidationCapabilityRun", { + ...run, + authority: "pyright", + tool: { ...run.tool, name: "pyright" }, + diagnosticCount: 2, + warningCount: 1, + noteCount: 1 + }), true); + assert.equal(isValidDefinition("PythonValidationCapabilityRun", { + ...run, + diagnosticCount: 1, + errorCount: 1 + }), false); + assert.equal(isValidDefinition("ValidationResult", validationResultWith({ pythonCapabilityRuns: [run] })), true); + const executedInvalidConfig = { + ...run, + status: "invalid_config", + diagnosticCount: 1, + errorCount: 1, + execution: { termination: "exited", exitCode: 0, failureSummary: "mypy rejected selected configuration: mypy.ini" } + }; + assert.equal(isValidDefinition("PythonValidationCapabilityRun", executedInvalidConfig), true); + assert.equal(isValidDefinition("PythonValidationCapabilityRun", { + ...run, + tool: { + ...run.tool, + executable: "/var/folders/private/opcore-python-types-workspace-secret/bin/mypy", + argv: ["/var/folders/private/opcore-python-types-workspace-secret/bin/mypy", "--output=json", "src/app.py"] + } + }), false); + const authorityConflict = { ...run, status: "invalid_config", diagnosticCount: 1, noteCount: 1 }; + delete authorityConflict.authority; + delete authorityConflict.authoritySource; + delete authorityConflict.tool; + delete authorityConflict.execution; + assert.equal(isValidDefinition("PythonValidationCapabilityRun", authorityConflict), true); + assert.equal(isValidDefinition("PythonValidationCapabilityRun", { ...run, authority: "pytest" }), false); + assert.equal(isValidDefinition("PythonValidationCapabilityRun", { ...run, content: "source text" }), false); + assert.equal(isValidDefinition("PythonValidationCapabilityRun", { + ...run, + tool: { ...run.tool, name: "pyright" } + }), false); + assert.equal(isValidDefinition("PythonValidationCapabilityRun", { + ...run, + tool: { ...run.tool, version: "latest" } + }), false); + assert.equal(isValidDefinition("PythonValidationCapabilityRun", { + ...run, + status: "timeout", + execution: { termination: "signal", signal: "SIGTERM", failureSummary: "terminated" } + }), false); + assert.equal(isValidDefinition("PythonValidationCapabilityRun", { + ...run, + status: "tool_failure", + execution: { termination: "exited", exitCode: 2 } + }), false); + assert.equal(isValidDefinition("PythonValidationCapabilityRun", { + ...executedInvalidConfig, + execution: { termination: "exited", exitCode: 0 } + }), false); + assert.equal(isValidDefinition("PythonValidationCapabilityRun", { + ...run, + status: "findings", + diagnosticCount: 1, + errorCount: 1, + execution: { termination: "exited", exitCode: 0 } + }), false); }); it("rejects absolute and parent-directory edit-change paths", () => { @@ -2165,7 +2388,7 @@ describe("Opcore JSON schema wire constraints", () => { graphPackageVersions: [ { packageName: "@the-open-engine/opcore-contracts", - version: "0.2.0" + version: "0.2.1" } ] }), @@ -2954,7 +3177,7 @@ function validCommandLatencyRecord(overrides = {}) { exitCode: 0, repo: validRepoShapeFingerprint(), timing: validCommandTiming(), - opcoreVersion: "0.2.0", + opcoreVersion: "0.2.1", ...overrides }; } @@ -3266,7 +3489,7 @@ function validManagedToolDescriptor(overrides = {}) { packageIdentity: { packageName: "opcore", artifactName: "opcore", - version: "0.2.0" + version: "0.2.1" }, entrypoints: [ { @@ -3333,6 +3556,12 @@ function validManagedToolDescriptor(overrides = {}) { graphModes: ["optional", "required"], hypothetical: true, statusSurfaces: ["status", "doctor"], + pythonProjectContext: { + schemaId: "opcore.python.project-context.v1", + outcomes: ["resolved", "degraded", "unsupported", "ambiguous"], + readOnly: true, + installs: false + }, writeGate: { initScopes: ["repo", "global"], harnesses: ["claude-code", "codex"], @@ -3461,7 +3690,7 @@ function descriptorNativeChecksumReferences(nativeArtifacts = descriptorNativeAr function validArtifactMetadata() { return { artifactName: "opcore-graph-core", - artifactVersion: "0.2.0", + artifactVersion: "0.2.1", targetPlatform: "test", binaryPath: "dist/native/test/opcore-graph-core", checksumPath: "dist/native/test/opcore-graph-core.sha256", @@ -3475,7 +3704,7 @@ function validHandshake() { provider: "opcore-graph", graphSchemaVersion: 1, artifactName: "opcore-graph-core", - artifactVersion: "0.2.0", + artifactVersion: "0.2.1", targetPlatform: "test", supportedOperations: ["build", "update", "watch", "status", "query", "ping", "health", "shutdown"], nodeKinds: [ @@ -3715,7 +3944,7 @@ function validOpcoreRuntimeInfo() { return { schemaVersion: 1, packageName: "opcore", - version: "0.2.0", + version: "0.2.1", bin: "opcore", artifactSource: "source_checkout", packageRoot: "/repo/packages/opcore", @@ -4007,11 +4236,11 @@ function validGraphReleaseReceipt() { graphPackageVersions: [ { packageName: "@the-open-engine/opcore-graph", - version: "0.2.0" + version: "0.2.1" }, ...graphCoreNativeSupportedTargets.map((target) => ({ packageName: graphCoreNativePackageNameForTarget(target), - version: "0.2.0" + version: "0.2.1" })) ], graphProviderSchemaVersion: 1, @@ -4103,7 +4332,7 @@ function validGraphReleaseReceipt() { })), packageInspection: { packageName: "@the-open-engine/opcore-graph", - tarballName: "covibes-opcore-graph-0.2.0.tgz", + tarballName: "covibes-opcore-graph-0.2.1.tgz", fileCount: 1, files: ["dist/index.js"], forbiddenMarkersAbsent: true, @@ -4251,10 +4480,10 @@ function validReleaseReceipt() { return { packageName, packageRoot: "packages/opcore", - version: "0.2.0", + version: "0.2.1", manifest: { name: packageName, - version: "0.2.0", + version: "0.2.1", license: "MIT", main: "dist/index.js", types: "dist/index.d.ts", @@ -4267,7 +4496,7 @@ function validReleaseReceipt() { ] }, tarball: { - filename: packageName.replace("@the-open-engine/", "the-open-engine-").replace("/", "-") + "-0.2.0.tgz", + filename: packageName.replace("@the-open-engine/", "the-open-engine-").replace("/", "-") + "-0.2.1.tgz", path: ".opcore/release/packages/package.tgz", sha256: "c".repeat(64), integrity: "sha512-test", @@ -4792,7 +5021,7 @@ function validAspDogfoodReceipt() { }; const manifest = { manifestVersion: "asp-server/0.1", - server: { id: "opcore", name: "Opcore", version: "0.2.0" }, + server: { id: "opcore", name: "Opcore", version: "0.2.1" }, protocolVersions: ["asp/0.1"], capabilities: ["check"], capabilityProfiles: ["core-check-provider", "opcore-core-check"], diff --git a/tests/validation-cli.test.mjs b/tests/validation-cli.test.mjs index cbc4d34..608b729 100644 --- a/tests/validation-cli.test.mjs +++ b/tests/validation-cli.test.mjs @@ -984,7 +984,7 @@ describe("validation CLI", () => { try { mkdirSync(join(temp, "crates/app/src"), { recursive: true }); writeFileSync(join(temp, "Cargo.toml"), '[workspace]\nmembers = ["crates/app"]\nresolver = "2"\n'); - writeFileSync(join(temp, "crates/app/Cargo.toml"), '[package]\nname = "app"\nversion = "0.2.0"\nedition = "2021"\n'); + writeFileSync(join(temp, "crates/app/Cargo.toml"), '[package]\nname = "app"\nversion = "0.2.1"\nedition = "2021"\n'); writeFileSync( join(temp, "crates/app/src/lib.rs"), "pub fn configured(a: i32, b: i32) -> i32 {\n if a > b { a } else { b }\n}\n" @@ -1264,7 +1264,7 @@ describe("validation CLI", () => { try { mkdirSync(join(temp, "crates/app/src"), { recursive: true }); writeFileSync(join(temp, "Cargo.toml"), '[workspace]\nmembers = ["crates/app"]\nresolver = "2"\n'); - writeFileSync(join(temp, "crates/app/Cargo.toml"), '[package]\nname = "app"\nversion = "0.2.0"\nedition = "2021"\n'); + writeFileSync(join(temp, "crates/app/Cargo.toml"), '[package]\nname = "app"\nversion = "0.2.1"\nedition = "2021"\n'); const sourcePath = join(temp, "crates/app/src/lib.rs"); writeFileSync(sourcePath, "pub fn safe() {}\n"); const requestPath = join(temp, "rust-pre-write.json"); diff --git a/tests/validation-overlays.test.mjs b/tests/validation-overlays.test.mjs index c423503..3b8d9a0 100644 --- a/tests/validation-overlays.test.mjs +++ b/tests/validation-overlays.test.mjs @@ -108,6 +108,22 @@ describe("validation overlays", () => { assert.equal(listFileCalls, 1); }); + it("preserves unavailable and truncated full-listing evidence for exact snapshots", async () => { + for (const fileSet of [ + { files: ["src/index.ts"], unavailable: true, message: "host listing unavailable" }, + { files: ["src/index.ts"], truncated: true, message: "host listing truncated" } + ]) { + const workspace = testWorkspace(); + workspace.listFiles = () => fileSet; + const view = await createValidationFileView({ request: request(), scope: scope(["src/index.ts"]), workspace }); + const universe = await view.listVisibleFileUniverse(); + + assert.equal(universe.complete, false); + assert.match(universe.message, /host listing/); + await assert.rejects(view.listCompleteVisibleFiles(), /host listing/); + } + }); + it("distinguishes delete overlays from missing workspace files", async () => { const workspace = testWorkspace({ "src/remove.ts": "export const remove = true;" diff --git a/tests/validation-python.test.mjs b/tests/validation-python.test.mjs index a0fa9f0..fb84fae 100644 --- a/tests/validation-python.test.mjs +++ b/tests/validation-python.test.mjs @@ -1,10 +1,11 @@ import { describe, it } from "node:test"; import assert from "node:assert/strict"; -import { chmodSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { chmodSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join, relative } from "node:path"; +import { setTimeout as delay } from "node:timers/promises"; import { fileURLToPath } from "node:url"; -import { createValidationCheckRegistry, createValidationRunner } from "../packages/validation/dist/index.js"; +import { createValidationCheckRegistry, createValidationGraphQuerySession, createValidationRunner } from "../packages/validation/dist/index.js"; import { PYTHON_DEAD_CODE_CHECK_ID, PYTHON_IMPORT_GRAPH_CHECK_ID, @@ -13,16 +14,36 @@ import { PYTHON_SYNTAX_CHECK_ID, PYTHON_TYPES_CHECK_ID, createPythonValidationAdapterStatus, - createPythonValidationChecks, - findPythonConfigFile, - resolvePythonTool, + createPythonValidationChecks as createCanonicalPythonValidationChecks, + createNodePythonProjectWorkspace, + createValidationFileViewPythonWorkspace, + createSyntaxCheck, + createTypeCheck, + isPythonSourcePath, + resolvePythonProjectContext, + resolvePythonProjectContexts, validationPythonAdapterName } from "../packages/validation-python/dist/index.js"; +import { runTool } from "../packages/validation-python/dist/process.js"; const repoRoot = dirname(fileURLToPath(import.meta.url)); const validationFixtureRoot = join(repoRoot, "../packages/fixtures/validation-python"); +const sourceExtractionPythonFixtureRoot = join(repoRoot, "../packages/fixtures/source-extraction/python"); +const sourceExtractionPythonExpected = JSON.parse( + readFileSync(join(sourceExtractionPythonFixtureRoot, "python.expected.json"), "utf8") +); describe("validation-python adapter", () => { + it("keeps compiler-truth fixture resources out of repository Python source discovery", () => { + const fixturePaths = walkFiles(join(validationFixtureRoot, "compiler-truth")); + assert.ok(fixturePaths.length > 0); + assert.ok(fixturePaths.every((path) => !isPythonSourcePath(path))); + assert.deepEqual( + Object.keys(fixtureFiles("compiler-truth")).sort(), + ["invalid/misplaced-future.py", "invalid/module-return.py", "pkg/valid.py", "pkg/valid.pyi"] + ); + }); + it("exports stable Python check ids and definitions", () => { const checks = createPythonValidationChecks(); const registry = createValidationCheckRegistry(checks); @@ -44,6 +65,46 @@ describe("validation-python adapter", () => { assert.equal(registry.byId.get(PYTHON_IMPORT_GRAPH_CHECK_ID)?.requiresGraph, true); }); + it("fails syntax and type checks closed when no canonical context resolver is injected", async () => { + for (const check of [createSyntaxCheck(), createTypeCheck()]) { + const result = await runner({ files: { "app.py": "VALUE = 1\n" }, checks: [check] }).runValidation(request({ + checks: [check.id], + scope: { kind: "files", files: ["app.py"] } + })); + assert.equal(result.status, "infrastructure_failure", check.id); + assert.match(result.diagnostics[0].message, /canonical python project context/i, check.id); + } + }); + + it("fails every target context closed before grouping targets by project", async () => { + const files = { + "pyproject.toml": "[project]\nname='fixture'\n", + "a.py": "VALUE = 1\n", + "z.py": "VALUE = 2\n" + }; + for (const checkId of [PYTHON_SYNTAX_CHECK_ID, PYTHON_TYPES_CHECK_ID]) { + const result = await runner({ + files, + checks: createPythonValidationChecks({ + env: { PATH: "/fixture/bin" }, + nodeWorkspace: projectWorkspace(files, () => true, new Set(["z.py"])), + processProbe: successfulProbe() + }) + }).runValidation(request({ + repo: { repoRoot: "/fixture" }, + checks: [checkId], + scope: { kind: "files", files: ["a.py", "z.py"] } + })); + + assert.notEqual(result.status, "passed", checkId); + assert.deepEqual(result.pythonProjectContexts.map((context) => [context.target, context.outcome]), [ + ["a.py", "resolved"], + ["z.py", "ambiguous"] + ]); + assert.equal(result.diagnostics.some((diagnostic) => diagnostic.path === "z.py"), true, `${checkId}: ${JSON.stringify(result)}`); + } + }); + it("reports missing Python type tooling as degraded instead of a silent pass", async () => { const env = { PATH: "" }; const isolatedRepoRoot = mkdtempSync(join(tmpdir(), "opcore-python-adapter-status-")); @@ -51,7 +112,7 @@ describe("validation-python adapter", () => { const status = createPythonValidationAdapterStatus({ env, repoRoot: isolatedRepoRoot }); assert.equal(status.status, "degraded"); assert.equal(status.degradedChecks?.[0]?.checkId, PYTHON_TYPES_CHECK_ID); - assert.equal(status.degradedChecks?.[0]?.requiredTool, "mypy or pyright"); + assert.equal(status.degradedChecks?.[0]?.requiredTool, "configured Python type authority"); } finally { rmSync(isolatedRepoRoot, { recursive: true, force: true }); } @@ -68,24 +129,22 @@ describe("validation-python adapter", () => { ); assert.equal(result.status, "unsupported_request"); - assert.deepEqual(result.diagnostics.map((diagnostic) => diagnostic.code), ["PYTHON_TYPES_UNSUPPORTED"]); + assert.deepEqual(result.diagnostics.map((diagnostic) => diagnostic.code), ["PYTHON_TYPES_UNSUPPORTED_TARGET"]); + assert.equal(result.pythonCapabilityRuns[0].status, "unsupported_target"); }); it("executes repo-local mypy and maps type diagnostics", async () => { const repoRoot = mkdtempSync(join(tmpdir(), "opcore-python-types-mypy-")); try { + writePassingPythonProtocolShim(repoRoot); writeToolShim( repoRoot, "mypy", [ "#!/bin/sh", "if [ \"$1\" = \"--version\" ]; then echo 'mypy 1.8.0'; exit 0; fi", - "while IFS= read -r line; do", - " case \"$line\" in", - " *\"'wrong'\"*) echo \"$1:1: error: Incompatible types in assignment (expression has type \\\"str\\\", variable has type \\\"int\\\") [assignment]\"; exit 1 ;;", - " esac", - "done < \"$1\"", - "exit 0", + "echo '{\"file\":\"pkg/app.py\",\"line\":1,\"column\":0,\"message\":\"Incompatible types in assignment\",\"hint\":null,\"code\":\"assignment\",\"severity\":\"error\"}'", + "exit 1", "" ].join("\n") ); @@ -94,7 +153,7 @@ describe("validation-python adapter", () => { files: { "pkg/app.py": "value: int = 'wrong'\n" }, - checks: createPythonValidationChecks({ env: { PATH: "" }, repoRoot }) + checks: createPythonValidationChecks({ env: { PATH: "" }, repoRoot, checker: "mypy" }) }).runValidation( request({ repo: { repoRoot }, @@ -107,6 +166,216 @@ describe("validation-python adapter", () => { assert.equal(result.diagnostics[0].category, "types"); assert.equal(result.diagnostics[0].code, "MYPY_ASSIGNMENT"); assert.match(result.diagnostics[0].message, /Incompatible types in assignment/); + assert.equal(result.diagnostics[0].line, 1); + assert.equal(result.diagnostics[0].tool.name, "mypy"); + assert.equal(result.diagnostics[0].tool.command.startsWith("repo:.venv/bin/mypy"), true); + assert.equal(result.pythonCapabilityRuns[0].tool.executable, "repo:.venv/bin/mypy"); + assert.equal(result.pythonCapabilityRuns[0].tool.argv[0], "repo:.venv/bin/mypy"); + assert.equal(result.pythonCapabilityRuns[0].tool.cwd, "."); + assert.equal(result.pythonCapabilityRuns[0].tool.source, "project_local_environment"); + assert.equal(result.pythonCapabilityRuns[0].tool.version, "1.8.0"); + assert.match(result.pythonCapabilityRuns[0].projectKey, /^sha256:[a-f0-9]{64}$/u); + assert.match(result.pythonCapabilityRuns[0].contextFingerprint, /^sha256:[a-f0-9]{64}$/u); + assert.match(result.pythonCapabilityRuns[0].afterStateManifestFingerprint, /^sha256:[a-f0-9]{64}$/u); + assert.equal(JSON.stringify(result.pythonCapabilityRuns).includes(repoRoot), false); + } finally { + rmSync(repoRoot, { recursive: true, force: true }); + } + }); + + it("materializes canonical transitive imports through the owning project's source roots", async () => { + const repoRoot = mkdtempSync(join(tmpdir(), "opcore-python-types-source-root-")); + try { + const projectRoot = join(repoRoot, "services/api"); + mkdirSync(projectRoot, { recursive: true }); + writePassingPythonProtocolShim(projectRoot); + writeToolShim( + projectRoot, + "mypy", + [ + "#!/bin/sh", + "if [ \"$1\" = \"--version\" ]; then echo 'mypy 1.8.0'; exit 0; fi", + "if [ -f ../__init__.py ] || [ -f ../leak.py ]; then echo 'initializer ascent escaped the source root' >&2; exit 9; fi", + "if [ ! -f src/acme/__init__.py ]; then echo 'package initializer was not materialized' >&2; exit 9; fi", + "if [ ! -f src/acme/models.py ]; then echo 'initializer dependency was not materialized' >&2; exit 9; fi", + "if [ ! -f src/acme/mid.py ]; then echo 'direct dependency was not materialized' >&2; exit 9; fi", + "if [ ! -f src/acme/dep.py ]; then echo 'transitive dependency was not materialized' >&2; exit 9; fi", + "if [ ! -f ../../libs/shared/src/shared/__init__.py ]; then echo 'cross-project initializer was not materialized' >&2; exit 9; fi", + "if [ ! -f ../../libs/shared/src/shared/bootstrap.py ]; then echo 'cross-project initializer dependency was not materialized' >&2; exit 9; fi", + "exit 0", + "" + ].join("\n") + ); + const files = { + "services/__init__.py": "from . import leak\n", + "services/leak.py": "VALUE = 'outside source root'\n", + "libs/shared/pyproject.toml": "[project]\nname='shared'\n", + "libs/shared/src/shared/__init__.py": "from . import bootstrap\n", + "libs/shared/src/shared/bootstrap.py": "READY = True\n", + "libs/shared/src/shared/models.py": "class SharedModel: pass\n", + "services/api/pyproject.toml": "[project]\nname='api'\n[tool.mypy]\n", + "services/api/src/acme/__init__.py": "from .models import Model\n", + "services/api/src/acme/ns/app.py": "from acme import (\n mid,\n)\nfrom shared import models as shared_models\nVALUE = mid.VALUE\n", + "services/api/src/acme/mid.py": "from acme import dep\nVALUE = dep.VALUE\n", + "services/api/src/acme/dep.py": "VALUE: int = 1\n", + "services/api/src/acme/models.py": "class Model: pass\n" + }; + const result = await runner({ + files, + checks: createPythonValidationChecks({ + repoRoot, + env: { PATH: "" }, + importAnalyzer: fixedImportAnalyzer([ + { fromPath: "services/__init__.py", toPath: "services/leak.py" }, + { fromPath: "services/api/src/acme/ns/app.py", toPath: "services/api/src/acme/mid.py" }, + { fromPath: "services/api/src/acme/ns/app.py", toPath: "libs/shared/src/shared/models.py" }, + { fromPath: "services/api/src/acme/mid.py", toPath: "services/api/src/acme/dep.py" }, + { fromPath: "services/api/src/acme/__init__.py", toPath: "services/api/src/acme/models.py" }, + { fromPath: "libs/shared/src/shared/__init__.py", toPath: "libs/shared/src/shared/bootstrap.py" }, + ]), + nodeWorkspace: projectWorkspace(files, () => true) + }) + }).runValidation(request({ + repo: { repoRoot }, + checks: [PYTHON_TYPES_CHECK_ID], + scope: { kind: "files", files: ["services/api/src/acme/ns/app.py"] } + })); + + assert.equal(result.status, "passed", JSON.stringify(result, null, 2)); + assert.equal(result.pythonProjectContexts[0].projectRoot, "services/api"); + assert.deepEqual(result.pythonProjectContexts[0].sourceRoots, ["services/api/src"]); + } finally { + rmSync(repoRoot, { recursive: true, force: true }); + } + }); + + it("materializes multiline-configured plugins and their graph-owned transitive imports", async () => { + const repoRoot = mkdtempSync(join(tmpdir(), "opcore-python-types-plugin-closure-")); + try { + writePassingPythonProtocolShim(repoRoot); + writeToolShim(repoRoot, "mypy", [ + "#!/bin/sh", + "if [ \"$1\" = \"--version\" ]; then echo 'mypy 2.3.0'; exit 0; fi", + "if [ ! -f src/acme/plugin.py ]; then echo 'plugin missing' >&2; exit 91; fi", + "if [ ! -f src/acme/plugin_helper.py ]; then echo 'plugin helper missing' >&2; exit 92; fi", + "case \"$PYTHONPATH\" in *\"$PWD/src\"*) ;; *) echo 'source root absent from isolated PYTHONPATH' >&2; exit 93;; esac", + "exit 0", + "" + ].join("\n")); + const files = { + "mypy.ini": "[mypy]\nplugins =\n acme.plugin\nmypy_path =\n src\n", + "src/acme/__init__.py": "", + "src/acme/app.py": "VALUE: int = 1\n", + "src/acme/plugin.py": "from acme import plugin_helper\n", + "src/acme/plugin_helper.py": "READY = True\n" + }; + const result = await runner({ + files, + checks: createPythonValidationChecks({ + repoRoot, + env: { PATH: "" }, + importAnalyzer: fixedImportAnalyzer([ + { fromPath: "src/acme/plugin.py", toPath: "src/acme/plugin_helper.py" } + ]), + nodeWorkspace: projectWorkspace(files, () => true) + }) + }).runValidation(request({ + repo: { repoRoot }, + checks: [PYTHON_TYPES_CHECK_ID], + scope: { kind: "files", files: ["src/acme/app.py"] } + })); + + assert.equal(result.status, "passed", JSON.stringify(result, null, 2)); + assert.deepEqual(result.pythonCapabilityRuns[0].selectedSourcePaths, [ + "src/acme/__init__.py", + "src/acme/app.py", + "src/acme/plugin.py", + "src/acme/plugin_helper.py" + ]); + } finally { + rmSync(repoRoot, { recursive: true, force: true }); + } + }); + + it("maps nested-project type diagnostics to repository-relative paths", async () => { + const repoRoot = mkdtempSync(join(tmpdir(), "opcore-python-types-nested-path-")); + try { + const projectRoot = join(repoRoot, "services/api"); + mkdirSync(projectRoot, { recursive: true }); + writePassingPythonProtocolShim(projectRoot); + writeToolShim( + projectRoot, + "mypy", + [ + "#!/bin/sh", + "if [ \"$1\" = \"--version\" ]; then echo 'mypy 1.8.0'; exit 0; fi", + "echo '{\"file\":\"src/acme/app.py\",\"line\":1,\"column\":0,\"message\":\"nested project type failure\",\"hint\":null,\"code\":\"assignment\",\"severity\":\"error\"}'", + "exit 1", + "" + ].join("\n") + ); + const files = { + "services/api/pyproject.toml": "[project]\nname='api'\n[tool.mypy]\n", + "services/api/src/acme/app.py": "value: int = 'wrong'\n" + }; + const result = await runner({ + files, + checks: createPythonValidationChecks({ + repoRoot, + env: { PATH: "" }, + nodeWorkspace: projectWorkspace(files, () => true) + }) + }).runValidation(request({ + repo: { repoRoot }, + checks: [PYTHON_TYPES_CHECK_ID], + scope: { kind: "files", files: ["services/api/src/acme/app.py"] } + })); + + assert.equal(result.status, "policy_failure"); + assert.equal(result.pythonProjectContexts[0].projectRoot, "services/api"); + assert.equal(result.diagnostics[0].path, "services/api/src/acme/app.py"); + } finally { + rmSync(repoRoot, { recursive: true, force: true }); + } + }); + + it("materializes root imports for flat targets in mixed flat/src projects", async () => { + const repoRoot = mkdtempSync(join(tmpdir(), "opcore-python-types-mixed-layout-")); + try { + writePassingPythonProtocolShim(repoRoot); + writeToolShim( + repoRoot, + "mypy", + [ + "#!/bin/sh", + "if [ \"$1\" = \"--version\" ]; then echo 'mypy 1.8.0'; exit 0; fi", + "if [ ! -f helper.py ]; then echo 'root dependency was not materialized' >&2; exit 9; fi", + "exit 0", + "" + ].join("\n") + ); + const files = { + "pyproject.toml": "[project]\nname='mixed'\n[tool.mypy]\n", + "script.py": "import helper\nVALUE = helper.VALUE\n", + "helper.py": "VALUE: int = 1\n", + "src/pkg/__init__.py": "VALUE = 2\n" + }; + const result = await runner({ + files, + checks: createPythonValidationChecks({ + env: { PATH: "" }, + repoRoot, + importAnalyzer: fixedImportAnalyzer([{ fromPath: "script.py", toPath: "helper.py" }]) + }) + }).runValidation(request({ + repo: { repoRoot }, + checks: [PYTHON_TYPES_CHECK_ID], + scope: { kind: "files", files: ["script.py"] } + })); + + assert.equal(result.status, "passed", JSON.stringify(result, null, 2)); + assert.deepEqual(result.pythonProjectContexts[0].sourceRoots, ["."]); + assert.deepEqual(result.pythonProjectContexts[0].layout, { kinds: ["flat"], paths: ["."] }); } finally { rmSync(repoRoot, { recursive: true, force: true }); } @@ -115,17 +384,18 @@ describe("validation-python adapter", () => { it("checks overlay after-state content instead of the original Python file", async () => { const repoRoot = mkdtempSync(join(tmpdir(), "opcore-python-types-overlay-")); try { + writePassingPythonProtocolShim(repoRoot); writeToolShim( repoRoot, "mypy", [ "#!/bin/sh", "if [ \"$1\" = \"--version\" ]; then echo 'mypy 1.8.0'; exit 0; fi", - "while IFS= read -r line; do", - " case \"$line\" in", - " *\"'wrong'\"*) echo \"$1:1: error: overlay type failure [assignment]\"; exit 1 ;;", - " esac", - "done < \"$1\"", + "for arg in \"$@\"; do target=\"$arg\"; done", + "if /usr/bin/grep -q \"'wrong'\" \"$target\"; then", + " echo '{\"file\":\"pkg/app.py\",\"line\":1,\"column\":0,\"message\":\"overlay type failure\",\"hint\":null,\"code\":\"assignment\",\"severity\":\"error\"}'", + " exit 1", + "fi", "exit 0", "" ].join("\n") @@ -135,7 +405,7 @@ describe("validation-python adapter", () => { files: { "pkg/app.py": "value: int = 1\n" }, - checks: createPythonValidationChecks({ env: { PATH: "" }, repoRoot }) + checks: createPythonValidationChecks({ env: { PATH: "" }, repoRoot, checker: "mypy" }) }).runValidation( request({ repo: { repoRoot }, @@ -152,28 +422,38 @@ describe("validation-python adapter", () => { } }); - it("prefers pyright when pyright project config is present", async () => { + it("executes configured pyright and normalizes machine JSON diagnostics", async () => { const repoRoot = mkdtempSync(join(tmpdir(), "opcore-python-types-pyright-")); try { + writePassingPythonProtocolShim(repoRoot); writeFileSync(join(repoRoot, "pyrightconfig.json"), "{}\n"); writeToolShim(repoRoot, "mypy", "#!/bin/sh\nif [ \"$1\" = \"--version\" ]; then echo 'mypy 1.8.0'; exit 0; fi\necho 'mypy should not run'; exit 1\n"); writeToolShim( repoRoot, "pyright", - [ - "#!/bin/sh", - "if [ \"$1\" = \"--version\" ]; then echo 'pyright 1.1.0'; exit 0; fi", - "echo \" $1:1:14 - error: Type \\\"Literal['wrong']\\\" is not assignable to declared type \\\"int\\\" (reportAssignmentType)\"", - "exit 1", - "" - ].join("\n") + pyrightShim("1.1.0", [{ + file: "pkg/app.py", + severity: "error", + message: "Type mismatch\n Literal is not assignable to int", + rule: "reportAssignmentType", + range: { start: { line: 0, character: 13 }, end: { line: 0, character: 20 } } + }], 1) ); const result = await runner({ files: { - "pkg/app.py": "value: int = 'wrong'\n" + "pkg/app.py": "value: int = 'wrong'\n", + "pyrightconfig.json": "{}\n" }, - checks: createPythonValidationChecks({ env: { PATH: "" }, repoRoot }) + checks: createPythonValidationChecks({ + env: { PATH: "" }, + repoRoot, + nodeWorkspace: createNodePythonProjectWorkspace(repoRoot), + toolArgv: { + mypy: [join(repoRoot, ".venv", "bin", "mypy")], + pyright: [join(repoRoot, ".venv", "bin", "pyright")] + }, + }) }).runValidation( request({ repo: { repoRoot }, @@ -181,125 +461,2052 @@ describe("validation-python adapter", () => { }) ); - assert.equal(result.status, "policy_failure"); + assert.equal(result.status, "policy_failure", JSON.stringify(result, null, 2)); assert.equal(result.diagnostics[0].code, "PYRIGHT_REPORT_ASSIGNMENT_TYPE"); assert.equal(result.diagnostics[0].path, "pkg/app.py"); + assert.equal(result.diagnostics[0].line, 1); + assert.equal(result.diagnostics[0].column, 14); + assert.equal(result.diagnostics[0].message.includes("\n"), true); + assert.equal(result.pythonCapabilityRuns[0].status, "findings"); + assert.deepEqual(result.pythonCapabilityRuns[0].tool.argv.slice(-5), [ + "--outputjson", "--project", "pyrightconfig.json", "--pythonpath", "repo:.venv/bin/python" + ]); + assert.equal(result.pythonProjectContexts[0].tools.find((tool) => tool.tool === "pyright")?.source, "explicit_override"); + assert.equal(result.pythonProjectContexts[0].tools.find((tool) => tool.tool === "pyright")?.configFile, "pyrightconfig.json"); } finally { rmSync(repoRoot, { recursive: true, force: true }); } }); - it("reports syntax diagnostics from overlay after-state content", async () => { - const result = await runner({ - files: { - "pkg/app.py": "value = 1\n" + it("executes [tool.pyright] and preserves pyrightconfig.json precedence", async () => { + for (const testCase of [ + { + name: "toml", + files: { + "pyproject.toml": "[project]\nname='fixture'\n[tool.pyright]\ninclude=['pkg']\n", + "pkg/app.py": "value: int = 1\n" + }, + configFile: "pyproject.toml" + }, + { + name: "json-precedence", + files: { + "pyrightconfig.json": "{}\n", + "pyproject.toml": "[project]\nname='fixture'\n[tool.pyright]\nstubPath='/tmp/host-stubs'\n", + "pkg/app.py": "value: int = 1\n" + }, + configFile: "pyrightconfig.json" } - }).runValidation( - request({ - checks: [PYTHON_SYNTAX_CHECK_ID], - overlays: [{ path: "pkg/app.py", action: "write", content: "if True\n value = (1\n" }] - }) - ); + ]) { + const repoRoot = mkdtempSync(join(tmpdir(), `opcore-python-pyright-${testCase.name}-`)); + try { + writePassingPythonProtocolShim(repoRoot); + writeToolShim(repoRoot, "pyright", pyrightShim("1.1.411", [], 0)); + const result = await runner({ + files: testCase.files, + checks: createPythonValidationChecks({ repoRoot, env: { PATH: "" }, nodeWorkspace: createNodePythonProjectWorkspace(repoRoot) }) + }).runValidation(request({ repo: { repoRoot }, checks: [PYTHON_TYPES_CHECK_ID] })); - assert.equal(result.status, "policy_failure"); - assert.deepEqual( - result.diagnostics.map((diagnostic) => diagnostic.code), - ["PY_SYNTAX_PARSE_ERROR"] - ); - assert.equal(result.diagnostics[0].path, "pkg/app.py"); + assert.equal(result.status, "passed", `${testCase.name}: ${JSON.stringify(result, null, 2)}`); + assert.deepEqual(result.pythonCapabilityRuns[0].selectedConfigPaths, [testCase.configFile]); + assert.equal(result.pythonCapabilityRuns[0].tool.configFile, testCase.configFile); + assert.deepEqual(result.pythonCapabilityRuns[0].tool.argv.slice(1, 4), ["--outputjson", "--project", testCase.configFile]); + } finally { + rmSync(repoRoot, { recursive: true, force: true }); + } + } }); - it("fails python.syntax for invalid Python grammar the heuristics miss", async () => { - const result = await runner({ - files: { - "pkg/app.py": "x = 1 2\n" - } - }).runValidation( - request({ - checks: [PYTHON_SYNTAX_CHECK_ID] - }) - ); + it("accepts the canonical repository root in every Pyright path field", async () => { + const repoRoot = mkdtempSync(join(tmpdir(), "opcore-python-pyright-root-paths-")); + try { + writePassingPythonProtocolShim(repoRoot); + writeToolShim(repoRoot, "pyright", pyrightShim("1.1.411", [], 0)); + const config = { + include: ["."], + exclude: ["."], + ignore: ["."], + extraPaths: ["."], + stubPath: ".", + typeshedPath: ".", + venvPath: ".", + venv: "fixture-env", + executionEnvironments: [{ root: ".", extraPaths: ["."] }] + }; + const result = await runner({ + files: { + "pyrightconfig.json": `${JSON.stringify(config)}\n`, + "pkg/app.py": "value: int = 1\n" + }, + checks: createPythonValidationChecks({ + repoRoot, + env: { PATH: "" }, + nodeWorkspace: createNodePythonProjectWorkspace(repoRoot) + }) + }).runValidation(request({ repo: { repoRoot }, checks: [PYTHON_TYPES_CHECK_ID] })); - assert.equal(result.status, "policy_failure"); - assert.deepEqual(result.diagnostics.map((diagnostic) => diagnostic.code), ["PY_SYNTAX_PARSE_ERROR"]); - assert.equal(result.diagnostics[0].path, "pkg/app.py"); + assert.equal(result.status, "passed", JSON.stringify(result, null, 2)); + assert.equal(result.pythonCapabilityRuns[0].status, "passed"); + assert.deepEqual(result.pythonCapabilityRuns[0].selectedSourcePaths, ["pkg/app.py"]); + } finally { + rmSync(repoRoot, { recursive: true, force: true }); + } }); - it("reports python.syntax as unsupported when no Python interpreter is available", async () => { - const result = await runner({ - files: { - "pkg/app.py": "x = 1 2\n" - }, - checks: createPythonValidationChecks({ env: { PATH: "" } }) - }).runValidation( - request({ - checks: [PYTHON_SYNTAX_CHECK_ID] - }) - ); + it("accepts Pyright glob patterns and the repository root without non-file realpath evidence", async () => { + const repoRoot = mkdtempSync(join(tmpdir(), "opcore-python-pyright-host-paths-")); + try { + writePassingPythonProtocolShim(repoRoot); + writeToolShim(repoRoot, "pyright", pyrightShim("1.1.411", [], 0)); + const config = { + include: ["src/**/*.py"], + exclude: ["**/__pycache__"], + ignore: ["generated/**"], + extraPaths: [".", "src"], + stubPath: "stubs", + executionEnvironments: [{ root: "src", extraPaths: [".", "stubs"] }] + }; + const files = { + "pyrightconfig.json": `${JSON.stringify(config)}\n`, + "src/app.py": "value: int = 1\n", + "stubs/external/__init__.pyi": "VALUE: int\n" + }; + const baseWorkspace = projectWorkspace(files, (path) => existsSync(path)); + const result = await runner({ + files, + checks: createPythonValidationChecks({ + repoRoot, + env: { PATH: "" }, + nodeWorkspace: { + ...baseWorkspace, + realpath: async (path) => files[path] === undefined + ? { path, symlink: false, unavailable: true } + : { path, symlink: false } + } + }) + }).runValidation(request({ + repo: { repoRoot }, + checks: [PYTHON_TYPES_CHECK_ID], + scope: { kind: "files", files: ["src/app.py"] } + })); - assert.equal(result.status, "unsupported_request"); - assert.deepEqual(result.diagnostics.map((diagnostic) => diagnostic.code), ["PY_SYNTAX_PARSER_UNAVAILABLE"]); + assert.equal(result.status, "passed", JSON.stringify(result, null, 2)); + assert.equal(result.pythonCapabilityRuns[0].status, "passed"); + assert.equal(result.diagnostics.some((entry) => entry.code === "PYTHON_TYPES_INVALID_CONFIG"), false); + } finally { + rmSync(repoRoot, { recursive: true, force: true }); + } }); - it("passes python.syntax for valid multi-line Python with no false positives", async () => { - const result = await runner({ - files: { - "pkg/app.py": "def public_api():\n if True:\n return 1\n return 0\n" + it("selects the type checker from overlay after-state config", async () => { + const repoRoot = mkdtempSync(join(tmpdir(), "opcore-python-types-overlay-config-")); + try { + writePassingPythonProtocolShim(repoRoot); + writeToolShim(repoRoot, "mypy", "#!/bin/sh\nif [ \"$1\" = \"--version\" ]; then echo 'mypy 1.8.0'; exit 0; fi\necho 'mypy should not run'; exit 1\n"); + writeToolShim( + repoRoot, + "pyright", + pyrightShim("1.1.0", [{ + file: "pkg/app.py", + severity: "error", + message: "overlay config selected Pyright", + rule: "reportAssignmentType", + range: { start: { line: 0, character: 13 }, end: { line: 0, character: 20 } } + }], 1) + ); + const files = { + "mypy.ini": "[mypy]\nstrict = true\n", + "pkg/app.py": "value: int = 'wrong'\n" + }; + const result = await runner({ + files, + checks: createPythonValidationChecks({ + env: { PATH: "" }, + repoRoot, + nodeWorkspace: projectWorkspace(files, () => true) + }) + }).runValidation(request({ + repo: { repoRoot }, + checks: [PYTHON_TYPES_CHECK_ID], + overlays: [ + { path: "mypy.ini", action: "delete" }, + { path: "pyrightconfig.json", action: "write", content: "{}\n" } + ] + })); + + assert.equal(result.status, "policy_failure", JSON.stringify(result, null, 2)); + assert.equal(result.diagnostics[0].code, "PYRIGHT_REPORT_ASSIGNMENT_TYPE"); + assert.equal(result.pythonCapabilityRuns[0].authority, "pyright", JSON.stringify(result, null, 2)); + assert.equal(result.pythonProjectContexts[0].tools.find((tool) => tool.tool === "pyright")?.configFile, "pyrightconfig.json"); + } finally { + rmSync(repoRoot, { recursive: true, force: true }); + } + }); + + it("switches pyright to mypy from the same exact authority overlay", async () => { + const repoRoot = mkdtempSync(join(tmpdir(), "opcore-python-types-overlay-pyright-to-mypy-")); + try { + writePassingPythonProtocolShim(repoRoot); + writeToolShim(repoRoot, "pyright", pyrightShim("1.1.411", [], 0)); + writeToolShim(repoRoot, "mypy", "#!/bin/sh\nif [ \"$1\" = \"--version\" ]; then echo 'mypy 2.3.0'; exit 0; fi\nexit 0\n"); + const files = { "pyrightconfig.json": "{}\n", "pkg/app.py": "value: int = 1\n" }; + const result = await runner({ + files, + checks: createPythonValidationChecks({ repoRoot, env: { PATH: "" }, nodeWorkspace: createNodePythonProjectWorkspace(repoRoot) }) + }).runValidation(request({ + repo: { repoRoot }, + checks: [PYTHON_TYPES_CHECK_ID], + overlays: [ + { path: "pyrightconfig.json", action: "delete" }, + { path: "mypy.ini", action: "write", content: "[mypy]\nstrict = true\n" } + ] + })); + + assert.equal(result.status, "passed", JSON.stringify(result, null, 2)); + assert.equal(result.pythonCapabilityRuns[0].authority, "mypy"); + assert.deepEqual(result.pythonCapabilityRuns[0].selectedConfigPaths, ["mypy.ini"]); + } finally { + rmSync(repoRoot, { recursive: true, force: true }); + } + }); + + it("fails pyright machine protocol contradictions and fatal exits closed", async () => { + const validFinding = pyrightPayload("1.1.411", [{ + file: "pkg/app.py", + severity: "error", + message: "assignment failure", + rule: "reportAssignmentType", + range: { start: { line: 0, character: 0 }, end: { line: 0, character: 1 } } + }]); + const clean = pyrightPayload("1.1.411", []); + const deeplyNested = '{"version":"1.1.411","generalDiagnostics":' + "[".repeat(10000) + "0" + "]".repeat(10000) + + ',"summary":{"filesAnalyzed":1,"errorCount":0,"warningCount":0,"informationCount":0,"timeInSec":0}}'; + const contradictory = pyrightPayload("1.1.411", [ + validFinding.generalDiagnostics[0], + { ...validFinding.generalDiagnostics[0], severity: "warning" } + ]); + const cases = [ + { name: "malformed", body: "printf '{not-json\\n'; exit 1" }, + { name: "truncated", body: `printf '%s' '${JSON.stringify(clean).slice(0, -2)}'; exit 0` }, + { name: "excessively-nested", body: `cat <<'JSON'\n${deeplyNested}\nJSON\nexit 1` }, + { name: "duplicate-key", body: `cat <<'JSON'\n${JSON.stringify(clean).replace('\"version\":\"1.1.411\"', '\"version\":\"1.1.411\",\"version\":\"1.1.411\"')}\nJSON\nexit 0` }, + { name: "version-mismatch", body: `cat <<'JSON'\n${JSON.stringify({ ...clean, version: "1.1.410" })}\nJSON\nexit 0` }, + { name: "summary-mismatch", body: `cat <<'JSON'\n${JSON.stringify({ ...validFinding, summary: { ...validFinding.summary, errorCount: 0 } })}\nJSON\nexit 1` }, + { name: "zero-files", body: `cat <<'JSON'\n${JSON.stringify({ ...clean, summary: { ...clean.summary, filesAnalyzed: 0 } })}\nJSON\nexit 0` }, + { name: "contradictory-diagnostic", body: `cat <<'JSON'\n${JSON.stringify(contradictory)}\nJSON\nexit 1` }, + { name: "out-of-repo", body: `cat <<'JSON'\n${JSON.stringify(pyrightPayload("1.1.411", [{ ...validFinding.generalDiagnostics[0], file: "/tmp/outside.py" }]))}\nJSON\nexit 1` }, + { name: "outside-source-closure", body: `cat <<'JSON'\n${JSON.stringify(pyrightPayload("1.1.411", [{ ...validFinding.generalDiagnostics[0], file: "pkg/missing.py" }]))}\nJSON\nexit 1` }, + { name: "reversed-range", body: `cat <<'JSON'\n${JSON.stringify(pyrightPayload("1.1.411", [{ ...validFinding.generalDiagnostics[0], range: { start: { line: 2, character: 1 }, end: { line: 1, character: 1 } } }]))}\nJSON\nexit 1` }, + { name: "out-of-bounds-range", body: `cat <<'JSON'\n${JSON.stringify(pyrightPayload("1.1.411", [{ ...validFinding.generalDiagnostics[0], range: { start: { line: 99, character: 0 }, end: { line: 99, character: 1 } } }]))}\nJSON\nexit 1` }, + { name: "stderr", body: `cat <<'JSON'\n${JSON.stringify(clean)}\nJSON\necho fatal >&2\nexit 0` }, + { name: "exit-zero-errors", body: `cat <<'JSON'\n${JSON.stringify(validFinding)}\nJSON\nexit 0` }, + { name: "exit-one-empty", body: `cat <<'JSON'\n${JSON.stringify(clean)}\nJSON\nexit 1` }, + { name: "fatal-two", body: `cat <<'JSON'\n${JSON.stringify(clean)}\nJSON\nexit 2` }, + { name: "illegal-four", body: `cat <<'JSON'\n${JSON.stringify(clean)}\nJSON\nexit 4` } + ]; + for (const testCase of cases) { + const repoRoot = mkdtempSync(join(tmpdir(), `opcore-python-pyright-${testCase.name}-`)); + try { + writePassingPythonProtocolShim(repoRoot); + writeToolShim(repoRoot, "pyright", rawPyrightShim("1.1.411", testCase.body)); + const files = { "pyrightconfig.json": "{}\n", "pkg/app.py": "value: int = 1\n" }; + const before = materializedMypyWorkspaces(); + const result = await runner({ + files, + checks: createPythonValidationChecks({ + repoRoot, + env: { PATH: "" }, + nodeWorkspace: createNodePythonProjectWorkspace(repoRoot) + }) + }).runValidation(request({ repo: { repoRoot }, checks: [PYTHON_TYPES_CHECK_ID] })); + assert.equal(result.status, "infrastructure_failure", `${testCase.name}: ${JSON.stringify(result, null, 2)}`); + assert.equal(result.pythonCapabilityRuns.length, 1, testCase.name); + assert.equal(result.pythonCapabilityRuns[0].status, "tool_failure", testCase.name); + assert.equal(result.diagnostics.some((entry) => entry.code === "PYTHON_TYPES_TOOL_FAILED"), true, testCase.name); + assert.deepEqual(materializedMypyWorkspaces(), before, testCase.name); + } finally { + rmSync(repoRoot, { recursive: true, force: true }); } - }).runValidation( - request({ - checks: [PYTHON_SYNTAX_CHECK_ID] - }) - ); + } + }); - assert.equal(result.status, "passed"); - assert.deepEqual(result.diagnostics, []); + it("maps pyright exit 3 to executed invalid_config evidence", async () => { + const repoRoot = mkdtempSync(join(tmpdir(), "opcore-python-pyright-config-exit-")); + try { + writePassingPythonProtocolShim(repoRoot); + const payload = pyrightPayload("1.1.411", [{ + file: "pyrightconfig.json", + severity: "error", + message: "Invalid configuration", + range: { start: { line: 0, character: 0 }, end: { line: 0, character: 1 } } + }]); + writeToolShim(repoRoot, "pyright", rawPyrightShim("1.1.411", `cat <<'JSON'\n${JSON.stringify(payload)}\nJSON\necho rejected >&2\nexit 3`)); + const files = { "pyrightconfig.json": "{}\n", "pkg/app.py": "value: int = 1\n" }; + const result = await runner({ + files, + checks: createPythonValidationChecks({ repoRoot, env: { PATH: "" }, nodeWorkspace: createNodePythonProjectWorkspace(repoRoot) }) + }).runValidation(request({ repo: { repoRoot }, checks: [PYTHON_TYPES_CHECK_ID] })); + + assert.equal(result.status, "unsupported_request", JSON.stringify(result, null, 2)); + assert.equal(result.pythonCapabilityRuns[0].status, "invalid_config"); + assert.deepEqual(result.pythonCapabilityRuns[0].execution, { + termination: "exited", + exitCode: 3, + failureSummary: "pyright rejected selected configuration: pyrightconfig.json" + }); + } finally { + rmSync(repoRoot, { recursive: true, force: true }); + } }); - it("reports source-hygiene diagnostics from overlay after-state content", async () => { - const result = await runner({ - files: { - "pkg/app.py": "value = 1\n" + it("maps pyright timeout, signal, and spawn failures and cleans exact workspaces", async () => { + const cases = [ + { name: "timeout", body: "/bin/sleep 2", status: "timeout", termination: "timeout", timeoutMs: 500 }, + { name: "signal", body: "kill -TERM $$", status: "tool_failure", termination: "signal" }, + { name: "spawn", body: "exit 0", status: "tool_failure", termination: "spawn_error", removeAfterProbe: true } + ]; + for (const testCase of cases) { + const repoRoot = mkdtempSync(join(tmpdir(), `opcore-python-pyright-${testCase.name}-`)); + try { + writePassingPythonProtocolShim(repoRoot); + const executable = join(repoRoot, ".venv/bin/pyright"); + writeToolShim(repoRoot, "pyright", testCase.removeAfterProbe + ? `#!/bin/sh\nif [ \"$1\" = \"--version\" ]; then /bin/rm -- \"$0\"; echo 'pyright 1.1.411'; exit 0; fi\nexit 0\n` + : rawPyrightShim("1.1.411", testCase.body)); + const files = { "pyrightconfig.json": "{}\n", "pkg/app.py": "value: int = 1\n" }; + const before = materializedMypyWorkspaces(); + const result = await runner({ + files, + checks: createPythonValidationChecks({ + repoRoot, + env: { PATH: "" }, + timeoutMs: testCase.timeoutMs, + nodeWorkspace: createNodePythonProjectWorkspace(repoRoot), + toolArgv: { pyright: [executable] } + }) + }).runValidation(request({ repo: { repoRoot }, checks: [PYTHON_TYPES_CHECK_ID] })); + assert.ok(result.pythonCapabilityRuns?.[0], JSON.stringify(result, null, 2)); + assert.equal(result.pythonCapabilityRuns[0].status, testCase.status, JSON.stringify(result, null, 2)); + assert.equal(result.pythonCapabilityRuns[0].execution.termination, testCase.termination); + assert.deepEqual(materializedMypyWorkspaces(), before); + } finally { + rmSync(repoRoot, { recursive: true, force: true }); } - }).runValidation( - request({ - checks: [PYTHON_SOURCE_HYGIENE_CHECK_ID], + } + }); + + it("accepts passed pyright warning and information evidence", async () => { + const repoRoot = mkdtempSync(join(tmpdir(), "opcore-python-pyright-warning-")); + try { + writePassingPythonProtocolShim(repoRoot); + writeToolShim(repoRoot, "pyright", pyrightShim("1.1.411", [ + { file: "pkg/app.py", severity: "warning", message: "warning", rule: "reportUnusedImport", range: { start: { line: 0, character: 0 }, end: { line: 0, character: 1 } } }, + { file: "pkg/app.py", severity: "information", message: "information", range: { start: { line: 0, character: 1 }, end: { line: 0, character: 2 } } } + ], 0)); + const files = { "pyrightconfig.json": "{}\n", "pkg/app.py": "value: int = 1\n" }; + const result = await runner({ + files, + checks: createPythonValidationChecks({ repoRoot, env: { PATH: "" }, nodeWorkspace: createNodePythonProjectWorkspace(repoRoot) }) + }).runValidation(request({ repo: { repoRoot }, checks: [PYTHON_TYPES_CHECK_ID] })); + + assert.equal(result.pythonCapabilityRuns[0].status, "passed", JSON.stringify(result, null, 2)); + assert.deepEqual({ + errors: result.pythonCapabilityRuns[0].errorCount, + warnings: result.pythonCapabilityRuns[0].warningCount, + notes: result.pythonCapabilityRuns[0].noteCount + }, { errors: 0, warnings: 1, notes: 1 }); + } finally { + rmSync(repoRoot, { recursive: true, force: true }); + } + }); + + it("materializes recursive pyright extends and rejects unsafe closures before execution", async () => { + const repoRoot = mkdtempSync(join(tmpdir(), "opcore-python-pyright-extends-")); + try { + writePassingPythonProtocolShim(repoRoot); + writeToolShim(repoRoot, "pyright", rawPyrightShim("1.1.411", [ + "test -f configs/base.json || exit 91", + "test -f configs/stubs/external/__init__.pyi || exit 92", + `cat <<'JSON'\n${JSON.stringify(pyrightPayload("1.1.411", []))}\nJSON`, + "exit 0" + ].join("\n"))); + const files = { + "pyrightconfig.json": "{ // JSONC\n \"extends\": \"configs/base.json\",\n \"include\": [\"src\"],\n}\n", + "configs/base.json": "{\"stubPath\":\"stubs\",\"extraPaths\":[\"src\"]}\n", + "configs/src/helper.py": "VALUE: int = 1\n", + "src/app.py": "value: int = 1\n", + "configs/stubs/external/__init__.pyi": "VALUE: int\n" + }; + const result = await runner({ + files, + checks: createPythonValidationChecks({ repoRoot, env: { PATH: "" }, nodeWorkspace: projectWorkspace(files, () => true) }) + }).runValidation(request({ repo: { repoRoot }, checks: [PYTHON_TYPES_CHECK_ID], scope: { kind: "files", files: ["src/app.py"] } })); + assert.equal(result.status, "passed", JSON.stringify(result, null, 2)); + assert.deepEqual(result.pythonCapabilityRuns[0].selectedConfigPaths, ["configs/base.json", "pyrightconfig.json"]); + + for (const [name, configFiles] of Object.entries({ + missing: { "pyrightconfig.json": "{\"extends\":\"configs/missing.json\"}\n" }, + rootDirectory: { "pyrightconfig.json": "{\"extends\":\".\"}\n" }, + cycle: { + "pyrightconfig.json": "{\"extends\":\"configs/a.json\"}\n", + "configs/a.json": "{\"extends\":\"b.json\"}\n", + "configs/b.json": "{\"extends\":\"a.json\"}\n" + }, + traversal: { "pyrightconfig.json": "{\"extraPaths\":[\"../outside\"]}\n" }, + absolute: { "pyrightconfig.json": "{\"stubPath\":\"/tmp/stubs\"}\n" } + })) { + const attempted = join(repoRoot, `attempted-${name}`); + writeToolShim(repoRoot, "pyright", rawPyrightShim("1.1.411", `touch ${JSON.stringify(attempted)}\nexit 0`)); + const caseFiles = { ...configFiles, "src/app.py": "value: int = 1\n" }; + const failed = await runner({ + files: caseFiles, + checks: createPythonValidationChecks({ repoRoot, env: { PATH: "" }, nodeWorkspace: projectWorkspace(caseFiles, () => true) }) + }).runValidation(request({ repo: { repoRoot }, checks: [PYTHON_TYPES_CHECK_ID], scope: { kind: "files", files: ["src/app.py"] } })); + assert.equal(failed.pythonCapabilityRuns[0].status, "invalid_config", `${name}: ${JSON.stringify(failed, null, 2)}`); + assert.equal(existsSync(attempted), false, name); + } + } finally { + rmSync(repoRoot, { recursive: true, force: true }); + } + }); + + it("materializes pyright stub write/delete overlays without stale inputs", async () => { + const repoRoot = mkdtempSync(join(tmpdir(), "opcore-python-pyright-stub-overlay-")); + try { + writePassingPythonProtocolShim(repoRoot); + writeToolShim(repoRoot, "pyright", rawPyrightShim("1.1.411", [ + "test ! -e stubs/old.pyi || exit 91", + "test -f stubs/new.pyi || exit 92", + `cat <<'JSON'\n${JSON.stringify(pyrightPayload("1.1.411", []))}\nJSON`, + "exit 0" + ].join("\n"))); + const files = { + "pyrightconfig.json": "{\"stubPath\":\"stubs\"}\n", + "pkg/app.py": "value: int = 1\n", + "stubs/old.pyi": "OLD: int\n" + }; + const result = await runner({ + files, + checks: createPythonValidationChecks({ repoRoot, env: { PATH: "" }, nodeWorkspace: createNodePythonProjectWorkspace(repoRoot) }) + }).runValidation(request({ + repo: { repoRoot }, + checks: [PYTHON_TYPES_CHECK_ID], overlays: [ - { - path: "pkg/app.py", - action: "write", - content: ["# type: ignore", "# noqa", "# fmt: off", "value = 1", ""].join("\n") - } + { path: "stubs/old.pyi", action: "delete" }, + { path: "stubs/new.pyi", action: "write", content: "NEW: int\n" } ] - }) - ); + })); - assert.equal(result.status, "policy_failure"); - assert.deepEqual( - result.diagnostics.map((diagnostic) => diagnostic.code), - ["PY_SOURCE_FORMATTER_DISABLED", "PY_SOURCE_NOQA_BROAD", "PY_SOURCE_TYPE_IGNORE"] - ); + assert.equal(result.status, "passed", JSON.stringify(result, null, 2)); + assert.equal(result.pythonCapabilityRuns[0].selectedSourcePaths.includes("stubs/old.pyi"), false); + assert.equal(result.pythonCapabilityRuns[0].selectedSourcePaths.includes("stubs/new.pyi"), true); + } finally { + rmSync(repoRoot, { recursive: true, force: true }); + } }); - it("reports missing Python IMPORTS_FROM graph edges for resolved repo imports", async () => { - const result = await runner({ - files: { - "pkg/app.py": "from .dep import value\nresult = value\n", + it("refuses symlinked pyright configs, configured roots, and stub inputs before execution", async () => { + const repoRoot = mkdtempSync(join(tmpdir(), "opcore-python-pyright-symlink-")); + try { + writePassingPythonProtocolShim(repoRoot); + const marker = join(repoRoot, "pyright-ran"); + writeToolShim(repoRoot, "pyright", rawPyrightShim("1.1.411", `touch ${JSON.stringify(marker)}\nexit 0`)); + const cases = [ + { + files: { + "pyrightconfig.json": "{\"extends\":\"configs/base.json\"}\n", + "configs/base.json": "{}\n", + "src/app.py": "VALUE = 1\n" + }, + symlinks: new Set(["configs/base.json"]) + }, + { + files: { + "pyrightconfig.json": "{\"stubPath\":\"stubs\"}\n", + "src/app.py": "VALUE = 1\n", + "stubs/external/__init__.pyi": "VALUE: int\n" + }, + symlinks: new Set(["stubs/external/__init__.pyi"]) + }, + { + files: { + "pyrightconfig.json": "{\"stubPath\":\"stubs\"}\n", + "src/app.py": "VALUE = 1\n" + }, + symlinks: new Set(["stubs"]) + } + ]; + for (const testCase of cases) { + const result = await runner({ + files: testCase.files, + checks: createPythonValidationChecks({ + repoRoot, + env: { PATH: "" }, + nodeWorkspace: projectWorkspace(testCase.files, () => true, testCase.symlinks) + }) + }).runValidation(request({ + repo: { repoRoot }, checks: [PYTHON_TYPES_CHECK_ID], scope: { kind: "files", files: ["src/app.py"] } + })); + assert.equal(result.pythonCapabilityRuns[0].status, "invalid_config", JSON.stringify(result, null, 2)); + assert.equal(existsSync(marker), false); + } + } finally { + rmSync(repoRoot, { recursive: true, force: true }); + } + }); + + it("does not fall back to mypy when configured pyright is unavailable", async () => { + const repoRoot = mkdtempSync(join(tmpdir(), "opcore-python-pyright-unavailable-")); + try { + writePassingPythonProtocolShim(repoRoot); + const mypyMarker = join(repoRoot, "mypy-ran"); + writeToolShim(repoRoot, "mypy", `#!/bin/sh\nif [ \"$1\" = \"--version\" ]; then echo 'mypy 2.3.0'; exit 0; fi\ntouch ${JSON.stringify(mypyMarker)}\nexit 0\n`); + const files = { "pyrightconfig.json": "{}\n", "pkg/app.py": "value: int = 1\n" }; + const result = await runner({ + files, + checks: createPythonValidationChecks({ + repoRoot, + env: { PATH: "" }, + nodeWorkspace: createNodePythonProjectWorkspace(repoRoot) + }) + }).runValidation(request({ repo: { repoRoot }, checks: [PYTHON_TYPES_CHECK_ID] })); + assert.equal(result.pythonCapabilityRuns[0].authority, "pyright", JSON.stringify(result, null, 2)); + assert.equal(result.pythonCapabilityRuns[0].status, "tool_unavailable", JSON.stringify(result, null, 2)); + assert.equal(existsSync(mypyMarker), false); + } finally { + rmSync(repoRoot, { recursive: true, force: true }); + } + }); + + it("retains a Pyright receipt when canonical interpreter probing is malformed", async () => { + const files = { + "pyrightconfig.json": "{}\n", + "pkg/app.py": "value: int = 1\n" + }; + const result = await runner({ + files, + checks: createPythonValidationChecks({ + env: { PATH: "/fixture/bin" }, + nodeWorkspace: projectWorkspace(files, () => true), + processProbe: successfulProbe("malformed-version") + }) + }).runValidation(request({ repo: { repoRoot: "/fixture" }, checks: [PYTHON_TYPES_CHECK_ID] })); + + assert.equal(result.pythonProjectContexts[0].interpreter, undefined); + assert.equal(result.pythonProjectContexts[0].reasons.some((reason) => + reason.code === "malformed_probe_output" && reason.tool === "python" + ), true); + assert.equal(result.pythonCapabilityRuns.length, 1, JSON.stringify(result, null, 2)); + assert.equal(result.pythonCapabilityRuns[0].authority, "pyright"); + assert.equal(result.pythonCapabilityRuns[0].status, "invalid_config"); + assert.equal(result.pythonCapabilityRuns[0].execution, undefined); + assert.equal(result.diagnostics.some((entry) => entry.code === "PYTHON_TYPES_INVALID_CONFIG"), true); + }); + + it("retains independent mixed mypy and pyright project receipts", async () => { + const repoRoot = mkdtempSync(join(tmpdir(), "opcore-python-mixed-authorities-")); + try { + const mypyRoot = join(repoRoot, "services/mypy-app"); + const pyrightRoot = join(repoRoot, "services/pyright-app"); + mkdirSync(mypyRoot, { recursive: true }); + mkdirSync(pyrightRoot, { recursive: true }); + writePassingPythonProtocolShim(mypyRoot); + writePassingPythonProtocolShim(pyrightRoot); + writeToolShim(mypyRoot, "mypy", "#!/bin/sh\nif [ \"$1\" = \"--version\" ]; then echo 'mypy 2.3.0'; exit 0; fi\nexit 0\n"); + writeToolShim(pyrightRoot, "pyright", pyrightShim("1.1.411", [], 0)); + const files = { + "services/mypy-app/pyproject.toml": "[project]\nname='mypy-app'\n[tool.mypy]\nstrict=true\n", + "services/mypy-app/app.py": "VALUE: int = 1\n", + "services/pyright-app/pyproject.toml": "[project]\nname='pyright-app'\n", + "services/pyright-app/pyrightconfig.json": "{\"include\":[\".\"]}\n", + "services/pyright-app/app.py": "VALUE: int = 1\n" + }; + const result = await runner({ + files, + checks: createPythonValidationChecks({ + repoRoot, + env: { PATH: "" }, + nodeWorkspace: projectWorkspace(files, (path) => existsSync(path)) + }) + }).runValidation(request({ + repo: { repoRoot }, + checks: [PYTHON_TYPES_CHECK_ID], + scope: { kind: "files", files: ["services/mypy-app/app.py", "services/pyright-app/app.py"] } + })); + + assert.equal(result.status, "passed", JSON.stringify(result, null, 2)); + assert.deepEqual(result.pythonCapabilityRuns.map((run) => [run.projectRoot, run.authority, run.status]), [ + ["services/mypy-app", "mypy", "passed"], + ["services/pyright-app", "pyright", "passed"] + ]); + } finally { + rmSync(repoRoot, { recursive: true, force: true }); + } + }); + + it("keeps root Pyright sources outside a nested mypy authority boundary", async () => { + const repoRoot = mkdtempSync(join(tmpdir(), "opcore-python-nested-authority-boundary-")); + try { + const nestedRoot = join(repoRoot, "nested"); + mkdirSync(nestedRoot, { recursive: true }); + writePassingPythonProtocolShim(repoRoot); + writePassingPythonProtocolShim(nestedRoot); + const nestedDiagnostic = { + file: "nested/app.py", + severity: "error", + message: "root Pyright crossed the nested mypy authority boundary", + rule: "reportAssignmentType", + range: { start: { line: 0, character: 13 }, end: { line: 0, character: 20 } } + }; + writeToolShim(repoRoot, "pyright", rawPyrightShim("1.1.411", [ + "if [ -f nested/app.py ]; then", + "cat <<'OPCORE_PYRIGHT_NESTED_JSON'", + JSON.stringify(pyrightPayload("1.1.411", [nestedDiagnostic])), + "OPCORE_PYRIGHT_NESTED_JSON", + "exit 1", + "fi", + "cat <<'OPCORE_PYRIGHT_ROOT_JSON'", + JSON.stringify(pyrightPayload("1.1.411", [])), + "OPCORE_PYRIGHT_ROOT_JSON", + "exit 0" + ].join("\n"))); + writeToolShim(nestedRoot, "mypy", "#!/bin/sh\nif [ \"$1\" = \"--version\" ]; then echo 'mypy 2.3.0'; exit 0; fi\nexit 0\n"); + const files = { + "pyrightconfig.json": "{\"include\":[\"**/*.py\"]}\n", + "root.py": "VALUE: int = 1\n", + "nested/pyproject.toml": "[project]\nname='nested'\n[tool.mypy]\nstrict=true\n", + "nested/app.py": "value: int = 'wrong'\n" + }; + const result = await runner({ + files, + checks: createPythonValidationChecks({ + repoRoot, + env: { PATH: "" }, + nodeWorkspace: projectWorkspace(files, (path) => existsSync(path)) + }) + }).runValidation(request({ + repo: { repoRoot }, + checks: [PYTHON_TYPES_CHECK_ID], + scope: { kind: "files", files: ["root.py", "nested/app.py"] } + })); + + assert.equal(result.status, "passed", JSON.stringify(result, null, 2)); + assert.deepEqual(result.pythonCapabilityRuns.map((run) => [run.projectRoot, run.authority, run.selectedSourcePaths]), [ + ["nested", "mypy", ["nested/app.py"]], + [".", "pyright", ["root.py"]] + ]); + } finally { + rmSync(repoRoot, { recursive: true, force: true }); + } + }); + + it("keeps root-only Pyright scope outside nested canonical project sources", async () => { + const repoRoot = mkdtempSync(join(tmpdir(), "opcore-python-root-only-authority-boundary-")); + try { + writePassingPythonProtocolShim(repoRoot); + const nestedRoot = join(repoRoot, "nested"); + mkdirSync(nestedRoot, { recursive: true }); + const nestedDiagnostic = { + file: "nested/app.py", + severity: "error", + message: "root-only Pyright materialized a nested canonical project", + rule: "reportAssignmentType", + range: { start: { line: 0, character: 13 }, end: { line: 0, character: 20 } } + }; + writeToolShim(repoRoot, "pyright", rawPyrightShim("1.1.411", [ + "if [ -f nested/app.py ]; then", + "cat <<'OPCORE_PYRIGHT_ROOT_ONLY_JSON'", + JSON.stringify(pyrightPayload("1.1.411", [nestedDiagnostic])), + "OPCORE_PYRIGHT_ROOT_ONLY_JSON", + "exit 1", + "fi", + "cat <<'OPCORE_PYRIGHT_ROOT_ONLY_CLEAN_JSON'", + JSON.stringify(pyrightPayload("1.1.411", [])), + "OPCORE_PYRIGHT_ROOT_ONLY_CLEAN_JSON", + "exit 0" + ].join("\n"))); + const files = { + "pyrightconfig.json": "{\"include\":[\"**/*.py\"]}\n", + "root.py": "VALUE: int = 1\n", + "nested/pyproject.toml": "[project]\nname='nested'\n[tool.mypy]\nstrict=true\n", + "nested/app.py": "value: int = 'wrong'\n" + }; + const result = await runner({ + files, + checks: createPythonValidationChecks({ + repoRoot, + env: { PATH: "" }, + nodeWorkspace: projectWorkspace(files, (path) => existsSync(path)) + }) + }).runValidation(request({ + repo: { repoRoot }, + checks: [PYTHON_TYPES_CHECK_ID], + scope: { kind: "files", files: ["root.py"] } + })); + + assert.equal(result.status, "passed", JSON.stringify(result, null, 2)); + assert.deepEqual(result.pythonCapabilityRuns.map((run) => [run.projectRoot, run.authority, run.selectedSourcePaths]), [ + [".", "pyright", ["root.py"]] + ]); + } finally { + rmSync(repoRoot, { recursive: true, force: true }); + } + }); + + it("does not select type authority from installed-tool availability", async () => { + const repoRoot = mkdtempSync(join(tmpdir(), "opcore-python-types-no-authority-")); + try { + writePassingPythonProtocolShim(repoRoot); + writeToolShim(repoRoot, "mypy", "#!/bin/sh\nif [ \"$1\" = \"--version\" ]; then echo 'mypy 2.3.0'; exit 0; fi\nexit 99\n"); + const result = await runner({ + files: { "pkg/app.py": "value: int = 1\n" }, + checks: createPythonValidationChecks({ repoRoot, env: { PATH: "" } }) + }).runValidation(request({ repo: { repoRoot }, checks: [PYTHON_TYPES_CHECK_ID] })); + + assert.equal(result.status, "unsupported_request"); + assert.equal(result.pythonCapabilityRuns[0].status, "unsupported_target"); + assert.equal(result.diagnostics[0].code, "PYTHON_TYPES_UNSUPPORTED_TARGET"); + } finally { + rmSync(repoRoot, { recursive: true, force: true }); + } + }); + + it("does not treat packaging-only setup.cfg or tox.ini as mypy authority", async () => { + const files = { + "setup.cfg": "[metadata]\nname = fixture\n[options]\npackages = find:\n", + "tox.ini": "[tox]\nenvlist = py312\n[testenv]\ncommands = pytest\n", + "pkg/app.py": "value: int = 1\n" + }; + const result = await runner({ + files, + checks: createPythonValidationChecks({ + env: { PATH: "/fixture/bin" }, + nodeWorkspace: projectWorkspace(files, () => true), + processProbe: successfulProbe() + }) + }).runValidation(request({ + repo: { repoRoot: "/fixture" }, + checks: [PYTHON_TYPES_CHECK_ID] + })); + + assert.equal(result.status, "unsupported_request", JSON.stringify(result, null, 2)); + assert.equal(result.pythonCapabilityRuns[0].status, "unsupported_target"); + assert.equal(result.pythonProjectContexts[0].tools.find((tool) => tool.tool === "mypy")?.configFile, undefined); + const status = createPythonValidationAdapterStatus({ contexts: result.pythonProjectContexts }); + assert.equal(status.status, "degraded"); + assert.equal(status.degradedChecks.find((check) => check.checkId === PYTHON_TYPES_CHECK_ID)?.requiredTool, "configured Python type authority"); + }); + + it("uses documented mypy config precedence without reading lower-priority output or plugin settings", async () => { + const repoRoot = mkdtempSync(join(tmpdir(), "opcore-python-types-config-precedence-")); + const hostRoot = mkdtempSync(join(tmpdir(), "opcore-python-types-config-precedence-host-")); + try { + writePassingPythonProtocolShim(repoRoot); + const executed = join(repoRoot, "mypy-executed"); + const outputVictim = join(hostRoot, "output-victim.xml"); + const pluginMarker = join(hostRoot, "plugin-executed"); + const hostPlugin = join(hostRoot, "host-plugin.py"); + writeFileSync(outputVictim, "UNCHANGED\n"); + writeFileSync(hostPlugin, `from pathlib import Path\nPath(${JSON.stringify(pluginMarker)}).write_text('executed')\n`); + writeToolShim(repoRoot, "mypy", [ + "#!/bin/sh", + "if [ \"$1\" = \"--version\" ]; then echo 'mypy 2.3.0'; exit 0; fi", + "config=", + "while [ \"$#\" -gt 0 ]; do", + " if [ \"$1\" = \"--config-file\" ]; then shift; config=$1; fi", + " shift", + "done", + "if [ \"$config\" != \"mypy.ini\" ]; then exit 97; fi", + `/usr/bin/touch ${JSON.stringify(executed)}`, + "exit 0", + "" + ].join("\n")); + const files = { + "mypy.ini": "[mypy]\nstrict = true\n", + ".mypy.ini": "[mypy\nmalformed = ignored\n", + "pyproject.toml": [ + "[project]", + "name = 'fixture'", + "[tool.mypy]", + `junit_xml = ${JSON.stringify(outputVictim)}`, + `plugins = [${JSON.stringify(hostPlugin)}]`, + "" + ].join("\n"), + "setup.cfg": `[mypy]\npython_executable = ${hostPlugin}\n`, + "tox.ini": `[mypy]\ncache_dir = ${hostRoot}\n`, + "pkg/app.py": "value: int = 1\n" + }; + const result = await runner({ + files, + checks: createPythonValidationChecks({ + repoRoot, + env: { PATH: "" }, + nodeWorkspace: createNodePythonProjectWorkspace(repoRoot) + }) + }).runValidation(request({ repo: { repoRoot }, checks: [PYTHON_TYPES_CHECK_ID] })); + + assert.equal(result.status, "passed", JSON.stringify(result, null, 2)); + assert.deepEqual(result.pythonCapabilityRuns[0].selectedConfigPaths, ["mypy.ini"]); + assert.equal(result.pythonCapabilityRuns[0].tool.configFile, "mypy.ini"); + assert.equal(result.pythonProjectContexts[0].tools.find((tool) => tool.tool === "mypy")?.configFile, "mypy.ini"); + assert.equal(result.pythonProjectContexts[0].reasons.some((reason) => reason.code === "invalid_config"), false); + assert.equal(existsSync(executed), true); + assert.equal(readFileSync(outputVictim, "utf8"), "UNCHANGED\n"); + assert.equal(existsSync(pluginMarker), false); + assert.equal(JSON.stringify(result.pythonCapabilityRuns).includes(hostRoot), false); + } finally { + rmSync(repoRoot, { recursive: true, force: true }); + rmSync(hostRoot, { recursive: true, force: true }); + } + }); + + it("fails a dedicated mypy config without a mypy section closed", async () => { + const files = { + "mypy.ini": "[metadata]\nname = not-mypy\n", + "pkg/app.py": "value: int = 1\n" + }; + const result = await runner({ + files, + checks: createPythonValidationChecks({ + env: { PATH: "/fixture/bin" }, + nodeWorkspace: projectWorkspace(files, () => true), + processProbe: successfulProbe() + }) + }).runValidation(request({ repo: { repoRoot: "/fixture" }, checks: [PYTHON_TYPES_CHECK_ID] })); + + assert.equal(result.status, "unsupported_request", JSON.stringify(result, null, 2)); + assert.equal(result.pythonCapabilityRuns[0].status, "invalid_config"); + assert.deepEqual(result.pythonCapabilityRuns[0].selectedConfigPaths, ["mypy.ini"]); + }); + + it("classifies mypy semantic config rejection as invalid_config", async () => { + const repoRoot = mkdtempSync(join(tmpdir(), "opcore-python-types-invalid-mypy-config-")); + try { + writePassingPythonProtocolShim(repoRoot); + writeToolShim(repoRoot, "mypy", [ + "#!/bin/sh", + "if [ \"$1\" = \"--version\" ]; then echo 'mypy 2.3.0'; exit 0; fi", + "echo \"mypy.ini: [mypy]: strict: Not a boolean: garbage\" >&2", + "exit 0", + "" + ].join("\n")); + const files = { + "mypy.ini": "[mypy]\nstrict = garbage\n", + "pkg/app.py": "value: int = 1\n" + }; + const result = await runner({ + files, + checks: createPythonValidationChecks({ + repoRoot, + env: { PATH: "" }, + nodeWorkspace: createNodePythonProjectWorkspace(repoRoot) + }) + }).runValidation(request({ repo: { repoRoot }, checks: [PYTHON_TYPES_CHECK_ID] })); + + assert.equal(result.status, "unsupported_request", JSON.stringify(result, null, 2)); + assert.equal(result.pythonCapabilityRuns[0].status, "invalid_config", JSON.stringify(result, null, 2)); + assert.equal(result.pythonCapabilityRuns[0].execution.termination, "exited"); + assert.equal(result.pythonCapabilityRuns[0].execution.exitCode, 0); + assert.equal(result.diagnostics[0].code, "PYTHON_TYPES_INVALID_CONFIG"); + } finally { + rmSync(repoRoot, { recursive: true, force: true }); + } + }); + + it("classifies complete JSON mypy config diagnostics as invalid_config", async () => { + const repoRoot = mkdtempSync(join(tmpdir(), "opcore-python-types-json-mypy-config-")); + try { + writePassingPythonProtocolShim(repoRoot); + writeToolShim(repoRoot, "mypy", [ + "#!/bin/sh", + "if [ \"$1\" = \"--version\" ]; then echo 'mypy 2.3.0'; exit 0; fi", + "printf '%s\\n' '{\"file\":\"mypy.ini\",\"line\":2,\"column\":0,\"end_line\":2,\"end_column\":1,\"message\":\"strict is not a boolean\",\"hint\":null,\"code\":\"misc\",\"severity\":\"error\"}' >&2", + "exit 2", + "" + ].join("\n")); + const files = { + "mypy.ini": "[mypy]\nstrict = garbage\n", + "pkg/app.py": "value: int = 1\n" + }; + const result = await runner({ + files, + checks: createPythonValidationChecks({ + repoRoot, + env: { PATH: "" }, + nodeWorkspace: createNodePythonProjectWorkspace(repoRoot) + }) + }).runValidation(request({ repo: { repoRoot }, checks: [PYTHON_TYPES_CHECK_ID] })); + + assert.equal(result.status, "unsupported_request", JSON.stringify(result, null, 2)); + assert.equal(result.pythonCapabilityRuns[0].status, "invalid_config"); + assert.equal(result.pythonCapabilityRuns[0].execution.exitCode, 2); + assert.equal(result.diagnostics[0].code, "PYTHON_TYPES_INVALID_CONFIG"); + } finally { + rmSync(repoRoot, { recursive: true, force: true }); + } + }); + + it("classifies an invalid mypy python_version before unsupported target policy", async () => { + const repoRoot = mkdtempSync(join(tmpdir(), "opcore-python-types-invalid-mypy-version-")); + try { + writePassingPythonProtocolShim(repoRoot); + const executed = join(repoRoot, "mypy-executed"); + writeToolShim(repoRoot, "mypy", [ + "#!/bin/sh", + "if [ \"$1\" = \"--version\" ]; then echo 'mypy 2.3.0'; exit 0; fi", + `/usr/bin/touch ${JSON.stringify(executed)}`, + "exit 99", + "" + ].join("\n")); + const files = { + "mypy.ini": "[mypy]\npython_version = garbage\n", + "pkg/app.py": "value: int = 1\n" + }; + const result = await runner({ + files, + checks: createPythonValidationChecks({ + repoRoot, + env: { PATH: "" }, + nodeWorkspace: createNodePythonProjectWorkspace(repoRoot) + }) + }).runValidation(request({ repo: { repoRoot }, checks: [PYTHON_TYPES_CHECK_ID] })); + + assert.equal(result.status, "unsupported_request", JSON.stringify(result, null, 2)); + assert.equal(result.pythonCapabilityRuns[0].status, "invalid_config"); + assert.equal(result.pythonCapabilityRuns[0].execution, undefined); + assert.equal(result.diagnostics[0].code, "PYTHON_TYPES_INVALID_CONFIG"); + assert.equal(existsSync(executed), false); + } finally { + rmSync(repoRoot, { recursive: true, force: true }); + } + }); + + it("runs configured mypy without falling back to an installed pyright", async () => { + const repoRoot = mkdtempSync(join(tmpdir(), "opcore-python-types-configured-mypy-")); + try { + writePassingPythonProtocolShim(repoRoot); + const mypyExecuted = join(repoRoot, "mypy-executed"); + const pyrightExecuted = join(repoRoot, "pyright-executed"); + writeToolShim(repoRoot, "mypy", [ + "#!/bin/sh", + "if [ \"$1\" = \"--version\" ]; then echo 'mypy 2.3.0'; exit 0; fi", + `/usr/bin/touch ${JSON.stringify(mypyExecuted)}`, + "exit 0", + "" + ].join("\n")); + writeToolShim(repoRoot, "pyright", [ + "#!/bin/sh", + "if [ \"$1\" = \"--version\" ]; then echo 'pyright 1.1.0'; exit 0; fi", + `/usr/bin/touch ${JSON.stringify(pyrightExecuted)}`, + "exit 99", + "" + ].join("\n")); + const files = { + "mypy.ini": "[mypy]\nstrict = true\n", + "pkg/app.py": "value: int = 1\n" + }; + const result = await runner({ + files, + checks: createPythonValidationChecks({ + checker: "mypy", + repoRoot, + env: { PATH: "" }, + nodeWorkspace: createNodePythonProjectWorkspace(repoRoot) + }) + }).runValidation(request({ repo: { repoRoot }, checks: [PYTHON_TYPES_CHECK_ID] })); + + assert.equal(result.status, "passed", JSON.stringify(result, null, 2)); + assert.equal(result.pythonCapabilityRuns[0].authority, "mypy"); + assert.equal(result.pythonCapabilityRuns[0].authoritySource, "explicit"); + assert.equal(existsSync(mypyExecuted), true); + assert.equal(existsSync(pyrightExecuted), false); + } finally { + rmSync(repoRoot, { recursive: true, force: true }); + } + }); + + it("reports configured mypy unavailable without falling back to installed pyright", async () => { + const repoRoot = mkdtempSync(join(tmpdir(), "opcore-python-types-mypy-unavailable-")); + try { + writePassingPythonProtocolShim(repoRoot); + const pyrightExecuted = join(repoRoot, "pyright-executed"); + writeToolShim(repoRoot, "pyright", [ + "#!/bin/sh", + "if [ \"$1\" = \"--version\" ]; then echo 'pyright 1.1.0'; exit 0; fi", + `/usr/bin/touch ${JSON.stringify(pyrightExecuted)}`, + "exit 99", + "" + ].join("\n")); + const files = { + "mypy.ini": "[mypy]\nstrict = true\n", + "pkg/app.py": "value: int = 1\n" + }; + const result = await runner({ + files, + checks: createPythonValidationChecks({ + repoRoot, + env: { PATH: "" }, + nodeWorkspace: createNodePythonProjectWorkspace(repoRoot) + }) + }).runValidation(request({ repo: { repoRoot }, checks: [PYTHON_TYPES_CHECK_ID] })); + + assert.equal(result.status, "unsupported_request"); + assert.equal(result.pythonCapabilityRuns[0].status, "tool_unavailable"); + assert.equal(result.pythonCapabilityRuns[0].authority, "mypy"); + assert.equal(existsSync(pyrightExecuted), false); + } finally { + rmSync(repoRoot, { recursive: true, force: true }); + } + }); + + it("fails explicit and configured authority conflicts without checker execution", async () => { + const repoRoot = mkdtempSync(join(tmpdir(), "opcore-python-types-explicit-conflict-")); + try { + writePassingPythonProtocolShim(repoRoot); + const mypyExecuted = join(repoRoot, "mypy-executed"); + writeToolShim(repoRoot, "mypy", [ + "#!/bin/sh", + "if [ \"$1\" = \"--version\" ]; then echo 'mypy 2.3.0'; exit 0; fi", + `/usr/bin/touch ${JSON.stringify(mypyExecuted)}`, + "exit 99", + "" + ].join("\n")); + const files = { + "mypy.ini": "[mypy]\nstrict = true\n", + "pkg/app.py": "value: int = 1\n" + }; + const result = await runner({ + files, + checks: createPythonValidationChecks({ + checker: "pyright", + repoRoot, + env: { PATH: "" }, + nodeWorkspace: createNodePythonProjectWorkspace(repoRoot) + }) + }).runValidation(request({ repo: { repoRoot }, checks: [PYTHON_TYPES_CHECK_ID] })); + + assert.equal(result.status, "unsupported_request"); + assert.equal(result.pythonCapabilityRuns[0].status, "invalid_config"); + assert.equal(result.pythonCapabilityRuns[0].authority, undefined); + assert.equal(result.pythonCapabilityRuns[0].authoritySource, undefined); + assert.equal(result.pythonCapabilityRuns[0].tool, undefined); + assert.equal(existsSync(mypyExecuted), false); + } finally { + rmSync(repoRoot, { recursive: true, force: true }); + } + }); + + it("fails conflicting checker authority closed without executing either checker", async () => { + const repoRoot = mkdtempSync(join(tmpdir(), "opcore-python-types-conflict-")); + try { + writePassingPythonProtocolShim(repoRoot); + for (const tool of ["mypy", "pyright"]) { + writeToolShim(repoRoot, tool, [ + "#!/bin/sh", + `if [ \"$1\" = \"--version\" ]; then echo '${tool} 2.3.0'; exit 0; fi`, + `/usr/bin/touch ${JSON.stringify(join(repoRoot, `${tool}-executed`))}`, + "exit 99", + "" + ].join("\n")); + } + const files = { + "mypy.ini": "[mypy]\nstrict = true\n", + "pyrightconfig.json": "{}\n", + "pkg/app.py": "value: int = 1\n" + }; + const result = await runner({ + files, + checks: createPythonValidationChecks({ + repoRoot, + env: { PATH: "" }, + nodeWorkspace: createNodePythonProjectWorkspace(repoRoot) + }) + }).runValidation(request({ repo: { repoRoot }, checks: [PYTHON_TYPES_CHECK_ID] })); + + assert.equal(result.status, "unsupported_request"); + assert.equal(result.pythonCapabilityRuns[0].status, "invalid_config"); + assert.deepEqual(result.pythonCapabilityRuns[0].selectedConfigPaths, ["mypy.ini", "pyrightconfig.json"]); + assert.equal(result.pythonCapabilityRuns[0].authority, undefined); + assert.equal(result.pythonCapabilityRuns[0].authoritySource, undefined); + assert.equal(result.pythonCapabilityRuns[0].tool, undefined); + assert.match(result.pythonCapabilityRuns[0].projectKey, /^sha256:[a-f0-9]{64}$/u); + assert.match(result.pythonCapabilityRuns[0].contextFingerprint, /^sha256:[a-f0-9]{64}$/u); + assert.match(result.pythonCapabilityRuns[0].afterStateManifestFingerprint, /^sha256:[a-f0-9]{64}$/u); + assert.equal(result.pythonCapabilityRuns[0].projectRoot, "."); + assert.equal(existsSync(join(repoRoot, "mypy-executed")), false); + assert.equal(existsSync(join(repoRoot, "pyright-executed")), false); + } finally { + rmSync(repoRoot, { recursive: true, force: true }); + } + }); + + it("retains separate mypy capability evidence and diagnostics for two projects", async () => { + const repoRoot = mkdtempSync(join(tmpdir(), "opcore-python-types-projects-")); + try { + const files = {}; + for (const project of ["a", "b"]) { + const projectRoot = join(repoRoot, project); + mkdirSync(projectRoot, { recursive: true }); + writePassingPythonProtocolShim(projectRoot); + writeToolShim(projectRoot, "mypy", [ + "#!/bin/sh", + "if [ \"$1\" = \"--version\" ]; then echo 'mypy 2.3.0'; exit 0; fi", + `echo '{\"file\":\"app.py\",\"line\":1,\"column\":0,\"end_line\":1,\"end_column\":5,\"message\":\"${project} failure\",\"hint\":null,\"code\":\"assignment\",\"severity\":\"error\"}'`, + "exit 1", + "" + ].join("\n")); + files[`${project}/pyproject.toml`] = `[project]\nname='${project}'\n[tool.mypy]\nstrict=true\n`; + files[`${project}/app.py`] = "value: int = 'wrong'\n"; + } + const result = await runner({ + files, + checks: createPythonValidationChecks({ + repoRoot, + env: { PATH: "" }, + nodeWorkspace: projectWorkspace(files, () => true) + }) + }).runValidation(request({ + repo: { repoRoot }, + checks: [PYTHON_TYPES_CHECK_ID], + scope: { kind: "files", files: ["b/app.py", "a/app.py"] } + })); + + assert.equal(result.status, "policy_failure", JSON.stringify(result, null, 2)); + assert.deepEqual(result.pythonCapabilityRuns.map((run) => run.projectRoot).sort(), ["a", "b"]); + assert.deepEqual(result.diagnostics.map((entry) => entry.path), ["a/app.py", "b/app.py"]); + assert.deepEqual( + result.pythonCapabilityRuns.map((run) => [run.projectRoot, run.selectedSourcePaths]).sort(), + [["a", ["a/app.py"]], ["b", ["b/app.py"]]] + ); + } finally { + rmSync(repoRoot, { recursive: true, force: true }); + } + }); + + it("rewrites explicit checker config paths into the materialized after-state workspace", async () => { + const repoRoot = mkdtempSync(join(tmpdir(), "opcore-python-types-config-path-")); + try { + writePassingPythonProtocolShim(repoRoot); + writeFileSync(join(repoRoot, "mypy.ini"), "[mypy]\nmarker = before\n"); + writeToolShim( + repoRoot, + "mypy", + [ + "#!/bin/sh", + "if [ \"$3\" = \"--version\" ]; then echo 'mypy 1.8.0'; exit 0; fi", + "if [ \"$1\" != \"--config-file\" ] || [ \"$2\" != \"mypy.ini\" ]; then echo \"unexpected config path: $*\" >&2; exit 9; fi", + "/usr/bin/grep -q 'marker = after' \"$2\" || { echo 'overlay config was not materialized' >&2; exit 9; }", + "exit 0", + "" + ].join("\n") + ); + const mypy = join(repoRoot, ".venv", "bin", "mypy"); + const files = { + "mypy.ini": "[mypy]\nmarker = before\n", + "pkg/app.py": "value: int = 1\n" + }; + const result = await runner({ + files, + checks: createPythonValidationChecks({ + env: { PATH: "" }, + repoRoot, + nodeWorkspace: createNodePythonProjectWorkspace(repoRoot), + toolArgv: { mypy: [mypy, "--config-file", join(repoRoot, "mypy.ini")] } + }) + }).runValidation(request({ + repo: { repoRoot }, + checks: [PYTHON_TYPES_CHECK_ID], + overlays: [{ path: "mypy.ini", action: "write", content: "[mypy]\nmarker = after\n" }] + })); + + assert.equal(result.status, "passed", JSON.stringify(result, null, 2)); + } finally { + rmSync(repoRoot, { recursive: true, force: true }); + } + }); + + it("isolates explicit mypy from poisoned host environment and global config discovery", async () => { + const repoRoot = mkdtempSync(join(tmpdir(), "opcore-python-types-host-isolation-")); + const poisonHome = mkdtempSync(join(tmpdir(), "opcore-python-types-poison-home-")); + try { + mkdirSync(join(poisonHome, ".config", "mypy"), { recursive: true }); + const poisonConfig = join(poisonHome, ".config", "mypy", "config"); + writeFileSync(poisonConfig, "[mypy]\nplugins = outside.plugin\n"); + writePassingPythonProtocolShim(repoRoot); + const mypy = join(repoRoot, ".venv", "bin", "mypy"); + writeToolShim(repoRoot, "mypy", [ + "#!/bin/sh", + `if [ \"$HOME\" = ${JSON.stringify(poisonHome)} ] || [ \"$XDG_CONFIG_HOME\" = ${JSON.stringify(poisonHome)} ]; then exit 9; fi`, + "if [ \"$MYPYPATH\" = poison ] || [ \"$PYTHONPATH\" = poison ] || [ \"$MYPY_CACHE_DIR\" = poison ]; then exit 9; fi", + "config=", + "while [ \"$#\" -gt 0 ]; do", + " case \"$1\" in", + " --config-file) shift; config=$1 ;;", + " --config-file=*) config=${1#--config-file=} ;;", + " esac", + " shift", + "done", + "if [ -z \"$config\" ] || [ ! -f \"$config\" ]; then exit 9; fi", + "/usr/bin/grep -q '^\\[mypy\\]' \"$config\" || exit 9", + "exit 0", + "" + ].join("\n")); + const files = { "pkg/app.py": "value: int = 1\n" }; + const result = await runner({ + files, + checks: createPythonValidationChecks({ + checker: "mypy", + repoRoot, + env: { + PATH: "", + HOME: poisonHome, + XDG_CONFIG_HOME: poisonHome, + MYPYPATH: "poison", + PYTHONPATH: "poison", + MYPY_CACHE_DIR: "poison" + }, + nodeWorkspace: projectWorkspace(files, () => true), + processProbe: successfulProbe(), + toolArgv: { mypy: [mypy] } + }) + }).runValidation(request({ repo: { repoRoot }, checks: [PYTHON_TYPES_CHECK_ID] })); + + assert.equal(result.status, "passed", JSON.stringify(result, null, 2)); + assert.deepEqual(result.pythonCapabilityRuns[0].selectedConfigPaths, []); + assert.equal(result.pythonCapabilityRuns[0].tool.configFile, undefined); + assert.equal(result.pythonCapabilityRuns[0].tool.argv.some((argument) => argument.startsWith("--config-file")), true); + } finally { + rmSync(repoRoot, { recursive: true, force: true }); + rmSync(poisonHome, { recursive: true, force: true }); + } + }); + + it("rejects absolute mypy_path before execution and cannot read an overlay-deleted poisoned worktree source", async () => { + const repoRoot = mkdtempSync(join(tmpdir(), "opcore-python-types-mypy-path-escape-")); + try { + writePassingPythonProtocolShim(repoRoot); + const executed = join(repoRoot, "mypy-executed"); + writeToolShim(repoRoot, "mypy", [ + "#!/bin/sh", + "if [ \"$1\" = \"--version\" ]; then echo 'mypy 2.3.0'; exit 0; fi", + `/usr/bin/touch ${JSON.stringify(executed)}`, + "exit 0", + "" + ].join("\n")); + mkdirSync(join(repoRoot, "poison"), { recursive: true }); + writeFileSync(join(repoRoot, "poison", "dependency.py"), "VALUE: str = 'host-only'\n"); + const files = { + "mypy.ini": `[mypy]\nmypy_path = ${join(repoRoot, "poison")}\n`, + "pkg/app.py": "from dependency import VALUE\nvalue: str = VALUE\n", + "poison/dependency.py": "VALUE: str = 'after-state'\n" + }; + const result = await runner({ + files, + checks: createPythonValidationChecks({ + repoRoot, + env: { PATH: "" }, + nodeWorkspace: createNodePythonProjectWorkspace(repoRoot) + }) + }).runValidation(request({ + repo: { repoRoot }, + checks: [PYTHON_TYPES_CHECK_ID], + overlays: [{ path: "poison/dependency.py", action: "delete" }] + })); + + assert.equal(result.status, "unsupported_request", JSON.stringify(result, null, 2)); + assert.equal(result.pythonCapabilityRuns[0].status, "invalid_config"); + assert.equal(result.pythonCapabilityRuns[0].execution, undefined); + assert.match(result.diagnostics[0].message, /mypy_path.*absolute/i); + assert.equal(existsSync(executed), false); + } finally { + rmSync(repoRoot, { recursive: true, force: true }); + } + }); + + it("rejects every configured mypy output path before execution and preserves source files", async () => { + for (const [option, value] of [ + ["junit_xml", "source"], + ["html_report", "source"], + ["timing_stats", "source"], + ["line_checking_stats", "source"], + ["cache_dir", "source"], + ["cache_map", "source"], + ["mypyc_annotation_file", "source"] + ]) { + const isolatedRepoRoot = mkdtempSync(join(tmpdir(), `opcore-python-types-${option}-`)); + try { + writePassingPythonProtocolShim(isolatedRepoRoot); + const sourcePath = join(isolatedRepoRoot, "pkg", "app.py"); + const executed = join(isolatedRepoRoot, "mypy-executed"); + mkdirSync(dirname(sourcePath), { recursive: true }); + writeFileSync(sourcePath, "value: int = 1\n"); + writeToolShim(isolatedRepoRoot, "mypy", [ + "#!/bin/sh", + "if [ \"$1\" = \"--version\" ]; then echo 'mypy 2.3.0'; exit 0; fi", + `/usr/bin/touch ${JSON.stringify(executed)}`, + `/usr/bin/printf 'MUTATED\\n' > ${JSON.stringify(sourcePath)}`, + "exit 0", + "" + ].join("\n")); + const configuredValue = value === "source" ? sourcePath : value; + const files = { + "mypy.ini": `[mypy]\n${option} = ${configuredValue}\n`, + "pkg/app.py": "value: int = 1\n" + }; + const result = await runner({ + files, + checks: createPythonValidationChecks({ + repoRoot: isolatedRepoRoot, + env: { PATH: "" }, + nodeWorkspace: createNodePythonProjectWorkspace(isolatedRepoRoot) + }) + }).runValidation(request({ repo: { repoRoot: isolatedRepoRoot }, checks: [PYTHON_TYPES_CHECK_ID] })); + + assert.equal(result.status, "unsupported_request", `${option}: ${JSON.stringify(result, null, 2)}`); + assert.equal(result.pythonCapabilityRuns[0].status, "invalid_config", option); + assert.equal(result.pythonCapabilityRuns[0].execution, undefined, option); + assert.match(result.diagnostics[0].message, new RegExp(option, "i"), option); + assert.equal(existsSync(executed), false, option); + assert.equal(readFileSync(sourcePath, "utf8"), "value: int = 1\n", option); + assert.equal(JSON.stringify(result.diagnostics).includes(sourcePath), false, option); + assert.equal(JSON.stringify(result.pythonCapabilityRuns).includes(sourcePath), false, option); + } finally { + rmSync(isolatedRepoRoot, { recursive: true, force: true }); + } + } + }); + + it("rejects mypy config paths that can read or execute outside the exact after-state", async () => { + for (const option of [ + "plugins", + "custom_typeshed_dir", + "python_executable", + "quickstart_file", + "custom_typing_module", + "shadow_file" + ]) { + const isolatedRepoRoot = mkdtempSync(join(tmpdir(), `opcore-python-types-${option}-escape-`)); + const hostRoot = mkdtempSync(join(tmpdir(), `opcore-python-types-${option}-host-`)); + try { + writePassingPythonProtocolShim(isolatedRepoRoot); + const executed = join(isolatedRepoRoot, "mypy-executed"); + const hostPath = join(hostRoot, option === "plugins" ? "host_plugin.py" : "host-input"); + writeFileSync(hostPath, option === "plugins" ? "raise RuntimeError('host plugin executed')\n" : "host-only\n"); + writeToolShim(isolatedRepoRoot, "mypy", [ + "#!/bin/sh", + "if [ \"$1\" = \"--version\" ]; then echo 'mypy 2.3.0'; exit 0; fi", + `/usr/bin/touch ${JSON.stringify(executed)}`, + "exit 0", + "" + ].join("\n")); + const configuredValue = option === "custom_typing_module" ? "host_typing" : hostPath; + const files = { + "mypy.ini": `[mypy]\n${option} = ${configuredValue}\n`, + "pkg/app.py": "value: int = 1\n" + }; + const result = await runner({ + files, + checks: createPythonValidationChecks({ + repoRoot: isolatedRepoRoot, + env: { PATH: "" }, + nodeWorkspace: createNodePythonProjectWorkspace(isolatedRepoRoot) + }) + }).runValidation(request({ repo: { repoRoot: isolatedRepoRoot }, checks: [PYTHON_TYPES_CHECK_ID] })); + + assert.equal(result.status, "unsupported_request", `${option}: ${JSON.stringify(result, null, 2)}`); + assert.equal(result.pythonCapabilityRuns[0].status, "invalid_config", option); + assert.equal(result.pythonCapabilityRuns[0].execution, undefined, option); + assert.match(result.diagnostics[0].message, new RegExp(option, "i"), option); + assert.equal(existsSync(executed), false, option); + assert.equal(JSON.stringify(result.diagnostics).includes(hostRoot), false, option); + assert.equal(JSON.stringify(result.pythonCapabilityRuns).includes(hostRoot), false, option); + } finally { + rmSync(isolatedRepoRoot, { recursive: true, force: true }); + rmSync(hostRoot, { recursive: true, force: true }); + } + } + }); + + it("reports syntax diagnostics from overlay after-state content", async () => { + const result = await runner({ + files: { + "pkg/app.py": "value = 1\n" + } + }).runValidation( + request({ + checks: [PYTHON_SYNTAX_CHECK_ID], + overlays: [{ path: "pkg/app.py", action: "write", content: "if True\n value = (1\n" }] + }) + ); + + assert.equal(result.status, "policy_failure"); + assert.deepEqual( + result.diagnostics.map((diagnostic) => diagnostic.code), + ["PY_SYNTAX_ERROR"] + ); + assert.equal(result.diagnostics[0].path, "pkg/app.py"); + }); + + it("fails python.syntax for invalid Python grammar the heuristics miss", async () => { + const result = await runner({ + files: { + "pkg/app.py": "x = 1 2\n" + } + }).runValidation( + request({ + checks: [PYTHON_SYNTAX_CHECK_ID] + }) + ); + + assert.equal(result.status, "policy_failure"); + assert.deepEqual(result.diagnostics.map((diagnostic) => diagnostic.code), ["PY_SYNTAX_ERROR"]); + assert.equal(result.diagnostics[0].path, "pkg/app.py"); + }); + + it("reports python.syntax as unsupported when no Python interpreter is available", async () => { + const result = await runner({ + files: { + "pkg/app.py": "x = 1 2\n" + }, + checks: createPythonValidationChecks({ env: { PATH: "" } }) + }).runValidation( + request({ + checks: [PYTHON_SYNTAX_CHECK_ID] + }) + ); + + assert.equal(result.status, "unsupported_request"); + assert.deepEqual(result.diagnostics.map((diagnostic) => diagnostic.code), ["PY_SYNTAX_TOOL_UNAVAILABLE"]); + assert.equal(result.manifest.runs[0].outcome, "tool_unavailable"); + }); + + it("passes python.syntax for valid multi-line Python with no false positives", async () => { + const result = await runner({ + files: Object.fromEntries( + Object.entries(fixtureFiles("compiler-truth")).filter(([path]) => path.startsWith("pkg/")) + ) + }).runValidation( + request({ + checks: [PYTHON_SYNTAX_CHECK_ID], + scope: { kind: "files", files: ["pkg/valid.py", "pkg/valid.pyi"] } + }) + ); + + assert.equal(result.status, "passed", JSON.stringify(result, null, 2)); + assert.deepEqual(result.diagnostics, []); + }); + + it("uses compile truth for control-flow and future-import failures ast.parse accepts", async () => { + const files = fixtureFiles("compiler-truth"); + const result = await runner({ + files: { + "invalid/misplaced-future.py": files["invalid/misplaced-future.py"], + "invalid/module-return.py": files["invalid/module-return.py"] + } + }).runValidation( + request({ + checks: [PYTHON_SYNTAX_CHECK_ID], + scope: { kind: "files", files: ["invalid/module-return.py", "invalid/misplaced-future.py"] } + }) + ); + + assert.equal(result.status, "policy_failure"); + assert.equal(result.manifest.runs[0].outcome, "findings"); + assert.deepEqual(result.diagnostics.map((entry) => entry.path), [ + "invalid/misplaced-future.py", + "invalid/module-return.py" + ]); + assert.deepEqual(result.diagnostics.map((entry) => entry.code), ["PY_SYNTAX_ERROR", "PY_SYNTAX_ERROR"]); + assert.ok(result.diagnostics.every((entry) => entry.line >= 1 && entry.column >= 1)); + assert.ok(result.diagnostics.every((entry) => entry.tool?.name === "python" && entry.tool.version)); + }); + + it("normalizes indentation, null-byte, and unterminated compiler failures with ranges", async () => { + const result = await runner({ + files: { + "pkg/a-indent.py": "if True:\npass\n", + "pkg/b-null.py": "value = 1\0\n", + "pkg/c-string.pyi": "value: str = 'unterminated\n" + } + }).runValidation( + request({ + checks: [PYTHON_SYNTAX_CHECK_ID], + scope: { kind: "files", files: ["pkg/c-string.pyi", "pkg/b-null.py", "pkg/a-indent.py"] } + }) + ); + + assert.deepEqual(result.diagnostics.map((entry) => entry.code), [ + "PY_INDENTATION_ERROR", + "PY_NULL_BYTE", + "PY_SYNTAX_ERROR" + ]); + assert.equal(result.diagnostics[0].line, 2); + assert.equal(result.diagnostics[0].column, 1); + assert.equal(result.diagnostics[2].line, 1); + assert.equal(result.diagnostics[2].endLine, 1); + }); + + it("preserves exact overlay after-state semantics for corrected, new, and deleted Python files", async () => { + const corrected = await runner({ files: { "pkg/app.py": "return 1\n" } }).runValidation( + request({ + overlays: [{ path: "pkg/app.py", action: "write", content: "value = 1\n" }] + }) + ); + const introduced = await runner({ files: { "pkg/app.py": "value = 1\n" } }).runValidation( + request({ + scope: { kind: "files", files: ["pkg/app.py"] }, + overlays: [ + { path: "pkg/new.py", action: "write", content: "break\n" }, + { path: "pkg/new.pyi", action: "write", content: "if True\n ...\n" } + ] + }) + ); + const deleted = await runner({ files: { "pkg/app.py": "return 1\n", "pkg/valid.py": "value = 1\n" } }).runValidation( + request({ + scope: { kind: "files", files: ["pkg/app.py", "pkg/valid.py"] }, + overlays: [{ path: "pkg/app.py", action: "delete" }] + }) + ); + + assert.equal(corrected.status, "passed"); + assert.equal(deleted.status, "passed"); + assert.equal(introduced.status, "policy_failure"); + assert.deepEqual(introduced.diagnostics.map((entry) => entry.path), ["pkg/new.py", "pkg/new.pyi"]); + }); + + it("executes the exact interpreter selected by the project resolver", async () => { + const repoRoot = mkdtempSync(join(tmpdir(), "opcore-python-exact-interpreter-")); + try { + const shimPath = writePythonProtocolShim(repoRoot, [ + `printf 'compiler' > '${join(repoRoot, "compiler-called")}'`, + protocolResponse("SHIM_PATH", [{ path: "pkg/app.py", status: "passed" }]) + ]); + replaceShimPlaceholder(shimPath); + const result = await runner({ + files: { "pkg/app.py": "value = 1\n" }, + checks: createPythonValidationChecks({ repoRoot }) + }).runValidation(request({ repo: { repoRoot } })); + + assert.equal(result.status, "passed"); + assert.equal(readFileSync(join(repoRoot, "compiler-called"), "utf8"), "compiler"); + } finally { + rmSync(repoRoot, { recursive: true, force: true }); + } + }); + + it("fails closed for nonzero, malformed, timeout, signal, and incomplete compiler protocol states", async () => { + const cases = [ + { + name: "nonzero", + branch: [protocolResponse("SHIM_PATH", [{ path: "pkg/app.py", status: "passed" }]), "exit 7"], + outcome: "tool_failure" + }, + { name: "malformed", branch: ["printf 'not-json\\n'"], outcome: "tool_failure" }, + { name: "timeout", branch: ["/bin/sleep 1"], outcome: "timeout", timeoutMs: 20 }, + { name: "signal", branch: ["kill -TERM $$"], outcome: "tool_failure" }, + { name: "incomplete", branch: [protocolResponse("SHIM_PATH", [])], outcome: "tool_failure" } + ]; + for (const testCase of cases) { + const repoRoot = mkdtempSync(join(tmpdir(), `opcore-python-protocol-${testCase.name}-`)); + try { + const shimPath = writePythonProtocolShim(repoRoot, testCase.branch); + replaceShimPlaceholder(shimPath); + const result = await runner({ + files: { "pkg/app.py": "value = 1\n" }, + checks: createPythonValidationChecks({ + repoRoot, + env: { PATH: "" }, + timeoutMs: testCase.timeoutMs + }) + }).runValidation(request({ repo: { repoRoot } })); + assert.equal(result.status, "infrastructure_failure", `${testCase.name}: ${JSON.stringify(result, null, 2)}`); + assert.equal(result.manifest.runs[0].outcome, testCase.outcome, testCase.name); + assert.equal(result.diagnostics[0].category, "infrastructure", testCase.name); + } finally { + rmSync(repoRoot, { recursive: true, force: true }); + } + } + }); + + it("normalizes compiler recursion and overflow protocol findings", async () => { + const repoRoot = mkdtempSync(join(tmpdir(), "opcore-python-compiler-limits-")); + try { + const shimPath = writePythonProtocolShim(repoRoot, [ + protocolResponse("SHIM_PATH", [ + { path: "pkg/a.py", status: "finding", error: { kind: "recursion_error", message: "recursion limit" } }, + { path: "pkg/b.pyi", status: "finding", error: { kind: "overflow_error", message: "compiler overflow" } } + ]) + ]); + replaceShimPlaceholder(shimPath); + const result = await runner({ + files: { "pkg/a.py": "value = 1\n", "pkg/b.pyi": "value: int\n" }, + checks: createPythonValidationChecks({ repoRoot, env: { PATH: "" }, checker: "mypy" }) + }).runValidation( + request({ repo: { repoRoot }, scope: { kind: "files", files: ["pkg/b.pyi", "pkg/a.py"] } }) + ); + + assert.deepEqual(result.diagnostics.map((entry) => entry.code), [ + "PY_COMPILER_RECURSION_ERROR", + "PY_COMPILER_OVERFLOW_ERROR" + ]); + } finally { + rmSync(repoRoot, { recursive: true, force: true }); + } + }); + + it("fails python.types when a nonzero checker result contains no parseable diagnostics", async () => { + const repoRoot = mkdtempSync(join(tmpdir(), "opcore-python-types-malformed-")); + try { + writePassingPythonProtocolShim(repoRoot); + writeToolShim(repoRoot, "mypy", "#!/bin/sh\nif [ \"$1\" = \"--version\" ]; then echo 'mypy 1.8.0'; exit 0; fi\necho 'unknown failure'\nexit 1\n"); + const result = await runner({ + files: { "pkg/app.py": "value: int = 1\n" }, + checks: createPythonValidationChecks({ repoRoot, env: { PATH: "" }, checker: "mypy" }) + }).runValidation(request({ repo: { repoRoot }, checks: [PYTHON_TYPES_CHECK_ID] })); + + assert.equal(result.status, "infrastructure_failure"); + assert.equal(result.manifest.runs[0].outcome, "tool_failure"); + assert.deepEqual(result.diagnostics.map((entry) => entry.code), ["PYTHON_TYPES_TOOL_FAILED"]); + } finally { + rmSync(repoRoot, { recursive: true, force: true }); + } + }); + + it("fails mypy machine-protocol, timeout, signal, spawn, and exit errors closed and cleans workspaces", async () => { + const cases = [ + { name: "malformed", body: "printf '{not-json\\n'; exit 1", status: "tool_failure", termination: "exited" }, + { + name: "partial", + body: "printf '%s\\n' '{\"file\":\"pkg/app.py\",\"line\":1,\"column\":0,\"message\":\"finding\",\"code\":\"assignment\",\"severity\":\"error\"}' '{not-json'; exit 1", + status: "tool_failure", + termination: "exited" + }, + { + name: "unknown-severity", + body: "printf '%s\\n' '{\"file\":\"pkg/app.py\",\"line\":1,\"column\":0,\"message\":\"finding\",\"code\":\"assignment\",\"severity\":\"fatal\"}'; exit 1", + status: "tool_failure", + termination: "exited" + }, + { + name: "reversed-same-line-range", + body: "printf '%s\\n' '{\"file\":\"pkg/app.py\",\"line\":1,\"column\":5,\"end_line\":1,\"end_column\":1,\"message\":\"bad range\",\"code\":\"assignment\",\"severity\":\"error\"}'; exit 1", + status: "tool_failure", + termination: "exited" + }, + { + name: "partial-config-stderr", + body: "printf '%s\\n' '{\"file\":\"mypy.ini\"}' >&2; exit 2", + status: "tool_failure", + termination: "exited" + }, + { + name: "unrelated-config-stderr", + body: "printf '%s\\n' 'mypy.ini: unrelated process failure' >&2; exit 2", + status: "tool_failure", + termination: "exited" + }, + { name: "empty-findings", body: "exit 1", status: "tool_failure", termination: "exited" }, + { name: "unexpected-exit", body: "exit 2", status: "tool_failure", termination: "exited" }, + { name: "timeout", body: "/bin/sleep 1", status: "timeout", termination: "timeout", timeoutMs: 20 }, + { name: "signal", body: "kill -TERM $$", status: "tool_failure", termination: "signal" } + ]; + for (const testCase of cases) { + const repoRoot = mkdtempSync(join(tmpdir(), `opcore-python-mypy-${testCase.name}-`)); + try { + writePassingPythonProtocolShim(repoRoot); + writeToolShim(repoRoot, "mypy", [ + "#!/bin/sh", + "if [ \"$1\" = \"--version\" ]; then echo 'mypy 2.3.0'; exit 0; fi", + testCase.body, + "" + ].join("\n")); + const files = { + "mypy.ini": "[mypy]\nstrict = true\n", + "pkg/app.py": "value: int = 1\n" + }; + const before = materializedMypyWorkspaces(); + const result = await runner({ + files, + checks: createPythonValidationChecks({ + repoRoot, + env: { PATH: "" }, + timeoutMs: testCase.timeoutMs, + processProbe: successfulProbe(), + nodeWorkspace: createNodePythonProjectWorkspace(repoRoot) + }) + }).runValidation(request({ repo: { repoRoot }, checks: [PYTHON_TYPES_CHECK_ID] })); + + assert.equal(result.status, "infrastructure_failure", `${testCase.name}: ${JSON.stringify(result, null, 2)}`); + const capabilityRun = result.pythonCapabilityRuns[0]; + assert.equal(capabilityRun.status, testCase.status, testCase.name); + assert.equal(capabilityRun.execution.termination, testCase.termination, testCase.name); + assert.match(capabilityRun.projectKey, /^sha256:[a-f0-9]{64}$/u, testCase.name); + assert.match(capabilityRun.contextFingerprint, /^sha256:[a-f0-9]{64}$/u, testCase.name); + assert.match(capabilityRun.afterStateManifestFingerprint, /^sha256:[a-f0-9]{64}$/u, testCase.name); + assert.equal(capabilityRun.projectRoot, ".", testCase.name); + assert.deepEqual(capabilityRun.targets, ["pkg/app.py"], testCase.name); + assert.deepEqual(capabilityRun.selectedConfigPaths, ["mypy.ini"], testCase.name); + assert.equal(JSON.stringify(result.pythonCapabilityRuns).includes(repoRoot), false, testCase.name); + assert.deepEqual(materializedMypyWorkspaces(), before, testCase.name); + } finally { + rmSync(repoRoot, { recursive: true, force: true }); + } + } + + const repoRoot = mkdtempSync(join(tmpdir(), "opcore-python-mypy-spawn-")); + try { + writePassingPythonProtocolShim(repoRoot); + writeToolShim(repoRoot, "mypy", [ + "#!/bin/sh", + "if [ \"$1\" = \"--version\" ]; then /bin/rm -- \"$0\"; echo 'mypy 2.3.0'; exit 0; fi", + "exit 0", + "" + ].join("\n")); + const files = { + "mypy.ini": "[mypy]\nstrict = true\n", + "pkg/app.py": "value: int = 1\n" + }; + const before = materializedMypyWorkspaces(); + const result = await runner({ + files, + checks: createPythonValidationChecks({ + repoRoot, + env: { PATH: "" }, + nodeWorkspace: createNodePythonProjectWorkspace(repoRoot) + }) + }).runValidation(request({ repo: { repoRoot }, checks: [PYTHON_TYPES_CHECK_ID] })); + + assert.equal(result.status, "infrastructure_failure"); + assert.equal(result.pythonCapabilityRuns[0].status, "tool_failure"); + assert.equal(result.pythonCapabilityRuns[0].execution.termination, "spawn_error"); + assert.equal(JSON.stringify(result.pythonCapabilityRuns).includes(repoRoot), false); + assert.deepEqual(materializedMypyWorkspaces(), before); + } finally { + rmSync(repoRoot, { recursive: true, force: true }); + } + }); + + it("fails closed and terminates the tool when requested stdin cannot be written", async () => { + const startedAt = Date.now(); + const result = await runTool("/bin/sh", ["-c", "exec 0<&-; /bin/sleep 5"], { + input: "x".repeat(8 * 1024 * 1024), + timeoutMs: 10_000 + }); + + assert.equal(result.ok, false, JSON.stringify(result)); + assert.equal(result.termination, "spawn_error"); + assert.match(result.failureMessage, /stdin write failed/i); + assert.ok(Date.now() - startedAt < 2_000, "stdin failure did not terminate the process tree promptly"); + }); + + it("kills mypy descendants when the authority process times out", async () => { + const repoRoot = mkdtempSync(join(tmpdir(), "opcore-python-mypy-descendant-timeout-")); + try { + writePassingPythonProtocolShim(repoRoot); + const pidFile = join(repoRoot, "descendant.pid"); + writeToolShim(repoRoot, "mypy", [ + "#!/bin/sh", + "if [ \"$1\" = \"--version\" ]; then echo 'mypy 2.3.0'; exit 0; fi", + "/bin/sleep 30 &", + `echo $! > ${JSON.stringify(pidFile)}`, + "/bin/sleep 30", + "" + ].join("\n")); + const files = { + "mypy.ini": "[mypy]\nstrict = true\n", + "pkg/app.py": "value: int = 1\n" + }; + const result = await runner({ + files, + checks: createPythonValidationChecks({ + repoRoot, + env: { PATH: "" }, + // The process-heavy CI suite can delay shim startup; keep headroom below the 30s child sleep. + timeoutMs: 2_000, + nodeWorkspace: createNodePythonProjectWorkspace(repoRoot), + processProbe: successfulProbe(), + toolArgv: { mypy: [join(repoRoot, ".venv", "bin", "mypy")] } + }) + }).runValidation(request({ repo: { repoRoot }, checks: [PYTHON_TYPES_CHECK_ID] })); + + assert.equal(result.pythonCapabilityRuns[0].status, "timeout"); + const descendantPid = Number(readFileSync(pidFile, "utf8").trim()); + assert.equal(await processExited(descendantPid), true, `descendant ${descendantPid} survived timeout`); + } finally { + rmSync(repoRoot, { recursive: true, force: true }); + } + }); + + it("reports source-hygiene diagnostics from overlay after-state content", async () => { + const result = await runner({ + files: { + "pkg/app.py": "value = 1\n" + } + }).runValidation( + request({ + checks: [PYTHON_SOURCE_HYGIENE_CHECK_ID], + overlays: [ + { + path: "pkg/app.py", + action: "write", + content: ["# type: ignore", "# noqa", "# fmt: off", "value = 1", ""].join("\n") + } + ] + }) + ); + + assert.equal(result.status, "policy_failure"); + assert.deepEqual( + result.diagnostics.map((diagnostic) => diagnostic.code), + ["PY_SOURCE_FORMATTER_DISABLED", "PY_SOURCE_NOQA_BROAD", "PY_SOURCE_TYPE_IGNORE"] + ); + }); + + it("reports missing Python IMPORTS_FROM graph edges for resolved repo imports", async () => { + const result = await runner({ + files: { + "pkg/app.py": "from .dep import value\nresult = value\n", "pkg/dep.py": "value = 1\n" }, + checks: createPythonValidationChecks({ + importAnalyzer: fixedImportAnalyzer([{ fromPath: "pkg/app.py", toPath: "pkg/dep.py" }]) + }), + graphProviderClient: graphClient({ + factQuery: (query) => availableFactResult(query, [], []) + }) + }).runValidation( + request({ + checks: [PYTHON_IMPORT_GRAPH_CHECK_ID], + scope: { kind: "files", files: ["pkg/app.py"] } + }) + ); + + assert.equal(result.status, "passed"); + assert.deepEqual(result.diagnostics.map((diagnostic) => diagnostic.code), ["PY_IMPORT_GRAPH_MISSING_EDGE"]); + assert.match(result.diagnostics[0].message, /pkg\/app\.py -> pkg\/dep\.py/); + }); + + it("reuses the exact after-state graph session instead of a second analyzer pass", async () => { + const analysisCalls = []; + const importAnalyzer = { + async analyze(files) { + analysisCalls.push(files); + return [{ fromPath: "pkg/app.py", toPath: "pkg/new.py" }]; + } + }; + const graphEdges = [ + { kind: "IMPORTS_FROM", from: "file:pkg/app.py", to: "file:pkg/new.py" } + ]; + const client = graphClient({ + factQuery: (query) => availableFactResult(query, [], graphEdges) + }); + const result = await runner({ + files: { + "pkg/__init__.py": "", + "pkg/app.py": "from pkg import old\n", + "pkg/old.py": "OLD = True\n" + }, + checks: createPythonValidationChecks({ importAnalyzer }), + graphProviderClient: client, + graphSessionFactory: exactGraphSessionFactory(client) + }).runValidation(request({ + checks: [PYTHON_IMPORT_GRAPH_CHECK_ID], + scope: { kind: "files", files: ["pkg/app.py"] }, + overlays: [{ + path: "pkg/app.py", + action: "write", + content: "from pkg import (\n new,\n)\n" + }, { + path: "pkg/new.py", + action: "write", + content: "NEW = True\n" + }] + })); + + assert.equal(result.status, "passed", JSON.stringify(result, null, 2)); + assert.deepEqual(result.diagnostics, []); + assert.equal(analysisCalls.length, 0); + }); + + it("fails import graph validation closed when the canonical analyzer is missing", async () => { + const checks = createCanonicalPythonValidationChecks({ + nodeWorkspace: canonicalTestPythonWorkspace() + }); + const client = graphClient(); + const result = await runner({ + files: { "pkg/app.py": "VALUE = 1\n" }, + checks, + graphProviderClient: client, + graphSessionFactory: exactGraphSessionFactory(client) + }).runValidation(request({ + checks: [PYTHON_IMPORT_GRAPH_CHECK_ID], + scope: { kind: "files", files: ["pkg/app.py"] } + })); + + assert.equal(result.status, "infrastructure_failure"); + assert.match(JSON.stringify(result), /python import analyzer/i); + }); + + it("runs graph-only dead-code and relevant-test checks without an import analyzer", async () => { + const checks = createCanonicalPythonValidationChecks({ + nodeWorkspace: canonicalTestPythonWorkspace() + }); + const result = await runner({ + files: { "pkg/app.py": "VALUE = 1\n" }, + checks, + graphProviderClient: graphClient() + }).runValidation(request({ + checks: [PYTHON_DEAD_CODE_CHECK_ID, PYTHON_RELEVANT_TESTS_CHECK_ID], + scope: { kind: "files", files: ["pkg/app.py"] } + })); + + assert.equal(result.status, "passed", JSON.stringify(result, null, 2)); + assert.deepEqual(result.diagnostics.map((diagnostic) => diagnostic.code).sort(), [ + "PY_DEAD_CODE_UNSUPPORTED", + "PY_RELEVANT_TESTS_ABSENT" + ]); + }); + + it("fails import-dependent validation closed for throwing and malformed analyzers", async (testContext) => { + const cases = [ + { + name: "throwing", + analyzer: { analyze: async () => { throw new Error("native analysis failed"); } }, + message: /native analysis failed/i + }, + { + name: "non-array", + analyzer: { analyze: async () => ({ edges: [] }) }, + message: /expected an array/i + }, + { + name: "outside after-state", + analyzer: fixedImportAnalyzer([{ fromPath: "pkg/app.py", toPath: "pkg/missing.py" }]), + message: /outside the supplied after-state/i + } + ]; + + for (const testCase of cases) { + await testContext.test(testCase.name, async () => { + const result = await runner({ + files: { "pkg/app.py": "VALUE = 1\n" }, + checks: createPythonValidationChecks({ importAnalyzer: testCase.analyzer }), + graphProviderClient: graphClient() + }).runValidation(request({ + checks: [PYTHON_IMPORT_GRAPH_CHECK_ID], + scope: { kind: "files", files: ["pkg/app.py"] } + })); + + assert.equal(result.status, "infrastructure_failure"); + assert.match(result.failure.cause, testCase.message); + }); + } + }); + + it("excludes deleted Python files from canonical after-state analysis", async () => { + const calls = []; + const client = graphClient(); + const result = await runner({ + files: { + "pkg/app.py": "from .dep import VALUE\n", + "pkg/dep.py": "VALUE = 1\n" + }, + checks: createPythonValidationChecks({ + importAnalyzer: { + async analyze(files) { + calls.push(files); + return []; + } + } + }), + graphProviderClient: client, + graphSessionFactory: exactGraphSessionFactory(client) + }).runValidation(request({ + checks: [PYTHON_IMPORT_GRAPH_CHECK_ID], + scope: { kind: "files", files: ["pkg/app.py"] }, + overlays: [{ path: "pkg/dep.py", action: "delete" }] + })); + + assert.equal(result.status, "passed"); + assert.equal(calls.length, 0); + assert.deepEqual(result.diagnostics, []); + }); + + it("skips deleted scoped Python targets before project or type-tool resolution", async () => { + const result = await runner({ + files: { "pkg/app.py": "VALUE = 1\n" }, + checks: createPythonValidationChecks({ importAnalyzer: fixedImportAnalyzer([]) }) + }).runValidation(request({ + checks: [PYTHON_SYNTAX_CHECK_ID, PYTHON_TYPES_CHECK_ID], + scope: { kind: "files", files: ["pkg/app.py"] }, + overlays: [{ path: "pkg/app.py", action: "delete" }] + })); + + assert.equal(result.status, "passed", JSON.stringify(result, null, 2)); + assert.deepEqual(result.pythonProjectContexts, []); + }); + + it("shares one canonical source resolver across graph-dependent Python checks", async () => { + let analysisCalls = 0; + const checks = createPythonValidationChecks({ + importAnalyzer: { + async analyze() { + analysisCalls += 1; + return []; + } + } + }); + const result = await runner({ + files: { "pkg/app.py": "VALUE = 1\n" }, + checks, + graphProviderClient: graphClient() + }).runValidation(request({ + checks: [PYTHON_IMPORT_GRAPH_CHECK_ID, PYTHON_DEAD_CODE_CHECK_ID, PYTHON_RELEVANT_TESTS_CHECK_ID], + scope: { kind: "files", files: ["pkg/app.py"] } + })); + + assert.equal(result.status, "passed"); + assert.equal(analysisCalls, 1); + }); + + it("consumes the shared canonical Python import edge matrix and reports only a genuinely omitted edge", async () => { + const files = fixtureFilesAt(sourceExtractionPythonFixtureRoot); + const expectedEdges = sourceExtractionPythonExpected.pythonImportEdges; + const graphEdges = expectedEdges.map(({ fromPath, toPath }) => ({ + kind: "IMPORTS_FROM", + from: `file:${fromPath}`, + to: `file:${toPath}` + })); + const scopeFiles = [...new Set(expectedEdges.map((edge) => edge.fromPath))].sort(); + const checks = createPythonValidationChecks({ importAnalyzer: fixedImportAnalyzer(expectedEdges) }); + const complete = await runner({ + files, + checks, graphProviderClient: graphClient({ - factQuery: (query) => availableFactResult(query, [], []) + factQuery: (query) => availableFactResult(query, [], graphEdges) }) - }).runValidation( - request({ - checks: [PYTHON_IMPORT_GRAPH_CHECK_ID], - scope: { kind: "files", files: ["pkg/app.py"] } + }).runValidation(request({ checks: [PYTHON_IMPORT_GRAPH_CHECK_ID], scope: { kind: "files", files: scopeFiles } })); + + assert.equal(complete.status, "passed"); + assert.deepEqual(complete.diagnostics, []); + + const omitted = expectedEdges.find((edge) => edge.fromPath === "src/cases/alias_case.py"); + assert.ok(omitted); + const incomplete = await runner({ + files, + checks, + graphProviderClient: graphClient({ + factQuery: (query) => availableFactResult( + query, + [], + graphEdges.filter((edge) => edge.from !== `file:${omitted.fromPath}` || edge.to !== `file:${omitted.toPath}`) + ) }) - ); + }).runValidation(request({ checks: [PYTHON_IMPORT_GRAPH_CHECK_ID], scope: { kind: "files", files: scopeFiles } })); - assert.equal(result.status, "passed"); - assert.deepEqual(result.diagnostics.map((diagnostic) => diagnostic.code), ["PY_IMPORT_GRAPH_MISSING_EDGE"]); - assert.match(result.diagnostics[0].message, /pkg\/app\.py -> pkg\/dep\.py/); + assert.deepEqual(incomplete.diagnostics.map((diagnostic) => diagnostic.code), ["PY_IMPORT_GRAPH_MISSING_EDGE"]); + assert.equal(incomplete.diagnostics[0].path, omitted.fromPath); + assert.match(incomplete.diagnostics[0].message, new RegExp(`${omitted.fromPath} -> ${omitted.toPath}`)); }); it("reports exported callable Python symbols without incoming CALLS as unused exports", async () => { @@ -432,6 +2639,9 @@ describe("validation-python adapter", () => { it("reports syntax, hygiene, and import graph diagnostics from failing Python validation fixture", async () => { const result = await runner({ files: fixtureFiles("failing"), + checks: createPythonValidationChecks({ + importAnalyzer: fixedImportAnalyzer([{ fromPath: "pkg/app.py", toPath: "pkg/dep.py" }]) + }), graphProviderClient: graphClient({ factQuery: (query) => availableFactResult(query, [], []) }) @@ -445,7 +2655,7 @@ describe("validation-python adapter", () => { assert.equal(result.status, "policy_failure"); assert.deepEqual( result.diagnostics.map((diagnostic) => diagnostic.code).sort(), - ["PY_IMPORT_GRAPH_MISSING_EDGE", "PY_SOURCE_TYPE_IGNORE", "PY_SYNTAX_PARSE_ERROR"] + ["PY_IMPORT_GRAPH_MISSING_EDGE", "PY_SOURCE_TYPE_IGNORE", "PY_SYNTAX_ERROR"] ); }); @@ -461,93 +2671,1313 @@ describe("validation-python adapter", () => { ); assert.equal(result.status, "unsupported_request"); - assert.deepEqual(result.diagnostics.map((diagnostic) => diagnostic.code), ["PYTHON_TYPES_UNSUPPORTED"]); + assert.deepEqual(result.diagnostics.map((diagnostic) => diagnostic.code), ["PYTHON_TYPES_UNSUPPORTED_TARGET"]); + assert.equal(result.pythonCapabilityRuns[0].status, "unsupported_target"); }); }); -describe("python toolchain resolver", () => { - it("resolves an available tool from PATH when no repo-local venv exists", () => { - const repoRoot = mkdtempSync(join(tmpdir(), "opcore-python-resolver-path-")); +describe("Python project-context resolver", () => { + it("filters non-Python paths without reading file contents from matching after-state indexes", async () => { + const indexedPaths = ["large-native-binary", "pkg/app.py", "pyproject.toml"]; + const workspace = createValidationFileViewPythonWorkspace({ + overlays: [], + scopeFiles: ["pkg/app.py"], + defaultReadState: "after", + listVisibleFiles: async () => indexedPaths, + readFile: async () => { throw new Error("unexpected file content read"); }, + readBefore: async () => { throw new Error("unexpected file content read"); }, + readAfter: async () => { throw new Error("unexpected file content read"); }, + exists: async () => { throw new Error("unexpected file content read"); }, + hasOverlay: () => false, + overlayFor: () => undefined + }, undefined, { + read: async () => undefined, + list: async () => indexedPaths, + exists: async () => true, + realpath: async (path) => ({ path, symlink: false }), + executableExists: async () => true + }); + + assert.deepEqual(await workspace.list(), ["pkg/app.py", "pyproject.toml"]); + }); + + it("keeps before-state project boundaries independent of current files and overlays", async () => { + const before = new Map([ + ["pyproject.toml", "[project]\nname='root'\n"], + ["services/api/app.py", "VALUE = 1\n"] + ]); + const current = { + "services/api/pyproject.toml": "[project]\nname='api'\n", + "services/api/app.py": "VALUE = 2\n", + "large-native-binary": "not Python project evidence", + }; + const overlays = [ + { path: "pyproject.toml", action: "delete" }, + { path: "services/api/pyproject.toml", action: "write", content: current["services/api/pyproject.toml"] } + ]; + const readBefore = async (path) => + before.has(path) ? { status: "found", content: before.get(path) } : { status: "missing" }; + const workspace = createValidationFileViewPythonWorkspace({ + overlays, + scopeFiles: ["services/api/app.py"], + defaultReadState: "before", + listVisibleFiles: async () => [...new Set([...before.keys(), ...Object.keys(current)])].sort(), + readFile: readBefore, + readBefore, + readAfter: readBefore, + exists: async (path) => { + if (path === "large-native-binary") throw new Error("irrelevant binary existence was probed"); + return before.has(path); + }, + hasOverlay: (path) => overlays.some((overlay) => overlay.path === path), + overlayFor: (path) => overlays.find((overlay) => overlay.path === path) + }, undefined, projectWorkspace(current, () => true)); + + assert.deepEqual(await workspace.list(), [...before.keys()].sort()); + const context = await resolvePythonProjectContext({ + repoRoot: "/fixture", + target: "services/api/app.py", + workspace, + processProbe: successfulProbe() + }); + assert.equal(context.projectRoot, "."); + }); + + it("resolves nearest nested projects, layouts, managers, and stable fingerprints", async () => { + const files = { + "pyproject.toml": "[project]\nname='root'\nrequires-python='>=3.11'\n", + "src/root/__init__.py": "VALUE = 1\n", + "services/api/pyproject.toml": "[project]\nname='api'\nrequires-python='>=3.12'\n[tool.uv]\npackage=true\n", + "services/api/uv.lock": "version = 1\n", + "services/api/src/acme/app.py": "VALUE = 2\n" + }; + const contexts = await resolvePythonProjectContexts({ + repoRoot: "/fixture", + targets: ["src/root/__init__.py", "services/api/src/acme/app.py"], + workspace: projectWorkspace(files, (command) => !command.includes("/")), + processProbe: successfulProbe() + }); + + assert.deepEqual(contexts.map((context) => context.projectRoot), ["services/api", "."]); + const nested = contexts[0]; + assert.deepEqual(nested.sourceRoots, ["services/api/src"]); + assert.deepEqual(nested.layout.kinds, ["namespace", "src"]); + assert.deepEqual(nested.managers.map((manager) => manager.kind), ["uv"]); + assert.equal(nested.outcome, "resolved"); + assert.match(nested.projectKey, /^sha256:[a-f0-9]{64}$/); + assert.match(nested.contextFingerprint, /^sha256:[a-f0-9]{64}$/); + }); + + it("keeps fingerprints stable across equivalent checkout roots", async () => { + const files = { + "pyproject.toml": "[project]\nname='fixture'\n", + "app.py": "VALUE = 1\n" + }; + const resolveAt = (repoRoot, mode) => resolvePythonProjectContext({ + repoRoot, + target: "app.py", + workspace: projectWorkspace(files, () => true), + processProbe: successfulProbe(), + ...(mode === "active" + ? { env: { VIRTUAL_ENV: `${repoRoot}/.active`, PATH: "/usr/bin" } } + : { + env: { PATH: "/usr/bin" }, + interpreterArgv: [`${repoRoot}/bin/python`, "-X", "dev"], + toolArgv: { mypy: [`${repoRoot}/bin/mypy`, `--config=${repoRoot}/mypy.ini`] } + }) + }); + + for (const mode of ["active", "explicit"]) { + const [left, right] = await Promise.all([ + resolveAt("/checkout-a", mode), + resolveAt("/checkout-b", mode) + ]); + assert.notEqual(left.interpreter?.executable, right.interpreter?.executable, mode); + assert.notEqual(left.interpreter?.cwd, right.interpreter?.cwd, mode); + assert.equal(left.projectKey, right.projectKey, mode); + assert.equal(left.contextFingerprint, right.contextFingerprint, mode); + } + }); + + it("resolves layout and source roots from the target in mixed flat/src projects", async () => { + const files = { + "pyproject.toml": "[project]\nname='mixed'\n", + "script.py": "VALUE = 1\n", + "helper.py": "VALUE = 2\n", + "src/pkg/__init__.py": "VALUE = 3\n" + }; + const [flat, src] = await Promise.all([ + resolvePythonProjectContext({ + repoRoot: "/fixture", + target: "script.py", + workspace: projectWorkspace(files, () => true), + processProbe: successfulProbe() + }), + resolvePythonProjectContext({ + repoRoot: "/fixture", + target: "src/pkg/__init__.py", + workspace: projectWorkspace(files, () => true), + processProbe: successfulProbe() + }) + ]); + + assert.deepEqual(flat.sourceRoots, ["."]); + assert.deepEqual(flat.layout, { kinds: ["flat"], paths: ["."] }); + assert.deepEqual(src.sourceRoots, ["src"]); + assert.deepEqual(src.layout, { kinds: ["package", "src"], paths: ["src"] }); + }); + + it("applies explicit, active, project-local, then PATH interpreter precedence", async () => { + const files = { "pyproject.toml": "[project]\nname='fixture'\n", "app.py": "VALUE = 1\n" }; + const cases = [ + { + source: "explicit_override", + options: { interpreterArgv: ["/explicit/python"] }, + available: (command) => command === "/explicit/python" || !command.includes("/") + }, + { + source: "active_environment", + options: { env: { VIRTUAL_ENV: "/fixture/.active", PATH: "/redacted" } }, + available: (command) => command.startsWith("/fixture/.active/") || !command.includes("/") + }, + { + source: "project_local_environment", + options: {}, + available: (command) => command.startsWith("/fixture/.venv/") || !command.includes("/") + }, + { + source: "path", + options: { env: { PATH: "/must-not-serialize" } }, + available: (command) => !command.includes("/") + } + ]; + for (const testCase of cases) { + const context = await resolvePythonProjectContext({ + repoRoot: "/fixture", + target: "app.py", + workspace: projectWorkspace(files, testCase.available), + processProbe: successfulProbe(), + ...testCase.options + }); + assert.equal(context.interpreter?.source, testCase.source); + assert.equal(JSON.stringify(context).includes("must-not-serialize"), false); + assert.equal(JSON.stringify(context).includes("/redacted"), false); + } + + const launcher = await resolvePythonProjectContext({ + repoRoot: "/fixture", + target: "app.py", + interpreterArgv: ["uv", "run", "python"], + workspace: projectWorkspace(files, () => true), + processProbe: successfulProbe() + }); + assert.equal(launcher.interpreter, undefined); + assert.equal(launcher.reasons.some((reason) => reason.code === "invalid_config" && reason.tool === "python"), true); + }); + + it("refuses shell and sync-capable manager overrides before executing a probe", async () => { + const isolatedRepoRoot = mkdtempSync(join(tmpdir(), "opcore-python-unsafe-override-")); + const marker = join(isolatedRepoRoot, "probe-side-effect"); try { - const resolution = resolvePythonTool("python", "node", ["--version"], { - repoRoot, - env: { PATH: process.env.PATH } + writeFileSync(join(isolatedRepoRoot, "pyproject.toml"), "[project]\nname='fixture'\n"); + writeFileSync(join(isolatedRepoRoot, "app.py"), "VALUE = 1\n"); + const context = await resolvePythonProjectContext({ + repoRoot: isolatedRepoRoot, + target: "app.py", + env: { PATH: "" }, + interpreterArgv: ["/bin/sh", "-c", `printf side-effect > ${marker}`], + toolArgv: { mypy: ["uv", "run", "mypy"] }, + workspace: createNodePythonProjectWorkspace(isolatedRepoRoot) }); - assert.equal(resolution.available, true); - assert.equal(resolution.source, "path"); + + assert.equal(existsSync(marker), false); + assert.equal(context.interpreter, undefined); + assert.equal(context.tools.find((tool) => tool.tool === "mypy")?.available, false); + assert.equal(context.reasons.some((reason) => reason.code === "invalid_config" && reason.tool === "python"), true); + assert.equal(context.reasons.some((reason) => reason.code === "invalid_config" && reason.tool === "mypy"), true); + assert.notEqual(context.outcome, "resolved"); } finally { - rmSync(repoRoot, { recursive: true, force: true }); + rmSync(isolatedRepoRoot, { recursive: true, force: true }); } }); - it("resolves a repo-local .venv/bin executable before falling back to PATH", () => { - const repoRoot = mkdtempSync(join(tmpdir(), "opcore-python-resolver-venv-")); - try { - const venvBin = join(repoRoot, ".venv", "bin"); - mkdirSync(venvBin, { recursive: true }); - const shimPath = join(venvBin, "mypy"); - writeFileSync(shimPath, "#!/bin/sh\necho 'mypy 1.0.0 (compiled: yes)'\nexit 0\n"); - chmodSync(shimPath, 0o755); + it("accepts active environments only at the nearest project boundary", async () => { + const files = { + "pyproject.toml": "[project]\nname='root'\n", + "app.py": "ROOT = 1\n", + "services/api/pyproject.toml": "[project]\nname='api'\n[tool.uv]\npackage=true\n", + "services/api/uv.lock": "version = 1\n", + "services/api/app.py": "NESTED = 1\n" + }; + for (const [variable, nestedSource] of [ + ["VIRTUAL_ENV", "active_environment"], + ["UV_PROJECT_ENVIRONMENT", "manager_environment"] + ]) { + const environment = variable === "VIRTUAL_ENV" ? "/fixture/services/api/.venv" : "/fixture/services/api/.uv-active"; + const contexts = await resolvePythonProjectContexts({ + repoRoot: "/fixture", + targets: ["app.py", "services/api/app.py"], + env: { PATH: "/usr/bin", [variable]: environment }, + workspace: projectWorkspace(files, (command) => command.startsWith(`${environment}/`) || !command.includes("/")), + processProbe: successfulProbe() + }); + const root = contexts.find((context) => context.target === "app.py"); + const nested = contexts.find((context) => context.target === "services/api/app.py"); + assert.equal(root?.interpreter?.source, "path", variable); + assert.equal(nested?.interpreter?.source, nestedSource, variable); + } + }); - const resolution = resolvePythonTool("mypy", "mypy", ["--version"], { - repoRoot, - env: { PATH: "" } + it("uses inherited active environments when no environment projection is injected", async () => { + const previous = process.env.VIRTUAL_ENV; + process.env.VIRTUAL_ENV = "/fixture/custom-env"; + try { + const context = await resolvePythonProjectContext({ + repoRoot: "/fixture", + target: "app.py", + workspace: projectWorkspace( + { "pyproject.toml": "[project]\nname='fixture'\n", "app.py": "VALUE = 1\n" }, + (command) => command.startsWith("/fixture/custom-env/") || !command.includes("/") + ), + processProbe: successfulProbe() }); - assert.equal(resolution.available, true); - assert.equal(resolution.source, "repo-venv"); - assert.ok(resolution.command.endsWith(join(".venv", "bin", "mypy"))); + assert.equal(context.interpreter?.source, "active_environment"); + assert.equal(context.interpreter?.executable, "/fixture/custom-env/bin/python"); } finally { - rmSync(repoRoot, { recursive: true, force: true }); + if (previous === undefined) delete process.env.VIRTUAL_ENV; + else process.env.VIRTUAL_ENV = previous; } }); - it("reports a missing tool as unavailable with a failure message", () => { - const repoRoot = mkdtempSync(join(tmpdir(), "opcore-python-resolver-missing-")); - try { - const resolution = resolvePythonTool("mypy", "mypy", ["--version"], { - repoRoot, - env: { PATH: "" } + it("discards failed PATH candidates after a later interpreter succeeds", async () => { + const baseProbe = successfulProbe(); + const context = await resolvePythonProjectContext({ + repoRoot: "/fixture", + target: "app.py", + env: { PATH: "/usr/bin" }, + workspace: projectWorkspace( + { "pyproject.toml": "[project]\nname='fixture'\n", "app.py": "VALUE = 1\n" }, + (command) => !command.includes("/") + ), + processProbe: { + ...baseProbe, + run(command, args, options) { + if (command === "python3") { + return probeResult(command, args, options, { + ok: false, + termination: "spawn_error", + exitCode: null, + failureMessage: "spawn python3 ENOENT" + }); + } + return baseProbe.run(command, args, options); + } + } + }); + + assert.equal(context.interpreter?.executable, "/usr/bin/python"); + assert.equal(context.outcome, "resolved"); + assert.equal(context.reasons.some((reason) => reason.tool === "python"), false); + }); + + it("rejects symlinked PDM interpreter evidence before reading its content", async () => { + const files = { + "pyproject.toml": "[project]\nname='fixture'\n[tool.pdm]\ndistribution=true\n", + "pdm.lock": "version = '4.5'\n", + ".pdm-python": "/external/python\n", + "app.py": "VALUE = 1\n" + }; + const context = await resolvePythonProjectContext({ + repoRoot: "/fixture", + target: "app.py", + env: { PATH: "/usr/bin" }, + workspace: projectWorkspace(files, (command) => !command.includes("/"), new Set([".pdm-python"])), + processProbe: successfulProbe() + }); + + assert.equal(context.outcome, "ambiguous"); + assert.equal(context.interpreter?.source, "path"); + assert.equal( + context.reasons.some((reason) => reason.code === "symlink_refused" && reason.path === ".pdm-python"), + true + ); + }); + + it("honors an explicitly declared zero patch version", async () => { + const context = await resolvePythonProjectContext({ + repoRoot: "/fixture", + target: "app.py", + env: { PATH: "/usr/bin" }, + workspace: projectWorkspace({ + "pyproject.toml": "[project]\nname='fixture'\nrequires-python='==3.12.0'\n", + "app.py": "VALUE = 1\n" + }, (command) => !command.includes("/")), + processProbe: successfulProbe("3.12.1") + }); + + assert.equal(context.outcome, "unsupported"); + assert.equal(context.reasons.some((reason) => reason.code === "incompatible_interpreter"), true); + }); + + it("distinguishes exact release equality from explicit prefix wildcards", async () => { + const resolveConstraint = (requiresPython) => resolvePythonProjectContext({ + repoRoot: "/fixture", + target: "app.py", + env: { PATH: "/usr/bin" }, + workspace: projectWorkspace({ + "pyproject.toml": `[project]\nname='fixture'\nrequires-python='${requiresPython}'\n`, + "app.py": "VALUE = 1\n" + }, (command) => !command.includes("/")), + processProbe: successfulProbe("3.11.9") + }); + + const [exactEqual, wildcardEqual, exactNotEqual, wildcardNotEqual] = await Promise.all([ + resolveConstraint("==3.11"), + resolveConstraint("==3.11.*"), + resolveConstraint("!=3.11"), + resolveConstraint("!=3.11.*") + ]); + assert.equal(exactEqual.outcome, "unsupported"); + assert.equal(wildcardEqual.outcome, "resolved"); + assert.equal(exactNotEqual.outcome, "resolved"); + assert.equal(wildcardNotEqual.outcome, "unsupported"); + }); + + it("accepts standard multiline TOML and reports malformed TOML as invalid_config", async () => { + const valid = await resolvePythonProjectContext({ + repoRoot: "/fixture", + target: "app.py", + workspace: projectWorkspace({ + "pyproject.toml": [ + "[project]", + "name = 'fixture'", + "requires-python = '>=3.12'", + "", + "[build-system]", + "requires = [", + " 'hatchling>=1',", + " 'hatch-vcs>=0.4',", + "]", + "build-backend = 'hatchling.build'", + "" + ].join("\n"), + "app.py": "VALUE = 1\n" + }, () => true), + processProbe: successfulProbe() + }); + assert.equal(valid.reasons.some((reason) => reason.code === "invalid_config"), false); + assert.deepEqual(valid.buildSystem, { + configFile: "pyproject.toml", + backend: "hatchling.build", + requires: ["hatch-vcs>=0.4", "hatchling>=1"] + }); + assert.deepEqual(valid.tools.find((tool) => tool.tool === "build")?.argv.slice(-2), ["-m", "build"]); + + const malformed = await resolvePythonProjectContext({ + repoRoot: "/fixture", + target: "app.py", + workspace: projectWorkspace({ + "pyproject.toml": "[project\nname = 'fixture'\n", + "app.py": "VALUE = 1\n" + }, () => true), + processProbe: successfulProbe() + }); + assert.equal(malformed.outcome, "degraded"); + assert.deepEqual( + malformed.reasons.filter((reason) => reason.code === "invalid_config").map((reason) => reason.path), + ["pyproject.toml"] + ); + }); + + it("reports malformed INI tool configuration as invalid_config", async () => { + const context = await resolvePythonProjectContext({ + repoRoot: "/fixture", + target: "app.py", + env: { PATH: "/usr/bin" }, + workspace: projectWorkspace({ + "pyproject.toml": "[project]\nname='fixture'\n", + "mypy.ini": "[mypy\nstrict = true\n", + "app.py": "VALUE = 1\n" + }, (command) => !command.includes("/")), + processProbe: successfulProbe() + }); + + assert.equal(context.outcome, "degraded"); + assert.equal( + context.reasons.some((reason) => reason.code === "invalid_config" && reason.path === "mypy.ini"), + true + ); + }); + + it("reads Poetry target metadata from TOML AST and reports unavailable build tooling honestly", async () => { + const context = await resolvePythonProjectContext({ + repoRoot: "/fixture", + target: "app.py", + workspace: projectWorkspace({ + "pyproject.toml": [ + "[tool.poetry]", + "name = 'fixture'", + "[tool.poetry.dependencies]", + "python = '>=3.13'", + "[build-system]", + "requires = ['poetry-core>=1']", + "build-backend = 'poetry.core.masonry.api'", + "" + ].join("\n"), + "app.py": "VALUE = 1\n" + }, () => true), + processProbe: buildUnavailableProbe() + }); + + assert.equal(context.targetRuntime.requiresPython, ">=3.13"); + assert.deepEqual(context.buildSystem, { + configFile: "pyproject.toml", + backend: "poetry.core.masonry.api", + requires: ["poetry-core>=1"] + }); + assert.equal(context.tools.find((tool) => tool.tool === "build")?.available, false); + assert.equal(context.reasons.some((reason) => reason.code === "tool_unavailable" && reason.tool === "build"), true); + }); + + it("resolves Windows environment-root python.exe after Scripts/python.exe", async () => { + const environmentRootPython = "C:\\fixture\\.venv\\python.exe"; + const context = await resolvePythonProjectContext({ + repoRoot: "C:\\fixture", + target: "app.py", + platform: "win32", + architecture: "x64", + workspace: projectWorkspace( + { "pyproject.toml": "[project]\nname='fixture'\nrequires-python='>=3.12'\n", "app.py": "VALUE = 1\n" }, + (command) => command === environmentRootPython || /\\Scripts\\(?:mypy|pyright|ruff|pytest)\.exe$/u.test(command) + ), + processProbe: windowsProbe(environmentRootPython) + }); + + assert.equal(context.interpreter?.source, "project_local_environment"); + assert.equal(context.interpreter?.executable, environmentRootPython); + assert.deepEqual(context.interpreter?.argv, [environmentRootPython]); + assert.equal(context.interpreter?.cwd, "C:\\fixture"); + assert.equal(context.outcome, "resolved"); + }); + + it("resolves Python tools from the selected UV manager environment before PATH", async () => { + const managerEnvironment = "/fixture/.uv-python"; + const managerBin = `${managerEnvironment}/bin`; + const context = await resolvePythonProjectContext({ + repoRoot: "/fixture", + target: "app.py", + env: { PATH: "/usr/bin", UV_PROJECT_ENVIRONMENT: managerEnvironment }, + workspace: projectWorkspace( + { + "pyproject.toml": "[project]\nname='fixture'\nrequires-python='>=3.12'\n[tool.uv]\npackage=true\n", + "uv.lock": "version=1\n", + "app.py": "VALUE = 1\n" + }, + (command) => command.startsWith(`${managerBin}/`) || !command.includes("/") + ), + processProbe: successfulProbe() + }); + + assert.equal(context.interpreter?.source, "manager_environment"); + assert.deepEqual( + context.tools.filter((tool) => tool.tool !== "build").map((tool) => ({ + tool: tool.tool, + source: tool.source, + executable: tool.executable + })), + ["mypy", "pyright", "pytest", "ruff"].map((tool) => ({ + tool, + source: "manager_environment", + executable: `${managerBin}/${tool}` + })) + ); + assert.equal(context.outcome, "resolved"); + }); + + it("reports deterministic path, symlink, manager, platform, target, and probe failures", async () => { + const baseFiles = { "pyproject.toml": "[project]\nname='fixture'\nrequires-python='>=3.12'\n", "app.py": "VALUE = 1\n" }; + for (const [mode, expected] of [ + ["timeout", "probe_timeout"], + ["signal", "probe_signal"], + ["spawn", "probe_spawn_failure"], + ["exit", "probe_exit_failure"], + ["malformed", "malformed_probe_output"] + ]) { + const context = await resolvePythonProjectContext({ + repoRoot: "/fixture", + target: "app.py", + interpreterArgv: ["/fixture/python"], + workspace: projectWorkspace(baseFiles, () => true), + processProbe: failingProbe(mode) }); - assert.equal(resolution.available, false); - assert.equal(resolution.source, "path"); - assert.ok(resolution.failureMessage); + assert.equal(context.reasons.some((reason) => reason.code === expected), true, mode); + assert.notEqual(context.outcome, "resolved", mode); + } + + const unsupportedPlatform = await resolvePythonProjectContext({ + repoRoot: "/fixture", + target: "app.py", + platform: "aix", + workspace: projectWorkspace(baseFiles, () => true) + }); + assert.equal(unsupportedPlatform.reasons[0].code, "unsupported_platform"); + + const unsupportedTarget = await resolvePythonProjectContext({ + repoRoot: "/fixture", + target: "app.py", + workspace: projectWorkspace( + { ...baseFiles, "pyproject.toml": "[project]\nname='fixture'\nrequires-python='^3.12'\n" }, + () => true + ), + processProbe: successfulProbe() + }); + assert.equal(unsupportedTarget.reasons.some((reason) => reason.code === "unsupported_target"), true); + + const ambiguous = await resolvePythonProjectContext({ + repoRoot: "/fixture", + target: "app.py", + workspace: projectWorkspace({ + ...baseFiles, + "uv.lock": "version=1\n", + "poetry.lock": "package=[]\n" + }, () => true, new Set(["app.py"])), + processProbe: successfulProbe() + }); + assert.equal(ambiguous.outcome, "ambiguous"); + assert.equal(ambiguous.reasons.some((reason) => reason.code === "conflicting_managers"), true); + assert.equal(ambiguous.reasons.some((reason) => reason.code === "symlink_refused"), true); + + await assert.rejects( + resolvePythonProjectContext({ + repoRoot: "/fixture", + target: "../escape.py", + workspace: projectWorkspace(baseFiles, () => true) + }), + (error) => error?.code === "path_refused" + ); + }); + + it("fails closed when successful interpreter or tool probes return malformed versions", async () => { + const files = { + "pyproject.toml": "[project]\nname='fixture'\nrequires-python='>=3.12'\n", + "app.py": "VALUE = 1\n" + }; + const malformedInterpreter = await resolvePythonProjectContext({ + repoRoot: "/fixture", + target: "app.py", + workspace: projectWorkspace(files, () => true), + processProbe: successfulProbe("garbage") + }); + assert.equal(malformedInterpreter.interpreter, undefined); + assert.equal( + malformedInterpreter.reasons.some((reason) => reason.code === "malformed_probe_output" && reason.tool === "python"), + true + ); + assert.notEqual(malformedInterpreter.outcome, "resolved"); + + const probeWithoutAbi = successfulProbe(); + const incompleteInterpreter = await resolvePythonProjectContext({ + repoRoot: "/fixture", + target: "app.py", + workspace: projectWorkspace(files, () => true), + processProbe: { + ...probeWithoutAbi, + run(command, args, options) { + const script = args[args.indexOf("-c") + 1] ?? ""; + if (!args.includes("-c") || script.includes("opcore.python.project-context.build.v1")) { + return probeWithoutAbi.run(command, args, options); + } + return probeResult(command, args, options, { + stdout: JSON.stringify({ + protocol: "opcore.python.project-context.interpreter.v1", + executable: command.includes("/") ? command : `/usr/bin/${command}`, + version: "3.12.4", + implementation: "CPython", + platform: "linux", + architecture: "x86_64" + }) + }); + } + } + }); + assert.equal(incompleteInterpreter.interpreter, undefined); + assert.equal( + incompleteInterpreter.reasons.some((reason) => reason.code === "malformed_probe_output" && reason.tool === "python"), + true + ); + assert.notEqual(incompleteInterpreter.outcome, "resolved"); + + const validProbe = successfulProbe(); + const malformedTools = await resolvePythonProjectContext({ + repoRoot: "/fixture", + target: "app.py", + workspace: projectWorkspace(files, () => true), + processProbe: { + ...validProbe, + run(command, args, options) { + return args.includes("-c") + ? validProbe.run(command, args, options) + : probeResult(command, args, options, { stdout: "not-a-version" }); + } + } + }); + assert.equal(malformedTools.tools.every((tool) => !tool.available), true); + assert.deepEqual( + malformedTools.reasons.filter((reason) => reason.code === "malformed_probe_output").map((reason) => reason.tool).sort(), + ["mypy", "pyright", "pytest", "ruff"] + ); + assert.notEqual(malformedTools.outcome, "resolved"); + + const malformedBuild = await resolvePythonProjectContext({ + repoRoot: "/fixture", + target: "app.py", + workspace: projectWorkspace({ + ...files, + "pyproject.toml": `${files["pyproject.toml"]}[build-system]\nrequires=['build']\nbuild-backend='example.backend'\n` + }, () => true), + processProbe: { + ...validProbe, + run(command, args, options) { + const script = args[args.indexOf("-c") + 1] ?? ""; + return script.includes("opcore.python.project-context.build.v1") + ? probeResult(command, args, options, { + stdout: JSON.stringify({ + protocol: "opcore.python.project-context.build.v1", + available: true, + version: "not-a-version" + }) + }) + : validProbe.run(command, args, options); + } + } + }); + assert.equal(malformedBuild.tools.find((tool) => tool.tool === "build")?.available, false); + assert.equal( + malformedBuild.reasons.some((reason) => reason.code === "malformed_probe_output" && reason.tool === "build"), + true + ); + assert.notEqual(malformedBuild.outcome, "resolved"); + + const missingBuildVersion = await resolvePythonProjectContext({ + repoRoot: "/fixture", + target: "app.py", + workspace: projectWorkspace({ + ...files, + "pyproject.toml": `${files["pyproject.toml"]}[build-system]\nrequires=['build']\nbuild-backend='example.backend'\n` + }, () => true), + processProbe: { + ...validProbe, + run(command, args, options) { + const script = args[args.indexOf("-c") + 1] ?? ""; + return script.includes("opcore.python.project-context.build.v1") + ? probeResult(command, args, options, { + stdout: JSON.stringify({ + protocol: "opcore.python.project-context.build.v1", + available: true, + version: null + }) + }) + : validProbe.run(command, args, options); + } + } + }); + assert.equal(missingBuildVersion.tools.find((tool) => tool.tool === "build")?.available, false); + assert.equal( + missingBuildVersion.reasons.some((reason) => reason.code === "malformed_probe_output" && reason.tool === "build"), + true + ); + assert.notEqual(missingBuildVersion.outcome, "resolved"); + }); + + it("does not use provider-local executable paths when the injected workspace refuses them", async () => { + const repoRoot = mkdtempSync(join(tmpdir(), "opcore-python-host-workspace-")); + try { + const localPython = join(repoRoot, ".venv", "bin", "python"); + mkdirSync(dirname(localPython), { recursive: true }); + writeFileSync(localPython, "#!/bin/sh\nexit 0\n"); + chmodSync(localPython, 0o755); + const files = { + "pyproject.toml": "[project]\nname='fixture'\n", + "app.py": "VALUE = 1\n" + }; + const result = await runner({ + files, + checks: createPythonValidationChecks({ + nodeWorkspace: projectWorkspace(files, () => false), + processProbe: { + run() { + throw new Error("provider-local executable must not be probed"); + } + } + }) + }).runValidation(request({ + repo: { repoRoot }, + checks: [PYTHON_SOURCE_HYGIENE_CHECK_ID], + scope: { kind: "files", files: ["app.py"] } + })); + + assert.equal(result.pythonProjectContexts[0].interpreter, undefined); + assert.equal(result.pythonProjectContexts[0].outcome, "unsupported"); } finally { rmSync(repoRoot, { recursive: true, force: true }); } }); - it("prefers pyproject.toml when no tool-specific config exists", () => { - const repoRoot = mkdtempSync(join(tmpdir(), "opcore-python-resolver-config-")); + it("refuses explicit interpreter paths outside the injected workspace trust boundary", async () => { + const repoRoot = mkdtempSync(join(tmpdir(), "opcore-python-explicit-host-path-")); try { - writeFileSync(join(repoRoot, "pyproject.toml"), "[tool.mypy]\n"); - const configFile = findPythonConfigFile(repoRoot, "mypy"); - assert.equal(configFile, join(repoRoot, "pyproject.toml")); + const localPython = join(repoRoot, ".venv", "bin", "python"); + mkdirSync(dirname(localPython), { recursive: true }); + writeFileSync(localPython, "#!/bin/sh\nexit 0\n"); + chmodSync(localPython, 0o755); + const result = await resolvePythonProjectContext({ + repoRoot, + target: "app.py", + interpreterArgv: [localPython], + workspace: projectWorkspace({ + "pyproject.toml": "[project]\nname='fixture'\n", + "app.py": "VALUE = 1\n" + }, () => false), + processProbe: { + run() { + throw new Error("refused explicit interpreter must not be probed"); + } + } + }); + + assert.equal(result.interpreter, undefined); + assert.equal(result.reasons.some((reason) => reason.code === "interpreter_unavailable"), true); + assert.equal(result.outcome, "unsupported"); } finally { rmSync(repoRoot, { recursive: true, force: true }); } }); - it("prefers a tool-specific config file over pyproject.toml", () => { - const repoRoot = mkdtempSync(join(tmpdir(), "opcore-python-resolver-config-specific-")); + it("resolves each validation file view once across Python checks", async () => { + const files = { + "pyproject.toml": "[project]\nname='fixture'\n", + "app.py": "VALUE = 1\n" + }; + const baseWorkspace = projectWorkspace(files, () => false); + let listCalls = 0; + const result = await runner({ + files, + checks: createPythonValidationChecks({ + nodeWorkspace: { + ...baseWorkspace, + list: async () => { + listCalls += 1; + return baseWorkspace.list(); + } + } + }) + }).runValidation(request({ + repo: { repoRoot: "/fixture" }, + checks: [PYTHON_SOURCE_HYGIENE_CHECK_ID, PYTHON_TYPES_CHECK_ID], + scope: { kind: "files", files: ["app.py"] } + })); + + assert.equal(result.pythonProjectContexts.length, 1); + assert.equal(listCalls, 1); + }); + + it("resolves each current file view independently of earlier context evidence", async () => { + const originalFiles = { + "pyproject.toml": "[project]\nname='fixture'\nrequires-python='>=3.11'\n", + "app.py": "VALUE = 1\n" + }; + const stale = await resolvePythonProjectContext({ + repoRoot: "/fixture", + target: "app.py", + workspace: projectWorkspace(originalFiles, () => true), + processProbe: successfulProbe("3.12.4") + }); + const currentFiles = { + ...originalFiles, + "pyproject.toml": "[project]\nname='fixture'\nrequires-python='>=3.13'\n" + }; + const result = await runner({ + files: currentFiles, + checks: createPythonValidationChecks({ + nodeWorkspace: projectWorkspace(currentFiles, () => true), + processProbe: successfulProbe("3.12.4") + }) + }).runValidation(request({ + repo: { repoRoot: "/fixture" }, + checks: [PYTHON_SOURCE_HYGIENE_CHECK_ID], + scope: { kind: "files", files: ["app.py"] } + })); + + assert.equal(result.pythonProjectContexts[0].targetRuntime.requiresPython, ">=3.13"); + assert.equal(result.pythonProjectContexts[0].outcome, "unsupported"); + assert.notEqual(result.pythonProjectContexts[0].contextFingerprint, stale.contextFingerprint); + }); + + it("parses setup.cfg python_requires and major-only PEP 440 range bounds", async () => { + const files = { + "setup.cfg": "[metadata]\nname = fixture\npython_requires = >=99\n[options]\npython_requires = >=3.11,<4\n", + "app.py": "VALUE = 1\n" + }; + const compatible = await resolvePythonProjectContext({ + repoRoot: "/fixture", + target: "app.py", + workspace: projectWorkspace(files, () => true), + processProbe: successfulProbe("3.12.4") + }); + const incompatible = await resolvePythonProjectContext({ + repoRoot: "/fixture", + target: "app.py", + workspace: projectWorkspace(files, () => true), + processProbe: successfulProbe("4.0.0") + }); + const belowFloor = await resolvePythonProjectContext({ + repoRoot: "/fixture", + target: "app.py", + workspace: projectWorkspace(files, () => true), + processProbe: successfulProbe("3.10.14") + }); + + assert.equal(compatible.targetRuntime.requiresPython, ">=3.11,<4"); + assert.equal(compatible.outcome, "resolved"); + assert.equal(incompatible.outcome, "unsupported"); + assert.equal(incompatible.reasons.some((reason) => reason.code === "incompatible_interpreter"), true); + assert.equal(belowFloor.outcome, "unsupported"); + assert.equal(belowFloor.reasons.some((reason) => reason.code === "incompatible_interpreter"), true); + }); + + it("applies PEP 440 compatible-release precision consistently", async () => { + const compatible = await resolvePythonProjectContext({ + repoRoot: "/fixture", + target: "app.py", + workspace: projectWorkspace({ + "pyproject.toml": "[project]\nname='fixture'\nrequires-python='~=3.11'\n", + "pyrightconfig.json": JSON.stringify({ pythonVersion: "3.12" }), + "app.py": "VALUE = 1\n" + }, () => true), + processProbe: successfulProbe("3.12.4") + }); + assert.equal(compatible.targetRuntime.conflicts.length, 0); + assert.equal(compatible.reasons.some((reason) => reason.code === "incompatible_interpreter"), false); + assert.equal(compatible.outcome, "resolved"); + + const incompatible = await resolvePythonProjectContext({ + repoRoot: "/fixture", + target: "app.py", + workspace: projectWorkspace({ + "pyproject.toml": "[project]\nname='fixture'\nrequires-python='~=3.11.0'\n", + "app.py": "VALUE = 1\n" + }, () => true), + processProbe: successfulProbe("3.12.4") + }); + assert.equal(incompatible.reasons.some((reason) => reason.code === "incompatible_interpreter"), true); + assert.equal(incompatible.outcome, "unsupported"); + + const underspecified = await resolvePythonProjectContext({ + repoRoot: "/fixture", + target: "app.py", + workspace: projectWorkspace({ + "pyproject.toml": "[project]\nname='fixture'\nrequires-python='~=3'\n", + "app.py": "VALUE = 1\n" + }, () => true), + processProbe: successfulProbe("3.12.4") + }); + assert.equal(underspecified.reasons.some((reason) => reason.code === "unsupported_target"), true); + assert.equal(underspecified.outcome, "unsupported"); + }); + + it("does not treat prerelease interpreters as final PEP 440 releases", async () => { + const resolveWithConstraint = (requiresPython) => resolvePythonProjectContext({ + repoRoot: "/fixture", + target: "app.py", + workspace: projectWorkspace({ + "pyproject.toml": `[project]\nname='fixture'\nrequires-python='${requiresPython}'\n`, + "app.py": "VALUE = 1\n" + }, () => true), + processProbe: successfulProbe("3.13.0rc1") + }); + + const finalOnly = await resolveWithConstraint(">=3.13"); + assert.equal(finalOnly.reasons.some((reason) => reason.code === "incompatible_interpreter"), true); + assert.equal(finalOnly.outcome, "unsupported"); + + const explicitPrerelease = await resolveWithConstraint(">=3.13rc1"); + assert.equal(explicitPrerelease.reasons.some((reason) => reason.code === "incompatible_interpreter"), false); + assert.equal(explicitPrerelease.outcome, "resolved"); + + const laterPrerelease = await resolveWithConstraint(">=3.13rc2"); + assert.equal(laterPrerelease.reasons.some((reason) => reason.code === "incompatible_interpreter"), true); + assert.equal(laterPrerelease.outcome, "unsupported"); + + const betaFloor = await resolveWithConstraint(">=3.13b1"); + assert.equal(betaFloor.reasons.some((reason) => reason.code === "incompatible_interpreter"), false); + assert.equal(betaFloor.outcome, "resolved"); + }); + + it("checks requires-python and configured target version independently", async () => { + const context = await resolvePythonProjectContext({ + repoRoot: "/fixture", + target: "app.py", + workspace: projectWorkspace({ + "pyproject.toml": "[project]\nname='fixture'\nrequires-python='>=3.10'\n", + "pyrightconfig.json": JSON.stringify({ pythonVersion: "3.12" }), + "app.py": "VALUE = 1\n" + }, () => true), + processProbe: successfulProbe("3.11.9") + }); + + assert.equal(context.targetRuntime.requiresPython, ">=3.10"); + assert.equal(context.targetRuntime.version, "3.12"); + assert.equal(context.reasons.some((reason) => reason.code === "incompatible_interpreter"), true); + assert.equal(context.outcome, "unsupported"); + }); + + it("uses JSONC target fields only from the precedence-selected Pyright config", async () => { + const preferred = await resolvePythonProjectContext({ + repoRoot: "/fixture", + target: "app.py", + workspace: projectWorkspace({ + "pyrightconfig.json": "{ // selected target\n \"pythonVersion\": \"3.14\",\n \"pythonPlatform\": \"linux\",\n}\n", + "pyproject.toml": "[project]\nname='fixture'\n[tool.pyright]\npythonVersion='3.11'\npythonPlatform='win32'\n", + "app.py": "VALUE = 1\n" + }, () => true), + processProbe: successfulProbe("3.14.1") + }); + + assert.deepEqual(preferred.targetRuntime, { + version: "3.14", + platform: "linux", + conflicts: [] + }); + assert.equal(preferred.outcome, "resolved"); + + const incompatible = await resolvePythonProjectContext({ + repoRoot: "/fixture", + target: "app.py", + workspace: projectWorkspace({ + "pyrightconfig.json": "{ // preserve JSONC fields\n \"pythonVersion\": \"3.13\",\n \"pythonPlatform\": \"linux\",\n \"pythonImplementation\": \"CPython\",\n}\n", + "app.py": "VALUE = 1\n" + }, () => true), + processProbe: successfulProbe("3.13.9", "darwin") + }); + + assert.equal(incompatible.targetRuntime.version, "3.13"); + assert.equal(incompatible.targetRuntime.platform, "linux"); + assert.equal(incompatible.targetRuntime.implementation, "CPython"); + assert.equal(incompatible.reasons.some((reason) => reason.code === "incompatible_interpreter"), true); + assert.equal(incompatible.outcome, "unsupported"); + }); + + it("inherits recursive Pyright target fields into compatibility and fingerprints with child overrides", async () => { + const inheritedFiles = { + "pyrightconfig.json": "{ // child override\n \"extends\": \"configs/base.json\",\n \"pythonVersion\": \"3.13\",\n}\n", + "configs/base.json": "{\n \"extends\": \"more/leaf.json\",\n \"pythonPlatform\": \"linux\",\n}\n", + "configs/more/leaf.json": "{\n \"pythonVersion\": \"3.11\",\n \"pythonImplementation\": \"CPython\",\n}\n", + "app.py": "VALUE = 1\n" + }; + const inherited = await resolvePythonProjectContext({ + repoRoot: "/fixture", + target: "app.py", + workspace: projectWorkspace(inheritedFiles, () => true), + processProbe: successfulProbe("3.13.9", "darwin") + }); + + assert.deepEqual(inherited.targetRuntime, { + version: "3.13", + platform: "linux", + implementation: "CPython", + conflicts: [] + }); + assert.equal(inherited.reasons.some((reason) => reason.code === "incompatible_interpreter"), true); + assert.equal(inherited.outcome, "unsupported"); + + const changed = await resolvePythonProjectContext({ + repoRoot: "/fixture", + target: "app.py", + workspace: projectWorkspace({ + ...inheritedFiles, + "configs/more/leaf.json": "{\n \"pythonVersion\": \"3.11\",\n \"pythonImplementation\": \"PyPy\",\n}\n" + }, () => true), + processProbe: successfulProbe("3.13.9", "darwin") + }); + + assert.equal(changed.targetRuntime.implementation, "PyPy"); + assert.notEqual(changed.contextFingerprint, inherited.contextFingerprint); + }); + + it("returns a typed ambiguous context for a target symlink escaping the repository", async () => { + const repoRoot = mkdtempSync(join(tmpdir(), "opcore-python-symlink-repo-")); + const externalRoot = mkdtempSync(join(tmpdir(), "opcore-python-symlink-external-")); try { - writeFileSync(join(repoRoot, "pyproject.toml"), "[tool.mypy]\n"); - writeFileSync(join(repoRoot, "mypy.ini"), "[mypy]\n"); - const configFile = findPythonConfigFile(repoRoot, "mypy"); - assert.equal(configFile, join(repoRoot, "mypy.ini")); + writeFileSync(join(repoRoot, "pyproject.toml"), "[project]\nname='fixture'\n"); + writeFileSync(join(externalRoot, "outside.py"), "VALUE = 1\n"); + symlinkSync(join(externalRoot, "outside.py"), join(repoRoot, "app.py")); + const context = await resolvePythonProjectContext({ + repoRoot, + target: "app.py", + workspace: createNodePythonProjectWorkspace(repoRoot), + processProbe: successfulProbe() + }); + assert.equal(context.outcome, "ambiguous"); + assert.equal(context.reasons.some((reason) => reason.code === "symlink_refused" && reason.path === "app.py"), true); } finally { rmSync(repoRoot, { recursive: true, force: true }); + rmSync(externalRoot, { recursive: true, force: true }); } }); + + it("preserves baseline symlink refusal when the target has an after-state overlay", async () => { + const files = { + "pyproject.toml": "[project]\nname='fixture'\n", + "app.py": "VALUE = 1\n" + }; + const result = await runner({ + files, + checks: createPythonValidationChecks({ + processProbe: successfulProbe(), + nodeWorkspace: projectWorkspace(files, () => true, new Set(["app.py"])) + }) + }).runValidation(request({ + repo: { repoRoot: "/fixture" }, + checks: [PYTHON_SOURCE_HYGIENE_CHECK_ID], + scope: { kind: "files", files: ["app.py"] }, + overlays: [{ path: "app.py", action: "write", content: "VALUE = 2\n" }] + })); + + assert.equal(result.pythonProjectContexts[0].outcome, "ambiguous"); + assert.equal( + result.pythonProjectContexts[0].reasons.some((reason) => reason.code === "symlink_refused" && reason.path === "app.py"), + true + ); + }); + + it("bounds package ancestry at the owning project and source root", async () => { + const context = await resolvePythonProjectContext({ + repoRoot: "/fixture", + target: "services/api/app.py", + workspace: projectWorkspace({ + "pyproject.toml": "[project]\nname='root'\n", + "services/api/pyproject.toml": "[project]\nname='api'\n", + "services/api/app.py": "VALUE = 1\n" + }, () => true), + processProbe: successfulProbe() + }); + assert.equal(context.projectRoot, "services/api"); + assert.deepEqual(context.layout.kinds, ["flat"]); + }); + + it("matches overlay after-state identity with the equivalent materialized tree", async () => { + const before = { + "pyproject.toml": "[project]\nname='fixture'\nrequires-python='>=3.11'\n", + "app.py": "VALUE = 1\n" + }; + const after = { + "pyproject.toml": "[project]\nname='fixture'\nrequires-python='>=3.12'\n[tool.uv]\npackage=true\n", + "app.py": "VALUE = 2\n" + }; + const validationResult = await runner({ + files: before, + checks: createPythonValidationChecks({ processProbe: successfulProbe() }) + }).runValidation(request({ + repo: { repoRoot: "/fixture" }, + checks: [PYTHON_SOURCE_HYGIENE_CHECK_ID], + scope: { kind: "files", files: ["app.py"] }, + overlays: [ + { path: "pyproject.toml", action: "write", content: after["pyproject.toml"] }, + { path: "app.py", action: "write", content: after["app.py"] } + ] + })); + const materialized = await resolvePythonProjectContext({ + repoRoot: "/fixture", + target: "app.py", + workspace: projectWorkspace(after, (command) => !command.includes("/")), + processProbe: successfulProbe() + }); + + assert.equal(validationResult.pythonProjectContexts.length, 1); + assert.equal(validationResult.pythonProjectContexts[0].contextFingerprint, materialized.contextFingerprint); + assert.equal(validationResult.pythonProjectContexts[0].projectKey, materialized.projectKey); + assert.equal(validationResult.pythonProjectContexts[0].outcome, materialized.outcome); + }); + + it("removes deleted project markers from overlay discovery and matches materialized identity", async () => { + const before = { + "pyproject.toml": "[project]\nname='root'\n", + "services/api/pyproject.toml": "[project]\nname='api'\n", + "services/api/app.py": "VALUE = 1\n" + }; + const result = await runner({ + files: before, + checks: createPythonValidationChecks({ + processProbe: successfulProbe(), + nodeWorkspace: projectWorkspace(before, (command) => !command.includes("/")) + }) + }).runValidation(request({ + repo: { repoRoot: "/fixture" }, + checks: [PYTHON_SOURCE_HYGIENE_CHECK_ID], + scope: { kind: "files", files: ["services/api/app.py"] }, + overlays: [{ path: "services/api/pyproject.toml", action: "delete" }] + })); + const materialized = await resolvePythonProjectContext({ + repoRoot: "/fixture", + target: "services/api/app.py", + workspace: projectWorkspace({ + "pyproject.toml": before["pyproject.toml"], + "services/api/app.py": before["services/api/app.py"] + }, (command) => !command.includes("/")), + processProbe: successfulProbe() + }); + const overlay = result.pythonProjectContexts[0]; + assert.equal(overlay.projectRoot, "."); + assert.equal(overlay.contextFingerprint, materialized.contextFingerprint, JSON.stringify({ overlay, materialized }, null, 2)); + assert.equal(overlay.outcome, materialized.outcome); + }); }); function runner(options = {}) { return createValidationRunner({ workspace: workspace(options), checks: options.checks ?? createPythonValidationChecks(), - graphProviderClient: options.graphProviderClient + graphProviderClient: options.graphProviderClient, + graphSessionFactory: options.graphSessionFactory + }); +} + +function exactGraphSessionFactory(client) { + return (args) => createValidationGraphQuerySession({ + ...args, + client, + status: availableStatus(args.request.graph.mode, args.request.repo) + }); +} + +function createPythonValidationChecks(options = {}) { + return createCanonicalPythonValidationChecks({ + ...options, + importAnalyzer: options.importAnalyzer ?? { analyze: async () => [] }, + nodeWorkspace: options.nodeWorkspace ?? canonicalTestPythonWorkspace() }); } +function canonicalTestPythonWorkspace() { + return { + read: async () => undefined, + list: async () => [], + exists: async () => false, + realpath: async (path) => ({ path, symlink: false }), + executableExists: async (path) => !path.includes("/") && !path.includes("\\") || existsSync(path) + }; +} + +function projectWorkspace(files, executableExists, symlinks = new Set()) { + const contents = new Map(Object.entries(files)); + return { + read: async (path) => contents.get(path), + list: async () => [...contents.keys()].sort(), + exists: async (path) => contents.has(path), + realpath: async (path) => ({ path, symlink: symlinks.has(path) }), + executableExists: async (path) => executableExists(path) + }; +} + +function fixedImportAnalyzer(edges) { + return { analyze: async () => edges }; +} + +function successfulProbe(version = "3.12.4", platform = "linux") { + return { + resolveExecutable(command) { + return command.includes("/") ? command : `/usr/bin/${command}`; + }, + run(command, args, options) { + const script = args[args.indexOf("-c") + 1] ?? ""; + const build = script.includes("opcore.python.project-context.build.v1"); + const interpreter = args.includes("-c") && !build; + return probeResult(command, args, options, { + stdout: build + ? JSON.stringify({ protocol: "opcore.python.project-context.build.v1", available: true, version: "1.2.2" }) + : interpreter + ? JSON.stringify({ + protocol: "opcore.python.project-context.interpreter.v1", + executable: command.includes("/") ? command : `/usr/bin/${command}`, + version, + implementation: "CPython", + platform, + architecture: "x86_64", + abi: "cpython-312", + soabi: "cpython-312-x86_64-linux-gnu" + }) + : `${command} 1.0.0` + }); + } + }; +} + +function buildUnavailableProbe() { + const base = successfulProbe(); + return { + ...base, + run(command, args, options) { + const script = args[args.indexOf("-c") + 1] ?? ""; + if (script.includes("opcore.python.project-context.build.v1")) { + return probeResult(command, args, options, { + stdout: JSON.stringify({ protocol: "opcore.python.project-context.build.v1", available: false, version: null }) + }); + } + return base.run(command, args, options); + } + }; +} + +function windowsProbe(interpreter) { + return { + run(command, args, options) { + return probeResult(command, args, options, { + stdout: args.includes("-c") + ? JSON.stringify({ + protocol: "opcore.python.project-context.interpreter.v1", + executable: interpreter, + version: "3.12.4", + implementation: "CPython", + platform: "win32", + architecture: "AMD64", + abi: "cpython-312", + soabi: "cp312-win_amd64" + }) + : `${command} 1.0.0` + }); + } + }; +} + +function failingProbe(mode) { + return { + run(command, args, options) { + if (mode === "malformed") return probeResult(command, args, options, { stdout: "not-json" }); + const base = probeResult(command, args, options, { ok: false }); + if (mode === "timeout") return { ...base, termination: "timeout", exitCode: null, failureMessage: "timeout" }; + if (mode === "signal") return { ...base, termination: "signal", exitCode: null, signal: "SIGTERM", failureMessage: "signal" }; + if (mode === "spawn") return { ...base, termination: "spawn_error", exitCode: null, failureMessage: "permission denied" }; + return { ...base, termination: "exited", exitCode: 9, failureMessage: "exit 9" }; + } + }; +} + +function probeResult(command, args, options, overrides = {}) { + return { + command, + args, + cwd: options.cwd, + allowedExitCodes: [0], + exitCode: 0, + signal: null, + stdout: "", + stderr: "", + termination: "exited", + ok: true, + ...overrides + }; +} + function request(overrides = {}) { return { requestId: "validation-python-1", @@ -572,6 +4002,7 @@ function workspace(options = {}) { const files = new Map(Object.entries(options.files ?? { "pkg/app.py": "value = 1\n" })); return { readFile: (path) => (files.has(path) ? { status: "found", content: files.get(path) } : { status: "missing" }), + listFiles: () => ({ files: [...files.keys()] }), listChangedFiles: () => ({ files: [...files.keys()] }), listStagedFiles: () => ({ files: [...files.keys()] }), listRepoFiles: () => ({ files: [...files.keys()] }), @@ -583,9 +4014,14 @@ function workspace(options = {}) { function fixtureFiles(name) { const root = join(validationFixtureRoot, name); + return fixtureFilesAt(root); +} + +function fixtureFilesAt(root) { const entries = {}; for (const path of walkFiles(root)) { - entries[relative(root, path).replaceAll("\\", "/")] = readFileSync(path, "utf8"); + const fixturePath = relative(root, path).replaceAll("\\", "/"); + entries[fixturePath.endsWith(".fixture") ? fixturePath.slice(0, -".fixture".length) : fixturePath] = readFileSync(path, "utf8"); } return entries; } @@ -600,6 +4036,23 @@ function walkFiles(root) { return paths.sort(); } +async function processExited(pid) { + for (let attempt = 0; attempt < 100; attempt += 1) { + try { + process.kill(pid, 0); + } catch (error) { + if (error?.code === "ESRCH") return true; + throw error; + } + await delay(10); + } + return false; +} + +function materializedMypyWorkspaces() { + return readdirSync(tmpdir()).filter((entry) => entry.startsWith("opcore-python-types-workspace-")).sort(); +} + function writeToolShim(repoRoot, name, content) { const bin = join(repoRoot, ".venv", "bin"); mkdirSync(bin, { recursive: true }); @@ -608,6 +4061,94 @@ function writeToolShim(repoRoot, name, content) { chmodSync(shimPath, 0o755); } +function pyrightShim(version, diagnostics, exitCode = diagnostics.some((entry) => entry.severity === "error") ? 1 : 0) { + const payload = pyrightPayload(version, diagnostics); + return rawPyrightShim(version, [ + "cat <<'OPCORE_PYRIGHT_JSON'", + JSON.stringify(payload), + "OPCORE_PYRIGHT_JSON", + `exit ${exitCode}` + ].join("\n")); +} + +function pyrightPayload(version, diagnostics) { + const summary = { + filesAnalyzed: 1, + errorCount: diagnostics.filter((entry) => entry.severity === "error").length, + warningCount: diagnostics.filter((entry) => entry.severity === "warning").length, + informationCount: diagnostics.filter((entry) => entry.severity === "information").length, + timeInSec: 0.01 + }; + return { version, time: "0", generalDiagnostics: diagnostics, summary }; +} + +function rawPyrightShim(version, body) { + return [ + "#!/bin/sh", + `for arg in \"$@\"; do if [ \"$arg\" = \"--version\" ]; then echo 'pyright ${version}'; exit 0; fi; done`, + body, + "" + ].join("\n"); +} + +function writePythonProtocolShim(repoRoot, compilerBranch) { + const bin = join(repoRoot, ".venv", "bin"); + mkdirSync(bin, { recursive: true }); + const shimPath = join(bin, "python"); + writeFileSync( + shimPath, + [ + "#!/bin/sh", + "case \"$4\" in", + " *opcore.python.project-context.interpreter.v1*)", + projectContextInterpreterResponse("SHIM_PATH"), + " ;;", + " *opcore.python.compile.v1*)", + ...compilerBranch, + " ;;", + " *) exit 9 ;;", + "esac", + "" + ].join("\n") + ); + chmodSync(shimPath, 0o755); + return shimPath; +} + +function writePassingPythonProtocolShim(repoRoot) { + const shimPath = writePythonProtocolShim(repoRoot, [protocolResponse("SHIM_PATH", [{ path: "pkg/app.py", status: "passed" }])]); + replaceShimPlaceholder(shimPath); + return shimPath; +} + +function projectContextInterpreterResponse(executable) { + return `printf '%s\\n' '${JSON.stringify({ + protocol: "opcore.python.project-context.interpreter.v1", + executable, + version: "3.12.13", + implementation: "CPython", + platform: "darwin", + architecture: "arm64", + abi: "cpython-312", + soabi: "cpython-312-darwin" + })}'`; +} + +function protocolResponse(executable, results) { + const payload = results === undefined + ? { protocol: "opcore.python.interpreter.v1", executable, version: "3.12.13" } + : { + protocol: "opcore.python.compile.v1", + interpreter: { executable, version: "3.12.13" }, + results + }; + return `printf '%s\\n' '${JSON.stringify(payload)}'`; +} + +function replaceShimPlaceholder(shimPath) { + writeFileSync(shimPath, readFileSync(shimPath, "utf8").replaceAll("SHIM_PATH", shimPath)); +} + function graphClient(overrides = {}) { return { status: (validationRequest) => availableStatus(validationRequest.graph.mode, validationRequest.repo), diff --git a/tests/validation-runner.test.mjs b/tests/validation-runner.test.mjs index 6b77479..62d2a33 100644 --- a/tests/validation-runner.test.mjs +++ b/tests/validation-runner.test.mjs @@ -1,6 +1,6 @@ import { describe, it } from "node:test"; import assert from "node:assert/strict"; -import { createValidationRunner } from "../packages/validation/dist/index.js"; +import { createValidationGraphQuerySession, createValidationRunner } from "../packages/validation/dist/index.js"; describe("validation runner", () => { it("returns invalid_payload for malformed requests without throwing validator errors", async () => { @@ -184,6 +184,35 @@ describe("validation runner", () => { assert.equal(warning.ok, true); }); + it("maps fine-grained check outcomes and sorts ranged diagnostics deterministically", async () => { + const findings = await runner([ + check("python.syntax", { + run: () => ({ + outcome: "findings", + diagnostics: [ + { category: "syntax", severity: "error", code: "B", message: "later", path: "pkg/app.py", line: 4, column: 2 }, + { category: "syntax", severity: "error", code: "A", message: "earlier", path: "pkg/app.py", line: 2, column: 8 } + ] + }) + }) + ]).runValidation(request({ checks: ["python.syntax"] })); + const timedOut = await runner([ + check("python.syntax", { + run: () => ({ + outcome: "timeout", + failureMessage: "compiler timed out", + diagnostics: [] + }) + }) + ]).runValidation(request({ checks: ["python.syntax"] })); + + assert.equal(findings.status, "policy_failure"); + assert.equal(findings.manifest.runs[0].outcome, "findings"); + assert.deepEqual(findings.diagnostics.map((entry) => entry.line), [2, 4]); + assert.equal(timedOut.status, "infrastructure_failure"); + assert.equal(timedOut.manifest.runs[0].outcome, "timeout"); + }); + it("stops running checks after the first policy failure when fail-fast is enabled", async () => { const observed = []; const result = await createValidationRunner({ @@ -299,6 +328,48 @@ describe("validation runner", () => { assert.equal(result.manifest.runs[0].diagnosticCount, 1); }); + it("preserves Python capability runs in results, completion events, and introduced after-state", async () => { + const events = []; + const current = await createValidationRunner({ + workspace: testWorkspace(), + onCheckComplete: (event) => events.push(event), + checks: [check("python.types", { run: async () => ({ diagnostics: [], pythonCapabilityRuns: [pythonCapabilityRun("a")] }) })] + }).runValidation(request({ checks: ["python.types"] })); + assert.equal(current.pythonCapabilityRuns[0].afterStateManifestFingerprint, `sha256:${"a".repeat(64)}`); + assert.deepEqual(events[0].pythonCapabilityRuns, current.pythonCapabilityRuns); + + const introduced = await runner([check("python.types", { + run: async (context) => ({ + diagnostics: [], + pythonCapabilityRuns: [pythonCapabilityRun(context.fileView.hasOverlay("src/index.ts") ? "b" : "a")] + }) + })]).runValidation(request({ + checks: ["python.types"], + reportMode: "introduced", + overlays: [{ path: "src/index.ts", action: "write", content: "export const value = 'introduced';" }] + })); + assert.equal(introduced.pythonCapabilityRuns.length, 1); + assert.equal(introduced.pythonCapabilityRuns[0].afterStateManifestFingerprint, `sha256:${"b".repeat(64)}`); + + const baselineUnavailable = await runner([check("python.types", { + run: async (context) => context.fileView.hasOverlay("src/index.ts") + ? { diagnostics: [], pythonCapabilityRuns: [pythonCapabilityRun("b")] } + : { + outcome: "tool_unavailable", + failureMessage: "baseline mypy unavailable", + diagnostics: [], + pythonCapabilityRuns: [unsupportedPythonCapabilityRun("a")] + } + })]).runValidation(request({ + checks: ["python.types"], + reportMode: "introduced", + overlays: [{ path: "src/index.ts", action: "write", content: "export const value = 'introduced';" }] + })); + assert.equal(baselineUnavailable.status, "unsupported_request"); + assert.equal(baselineUnavailable.pythonCapabilityRuns[0].status, "passed"); + assert.equal(baselineUnavailable.pythonCapabilityRuns[0].afterStateManifestFingerprint, `sha256:${"b".repeat(64)}`); + }); + it("fail-fast in introduced report mode stops only after an introduced policy failure", async () => { const observed = []; const events = []; @@ -732,6 +803,159 @@ describe("validation runner", () => { assert.equal(required.graphStatus.state, "schema_mismatch"); assert.equal(required.failure.cause, "schema_mismatch"); }); + + it("builds and disposes one exact graph session per introduced file-view state and shares it across checks", async () => { + const factoryStates = []; + const disposedStates = []; + const observations = []; + const exactFactory = async (args) => { + factoryStates.push(args.identity.state); + const session = await createValidationGraphQuerySession({ + ...args, + status: availableStatus(args.request.graph.mode, args.request.repo), + client: graphClient() + }); + return { ...session, dispose: () => disposedStates.push(args.identity.state) }; + }; + const checks = ["graph.first", "graph.second"].map((id) => check(id, { + requiresGraph: true, + run: (context) => { + observations.push(`${id}:${context.graph.identity.state}`); + return { diagnostics: [] }; + } + })); + + const result = await runner(checks, { graphSessionFactory: exactFactory, onCheckComplete: () => {} }).runValidation(request({ + checks: undefined, + reportMode: "introduced", + overlays: [{ path: "src/index.ts", action: "write", content: "export const value = 'after';" }] + })); + + assert.equal(result.status, "passed"); + assert.deepEqual(factoryStates, ["before", "after"]); + assert.deepEqual(observations, [ + "graph.first:before", + "graph.first:after", + "graph.second:before", + "graph.second:after" + ]); + assert.deepEqual(disposedStates, ["after", "before"]); + }); + + it("uses separate exact graph sessions for introduced file-view states without overlays", async () => { + const identities = []; + const result = await runner([ + check("graph.required", { requiresGraph: true }) + ], { + graphSessionFactory: async (args) => { + identities.push(args.identity); + return createValidationGraphQuerySession({ + ...args, + status: availableStatus(args.request.graph.mode, args.request.repo), + client: graphClient() + }); + } + }).runValidation(request({ + checks: ["graph.required"], + reportMode: "introduced" + })); + + assert.equal(result.status, "passed"); + assert.deepEqual(identities, [ + { kind: "exact", state: "before" }, + { kind: "exact", state: "after" } + ]); + }); + + it("fails closed for exact-state graph query errors even when persistent graph mode is optional", async () => { + let disposed = 0; + const result = await runner([ + check("graph.optional", { + graphUsage: "optional", + run: async (context) => { + await context.graph.facts({ kind: "nodes" }); + } + }), + check("syntax") + ], { + graphSessionFactory: async (args) => { + const session = await createValidationGraphQuerySession({ + ...args, + status: availableStatus("optional", args.request.repo), + client: graphClient({ + factQuery: () => ({ status: graphFailure("error", "query_failed", "optional") }) + }) + }); + return { ...session, dispose: () => { disposed += 1; } }; + } + }).runValidation(request({ + checks: undefined, + overlays: [{ path: "src/index.ts", action: "write", content: "export const value = 'after';" }] + })); + + assert.equal(result.status, "provider_failure"); + assert.equal(result.ok, false); + assert.equal(result.failure.category, "provider_failure"); + assert.equal(disposed, 1); + }); + + it("shares run resources across checks and disposes them once in reverse order", async () => { + const events = []; + let shared; + const result = await runner([ + check("resource.first", { + run: async (context) => { + shared = await context.resources.getOrCreate("shared", () => { + events.push("create:shared"); + return { dispose: () => events.push("dispose:shared") }; + }); + return { diagnostics: [] }; + } + }), + check("resource.second", { + run: async (context) => { + assert.equal(await context.resources.getOrCreate("shared", () => { + throw new Error("shared resource recreated"); + }), shared); + await context.resources.getOrCreate("second", () => { + events.push("create:second"); + return { dispose: () => events.push("dispose:second") }; + }); + return { diagnostics: [] }; + } + }) + ]).runValidation(request({ checks: ["resource.first", "resource.second"] })); + + assert.equal(result.status, "passed"); + assert.deepEqual(events, ["create:shared", "create:second", "dispose:second", "dispose:shared"]); + }); + + it("evicts rejected resource factories and disposes replacements after check failures", async () => { + let attempts = 0; + let disposals = 0; + const result = await runner([ + check("resource.failure", { + run: async (context) => { + await assert.rejects( + context.resources.getOrCreate("replaceable", () => { + attempts += 1; + throw new Error("first creation failed"); + }), + /first creation failed/ + ); + await context.resources.getOrCreate("replaceable", () => { + attempts += 1; + return { dispose: () => { disposals += 1; } }; + }); + throw new Error("check crashed after acquisition"); + } + }) + ]).runValidation(request({ checks: ["resource.failure"] })); + + assert.equal(result.status, "infrastructure_failure"); + assert.equal(attempts, 2); + assert.equal(disposals, 1); + }); }); function runner(checks, options = {}) { @@ -924,3 +1148,43 @@ function sequenceClock(values, iso) { isoNow: () => iso }; } + +function pythonCapabilityRun(fingerprintCharacter) { + return { + schemaId: "opcore.python.validation-capability-run", + schemaVersion: 1, + capability: "types", + checkId: "python.types", + projectKey: `sha256:${"1".repeat(64)}`, + contextFingerprint: `sha256:${"2".repeat(64)}`, + projectRoot: ".", + targets: ["pkg/app.py"], + selectedSourcePaths: ["pkg/app.py"], + selectedConfigPaths: [], + afterStateManifestFingerprint: `sha256:${fingerprintCharacter.repeat(64)}`, + authority: "mypy", + authoritySource: "explicit", + status: "passed", + tool: { + name: "mypy", + executable: "external:mypy", + argv: ["external:mypy", "--output=json", "pkg/app.py"], + cwd: ".", + source: "explicit_override", + version: "2.3.0" + }, + execution: { termination: "exited", exitCode: 0 }, + durationMs: 1, + diagnosticCount: 0, + errorCount: 0, + warningCount: 0, + noteCount: 0 + }; +} + +function unsupportedPythonCapabilityRun(fingerprintCharacter) { + const run = pythonCapabilityRun(fingerprintCharacter); + delete run.execution; + run.status = "tool_unavailable"; + return run; +} diff --git a/tests/validation-rust-package-scope.test.mjs b/tests/validation-rust-package-scope.test.mjs index d4d2bd6..2355d2d 100644 --- a/tests/validation-rust-package-scope.test.mjs +++ b/tests/validation-rust-package-scope.test.mjs @@ -106,7 +106,7 @@ function writePackageScopeFixture(temp) { } function packageManifest(name) { - return `[package]\nname = "${name}"\nversion = "0.2.0"\nedition = "2021"\n`; + return `[package]\nname = "${name}"\nversion = "0.2.1"\nedition = "2021"\n`; } function writeCargoStub(cargo, logPath) { diff --git a/tests/validation-rust.test.mjs b/tests/validation-rust.test.mjs index 4e69e73..da6c596 100644 --- a/tests/validation-rust.test.mjs +++ b/tests/validation-rust.test.mjs @@ -28,6 +28,7 @@ import { validationRustAdapterName } from "../packages/validation-rust/dist/index.js"; import { runTool } from "../packages/validation-rust/dist/process.js"; +import { materializeRustWorkspace } from "../packages/validation-rust/dist/materialize.js"; import { commitWorktreeFile, defaultCargoMetadata, @@ -477,7 +478,7 @@ const rustCheckIds = [ mkdirSync(join(temp, "src"), { recursive: true }); writeFileSync( join(temp, "Cargo.toml"), - ['[package]', 'name = "staged-dirty"', 'version = "0.2.0"', 'edition = "2021"', ""].join("\n") + ['[package]', 'name = "staged-dirty"', 'version = "0.2.1"', 'edition = "2021"', ""].join("\n") ); writeFileSync(join(temp, "src/lib.rs"), "mod other;\npub fn value() -> i32 { other::value() }\n"); writeFileSync(join(temp, "src/other.rs"), "pub fn value() -> i32 { 1 }\n"); @@ -511,7 +512,7 @@ const rustCheckIds = [ mkdirSync(join(temp, "src"), { recursive: true }); writeFileSync( join(temp, "Cargo.toml"), - ['[package]', 'name = "import-context"', 'version = "0.2.0"', 'edition = "2021"', ""].join("\n") + ['[package]', 'name = "import-context"', 'version = "0.2.1"', 'edition = "2021"', ""].join("\n") ); writeFileSync(join(temp, "src/lib.rs"), "pub mod store;\n"); writeFileSync(join(temp, "src/store.rs"), "pub fn value() -> i32 { 1 }\n"); @@ -1525,6 +1526,83 @@ const rustCheckIds = [ } }); + it("shares one materialized workspace across Rust checks and removes it after validation", async () => { + const temp = mkdtempSync(join(tmpdir(), "opcore-validation-rust-shared-workspace-")); + try { + const repoRoot = join(temp, "repo"); + mkdirSync(join(repoRoot, "crates/app/src"), { recursive: true }); + writeFileSync(join(repoRoot, "Cargo.toml"), rustCrate()["Cargo.toml"]); + writeFileSync(join(repoRoot, "crates/app/Cargo.toml"), rustCrate()["crates/app/Cargo.toml"]); + writeFileSync(join(repoRoot, "crates/app/src/lib.rs"), rustCrate()["crates/app/src/lib.rs"]); + const logPath = join(temp, "cargo.log"); + const { env } = writeFakeRustToolchain(join(temp, "bin"), { + cargo: { + logPath, + logCwd: true + } + }); + const validationRunner = createValidationRunner({ + workspace: createNodeValidationWorkspace({ repoRoot }), + checks: createRustValidationChecks({ env }) + }); + + const result = await validationRunner.runValidation( + request({ + repo: { repoRoot }, + checks: [ + RUST_CARGO_CHECK_ID, + RUST_CLIPPY_CHECK_ID, + RUST_RUSTDOC_CHECK_ID, + RUST_IMPORT_GRAPH_CHECK_ID, + RUST_UNUSED_DEPS_CHECK_ID + ], + scope: { + kind: "files", + files: ["Cargo.toml", "crates/app/Cargo.toml", "crates/app/src/lib.rs"] + } + }) + ); + + assert.equal(result.status, "passed", JSON.stringify(result, null, 2)); + const workspaceRoots = readFileSync(logPath, "utf8") + .split(/\r?\n/) + .map((line) => /\tCWD=([^\t]+)$/.exec(line)?.[1]) + .filter((path) => path?.includes("lattice-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.equal(existsSync(workspaceRoots[0]), false); + } finally { + rmSync(temp, { recursive: true, force: true }); + } + }); + + it("keeps distinct materialization environments isolated and cleans both workspaces", async () => { + const roots = []; + const checks = ["first", "second"].map((suffix) => ({ + id: `rust.test-materialization-${suffix}`, + owner: "validation-rust-test", + adapter: "rust", + defaultSeverity: "error", + supportedScopes: ["files"], + run: async (context) => { + const workspace = await materializeRustWorkspace(context, { + env: { ...process.env, OPCORE_MATERIALIZATION_VARIANT: suffix } + }); + roots.push(workspace.root); + return { diagnostics: [] }; + } + })); + + const result = await rustGraphRunner({ checks }).runValidation( + request({ checks: checks.map((check) => check.id) }) + ); + + assert.equal(result.status, "passed", JSON.stringify(result, null, 2)); + assert.equal(new Set(roots).size, 2); + assert.equal(roots.every((root) => existsSync(root) === false), true); + }); + it("keys cargo target dirs by generic overlay after-state without writing overlays to the real repo", async () => { const temp = mkdtempSync(join(tmpdir(), "opcore-validation-rust-target-overlay-")); try { diff --git a/tests/validation-typescript.test.mjs b/tests/validation-typescript.test.mjs index ce03504..21ff5cc 100644 --- a/tests/validation-typescript.test.mjs +++ b/tests/validation-typescript.test.mjs @@ -2843,7 +2843,7 @@ function graphHandshake(overrides = {}) { provider: "opcore-graph", graphSchemaVersion: 1, artifactName: "opcore-graph-core", - artifactVersion: "0.2.0", + artifactVersion: "0.2.1", targetPlatform: "test", supportedOperations: ["build", "update", "watch", "status", "query", "ping", "health", "shutdown"], nodeKinds: ["File", "Function", "Variable"], @@ -2868,7 +2868,7 @@ function graphHandshake(overrides = {}) { ], artifact: { artifactName: "opcore-graph-core", - artifactVersion: "0.2.0", + artifactVersion: "0.2.1", targetPlatform: "test", binaryPath: "dist/native/test/opcore-graph-core", checksumPath: "dist/native/test/opcore-graph-core.sha256",