From 333c1b814162dd6dfedddbdefc64847ff56a0e28 Mon Sep 17 00:00:00 2001 From: Tom Dupuis <60640908+tomdps@users.noreply.github.com> Date: Fri, 24 Jul 2026 05:58:33 +0200 Subject: [PATCH] feat(validation-python): add opt-in Ruff lint and format checks Implements #258. Adds `python.ruff-lint` and `python.ruff-format` as validation-python-owned warning checks with `defaultScopes: []`. They activate only through explicit selection or `.opcore/config` `validation.checks.defaults`; `checks.disabled` wins. Unrequested and disabled checks emit non-execution capability receipts, report skipped runs, never probe or invoke Ruff, and never degrade enforced coverage. `python.source-hygiene` is unchanged. Execution reuses the #246 canonical Python project context, the #209 graph import closure, and a shared #245-style exact after-state workspace primitive (`python-execution-workspace.ts`) with sanitized HOME/XDG/TMP/PATH runtime. Lint runs `ruff check --output-format=json --no-fix --no-cache --force-exclude` and consumes strict JSON; format runs bounded `ruff format --check --no-cache --force-exclude` batches and refines exit-1 batches by file without parsing human prose. Target-applicable `.ruff.toml`, `ruff.toml`, and `[tool.ruff]` configuration plus its recursive `extend` closure require non-symlink realpath evidence and are materialized without mutating source, config, lockfiles, environments, or caches. Extends `PythonValidationCapabilityRun` into a portable Ruff receipt variant with project key, context fingerprint, after-state manifest fingerprint, source/config paths, cwd, portable executable/argv, tool provenance, termination, duration, and diagnostic counts, plus runtime and JSON-schema state-machine invariants that reject contradictory or incomplete failure-state execution evidence. Status, scan, doctor, metrics, and ASP reporting are activation-aware: missing Ruff degrades only while a Ruff check is active, the `python.ruff_lint_findings`/`python.ruff_format_findings` signals derive only from executed receipts, and ASP executes and reports only selected check ids. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/ci.yml | 5 + AGENTS.md | 5 +- CLAUDE.md | 5 +- docs/architecture/runtime-cli-ard.md | 6 + docs/planning/opcore-alpha-roadmap.md | 2 +- packages/asp-provider/src/mapping.ts | 154 +- .../src/validation-composition.ts | 37 +- .../schemas/opcore-contracts.schema.json | 1047 +++++++- packages/contracts/src/index.ts | 422 +++- packages/fixtures/src/index.ts | 2 + packages/fixtures/validation-python/README.md | 2 +- .../mypy-authority/src/acme/__init__.py | 1 - .../src/acme/__init__.py.fixture | 0 .../src/acme/{app.py => app.py.fixture} | 0 ...{mypy_plugin.py => mypy_plugin.py.fixture} | 0 ...n_support.py => plugin_support.py.fixture} | 0 .../src/acme/{widget.py => widget.py.fixture} | 0 .../{__init__.pyi => __init__.pyi.fixture} | 0 .../src/advanced/validation-composition.ts | 17 +- packages/opcore/src/doctor.ts | 4 +- packages/opcore/src/json-output.ts | 8 + packages/opcore/src/reporting.ts | 122 + packages/opcore/src/status-state.ts | 30 +- packages/opcore/src/status-validation.ts | 50 +- packages/opcore/src/validation-composition.ts | 20 +- packages/validation-policy/src/factory.ts | 31 +- packages/validation-python/src/check-ids.ts | 4 + .../src/environment-resolution.ts | 24 +- packages/validation-python/src/index.ts | 41 +- .../validation-python/src/node-shims.d.ts | 1 + packages/validation-python/src/process.ts | 12 +- .../validation-python/src/project-context.ts | 38 +- .../validation-python/src/project-groups.ts | 24 + .../src/python-check-result.ts | 12 + .../src/python-context-result.ts | 19 + .../src/python-execution-workspace.ts | 248 ++ .../src/ruff-capability-run.ts | 170 ++ .../src/ruff-check-definition.ts | 63 + .../src/ruff-check-shared.ts | 327 +++ .../src/ruff-config-paths.ts | 86 + .../src/ruff-config-proof.ts | 80 + .../src/ruff-execution-workspace.ts | 289 +++ .../validation-python/src/ruff-execution.ts | 239 ++ .../src/ruff-format-check.ts | 110 + .../src/ruff-format-refinement.ts | 178 ++ .../src/ruff-invocation-failure.ts | 59 + .../validation-python/src/ruff-lint-check.ts | 146 ++ .../validation-python/src/ruff-lint-output.ts | 131 + .../src/smol-toml-shims.d.ts | 1 + .../validation-python/src/source-closure.ts | 24 +- .../validation-python/src/source-files.ts | 63 +- .../validation-python/src/source-types.ts | 5 +- .../validation-python/src/static-config.ts | 344 ++- .../validation-python/src/syntax-check.ts | 32 +- packages/validation-python/src/toolchain.ts | 94 +- .../src/type-capability-run.ts | 121 +- packages/validation-python/src/type-check.ts | 29 +- packages/validation-python/src/type-result.ts | 6 +- .../src/type-runner-runtime.ts | 2 +- .../src/type-runner-types.ts | 4 +- packages/validation/src/aggregation.ts | 15 +- packages/validation/src/registry.ts | 20 + packages/validation/src/runner.ts | 200 +- scripts/check-python-mypy-authority.mjs | 6 +- scripts/check-python-pyright-authority.mjs | 4 +- scripts/generate-cutover-receipt.mjs | 6 +- tests/asp-provider.test.mjs | 312 ++- tests/conformance.test.mjs | 2 + tests/contracts.test.mjs | 291 +++ tests/fixtures/package-packlists.json | 48 + tests/installed-bins.test.mjs | 284 ++- tests/opcore-facade.test.mjs | 86 +- tests/opcore-metrics.test.mjs | 160 ++ tests/schema-contracts.test.mjs | 262 ++ tests/validation-cli.test.mjs | 22 +- tests/validation-python.test.mjs | 2178 ++++++++++++++++- tests/validation-runner.test.mjs | 128 + 77 files changed, 8662 insertions(+), 358 deletions(-) delete mode 100644 packages/fixtures/validation-python/mypy-authority/src/acme/__init__.py create mode 100644 packages/fixtures/validation-python/mypy-authority/src/acme/__init__.py.fixture rename packages/fixtures/validation-python/mypy-authority/src/acme/{app.py => app.py.fixture} (100%) rename packages/fixtures/validation-python/mypy-authority/src/acme/{mypy_plugin.py => mypy_plugin.py.fixture} (100%) rename packages/fixtures/validation-python/mypy-authority/src/acme/{plugin_support.py => plugin_support.py.fixture} (100%) rename packages/fixtures/validation-python/mypy-authority/src/acme/{widget.py => widget.py.fixture} (100%) rename packages/fixtures/validation-python/mypy-authority/stubs/external/{__init__.pyi => __init__.pyi.fixture} (100%) create mode 100644 packages/validation-python/src/project-groups.ts create mode 100644 packages/validation-python/src/python-check-result.ts create mode 100644 packages/validation-python/src/python-context-result.ts create mode 100644 packages/validation-python/src/python-execution-workspace.ts create mode 100644 packages/validation-python/src/ruff-capability-run.ts create mode 100644 packages/validation-python/src/ruff-check-definition.ts create mode 100644 packages/validation-python/src/ruff-check-shared.ts create mode 100644 packages/validation-python/src/ruff-config-paths.ts create mode 100644 packages/validation-python/src/ruff-config-proof.ts create mode 100644 packages/validation-python/src/ruff-execution-workspace.ts create mode 100644 packages/validation-python/src/ruff-execution.ts create mode 100644 packages/validation-python/src/ruff-format-check.ts create mode 100644 packages/validation-python/src/ruff-format-refinement.ts create mode 100644 packages/validation-python/src/ruff-invocation-failure.ts create mode 100644 packages/validation-python/src/ruff-lint-check.ts create mode 100644 packages/validation-python/src/ruff-lint-output.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2a6a55b..c5a0e53 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -59,6 +59,11 @@ jobs: with: python-version: "3.12" + - name: Provision pinned Ruff proof artifact + run: | + python -m pip install --disable-pip-version-check ruff==0.6.9 + test "$(ruff --version)" = "ruff 0.6.9" + - name: Setup Rust uses: dtolnay/rust-toolchain@stable with: diff --git a/AGENTS.md b/AGENTS.md index 0e75742..9bedb52 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -91,6 +91,7 @@ Opcore is the code-intelligence and robustness monorepo for graph context, edit - #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. +- #258 keeps `python.ruff-lint` and `python.ruff-format` separate from `python.source-hygiene` and opt-in through explicit selection or `.opcore/config` defaults. They execute the #246-selected Ruff over a temporary #245 after-state workspace with fixes, writes, and caches disabled; lint consumes JSON and format uses bounded exit-code refinement. Closest target-applicable `.ruff.toml`, `ruff.toml`, or `[tool.ruff]` configuration searches through the repository root across nested Python project boundaries, partitions project execution, requires non-symlink realpath evidence for every selected or recursively extended config, and materializes only that config closure. Python types and Ruff share the `packages/validation-python/src/python-execution-workspace.ts` primitive and sanitized HOME/XDG/TMP/PATH runtime for after-state fingerprinting, materialization, execution isolation, and cleanup while capability code selects its own support files. Ruff receipts use the shared `afterStateManifestFingerprint` field and portable executable/argv locators. Missing Ruff degrades status only while a Ruff check is active, and metrics require executed capability receipts - WHY: optional source tooling must never be probed, invoked, counted, or reported as enforced when policy did not select it, target-local configuration must not leak across files, host state must not affect results, and parallel materializers can make tool inputs diverge from receipts. - #209 makes Rust graph-core the sole parser/resolver for Python repo imports. `@the-open-engine/opcore-graph` materializes supplied `.py`/`.pyi` after-state files only in an isolated temporary repo and returns canonical directed `IMPORTS_FROM` file edges; validation-python owns only the structural analyzer contract, visible-file enumeration, cached target/transitive closure, and edge consumption. Opcore, advanced validation, validation-policy, and ASP inject the graph adapter; missing/failed/malformed analysis is an infrastructure failure, never empty success - WHY: a second TypeScript import grammar/resolver diverges on multiline syntax, overlays, packages, stubs, namespaces, and src layouts. - #197 makes hypothetical graph evaluation exact-state: validation creates one `ValidationFileView` per before/after state and owns one disposable graph session for that view; graph materializes the complete visible TS/TSX/JS/JSX, Python `.py`/`.pyi`, and Rust `.rs` universe into a bounded isolated root, builds graph-core once, shares the immutable session across checks, and removes it on every exit. Introduced mode must use distinct before/after snapshots, ASP listings must preserve host truncation, and exact-state construction/query failure is non-pass even when persistent graph mode is optional - WHY: a persistent target-repo graph or incomplete listing cannot describe hypothetical file contents and must never produce a false clean pre-write result. - #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. @@ -214,8 +215,6 @@ Opcore is the code-intelligence and robustness monorepo for graph context, edit ## Commands - Setup and wrappers: `npm run setup`, `npm run setup:tools`, `source scripts/dev-env.sh`, `npm run ace:install`, `npm run ace:sync`, `npm run ace:validate`. -- Core proof: `npm run ci`, targeted `node --test tests/...`, `npm run rust:check`, `npm run ci:local` or `npm run verify`. -- Release proof: `npm run graph:artifact`, `npm run descriptor:artifact`, `npm run asp-provider:manifest`, `npm run graph-release:check`, `npm run release-receipt:check`, `npm run cutover:check`, `npm run asp-dogfood:check`, `npm run pack:check`, `npm run release:hygiene`. -- Current-tool guardrails: `npm run current-tools:validate-changed`, `npm run current-tools:validate-all`, `npm run current-tools:validate-rust-graph`. +- Proof: core `npm run ci`, targeted `node --test tests/...`, `npm run rust:check`, `npm run ci:local` or `npm run verify`; release `npm run graph:artifact`, `npm run descriptor:artifact`, `npm run asp-provider:manifest`, `npm run graph-release:check`, `npm run release-receipt:check`, `npm run cutover:check`, `npm run asp-dogfood:check`, `npm run pack:check`, `npm run release:hygiene`; retained guardrails `npm run current-tools:validate-changed`, `npm run current-tools:validate-all`, `npm run current-tools:validate-rust-graph`. - Public scan/readiness: `opcore --repo . --json`, `opcore status --repo . --json`, `opcore --version --json`, `opcore doctor --repo . --json`, `opcore measure --repo . --json`, `opcore try --json`. - Public setup/check/provider: `opcore install --repo . --json`, `opcore install --repo . --yes --json`, `opcore uninstall --repo . --yes --json`, `opcore check --changed --json`, `opcore-asp-provider --stdio`. diff --git a/CLAUDE.md b/CLAUDE.md index 0e75742..9bedb52 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -91,6 +91,7 @@ Opcore is the code-intelligence and robustness monorepo for graph context, edit - #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. +- #258 keeps `python.ruff-lint` and `python.ruff-format` separate from `python.source-hygiene` and opt-in through explicit selection or `.opcore/config` defaults. They execute the #246-selected Ruff over a temporary #245 after-state workspace with fixes, writes, and caches disabled; lint consumes JSON and format uses bounded exit-code refinement. Closest target-applicable `.ruff.toml`, `ruff.toml`, or `[tool.ruff]` configuration searches through the repository root across nested Python project boundaries, partitions project execution, requires non-symlink realpath evidence for every selected or recursively extended config, and materializes only that config closure. Python types and Ruff share the `packages/validation-python/src/python-execution-workspace.ts` primitive and sanitized HOME/XDG/TMP/PATH runtime for after-state fingerprinting, materialization, execution isolation, and cleanup while capability code selects its own support files. Ruff receipts use the shared `afterStateManifestFingerprint` field and portable executable/argv locators. Missing Ruff degrades status only while a Ruff check is active, and metrics require executed capability receipts - WHY: optional source tooling must never be probed, invoked, counted, or reported as enforced when policy did not select it, target-local configuration must not leak across files, host state must not affect results, and parallel materializers can make tool inputs diverge from receipts. - #209 makes Rust graph-core the sole parser/resolver for Python repo imports. `@the-open-engine/opcore-graph` materializes supplied `.py`/`.pyi` after-state files only in an isolated temporary repo and returns canonical directed `IMPORTS_FROM` file edges; validation-python owns only the structural analyzer contract, visible-file enumeration, cached target/transitive closure, and edge consumption. Opcore, advanced validation, validation-policy, and ASP inject the graph adapter; missing/failed/malformed analysis is an infrastructure failure, never empty success - WHY: a second TypeScript import grammar/resolver diverges on multiline syntax, overlays, packages, stubs, namespaces, and src layouts. - #197 makes hypothetical graph evaluation exact-state: validation creates one `ValidationFileView` per before/after state and owns one disposable graph session for that view; graph materializes the complete visible TS/TSX/JS/JSX, Python `.py`/`.pyi`, and Rust `.rs` universe into a bounded isolated root, builds graph-core once, shares the immutable session across checks, and removes it on every exit. Introduced mode must use distinct before/after snapshots, ASP listings must preserve host truncation, and exact-state construction/query failure is non-pass even when persistent graph mode is optional - WHY: a persistent target-repo graph or incomplete listing cannot describe hypothetical file contents and must never produce a false clean pre-write result. - #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. @@ -214,8 +215,6 @@ Opcore is the code-intelligence and robustness monorepo for graph context, edit ## Commands - Setup and wrappers: `npm run setup`, `npm run setup:tools`, `source scripts/dev-env.sh`, `npm run ace:install`, `npm run ace:sync`, `npm run ace:validate`. -- Core proof: `npm run ci`, targeted `node --test tests/...`, `npm run rust:check`, `npm run ci:local` or `npm run verify`. -- Release proof: `npm run graph:artifact`, `npm run descriptor:artifact`, `npm run asp-provider:manifest`, `npm run graph-release:check`, `npm run release-receipt:check`, `npm run cutover:check`, `npm run asp-dogfood:check`, `npm run pack:check`, `npm run release:hygiene`. -- Current-tool guardrails: `npm run current-tools:validate-changed`, `npm run current-tools:validate-all`, `npm run current-tools:validate-rust-graph`. +- Proof: core `npm run ci`, targeted `node --test tests/...`, `npm run rust:check`, `npm run ci:local` or `npm run verify`; release `npm run graph:artifact`, `npm run descriptor:artifact`, `npm run asp-provider:manifest`, `npm run graph-release:check`, `npm run release-receipt:check`, `npm run cutover:check`, `npm run asp-dogfood:check`, `npm run pack:check`, `npm run release:hygiene`; retained guardrails `npm run current-tools:validate-changed`, `npm run current-tools:validate-all`, `npm run current-tools:validate-rust-graph`. - Public scan/readiness: `opcore --repo . --json`, `opcore status --repo . --json`, `opcore --version --json`, `opcore doctor --repo . --json`, `opcore measure --repo . --json`, `opcore try --json`. - Public setup/check/provider: `opcore install --repo . --json`, `opcore install --repo . --yes --json`, `opcore uninstall --repo . --yes --json`, `opcore check --changed --json`, `opcore-asp-provider --stdio`. diff --git a/docs/architecture/runtime-cli-ard.md b/docs/architecture/runtime-cli-ard.md index 0c534c3..55a8cfa 100644 --- a/docs/architecture/runtime-cli-ard.md +++ b/docs/architecture/runtime-cli-ard.md @@ -130,6 +130,12 @@ Rust checks read candidate files through `ValidationCheckContext.fileView`. Chec 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. +## Opt-in Ruff Validation + +#258 adds `python.ruff-lint` and `python.ruff-format` as validation-python-owned warning checks with no default scopes. Explicit selection or `.opcore/config` validation defaults activate them; disabled policy wins. Unselected and disabled checks emit non-execution receipts and do not probe Ruff or degrade status. `python.source-hygiene` remains an independent Opcore policy check. + +Both checks reuse the canonical Python project context, graph-derived source closure, and temporary after-state workspace. Target-applicable Ruff configuration discovery continues through the repository root across nested Python project boundaries. The selected config and every recursive `extend` file require non-symlink realpath evidence before their overlay-aware contents are read or materialized. Ruff configuration, target version, and overlays are materialized without mutating source, configuration, lockfiles, environments, or caches. Lint runs `ruff check --output-format=json --no-fix --no-cache --force-exclude`; format runs bounded `ruff format --check --no-cache --force-exclude` batches and refines exit-1 batches by file without parsing human prose. Every execution carries portable project, context, `afterStateManifestFingerprint`, path, executable/argv, tool, termination, duration, and diagnostic-count evidence; a deleted-only after-state scope emits an explicit non-execution `not_applicable` receipt. Missing tools, invalid selected configuration, malformed output, unsupported targets, timeouts, signals, spawn failures, output overflow, and unknown exits remain explicit non-pass outcomes. + ## 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/planning/opcore-alpha-roadmap.md b/docs/planning/opcore-alpha-roadmap.md index 8f17f8e..f689762 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 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. +- Python `.py`/`.pyi` graph-backed structure, untested modules, dead exports, syntax, source-hygiene, import graph, relevant-test signals, and configured-authority `python.types`; exactly one mypy or Pyright authority runs per project, while absent/conflicting/unavailable authority remains degraded. Opt-in `python.ruff-lint`/`python.ruff-format` add receipt-backed findings and activation-aware degraded status. Python readiness and parity remain gated on later release evidence. - Unsupported language census with no fake findings. Do not ship headline claims for generic complexity, TS complexity, Python code analysis, Go/Java analysis, security, cross-repo percentiles, automatic fixes, or old-tool replacement. diff --git a/packages/asp-provider/src/mapping.ts b/packages/asp-provider/src/mapping.ts index fd28597..e22255c 100644 --- a/packages/asp-provider/src/mapping.ts +++ b/packages/asp-provider/src/mapping.ts @@ -35,6 +35,7 @@ import { } from "./protocol.js"; import { createAspHostValidationWorkspace } from "./workspace.js"; import { + aspProviderValidationChecks, createAspProviderValidationRunner, defaultAspProviderValidationCheckIds, defaultAspProviderValidationManifest, @@ -97,12 +98,12 @@ export async function evaluateChangeset( const timing = createAssessmentTiming(); const params = normalizeEvaluateParams(rawParams); const digest = params.changesetDigest ?? computeChangesetDigest(params.changeset); - const selectedIds = normalizeRequestedChecks(params.checks); - const selectedChecks = selectedValidationChecks(selectedIds); const changedPaths = changedScopePaths(params.changeset.changes); const workspace = timing.timeSync("host_workspace_binding", () => createAspHostValidationWorkspace(peer, initialize, initialized, params.changeset) ); + let selectedIds: readonly string[] = []; + let policyChecks: Awaited> = []; let validationResult: ValidationResult; let request: ValidationRequest; const mappingDegradations = comparisonDegradations(params.comparison ?? "introduced"); @@ -110,6 +111,9 @@ export async function evaluateChangeset( try { request = await timing.timeAsync("changeset_overlay_mapping", async () => { const overlays = await overlaysForChanges(params.changeset.changes, workspace); + policyChecks = await aspProviderValidationChecks(workspace.workspace, workspace.pythonWorkspace, overlays); + selectedIds = normalizeRequestedChecks(params.checks, policyChecks); + const selectedChecks = selectedValidationChecks(policyChecks, selectedIds); const graphMode = graphModeFor(params.callSite, selectedChecks); return { requestId: digest, @@ -124,12 +128,12 @@ export async function evaluateChangeset( mode: graphMode, provider: "opcore-graph" }, - checks: selectedIds, + checks: params.checks === undefined ? undefined : selectedIds, overlays }; }); validationResult = await timing.timeAsync("validation", () => - createAspProviderValidationRunner(workspace.workspace, workspace.pythonWorkspace).runValidation(request) + createAspProviderValidationRunner(workspace.workspace, policyChecks).runValidation(request) ); } catch (error) { const validationFailure = errorAssessment({ @@ -190,10 +194,17 @@ function normalizeEvaluateParams(value: unknown): EvaluateChangesetParams { }; } -function normalizeRequestedChecks(checks: readonly string[] | undefined): readonly string[] { - if (checks === undefined) return defaultAspProviderValidationCheckIds; +function normalizeRequestedChecks( + checks: readonly string[] | undefined, + policyChecks: readonly { id: string; supportedScopes: readonly string[]; defaultScopes?: readonly string[] }[] +): readonly string[] { + if (checks === undefined) { + return policyChecks + .filter((check) => (check.defaultScopes ?? check.supportedScopes).includes("files")) + .map((check) => check.id); + } const selected: string[] = []; - const known = new Set(defaultAspProviderValidationCheckIds); + const known = new Set(policyChecks.map((check) => check.id)); for (const check of checks) { if (typeof check !== "string" || check.trim().length === 0) throw new Error("Requested checks must be non-empty strings"); const normalized = check.trim(); @@ -294,7 +305,7 @@ function assessmentFromValidationResult(args: { diagnostics: args.validationResult.diagnostics.map((diagnostic) => mapDiagnostic(diagnostic, args.changesetDigest, args.requestedComparison) ), - evidence: validationEvidence(args.validationResult), + evidence: validationEvidence(args.validationResult, args.selectedIds), coverage: { requested: coveragePart(args.changedPaths, args.selectedIds, args.requestedComparison), covered: coveragePart(args.changedPaths, coveredRules(args.validationResult, args.selectedIds), supportedComparison), @@ -401,6 +412,9 @@ function diagnosticRange(diagnostic: ValidationDiagnostic): JsonObject | undefin } function checkIdForDiagnostic(diagnostic: ValidationDiagnostic): string { + const code = diagnostic.code ?? ""; + if (code.startsWith("PY_RUFF_LINT_")) return "python.ruff-lint"; + if (code.startsWith("PY_RUFF_FORMAT_")) return "python.ruff-format"; if (diagnostic.category === "syntax") return diagnostic.path?.endsWith(".py") || diagnostic.path?.endsWith(".pyi") ? "python.syntax" : "typescript.syntax"; @@ -421,6 +435,8 @@ function policyCheckIdForDiagnostic(diagnostic: ValidationDiagnostic): string { const code = diagnostic.code ?? ""; if (code.startsWith("TS_FUNCTION_")) return "typescript.function-metrics"; if (code.startsWith("TS_FILE_")) return "typescript.file-length"; + if (code.startsWith("PY_RUFF_LINT_")) return "python.ruff-lint"; + if (code.startsWith("PY_RUFF_FORMAT_")) return "python.ruff-format"; if (code.startsWith("PY_SOURCE_")) return "python.source-hygiene"; if (code.startsWith("RUST_FILE_")) return "rust.file-length"; if (code.startsWith("RUST_FUNCTION_")) return "rust.function-metrics"; @@ -440,13 +456,14 @@ function validationCoverageDegradations(result: ValidationResult, selectedIds: r const degraded: ProviderCoverageDegradation[] = []; const unsupported: ProviderCoverageDegradation[] = []; for (const context of result.pythonProjectContexts ?? []) { - if (context.outcome === "resolved") continue; + const relevantReasons = context.reasons.filter((reason) => pythonContextReasonIsRelevant(reason.tool, selectedIds)); + if (relevantReasons.length === 0) 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("; ") || + detail: relevantReasons.map((item) => `${item.code}: ${item.message}`).join("; ") || `Python project context ${context.outcome}` }; degraded.push(entry); @@ -461,7 +478,11 @@ function validationCoverageDegradations(result: ValidationResult, selectedIds: r }; degraded.push(entry); } + // WHY: opt-in checks the host did not select record inactive runs; they are not coverage gaps + // for the requested rules. + const selected = new Set(selectedIds); for (const run of result.manifest?.runs ?? []) { + if (!selected.has(run.checkId)) continue; if (run.status === "passed" || run.status === "policy_failure") continue; const reason = run.status === "unsupported_request" ? "unsupported" : run.status === "skipped" ? "unavailable" : "error"; const entry = { @@ -498,6 +519,21 @@ function validationCoverageDegradations(result: ValidationResult, selectedIds: r return { degraded, unsupported }; } +function pythonContextReasonIsRelevant(tool: string | undefined, selectedIds: readonly string[]): boolean { + const selected = new Set(selectedIds); + if (tool === "ruff") { + return selected.has("python.ruff-lint") || selected.has("python.ruff-format"); + } + if (tool === "mypy" || tool === "pyright") return selected.has("python.types"); + if (tool === "pytest") return selected.has("python.relevant-tests"); + if (tool === "python") { + return selected.has("python.syntax") || + selected.has("python.types") || + selected.has("python.relevant-tests"); + } + return selectedIds.some((checkId) => checkId.startsWith("python.")); +} + function graphStatusMessage(status: GraphProviderStatus): string { if ("failure" in status) return status.failure.message; return status.message ?? `Graph provider is not available: ${status.state}`; @@ -547,7 +583,7 @@ function coveredRules(result: ValidationResult, selectedIds: readonly string[]): return selectedIds.filter((checkId) => !skipped.has(checkId) && !failed.has(checkId)); } -function validationEvidence(result: ValidationResult): JsonObject[] { +function validationEvidence(result: ValidationResult, selectedIds: readonly string[]): JsonObject[] { const data: JsonObject = { validationStatus: result.status, checkCount: result.manifest?.checks.length ?? 0, @@ -608,40 +644,86 @@ function validationEvidence(result: ValidationResult): JsonObject[] { }); } for (const run of result.pythonCapabilityRuns ?? []) { + if (!selectedIds.includes(run.checkId)) continue; validatePythonValidationCapabilityRun(run); + if (run.capability === "types") { + evidence.push({ + kind: "python_validation_capability_run", + message: `Python types 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 } }) + } + }); + continue; + } evidence.push({ kind: "python_validation_capability_run", - message: `Python ${run.capability} capability evidence for ${run.projectRoot}.`, + message: `Python ${run.capability} capability evidence for ${run.checkId}.`, 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, + capability: run.capability, + state: run.state, + ...(run.projectKey === undefined ? {} : { projectKey: run.projectKey }), + ...(run.contextFingerprint === undefined ? {} : { contextFingerprint: run.contextFingerprint }), + ...(run.afterStateManifestFingerprint === undefined + ? {} + : { afterStateManifestFingerprint: run.afterStateManifestFingerprint }), + ...(run.sourcePaths === undefined ? {} : { sourcePaths: [...run.sourcePaths] }), + ...(run.configPaths === undefined ? {} : { configPaths: [...run.configPaths] }), + ...(run.executable === undefined ? {} : { executable: run.executable }), + ...(run.command === undefined ? {} : { command: run.command }), + ...(run.argv === undefined ? {} : { argv: [...run.argv] }), + ...(run.cwd === undefined ? {} : { cwd: run.cwd }), + ...(run.configPath === undefined ? {} : { configPath: run.configPath }), + ...(run.toolVersion === undefined ? {} : { toolVersion: run.toolVersion }), + ...(run.toolSource === undefined ? {} : { toolSource: run.toolSource }), + ...(run.termination === undefined ? {} : { termination: run.termination }), + ...(run.exitCode === undefined ? {} : { exitCode: run.exitCode }), + ...(run.signal === undefined ? {} : { signal: run.signal }), + ...(run.invocations === undefined + ? {} + : { + invocations: run.invocations.map((invocation) => ({ + argv: [...invocation.argv], + termination: invocation.termination, + ...(invocation.exitCode === undefined ? {} : { exitCode: invocation.exitCode }), + ...(invocation.signal === undefined ? {} : { signal: invocation.signal }), + durationMs: invocation.durationMs + })) + }), 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 } }) + ...(run.failureMessage === undefined ? {} : { failureMessage: run.failureMessage }) } }); } diff --git a/packages/asp-provider/src/validation-composition.ts b/packages/asp-provider/src/validation-composition.ts index b9cc202..8030b4f 100644 --- a/packages/asp-provider/src/validation-composition.ts +++ b/packages/asp-provider/src/validation-composition.ts @@ -31,7 +31,8 @@ import { } from "@the-open-engine/opcore-validation"; import { createBuiltInValidationChecks, - parseOpcoreRepoConfig + parseOpcoreRepoConfig, + validationChecksForConfigPolicy } from "@the-open-engine/opcore-validation-policy"; import { graphProviderDetectChanges, @@ -52,16 +53,18 @@ export const defaultAspProviderValidationChecks = createBuiltInValidationChecks( export const defaultAspProviderValidationCheckIds = defaultAspProviderValidationChecks.map((check) => check.id); export const defaultAspProviderValidationManifest = createValidationCheckManifest(defaultAspProviderValidationChecks); -export function createAspProviderValidationRunner(workspace: ValidationWorkspace, pythonWorkspace: PythonProjectWorkspace): { +export function createAspProviderValidationRunner( + workspace: ValidationWorkspace, + checks: readonly ValidationCheckDefinition[] +): { runValidation(request: ValidationRequest): Promise; } { return { - async runValidation(request) { - const config = await readHostConfig(workspace, request); + runValidation(request) { const graphProviderClient = createAspValidationGraphProviderClient(); return createValidationRunner({ workspace, - checks: createBuiltInValidationChecks(config, { ...aspProviderPolicyOptions, pythonWorkspace }), + checks, graphProviderClient, graphSessionFactory: createStateAwareValidationGraphSessionFactory({ persistentClient: graphProviderClient, @@ -72,14 +75,28 @@ export function createAspProviderValidationRunner(workspace: ValidationWorkspace }; } -export function selectedValidationChecks(checkIds?: readonly string[]): readonly ValidationCheckDefinition[] { - if (checkIds === undefined) return defaultAspProviderValidationChecks; +export async function aspProviderValidationChecks( + workspace: ValidationWorkspace, + pythonWorkspace: PythonProjectWorkspace, + overlays: ValidationRequest["overlays"] +): Promise { + const config = await readHostConfig(workspace, overlays); + return validationChecksForConfigPolicy(config, { ...aspProviderPolicyOptions, pythonWorkspace }); +} + +export function selectedValidationChecks( + checks: readonly ValidationCheckDefinition[], + checkIds?: readonly string[] +): readonly ValidationCheckDefinition[] { + if (checkIds === undefined) { + return checks.filter((check) => (check.defaultScopes ?? check.supportedScopes).includes("files")); + } const requested = new Set(checkIds); - return defaultAspProviderValidationChecks.filter((check) => requested.has(check.id)); + return checks.filter((check) => requested.has(check.id)); } -async function readHostConfig(workspace: ValidationWorkspace, request: ValidationRequest) { - const overlay = request.overlays.find((entry) => entry.path === ".opcore/config"); +async function readHostConfig(workspace: ValidationWorkspace, overlays: ValidationRequest["overlays"]) { + const overlay = 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"); diff --git a/packages/contracts/schemas/opcore-contracts.schema.json b/packages/contracts/schemas/opcore-contracts.schema.json index c32a92c..232cd7d 100644 --- a/packages/contracts/schemas/opcore-contracts.schema.json +++ b/packages/contracts/schemas/opcore-contracts.schema.json @@ -2766,6 +2766,390 @@ } ] }, + "PythonRuffValidationCapabilityRun": { + "type": "object", + "additionalProperties": false, + "required": [ + "schemaId", "schemaVersion", "checkId", "capability", "state", "durationMs", "diagnosticCount" + ], + "properties": { + "schemaId": { "const": "opcore.python.validation-capability-run" }, + "schemaVersion": { "const": 1 }, + "checkId": { "enum": ["python.ruff-lint", "python.ruff-format"] }, + "capability": { "enum": ["ruff_lint", "ruff_format"] }, + "state": { + "enum": [ + "passed", "findings", "tool_unavailable", "invalid_config", "timeout", + "unsupported_target", "tool_failure", "not_applicable", "disabled" + ] + }, + "projectKey": { "type": "string", "pattern": "^sha256:[a-f0-9]{64}$" }, + "contextFingerprint": { "type": "string", "pattern": "^sha256:[a-f0-9]{64}$" }, + "afterStateManifestFingerprint": { "type": "string", "pattern": "^sha256:[a-f0-9]{64}$" }, + "sourcePaths": { + "type": "array", "uniqueItems": true, "items": { "$ref": "#/$defs/RepoRelativePath" } + }, + "configPaths": { + "type": "array", "uniqueItems": true, "items": { "$ref": "#/$defs/RepoRelativePath" } + }, + "executable": { + "type": "string", + "pattern": "^(?:(?:repo|project):(?!.*(?:^|/)\\.\\.(?:/|$))[A-Za-z0-9._+@-]+(?:/[A-Za-z0-9._+@-]+)*|(?:path|external):[A-Za-z0-9_.+-]+)$" + }, + "command": { + "type": "string", + "minLength": 1, + "pattern": "^(?!(?:/|\\\\|[A-Za-z]:[\\\\/]))(?!.*[\\s(\\\"'=](?:/|\\\\|[A-Za-z]:[\\\\/]))(?!.*file://).+$" + }, + "argv": { + "type": "array", + "minItems": 1, + "items": { + "type": "string", + "minLength": 1, + "pattern": "^(?!(?:/|\\\\|[A-Za-z]:[\\\\/]))(?!.*[\\s(\\\"'=](?:/|\\\\|[A-Za-z]:[\\\\/]))(?!.*file://).+$" + } + }, + "cwd": { "$ref": "#/$defs/PythonProjectRoot" }, + "configPath": { "$ref": "#/$defs/RepoRelativePath" }, + "toolVersion": { "type": "string", "pattern": "^[0-9]+\\.[0-9][-+._A-Za-z0-9]*$" }, + "toolSource": { + "enum": ["explicit_override", "active_environment", "project_local_environment", "manager_environment", "path"] + }, + "termination": { "enum": ["exited", "timeout", "signal", "spawn_error", "overflow"] }, + "exitCode": { "type": "integer", "minimum": 0 }, + "signal": { "type": "string", "minLength": 1 }, + "invocations": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["argv", "termination", "durationMs"], + "properties": { + "argv": { + "type": "array", + "minItems": 1, + "items": { + "type": "string", + "minLength": 1, + "pattern": "^(?!(?:/|\\\\|[A-Za-z]:[\\\\/]))(?!.*[\\s(\\\"'=](?:/|\\\\|[A-Za-z]:[\\\\/]))(?!.*file://).+$" + } + }, + "termination": { "enum": ["exited", "timeout", "signal", "spawn_error", "overflow"] }, + "exitCode": { "type": "integer", "minimum": 0 }, + "signal": { "type": "string", "minLength": 1 }, + "durationMs": { "type": "number", "minimum": 0 } + }, + "allOf": [ + { + "if": { "required": ["signal"] }, + "then": { "properties": { "termination": { "const": "signal" } } } + }, + { + "if": { "required": ["exitCode"] }, + "then": { "properties": { "termination": { "const": "exited" } } } + }, + { + "if": { "properties": { "termination": { "const": "exited" } }, "required": ["termination"] }, + "then": { "required": ["exitCode"], "not": { "required": ["signal"] } } + }, + { + "if": { "properties": { "termination": { "const": "signal" } }, "required": ["termination"] }, + "then": { "required": ["signal"], "not": { "required": ["exitCode"] } } + }, + { + "if": { + "properties": { "termination": { "enum": ["timeout", "spawn_error", "overflow"] } }, + "required": ["termination"] + }, + "then": { + "not": { + "anyOf": [{ "required": ["exitCode"] }, { "required": ["signal"] }] + } + } + } + ] + } + }, + "durationMs": { "type": "number", "minimum": 0 }, + "diagnosticCount": { "type": "integer", "minimum": 0 }, + "failureMessage": { + "type": "string", + "minLength": 1, + "maxLength": 1024, + "pattern": "^(?!(?:/|\\\\|[A-Za-z]:[\\\\/]))(?!.*[\\s(\\\"'=](?:/|\\\\|[A-Za-z]:[\\\\/]))(?!.*file://).+$" + } + }, + "allOf": [ + { + "if": { "properties": { "capability": { "const": "ruff_lint" } }, "required": ["capability"] }, + "then": { "properties": { "checkId": { "const": "python.ruff-lint" } } } + }, + { + "if": { "properties": { "capability": { "const": "ruff_format" } }, "required": ["capability"] }, + "then": { "properties": { "checkId": { "const": "python.ruff-format" } } } + }, + { + "if": { + "properties": { + "state": { "enum": ["not_applicable", "disabled"] } + }, + "required": ["state"] + }, + "then": { + "not": { + "anyOf": [ + { "required": ["termination"] }, { "required": ["exitCode"] }, { "required": ["signal"] }, + { "required": ["command"] }, { "required": ["argv"] }, { "required": ["invocations"] } + ] + } + } + }, + { + "if": { + "properties": { + "state": { + "enum": [ + "passed", "findings", "tool_unavailable", "invalid_config", "timeout", + "unsupported_target", "tool_failure" + ] + } + }, + "required": ["state"] + }, + "then": { + "required": [ + "projectKey", "contextFingerprint", "afterStateManifestFingerprint", + "sourcePaths", "configPaths", "cwd" + ], + "properties": { + "sourcePaths": { "minItems": 1 } + } + } + }, + { + "if": { "properties": { "termination": { "const": "exited" } }, "required": ["termination"] }, + "then": { "required": ["exitCode"], "not": { "required": ["signal"] } } + }, + { + "if": { "properties": { "termination": { "const": "signal" } }, "required": ["termination"] }, + "then": { "required": ["signal"], "not": { "required": ["exitCode"] } } + }, + { + "if": { + "properties": { "termination": { "enum": ["timeout", "spawn_error", "overflow"] } }, + "required": ["termination"] + }, + "then": { + "not": { + "anyOf": [{ "required": ["exitCode"] }, { "required": ["signal"] }] + } + } + }, + { + "if": { + "properties": { "state": { "enum": ["tool_unavailable", "unsupported_target"] } }, + "required": ["state"] + }, + "then": { + "required": ["failureMessage"], + "not": { + "anyOf": [ + { "required": ["termination"] }, { "required": ["exitCode"] }, { "required": ["signal"] }, + { "required": ["command"] }, { "required": ["argv"] }, { "required": ["invocations"] } + ] + } + } + }, + { + "if": { "properties": { "state": { "const": "timeout" } }, "required": ["state"] }, + "then": { + "required": [ + "executable", "command", "argv", "toolVersion", "toolSource", "termination", + "invocations", "failureMessage" + ], + "properties": { + "termination": { "const": "timeout" }, + "durationMs": { "exclusiveMinimum": 0 }, + "invocations": { + "contains": { + "properties": { "termination": { "const": "timeout" } }, + "required": ["termination"] + }, + "minContains": 1 + } + } + } + }, + { + "if": { "properties": { "state": { "const": "invalid_config" } }, "required": ["state"] }, + "then": { + "required": ["failureMessage"], + "anyOf": [ + { + "not": { + "anyOf": [ + { "required": ["termination"] }, { "required": ["exitCode"] }, { "required": ["signal"] }, + { "required": ["command"] }, { "required": ["argv"] }, { "required": ["invocations"] } + ] + } + }, + { + "required": [ + "executable", "command", "argv", "toolVersion", "toolSource", "termination", + "exitCode", "invocations" + ], + "properties": { + "termination": { "const": "exited" }, + "exitCode": { "const": 2 }, + "durationMs": { "exclusiveMinimum": 0 }, + "invocations": { + "contains": { + "properties": { + "termination": { "const": "exited" }, + "exitCode": { "const": 2 } + }, + "required": ["termination", "exitCode"] + }, + "minContains": 1 + } + } + } + ] + } + }, + { + "if": { "properties": { "state": { "const": "tool_failure" } }, "required": ["state"] }, + "then": { + "required": ["failureMessage"], + "anyOf": [ + { + "not": { + "anyOf": [ + { "required": ["termination"] }, { "required": ["exitCode"] }, { "required": ["signal"] }, + { "required": ["command"] }, { "required": ["argv"] }, { "required": ["invocations"] } + ] + } + }, + { + "required": [ + "executable", "command", "argv", "toolVersion", "toolSource", "termination", "invocations" + ], + "properties": { + "termination": { "enum": ["exited", "signal", "spawn_error", "overflow"] }, + "durationMs": { "exclusiveMinimum": 0 } + }, + "allOf": [ + { + "if": { + "properties": { "termination": { "const": "exited" } }, + "required": ["termination"] + }, + "then": { + "properties": { + "invocations": { + "contains": { + "properties": { "termination": { "const": "exited" } }, + "required": ["termination", "exitCode"] + }, + "minContains": 1 + } + } + } + }, + { + "if": { + "properties": { "termination": { "const": "signal" } }, + "required": ["termination"] + }, + "then": { + "properties": { + "invocations": { + "contains": { + "properties": { "termination": { "const": "signal" } }, + "required": ["termination", "signal"] + }, + "minContains": 1 + } + } + } + }, + { + "if": { + "properties": { "termination": { "const": "spawn_error" } }, + "required": ["termination"] + }, + "then": { + "properties": { + "invocations": { + "contains": { + "properties": { "termination": { "const": "spawn_error" } }, + "required": ["termination"] + }, + "minContains": 1 + } + } + } + }, + { + "if": { + "properties": { "termination": { "const": "overflow" } }, + "required": ["termination"] + }, + "then": { + "properties": { + "invocations": { + "contains": { + "properties": { "termination": { "const": "overflow" } }, + "required": ["termination"] + }, + "minContains": 1 + } + } + } + } + ] + } + ] + } + }, + { + "if": { + "properties": { "state": { "enum": ["passed", "findings"] } }, + "required": ["state"] + }, + "then": { + "required": [ + "executable", "command", "argv", "toolVersion", "toolSource", "termination", + "exitCode", "invocations" + ], + "properties": { + "termination": { "const": "exited" }, + "durationMs": { "exclusiveMinimum": 0 }, + "invocations": { + "items": { + "properties": { + "termination": { "const": "exited" }, + "exitCode": { "enum": [0, 1] }, + "durationMs": { "exclusiveMinimum": 0 } + }, + "required": ["exitCode"] + } + } + } + } + }, + { + "if": { "properties": { "state": { "const": "passed" } }, "required": ["state"] }, + "then": { "properties": { "exitCode": { "const": 0 }, "diagnosticCount": { "const": 0 } } } + }, + { + "if": { "properties": { "state": { "const": "findings" } }, "required": ["state"] }, + "then": { "properties": { "exitCode": { "const": 1 }, "diagnosticCount": { "minimum": 1 } } } + } + ] + }, "ValidationResult": { "type": "object", "additionalProperties": false, @@ -2915,7 +3299,12 @@ }, "pythonCapabilityRuns": { "type": "array", - "items": { "$ref": "#/$defs/PythonValidationCapabilityRun" } + "items": { + "oneOf": [ + { "$ref": "#/$defs/PythonValidationCapabilityRun" }, + { "$ref": "#/$defs/PythonRuffValidationCapabilityRun" } + ] + } }, "failure": { "type": "object", @@ -3106,6 +3495,662 @@ "failureMessage": { "type": "string", "minLength": 1 + }, + "pythonCapabilityRuns": { + "type": "array", + "items": { + "oneOf": [ + { "$ref": "#/$defs/PythonValidationCapabilityRun" }, + { + "type": "object", + "additionalProperties": false, + "required": [ + "schemaId", + "schemaVersion", + "checkId", + "capability", + "state", + "durationMs", + "diagnosticCount" + ], + "properties": { + "schemaId": { + "const": "opcore.python.validation-capability-run" + }, + "schemaVersion": { + "const": 1 + }, + "checkId": { + "$ref": "#/$defs/ValidationCheckId" + }, + "capability": { + "enum": ["ruff_lint", "ruff_format"] + }, + "state": { + "enum": [ + "passed", + "findings", + "tool_unavailable", + "invalid_config", + "timeout", + "unsupported_target", + "tool_failure", + "not_applicable", + "disabled" + ] + }, + "projectKey": { + "type": "string", + "pattern": "^sha256:[a-f0-9]{64}$" + }, + "contextFingerprint": { + "type": "string", + "pattern": "^sha256:[a-f0-9]{64}$" + }, + "afterStateManifestFingerprint": { + "type": "string", + "pattern": "^sha256:[a-f0-9]{64}$" + }, + "sourcePaths": { + "type": "array", + "items": { "$ref": "#/$defs/RepoRelativePath" } + }, + "configPaths": { + "type": "array", + "items": { "$ref": "#/$defs/RepoRelativePath" } + }, + "executable": { + "type": "string", + "pattern": "^(?:(?:repo|project):(?!.*(?:^|/)\\.\\.(?:/|$))[A-Za-z0-9._+@-]+(?:/[A-Za-z0-9._+@-]+)*|(?:path|external):[A-Za-z0-9_.+-]+)$" + }, + "command": { + "type": "string", + "minLength": 1, + "pattern": "^(?!(?:/|\\\\|[A-Za-z]:[\\\\/]))(?!.*[\\s(\\\"'=](?:/|\\\\|[A-Za-z]:[\\\\/]))(?!.*file://).+$" + }, + "argv": { + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "pattern": "^(?!(?:/|\\\\|[A-Za-z]:[\\\\/]))(?!.*[\\s(\\\"'=](?:/|\\\\|[A-Za-z]:[\\\\/]))(?!.*file://).+$" + }, + "minItems": 1 + }, + "cwd": { + "anyOf": [ + { "const": "." }, + { "$ref": "#/$defs/RepoRelativePath" } + ] + }, + "configPath": { + "$ref": "#/$defs/RepoRelativePath" + }, + "toolVersion": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9][-+._A-Za-z0-9]*$" + }, + "toolSource": { + "enum": [ + "explicit_override", + "active_environment", + "project_local_environment", + "manager_environment", + "path" + ] + }, + "termination": { + "enum": ["exited", "timeout", "signal", "spawn_error", "overflow"] + }, + "exitCode": { + "type": "integer", + "minimum": 0 + }, + "signal": { + "type": "string", + "minLength": 1 + }, + "invocations": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["argv", "termination", "durationMs"], + "properties": { + "argv": { + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "pattern": "^(?!(?:/|\\\\|[A-Za-z]:[\\\\/]))(?!.*[\\s(\\\"'=](?:/|\\\\|[A-Za-z]:[\\\\/]))(?!.*file://).+$" + }, + "minItems": 1 + }, + "termination": { + "enum": ["exited", "timeout", "signal", "spawn_error", "overflow"] + }, + "exitCode": { + "type": "integer", + "minimum": 0 + }, + "signal": { + "type": "string", + "minLength": 1 + }, + "durationMs": { + "type": "number", + "minimum": 0 + } + }, + "allOf": [ + { + "if": { "required": ["signal"] }, + "then": { + "properties": { + "termination": { "const": "signal" } + } + } + }, + { + "if": { "required": ["exitCode"] }, + "then": { + "properties": { + "termination": { "const": "exited" } + } + } + }, + { + "if": { + "properties": { "termination": { "const": "exited" } }, + "required": ["termination"] + }, + "then": { + "required": ["exitCode"], + "not": { "required": ["signal"] } + } + }, + { + "if": { + "properties": { "termination": { "const": "signal" } }, + "required": ["termination"] + }, + "then": { + "required": ["signal"], + "not": { "required": ["exitCode"] } + } + }, + { + "if": { + "properties": { + "termination": { "enum": ["timeout", "spawn_error", "overflow"] } + }, + "required": ["termination"] + }, + "then": { + "not": { + "anyOf": [ + { "required": ["exitCode"] }, + { "required": ["signal"] } + ] + } + } + } + ] + } + }, + "durationMs": { + "type": "number", + "minimum": 0 + }, + "diagnosticCount": { + "type": "integer", + "minimum": 0 + }, + "failureMessage": { + "type": "string", + "minLength": 1, + "maxLength": 1024, + "pattern": "^(?!(?:/|\\\\|[A-Za-z]:[\\\\/]))(?!.*[\\s(\\\"'=](?:/|\\\\|[A-Za-z]:[\\\\/]))(?!.*file://).+$" + } + }, + "allOf": [ + { + "if": { + "properties": { + "state": { + "enum": ["not_applicable", "disabled"] + } + } + }, + "then": { + "not": { + "anyOf": [ + { "required": ["termination"] }, + { "required": ["exitCode"] }, + { "required": ["signal"] }, + { "required": ["command"] }, + { "required": ["argv"] }, + { "required": ["invocations"] } + ] + } + } + }, + { + "if": { + "required": ["signal"] + }, + "then": { + "properties": { + "termination": { "const": "signal" } + }, + "required": ["termination"] + } + }, + { + "if": { + "required": ["exitCode"] + }, + "then": { + "properties": { + "termination": { "const": "exited" } + }, + "required": ["termination"] + } + }, + { + "if": { + "properties": { "termination": { "const": "exited" } }, + "required": ["termination"] + }, + "then": { + "required": ["exitCode"], + "not": { "required": ["signal"] } + } + }, + { + "if": { + "properties": { "termination": { "const": "signal" } }, + "required": ["termination"] + }, + "then": { + "required": ["signal"], + "not": { "required": ["exitCode"] } + } + }, + { + "if": { + "properties": { + "termination": { "enum": ["timeout", "spawn_error", "overflow"] } + }, + "required": ["termination"] + }, + "then": { + "not": { + "anyOf": [ + { "required": ["exitCode"] }, + { "required": ["signal"] } + ] + } + } + }, + { + "if": { + "properties": { + "capability": { "const": "ruff_lint" } + } + }, + "then": { + "properties": { + "checkId": { "const": "python.ruff-lint" } + } + } + }, + { + "if": { + "properties": { + "state": { "enum": ["tool_unavailable", "unsupported_target"] } + }, + "required": ["state"] + }, + "then": { + "required": ["failureMessage"], + "not": { + "anyOf": [ + { "required": ["termination"] }, + { "required": ["exitCode"] }, + { "required": ["signal"] }, + { "required": ["command"] }, + { "required": ["argv"] }, + { "required": ["invocations"] } + ] + } + } + }, + { + "if": { + "properties": { "state": { "const": "timeout" } }, + "required": ["state"] + }, + "then": { + "required": [ + "executable", + "command", + "argv", + "toolVersion", + "toolSource", + "termination", + "invocations", + "failureMessage" + ], + "properties": { + "termination": { "const": "timeout" }, + "durationMs": { "exclusiveMinimum": 0 }, + "invocations": { + "contains": { + "properties": { + "termination": { "const": "timeout" } + }, + "required": ["termination"] + }, + "minContains": 1 + } + } + } + }, + { + "if": { + "properties": { "state": { "const": "invalid_config" } }, + "required": ["state"] + }, + "then": { + "required": ["failureMessage"], + "anyOf": [ + { + "not": { + "anyOf": [ + { "required": ["termination"] }, + { "required": ["exitCode"] }, + { "required": ["signal"] }, + { "required": ["command"] }, + { "required": ["argv"] }, + { "required": ["invocations"] } + ] + } + }, + { + "required": [ + "executable", + "command", + "argv", + "toolVersion", + "toolSource", + "termination", + "exitCode", + "invocations" + ], + "properties": { + "termination": { "const": "exited" }, + "exitCode": { "const": 2 }, + "durationMs": { "exclusiveMinimum": 0 }, + "invocations": { + "contains": { + "properties": { + "termination": { "const": "exited" }, + "exitCode": { "const": 2 } + }, + "required": ["termination", "exitCode"] + }, + "minContains": 1 + } + } + } + ] + } + }, + { + "if": { + "properties": { "state": { "const": "tool_failure" } }, + "required": ["state"] + }, + "then": { + "required": ["failureMessage"], + "anyOf": [ + { + "not": { + "anyOf": [ + { "required": ["termination"] }, + { "required": ["exitCode"] }, + { "required": ["signal"] }, + { "required": ["command"] }, + { "required": ["argv"] }, + { "required": ["invocations"] } + ] + } + }, + { + "required": [ + "executable", + "command", + "argv", + "toolVersion", + "toolSource", + "termination", + "invocations" + ], + "properties": { + "termination": { + "enum": ["exited", "signal", "spawn_error", "overflow"] + }, + "durationMs": { "exclusiveMinimum": 0 } + }, + "allOf": [ + { + "if": { + "properties": { "termination": { "const": "exited" } }, + "required": ["termination"] + }, + "then": { + "properties": { + "invocations": { + "contains": { + "properties": { "termination": { "const": "exited" } }, + "required": ["termination", "exitCode"] + }, + "minContains": 1 + } + } + } + }, + { + "if": { + "properties": { "termination": { "const": "signal" } }, + "required": ["termination"] + }, + "then": { + "properties": { + "invocations": { + "contains": { + "properties": { "termination": { "const": "signal" } }, + "required": ["termination", "signal"] + }, + "minContains": 1 + } + } + } + }, + { + "if": { + "properties": { "termination": { "const": "spawn_error" } }, + "required": ["termination"] + }, + "then": { + "properties": { + "invocations": { + "contains": { + "properties": { "termination": { "const": "spawn_error" } }, + "required": ["termination"] + }, + "minContains": 1 + } + } + } + }, + { + "if": { + "properties": { "termination": { "const": "overflow" } }, + "required": ["termination"] + }, + "then": { + "properties": { + "invocations": { + "contains": { + "properties": { "termination": { "const": "overflow" } }, + "required": ["termination"] + }, + "minContains": 1 + } + } + } + } + ] + } + ] + } + }, + { + "if": { + "properties": { + "capability": { "const": "ruff_format" } + } + }, + "then": { + "properties": { + "checkId": { "const": "python.ruff-format" } + } + } + }, + { + "if": { + "properties": { + "capability": { + "enum": ["ruff_lint", "ruff_format"] + }, + "state": { + "enum": [ + "passed", "findings", "tool_unavailable", "invalid_config", "timeout", + "unsupported_target", "tool_failure" + ] + } + } + }, + "then": { + "required": [ + "projectKey", + "contextFingerprint", + "afterStateManifestFingerprint", + "sourcePaths", + "configPaths", + "cwd" + ], + "properties": { + "sourcePaths": { + "minItems": 1 + } + } + } + }, + { + "if": { + "properties": { + "capability": { + "enum": ["ruff_lint", "ruff_format"] + }, + "state": { + "enum": ["passed", "findings"] + } + } + }, + "then": { + "required": [ + "projectKey", + "contextFingerprint", + "afterStateManifestFingerprint", + "sourcePaths", + "configPaths", + "executable", + "command", + "argv", + "cwd", + "toolVersion", + "toolSource", + "termination", + "exitCode", + "invocations" + ], + "properties": { + "sourcePaths": { + "minItems": 1 + }, + "termination": { + "const": "exited" + }, + "durationMs": { + "exclusiveMinimum": 0 + }, + "invocations": { + "items": { + "properties": { + "termination": { "const": "exited" }, + "exitCode": { + "enum": [0, 1] + }, + "durationMs": { + "exclusiveMinimum": 0 + } + }, + "required": ["exitCode"] + } + } + } + } + }, + { + "if": { + "properties": { + "capability": { + "enum": ["ruff_lint", "ruff_format"] + }, + "state": { "const": "passed" } + } + }, + "then": { + "properties": { + "exitCode": { "const": 0 }, + "diagnosticCount": { "const": 0 } + } + } + }, + { + "if": { + "properties": { + "capability": { + "enum": ["ruff_lint", "ruff_format"] + }, + "state": { "const": "findings" } + } + }, + "then": { + "properties": { + "exitCode": { "const": 1 }, + "diagnosticCount": { "minimum": 1 } + } + } + } + ] + } + ] + } } }, "allOf": [ diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 2ad9bb5..8d89f6f 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -186,6 +186,25 @@ export type PythonValidationAuthoritySource = (typeof pythonValidationAuthorityS export const pythonValidationCapabilityTerminationKinds = ["exited", "timeout", "signal", "spawn_error"] as const; export type PythonValidationCapabilityTerminationKind = (typeof pythonValidationCapabilityTerminationKinds)[number]; +export const pythonValidationCapabilities = ["types", "ruff_lint", "ruff_format"] as const; +export type PythonValidationCapability = (typeof pythonValidationCapabilities)[number]; + +export const pythonValidationCapabilityStates = [ + ...validationCheckOutcomes, + "not_applicable", + "disabled" +] as const; +export type PythonValidationCapabilityState = (typeof pythonValidationCapabilityStates)[number]; + +export const pythonValidationCapabilityTerminations = [ + "exited", + "timeout", + "signal", + "spawn_error", + "overflow" +] as const; +export type PythonValidationCapabilityTermination = (typeof pythonValidationCapabilityTerminations)[number]; + export const validationSkippedCheckReasons = [ "graph_unavailable", "unsupported_scope", @@ -1307,7 +1326,7 @@ export interface PythonValidationCapabilityExecution { } /** Portable, source-free evidence for one attempted Python capability in one canonical project. */ -export interface PythonValidationCapabilityRun { +export interface PythonTypesValidationCapabilityRun { schemaId: typeof PYTHON_VALIDATION_CAPABILITY_RUN_SCHEMA_ID; schemaVersion: 1; capability: "types"; @@ -1361,6 +1380,45 @@ export interface ValidationCheckManifestEntry { requiresGraph: boolean; } +export interface PythonRuffValidationCapabilityRun { + schemaId: typeof PYTHON_VALIDATION_CAPABILITY_RUN_SCHEMA_ID; + schemaVersion: 1; + checkId: "python.ruff-lint" | "python.ruff-format"; + capability: "ruff_lint" | "ruff_format"; + state: PythonValidationCapabilityState; + projectKey?: string; + contextFingerprint?: string; + afterStateManifestFingerprint?: string; + sourcePaths?: readonly string[]; + configPaths?: readonly string[]; + executable?: string; + command?: string; + argv?: readonly string[]; + cwd?: string; + configPath?: string; + toolVersion?: string; + toolSource?: PythonProjectExecutableSource; + termination?: PythonValidationCapabilityTermination; + exitCode?: number; + signal?: string; + invocations?: readonly PythonValidationCapabilityInvocation[]; + durationMs: number; + diagnosticCount: number; + failureMessage?: string; +} + +export type PythonValidationCapabilityRun = + | PythonTypesValidationCapabilityRun + | PythonRuffValidationCapabilityRun; + +export interface PythonValidationCapabilityInvocation { + argv: readonly string[]; + termination: PythonValidationCapabilityTermination; + exitCode?: number; + signal?: string; + durationMs: number; +} + export interface ValidationCheckRunSummary { checkId: string; status: ValidationCheckRunStatus; @@ -1368,6 +1426,7 @@ export interface ValidationCheckRunSummary { durationMs?: number; diagnosticCount?: number; failureMessage?: string; + pythonCapabilityRuns?: readonly PythonValidationCapabilityRun[]; } export interface ValidationSkippedCheck { @@ -6685,6 +6744,14 @@ export function validateValidationResultPayload(result: ValidationResult): Valid export function validatePythonValidationCapabilityRun( run: PythonValidationCapabilityRun ): PythonValidationCapabilityRun { + return run.capability === "types" + ? validatePythonTypesValidationCapabilityRun(run) + : validatePythonRuffValidationCapabilityRun(run); +} + +function validatePythonTypesValidationCapabilityRun( + run: PythonTypesValidationCapabilityRun +): PythonTypesValidationCapabilityRun { if (!run || typeof run !== "object") throw new Error("Python validation capability run is required"); validatePythonCapabilityRunShape(run); validatePythonCapabilityRunIdentity(run); @@ -6695,7 +6762,7 @@ export function validatePythonValidationCapabilityRun( return run; } -function validatePythonCapabilityRunShape(run: PythonValidationCapabilityRun): void { +function validatePythonCapabilityRunShape(run: PythonTypesValidationCapabilityRun): void { validateExactObjectKeys(run, [ "schemaId", "schemaVersion", "capability", "checkId", "projectKey", "contextFingerprint", "projectRoot", "targets", "selectedSourcePaths", "selectedConfigPaths", "afterStateManifestFingerprint", "authority", @@ -6711,7 +6778,7 @@ function validatePythonCapabilityRunShape(run: PythonValidationCapabilityRun): v } } -function validatePythonCapabilityRunIdentity(run: PythonValidationCapabilityRun): void { +function validatePythonCapabilityRunIdentity(run: PythonTypesValidationCapabilityRun): void { validateSha256Identity(run.projectKey, "Python validation capability run projectKey"); validateSha256Identity(run.contextFingerprint, "Python validation capability run contextFingerprint"); validatePythonProjectRoot(run.projectRoot, "Python validation capability run projectRoot"); @@ -6741,7 +6808,7 @@ function validatePythonCapabilityRunIdentity(run: PythonValidationCapabilityRun) } } -function validatePythonCapabilityRunCounts(run: PythonValidationCapabilityRun): void { +function validatePythonCapabilityRunCounts(run: PythonTypesValidationCapabilityRun): void { for (const [key, value] of [ ["durationMs", run.durationMs], ["diagnosticCount", run.diagnosticCount], ["errorCount", run.errorCount], ["warningCount", run.warningCount], ["noteCount", run.noteCount] @@ -6751,7 +6818,7 @@ function validatePythonCapabilityRunCounts(run: PythonValidationCapabilityRun): } } -function validatePythonCapabilityRunStatus(run: PythonValidationCapabilityRun): void { +function validatePythonCapabilityRunStatus(run: PythonTypesValidationCapabilityRun): void { if (run.status === "passed") validatePassedPythonCapability(run); if (run.status === "findings") validateFindingsPythonCapability(run); if (run.status === "timeout") validateTimeoutPythonCapability(run); @@ -6761,14 +6828,14 @@ function validatePythonCapabilityRunStatus(run: PythonValidationCapabilityRun): if (run.status === "tool_failure") validateFailedPythonCapability(run); } -function validatePassedPythonCapability(run: PythonValidationCapabilityRun): void { +function validatePassedPythonCapability(run: PythonTypesValidationCapabilityRun): void { requireExitedPythonCapability(run); if (run.execution?.exitCode !== 0 || run.errorCount !== 0) { throw new Error("Passed Python validation capability run requires exit 0 and zero errors"); } } -function validateFindingsPythonCapability(run: PythonValidationCapabilityRun): void { +function validateFindingsPythonCapability(run: PythonTypesValidationCapabilityRun): void { requireExitedPythonCapability(run); if (run.execution?.exitCode !== 1 || run.diagnosticCount === 0) { throw new Error("Findings Python validation capability run requires exit 1 and diagnostics"); @@ -6778,19 +6845,19 @@ function validateFindingsPythonCapability(run: PythonValidationCapabilityRun): v } } -function requireExitedPythonCapability(run: PythonValidationCapabilityRun): void { +function requireExitedPythonCapability(run: PythonTypesValidationCapabilityRun): void { if (run.tool === undefined || run.execution?.termination !== "exited") { throw new Error(`Python validation capability run ${run.status} requires exited tool evidence`); } } -function validateTimeoutPythonCapability(run: PythonValidationCapabilityRun): void { +function validateTimeoutPythonCapability(run: PythonTypesValidationCapabilityRun): void { if (run.tool === undefined || run.execution?.termination !== "timeout" || run.execution.failureSummary === undefined) { throw new Error("Timeout Python validation capability run requires tool and timeout failure evidence"); } } -function validateInvalidPythonCapability(run: PythonValidationCapabilityRun): void { +function validateInvalidPythonCapability(run: PythonTypesValidationCapabilityRun): void { if (run.authority === undefined && (run.tool !== undefined || run.execution !== undefined)) { throw new Error("Unselected invalid-config Python validation capability run must not include tool or execution evidence"); } @@ -6800,17 +6867,17 @@ function validateInvalidPythonCapability(run: PythonValidationCapabilityRun): vo } } -function validateUnexecutedPythonCapability(run: PythonValidationCapabilityRun): void { +function validateUnexecutedPythonCapability(run: PythonTypesValidationCapabilityRun): void { if (run.execution !== undefined) throw new Error(`${run.status} Python validation capability run must not include execution evidence`); } -function validateUnavailablePythonCapability(run: PythonValidationCapabilityRun): void { +function validateUnavailablePythonCapability(run: PythonTypesValidationCapabilityRun): void { if (run.tool === undefined || run.execution !== undefined) { throw new Error("Tool-unavailable Python validation capability run requires tool provenance without execution"); } } -function validateFailedPythonCapability(run: PythonValidationCapabilityRun): void { +function validateFailedPythonCapability(run: PythonTypesValidationCapabilityRun): void { if (run.tool === undefined || run.execution === undefined || run.execution.termination === "timeout" || run.execution.failureSummary === undefined) { throw new Error("Tool-failure Python validation capability run requires non-timeout tool failure evidence"); } @@ -6826,7 +6893,7 @@ export function validatePythonValidationCapabilityRuns( function validatePythonValidationCapabilityTool( tool: PythonValidationCapabilityToolProvenance, - run: PythonValidationCapabilityRun + run: PythonTypesValidationCapabilityRun ): void { validateExactObjectKeys(tool, ["name", "executable", "argv", "cwd", "source", "version", "configFile"], "Python validation capability tool"); if (run.authority === undefined || tool.name !== run.authority) throw new Error("Python validation capability tool must match authority"); @@ -7623,6 +7690,12 @@ function validateValidationCheckRunSummary(run: ValidationCheckRunSummary): Vali ) { throw new Error("Validation check run summary failureMessage is required for failure statuses"); } + if (run.pythonCapabilityRuns !== undefined) { + if (!Array.isArray(run.pythonCapabilityRuns)) { + throw new Error("Validation check run summary pythonCapabilityRuns must be an array"); + } + for (const capabilityRun of run.pythonCapabilityRuns) validatePythonValidationCapabilityRun(capabilityRun); + } return run; } @@ -7642,6 +7715,327 @@ function validateValidationCheckOutcomeStatus(run: ValidationCheckRunSummary): v } } +function validatePythonRuffValidationCapabilityRun( + run: PythonRuffValidationCapabilityRun +): PythonRuffValidationCapabilityRun { + if (!run || typeof run !== "object") { + throw new Error("Python validation capability run is required"); + } + validateExactObjectKeys(run, [ + "schemaId", "schemaVersion", "checkId", "capability", "state", "projectKey", "contextFingerprint", + "afterStateManifestFingerprint", "sourcePaths", "configPaths", "executable", "command", "argv", "cwd", + "configPath", "toolVersion", "toolSource", "termination", "exitCode", "signal", "invocations", + "durationMs", "diagnosticCount", "failureMessage" + ], "Python Ruff validation capability run"); + if (run.schemaId !== PYTHON_VALIDATION_CAPABILITY_RUN_SCHEMA_ID) { + throw new Error(`Python validation capability run schemaId must be ${PYTHON_VALIDATION_CAPABILITY_RUN_SCHEMA_ID}`); + } + if (run.schemaVersion !== 1) throw new Error("Python validation capability run schemaVersion must be 1"); + validateValidationCheckId(run.checkId, "Python validation capability run checkId"); + if (!includesString(pythonValidationCapabilities, run.capability)) { + throw new Error(`Unknown Python validation capability: ${String(run.capability)}`); + } + if (!includesString(pythonValidationCapabilityStates, run.state)) { + throw new Error(`Unknown Python validation capability state: ${String(run.state)}`); + } + if (run.projectKey !== undefined) validateSha256Identity(run.projectKey, "Python validation capability run projectKey"); + if (run.contextFingerprint !== undefined) { + validateSha256Identity(run.contextFingerprint, "Python validation capability run contextFingerprint"); + } + if (run.afterStateManifestFingerprint !== undefined) { + validateSha256Identity( + run.afterStateManifestFingerprint, + "Python validation capability run afterStateManifestFingerprint" + ); + } + if (run.sourcePaths !== undefined) { + validateStringArray(run.sourcePaths, "Python validation capability run sourcePaths", { allowEmpty: true }); + for (const path of run.sourcePaths) validateRepoRelativePath(path); + } + if (run.configPaths !== undefined) { + validateStringArray(run.configPaths, "Python validation capability run configPaths", { allowEmpty: true }); + for (const path of run.configPaths) validateRepoRelativePath(path); + } + if (run.executable !== undefined) validatePortablePythonCapabilityExecutable(run.executable); + if (run.command !== undefined) validateNonEmptyString(run.command, "Python validation capability run command"); + if (run.argv !== undefined) { + validateStringArray(run.argv, "Python validation capability run argv", { allowEmpty: false }); + validatePortablePythonCapabilityArgv(run.argv); + } + if (run.cwd !== undefined) validateNonEmptyString(run.cwd, "Python validation capability run cwd"); + if (run.configPath !== undefined) validateRepoRelativePath(run.configPath); + if (run.toolVersion !== undefined) { + validateNonEmptyString(run.toolVersion, "Python validation capability run toolVersion"); + if (!/^[0-9]+\.[0-9][-+._A-Za-z0-9]*$/u.test(run.toolVersion)) { + throw new Error("Python validation capability run toolVersion must be exact version provenance"); + } + } + if (run.toolSource !== undefined && !includesString(pythonProjectExecutableSources, run.toolSource)) { + throw new Error(`Unknown Python validation capability run toolSource: ${String(run.toolSource)}`); + } + if (run.termination !== undefined && !includesString(pythonValidationCapabilityTerminations, run.termination)) { + throw new Error(`Unknown Python validation capability termination: ${String(run.termination)}`); + } + if (run.exitCode !== undefined) validateNonNegativeInteger(run.exitCode, "Python validation capability run exitCode"); + if (run.signal !== undefined) validateNonEmptyString(run.signal, "Python validation capability run signal"); + if (run.invocations !== undefined) { + if (!Array.isArray(run.invocations) || run.invocations.length === 0) { + throw new Error("Python validation capability run invocations must be a non-empty array"); + } + for (const invocation of run.invocations) { + validatePythonValidationCapabilityInvocation(invocation); + if (run.executable !== undefined && invocation.argv[0] !== run.executable) { + throw new Error("Python validation capability invocation argv must start with executable"); + } + } + } + validateNonNegativeNumber(run.durationMs, "Python validation capability run durationMs"); + validateNonNegativeInteger(run.diagnosticCount, "Python validation capability run diagnosticCount"); + if (run.failureMessage !== undefined) { + validateNonEmptyString(run.failureMessage, "Python validation capability run failureMessage"); + if (containsHostAbsolutePath(run.failureMessage)) { + throw new Error("Python validation capability run failureMessage must not contain host-absolute paths"); + } + } + if (run.state === "not_applicable" || run.state === "disabled") { + if ( + run.termination !== undefined || + run.exitCode !== undefined || + run.signal !== undefined || + run.command !== undefined || + run.argv !== undefined || + run.invocations !== undefined + ) { + throw new Error(`Python validation capability state ${run.state} must not record a process invocation`); + } + } + if (run.signal !== undefined && run.termination !== "signal") { + throw new Error("Python validation capability run signal requires signal termination"); + } + if (run.exitCode !== undefined && run.termination !== "exited") { + throw new Error("Python validation capability run exitCode requires exited termination"); + } + validateRuffCapabilityRun(run); + return run; +} + +function validatePythonValidationCapabilityInvocation(invocation: PythonValidationCapabilityInvocation): void { + if (!invocation || typeof invocation !== "object") { + throw new Error("Python validation capability invocation is required"); + } + validateStringArray(invocation.argv, "Python validation capability invocation argv", { allowEmpty: false }); + validatePortablePythonCapabilityArgv(invocation.argv); + if (!includesString(pythonValidationCapabilityTerminations, invocation.termination)) { + throw new Error(`Unknown Python validation capability invocation termination: ${String(invocation.termination)}`); + } + if (invocation.exitCode !== undefined) { + validateNonNegativeInteger(invocation.exitCode, "Python validation capability invocation exitCode"); + if (invocation.termination !== "exited") { + throw new Error("Python validation capability invocation exitCode requires exited termination"); + } + } + if (invocation.signal !== undefined) { + validateNonEmptyString(invocation.signal, "Python validation capability invocation signal"); + if (invocation.termination !== "signal") { + throw new Error("Python validation capability invocation signal requires signal termination"); + } + } + validateRuffTerminationEvidence(invocation, "Python validation capability invocation"); + validateNonNegativeNumber(invocation.durationMs, "Python validation capability invocation durationMs"); +} + +function validateRuffCapabilityRun(run: PythonRuffValidationCapabilityRun): void { + const expectedCheckId = + run.capability === "ruff_lint" + ? "python.ruff-lint" + : run.capability === "ruff_format" + ? "python.ruff-format" + : undefined; + if (expectedCheckId === undefined) return; + if (run.checkId !== expectedCheckId) { + throw new Error(`Python validation capability ${run.capability} requires checkId ${expectedCheckId}`); + } + if (run.state === "not_applicable" || run.state === "disabled") return; + const exactStateFields: readonly (keyof PythonRuffValidationCapabilityRun)[] = [ + "projectKey", + "contextFingerprint", + "afterStateManifestFingerprint", + "sourcePaths", + "configPaths", + "cwd" + ]; + for (const field of exactStateFields) { + if (run[field] === undefined) { + throw new Error(`Activated Ruff capability run requires ${field}`); + } + } + if ((run.sourcePaths?.length ?? 0) === 0) { + throw new Error("Activated Ruff capability run requires at least one source path"); + } + if (run.cwd !== "." && run.cwd !== undefined) validateRepoRelativePath(run.cwd); + if ( + run.state === "tool_unavailable" || + run.state === "unsupported_target" + ) { + requireRuffFailureMessage(run); + rejectRuffProcessEvidence(run); + return; + } + if (run.state === "timeout") { + requireRuffFailureMessage(run); + requireExecutedRuffCapability(run); + if (run.termination !== "timeout") { + throw new Error("Ruff capability timeout requires timeout termination"); + } + validateExecutedRuffCapabilityCoherence(run); + return; + } + if (run.state === "invalid_config") { + requireRuffFailureMessage(run); + if (!hasRuffProcessEvidence(run)) return; + requireExecutedRuffCapability(run); + if (run.termination !== "exited" || run.exitCode !== 2) { + throw new Error("Executed Ruff capability invalid_config requires exited configuration-rejection evidence"); + } + validateExecutedRuffCapabilityCoherence(run); + return; + } + if (run.state === "tool_failure") { + requireRuffFailureMessage(run); + if (!hasRuffProcessEvidence(run)) return; + requireExecutedRuffCapability(run); + if (run.termination === "timeout") { + throw new Error("Executed Ruff capability tool_failure must not use timeout termination"); + } + validateExecutedRuffCapabilityCoherence(run); + return; + } + if (run.state !== "passed" && run.state !== "findings") return; + requireExecutedRuffCapability(run); + if (run.termination !== "exited") { + throw new Error("Executed Ruff capability run requires exited termination"); + } + for (const invocation of run.invocations ?? []) { + if (invocation.termination !== "exited" || (invocation.exitCode !== 0 && invocation.exitCode !== 1)) { + throw new Error("Executed Ruff capability invocation requires exited Ruff result code 0 or 1"); + } + } + validateExecutedRuffCapabilityCoherence(run); + const expectedExitCode = run.state === "passed" ? 0 : 1; + if (run.exitCode !== expectedExitCode) { + throw new Error(`Executed Ruff capability state ${run.state} requires exitCode ${expectedExitCode}`); + } + if (run.state === "passed" && run.diagnosticCount !== 0) { + throw new Error("Passed Ruff capability run requires zero diagnostics"); + } + if (run.state === "findings" && run.diagnosticCount <= 0) { + throw new Error("Ruff findings capability run requires positive diagnosticCount"); + } +} + +function requireRuffFailureMessage(run: PythonRuffValidationCapabilityRun): void { + if (run.failureMessage === undefined) { + throw new Error(`Ruff capability state ${run.state} requires failureMessage`); + } +} + +function hasRuffProcessEvidence(run: PythonRuffValidationCapabilityRun): boolean { + return ( + run.command !== undefined || + run.argv !== undefined || + run.termination !== undefined || + run.exitCode !== undefined || + run.signal !== undefined || + run.invocations !== undefined + ); +} + +function rejectRuffProcessEvidence(run: PythonRuffValidationCapabilityRun): void { + if (hasRuffProcessEvidence(run)) { + throw new Error(`Ruff capability state ${run.state} must not record process evidence`); + } +} + +function requireExecutedRuffCapability(run: PythonRuffValidationCapabilityRun): void { + const executionFields: readonly (keyof PythonRuffValidationCapabilityRun)[] = [ + "executable", + "command", + "argv", + "toolVersion", + "toolSource", + "termination", + "invocations" + ]; + for (const field of executionFields) { + if (run[field] === undefined) { + throw new Error(`Executed Ruff capability run requires ${field}`); + } + } + if (run.durationMs <= 0) { + throw new Error("Executed Ruff capability run requires positive durationMs"); + } + if (run.argv?.[0] !== run.executable) { + throw new Error("Executed Ruff capability run argv must start with executable"); + } + if (run.command !== run.argv?.join(" ")) { + throw new Error("Executed Ruff capability run command must match argv"); + } + validateRuffTerminationEvidence(run, "Executed Ruff capability run"); + for (const invocation of run.invocations ?? []) { + if (invocation.argv[0] !== run.executable) { + throw new Error("Executed Ruff capability invocation argv must start with executable"); + } + if (invocation.durationMs <= 0) { + throw new Error("Executed Ruff capability invocation requires positive durationMs"); + } + } +} + +function validateExecutedRuffCapabilityCoherence(run: PythonRuffValidationCapabilityRun): void { + const matchingInvocation = run.invocations?.some((invocation) => + invocation.termination === run.termination && + invocation.exitCode === run.exitCode && + invocation.signal === run.signal && + invocation.argv.length === run.argv?.length && + invocation.argv.every((argument, index) => argument === run.argv?.[index]) + ); + if (matchingInvocation !== true) { + throw new Error("Executed Ruff capability run requires an invocation matching its argv and termination evidence"); + } +} + +function validateRuffTerminationEvidence( + evidence: Pick< + PythonRuffValidationCapabilityRun | PythonValidationCapabilityInvocation, + "termination" | "exitCode" | "signal" + >, + label: string +): void { + if (evidence.termination === "exited") { + if (evidence.exitCode === undefined || evidence.signal !== undefined) { + throw new Error(`${label} exited termination requires exitCode without signal`); + } + return; + } + if (evidence.termination === "signal") { + if (evidence.signal === undefined || evidence.exitCode !== undefined) { + throw new Error(`${label} signal termination requires signal without exitCode`); + } + return; + } + if (evidence.exitCode !== undefined || evidence.signal !== undefined) { + throw new Error(`${label} ${String(evidence.termination)} termination must not record exitCode or signal`); + } +} + +function validatePortablePythonCapabilityArgv(argv: readonly string[]): void { + for (const argument of argv) { + if (containsHostAbsolutePath(argument)) { + throw new Error("Python validation capability run requires portable argv without host-absolute paths"); + } + } +} + function validateValidationSkippedCheck(skippedCheck: ValidationSkippedCheck): ValidationSkippedCheck { if (!skippedCheck || typeof skippedCheck !== "object") { throw new Error("Validation skipped check is required"); diff --git a/packages/fixtures/src/index.ts b/packages/fixtures/src/index.ts index 206bfca..1c524b4 100644 --- a/packages/fixtures/src/index.ts +++ b/packages/fixtures/src/index.ts @@ -606,6 +606,8 @@ export const conformanceFixtureMetadata = [ checks: [ "python.syntax", "python.source-hygiene", + "python.ruff-lint", + "python.ruff-format", "python.types", "python.import-graph", "python.dead-code", diff --git a/packages/fixtures/validation-python/README.md b/packages/fixtures/validation-python/README.md index 50c138b..f73c6f6 100644 --- a/packages/fixtures/validation-python/README.md +++ b/packages/fixtures/validation-python/README.md @@ -1,5 +1,5 @@ # Python validation fixtures -`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. +`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. Source and configuration resources retain a `.fixture` suffix on disk so Opcore does not discover them as live repository inputs; the proof script strips that suffix in its isolated workspace. `scripts/check-python-mypy-authority.mjs` runs pinned mypy over both clean and hypothetical/materialized after-states and verifies portable manifest identity plus cleanup. `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/mypy-authority/src/acme/__init__.py b/packages/fixtures/validation-python/mypy-authority/src/acme/__init__.py deleted file mode 100644 index 8b13789..0000000 --- a/packages/fixtures/validation-python/mypy-authority/src/acme/__init__.py +++ /dev/null @@ -1 +0,0 @@ - diff --git a/packages/fixtures/validation-python/mypy-authority/src/acme/__init__.py.fixture b/packages/fixtures/validation-python/mypy-authority/src/acme/__init__.py.fixture new file mode 100644 index 0000000..e69de29 diff --git a/packages/fixtures/validation-python/mypy-authority/src/acme/app.py b/packages/fixtures/validation-python/mypy-authority/src/acme/app.py.fixture similarity index 100% rename from packages/fixtures/validation-python/mypy-authority/src/acme/app.py rename to packages/fixtures/validation-python/mypy-authority/src/acme/app.py.fixture 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.fixture similarity index 100% rename from packages/fixtures/validation-python/mypy-authority/src/acme/mypy_plugin.py rename to packages/fixtures/validation-python/mypy-authority/src/acme/mypy_plugin.py.fixture 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.fixture similarity index 100% rename from packages/fixtures/validation-python/mypy-authority/src/acme/plugin_support.py rename to packages/fixtures/validation-python/mypy-authority/src/acme/plugin_support.py.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.fixture similarity index 100% rename from packages/fixtures/validation-python/mypy-authority/src/acme/widget.py rename to packages/fixtures/validation-python/mypy-authority/src/acme/widget.py.fixture diff --git a/packages/fixtures/validation-python/mypy-authority/stubs/external/__init__.pyi b/packages/fixtures/validation-python/mypy-authority/stubs/external/__init__.pyi.fixture similarity index 100% rename from packages/fixtures/validation-python/mypy-authority/stubs/external/__init__.pyi rename to packages/fixtures/validation-python/mypy-authority/stubs/external/__init__.pyi.fixture diff --git a/packages/opcore/src/advanced/validation-composition.ts b/packages/opcore/src/advanced/validation-composition.ts index 78d1089..ed1430d 100644 --- a/packages/opcore/src/advanced/validation-composition.ts +++ b/packages/opcore/src/advanced/validation-composition.ts @@ -80,11 +80,18 @@ export function createDefaultValidationStatusPayload(options: { pythonProjectContexts?: readonly PythonProjectContext[]; }): ValidationStatusPayload { const graphMode = options.graphMode ?? "optional"; + const checks = validationChecksForRepoPolicy(options.repoRoot); return createValidationStatusPayload({ - checks: validationChecksForRepoPolicy(options.repoRoot), + checks, adapters: [ createRustValidationAdapterStatus(), - createPythonValidationAdapterStatus({ repoRoot: options.repoRoot, contexts: options.pythonProjectContexts }) + createPythonValidationAdapterStatus({ + repoRoot: options.repoRoot, + contexts: options.pythonProjectContexts, + activeCheckIds: checks + .filter((check) => (check.defaultScopes ?? check.supportedScopes).length > 0) + .map((check) => check.id) + }) ], graphMode, graphStatus: cliGraphStatus({ repoRoot: options.repoRoot }, graphMode) @@ -98,9 +105,9 @@ function defaultValidationAdapterOptions(repoRoot = process.cwd()): ValidationCo graphProviderClient, graphSessionFactory: createCliValidationGraphSessionFactory(graphProviderClient), defaultRepoRoot: repoRoot, - workspaceFactory: (repoRoot) => + workspaceFactory: (targetRepoRoot) => createNodeValidationWorkspace({ - repoRoot, + repoRoot: targetRepoRoot, skippedPathSegments: commonSkippedPathSegments }) }; @@ -122,3 +129,5 @@ function createCliValidationGraphProviderClient(): ValidationGraphProviderClient detectChanges: cliGraphDetectChanges }; } + +export { defaultValidationChecks, createPythonValidationChecks, createRustValidationChecks }; diff --git a/packages/opcore/src/doctor.ts b/packages/opcore/src/doctor.ts index a34295c..d85c0c9 100644 --- a/packages/opcore/src/doctor.ts +++ b/packages/opcore/src/doctor.ts @@ -3,7 +3,7 @@ import { createCommandRouterResult } from "@the-open-engine/opcore-contracts"; import { existsSync, readFileSync } from "node:fs"; import { join } from "node:path"; import { readOpcoreRuntimeInfo } from "./runtime-info.js"; -import { resolveRepo, validationPolicySummary } from "./status.js"; +import { resolveRepo } from "./status.js"; import { createRepoState } from "./status-state.js"; import { createDefaultValidationStatusPayload } from "./validation-composition.js"; @@ -63,7 +63,7 @@ export async function routeOpcoreDoctor(argv: readonly string[], parsed: ParsedC pythonProjectContexts: repoState.validation.pythonProjectContexts }); const runtimeInfo = readOpcoreRuntimeInfo(); - const policy = validationPolicySummary(resolution.resolution.root, validationStatus.adapterRegistry.checkIds); + const policy = repoState.validation.policy; const opcoreDoctor: OpcoreDoctorPayload = { schemaVersion: 1, runtime: runtimeInfo, diff --git a/packages/opcore/src/json-output.ts b/packages/opcore/src/json-output.ts index 3d9360b..b9d3704 100644 --- a/packages/opcore/src/json-output.ts +++ b/packages/opcore/src/json-output.ts @@ -29,6 +29,14 @@ function compactValidationManifest(manifest: ValidationResultManifest): Validati generatedAt: manifest.generatedAt }; if (manifest.durationMs !== undefined) compact.durationMs = manifest.durationMs; + const capabilityRuns = (manifest.runs ?? []).filter((run) => + run.pythonCapabilityRuns?.some((capabilityRun) => + (capabilityRun.capability === "ruff_lint" || capabilityRun.capability === "ruff_format") && + capabilityRun.state !== "not_applicable" && + capabilityRun.state !== "disabled" + ) === true + ); + if (capabilityRuns.length > 0) compact.runs = capabilityRuns; return compact; } diff --git a/packages/opcore/src/reporting.ts b/packages/opcore/src/reporting.ts index 8ea7808..5a31bf8 100644 --- a/packages/opcore/src/reporting.ts +++ b/packages/opcore/src/reporting.ts @@ -16,6 +16,8 @@ import type { OpcoreMetricReport, OpcoreMetricSignal, OpcoreRepoStatePayload, + PythonRuffValidationCapabilityRun, + PythonValidationCapabilityRun, ValidationDiagnostic, ValidationResult, ValidationSkippedCheck @@ -72,6 +74,8 @@ const rustGraphSignalsCheckId = "rust.graph-signals"; const rustFileLengthCheckId = "rust.file-length"; const pythonSyntaxCheckId = "python.syntax"; const pythonSourceHygieneCheckId = "python.source-hygiene"; +const pythonRuffLintCheckId = "python.ruff-lint"; +const pythonRuffFormatCheckId = "python.ruff-format"; const pythonTypesCheckId = "python.types"; const pythonImportGraphCheckId = "python.import-graph"; const pythonDeadCodeCheckId = "python.dead-code"; @@ -234,6 +238,28 @@ export function createOpcoreMetricReport(input: CreateOpcoreMetricReportInput): checkId: pythonRelevantTestsCheckId, diagnostics: diagnostics.filter((diagnostic) => diagnostic.code === "PY_RELEVANT_TESTS_ABSENT" && hasPath(diagnostic)) }); + addReceiptBackedDiagnosticSignal(signals, validationResult, { + id: "python.ruff_lint_findings", + title: "Python Ruff lint findings", + category: "python", + severity: "warning", + checkId: pythonRuffLintCheckId, + capability: "ruff_lint", + diagnostics: diagnostics.filter((diagnostic) => + diagnostic.category === "policy" && + diagnostic.code?.startsWith("PY_RUFF_LINT_") === true && + hasPath(diagnostic) + ) + }); + addReceiptBackedDiagnosticSignal(signals, validationResult, { + id: "python.ruff_format_findings", + title: "Python Ruff format drift", + category: "python", + severity: "warning", + checkId: pythonRuffFormatCheckId, + capability: "ruff_format", + diagnostics: diagnostics.filter((diagnostic) => diagnostic.code === "PY_RUFF_FORMAT_DRIFT" && hasPath(diagnostic)) + }); addDiagnosticSignal(signals, { id: "python.dead_exports", title: "Python exported symbols without incoming CALLS evidence", @@ -723,6 +749,102 @@ function addMappedDiagnosticSignal( }); } +function addReceiptBackedDiagnosticSignal( + signals: OpcoreMetricSignal[], + result: ValidationResult | undefined, + args: { + id: string; + title: string; + category: OpcoreMetricSignal["category"]; + severity: OpcoreMetricSignal["severity"]; + checkId: string; + capability: "ruff_lint" | "ruff_format"; + diagnostics: readonly ValidationDiagnostic[]; + } +): void { + const capabilityRuns = uniquePythonCapabilityRuns([ + ...(result?.pythonCapabilityRuns ?? []), + ...(result?.manifest?.runs ?? []).flatMap((run) => run.pythonCapabilityRuns ?? []) + ]) + .filter(isProvenRuffFindingsRun) + .filter((run) => + run.checkId === args.checkId && + run.capability === args.capability + ); + const count = capabilityRuns.reduce((total, run) => total + run.diagnosticCount, 0); + if (count === 0) return; + const evidence = args.diagnostics.length > 0 + ? args.diagnostics.flatMap((diagnostic) => diagnosticEvidence(diagnostic, args.checkId)) + : capabilityRuns + .map((run): OpcoreMetricEvidence | undefined => { + const path = run.sourcePaths?.[0]; + if (path === undefined) return undefined; + return { + source: "validation_manifest", + path, + message: `${args.checkId} reported ${run.diagnosticCount} finding${run.diagnosticCount === 1 ? "" : "s"} for ${path}.`, + checkId: args.checkId + }; + }) + .filter((entry): entry is OpcoreMetricEvidence => entry !== undefined); + if (evidence.length === 0) return; + signals.push({ + id: args.id, + title: args.title, + category: args.category, + severity: args.severity, + count, + evidence + }); +} + +function uniquePythonCapabilityRuns( + runs: readonly PythonValidationCapabilityRun[] +): readonly PythonValidationCapabilityRun[] { + return [...new Map(runs.map((run) => [JSON.stringify(run), run])).values()]; +} + +function isProvenRuffFindingsRun( + run: PythonValidationCapabilityRun +): run is PythonRuffValidationCapabilityRun { + const sha256Identity = /^sha256:[a-f0-9]{64}$/u; + return run.capability !== "types" && + run.state === "findings" && + run.checkId === (run.capability === "ruff_lint" ? "python.ruff-lint" : "python.ruff-format") && + typeof run.projectKey === "string" && + sha256Identity.test(run.projectKey) && + typeof run.contextFingerprint === "string" && + sha256Identity.test(run.contextFingerprint) && + typeof run.afterStateManifestFingerprint === "string" && + sha256Identity.test(run.afterStateManifestFingerprint) && + Array.isArray(run.sourcePaths) && + run.sourcePaths.length > 0 && + Array.isArray(run.configPaths) && + typeof run.executable === "string" && + run.executable.length > 0 && + Array.isArray(run.argv) && + run.argv.length > 0 && + run.argv[0] === run.executable && + run.command === run.argv.join(" ") && + typeof run.cwd === "string" && + run.cwd.length > 0 && + typeof run.toolVersion === "string" && + run.toolVersion.length > 0 && + run.toolSource !== undefined && + run.termination === "exited" && + run.exitCode === 1 && + run.durationMs > 0 && + run.diagnosticCount > 0 && + Array.isArray(run.invocations) && + run.invocations.length > 0 && + run.invocations.every((invocation) => + invocation.argv[0] === run.executable && + invocation.termination === "exited" && + (invocation.exitCode === 0 || invocation.exitCode === 1) && + invocation.durationMs > 0 + ); +} + function addUnsupportedLanguageSignal(signals: OpcoreMetricSignal[], repoState: OpcoreRepoStatePayload): void { if (repoState.coverage.unsupported.totalFiles <= 0) return; const evidence = repoState.coverage.unsupported.stacks.flatMap((stack) => diff --git a/packages/opcore/src/status-state.ts b/packages/opcore/src/status-state.ts index 6de0d0c..df352eb 100644 --- a/packages/opcore/src/status-state.ts +++ b/packages/opcore/src/status-state.ts @@ -1,5 +1,9 @@ import type { GraphProviderStatus, OpcoreRepoStatePayload } from "@the-open-engine/opcore-contracts"; import { + PYTHON_RELEVANT_TESTS_CHECK_ID, + PYTHON_RUFF_FORMAT_CHECK_ID, + PYTHON_RUFF_LINT_CHECK_ID, + PYTHON_TYPES_CHECK_ID, createNodePythonProjectWorkspace, isPythonSourcePath, resolvePythonProjectContexts @@ -11,6 +15,7 @@ import { readRepoCensus, type CensusTraversalFailure } from "./status-census.js"; +import { validationChecksForRepoPolicy } from "./repo-validation-policy.js"; import { computeCoverage } from "./status-coverage.js"; import type { RepoResolution } from "./status-repo.js"; import { validationPolicySummary, validationSummary } from "./status-validation.js"; @@ -26,13 +31,19 @@ interface StatusWarningOptions { export async function createRepoState(resolution: RepoResolution): Promise { const census = readRepoCensus(resolution); const coverage = computeCoverage(census.files); + const checks = validationChecksForRepoPolicy(resolution.root); + const activeCheckIds = checks + .filter((check) => (check.defaultScopes ?? check.supportedScopes).length > 0) + .map((check) => check.id); const pythonTargets = census.files.filter(isPythonSourcePath); + const pythonToolKinds = activePythonToolKinds(activeCheckIds); const pythonProjectContexts = pythonTargets.length === 0 ? [] : await resolvePythonProjectContexts({ repoRoot: resolution.root, targets: pythonTargets, - workspace: createNodePythonProjectWorkspace(resolution.root) + workspace: createNodePythonProjectWorkspace(resolution.root), + toolKinds: pythonToolKinds }); const validationStatus = createDefaultValidationStatusPayload({ repoRoot: resolution.root, @@ -40,7 +51,7 @@ export async function createRepoState(resolution: RepoResolution): Promise(); + if (activeCheckIds.includes(PYTHON_TYPES_CHECK_ID)) { + toolKinds.add("mypy"); + toolKinds.add("pyright"); + } + if (activeCheckIds.includes(PYTHON_RUFF_LINT_CHECK_ID) || activeCheckIds.includes(PYTHON_RUFF_FORMAT_CHECK_ID)) { + toolKinds.add("ruff"); + } + if (activeCheckIds.includes(PYTHON_RELEVANT_TESTS_CHECK_ID)) { + toolKinds.add("pytest"); + } + return [...toolKinds]; +} + function graphSummary(graphStatus: GraphProviderStatus): OpcoreRepoStatePayload["graph"] { return { state: graphStatus.state, diff --git a/packages/opcore/src/status-validation.ts b/packages/opcore/src/status-validation.ts index 965f980..3cd9353 100644 --- a/packages/opcore/src/status-validation.ts +++ b/packages/opcore/src/status-validation.ts @@ -4,6 +4,13 @@ import type { PythonProjectContext, ValidationAdapterRuntimeStatus } from "@the-open-engine/opcore-contracts"; +import { + PYTHON_RELEVANT_TESTS_CHECK_ID, + PYTHON_RUFF_FORMAT_CHECK_ID, + PYTHON_RUFF_LINT_CHECK_ID, + PYTHON_SYNTAX_CHECK_ID, + PYTHON_TYPES_CHECK_ID +} from "@the-open-engine/opcore-validation-python"; import { existsSync } from "node:fs"; import { join } from "node:path"; import { readOpcoreRepoConfig } from "./repo-validation-config.js"; @@ -17,19 +24,22 @@ export function validationSummary( pythonProjectContexts: readonly PythonProjectContext[] = [] ): OpcoreRepoStatePayload["validation"] { const adapters = validationStatus.adapterRegistry.adapters ?? []; - const degraded = adapters.flatMap((adapter) => degradedToolchains(adapter)); + const degraded = adapters.flatMap((adapter) => degradedToolchains(adapter, policy.configuredChecks)); return { ready: validationStatus.ready, checkCount: validationStatus.adapterRegistry.checkIds.length, policy, pythonProjectContexts, - adapters: adapters.map((adapter) => ({ - adapter: adapter.adapter, - status: adapter.status, - checkCount: adapter.checkIds.length, - degradedChecks: (adapter.degradedChecks ?? []).map((check) => check.checkId), - missingTools: (adapter.toolchain ?? []).filter((tool) => !tool.available).map((tool) => tool.tool) - })), + adapters: adapters.map((adapter) => { + const degradedAdapterTools = degradedToolchains(adapter, policy.configuredChecks); + return { + adapter: adapter.adapter, + status: adapter.status, + checkCount: adapter.checkIds.length, + degradedChecks: (adapter.degradedChecks ?? []).map((check) => check.checkId), + missingTools: [...new Set(degradedAdapterTools.map((tool) => tool.tool))] + }; + }), degradedToolchains: relevantDegradedToolchainsForCoverage(coverage, degraded) }; } @@ -52,13 +62,35 @@ export function validationPolicySummary( } function degradedToolchains( - adapter: ValidationAdapterRuntimeStatus + adapter: ValidationAdapterRuntimeStatus, + activeCheckIds: readonly string[] ): OpcoreRepoStatePayload["validation"]["degradedToolchains"] { + const activePythonTools = pythonToolsForActiveChecks(activeCheckIds); return (adapter.toolchain ?? []) .filter((tool) => !tool.available) + .filter((tool) => adapter.adapter !== "python" || activePythonTools.has(tool.tool)) .map((tool) => ({ adapter: adapter.adapter, tool: tool.tool, ...(tool.failureMessage ? { failureMessage: tool.failureMessage } : {}) })); } + +function pythonToolsForActiveChecks(activeCheckIds: readonly string[]): ReadonlySet { + const active = new Set(activeCheckIds); + const tools = new Set(); + if (active.has(PYTHON_SYNTAX_CHECK_ID)) tools.add("python"); + if (active.has(PYTHON_TYPES_CHECK_ID)) { + tools.add("python"); + tools.add("mypy"); + tools.add("pyright"); + } + if (active.has(PYTHON_RUFF_LINT_CHECK_ID) || active.has(PYTHON_RUFF_FORMAT_CHECK_ID)) { + tools.add("ruff"); + } + if (active.has(PYTHON_RELEVANT_TESTS_CHECK_ID)) { + tools.add("python"); + tools.add("pytest"); + } + return tools; +} diff --git a/packages/opcore/src/validation-composition.ts b/packages/opcore/src/validation-composition.ts index 31f000d..04f423b 100644 --- a/packages/opcore/src/validation-composition.ts +++ b/packages/opcore/src/validation-composition.ts @@ -75,13 +75,15 @@ export function createDefaultValidationStatusPayload(options: { pythonProjectContexts?: readonly PythonProjectContext[]; }): ValidationStatusPayload { const graphMode = options.graphMode ?? "optional"; + const checks = validationChecksForRepoPolicy(options.repoRoot); return createValidationStatusPayload({ - checks: validationChecksForRepoPolicy(options.repoRoot), + checks, adapters: [ createRustValidationAdapterStatus(), createPythonValidationAdapterStatus({ repoRoot: options.repoRoot, - contexts: options.pythonProjectContexts + contexts: options.pythonProjectContexts, + activeCheckIds: activeDefaultCheckIds(checks) }) ], graphMode, @@ -89,6 +91,14 @@ export function createDefaultValidationStatusPayload(options: { }); } +function activeDefaultCheckIds( + checks: readonly ReturnType[number][] +): readonly string[] { + return checks + .filter((check) => (check.defaultScopes ?? check.supportedScopes).length > 0) + .map((check) => check.id); +} + function defaultValidationAdapterOptions(repoRoot = process.cwd()): ValidationCommandAdapterOptions { const graphProviderClient = createOpcoreValidationGraphProviderClient(); return { @@ -97,9 +107,9 @@ function defaultValidationAdapterOptions(repoRoot = process.cwd()): ValidationCo graphSessionFactory: createOpcoreValidationGraphSessionFactory(graphProviderClient), runtime: opcorePublicValidationRuntimePolicy, defaultRepoRoot: repoRoot, - workspaceFactory: (repoRoot) => + workspaceFactory: (targetRepoRoot) => createNodeValidationWorkspace({ - repoRoot, + repoRoot: targetRepoRoot, skippedPathSegments: commonSkippedPathSegments }) }; @@ -121,3 +131,5 @@ export function createOpcoreValidationGraphProviderClient(): ValidationGraphProv detectChanges: opcoreGraphDetectChanges }; } + +export { defaultValidationChecks, createPythonValidationChecks, createRustValidationChecks }; diff --git a/packages/validation-policy/src/factory.ts b/packages/validation-policy/src/factory.ts index ac1ae36..806fad7 100644 --- a/packages/validation-policy/src/factory.ts +++ b/packages/validation-policy/src/factory.ts @@ -29,6 +29,13 @@ export function validationChecksForRepoPolicyAndCoverage( return validationChecksForRepoPolicy(repoRoot, options).filter((check) => adapters.has(check.adapter)); } +export function validationChecksForConfigPolicy( + config: OpcoreRepoConfig, + options: OpcoreRepoValidationPolicyOptions = {} +): readonly ValidationCheckDefinition[] { + return applyValidationPolicy(createBuiltInValidationChecks(config, options), config); +} + export function createBuiltInValidationChecks( config?: OpcoreRepoConfig, options: OpcoreRepoValidationPolicyOptions = {}, @@ -69,6 +76,13 @@ function validationChecksForRepoConfig( const builtIns = createBuiltInValidationChecks(config, options, repoRoot); const packChecks = loadRepoCheckPacks(repoRoot, config).flatMap((pack) => pack.checks); const available = [...builtIns, ...packChecks]; + return applyValidationPolicy(available, config); +} + +function applyValidationPolicy( + available: readonly ValidationCheckDefinition[], + config: OpcoreRepoConfig +): readonly ValidationCheckDefinition[] { createValidationCheckRegistry(available); const knownCheckIds = new Set(available.map((check) => check.id)); @@ -84,8 +98,9 @@ function validationChecksForRepoConfig( 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) => applyDisabledPolicy(check, disabled)) + .filter((check): check is ValidationCheckDefinition => check !== undefined) .map((check) => applyPathPolicy(check, config.validation.pathPolicy, filteredContexts)); createValidationCheckRegistry(checks); return checks; @@ -151,6 +166,20 @@ function applyDefaultScopePolicy(check: ValidationCheckDefinition, defaults: Rea return { ...check, defaultScopes: check.supportedScopes }; } +function applyDisabledPolicy( + check: ValidationCheckDefinition, + disabled: ReadonlySet +): ValidationCheckDefinition | undefined { + if (!disabled.has(check.id)) return check; + if (check.inactiveResult === undefined) return undefined; + return { + ...check, + defaultScopes: [], + inactiveStateWhenUnselected: "disabled", + run: (context) => check.inactiveResult?.(context, "disabled") + }; +} + function applyPathPolicy( check: ValidationCheckDefinition, pathPolicy: OpcorePathPolicy | undefined, diff --git a/packages/validation-python/src/check-ids.ts b/packages/validation-python/src/check-ids.ts index 49d4060..dd59c78 100644 --- a/packages/validation-python/src/check-ids.ts +++ b/packages/validation-python/src/check-ids.ts @@ -1,5 +1,7 @@ export const PYTHON_SYNTAX_CHECK_ID = "python.syntax"; export const PYTHON_SOURCE_HYGIENE_CHECK_ID = "python.source-hygiene"; +export const PYTHON_RUFF_LINT_CHECK_ID = "python.ruff-lint"; +export const PYTHON_RUFF_FORMAT_CHECK_ID = "python.ruff-format"; export const PYTHON_TYPES_CHECK_ID = "python.types"; export const PYTHON_IMPORT_GRAPH_CHECK_ID = "python.import-graph"; export const PYTHON_DEAD_CODE_CHECK_ID = "python.dead-code"; @@ -8,6 +10,8 @@ export const PYTHON_RELEVANT_TESTS_CHECK_ID = "python.relevant-tests"; export const pythonValidationCheckIds = [ PYTHON_SYNTAX_CHECK_ID, PYTHON_SOURCE_HYGIENE_CHECK_ID, + PYTHON_RUFF_LINT_CHECK_ID, + PYTHON_RUFF_FORMAT_CHECK_ID, PYTHON_TYPES_CHECK_ID, PYTHON_IMPORT_GRAPH_CHECK_ID, PYTHON_DEAD_CODE_CHECK_ID, diff --git a/packages/validation-python/src/environment-resolution.ts b/packages/validation-python/src/environment-resolution.ts index ef6c181..c53b20c 100644 --- a/packages/validation-python/src/environment-resolution.ts +++ b/packages/validation-python/src/environment-resolution.ts @@ -28,6 +28,7 @@ export interface PythonProjectEnvironmentOptions { env?: Readonly>; interpreterArgv?: readonly string[]; toolArgv?: Partial>; + toolKinds?: readonly Exclude[]; target: PythonProjectTarget; managers: readonly PythonProjectManagerEvidence[]; toolConfigs: Readonly>; @@ -205,7 +206,7 @@ async function resolveTools( managerCandidates: ManagerExecutableCandidates, reasons: PythonProjectContextReason[] ): Promise { - const toolKinds: PythonProjectToolKind[] = ["mypy", "pyright", "ruff", "pytest"]; + const toolKinds = (options.toolKinds ?? ["mypy", "pyright", "ruff", "pytest"]) as readonly Exclude[]; const tools: PythonProjectToolProvenance[] = []; for (const tool of toolKinds) { const configFile = tool === "mypy" || tool === "pyright" || tool === "ruff" || tool === "pytest" @@ -232,7 +233,8 @@ async function resolveTools( 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"], { + const probePrefix = tool === "ruff" ? withoutToolConfigOptions(prefix, tool) : prefix; + const result = await processProbe.run(command, [...probePrefix, "--version"], { cwd: projectCwd(options), env, timeoutMs: options.timeoutMs ?? 10000 }); if (!result.ok) { @@ -698,6 +700,24 @@ function safeToolOptionPrefix(tool: Exclude, arg return true; } +function withoutToolConfigOptions( + args: readonly string[], + tool: Exclude +): readonly string[] { + const options = toolConfigOptions[tool]; + const result: string[] = []; + for (let index = 0; index < args.length; index += 1) { + const argument = args[index]; + if (options.includes(argument)) { + index += 1; + continue; + } + if (options.some((option) => argument.startsWith(`${option}=`))) continue; + result.push(argument); + } + return result; +} + function safeToolConfigPaths( tool: Exclude, args: readonly string[], diff --git a/packages/validation-python/src/index.ts b/packages/validation-python/src/index.ts index 1ff1cbc..04753ac 100644 --- a/packages/validation-python/src/index.ts +++ b/packages/validation-python/src/index.ts @@ -1,8 +1,16 @@ import type { ValidationCheckDefinition, ValidationCheckResult } from "@the-open-engine/opcore-validation"; import type { ValidationDiagnostic } from "@the-open-engine/opcore-contracts"; +import { + PYTHON_RELEVANT_TESTS_CHECK_ID, + PYTHON_RUFF_FORMAT_CHECK_ID, + PYTHON_RUFF_LINT_CHECK_ID, + PYTHON_TYPES_CHECK_ID +} from "./check-ids.js"; import { createDeadCodeCheck } from "./dead-code-check.js"; import { createImportGraphCheck } from "./import-graph-check.js"; import { createRelevantTestsCheck } from "./relevant-tests-check.js"; +import { createRuffFormatCheck, type PythonRuffFormatCheckOptions } from "./ruff-format-check.js"; +import { createRuffLintCheck, type PythonRuffLintCheckOptions } from "./ruff-lint-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"; @@ -13,6 +21,8 @@ export { PYTHON_DEAD_CODE_CHECK_ID, PYTHON_IMPORT_GRAPH_CHECK_ID, PYTHON_RELEVANT_TESTS_CHECK_ID, + PYTHON_RUFF_FORMAT_CHECK_ID, + PYTHON_RUFF_LINT_CHECK_ID, PYTHON_SOURCE_HYGIENE_CHECK_ID, PYTHON_SYNTAX_CHECK_ID, PYTHON_TYPES_CHECK_ID, @@ -35,10 +45,19 @@ export { } from "./project-workspace.js"; export type { PythonProjectProcessProbe } from "./environment-resolution.js"; export { createPythonValidationAdapterStatus, type PythonValidationToolchainOptions } from "./toolchain.js"; +export { + createRuffFormatCheck, + type PythonRuffFormatCheckOptions +} from "./ruff-format-check.js"; +export { + createRuffLintCheck, + type PythonRuffLintCheckOptions +} from "./ruff-lint-check.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, PythonRuffLintCheckOptions, PythonRuffFormatCheckOptions { importAnalyzer?: PythonImportAnalyzer; } @@ -60,6 +79,8 @@ export function createPythonValidationChecks( return [ createSyntaxCheck(options, resolveContexts), createSourceHygieneCheck(), + createRuffLintCheck(options, resolveContexts, resolveSources), + createRuffFormatCheck(options, resolveContexts, resolveSources), createTypeCheck(options, resolveContexts, resolveSources), createImportGraphCheck(resolveSources), createDeadCodeCheck(resolveRoots), @@ -76,10 +97,26 @@ function withPythonProjectContexts( run: async (context) => { const result = await check.run(context); if (pythonInputSet(context).length === 0) return result; - const pythonProjectContexts = await resolveContexts(context); + const pythonProjectContexts = await resolveContexts(context, undefined, toolKindsForExecution(context.selectedCheckIds, check.id)); if (result === undefined) return { diagnostics: [], pythonProjectContexts }; if (Array.isArray(result)) return { diagnostics: result as readonly ValidationDiagnostic[], pythonProjectContexts }; return { ...(result as ValidationCheckResult), pythonProjectContexts }; } }; } + +function toolKindsForCheck(checkId: string): readonly ("mypy" | "pyright" | "ruff" | "pytest")[] { + if (checkId === PYTHON_RUFF_LINT_CHECK_ID || checkId === PYTHON_RUFF_FORMAT_CHECK_ID) return ["ruff"]; + if (checkId === PYTHON_TYPES_CHECK_ID) return ["mypy", "pyright"]; + if (checkId === PYTHON_RELEVANT_TESTS_CHECK_ID) return ["pytest"]; + return []; +} + +function toolKindsForExecution( + requestedChecks: readonly string[] | undefined, + checkId: string +): readonly ("mypy" | "pyright" | "ruff" | "pytest")[] { + const current = toolKindsForCheck(checkId); + if (current.length === 0 || requestedChecks === undefined) return current; + return [...new Set(requestedChecks.flatMap((requestedCheckId) => toolKindsForCheck(requestedCheckId)))]; +} diff --git a/packages/validation-python/src/node-shims.d.ts b/packages/validation-python/src/node-shims.d.ts index a170f0e..f84946f 100644 --- a/packages/validation-python/src/node-shims.d.ts +++ b/packages/validation-python/src/node-shims.d.ts @@ -122,6 +122,7 @@ declare namespace NodeJS { declare const process: { env: Record; execPath: string; + pid: number; 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 a0fe552..421ef74 100644 --- a/packages/validation-python/src/process.ts +++ b/packages/validation-python/src/process.ts @@ -32,6 +32,12 @@ export interface PythonToolSignalResult extends PythonToolRunBase { signal: string; } +export interface PythonToolOverflowResult extends PythonToolRunBase { + termination: "overflow"; + ok: false; + failureMessage: string; +} + export interface PythonToolSpawnErrorResult extends PythonToolRunBase { termination: "spawn_error"; ok: false; @@ -42,6 +48,7 @@ export type PythonToolRunResult = | PythonToolExitedResult | PythonToolTimeoutResult | PythonToolSignalResult + | PythonToolOverflowResult | PythonToolSpawnErrorResult; export interface PythonToolRunOptions { @@ -191,7 +198,10 @@ function classifyProcessResult( 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 (capture.outputFailure !== undefined) { + return { ...base, termination: "overflow", ok: false, failureMessage: capture.outputFailure }; + } + const failure = capture.spawnFailure ?? capture.inputFailure; 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` }; } diff --git a/packages/validation-python/src/project-context.ts b/packages/validation-python/src/project-context.ts index d244440..5bac910 100644 --- a/packages/validation-python/src/project-context.ts +++ b/packages/validation-python/src/project-context.ts @@ -27,6 +27,7 @@ export interface ResolvePythonProjectContextsOptions { workspace: PythonProjectWorkspace; interpreterArgv?: readonly string[]; toolArgv?: Partial>; + toolKinds?: readonly Exclude[]; env?: Readonly>; platform?: string; architecture?: string; @@ -74,13 +75,20 @@ async function resolveTarget( 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); + const config = filterStaticConfigReasons( + await readPythonStaticProjectConfig(options.workspace, discovery.projectRoot, visibleFiles, { + target, + ...(options.toolKinds === undefined ? {} : { toolKinds: options.toolKinds }) + }), + options.toolKinds + ); inputs = { config, environment: refusedEnvironment() }; } else { - let inputPromise = projectInputs.get(discovery.projectRoot); + const inputKey = `${discovery.projectRoot}\0${target}`; + let inputPromise = projectInputs.get(inputKey); if (inputPromise === undefined) { - inputPromise = resolveProjectInputs(options, discovery.projectRoot, visibleFiles); - projectInputs.set(discovery.projectRoot, inputPromise); + inputPromise = resolveProjectInputs(options, discovery.projectRoot, target, visibleFiles); + projectInputs.set(inputKey, inputPromise); } inputs = await inputPromise; } @@ -140,9 +148,16 @@ interface ResolvedProjectInputs { async function resolveProjectInputs( options: ResolvePythonProjectContextsOptions, projectRoot: string, + target: string, visibleFiles: readonly string[] ): Promise { - const config = await readPythonStaticProjectConfig(options.workspace, projectRoot, visibleFiles); + const config = filterStaticConfigReasons( + await readPythonStaticProjectConfig(options.workspace, projectRoot, visibleFiles, { + target, + ...(options.toolKinds === undefined ? {} : { toolKinds: options.toolKinds }) + }), + options.toolKinds + ); const environment = config.reasons.some(isPathRefusalReason) ? refusedEnvironment() : await resolvePythonProjectEnvironment({ @@ -152,6 +167,7 @@ async function resolveProjectInputs( target: config.target, managers: config.managers, toolConfigs: config.toolConfigs, + ...(options.toolKinds === undefined ? {} : { toolKinds: options.toolKinds }), ...(config.buildSystem === undefined ? {} : { buildSystem: config.buildSystem }), ...(options.interpreterArgv === undefined ? {} : { interpreterArgv: options.interpreterArgv }), ...(options.toolArgv === undefined ? {} : { toolArgv: options.toolArgv }), @@ -164,6 +180,18 @@ async function resolveProjectInputs( return { config, environment }; } +function filterStaticConfigReasons( + config: Awaited>, + toolKinds: ResolvePythonProjectContextsOptions["toolKinds"] +): Awaited> { + if (toolKinds === undefined) return config; + const selected = new Set(["python", "build", ...toolKinds]); + return { + ...config, + reasons: config.reasons.filter((reason) => reason.tool === undefined || selected.has(reason.tool)) + }; +} + function refusedEnvironment(): Awaited> { return { tools: [], reasons: [], fingerprintInput: { refused: true } }; } diff --git a/packages/validation-python/src/project-groups.ts b/packages/validation-python/src/project-groups.ts new file mode 100644 index 0000000..1c2f47c --- /dev/null +++ b/packages/validation-python/src/project-groups.ts @@ -0,0 +1,24 @@ +import type { PythonProjectContext } from "@the-open-engine/opcore-contracts"; + +export interface PythonProjectGroup { + context: PythonProjectContext; + targets: readonly string[]; +} + +export function groupPythonProjectContexts( + contexts: readonly PythonProjectContext[] +): readonly PythonProjectGroup[] { + const groups = new Map(); + for (const context of contexts) { + const groupKey = `${context.projectKey}\0${context.tools + .map((tool) => `${tool.tool}:${tool.configFile ?? ""}`) + .sort() + .join(",")}`; + const group = groups.get(groupKey) ?? { context, targets: [] }; + group.targets.push(context.target); + groups.set(groupKey, group); + } + return [...groups.values()] + .map((group) => ({ context: group.context, targets: [...new Set(group.targets)].sort() })) + .sort((left, right) => left.context.projectRoot.localeCompare(right.context.projectRoot)); +} diff --git a/packages/validation-python/src/python-check-result.ts b/packages/validation-python/src/python-check-result.ts new file mode 100644 index 0000000..ea9fc22 --- /dev/null +++ b/packages/validation-python/src/python-check-result.ts @@ -0,0 +1,12 @@ +import type { ValidationCheckResult } from "@the-open-engine/opcore-validation"; + +export function appendPythonCapabilityRuns( + result: ValidationCheckResult, + priorRuns: NonNullable +): ValidationCheckResult { + if (priorRuns.length === 0) return result; + return { + ...result, + pythonCapabilityRuns: [...priorRuns, ...(result.pythonCapabilityRuns ?? [])] + }; +} diff --git a/packages/validation-python/src/python-context-result.ts b/packages/validation-python/src/python-context-result.ts new file mode 100644 index 0000000..914bd33 --- /dev/null +++ b/packages/validation-python/src/python-context-result.ts @@ -0,0 +1,19 @@ +import type { ValidationCheckResult } from "@the-open-engine/opcore-validation"; +import { diagnostic } from "./diagnostics.js"; + +export function missingPythonProjectContextResult( + code: string, + 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, + message + })] + }; +} diff --git a/packages/validation-python/src/python-execution-workspace.ts b/packages/validation-python/src/python-execution-workspace.ts new file mode 100644 index 0000000..415681d --- /dev/null +++ b/packages/validation-python/src/python-execution-workspace.ts @@ -0,0 +1,248 @@ +import type { + PythonProjectContext, + PythonProjectToolProvenance, + ValidationDiagnosticToolProvenance +} from "@the-open-engine/opcore-contracts"; +import type { + ValidationFileReadStatus, + ValidationFileView +} from "@the-open-engine/opcore-validation"; +import { mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs"; +import { mkdir } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join, relative, resolve, sep } from "node:path"; +import { pythonProjectDigest } from "./project-fingerprint.js"; + +export interface PythonExecutionWorkspaceEvidence { + projectCwdRelative: string; + afterStateFingerprint: string; + sourcePaths: readonly string[]; + configPaths: readonly string[]; +} + +export interface MaterializedPythonExecutionWorkspace extends PythonExecutionWorkspaceEvidence { + root: string; + runtimeRoot: string; + projectCwd: string; + afterStateContentByPath: ReadonlyMap; + cleanup(): void; +} + +export interface PythonExecutionWorkspaceRecord { + path: string; + status: ValidationFileReadStatus; + checksum?: string; + content?: string; + materializedContent?: string; +} + +export interface PythonExecutionWorkspacePreparation { + project: PythonProjectContext; + targets: readonly string[]; + sourcePaths: readonly string[]; + configPaths: readonly string[]; + afterStateFingerprint: string; + records: readonly PythonExecutionWorkspaceRecord[]; +} + +interface PythonExecutionWorkspaceMaterializationOptions { + tempPrefix: string; + generatedProjectFiles?: readonly { + path: string; + content: string; + }[]; + runtimeDirectories?: readonly string[]; +} + +export async function readPythonExecutionWorkspaceRecords( + fileView: ValidationFileView, + paths: readonly string[] +): Promise { + const records: PythonExecutionWorkspaceRecord[] = []; + for (const path of uniqueSorted(paths)) { + const state = await fileView.readAfter(path); + records.push({ + path, + status: state.status, + ...(state.status === "found" ? { checksum: state.checksum, content: state.content } : {}) + }); + } + return records; +} + +export function preparePythonExecutionWorkspace(args: { + project: PythonProjectContext; + targets: readonly string[]; + sourcePaths: readonly string[]; + configPaths: readonly string[]; + records: readonly PythonExecutionWorkspaceRecord[]; +}): PythonExecutionWorkspacePreparation { + const targets = uniqueSorted(args.targets); + const sourcePaths = uniqueSorted(args.sourcePaths); + const configPaths = uniqueSorted(args.configPaths); + const records = [...args.records].sort((left, right) => left.path.localeCompare(right.path)); + const portableRecords = records.map(({ content, materializedContent: _materializedContent, ...record }) => ({ + ...record, + ...(record.status === "found" && record.checksum === undefined && content !== undefined + ? { checksum: pythonProjectDigest(content) } + : {}) + })); + return { + project: args.project, + targets, + sourcePaths, + configPaths, + afterStateFingerprint: pythonProjectDigest({ + projectKey: args.project.projectKey, + contextFingerprint: args.project.contextFingerprint, + projectRoot: args.project.projectRoot, + targets, + sourcePaths, + configPaths, + records: portableRecords + }), + records + }; +} + +export async function materializePreparedPythonExecutionWorkspace( + preparation: PythonExecutionWorkspacePreparation, + options: PythonExecutionWorkspaceMaterializationOptions +): Promise { + const tempRoot = mkdtempSync(join(tmpdir(), options.tempPrefix)); + const rawRoot = join(tempRoot, "repo"); + try { + await mkdir(rawRoot, { recursive: true }); + const root = realpathSync(rawRoot); + const runtimeRoot = realpathSync(tempRoot); + for (const record of preparation.records) { + if (record.status !== "found") continue; + const content = record.materializedContent ?? record.content; + if (content === undefined) { + throw new Error(`Found after-state Python workspace record omitted content: ${record.path}`); + } + await writeMaterializedFile(root, record.path, content); + } + const projectCwdRelative = preparation.project.projectRoot; + const projectCwd = projectCwdRelative === "." ? root : resolveRepoPath(root, projectCwdRelative); + await mkdir(projectCwd, { recursive: true }); + for (const file of options.generatedProjectFiles ?? []) { + await writeMaterializedFile(projectCwd, file.path, file.content); + } + for (const path of options.runtimeDirectories ?? []) { + await mkdir(resolveRepoPath(runtimeRoot, path), { recursive: true }); + } + return { + root, + runtimeRoot, + projectCwd, + projectCwdRelative, + afterStateFingerprint: preparation.afterStateFingerprint, + sourcePaths: preparation.sourcePaths, + configPaths: preparation.configPaths, + 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 relativeProjectPath(path: string, projectRoot: string): string { + const normalizedPath = normalizePortablePath(path); + const normalizedProjectRoot = normalizeMaterializedProjectRoot(projectRoot, normalizedPath); + if (normalizedProjectRoot === ".") return normalizedPath; + return normalizedPath.startsWith(`${normalizedProjectRoot}/`) + ? normalizedPath.slice(normalizedProjectRoot.length + 1) + : normalizedPath; +} + +export function selectedRepoRelativeDiagnosticPath( + path: string, + checkerCwd: string, + workspaceRoot: string, + selectedSourcePaths: readonly string[] +): string | undefined { + const absolute = canonicalExistingPath(resolve(checkerCwd, path)); + const canonicalWorkspaceRoot = canonicalExistingPath(workspaceRoot); + const relativePath = relative(canonicalWorkspaceRoot, absolute).replaceAll("\\", "/"); + if ( + relativePath.length === 0 || + relativePath === ".." || + relativePath.startsWith("../") || + isAbsolutePortablePath(relativePath) + ) { + return undefined; + } + return new Set(selectedSourcePaths.map(normalizePortablePath)).has(relativePath) + ? relativePath + : undefined; +} + +function canonicalExistingPath(path: string): string { + try { + return realpathSync(path); + } catch { + return path; + } +} + +export function toolProvenance(tool: PythonProjectToolProvenance): ValidationDiagnosticToolProvenance { + return { + name: tool.tool, + command: tool.argv.join(" "), + ...(tool.version === undefined ? {} : { version: tool.version }), + source: tool.source, + cwd: tool.cwd + }; +} + +async function writeMaterializedFile(root: string, path: string, content: string): Promise { + const absolutePath = resolveRepoPath(root, path); + await mkdir(dirname(absolutePath), { recursive: true }); + writeFileSync(absolutePath, content); +} + +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}`); + } + return absolutePath; +} + +function normalizeMaterializedProjectRoot(projectRoot: string, path: string): string { + const normalizedRoot = normalizePortablePath(projectRoot).replace(/\/+$/u, ""); + if (normalizedRoot.length === 0 || normalizedRoot === ".") return "."; + if (!isAbsolutePortablePath(normalizedRoot)) return normalizedRoot; + const pathDirectory = portableDirname(path); + if (pathDirectory === ".") return "."; + const rootSegments = normalizedRoot.split("/").filter(Boolean); + const pathSegments = pathDirectory.split("/").filter(Boolean); + if (pathSegments.length === 0 || pathSegments.length > rootSegments.length) return path; + const rootSuffix = rootSegments.slice(-pathSegments.length).join("/"); + return rootSuffix === pathDirectory ? pathDirectory : path; +} + +export function normalizePortablePath(path: string): string { + return path.replaceAll("\\", "/").replace(/\/+$/u, "") || "."; +} + +export function isAbsolutePortablePath(path: string): boolean { + return path.startsWith("/") || /^[A-Za-z]:\//u.test(path) || path.startsWith("//"); +} + +export function portableDirname(path: string): string { + const separatorIndex = path.lastIndexOf("/"); + return separatorIndex < 0 ? "." : path.slice(0, separatorIndex) || "/"; +} + +function uniqueSorted(values: readonly string[]): readonly string[] { + return [...new Set(values)].sort(); +} diff --git a/packages/validation-python/src/ruff-capability-run.ts b/packages/validation-python/src/ruff-capability-run.ts new file mode 100644 index 0000000..05e006a --- /dev/null +++ b/packages/validation-python/src/ruff-capability-run.ts @@ -0,0 +1,170 @@ +import { + PYTHON_VALIDATION_CAPABILITY_RUN_SCHEMA_ID, + type PythonProjectContext, + type PythonProjectToolProvenance, + type PythonRuffValidationCapabilityRun, + type PythonValidationCapabilityInvocation, + type PythonValidationCapabilityState +} from "@the-open-engine/opcore-contracts"; +import type { + MaterializedPythonExecutionWorkspace, + PythonExecutionWorkspaceEvidence +} from "./python-execution-workspace.js"; +import type { PythonToolRunResult } from "./process.js"; +import { + portablePythonExecutableLocator, + portablePythonValidationArgument +} from "./type-capability-run.js"; + +export function pythonCapabilityInvocation( + executable: string, + args: readonly string[], + result: PythonToolRunResult, + durationMs: number +): PythonValidationCapabilityInvocation { + return { + argv: [executable, ...args], + termination: result.termination, + ...(result.termination === "exited" ? { exitCode: result.exitCode } : {}), + ...(result.termination === "signal" ? { signal: result.signal } : {}), + durationMs: Math.max(1, durationMs) + }; +} + +export function pythonCapabilityRun( + checkId: PythonRuffValidationCapabilityRun["checkId"], + capability: PythonRuffValidationCapabilityRun["capability"], + state: PythonValidationCapabilityState, + args: { + project: PythonProjectContext; + workspace?: PythonExecutionWorkspaceEvidence & + Partial>; + tool?: PythonProjectToolProvenance; + configPath?: string; + argv?: readonly string[]; + invocations?: readonly PythonValidationCapabilityInvocation[]; + result?: PythonToolRunResult; + durationMs: number; + diagnosticCount: number; + failureMessage?: string; + } +): PythonRuffValidationCapabilityRun { + const run: PythonRuffValidationCapabilityRun = { + schemaId: PYTHON_VALIDATION_CAPABILITY_RUN_SCHEMA_ID, + schemaVersion: 1, + checkId, + capability, + state, + projectKey: args.project.projectKey, + contextFingerprint: args.project.contextFingerprint, + durationMs: + (capability === "ruff_lint" || capability === "ruff_format") && + (state === "passed" || state === "findings") + ? Math.max(1, args.durationMs) + : args.durationMs, + diagnosticCount: args.diagnosticCount, + ...(args.failureMessage === undefined + ? {} + : { failureMessage: portableFailureMessage(args.failureMessage, args) }) + }; + if (args.workspace !== undefined) { + run.afterStateManifestFingerprint = args.workspace.afterStateFingerprint; + run.sourcePaths = args.workspace.sourcePaths; + run.configPaths = args.workspace.configPaths; + run.cwd = args.workspace.projectCwdRelative; + } + const portableExecutable = args.tool === undefined + ? undefined + : portablePythonExecutableLocator(args.tool.executable, args.project.repositoryRoot); + if (args.argv !== undefined) { + run.argv = portableArgv(args.argv, args.project.repositoryRoot, portableExecutable); + run.command = run.argv.join(" "); + } + if (args.invocations !== undefined) { + run.invocations = args.invocations.map((invocation) => ({ + ...invocation, + argv: portableArgv(invocation.argv, args.project.repositoryRoot, portableExecutable) + })); + } + if (args.tool !== undefined) { + run.executable = portableExecutable; + run.toolVersion = args.tool.version; + run.toolSource = args.tool.source; + run.configPath = args.configPath ?? args.tool.configFile; + } + if (args.result !== undefined) { + run.termination = args.result.termination; + if (args.result.termination === "exited") run.exitCode = args.result.exitCode; + if (args.result.termination === "signal") run.signal = args.result.signal; + } + if (state === "not_applicable" || state === "disabled") { + delete run.command; + delete run.argv; + delete run.executable; + delete run.toolVersion; + delete run.toolSource; + delete run.termination; + delete run.exitCode; + delete run.signal; + delete run.invocations; + } + return run; +} + +function portableArgv( + argv: readonly string[], + repositoryRoot: string, + portableExecutable: string | undefined +): readonly string[] { + return argv.map((argument, index) => + index === 0 && portableExecutable !== undefined + ? portableExecutable + : portablePythonValidationArgument(argument, repositoryRoot) + ); +} + +function portableFailureMessage( + message: string, + args: { + project: PythonProjectContext; + workspace?: Partial>; + tool?: PythonProjectToolProvenance; + } +): string { + const executable = args.tool === undefined + ? undefined + : portablePythonExecutableLocator(args.tool.executable, args.project.repositoryRoot); + const replacements = [ + ...(args.tool === undefined || executable === undefined ? [] : [[args.tool.executable, executable] as const]), + ...(args.workspace?.projectCwd === undefined || args.workspace.root === undefined || args.workspace.runtimeRoot === undefined + ? [] + : [ + [args.workspace.projectCwd, "project:cwd"] as const, + [args.workspace.root, "project:workspace"] as const, + [args.workspace.runtimeRoot, "external:runtime"] as const + ]), + [args.project.repositoryRoot, "repo:root"] as const + ].sort((left, right) => right[0].length - left[0].length); + let portable = message; + for (const [hostPath, locator] of replacements) { + if (hostPath.length > 0) portable = portable.replaceAll(hostPath, locator); + } + const normalized = portable.replace(/[\r\n\t]+/gu, " ").replace(/\s+/gu, " ").trim(); + return (normalized.length === 0 ? "Ruff validation failed without a message" : normalized).slice(0, 1024); +} + +export function inactivePythonCapabilityRun( + checkId: PythonRuffValidationCapabilityRun["checkId"], + capability: PythonRuffValidationCapabilityRun["capability"], + state: Extract +): PythonRuffValidationCapabilityRun { + return { + schemaId: PYTHON_VALIDATION_CAPABILITY_RUN_SCHEMA_ID, + schemaVersion: 1, + checkId, + capability, + state, + durationMs: 0, + diagnosticCount: 0 + }; +} diff --git a/packages/validation-python/src/ruff-check-definition.ts b/packages/validation-python/src/ruff-check-definition.ts new file mode 100644 index 0000000..16d8259 --- /dev/null +++ b/packages/validation-python/src/ruff-check-definition.ts @@ -0,0 +1,63 @@ +import type { PythonRuffValidationCapabilityRun, ValidationDiagnostic } from "@the-open-engine/opcore-contracts"; +import type { + ValidationCheckContext, + ValidationCheckDefinition, + ValidationCheckResult +} from "@the-open-engine/opcore-validation"; +import { pythonCheckAdapter, pythonCheckOwner, supportedPythonValidationScopes } from "./check-constants.js"; +import { inactivePythonCapabilityRun } from "./ruff-capability-run.js"; +import { + type PythonRuffCheckOptions, + resolveRuffProjects, + runResolvedRuffProjects, + type RuffProjectExecution +} from "./ruff-check-shared.js"; +import type { PythonProjectContextResolver, PythonSourceSetResolver } from "./source-files.js"; + +export type RuffExecutedProjectResult = + | { status: "failed"; result: ValidationCheckResult } + | { + status: "passed"; + diagnostics: readonly ValidationDiagnostic[]; + pythonCapabilityRun: PythonRuffValidationCapabilityRun; + }; + +export function createRuffCheckDefinition(args: { + checkId: "python.ruff-lint" | "python.ruff-format"; + capability: "ruff_lint" | "ruff_format"; + kind: "lint" | "format"; + options: PythonRuffCheckOptions; + resolveContexts?: PythonProjectContextResolver; + resolveSources?: PythonSourceSetResolver; + runProject: ( + project: RuffProjectExecution, + context: ValidationCheckContext, + options: PythonRuffCheckOptions + ) => Promise; +}): ValidationCheckDefinition { + return { + id: args.checkId, + owner: pythonCheckOwner, + adapter: pythonCheckAdapter, + defaultSeverity: "warning", + supportedScopes: supportedPythonValidationScopes, + defaultScopes: [], + // WHY: an opt-in check that never ran must never read as a passing check, so the + // inactive run stays skipped and carries only its non-execution receipt. + inactiveResult: (_context, state) => ({ + status: "skipped", + diagnostics: [], + failureMessage: state === "disabled" + ? `${args.checkId} is disabled by repository validation policy.` + : `${args.checkId} was not requested; Ruff validation is opt-in.`, + pythonCapabilityRuns: [inactivePythonCapabilityRun(args.checkId, args.capability, state)] + }), + run: async (context) => { + const resolved = await resolveRuffProjects(args.kind, context, args.resolveContexts, args.resolveSources); + if ("result" in resolved) return resolved.result; + return runResolvedRuffProjects(context, resolved, (project) => + args.runProject(project, context, args.options) + ); + } + }; +} diff --git a/packages/validation-python/src/ruff-check-shared.ts b/packages/validation-python/src/ruff-check-shared.ts new file mode 100644 index 0000000..2af56f0 --- /dev/null +++ b/packages/validation-python/src/ruff-check-shared.ts @@ -0,0 +1,327 @@ +import type { PythonProjectContext, PythonValidationCapabilityInvocation, PythonRuffValidationCapabilityRun, ValidationDiagnostic } from "@the-open-engine/opcore-contracts"; +import type { ValidationCheckContext, ValidationCheckResult } from "@the-open-engine/opcore-validation"; +import { PYTHON_RUFF_FORMAT_CHECK_ID, PYTHON_RUFF_LINT_CHECK_ID } from "./check-ids.js"; +import { groupPythonProjectContexts } from "./project-groups.js"; +import type { PythonToolRunResult } from "./process.js"; +import type { + MaterializedPythonExecutionWorkspace, + PythonExecutionWorkspaceEvidence +} from "./python-execution-workspace.js"; +import { + materializePythonExecutionWorkspace, + PythonExecutionWorkspaceConfigError +} from "./ruff-execution-workspace.js"; +import { sortDiagnostics } from "./diagnostics.js"; +import { inactivePythonCapabilityRun, pythonCapabilityRun } from "./ruff-capability-run.js"; +import { + hasUnresolvedRuffContext, + missingRuffContextResult, + ruffConfigurationFailure, + ruffContextFailure, + selectRuffTool +} from "./ruff-execution.js"; +import { + pythonInputSet, + selectPythonSourceFilesForTargets, + skippedPythonInputResult, + type PythonMaterializedSourceFile, + type PythonProjectContextResolver, + type PythonSourceSetResolver +} from "./source-files.js"; +import type { PythonValidationToolchainOptions } from "./toolchain.js"; + +export interface PythonRuffCheckOptions extends Omit { + timeoutMs?: number; +} + +export interface RuffResolvedProjects { + contexts: readonly PythonProjectContext[]; + sourceSet: Awaited>>; + resolveContexts: PythonProjectContextResolver; +} + +export interface RuffProjectExecution { + projectContext: PythonProjectContext; + targets: readonly string[]; + sourceFiles: readonly PythonMaterializedSourceFile[]; +} + +export interface RuffProjectInvocation { + projectContext: PythonProjectContext; + workspace: MaterializedPythonExecutionWorkspace; + tool: NonNullable>; + args: readonly string[]; + targets: readonly string[]; + options: PythonRuffCheckOptions; +} + +export interface RuffFailureReceipt { + checkId: PythonRuffValidationCapabilityRun["checkId"]; + capability: PythonRuffValidationCapabilityRun["capability"]; + projectContext: PythonProjectContext; + workspace: MaterializedPythonExecutionWorkspace; + tool: NonNullable>; + args: readonly string[]; + invocations?: readonly PythonValidationCapabilityInvocation[]; + result: PythonToolRunResult; + durationMs: number; + diagnosticCount: number; + failure: ValidationCheckResult; +} + +type RuffProjectResult = + | { status: "failed"; result: ValidationCheckResult } + | { status: "passed"; diagnostics: readonly ValidationDiagnostic[]; pythonCapabilityRun: PythonRuffValidationCapabilityRun }; + +export async function resolveRuffProjects( + kind: "lint" | "format", + context: ValidationCheckContext, + resolveContexts: PythonProjectContextResolver | undefined, + resolveSources: PythonSourceSetResolver | undefined +): Promise { + const skipped = skippedPythonInputResult(context); + if (skipped !== undefined) return { result: skipped }; + if (resolveContexts === undefined) return { result: missingRuffContextResult(kind, pythonInputSet(context)) }; + if (resolveSources === undefined) throw new Error(`A shared Python source-set resolver is required for Ruff ${kind} validation`); + const sourceSet = await resolveSources(context); + if (sourceSet.rootPaths.length === 0) { + const capability = kind === "lint" + ? { checkId: PYTHON_RUFF_LINT_CHECK_ID as "python.ruff-lint", name: "ruff_lint" as const } + : { checkId: PYTHON_RUFF_FORMAT_CHECK_ID as "python.ruff-format", name: "ruff_format" as const }; + return { + result: { + status: "skipped", + diagnostics: [], + failureMessage: "No Python after-state source files were selected.", + pythonCapabilityRuns: [inactivePythonCapabilityRun(capability.checkId, capability.name, "not_applicable")] + } + }; + } + const contexts = await resolveContexts(context, undefined, ["ruff"]); + const missing = sourceSet.rootPaths.filter((path) => !contexts.some((candidate) => candidate.target === path)); + if (contexts.length === 0 || missing.length > 0) return { result: missingRuffContextResult(kind, missing) }; + return { contexts, sourceSet, resolveContexts }; +} + +function ruffContextFailureWithReceipt( + kind: "lint" | "format", + context: PythonProjectContext, + workspace: PythonExecutionWorkspaceEvidence +): ValidationCheckResult { + const failure = ruffContextFailure(kind, context); + const tool = context.tools.find((entry) => entry.tool === "ruff"); + const config = kind === "lint" + ? { checkId: "python.ruff-lint" as const, capability: "ruff_lint" as const } + : { checkId: "python.ruff-format" as const, capability: "ruff_format" as const }; + return { + ...failure, + pythonCapabilityRuns: [pythonCapabilityRun( + config.checkId, + config.capability, + failure.outcome ?? "tool_failure", + { + project: context, + workspace, + ...(tool === undefined ? {} : { tool }), + durationMs: 0, + diagnosticCount: failure.diagnostics?.length ?? 0, + failureMessage: failure.failureMessage + } + )] + }; +} + +export async function runResolvedRuffProjects( + context: ValidationCheckContext, + resolved: RuffResolvedProjects, + executeProject: ( + project: RuffProjectExecution + ) => Promise +): Promise { + const diagnostics: ValidationDiagnostic[] = []; + const pythonCapabilityRuns: PythonRuffValidationCapabilityRun[] = []; + const failures: ValidationCheckResult[] = []; + for (const project of groupPythonProjectContexts(resolved.contexts)) { + const result = await executeProject({ + projectContext: project.context, + targets: project.targets, + sourceFiles: await selectPythonSourceFilesForTargets( + context, + resolved.sourceSet, + resolved.resolveContexts, + project.targets + ) + }); + if (result.status === "failed") { + failures.push(result.result); + diagnostics.push(...(result.result.diagnostics ?? [])); + for (const run of result.result.pythonCapabilityRuns ?? []) { + if (run.capability !== "ruff_lint" && run.capability !== "ruff_format") { + throw new Error(`Ruff validation returned unrelated Python capability evidence: ${run.capability}`); + } + pythonCapabilityRuns.push(run); + } + } else { + diagnostics.push(...result.diagnostics); + pythonCapabilityRuns.push(result.pythonCapabilityRun); + } + } + if (failures.length > 0) { + const primaryFailure = failures.reduce((selected, candidate) => + ruffFailureRank(candidate) > ruffFailureRank(selected) ? candidate : selected + ); + return { + ...primaryFailure, + diagnostics: sortDiagnostics(diagnostics), + pythonCapabilityRuns + }; + } + return { + outcome: diagnostics.length === 0 ? "passed" : "findings", + diagnostics: sortDiagnostics(diagnostics), + pythonCapabilityRuns + }; +} + +export async function runSingleRuffProject( + args: { + checkId: PythonRuffValidationCapabilityRun["checkId"]; + capability: PythonRuffValidationCapabilityRun["capability"]; + context: ValidationCheckContext; + kind: "lint" | "format"; + project: RuffProjectExecution; + options: PythonRuffCheckOptions; + createArgs: (projectContext: PythonProjectContext, tool: NonNullable>, targets: readonly string[]) => readonly string[]; + execute: (invocation: RuffProjectInvocation) => RuffProjectResult | Promise; + } +): Promise { + const { capability, checkId, context, createArgs, execute, kind, options, project } = args; + const { projectContext, sourceFiles, targets } = project; + const selectedRuff = selectRuffTool(projectContext); + const ruffEvidence = selectedRuff ?? projectContext.tools.find((entry) => entry.tool === "ruff"); + let workspace: MaterializedPythonExecutionWorkspace; + try { + workspace = await materializePythonExecutionWorkspace( + context, + { context: projectContext, targets }, + sourceFiles, + options.nodeWorkspace + ); + } catch (error) { + if (!(error instanceof PythonExecutionWorkspaceConfigError)) throw error; + if (ruffEvidence === undefined) { + const contextFailure = ruffContextFailure(kind, projectContext); + return { + status: "failed", + result: { + ...contextFailure, + pythonCapabilityRuns: [pythonCapabilityRun(checkId, capability, "tool_failure", { + project: projectContext, + workspace: error.workspaceEvidence, + durationMs: 0, + diagnosticCount: contextFailure.diagnostics?.length ?? 0, + failureMessage: error.message + })] + } + }; + } + const failure = ruffConfigurationFailure(kind, ruffEvidence, error.message, error.configPath); + return { + status: "failed", + result: { + ...failure, + pythonCapabilityRuns: [pythonCapabilityRun(checkId, capability, "invalid_config", { + project: projectContext, + workspace: error.workspaceEvidence, + tool: ruffEvidence, + ...(error.configPaths.includes(error.configPath) ? { configPath: error.configPath } : {}), + durationMs: 0, + diagnosticCount: failure.diagnostics?.length ?? 0, + failureMessage: error.message + })] + } + }; + } + try { + if (hasUnresolvedRuffContext(projectContext) || selectedRuff === undefined) { + return { + status: "failed", + result: ruffContextFailureWithReceipt(kind, projectContext, workspace) + }; + } + return await execute({ + projectContext, + workspace, + tool: selectedRuff, + args: createArgs(projectContext, selectedRuff, targets), + targets, + options + }); + } finally { + workspace.cleanup(); + } +} + +function ruffFailureRank(result: ValidationCheckResult): number { + if (result.outcome === "timeout" || result.outcome === "tool_failure") return 3; + if ( + result.outcome === "invalid_config" || + result.outcome === "tool_unavailable" || + result.outcome === "unsupported_target" + ) { + return 2; + } + return result.status === "infrastructure_failure" || result.status === "provider_failure" + ? 3 + : result.status === "unsupported_request" + ? 2 + : 1; +} + +export function ruffFailureWithReceipt(receipt: RuffFailureReceipt): ValidationCheckResult { + return { + ...receipt.failure, + pythonCapabilityRuns: [pythonCapabilityRun(receipt.checkId, receipt.capability, receipt.failure.outcome ?? "tool_failure", { + project: receipt.projectContext, + workspace: receipt.workspace, + tool: receipt.tool, + argv: [receipt.tool.executable, ...receipt.args], + ...(receipt.invocations === undefined || receipt.invocations.length === 0 + ? {} + : { invocations: receipt.invocations }), + result: receipt.result, + durationMs: receipt.durationMs, + diagnosticCount: receipt.diagnosticCount, + failureMessage: receipt.failure.failureMessage + })] + }; +} + +export function ruffFailureResult(args: { + kind: "lint" | "format"; + invocation: RuffProjectInvocation; + result: PythonToolRunResult; + durationMs: number; + failure: ValidationCheckResult; + invocations: readonly PythonValidationCapabilityInvocation[]; +}): { status: "failed"; result: ValidationCheckResult } { + const config = args.kind === "lint" + ? { checkId: PYTHON_RUFF_LINT_CHECK_ID as "python.ruff-lint", capability: "ruff_lint" as const } + : { checkId: PYTHON_RUFF_FORMAT_CHECK_ID as "python.ruff-format", capability: "ruff_format" as const }; + return { + status: "failed", + result: ruffFailureWithReceipt({ + checkId: config.checkId, + capability: config.capability, + projectContext: args.invocation.projectContext, + workspace: args.invocation.workspace, + tool: args.invocation.tool, + args: args.invocation.args, + invocations: args.invocations, + result: args.result, + durationMs: args.durationMs, + diagnosticCount: 0, + failure: args.failure + }) + }; +} diff --git a/packages/validation-python/src/ruff-config-paths.ts b/packages/validation-python/src/ruff-config-paths.ts new file mode 100644 index 0000000..b387185 --- /dev/null +++ b/packages/validation-python/src/ruff-config-paths.ts @@ -0,0 +1,86 @@ +import { + isAbsolutePortablePath, + normalizePortablePath, + portableDirname +} from "./python-execution-workspace.js"; + +export function resolveRuffExtendPath( + configPath: string, + extend: string, + repositoryRoot: string +): { status: "resolved"; path: string; rewrite: boolean } | { status: "invalid"; message: string } { + const normalized = extend.replaceAll("\\", "/"); + if (isAbsolutePortablePath(normalized)) { + try { + return { + status: "resolved", + path: repoRelativeConfigPath(normalized, repositoryRoot), + rewrite: true + }; + } catch (error) { + return { + status: "invalid", + message: `Ruff configuration ${configPath} extends a path outside the after-state repository: ${ + error instanceof Error ? error.message : String(error) + }` + }; + } + } + const path = resolvePortableRepoPath(portableDirname(configPath), normalized); + return path === undefined + ? { + status: "invalid", + message: `Ruff configuration ${configPath} has an extend path that escapes the after-state repository: ${extend}` + } + : { status: "resolved", path, rewrite: false }; +} + +function resolvePortableRepoPath(directory: string, path: string): string | undefined { + const normalized = path.replaceAll("\\", "/"); + const segments: string[] = directory === "." ? [] : directory.split("/").filter(Boolean); + for (const segment of normalized.split("/")) { + if (segment.length === 0 || segment === ".") continue; + if (segment === "..") { + if (segments.length === 0) return undefined; + segments.pop(); + continue; + } + segments.push(segment); + } + return segments.length === 0 ? undefined : segments.join("/"); +} + +export function repoRelativeConfigPath(path: string, repositoryRoot: string): string { + const normalizedPath = normalizePortablePath(path); + if (!isAbsolutePortablePath(normalizedPath)) { + const resolved = resolvePortableRepoPath(".", normalizedPath); + if (resolved === undefined) { + throw new Error(`configuration path is not repo-relative: ${path}`); + } + return resolved; + } + const normalizedRoot = normalizePortablePath(repositoryRoot); + const caseInsensitive = /^[A-Za-z]:\//u.test(normalizedRoot); + const comparablePath = caseInsensitive ? normalizedPath.toLowerCase() : normalizedPath; + const comparableRoot = caseInsensitive ? normalizedRoot.toLowerCase() : normalizedRoot; + if (!comparablePath.startsWith(`${comparableRoot}/`)) { + throw new Error(`absolute path is outside ${repositoryRoot}`); + } + const relativePath = normalizedPath.slice(normalizedRoot.length + 1); + const resolved = resolvePortableRepoPath(".", relativePath); + if (resolved === undefined) throw new Error(`absolute path does not identify a repository file: ${path}`); + return resolved; +} + +export function portableRelativePath(fromDirectory: string, toPath: string): string { + const from = fromDirectory === "." ? [] : fromDirectory.split("/").filter(Boolean); + const to = toPath.split("/").filter(Boolean); + let shared = 0; + while (shared < from.length && shared < to.length && from[shared] === to[shared]) shared += 1; + const relativePath = [...Array(from.length - shared).fill(".."), ...to.slice(shared)].join("/"); + return relativePath.length === 0 ? "." : relativePath; +} + +export function isPyprojectConfig(path: string): boolean { + return normalizePortablePath(path).split("/").at(-1)?.toLowerCase() === "pyproject.toml"; +} diff --git a/packages/validation-python/src/ruff-config-proof.ts b/packages/validation-python/src/ruff-config-proof.ts new file mode 100644 index 0000000..62e1212 --- /dev/null +++ b/packages/validation-python/src/ruff-config-proof.ts @@ -0,0 +1,80 @@ +import type { + PythonProjectContext, + PythonProjectToolProvenance +} from "@the-open-engine/opcore-contracts"; +import type { ValidationCheckResult } from "@the-open-engine/opcore-validation"; +import { runTool, type PythonToolRunResult } from "./process.js"; +import { + ruffCommandArgs, + ruffConfigurationFailure, + ruffExecutionProvenance, + type RuffCheckKind +} from "./ruff-execution.js"; + +export async function proveRuffConfigurationFailure(args: { + kind: RuffCheckKind; + tool: PythonProjectToolProvenance; + project: Pick; + configPaths: readonly string[]; + cwd: string; + env: Record; + target: string; + timeoutMs: number; +}): Promise<{ + args: readonly string[]; + result: PythonToolRunResult; + failure?: ValidationCheckResult; + durationMs: number; +} | undefined> { + if (args.tool.configFile === undefined || args.timeoutMs <= 0) return undefined; + const probeArgs = ruffCommandArgs(args.tool, args.project, "check", [ + "--show-settings", + "--output-format=json", + "--no-cache", + "--force-exclude", + args.target + ]); + const startedAt = Date.now(); + const result = await runTool(args.tool.executable, probeArgs, { + cwd: args.cwd, + env: args.env, + timeoutMs: Math.max(1, args.timeoutMs), + allowedExitCodes: [0] + }); + const durationMs = Math.max(1, Date.now() - startedAt); + const rejectedConfig = + result.termination === "exited" && result.exitCode === 2 + ? rejectedRuffConfigPath(result.stderr, args.configPaths) + : undefined; + const message = `Ruff rejected selected after-state configuration ${rejectedConfig ?? args.tool.configFile}`; + const provenance = ruffExecutionProvenance(args.tool, probeArgs, args.project); + return { + args: probeArgs, + result, + ...(rejectedConfig !== undefined + ? { failure: ruffConfigurationFailure(args.kind, args.tool, message, rejectedConfig, provenance) } + : {}), + durationMs + }; +} + +function rejectedRuffConfigPath(stderr: string, configFiles: readonly string[]): string | undefined { + const text = stderr.toLowerCase(); + const identifiesRejection = + /\breject(?:ed|ion)?\b|\bfailed to (?:load|parse|read|resolve)\b|\b(?:parse|parsing) error\b|\binvalid (?:config(?:uration)?|type|value)\b|\bunknown (?:field|option|property|rule|setting|variant)\b|\bexpected\b|\bcircular dependency\b|\bcycle detected\b/u.test(text); + if (!identifiesRejection) return undefined; + const normalizedConfigs = [...new Set(configFiles)].map((configFile) => ({ + configFile, + normalized: configFile.replaceAll("\\", "/").toLowerCase() + })); + const exact = normalizedConfigs + .filter(({ normalized }) => normalized.includes("/")) + .sort((left, right) => right.normalized.length - left.normalized.length) + .find(({ normalized }) => text.includes(normalized)); + if (exact !== undefined) return exact.configFile; + const basenameMatches = normalizedConfigs.filter(({ normalized }) => { + const name = normalized.split("/").at(-1); + return name !== undefined && text.includes(name); + }); + return basenameMatches.length === 1 ? basenameMatches[0].configFile : undefined; +} diff --git a/packages/validation-python/src/ruff-execution-workspace.ts b/packages/validation-python/src/ruff-execution-workspace.ts new file mode 100644 index 0000000..209b798 --- /dev/null +++ b/packages/validation-python/src/ruff-execution-workspace.ts @@ -0,0 +1,289 @@ +import type { PythonProjectContext } from "@the-open-engine/opcore-contracts"; +import type { ValidationCheckContext } from "@the-open-engine/opcore-validation"; +import { parse as parseToml, stringify as stringifyToml } from "smol-toml"; +import { + createValidationFileViewPythonWorkspace, + type PythonProjectWorkspace +} from "./project-workspace.js"; +import { + materializePreparedPythonExecutionWorkspace, + portableDirname, + preparePythonExecutionWorkspace, + type MaterializedPythonExecutionWorkspace, + type PythonExecutionWorkspaceEvidence, + type PythonExecutionWorkspaceRecord +} from "./python-execution-workspace.js"; +import type { PythonProjectGroup } from "./project-groups.js"; +import { + isPyprojectConfig, + portableRelativePath, + repoRelativeConfigPath, + resolveRuffExtendPath +} from "./ruff-config-paths.js"; +import type { PythonMaterializedSourceFile } from "./source-files.js"; + +/** + * Raised while resolving the selected Ruff configuration closure, before the after-state + * workspace evidence exists. It never escapes this module. + */ +class RuffConfigResolutionError extends Error { + readonly configPath: string; + configPaths: readonly string[] = []; + configRecords: readonly PythonExecutionWorkspaceRecord[] = []; + + constructor(configPath: string, message: string) { + super(message); + this.name = "RuffConfigResolutionError"; + this.configPath = configPath; + } +} + +/** + * Selected-configuration failure raised to Ruff checks. It always carries the exact after-state + * evidence a pre-execution receipt must bind - WHY: an activated Ruff receipt without source, + * config, cwd, and fingerprint evidence cannot prove which state was refused. + */ +export class PythonExecutionWorkspaceConfigError extends Error { + readonly configPath: string; + readonly configPaths: readonly string[]; + readonly workspaceEvidence: PythonExecutionWorkspaceEvidence; + + constructor(source: RuffConfigResolutionError, workspaceEvidence: PythonExecutionWorkspaceEvidence) { + super(source.message); + this.name = "PythonExecutionWorkspaceConfigError"; + this.configPath = source.configPath; + this.configPaths = source.configPaths; + this.workspaceEvidence = workspaceEvidence; + } +} + +export async function materializePythonExecutionWorkspace( + validation: ValidationCheckContext, + project: PythonProjectGroup, + files: readonly PythonMaterializedSourceFile[], + nodeWorkspace?: PythonProjectWorkspace +): Promise { + const sourceRecords: PythonExecutionWorkspaceRecord[] = files.map((file) => ({ + path: file.path, + status: "found", + content: file.content + })); + let supportFiles: Awaited>; + try { + supportFiles = await readSelectedRuffSupportFiles(validation, project.context, nodeWorkspace); + } catch (error) { + if (!(error instanceof RuffConfigResolutionError)) throw error; + const preparation = preparePythonExecutionWorkspace({ + project: project.context, + targets: project.targets, + sourcePaths: sourceRecords.map((record) => record.path), + configPaths: error.configPaths, + records: [...sourceRecords, ...error.configRecords] + }); + throw new PythonExecutionWorkspaceConfigError(error, { + projectCwdRelative: preparation.project.projectRoot, + afterStateFingerprint: preparation.afterStateFingerprint, + sourcePaths: preparation.sourcePaths, + configPaths: preparation.configPaths + }); + } + const supportRecords: PythonExecutionWorkspaceRecord[] = [...supportFiles.afterStateByPath] + .map(([path, content]) => { + const materializedContent = supportFiles.materializedByPath.get(path); + return { + path, + status: "found", + content, + ...(materializedContent === undefined || materializedContent === content + ? {} + : { materializedContent }) + }; + }); + const preparation = preparePythonExecutionWorkspace({ + project: project.context, + targets: project.targets, + sourcePaths: sourceRecords.map((record) => record.path), + configPaths: supportRecords.map((record) => record.path), + records: [...sourceRecords, ...supportRecords] + }); + return materializePreparedPythonExecutionWorkspace(preparation, { + tempPrefix: "opcore-python-check-", + runtimeDirectories: ["home", "xdg-config", "xdg-cache", "tmp", "ruff-cache"] + }); +} + +async function readSelectedRuffSupportFiles( + validation: ValidationCheckContext, + context: PythonProjectContext, + nodeWorkspace: PythonProjectWorkspace | undefined +): Promise<{ + afterStateByPath: ReadonlyMap; + materializedByPath: ReadonlyMap; +}> { + const selectedConfigPaths = context.tools + .filter((tool) => tool.tool === "ruff" && tool.configFile !== undefined) + .map((tool) => tool.configFile as string); + const supportPaths = new Set(); + const workspace = createValidationFileViewPythonWorkspace(validation.fileView, undefined, nodeWorkspace); + const materializedOverrides = await addRuffExtendClosure( + validation, + context, + selectedConfigPaths, + supportPaths, + workspace + ); + const afterStateByPath = new Map(); + const materializedByPath = new Map(); + for (const path of [...supportPaths].sort()) { + await assertConfinedRuffConfigPath(workspace, path); + const result = await validation.fileView.readAfter(path); + if (result.status !== "found") continue; + afterStateByPath.set(path, result.content); + materializedByPath.set(path, materializedOverrides.get(path) ?? result.content); + } + return { afterStateByPath, materializedByPath }; +} + +async function addRuffExtendClosure( + validation: ValidationCheckContext, + context: PythonProjectContext, + selectedConfigPaths: readonly string[], + supportPaths: Set, + workspace: PythonProjectWorkspace +): Promise> { + const roots = selectedConfigPaths.map((path) => normalizeSelectedConfigPath(path, context.repositoryRoot)); + const visited = new Set(); + const active: string[] = []; + const configRecords = new Map(); + const materializedOverrides = new Map(); + + const visit = async (path: string): Promise => { + if (visited.has(path)) return; + const cycleStart = active.indexOf(path); + if (cycleStart >= 0) { + const declaringPath = active.at(-1) ?? path; + throw new RuffConfigResolutionError( + declaringPath, + `Ruff configuration extend cycle detected: ${[...active.slice(cycleStart), path].join(" -> ")}` + ); + } + active.push(path); + supportPaths.add(path); + await assertConfinedRuffConfigPath(workspace, path); + const result = await validation.fileView.readAfter(path); + configRecords.set(path, { + path, + status: result.status, + ...(result.status === "found" ? { checksum: result.checksum, content: result.content } : {}) + }); + if (result.status !== "found") { + throw new RuffConfigResolutionError( + path, + `Ruff configuration is missing from the after-state: ${path}` + ); + } + const parsed = parseRuffConfig(result.content, path); + const extend = ruffExtendValue(parsed, path); + if (extend === undefined) { + active.pop(); + visited.add(path); + return; + } + const extended = resolveRuffExtendPath(path, extend, context.repositoryRoot); + if (extended.status === "invalid") { + throw new RuffConfigResolutionError(path, extended.message); + } + supportPaths.add(extended.path); + if (extended.rewrite) { + setRuffExtendValue(parsed, path, portableRelativePath(portableDirname(path), extended.path)); + materializedOverrides.set(path, stringifyToml(parsed)); + } + await visit(extended.path); + active.pop(); + visited.add(path); + }; + + try { + for (const path of roots) await visit(path); + } catch (error) { + if (error instanceof RuffConfigResolutionError) { + error.configPaths = [...supportPaths].sort(); + error.configRecords = [...configRecords.values()].sort((left, right) => left.path.localeCompare(right.path)); + } + throw error; + } + return materializedOverrides; +} + +function normalizeSelectedConfigPath(path: string, repositoryRoot: string): string { + try { + return repoRelativeConfigPath(path, repositoryRoot); + } catch (error) { + throw new RuffConfigResolutionError( + path, + `Ruff configuration path is outside the after-state repository: ${ + error instanceof Error ? error.message : String(error) + }` + ); + } +} + +async function assertConfinedRuffConfigPath( + workspace: PythonProjectWorkspace, + path: string +): Promise { + const resolved = await workspace.realpath(path); + if (resolved.unavailable) { + throw new RuffConfigResolutionError( + path, + `Ruff configuration realpath evidence is unavailable: ${path}` + ); + } + if (resolved.symlink || resolved.path !== path) { + throw new RuffConfigResolutionError( + path, + `Symlinked Ruff configuration path is refused: ${path}` + ); + } +} + +function parseRuffConfig(content: string, path: string): Record { + try { + const parsed = asTable(parseToml(content)); + if (parsed === undefined) throw new Error("TOML document root is not a table"); + return parsed; + } catch { + throw new RuffConfigResolutionError( + path, + `Ruff configuration is malformed: ${path}` + ); + } +} + +function ruffExtendValue(root: Record, configPath: string): string | undefined { + const tool = asTable(root?.tool); + const ruff = asTable(tool?.ruff); + return !isPyprojectConfig(configPath) && typeof root?.extend === "string" + ? root.extend + : typeof ruff?.extend === "string" + ? ruff.extend + : undefined; +} + +function setRuffExtendValue(root: Record, configPath: string, value: string): void { + if (!isPyprojectConfig(configPath) && typeof root.extend === "string") { + root.extend = value; + return; + } + const ruff = asTable(asTable(root.tool)?.ruff); + if (ruff === undefined || typeof ruff.extend !== "string") { + throw new Error("Ruff extend rewrite requires a parsed extend value"); + } + ruff.extend = value; +} + +function asTable(value: unknown): Record | undefined { + return value !== null && typeof value === "object" && !Array.isArray(value) + ? value as Record + : undefined; +} diff --git a/packages/validation-python/src/ruff-execution.ts b/packages/validation-python/src/ruff-execution.ts new file mode 100644 index 0000000..da70fb0 --- /dev/null +++ b/packages/validation-python/src/ruff-execution.ts @@ -0,0 +1,239 @@ +import type { + PythonProjectContext, + PythonProjectToolProvenance, + ValidationCheckOutcome, + ValidationDiagnosticToolProvenance, +} from "@the-open-engine/opcore-contracts"; +import type { ValidationCheckResult } from "@the-open-engine/opcore-validation"; +import { join } from "node:path"; +import { diagnostic } from "./diagnostics.js"; +import { missingPythonProjectContextResult } from "./python-context-result.js"; +import { + isAbsolutePortablePath, + normalizePortablePath, + toolProvenance, + type MaterializedPythonExecutionWorkspace +} from "./python-execution-workspace.js"; +import type { PythonToolRunResult } from "./process.js"; +import { + portablePythonExecutableLocator, + portablePythonValidationArgument +} from "./type-capability-run.js"; +import { isolatedTypeEnvironmentBase } from "./type-runner-runtime.js"; + +export type RuffCheckKind = "lint" | "format"; + +export function ruffCommandArgs( + tool: PythonProjectToolProvenance, + project: Pick, + subcommand: "check" | "format", + args: readonly string[] +): readonly string[] { + return [ + ...withoutRuffConfigOptions(tool.argv.slice(1)), + subcommand, + ...(tool.configFile === undefined + ? [] + : ["--config", materializedRuffConfigPath(tool.configFile, project)]), + ...args + ]; +} + +export function ruffRunEnv( + env: Readonly> | undefined, + tool: PythonProjectToolProvenance, + workspace: Pick +): Record { + return isolatedTypeEnvironmentBase({ + input: env === undefined ? undefined : { ...env }, + executable: tool.executable, + workspace, + extra: { + PYTHONPATH: "", + RUFF_NO_CACHE: "1", + RUFF_CACHE_DIR: join(workspace.runtimeRoot, "ruff-cache") + } + }); +} + +export function missingRuffContextResult(kind: RuffCheckKind, missing: readonly string[]): ValidationCheckResult { + return missingPythonProjectContextResult( + kind === "lint" ? "PY_RUFF_LINT_CONTEXT_MISSING" : "PY_RUFF_FORMAT_CONTEXT_MISSING", + missing + ); +} + +export function hasUnresolvedRuffContext(context: PythonProjectContext): boolean { + return context.reasons.some(isRuffContextFailureReason); +} + +function isRuffContextFailureReason(reason: PythonProjectContext["reasons"][number]): boolean { + return reason.tool === "ruff"; +} + +export function selectRuffTool(context: PythonProjectContext): PythonProjectToolProvenance | undefined { + return context.tools.find((tool) => tool.tool === "ruff" && tool.available); +} + +export function ruffContextFailure(kind: RuffCheckKind, context: PythonProjectContext): ValidationCheckResult { + const tool = context.tools.find((entry) => entry.tool === "ruff"); + const reason = context.reasons.find(isRuffContextFailureReason) ?? context.reasons[0]; + const outcome = ruffContextOutcome(reason?.code); + const unsupported = outcome === "tool_unavailable" || outcome === "invalid_config" || outcome === "unsupported_target"; + const message = reason?.message ?? `Ruff is unavailable for ${context.projectRoot}`; + return { + outcome, + failureMessage: message, + diagnostics: [diagnostic({ + category: "infrastructure", + severity: unsupported ? "info" : "error", + code: `${ruffDiagnosticPrefix(kind)}_${resolutionSuffix(outcome)}`, + message, + path: context.target, + ...(tool === undefined ? {} : { tool: toolProvenance(tool) }) + })] + }; +} + +export function ruffProcessFailure( + kind: RuffCheckKind, + tool: PythonProjectToolProvenance, + result: PythonToolRunResult, + project: Pick +): ValidationCheckResult { + const provenance = ruffExecutionProvenance(tool, result.args, project); + if (result.termination === "timeout") { + return ruffToolFailure(kind, "timeout", result.failureMessage ?? "ruff timed out", tool, provenance); + } + return ruffToolFailure(kind, "tool_failure", result.failureMessage ?? "ruff invocation failed", tool, provenance); +} + +export function ruffConfigurationFailure( + kind: RuffCheckKind, + tool: PythonProjectToolProvenance, + message: string, + path?: string, + provenance?: ValidationDiagnosticToolProvenance +): ValidationCheckResult { + return { + ...ruffToolFailure(kind, "invalid_config", message, tool, provenance), + diagnostics: [diagnostic({ + category: "infrastructure", + severity: "info", + code: `${ruffDiagnosticPrefix(kind)}_INVALID_CONFIG`, + message, + ...(path === undefined ? {} : { path }), + tool: provenance ?? toolProvenance(tool) + })] + }; +} + +function withoutRuffConfigOptions(args: readonly string[]): readonly string[] { + const result: string[] = []; + for (let index = 0; index < args.length; index += 1) { + const argument = args[index]; + if (argument === "--config") { + index += 1; + continue; + } + if (argument.startsWith("--config=")) continue; + result.push(argument); + } + return result; +} + +function materializedRuffConfigPath( + configFile: string, + project: Pick +): string { + let normalizedConfig = normalizePortablePath(configFile); + if (isAbsolutePortablePath(normalizedConfig)) { + const normalizedRepository = normalizePortablePath(project.repositoryRoot); + const caseInsensitive = /^[A-Za-z]:\//u.test(normalizedRepository); + const comparableConfig = caseInsensitive ? normalizedConfig.toLowerCase() : normalizedConfig; + const comparableRepository = caseInsensitive ? normalizedRepository.toLowerCase() : normalizedRepository; + if (!comparableConfig.startsWith(`${comparableRepository}/`)) { + throw new Error(`Ruff configuration path is outside the repository: ${configFile}`); + } + normalizedConfig = normalizedConfig.slice(normalizedRepository.length + 1); + } + const normalizedProject = normalizePortablePath(project.projectRoot); + if (normalizedProject === ".") return normalizedConfig; + const projectSegments = normalizedProject.split("/").filter(Boolean); + const configSegments = normalizedConfig.split("/").filter(Boolean); + let shared = 0; + while ( + shared < projectSegments.length && + shared < configSegments.length && + projectSegments[shared] === configSegments[shared] + ) { + shared += 1; + } + const relative = [ + ...Array(projectSegments.length - shared).fill(".."), + ...configSegments.slice(shared) + ].join("/"); + if (relative.length === 0) { + throw new Error(`Ruff configuration path must identify a file: ${configFile}`); + } + return relative; +} + +function ruffToolFailure( + kind: RuffCheckKind, + outcome: Exclude, + message: string, + tool: PythonProjectToolProvenance, + provenance?: ValidationDiagnosticToolProvenance +): ValidationCheckResult { + const suffix = outcome === "timeout" ? "TIMEOUT" : outcome === "invalid_config" ? "INVALID_CONFIG" : "TOOL_FAILED"; + return { + outcome, + failureMessage: message, + diagnostics: [diagnostic({ + category: "infrastructure", + code: `${ruffDiagnosticPrefix(kind)}_${suffix}`, + message, + tool: provenance ?? toolProvenance(tool) + })] + }; +} + +export function ruffExecutionProvenance( + tool: PythonProjectToolProvenance, + args: readonly string[], + project: Pick +): ValidationDiagnosticToolProvenance { + const executable = portablePythonExecutableLocator(tool.executable, project.repositoryRoot); + const argv = [ + executable, + ...args.map((argument) => portablePythonValidationArgument(argument, project.repositoryRoot)) + ]; + return { + name: tool.tool, + command: argv.join(" "), + ...(tool.version === undefined ? {} : { version: tool.version }), + source: tool.source, + cwd: project.projectRoot + }; +} + +function ruffContextOutcome(code: PythonProjectContext["reasons"][number]["code"] | undefined): ValidationCheckOutcome { + if (code === "invalid_config") return "invalid_config"; + if (code === "interpreter_unavailable" || code === "tool_unavailable") return "tool_unavailable"; + if (code === "unsupported_target" || code === "unsupported_platform") return "unsupported_target"; + return "tool_failure"; +} + +function ruffDiagnosticPrefix(kind: RuffCheckKind): "PY_RUFF_LINT" | "PY_RUFF_FORMAT" { + return kind === "lint" ? "PY_RUFF_LINT" : "PY_RUFF_FORMAT"; +} + +function resolutionSuffix( + outcome: ValidationCheckOutcome +): "INVALID_CONFIG" | "TOOL_UNAVAILABLE" | "UNSUPPORTED_TARGET" | "TOOL_FAILED" { + if (outcome === "invalid_config") return "INVALID_CONFIG"; + if (outcome === "tool_unavailable") return "TOOL_UNAVAILABLE"; + if (outcome === "unsupported_target") return "UNSUPPORTED_TARGET"; + return "TOOL_FAILED"; +} diff --git a/packages/validation-python/src/ruff-format-check.ts b/packages/validation-python/src/ruff-format-check.ts new file mode 100644 index 0000000..68d45fe --- /dev/null +++ b/packages/validation-python/src/ruff-format-check.ts @@ -0,0 +1,110 @@ +import type { ValidationCheckDefinition } from "@the-open-engine/opcore-validation"; +import { PYTHON_RUFF_FORMAT_CHECK_ID } from "./check-ids.js"; +import { diagnostic } from "./diagnostics.js"; +import { relativeProjectPath } from "./python-execution-workspace.js"; +import { pythonCapabilityRun } from "./ruff-capability-run.js"; +import { + createRuffCheckDefinition, + type RuffExecutedProjectResult +} from "./ruff-check-definition.js"; +import { + type PythonRuffCheckOptions, + ruffFailureResult, + runSingleRuffProject, + type RuffProjectInvocation +} from "./ruff-check-shared.js"; +import { + ruffCommandArgs, + ruffExecutionProvenance, + ruffRunEnv +} from "./ruff-execution.js"; +import { + formatCapabilityInvocations, + refineFormatDriftPaths +} from "./ruff-format-refinement.js"; +import { failedRuffInvocation } from "./ruff-invocation-failure.js"; +import type { PythonProjectContextResolver, PythonSourceSetResolver } from "./source-files.js"; + +export type PythonRuffFormatCheckOptions = PythonRuffCheckOptions; + +export function createRuffFormatCheck( + options: PythonRuffCheckOptions = {}, + resolveContexts?: PythonProjectContextResolver, + resolveSources?: PythonSourceSetResolver +): ValidationCheckDefinition { + return createRuffCheckDefinition({ + checkId: PYTHON_RUFF_FORMAT_CHECK_ID, + capability: "ruff_format", + options, + resolveContexts, + resolveSources, + kind: "format", + runProject: (project, context, runOptions) => + runSingleRuffProject({ + checkId: PYTHON_RUFF_FORMAT_CHECK_ID, + capability: "ruff_format", + context, + kind: "format", + project, + options: runOptions, + createArgs: (projectContext, tool, targets) => ruffCommandArgs(tool, projectContext, "format", [ + "--check", + "--no-cache", + "--force-exclude", + ...targets.map((path) => relativeProjectPath(path, projectContext.projectRoot)) + ]), + execute: executeFormatInvocation + }) + }); +} + +async function executeFormatInvocation(invocation: RuffProjectInvocation): Promise { + const driftPaths = await refineFormatDriftPaths({ + tool: invocation.tool, + project: invocation.projectContext, + cwd: invocation.workspace.projectCwd, + targets: invocation.targets, + env: ruffRunEnv(invocation.options.env, invocation.tool, invocation.workspace), + timeoutMs: invocation.options.timeoutMs ?? 30000 + }); + const result = driftPaths.invocation.result; + const formatInvocations = formatCapabilityInvocations(invocation.tool.executable, driftPaths.invocations); + const executedInvocation = { ...invocation, args: driftPaths.invocation.args }; + if ("failure" in driftPaths) { + return failedRuffInvocation({ + kind: "format", + invocation: executedInvocation, + result, + durationMs: driftPaths.durationMs, + invocations: formatInvocations, + failure: driftPaths.failure + }); + } + const diagnostics = driftPaths.paths.map((path) => diagnostic({ + category: "policy", + severity: "warning", + path, + code: "PY_RUFF_FORMAT_DRIFT", + message: `ruff format --check would reformat ${path}`, + tool: ruffExecutionProvenance(invocation.tool, driftPaths.invocation.args, invocation.projectContext) + })); + return { + status: "passed", + diagnostics, + pythonCapabilityRun: pythonCapabilityRun( + PYTHON_RUFF_FORMAT_CHECK_ID, + "ruff_format", + diagnostics.length === 0 ? "passed" : "findings", + { + project: invocation.projectContext, + workspace: invocation.workspace, + tool: invocation.tool, + argv: [invocation.tool.executable, ...driftPaths.invocation.args], + invocations: formatInvocations, + result, + durationMs: driftPaths.durationMs, + diagnosticCount: diagnostics.length + } + ) + }; +} diff --git a/packages/validation-python/src/ruff-format-refinement.ts b/packages/validation-python/src/ruff-format-refinement.ts new file mode 100644 index 0000000..6cdf142 --- /dev/null +++ b/packages/validation-python/src/ruff-format-refinement.ts @@ -0,0 +1,178 @@ +import type { + PythonProjectContext, + PythonValidationCapabilityInvocation +} from "@the-open-engine/opcore-contracts"; +import type { ValidationCheckResult } from "@the-open-engine/opcore-validation"; +import { runTool, type PythonToolRunResult } from "./process.js"; +import { relativeProjectPath } from "./python-execution-workspace.js"; +import { pythonCapabilityInvocation } from "./ruff-capability-run.js"; +import { ruffCommandArgs, ruffProcessFailure, selectRuffTool } from "./ruff-execution.js"; + +const formatBatchSize = 64; +const maxFormatInvocations = 512; + +export interface RuffFormatRefinement { + tool: NonNullable>; + project: Pick; + cwd: string; + targets: readonly string[]; + env: Record; + timeoutMs: number; +} + +export interface RuffFormatInvocationEvidence { + args: readonly string[]; + result: PythonToolRunResult; + durationMs: number; +} + +export async function refineFormatDriftPaths( + refinement: RuffFormatRefinement +): Promise< + | { + paths: readonly string[]; + invocation: RuffFormatInvocationEvidence; + invocations: readonly RuffFormatInvocationEvidence[]; + durationMs: number; + invocationCount: number; + } + | { + failure: ValidationCheckResult; + invocation: RuffFormatInvocationEvidence; + invocations: readonly RuffFormatInvocationEvidence[]; + durationMs: number; + invocationCount: number; + } +> { + if (refinement.targets.length === 0) throw new Error("Ruff format refinement requires at least one target"); + const state: RuffFormatRefinementState = { + startedAt: Date.now(), + deadline: Date.now() + refinement.timeoutMs, + invocationCount: 0, + invocations: [] + }; + const paths: string[] = []; + for (let index = 0; index < refinement.targets.length; index += formatBatchSize) { + const batch = refinement.targets.slice(index, index + formatBatchSize); + const result = await refineFormatBatch(refinement, batch, state); + if ("failure" in result) { + return { + ...result, + invocations: state.invocations, + durationMs: Math.max(1, Date.now() - state.startedAt), + invocationCount: state.invocationCount + }; + } + paths.push(...result.paths); + } + if (state.representative === undefined) throw new Error("Ruff format refinement produced no execution evidence"); + return { + paths: [...new Set(paths)].sort((a, b) => a.localeCompare(b)), + invocation: state.representative, + invocations: state.invocations, + durationMs: Math.max(1, Date.now() - state.startedAt), + invocationCount: state.invocationCount + }; +} + +interface RuffFormatRefinementState { + startedAt: number; + deadline: number; + invocationCount: number; + invocations: RuffFormatInvocationEvidence[]; + representative?: RuffFormatInvocationEvidence; +} + +async function refineFormatBatch( + refinement: RuffFormatRefinement, + targets: readonly string[], + state: RuffFormatRefinementState +): Promise<{ paths: readonly string[] } | { failure: ValidationCheckResult; invocation: RuffFormatInvocationEvidence }> { + const args = ruffCommandArgs(refinement.tool, refinement.project, "format", [ + "--check", + "--no-cache", + "--force-exclude", + ...targets.map((path) => relativeProjectPath(path, refinement.project.projectRoot)) + ]); + if (state.invocationCount >= maxFormatInvocations) { + return boundedFailure(refinement, state, args, "overflow", `ruff format exceeded ${maxFormatInvocations} bounded invocations`); + } + const remainingMs = state.deadline - Date.now(); + if (remainingMs <= 0) { + return boundedFailure(refinement, state, args, "timeout", `ruff format exceeded its ${refinement.timeoutMs}ms total time bound`); + } + state.invocationCount += 1; + const invocationStartedAt = Date.now(); + const result = await runTool(refinement.tool.executable, args, { + cwd: refinement.cwd, + env: refinement.env, + timeoutMs: Math.max(1, Math.min(refinement.timeoutMs, remainingMs)), + allowedExitCodes: [0, 1] + }); + const invocation = { + args, + result, + durationMs: Math.max(1, Date.now() - invocationStartedAt) + }; + state.invocations.push(invocation); + if (state.representative === undefined || (state.representative.result.exitCode === 0 && result.exitCode === 1)) { + state.representative = invocation; + } + if (!result.ok) return { failure: ruffProcessFailure("format", refinement.tool, result, refinement.project), invocation }; + if (result.exitCode === 0) return { paths: [] }; + if (targets.length === 1) return { paths: [targets[0]] }; + const middle = Math.ceil(targets.length / 2); + const left = await refineFormatBatch(refinement, targets.slice(0, middle), state); + if ("failure" in left) return left; + const right = await refineFormatBatch(refinement, targets.slice(middle), state); + if ("failure" in right) return right; + return { paths: [...left.paths, ...right.paths].sort((a, b) => a.localeCompare(b)) }; +} + +function boundedFailure( + refinement: RuffFormatRefinement, + state: RuffFormatRefinementState, + args: readonly string[], + termination: "timeout" | "overflow", + failureMessage: string +): { failure: ValidationCheckResult; invocation: RuffFormatInvocationEvidence } { + // WHY: the bounded attempt is the run-level termination evidence, so it must also appear + // in the recorded invocation list that receipt consumers reconcile against. + const invocation = boundedInvocation(refinement, args, termination, failureMessage); + state.invocations.push(invocation); + return { failure: ruffProcessFailure("format", refinement.tool, invocation.result, refinement.project), invocation }; +} + +function boundedInvocation( + refinement: RuffFormatRefinement, + args: readonly string[], + termination: "timeout" | "overflow", + failureMessage: string +): RuffFormatInvocationEvidence { + return { + args, + result: { + command: refinement.tool.executable, + args, + cwd: refinement.cwd, + allowedExitCodes: [0, 1], + exitCode: null, + signal: null, + stdout: "", + stderr: "", + termination, + ok: false, + failureMessage + }, + durationMs: 1 + }; +} + +export function formatCapabilityInvocations( + executable: string, + invocations: readonly RuffFormatInvocationEvidence[] +): readonly PythonValidationCapabilityInvocation[] { + return invocations.map((invocation) => + pythonCapabilityInvocation(executable, invocation.args, invocation.result, invocation.durationMs) + ); +} diff --git a/packages/validation-python/src/ruff-invocation-failure.ts b/packages/validation-python/src/ruff-invocation-failure.ts new file mode 100644 index 0000000..a43e0d1 --- /dev/null +++ b/packages/validation-python/src/ruff-invocation-failure.ts @@ -0,0 +1,59 @@ +import type { PythonValidationCapabilityInvocation } from "@the-open-engine/opcore-contracts"; +import type { ValidationCheckResult } from "@the-open-engine/opcore-validation"; +import { relativeProjectPath } from "./python-execution-workspace.js"; +import { pythonCapabilityInvocation } from "./ruff-capability-run.js"; +import { proveRuffConfigurationFailure } from "./ruff-config-proof.js"; +import { + ruffFailureResult, + type RuffProjectInvocation +} from "./ruff-check-shared.js"; +import { + ruffProcessFailure, + ruffRunEnv, + type RuffCheckKind +} from "./ruff-execution.js"; +import type { RuffExecutedProjectResult } from "./ruff-check-definition.js"; +import type { PythonToolRunResult } from "./process.js"; + +export async function failedRuffInvocation(args: { + kind: RuffCheckKind; + invocation: RuffProjectInvocation; + result: PythonToolRunResult; + durationMs: number; + invocations: readonly PythonValidationCapabilityInvocation[]; + failure?: ValidationCheckResult; +}): Promise { + const { durationMs, invocation, kind, result } = args; + const proof = result.termination === "exited" && result.exitCode === 2 + ? await proveRuffConfigurationFailure({ + kind, + tool: invocation.tool, + project: invocation.projectContext, + configPaths: invocation.workspace.configPaths, + cwd: invocation.workspace.projectCwd, + env: ruffRunEnv(invocation.options.env, invocation.tool, invocation.workspace), + target: relativeProjectPath(invocation.targets[0], invocation.projectContext.projectRoot), + timeoutMs: (invocation.options.timeoutMs ?? 30000) - durationMs + }) + : undefined; + const proofInvocation = proof === undefined + ? [] + : [pythonCapabilityInvocation(invocation.tool.executable, proof.args, proof.result, proof.durationMs)]; + return proof?.failure === undefined + ? ruffFailureResult({ + kind, + invocation, + result, + durationMs: durationMs + (proof?.durationMs ?? 0), + failure: args.failure ?? ruffProcessFailure(kind, invocation.tool, result, invocation.projectContext), + invocations: [...args.invocations, ...proofInvocation] + }) + : ruffFailureResult({ + kind, + invocation: { ...invocation, args: proof.args }, + result: proof.result, + durationMs: durationMs + proof.durationMs, + failure: proof.failure, + invocations: [...args.invocations, ...proofInvocation] + }); +} diff --git a/packages/validation-python/src/ruff-lint-check.ts b/packages/validation-python/src/ruff-lint-check.ts new file mode 100644 index 0000000..8fddf51 --- /dev/null +++ b/packages/validation-python/src/ruff-lint-check.ts @@ -0,0 +1,146 @@ +import type { ValidationCheckDefinition, ValidationCheckResult } from "@the-open-engine/opcore-validation"; +import { PYTHON_RUFF_LINT_CHECK_ID } from "./check-ids.js"; +import { diagnostic } from "./diagnostics.js"; +import { runTool } from "./process.js"; +import { relativeProjectPath } from "./python-execution-workspace.js"; +import { + pythonCapabilityInvocation, + pythonCapabilityRun +} from "./ruff-capability-run.js"; +import { + createRuffCheckDefinition, + type RuffExecutedProjectResult +} from "./ruff-check-definition.js"; +import { + type PythonRuffCheckOptions, + ruffFailureResult, + runSingleRuffProject, + type RuffProjectInvocation +} from "./ruff-check-shared.js"; +import { + ruffCommandArgs, + ruffExecutionProvenance, + ruffRunEnv +} from "./ruff-execution.js"; +import { failedRuffInvocation } from "./ruff-invocation-failure.js"; +import { parseRuffLintDiagnostics } from "./ruff-lint-output.js"; +import type { PythonProjectContextResolver, PythonSourceSetResolver } from "./source-files.js"; + +export type PythonRuffLintCheckOptions = PythonRuffCheckOptions; + +export function createRuffLintCheck( + options: PythonRuffCheckOptions = {}, + resolveContexts?: PythonProjectContextResolver, + resolveSources?: PythonSourceSetResolver +): ValidationCheckDefinition { + return createRuffCheckDefinition({ + checkId: PYTHON_RUFF_LINT_CHECK_ID, + capability: "ruff_lint", + options, + resolveContexts, + resolveSources, + kind: "lint", + runProject: (project, context, runOptions) => + runSingleRuffProject({ + checkId: PYTHON_RUFF_LINT_CHECK_ID, + capability: "ruff_lint", + context, + kind: "lint", + project, + options: runOptions, + createArgs: (projectContext, tool, targets) => ruffCommandArgs(tool, projectContext, "check", [ + "--output-format=json", + "--no-fix", + "--no-cache", + "--force-exclude", + ...targets.map((path) => relativeProjectPath(path, projectContext.projectRoot)) + ]), + execute: executeLintInvocation + }) + }); +} + +async function executeLintInvocation(invocation: RuffProjectInvocation): Promise { + const startedAt = Date.now(); + const result = await runTool(invocation.tool.executable, invocation.args, { + cwd: invocation.workspace.projectCwd, + env: ruffRunEnv(invocation.options.env, invocation.tool, invocation.workspace), + timeoutMs: invocation.options.timeoutMs ?? 30000, + allowedExitCodes: [0, 1] + }); + const durationMs = Math.max(1, Date.now() - startedAt); + const evidence = [pythonCapabilityInvocation(invocation.tool.executable, invocation.args, result, durationMs)]; + if (!result.ok) { + return failedRuffInvocation({ + kind: "lint", + invocation, + result, + durationMs, + invocations: evidence + }); + } + const provenance = ruffExecutionProvenance(invocation.tool, invocation.args, invocation.projectContext); + const parsed = parseRuffLintDiagnostics(result.stdout, provenance, invocation.workspace); + if (parsed.status === "malformed") { + return ruffFailureResult({ + kind: "lint", + invocation, + result, + durationMs, + failure: malformedLintResult(parsed.message, provenance), + invocations: evidence + }); + } + if (lintOutputContradictsExit(result.exitCode, parsed.diagnostics.length)) { + return ruffFailureResult({ + kind: "lint", + invocation, + result, + durationMs, + failure: malformedLintResult( + `ruff lint exit ${result.exitCode} contradicted its JSON diagnostic count ${parsed.diagnostics.length}`, + provenance + ), + invocations: evidence + }); + } + return { + status: "passed", + diagnostics: parsed.diagnostics, + pythonCapabilityRun: pythonCapabilityRun( + PYTHON_RUFF_LINT_CHECK_ID, + "ruff_lint", + parsed.diagnostics.length === 0 ? "passed" : "findings", + { + project: invocation.projectContext, + workspace: invocation.workspace, + tool: invocation.tool, + argv: [invocation.tool.executable, ...invocation.args], + invocations: evidence, + result, + durationMs, + diagnosticCount: parsed.diagnostics.length + } + ) + }; +} + +function lintOutputContradictsExit(exitCode: number | null, diagnosticCount: number): boolean { + return (exitCode === 0 && diagnosticCount > 0) || (exitCode === 1 && diagnosticCount === 0); +} + +function malformedLintResult( + message: string, + provenance: ReturnType +): ValidationCheckResult { + return { + outcome: "tool_failure", + failureMessage: message, + diagnostics: [diagnostic({ + category: "infrastructure", + code: "PY_RUFF_LINT_TOOL_FAILED", + message, + tool: provenance + })] + }; +} diff --git a/packages/validation-python/src/ruff-lint-output.ts b/packages/validation-python/src/ruff-lint-output.ts new file mode 100644 index 0000000..d48fbac --- /dev/null +++ b/packages/validation-python/src/ruff-lint-output.ts @@ -0,0 +1,131 @@ +import type { + ValidationDiagnostic, + ValidationDiagnosticToolProvenance +} from "@the-open-engine/opcore-contracts"; +import { diagnostic, sortDiagnostics } from "./diagnostics.js"; +import { + selectedRepoRelativeDiagnosticPath, + type MaterializedPythonExecutionWorkspace +} from "./python-execution-workspace.js"; + +type RuffLintEntry = { + code: string | null; + filename: string; + location: { row: number; column: number }; + end_location?: { row: number; column: number }; + message: string; +}; + +const ruffSyntaxDiagnosticCode = "PY_RUFF_LINT_SYNTAX_ERROR"; + +export function parseRuffLintDiagnostics( + stdout: string, + provenance: ValidationDiagnosticToolProvenance, + workspace: MaterializedPythonExecutionWorkspace +): { status: "parsed"; diagnostics: readonly ValidationDiagnostic[] } | { status: "malformed"; message: string } { + let payload: unknown; + try { + payload = JSON.parse(stdout.trim()); + } catch { + return { status: "malformed", message: "ruff lint returned malformed JSON output" }; + } + if (!Array.isArray(payload)) return { status: "malformed", message: "ruff lint output must be a JSON array" }; + const diagnostics = payload.map((entry) => parseLintDiagnostic(entry, provenance, workspace)); + if (diagnostics.some((entry) => entry === undefined)) { + return { status: "malformed", message: "ruff lint output contained an invalid diagnostic entry" }; + } + return { status: "parsed", diagnostics: sortDiagnostics(diagnostics as ValidationDiagnostic[]) }; +} + +function parseLintDiagnostic( + value: unknown, + provenance: ValidationDiagnosticToolProvenance, + workspace: MaterializedPythonExecutionWorkspace +): ValidationDiagnostic | undefined { + const entry = parseRuffLintEntry(value); + if (entry === undefined) return undefined; + const path = selectedRepoRelativeDiagnosticPath( + entry.filename, + workspace.projectCwd, + workspace.root, + workspace.sourcePaths + ); + if (path === undefined) return undefined; + return diagnostic({ + category: "policy", + severity: "warning", + path, + code: entry.code === null + ? ruffSyntaxDiagnosticCode + : `PY_RUFF_LINT_${normalizeDiagnosticCode(entry.code)}`, + message: entry.message, + line: entry.location.row, + column: entry.location.column, + endLine: entry.end_location?.row, + endColumn: entry.end_location?.column, + tool: provenance + }); +} + +function parseRuffLintEntry(value: unknown): RuffLintEntry | undefined { + const record = asRecord(value); + if (record === undefined) return undefined; + const code = nullableDiagnosticCode(record); + const filename = requiredString(record, "filename"); + const message = requiredString(record, "message"); + const location = requiredLocation(record, "location"); + const endLocation = optionalLocation(record, "end_location"); + if (code === undefined || filename === undefined || message === undefined || location === undefined || endLocation === null) { + return undefined; + } + return { + code, + filename, + location, + ...(endLocation === undefined ? {} : { end_location: endLocation }), + message + }; +} + +function nullableDiagnosticCode(record: Record): string | null | undefined { + if (!Object.hasOwn(record, "code")) return undefined; + const value = record.code; + if (value === null) return null; + return typeof value === "string" && value.length > 0 ? value : undefined; +} + +function asRecord(value: unknown): Record | undefined { + return value !== null && typeof value === "object" ? value as Record : undefined; +} + +function requiredString(record: Record, key: string): string | undefined { + const value = record[key]; + return typeof value === "string" && value.length > 0 ? value : undefined; +} + +function requiredLocation(record: Record, key: string): { row: number; column: number } | undefined { + return parseRuffLocation(record[key]); +} + +function optionalLocation( + record: Record, + key: string +): { row: number; column: number } | undefined | null { + if (!(key in record) || record[key] === undefined) return undefined; + return parseRuffLocation(record[key]) ?? null; +} + +function parseRuffLocation(value: unknown): { row: number; column: number } | undefined { + const record = asRecord(value); + if (record === undefined) return undefined; + const row = record.row; + const column = record.column; + return typeof row === "number" && Number.isInteger(row) && row > 0 && + typeof column === "number" && Number.isInteger(column) && column > 0 + ? { row, column } + : 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/smol-toml-shims.d.ts b/packages/validation-python/src/smol-toml-shims.d.ts index d43a629..3d19e06 100644 --- a/packages/validation-python/src/smol-toml-shims.d.ts +++ b/packages/validation-python/src/smol-toml-shims.d.ts @@ -7,4 +7,5 @@ declare module "smol-toml" { } export function parse(toml: string, options?: ParseOptions): TomlTable; + export function stringify(table: TomlTable): string; } diff --git a/packages/validation-python/src/source-closure.ts b/packages/validation-python/src/source-closure.ts index b9b1884..becd4ee 100644 --- a/packages/validation-python/src/source-closure.ts +++ b/packages/validation-python/src/source-closure.ts @@ -26,7 +26,11 @@ export async function expandPythonSourceClosure({ while (true) { const unresolvedTargets = selected.filter((path) => !projectContexts.has(path)); if (unresolvedTargets.length > 0) { - for (const projectContext of await resolveContexts(context, unresolvedTargets)) { + for (const projectContext of await resolveContexts( + context, + unresolvedTargets, + projectToolKinds(context.selectedCheckIds ?? context.request.checks ?? []) + )) { projectContexts.set(projectContext.target, projectContext); } } @@ -39,6 +43,24 @@ export async function expandPythonSourceClosure({ } } +function projectToolKinds( + selectedCheckIds: readonly string[] +): readonly ("mypy" | "pyright" | "ruff" | "pytest")[] { + const kinds = new Set<"mypy" | "pyright" | "ruff" | "pytest">(); + if (selectedCheckIds.includes("python.types")) { + kinds.add("mypy"); + kinds.add("pyright"); + } + if ( + selectedCheckIds.includes("python.ruff-lint") || + selectedCheckIds.includes("python.ruff-format") + ) { + kinds.add("ruff"); + } + if (selectedCheckIds.includes("python.relevant-tests")) kinds.add("pytest"); + return [...kinds]; +} + function includePackageInitializers( selectedPaths: readonly string[], sourceByPath: ReadonlyMap, diff --git a/packages/validation-python/src/source-files.ts b/packages/validation-python/src/source-files.ts index 8831da7..5c187cd 100644 --- a/packages/validation-python/src/source-files.ts +++ b/packages/validation-python/src/source-files.ts @@ -20,6 +20,7 @@ import type { } from "./source-types.js"; export const pythonSourceExtensions = [".py", ".pyi"] as const; +const allPythonProjectToolKinds = ["mypy", "pyright", "ruff", "pytest"] as const; export type { PythonMaterializedSourceFile, @@ -29,41 +30,62 @@ export type { PythonSourceSetResolver } from "./source-types.js"; +export async function selectPythonSourceFilesForTargets( + context: ValidationCheckContext, + sourceSet: PythonMaterializedSourceSet, + resolveContexts: PythonProjectContextResolver, + rootPaths: readonly string[] +): Promise { + if (sourceSet.files.length === 0 || rootPaths.length === 0) return []; + const selectedPaths = await expandPythonSourceClosure({ + context, + rootPaths: uniqueSorted(rootPaths.map(normalizeValidationFileViewPath).filter((path) => sourceSet.sourceFileByPath.has(path))), + edges: sourceSet.repoImports, + sourceByPath: sourceSet.sourceFileByPath, + resolveContexts + }); + return selectedPaths.map((path) => sourceSet.sourceFileByPath.get(path)).filter(isDefined); +} + export function createPythonProjectContextResolver( options: Omit & { nodeWorkspace?: PythonProjectWorkspace; } = {} ): PythonProjectContextResolver { const cache = new WeakMap(); - return async (context, requestedTargets) => { + return async (context, requestedTargets, requestedToolKinds) => { let cached = cache.get(context.fileView); if (cached === undefined) { - cached = { contexts: new Map() }; + cached = { contexts: new Map(), pending: 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)); + const normalizedToolKinds = normalizeToolKinds(requestedToolKinds); + const toolKey = normalizedToolKinds.join(","); 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; + const missing = targets.filter((target) => !cached.contexts.has(cacheKey(target, toolKey))); + if (missing.length === 0) return targets.map((target) => requiredProjectContext(cached.contexts, cacheKey(target, toolKey))); + const pending = cached.pending.get(toolKey); + if (pending !== undefined) { + await pending; continue; } - const pending = resolvePythonProjectContexts({ + const nextPending = resolvePythonProjectContexts({ repoRoot: context.request.repo.repoRoot ?? process.cwd(), targets: missing, workspace: createValidationFileViewPythonWorkspace(context.fileView, undefined, options.nodeWorkspace), + ...(normalizedToolKinds.length === allPythonProjectToolKinds.length ? {} : { toolKinds: normalizedToolKinds }), ...withoutNodeWorkspace(options) }).then((contexts) => { - for (const projectContext of contexts) cached.contexts.set(projectContext.target, projectContext); + for (const projectContext of contexts) cached.contexts.set(cacheKey(projectContext.target, toolKey), projectContext); }); - cached.pending = pending; + cached.pending.set(toolKey, nextPending); try { - await pending; + await nextPending; } finally { - delete cached.pending; + cached.pending.delete(toolKey); } } }; @@ -72,7 +94,7 @@ export function createPythonProjectContextResolver( interface PythonProjectContextCache { contexts: Map; inputTargets?: Promise; - pending?: Promise; + pending: Map>; } function requiredProjectContext( @@ -138,6 +160,19 @@ export function skippedPythonInputResult(context: ValidationCheckContext): Valid }; } +function normalizeToolKinds( + toolKinds: readonly (typeof allPythonProjectToolKinds)[number][] | undefined +): readonly (typeof allPythonProjectToolKinds)[number][] { + if (toolKinds === undefined) return [...allPythonProjectToolKinds]; + return uniqueSorted( + toolKinds.filter((tool): tool is (typeof allPythonProjectToolKinds)[number] => allPythonProjectToolKinds.includes(tool)) + ) as readonly (typeof allPythonProjectToolKinds)[number][]; +} + +function cacheKey(target: string, toolKey: string): string { + return `${toolKey}\0${target}`; +} + export function createPythonSourceSetResolver( importAnalyzer: PythonImportAnalyzer | undefined, resolveContexts: PythonProjectContextResolver @@ -216,3 +251,7 @@ function emptySourceSet(): PythonMaterializedSourceSet { export function uniqueSorted(values: readonly string[]): readonly string[] { return [...new Set(values)].sort(); } + +function isDefined(value: T | undefined): value is T { + return value !== undefined; +} diff --git a/packages/validation-python/src/source-types.ts b/packages/validation-python/src/source-types.ts index 12f6ab3..336447a 100644 --- a/packages/validation-python/src/source-types.ts +++ b/packages/validation-python/src/source-types.ts @@ -1,4 +1,4 @@ -import type { PythonProjectContext } from "@the-open-engine/opcore-contracts"; +import type { PythonProjectContext, PythonProjectToolKind } from "@the-open-engine/opcore-contracts"; import type { ValidationCheckContext } from "@the-open-engine/opcore-validation"; import type { PythonImportEdge, @@ -19,7 +19,8 @@ export interface PythonMaterializedSourceSet { export type PythonProjectContextResolver = ( context: ValidationCheckContext, - targets?: readonly string[] + targets?: readonly string[], + toolKinds?: readonly Exclude[] ) => Promise; export type PythonSourceRootResolver = ( diff --git a/packages/validation-python/src/static-config.ts b/packages/validation-python/src/static-config.ts index fa49b15..440954a 100644 --- a/packages/validation-python/src/static-config.ts +++ b/packages/validation-python/src/static-config.ts @@ -22,7 +22,7 @@ import { export const pythonToolConfigPrecedence = { pyright: ["pyrightconfig.json", "pyproject.toml"], - ruff: ["ruff.toml", ".ruff.toml", "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; @@ -42,15 +42,32 @@ interface PythonToolConfigInputs { contents: ReadonlyMap; tomlDocuments: ReadonlyMap; iniDocuments: ReadonlyMap; + selectedRuffConfig?: string; + scopedRuffSelection: boolean; +} + +export interface ReadPythonStaticProjectConfigOptions { + target?: string; + toolKinds?: readonly ("mypy" | "pyright" | "ruff" | "pytest")[]; } export async function readPythonStaticProjectConfig( workspace: PythonProjectWorkspace, projectRoot: string, - visibleFiles: readonly string[] + visibleFiles: readonly string[], + options: ReadPythonStaticProjectConfigOptions = {} ): Promise { const direct = directChildren(projectRoot, visibleFiles); - const relevant = direct.filter(isRelevantPythonConfig); + const selectedRuffConfig = options.target === undefined || options.toolKinds !== undefined && !options.toolKinds.includes("ruff") + ? undefined + : await selectClosestTargetRuffConfig(workspace, options.target, visibleFiles); + const relevant = [...new Set([ + ...direct.filter((path) => + isRelevantPythonConfig(path) && + (!isDedicatedRuffConfig(path) || path === selectedRuffConfig) + ), + ...(selectedRuffConfig === undefined ? [] : [selectedRuffConfig]) + ])].sort(); const contents = new Map(); const jsonDocuments = new Map(); const tomlDocuments = new Map(); @@ -59,11 +76,21 @@ export async function readPythonStaticProjectConfig( 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}` }); + reasons.push({ + code: "ambiguous_path", + path, + ...configReasonTool(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}` }); + reasons.push({ + code: "symlink_refused", + path, + ...configReasonTool(path), + message: `Symlinked Python config path is ambiguous: ${path}` + }); continue; } const value = await workspace.read(path); @@ -79,14 +106,33 @@ export async function readPythonStaticProjectConfig( 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}` }); + reasons.push({ + code: "invalid_config", + path, + ...configReasonTool(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}` }); + reasons.push({ + code: "invalid_config", + path, + ...configReasonTool(path), + message: `Python project config is malformed: ${path}` + }); + for (const tool of declaredPyprojectTools(path, value)) { + if (tool === "ruff" && path !== selectedRuffConfig) continue; + reasons.push({ + code: "invalid_config", + path, + tool, + message: `Python project config is malformed: ${path}` + }); + } } } if (path.endsWith(".ini") || path.endsWith(".cfg")) { @@ -97,6 +143,7 @@ export async function readPythonStaticProjectConfig( reasons.push({ code: "invalid_config", path, + ...configReasonTool(path), message: `Python project config is malformed: ${path}` }); } @@ -111,7 +158,15 @@ export async function readPythonStaticProjectConfig( message: `Conflicting Python dependency managers at ${projectRoot}: ${managers.map((entry) => entry.kind).join(", ")}` }); } - const toolConfigs = selectToolConfigs({ projectRoot, direct, contents, tomlDocuments, iniDocuments }, reasons); + const toolConfigs = selectToolConfigs({ + projectRoot, + direct, + contents, + tomlDocuments, + iniDocuments, + ...(selectedRuffConfig === undefined ? {} : { selectedRuffConfig }), + scopedRuffSelection: options.target !== undefined + }, reasons); const pyrightTarget = toolConfigs.pyright === undefined ? undefined : await readSelectedPyrightTarget(workspace, toolConfigs.pyright, contents, reasons); @@ -391,6 +446,9 @@ function configuredToolPaths( input: PythonToolConfigInputs, tool: "mypy" | "pyright" | "ruff" | "pytest", ): readonly string[] { + if (tool === "ruff" && input.scopedRuffSelection) { + return input.selectedRuffConfig === undefined ? [] : [input.selectedRuffConfig]; + } const candidates = pythonToolConfigPrecedence[tool]; const names = new Set(input.direct.map(basename)); const section = { @@ -403,7 +461,10 @@ function configuredToolPaths( 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; + const document = input.tomlDocuments.get(path); + return document === undefined + ? declaresTomlTable(input.contents.get(path), section) + : tomlTableAt(document, section.split(".")) !== undefined; } if (tool === "mypy") { return isDedicatedMypyConfig(path) || pythonIniHasSection(input.iniDocuments.get(path), "mypy"); @@ -420,6 +481,271 @@ function isDedicatedMypyConfig(path: string): boolean { return name === "mypy.ini" || name === ".mypy.ini"; } +function configReasonTool(path: string): Pick | Record { + const name = basename(path); + if (name === "ruff.toml" || name === ".ruff.toml") return { tool: "ruff" }; + if (name === "pyrightconfig.json") return { tool: "pyright" }; + if (name === "mypy.ini" || name === ".mypy.ini") return { tool: "mypy" }; + if (name === "pytest.ini") return { tool: "pytest" }; + return {}; +} + +function declaredPyprojectTools( + path: string, + content: string +): readonly ("mypy" | "pyright" | "ruff" | "pytest")[] { + if (basename(path) !== "pyproject.toml") return []; + return ([ + ["mypy", "tool.mypy"], + ["pyright", "tool.pyright"], + ["ruff", "tool.ruff"], + ["pytest", "tool.pytest.ini_options"] + ] as const) + .filter(([, section]) => declaresTomlTable(content, section)) + .map(([tool]) => tool); +} + +function declaresTomlTable(content: string | undefined, section: string): boolean { + if (content === undefined) return false; + const expected = section.split("."); + let currentTable: readonly string[] = []; + for (const rawLine of content.split(/\r?\n/u)) { + const line = stripTomlComment(rawLine).trim(); + if (line.length === 0) continue; + const table = parseTomlTableHeader(line); + if (table !== undefined) { + currentTable = table; + if (startsWithTomlPath(currentTable, expected)) return true; + continue; + } + const assignment = splitTomlAssignment(line); + if (assignment === undefined) continue; + const key = parseTomlDottedKey(assignment.key); + if (key === undefined) continue; + const path = [...currentTable, ...key]; + if (startsWithTomlPath(path, expected)) return true; + if ( + expected.length === 2 && + path.length === 1 && + path[0] === expected[0] && + inlineTomlTableDeclaresKey(assignment.value, expected[1]) + ) { + return true; + } + } + return false; +} + +async function selectClosestTargetRuffConfig( + workspace: PythonProjectWorkspace, + target: string, + visibleFiles: readonly string[] +): Promise { + const visible = new Set(visibleFiles); + for (const directory of ancestorDirectories(dirname(target))) { + for (const name of pythonToolConfigPrecedence.ruff) { + const path = joinRoot(directory, name); + if (!visible.has(path)) continue; + if (name !== "pyproject.toml") return path; + const resolved = await workspace.realpath(path); + if (resolved.unavailable || resolved.symlink || resolved.path !== path) continue; + const content = await workspace.read(path); + if (content === undefined) continue; + try { + if (tomlTableAt(parsePythonToml(content), ["tool", "ruff"]) !== undefined) return path; + } catch { + if (declaresTomlTable(content, "tool.ruff")) return path; + } + } + } + return undefined; +} + +function ancestorDirectories(start: string): readonly string[] { + const directories: string[] = []; + let current = start; + while (true) { + directories.push(current); + if (current === ".") break; + current = dirname(current); + } + return directories; +} + +function isDedicatedRuffConfig(path: string): boolean { + const name = basename(path); + return name === "ruff.toml" || name === ".ruff.toml"; +} + +function stripTomlComment(line: string): string { + let quote: "'" | "\"" | undefined; + let escaped = false; + for (let index = 0; index < line.length; index += 1) { + const character = line[index]; + if (quote === "\"") { + if (escaped) { + escaped = false; + continue; + } + if (character === "\\") { + escaped = true; + continue; + } + if (character === quote) quote = undefined; + continue; + } + if (quote === "'") { + if (character === quote) quote = undefined; + continue; + } + if (character === "\"" || character === "'") { + quote = character; + continue; + } + if (character === "#") return line.slice(0, index); + } + return line; +} + +function parseTomlTableHeader(line: string): readonly string[] | undefined { + if (!line.startsWith("[") || line.startsWith("[[")) return undefined; + const closing = findTomlToken(line, "]", 1); + if (closing < 0 || line.slice(closing + 1).trim().length > 0) return undefined; + return parseTomlDottedKey(line.slice(1, closing)); +} + +function splitTomlAssignment(line: string): { key: string; value: string } | undefined { + const equals = findTomlToken(line, "=", 0); + if (equals < 0) return undefined; + return { key: line.slice(0, equals), value: line.slice(equals + 1).trim() }; +} + +function findTomlToken(value: string, token: string, start: number): number { + let quote: "'" | "\"" | undefined; + let escaped = false; + for (let index = start; index < value.length; index += 1) { + const character = value[index]; + if (quote === "\"") { + if (escaped) { + escaped = false; + continue; + } + if (character === "\\") { + escaped = true; + continue; + } + if (character === quote) quote = undefined; + continue; + } + if (quote === "'") { + if (character === quote) quote = undefined; + continue; + } + if (character === "\"" || character === "'") { + quote = character; + continue; + } + if (character === token) return index; + } + return -1; +} + +function parseTomlDottedKey(value: string): readonly string[] | undefined { + const segments: string[] = []; + let index = 0; + while (index < value.length) { + while (/\s/u.test(value[index] ?? "")) index += 1; + if (index >= value.length) return undefined; + const quote = value[index] === "\"" || value[index] === "'" ? value[index] : undefined; + let segment = ""; + if (quote !== undefined) { + index += 1; + let closed = false; + while (index < value.length) { + const character = value[index]; + if (character === quote) { + index += 1; + closed = true; + break; + } + if (quote === "\"" && character === "\\" && index + 1 < value.length) { + segment += value[index + 1]; + index += 2; + continue; + } + segment += character; + index += 1; + } + if (!closed) return undefined; + } else { + const start = index; + while (index < value.length && /[A-Za-z0-9_-]/u.test(value[index])) index += 1; + segment = value.slice(start, index); + } + if (segment.length === 0) return undefined; + segments.push(segment); + while (/\s/u.test(value[index] ?? "")) index += 1; + if (index >= value.length) return segments; + if (value[index] !== ".") return undefined; + index += 1; + } + return undefined; +} + +function inlineTomlTableDeclaresKey(value: string, expected: string): boolean { + const trimmed = value.trim(); + if (!trimmed.startsWith("{")) return false; + let index = 1; + while (index < trimmed.length) { + while (/[\s,]/u.test(trimmed[index] ?? "")) index += 1; + if (trimmed[index] === "}" || index >= trimmed.length) return false; + const equals = findTomlToken(trimmed, "=", index); + if (equals < 0) return false; + const key = parseTomlDottedKey(trimmed.slice(index, equals).trim()); + if (key?.length === 1 && key[0] === expected) return true; + index = skipTomlValue(trimmed, equals + 1); + } + return false; +} + +function skipTomlValue(value: string, start: number): number { + let quote: "'" | "\"" | undefined; + let escaped = false; + let squareDepth = 0; + let curlyDepth = 0; + for (let index = start; index < value.length; index += 1) { + const character = value[index]; + if (quote === "\"") { + if (escaped) escaped = false; + else if (character === "\\") escaped = true; + else if (character === quote) quote = undefined; + continue; + } + if (quote === "'") { + if (character === quote) quote = undefined; + continue; + } + if (character === "\"" || character === "'") quote = character; + else if (character === "[") squareDepth += 1; + else if (character === "]") squareDepth -= 1; + else if (character === "{") curlyDepth += 1; + else if (character === "}") { + if (curlyDepth === 0 && squareDepth === 0) return index; + curlyDepth -= 1; + } else if (character === "," && squareDepth === 0 && curlyDepth === 0) return index + 1; + } + return value.length; +} + +function startsWithTomlPath(path: readonly string[], expected: readonly string[]): boolean { + return expected.every((segment, index) => path[index] === segment); +} + +function dirname(path: string): string { + const index = path.lastIndexOf("/"); + return index < 0 ? "." : path.slice(0, index) || "."; +} + function isTomlConfig(path: string): boolean { return path.endsWith(".toml") || ["Pipfile", "poetry.lock", "pdm.lock", "uv.lock"].includes(basename(path)); } diff --git a/packages/validation-python/src/syntax-check.ts b/packages/validation-python/src/syntax-check.ts index 2401655..d80fd68 100644 --- a/packages/validation-python/src/syntax-check.ts +++ b/packages/validation-python/src/syntax-check.ts @@ -17,6 +17,8 @@ import { type PythonCompilerFinding } from "./compiler-protocol.js"; import { diagnostic, sortDiagnostics } from "./diagnostics.js"; +import { groupPythonProjectContexts } from "./project-groups.js"; +import { missingPythonProjectContextResult } from "./python-context-result.js"; import { runTool } from "./process.js"; import { readPythonAfterSources, skippedPythonInputResult, type PythonProjectContextResolver } from "./source-files.js"; import { type PythonValidationToolchainOptions } from "./toolchain.js"; @@ -42,17 +44,17 @@ export function createSyntaxCheck( 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 resolvedContexts = await resolveContexts(context, undefined, []); 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 contexts = groupPythonProjectContexts(resolvedContexts); const diagnostics: ValidationDiagnostic[] = []; for (const project of contexts) { - if (project.interpreter === undefined) return resolutionFailure(project); + if (project.context.interpreter === undefined) return resolutionFailure(project.context); const selected = sources.filter((source) => project.targets.includes(source.path)); - const result = await compileSources(project.interpreter, selected, options); + const result = await compileSources(project.context.interpreter, selected, options); diagnostics.push(...(result.diagnostics ?? [])); if (result.outcome !== "passed" && result.outcome !== "findings") return result; } @@ -62,27 +64,7 @@ export function createSyntaxCheck( } 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 })] - }; -} - -type PythonSyntaxProjectGroup = PythonProjectContext & { targets: readonly string[] }; - -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); - } - return [...groups.values()] - .map(({ context, targets }) => ({ ...context, targets: [...new Set(targets)].sort() })) - .sort((left, right) => left.projectRoot.localeCompare(right.projectRoot)); + return missingPythonProjectContextResult("PY_SYNTAX_CONTEXT_MISSING", missing); } async function compileSources( diff --git a/packages/validation-python/src/toolchain.ts b/packages/validation-python/src/toolchain.ts index da6e241..29a2a3c 100644 --- a/packages/validation-python/src/toolchain.ts +++ b/packages/validation-python/src/toolchain.ts @@ -5,11 +5,19 @@ import type { ValidationAdapterRuntimeStatus, ValidationAdapterToolchainStatus } from "@the-open-engine/opcore-contracts"; -import { PYTHON_SYNTAX_CHECK_ID, PYTHON_TYPES_CHECK_ID, pythonValidationCheckIds } from "./check-ids.js"; +import { + PYTHON_RELEVANT_TESTS_CHECK_ID, + PYTHON_RUFF_FORMAT_CHECK_ID, + PYTHON_RUFF_LINT_CHECK_ID, + PYTHON_SYNTAX_CHECK_ID, + PYTHON_TYPES_CHECK_ID, + pythonValidationCheckIds +} from "./check-ids.js"; import { validationPythonAdapterName } from "./check-constants.js"; import type { PythonProjectProcessProbe } from "./environment-resolution.js"; import type { PythonProjectWorkspace } from "./project-workspace.js"; import { selectPythonTypeAuthority } from "./type-authority.js"; +import { hasUnresolvedRuffContext } from "./ruff-execution.js"; export interface PythonValidationToolchainOptions { repoRoot?: string; @@ -22,16 +30,20 @@ export interface PythonValidationToolchainOptions { timeoutMs?: number; contexts?: readonly PythonProjectContext[]; nodeWorkspace?: PythonProjectWorkspace; + activeCheckIds?: readonly string[]; } export function createPythonValidationAdapterStatus( options: PythonValidationToolchainOptions = {} ): ValidationAdapterRuntimeStatus { const toolchain = options.contexts === undefined ? unresolvedContextToolchain() : toolchainFromContexts(options.contexts); - const degradedChecks = pythonTypeDegradedChecks(options.contexts); + const missing = new Set(toolchain.filter((tool) => !tool.available).map((tool) => tool.tool)); + const activeChecks = new Set(options.activeCheckIds ?? [PYTHON_SYNTAX_CHECK_ID, PYTHON_TYPES_CHECK_ID]); + const degradedChecks = pythonDegradedChecks(options.contexts, missing, activeChecks); + const contextDegraded = (options.contexts ?? []).some((context) => contextHasActiveFailure(context, activeChecks)); return { adapter: validationPythonAdapterName, - status: degradedChecks.length > 0 || options.contexts?.some((context) => context.outcome !== "resolved") + status: degradedChecks.length > 0 || contextDegraded ? "degraded" : "available", checkIds: [...pythonValidationCheckIds], @@ -41,15 +53,22 @@ export function createPythonValidationAdapterStatus( }; } -function pythonTypeDegradedChecks( - contexts: readonly PythonProjectContext[] | undefined +function pythonDegradedChecks( + contexts: readonly PythonProjectContext[] | undefined, + missing: ReadonlySet, + activeChecks: ReadonlySet ): readonly ValidationAdapterDegradedCheckStatus[] { - if (contexts === undefined) return [contextRequiredDegradation()]; - const typeGaps = contexts.flatMap(pythonTypeGap); - const syntaxUnavailable = contexts.some((context) => context.interpreter === undefined); + const typeGaps = activeChecks.has(PYTHON_TYPES_CHECK_ID) + ? contexts?.flatMap(pythonTypeGap) ?? [] + : []; + const syntaxUnavailable = + activeChecks.has(PYTHON_SYNTAX_CHECK_ID) && + (contexts?.some((context) => context.interpreter === undefined) ?? true); return [ + ...(activeChecks.has(PYTHON_TYPES_CHECK_ID) && contexts === undefined ? [contextRequiredDegradation()] : []), ...(typeGaps.length === 0 ? [] : [typeGapDegradation(typeGaps)]), - ...(syntaxUnavailable ? [syntaxToolDegradation()] : []) + ...(syntaxUnavailable ? [syntaxToolDegradation()] : []), + ...ruffToolDegradations(missing, activeChecks) ]; } @@ -108,6 +127,63 @@ function syntaxToolDegradation(): ValidationAdapterDegradedCheckStatus { }; } +function ruffToolDegradations( + missing: ReadonlySet, + activeChecks: ReadonlySet +): readonly ValidationAdapterDegradedCheckStatus[] { + const degraded: ValidationAdapterDegradedCheckStatus[] = []; + if (missing.has("ruff")) { + if (activeChecks.has(PYTHON_RUFF_LINT_CHECK_ID)) { + degraded.push({ + checkId: PYTHON_RUFF_LINT_CHECK_ID, + status: "unsupported_request", + reason: "optional_tool_unavailable", + requiredTool: "ruff", + message: "Ruff is unavailable; python.ruff-lint cannot lint the selected after-state." + }); + } + if (activeChecks.has(PYTHON_RUFF_FORMAT_CHECK_ID)) { + degraded.push({ + checkId: PYTHON_RUFF_FORMAT_CHECK_ID, + status: "unsupported_request", + reason: "optional_tool_unavailable", + requiredTool: "ruff", + message: "Ruff is unavailable; python.ruff-format cannot verify formatting for the selected after-state." + }); + } + } + return degraded; +} + +function contextHasActiveFailure( + context: PythonProjectContext, + activeChecks: ReadonlySet +): boolean { + if (context.outcome === "resolved") return false; + const activeTypes = activeChecks.has(PYTHON_TYPES_CHECK_ID); + const activeSyntax = activeChecks.has(PYTHON_SYNTAX_CHECK_ID); + const activeRuff = activeChecks.has(PYTHON_RUFF_LINT_CHECK_ID) || activeChecks.has(PYTHON_RUFF_FORMAT_CHECK_ID); + const activeRelevantTests = activeChecks.has(PYTHON_RELEVANT_TESTS_CHECK_ID); + const activeContextConsumer = activeTypes || activeSyntax || activeRuff || activeRelevantTests; + if (!activeContextConsumer) return false; + if (activeRuff && hasUnresolvedRuffContext(context)) return true; + return context.reasons.some((reason) => { + switch (reason.tool) { + case "mypy": + case "pyright": + return activeTypes; + case "ruff": + return activeRuff; + case "pytest": + return activeRelevantTests; + case "python": + return activeTypes || activeSyntax || activeRelevantTests; + default: + return reason.code !== "missing_config" && (activeTypes || activeSyntax || activeRelevantTests); + } + }); +} + function toolchainFromContexts(contexts: readonly PythonProjectContext[]): readonly ValidationAdapterToolchainStatus[] { const entries: ValidationAdapterToolchainStatus[] = []; for (const context of contexts) { diff --git a/packages/validation-python/src/type-capability-run.ts b/packages/validation-python/src/type-capability-run.ts index 41a1dc6..e99d4b0 100644 --- a/packages/validation-python/src/type-capability-run.ts +++ b/packages/validation-python/src/type-capability-run.ts @@ -3,26 +3,21 @@ import type { PythonProjectToolProvenance, PythonValidationAuthority, PythonValidationAuthoritySource, - PythonValidationCapabilityRun, + PythonTypesValidationCapabilityRun, 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"; +import { normalizeValidationFileViewPath, type ValidationFileView } from "@the-open-engine/opcore-validation"; +import { isAbsolute, posix, relative, resolve } from "node:path"; +import { + materializePreparedPythonExecutionWorkspace, + preparePythonExecutionWorkspace, + readPythonExecutionWorkspaceRecords, + type PythonExecutionWorkspaceRecord +} from "./python-execution-workspace.js"; 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[]; @@ -30,7 +25,7 @@ export interface PythonTypeCapabilityPreparation { selectedConfigPaths: readonly string[]; moduleSearchRoots: readonly string[]; afterStateManifestFingerprint: string; - records: readonly ManifestRecord[]; + records: readonly PythonExecutionWorkspaceRecord[]; } export interface MaterializedPythonTypeWorkspace { @@ -56,26 +51,17 @@ export async function preparePythonTypeCapability(args: { const selectedSourcePaths = uniqueSorted(args.sourcePaths); const selectedConfigPaths = uniqueSorted(args.configPaths); const manifestConfigPaths = authorityCandidatePaths(args.project.projectRoot); - const records: ManifestRecord[] = []; - for (const path of uniqueSorted([ + const records = await readPythonExecutionWorkspaceRecords(args.fileView, [ ...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, + ]); + const workspacePreparation = preparePythonExecutionWorkspace({ + project: args.project, targets, - records: portableRecords + sourcePaths: selectedSourcePaths, + configPaths: selectedConfigPaths, + records }); return { project: args.project, @@ -83,8 +69,8 @@ export async function preparePythonTypeCapability(args: { selectedSourcePaths, selectedConfigPaths, moduleSearchRoots: uniqueSorted(args.moduleSearchRoots ?? args.project.sourceRoots), - afterStateManifestFingerprint: `sha256:${createHash("sha256").update(canonical, "utf8").digest("hex")}`, - records + afterStateManifestFingerprint: workspacePreparation.afterStateFingerprint, + records: workspacePreparation.records }; } @@ -96,43 +82,34 @@ function authorityCandidatePaths(projectRoot: string): readonly string[] { export async function materializePythonTypeCapability( preparation: PythonTypeCapabilityPreparation ): Promise { - const tempRoot = mkdtempSync(join(tmpdir(), "opcore-python-types-workspace-")); - const rawRoot = join(tempRoot, "repo"); + const workspace = await materializePreparedPythonExecutionWorkspace({ + project: preparation.project, + targets: preparation.targets, + sourcePaths: preparation.selectedSourcePaths, + configPaths: preparation.selectedConfigPaths, + afterStateFingerprint: preparation.afterStateManifestFingerprint, + records: preparation.records + }, { + tempPrefix: `opcore-python-types-workspace-${process.pid}-`, + generatedProjectFiles: [{ path: isolatedMypyConfigPath, content: "[mypy]\n" }], + runtimeDirectories: ["home", "xdg-config", "xdg-cache", "tmp", "pyright-cache"] + }); 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) + path === "." ? workspace.root : resolveRepoPath(workspace.root, path) ); return { - root, - runtimeRoot, - projectCwd, + root: workspace.root, + runtimeRoot: workspace.runtimeRoot, + projectCwd: workspace.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 }) + selectedSourcePaths: workspace.sourcePaths, + selectedConfigPaths: workspace.configPaths, + afterStateContentByPath: workspace.afterStateContentByPath, + cleanup: workspace.cleanup }; } catch (error) { - rmSync(tempRoot, { recursive: true, force: true }); + workspace.cleanup(); throw error; } } @@ -145,8 +122,8 @@ export function createPythonTypeCapabilityRun(args: { durationMs: number; counts?: { diagnosticCount: number; errorCount: number; warningCount: number; noteCount: number }; tool?: PythonValidationCapabilityToolProvenance; - execution?: PythonValidationCapabilityRun["execution"]; -}): PythonValidationCapabilityRun { + execution?: PythonTypesValidationCapabilityRun["execution"]; +}): PythonTypesValidationCapabilityRun { const counts = args.counts ?? { diagnosticCount: 0, errorCount: 0, warningCount: 0, noteCount: 0 }; return { schemaId: "opcore.python.validation-capability-run", @@ -176,14 +153,14 @@ export function portablePythonValidationTool(args: { authority: PythonValidationAuthority; argv?: readonly string[]; }): PythonValidationCapabilityToolProvenance { - const executable = portableExecutableLocator( + const executable = portablePythonExecutableLocator( 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)); + : portablePythonValidationArgument(argument, args.preparation.project.repositoryRoot)); return { name: args.authority, executable, @@ -195,15 +172,15 @@ export function portablePythonValidationTool(args: { }; } -function portableArgument(argument: string, repositoryRoot: string): string { +export function portablePythonValidationArgument(argument: string, repositoryRoot: string): string { const assignment = /^(.*?=)(.*)$/u.exec(argument); if (assignment !== null && isHostAbsolutePath(assignment[2])) { - return `${assignment[1]}${portableExecutableLocator(assignment[2], repositoryRoot)}`; + return `${assignment[1]}${portablePythonExecutableLocator(assignment[2], repositoryRoot)}`; } - return isHostAbsolutePath(argument) ? portableExecutableLocator(argument, repositoryRoot) : argument; + return isHostAbsolutePath(argument) ? portablePythonExecutableLocator(argument, repositoryRoot) : argument; } -function portableExecutableLocator(executable: string, repositoryRoot: string): string { +export function portablePythonExecutableLocator(executable: string, repositoryRoot: string): string { if (!isHostAbsolutePath(executable)) { if (!executable.includes("/") && !executable.includes("\\")) return `path:${executable}`; const normalized = posix.normalize(executable.replaceAll("\\", "/").replace(/^\.\//u, "")); @@ -243,7 +220,7 @@ export function repoRelativeMaterializedPath(path: string, projectCwd: string, w 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("..")) { + if (relativePath === "" || relativePath === ".." || relativePath.startsWith("../") || relativePath.startsWith("..\\")) { throw new Error(`Repo-relative path escapes materialized Python workspace: ${path}`); } return absolute; diff --git a/packages/validation-python/src/type-check.ts b/packages/validation-python/src/type-check.ts index 6b29ecc..fa613af 100644 --- a/packages/validation-python/src/type-check.ts +++ b/packages/validation-python/src/type-check.ts @@ -3,7 +3,7 @@ import type { PythonProjectToolProvenance, PythonValidationAuthority, PythonValidationAuthoritySource, - PythonValidationCapabilityRun, + PythonTypesValidationCapabilityRun, PythonValidationCapabilityRunStatus, PythonValidationCapabilityToolProvenance, ValidationCheckOutcome, @@ -21,8 +21,10 @@ 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 { missingPythonProjectContextResult } from "./python-context-result.js"; import { pythonInputSet, + selectPythonSourceFilesForTargets, skippedPythonInputResult, type PythonMaterializedSourceSet, type PythonProjectContextResolver, @@ -48,7 +50,7 @@ interface PythonProjectGroup { } interface ProjectAttempt { - run?: PythonValidationCapabilityRun; + run?: PythonTypesValidationCapabilityRun; diagnostics: readonly ValidationDiagnostic[]; outcome: ValidationCheckOutcome; failureMessage?: string; @@ -68,7 +70,9 @@ export function createTypeCheck( run: async (validation) => { const skipped = skippedPythonInputResult(validation); if (skipped !== undefined) return skipped; - if (resolveContexts === undefined) return missingContextResult(pythonInputSet(validation)); + if (resolveContexts === undefined) { + return missingPythonProjectContextResult("PYTHON_CONTEXT_MISSING", 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) { @@ -77,10 +81,10 @@ export function createTypeCheck( // 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 contexts = await resolveContexts(validation, sourceSet.allPaths, ["mypy", "pyright"]); 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); + if (missing.length > 0) return missingPythonProjectContextResult("PYTHON_CONTEXT_MISSING", missing); const projects = groupProjectContexts(sourceSet.rootPaths, contextByTarget); const activeProjectKeys = new Set(projects.map((project) => project.context.projectKey)); const attempts: ProjectAttempt[] = []; @@ -404,18 +408,3 @@ function aggregateOutcome(outcomes: readonly ValidationCheckOutcome[]): Validati function uniqueSorted(values: readonly string[]): readonly string[] { return [...new Set(values)].sort(); } - -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 index 7338077..dac0bfa 100644 --- a/packages/validation-python/src/type-result.ts +++ b/packages/validation-python/src/type-result.ts @@ -49,7 +49,11 @@ export function terminatedTypeResult( tool: context.tool, status, durationMs, - execution: nonExitedExecution(result.termination, result.signal, failureMessage), + execution: nonExitedExecution( + result.termination === "overflow" ? "spawn_error" : result.termination, + result.signal, + failureMessage + ), failureMessage }); } diff --git a/packages/validation-python/src/type-runner-runtime.ts b/packages/validation-python/src/type-runner-runtime.ts index 7662a64..1cc1608 100644 --- a/packages/validation-python/src/type-runner-runtime.ts +++ b/packages/validation-python/src/type-runner-runtime.ts @@ -34,7 +34,7 @@ export async function withMaterializedTypeExecution( export function isolatedTypeEnvironmentBase(args: { input: Record | undefined; executable: string; - workspace: MaterializedPythonTypeWorkspace; + workspace: Pick; includeProcessExecPath?: boolean; extra?: Record; }): Record { diff --git a/packages/validation-python/src/type-runner-types.ts b/packages/validation-python/src/type-runner-types.ts index eccf06b..dbb0830 100644 --- a/packages/validation-python/src/type-runner-types.ts +++ b/packages/validation-python/src/type-runner-types.ts @@ -2,7 +2,7 @@ import type { PythonProjectToolProvenance, PythonValidationAuthority, PythonValidationAuthoritySource, - PythonValidationCapabilityRun, + PythonTypesValidationCapabilityRun, PythonValidationCapabilityToolProvenance, ValidationDiagnostic } from "@the-open-engine/opcore-contracts"; @@ -12,7 +12,7 @@ import type { } from "./type-capability-run.js"; export interface TypeCapabilityResult { - run: PythonValidationCapabilityRun; + run: PythonTypesValidationCapabilityRun; diagnostics: readonly ValidationDiagnostic[]; failureMessage?: string; } diff --git a/packages/validation/src/aggregation.ts b/packages/validation/src/aggregation.ts index 57de785..1d94ce6 100644 --- a/packages/validation/src/aggregation.ts +++ b/packages/validation/src/aggregation.ts @@ -84,10 +84,17 @@ 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 ?? ""); + return pythonCapabilitySortKey(left).localeCompare(pythonCapabilitySortKey(right)); +} + +function pythonCapabilitySortKey(run: PythonValidationCapabilityRun): string { + return JSON.stringify({ + checkId: run.checkId, + capability: run.capability, + projectKey: run.projectKey ?? "", + contextFingerprint: run.contextFingerprint ?? "", + receipt: run + }); } function deduplicatePythonProjectContexts(contexts: readonly PythonProjectContext[]): readonly PythonProjectContext[] { diff --git a/packages/validation/src/registry.ts b/packages/validation/src/registry.ts index 853df18..358f2e9 100644 --- a/packages/validation/src/registry.ts +++ b/packages/validation/src/registry.ts @@ -20,6 +20,7 @@ const diagnosticSeverities = ["info", "warning", "error"] as const; export interface ValidationCheckContext { request: ValidationRequest; + selectedCheckIds: readonly string[]; scope: ResolvedValidationScope; graphStatus: GraphProviderStatus; graph: ValidationGraphQuerySession; @@ -29,6 +30,7 @@ export interface ValidationCheckContext { } export type ValidationPersistentCacheMode = "enabled" | "disabled"; +export type ValidationInactiveCheckState = "not_applicable" | "disabled"; export interface ValidationRuntimePolicy { persistentCaches: ValidationPersistentCacheMode; @@ -52,6 +54,11 @@ export interface ValidationCheckDefinition { defaultScopes?: readonly ValidationScopeKind[]; requiresGraph?: boolean; graphUsage?: "none" | "optional" | "required"; + inactiveResult?: ( + context: ValidationCheckContext, + state: ValidationInactiveCheckState + ) => ValidationCheckResult | readonly ValidationDiagnostic[] | void | Promise; + inactiveStateWhenUnselected?: ValidationInactiveCheckState; graphRequirements?: ( context: ValidationCheckContext ) => readonly ValidationGraphQueryRequirement[] | Promise; @@ -184,6 +191,19 @@ function validateValidationCheckDefinition(definition: ValidationCheckDefinition if (definition.graphUsage !== undefined && !["none", "optional", "required"].includes(definition.graphUsage)) { throw new ValidationCheckRegistryError("Validation check graphUsage must be none, optional, or required"); } + if (definition.inactiveResult !== undefined && typeof definition.inactiveResult !== "function") { + throw new ValidationCheckRegistryError("Validation check inactiveResult must be a function"); + } + if (definition.inactiveStateWhenUnselected !== undefined) { + if (definition.inactiveResult === undefined) { + throw new ValidationCheckRegistryError("Validation check inactiveStateWhenUnselected requires inactiveResult"); + } + if (definition.inactiveStateWhenUnselected !== "not_applicable" && definition.inactiveStateWhenUnselected !== "disabled") { + throw new ValidationCheckRegistryError( + `Unknown validation check inactiveStateWhenUnselected: ${String(definition.inactiveStateWhenUnselected)}` + ); + } + } if (definition.graphRequirements !== undefined && typeof definition.graphRequirements !== "function") { throw new ValidationCheckRegistryError("Validation check graphRequirements must be a function"); } diff --git a/packages/validation/src/runner.ts b/packages/validation/src/runner.ts index 2d48b02..493c832 100644 --- a/packages/validation/src/runner.ts +++ b/packages/validation/src/runner.ts @@ -108,6 +108,8 @@ interface ExecuteChecksArgs { graph: ValidationGraphQuerySession; fileView: ValidationFileView; resources: ValidationRunResources; + allChecks: readonly ValidationCheckDefinition[]; + activeCheckIds: readonly string[]; selectedChecks: readonly ValidationCheckDefinition[]; totalStartedAt: number; clock: ValidationClock; @@ -411,6 +413,8 @@ async function executeValidationState( graph: state.graph, fileView: state.fileView, resources: state.resources, + allChecks: args.options.registry.checks, + activeCheckIds: args.selectedChecks.map((check) => check.id), selectedChecks, totalStartedAt: args.totalStartedAt, clock: options.clock, @@ -470,73 +474,129 @@ async function executeSelectedChecks(args: ExecuteChecksArgs): Promise { + const selectedCheckIds = new Set(args.activeCheckIds); + for (const check of args.allChecks) { + if ( + selectedCheckIds.has(check.id) || + check.inactiveResult === undefined || + !check.supportedScopes.includes(args.scope.kind) + ) { + continue; } + const normalized = normalizeCheckResult( + await check.inactiveResult(checkContext(args), check.inactiveStateWhenUnselected ?? "not_applicable") + ); + const diagnostics = normalized.diagnostics ?? []; + const status = normalized.status ?? statusForOutcome(normalized.outcome) ?? + (diagnostics.some((diagnostic) => diagnostic.severity === "error") ? "policy_failure" : "passed"); + const failureStatus = failureStatusForCheckRun(status); + const failureMessage = + normalized.failureMessage ?? (failureStatus === undefined ? undefined : `Validation check returned ${status}`); + execution.runs.push({ + checkId: check.id, + status, + ...(normalized.outcome === undefined ? {} : { outcome: normalized.outcome }), + durationMs: 0, + diagnosticCount: diagnostics.length, + ...(failureMessage === undefined ? {} : { failureMessage }), + ...(normalized.pythonCapabilityRuns === undefined + ? {} + : { pythonCapabilityRuns: normalized.pythonCapabilityRuns }) + }); + mergePythonProjectContexts(execution.pythonProjectContexts, normalized.pythonProjectContexts ?? []); + execution.diagnosticsByCheck.set(check.id, [...diagnostics]); + execution.diagnostics.push(...diagnostics); } - return execution; } function outcomeCheckCompleteEvent(check: ValidationCheckDefinition, outcome: SingleCheckOutcome): ValidationCheckCompleteEvent { @@ -678,7 +738,10 @@ async function runSingleCheck(check: ValidationCheckDefinition, args: ExecuteChe ...(normalized.outcome === undefined ? {} : { outcome: normalized.outcome }), durationMs: elapsed(checkStartedAt, args.clock.nowMs()), diagnosticCount: diagnostics.length, - ...(failureMessage === undefined ? {} : { failureMessage }) + ...(failureMessage === undefined ? {} : { failureMessage }), + ...(normalized.pythonCapabilityRuns === undefined + ? {} + : { pythonCapabilityRuns: normalized.pythonCapabilityRuns }) } }; } catch (error) { @@ -717,6 +780,7 @@ async function runSingleCheck(check: ValidationCheckDefinition, args: ExecuteChe function checkContext(args: ExecuteChecksArgs) { return { request: args.request, + selectedCheckIds: args.activeCheckIds, scope: args.scope, graphStatus: args.graph.status, graph: args.graph, @@ -959,13 +1023,21 @@ function mergePythonCapabilityRuns( 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 ?? "") + pythonCapabilitySortKey(left).localeCompare(pythonCapabilitySortKey(right)) )); } +function pythonCapabilitySortKey(run: PythonValidationCapabilityRun): string { + return JSON.stringify({ + checkId: run.checkId, + capability: run.capability, + projectKey: "projectKey" in run ? run.projectKey ?? "" : "", + contextFingerprint: "contextFingerprint" in run ? run.contextFingerprint ?? "" : "", + state: "state" in run ? run.state : "", + receipt: run + }); +} + function isValidationDiagnosticArray( result: ValidationCheckResult | readonly ValidationDiagnostic[] ): result is readonly ValidationDiagnostic[] { diff --git a/scripts/check-python-mypy-authority.mjs b/scripts/check-python-mypy-authority.mjs index 02bef42..e7a16bc 100644 --- a/scripts/check-python-mypy-authority.mjs +++ b/scripts/check-python-mypy-authority.mjs @@ -106,7 +106,7 @@ try { } finally { rmSync(attackRoot, { recursive: true, force: true }); } -assert.equal(readFileSync(resolve(fixtureRoot, "src/acme/app.py"), "utf8"), originalApp); +assert.equal(readFileSync(resolve(fixtureRoot, "src/acme/app.py.fixture"), "utf8"), originalApp); assert.equal(existsSync(resolve(fixtureRoot, ".mypy_cache")), false); assert.equal(existsSync(resolve(fixtureRoot, "__pycache__")), false); assert.deepEqual(mypyTempWorkspaces(), tempBefore); @@ -189,7 +189,9 @@ function walk(directory) { } function mypyTempWorkspaces() { - return readdirSync(tmpdir()).filter((name) => name.startsWith("opcore-python-types-workspace-")).sort(); + return readdirSync(tmpdir()) + .filter((name) => name.startsWith(`opcore-python-types-workspace-${process.pid}-`)) + .sort(); } function normalizedDiagnostic(entry) { diff --git a/scripts/check-python-pyright-authority.mjs b/scripts/check-python-pyright-authority.mjs index 6cf70fa..9871c17 100644 --- a/scripts/check-python-pyright-authority.mjs +++ b/scripts/check-python-pyright-authority.mjs @@ -219,7 +219,9 @@ function treeHash(root, include = () => true) { } function pyrightTempWorkspaces() { - return readdirSync(tmpdir()).filter((name) => name.startsWith("opcore-python-types-workspace-")).sort(); + return readdirSync(tmpdir()) + .filter((name) => name.startsWith(`opcore-python-types-workspace-${process.pid}-`)) + .sort(); } function normalizedDiagnostic(entry) { diff --git a/scripts/generate-cutover-receipt.mjs b/scripts/generate-cutover-receipt.mjs index 57f1388..30b9ddf 100644 --- a/scripts/generate-cutover-receipt.mjs +++ b/scripts/generate-cutover-receipt.mjs @@ -530,7 +530,7 @@ function runPythonToolDegradationNegativeChecks(tempRoot, opcoreBin, env, comman if (statusParsed.owner !== "runtime" || statusParsed.validationResult !== undefined || statusParsed.repoState === undefined) { throw new Error("python-toolchain-degraded-no-tools did not return read-only repoState status evidence"); } - assertPythonRepoState("python-toolchain-degraded-no-tools", statusParsed.repoState, ["mypy", "pyright", "ruff", "pytest"]); + assertPythonRepoState("python-toolchain-degraded-no-tools", statusParsed.repoState, ["mypy", "pyright", "pytest"]); return [ { id: "python-types-degraded-no-tools", @@ -544,7 +544,7 @@ function runPythonToolDegradationNegativeChecks(tempRoot, opcoreBin, env, comman command: ["opcore", "check", "files", "src/acme/app.py", "--checks", "python.source-hygiene"], status: "passed", exitCode: 0, - assertion: "source-hygiene check ran built-in policy while status reported ruff absent" + assertion: "source hygiene stayed honest without ruff" }, { id: "python-relevant-tests-no-pytest", @@ -558,7 +558,7 @@ function runPythonToolDegradationNegativeChecks(tempRoot, opcoreBin, env, comman command: ["opcore", "status"], status: "passed", exitCode: 0, - assertion: "read-only status reported absent mypy, pyright, ruff, and pytest as degraded" + assertion: "missing Python toolchain stayed degraded" } ]; } diff --git a/tests/asp-provider.test.mjs b/tests/asp-provider.test.mjs index a7d1dc1..4607fa7 100644 --- a/tests/asp-provider.test.mjs +++ b/tests/asp-provider.test.mjs @@ -33,6 +33,8 @@ const allCheckIds = [ "rust.function-metrics", "python.syntax", "python.source-hygiene", + "python.ruff-lint", + "python.ruff-format", "python.types", "python.import-graph", "python.dead-code", @@ -413,7 +415,7 @@ describe("Opcore ASP provider", () => { 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"), + contextEvidence.data.reasons.some((reason) => reason.code === "tool_unavailable" || reason.code === "interpreter_unavailable"), true, JSON.stringify(contextEvidence.data.reasons) ); @@ -551,6 +553,309 @@ describe("Opcore ASP provider", () => { } }); + it("emits Python capability receipt evidence for selected Ruff checks", { timeout: 60000 }, async () => { + assert.equal(existsSync(providerBin), true, "run npm run build before asp-provider tests"); + const repo = mkdtempSync(join(tmpdir(), "opcore-asp-provider-ruff-")); + try { + const toolBin = join(repo, ".venv", "bin"); + mkdirSync(toolBin, { recursive: true }); + writeFileSync(join(repo, "pyproject.toml"), "[project]\nname='ruff-fixture'\nrequires-python='>=3.8'\n"); + writeFileSync(join(repo, "ruff.toml"), "[lint]\nselect = [\"F401\"]\n"); + writeFileSync(join(repo, "pkg.py"), "value = 1\n"); + const ruffPath = join(toolBin, "ruff"); + writeFileSync( + ruffPath, + [ + "#!/bin/sh", + "if [ \"$1\" = \"--version\" ]; then echo 'ruff 0.6.9'; exit 0; fi", + "if [ \"$1\" = \"check\" ]; then", + " printf '%s\\n' '[{\"code\":\"F401\",\"filename\":\"pkg.py\",\"location\":{\"row\":1,\"column\":8},\"end_location\":{\"row\":1,\"column\":10},\"message\":\"unused import\"}]'", + " exit 1", + "fi", + "exit 2", + "" + ].join("\n") + ); + chmodSync(ruffPath, 0o755); + + const host = createHostWorkspace({ + "pyproject.toml": "[project]\nname='ruff-fixture'\nrequires-python='>=3.8'\n", + "ruff.toml": "[lint]\nselect = [\"F401\"]\n", + "pkg.py": "value = 1\n" + }); + const peer = spawnProvider(host, process.env, repo); + 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("pkg.py", "import os\n")]), + comparison: "all", + checks: ["python.ruff-lint"] + }); + assert.equal(assessment.status, "complete", JSON.stringify(assessment, null, 2)); + assert.equal( + assessment.coverage.degraded.some((entry) => entry.requirement.startsWith("python-project-context:")), + false, + JSON.stringify(assessment.coverage, null, 2) + ); + const receipts = assessment.evidence.filter((entry) => entry.kind === "python_validation_capability_run"); + assert.deepEqual(receipts.map((receipt) => receipt.data.checkId), ["python.ruff-lint"]); + assert.equal(receipts[0].data.capability, "ruff_lint"); + assert.equal(receipts[0].data.state, "findings"); + assert.match(receipts[0].data.afterStateManifestFingerprint, /^sha256:[a-f0-9]{64}$/); + assert.deepEqual(receipts[0].data.sourcePaths, ["pkg.py"]); + assert.equal(receipts[0].data.executable, "repo:.venv/bin/ruff"); + assert.equal(receipts[0].data.argv[0], "repo:.venv/bin/ruff"); + assert.equal(JSON.stringify(receipts[0].data).includes(repo), false); + assert.equal(receipts[0].data.diagnosticCount, 1); + assert.equal(receipts[0].data.termination, "exited"); + assert.equal(receipts[0].data.exitCode, 1); + } finally { + peer.close(); + } + } finally { + rmSync(repo, { recursive: true, force: true }); + } + }); + + it("does not activate opt-in Ruff checks when check selection is omitted", { timeout: 60000 }, async () => { + assert.equal(existsSync(providerBin), true, "run npm run build before asp-provider tests"); + const repo = mkdtempSync(join(tmpdir(), "opcore-asp-provider-ruff-defaults-")); + try { + const toolBin = join(repo, ".venv", "bin"); + const marker = join(repo, "ruff-invoked"); + mkdirSync(toolBin, { recursive: true }); + writeFileSync(join(repo, "pyproject.toml"), "[project]\nname='ruff-defaults'\n"); + writeFileSync(join(repo, "app.py"), "value = 1\n"); + const ruffPath = join(toolBin, "ruff"); + writeFileSync(ruffPath, `#!/bin/sh\nprintf invoked > ${JSON.stringify(marker)}\nexit 2\n`); + chmodSync(ruffPath, 0o755); + + const host = createHostWorkspace({ + "pyproject.toml": "[project]\nname='ruff-defaults'\n", + "app.py": "value = 1\n" + }); + const peer = spawnProvider(host, { ...process.env, PATH: "" }, repo); + 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("app.py", "value = 2\n")]), + comparison: "all" + }); + + assert.equal(existsSync(marker), false); + assert.equal( + assessment.evidence.some((entry) => + entry.kind === "python_validation_capability_run" && + (entry.data.checkId === "python.ruff-lint" || entry.data.checkId === "python.ruff-format") + ), + false + ); + } finally { + peer.close(); + } + } finally { + rmSync(repo, { recursive: true, force: true }); + } + }); + + it("activates Ruff defaults from host config when ASP check selection is omitted", { timeout: 60000 }, async () => { + assert.equal(existsSync(providerBin), true, "run npm run build before asp-provider tests"); + const repo = mkdtempSync(join(tmpdir(), "opcore-asp-provider-ruff-policy-default-")); + try { + const toolBin = join(repo, ".venv", "bin"); + const marker = join(repo, "ruff-invoked"); + const config = JSON.stringify({ + validation: { + checks: { + defaults: ["python.ruff-lint"] + } + } + }); + mkdirSync(toolBin, { recursive: true }); + writeFileSync(join(repo, "pyproject.toml"), "[project]\nname='ruff-default'\n"); + writeFileSync(join(repo, "app.py"), "value = 1\n"); + const ruffPath = join(toolBin, "ruff"); + writeFileSync( + ruffPath, + [ + "#!/bin/sh", + "if [ \"$1\" = \"--version\" ]; then echo 'ruff 0.6.9'; exit 0; fi", + `printf invoked > ${JSON.stringify(marker)}`, + "printf '%s\\n' '[]'", + "exit 0", + "" + ].join("\n") + ); + chmodSync(ruffPath, 0o755); + + const host = createHostWorkspace({ + ".opcore/config": config, + "pyproject.toml": "[project]\nname='ruff-default'\n", + "app.py": "value = 1\n" + }); + const peer = spawnProvider(host, process.env, repo); + 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("app.py", "value = 2\n")]), + comparison: "all" + }); + + assert.equal(existsSync(marker), true); + const receipt = assessment.evidence.find((entry) => + entry.kind === "python_validation_capability_run" && + entry.data.checkId === "python.ruff-lint" + ); + assert.equal(receipt?.data.state, "passed"); + assert.equal(receipt?.data.invocations?.length, 1); + } finally { + peer.close(); + } + } finally { + rmSync(repo, { recursive: true, force: true }); + } + }); + + it("preserves selected Ruff identity for activated tool-unavailable diagnostics", { timeout: 60000 }, async () => { + assert.equal(existsSync(providerBin), true, "run npm run build before asp-provider tests"); + const repo = mkdtempSync(join(tmpdir(), "opcore-asp-provider-ruff-unavailable-")); + try { + writeFileSync(join(repo, "pyproject.toml"), "[project]\nname='ruff-unavailable'\n[tool.ruff]\n"); + writeFileSync(join(repo, "app.py"), "value = 1\n"); + const host = createHostWorkspace({ + "pyproject.toml": "[project]\nname='ruff-unavailable'\n[tool.ruff]\n", + "app.py": "value = 1\n" + }); + const peer = spawnProvider(host, { ...process.env, PATH: "" }, repo); + 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("app.py", "value = 2\n")]), + comparison: "all", + checks: ["python.ruff-lint"] + }); + + const unavailable = assessment.diagnostics.find((entry) => + entry.code.endsWith("/PY_RUFF_LINT_TOOL_UNAVAILABLE") + ); + assert.equal( + unavailable?.code, + "opcore/python.ruff-lint/PY_RUFF_LINT_TOOL_UNAVAILABLE" + ); + } finally { + peer.close(); + } + } finally { + rmSync(repo, { recursive: true, force: true }); + } + }); + + it("applies ASP disabled policy before explicit Ruff selection", { timeout: 60000 }, async () => { + assert.equal(existsSync(providerBin), true, "run npm run build before asp-provider tests"); + const repo = mkdtempSync(join(tmpdir(), "opcore-asp-provider-ruff-policy-disabled-")); + try { + const toolBin = join(repo, ".venv", "bin"); + const marker = join(repo, "ruff-invoked"); + const config = JSON.stringify({ + validation: { + checks: { + defaults: ["python.ruff-lint"], + disabled: ["python.ruff-lint"] + } + } + }); + mkdirSync(toolBin, { recursive: true }); + writeFileSync(join(repo, "pyproject.toml"), "[project]\nname='ruff-disabled'\n"); + writeFileSync(join(repo, "app.py"), "value = 1\n"); + const ruffPath = join(toolBin, "ruff"); + writeFileSync(ruffPath, `#!/bin/sh\nprintf invoked > ${JSON.stringify(marker)}\nexit 1\n`); + chmodSync(ruffPath, 0o755); + + const host = createHostWorkspace({ + ".opcore/config": config, + "pyproject.toml": "[project]\nname='ruff-disabled'\n", + "app.py": "value = 1\n" + }); + const peer = spawnProvider(host, { ...process.env, PATH: "" }, repo); + 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("app.py", "value = 2\n")]), + comparison: "all", + checks: ["python.ruff-lint"] + }); + + assert.equal(existsSync(marker), false); + const receipt = assessment.evidence.find((entry) => + entry.kind === "python_validation_capability_run" && + entry.data.checkId === "python.ruff-lint" + ); + assert.equal(receipt?.data.state, "disabled"); + assert.equal(receipt?.data.termination, undefined); + } 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"); @@ -717,9 +1022,10 @@ describe("Opcore ASP provider claim scrub", () => { }); }); -function spawnProvider(host) { +function spawnProvider(host, env = process.env, cwd = repoRoot) { const child = spawn(process.execPath, [providerBin, "--stdio"], { - cwd: repoRoot, + cwd, + env, stdio: ["pipe", "pipe", "pipe"] }); const peer = new TestJsonRpcPeer(child, host).start(); diff --git a/tests/conformance.test.mjs b/tests/conformance.test.mjs index ce1f3f7..547a67d 100644 --- a/tests/conformance.test.mjs +++ b/tests/conformance.test.mjs @@ -400,6 +400,8 @@ describe("conformance fixture metadata", () => { assert.deepEqual(validation.checks, [ "python.syntax", "python.source-hygiene", + "python.ruff-lint", + "python.ruff-format", "python.types", "python.import-graph", "python.dead-code", diff --git a/tests/contracts.test.mjs b/tests/contracts.test.mjs index eca2bb9..7a55c32 100644 --- a/tests/contracts.test.mjs +++ b/tests/contracts.test.mjs @@ -1223,6 +1223,297 @@ describe("Opcore shared contracts", () => { ); }); + it("validates portable Python capability receipts on validation runs", () => { + const result = validateValidationResultPayload(validValidationResult({ + status: "policy_failure", + diagnostics: [], + failure: { category: "policy_failure", message: "finding" }, + manifest: { + schemaVersion: GRAPH_SCHEMA_VERSION, + checks: ["python.ruff-lint"], + generatedAt: "2026-07-18T00:00:00.000Z", + runs: [{ + checkId: "python.ruff-lint", + status: "policy_failure", + outcome: "findings", + diagnosticCount: 1, + pythonCapabilityRuns: [{ + schemaId: "opcore.python.validation-capability-run", + schemaVersion: 1, + checkId: "python.ruff-lint", + capability: "ruff_lint", + state: "findings", + projectKey: `sha256:${"1".repeat(64)}`, + contextFingerprint: `sha256:${"2".repeat(64)}`, + afterStateManifestFingerprint: `sha256:${"3".repeat(64)}`, + sourcePaths: ["pkg/app.py"], + configPaths: ["ruff.toml"], + executable: "repo:.venv/bin/ruff", + command: "repo:.venv/bin/ruff check --output-format=json pkg/app.py", + argv: ["repo:.venv/bin/ruff", "check", "--output-format=json", "pkg/app.py"], + cwd: ".", + configPath: "ruff.toml", + toolVersion: "0.6.9", + toolSource: "project_local_environment", + termination: "exited", + exitCode: 1, + invocations: [{ + argv: ["repo:.venv/bin/ruff", "check", "--output-format=json", "pkg/app.py"], + termination: "exited", + exitCode: 1, + durationMs: 12 + }], + durationMs: 12, + diagnosticCount: 1 + }] + }] + } + })); + + assert.equal(result.manifest.runs[0].pythonCapabilityRuns[0].capability, "ruff_lint"); + assert.equal(result.manifest.runs[0].pythonCapabilityRuns[0].termination, "exited"); + const portableRun = result.manifest.runs[0].pythonCapabilityRuns[0]; + for (const invalidRun of [ + { ...portableRun, executable: "/tmp/private/ruff" }, + { ...portableRun, argv: [portableRun.executable, "--cache-dir=/tmp/private/cache"] }, + { + ...portableRun, + invocations: [{ + ...portableRun.invocations[0], + argv: ["/tmp/private/ruff", "check", "pkg/app.py"] + }] + }, + { ...portableRun, failureMessage: "Ruff failed in /tmp/private/workspace" } + ]) { + assert.throws( + () => validatePythonValidationCapabilityRun(invalidRun), + /portable|host-absolute/ + ); + } + const legacyFingerprintRun = { + ...portableRun, + afterStateFingerprint: portableRun.afterStateManifestFingerprint + }; + delete legacyFingerprintRun.afterStateManifestFingerprint; + assert.throws( + () => validatePythonValidationCapabilityRun(legacyFingerprintRun), + /unexpected properties/ + ); + assert.throws( + () => validateValidationResultPayload(validValidationResult({ + status: "policy_failure", + diagnostics: [], + failure: { category: "policy_failure", message: "finding" }, + manifest: { + schemaVersion: GRAPH_SCHEMA_VERSION, + checks: ["python.ruff-lint"], + generatedAt: "2026-07-18T00:00:00.000Z", + runs: [{ + checkId: "python.ruff-lint", + status: "policy_failure", + outcome: "findings", + diagnosticCount: 1, + pythonCapabilityRuns: [{ + schemaId: "opcore.python.validation-capability-run", + schemaVersion: 1, + checkId: "python.ruff-lint", + capability: "ruff_lint", + state: "disabled", + argv: ["repo:.venv/bin/ruff"], + durationMs: 0, + diagnosticCount: 0 + }] + }] + } + })), + /must not record a process invocation/ + ); + const unavailableRun = { + ...portableRun, + state: "tool_unavailable", + durationMs: 0, + diagnosticCount: 1, + failureMessage: "Ruff is unavailable" + }; + for (const field of [ + "executable", "command", "argv", "configPath", "toolVersion", "toolSource", + "termination", "exitCode", "invocations" + ]) { + delete unavailableRun[field]; + } + assert.equal(validatePythonValidationCapabilityRun(unavailableRun).state, "tool_unavailable"); + const unsupportedRun = { + ...unavailableRun, + state: "unsupported_target", + failureMessage: "Ruff does not support the selected target" + }; + const unexecutedInvalidConfigRun = { + ...unavailableRun, + state: "invalid_config", + executable: portableRun.executable, + toolVersion: portableRun.toolVersion, + toolSource: portableRun.toolSource, + failureMessage: "Ruff rejected the selected configuration" + }; + const timeoutRun = { + ...portableRun, + state: "timeout", + termination: "timeout", + diagnosticCount: 0, + failureMessage: "Ruff timed out", + invocations: [{ + argv: portableRun.argv, + termination: "timeout", + durationMs: 12 + }] + }; + delete timeoutRun.exitCode; + const executedInvalidConfigRun = { + ...portableRun, + state: "invalid_config", + exitCode: 2, + diagnosticCount: 1, + failureMessage: "Ruff rejected the selected configuration", + invocations: [{ + argv: portableRun.argv, + termination: "exited", + exitCode: 2, + durationMs: 12 + }] + }; + const toolFailureRun = { + ...portableRun, + state: "tool_failure", + exitCode: 2, + diagnosticCount: 0, + failureMessage: "Ruff failed", + invocations: [{ + argv: portableRun.argv, + termination: "exited", + exitCode: 2, + durationMs: 12 + }] + }; + const signalToolFailureRun = { + ...portableRun, + state: "tool_failure", + termination: "signal", + signal: "SIGTERM", + diagnosticCount: 0, + failureMessage: "Ruff was terminated", + invocations: [{ + argv: portableRun.argv, + termination: "signal", + signal: "SIGTERM", + durationMs: 12 + }] + }; + delete signalToolFailureRun.exitCode; + for (const validFailureRun of [ + unavailableRun, + unsupportedRun, + unexecutedInvalidConfigRun, + timeoutRun, + executedInvalidConfigRun, + toolFailureRun, + signalToolFailureRun + ]) { + assert.equal(validatePythonValidationCapabilityRun(validFailureRun).state, validFailureRun.state); + } + for (const [name, invalidFailureRun] of [ + ["tool_unavailable with process evidence", { ...portableRun, state: "tool_unavailable", failureMessage: "Ruff unavailable" }], + ["unsupported_target with process evidence", { ...portableRun, state: "unsupported_target", failureMessage: "Unsupported target" }], + ["timeout without execution evidence", { ...unavailableRun, state: "timeout", failureMessage: "Ruff timed out" }], + ["timeout with contradictory invocation evidence", { + ...timeoutRun, + invocations: [{ + argv: portableRun.argv, + termination: "exited", + exitCode: 0, + durationMs: 12 + }] + }], + ["invalid_config with partial execution evidence", { + ...unexecutedInvalidConfigRun, + command: portableRun.command + }], + ["tool_failure with partial execution evidence", { + ...unavailableRun, + state: "tool_failure", + argv: portableRun.argv, + failureMessage: "Ruff failed" + }], + ["tool_failure with contradictory invocation evidence", { + ...signalToolFailureRun, + invocations: [{ + argv: portableRun.argv, + termination: "exited", + exitCode: 0, + durationMs: 12 + }] + }] + ]) { + assert.throws( + () => validatePythonValidationCapabilityRun(invalidFailureRun), + /Ruff capability/, + name + ); + } + for (const field of [ + "projectKey", "contextFingerprint", "afterStateManifestFingerprint", "sourcePaths", "configPaths", "cwd" + ]) { + const incomplete = { ...unavailableRun }; + delete incomplete[field]; + assert.throws( + () => validatePythonValidationCapabilityRun(incomplete), + /Activated Ruff capability run requires/, + field + ); + } + for (const field of ["projectKey", "contextFingerprint", "afterStateManifestFingerprint", "invocations"]) { + const incomplete = { ...result.manifest.runs[0].pythonCapabilityRuns[0] }; + delete incomplete[field]; + assert.throws( + () => validateValidationResultPayload(validValidationResult({ + status: "policy_failure", + diagnostics: [], + failure: { category: "policy_failure", message: "finding" }, + manifest: { + ...result.manifest, + runs: [{ + ...result.manifest.runs[0], + pythonCapabilityRuns: [incomplete] + }] + } + })), + /Ruff capability run requires/, + field + ); + } + for (const field of ["projectKey", "contextFingerprint", "afterStateManifestFingerprint"]) { + const invalidIdentity = { + ...result.manifest.runs[0].pythonCapabilityRuns[0], + [field]: "not-a-sha256-identity" + }; + assert.throws( + () => validateValidationResultPayload(validValidationResult({ + status: "policy_failure", + diagnostics: [], + failure: { category: "policy_failure", message: "finding" }, + manifest: { + ...result.manifest, + runs: [{ + ...result.manifest.runs[0], + pythonCapabilityRuns: [invalidIdentity] + }] + } + })), + /sha256 identity/, + field + ); + } + }); + it("rejects ambiguous repo identity and untyped provider failures", () => { assert.throws( () => diff --git a/tests/fixtures/package-packlists.json b/tests/fixtures/package-packlists.json index 8859471..7dffd92 100644 --- a/tests/fixtures/package-packlists.json +++ b/tests/fixtures/package-packlists.json @@ -430,6 +430,15 @@ "node_modules/@the-open-engine/opcore-validation-python/dist/process.d.ts", "node_modules/@the-open-engine/opcore-validation-python/dist/process.d.ts.map", "node_modules/@the-open-engine/opcore-validation-python/dist/process.js", + "node_modules/@the-open-engine/opcore-validation-python/dist/python-check-result.d.ts", + "node_modules/@the-open-engine/opcore-validation-python/dist/python-check-result.d.ts.map", + "node_modules/@the-open-engine/opcore-validation-python/dist/python-check-result.js", + "node_modules/@the-open-engine/opcore-validation-python/dist/python-context-result.d.ts", + "node_modules/@the-open-engine/opcore-validation-python/dist/python-context-result.d.ts.map", + "node_modules/@the-open-engine/opcore-validation-python/dist/python-context-result.js", + "node_modules/@the-open-engine/opcore-validation-python/dist/python-execution-workspace.d.ts", + "node_modules/@the-open-engine/opcore-validation-python/dist/python-execution-workspace.d.ts.map", + "node_modules/@the-open-engine/opcore-validation-python/dist/python-execution-workspace.js", "node_modules/@the-open-engine/opcore-validation-python/dist/project-config-files.d.ts", "node_modules/@the-open-engine/opcore-validation-python/dist/project-config-files.d.ts.map", "node_modules/@the-open-engine/opcore-validation-python/dist/project-config-files.js", @@ -442,6 +451,9 @@ "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-groups.d.ts", + "node_modules/@the-open-engine/opcore-validation-python/dist/project-groups.d.ts.map", + "node_modules/@the-open-engine/opcore-validation-python/dist/project-groups.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", @@ -463,6 +475,42 @@ "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/ruff-capability-run.d.ts", + "node_modules/@the-open-engine/opcore-validation-python/dist/ruff-capability-run.d.ts.map", + "node_modules/@the-open-engine/opcore-validation-python/dist/ruff-capability-run.js", + "node_modules/@the-open-engine/opcore-validation-python/dist/ruff-check-definition.d.ts", + "node_modules/@the-open-engine/opcore-validation-python/dist/ruff-check-definition.d.ts.map", + "node_modules/@the-open-engine/opcore-validation-python/dist/ruff-check-definition.js", + "node_modules/@the-open-engine/opcore-validation-python/dist/ruff-check-shared.d.ts", + "node_modules/@the-open-engine/opcore-validation-python/dist/ruff-check-shared.d.ts.map", + "node_modules/@the-open-engine/opcore-validation-python/dist/ruff-check-shared.js", + "node_modules/@the-open-engine/opcore-validation-python/dist/ruff-config-paths.d.ts", + "node_modules/@the-open-engine/opcore-validation-python/dist/ruff-config-paths.d.ts.map", + "node_modules/@the-open-engine/opcore-validation-python/dist/ruff-config-paths.js", + "node_modules/@the-open-engine/opcore-validation-python/dist/ruff-config-proof.d.ts", + "node_modules/@the-open-engine/opcore-validation-python/dist/ruff-config-proof.d.ts.map", + "node_modules/@the-open-engine/opcore-validation-python/dist/ruff-config-proof.js", + "node_modules/@the-open-engine/opcore-validation-python/dist/ruff-execution.d.ts", + "node_modules/@the-open-engine/opcore-validation-python/dist/ruff-execution.d.ts.map", + "node_modules/@the-open-engine/opcore-validation-python/dist/ruff-execution.js", + "node_modules/@the-open-engine/opcore-validation-python/dist/ruff-execution-workspace.d.ts", + "node_modules/@the-open-engine/opcore-validation-python/dist/ruff-execution-workspace.d.ts.map", + "node_modules/@the-open-engine/opcore-validation-python/dist/ruff-execution-workspace.js", + "node_modules/@the-open-engine/opcore-validation-python/dist/ruff-format-check.d.ts", + "node_modules/@the-open-engine/opcore-validation-python/dist/ruff-format-check.d.ts.map", + "node_modules/@the-open-engine/opcore-validation-python/dist/ruff-format-check.js", + "node_modules/@the-open-engine/opcore-validation-python/dist/ruff-format-refinement.d.ts", + "node_modules/@the-open-engine/opcore-validation-python/dist/ruff-format-refinement.d.ts.map", + "node_modules/@the-open-engine/opcore-validation-python/dist/ruff-format-refinement.js", + "node_modules/@the-open-engine/opcore-validation-python/dist/ruff-invocation-failure.d.ts", + "node_modules/@the-open-engine/opcore-validation-python/dist/ruff-invocation-failure.d.ts.map", + "node_modules/@the-open-engine/opcore-validation-python/dist/ruff-invocation-failure.js", + "node_modules/@the-open-engine/opcore-validation-python/dist/ruff-lint-check.d.ts", + "node_modules/@the-open-engine/opcore-validation-python/dist/ruff-lint-check.d.ts.map", + "node_modules/@the-open-engine/opcore-validation-python/dist/ruff-lint-check.js", + "node_modules/@the-open-engine/opcore-validation-python/dist/ruff-lint-output.d.ts", + "node_modules/@the-open-engine/opcore-validation-python/dist/ruff-lint-output.d.ts.map", + "node_modules/@the-open-engine/opcore-validation-python/dist/ruff-lint-output.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", diff --git a/tests/installed-bins.test.mjs b/tests/installed-bins.test.mjs index abef8f9..27ce8fe 100644 --- a/tests/installed-bins.test.mjs +++ b/tests/installed-bins.test.mjs @@ -2,7 +2,7 @@ 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, realpathSync, rmSync, writeFileSync } from "node:fs"; +import { chmodSync, copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { performance } from "node:perf_hooks"; @@ -57,18 +57,20 @@ describe("installed package bins", () => { 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)); + assert.deepEqual(pythonContextProjectIdentity(aspContext), pythonContextProjectIdentity(nestedStatusContext)); const opcoreScan = assertSmoke(project, ["--json"], 0, "opcore"); assert.deepEqual(opcoreScan.canonicalCommand, ["opcore", "scan"]); assert.equal(Object.hasOwn(opcoreScan, "validationResult"), true); + const nestedScanContext = pythonContextFor(opcoreScan.validationResult.pythonProjectContexts, "services/api/src/app.py"); assert.deepEqual( - pythonContextIdentity(pythonContextFor(opcoreScan.validationResult.pythonProjectContexts, "services/api/src/app.py")), - pythonContextIdentity(nestedStatusContext) + pythonContextProjectIdentity(nestedScanContext), + pythonContextProjectIdentity(nestedStatusContext) ); + assert.deepEqual(pythonContextProjectIdentity(aspContext), pythonContextProjectIdentity(nestedScanContext)); 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) + pythonContextIdentity(nestedScanContext) ); const opcoreCheck = assertSmoke(project, [ "check", @@ -80,8 +82,8 @@ describe("installed package bins", () => { "--json" ], 0, "opcore"); assert.deepEqual( - pythonContextIdentity(pythonContextFor(opcoreCheck.validationResult.pythonProjectContexts, "services/api/src/app.py")), - pythonContextIdentity(nestedStatusContext) + pythonContextProjectIdentity(pythonContextFor(opcoreCheck.validationResult.pythonProjectContexts, "services/api/src/app.py")), + pythonContextProjectIdentity(nestedScanContext) ); const opcoreInit = assertSmoke(project, ["install", "--json"], 0, "opcore"); assert.deepEqual(opcoreInit.canonicalCommand, ["opcore", "install"]); @@ -90,8 +92,8 @@ describe("installed package bins", () => { 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) + pythonContextProjectIdentity(pythonContextFor(opcoreInit.opcoreInit.settings.python.contexts, "services/api/src/app.py")), + pythonContextProjectIdentity(nestedScanContext) ); assert.equal(existsSync(join(project, ".opcore", "config")), false); assert.equal(existsSync(join(project, "AGENTS.md")), false); @@ -162,6 +164,8 @@ describe("installed package bins", () => { "rust.function-metrics", "python.syntax", "python.source-hygiene", + "python.ruff-lint", + "python.ruff-format", "python.types", "python.import-graph", "python.dead-code", @@ -349,6 +353,238 @@ describe("installed package bins", () => { rmSync(temp, { recursive: true, force: true }); } }); + + it("runs packed Opcore with pinned real Ruff without mutating repo state", { timeout: 120000 }, () => { + const ruff = pinnedRuffExecutable(); + const temp = mkdtempSync(join(tmpdir(), "opcore-installed-real-ruff-")); + try { + const tarball = packWorkspace("opcore", temp); + const project = join(temp, "project"); + mkdirSync(project); + run("npm", ["init", "-y"], { cwd: project }); + run("npm", ["install", "--ignore-scripts", "--no-audit", "--no-fund", tarball], { cwd: project }); + for (const [path, content] of Object.entries({ + "src/app.py": "import os\nVALUE=1\n", + "src/syntax_error.py": "def broken(\n return 1\n", + "src/ruff.toml": "target-version = \"py38\"\n[lint]\nselect = [\"F401\"]\n", + "root.py": "ROOT = 1\n", + "ruff.toml": "target-version = \"py38\"\n[lint]\nselect = [\"F401\"]\n", + "other/ruff.toml": "extend = \"/outside/does-not-exist.toml\"\n", + "malformed-dotted/pyproject.toml": "tool.ruff = { line-length = 88 }\nBROKEN = [\n", + "malformed-dotted/app.py": "VALUE = 1\n", + "malformed-quoted/pyproject.toml": "\"tool\".\"ruff\" = { line-length = 88 }\nBROKEN = [\n", + "malformed-quoted/app.py": "VALUE = 1\n", + "malformed-inline/pyproject.toml": "tool = { ruff = { line-length = 88 } }\nBROKEN = [\n", + "malformed-inline/app.py": "VALUE = 1\n", + "uv.lock": "version = 1\n", + ".venv/sentinel": "environment-unchanged\n", + ".ruff_cache/sentinel": "cache-unchanged\n" + })) { + mkdirSync(dirname(join(project, path)), { recursive: true }); + writeFileSync(join(project, path), content); + } + const projectRuff = join(project, ".venv", "bin", process.platform === "win32" ? "ruff.exe" : "ruff"); + mkdirSync(dirname(projectRuff), { recursive: true }); + copyFileSync(ruff, projectRuff); + chmodSync(projectRuff, 0o755); + for (const directory of ["malformed-dotted", "malformed-quoted", "malformed-inline"]) { + const nestedRuff = join( + project, + directory, + ".venv", + "bin", + process.platform === "win32" ? "ruff.exe" : "ruff" + ); + mkdirSync(dirname(nestedRuff), { recursive: true }); + copyFileSync(ruff, nestedRuff); + chmodSync(nestedRuff, 0o755); + } + const protectedPaths = [ + "src/app.py", + "src/syntax_error.py", + "src/ruff.toml", + "root.py", + "ruff.toml", + "other/ruff.toml", + "malformed-dotted/pyproject.toml", + "malformed-dotted/app.py", + "malformed-quoted/pyproject.toml", + "malformed-quoted/app.py", + "malformed-inline/pyproject.toml", + "malformed-inline/app.py", + `malformed-dotted/.venv/bin/${process.platform === "win32" ? "ruff.exe" : "ruff"}`, + `malformed-quoted/.venv/bin/${process.platform === "win32" ? "ruff.exe" : "ruff"}`, + `malformed-inline/.venv/bin/${process.platform === "win32" ? "ruff.exe" : "ruff"}`, + "uv.lock", + ".venv/sentinel", + `.venv/bin/${process.platform === "win32" ? "ruff.exe" : "ruff"}`, + ".ruff_cache/sentinel" + ]; + const before = snapshotFiles(project, protectedPaths); + const tempWorkspacesBefore = pythonExecutionTempWorkspaces(); + const result = assertCliJson( + binPath(project, "opcore"), + [ + "check", + "files", + "--files", + "src/app.py", + "--checks", + "python.ruff-lint,python.ruff-format", + "--json" + ], + 1, + project, + { + env: { + ...sourceSafeOpcoreEnv(), + PATH: [dirname(process.execPath), dirname(ruff), "/usr/bin", "/bin", "/opt/homebrew/bin"].join(":") + } + } + ); + + assertSourceSnapshot(project, before); + assert.deepEqual(pythonExecutionTempWorkspaces(), tempWorkspacesBefore); + assert.ok(result.validationResult?.manifest?.runs, JSON.stringify(result, null, 2)); + const runs = new Map(result.validationResult.manifest.runs.map((run) => [run.checkId, run])); + const lint = runs.get("python.ruff-lint")?.pythonCapabilityRuns?.[0]; + const format = runs.get("python.ruff-format")?.pythonCapabilityRuns?.[0]; + assert.equal(lint?.state, "findings"); + assert.equal(format?.state, "findings"); + for (const receipt of [lint, format]) { + assert.equal(receipt?.toolVersion, "0.6.9"); + assert.equal(receipt?.toolSource, "project_local_environment"); + assert.equal(receipt?.cwd, "."); + assert.equal(receipt?.configPath, "src/ruff.toml"); + assert.deepEqual(receipt?.sourcePaths, ["src/app.py"]); + assert.deepEqual(receipt?.configPaths, ["src/ruff.toml"]); + assert.match(receipt?.projectKey ?? "", /^sha256:[a-f0-9]{64}$/); + assert.match(receipt?.contextFingerprint ?? "", /^sha256:[a-f0-9]{64}$/); + assert.match(receipt?.afterStateManifestFingerprint ?? "", /^sha256:[a-f0-9]{64}$/); + assert.equal(receipt?.executable, "repo:.venv/bin/ruff"); + assert.equal(receipt?.argv?.[0], "repo:.venv/bin/ruff"); + assert.equal( + receipt?.invocations?.every((invocation) => invocation.argv[0] === "repo:.venv/bin/ruff"), + true + ); + assert.equal(JSON.stringify(receipt).includes(project), false); + assert.equal(receipt?.termination, "exited"); + assert.equal(receipt?.exitCode, 1); + assert.equal(receipt?.diagnosticCount, 1); + assert.equal((receipt?.command ?? "").includes("opcore-python-check-"), false); + } + assert.deepEqual( + lint?.argv?.slice(1, 8), + ["check", "--config", "src/ruff.toml", "--output-format=json", "--no-fix", "--no-cache", "--force-exclude"] + ); + assert.equal(lint?.argv?.includes("--no-cache"), true); + assert.equal(format?.argv?.includes("--no-cache"), true); + assert.equal(format?.argv?.includes("--check"), true); + assert.deepEqual(format?.argv?.slice(1, 4), ["format", "--config", "src/ruff.toml"]); + + const syntaxResult = assertCliJson( + binPath(project, "opcore"), + [ + "check", + "files", + "--files", + "src/syntax_error.py", + "--checks", + "python.ruff-lint", + "--json" + ], + 1, + project, + { + env: { + ...sourceSafeOpcoreEnv(), + PATH: [dirname(process.execPath), dirname(ruff), "/usr/bin", "/bin", "/opt/homebrew/bin"].join(":") + } + } + ); + assertSourceSnapshot(project, before); + assert.deepEqual(pythonExecutionTempWorkspaces(), tempWorkspacesBefore); + assert.deepEqual( + syntaxResult.validationResult?.diagnostics?.map((diagnostic) => diagnostic.code), + ["PY_RUFF_LINT_SYNTAX_ERROR"] + ); + const syntaxRun = syntaxResult.validationResult?.manifest?.runs?.find( + (run) => run.checkId === "python.ruff-lint" + ); + assert.equal(syntaxRun?.outcome, "findings"); + assert.equal(syntaxRun?.pythonCapabilityRuns?.[0]?.state, "findings"); + assert.equal(syntaxRun?.pythonCapabilityRuns?.[0]?.diagnosticCount, 1); + + const partitionedResult = assertCliJson( + binPath(project, "opcore"), + [ + "check", + "files", + "--files", + "src/app.py,root.py", + "--checks", + "python.ruff-lint", + "--json" + ], + 1, + project, + { + env: { + ...sourceSafeOpcoreEnv(), + PATH: [dirname(process.execPath), dirname(ruff), "/usr/bin", "/bin", "/opt/homebrew/bin"].join(":") + } + } + ); + assertSourceSnapshot(project, before); + assert.deepEqual(pythonExecutionTempWorkspaces(), tempWorkspacesBefore); + assert.deepEqual( + partitionedResult.validationResult?.diagnostics?.map((diagnostic) => diagnostic.path), + ["src/app.py"] + ); + const partitionedRuns = partitionedResult.validationResult?.manifest?.runs?.find( + (run) => run.checkId === "python.ruff-lint" + )?.pythonCapabilityRuns; + assert.deepEqual( + partitionedRuns?.map((receipt) => [receipt.configPath, receipt.configPaths, receipt.sourcePaths]), + [ + ["ruff.toml", ["ruff.toml"], ["root.py"]], + ["src/ruff.toml", ["src/ruff.toml"], ["src/app.py"]] + ] + ); + + for (const directory of ["malformed-dotted", "malformed-quoted", "malformed-inline"]) { + const malformedResult = assertCliJson( + binPath(project, "opcore"), + [ + "check", + "files", + "--files", + `${directory}/app.py`, + "--checks", + "python.ruff-lint", + "--json" + ], + 1, + project, + { + env: { + ...sourceSafeOpcoreEnv(), + PATH: [dirname(process.execPath), dirname(ruff), "/usr/bin", "/bin", "/opt/homebrew/bin"].join(":") + } + } + ); + assert.equal(malformedResult.validationResult?.manifest?.runs?.[0]?.outcome, "invalid_config"); + assert.equal( + malformedResult.validationResult?.manifest?.runs?.[0]?.pythonCapabilityRuns?.[0]?.configPath, + `${directory}/pyproject.toml` + ); + } + assertSourceSnapshot(project, before); + assert.deepEqual(pythonExecutionTempWorkspaces(), tempWorkspacesBefore); + } finally { + rmSync(temp, { recursive: true, force: true }); + } + }); }); function packWorkspace(packageName, destination) { @@ -508,6 +744,15 @@ function pythonContextIdentity(context) { }; } +function pythonContextProjectIdentity(context) { + return { + target: context.target, + projectRoot: context.projectRoot, + projectKey: context.projectKey, + interpreter: context.interpreter?.argv ?? null + }; +} + function assertSmoke(project, args, expectedExitCode, bin = "opcore") { return assertCliJson(binPath(project, bin), args, expectedExitCode, project); } @@ -893,6 +1138,27 @@ function sourceSafeOpcoreEnv() { }; } +function pinnedRuffExecutable() { + const locator = process.platform === "win32" ? "where" : "which"; + const located = spawnSync(locator, ["ruff"], { + encoding: "utf8", + env: process.env, + stdio: ["ignore", "pipe", "pipe"] + }); + assert.equal(located.status, 0, "Pinned Ruff 0.6.9 must be provisioned before installed-bin tests"); + const executable = located.stdout.trim().split(/\r?\n/u)[0]; + assert.ok(executable); + const version = run(executable, ["--version"]); + assert.equal(version.stdout.trim(), "ruff 0.6.9"); + return executable; +} + +function pythonExecutionTempWorkspaces() { + return readdirSync(tmpdir()) + .filter((entry) => entry.startsWith("opcore-python-check-")) + .sort(); +} + function assertFixtureCoverage(repoState, fixture) { assert.equal(repoState.repo.git.available, true, fixture.id); assert.equal(repoState.coverage.totalFiles, expectedFixtureTotalFiles(fixture), `${fixture.id} total files`); diff --git a/tests/opcore-facade.test.mjs b/tests/opcore-facade.test.mjs index 898d365..2ca75c4 100644 --- a/tests/opcore-facade.test.mjs +++ b/tests/opcore-facade.test.mjs @@ -1,7 +1,7 @@ import { describe, it } from "node:test"; import assert from "node:assert/strict"; import { spawn, spawnSync } from "node:child_process"; -import { cpSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { chmodSync, cpSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join, relative, resolve } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; @@ -449,7 +449,89 @@ describe("opcore public facade", () => { })), [] ); - assert.deepEqual(degradedPythonTools, ["mypy", "pyright", "python", "pytest", "ruff"].sort()); + assert.deepEqual(degradedPythonTools, ["mypy", "pyright", "pytest", "python"].sort()); + } finally { + rmSync(temp, { recursive: true, force: true }); + } + }); + + it("keeps inactive Ruff tooling out of status degradation and missing-tools output", () => { + const temp = mkdtempSync(join(tmpdir(), "opcore-python-inactive-ruff-status-")); + try { + mkdirSync(join(temp, ".venv", "bin"), { recursive: true }); + mkdirSync(join(temp, ".opcore"), { recursive: true }); + writeFileSync(join(temp, ".opcore", "config"), JSON.stringify({ + validation: { + checks: { + disabled: ["python.syntax", "python.types", "python.relevant-tests"] + } + } + }, null, 2)); + writeFileSync(join(temp, "pyproject.toml"), "[project]\nname='fixture'\nrequires-python='>=3.11'\n"); + writeFileSync(join(temp, "app.py"), "VALUE = 1\n"); + writeFileSync(join(temp, ".venv", "bin", "python"), `#!/bin/sh +printf '%s\\n' '${JSON.stringify({ + protocol: "opcore.python.project-context.interpreter.v1", + executable: join(temp, ".venv", "bin", "python"), + version: "3.12.13", + implementation: "CPython", + platform: process.platform, + architecture: process.arch, + abi: "cpython-312", + soabi: "cpython-312" +})}' +`); + chmodSync(join(temp, ".venv", "bin", "python"), 0o755); + writeFileSync(join(temp, ".venv", "bin", "python3"), readFileSync(join(temp, ".venv", "bin", "python"), "utf8")); + chmodSync(join(temp, ".venv", "bin", "python3"), 0o755); + + const result = parseJson(runOpcore(["status", "--repo", temp, "--json"], temp, 0, { ...process.env, PATH: "" }).stdout); + const pythonAdapter = result.repoState.validation.adapters.find((adapter) => adapter.adapter === "python"); + + assert.equal(pythonAdapter.status, "available"); + assert.equal(pythonAdapter.degradedChecks.includes("python.ruff-lint"), false); + assert.equal(pythonAdapter.degradedChecks.includes("python.ruff-format"), false); + assert.equal(pythonAdapter.missingTools.includes("ruff"), false); + assert.equal( + result.repoState.validation.degradedToolchains.some((tool) => tool.adapter === "python" && tool.tool === "ruff"), + false + ); + + writeFileSync(join(temp, ".opcore", "config"), JSON.stringify({ + validation: { + checks: { + defaults: ["python.ruff-lint"], + disabled: ["python.syntax", "python.types", "python.relevant-tests"] + } + } + }, null, 2)); + const active = parseJson(runOpcore(["status", "--repo", temp, "--json"], temp, 0, { ...process.env, PATH: "" }).stdout); + const activePython = active.repoState.validation.adapters.find((adapter) => adapter.adapter === "python"); + assert.equal(activePython.status, "degraded"); + assert.equal(activePython.degradedChecks.includes("python.ruff-lint"), true); + assert.equal(activePython.missingTools.includes("ruff"), true); + assert.equal( + active.repoState.validation.degradedToolchains.some((tool) => tool.adapter === "python" && tool.tool === "ruff"), + true + ); + + rmSync(join(temp, ".venv", "bin", "python")); + rmSync(join(temp, ".venv", "bin", "python3")); + writeFileSync(join(temp, ".venv", "bin", "ruff"), "#!/bin/sh\necho 'ruff 0.6.9'\n"); + chmodSync(join(temp, ".venv", "bin", "ruff"), 0o755); + const standalone = parseJson(runOpcore(["status", "--repo", temp, "--json"], temp, 0, { + ...process.env, + PATH: "" + }).stdout); + const standalonePython = standalone.repoState.validation.adapters.find((adapter) => adapter.adapter === "python"); + assert.equal(standalonePython.status, "available"); + assert.deepEqual(standalonePython.degradedChecks, []); + assert.deepEqual(standalonePython.missingTools, []); + assert.equal( + standalone.repoState.validation.degradedToolchains.some((tool) => tool.adapter === "python" && tool.tool === "python"), + false + ); + assert.equal(standalone.repoState.warnings.some((warning) => warning.includes("python")), false); } finally { rmSync(temp, { recursive: true, force: true }); } diff --git a/tests/opcore-metrics.test.mjs b/tests/opcore-metrics.test.mjs index 6cc8342..e0f0750 100644 --- a/tests/opcore-metrics.test.mjs +++ b/tests/opcore-metrics.test.mjs @@ -44,6 +44,8 @@ describe("Opcore metrics", () => { assert.equal(signals.get("python.untested_modules").count, 1); assert.equal(signals.get("python.dead_exports").evidence[0].path, "pkg/api.py"); assert.equal(signals.get("python.source_hygiene").count, 1); + assert.equal(signals.get("python.ruff_lint_findings").count, 1); + assert.equal(signals.get("python.ruff_format_findings").count, 1); assert.equal(signals.get("python.import_graph").count, 1); assert.equal(signals.get("coverage.unsupported_stacks").count, 2); assert.equal(report.signals.every((signal) => signal.count > 0 && signal.evidence.every((entry) => entry.path)), true); @@ -57,6 +59,82 @@ describe("Opcore metrics", () => { assert.equal(Object.hasOwn(report, "score"), false); }); + it("counts Ruff metrics only from executed findings receipts", () => { + const validation = validationResult(); + const duplicated = createOpcoreMetricReport({ + repoState: repoState(), + validationResult: { + ...validation, + pythonCapabilityRuns: validation.manifest.runs.flatMap((run) => run.pythonCapabilityRuns ?? []) + }, + graphFacts: graphFacts(), + generatedAt: "2026-06-25T00:00:00.000Z" + }); + assert.equal( + duplicated.signals.find((signal) => signal.id === "python.ruff_lint_findings")?.count, + 1 + ); + assert.equal( + duplicated.signals.find((signal) => signal.id === "python.ruff_format_findings")?.count, + 1 + ); + + const report = createOpcoreMetricReport({ + repoState: repoState(), + validationResult: { + ...validation, + diagnostics: [{ + category: "infrastructure", + severity: "info", + path: "pkg/app.py", + code: "PY_RUFF_LINT_INVALID_CONFIG", + message: "Ruff config is invalid." + }], + manifest: { + ...validation.manifest, + runs: [{ + checkId: "python.ruff-lint", + status: "unsupported_request", + outcome: "invalid_config", + diagnosticCount: 1, + pythonCapabilityRuns: [{ + checkId: "python.ruff-lint", + capability: "ruff_lint", + state: "invalid_config", + sourcePaths: ["pkg/app.py"], + durationMs: 0, + diagnosticCount: 1 + }] + }] + } + }, + graphFacts: graphFacts(), + generatedAt: "2026-06-25T00:00:00.000Z" + }); + + assert.equal(report.signals.some((signal) => signal.id === "python.ruff_lint_findings"), false); + + const executed = validationResult(); + const incompleteReceipt = { ...executed.manifest.runs[0].pythonCapabilityRuns[0] }; + delete incompleteReceipt.projectKey; + const incompleteReport = createOpcoreMetricReport({ + repoState: repoState(), + validationResult: { + ...executed, + manifest: { + ...executed.manifest, + runs: [{ + ...executed.manifest.runs[0], + pythonCapabilityRuns: [incompleteReceipt] + }] + } + }, + graphFacts: graphFacts(), + generatedAt: "2026-06-25T00:00:00.000Z" + }); + assert.equal(incompleteReport.signals.some((signal) => signal.id === "python.ruff_lint_findings"), false); + }); + it("separates graph-backed Rust signals from validation toolchain drift", () => { const report = createOpcoreMetricReport({ repoState: repoState({ degradedToolchains: [] }), @@ -452,6 +530,20 @@ function validationResult() { code: "PY_SOURCE_TYPE_IGNORE", message: "Python type-ignore suppressions are not allowed." }, + { + category: "policy", + severity: "warning", + path: "pkg/app.py", + code: "PY_RUFF_LINT_F401", + message: "unused import" + }, + { + category: "policy", + severity: "warning", + path: "pkg/format.py", + code: "PY_RUFF_FORMAT_DRIFT", + message: "ruff format --check would reformat pkg/format.py" + }, { category: "graph", severity: "warning", @@ -480,11 +572,79 @@ function validationResult() { "rust.fmt", "python.syntax", "python.source-hygiene", + "python.ruff-lint", + "python.ruff-format", "python.types", "python.import-graph", "python.dead-code", "python.relevant-tests" ], + runs: [ + { + checkId: "python.ruff-lint", + status: "policy_failure", + outcome: "findings", + diagnosticCount: 1, + pythonCapabilityRuns: [{ + checkId: "python.ruff-lint", + capability: "ruff_lint", + state: "findings", + projectKey: `sha256:${"1".repeat(64)}`, + contextFingerprint: `sha256:${"2".repeat(64)}`, + afterStateManifestFingerprint: `sha256:${"3".repeat(64)}`, + sourcePaths: ["pkg/app.py"], + configPaths: ["ruff.toml"], + executable: "repo:.venv/bin/ruff", + command: "repo:.venv/bin/ruff check pkg/app.py", + argv: ["repo:.venv/bin/ruff", "check", "pkg/app.py"], + cwd: ".", + toolVersion: "0.6.9", + toolSource: "project_local_environment", + termination: "exited", + exitCode: 1, + invocations: [{ + argv: ["repo:.venv/bin/ruff", "check", "pkg/app.py"], + termination: "exited", + exitCode: 1, + durationMs: 9 + }], + durationMs: 9, + diagnosticCount: 1 + }] + }, + { + checkId: "python.ruff-format", + status: "policy_failure", + outcome: "findings", + diagnosticCount: 1, + pythonCapabilityRuns: [{ + checkId: "python.ruff-format", + capability: "ruff_format", + state: "findings", + projectKey: `sha256:${"4".repeat(64)}`, + contextFingerprint: `sha256:${"5".repeat(64)}`, + afterStateManifestFingerprint: `sha256:${"6".repeat(64)}`, + sourcePaths: ["pkg/format.py"], + configPaths: [], + executable: "repo:.venv/bin/ruff", + command: "repo:.venv/bin/ruff format --check pkg/format.py", + argv: ["repo:.venv/bin/ruff", "format", "--check", "pkg/format.py"], + cwd: ".", + toolVersion: "0.6.9", + toolSource: "project_local_environment", + termination: "exited", + exitCode: 1, + invocations: [{ + argv: ["repo:.venv/bin/ruff", "format", "--check", "pkg/format.py"], + termination: "exited", + exitCode: 1, + durationMs: 5 + }], + durationMs: 5, + diagnosticCount: 1 + }] + } + ], generatedAt: "2026-06-25T00:00:00.000Z" } }; diff --git a/tests/schema-contracts.test.mjs b/tests/schema-contracts.test.mjs index feecf91..6b80a46 100644 --- a/tests/schema-contracts.test.mjs +++ b/tests/schema-contracts.test.mjs @@ -866,6 +866,268 @@ describe("Opcore JSON schema wire constraints", () => { ); }); + it("validates portable Python capability receipts on manifest runs", () => { + const manifest = { + schemaVersion: 1, + checks: ["python.ruff-lint"], + generatedAt: "2026-07-18T00:00:00.000Z", + runs: [{ + checkId: "python.ruff-lint", + status: "policy_failure", + outcome: "findings", + diagnosticCount: 1, + pythonCapabilityRuns: [{ + schemaId: "opcore.python.validation-capability-run", + schemaVersion: 1, + checkId: "python.ruff-lint", + capability: "ruff_lint", + state: "findings", + projectKey: `sha256:${"1".repeat(64)}`, + contextFingerprint: `sha256:${"2".repeat(64)}`, + afterStateManifestFingerprint: `sha256:${"3".repeat(64)}`, + sourcePaths: ["pkg/app.py"], + configPaths: ["ruff.toml"], + executable: "repo:.venv/bin/ruff", + command: "repo:.venv/bin/ruff check --output-format=json pkg/app.py", + argv: ["repo:.venv/bin/ruff", "check", "--output-format=json", "pkg/app.py"], + cwd: ".", + configPath: "ruff.toml", + toolVersion: "0.6.9", + toolSource: "project_local_environment", + termination: "exited", + exitCode: 1, + invocations: [{ + argv: ["repo:.venv/bin/ruff", "check", "--output-format=json", "pkg/app.py"], + termination: "exited", + exitCode: 1, + durationMs: 12 + }], + durationMs: 12, + diagnosticCount: 1 + }] + }] + }; + + assert.equal(isValidDefinition("ValidationResult", validationResultWith({ manifest })), true); + const portableRun = manifest.runs[0].pythonCapabilityRuns[0]; + const validNestedRuffRun = (run) => isValidDefinition( + "ValidationResult", + validationResultWith({ + manifest: { + ...manifest, + runs: [{ ...manifest.runs[0], pythonCapabilityRuns: [run] }] + } + }) + ); + const assertRuffSchemaCopies = (run, expected, message) => { + assert.equal(isValidDefinition("PythonRuffValidationCapabilityRun", run), expected, `canonical: ${message}`); + assert.equal(validNestedRuffRun(run), expected, `nested: ${message}`); + }; + assertRuffSchemaCopies(portableRun, true, "findings"); + for (const invalidRun of [ + { ...portableRun, executable: "/tmp/private/ruff" }, + { ...portableRun, argv: [portableRun.executable, "--cache-dir=/tmp/private/cache"] }, + { + ...portableRun, + invocations: [{ + ...portableRun.invocations[0], + argv: ["/tmp/private/ruff", "check", "pkg/app.py"] + }] + }, + { ...portableRun, failureMessage: "Ruff failed in /tmp/private/workspace" } + ]) { + assert.equal( + isValidDefinition("ValidationResult", validationResultWith({ + manifest: { + ...manifest, + runs: [{ ...manifest.runs[0], pythonCapabilityRuns: [invalidRun] }] + } + })), + false + ); + } + assert.equal( + isValidDefinition( + "ValidationResult", + validationResultWith({ + manifest: { + ...manifest, + runs: [{ + ...manifest.runs[0], + pythonCapabilityRuns: [{ ...manifest.runs[0].pythonCapabilityRuns[0], state: "disabled", argv: ["repo:.venv/bin/ruff"] }] + }] + } + }) + ), + false + ); + const unavailableRun = { + ...manifest.runs[0].pythonCapabilityRuns[0], + state: "tool_unavailable", + durationMs: 0, + diagnosticCount: 1, + failureMessage: "Ruff is unavailable" + }; + for (const field of [ + "executable", "command", "argv", "configPath", "toolVersion", "toolSource", + "termination", "exitCode", "invocations" + ]) { + delete unavailableRun[field]; + } + assertRuffSchemaCopies(unavailableRun, true, "tool_unavailable"); + const unsupportedRun = { + ...unavailableRun, + state: "unsupported_target", + failureMessage: "Ruff does not support the selected target" + }; + const unexecutedInvalidConfigRun = { + ...unavailableRun, + state: "invalid_config", + executable: portableRun.executable, + toolVersion: portableRun.toolVersion, + toolSource: portableRun.toolSource, + failureMessage: "Ruff rejected the selected configuration" + }; + const timeoutRun = { + ...portableRun, + state: "timeout", + termination: "timeout", + diagnosticCount: 0, + failureMessage: "Ruff timed out", + invocations: [{ + argv: portableRun.argv, + termination: "timeout", + durationMs: 12 + }] + }; + delete timeoutRun.exitCode; + const executedInvalidConfigRun = { + ...portableRun, + state: "invalid_config", + exitCode: 2, + diagnosticCount: 1, + failureMessage: "Ruff rejected the selected configuration", + invocations: [{ + argv: portableRun.argv, + termination: "exited", + exitCode: 2, + durationMs: 12 + }] + }; + const toolFailureRun = { + ...portableRun, + state: "tool_failure", + exitCode: 2, + diagnosticCount: 0, + failureMessage: "Ruff failed", + invocations: [{ + argv: portableRun.argv, + termination: "exited", + exitCode: 2, + durationMs: 12 + }] + }; + const signalToolFailureRun = { + ...portableRun, + state: "tool_failure", + termination: "signal", + signal: "SIGTERM", + diagnosticCount: 0, + failureMessage: "Ruff was terminated", + invocations: [{ + argv: portableRun.argv, + termination: "signal", + signal: "SIGTERM", + durationMs: 12 + }] + }; + delete signalToolFailureRun.exitCode; + for (const validFailureRun of [ + unsupportedRun, + unexecutedInvalidConfigRun, + timeoutRun, + executedInvalidConfigRun, + toolFailureRun, + signalToolFailureRun + ]) { + assertRuffSchemaCopies(validFailureRun, true, validFailureRun.state); + } + for (const [name, invalidFailureRun] of [ + ["tool_unavailable with process evidence", { ...portableRun, state: "tool_unavailable", failureMessage: "Ruff unavailable" }], + ["unsupported_target with process evidence", { ...portableRun, state: "unsupported_target", failureMessage: "Unsupported target" }], + ["timeout without execution evidence", { ...unavailableRun, state: "timeout", failureMessage: "Ruff timed out" }], + ["timeout with contradictory invocation evidence", { + ...timeoutRun, + invocations: [{ + argv: portableRun.argv, + termination: "exited", + exitCode: 0, + durationMs: 12 + }] + }], + ["invalid_config with partial execution evidence", { + ...unexecutedInvalidConfigRun, + command: portableRun.command + }], + ["tool_failure with partial execution evidence", { + ...unavailableRun, + state: "tool_failure", + argv: portableRun.argv, + failureMessage: "Ruff failed" + }], + ["tool_failure with contradictory invocation evidence", { + ...signalToolFailureRun, + invocations: [{ + argv: portableRun.argv, + termination: "exited", + exitCode: 0, + durationMs: 12 + }] + }] + ]) { + assertRuffSchemaCopies(invalidFailureRun, false, name); + } + for (const field of [ + "projectKey", "contextFingerprint", "afterStateManifestFingerprint", "sourcePaths", "configPaths", "cwd" + ]) { + const incomplete = { ...unavailableRun }; + delete incomplete[field]; + assert.equal( + isValidDefinition( + "ValidationResult", + validationResultWith({ + manifest: { + ...manifest, + runs: [{ ...manifest.runs[0], pythonCapabilityRuns: [incomplete] }] + } + }) + ), + false, + field + ); + } + for (const field of ["projectKey", "contextFingerprint", "afterStateManifestFingerprint", "invocations"]) { + const incomplete = { ...manifest.runs[0].pythonCapabilityRuns[0] }; + delete incomplete[field]; + assert.equal( + isValidDefinition( + "ValidationResult", + validationResultWith({ + manifest: { + ...manifest, + runs: [{ + ...manifest.runs[0], + pythonCapabilityRuns: [incomplete] + }] + } + }) + ), + false, + field + ); + } + }); + it("validates Python project-context wire identity and typed outcome vocabulary", () => { const context = pythonProjectContextWith(); assert.equal(isValidDefinition("PythonProjectContext", context), true); diff --git a/tests/validation-cli.test.mjs b/tests/validation-cli.test.mjs index 608b729..10a157e 100644 --- a/tests/validation-cli.test.mjs +++ b/tests/validation-cli.test.mjs @@ -43,6 +43,7 @@ const pythonCheckIds = [ "python.dead-code", "python.relevant-tests" ]; +const optInPythonCheckIds = ["python.ruff-lint", "python.ruff-format"]; const docsCheckIds = [ "docs.existence", "docs.staleness", @@ -59,6 +60,16 @@ const cloneCheckIds = ["clone.duplication"]; const typeScriptExecutableDefaultCheckIds = typeScriptCheckIds.filter((checkId) => checkId !== "typescript.lint"); const executableDefaultCheckIds = [...typeScriptExecutableDefaultCheckIds, ...rustCheckIds, ...pythonCheckIds, ...cloneCheckIds]; const defaultCheckIds = [...typeScriptCheckIds, ...rustCheckIds, ...pythonCheckIds, ...docsCheckIds, ...cloneCheckIds]; +const availableCheckIds = [ + ...typeScriptCheckIds, + ...rustCheckIds, + pythonCheckIds[0], + pythonCheckIds[1], + ...optInPythonCheckIds, + ...pythonCheckIds.slice(2), + ...docsCheckIds, + ...cloneCheckIds +]; describe("validation CLI", () => { it("keeps opcore status separate from validation execution results", async () => { @@ -71,16 +82,17 @@ describe("validation CLI", () => { assert.equal(result.status, "ok"); assert.deepEqual(result.canonicalCommand, ["opcore", "status"]); - assert.equal(result.repoState.validation.checkCount, defaultCheckIds.length); + assert.equal(result.repoState.validation.checkCount, availableCheckIds.length); assert.equal(result.repoState.validation.policy.state, "missing"); - assert.deepEqual(result.repoState.validation.policy.configuredChecks, defaultCheckIds); + assert.deepEqual(result.repoState.validation.policy.configuredChecks, executableDefaultCheckIds); assert.equal(Object.hasOwn(result, "validationResult"), false); assert.equal(Object.hasOwn(result, "validationStatus"), false); assertCommandTiming(result); const compatible = run(["status", "--json"]); assert.deepEqual(compatible.canonicalCommand, ["opcore", "status"]); - assert.equal(compatible.validationStatus.adapterRegistry.checkIds.length, defaultCheckIds.length); + assert.equal(compatible.validationStatus.adapterRegistry.checkIds.length, availableCheckIds.length); + assert.deepEqual(compatible.validationStatus.adapterRegistry.checkIds, availableCheckIds); assert.equal(Object.hasOwn(compatible, "repoState"), false); } finally { rmSync(temp, { recursive: true, force: true }); @@ -258,7 +270,7 @@ describe("validation CLI", () => { assert.equal(result.status, "ok"); assert.deepEqual( result.validationResult.manifest.entries.map((entry) => entry.checkId), - defaultCheckIds + availableCheckIds ); for (const checkId of ["rust.fmt", "rust.cargo-check", "rust.clippy"]) { assert.equal(result.validationResult.manifest.checks.includes(checkId), true, checkId); @@ -1347,7 +1359,7 @@ describe("validation CLI", () => { const result = run([command, "--json"], [0], { env: full.env }); assert.equal(result.owner, "runtime"); assert.equal(result.validationStatus.ready, true); - assert.deepEqual(result.validationStatus.adapterRegistry.checkIds, defaultCheckIds); + assert.deepEqual(result.validationStatus.adapterRegistry.checkIds, availableCheckIds); assert.equal(result.validationStatus.adapterRegistry.checkIds.includes("rust.file-length"), true); const rustAdapter = result.validationStatus.adapterRegistry.adapters.find((adapter) => adapter.adapter === "rust"); const pythonAdapter = result.validationStatus.adapterRegistry.adapters.find((adapter) => adapter.adapter === "python"); diff --git a/tests/validation-python.test.mjs b/tests/validation-python.test.mjs index fb84fae..47235cb 100644 --- a/tests/validation-python.test.mjs +++ b/tests/validation-python.test.mjs @@ -6,10 +6,13 @@ import { dirname, join, relative } from "node:path"; import { setTimeout as delay } from "node:timers/promises"; import { fileURLToPath } from "node:url"; import { createValidationCheckRegistry, createValidationGraphQuerySession, createValidationRunner } from "../packages/validation/dist/index.js"; +import { validationChecksForRepoPolicy } from "../packages/validation-policy/dist/index.js"; import { PYTHON_DEAD_CODE_CHECK_ID, PYTHON_IMPORT_GRAPH_CHECK_ID, PYTHON_RELEVANT_TESTS_CHECK_ID, + PYTHON_RUFF_FORMAT_CHECK_ID, + PYTHON_RUFF_LINT_CHECK_ID, PYTHON_SOURCE_HYGIENE_CHECK_ID, PYTHON_SYNTAX_CHECK_ID, PYTHON_TYPES_CHECK_ID, @@ -25,6 +28,7 @@ import { validationPythonAdapterName } from "../packages/validation-python/dist/index.js"; import { runTool } from "../packages/validation-python/dist/process.js"; +import { ruffCommandArgs } from "../packages/validation-python/dist/ruff-execution.js"; const repoRoot = dirname(fileURLToPath(import.meta.url)); const validationFixtureRoot = join(repoRoot, "../packages/fixtures/validation-python"); @@ -44,6 +48,21 @@ describe("validation-python adapter", () => { ); }); + it("keeps mypy-authority fixture resources out of repository Python source discovery", () => { + const fixturePaths = walkFiles(join(validationFixtureRoot, "mypy-authority")); + assert.ok(fixturePaths.length > 0); + assert.ok(fixturePaths.every((path) => !isPythonSourcePath(path))); + assert.deepEqual(Object.keys(fixtureFiles("mypy-authority")).sort(), [ + "pyproject.toml", + "src/acme/__init__.py", + "src/acme/app.py", + "src/acme/mypy_plugin.py", + "src/acme/plugin_support.py", + "src/acme/widget.py", + "stubs/external/__init__.pyi" + ]); + }); + it("exports stable Python check ids and definitions", () => { const checks = createPythonValidationChecks(); const registry = createValidationCheckRegistry(checks); @@ -54,6 +73,8 @@ describe("validation-python adapter", () => { [ PYTHON_SYNTAX_CHECK_ID, PYTHON_SOURCE_HYGIENE_CHECK_ID, + PYTHON_RUFF_LINT_CHECK_ID, + PYTHON_RUFF_FORMAT_CHECK_ID, PYTHON_TYPES_CHECK_ID, PYTHON_IMPORT_GRAPH_CHECK_ID, PYTHON_DEAD_CODE_CHECK_ID, @@ -65,6 +86,39 @@ describe("validation-python adapter", () => { assert.equal(registry.byId.get(PYTHON_IMPORT_GRAPH_CHECK_ID)?.requiresGraph, true); }); + it("places canonical explicit Ruff config overrides after the subcommand", () => { + const baseTool = { + tool: "ruff", + available: true, + executable: "/usr/bin/ruff", + configFile: "pkg/ruff.toml", + cwd: "/repo/pkg", + source: "explicit_override" + }; + assert.deepEqual( + ruffCommandArgs( + { ...baseTool, argv: ["/usr/bin/ruff", "--config", "pkg/ruff.toml"] }, + { projectRoot: "pkg", repositoryRoot: "/repo" }, + "check", + ["app.py"] + ), + ["check", "--config", "ruff.toml", "app.py"] + ); + assert.deepEqual( + ruffCommandArgs( + { + ...baseTool, + configFile: "ruff.toml", + argv: ["/usr/bin/ruff", "--config=../../ruff.toml"] + }, + { projectRoot: "services/api", repositoryRoot: "/repo" }, + "format", + ["--check", "app.py"] + ), + ["format", "--config", "../../ruff.toml", "--check", "app.py"] + ); + }); + 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({ @@ -241,7 +295,7 @@ describe("validation-python adapter", () => { scope: { kind: "files", files: ["services/api/src/acme/ns/app.py"] } })); - assert.equal(result.status, "passed", JSON.stringify(result, null, 2)); + if (result.status !== "passed") throw new Error(JSON.stringify(result, null, 2)); assert.equal(result.pythonProjectContexts[0].projectRoot, "services/api"); assert.deepEqual(result.pythonProjectContexts[0].sourceRoots, ["services/api/src"]); } finally { @@ -775,12 +829,24 @@ describe("validation-python adapter", () => { : rawPyrightShim("1.1.411", testCase.body)); const files = { "pyrightconfig.json": "{}\n", "pkg/app.py": "value: int = 1\n" }; const before = materializedMypyWorkspaces(); + const baseProbe = successfulProbe(); + const processProbe = testCase.removeAfterProbe + ? { + ...baseProbe, + async run(command, args, options) { + const result = await baseProbe.run(command, args, options); + if (command === executable && args.includes("--version")) rmSync(executable, { force: true }); + return result; + } + } + : baseProbe; const result = await runner({ files, checks: createPythonValidationChecks({ repoRoot, env: { PATH: "" }, timeoutMs: testCase.timeoutMs, + processProbe, nodeWorkspace: createNodePythonProjectWorkspace(repoRoot), toolArgv: { pyright: [executable] } }) @@ -1987,7 +2053,7 @@ describe("validation-python adapter", () => { checks: createPythonValidationChecks({ repoRoot }) }).runValidation(request({ repo: { repoRoot } })); - assert.equal(result.status, "passed"); + assert.equal(result.status, "passed", JSON.stringify(result, null, 2)); assert.equal(readFileSync(join(repoRoot, "compiler-called"), "utf8"), "compiler"); } finally { rmSync(repoRoot, { recursive: true, force: true }); @@ -2072,6 +2138,2096 @@ describe("validation-python adapter", () => { } }); + it("runs opt-in Ruff lint and records a portable receipt", async () => { + const repoRoot = mkdtempSync(join(tmpdir(), "opcore-python-ruff-lint-")); + try { + writePassingPythonProtocolShim(repoRoot); + writeToolShim( + repoRoot, + "ruff", + [ + "#!/bin/sh", + "if [ \"$1\" = \"--version\" ]; then echo 'ruff 0.6.9'; exit 0; fi", + "if [ \"$1\" = \"check\" ]; then", + " printf '%s\\n' '[{\"code\":\"F401\",\"filename\":\"pkg/app.py\",\"location\":{\"row\":1,\"column\":8},\"end_location\":{\"row\":1,\"column\":10},\"message\":\"unused import\"}]'", + " exit 1", + "fi", + "exit 2", + "" + ].join("\n") + ); + + const result = await runner({ + files: { + "pkg/app.py": "value = 1\n", + "ruff.toml": "[lint]\nselect = [\"F401\"]\n" + }, + checks: createPythonValidationChecks({ repoRoot, env: { PATH: "" } }) + }).runValidation( + request({ + repo: { repoRoot }, + checks: [PYTHON_RUFF_LINT_CHECK_ID], + overlays: [{ path: "pkg/app.py", action: "write", content: "import os\n" }] + }) + ); + + assert.equal(result.status, "policy_failure"); + assert.deepEqual(result.diagnostics.map((entry) => entry.code), ["PY_RUFF_LINT_F401"]); + const receipt = result.manifest.runs[0].pythonCapabilityRuns?.[0]; + assert.equal(receipt?.checkId, PYTHON_RUFF_LINT_CHECK_ID); + assert.equal(receipt?.capability, "ruff_lint"); + assert.equal(receipt?.state, "findings"); + assert.equal(receipt?.cwd, "."); + assert.deepEqual(receipt?.sourcePaths, ["pkg/app.py"]); + assert.deepEqual(receipt?.configPaths, ["ruff.toml"]); + assert.equal(receipt?.argv.includes("check"), true); + assert.equal(receipt?.argv.includes("pkg/app.py"), true); + assert.equal(receipt?.configPath, "ruff.toml"); + assert.equal(receipt?.termination, "exited"); + assert.equal(receipt?.exitCode, 1); + assert.equal(receipt?.diagnosticCount, 1); + assert.match(receipt?.afterStateManifestFingerprint ?? "", /^sha256:[a-f0-9]{64}$/); + assert.equal(receipt?.executable, "repo:.venv/bin/ruff"); + assert.equal(receipt?.argv?.[0], "repo:.venv/bin/ruff"); + assert.equal(receipt?.invocations?.every((invocation) => invocation.argv[0] === "repo:.venv/bin/ruff"), true); + assert.equal(JSON.stringify(receipt).includes(repoRoot), false); + assert.equal((receipt?.command ?? "").includes("opcore-python-check"), false); + assert.equal(receipt?.argv.includes("--no-fix"), true); + assert.equal(receipt?.argv.includes("--no-cache"), true); + assert.equal(result.diagnostics[0].tool?.command, receipt?.command); + assert.equal(result.diagnostics[0].tool?.cwd, receipt?.cwd); + } finally { + rmSync(repoRoot, { recursive: true, force: true }); + } + }); + + it("maps Ruff syntax diagnostics with null codes to deterministic findings", async () => { + const repoRoot = mkdtempSync(join(tmpdir(), "opcore-python-ruff-null-code-")); + try { + writePassingPythonProtocolShim(repoRoot); + writeToolShim( + repoRoot, + "ruff", + [ + "#!/bin/sh", + "if [ \"$1\" = \"--version\" ]; then echo 'ruff 0.6.9'; exit 0; fi", + "if [ \"$1\" = \"check\" ]; then", + " printf '%s\\n' '[{\"cell\":null,\"code\":null,\"end_location\":{\"column\":1,\"row\":2},\"filename\":\"pkg/app.py\",\"fix\":null,\"location\":{\"column\":12,\"row\":1},\"message\":\"SyntaxError: Expected \\\")\\\", found newline\",\"noqa_row\":null,\"url\":null}]'", + " exit 1", + "fi", + "exit 2", + "" + ].join("\n") + ); + + const result = await runner({ + files: { + "pkg/app.py": "print(\"oops\"\\n", + "ruff.toml": "target-version = \"py38\"\n" + }, + checks: createPythonValidationChecks({ repoRoot, env: { PATH: "" } }) + }).runValidation( + request({ + repo: { repoRoot }, + checks: [PYTHON_RUFF_LINT_CHECK_ID] + }) + ); + + assert.equal(result.status, "policy_failure", JSON.stringify(result, null, 2)); + assert.deepEqual(result.diagnostics.map((entry) => entry.code), ["PY_RUFF_LINT_SYNTAX_ERROR"]); + assert.equal(result.diagnostics[0].line, 1); + assert.equal(result.diagnostics[0].column, 12); + assert.equal(result.manifest.runs[0].outcome, "findings"); + const receipt = result.manifest.runs[0].pythonCapabilityRuns?.[0]; + assert.equal(receipt?.state, "findings"); + assert.equal(receipt?.diagnosticCount, 1); + assert.equal(receipt?.exitCode, 1); + } finally { + rmSync(repoRoot, { recursive: true, force: true }); + } + }); + + it("runs Ruff without Python and keeps standalone Ruff status available", async () => { + const repoRoot = mkdtempSync(join(tmpdir(), "opcore-python-ruff-no-interpreter-")); + try { + writeToolShim( + repoRoot, + "ruff", + [ + "#!/bin/sh", + "if [ \"$1\" = \"--version\" ]; then echo 'ruff 0.6.9'; exit 0; fi", + "if [ \"$1\" = \"check\" ]; then printf '%s\\n' '[]'; exit 0; fi", + "exit 2", + "" + ].join("\n") + ); + + const result = await runner({ + files: { + "pyproject.toml": "[project]\nname='fixture'\n[tool.mypy]\n", + "pkg/app.py": "value = 1\n" + }, + checks: createPythonValidationChecks({ repoRoot, env: { PATH: "" } }) + }).runValidation( + request({ + repo: { repoRoot }, + checks: [PYTHON_RUFF_LINT_CHECK_ID] + }) + ); + + assert.equal(result.status, "passed"); + assert.equal(result.pythonProjectContexts[0].outcome, "unsupported"); + assert.equal( + result.pythonProjectContexts[0].reasons.some((reason) => reason.code === "interpreter_unavailable"), + true + ); + assert.equal( + result.pythonProjectContexts[0].tools.some((tool) => tool.tool === "ruff" && tool.available), + true + ); + assert.equal(result.manifest.runs[0].pythonCapabilityRuns?.[0]?.state, "passed"); + + const status = createPythonValidationAdapterStatus({ + contexts: result.pythonProjectContexts, + activeCheckIds: [PYTHON_RUFF_LINT_CHECK_ID] + }); + assert.equal(status.status, "available"); + assert.deepEqual(status.degradedChecks, []); + } finally { + rmSync(repoRoot, { recursive: true, force: true }); + } + }); + + it("does not attribute malformed mypy, Pyright, or pytest config to a Ruff-only run", async () => { + const repoRoot = mkdtempSync(join(tmpdir(), "opcore-python-ruff-unrelated-config-")); + try { + writeToolShim( + repoRoot, + "ruff", + [ + "#!/bin/sh", + "if [ \"$1\" = \"--version\" ]; then echo 'ruff 0.6.9'; exit 0; fi", + "if [ \"$1\" = \"check\" ]; then printf '%s\\n' '[]'; exit 0; fi", + "exit 2", + "" + ].join("\n") + ); + + const result = await runner({ + files: { + "pkg/app.py": "value = 1\n", + "pyproject.toml": "[tool.mypy]\npython_version = [\n", + "mypy.ini": "option-before-section = true\n", + "pyrightconfig.json": "{not-json\n", + "pytest.ini": "option-before-section = true\n" + }, + checks: createPythonValidationChecks({ repoRoot, env: { PATH: "" } }) + }).runValidation(request({ + repo: { repoRoot }, + checks: [PYTHON_RUFF_LINT_CHECK_ID], + scope: { kind: "files", files: ["pkg/app.py"] } + })); + + assert.equal(result.status, "passed", JSON.stringify(result, null, 2)); + assert.equal(result.manifest.runs[0].pythonCapabilityRuns?.[0]?.state, "passed"); + assert.deepEqual( + [...new Set(result.pythonProjectContexts[0].reasons + .filter((reason) => reason.code === "invalid_config") + .map((reason) => reason.tool) + .filter((tool) => tool !== undefined))].sort(), + [] + ); + assert.equal( + result.pythonProjectContexts[0].reasons.some((reason) => + reason.code === "invalid_config" && reason.tool === "ruff" + ), + false + ); + assert.equal( + result.pythonProjectContexts[0].tools.find((tool) => tool.tool === "ruff")?.configFile, + undefined + ); + } finally { + rmSync(repoRoot, { recursive: true, force: true }); + } + }); + + it("ignores top-level pyproject extend while honoring the selected tool.ruff table", async () => { + const repoRoot = mkdtempSync(join(tmpdir(), "opcore-python-ruff-pyproject-extend-")); + try { + writeToolShim( + repoRoot, + "ruff", + [ + "#!/bin/sh", + "if [ \"$1\" = \"--version\" ]; then echo 'ruff 0.6.9'; exit 0; fi", + "if [ \"$1\" = \"check\" ]; then printf '%s\\n' '[]'; exit 0; fi", + "exit 2", + "" + ].join("\n") + ); + const result = await runner({ + files: { + "pyproject.toml": [ + "extend = \"../unrelated.toml\"", + "[project]", + "name = \"fixture\"", + "[tool.ruff]", + "line-length = 99", + "" + ].join("\n"), + "pkg/app.py": "value = 1\n" + }, + checks: createPythonValidationChecks({ repoRoot, env: { PATH: "" } }) + }).runValidation(request({ + repo: { repoRoot }, + checks: [PYTHON_RUFF_LINT_CHECK_ID], + scope: { kind: "files", files: ["pkg/app.py"] } + })); + + assert.equal(result.status, "passed", JSON.stringify(result, null, 2)); + assert.deepEqual(result.manifest.runs[0].pythonCapabilityRuns?.[0]?.configPaths, ["pyproject.toml"]); + } finally { + rmSync(repoRoot, { recursive: true, force: true }); + } + }); + + it("materializes overlay-aware Ruff extend config outside a nested project boundary", async () => { + const repoRoot = mkdtempSync(join(tmpdir(), "opcore-python-ruff-extend-")); + try { + writePassingPythonProtocolShim(join(repoRoot, "apps/api")); + writeToolShim( + join(repoRoot, "apps/api"), + "ruff", + [ + "#!/bin/sh", + "if [ \"$1\" = \"--version\" ]; then echo 'ruff 0.6.9'; exit 0; fi", + "if [ \"$1\" = \"--config\" ]; then shift 2; fi", + "IFS= read -r extended_line < ../../config/base-style.toml", + "if [ \"$1\" = \"check\" ] && [ \"$extended_line\" = 'line-length = 99' ]; then", + " printf '%s\\n' '[]'", + " exit 0", + "fi", + "exit 2", + "" + ].join("\n") + ); + + const result = await runner({ + files: { + "apps/api/pyproject.toml": "[project]\nname = 'api'\n", + "apps/api/ruff.toml": "extend = \"../../config/base-style.toml\"\n", + "apps/api/src/app.py": "VALUE = 1\n", + "config/base-style.toml": "line-length = 88\n" + }, + checks: createPythonValidationChecks({ repoRoot, env: { PATH: "" } }) + }).runValidation(request({ + repo: { repoRoot }, + checks: [PYTHON_RUFF_LINT_CHECK_ID], + scope: { kind: "files", files: ["apps/api/src/app.py"] }, + overlays: [{ path: "config/base-style.toml", action: "write", content: "line-length = 99\n" }] + })); + + assert.equal(result.status, "passed", JSON.stringify(result, null, 2)); + const receipt = result.manifest.runs[0].pythonCapabilityRuns?.[0]; + assert.deepEqual(receipt?.configPaths, [ + "apps/api/ruff.toml", + "config/base-style.toml" + ]); + assert.equal(receipt?.state, "passed"); + } finally { + rmSync(repoRoot, { recursive: true, force: true }); + } + }); + + it("materializes only the selected Ruff extend chain and ignores unrelated sibling config", async () => { + const repoRoot = mkdtempSync(join(tmpdir(), "opcore-python-ruff-absolute-extend-")); + try { + writePassingPythonProtocolShim(repoRoot); + writeToolShim( + repoRoot, + "ruff", + [ + "#!/bin/sh", + "if [ \"$1\" = \"--version\" ]; then echo 'ruff 0.6.9'; exit 0; fi", + "IFS= read -r root_extend < ruff.toml", + "IFS= read -r base_extend < config/base-style.toml", + "IFS= read -r final_style < config/final-style.toml", + "if [ \"$1\" = \"check\" ] &&", + " [ \"$root_extend\" = 'extend = \"config/base-style.toml\"' ] &&", + " [ \"$base_extend\" = 'extend = \"final-style.toml\"' ] &&", + " [ \"$final_style\" = 'line-length = 99' ]; then", + " printf '%s\\n' '[]'", + " exit 0", + "fi", + "exit 2", + "" + ].join("\n") + ); + const absoluteBase = join(repoRoot, "config/base-style.toml"); + const absoluteFinal = join(repoRoot, "config/final-style.toml"); + const result = await runner({ + files: { + "pyproject.toml": "[project]\nname = 'absolute-extend'\n", + "ruff.toml": `extend = ${JSON.stringify(absoluteBase)}\n`, + "config/base-style.toml": `extend = ${JSON.stringify(absoluteFinal)}\n`, + "config/final-style.toml": "line-length = 88\n", + "other/ruff.toml": "extend = \"/outside/does-not-exist.toml\"\n", + "pkg/app.py": "VALUE = 1\n" + }, + checks: createPythonValidationChecks({ repoRoot, env: { PATH: "" } }) + }).runValidation(request({ + repo: { repoRoot }, + checks: [PYTHON_RUFF_LINT_CHECK_ID], + scope: { kind: "files", files: ["pkg/app.py"] }, + overlays: [{ path: "config/final-style.toml", action: "write", content: "line-length = 99\n" }] + })); + + assert.equal(result.status, "passed", JSON.stringify(result, null, 2)); + const receipt = result.manifest.runs[0].pythonCapabilityRuns?.[0]; + assert.deepEqual(receipt?.configPaths, [ + "config/base-style.toml", + "config/final-style.toml", + "ruff.toml" + ]); + assert.equal(receipt?.state, "passed"); + } finally { + rmSync(repoRoot, { recursive: true, force: true }); + } + }); + + it("partitions Ruff execution by each target's closest configuration", async () => { + const repoRoot = mkdtempSync(join(tmpdir(), "opcore-python-ruff-target-config-")); + try { + writeToolShim( + repoRoot, + "ruff", + [ + "#!/bin/sh", + "if [ \"$1\" = \"--version\" ]; then echo 'ruff 0.6.9'; exit 0; fi", + "case \" $* \" in", + " *' pkg/app.py '*)", + " /usr/bin/grep -q '^line-length = 99$' pkg/ruff.toml || exit 9", + " [ ! -e ruff.toml ] || exit 9", + " printf '%s\\n' '[{\"code\":\"F401\",\"filename\":\"pkg/app.py\",\"location\":{\"row\":1,\"column\":1},\"message\":\"nested config\"}]'", + " exit 1", + " ;;", + " *' root.py '*)", + " /usr/bin/grep -q '^line-length = 88$' ruff.toml || exit 9", + " [ ! -e pkg/ruff.toml ] || exit 9", + " printf '%s\\n' '[]'", + " exit 0", + " ;;", + "esac", + "exit 9", + "" + ].join("\n") + ); + const result = await runner({ + files: { + "ruff.toml": "line-length = 88\n", + "root.py": "ROOT = 1\n", + "pkg/ruff.toml": "line-length = 99\n", + "pkg/app.py": "APP = 1\n" + }, + checks: createPythonValidationChecks({ repoRoot, env: { PATH: "" } }) + }).runValidation(request({ + repo: { repoRoot }, + checks: [PYTHON_RUFF_LINT_CHECK_ID], + scope: { kind: "files", files: ["root.py", "pkg/app.py"] } + })); + + assert.equal(result.status, "policy_failure", JSON.stringify(result, null, 2)); + assert.deepEqual(result.diagnostics.map((entry) => entry.path), ["pkg/app.py"]); + assert.deepEqual( + result.manifest.runs[0].pythonCapabilityRuns?.map((receipt) => receipt.configPaths), + [["pkg/ruff.toml"], ["ruff.toml"]] + ); + assert.deepEqual( + result.pythonProjectContexts.map((context) => [ + context.target, + context.tools.find((tool) => tool.tool === "ruff")?.configFile + ]), + [["pkg/app.py", "pkg/ruff.toml"], ["root.py", "ruff.toml"]] + ); + } finally { + rmSync(repoRoot, { recursive: true, force: true }); + } + }); + + it("uses the closest ancestor Ruff config across a nested project boundary", async () => { + const repoRoot = mkdtempSync(join(tmpdir(), "opcore-python-ruff-ancestor-config-")); + try { + writeToolShim( + join(repoRoot, "services/api"), + "ruff", + [ + "#!/bin/sh", + "if [ \"$1\" = \"--version\" ]; then echo 'ruff 0.6.9'; exit 0; fi", + "if [ \"$1\" = \"check\" ] && /usr/bin/grep -q 'E501' ../../ruff.toml; then", + " printf '%s\\n' '[{\"code\":\"E501\",\"filename\":\"app.py\",\"location\":{\"row\":1,\"column\":89},\"message\":\"line too long\"}]'", + " exit 1", + "fi", + "printf '%s\\n' '[]'", + "exit 0", + "" + ].join("\n") + ); + const result = await runner({ + files: { + "ruff.toml": "[lint]\nselect = [\"E501\"]\n", + "services/api/pyproject.toml": "[project]\nname = \"api\"\n", + "services/api/app.py": `${"x".repeat(100)}\n` + }, + checks: createPythonValidationChecks({ + repoRoot, + env: { PATH: "" }, + nodeWorkspace: createNodePythonProjectWorkspace(repoRoot) + }) + }).runValidation(request({ + repo: { repoRoot }, + checks: [PYTHON_RUFF_LINT_CHECK_ID], + scope: { kind: "files", files: ["services/api/app.py"] } + })); + + assert.equal(result.status, "policy_failure", JSON.stringify(result, null, 2)); + assert.deepEqual(result.diagnostics.map((entry) => entry.code), ["PY_RUFF_LINT_E501"]); + assert.equal( + result.pythonProjectContexts[0].tools.find((tool) => tool.tool === "ruff")?.configFile, + "ruff.toml" + ); + assert.deepEqual(result.manifest.runs[0].pythonCapabilityRuns?.[0]?.configPaths, ["ruff.toml"]); + } finally { + rmSync(repoRoot, { recursive: true, force: true }); + } + }); + + it("executes nested explicit Ruff config overrides after the subcommand with the materialized ancestor path", async () => { + const repoRoot = mkdtempSync(join(tmpdir(), "opcore-python-ruff-explicit-config-")); + const projectRoot = join(repoRoot, "services/api"); + try { + writeToolShim( + projectRoot, + "ruff", + [ + "#!/bin/sh", + "if [ \"$1\" = \"--version\" ]; then echo 'ruff 0.6.9'; exit 0; fi", + "if [ \"$1\" = \"check\" ] && [ \"$2\" = \"--config\" ] && [ \"$3\" = \"../../ruff.toml\" ]; then", + " printf '%s\\n' '[]'", + " exit 0", + "fi", + "if [ \"$1\" = \"format\" ] && [ \"$2\" = \"--config\" ] && [ \"$3\" = \"../../ruff.toml\" ]; then", + " exit 0", + "fi", + "exit 9", + "" + ].join("\n") + ); + const ruff = join(projectRoot, ".venv/bin/ruff"); + const result = await runner({ + files: { + "ruff.toml": "line-length = 88\n", + "services/api/pyproject.toml": "[project]\nname = \"api\"\n", + "services/api/app.py": "VALUE = 1\n" + }, + checks: createPythonValidationChecks({ + repoRoot, + env: { PATH: "" }, + nodeWorkspace: createNodePythonProjectWorkspace(repoRoot), + toolArgv: { ruff: [ruff, "--config", "../../ruff.toml"] } + }) + }).runValidation(request({ + repo: { repoRoot }, + checks: [PYTHON_RUFF_LINT_CHECK_ID, PYTHON_RUFF_FORMAT_CHECK_ID], + scope: { kind: "files", files: ["services/api/app.py"] } + })); + + assert.equal(result.status, "passed", JSON.stringify(result, null, 2)); + const runs = new Map(result.manifest.runs.map((run) => [run.checkId, run])); + assert.deepEqual( + runs.get(PYTHON_RUFF_LINT_CHECK_ID)?.pythonCapabilityRuns?.[0]?.argv?.slice(1, 4), + ["check", "--config", "../../ruff.toml"] + ); + assert.deepEqual( + runs.get(PYTHON_RUFF_FORMAT_CHECK_ID)?.pythonCapabilityRuns?.[0]?.argv?.slice(1, 4), + ["format", "--config", "../../ruff.toml"] + ); + } finally { + rmSync(repoRoot, { recursive: true, force: true }); + } + }); + + it("rejects Ruff lint diagnostics outside the selected after-state source set", async () => { + for (const [id, filename] of [ + ["outside", "/etc/passwd"], + ["unselected", "pkg/unselected.py"] + ]) { + const repoRoot = mkdtempSync(join(tmpdir(), `opcore-python-ruff-output-${id}-`)); + try { + writeToolShim( + repoRoot, + "ruff", + [ + "#!/bin/sh", + "if [ \"$1\" = \"--version\" ]; then echo 'ruff 0.6.9'; exit 0; fi", + `printf '%s\\n' ${JSON.stringify(JSON.stringify([{ + code: "F401", + filename, + location: { row: 1, column: 1 }, + message: "unauthorized path" + }]))}`, + "exit 1", + "" + ].join("\n") + ); + const result = await runner({ + files: { + "pkg/app.py": "APP = 1\n", + "pkg/unselected.py": "UNSELECTED = 1\n" + }, + checks: createPythonValidationChecks({ repoRoot, env: { PATH: "" } }) + }).runValidation(request({ + repo: { repoRoot }, + checks: [PYTHON_RUFF_LINT_CHECK_ID], + scope: { kind: "files", files: ["pkg/app.py"] } + })); + + assert.equal(result.status, "infrastructure_failure", `${id}: ${JSON.stringify(result, null, 2)}`); + assert.equal(result.manifest.runs[0].outcome, "tool_failure"); + assert.deepEqual(result.diagnostics.map((entry) => entry.code), ["PY_RUFF_LINT_TOOL_FAILED"]); + assert.equal(JSON.stringify(result).includes(filename), false); + } finally { + rmSync(repoRoot, { recursive: true, force: true }); + } + } + }); + + it("keeps unrelated refused tool configuration from disabling a Ruff-only run", async () => { + const repoRoot = mkdtempSync(join(tmpdir(), "opcore-python-ruff-refusal-scope-")); + try { + writeToolShim( + repoRoot, + "ruff", + [ + "#!/bin/sh", + "if [ \"$1\" = \"--version\" ]; then echo 'ruff 0.6.9'; exit 0; fi", + "printf '%s\\n' '[]'", + "exit 0", + "" + ].join("\n") + ); + const files = { + "mypy.ini": "[mypy]\nstrict = true\n", + "ruff.toml": "line-length = 88\n", + "pkg/app.py": "APP = 1\n" + }; + const result = await runner({ + files, + checks: createPythonValidationChecks({ + repoRoot, + env: { PATH: "" }, + nodeWorkspace: projectWorkspace(files, () => true, new Set(["mypy.ini"])) + }) + }).runValidation(request({ + repo: { repoRoot }, + checks: [PYTHON_RUFF_LINT_CHECK_ID], + scope: { kind: "files", files: ["pkg/app.py"] } + })); + + assert.equal(result.status, "passed", JSON.stringify(result, null, 2)); + assert.equal(result.manifest.runs[0].pythonCapabilityRuns?.[0]?.state, "passed"); + assert.equal( + result.pythonProjectContexts[0].reasons.some((reason) => reason.tool === "mypy"), + false + ); + } finally { + rmSync(repoRoot, { recursive: true, force: true }); + } + }); + + it("isolates Ruff execution from poisoned host paths and Python state", async () => { + const repoRoot = mkdtempSync(join(tmpdir(), "opcore-python-ruff-host-isolation-")); + const poisonRoot = mkdtempSync(join(tmpdir(), "opcore-python-ruff-poison-")); + try { + writeToolShim( + repoRoot, + "ruff", + [ + "#!/bin/sh", + "if [ \"$1\" = \"--version\" ]; then echo 'ruff 0.6.9'; exit 0; fi", + `case \"$HOME:$XDG_CONFIG_HOME:$XDG_CACHE_HOME:$TMPDIR:$PATH:$PYTHONPATH\" in *${poisonRoot}*) exit 9 ;; esac`, + "[ \"$PYTHONNOUSERSITE\" = 1 ] || exit 9", + "[ \"$PYTHONDONTWRITEBYTECODE\" = 1 ] || exit 9", + "[ \"$PWD\" = \"$(pwd)\" ] || exit 9", + "[ \"$RUFF_CACHE_DIR\" = \"${TMPDIR%/tmp}/ruff-cache\" ] || exit 9", + "printf '%s\\n' '[]'", + "exit 0", + "" + ].join("\n") + ); + const result = await runner({ + files: { "pkg/app.py": "APP = 1\n" }, + checks: createPythonValidationChecks({ + repoRoot, + env: { + PATH: poisonRoot, + HOME: poisonRoot, + XDG_CONFIG_HOME: poisonRoot, + XDG_CACHE_HOME: poisonRoot, + TMPDIR: poisonRoot, + PYTHONPATH: poisonRoot + } + }) + }).runValidation(request({ + repo: { repoRoot }, + checks: [PYTHON_RUFF_LINT_CHECK_ID], + scope: { kind: "files", files: ["pkg/app.py"] } + })); + + assert.equal(result.status, "passed", JSON.stringify(result, null, 2)); + } finally { + rmSync(repoRoot, { recursive: true, force: true }); + rmSync(poisonRoot, { recursive: true, force: true }); + } + }); + + it("attributes malformed pyproject Ruff dotted, quoted, and inline declarations", async () => { + const declarations = [ + "tool.ruff = { line-length = 88 }", + "\"tool\".\"ruff\" = { line-length = 88 }", + "tool = { ruff = { line-length = 88 } }" + ]; + for (const [index, declaration] of declarations.entries()) { + const repoRoot = mkdtempSync(join(tmpdir(), `opcore-python-ruff-declaration-${index}-`)); + const markerPath = join(repoRoot, "ruff-check-executed"); + try { + writeToolShim( + repoRoot, + "ruff", + [ + "#!/bin/sh", + "if [ \"$1\" = \"--version\" ]; then echo 'ruff 0.6.9'; exit 0; fi", + `printf executed > ${JSON.stringify(markerPath)}`, + "printf '%s\\n' '[]'", + "exit 0", + "" + ].join("\n") + ); + const result = await runner({ + files: { + "pyproject.toml": `${declaration}\nBROKEN = [\n`, + "pkg/app.py": "APP = 1\n" + }, + checks: createPythonValidationChecks({ repoRoot, env: { PATH: "" } }) + }).runValidation(request({ + repo: { repoRoot }, + checks: [PYTHON_RUFF_LINT_CHECK_ID], + scope: { kind: "files", files: ["pkg/app.py"] } + })); + + assert.equal(result.status, "unsupported_request", `${declaration}: ${JSON.stringify(result, null, 2)}`); + assert.equal(result.manifest.runs[0].outcome, "invalid_config"); + assert.equal(result.manifest.runs[0].pythonCapabilityRuns?.[0]?.configPath, "pyproject.toml"); + assert.equal(existsSync(markerPath), false); + } finally { + rmSync(repoRoot, { recursive: true, force: true }); + } + } + }); + + it("fails closed before execution when an absolute Ruff extend escapes the after-state repository", async () => { + const repoRoot = mkdtempSync(join(tmpdir(), "opcore-python-ruff-external-extend-")); + const markerPath = join(repoRoot, "ruff-check-executed"); + try { + writePassingPythonProtocolShim(repoRoot); + writeToolShim( + repoRoot, + "ruff", + [ + "#!/bin/sh", + "if [ \"$1\" = \"--version\" ]; then echo 'ruff 0.6.9'; exit 0; fi", + `printf executed > '${markerPath}'`, + "exit 0", + "" + ].join("\n") + ); + const result = await runner({ + files: { + "ruff.toml": "extend = \"/outside/opcore-style.toml\"\n", + "pkg/app.py": "VALUE = 1\n" + }, + checks: createPythonValidationChecks({ repoRoot, env: { PATH: "" } }) + }).runValidation(request({ + repo: { repoRoot }, + checks: [PYTHON_RUFF_LINT_CHECK_ID], + scope: { kind: "files", files: ["pkg/app.py"] } + })); + + assert.equal(result.status, "unsupported_request", JSON.stringify(result, null, 2)); + assert.equal(result.manifest.runs[0].outcome, "invalid_config"); + assert.equal(result.manifest.runs[0].pythonCapabilityRuns?.[0]?.state, "invalid_config"); + assert.deepEqual(result.diagnostics.map((entry) => entry.code), ["PY_RUFF_LINT_INVALID_CONFIG"]); + assert.equal(existsSync(markerPath), false); + } finally { + rmSync(repoRoot, { recursive: true, force: true }); + } + }); + + it("fails closed before reading a Ruff extend symlink that resolves outside the repository", async () => { + const repoRoot = mkdtempSync(join(tmpdir(), "opcore-python-ruff-symlink-extend-")); + const externalRoot = mkdtempSync(join(tmpdir(), "opcore-python-ruff-symlink-external-")); + const markerPath = join(repoRoot, "ruff-check-executed"); + try { + mkdirSync(join(repoRoot, "config"), { recursive: true }); + writeFileSync(join(externalRoot, "base.toml"), "line-length = 200\n"); + symlinkSync(join(externalRoot, "base.toml"), join(repoRoot, "config/base.toml")); + writeToolShim( + repoRoot, + "ruff", + [ + "#!/bin/sh", + "if [ \"$1\" = \"--version\" ]; then echo 'ruff 0.6.9'; exit 0; fi", + `printf executed > '${markerPath}'`, + "exit 0", + "" + ].join("\n") + ); + const result = await runner({ + files: { + "ruff.toml": "extend = \"config/base.toml\"\n", + "config/base.toml": "line-length = 200\n", + "pkg/app.py": "VALUE = 1\n" + }, + checks: createPythonValidationChecks({ + repoRoot, + env: { PATH: "" }, + nodeWorkspace: createNodePythonProjectWorkspace(repoRoot) + }) + }).runValidation(request({ + repo: { repoRoot }, + checks: [PYTHON_RUFF_LINT_CHECK_ID], + scope: { kind: "files", files: ["pkg/app.py"] } + })); + + assert.equal(result.status, "unsupported_request", JSON.stringify(result, null, 2)); + assert.equal(result.manifest.runs[0].outcome, "invalid_config"); + assert.deepEqual(result.diagnostics.map((entry) => entry.code), ["PY_RUFF_LINT_INVALID_CONFIG"]); + assert.match(result.diagnostics[0].message, /symlinked Ruff configuration path/i); + assert.equal(existsSync(markerPath), false); + } finally { + rmSync(repoRoot, { recursive: true, force: true }); + rmSync(externalRoot, { recursive: true, force: true }); + } + }); + + it("Ruff activation: records not_applicable receipts with zero Ruff processes when unrequested", async () => { + const repoRoot = mkdtempSync(join(tmpdir(), "opcore-python-ruff-inactive-")); + const markerPath = join(repoRoot, "ruff-called"); + try { + writePassingPythonProtocolShim(repoRoot); + writeToolShim( + repoRoot, + "ruff", + [ + "#!/bin/sh", + `printf 'called' > '${markerPath}'`, + "exit 1", + "" + ].join("\n") + ); + const result = await runner({ + files: { "pkg/app.py": "value = 1\n" }, + checks: createPythonValidationChecks({ repoRoot, env: { PATH: "" } }) + }).runValidation( + request({ + repo: { repoRoot }, + checks: [PYTHON_SOURCE_HYGIENE_CHECK_ID] + }) + ); + + assert.equal(result.status, "passed"); + const lintRun = result.manifest.runs.find((run) => run.checkId === PYTHON_RUFF_LINT_CHECK_ID); + const formatRun = result.manifest.runs.find((run) => run.checkId === PYTHON_RUFF_FORMAT_CHECK_ID); + assert.equal(lintRun?.status, "skipped"); + assert.equal(lintRun?.pythonCapabilityRuns?.[0]?.state, "not_applicable"); + assert.equal(lintRun?.pythonCapabilityRuns?.[0]?.diagnosticCount, 0); + assert.equal(formatRun?.status, "skipped"); + assert.equal(formatRun?.pythonCapabilityRuns?.[0]?.state, "not_applicable"); + assert.equal(formatRun?.pythonCapabilityRuns?.[0]?.diagnosticCount, 0); + assert.equal(existsSync(markerPath), false); + } finally { + rmSync(repoRoot, { recursive: true, force: true }); + } + }); + + it("records deleted-only Ruff scope as not applicable without probing or executing Ruff", async () => { + const repoRoot = mkdtempSync(join(tmpdir(), "opcore-python-ruff-deleted-only-")); + const markerPath = join(repoRoot, "ruff-called"); + try { + writeToolShim( + repoRoot, + "ruff", + [ + "#!/bin/sh", + `printf called > '${markerPath}'`, + "exit 1", + "" + ].join("\n") + ); + const result = await runner({ + files: { "pkg/app.py": "VALUE = 1\n" }, + checks: createPythonValidationChecks({ repoRoot, env: { PATH: "" } }) + }).runValidation(request({ + repo: { repoRoot }, + checks: [PYTHON_RUFF_LINT_CHECK_ID], + scope: { kind: "files", files: ["pkg/app.py"] }, + overlays: [{ path: "pkg/app.py", action: "delete" }] + })); + + const run = result.manifest.runs.find((entry) => entry.checkId === PYTHON_RUFF_LINT_CHECK_ID); + assert.equal(run?.status, "skipped", JSON.stringify(result, null, 2)); + assert.equal(run?.pythonCapabilityRuns?.[0]?.state, "not_applicable"); + assert.equal(run?.pythonCapabilityRuns?.[0]?.durationMs, 0); + assert.equal(existsSync(markerPath), false); + } finally { + rmSync(repoRoot, { recursive: true, force: true }); + } + }); + + it("refines Ruff format drift to the exact file set and records a findings receipt", async () => { + const repoRoot = mkdtempSync(join(tmpdir(), "opcore-python-ruff-format-")); + try { + writePassingPythonProtocolShim(repoRoot); + writeToolShim( + repoRoot, + "ruff", + [ + "#!/bin/sh", + "if [ \"$1\" = \"--version\" ]; then echo 'ruff 0.6.9'; exit 0; fi", + "if [ \"$1\" = \"format\" ]; then", + " shift", + " for arg in \"$@\"; do", + " case \"$arg\" in", + " *pkg/bad.py) exit 1 ;;", + " esac", + " done", + " exit 0", + "fi", + "exit 2", + "" + ].join("\n") + ); + + const result = await runner({ + files: { + "pkg/good.py": "value = 1\n", + "pkg/bad.py": "value=1\n" + }, + checks: createPythonValidationChecks({ repoRoot, env: { PATH: "" } }) + }).runValidation( + request({ + repo: { repoRoot }, + checks: [PYTHON_RUFF_FORMAT_CHECK_ID], + scope: { kind: "files", files: ["pkg/good.py", "pkg/bad.py"] } + }) + ); + + assert.equal(result.status, "policy_failure"); + assert.deepEqual(result.diagnostics.map((entry) => entry.code), ["PY_RUFF_FORMAT_DRIFT"]); + assert.deepEqual(result.diagnostics.map((entry) => entry.path), ["pkg/bad.py"]); + const receipt = result.manifest.runs[0].pythonCapabilityRuns?.[0]; + assert.equal(receipt?.capability, "ruff_format"); + assert.equal(receipt?.state, "findings"); + assert.equal(receipt?.termination, "exited"); + assert.equal(receipt?.exitCode, 1); + assert.equal(receipt?.diagnosticCount, 1); + assert.deepEqual(receipt?.sourcePaths, ["pkg/bad.py", "pkg/good.py"]); + assert.equal(receipt?.argv.includes("format"), true); + assert.equal(receipt?.argv.includes("pkg/bad.py"), true); + assert.equal(receipt?.argv.includes("pkg/good.py"), true); + assert.equal(receipt?.argv.includes("--no-cache"), true); + assert.equal(receipt?.invocations?.length, 3); + assert.equal(receipt?.invocations?.every((invocation) => invocation.termination === "exited"), true); + assert.equal(result.diagnostics[0].tool?.command, receipt?.command); + assert.equal(result.diagnostics[0].tool?.cwd, receipt?.cwd); + } finally { + rmSync(repoRoot, { recursive: true, force: true }); + } + }); + + it("bounds Ruff format argv batches before refinement", async () => { + const repoRoot = mkdtempSync(join(tmpdir(), "opcore-python-ruff-format-batches-")); + const markerPath = join(repoRoot, "format-invocations"); + try { + writePassingPythonProtocolShim(repoRoot); + writeToolShim( + repoRoot, + "ruff", + [ + "#!/bin/sh", + "if [ \"$1\" = \"--version\" ]; then echo 'ruff 0.6.9'; exit 0; fi", + `if [ \"$1\" = \"format\" ]; then printf x >> '${markerPath}'; exit 0; fi`, + "exit 2", + "" + ].join("\n") + ); + const files = Object.fromEntries( + Array.from({ length: 65 }, (_, index) => [`pkg/file_${String(index).padStart(2, "0")}.py`, "VALUE = 1\n"]) + ); + const targets = Object.keys(files); + const result = await runner({ + files, + checks: createPythonValidationChecks({ repoRoot, env: { PATH: "" } }) + }).runValidation(request({ + repo: { repoRoot }, + checks: [PYTHON_RUFF_FORMAT_CHECK_ID], + scope: { kind: "files", files: targets } + })); + + assert.equal(result.status, "passed", JSON.stringify(result, null, 2)); + assert.equal(readFileSync(markerPath, "utf8"), "xx"); + const receipt = result.manifest.runs[0].pythonCapabilityRuns?.[0]; + assert.equal(receipt?.state, "passed"); + assert.equal(receipt?.sourcePaths?.length, 65); + assert.equal((receipt?.argv?.filter((argument) => argument.endsWith(".py")).length ?? 0) <= 64, true); + assert.equal(receipt?.invocations?.length, 2); + assert.equal( + receipt?.invocations?.every( + (invocation) => invocation.argv.filter((argument) => argument.endsWith(".py")).length <= 64 + ), + true + ); + } finally { + rmSync(repoRoot, { recursive: true, force: true }); + } + }); + + it("fails closed with bounded invocation evidence when Ruff format refinement exceeds its invocation bound", async () => { + const repoRoot = mkdtempSync(join(tmpdir(), "opcore-python-ruff-format-invocation-bound-")); + try { + writePassingPythonProtocolShim(repoRoot); + writeToolShim( + repoRoot, + "ruff", + [ + "#!/bin/sh", + "if [ \"$1\" = \"--version\" ]; then echo 'ruff 0.6.9'; exit 0; fi", + "if [ \"$1\" = \"format\" ]; then exit 1; fi", + "exit 2", + "" + ].join("\n") + ); + const files = Object.fromEntries( + Array.from({ length: 320 }, (_, index) => [`pkg/file_${String(index).padStart(3, "0")}.py`, "value=1\n"]) + ); + const targets = Object.keys(files); + const result = await runner({ + files, + checks: createPythonValidationChecks({ repoRoot, env: { PATH: "" } }) + }).runValidation(request({ + repo: { repoRoot }, + checks: [PYTHON_RUFF_FORMAT_CHECK_ID], + scope: { kind: "files", files: targets } + })); + + assert.equal(result.status, "infrastructure_failure", JSON.stringify(result.failure, null, 2)); + const run = result.manifest.runs.find((entry) => entry.checkId === PYTHON_RUFF_FORMAT_CHECK_ID); + assert.equal(run?.outcome, "tool_failure"); + assert.deepEqual(result.diagnostics.map((entry) => entry.code), ["PY_RUFF_FORMAT_TOOL_FAILED"]); + const receipt = run?.pythonCapabilityRuns?.[0]; + assert.equal(receipt?.state, "tool_failure"); + assert.equal(receipt?.termination, "overflow"); + assert.equal(receipt?.exitCode, undefined); + assert.match(receipt?.failureMessage ?? "", /bounded invocations/u); + const bounded = receipt?.invocations?.filter((invocation) => invocation.termination === "overflow") ?? []; + assert.equal(bounded.length, 1); + assert.deepEqual(bounded[0].argv, receipt?.argv); + } finally { + rmSync(repoRoot, { recursive: true, force: true }); + } + }); + + it("Ruff outcome matrix: fails closed for malformed, contradictory, exit, timeout, signal, spawn, and overflow states", async () => { + const cases = [ + { + id: "malformed", + body: "printf '%s\\n' 'not-json'\nexit 1", + outcome: "tool_failure", + code: "PY_RUFF_LINT_TOOL_FAILED" + }, + { + id: "contradictory-clean", + body: "printf '%s\\n' '[]'\nexit 1", + outcome: "tool_failure", + code: "PY_RUFF_LINT_TOOL_FAILED" + }, + { + id: "contradictory-findings", + body: "printf '%s\\n' '[{\"code\":\"F401\",\"filename\":\"pkg/app.py\",\"location\":{\"row\":1,\"column\":1},\"message\":\"unused\"}]'\nexit 0", + outcome: "tool_failure", + code: "PY_RUFF_LINT_TOOL_FAILED" + }, + { + id: "unknown-exit", + body: "printf '%s\\n' '[]'\nexit 2", + outcome: "tool_failure", + code: "PY_RUFF_LINT_TOOL_FAILED" + }, + { + id: "timeout", + body: "while :; do :; done", + outcome: "timeout", + code: "PY_RUFF_LINT_TIMEOUT", + timeoutMs: 20 + }, + { + id: "signal", + body: "kill -TERM $", + outcome: "tool_failure", + code: "PY_RUFF_LINT_TOOL_FAILED" + }, + { + id: "spawn", + version: `echo 'ruff 0.6.9'; /bin/rm \"$0\"; exit 0`, + body: "exit 0", + outcome: "tool_failure", + code: "PY_RUFF_LINT_TOOL_FAILED" + }, + { + id: "overflow", + body: `'${process.execPath}' -e 'process.stdout.write(\"x\".repeat(1100000))'\nexit 0`, + outcome: "tool_failure", + code: "PY_RUFF_LINT_TOOL_FAILED" + } + ]; + for (const fixture of cases) { + const repoRoot = mkdtempSync(join(tmpdir(), `opcore-python-ruff-${fixture.id}-`)); + try { + writePassingPythonProtocolShim(repoRoot); + writeToolShim( + repoRoot, + "ruff", + [ + "#!/bin/sh", + `if [ \"$1\" = \"--version\" ]; then ${fixture.version ?? "echo 'ruff 0.6.9'; exit 0"}; fi`, + fixture.body, + "" + ].join("\n") + ); + const baseProbe = successfulProbe(); + const processProbe = fixture.id === "spawn" + ? { + ...baseProbe, + run(command, args, options) { + const result = baseProbe.run(command, args, options); + if (command.endsWith("/ruff")) rmSync(command, { force: true }); + return result; + } + } + : baseProbe; + const result = await runner({ + files: { "pkg/app.py": "VALUE = 1\n" }, + checks: createPythonValidationChecks({ + repoRoot, + env: { PATH: "" }, + processProbe, + timeoutMs: fixture.timeoutMs ?? 30000 + }) + }).runValidation(request({ + repo: { repoRoot }, + checks: [PYTHON_RUFF_LINT_CHECK_ID], + scope: { kind: "files", files: ["pkg/app.py"] } + })); + + assert.equal(result.status, "infrastructure_failure", `${fixture.id}: ${JSON.stringify(result, null, 2)}`); + const run = result.manifest.runs[0]; + assert.equal(run.outcome, fixture.outcome, `${fixture.id}: ${JSON.stringify(result, null, 2)}`); + assert.deepEqual(result.diagnostics.map((diagnostic) => diagnostic.code), [fixture.code], fixture.id); + const receipt = run.pythonCapabilityRuns?.[0]; + assert.equal(receipt?.state, fixture.outcome, fixture.id); + assert.notEqual(receipt?.state, "passed", fixture.id); + if (fixture.id === "overflow") assert.equal(receipt?.termination, "overflow"); + } finally { + rmSync(repoRoot, { recursive: true, force: true }); + } + } + }); + + it("Ruff outcome matrix: proves malformed dedicated and pyproject config as invalid_config without executing a check", async () => { + for (const [configPath, configContent] of [ + ["ruff.toml", "line-length = [\n"], + ["pyproject.toml", "[project]\nname = 'fixture'\n[tool.ruff]\nline-length = [\n"] + ]) { + for (const checkId of [PYTHON_RUFF_LINT_CHECK_ID, PYTHON_RUFF_FORMAT_CHECK_ID]) { + const repoRoot = mkdtempSync(join(tmpdir(), "opcore-python-ruff-invalid-config-")); + const markerPath = join(repoRoot, "ruff-check-executed"); + try { + writePassingPythonProtocolShim(repoRoot); + writeToolShim( + repoRoot, + "ruff", + [ + "#!/bin/sh", + "if [ \"$1\" = \"--version\" ]; then echo 'ruff 0.6.9'; exit 0; fi", + `printf executed > '${markerPath}'`, + "exit 0", + "" + ].join("\n") + ); + const result = await runner({ + files: { + "pkg/app.py": "VALUE = 1\n", + [configPath]: configContent + }, + checks: createPythonValidationChecks({ repoRoot, env: { PATH: "" } }) + }).runValidation(request({ + repo: { repoRoot }, + checks: [checkId], + scope: { kind: "files", files: ["pkg/app.py"] } + })); + + assert.equal(result.status, "unsupported_request", `${checkId} ${configPath}: ${JSON.stringify(result, null, 2)}`); + assert.equal(result.manifest.runs[0].outcome, "invalid_config"); + const receipt = result.manifest.runs[0].pythonCapabilityRuns?.[0]; + assert.equal(receipt?.state, "invalid_config"); + assert.equal(receipt?.configPath, configPath); + assert.equal( + result.pythonProjectContexts[0].reasons.some((reason) => + reason.code === "invalid_config" && + reason.tool === "ruff" && + reason.path === configPath + ), + true + ); + assert.equal(existsSync(markerPath), false); + } finally { + rmSync(repoRoot, { recursive: true, force: true }); + } + } + } + }); + + it("classifies malformed extended Ruff configs as invalid_config before lint or format execution", async () => { + for (const checkId of [PYTHON_RUFF_LINT_CHECK_ID, PYTHON_RUFF_FORMAT_CHECK_ID]) { + const repoRoot = mkdtempSync(join(tmpdir(), "opcore-python-ruff-malformed-extend-")); + const markerPath = join(repoRoot, "ruff-check-executed"); + try { + writeToolShim( + repoRoot, + "ruff", + [ + "#!/bin/sh", + "if [ \"$1\" = \"--version\" ]; then echo 'ruff 0.6.9'; exit 0; fi", + `printf executed > '${markerPath}'`, + "printf '%s\\n' 'Failed to parse config/base.toml: expected a value' >&2", + "exit 2", + "" + ].join("\n") + ); + const result = await runner({ + files: { + "pkg/app.py": "VALUE = 1\n", + "ruff.toml": "extend = \"config/base.toml\"\n", + "config/base.toml": "line-length = [\n" + }, + checks: createPythonValidationChecks({ repoRoot, env: { PATH: "" } }) + }).runValidation(request({ + repo: { repoRoot }, + checks: [checkId], + scope: { kind: "files", files: ["pkg/app.py"] } + })); + + assert.equal(result.status, "unsupported_request", `${checkId}: ${JSON.stringify(result, null, 2)}`); + assert.equal(result.manifest.runs[0].outcome, "invalid_config"); + const receipt = result.manifest.runs[0].pythonCapabilityRuns?.[0]; + assert.equal(receipt?.state, "invalid_config"); + assert.equal(receipt?.configPath, "config/base.toml"); + assert.match(receipt?.afterStateManifestFingerprint ?? "", /^sha256:[a-f0-9]{64}$/u); + assert.deepEqual(receipt?.sourcePaths, ["pkg/app.py"]); + assert.deepEqual(receipt?.configPaths, ["config/base.toml", "ruff.toml"]); + assert.equal(receipt?.cwd, "."); + assert.deepEqual(result.diagnostics.map((entry) => entry.path), ["config/base.toml"]); + assert.equal(existsSync(markerPath), false); + } finally { + rmSync(repoRoot, { recursive: true, force: true }); + } + } + }); + + it("classifies deleted extended Ruff configs as invalid_config without executing Ruff", async () => { + const repoRoot = mkdtempSync(join(tmpdir(), "opcore-python-ruff-deleted-extend-")); + const markerPath = join(repoRoot, "ruff-check-executed"); + try { + writeToolShim( + repoRoot, + "ruff", + [ + "#!/bin/sh", + "if [ \"$1\" = \"--version\" ]; then echo 'ruff 0.6.9'; exit 0; fi", + `printf executed > '${markerPath}'`, + "exit 2", + "" + ].join("\n") + ); + const result = await runner({ + files: { + "pkg/app.py": "VALUE = 1\n", + "ruff.toml": "extend = \"config/base.toml\"\n", + "config/base.toml": "line-length = 88\n" + }, + checks: createPythonValidationChecks({ repoRoot, env: { PATH: "" } }) + }).runValidation(request({ + repo: { repoRoot }, + checks: [PYTHON_RUFF_LINT_CHECK_ID], + scope: { kind: "files", files: ["pkg/app.py"] }, + overlays: [{ path: "config/base.toml", action: "delete" }] + })); + + assert.equal(result.status, "unsupported_request", JSON.stringify(result, null, 2)); + assert.equal(result.manifest.runs[0].outcome, "invalid_config"); + const receipt = result.manifest.runs[0].pythonCapabilityRuns?.[0]; + assert.equal(receipt?.state, "invalid_config"); + assert.equal(receipt?.configPath, "config/base.toml"); + assert.match(receipt?.afterStateManifestFingerprint ?? "", /^sha256:[a-f0-9]{64}$/u); + assert.deepEqual(receipt?.sourcePaths, ["pkg/app.py"]); + assert.deepEqual(receipt?.configPaths, ["config/base.toml", "ruff.toml"]); + assert.equal(receipt?.cwd, "."); + assert.deepEqual(result.diagnostics.map((entry) => entry.path), ["config/base.toml"]); + assert.equal(existsSync(markerPath), false); + } finally { + rmSync(repoRoot, { recursive: true, force: true }); + } + }); + + it("classifies Ruff extend cycles as exact-state invalid_config without executing Ruff", async () => { + for (const checkId of [PYTHON_RUFF_LINT_CHECK_ID, PYTHON_RUFF_FORMAT_CHECK_ID]) { + const repoRoot = mkdtempSync(join(tmpdir(), "opcore-python-ruff-cycle-extend-")); + const markerPath = join(repoRoot, "ruff-check-executed"); + try { + writeToolShim( + repoRoot, + "ruff", + [ + "#!/bin/sh", + "if [ \"$1\" = \"--version\" ]; then echo 'ruff 0.6.9'; exit 0; fi", + `printf executed > '${markerPath}'`, + "printf '%s\\n' 'Circular dependency detected in ruff.toml' >&2", + "exit 2", + "" + ].join("\n") + ); + const result = await runner({ + files: { + "pkg/app.py": "VALUE = 1\n", + "ruff.toml": "extend = \"config/base.toml\"\n", + "config/base.toml": "extend = \"../ruff.toml\"\n" + }, + checks: createPythonValidationChecks({ repoRoot, env: { PATH: "" } }) + }).runValidation(request({ + repo: { repoRoot }, + checks: [checkId], + scope: { kind: "files", files: ["pkg/app.py"] } + })); + + assert.equal(result.status, "unsupported_request", `${checkId}: ${JSON.stringify(result, null, 2)}`); + assert.equal(result.manifest.runs[0].outcome, "invalid_config"); + const receipt = result.manifest.runs[0].pythonCapabilityRuns?.[0]; + assert.equal(receipt?.state, "invalid_config"); + assert.equal(receipt?.configPath, "config/base.toml"); + assert.match(receipt?.afterStateManifestFingerprint ?? "", /^sha256:[a-f0-9]{64}$/u); + assert.deepEqual(receipt?.sourcePaths, ["pkg/app.py"]); + assert.deepEqual(receipt?.configPaths, ["config/base.toml", "ruff.toml"]); + assert.equal(receipt?.cwd, "."); + assert.deepEqual(result.diagnostics.map((entry) => entry.path), ["config/base.toml"]); + assert.equal(existsSync(markerPath), false); + } finally { + rmSync(repoRoot, { recursive: true, force: true }); + } + } + }); + + it("uses the selected Ruff config closure to prove semantic extended-config rejection", async () => { + for (const checkId of [PYTHON_RUFF_LINT_CHECK_ID, PYTHON_RUFF_FORMAT_CHECK_ID]) { + const repoRoot = mkdtempSync(join(tmpdir(), "opcore-python-ruff-semantic-extend-")); + try { + writeToolShim( + repoRoot, + "ruff", + [ + "#!/bin/sh", + "if [ \"$1\" = \"--version\" ]; then echo 'ruff 0.6.9'; exit 0; fi", + "printf '%s\\n' 'Failed to parse config/base.toml: invalid value' >&2", + "exit 2", + "" + ].join("\n") + ); + const result = await runner({ + files: { + "pkg/app.py": "VALUE = 1\n", + "ruff.toml": "extend = \"config/base.toml\"\n", + "config/base.toml": "line-length = \"wide\"\n" + }, + checks: createPythonValidationChecks({ repoRoot, env: { PATH: "" } }) + }).runValidation(request({ + repo: { repoRoot }, + checks: [checkId], + scope: { kind: "files", files: ["pkg/app.py"] } + })); + + assert.equal(result.status, "unsupported_request", `${checkId}: ${JSON.stringify(result, null, 2)}`); + assert.equal(result.manifest.runs[0].outcome, "invalid_config"); + assert.equal(result.manifest.runs[0].pythonCapabilityRuns?.[0]?.state, "invalid_config"); + assert.deepEqual(result.diagnostics.map((entry) => entry.path), ["config/base.toml"]); + assert.equal(result.manifest.runs[0].pythonCapabilityRuns?.[0]?.invocations?.length, 2); + } finally { + rmSync(repoRoot, { recursive: true, force: true }); + } + } + }); + + it("Ruff outcome matrix: uses a settings probe to prove semantic config rejection separately from internal exit", async () => { + const repoRoot = mkdtempSync(join(tmpdir(), "opcore-python-ruff-semantic-config-")); + const invocationMarker = join(repoRoot, "ruff-invocations"); + try { + writePassingPythonProtocolShim(repoRoot); + writeToolShim( + repoRoot, + "ruff", + [ + "#!/bin/sh", + "if [ \"$1\" = \"--version\" ]; then echo 'ruff 0.6.9'; exit 0; fi", + `printf x >> '${invocationMarker}'`, + "case \" $* \" in", + " *' --show-settings '*)", + " printf '%s\\n' 'selected configuration ruff.toml rejected' >&2", + " exit 2", + " ;;", + "esac", + "if [ \"$1\" = \"check\" ]; then", + " printf '%s\\n' 'check failed to load selected configuration' >&2", + " exit 2", + "fi", + "exit 9", + "" + ].join("\n") + ); + const result = await runner({ + files: { + "pkg/app.py": "VALUE = 1\n", + "ruff.toml": "line-length = \"wide\"\n" + }, + checks: createPythonValidationChecks({ repoRoot, env: { PATH: "" } }) + }).runValidation(request({ + repo: { repoRoot }, + checks: [PYTHON_RUFF_LINT_CHECK_ID], + scope: { kind: "files", files: ["pkg/app.py"] } + })); + + assert.equal(result.status, "unsupported_request", JSON.stringify(result, null, 2)); + assert.equal(result.manifest.runs[0].outcome, "invalid_config"); + assert.deepEqual(result.diagnostics.map((entry) => entry.code), ["PY_RUFF_LINT_INVALID_CONFIG"]); + assert.equal(readFileSync(invocationMarker, "utf8"), "xx"); + const receipt = result.manifest.runs[0].pythonCapabilityRuns?.[0]; + assert.equal(receipt?.state, "invalid_config"); + assert.equal(receipt?.exitCode, 2); + assert.equal(receipt?.argv.includes("--show-settings"), true); + assert.equal(receipt?.configPath, "ruff.toml"); + assert.equal(receipt?.invocations?.length, 2); + } finally { + rmSync(repoRoot, { recursive: true, force: true }); + } + }); + + it("does not relabel an unrelated configuration cache failure as invalid_config", async () => { + const repoRoot = mkdtempSync(join(tmpdir(), "opcore-python-ruff-config-cache-failure-")); + try { + writeToolShim( + repoRoot, + "ruff", + [ + "#!/bin/sh", + "if [ \"$1\" = \"--version\" ]; then echo 'ruff 0.6.9'; exit 0; fi", + "printf '%s\\n' 'error: configuration cache initialization failed for ruff.toml' >&2", + "exit 2", + "" + ].join("\n") + ); + const result = await runner({ + files: { + "pkg/app.py": "VALUE = 1\n", + "ruff.toml": "line-length = 88\n" + }, + checks: createPythonValidationChecks({ repoRoot, env: { PATH: "" } }) + }).runValidation(request({ + repo: { repoRoot }, + checks: [PYTHON_RUFF_LINT_CHECK_ID], + scope: { kind: "files", files: ["pkg/app.py"] } + })); + + assert.equal(result.status, "infrastructure_failure", JSON.stringify(result, null, 2)); + assert.equal(result.manifest.runs[0].outcome, "tool_failure"); + assert.equal(result.manifest.runs[0].pythonCapabilityRuns?.[0]?.state, "tool_failure"); + } finally { + rmSync(repoRoot, { recursive: true, force: true }); + } + }); + + it("keeps unsupported settings probes as tool failures with complete invocation evidence", async () => { + const repoRoot = mkdtempSync(join(tmpdir(), "opcore-python-ruff-unsupported-settings-")); + try { + writePassingPythonProtocolShim(repoRoot); + writeToolShim( + repoRoot, + "ruff", + [ + "#!/bin/sh", + "if [ \"$1\" = \"--version\" ]; then echo 'ruff 0.6.9'; exit 0; fi", + "if [ \"$1\" = \"check\" ] && [ \"$2\" = \"--show-settings\" ]; then", + " printf '%s\\n' \"unexpected argument '--show-settings'\" >&2", + " exit 2", + "fi", + "if [ \"$1\" = \"check\" ]; then", + " printf '%s\\n' 'internal execution failure' >&2", + " exit 2", + "fi", + "exit 9", + "" + ].join("\n") + ); + const result = await runner({ + files: { + "pkg/app.py": "VALUE = 1\n", + "ruff.toml": "line-length = 88\n" + }, + checks: createPythonValidationChecks({ repoRoot, env: { PATH: "" } }) + }).runValidation(request({ + repo: { repoRoot }, + checks: [PYTHON_RUFF_LINT_CHECK_ID], + scope: { kind: "files", files: ["pkg/app.py"] } + })); + + assert.equal(result.status, "infrastructure_failure", JSON.stringify(result, null, 2)); + assert.equal(result.manifest.runs[0].outcome, "tool_failure"); + const receipt = result.manifest.runs[0].pythonCapabilityRuns?.[0]; + assert.equal(receipt?.state, "tool_failure"); + assert.equal(receipt?.invocations?.length, 2); + assert.equal(receipt?.invocations?.[1]?.argv.includes("--show-settings"), true); + } finally { + rmSync(repoRoot, { recursive: true, force: true }); + } + }); + + it("Ruff outcome matrix: reports an activated missing tool without a false pass", async () => { + const repoRoot = mkdtempSync(join(tmpdir(), "opcore-python-ruff-missing-")); + try { + writePassingPythonProtocolShim(repoRoot); + const result = await runner({ + files: { "pkg/app.py": "VALUE = 1\n" }, + checks: createPythonValidationChecks({ repoRoot, env: { PATH: "" } }) + }).runValidation(request({ + repo: { repoRoot }, + checks: [PYTHON_RUFF_LINT_CHECK_ID], + scope: { kind: "files", files: ["pkg/app.py"] } + })); + + assert.equal(result.status, "unsupported_request", JSON.stringify(result, null, 2)); + assert.equal(result.manifest.runs[0].outcome, "tool_unavailable"); + const receipt = result.manifest.runs[0].pythonCapabilityRuns?.[0]; + assert.equal(receipt?.state, "tool_unavailable"); + assert.equal(receipt?.termination, undefined); + assert.equal(receipt?.diagnosticCount, 1); + } finally { + rmSync(repoRoot, { recursive: true, force: true }); + } + }); + + it("Ruff activation: disabled policy wins with zero Ruff processes", async () => { + const repoRoot = mkdtempSync(join(tmpdir(), "opcore-python-ruff-disabled-")); + const markerPath = join(repoRoot, "ruff-called"); + try { + writePassingPythonProtocolShim(repoRoot); + mkdirSync(join(repoRoot, ".opcore"), { recursive: true }); + writeFileSync( + join(repoRoot, ".opcore", "config"), + JSON.stringify({ + validation: { + checks: { + disabled: [PYTHON_RUFF_LINT_CHECK_ID] + } + } + }) + ); + writeToolShim( + repoRoot, + "ruff", + [ + "#!/bin/sh", + `printf 'called' > '${markerPath}'`, + "exit 1", + "" + ].join("\n") + ); + const result = await createValidationRunner({ + workspace: workspace({ + repoRoot, + files: { "pkg/app.py": "value = 1\n" } + }), + checks: validationChecksForRepoPolicy(repoRoot, { + pythonWorkspace: canonicalTestPythonWorkspace(), + pythonImportAnalyzer: fixedImportAnalyzer([]) + }) + }).runValidation( + request({ + repo: { repoRoot }, + checks: [PYTHON_RUFF_LINT_CHECK_ID] + }) + ); + + assert.equal(result.status, "skipped"); + const run = result.manifest.runs.find((entry) => entry.checkId === PYTHON_RUFF_LINT_CHECK_ID); + assert.equal(run?.status, "skipped"); + assert.equal(run?.pythonCapabilityRuns?.[0]?.state, "disabled"); + assert.equal(run?.pythonCapabilityRuns?.[0]?.diagnosticCount, 0); + assert.equal(existsSync(markerPath), false); + } finally { + rmSync(repoRoot, { recursive: true, force: true }); + } + }); + + it("finalizes inactive Ruff receipts after an earlier check infrastructure failure", async () => { + const repoRoot = mkdtempSync(join(tmpdir(), "opcore-python-ruff-early-failure-")); + try { + mkdirSync(join(repoRoot, ".opcore"), { recursive: true }); + writeFileSync( + join(repoRoot, ".opcore", "config"), + JSON.stringify({ + validation: { + checks: { + disabled: [PYTHON_RUFF_LINT_CHECK_ID] + } + } + }) + ); + const failingCheck = { + id: "fixture.infrastructure", + owner: "fixture", + adapter: "fixture", + defaultSeverity: "error", + supportedScopes: ["files"], + run: () => ({ + outcome: "tool_failure", + failureMessage: "fixture failed before Ruff", + diagnostics: [] + }) + }; + const result = await createValidationRunner({ + workspace: workspace({ files: { "pkg/app.py": "VALUE = 1\n" } }), + checks: [ + failingCheck, + ...validationChecksForRepoPolicy(repoRoot, { + pythonWorkspace: canonicalTestPythonWorkspace() + }) + ] + }).runValidation(request({ + repo: { repoRoot }, + checks: ["fixture.infrastructure"], + scope: { kind: "files", files: ["pkg/app.py"] } + })); + + assert.equal(result.status, "infrastructure_failure"); + const lintRun = result.manifest.runs.find((run) => run.checkId === PYTHON_RUFF_LINT_CHECK_ID); + const formatRun = result.manifest.runs.find((run) => run.checkId === PYTHON_RUFF_FORMAT_CHECK_ID); + assert.equal(lintRun?.pythonCapabilityRuns?.[0]?.state, "disabled"); + assert.equal(formatRun?.pythonCapabilityRuns?.[0]?.state, "not_applicable"); + } finally { + rmSync(repoRoot, { recursive: true, force: true }); + } + }); + + it("Ruff activation: repo defaults execute the selected Ruff check", async () => { + const repoRoot = mkdtempSync(join(tmpdir(), "opcore-python-ruff-default-")); + const markerPath = join(repoRoot, "ruff-executed"); + try { + mkdirSync(join(repoRoot, ".opcore"), { recursive: true }); + writeFileSync( + join(repoRoot, ".opcore", "config"), + JSON.stringify({ validation: { checks: { defaults: [PYTHON_RUFF_LINT_CHECK_ID] } } }) + ); + writePassingPythonProtocolShim(repoRoot); + writeToolShim( + repoRoot, + "ruff", + [ + "#!/bin/sh", + "if [ \"$1\" = \"--version\" ]; then echo 'ruff 0.6.9'; exit 0; fi", + `printf executed > '${markerPath}'`, + "printf '%s\\n' '[]'", + "exit 0", + "" + ].join("\n") + ); + const result = createValidationRunner({ + workspace: workspace({ repoRoot, files: { "pkg/app.py": "VALUE = 1\n" } }), + checks: validationChecksForRepoPolicy(repoRoot, { + pythonWorkspace: canonicalTestPythonWorkspace(), + pythonImportAnalyzer: fixedImportAnalyzer([]) + }) + }).runValidation(request({ + repo: { repoRoot }, + checks: undefined, + scope: { kind: "files", files: ["pkg/app.py"] } + })); + + const resolved = await result; + assert.equal(resolved.status, "unsupported_request", JSON.stringify(resolved, null, 2)); + assert.equal(readFileSync(markerPath, "utf8"), "executed"); + const run = resolved.manifest.runs.find((entry) => entry.checkId === PYTHON_RUFF_LINT_CHECK_ID); + assert.equal(run?.pythonCapabilityRuns?.[0]?.state, "passed"); + } finally { + rmSync(repoRoot, { recursive: true, force: true }); + } + }); + + it("records exact per-project Ruff receipt source paths and executed command", async () => { + const repoRoot = mkdtempSync(join(tmpdir(), "opcore-python-ruff-multi-project-")); + try { + for (const projectRoot of [join(repoRoot, "apps/one"), join(repoRoot, "apps/two")]) { + writePassingPythonProtocolShim(projectRoot); + writeToolShim( + projectRoot, + "ruff", + [ + "#!/bin/sh", + "if [ \"$1\" = \"--version\" ]; then echo 'ruff 0.6.9'; exit 0; fi", + "if [ \"$1\" = \"check\" ]; then", + " printf '%s\\n' '[]'", + " exit 0", + "fi", + "exit 2", + "" + ].join("\n") + ); + } + + const result = await runner({ + files: { + "apps/one/pyproject.toml": "[project]\nname='one'\n[tool.ruff]\n", + "apps/one/pkg/app.py": "value = 1\n", + "apps/two/pyproject.toml": "[project]\nname='two'\n[tool.ruff]\n", + "apps/two/pkg/app.py": "value = 2\n" + }, + checks: createPythonValidationChecks({ repoRoot, env: { PATH: "" } }) + }).runValidation( + request({ + repo: { repoRoot }, + checks: [PYTHON_RUFF_LINT_CHECK_ID], + scope: { + kind: "files", + files: ["apps/one/pkg/app.py", "apps/two/pkg/app.py"] + } + }) + ); + + assert.equal(result.status, "passed"); + const receipts = result.manifest.runs[0].pythonCapabilityRuns ?? []; + assert.equal(receipts.length, 2); + assert.deepEqual( + receipts.map((receipt) => ({ + cwd: receipt.cwd, + sourcePaths: receipt.sourcePaths, + command: receipt.command + })), + [ + { + cwd: "apps/one", + sourcePaths: ["apps/one/pkg/app.py"], + command: `${receipts[0].argv.join(" ")}` + }, + { + cwd: "apps/two", + sourcePaths: ["apps/two/pkg/app.py"], + command: `${receipts[1].argv.join(" ")}` + } + ] + ); + assert.equal(receipts[0].command.includes("check"), true); + assert.equal(receipts[1].command.includes("check"), true); + assert.equal(receipts[0].command.includes("apps/two/pkg/app.py"), false); + assert.equal(receipts[1].command.includes("apps/one/pkg/app.py"), false); + } finally { + rmSync(repoRoot, { recursive: true, force: true }); + } + }); + + it("preserves Ruff tool provenance on mixed Ruff and type runs", async () => { + const repoRoot = mkdtempSync(join(tmpdir(), "opcore-python-ruff-types-context-")); + try { + writePassingPythonProtocolShim(repoRoot); + writeToolShim( + repoRoot, + "ruff", + [ + "#!/bin/sh", + "if [ \"$1\" = \"--version\" ]; then echo 'ruff 0.6.9'; exit 0; fi", + "if [ \"$1\" = \"check\" ]; then printf '%s\\n' '[]'; exit 0; fi", + "exit 2", + "" + ].join("\n") + ); + writeToolShim( + repoRoot, + "mypy", + [ + "#!/bin/sh", + "if [ \"$1\" = \"--version\" ]; then echo 'mypy 1.8.0'; exit 0; fi", + "exit 0", + "" + ].join("\n") + ); + + const result = await runner({ + files: { + "app.py": "value: int = 1\n", + "pyproject.toml": "[project]\nname='fixture'\n[tool.mypy]\n", + "ruff.toml": "[lint]\nselect = ['F401']\n" + }, + checks: createPythonValidationChecks({ repoRoot, env: { PATH: "" } }) + }).runValidation( + request({ + repo: { repoRoot }, + checks: [PYTHON_RUFF_LINT_CHECK_ID, PYTHON_TYPES_CHECK_ID], + scope: { kind: "files", files: ["app.py"] } + }) + ); + + assert.equal(result.status, "passed"); + assert.equal(result.pythonProjectContexts.length, 1); + const tools = result.pythonProjectContexts[0].tools; + assert.equal( + tools.some((tool) => tool.tool === "ruff" && tool.available === true && tool.configFile === "ruff.toml"), + true, + JSON.stringify(tools, null, 2) + ); + assert.equal(tools.some((tool) => tool.tool === "mypy" && tool.available === true), true); + } finally { + rmSync(repoRoot, { recursive: true, force: true }); + } + }); + + it("preserves Ruff tool provenance when policy defaults enable mixed Ruff and type runs", async () => { + const repoRoot = mkdtempSync(join(tmpdir(), "opcore-python-ruff-types-default-context-")); + try { + writePassingPythonProtocolShim(repoRoot); + writeToolShim( + repoRoot, + "ruff", + [ + "#!/bin/sh", + "if [ \"$1\" = \"--version\" ]; then echo 'ruff 0.6.9'; exit 0; fi", + "if [ \"$1\" = \"check\" ]; then printf '%s\\n' '[]'; exit 0; fi", + "exit 2", + "" + ].join("\n") + ); + writeToolShim( + repoRoot, + "mypy", + [ + "#!/bin/sh", + "if [ \"$1\" = \"--version\" ]; then echo 'mypy 1.8.0'; exit 0; fi", + "exit 0", + "" + ].join("\n") + ); + const checks = createPythonValidationChecks({ repoRoot, env: { PATH: "" } }).map((check) => + check.id === PYTHON_RUFF_LINT_CHECK_ID || check.id === PYTHON_TYPES_CHECK_ID + ? { ...check, defaultScopes: check.supportedScopes } + : { ...check, defaultScopes: [] } + ); + + const result = await createValidationRunner({ + workspace: workspace({ + repoRoot, + files: { + "app.py": "value: int = 1\n", + "pyproject.toml": "[project]\nname='fixture'\n[tool.mypy]\n", + "ruff.toml": "[lint]\nselect = ['F401']\n" + } + }), + checks + }).runValidation( + request({ + repo: { repoRoot }, + checks: undefined, + scope: { kind: "files", files: ["app.py"] } + }) + ); + + assert.equal(result.status, "passed"); + assert.equal(result.pythonProjectContexts.length, 1); + const tools = result.pythonProjectContexts[0].tools; + assert.equal( + tools.some((tool) => tool.tool === "ruff" && tool.available === true && tool.configFile === "ruff.toml"), + true, + JSON.stringify(tools, null, 2) + ); + assert.equal(tools.some((tool) => tool.tool === "mypy" && tool.available === true), true); + } finally { + rmSync(repoRoot, { recursive: true, force: true }); + } + }); + + it("retains earlier Ruff lint receipts when a later project fails", async () => { + const repoRoot = mkdtempSync(join(tmpdir(), "opcore-python-ruff-lint-multi-failure-")); + try { + writePassingPythonProtocolShim(join(repoRoot, "apps/one")); + writePassingPythonProtocolShim(join(repoRoot, "apps/two")); + writeToolShim( + join(repoRoot, "apps/one"), + "ruff", + [ + "#!/bin/sh", + "if [ \"$1\" = \"--version\" ]; then echo 'ruff 0.6.9'; exit 0; fi", + "if [ \"$1\" = \"check\" ]; then printf '%s\\n' '[]'; exit 0; fi", + "exit 2", + "" + ].join("\n") + ); + writeToolShim( + join(repoRoot, "apps/two"), + "ruff", + [ + "#!/bin/sh", + "if [ \"$1\" = \"--version\" ]; then echo 'ruff 0.6.9'; exit 0; fi", + "if [ \"$1\" = \"check\" ]; then printf '%s\\n' 'not-json'; exit 1; fi", + "exit 2", + "" + ].join("\n") + ); + + const result = await runner({ + files: { + "apps/one/pyproject.toml": "[project]\nname='one'\n[tool.ruff]\n", + "apps/one/pkg/app.py": "value = 1\n", + "apps/two/pyproject.toml": "[project]\nname='two'\n[tool.ruff]\n", + "apps/two/pkg/app.py": "value = 2\n" + }, + checks: createPythonValidationChecks({ repoRoot, env: { PATH: "" } }) + }).runValidation( + request({ + repo: { repoRoot }, + checks: [PYTHON_RUFF_LINT_CHECK_ID], + scope: { + kind: "files", + files: ["apps/one/pkg/app.py", "apps/two/pkg/app.py"] + } + }) + ); + + assert.equal(result.status, "infrastructure_failure"); + const receipts = result.manifest.runs[0].pythonCapabilityRuns ?? []; + assert.equal(receipts.length, 2); + assert.deepEqual( + receipts.map((receipt) => ({ cwd: receipt.cwd, state: receipt.state, exitCode: receipt.exitCode })), + [ + { cwd: "apps/one", state: "passed", exitCode: 0 }, + { cwd: "apps/two", state: "tool_failure", exitCode: 1 } + ] + ); + } finally { + rmSync(repoRoot, { recursive: true, force: true }); + } + }); + + it("retains earlier Ruff diagnostics and attempts remaining projects after a mixed-project failure", async () => { + const repoRoot = mkdtempSync(join(tmpdir(), "opcore-python-ruff-lint-complete-aggregation-")); + const finalMarker = join(repoRoot, "apps/c/ruff-executed"); + try { + for (const project of ["a", "b", "c"]) writePassingPythonProtocolShim(join(repoRoot, `apps/${project}`)); + writeToolShim( + join(repoRoot, "apps/a"), + "ruff", + [ + "#!/bin/sh", + "if [ \"$1\" = \"--version\" ]; then echo 'ruff 0.6.9'; exit 0; fi", + "printf '%s\\n' '[{\"code\":\"F401\",\"filename\":\"pkg/app.py\",\"location\":{\"row\":1,\"column\":1},\"message\":\"unused import\"}]'", + "exit 1", + "" + ].join("\n") + ); + writeToolShim( + join(repoRoot, "apps/b"), + "ruff", + [ + "#!/bin/sh", + "if [ \"$1\" = \"--version\" ]; then echo 'ruff 0.6.9'; exit 0; fi", + "printf '%s\\n' 'not-json'", + "exit 1", + "" + ].join("\n") + ); + writeToolShim( + join(repoRoot, "apps/c"), + "ruff", + [ + "#!/bin/sh", + "if [ \"$1\" = \"--version\" ]; then echo 'ruff 0.6.9'; exit 0; fi", + `printf executed > '${finalMarker}'`, + "printf '%s\\n' '[]'", + "exit 0", + "" + ].join("\n") + ); + const files = {}; + for (const project of ["a", "b", "c"]) { + files[`apps/${project}/pyproject.toml`] = `[project]\nname='${project}'\n[tool.ruff]\n`; + files[`apps/${project}/pkg/app.py`] = "import os\n"; + } + + const result = await runner({ + files, + checks: createPythonValidationChecks({ repoRoot, env: { PATH: "" } }) + }).runValidation(request({ + repo: { repoRoot }, + checks: [PYTHON_RUFF_LINT_CHECK_ID], + scope: { + kind: "files", + files: ["apps/a/pkg/app.py", "apps/b/pkg/app.py", "apps/c/pkg/app.py"] + } + })); + + assert.equal(result.status, "infrastructure_failure", JSON.stringify(result, null, 2)); + assert.deepEqual( + result.diagnostics.map((diagnostic) => [diagnostic.code, diagnostic.path]), + [ + ["PY_RUFF_LINT_TOOL_FAILED", undefined], + ["PY_RUFF_LINT_F401", "apps/a/pkg/app.py"] + ] + ); + assert.deepEqual( + result.manifest.runs[0].pythonCapabilityRuns?.map((receipt) => [receipt.cwd, receipt.state]), + [["apps/a", "findings"], ["apps/b", "tool_failure"], ["apps/c", "passed"]] + ); + assert.equal(existsSync(finalMarker), true); + } finally { + rmSync(repoRoot, { recursive: true, force: true }); + } + }); + + it("continues healthy Ruff projects and binds unavailable receipts to each exact after-state", async () => { + const repoRoot = mkdtempSync(join(tmpdir(), "opcore-python-ruff-lint-unavailable-project-")); + const healthyMarker = join(repoRoot, "apps/b/ruff-executed"); + try { + for (const project of ["a", "b"]) writePassingPythonProtocolShim(join(repoRoot, `apps/${project}`)); + writeToolShim( + join(repoRoot, "apps/b"), + "ruff", + [ + "#!/bin/sh", + "if [ \"$1\" = \"--version\" ]; then echo 'ruff 0.6.9'; exit 0; fi", + `printf executed > '${healthyMarker}'`, + "printf '%s\\n' '[]'", + "exit 0", + "" + ].join("\n") + ); + const result = await runner({ + files: { + "apps/a/pyproject.toml": "[project]\nname='a'\n[tool.ruff]\n", + "apps/a/pkg/app.py": "value = 1\n", + "apps/b/pyproject.toml": "[project]\nname='b'\n[tool.ruff]\n", + "apps/b/pkg/app.py": "value = 2\n" + }, + checks: createPythonValidationChecks({ repoRoot, env: { PATH: "" } }) + }).runValidation(request({ + repo: { repoRoot }, + checks: [PYTHON_RUFF_LINT_CHECK_ID], + scope: { + kind: "files", + files: ["apps/a/pkg/app.py", "apps/b/pkg/app.py"] + } + })); + + assert.equal(result.status, "unsupported_request", JSON.stringify(result, null, 2)); + const receipts = result.manifest.runs[0].pythonCapabilityRuns ?? []; + assert.deepEqual( + receipts.map((receipt) => [receipt.cwd, receipt.state]), + [["apps/a", "tool_unavailable"], ["apps/b", "passed"]] + ); + const unavailable = receipts[0]; + assert.match(unavailable.projectKey, /^sha256:[a-f0-9]{64}$/u); + assert.match(unavailable.contextFingerprint, /^sha256:[a-f0-9]{64}$/u); + assert.match(unavailable.afterStateManifestFingerprint, /^sha256:[a-f0-9]{64}$/u); + assert.deepEqual(unavailable.sourcePaths, ["apps/a/pkg/app.py"]); + assert.deepEqual(unavailable.configPaths, ["apps/a/pyproject.toml"]); + assert.equal(existsSync(healthyMarker), true); + } finally { + rmSync(repoRoot, { recursive: true, force: true }); + } + }); + + it("retains earlier Ruff format receipts when a later project fails", async () => { + const repoRoot = mkdtempSync(join(tmpdir(), "opcore-python-ruff-format-multi-failure-")); + try { + writePassingPythonProtocolShim(join(repoRoot, "apps/one")); + writePassingPythonProtocolShim(join(repoRoot, "apps/two")); + writeToolShim( + join(repoRoot, "apps/one"), + "ruff", + [ + "#!/bin/sh", + "if [ \"$1\" = \"--version\" ]; then echo 'ruff 0.6.9'; exit 0; fi", + "if [ \"$1\" = \"format\" ]; then exit 0; fi", + "exit 2", + "" + ].join("\n") + ); + writeToolShim( + join(repoRoot, "apps/two"), + "ruff", + [ + "#!/bin/sh", + "if [ \"$1\" = \"--version\" ]; then echo 'ruff 0.6.9'; exit 0; fi", + "if [ \"$1\" = \"format\" ]; then echo 'internal error' >&2; exit 2; fi", + "exit 2", + "" + ].join("\n") + ); + + const result = await runner({ + files: { + "apps/one/pyproject.toml": "[project]\nname='one'\n[tool.ruff]\n", + "apps/one/pkg/app.py": "value = 1\n", + "apps/two/pyproject.toml": "[project]\nname='two'\n[tool.ruff]\n", + "apps/two/pkg/app.py": "value = 2\n" + }, + checks: createPythonValidationChecks({ repoRoot, env: { PATH: "" } }) + }).runValidation( + request({ + repo: { repoRoot }, + checks: [PYTHON_RUFF_FORMAT_CHECK_ID], + scope: { + kind: "files", + files: ["apps/one/pkg/app.py", "apps/two/pkg/app.py"] + } + }) + ); + + assert.equal(result.status, "infrastructure_failure"); + const receipts = result.manifest.runs[0].pythonCapabilityRuns ?? []; + assert.equal(receipts.length, 2); + assert.deepEqual( + receipts.map((receipt) => ({ cwd: receipt.cwd, state: receipt.state, exitCode: receipt.exitCode })), + [ + { cwd: "apps/one", state: "passed", exitCode: 0 }, + { cwd: "apps/two", state: "tool_failure", exitCode: 2 } + ] + ); + } 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" }, @@ -3453,7 +5609,7 @@ describe("Python project-context resolver", () => { } }); - it("resolves each validation file view once across Python checks", async () => { + it("resolves each validation file view once per required Python tool selection", async () => { const files = { "pyproject.toml": "[project]\nname='fixture'\n", "app.py": "VALUE = 1\n" @@ -3478,7 +5634,7 @@ describe("Python project-context resolver", () => { })); assert.equal(result.pythonProjectContexts.length, 1); - assert.equal(listCalls, 1); + assert.equal(listCalls, 2); }); it("resolves each current file view independently of earlier context evidence", async () => { @@ -3765,7 +5921,7 @@ describe("Python project-context resolver", () => { assert.deepEqual(context.layout.kinds, ["flat"]); }); - it("matches overlay after-state identity with the equivalent materialized tree", async () => { + it("preserves overlay after-state project identity without probing inactive Ruff tools", async () => { const before = { "pyproject.toml": "[project]\nname='fixture'\nrequires-python='>=3.11'\n", "app.py": "VALUE = 1\n" @@ -3794,12 +5950,13 @@ describe("Python project-context resolver", () => { }); 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].projectRoot, materialized.projectRoot); assert.equal(validationResult.pythonProjectContexts[0].outcome, materialized.outcome); + assert.deepEqual(validationResult.pythonProjectContexts[0].tools, []); }); - it("removes deleted project markers from overlay discovery and matches materialized identity", async () => { + it("removes deleted project markers from overlay discovery without probing inactive Ruff tools", async () => { const before = { "pyproject.toml": "[project]\nname='root'\n", "services/api/pyproject.toml": "[project]\nname='api'\n", @@ -3828,8 +5985,9 @@ describe("Python project-context resolver", () => { }); const overlay = result.pythonProjectContexts[0]; assert.equal(overlay.projectRoot, "."); - assert.equal(overlay.contextFingerprint, materialized.contextFingerprint, JSON.stringify({ overlay, materialized }, null, 2)); + assert.equal(overlay.projectKey, materialized.projectKey, JSON.stringify({ overlay, materialized }, null, 2)); assert.equal(overlay.outcome, materialized.outcome); + assert.deepEqual(overlay.tools, []); }); }); @@ -4050,7 +6208,9 @@ async function processExited(pid) { } function materializedMypyWorkspaces() { - return readdirSync(tmpdir()).filter((entry) => entry.startsWith("opcore-python-types-workspace-")).sort(); + return readdirSync(tmpdir()) + .filter((entry) => entry.startsWith(`opcore-python-types-workspace-${process.pid}-`)) + .sort(); } function writeToolShim(repoRoot, name, content) { diff --git a/tests/validation-runner.test.mjs b/tests/validation-runner.test.mjs index 62d2a33..6956751 100644 --- a/tests/validation-runner.test.mjs +++ b/tests/validation-runner.test.mjs @@ -251,6 +251,134 @@ describe("validation runner", () => { ); }); + it("finalizes inactive capability receipts after provider, infrastructure, and fail-fast exits", async () => { + const inactiveRuff = check("python.ruff-lint", { + defaultSeverity: "warning", + defaultScopes: [], + inactiveResult: (_context, state) => ({ + diagnostics: [], + pythonCapabilityRuns: [{ + schemaId: "opcore.python.validation-capability-run", + schemaVersion: 1, + checkId: "python.ruff-lint", + capability: "ruff_lint", + state, + durationMs: 0, + diagnosticCount: 0 + }] + }) + }); + const assertInactiveReceipt = (result, label) => { + const run = result.manifest.runs.find((entry) => entry.checkId === "python.ruff-lint"); + assert.equal(run?.pythonCapabilityRuns?.[0]?.state, "not_applicable", label); + }; + + const providerFailure = await runner( + [ + check("imports.no-cycles", { + requiresGraph: true, + graphRequirements: () => [{ operation: "factQuery", selector: { kind: "nodes" } }] + }), + inactiveRuff + ], + { + graphProviderClient: graphClient({ + status: (validationRequest) => availableStatus(validationRequest.graph.mode, validationRequest.repo), + factQuery: () => ({ status: graphFailure("error", "query_failed", "required") }) + }) + } + ).runValidation(request({ + checks: ["imports.no-cycles"], + graph: { mode: "required", provider: "opcore-graph" } + })); + assert.equal(providerFailure.status, "provider_failure"); + assertInactiveReceipt(providerFailure, "provider"); + + const infrastructureFailure = await runner([ + check("fixture.infrastructure", { + run: () => { + throw new Error("fixture infrastructure failure"); + } + }), + inactiveRuff + ]).runValidation(request({ checks: ["fixture.infrastructure"] })); + assert.equal(infrastructureFailure.status, "infrastructure_failure"); + assertInactiveReceipt(infrastructureFailure, "infrastructure"); + + const failFast = await createValidationRunner({ + workspace: testWorkspace(), + failFast: true, + checks: [ + check("fixture.policy", { + diagnostics: [{ + category: "policy", + severity: "error", + message: "fixture policy finding", + path: "src/index.ts" + }] + }), + inactiveRuff + ] + }).runValidation(request({ checks: ["fixture.policy"] })); + assert.equal(failFast.status, "policy_failure"); + assertInactiveReceipt(failFast, "fail-fast"); + }); + + it("does not synthesize inactive runs for globally selected checks during incremental introduced validation", async () => { + const selectedCheckIds = ["python.ruff-lint", "python.ruff-format"]; + const observedSelections = []; + const ruffCheck = (id, capability) => check(id, { + defaultSeverity: "warning", + defaultScopes: [], + inactiveResult: (_context, state) => ({ + diagnostics: [], + pythonCapabilityRuns: [{ + schemaId: "opcore.python.validation-capability-run", + schemaVersion: 1, + checkId: id, + capability, + state, + durationMs: 0, + diagnosticCount: 0 + }] + }), + run: (context) => { + observedSelections.push([id, ...context.selectedCheckIds]); + return { diagnostics: [] }; + } + }); + const result = await createValidationRunner({ + workspace: testWorkspace(), + onCheckComplete: () => {}, + checks: [ + ruffCheck("python.ruff-lint", "ruff_lint"), + ruffCheck("python.ruff-format", "ruff_format") + ] + }).runValidation(request({ + checks: selectedCheckIds, + reportMode: "introduced", + overlays: [{ path: "src/index.ts", action: "write", content: "export const value = 'introduced';" }] + })); + + assert.equal(result.status, "passed", JSON.stringify(result, null, 2)); + assert.deepEqual( + result.manifest.runs.map((run) => [run.checkId, run.pythonCapabilityRuns]), + [ + ["python.ruff-lint", undefined], + ["python.ruff-format", undefined] + ] + ); + assert.deepEqual( + observedSelections, + [ + ["python.ruff-lint", ...selectedCheckIds], + ["python.ruff-lint", ...selectedCheckIds], + ["python.ruff-format", ...selectedCheckIds], + ["python.ruff-format", ...selectedCheckIds] + ] + ); + }); + it("streams each completed check result in order", async () => { const events = []; const result = await createValidationRunner({