From 4ad7f8cf253636d4c5d0a24e4268ed9931a50c54 Mon Sep 17 00:00:00 2001 From: Tom Dupuis <60640908+tomdps@users.noreply.github.com> Date: Wed, 15 Jul 2026 12:23:06 -0700 Subject: [PATCH 1/5] feat: resolve Python project contexts canonically --- AGENTS.md | 1 + CLAUDE.md | 1 + package-lock.json | 30 +- package.json | 1 + packages/asp-provider/src/manifest.ts | 12 + packages/asp-provider/src/mapping.ts | 65 +- .../src/validation-composition.ts | 20 +- packages/asp-provider/src/workspace.ts | 25 +- .../schemas/opcore-contracts.schema.json | 223 +++ packages/contracts/src/index.ts | 344 +++++ .../descriptors/opcore.managed-tool.json | 11 + .../flatpkg/__init__.py.fixture | 1 + .../project-context/requirements.txt.fixture | 1 + .../project-context/resolver-matrix.json | 348 +++++ .../services/api/mypy.ini.fixture | 2 + .../services/api/pyproject.toml.fixture | 13 + .../services/api/pyrightconfig.json.fixture | 1 + .../services/api/pytest.ini.fixture | 2 + .../services/api/ruff.toml.fixture | 1 + .../services/api/src/acme/api.py.fixture | 2 + .../services/api/uv.lock.fixture | 1 + .../services/conflict/app.py.fixture | 1 + .../services/conflict/poetry.lock.fixture | 1 + .../services/conflict/pyproject.toml.fixture | 6 + .../services/conflict/uv.lock.fixture | 1 + .../services/incompatible/app.py.fixture | 1 + .../incompatible/pyproject.toml.fixture | 3 + .../services/invalid/app.py.fixture | 1 + .../services/invalid/pyproject.toml.fixture | 2 + .../services/mixed/helper.py.fixture | 1 + .../services/mixed/pyproject.toml.fixture | 3 + .../services/mixed/script.py.fixture | 3 + .../mixed/src/pkg/__init__.py.fixture | 1 + .../services/pdm/pdm.lock.fixture | 2 + .../services/pdm/pyproject.toml.fixture | 6 + .../services/pdm/src/pdm_pkg/app.py.fixture | 1 + .../services/poetry/pkg/__init__.py.fixture | 1 + .../services/poetry/poetry.lock.fixture | 1 + .../services/poetry/pyproject.toml.fixture | 6 + .../services/stubs/Pipfile.fixture | 5 + .../services/stubs/Pipfile.lock.fixture | 1 + .../stubs/src/sdk/__init__.pyi.fixture | 1 + packages/opcore/package.json | 2 + packages/opcore/src/advanced/descriptor.ts | 6 + packages/opcore/src/advanced/router.ts | 4 +- .../src/advanced/validation-composition.ts | 7 +- packages/opcore/src/doctor.ts | 7 +- packages/opcore/src/init.ts | 162 +-- packages/opcore/src/repo-validation-policy.ts | 19 +- packages/opcore/src/reporting.ts | 4 +- .../opcore/src/scan-validation-preview.ts | 3 + packages/opcore/src/scan.ts | 8 +- packages/opcore/src/serve-telemetry.ts | 39 +- packages/opcore/src/status-state.ts | 24 +- packages/opcore/src/status-validation.ts | 5 +- packages/opcore/src/status.ts | 4 +- packages/opcore/src/validation-composition.ts | 10 +- packages/validation-policy/src/config.ts | 12 + packages/validation-policy/src/factory.ts | 10 +- packages/validation-policy/src/types.ts | 4 + packages/validation-python/README.md | 8 + packages/validation-python/package.json | 3 +- .../src/compiler-protocol.ts | 13 +- .../src/environment-resolution.ts | 767 ++++++++++ .../src/import-graph-check.ts | 7 +- packages/validation-python/src/index.ts | 64 +- .../validation-python/src/node-shims.d.ts | 8 + .../validation-python/src/project-context.ts | 220 +++ .../src/project-discovery.ts | 130 ++ .../src/project-fingerprint.ts | 79 ++ .../src/project-workspace.ts | 167 +++ .../validation-python/src/source-files.ts | 98 +- .../validation-python/src/static-config.ts | 323 +++++ .../validation-python/src/syntax-check.ts | 103 +- .../src/toolchain-resolver.ts | 265 ---- packages/validation-python/src/toolchain.ts | 113 +- packages/validation-python/src/type-check.ts | 334 +++-- .../src/version-constraint.ts | 102 ++ packages/validation/src/aggregation.ts | 11 +- packages/validation/src/registry.ts | 2 + packages/validation/src/runner.ts | 28 +- scripts/check-python-resolver-matrix.mjs | 333 +++++ scripts/license-report.mjs | 11 +- scripts/release-package-dirs.mjs | 1 + scripts/stage-opcore-bundle.mjs | 18 +- scripts/write-asp-provider-manifest.mjs | 12 +- tests/asp-provider.test.mjs | 129 +- tests/contracts.test.mjs | 129 ++ tests/fixtures/package-packlists.json | 46 +- tests/gate-negative-fixtures.test.mjs | 4 +- tests/import-boundaries.test.mjs | 2 +- tests/installed-bins.test.mjs | 234 +++- tests/opcore-facade.test.mjs | 5 +- tests/package-identity.test.mjs | 1 + tests/schema-contracts.test.mjs | 78 ++ tests/validation-python.test.mjs | 1247 +++++++++++++++-- 96 files changed, 5755 insertions(+), 823 deletions(-) create mode 100644 packages/fixtures/validation-python/project-context/flatpkg/__init__.py.fixture create mode 100644 packages/fixtures/validation-python/project-context/requirements.txt.fixture create mode 100644 packages/fixtures/validation-python/project-context/resolver-matrix.json create mode 100644 packages/fixtures/validation-python/project-context/services/api/mypy.ini.fixture create mode 100644 packages/fixtures/validation-python/project-context/services/api/pyproject.toml.fixture create mode 100644 packages/fixtures/validation-python/project-context/services/api/pyrightconfig.json.fixture create mode 100644 packages/fixtures/validation-python/project-context/services/api/pytest.ini.fixture create mode 100644 packages/fixtures/validation-python/project-context/services/api/ruff.toml.fixture create mode 100644 packages/fixtures/validation-python/project-context/services/api/src/acme/api.py.fixture create mode 100644 packages/fixtures/validation-python/project-context/services/api/uv.lock.fixture create mode 100644 packages/fixtures/validation-python/project-context/services/conflict/app.py.fixture create mode 100644 packages/fixtures/validation-python/project-context/services/conflict/poetry.lock.fixture create mode 100644 packages/fixtures/validation-python/project-context/services/conflict/pyproject.toml.fixture create mode 100644 packages/fixtures/validation-python/project-context/services/conflict/uv.lock.fixture create mode 100644 packages/fixtures/validation-python/project-context/services/incompatible/app.py.fixture create mode 100644 packages/fixtures/validation-python/project-context/services/incompatible/pyproject.toml.fixture create mode 100644 packages/fixtures/validation-python/project-context/services/invalid/app.py.fixture create mode 100644 packages/fixtures/validation-python/project-context/services/invalid/pyproject.toml.fixture create mode 100644 packages/fixtures/validation-python/project-context/services/mixed/helper.py.fixture create mode 100644 packages/fixtures/validation-python/project-context/services/mixed/pyproject.toml.fixture create mode 100644 packages/fixtures/validation-python/project-context/services/mixed/script.py.fixture create mode 100644 packages/fixtures/validation-python/project-context/services/mixed/src/pkg/__init__.py.fixture create mode 100644 packages/fixtures/validation-python/project-context/services/pdm/pdm.lock.fixture create mode 100644 packages/fixtures/validation-python/project-context/services/pdm/pyproject.toml.fixture create mode 100644 packages/fixtures/validation-python/project-context/services/pdm/src/pdm_pkg/app.py.fixture create mode 100644 packages/fixtures/validation-python/project-context/services/poetry/pkg/__init__.py.fixture create mode 100644 packages/fixtures/validation-python/project-context/services/poetry/poetry.lock.fixture create mode 100644 packages/fixtures/validation-python/project-context/services/poetry/pyproject.toml.fixture create mode 100644 packages/fixtures/validation-python/project-context/services/stubs/Pipfile.fixture create mode 100644 packages/fixtures/validation-python/project-context/services/stubs/Pipfile.lock.fixture create mode 100644 packages/fixtures/validation-python/project-context/services/stubs/src/sdk/__init__.pyi.fixture create mode 100644 packages/validation-python/src/environment-resolution.ts create mode 100644 packages/validation-python/src/project-context.ts create mode 100644 packages/validation-python/src/project-discovery.ts create mode 100644 packages/validation-python/src/project-fingerprint.ts create mode 100644 packages/validation-python/src/project-workspace.ts create mode 100644 packages/validation-python/src/static-config.ts delete mode 100644 packages/validation-python/src/toolchain-resolver.ts create mode 100644 packages/validation-python/src/version-constraint.ts create mode 100644 scripts/check-python-resolver-matrix.mjs diff --git a/AGENTS.md b/AGENTS.md index eaf5466..6366542 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -90,6 +90,7 @@ Opcore is the code-intelligence and robustness monorepo for graph context, edit - #16 Python generated/private/dependency roots are excluded from discovery and status census: `.venv`, `venv`, `env`, `__pycache__`, `.eggs`, `build`, `.tox`, `.mypy_cache`, `.pytest_cache`, `.ruff_cache`, `site-packages`, `*.egg-info`, and `*.dist-info` - WHY: dependency/cache artifacts must not create graph freshness or coverage evidence. - #17 Python export metadata is best-effort: `__all__` wins when present, otherwise the leading-underscore convention marks module-level public names; file `exports[]` entries must include policy and supportedSymbol - WHY: Python has no enforced export boundary. - #244 `python.syntax` resolves one concrete interpreter through the Python project resolver, executes that exact interpreter with an isolated versioned JSON compile protocol over sorted `.py`/`.pyi` after-state content, and reports ranged/provenanced compiler diagnostics plus fine-grained outcomes. Parser-success overrides and hand-written grammar heuristics are forbidden; malformed output, nonzero exits, signals, and timeouts fail closed - WHY: syntax truth, target-version truth, and tool provenance must come from the same compiler invocation without mutating the source repo. +- #246 makes `@the-open-engine/opcore-validation-python` the sole dynamic owner of `opcore.python.project-context.v1`: every Python target resolves against its nearest project boundary through an injected read/list/exists/realpath workspace view, smol-toml AST config/build metadata, exact interpreter/tool/build probes, and after-state content. Missing realpath evidence is ambiguous, deleted overlays cannot remain discovery markers, and declared constraints never become invented exact versions. Validation, status, scan, init/install preview, metrics, ASP, and installed execution must reuse the resulting project key, context fingerprint, outcome, and provenance; ASP workspace/config reads must remain host-callback-only. Static descriptors advertise only the schema, outcome vocabulary, read-only behavior, and no-install guarantee - WHY: root-scoped and duplicate project/environment discovery validates nested monorepo files with the wrong interpreter and makes surfaces disagree. - #19 requires graph status to preserve real WAL checkpoint evidence from the latest pipeline summary and release gates to fail missing/fabricated WAL evidence - WHY: freshness and checkpoint pressure must remain host-visible provider facts. - #19 treats `opcore graph serve` as the stdio/MCP hot-query replacement, not a Unix socket, with parallel independent serve sessions as the supported concurrency evidence. - #19 keeps current external CRG receipts as non-implementation compatibility evidence only - WHY: old CRG remains a guardrail until downstream cutover issues consume the Opcore proof. diff --git a/CLAUDE.md b/CLAUDE.md index eaf5466..6366542 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -90,6 +90,7 @@ Opcore is the code-intelligence and robustness monorepo for graph context, edit - #16 Python generated/private/dependency roots are excluded from discovery and status census: `.venv`, `venv`, `env`, `__pycache__`, `.eggs`, `build`, `.tox`, `.mypy_cache`, `.pytest_cache`, `.ruff_cache`, `site-packages`, `*.egg-info`, and `*.dist-info` - WHY: dependency/cache artifacts must not create graph freshness or coverage evidence. - #17 Python export metadata is best-effort: `__all__` wins when present, otherwise the leading-underscore convention marks module-level public names; file `exports[]` entries must include policy and supportedSymbol - WHY: Python has no enforced export boundary. - #244 `python.syntax` resolves one concrete interpreter through the Python project resolver, executes that exact interpreter with an isolated versioned JSON compile protocol over sorted `.py`/`.pyi` after-state content, and reports ranged/provenanced compiler diagnostics plus fine-grained outcomes. Parser-success overrides and hand-written grammar heuristics are forbidden; malformed output, nonzero exits, signals, and timeouts fail closed - WHY: syntax truth, target-version truth, and tool provenance must come from the same compiler invocation without mutating the source repo. +- #246 makes `@the-open-engine/opcore-validation-python` the sole dynamic owner of `opcore.python.project-context.v1`: every Python target resolves against its nearest project boundary through an injected read/list/exists/realpath workspace view, smol-toml AST config/build metadata, exact interpreter/tool/build probes, and after-state content. Missing realpath evidence is ambiguous, deleted overlays cannot remain discovery markers, and declared constraints never become invented exact versions. Validation, status, scan, init/install preview, metrics, ASP, and installed execution must reuse the resulting project key, context fingerprint, outcome, and provenance; ASP workspace/config reads must remain host-callback-only. Static descriptors advertise only the schema, outcome vocabulary, read-only behavior, and no-install guarantee - WHY: root-scoped and duplicate project/environment discovery validates nested monorepo files with the wrong interpreter and makes surfaces disagree. - #19 requires graph status to preserve real WAL checkpoint evidence from the latest pipeline summary and release gates to fail missing/fabricated WAL evidence - WHY: freshness and checkpoint pressure must remain host-visible provider facts. - #19 treats `opcore graph serve` as the stdio/MCP hot-query replacement, not a Unix socket, with parallel independent serve sessions as the supported concurrency evidence. - #19 keeps current external CRG receipts as non-implementation compatibility evidence only - WHY: old CRG remains a guardrail until downstream cutover issues consume the Opcore proof. diff --git a/package-lock.json b/package-lock.json index 4c6985e..ffce2d5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1753,6 +1753,7 @@ "path-browserify", "picomatch", "semver", + "smol-toml", "tinyglobby", "ts-api-utils", "ts-morph", @@ -1773,6 +1774,7 @@ "@the-open-engine/opcore-validation-rust": "0.2.0", "@the-open-engine/opcore-validation-typescript": "0.2.0", "@typescript-eslint/typescript-estree": "^8.62.0", + "smol-toml": "1.6.0", "ts-morph": "^28.0.0", "typescript": "^5.9.3" }, @@ -1825,6 +1827,19 @@ "linux" ] }, + "packages/opcore/node_modules/smol-toml": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.6.0.tgz", + "integrity": "sha512-4zemZi0HvTnYwLfrpk/CF9LOd9Lt87kAt50GnqhMpyF9U3poDAP2+iukq2bZsO/ufegbYehBkqINbsWxj4l4cw==", + "inBundle": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">= 18" + }, + "funding": { + "url": "https://github.com/sponsors/cyyynthia" + } + }, "packages/validation": { "name": "@the-open-engine/opcore-validation", "version": "0.2.0", @@ -1883,12 +1898,25 @@ "license": "MIT", "dependencies": { "@the-open-engine/opcore-contracts": "0.2.0", - "@the-open-engine/opcore-validation": "0.2.0" + "@the-open-engine/opcore-validation": "0.2.0", + "smol-toml": "1.6.0" }, "engines": { "node": ">=22" } }, + "packages/validation-python/node_modules/smol-toml": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.6.0.tgz", + "integrity": "sha512-4zemZi0HvTnYwLfrpk/CF9LOd9Lt87kAt50GnqhMpyF9U3poDAP2+iukq2bZsO/ufegbYehBkqINbsWxj4l4cw==", + "license": "BSD-3-Clause", + "engines": { + "node": ">= 18" + }, + "funding": { + "url": "https://github.com/sponsors/cyyynthia" + } + }, "packages/validation-rust": { "name": "@the-open-engine/opcore-validation-rust", "version": "0.2.0", diff --git a/package.json b/package.json index 3ce7140..f2c1d11 100644 --- a/package.json +++ b/package.json @@ -43,6 +43,7 @@ "lint": "node scripts/check-workspace.mjs", "pack:check": "node scripts/check-packages.mjs", "provenance:check": "node scripts/check-provenance.mjs", + "python:resolver-matrix": "node scripts/check-python-resolver-matrix.mjs", "release:hygiene": "node scripts/check-release-hygiene.mjs", "rust:check": "npm run rust:fmt && npm run rust:clippy && npm run rust:test", "rust:clippy": "cargo clippy --all-targets --all-features -- -D warnings", diff --git a/packages/asp-provider/src/manifest.ts b/packages/asp-provider/src/manifest.ts index 2953850..9c068df 100644 --- a/packages/asp-provider/src/manifest.ts +++ b/packages/asp-provider/src/manifest.ts @@ -68,6 +68,12 @@ export type OpcoreAspProviderManifest = { protocolVersion: string; capabilityFamilies: readonly ["check"]; checks: readonly string[]; + pythonProjectContext: { + schemaId: "opcore.python.project-context.v1"; + outcomes: readonly ["resolved", "degraded", "unsupported", "ambiguous"]; + readOnly: true; + installs: false; + }; executable: { packageName: string; bin: "opcore-asp-provider"; @@ -102,6 +108,12 @@ export function createOpcoreAspProviderManifest(options: { packageRoot?: string; protocolVersion: ASP_PROTOCOL_VERSION, capabilityFamilies: ["check"], checks: defaultAspProviderValidationCheckIds, + pythonProjectContext: { + schemaId: "opcore.python.project-context.v1", + outcomes: ["resolved", "degraded", "unsupported", "ambiguous"], + readOnly: true, + installs: false + }, executable: { packageName: OPCORE_PROVIDER_PACKAGE, bin: "opcore-asp-provider", diff --git a/packages/asp-provider/src/mapping.ts b/packages/asp-provider/src/mapping.ts index b100a9f..59cff4c 100644 --- a/packages/asp-provider/src/mapping.ts +++ b/packages/asp-provider/src/mapping.ts @@ -61,7 +61,13 @@ export function initializeResult(params: InitializeParams): JsonObject { manifest: defaultAspProviderValidationManifest, scopes: ["changeset", "paths"], comparisons: [supportedComparison], - fixes: false + fixes: false, + pythonProjectContext: { + schemaId: "opcore.python.project-context.v1", + outcomes: ["resolved", "degraded", "unsupported", "ambiguous"], + readOnly: true, + installs: false + } } }, requestedPermissions: { @@ -119,7 +125,7 @@ export async function evaluateChangeset( }; }); validationResult = await timing.timeAsync("validation", () => - createAspProviderValidationRunner(workspace.workspace).runValidation(request) + createAspProviderValidationRunner(workspace.workspace, workspace.pythonWorkspace).runValidation(request) ); } catch (error) { const validationFailure = errorAssessment({ @@ -429,6 +435,19 @@ function validationCoverageDegradations(result: ValidationResult, selectedIds: r } { const degraded: ProviderCoverageDegradation[] = []; const unsupported: ProviderCoverageDegradation[] = []; + for (const context of result.pythonProjectContexts ?? []) { + if (context.outcome === "resolved") continue; + const reason = context.outcome === "unsupported" ? "unsupported" : "unavailable"; + const entry = { + source: providerSource, + reason, + requirement: `python-project-context:${context.target}`, + detail: context.reasons.map((item) => `${item.code}: ${item.message}`).join("; ") || + `Python project context ${context.outcome}` + }; + degraded.push(entry); + if (reason === "unsupported") unsupported.push(entry); + } for (const skipped of result.manifest?.skippedChecks ?? []) { const entry = { source: providerSource, @@ -538,13 +557,53 @@ function validationEvidence(result: ValidationResult): JsonObject[] { data.refusalCategory = result.refusal.category; data.refusalMessage = result.refusal.message; } - return [ + const evidence: JsonObject[] = [ { kind: "message", message: "Opcore validation result mapped to ASP Core check assessment.", data } ]; + for (const context of result.pythonProjectContexts ?? []) { + evidence.push({ + kind: "python_project_context", + message: `Canonical Python project context for ${context.target}.`, + data: { + schemaId: context.schemaId, + target: context.target, + projectRoot: context.projectRoot, + projectKey: context.projectKey, + contextFingerprint: context.contextFingerprint, + outcome: context.outcome, + interpreter: context.interpreter === undefined + ? null + : { + executable: context.interpreter.executable, + argv: [...context.interpreter.argv], + cwd: context.interpreter.cwd, + source: context.interpreter.source, + ...(context.interpreter.version === undefined ? {} : { version: context.interpreter.version }), + ...(context.interpreter.implementation === undefined ? {} : { implementation: context.interpreter.implementation }), + ...(context.interpreter.platform === undefined ? {} : { platform: context.interpreter.platform }), + ...(context.interpreter.architecture === undefined ? {} : { architecture: context.interpreter.architecture }), + ...(context.interpreter.abi === undefined ? {} : { abi: context.interpreter.abi }), + ...(context.interpreter.soabi === undefined ? {} : { soabi: context.interpreter.soabi }) + }, + tools: context.tools.map((tool) => ({ + tool: tool.tool, + available: tool.available, + executable: tool.executable, + argv: [...tool.argv], + cwd: tool.cwd, + source: tool.source, + ...(tool.configFile === undefined ? {} : { configFile: tool.configFile }), + ...(tool.version === undefined ? {} : { version: tool.version }) + })), + reasons: context.reasons.map((reason) => ({ code: reason.code, message: reason.message })) + } + }); + } + return evidence; } function providerMetadata(): Assessment["provider"] { diff --git a/packages/asp-provider/src/validation-composition.ts b/packages/asp-provider/src/validation-composition.ts index d5d707c..c052b39 100644 --- a/packages/asp-provider/src/validation-composition.ts +++ b/packages/asp-provider/src/validation-composition.ts @@ -29,7 +29,7 @@ import { } from "@the-open-engine/opcore-validation"; import { createBuiltInValidationChecks, - validationChecksForRepoPolicy + parseOpcoreRepoConfig } from "@the-open-engine/opcore-validation-policy"; import { graphProviderDetectChanges, @@ -39,6 +39,7 @@ import { graphProviderReviewContext, graphProviderStatus } from "@the-open-engine/opcore-graph"; +import type { PythonProjectWorkspace } from "@the-open-engine/opcore-validation-python"; const aspProviderPolicyOptions = { clone: false } as const; @@ -47,14 +48,15 @@ export const defaultAspProviderValidationChecks = createBuiltInValidationChecks( export const defaultAspProviderValidationCheckIds = defaultAspProviderValidationChecks.map((check) => check.id); export const defaultAspProviderValidationManifest = createValidationCheckManifest(defaultAspProviderValidationChecks); -export function createAspProviderValidationRunner(workspace: ValidationWorkspace): { +export function createAspProviderValidationRunner(workspace: ValidationWorkspace, pythonWorkspace: PythonProjectWorkspace): { runValidation(request: ValidationRequest): Promise; } { return { - runValidation(request) { + async runValidation(request) { + const config = await readHostConfig(workspace, request); return createValidationRunner({ workspace, - checks: validationChecksForAspRequest(request), + checks: createBuiltInValidationChecks(config, { ...aspProviderPolicyOptions, pythonWorkspace }), graphProviderClient: createAspValidationGraphProviderClient() }).runValidation(request); } @@ -67,10 +69,12 @@ export function selectedValidationChecks(checkIds?: readonly string[]): readonly return defaultAspProviderValidationChecks.filter((check) => requested.has(check.id)); } -function validationChecksForAspRequest(request: ValidationRequest): readonly ValidationCheckDefinition[] { - const repoRoot = request.repo.repoRoot; - if (repoRoot === undefined) return defaultAspProviderValidationChecks; - return validationChecksForRepoPolicy(repoRoot, aspProviderPolicyOptions); +async function readHostConfig(workspace: ValidationWorkspace, request: ValidationRequest) { + const overlay = request.overlays.find((entry) => entry.path === ".opcore/config"); + if (overlay?.action === "write") return parseOpcoreRepoConfig(overlay.content); + if (overlay?.action === "delete") return parseOpcoreRepoConfig(undefined); + const result = await workspace.readFile(".opcore/config"); + return parseOpcoreRepoConfig(result.status === "found" ? result.content : undefined); } function createAspValidationGraphProviderClient(): ValidationGraphProviderClient { diff --git a/packages/asp-provider/src/workspace.ts b/packages/asp-provider/src/workspace.ts index f672cc5..1853de7 100644 --- a/packages/asp-provider/src/workspace.ts +++ b/packages/asp-provider/src/workspace.ts @@ -1,10 +1,12 @@ import type { ValidationWorkspace, ValidationWorkspaceFileSet, ValidationWorkspaceReadFileResult } from "@the-open-engine/opcore-validation"; import { calculateValidationFileChecksum } from "@the-open-engine/opcore-validation"; +import type { PythonProjectWorkspace } from "@the-open-engine/opcore-validation-python"; import type { JsonRpcPeer } from "./json-rpc.js"; import type { Baseline, ChangeSet, EncodedBlob, InitializedParams, InitializeParams, InlineBlob, JsonObject } from "./protocol.js"; export type AspHostValidationWorkspace = { workspace: ValidationWorkspace; + pythonWorkspace: PythonProjectWorkspace; readBlobText(blobId: string): Promise; readInlineOrBlobText(blob: string | InlineBlob | undefined, label: string): Promise; checksumForBlob(blobId: string): Promise; @@ -76,6 +78,7 @@ export function createAspHostValidationWorkspace( const workspace: ValidationWorkspace = { readFile, + listFiles: async () => listRepoFiles(), listRepoFiles, listPackageFiles: async (_packageName, packageRoot) => { const files = await listRepoFiles(); @@ -88,8 +91,25 @@ export function createAspHostValidationWorkspace( } }; + const pythonWorkspace: PythonProjectWorkspace = { + read: async (path) => { + const result = await readFile(path); + return result.status === "found" ? result.content : undefined; + }, + list: async () => (await listTree()).filter(isFileLikeTreeEntry).map((entry) => entry.path).sort(), + exists: async (path) => (await readFile(path)).status === "found", + realpath: async (path) => { + let entry = entryByPath.get(path); + if (entry === undefined) entry = (await listTree([path])).find((candidate) => candidate.path === path); + if (entry === undefined || entry.kind === undefined) return { path, symlink: false, unavailable: true }; + return entry.kind === "file" ? { path, symlink: false } : { path, symlink: true }; + }, + executableExists: async () => false + }; + return { workspace, + pythonWorkspace, readBlobText, readInlineOrBlobText: async (blob, label) => { if (typeof blob === "string") return readBlobText(blob); @@ -110,7 +130,6 @@ function normalizeTreeEntry(value: unknown): TreeEntry | undefined { if (!value || typeof value !== "object") return undefined; const entry = value as { path?: unknown; blobId?: unknown; kind?: unknown }; if (typeof entry.path !== "string" || typeof entry.blobId !== "string") return undefined; - if (entry.kind !== undefined && entry.kind !== "file") return undefined; return { path: entry.path, blobId: entry.blobId, @@ -118,6 +137,10 @@ function normalizeTreeEntry(value: unknown): TreeEntry | undefined { }; } +function isFileLikeTreeEntry(entry: TreeEntry): boolean { + return entry.kind === undefined || entry.kind === "file" || entry.kind === "symlink"; +} + function decodeBlob(blob: EncodedBlob): string { if (typeof blob.content === "string") return blob.content; if (typeof blob.bytes !== "string") throw new Error(`Host blob ${blob.id} is missing bytes`); diff --git a/packages/contracts/schemas/opcore-contracts.schema.json b/packages/contracts/schemas/opcore-contracts.schema.json index 9d10568..8073d8d 100644 --- a/packages/contracts/schemas/opcore-contracts.schema.json +++ b/packages/contracts/schemas/opcore-contracts.schema.json @@ -2383,6 +2383,171 @@ } } }, + "PythonProjectContext": { + "type": "object", + "additionalProperties": false, + "required": [ + "schemaId", "schemaVersion", "target", "repositoryRoot", "projectRoot", "projectBoundary", + "sourceRoots", "layout", "evidence", "targetRuntime", "managers", "tools", "projectKey", + "contextFingerprint", "outcome", "reasons" + ], + "properties": { + "schemaId": { "const": "opcore.python.project-context.v1" }, + "schemaVersion": { "const": 1 }, + "target": { "allOf": [{ "$ref": "#/$defs/RepoRelativePath" }, { "pattern": "\\.pyi?$" }] }, + "repositoryRoot": { "type": "string", "minLength": 1 }, + "projectRoot": { "$ref": "#/$defs/PythonProjectRoot" }, + "projectBoundary": { "$ref": "#/$defs/PythonProjectRoot" }, + "sourceRoots": { + "type": "array", "minItems": 1, "uniqueItems": true, + "items": { "$ref": "#/$defs/PythonProjectRoot" } + }, + "layout": { + "type": "object", "additionalProperties": false, "required": ["kinds", "paths"], + "properties": { + "kinds": { + "type": "array", "minItems": 1, "uniqueItems": true, + "items": { "enum": ["flat", "src", "namespace", "stub", "package"] } + }, + "paths": { + "type": "array", "minItems": 1, "uniqueItems": true, + "items": { "$ref": "#/$defs/PythonProjectRoot" } + } + } + }, + "evidence": { + "type": "array", + "items": { + "type": "object", "additionalProperties": false, "required": ["path", "role"], + "properties": { + "path": { "$ref": "#/$defs/RepoRelativePath" }, + "role": { "enum": ["boundary", "config", "lock", "requirements", "build", "layout"] } + } + } + }, + "targetRuntime": { + "type": "object", "additionalProperties": false, "required": ["conflicts"], + "properties": { + "requiresPython": { "type": "string", "minLength": 1 }, + "version": { "type": "string", "minLength": 1 }, + "platform": { "type": "string", "minLength": 1 }, + "implementation": { "type": "string", "minLength": 1 }, + "conflicts": { "type": "array", "items": { "type": "string", "minLength": 1 } } + } + }, + "managers": { + "type": "array", + "items": { + "type": "object", "additionalProperties": false, "required": ["kind", "configFiles", "lockFiles"], + "properties": { + "kind": { "enum": ["pip", "uv", "poetry", "pdm", "pipenv"] }, + "configFiles": { "type": "array", "items": { "$ref": "#/$defs/RepoRelativePath" } }, + "lockFiles": { "type": "array", "items": { "$ref": "#/$defs/RepoRelativePath" } } + } + } + }, + "buildSystem": { + "type": "object", + "additionalProperties": false, + "required": ["configFile", "requires"], + "properties": { + "configFile": { "$ref": "#/$defs/RepoRelativePath" }, + "backend": { "type": "string", "minLength": 1 }, + "requires": { + "type": "array", + "items": { "type": "string", "minLength": 1 } + } + } + }, + "interpreter": { "$ref": "#/$defs/PythonInterpreterProvenance" }, + "tools": { + "type": "array", + "items": { + "allOf": [ + { "$ref": "#/$defs/PythonExecutableProvenance" }, + { + "type": "object", "required": ["tool", "available"], + "properties": { + "tool": { "enum": ["mypy", "pyright", "ruff", "pytest", "build"] }, + "available": { "type": "boolean" } + } + }, + { + "if": { "properties": { "available": { "const": true } }, "required": ["available"] }, + "then": { "required": ["version"] } + } + ] + } + }, + "projectKey": { "type": "string", "pattern": "^sha256:[a-f0-9]{64}$" }, + "contextFingerprint": { "type": "string", "pattern": "^sha256:[a-f0-9]{64}$" }, + "outcome": { "enum": ["resolved", "degraded", "unsupported", "ambiguous"] }, + "reasons": { + "type": "array", + "items": { + "type": "object", "additionalProperties": false, "required": ["code", "message"], + "properties": { + "code": { + "enum": [ + "missing_config", "invalid_config", "conflicting_managers", "conflicting_targets", + "interpreter_unavailable", "tool_unavailable", "probe_timeout", "probe_signal", + "probe_spawn_failure", "probe_exit_failure", "malformed_probe_output", "unsupported_target", + "unsupported_platform", "path_refused", "symlink_refused", "incompatible_interpreter", "ambiguous_path" + ] + }, + "message": { "type": "string", "minLength": 1 }, + "path": { "$ref": "#/$defs/RepoRelativePath" }, + "tool": { "type": "string", "minLength": 1 } + } + } + } + }, + "allOf": [ + { + "if": { "properties": { "outcome": { "const": "resolved" } }, "required": ["outcome"] }, + "then": { "properties": { "reasons": { "maxItems": 0 } } }, + "else": { "properties": { "reasons": { "minItems": 1 } } } + } + ] + }, + "PythonProjectRoot": { + "anyOf": [{ "const": "." }, { "$ref": "#/$defs/RepoRelativePath" }] + }, + "PythonExecutableProvenance": { + "type": "object", + "additionalProperties": false, + "required": ["executable", "argv", "cwd", "source"], + "properties": { + "executable": { "type": "string", "minLength": 1 }, + "argv": { "type": "array", "minItems": 1, "items": { "type": "string", "minLength": 1 } }, + "cwd": { "type": "string", "minLength": 1 }, + "source": { "enum": ["explicit_override", "active_environment", "project_local_environment", "manager_environment", "path"] }, + "version": { "type": "string", "pattern": "^[0-9]+(?:\\.[0-9]+)+(?:[-+._A-Za-z0-9]*)?$" }, + "configFile": { "$ref": "#/$defs/RepoRelativePath" }, + "tool": { "enum": ["mypy", "pyright", "ruff", "pytest", "build"] }, + "available": { "type": "boolean" }, + "implementation": { "type": "string", "minLength": 1 }, + "platform": { "type": "string", "minLength": 1 }, + "architecture": { "type": "string", "minLength": 1 }, + "abi": { "type": "string", "minLength": 1 }, + "soabi": { "type": "string", "minLength": 1 } + } + }, + "PythonInterpreterProvenance": { + "allOf": [ + { "$ref": "#/$defs/PythonExecutableProvenance" }, + { + "type": "object", + "required": ["version", "implementation", "platform", "architecture", "abi", "soabi"], + "properties": { + "version": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+(?:(?:a|b|rc)[0-9]+)?(?:\\+[A-Za-z0-9]+(?:[._-][A-Za-z0-9]+)*)?$" + } + } + } + ] + }, "ValidationResult": { "type": "object", "additionalProperties": false, @@ -2591,6 +2756,10 @@ } } }, + "pythonProjectContexts": { + "type": "array", + "items": { "$ref": "#/$defs/PythonProjectContext" } + }, "manifest": { "type": "object", "additionalProperties": false, @@ -4328,6 +4497,7 @@ "graphModes", "hypothetical", "statusSurfaces", + "pythonProjectContext", "writeGate", "checkIds" ], @@ -4516,6 +4686,47 @@ ] } }, + "pythonProjectContext": { + "type": "object", + "additionalProperties": false, + "required": [ + "schemaId", + "outcomes", + "readOnly", + "installs" + ], + "properties": { + "schemaId": { + "const": "opcore.python.project-context.v1" + }, + "outcomes": { + "type": "array", + "minItems": 4, + "maxItems": 4, + "uniqueItems": true, + "items": { + "enum": [ + "resolved", + "degraded", + "unsupported", + "ambiguous" + ] + }, + "allOf": [ + { "contains": { "const": "resolved" } }, + { "contains": { "const": "degraded" } }, + { "contains": { "const": "unsupported" } }, + { "contains": { "const": "ambiguous" } } + ] + }, + "readOnly": { + "const": true + }, + "installs": { + "const": false + } + } + }, "writeGate": { "type": "object", "additionalProperties": false, @@ -6220,6 +6431,10 @@ } } }, + "pythonProjectContexts": { + "type": "array", + "items": { "$ref": "#/$defs/PythonProjectContext" } + }, "degradedToolchains": { "type": "array", "items": { @@ -6760,6 +6975,10 @@ }, "policy": { "$ref": "#/$defs/OpcoreValidationPolicySummary" + }, + "pythonProjectContexts": { + "type": "array", + "items": { "$ref": "#/$defs/PythonProjectContext" } } } }, @@ -7633,6 +7852,10 @@ "pattern": "^[a-z][a-z0-9]*(?:[._:-][a-z0-9]+)*$" } }, + "contexts": { + "type": "array", + "items": { "$ref": "#/$defs/PythonProjectContext" } + }, "notes": { "type": "array", "items": { diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index a3ea852..3a58af8 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -1150,6 +1150,131 @@ export interface ValidationRequest { reportMode?: ValidationReportMode; } +export const PYTHON_PROJECT_CONTEXT_SCHEMA_ID = "opcore.python.project-context.v1" as const; + +export const pythonProjectContextOutcomes = ["resolved", "degraded", "unsupported", "ambiguous"] as const; +export type PythonProjectContextOutcome = (typeof pythonProjectContextOutcomes)[number]; + +export const pythonProjectContextReasonCodes = [ + "missing_config", + "invalid_config", + "conflicting_managers", + "conflicting_targets", + "interpreter_unavailable", + "tool_unavailable", + "probe_timeout", + "probe_signal", + "probe_spawn_failure", + "probe_exit_failure", + "malformed_probe_output", + "unsupported_target", + "unsupported_platform", + "path_refused", + "symlink_refused", + "incompatible_interpreter", + "ambiguous_path" +] as const; +export type PythonProjectContextReasonCode = (typeof pythonProjectContextReasonCodes)[number]; + +export const pythonProjectManagerKinds = ["pip", "uv", "poetry", "pdm", "pipenv"] as const; +export type PythonProjectManagerKind = (typeof pythonProjectManagerKinds)[number]; + +export const pythonProjectLayoutKinds = ["flat", "src", "namespace", "stub", "package"] as const; +export type PythonProjectLayoutKind = (typeof pythonProjectLayoutKinds)[number]; + +export const pythonProjectExecutableSources = [ + "explicit_override", + "active_environment", + "project_local_environment", + "manager_environment", + "path" +] as const; +export type PythonProjectExecutableSource = (typeof pythonProjectExecutableSources)[number]; + +export const pythonProjectToolKinds = ["mypy", "pyright", "ruff", "pytest", "build"] as const; +export type PythonProjectToolKind = (typeof pythonProjectToolKinds)[number]; + +export interface PythonProjectContextReason { + code: PythonProjectContextReasonCode; + message: string; + path?: string; + tool?: string; +} + +export interface PythonProjectFileEvidence { + path: string; + role: "boundary" | "config" | "lock" | "requirements" | "build" | "layout"; +} + +export interface PythonProjectManagerEvidence { + kind: PythonProjectManagerKind; + configFiles: readonly string[]; + lockFiles: readonly string[]; +} + +export interface PythonProjectExecutableProvenance { + executable: string; + argv: readonly string[]; + cwd: string; + source: PythonProjectExecutableSource; + version?: string; + configFile?: string; +} + +export interface PythonInterpreterProvenance extends PythonProjectExecutableProvenance { + version: string; + implementation: string; + platform: string; + architecture: string; + abi: string; + soabi: string; +} + +export interface PythonProjectToolProvenance extends PythonProjectExecutableProvenance { + tool: PythonProjectToolKind; + available: boolean; +} + +export interface PythonProjectTarget { + requiresPython?: string; + version?: string; + platform?: string; + implementation?: string; + conflicts: readonly string[]; +} + +export interface PythonProjectLayoutEvidence { + kinds: readonly PythonProjectLayoutKind[]; + paths: readonly string[]; +} + +export interface PythonProjectBuildSystem { + configFile: string; + backend?: string; + requires: readonly string[]; +} + +export interface PythonProjectContext { + schemaId: typeof PYTHON_PROJECT_CONTEXT_SCHEMA_ID; + schemaVersion: 1; + target: string; + repositoryRoot: string; + projectRoot: string; + projectBoundary: string; + sourceRoots: readonly string[]; + layout: PythonProjectLayoutEvidence; + evidence: readonly PythonProjectFileEvidence[]; + targetRuntime: PythonProjectTarget; + managers: readonly PythonProjectManagerEvidence[]; + buildSystem?: PythonProjectBuildSystem; + interpreter?: PythonInterpreterProvenance; + tools: readonly PythonProjectToolProvenance[]; + projectKey: string; + contextFingerprint: string; + outcome: PythonProjectContextOutcome; + reasons: readonly PythonProjectContextReason[]; +} + export interface ValidationDiagnostic { category: ValidationDiagnosticCategory; message: string; @@ -1213,6 +1338,7 @@ export interface ValidationResult { failure?: ValidationFailure; refusal?: EditRefusal; manifest?: ValidationResultManifest; + pythonProjectContexts?: readonly PythonProjectContext[]; } export interface RequiredContextDocPolicy { @@ -1424,6 +1550,12 @@ export interface ManagedToolDescriptorCapabilities { graphModes: readonly GraphProviderMode[]; hypothetical: true; statusSurfaces: readonly ("status" | "doctor")[]; + pythonProjectContext: { + schemaId: typeof PYTHON_PROJECT_CONTEXT_SCHEMA_ID; + outcomes: readonly PythonProjectContextOutcome[]; + readOnly: true; + installs: false; + }; writeGate: { initScopes: readonly OpcoreInitScope[]; harnesses: readonly ("claude-code" | "codex")[]; @@ -2167,6 +2299,7 @@ export interface OpcoreRepoStatePayload { tool: string; failureMessage?: string; }[]; + pythonProjectContexts?: readonly PythonProjectContext[]; }; activation: { ready: boolean; @@ -2292,6 +2425,7 @@ export interface OpcoreInitPythonEnvironment { path: string; }[]; notes: readonly string[]; + contexts?: readonly PythonProjectContext[]; } export interface OpcoreInitSettings { @@ -2386,6 +2520,7 @@ export interface OpcoreMetricReport { diagnosticCount: number; checkCount: number; policy?: OpcoreValidationPolicySummary; + pythonProjectContexts?: readonly PythonProjectContext[]; }; signals: readonly OpcoreMetricSignal[]; degradations: readonly OpcoreMetricDegradation[]; @@ -3979,6 +4114,20 @@ function validateManagedToolValidationCapabilities(validation: ManagedToolDescri validateExactStringSet(validation.graphModes, graphProviderModes, "Managed tool descriptor validation graph modes"); if (validation.hypothetical !== true) throw new Error("Managed tool descriptor validation hypothetical must be true"); validateExactStringSet(validation.statusSurfaces, ["status", "doctor"], "Managed tool descriptor validation status surfaces"); + if (!validation.pythonProjectContext || typeof validation.pythonProjectContext !== "object") { + throw new Error("Managed tool descriptor Python project context capability is required"); + } + if (validation.pythonProjectContext.schemaId !== PYTHON_PROJECT_CONTEXT_SCHEMA_ID) { + throw new Error(`Managed tool descriptor Python project context schemaId must be ${PYTHON_PROJECT_CONTEXT_SCHEMA_ID}`); + } + validateExactStringSet( + validation.pythonProjectContext.outcomes, + pythonProjectContextOutcomes, + "Managed tool descriptor Python project context outcomes" + ); + if (validation.pythonProjectContext.readOnly !== true || validation.pythonProjectContext.installs !== false) { + throw new Error("Managed tool descriptor Python project context must be read-only and no-install"); + } validateManagedToolValidationWriteGate(validation.writeGate); validateValidationChecks(validation.checkIds, "Managed tool descriptor validation checkIds"); } @@ -4404,6 +4553,9 @@ export function validateOpcoreRepoStatePayload(payload: OpcoreRepoStatePayload): validateNonEmptyString(tool.failureMessage, "Opcore repo state validation degraded failureMessage"); } } + if (payload.validation.pythonProjectContexts !== undefined) { + validatePythonProjectContexts(payload.validation.pythonProjectContexts); + } if (!payload.activation || typeof payload.activation !== "object") { throw new Error("Opcore repo state activation is required"); @@ -4664,6 +4816,7 @@ function validateOpcoreInitPythonEnvironment(environment: OpcoreInitPythonEnviro validateRepoRelativePath(virtualEnvironment.path); } validateStringArray(environment.notes, "Opcore init Python environment notes", { allowEmpty: true }); + if (environment.contexts !== undefined) validatePythonProjectContexts(environment.contexts); return environment; } @@ -4943,6 +5096,9 @@ export function validateOpcoreMetricReport(report: OpcoreMetricReport): OpcoreMe if (report.validation.policy !== undefined) { validateOpcoreValidationPolicySummary(report.validation.policy, "Opcore metric report validation policy"); } + if (report.validation.pythonProjectContexts !== undefined) { + validatePythonProjectContexts(report.validation.pythonProjectContexts); + } if (!Array.isArray(report.signals)) { throw new Error("Opcore metric report signals must be an array"); } @@ -6464,9 +6620,197 @@ export function validateValidationResultPayload(result: ValidationResult): Valid if (result.manifest !== undefined) { validateValidationResultManifest(result.manifest); } + if (result.pythonProjectContexts !== undefined) validatePythonProjectContexts(result.pythonProjectContexts); return result; } +export function validatePythonProjectContext(context: PythonProjectContext): PythonProjectContext { + if (!context || typeof context !== "object") throw new Error("Python project context is required"); + validateExactObjectKeys(context, [ + "schemaId", "schemaVersion", "target", "repositoryRoot", "projectRoot", "projectBoundary", "sourceRoots", + "layout", "evidence", "targetRuntime", "managers", "buildSystem", "interpreter", "tools", "projectKey", + "contextFingerprint", "outcome", "reasons" + ], "Python project context"); + if (context.schemaId !== PYTHON_PROJECT_CONTEXT_SCHEMA_ID) { + throw new Error(`Python project context schemaId must be ${PYTHON_PROJECT_CONTEXT_SCHEMA_ID}`); + } + if (context.schemaVersion !== 1) throw new Error("Python project context schemaVersion must be 1"); + validateRepoRelativePath(context.target); + if (!/\.pyi?$/u.test(context.target)) throw new Error("Python project context target must be a .py or .pyi path"); + validateNonEmptyString(context.repositoryRoot, "Python project context repositoryRoot"); + validatePythonProjectRoot(context.projectRoot, "Python project context projectRoot"); + validatePythonProjectRoot(context.projectBoundary, "Python project context projectBoundary"); + validatePythonProjectRoots(context.sourceRoots, "Python project context sourceRoots"); + if (!context.layout || typeof context.layout !== "object") throw new Error("Python project context layout is required"); + validateExactObjectKeys(context.layout, ["kinds", "paths"], "Python project context layout"); + validateExactEnumArray(context.layout.kinds, pythonProjectLayoutKinds, "Python project context layout kinds", false); + validatePythonProjectRoots(context.layout.paths, "Python project context layout paths"); + if (!Array.isArray(context.evidence)) throw new Error("Python project context evidence must be an array"); + for (const entry of context.evidence) { + validateExactObjectKeys(entry, ["path", "role"], "Python project context evidence"); + validateRepoRelativePath(entry.path); + if (!includesString(["boundary", "config", "lock", "requirements", "build", "layout"] as const, entry.role)) { + throw new Error(`Unknown Python project evidence role: ${String(entry.role)}`); + } + } + validatePythonProjectTarget(context.targetRuntime); + if (!Array.isArray(context.managers)) throw new Error("Python project context managers must be an array"); + for (const manager of context.managers) { + validateExactObjectKeys(manager, ["kind", "configFiles", "lockFiles"], "Python project manager evidence"); + if (!includesString(pythonProjectManagerKinds, manager.kind)) { + throw new Error(`Unknown Python project manager kind: ${String(manager.kind)}`); + } + validateRepoPathArray(manager.configFiles, "Python project manager configFiles"); + validateRepoPathArray(manager.lockFiles, "Python project manager lockFiles"); + } + if (context.buildSystem !== undefined) { + validateExactObjectKeys(context.buildSystem, ["configFile", "backend", "requires"], "Python project buildSystem"); + validateRepoRelativePath(context.buildSystem.configFile); + if (context.buildSystem.backend !== undefined) { + validateNonEmptyString(context.buildSystem.backend, "Python project buildSystem backend"); + } + validateStringArray(context.buildSystem.requires, "Python project buildSystem requires", { allowEmpty: true }); + } + if (context.interpreter !== undefined) validatePythonInterpreterProvenance(context.interpreter); + if (!Array.isArray(context.tools)) throw new Error("Python project context tools must be an array"); + for (const tool of context.tools) { + validateExactObjectKeys( + tool, + ["tool", "available", "executable", "argv", "cwd", "source", "version", "configFile"], + "Python project tool provenance" + ); + if (!includesString(pythonProjectToolKinds, tool.tool)) throw new Error(`Unknown Python project tool: ${String(tool.tool)}`); + if (typeof tool.available !== "boolean") throw new Error("Python project tool available must be boolean"); + validatePythonExecutableProvenance(tool, `Python project tool ${tool.tool}`); + if (tool.available && tool.version === undefined) { + throw new Error(`Available Python project tool ${tool.tool} must include version provenance`); + } + } + validateSha256Identity(context.projectKey, "Python project context projectKey"); + validateSha256Identity(context.contextFingerprint, "Python project context contextFingerprint"); + if (!includesString(pythonProjectContextOutcomes, context.outcome)) { + throw new Error(`Unknown Python project context outcome: ${String(context.outcome)}`); + } + if (!Array.isArray(context.reasons)) throw new Error("Python project context reasons must be an array"); + for (const reason of context.reasons) { + validateExactObjectKeys(reason, ["code", "message", "path", "tool"], "Python project context reason"); + if (!includesString(pythonProjectContextReasonCodes, reason.code)) { + throw new Error(`Unknown Python project context reason: ${String(reason.code)}`); + } + validateNonEmptyString(reason.message, "Python project context reason message"); + if (reason.path !== undefined) validateRepoRelativePath(reason.path); + if (reason.tool !== undefined) validateNonEmptyString(reason.tool, "Python project context reason tool"); + } + if (context.outcome === "resolved" && context.reasons.length > 0) { + throw new Error("Resolved Python project context must not include reasons"); + } + if (context.outcome !== "resolved" && context.reasons.length === 0) { + throw new Error("Non-resolved Python project context must include reasons"); + } + return context; +} + +export function validatePythonProjectContexts(contexts: readonly PythonProjectContext[]): readonly PythonProjectContext[] { + if (!Array.isArray(contexts)) throw new Error("Python project contexts must be an array"); + const targets = new Set(); + for (const context of contexts) { + validatePythonProjectContext(context); + if (targets.has(context.target)) throw new Error(`Duplicate Python project context target: ${context.target}`); + targets.add(context.target); + } + return contexts; +} + +function validatePythonProjectTarget(target: PythonProjectTarget): void { + if (!target || typeof target !== "object") throw new Error("Python project targetRuntime is required"); + validateExactObjectKeys( + target, + ["requiresPython", "version", "platform", "implementation", "conflicts"], + "Python project targetRuntime" + ); + for (const [key, value] of Object.entries(target)) { + if (key === "conflicts") continue; + if (value !== undefined) validateNonEmptyString(value, `Python project targetRuntime ${key}`); + } + validateStringArray(target.conflicts, "Python project targetRuntime conflicts", { allowEmpty: true }); +} + +function validatePythonExecutableProvenance(value: PythonProjectExecutableProvenance, label: string): void { + if (!value || typeof value !== "object") throw new Error(`${label} provenance is required`); + validateNonEmptyString(value.executable, `${label} executable`); + validateStringArray(value.argv, `${label} argv`, { allowEmpty: false }); + if (value.argv[0] !== value.executable) throw new Error(`${label} argv must start with executable`); + validateNonEmptyString(value.cwd, `${label} cwd`); + if (!includesString(pythonProjectExecutableSources, value.source)) throw new Error(`Unknown ${label} source: ${String(value.source)}`); + if (value.version !== undefined) { + validateNonEmptyString(value.version, `${label} version`); + if (!/^\d+(?:\.\d+)+(?:[-+._A-Za-z0-9]*)?$/u.test(value.version)) { + throw new Error(`${label} version must be exact version provenance`); + } + } + if (value.configFile !== undefined) validateRepoRelativePath(value.configFile); +} + +function validatePythonInterpreterProvenance(value: PythonInterpreterProvenance): void { + validateExactObjectKeys(value, [ + "executable", "argv", "cwd", "source", "version", "configFile", "implementation", "platform", "architecture", + "abi", "soabi" + ], "Python interpreter provenance"); + validatePythonExecutableProvenance(value, "Python interpreter"); + if (!/^\d+\.\d+\.\d+(?:(?:a|b|rc)\d+)?(?:\+[A-Za-z0-9]+(?:[._-][A-Za-z0-9]+)*)?$/u.test(value.version)) { + throw new Error("Python interpreter version must be an exact Python version"); + } + for (const [key, field] of [ + ["implementation", value.implementation], + ["platform", value.platform], + ["architecture", value.architecture], + ["abi", value.abi], + ["soabi", value.soabi] + ] as const) { + validateNonEmptyString(field, `Python interpreter ${key}`); + } +} + +function validatePythonProjectRoot(value: string, label: string): void { + if (value === ".") return; + validateRepoRelativePath(value); +} + +function validatePythonProjectRoots(values: readonly string[], label: string): void { + if (!Array.isArray(values) || values.length === 0) throw new Error(`${label} must be a non-empty array`); + for (const value of values) validatePythonProjectRoot(value, label); +} + +function validateRepoPathArray(values: readonly string[], label: string): void { + if (!Array.isArray(values)) throw new Error(`${label} must be an array`); + for (const value of values) validateRepoRelativePath(value); +} + +function validateExactEnumArray( + values: readonly T[], allowed: readonly T[], label: string, requireAll: boolean +): void { + if (!Array.isArray(values) || values.length === 0) throw new Error(`${label} must be a non-empty array`); + const seen = new Set(); + for (const value of values) { + if (!includesString(allowed, value)) throw new Error(`Unknown ${label} value: ${String(value)}`); + if (seen.has(value)) throw new Error(`${label} must not contain duplicates`); + seen.add(value); + } + if (requireAll && seen.size !== allowed.length) throw new Error(`${label} must contain every supported value`); +} + +function validateSha256Identity(value: string, label: string): void { + if (!/^sha256:[a-f0-9]{64}$/u.test(value)) throw new Error(`${label} must be a sha256 identity`); +} + +function validateExactObjectKeys(value: object, allowedKeys: readonly string[], label: string): void { + const allowed = new Set(allowedKeys); + const unexpected = Object.keys(value).filter((key) => !allowed.has(key)); + if (unexpected.length > 0) { + throw new Error(`${label} has unexpected properties: ${unexpected.sort().join(", ")}`); + } +} + export function validateRequiredContextDocPolicy(policy: RequiredContextDocPolicy): RequiredContextDocPolicy { if (!policy || typeof policy !== "object") { throw new Error("Required context doc policy is required"); diff --git a/packages/fixtures/descriptors/opcore.managed-tool.json b/packages/fixtures/descriptors/opcore.managed-tool.json index 6609dd6..e8bd947 100644 --- a/packages/fixtures/descriptors/opcore.managed-tool.json +++ b/packages/fixtures/descriptors/opcore.managed-tool.json @@ -334,6 +334,17 @@ "status", "doctor" ], + "pythonProjectContext": { + "schemaId": "opcore.python.project-context.v1", + "outcomes": [ + "resolved", + "degraded", + "unsupported", + "ambiguous" + ], + "readOnly": true, + "installs": false + }, "writeGate": { "initScopes": [ "repo", diff --git a/packages/fixtures/validation-python/project-context/flatpkg/__init__.py.fixture b/packages/fixtures/validation-python/project-context/flatpkg/__init__.py.fixture new file mode 100644 index 0000000..b15b1b0 --- /dev/null +++ b/packages/fixtures/validation-python/project-context/flatpkg/__init__.py.fixture @@ -0,0 +1 @@ +VALUE = 1 diff --git a/packages/fixtures/validation-python/project-context/requirements.txt.fixture b/packages/fixtures/validation-python/project-context/requirements.txt.fixture new file mode 100644 index 0000000..69e74b3 --- /dev/null +++ b/packages/fixtures/validation-python/project-context/requirements.txt.fixture @@ -0,0 +1 @@ +typing-extensions>=4 diff --git a/packages/fixtures/validation-python/project-context/resolver-matrix.json b/packages/fixtures/validation-python/project-context/resolver-matrix.json new file mode 100644 index 0000000..c69f4de --- /dev/null +++ b/packages/fixtures/validation-python/project-context/resolver-matrix.json @@ -0,0 +1,348 @@ +{ + "schemaVersion": 1, + "rows": [ + { + "id": "real-posix-flat-package", + "execution": "real", + "target": "flatpkg/__init__.py", + "expected": { + "target": "flatpkg/__init__.py", + "repositoryRoot": "", + "projectRoot": ".", + "projectBoundary": ".", + "sourceRoots": ["."], + "layout": { "kinds": ["flat", "package"], "paths": ["."] }, + "evidence": [ + { "path": "requirements.txt", "role": "boundary" }, + { "path": "requirements.txt", "role": "requirements" } + ], + "targetRuntime": { "conflicts": [] }, + "managers": [ + { "kind": "pip", "configFiles": ["requirements.txt"], "lockFiles": [] } + ], + "interpreter": { + "executable": "", + "argv": [""], + "cwd": "", + "source": "explicit_override", + "version": "", + "implementation": "CPython", + "platform": "", + "architecture": "" + }, + "tools": [ + { "tool": "mypy", "available": false, "executable": "/opcore-missing-tools/mypy", "argv": ["/opcore-missing-tools/mypy"], "cwd": "", "source": "explicit_override" }, + { "tool": "pyright", "available": false, "executable": "/opcore-missing-tools/pyright", "argv": ["/opcore-missing-tools/pyright"], "cwd": "", "source": "explicit_override" }, + { "tool": "pytest", "available": false, "executable": "/opcore-missing-tools/pytest", "argv": ["/opcore-missing-tools/pytest"], "cwd": "", "source": "explicit_override" }, + { "tool": "ruff", "available": false, "executable": "/opcore-missing-tools/ruff", "argv": ["/opcore-missing-tools/ruff"], "cwd": "", "source": "explicit_override" } + ], + "projectKey": "", + "contextFingerprint": "", + "outcome": "degraded", + "reasons": [ + { "code": "tool_unavailable", "tool": "mypy" }, + { "code": "tool_unavailable", "tool": "pyright" }, + { "code": "tool_unavailable", "tool": "pytest" }, + { "code": "tool_unavailable", "tool": "ruff" } + ] + } + }, + { + "id": "simulated-posix-nested-uv-namespace", + "execution": "simulated", + "platform": "linux", + "target": "services/api/src/acme/api.py", + "expected": { + "target": "services/api/src/acme/api.py", + "repositoryRoot": "/fixture", + "projectRoot": "services/api", + "projectBoundary": "services/api", + "sourceRoots": ["services/api/src"], + "layout": { "kinds": ["namespace", "src"], "paths": ["services/api/src"] }, + "evidence": [ + { "path": "services/api/mypy.ini", "role": "config" }, + { "path": "services/api/pyproject.toml", "role": "boundary" }, + { "path": "services/api/pyproject.toml", "role": "build" }, + { "path": "services/api/pyrightconfig.json", "role": "config" }, + { "path": "services/api/pytest.ini", "role": "config" }, + { "path": "services/api/ruff.toml", "role": "config" }, + { "path": "services/api/src", "role": "layout" }, + { "path": "services/api/uv.lock", "role": "boundary" }, + { "path": "services/api/uv.lock", "role": "lock" } + ], + "targetRuntime": { "requiresPython": ">=3.12", "conflicts": [] }, + "managers": [ + { "kind": "uv", "configFiles": ["services/api/pyproject.toml"], "lockFiles": ["services/api/uv.lock"] } + ], + "buildSystem": { + "configFile": "services/api/pyproject.toml", + "backend": "hatchling.build", + "requires": ["hatch-vcs>=0.4", "hatchling>=1"] + }, + "interpreter": { + "executable": "/fixture/services/api/.venv/bin/python", + "argv": ["/fixture/services/api/.venv/bin/python"], + "cwd": "/fixture/services/api", + "source": "project_local_environment", + "version": "3.12.4", + "implementation": "CPython", + "platform": "linux", + "architecture": "x86_64", + "abi": "cpython-312", + "soabi": "cpython-312-x86_64-linux-gnu" + }, + "tools": [ + { "tool": "build", "available": true, "executable": "/fixture/services/api/.venv/bin/python", "argv": ["/fixture/services/api/.venv/bin/python", "-m", "build"], "cwd": "/fixture/services/api", "source": "project_local_environment", "version": "1.0.0", "configFile": "services/api/pyproject.toml" }, + { "tool": "mypy", "available": true, "executable": "/fixture/services/api/.venv/bin/mypy", "argv": ["/fixture/services/api/.venv/bin/mypy"], "cwd": "/fixture/services/api", "source": "project_local_environment", "version": "1.0.0", "configFile": "services/api/mypy.ini" }, + { "tool": "pyright", "available": true, "executable": "/fixture/services/api/.venv/bin/pyright", "argv": ["/fixture/services/api/.venv/bin/pyright"], "cwd": "/fixture/services/api", "source": "project_local_environment", "version": "1.0.0", "configFile": "services/api/pyrightconfig.json" }, + { "tool": "pytest", "available": true, "executable": "/fixture/services/api/.venv/bin/pytest", "argv": ["/fixture/services/api/.venv/bin/pytest"], "cwd": "/fixture/services/api", "source": "project_local_environment", "version": "1.0.0", "configFile": "services/api/pytest.ini" }, + { "tool": "ruff", "available": true, "executable": "/fixture/services/api/.venv/bin/ruff", "argv": ["/fixture/services/api/.venv/bin/ruff"], "cwd": "/fixture/services/api", "source": "project_local_environment", "version": "1.0.0", "configFile": "services/api/ruff.toml" } + ], + "projectKey": "", + "contextFingerprint": "", + "outcome": "resolved", + "reasons": [] + } + }, + { + "id": "simulated-posix-mixed-flat-src-root-target", + "execution": "simulated", + "platform": "linux", + "target": "services/mixed/script.py", + "expected": { + "target": "services/mixed/script.py", + "repositoryRoot": "/fixture", + "projectRoot": "services/mixed", + "projectBoundary": "services/mixed", + "sourceRoots": ["services/mixed"], + "layout": { "kinds": ["flat"], "paths": ["services/mixed"] }, + "evidence": [ + { "path": "services/mixed/pyproject.toml", "role": "boundary" }, + { "path": "services/mixed/pyproject.toml", "role": "config" }, + { "path": "services/mixed", "role": "layout" } + ], + "targetRuntime": { "requiresPython": ">=3.12", "conflicts": [] }, + "managers": [], + "interpreter": { + "executable": "/fixture/services/mixed/.venv/bin/python", + "argv": ["/fixture/services/mixed/.venv/bin/python"], + "cwd": "/fixture/services/mixed", + "source": "project_local_environment", + "version": "3.12.4", + "implementation": "CPython", + "platform": "linux", + "architecture": "x86_64", + "abi": "cpython-312", + "soabi": "cpython-312-x86_64-linux-gnu" + }, + "tools": [ + { "tool": "mypy", "available": true, "executable": "/fixture/services/mixed/.venv/bin/mypy", "argv": ["/fixture/services/mixed/.venv/bin/mypy"], "cwd": "/fixture/services/mixed", "source": "project_local_environment", "version": "1.0.0" }, + { "tool": "pyright", "available": true, "executable": "/fixture/services/mixed/.venv/bin/pyright", "argv": ["/fixture/services/mixed/.venv/bin/pyright"], "cwd": "/fixture/services/mixed", "source": "project_local_environment", "version": "1.0.0" }, + { "tool": "pytest", "available": true, "executable": "/fixture/services/mixed/.venv/bin/pytest", "argv": ["/fixture/services/mixed/.venv/bin/pytest"], "cwd": "/fixture/services/mixed", "source": "project_local_environment", "version": "1.0.0" }, + { "tool": "ruff", "available": true, "executable": "/fixture/services/mixed/.venv/bin/ruff", "argv": ["/fixture/services/mixed/.venv/bin/ruff"], "cwd": "/fixture/services/mixed", "source": "project_local_environment", "version": "1.0.0" } + ], + "projectKey": "", + "contextFingerprint": "", + "outcome": "resolved", + "reasons": [] + } + }, + { + "id": "simulated-posix-poetry-flat-package", + "execution": "simulated", + "platform": "linux", + "target": "services/poetry/pkg/__init__.py", + "expected": { + "target": "services/poetry/pkg/__init__.py", + "repositoryRoot": "/fixture", + "projectRoot": "services/poetry", + "projectBoundary": "services/poetry", + "sourceRoots": ["services/poetry"], + "layout": { "kinds": ["flat", "package"], "paths": ["services/poetry"] }, + "evidence": [ + { "path": "services/poetry/poetry.lock", "role": "boundary" }, + { "path": "services/poetry/poetry.lock", "role": "lock" }, + { "path": "services/poetry/pyproject.toml", "role": "boundary" }, + { "path": "services/poetry/pyproject.toml", "role": "config" }, + { "path": "services/poetry", "role": "layout" } + ], + "targetRuntime": { "requiresPython": ">=3.12", "conflicts": [] }, + "managers": [ + { "kind": "poetry", "configFiles": ["services/poetry/pyproject.toml"], "lockFiles": ["services/poetry/poetry.lock"] } + ], + "interpreter": { "executable": "/fixture/services/poetry/.venv/bin/python", "argv": ["/fixture/services/poetry/.venv/bin/python"], "cwd": "/fixture/services/poetry", "source": "project_local_environment", "version": "3.12.4", "implementation": "CPython", "platform": "linux", "architecture": "x86_64", "abi": "cpython-312", "soabi": "cpython-312-x86_64-linux-gnu" }, + "tools": [ + { "tool": "mypy", "available": true, "executable": "/fixture/services/poetry/.venv/bin/mypy", "argv": ["/fixture/services/poetry/.venv/bin/mypy"], "cwd": "/fixture/services/poetry", "source": "project_local_environment", "version": "1.0.0" }, + { "tool": "pyright", "available": true, "executable": "/fixture/services/poetry/.venv/bin/pyright", "argv": ["/fixture/services/poetry/.venv/bin/pyright"], "cwd": "/fixture/services/poetry", "source": "project_local_environment", "version": "1.0.0" }, + { "tool": "pytest", "available": true, "executable": "/fixture/services/poetry/.venv/bin/pytest", "argv": ["/fixture/services/poetry/.venv/bin/pytest"], "cwd": "/fixture/services/poetry", "source": "project_local_environment", "version": "1.0.0" }, + { "tool": "ruff", "available": true, "executable": "/fixture/services/poetry/.venv/bin/ruff", "argv": ["/fixture/services/poetry/.venv/bin/ruff"], "cwd": "/fixture/services/poetry", "source": "project_local_environment", "version": "1.0.0" } + ], + "projectKey": "", "contextFingerprint": "", "outcome": "resolved", "reasons": [] + } + }, + { + "id": "simulated-posix-pdm-src-namespace", + "execution": "simulated", + "platform": "linux", + "target": "services/pdm/src/pdm_pkg/app.py", + "expected": { + "target": "services/pdm/src/pdm_pkg/app.py", + "repositoryRoot": "/fixture", + "projectRoot": "services/pdm", + "projectBoundary": "services/pdm", + "sourceRoots": ["services/pdm/src"], + "layout": { "kinds": ["namespace", "src"], "paths": ["services/pdm/src"] }, + "evidence": [ + { "path": "services/pdm/pdm.lock", "role": "boundary" }, + { "path": "services/pdm/pdm.lock", "role": "lock" }, + { "path": "services/pdm/pyproject.toml", "role": "boundary" }, + { "path": "services/pdm/pyproject.toml", "role": "config" }, + { "path": "services/pdm/src", "role": "layout" } + ], + "targetRuntime": { "requiresPython": ">=3.12", "conflicts": [] }, + "managers": [ + { "kind": "pdm", "configFiles": ["services/pdm/pyproject.toml"], "lockFiles": ["services/pdm/pdm.lock"] } + ], + "interpreter": { "executable": "/fixture/services/pdm/.venv/bin/python", "argv": ["/fixture/services/pdm/.venv/bin/python"], "cwd": "/fixture/services/pdm", "source": "project_local_environment", "version": "3.12.4", "implementation": "CPython", "platform": "linux", "architecture": "x86_64", "abi": "cpython-312", "soabi": "cpython-312-x86_64-linux-gnu" }, + "tools": [ + { "tool": "mypy", "available": true, "executable": "/fixture/services/pdm/.venv/bin/mypy", "argv": ["/fixture/services/pdm/.venv/bin/mypy"], "cwd": "/fixture/services/pdm", "source": "project_local_environment", "version": "1.0.0" }, + { "tool": "pyright", "available": true, "executable": "/fixture/services/pdm/.venv/bin/pyright", "argv": ["/fixture/services/pdm/.venv/bin/pyright"], "cwd": "/fixture/services/pdm", "source": "project_local_environment", "version": "1.0.0" }, + { "tool": "pytest", "available": true, "executable": "/fixture/services/pdm/.venv/bin/pytest", "argv": ["/fixture/services/pdm/.venv/bin/pytest"], "cwd": "/fixture/services/pdm", "source": "project_local_environment", "version": "1.0.0" }, + { "tool": "ruff", "available": true, "executable": "/fixture/services/pdm/.venv/bin/ruff", "argv": ["/fixture/services/pdm/.venv/bin/ruff"], "cwd": "/fixture/services/pdm", "source": "project_local_environment", "version": "1.0.0" } + ], + "projectKey": "", "contextFingerprint": "", "outcome": "resolved", "reasons": [] + } + }, + { + "id": "simulated-windows-pipenv-stub-environment-root-python", + "execution": "simulated", + "platform": "win32", + "windowsInterpreterLayout": "environment-root", + "target": "services/stubs/src/sdk/__init__.pyi", + "expected": { + "target": "services/stubs/src/sdk/__init__.pyi", + "repositoryRoot": "C:\\fixture", + "projectRoot": "services/stubs", + "projectBoundary": "services/stubs", + "sourceRoots": ["services/stubs/src"], + "layout": { "kinds": ["package", "src", "stub"], "paths": ["services/stubs/src"] }, + "evidence": [ + { "path": "services/stubs/Pipfile.lock", "role": "boundary" }, + { "path": "services/stubs/Pipfile.lock", "role": "lock" }, + { "path": "services/stubs/Pipfile", "role": "boundary" }, + { "path": "services/stubs/Pipfile", "role": "config" }, + { "path": "services/stubs/src", "role": "layout" } + ], + "targetRuntime": { "version": "3.12", "conflicts": [] }, + "managers": [ + { "kind": "pipenv", "configFiles": ["services/stubs/Pipfile"], "lockFiles": ["services/stubs/Pipfile.lock"] } + ], + "interpreter": { "executable": "C:\\fixture\\services\\stubs\\.venv\\python.exe", "argv": ["C:\\fixture\\services\\stubs\\.venv\\python.exe"], "cwd": "C:\\fixture\\services\\stubs", "source": "project_local_environment", "version": "3.12.4", "implementation": "CPython", "platform": "win32", "architecture": "AMD64", "abi": "cpython-312", "soabi": "cp312-win_amd64" }, + "tools": [ + { "tool": "mypy", "available": true, "executable": "C:\\fixture\\services\\stubs\\.venv\\Scripts\\mypy.exe", "argv": ["C:\\fixture\\services\\stubs\\.venv\\Scripts\\mypy.exe"], "cwd": "C:\\fixture\\services\\stubs", "source": "project_local_environment", "version": "1.0.0" }, + { "tool": "pyright", "available": true, "executable": "C:\\fixture\\services\\stubs\\.venv\\Scripts\\pyright.exe", "argv": ["C:\\fixture\\services\\stubs\\.venv\\Scripts\\pyright.exe"], "cwd": "C:\\fixture\\services\\stubs", "source": "project_local_environment", "version": "1.0.0" }, + { "tool": "pytest", "available": true, "executable": "C:\\fixture\\services\\stubs\\.venv\\Scripts\\pytest.exe", "argv": ["C:\\fixture\\services\\stubs\\.venv\\Scripts\\pytest.exe"], "cwd": "C:\\fixture\\services\\stubs", "source": "project_local_environment", "version": "1.0.0" }, + { "tool": "ruff", "available": true, "executable": "C:\\fixture\\services\\stubs\\.venv\\Scripts\\ruff.exe", "argv": ["C:\\fixture\\services\\stubs\\.venv\\Scripts\\ruff.exe"], "cwd": "C:\\fixture\\services\\stubs", "source": "project_local_environment", "version": "1.0.0" } + ], + "projectKey": "", "contextFingerprint": "", "outcome": "resolved", "reasons": [] + } + }, + { + "id": "simulated-posix-invalid-toml", + "execution": "simulated", + "platform": "linux", + "target": "services/invalid/app.py", + "expected": { + "target": "services/invalid/app.py", + "repositoryRoot": "/fixture", + "projectRoot": "services/invalid", + "projectBoundary": "services/invalid", + "sourceRoots": ["services/invalid"], + "layout": { "kinds": ["flat"], "paths": ["services/invalid"] }, + "evidence": [ + { "path": "services/invalid/pyproject.toml", "role": "boundary" }, + { "path": "services/invalid/pyproject.toml", "role": "config" }, + { "path": "services/invalid", "role": "layout" } + ], + "targetRuntime": { "conflicts": [] }, + "managers": [], + "interpreter": { "executable": "/fixture/services/invalid/.venv/bin/python", "argv": ["/fixture/services/invalid/.venv/bin/python"], "cwd": "/fixture/services/invalid", "source": "project_local_environment", "version": "3.12.4" }, + "tools": [ + { "tool": "mypy", "available": true, "argv": ["/fixture/services/invalid/.venv/bin/mypy"], "cwd": "/fixture/services/invalid", "source": "project_local_environment" }, + { "tool": "pyright", "available": true, "argv": ["/fixture/services/invalid/.venv/bin/pyright"], "cwd": "/fixture/services/invalid", "source": "project_local_environment" }, + { "tool": "pytest", "available": true, "argv": ["/fixture/services/invalid/.venv/bin/pytest"], "cwd": "/fixture/services/invalid", "source": "project_local_environment" }, + { "tool": "ruff", "available": true, "argv": ["/fixture/services/invalid/.venv/bin/ruff"], "cwd": "/fixture/services/invalid", "source": "project_local_environment" } + ], + "projectKey": "", "contextFingerprint": "", "outcome": "degraded", + "reasons": [{ "code": "invalid_config", "path": "services/invalid/pyproject.toml" }] + } + }, + { + "id": "simulated-posix-conflicting-managers", + "execution": "simulated", + "platform": "linux", + "target": "services/conflict/app.py", + "expected": { + "target": "services/conflict/app.py", + "repositoryRoot": "/fixture", + "projectRoot": "services/conflict", + "projectBoundary": "services/conflict", + "sourceRoots": ["services/conflict"], + "layout": { "kinds": ["flat"], "paths": ["services/conflict"] }, + "evidence": [ + { "path": "services/conflict/poetry.lock", "role": "boundary" }, + { "path": "services/conflict/poetry.lock", "role": "lock" }, + { "path": "services/conflict/pyproject.toml", "role": "boundary" }, + { "path": "services/conflict/pyproject.toml", "role": "config" }, + { "path": "services/conflict/uv.lock", "role": "boundary" }, + { "path": "services/conflict/uv.lock", "role": "lock" }, + { "path": "services/conflict", "role": "layout" } + ], + "targetRuntime": { "requiresPython": ">=3.12", "conflicts": [] }, + "managers": [ + { "kind": "poetry", "configFiles": [], "lockFiles": ["services/conflict/poetry.lock"] }, + { "kind": "uv", "configFiles": ["services/conflict/pyproject.toml"], "lockFiles": ["services/conflict/uv.lock"] } + ], + "interpreter": { "executable": "/fixture/services/conflict/.venv/bin/python", "argv": ["/fixture/services/conflict/.venv/bin/python"], "cwd": "/fixture/services/conflict", "source": "project_local_environment", "version": "3.12.4" }, + "tools": [ + { "tool": "mypy", "available": true, "argv": ["/fixture/services/conflict/.venv/bin/mypy"], "cwd": "/fixture/services/conflict", "source": "project_local_environment" }, + { "tool": "pyright", "available": true, "argv": ["/fixture/services/conflict/.venv/bin/pyright"], "cwd": "/fixture/services/conflict", "source": "project_local_environment" }, + { "tool": "pytest", "available": true, "argv": ["/fixture/services/conflict/.venv/bin/pytest"], "cwd": "/fixture/services/conflict", "source": "project_local_environment" }, + { "tool": "ruff", "available": true, "argv": ["/fixture/services/conflict/.venv/bin/ruff"], "cwd": "/fixture/services/conflict", "source": "project_local_environment" } + ], + "projectKey": "", "contextFingerprint": "", "outcome": "ambiguous", + "reasons": [{ "code": "conflicting_managers" }] + } + }, + { + "id": "simulated-posix-incompatible-interpreter", + "execution": "simulated", + "platform": "linux", + "target": "services/incompatible/app.py", + "expected": { + "target": "services/incompatible/app.py", + "repositoryRoot": "/fixture", + "projectRoot": "services/incompatible", + "projectBoundary": "services/incompatible", + "sourceRoots": ["services/incompatible"], + "layout": { "kinds": ["flat"], "paths": ["services/incompatible"] }, + "evidence": [ + { "path": "services/incompatible/pyproject.toml", "role": "boundary" }, + { "path": "services/incompatible/pyproject.toml", "role": "config" }, + { "path": "services/incompatible", "role": "layout" } + ], + "targetRuntime": { "requiresPython": ">=3.13", "conflicts": [] }, + "managers": [], + "interpreter": { "executable": "/fixture/services/incompatible/.venv/bin/python", "argv": ["/fixture/services/incompatible/.venv/bin/python"], "cwd": "/fixture/services/incompatible", "source": "project_local_environment", "version": "3.12.4" }, + "tools": [ + { "tool": "mypy", "available": true, "argv": ["/fixture/services/incompatible/.venv/bin/mypy"], "cwd": "/fixture/services/incompatible", "source": "project_local_environment" }, + { "tool": "pyright", "available": true, "argv": ["/fixture/services/incompatible/.venv/bin/pyright"], "cwd": "/fixture/services/incompatible", "source": "project_local_environment" }, + { "tool": "pytest", "available": true, "argv": ["/fixture/services/incompatible/.venv/bin/pytest"], "cwd": "/fixture/services/incompatible", "source": "project_local_environment" }, + { "tool": "ruff", "available": true, "argv": ["/fixture/services/incompatible/.venv/bin/ruff"], "cwd": "/fixture/services/incompatible", "source": "project_local_environment" } + ], + "projectKey": "", "contextFingerprint": "", "outcome": "unsupported", + "reasons": [{ "code": "incompatible_interpreter", "tool": "python" }] + } + } + ] +} diff --git a/packages/fixtures/validation-python/project-context/services/api/mypy.ini.fixture b/packages/fixtures/validation-python/project-context/services/api/mypy.ini.fixture new file mode 100644 index 0000000..010ed68 --- /dev/null +++ b/packages/fixtures/validation-python/project-context/services/api/mypy.ini.fixture @@ -0,0 +1,2 @@ +[mypy] +python_version = 3.12 diff --git a/packages/fixtures/validation-python/project-context/services/api/pyproject.toml.fixture b/packages/fixtures/validation-python/project-context/services/api/pyproject.toml.fixture new file mode 100644 index 0000000..dec0a5c --- /dev/null +++ b/packages/fixtures/validation-python/project-context/services/api/pyproject.toml.fixture @@ -0,0 +1,13 @@ +[project] +name = "fixture-api" +requires-python = ">=3.12" + +[tool.uv] +package = true + +[build-system] +requires = [ + "hatchling>=1", + "hatch-vcs>=0.4", +] +build-backend = "hatchling.build" diff --git a/packages/fixtures/validation-python/project-context/services/api/pyrightconfig.json.fixture b/packages/fixtures/validation-python/project-context/services/api/pyrightconfig.json.fixture new file mode 100644 index 0000000..92e4ec8 --- /dev/null +++ b/packages/fixtures/validation-python/project-context/services/api/pyrightconfig.json.fixture @@ -0,0 +1 @@ +{"pythonVersion":"3.12"} diff --git a/packages/fixtures/validation-python/project-context/services/api/pytest.ini.fixture b/packages/fixtures/validation-python/project-context/services/api/pytest.ini.fixture new file mode 100644 index 0000000..5ee6477 --- /dev/null +++ b/packages/fixtures/validation-python/project-context/services/api/pytest.ini.fixture @@ -0,0 +1,2 @@ +[pytest] +testpaths = tests diff --git a/packages/fixtures/validation-python/project-context/services/api/ruff.toml.fixture b/packages/fixtures/validation-python/project-context/services/api/ruff.toml.fixture new file mode 100644 index 0000000..8b96752 --- /dev/null +++ b/packages/fixtures/validation-python/project-context/services/api/ruff.toml.fixture @@ -0,0 +1 @@ +target-version = "py312" diff --git a/packages/fixtures/validation-python/project-context/services/api/src/acme/api.py.fixture b/packages/fixtures/validation-python/project-context/services/api/src/acme/api.py.fixture new file mode 100644 index 0000000..605bacb --- /dev/null +++ b/packages/fixtures/validation-python/project-context/services/api/src/acme/api.py.fixture @@ -0,0 +1,2 @@ +def handler() -> str: + return "ok" diff --git a/packages/fixtures/validation-python/project-context/services/api/uv.lock.fixture b/packages/fixtures/validation-python/project-context/services/api/uv.lock.fixture new file mode 100644 index 0000000..d9914df --- /dev/null +++ b/packages/fixtures/validation-python/project-context/services/api/uv.lock.fixture @@ -0,0 +1 @@ +version = 1 diff --git a/packages/fixtures/validation-python/project-context/services/conflict/app.py.fixture b/packages/fixtures/validation-python/project-context/services/conflict/app.py.fixture new file mode 100644 index 0000000..b15b1b0 --- /dev/null +++ b/packages/fixtures/validation-python/project-context/services/conflict/app.py.fixture @@ -0,0 +1 @@ +VALUE = 1 diff --git a/packages/fixtures/validation-python/project-context/services/conflict/poetry.lock.fixture b/packages/fixtures/validation-python/project-context/services/conflict/poetry.lock.fixture new file mode 100644 index 0000000..c4293b3 --- /dev/null +++ b/packages/fixtures/validation-python/project-context/services/conflict/poetry.lock.fixture @@ -0,0 +1 @@ +package = [] diff --git a/packages/fixtures/validation-python/project-context/services/conflict/pyproject.toml.fixture b/packages/fixtures/validation-python/project-context/services/conflict/pyproject.toml.fixture new file mode 100644 index 0000000..d2d4134 --- /dev/null +++ b/packages/fixtures/validation-python/project-context/services/conflict/pyproject.toml.fixture @@ -0,0 +1,6 @@ +[project] +name = "fixture-conflict" +requires-python = ">=3.12" + +[tool.uv] +package = true diff --git a/packages/fixtures/validation-python/project-context/services/conflict/uv.lock.fixture b/packages/fixtures/validation-python/project-context/services/conflict/uv.lock.fixture new file mode 100644 index 0000000..d9914df --- /dev/null +++ b/packages/fixtures/validation-python/project-context/services/conflict/uv.lock.fixture @@ -0,0 +1 @@ +version = 1 diff --git a/packages/fixtures/validation-python/project-context/services/incompatible/app.py.fixture b/packages/fixtures/validation-python/project-context/services/incompatible/app.py.fixture new file mode 100644 index 0000000..b15b1b0 --- /dev/null +++ b/packages/fixtures/validation-python/project-context/services/incompatible/app.py.fixture @@ -0,0 +1 @@ +VALUE = 1 diff --git a/packages/fixtures/validation-python/project-context/services/incompatible/pyproject.toml.fixture b/packages/fixtures/validation-python/project-context/services/incompatible/pyproject.toml.fixture new file mode 100644 index 0000000..6d743a4 --- /dev/null +++ b/packages/fixtures/validation-python/project-context/services/incompatible/pyproject.toml.fixture @@ -0,0 +1,3 @@ +[project] +name = "fixture-incompatible" +requires-python = ">=3.13" diff --git a/packages/fixtures/validation-python/project-context/services/invalid/app.py.fixture b/packages/fixtures/validation-python/project-context/services/invalid/app.py.fixture new file mode 100644 index 0000000..b15b1b0 --- /dev/null +++ b/packages/fixtures/validation-python/project-context/services/invalid/app.py.fixture @@ -0,0 +1 @@ +VALUE = 1 diff --git a/packages/fixtures/validation-python/project-context/services/invalid/pyproject.toml.fixture b/packages/fixtures/validation-python/project-context/services/invalid/pyproject.toml.fixture new file mode 100644 index 0000000..f97156f --- /dev/null +++ b/packages/fixtures/validation-python/project-context/services/invalid/pyproject.toml.fixture @@ -0,0 +1,2 @@ +[project +name = "fixture-invalid" diff --git a/packages/fixtures/validation-python/project-context/services/mixed/helper.py.fixture b/packages/fixtures/validation-python/project-context/services/mixed/helper.py.fixture new file mode 100644 index 0000000..d3b6e65 --- /dev/null +++ b/packages/fixtures/validation-python/project-context/services/mixed/helper.py.fixture @@ -0,0 +1 @@ +VALUE: int = 1 diff --git a/packages/fixtures/validation-python/project-context/services/mixed/pyproject.toml.fixture b/packages/fixtures/validation-python/project-context/services/mixed/pyproject.toml.fixture new file mode 100644 index 0000000..06918e8 --- /dev/null +++ b/packages/fixtures/validation-python/project-context/services/mixed/pyproject.toml.fixture @@ -0,0 +1,3 @@ +[project] +name = "mixed" +requires-python = ">=3.12" diff --git a/packages/fixtures/validation-python/project-context/services/mixed/script.py.fixture b/packages/fixtures/validation-python/project-context/services/mixed/script.py.fixture new file mode 100644 index 0000000..cbb703d --- /dev/null +++ b/packages/fixtures/validation-python/project-context/services/mixed/script.py.fixture @@ -0,0 +1,3 @@ +import helper + +VALUE = helper.VALUE diff --git a/packages/fixtures/validation-python/project-context/services/mixed/src/pkg/__init__.py.fixture b/packages/fixtures/validation-python/project-context/services/mixed/src/pkg/__init__.py.fixture new file mode 100644 index 0000000..c8fcecd --- /dev/null +++ b/packages/fixtures/validation-python/project-context/services/mixed/src/pkg/__init__.py.fixture @@ -0,0 +1 @@ +VALUE = 2 diff --git a/packages/fixtures/validation-python/project-context/services/pdm/pdm.lock.fixture b/packages/fixtures/validation-python/project-context/services/pdm/pdm.lock.fixture new file mode 100644 index 0000000..b61538e --- /dev/null +++ b/packages/fixtures/validation-python/project-context/services/pdm/pdm.lock.fixture @@ -0,0 +1,2 @@ +[metadata] +lock_version = "4.5" diff --git a/packages/fixtures/validation-python/project-context/services/pdm/pyproject.toml.fixture b/packages/fixtures/validation-python/project-context/services/pdm/pyproject.toml.fixture new file mode 100644 index 0000000..2ae8ac4 --- /dev/null +++ b/packages/fixtures/validation-python/project-context/services/pdm/pyproject.toml.fixture @@ -0,0 +1,6 @@ +[project] +name = "fixture-pdm" +requires-python = ">=3.12" + +[tool.pdm] +distribution = true diff --git a/packages/fixtures/validation-python/project-context/services/pdm/src/pdm_pkg/app.py.fixture b/packages/fixtures/validation-python/project-context/services/pdm/src/pdm_pkg/app.py.fixture new file mode 100644 index 0000000..b15b1b0 --- /dev/null +++ b/packages/fixtures/validation-python/project-context/services/pdm/src/pdm_pkg/app.py.fixture @@ -0,0 +1 @@ +VALUE = 1 diff --git a/packages/fixtures/validation-python/project-context/services/poetry/pkg/__init__.py.fixture b/packages/fixtures/validation-python/project-context/services/poetry/pkg/__init__.py.fixture new file mode 100644 index 0000000..b15b1b0 --- /dev/null +++ b/packages/fixtures/validation-python/project-context/services/poetry/pkg/__init__.py.fixture @@ -0,0 +1 @@ +VALUE = 1 diff --git a/packages/fixtures/validation-python/project-context/services/poetry/poetry.lock.fixture b/packages/fixtures/validation-python/project-context/services/poetry/poetry.lock.fixture new file mode 100644 index 0000000..c4293b3 --- /dev/null +++ b/packages/fixtures/validation-python/project-context/services/poetry/poetry.lock.fixture @@ -0,0 +1 @@ +package = [] diff --git a/packages/fixtures/validation-python/project-context/services/poetry/pyproject.toml.fixture b/packages/fixtures/validation-python/project-context/services/poetry/pyproject.toml.fixture new file mode 100644 index 0000000..9ae2c56 --- /dev/null +++ b/packages/fixtures/validation-python/project-context/services/poetry/pyproject.toml.fixture @@ -0,0 +1,6 @@ +[tool.poetry] +name = "fixture-poetry" +version = "0.1.0" + +[tool.poetry.dependencies] +python = ">=3.12" diff --git a/packages/fixtures/validation-python/project-context/services/stubs/Pipfile.fixture b/packages/fixtures/validation-python/project-context/services/stubs/Pipfile.fixture new file mode 100644 index 0000000..980eed7 --- /dev/null +++ b/packages/fixtures/validation-python/project-context/services/stubs/Pipfile.fixture @@ -0,0 +1,5 @@ +[[source]] +url = "https://pypi.org/simple" + +[requires] +python_version = "3.12" diff --git a/packages/fixtures/validation-python/project-context/services/stubs/Pipfile.lock.fixture b/packages/fixtures/validation-python/project-context/services/stubs/Pipfile.lock.fixture new file mode 100644 index 0000000..a48e542 --- /dev/null +++ b/packages/fixtures/validation-python/project-context/services/stubs/Pipfile.lock.fixture @@ -0,0 +1 @@ +{"_meta":{"requires":{"python_version":"3.12"}},"default":{},"develop":{}} diff --git a/packages/fixtures/validation-python/project-context/services/stubs/src/sdk/__init__.pyi.fixture b/packages/fixtures/validation-python/project-context/services/stubs/src/sdk/__init__.pyi.fixture new file mode 100644 index 0000000..c816088 --- /dev/null +++ b/packages/fixtures/validation-python/project-context/services/stubs/src/sdk/__init__.pyi.fixture @@ -0,0 +1 @@ +def identity(value: str) -> str: ... diff --git a/packages/opcore/package.json b/packages/opcore/package.json index 997132f..b988add 100644 --- a/packages/opcore/package.json +++ b/packages/opcore/package.json @@ -85,6 +85,7 @@ "path-browserify", "picomatch", "semver", + "smol-toml", "tinyglobby", "ts-api-utils", "ts-morph", @@ -103,6 +104,7 @@ "@the-open-engine/opcore-validation-rust": "0.2.0", "@the-open-engine/opcore-validation-typescript": "0.2.0", "@typescript-eslint/typescript-estree": "^8.62.0", + "smol-toml": "1.6.0", "ts-morph": "^28.0.0", "typescript": "^5.9.3" }, diff --git a/packages/opcore/src/advanced/descriptor.ts b/packages/opcore/src/advanced/descriptor.ts index c527cc8..8063429 100644 --- a/packages/opcore/src/advanced/descriptor.ts +++ b/packages/opcore/src/advanced/descriptor.ts @@ -140,6 +140,12 @@ export function createOpcoreManagedToolDescriptor(options: OpcoreManagedToolDesc graphModes: graphProviderModes, hypothetical: true, statusSurfaces: ["status", "doctor"], + pythonProjectContext: { + schemaId: "opcore.python.project-context.v1", + outcomes: ["resolved", "degraded", "unsupported", "ambiguous"], + readOnly: true, + installs: false + }, writeGate: { initScopes: ["repo", "global"], harnesses: ["claude-code", "codex"], diff --git a/packages/opcore/src/advanced/router.ts b/packages/opcore/src/advanced/router.ts index 6a3c434..6501fa4 100644 --- a/packages/opcore/src/advanced/router.ts +++ b/packages/opcore/src/advanced/router.ts @@ -143,12 +143,12 @@ async function routeLattice( }); } -function routeRuntimeCommand( +async function routeRuntimeCommand( argv: readonly string[], parsed: ParsedCommandArgv, command: RuntimeCommand, rest: readonly string[] -): CommandRouterResult { +): Promise { if (command === "doctor") { return routeOpcoreDoctor(argv, { args: [command, ...rest], json: parsed.json }); } diff --git a/packages/opcore/src/advanced/validation-composition.ts b/packages/opcore/src/advanced/validation-composition.ts index 81802f5..5be63ae 100644 --- a/packages/opcore/src/advanced/validation-composition.ts +++ b/packages/opcore/src/advanced/validation-composition.ts @@ -1,6 +1,7 @@ import type { CommandAdapter, GraphProviderMode, + PythonProjectContext, ValidationRequest, ValidationResult, ValidationStatusPayload @@ -72,11 +73,15 @@ declare const process: { export function createDefaultValidationStatusPayload(options: { repoRoot: string; graphMode?: GraphProviderMode; + pythonProjectContexts?: readonly PythonProjectContext[]; }): ValidationStatusPayload { const graphMode = options.graphMode ?? "optional"; return createValidationStatusPayload({ checks: validationChecksForRepoPolicy(options.repoRoot), - adapters: [createRustValidationAdapterStatus(), createPythonValidationAdapterStatus({ repoRoot: options.repoRoot })], + adapters: [ + createRustValidationAdapterStatus(), + createPythonValidationAdapterStatus({ repoRoot: options.repoRoot, contexts: options.pythonProjectContexts }) + ], graphMode, graphStatus: cliGraphStatus({ repoRoot: options.repoRoot }, graphMode) }); diff --git a/packages/opcore/src/doctor.ts b/packages/opcore/src/doctor.ts index ea7fdf5..a34295c 100644 --- a/packages/opcore/src/doctor.ts +++ b/packages/opcore/src/doctor.ts @@ -4,6 +4,7 @@ import { existsSync, readFileSync } from "node:fs"; import { join } from "node:path"; import { readOpcoreRuntimeInfo } from "./runtime-info.js"; import { resolveRepo, validationPolicySummary } from "./status.js"; +import { createRepoState } from "./status-state.js"; import { createDefaultValidationStatusPayload } from "./validation-composition.js"; declare const process: { @@ -17,7 +18,7 @@ const generatedStateIgnores = [ ".robustness-engine-cache/" ]; -export function routeOpcoreDoctor(argv: readonly string[], parsed: ParsedCommandArgv): CommandRouterResult { +export async function routeOpcoreDoctor(argv: readonly string[], parsed: ParsedCommandArgv): Promise { const rest = parsed.args.slice(1); if (rest.some((arg) => helpArgs.has(arg))) { return createCommandRouterResult({ @@ -55,9 +56,11 @@ export function routeOpcoreDoctor(argv: readonly string[], parsed: ParsedCommand }); } + const repoState = await createRepoState(resolution.resolution); const validationStatus = createDefaultValidationStatusPayload({ repoRoot: resolution.resolution.root, - graphMode: "optional" + graphMode: "optional", + pythonProjectContexts: repoState.validation.pythonProjectContexts }); const runtimeInfo = readOpcoreRuntimeInfo(); const policy = validationPolicySummary(resolution.resolution.root, validationStatus.adapterRegistry.checkIds); diff --git a/packages/opcore/src/init.ts b/packages/opcore/src/init.ts index f688561..fc3d821 100644 --- a/packages/opcore/src/init.ts +++ b/packages/opcore/src/init.ts @@ -96,19 +96,6 @@ const allowedGlobalUndoPaths = new Set([ codexHooksPath ]); const rustActiveValidationKinds = new Set([".rs", ".inc", "Cargo.toml"]); -const pythonProjectFileNames = new Set(["pyproject.toml", "Pipfile", "poetry.lock", "uv.lock"]); -const pythonVirtualEnvDirs = new Set([".venv", "venv", "env"]); -const pythonDiscoverySkipDirs = new Set([ - ...commonSkippedPathSegments, - "__pycache__", - ".eggs", - "build", - ".tox", - ".mypy_cache", - ".pytest_cache", - ".ruff_cache", - "site-packages" -]); interface ParsedInitArgs { command: OpcoreSetupCommand; @@ -272,7 +259,7 @@ async function routeOpcoreSetup( } const context: InitContext = { scan: createInitScanSummary(analysis.repoState, analysis.validationResult), - settings: createInitSettings(analysis.repoState, resolution.resolution.root), + settings: createInitSettings(analysis.repoState), interaction: { tty: isInteractiveRuntime(runtime), promptState: "not_requested" @@ -682,14 +669,14 @@ function createInitScanSummary(repoState: OpcoreRepoStatePayload, validationResu }; } -function createInitSettings(repoState: OpcoreRepoStatePayload, repoRoot: string): OpcoreInitSettings { +function createInitSettings(repoState: OpcoreRepoStatePayload): OpcoreInitSettings { const unsupportedLanguages = new Set(repoState.coverage.unsupported.stacks.map((stack) => stack.language)); const degradedRustTools = repoState.validation.degradedToolchains.filter((tool) => tool.adapter === "rust").map((tool) => tool.tool); const degradedPythonTools = repoState.validation.degradedToolchains.filter((tool) => tool.adapter === "python").map((tool) => tool.tool); const rustHasActiveValidationInput = repoState.coverage.validation.extensions.some((entry) => rustActiveValidationKinds.has(entry.extension) ); - const pythonProject = detectPythonProjectSignals(repoRoot); + const pythonProject = pythonEnvironmentFromContexts(repoState.validation.pythonProjectContexts ?? []); return { languages: repoState.coverage.languages.map((language): OpcoreInitLanguageSetting => { const rustRetainedOnly = @@ -790,118 +777,55 @@ function notesForLanguage( return notes; } -function detectPythonProjectSignals(repoRoot: string): OpcoreInitPythonEnvironment { - const files: string[] = []; - const virtualEnvironmentPaths: string[] = []; - const stack = [""]; - while (stack.length > 0) { - const relativeDir = stack.pop(); - if (relativeDir === undefined) continue; - let entries: ReturnType; - try { - entries = readdirSync(relativeDir.length > 0 ? resolveRepoPath(repoRoot, relativeDir) : repoRoot, { withFileTypes: true }); - } catch { - continue; +function pythonEnvironmentFromContexts( + contexts: NonNullable +): OpcoreInitPythonEnvironment { + const managerKind = { + pip: "requirements", + uv: "uv", + poetry: "poetry", + pdm: "pyproject", + pipenv: "pipfile" + } as const; + const managers = new Map(); + const environments = new Map(); + for (const context of contexts) { + for (const manager of context.managers) { + const path = manager.lockFiles[0] ?? manager.configFiles[0]; + if (path !== undefined) { + const value = { kind: managerKind[manager.kind], path }; + managers.set(`${value.kind}\0${value.path}`, value); + } } - for (const entry of entries) { - const relativePath = relativeDir.length > 0 ? `${relativeDir}/${entry.name}` : entry.name; - const entryStat = lstatIfExists(resolveRepoPath(repoRoot, relativePath)); - if (!entryStat || entryStat.isSymbolicLink()) continue; - if (entry.isDirectory()) { - if (pythonVirtualEnvDirs.has(entry.name)) { - virtualEnvironmentPaths.push(relativePath); - continue; - } - if (pythonDiscoverySkipDirs.has(entry.name) || entry.name.endsWith(".egg-info") || entry.name.endsWith(".dist-info")) { - continue; - } - stack.push(relativePath); - continue; + if (context.interpreter?.source === "project_local_environment") { + const executable = context.interpreter.executable.replaceAll("\\", "/"); + const suffix = executable.match(/\/(?:bin\/python[^/]*|Scripts\/python\.exe|python\.exe)$/u)?.[0]; + if (suffix !== undefined) { + const environmentRoot = executable.slice(0, -suffix.length); + const path = relative(context.repositoryRoot, environmentRoot).replaceAll("\\", "/"); + if (path.length > 0 && !path.startsWith("..")) environments.set(path, { kind: "venv", path }); } - if (entry.isFile() && isPythonProjectFile(entry.name)) files.push(relativePath); } } - const uniqueFiles = uniqueStrings(files).sort(); - const dependencyManagers = pythonDependencyManagers(uniqueFiles); - const virtualEnvironments = uniqueStrings(virtualEnvironmentPaths) - .sort() - .map((path) => ({ kind: "venv" as const, path })); + const projectRoots = [...new Set(contexts.map((context) => context.projectRoot))].sort(); + const outcomes = [...new Set(contexts.map((context) => context.outcome))].sort(); + const evidenceFiles = [...new Set(contexts.flatMap((context) => context.evidence.map((entry) => entry.path)))].sort(); return { - dependencyManagers, - virtualEnvironments, - notes: pythonEnvironmentNotes(uniqueFiles, dependencyManagers, virtualEnvironments) + dependencyManagers: [...managers.values()].sort((left, right) => left.path.localeCompare(right.path)), + virtualEnvironments: [...environments.values()].sort((left, right) => left.path.localeCompare(right.path)), + notes: contexts.length === 0 + ? [] + : [ + `Canonical Python project contexts: ${projectRoots.join(", ") || "."}.`, + `Canonical Python project evidence: ${evidenceFiles.join(", ")}.`, + `Python context outcomes: ${outcomes.join(", ")}.` + ], + contexts }; } -function isPythonProjectFile(name: string): boolean { - return pythonProjectFileNames.has(name) || /^requirements.*\.txt$/u.test(name); -} - -function pythonDependencyManagers(files: readonly string[]): OpcoreInitPythonEnvironment["dependencyManagers"] { - const managers: OpcoreInitPythonEnvironment["dependencyManagers"][number][] = []; - const seen = new Set(); - for (const file of files) { - const name = file.split("/").at(-1) ?? file; - const kind = pythonDependencyManagerKind(name); - if (kind === undefined) continue; - const key = `${kind}\0${file}`; - if (seen.has(key)) continue; - seen.add(key); - managers.push({ kind, path: file }); - } - return managers.sort((left, right) => left.path.localeCompare(right.path) || left.kind.localeCompare(right.kind)); -} - -function pythonDependencyManagerKind( - name: string -): OpcoreInitPythonEnvironment["dependencyManagers"][number]["kind"] | undefined { - if (name === "pyproject.toml") return "pyproject"; - if (/^requirements.*\.txt$/u.test(name)) return "requirements"; - if (name === "Pipfile") return "pipfile"; - if (name === "poetry.lock") return "poetry"; - if (name === "uv.lock") return "uv"; - return undefined; -} - -function pythonEnvironmentNotes( - files: readonly string[], - dependencyManagers: OpcoreInitPythonEnvironment["dependencyManagers"], - virtualEnvironments: OpcoreInitPythonEnvironment["virtualEnvironments"] -): string[] { - const notes: string[] = []; - if (files.length > 0) { - notes.push(`Detected Python project files: ${formatExamples(files)}.`); - } - if (dependencyManagers.length > 0) { - notes.push(`Detected Python dependency managers: ${formatManagerKinds(dependencyManagers)}.`); - } - if (virtualEnvironments.length > 0) { - notes.push(`Detected Python virtualenv directories: ${formatExamples(virtualEnvironments.map((entry) => entry.path))}.`); - } - if (files.length > 0 || dependencyManagers.length > 0 || virtualEnvironments.length > 0) { - notes.push("Python graph and validation coverage is reported with missing tools as degraded coverage."); - } - return notes; -} - -function formatManagerKinds(dependencyManagers: OpcoreInitPythonEnvironment["dependencyManagers"]): string { - const labels = new Map([ - ["pyproject", "pyproject"], - ["requirements", "pip requirements"], - ["pipfile", "Pipenv"], - ["poetry", "Poetry"], - ["uv", "uv"] - ]); - return [...new Set(dependencyManagers.map((entry) => labels.get(entry.kind) ?? entry.kind))].sort().join(", "); -} - function hasPythonEnvironmentSignals(environment: OpcoreInitPythonEnvironment): boolean { - return environment.dependencyManagers.length > 0 || environment.virtualEnvironments.length > 0 || environment.notes.length > 0; -} - -function formatExamples(values: readonly string[]): string { - const visible = values.slice(0, 5).join(", "); - return values.length > 5 ? `${visible}, +${values.length - 5} more` : visible; + return (environment.contexts?.length ?? 0) > 0; } function withContext(payload: OpcoreInitPlanPayload, context: InitContext, timing: TimingState): OpcoreInitPlanPayload { diff --git a/packages/opcore/src/repo-validation-policy.ts b/packages/opcore/src/repo-validation-policy.ts index 2af7376..dff56fb 100644 --- a/packages/opcore/src/repo-validation-policy.ts +++ b/packages/opcore/src/repo-validation-policy.ts @@ -4,6 +4,8 @@ import { validationChecksForRepoPolicyAndCoverage as validationChecksForSharedRepoPolicyAndCoverage } from "@the-open-engine/opcore-validation-policy"; import { invokeCloneAnalysis } from "./clone-invoker.js"; +import type { PythonProjectContext } from "@the-open-engine/opcore-contracts"; +import { createNodePythonProjectWorkspace } from "@the-open-engine/opcore-validation-python"; const opcoreValidationPolicyOptions = { clone: { @@ -14,9 +16,20 @@ const opcoreValidationPolicyOptions = { export const defaultValidationChecks = createBuiltInValidationChecks(undefined, opcoreValidationPolicyOptions); export function validationChecksForRepoPolicy(repoRoot: string) { - return validationChecksForSharedRepoPolicy(repoRoot, opcoreValidationPolicyOptions); + return validationChecksForSharedRepoPolicy(repoRoot, { + ...opcoreValidationPolicyOptions, + pythonWorkspace: createNodePythonProjectWorkspace(repoRoot) + }); } -export function validationChecksForRepoPolicyAndCoverage(repoRoot: string, adapters: ReadonlySet) { - return validationChecksForSharedRepoPolicyAndCoverage(repoRoot, adapters, opcoreValidationPolicyOptions); +export function validationChecksForRepoPolicyAndCoverage( + repoRoot: string, + adapters: ReadonlySet, + pythonProjectContexts?: readonly PythonProjectContext[] +) { + return validationChecksForSharedRepoPolicyAndCoverage(repoRoot, adapters, { + ...opcoreValidationPolicyOptions, + pythonWorkspace: createNodePythonProjectWorkspace(repoRoot), + ...(pythonProjectContexts === undefined ? {} : { pythonProjectContexts }) + }); } diff --git a/packages/opcore/src/reporting.ts b/packages/opcore/src/reporting.ts index eb13ec3..b278141 100644 --- a/packages/opcore/src/reporting.ts +++ b/packages/opcore/src/reporting.ts @@ -283,7 +283,9 @@ export function createOpcoreMetricReport(input: CreateOpcoreMetricReportInput): ...(validationResult?.status ? { status: validationResult.status } : {}), diagnosticCount: validationResult?.diagnostics.length ?? 0, checkCount: validationResult?.manifest?.checks.length ?? input.repoState.validation.checkCount, - policy: input.repoState.validation.policy + policy: input.repoState.validation.policy, + pythonProjectContexts: + validationResult?.pythonProjectContexts ?? input.repoState.validation.pythonProjectContexts ?? [] }, signals: sortSignals(signals), degradations: sortDegradations(degradations), diff --git a/packages/opcore/src/scan-validation-preview.ts b/packages/opcore/src/scan-validation-preview.ts index 79f0490..9d0a6d6 100644 --- a/packages/opcore/src/scan-validation-preview.ts +++ b/packages/opcore/src/scan-validation-preview.ts @@ -16,6 +16,9 @@ export function compactScanValidationResult( if (result.graphStatus !== undefined) compact.graphStatus = result.graphStatus; if (result.failure !== undefined) compact.failure = result.failure; if (result.refusal !== undefined) compact.refusal = result.refusal; + if (result.pythonProjectContexts !== undefined) { + compact.pythonProjectContexts = [...result.pythonProjectContexts]; + } if (result.manifest !== undefined) { compact.manifest = { schemaVersion: result.manifest.schemaVersion, diff --git a/packages/opcore/src/scan.ts b/packages/opcore/src/scan.ts index dbf8422..d632eac 100644 --- a/packages/opcore/src/scan.ts +++ b/packages/opcore/src/scan.ts @@ -92,7 +92,7 @@ export async function routeOpcoreScan( } export async function createOpcoreScanAnalysis(resolution: RepoResolution): Promise { - const repoState = createRepoState(resolution); + const repoState = await createRepoState(resolution); const graphMode: GraphProviderMode = repoState.graph.state === "available" ? "required" : "optional"; const validationRequest: ValidationRequest = { repo: { @@ -142,7 +142,11 @@ function createScanValidationPlan(repoState: OpcoreRepoStatePayload): { }); } return { - checks: validationChecksForRepoPolicyAndCoverage(repoState.repo.root, activeAdapters), + checks: validationChecksForRepoPolicyAndCoverage( + repoState.repo.root, + activeAdapters, + repoState.validation.pythonProjectContexts + ), skippedChecks }; } diff --git a/packages/opcore/src/serve-telemetry.ts b/packages/opcore/src/serve-telemetry.ts index 4d7a77c..ea26ca6 100644 --- a/packages/opcore/src/serve-telemetry.ts +++ b/packages/opcore/src/serve-telemetry.ts @@ -5,38 +5,39 @@ import { createRepoState, resolveRepo } from "./status.js"; import { createCommandLatencyRecord } from "./timing.js"; export function createGraphServeTelemetry(): GraphServeTelemetry { - const repoStateCache = new Map(); + const repoStateCache = new Map>(); return { recordFrameTiming(event): void { const repoRoot = event.repo.repoRoot; if (!repoRoot) return; - const repoState = cachedRepoState(repoStateCache, repoRoot); - if (!repoState) return; - const result: CommandRouterResult = { - schemaVersion: 1, - bin: "opcore", - argv: event.canonicalCommand, - canonicalCommand: event.canonicalCommand, - owner: event.owner, - status: event.status, - exitCode: event.exitCode, - message: `${event.canonicalCommand.join(" ")} frame timing`, - json: false, - timing: event.timing - }; - writeCommandLatencyTelemetry(repoState.repo.root, createCommandLatencyRecord(result, repoState)); + void cachedRepoState(repoStateCache, repoRoot).then((repoState) => { + if (!repoState) return; + const result: CommandRouterResult = { + schemaVersion: 1, + bin: "opcore", + argv: event.canonicalCommand, + canonicalCommand: event.canonicalCommand, + owner: event.owner, + status: event.status, + exitCode: event.exitCode, + message: `${event.canonicalCommand.join(" ")} frame timing`, + json: false, + timing: event.timing + }; + writeCommandLatencyTelemetry(repoState.repo.root, createCommandLatencyRecord(result, repoState)); + }); } }; } function cachedRepoState( - cache: Map, + cache: Map>, repoRoot: string -): OpcoreRepoStatePayload | undefined { +): Promise { const cached = cache.get(repoRoot); if (cached) return cached; const resolution = resolveRepo(repoRoot, "opcore graph serve telemetry"); - if (!resolution.ok) return undefined; + if (!resolution.ok) return Promise.resolve(undefined); const repoState = createRepoState(resolution.resolution); cache.set(repoRoot, repoState); return repoState; diff --git a/packages/opcore/src/status-state.ts b/packages/opcore/src/status-state.ts index 2d96e1b..6de0d0c 100644 --- a/packages/opcore/src/status-state.ts +++ b/packages/opcore/src/status-state.ts @@ -1,4 +1,9 @@ import type { GraphProviderStatus, OpcoreRepoStatePayload } from "@the-open-engine/opcore-contracts"; +import { + createNodePythonProjectWorkspace, + isPythonSourcePath, + resolvePythonProjectContexts +} from "@the-open-engine/opcore-validation-python"; import { existsSync } from "node:fs"; import { join } from "node:path"; import { @@ -18,16 +23,25 @@ interface StatusWarningOptions { traversalFailures: readonly CensusTraversalFailure[]; } -export function createRepoState(resolution: RepoResolution): OpcoreRepoStatePayload { +export async function createRepoState(resolution: RepoResolution): Promise { + const census = readRepoCensus(resolution); + const coverage = computeCoverage(census.files); + const pythonTargets = census.files.filter(isPythonSourcePath); + const pythonProjectContexts = pythonTargets.length === 0 + ? [] + : await resolvePythonProjectContexts({ + repoRoot: resolution.root, + targets: pythonTargets, + workspace: createNodePythonProjectWorkspace(resolution.root) + }); const validationStatus = createDefaultValidationStatusPayload({ repoRoot: resolution.root, - graphMode: "optional" + graphMode: "optional", + pythonProjectContexts }); - const census = readRepoCensus(resolution); - const coverage = computeCoverage(census.files); const graphStatus = validationStatus.graph.status; const policy = validationPolicySummary(resolution.root, validationStatus.adapterRegistry.checkIds); - const validation = validationSummary(validationStatus, coverage, policy); + const validation = validationSummary(validationStatus, coverage, policy, pythonProjectContexts); const blockers = statusBlockers(graphStatus, census.traversalFailures); const level = activationLevel(graphStatus, validation.ready, blockers); const warningOptions = { coverage, validation, graphStatus, traversalFailures: census.traversalFailures }; diff --git a/packages/opcore/src/status-validation.ts b/packages/opcore/src/status-validation.ts index 4f950dc..965f980 100644 --- a/packages/opcore/src/status-validation.ts +++ b/packages/opcore/src/status-validation.ts @@ -1,6 +1,7 @@ import type { OpcoreRepoStatePayload, OpcoreValidationPolicySummary, + PythonProjectContext, ValidationAdapterRuntimeStatus } from "@the-open-engine/opcore-contracts"; import { existsSync } from "node:fs"; @@ -12,7 +13,8 @@ import type { createDefaultValidationStatusPayload } from "./validation-composit export function validationSummary( validationStatus: ReturnType, coverage: OpcoreRepoStatePayload["coverage"], - policy: OpcoreValidationPolicySummary + policy: OpcoreValidationPolicySummary, + pythonProjectContexts: readonly PythonProjectContext[] = [] ): OpcoreRepoStatePayload["validation"] { const adapters = validationStatus.adapterRegistry.adapters ?? []; const degraded = adapters.flatMap((adapter) => degradedToolchains(adapter)); @@ -20,6 +22,7 @@ export function validationSummary( ready: validationStatus.ready, checkCount: validationStatus.adapterRegistry.checkIds.length, policy, + pythonProjectContexts, adapters: adapters.map((adapter) => ({ adapter: adapter.adapter, status: adapter.status, diff --git a/packages/opcore/src/status.ts b/packages/opcore/src/status.ts index 0c0c047..91cc2c7 100644 --- a/packages/opcore/src/status.ts +++ b/packages/opcore/src/status.ts @@ -21,7 +21,7 @@ export { resolveRepo, type RepoResolution } from "./status-repo.js"; export { createRepoState } from "./status-state.js"; export { validationPolicySummary } from "./status-validation.js"; -export function routeOpcoreStatus(argv: readonly string[], parsed: ParsedCommandArgv): CommandRouterResult { +export async function routeOpcoreStatus(argv: readonly string[], parsed: ParsedCommandArgv): Promise { const rest = parsed.args.slice(1); if (rest.some((arg) => isStatusHelpArg(arg))) return statusHelpResult(argv, parsed); const parsedStatus = parseOpcoreStatusArgs(rest); @@ -31,7 +31,7 @@ export function routeOpcoreStatus(argv: readonly string[], parsed: ParsedCommand let repoState: OpcoreRepoStatePayload; try { - repoState = createRepoState(resolution.resolution); + repoState = await createRepoState(resolution.resolution); } catch (error) { return statusErrorResult(argv, parsed, errorMessage(error)); } diff --git a/packages/opcore/src/validation-composition.ts b/packages/opcore/src/validation-composition.ts index 351995c..6607d43 100644 --- a/packages/opcore/src/validation-composition.ts +++ b/packages/opcore/src/validation-composition.ts @@ -1,6 +1,7 @@ import type { CommandAdapter, GraphProviderMode, + PythonProjectContext, ValidationRequest, ValidationResult, ValidationStatusPayload @@ -67,11 +68,18 @@ export const opcoreValidationRunner = { export function createDefaultValidationStatusPayload(options: { repoRoot: string; graphMode?: GraphProviderMode; + pythonProjectContexts?: readonly PythonProjectContext[]; }): ValidationStatusPayload { const graphMode = options.graphMode ?? "optional"; return createValidationStatusPayload({ checks: validationChecksForRepoPolicy(options.repoRoot), - adapters: [createRustValidationAdapterStatus(), createPythonValidationAdapterStatus({ repoRoot: options.repoRoot })], + adapters: [ + createRustValidationAdapterStatus(), + createPythonValidationAdapterStatus({ + repoRoot: options.repoRoot, + contexts: options.pythonProjectContexts + }) + ], graphMode, graphStatus: opcoreGraphStatus({ repoRoot: options.repoRoot }, graphMode) }); diff --git a/packages/validation-policy/src/config.ts b/packages/validation-policy/src/config.ts index 2140ab1..8cd5ea6 100644 --- a/packages/validation-policy/src/config.ts +++ b/packages/validation-policy/src/config.ts @@ -7,6 +7,18 @@ export function readOpcoreRepoConfig(repoRoot: string): OpcoreRepoConfig { return parsed === undefined ? defaultRepoConfig() : { validation: readValidationConfig(parsed.validation) }; } +export function parseOpcoreRepoConfig(content: string | undefined): OpcoreRepoConfig { + if (content === undefined) return defaultRepoConfig(); + let parsed: unknown; + try { + parsed = JSON.parse(content); + } catch (error) { + throw new Error(`Invalid Opcore config ${configPath}: ${errorMessage(error)}`); + } + if (!isPlainObject(parsed)) throw new Error(`Invalid Opcore config ${configPath}: expected JSON object`); + return { validation: readValidationConfig(parsed.validation) }; +} + export function defaultRepoConfig(): OpcoreRepoConfig { return { validation: { checks: defaultChecksConfig() } }; } diff --git a/packages/validation-policy/src/factory.ts b/packages/validation-policy/src/factory.ts index 314a5e6..fb8fc86 100644 --- a/packages/validation-policy/src/factory.ts +++ b/packages/validation-policy/src/factory.ts @@ -3,7 +3,7 @@ import type { ValidationCheckDefinition } from "@the-open-engine/opcore-validati import { createValidationCheckRegistry } from "@the-open-engine/opcore-validation"; import { createCloneValidationChecks } from "@the-open-engine/opcore-validation-clone"; import { createDocsValidationChecks, type CreateDocsValidationChecksOptions } from "@the-open-engine/opcore-validation-docs"; -import { createPythonValidationChecks } from "@the-open-engine/opcore-validation-python"; +import { createNodePythonProjectWorkspace, createPythonValidationChecks } from "@the-open-engine/opcore-validation-python"; import { createRustValidationChecks } from "@the-open-engine/opcore-validation-rust"; import { createTypeScriptValidationChecks } from "@the-open-engine/opcore-validation-typescript"; import { readOpcoreRepoConfig } from "./config.js"; @@ -35,6 +35,7 @@ export function createBuiltInValidationChecks( repoRoot?: string ): readonly ValidationCheckDefinition[] { const checksConfig = config?.validation.checks; + const pythonWorkspace = options.pythonWorkspace ?? (repoRoot === undefined ? undefined : createNodePythonProjectWorkspace(repoRoot)); return [ ...createTypeScriptValidationChecks({ fileLength: checksConfig?.typescript?.fileLength, @@ -51,7 +52,12 @@ export function createBuiltInValidationChecks( deadCode: checksConfig?.typescript?.deadCode }), ...createRustValidationChecks(rustValidationOptions(config)), - ...createPythonValidationChecks(repoRoot !== undefined ? { repoRoot } : {}), + ...createPythonValidationChecks( + { + ...(options.pythonProjectContexts === undefined ? {} : { contexts: options.pythonProjectContexts }), + ...(pythonWorkspace === undefined ? {} : { nodeWorkspace: pythonWorkspace }) + } + ), ...createDocsValidationChecks(docsValidationOptions(checksConfig?.docs)), ...cloneChecks(checksConfig?.clone, options) ]; diff --git a/packages/validation-policy/src/types.ts b/packages/validation-policy/src/types.ts index ecf8dea..c395fa0 100644 --- a/packages/validation-policy/src/types.ts +++ b/packages/validation-policy/src/types.ts @@ -1,4 +1,6 @@ import type { ValidationCheckDefinition } from "@the-open-engine/opcore-validation"; +import type { PythonProjectContext } from "@the-open-engine/opcore-contracts"; +import type { PythonProjectWorkspace } from "@the-open-engine/opcore-validation-python"; import type { CloneNativeInvoker } from "@the-open-engine/opcore-validation-clone"; export const configPath = ".opcore/config"; @@ -112,6 +114,8 @@ export interface OpcoreCheckPack { } export interface OpcoreRepoValidationPolicyOptions { + pythonProjectContexts?: readonly PythonProjectContext[]; + pythonWorkspace?: PythonProjectWorkspace; clone?: false | { invoke?: CloneNativeInvoker["invoke"]; }; diff --git a/packages/validation-python/README.md b/packages/validation-python/README.md index 291e4cf..243e269 100644 --- a/packages/validation-python/README.md +++ b/packages/validation-python/README.md @@ -1,3 +1,11 @@ # @the-open-engine/opcore-validation-python Internal Opcore validation adapter package for the gated Python language-support workstream. Public Python enablement remains blocked on installed-artifact receipts and maintainer approval. + +This package owns the canonical, read-only `opcore.python.project-context.v1` resolver. It resolves each `.py`/`.pyi` target to its nearest project boundary, layout/source roots, static manager/build/tool configuration, declared runtime, and exact interpreter/tool provenance. It never installs or synchronizes dependencies, creates environments, executes project imports/setup/package code, writes workspace files, or keeps a persistent cache. + +Consumers must inject a workspace view with read/list/exists/realpath operations. Missing realpath evidence is an ambiguous context, not an assumed non-symlink. Validation uses `ValidationFileView` after-state adapters so written and deleted config/source overlays share one identity; ASP uses host workspace callbacks only. Node status and installed-artifact surfaces use the read-only Node adapter. TOML decisions consume the parsed `smol-toml` AST, static build metadata is retained, and build availability is probed independently. All consumers reuse the resolver result instead of scanning project/config/environment filenames independently. + +Precedence is nearest project, explicit per-tool override, compatible active environment, project-local `.venv`/`venv`/`env`, safe installed manager evidence, then PATH. Conflicting same-root manager or target evidence is ambiguous. Missing tools and probe timeout/signal/spawn/exit/malformed-output failures remain typed and visible; unresolved required contexts cannot produce a clean Python check. + +Run `npm run python:resolver-matrix -- --json` for checked fixture evidence covering real POSIX execution and explicitly simulated POSIX/Windows layouts. diff --git a/packages/validation-python/package.json b/packages/validation-python/package.json index 3c7777b..67f5074 100644 --- a/packages/validation-python/package.json +++ b/packages/validation-python/package.json @@ -25,7 +25,8 @@ ], "dependencies": { "@the-open-engine/opcore-contracts": "0.2.0", - "@the-open-engine/opcore-validation": "0.2.0" + "@the-open-engine/opcore-validation": "0.2.0", + "smol-toml": "1.6.0" }, "engines": { "node": ">=22" diff --git a/packages/validation-python/src/compiler-protocol.ts b/packages/validation-python/src/compiler-protocol.ts index 0446d3f..a8f19ec 100644 --- a/packages/validation-python/src/compiler-protocol.ts +++ b/packages/validation-python/src/compiler-protocol.ts @@ -1,7 +1,6 @@ -import type { ValidationDiagnosticToolProvenance } from "@the-open-engine/opcore-contracts"; +import type { PythonInterpreterProvenance, ValidationDiagnosticToolProvenance } from "@the-open-engine/opcore-contracts"; import { hasExactProtocolKeys, hasOnlyProtocolKeys, isProtocolRecord } from "./protocol-validation.js"; import type { PythonMaterializedSourceFile } from "./source-files.js"; -import type { ResolvedPythonInterpreter } from "./toolchain-resolver.js"; export const PYTHON_COMPILE_PROTOCOL = "opcore.python.compile.v1"; @@ -105,7 +104,7 @@ export function compilerRequest(files: readonly PythonMaterializedSourceFile[]): export function parseCompilerResponse( stdout: string, files: readonly PythonMaterializedSourceFile[], - interpreter: ResolvedPythonInterpreter + interpreter: PythonInterpreterProvenance ): PythonCompilerProtocolResult { const parsed = parseJsonObject(stdout); if (parsed === undefined || !hasExactProtocolKeys(parsed, ["protocol", "interpreter", "results"])) { @@ -126,10 +125,10 @@ export function parseCompilerResponse( return { status: "parsed", findings }; } -export function compilerToolProvenance(interpreter: ResolvedPythonInterpreter): ValidationDiagnosticToolProvenance { +export function compilerToolProvenance(interpreter: PythonInterpreterProvenance): ValidationDiagnosticToolProvenance { return { name: "python", - command: interpreter.command, + command: interpreter.argv.join(" "), version: interpreter.version, source: interpreter.source, cwd: interpreter.cwd @@ -190,9 +189,9 @@ function validCompilerLocationShape(location: Omit | undefined { diff --git a/packages/validation-python/src/environment-resolution.ts b/packages/validation-python/src/environment-resolution.ts new file mode 100644 index 0000000..10c4897 --- /dev/null +++ b/packages/validation-python/src/environment-resolution.ts @@ -0,0 +1,767 @@ +import type { + PythonInterpreterProvenance, + PythonProjectBuildSystem, + PythonProjectContextReason, + PythonProjectExecutableSource, + PythonProjectManagerEvidence, + PythonProjectTarget, + PythonProjectToolKind, + PythonProjectToolProvenance +} from "@the-open-engine/opcore-contracts"; +import { existsSync } from "node:fs"; +import { delimiter, isAbsolute, resolve } from "node:path"; +import { runTool, type PythonToolRunOptions, type PythonToolRunResult } from "./process.js"; +import type { PythonProjectWorkspace } from "./project-workspace.js"; +import { isSupportedPythonVersionConstraint, pythonVersionSatisfiesConstraint } from "./version-constraint.js"; + +export interface PythonProjectProcessProbe { + run(command: string, args: readonly string[], options: PythonToolRunOptions): PythonToolRunResult; + resolveExecutable?(command: string, options: { env: Readonly>; platform: string }): string | undefined; +} + +export interface PythonProjectEnvironmentOptions { + repoRoot: string; + projectRoot: string; + workspace: PythonProjectWorkspace; + platform?: string; + architecture?: string; + env?: Readonly>; + interpreterArgv?: readonly string[]; + toolArgv?: Partial>; + target: PythonProjectTarget; + managers: readonly PythonProjectManagerEvidence[]; + toolConfigs: Readonly>; + buildSystem?: PythonProjectBuildSystem; + processProbe?: PythonProjectProcessProbe; + timeoutMs?: number; +} + +export interface PythonProjectEnvironmentResolution { + interpreter?: PythonInterpreterProvenance; + tools: readonly PythonProjectToolProvenance[]; + reasons: readonly PythonProjectContextReason[]; + fingerprintInput: unknown; +} + +interface ExecutableCandidate { + argv: readonly string[]; + source: PythonProjectExecutableSource; +} + +interface ManagerExecutableCandidates { + interpreters: readonly ExecutableCandidate[]; + tools: Readonly>>; +} + +const interpreterProbeScript = [ + "import json, platform, sys, sysconfig", + "print(json.dumps({'protocol':'opcore.python.project-context.interpreter.v1','executable':sys.executable,'version':platform.python_version(),'implementation':platform.python_implementation(),'platform':sys.platform,'architecture':platform.machine(),'abi':getattr(sys.implementation,'cache_tag',None),'soabi':sysconfig.get_config_var('SOABI')}))" +].join("\n"); + +const buildProbeScript = [ + "import importlib.metadata, importlib.util, json", + "available = importlib.util.find_spec('build') is not None", + "version = None", + "if available:", + " try:", + " version = importlib.metadata.version('build')", + " except importlib.metadata.PackageNotFoundError:", + " pass", + "print(json.dumps({'protocol':'opcore.python.project-context.build.v1','available':available,'version':version}))" +].join("\n"); + +export async function resolvePythonProjectEnvironment( + options: PythonProjectEnvironmentOptions +): Promise { + const env = effectiveProcessEnvironment(options.env); + const effectiveOptions = { ...options, env }; + const platform = options.platform ?? process.platform; + if (!["linux", "darwin", "win32"].includes(platform)) { + return { + tools: [], + reasons: [{ code: "unsupported_platform", message: `Unsupported Python project platform: ${platform}` }], + fingerprintInput: { + platform, + architecture: options.architecture, + environment: sanitizedFingerprintEnvironment(env, options.projectRoot), + explicit: { interpreterArgv: options.interpreterArgv, toolArgv: options.toolArgv } + } + }; + } + const processProbe = options.processProbe ?? { run: runTool, resolveExecutable: resolvePathExecutable }; + const reasons: PythonProjectContextReason[] = []; + const managerCandidates = await resolveManagerExecutableCandidates(effectiveOptions, reasons); + const interpreter = await resolveInterpreter(effectiveOptions, processProbe, env, managerCandidates, reasons); + const tools = await resolveTools(effectiveOptions, processProbe, env, interpreter, managerCandidates, reasons); + return { + ...(interpreter === undefined ? {} : { interpreter }), + tools, + reasons, + fingerprintInput: { + platform: options.platform ?? process.platform, + architecture: options.architecture, + environment: sanitizedFingerprintEnvironment(env, options.projectRoot), + explicit: { interpreterArgv: options.interpreterArgv, toolArgv: options.toolArgv }, + interpreter, + tools, + failures: reasons.map((reason) => ({ code: reason.code, tool: reason.tool })) + } + }; +} + +async function resolveInterpreter( + options: PythonProjectEnvironmentOptions, + processProbe: PythonProjectProcessProbe, + env: Record, + managerCandidates: ManagerExecutableCandidates, + reasons: PythonProjectContextReason[] +): Promise { + const candidates = interpreterCandidates(options, managerCandidates, reasons); + if (options.interpreterArgv !== undefined && candidates.length === 0) return undefined; + let pathFailure: PythonProjectContextReason | undefined; + for (const candidate of candidates) { + if (!(await candidateAvailable(options.workspace, candidate.argv[0]))) continue; + const command = candidate.argv[0]; + const prefix = candidate.argv.slice(1); + const args = [...prefix, "-I", "-B", "-c", interpreterProbeScript]; + const result = processProbe.run(command, args, { + cwd: projectCwd(options), env, timeoutMs: options.timeoutMs ?? 10000 + }); + if (!result.ok) { + const failure = probeFailureReason(result, "python"); + if (candidate.source !== "path") { + reasons.push(failure); + return undefined; + } + pathFailure = failure; + continue; + } + const metadata = parseInterpreterMetadata(result.stdout); + if (metadata === undefined) { + const failure = { + code: "malformed_probe_output" as const, + tool: "python", + message: "Python interpreter probe returned malformed metadata" + }; + if (candidate.source !== "path") { + reasons.push(failure); + return undefined; + } + pathFailure = failure; + continue; + } + const runnable = runnableInterpreterArgv(candidate, metadata.executable, processProbe, env, options.platform ?? process.platform); + const provenance: PythonInterpreterProvenance = { + executable: runnable[0], + argv: runnable, + cwd: projectCwd(options), + source: candidate.source, + version: metadata.version, + implementation: metadata.implementation, + platform: metadata.platform, + architecture: metadata.architecture, + abi: metadata.abi, + soabi: metadata.soabi + }; + const unsupported = unsupportedTargetConstraint(options.target); + if (unsupported !== undefined) { + reasons.push({ code: "unsupported_target", tool: "python", message: unsupported }); + return provenance; + } + const incompatibility = targetIncompatibility(provenance, options.target); + if (incompatibility !== undefined) { + reasons.push({ code: "incompatible_interpreter", tool: "python", message: incompatibility }); + return provenance; + } + return provenance; + } + if (pathFailure !== undefined) reasons.push(pathFailure); + reasons.push({ code: "interpreter_unavailable", tool: "python", message: `No Python interpreter is available for ${options.projectRoot}` }); + return undefined; +} + +function unsupportedTargetConstraint(target: PythonProjectTarget): string | undefined { + for (const constraint of [target.requiresPython, target.version]) { + if (constraint === undefined) continue; + if (!isSupportedPythonVersionConstraint(constraint)) { + return `Unsupported Python target constraint: ${constraint}`; + } + } + return undefined; +} + +async function resolveTools( + options: PythonProjectEnvironmentOptions, + processProbe: PythonProjectProcessProbe, + env: Record, + interpreter: PythonInterpreterProvenance | undefined, + managerCandidates: ManagerExecutableCandidates, + reasons: PythonProjectContextReason[] +): Promise { + const toolKinds: PythonProjectToolKind[] = ["mypy", "pyright", "ruff", "pytest"]; + const tools: PythonProjectToolProvenance[] = []; + for (const tool of toolKinds) { + const configFile = tool === "mypy" || tool === "pyright" || tool === "ruff" || tool === "pytest" + ? options.toolConfigs[tool] + : undefined; + const candidates = toolCandidates(tool, options, managerCandidates, reasons); + if (options.toolArgv?.[tool] !== undefined && candidates.length === 0) { + const override = options.toolArgv[tool]; + if (override === undefined) throw new Error(`Python ${tool} argv override is required`); + tools.push({ + tool, + available: false, + executable: override[0], + argv: [...override], + cwd: projectCwd(options), + source: "explicit_override", + ...(configFile === undefined ? {} : { configFile }) + }); + continue; + } + let resolvedTool: PythonProjectToolProvenance | undefined; + let failure: PythonProjectContextReason | undefined; + for (const candidate of candidates) { + if (!(await candidateAvailable(options.workspace, candidate.argv[0]))) continue; + const command = candidate.argv[0]; + const prefix = candidate.argv.slice(1); + const result = processProbe.run(command, [...prefix, "--version"], { + cwd: projectCwd(options), env, timeoutMs: options.timeoutMs ?? 10000 + }); + if (!result.ok) { + failure = probeFailureReason(result, tool); + if (candidate.source !== "path") break; + continue; + } + const version = firstVersion(result.stdout, result.stderr); + if (version === undefined) { + failure = { + code: "malformed_probe_output", + tool, + message: `${tool} version probe returned malformed metadata` + }; + if (candidate.source !== "path") break; + continue; + } + const executable = candidate.source === "path" + ? processProbe.resolveExecutable?.(command, { env, platform: options.platform ?? process.platform }) ?? command + : command; + resolvedTool = { + tool, + available: true, + executable, + argv: [executable, ...prefix], + cwd: projectCwd(options), + source: candidate.source, + version, + ...(configFile === undefined ? {} : { configFile }) + }; + break; + } + if (resolvedTool !== undefined) { + tools.push(resolvedTool); + continue; + } + const fallback = candidates[0] ?? { argv: [tool], source: "path" as const }; + tools.push({ + tool, + available: false, + executable: fallback.argv[0], + argv: fallback.argv, + cwd: projectCwd(options), + source: fallback.source, + ...(configFile === undefined ? {} : { configFile }) + }); + reasons.push(failure ?? { code: "tool_unavailable", tool, message: `${tool} is unavailable for ${options.projectRoot}` }); + } + if (options.buildSystem !== undefined) { + tools.push(resolveBuildTool(options, processProbe, env, interpreter, reasons)); + } + return tools.sort((left, right) => left.tool.localeCompare(right.tool)); +} + +function runnableInterpreterArgv( + candidate: ExecutableCandidate, + probedExecutable: string, + processProbe: PythonProjectProcessProbe, + env: Readonly>, + platform: string +): readonly string[] { + const [command, ...args] = candidate.argv; + if (args.length === 0) return [probedExecutable]; + const launcher = processProbe.resolveExecutable?.(command, { env, platform }) ?? + (isAbsolute(command) || /^[A-Za-z]:[\\/]/u.test(command) ? command : undefined); + return launcher === undefined ? [probedExecutable] : [launcher, ...args]; +} + +function resolveBuildTool( + options: PythonProjectEnvironmentOptions, + processProbe: PythonProjectProcessProbe, + env: Record, + interpreter: PythonInterpreterProvenance | undefined, + reasons: PythonProjectContextReason[] +): PythonProjectToolProvenance { + const buildSystem = options.buildSystem; + if (buildSystem === undefined) throw new Error("Python build tool resolution requires build-system metadata"); + const fallbackExecutable = interpreter?.executable ?? "python"; + const fallbackArgv = interpreter === undefined ? [fallbackExecutable, "-m", "build"] : [...interpreter.argv, "-m", "build"]; + if (interpreter === undefined) { + reasons.push({ code: "tool_unavailable", tool: "build", message: `build is unavailable without an interpreter for ${options.projectRoot}` }); + return { + tool: "build", + available: false, + executable: fallbackExecutable, + argv: fallbackArgv, + cwd: projectCwd(options), + source: "path", + configFile: buildSystem.configFile + }; + } + const result = processProbe.run(interpreter.executable, [...interpreter.argv.slice(1), "-I", "-B", "-c", buildProbeScript], { + cwd: interpreter.cwd, + env, + timeoutMs: options.timeoutMs ?? 10000 + }); + if (!result.ok) { + reasons.push(probeFailureReason(result, "build")); + return { + tool: "build", + available: false, + executable: interpreter.executable, + argv: [...interpreter.argv, "-m", "build"], + cwd: interpreter.cwd, + source: interpreter.source, + configFile: buildSystem.configFile + }; + } + const metadata = parseBuildMetadata(result.stdout); + if (metadata === undefined) { + reasons.push({ code: "malformed_probe_output", tool: "build", message: "Python build probe returned malformed metadata" }); + } else if (!metadata.available) { + reasons.push({ code: "tool_unavailable", tool: "build", message: `build is unavailable for ${options.projectRoot}` }); + } + return { + tool: "build", + available: metadata?.available === true, + executable: interpreter.executable, + argv: [...interpreter.argv, "-m", "build"], + cwd: interpreter.cwd, + source: interpreter.source, + ...(metadata?.version === undefined ? {} : { version: metadata.version }), + configFile: buildSystem.configFile + }; +} + +function parseBuildMetadata(stdout: string): { available: boolean; version?: string } | undefined { + let value: unknown; + try { + value = JSON.parse(stdout.trim()); + } catch { + return undefined; + } + if (!value || typeof value !== "object") return undefined; + const record = value as Record; + if (record.protocol !== "opcore.python.project-context.build.v1" || typeof record.available !== "boolean") return undefined; + if (record.version !== null && record.version !== undefined && (typeof record.version !== "string" || record.version.length === 0)) { + return undefined; + } + if (record.available && typeof record.version !== "string") return undefined; + if (typeof record.version === "string" && !isExactToolVersion(record.version)) return undefined; + return { available: record.available, ...(typeof record.version === "string" ? { version: record.version } : {}) }; +} + +function resolvePathExecutable( + command: string, + options: { env: Readonly>; platform: string } +): string | undefined { + if (isAbsolute(command) || command.includes("/") || command.includes("\\")) return existsSync(command) ? command : undefined; + const path = options.env.PATH; + if (path === undefined) return undefined; + const extensions = options.platform === "win32" + ? (options.env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";").filter(Boolean) + : [""]; + for (const directory of path.split(delimiter).filter(Boolean)) { + for (const extension of extensions) { + const candidate = resolve(directory, options.platform === "win32" ? `${command}${extension}` : command); + if (existsSync(candidate)) return candidate; + } + } + return undefined; +} + +function interpreterCandidates( + options: PythonProjectEnvironmentOptions, + managerCandidates: ManagerExecutableCandidates, + reasons: PythonProjectContextReason[] +): readonly ExecutableCandidate[] { + if (options.interpreterArgv !== undefined) { + const override = validateOverride(options.interpreterArgv, "interpreter", reasons); + return override === undefined ? [] : [override]; + } + const candidates: ExecutableCandidate[] = []; + const active = options.env?.VIRTUAL_ENV; + if (active !== undefined && activeEnvironmentCompatible(active, options)) { + candidates.push(...environmentInterpreterExecutables(active, options.platform).map((executable) => ({ + argv: [executable], + source: "active_environment" as const + }))); + } + for (const directory of [".venv", "venv", "env"]) { + candidates.push(...projectEnvironmentInterpreterExecutables(options, directory).map((executable) => ({ + argv: [executable], + source: "project_local_environment" as const + }))); + } + candidates.push(...managerCandidates.interpreters); + candidates.push({ argv: [options.platform === "win32" ? "python" : "python3"], source: "path" }); + if (options.platform !== "win32") candidates.push({ argv: ["python"], source: "path" }); + return candidates; +} + +function toolCandidates( + tool: PythonProjectToolKind, + options: PythonProjectEnvironmentOptions, + managerCandidates: ManagerExecutableCandidates, + reasons: PythonProjectContextReason[] +): readonly ExecutableCandidate[] { + const override = options.toolArgv?.[tool]; + if (override !== undefined) { + const candidate = validateOverride(override, tool, reasons); + return candidate === undefined ? [] : [candidate]; + } + const candidates: ExecutableCandidate[] = []; + const active = options.env?.VIRTUAL_ENV; + if (active !== undefined && activeEnvironmentCompatible(active, options)) { + candidates.push({ argv: [environmentExecutable(active, tool, options.platform)], source: "active_environment" }); + } + for (const directory of [".venv", "venv", "env"]) { + candidates.push({ argv: [projectEnvironmentExecutable(options, directory, tool)], source: "project_local_environment" }); + } + if (tool === "pyright") { + candidates.push({ argv: [absoluteProjectPath(options, "node_modules/.bin/pyright")], source: "project_local_environment" }); + } + candidates.push(...managerCandidates.tools[tool] ?? []); + candidates.push({ argv: [tool], source: "path" }); + return candidates; +} + +async function resolveManagerExecutableCandidates( + options: PythonProjectEnvironmentOptions, + reasons: PythonProjectContextReason[] +): Promise { + const uv = options.env?.UV_PROJECT_ENVIRONMENT; + if (uv !== undefined && activeEnvironmentCompatible(uv, options)) { + return environmentCandidates(uv, options.platform); + } + if (options.managers.some((manager) => manager.kind === "pdm")) { + const pdmPath = options.projectRoot === "." ? ".pdm-python" : `${options.projectRoot}/.pdm-python`; + if (!(await options.workspace.exists(pdmPath))) return emptyManagerCandidates(); + const resolved = await options.workspace.realpath(pdmPath); + if (resolved.unavailable) { + reasons.push({ + code: "ambiguous_path", + path: pdmPath, + message: `PDM interpreter evidence realpath is unavailable: ${pdmPath}` + }); + return emptyManagerCandidates(); + } + if (resolved.symlink || resolved.path !== pdmPath) { + reasons.push({ + code: "symlink_refused", + path: pdmPath, + message: `Symlinked PDM interpreter evidence is ambiguous: ${pdmPath}` + }); + return emptyManagerCandidates(); + } + const content = (await options.workspace.read(pdmPath))?.trim(); + if (content !== undefined && content.length > 0 && !content.includes("\0")) { + return interpreterPathCandidates(content, options.platform); + } + } + return emptyManagerCandidates(); +} + +function environmentCandidates(environment: string, platform: string | undefined): ManagerExecutableCandidates { + return { + interpreters: environmentInterpreterExecutables(environment, platform).map((executable) => ({ + argv: [executable], + source: "manager_environment" as const + })), + tools: managerToolKinds().reduce>>((tools, tool) => { + tools[tool] = [{ argv: [environmentExecutable(environment, tool, platform)], source: "manager_environment" }]; + return tools; + }, {}) + }; +} + +function interpreterPathCandidates(interpreter: string, platform: string | undefined): ManagerExecutableCandidates { + return { + interpreters: [{ argv: [interpreter], source: "manager_environment" }], + tools: managerToolKinds().reduce>>((tools, tool) => { + tools[tool] = [{ argv: [toolBesideInterpreter(interpreter, tool, platform)], source: "manager_environment" }]; + return tools; + }, {}) + }; +} + +function emptyManagerCandidates(): ManagerExecutableCandidates { + return { interpreters: [], tools: {} }; +} + +function managerToolKinds(): readonly Exclude[] { + return ["mypy", "pyright", "ruff", "pytest"]; +} + +function toolBesideInterpreter(interpreter: string, tool: string, platform: string | undefined): string { + const normalized = interpreter.replaceAll("\\", "/"); + const directory = normalized.slice(0, normalized.lastIndexOf("/")); + if (platform === "win32") { + const scriptsDirectory = directory.toLowerCase().endsWith("/scripts") ? directory : `${directory}/Scripts`; + return `${scriptsDirectory}/${tool}.exe`.replaceAll("/", "\\"); + } + return `${directory}/${tool}`; +} + +function parseInterpreterMetadata(stdout: string): { + executable: string; version: string; implementation: string; platform: string; architecture: string; abi: string; soabi: string; +} | undefined { + let value: unknown; + try { + value = JSON.parse(stdout.trim()); + } catch { + return undefined; + } + if (!value || typeof value !== "object") return undefined; + const record = value as Record; + if (record.protocol !== "opcore.python.project-context.interpreter.v1") return undefined; + for (const key of ["executable", "version", "implementation", "platform", "architecture"] as const) { + if (typeof record[key] !== "string" || record[key].length === 0) return undefined; + } + if (!isAbsolute(record.executable as string) && !/^[A-Za-z]:[\\/]/u.test(record.executable as string)) return undefined; + if (!isExactPythonInterpreterVersion(record.version as string)) return undefined; + for (const key of ["abi", "soabi"] as const) { + if (typeof record[key] !== "string" || record[key].length === 0) return undefined; + } + return { + executable: record.executable as string, + version: record.version as string, + implementation: record.implementation as string, + platform: record.platform as string, + architecture: record.architecture as string, + abi: record.abi as string, + soabi: record.soabi as string + }; +} + +function targetIncompatibility(interpreter: PythonInterpreterProvenance, target: PythonProjectTarget): string | undefined { + if (target.implementation !== undefined && interpreter.implementation !== undefined && + target.implementation.toLowerCase() !== interpreter.implementation.toLowerCase()) { + return `Selected ${interpreter.implementation} does not satisfy target implementation ${target.implementation}`; + } + if (target.platform !== undefined && interpreter.platform !== undefined && !platformMatches(interpreter.platform, target.platform)) { + return `Selected platform ${interpreter.platform} does not satisfy target platform ${target.platform}`; + } + if (target.requiresPython !== undefined && interpreter.version !== undefined && + !pythonVersionSatisfiesConstraint(interpreter.version, target.requiresPython)) { + return `Selected Python ${interpreter.version} does not satisfy ${target.requiresPython}`; + } + if (target.version !== undefined && interpreter.version !== undefined && + !pythonVersionMatchesTarget(interpreter.version, target.version)) { + return `Selected Python ${interpreter.version} does not satisfy ${target.version}`; + } + return undefined; +} + +function pythonVersionMatchesTarget(version: string, target: string): boolean { + const normalized = target.trim(); + const constraint = /^\d+\.\d+$/u.test(normalized) ? `==${normalized}.*` : normalized; + return pythonVersionSatisfiesConstraint(version, constraint); +} + +function isExactPythonInterpreterVersion(value: string): boolean { + return /^\d+\.\d+\.\d+(?:(?:a|b|rc)\d+)?(?:\+[A-Za-z0-9]+(?:[._-][A-Za-z0-9]+)*)?$/u.test(value); +} + +function isExactToolVersion(value: string): boolean { + return /^\d+(?:\.\d+)+(?:[-+._A-Za-z0-9]*)?$/u.test(value); +} + +function platformMatches(actual: string, expected: string): boolean { + const normalized = expected.toLowerCase(); + return actual.toLowerCase() === normalized || (normalized === "linux" && actual.startsWith("linux")) || + (normalized === "windows" && actual === "win32") || (normalized === "darwin" && actual === "darwin"); +} + +function probeFailureReason(result: PythonToolRunResult, tool: string): PythonProjectContextReason { + if (result.termination === "timeout") return { code: "probe_timeout", tool, message: `${tool} probe timed out` }; + if (result.termination === "signal") return { code: "probe_signal", tool, message: `${tool} probe terminated with signal ${result.signal}` }; + if (result.termination === "spawn_error") { + return missingCommand(result.failureMessage) + ? { code: "tool_unavailable", tool, message: `${tool} is unavailable` } + : { code: "probe_spawn_failure", tool, message: `${tool} probe could not spawn` }; + } + return { code: "probe_exit_failure", tool, message: `${tool} probe exited with code ${result.exitCode}` }; +} + +function firstVersion(stdout: string, stderr: string): string | undefined { + const line = `${stdout}\n${stderr}`.split(/\r?\n/u).map((entry) => entry.trim()).find(Boolean); + const version = line === undefined ? undefined : /\b\d+(?:\.\d+)+(?:[-+._A-Za-z0-9]*)?/u.exec(line)?.[0]; + return version; +} + +function validateOverride( + argv: readonly string[], + label: "interpreter" | PythonProjectToolKind, + reasons: PythonProjectContextReason[] +): ExecutableCandidate | undefined { + if (!Array.isArray(argv) || argv.length === 0 || argv.some((entry) => typeof entry !== "string" || entry.length === 0)) { + throw new Error(`Python ${label} argv override must be a non-empty string array`); + } + if (!safeOverrideArgv(argv, label)) { + reasons.push({ + code: "invalid_config", + tool: label === "interpreter" ? "python" : label, + message: `Unsafe Python ${label} argv override was refused without execution` + }); + return undefined; + } + return { argv: [...argv], source: "explicit_override" }; +} + +function activeEnvironmentCompatible(environment: string, options: PythonProjectEnvironmentOptions): boolean { + const caseFold = (value: string): string => options.platform === "win32" ? value.toLowerCase() : value; + const envPath = caseFold(normalizePath(environment)); + const root = caseFold(normalizePath(projectCwd(options))); + if (!envPath.startsWith(`${root}/`)) return false; + const relativeEnvironment = envPath.slice(root.length + 1); + return relativeEnvironment.length > 0 && !relativeEnvironment.includes("/"); +} + +function safeOverrideArgv(argv: readonly string[], label: "interpreter" | PythonProjectToolKind): boolean { + const executable = executableBasename(argv[0]); + const prefix = argv.slice(1); + if (label === "interpreter") { + if (!/^python(?:\d+(?:\.\d+)*)?(?:\.exe)?$/u.test(executable)) return false; + return safePythonOptionPrefix(prefix); + } + if (label === "build" || executable !== label && executable !== `${label}.exe`) return false; + return safeToolOptionPrefix(label, prefix); +} + +function executableBasename(command: string): string { + const normalized = command.replaceAll("\\", "/"); + return normalized.slice(normalized.lastIndexOf("/") + 1).toLowerCase(); +} + +function safePythonOptionPrefix(args: readonly string[]): boolean { + const switches = new Set(["-B", "-E", "-I", "-O", "-OO", "-P", "-S", "-s", "-u", "-v", "-q", "-b", "-bb", "-d", "-x"]); + for (let index = 0; index < args.length; index += 1) { + const argument = args[index]; + if (switches.has(argument)) continue; + if (argument === "-X" && args[index + 1] === "dev") { + index += 1; + continue; + } + if (argument === "-Xdev") continue; + return false; + } + return true; +} + +function safeToolOptionPrefix(tool: Exclude, args: readonly string[]): boolean { + const valuedOptions: Readonly, readonly string[]>> = { + mypy: ["--config", "--config-file"], + pyright: ["--project", "-p"], + ruff: ["--config"], + pytest: ["--config-file", "-c"] + }; + const options = valuedOptions[tool]; + for (let index = 0; index < args.length; index += 1) { + const argument = args[index]; + const exact = options.find((option) => argument === option); + if (exact !== undefined) { + const value = args[index + 1]; + if (value === undefined || value.length === 0 || value.startsWith("-")) return false; + index += 1; + continue; + } + if (options.some((option) => argument.startsWith(`${option}=`) && argument.length > option.length + 1)) continue; + return false; + } + return true; +} + +function environmentExecutable(environment: string, tool: string, platform: string | undefined): string { + const suffix = platform === "win32" ? `Scripts/${tool}.exe` : `bin/${tool}`; + return joinAbsolute(environment, suffix, platform); +} + +function environmentInterpreterExecutables(environment: string, platform: string | undefined): readonly string[] { + if (platform === "win32") { + return [ + joinAbsolute(environment, "Scripts/python.exe", platform), + joinAbsolute(environment, "python.exe", platform) + ]; + } + return [joinAbsolute(environment, "bin/python", platform), joinAbsolute(environment, "bin/python3", platform)]; +} + +function projectEnvironmentInterpreterExecutables( + options: PythonProjectEnvironmentOptions, + directory: string +): readonly string[] { + const project = options.projectRoot === "." ? "" : `${options.projectRoot}/`; + const environment = joinAbsolute(options.repoRoot, `${project}${directory}`, options.platform); + return environmentInterpreterExecutables(environment, options.platform); +} + +function projectEnvironmentExecutable(options: PythonProjectEnvironmentOptions, directory: string, tool: string): string { + const suffix = options.platform === "win32" ? `${directory}/Scripts/${tool}.exe` : `${directory}/bin/${tool}`; + return absoluteProjectPath(options, suffix); +} + +function absoluteProjectPath(options: PythonProjectEnvironmentOptions, suffix: string): string { + const project = options.projectRoot === "." ? "" : `${options.projectRoot}/`; + return joinAbsolute(options.repoRoot, `${project}${suffix}`, options.platform); +} + +function projectCwd(options: PythonProjectEnvironmentOptions): string { + return options.projectRoot === "." ? options.repoRoot : joinAbsolute(options.repoRoot, options.projectRoot, options.platform); +} + +function joinAbsolute(root: string, suffix: string, platform: string | undefined): string { + if (platform === "win32") return `${root.replace(/[\\/]+$/u, "")}\\${suffix.replaceAll("/", "\\")}`; + return resolve(root, suffix); +} + +async function candidateAvailable(workspace: PythonProjectWorkspace, command: string): Promise { + return workspace.executableExists(command); +} + +function effectiveProcessEnvironment(env: Readonly> | undefined): Record { + if (env === undefined) return { ...process.env }; + return { ...env }; +} + +function sanitizedFingerprintEnvironment( + env: Readonly> | undefined, + projectRoot: string +): Record { + const selected: Record = {}; + for (const key of ["VIRTUAL_ENV", "UV_PROJECT_ENVIRONMENT", "PDM_VENV_IN_PROJECT", "POETRY_ACTIVE", "PIPENV_ACTIVE"] as const) { + const value = env?.[key]; + if (value === undefined) continue; + selected[key] = key.endsWith("ACTIVE") || key === "PDM_VENV_IN_PROJECT" ? value === "1" || value === "true" : normalizePath(value); + } + selected.projectRoot = projectRoot; + selected.pathProvided = env?.PATH !== undefined; + return selected; +} + +function normalizePath(value: string): string { + return value.replaceAll("\\", "/").replace(/\/$/u, ""); +} + +function missingCommand(message: string | undefined): boolean { + return message?.includes("ENOENT") === true || message?.toLowerCase().includes("not found") === true; +} diff --git a/packages/validation-python/src/import-graph-check.ts b/packages/validation-python/src/import-graph-check.ts index 0508b7f..aa04625 100644 --- a/packages/validation-python/src/import-graph-check.ts +++ b/packages/validation-python/src/import-graph-check.ts @@ -3,9 +3,9 @@ import type { ValidationCheckDefinition } from "@the-open-engine/opcore-validati import { PYTHON_IMPORT_GRAPH_CHECK_ID } from "./check-ids.js"; import { pythonCheckAdapter, pythonCheckOwner, supportedPythonValidationScopes } from "./check-constants.js"; import { importGraphRequirements } from "./graph-requirements.js"; -import { materializePythonSources, toFileNodeId } from "./source-files.js"; +import { materializePythonSources, toFileNodeId, type PythonProjectContextResolver } from "./source-files.js"; -export function createImportGraphCheck(): ValidationCheckDefinition { +export function createImportGraphCheck(resolveContexts?: PythonProjectContextResolver): ValidationCheckDefinition { return { id: PYTHON_IMPORT_GRAPH_CHECK_ID, owner: pythonCheckOwner, @@ -15,7 +15,8 @@ export function createImportGraphCheck(): ValidationCheckDefinition { requiresGraph: true, graphRequirements: importGraphRequirements, run: async (context) => { - const [sourceSet, edges] = await Promise.all([materializePythonSources(context), context.graph.importsFrom()]); + const [contexts, edges] = await Promise.all([resolveContexts?.(context) ?? [], context.graph.importsFrom()]); + const sourceSet = await materializePythonSources(context, contexts); const diagnostics = sourceSet.repoImports .filter((repoImport) => !edges.some((edge) => matchesDirectedFileEdge(edge, repoImport.fromPath, repoImport.resolvedPath))) .map((repoImport): ValidationDiagnostic => ({ diff --git a/packages/validation-python/src/index.ts b/packages/validation-python/src/index.ts index 34c9ea8..c04db83 100644 --- a/packages/validation-python/src/index.ts +++ b/packages/validation-python/src/index.ts @@ -1,9 +1,11 @@ -import type { ValidationCheckDefinition } from "@the-open-engine/opcore-validation"; +import type { ValidationCheckDefinition, ValidationCheckResult } from "@the-open-engine/opcore-validation"; +import type { ValidationDiagnostic } from "@the-open-engine/opcore-contracts"; import { createDeadCodeCheck } from "./dead-code-check.js"; import { createImportGraphCheck } from "./import-graph-check.js"; import { createRelevantTestsCheck } from "./relevant-tests-check.js"; import { createSourceHygieneCheck } from "./source-hygiene-check.js"; import { createSyntaxCheck, type PythonSyntaxCheckOptions } from "./syntax-check.js"; +import { createPythonProjectContextResolver, pythonInputSet } from "./source-files.js"; import { createTypeCheck, type PythonTypeCheckOptions } from "./type-check.js"; export { @@ -18,19 +20,19 @@ export { } from "./check-ids.js"; export { validationPythonAdapterName } from "./check-constants.js"; export { isPythonSourcePath } from "./source-files.js"; -export { createPythonValidationAdapterStatus, probePythonToolchain, type PythonValidationToolchainOptions } from "./toolchain.js"; export { - resolvePythonInterpreter, - resolvePythonTool, - findPythonConfigFile, - type PythonInterpreterResolution, - type PythonInterpreterResolutionOutcome, - type ResolvedPythonInterpreter, - type UnresolvedPythonInterpreter, - type PythonToolResolution, - type PythonToolResolutionSource, - type PythonToolResolverOptions -} from "./toolchain-resolver.js"; + resolvePythonProjectContext, + resolvePythonProjectContexts, + type ResolvePythonProjectContextsOptions +} from "./project-context.js"; +export { + createNodePythonProjectWorkspace, + createValidationFileViewPythonWorkspace, + type PythonProjectWorkspace, + type PythonProjectWorkspaceRealpath +} from "./project-workspace.js"; +export type { PythonProjectProcessProbe } from "./environment-resolution.js"; +export { createPythonValidationAdapterStatus, type PythonValidationToolchainOptions } from "./toolchain.js"; export { createSyntaxCheck, type PythonSyntaxCheckOptions } from "./syntax-check.js"; export { createTypeCheck, type PythonTypeCheckOptions } from "./type-check.js"; @@ -39,12 +41,40 @@ export interface CreatePythonValidationChecksOptions extends PythonTypeCheckOpti export function createPythonValidationChecks( options: CreatePythonValidationChecksOptions = {} ): readonly ValidationCheckDefinition[] { + const resolveContexts = createPythonProjectContextResolver({ + ...(options.contexts === undefined ? {} : { contexts: options.contexts }), + ...(options.nodeWorkspace === undefined ? {} : { nodeWorkspace: options.nodeWorkspace }), + ...(options.env === undefined ? {} : { env: options.env }), + ...(options.interpreterArgv === undefined ? {} : { interpreterArgv: options.interpreterArgv }), + ...(options.toolArgv === undefined ? {} : { toolArgv: options.toolArgv }), + ...(options.platform === undefined ? {} : { platform: options.platform }), + ...(options.architecture === undefined ? {} : { architecture: options.architecture }), + ...(options.processProbe === undefined ? {} : { processProbe: options.processProbe }), + ...(options.timeoutMs === undefined ? {} : { timeoutMs: options.timeoutMs }) + }); return [ - createSyntaxCheck(options), + createSyntaxCheck(options, resolveContexts), createSourceHygieneCheck(), - createTypeCheck(options), - createImportGraphCheck(), + createTypeCheck(options, resolveContexts), + createImportGraphCheck(resolveContexts), createDeadCodeCheck(), createRelevantTestsCheck() - ]; + ].map((check) => withPythonProjectContexts(check, resolveContexts)); +} + +function withPythonProjectContexts( + check: ValidationCheckDefinition, + resolveContexts: ReturnType +): ValidationCheckDefinition { + return { + ...check, + run: async (context) => { + const result = await check.run(context); + if (pythonInputSet(context).length === 0) return result; + const pythonProjectContexts = await resolveContexts(context); + if (result === undefined) return { diagnostics: [], pythonProjectContexts }; + if (Array.isArray(result)) return { diagnostics: result as readonly ValidationDiagnostic[], pythonProjectContexts }; + return { ...(result as ValidationCheckResult), pythonProjectContexts }; + } + }; } diff --git a/packages/validation-python/src/node-shims.d.ts b/packages/validation-python/src/node-shims.d.ts index 7ef85a8..bec93bb 100644 --- a/packages/validation-python/src/node-shims.d.ts +++ b/packages/validation-python/src/node-shims.d.ts @@ -23,6 +23,7 @@ declare module "node:child_process" { declare module "node:fs" { export function existsSync(path: string): boolean; + export function realpathSync(path: string): string; export function mkdirSync(path: string, options?: { recursive?: boolean }): void; export function mkdtempSync(prefix: string): string; export function rmSync(path: string, options?: { recursive?: boolean; force?: boolean }): void; @@ -30,8 +31,13 @@ declare module "node:fs" { } declare module "node:fs/promises" { + export function access(path: string): Promise; export function copyFile(source: string, destination: string): Promise; + export function lstat(path: string): Promise<{ isFile(): boolean; isSymbolicLink(): boolean }>; export function mkdir(path: string, options?: { recursive?: boolean }): Promise; + export function readFile(path: string, encoding: "utf8"): Promise; + export function readdir(path: string, options: { recursive: true }): Promise; + export function realpath(path: string): Promise; } declare module "node:os" { @@ -39,6 +45,7 @@ declare module "node:os" { } declare module "node:path" { + export const delimiter: string; export function dirname(path: string): string; export function join(...paths: string[]): string; export function isAbsolute(path: string): boolean; @@ -56,4 +63,5 @@ interface Buffer { declare const process: { env: Record; cwd(): string; + platform: string; }; diff --git a/packages/validation-python/src/project-context.ts b/packages/validation-python/src/project-context.ts new file mode 100644 index 0000000..d244440 --- /dev/null +++ b/packages/validation-python/src/project-context.ts @@ -0,0 +1,220 @@ +import type { + PythonProjectContext, + PythonProjectContextOutcome, + PythonProjectContextReason, + PythonProjectToolKind +} from "@the-open-engine/opcore-contracts"; +import { + PYTHON_PROJECT_CONTEXT_SCHEMA_ID, + validatePythonProjectContext, + validateRepoRelativePath +} from "@the-open-engine/opcore-contracts"; +import { discoverPythonProject } from "./project-discovery.js"; +import { + resolvePythonProjectEnvironment, + type PythonProjectProcessProbe +} from "./environment-resolution.js"; +import { + normalizePythonProjectFingerprintInput, + pythonProjectDigest +} from "./project-fingerprint.js"; +import type { PythonProjectWorkspace } from "./project-workspace.js"; +import { readPythonStaticProjectConfig } from "./static-config.js"; + +export interface ResolvePythonProjectContextsOptions { + repoRoot: string; + targets: readonly string[]; + workspace: PythonProjectWorkspace; + interpreterArgv?: readonly string[]; + toolArgv?: Partial>; + env?: Readonly>; + platform?: string; + architecture?: string; + processProbe?: PythonProjectProcessProbe; + timeoutMs?: number; +} + +export class PythonProjectContextResolutionError extends Error { + readonly code: "path_refused"; + + constructor(message: string) { + super(message); + this.name = "PythonProjectContextResolutionError"; + this.code = "path_refused"; + } +} + +export async function resolvePythonProjectContexts( + options: ResolvePythonProjectContextsOptions +): Promise { + const targets = normalizeTargets(options.targets); + const visibleFiles = [...new Set(await options.workspace.list())].sort(); + const projectInputs = new Map>(); + const contexts: PythonProjectContext[] = []; + for (const target of targets) { + contexts.push(await resolveTarget(options, target, visibleFiles, projectInputs)); + } + return contexts; +} + +export async function resolvePythonProjectContext( + options: Omit & { target: string } +): Promise { + const [context] = await resolvePythonProjectContexts({ ...options, targets: [options.target] }); + if (context === undefined) throw new Error("Python project context target is required"); + return context; +} + +async function resolveTarget( + options: ResolvePythonProjectContextsOptions, + target: string, + visibleFiles: readonly string[], + projectInputs: Map> +): Promise { + const discovery = await discoverPythonProject(options.workspace, target, visibleFiles); + let inputs: ResolvedProjectInputs; + if (discovery.reasons.some(isPathRefusalReason)) { + const config = await readPythonStaticProjectConfig(options.workspace, discovery.projectRoot, visibleFiles); + inputs = { config, environment: refusedEnvironment() }; + } else { + let inputPromise = projectInputs.get(discovery.projectRoot); + if (inputPromise === undefined) { + inputPromise = resolveProjectInputs(options, discovery.projectRoot, visibleFiles); + projectInputs.set(discovery.projectRoot, inputPromise); + } + inputs = await inputPromise; + } + const { config, environment } = inputs; + const reasons = deduplicateReasons([...discovery.reasons, ...config.reasons, ...environment.reasons]); + const evidence = mergeEvidence(discovery.evidence, config); + const projectKey = pythonProjectDigest({ schemaId: PYTHON_PROJECT_CONTEXT_SCHEMA_ID, projectRoot: discovery.projectRoot }); + const targetContent = discovery.reasons.some(isPathRefusalReason) + ? undefined + : await options.workspace.read(target); + const fingerprintInput = { + schemaId: PYTHON_PROJECT_CONTEXT_SCHEMA_ID, + target, + targetContent: targetContent === undefined ? null : pythonProjectDigest(targetContent), + projectRoot: discovery.projectRoot, + sourceRoots: discovery.sourceRoots, + layout: discovery.layout, + configContents: [...config.contents].map(([path, content]) => [path, pythonProjectDigest(content)]), + targetRuntime: config.target, + managers: config.managers, + ...(config.buildSystem === undefined ? {} : { buildSystem: config.buildSystem }), + explicit: { interpreterArgv: options.interpreterArgv, toolArgv: options.toolArgv }, + environment: environment.fingerprintInput + }; + const contextFingerprint = pythonProjectDigest(normalizePythonProjectFingerprintInput( + fingerprintInput, + options.repoRoot, + options.platform ?? process.platform + )); + return validatePythonProjectContext({ + schemaId: PYTHON_PROJECT_CONTEXT_SCHEMA_ID, + schemaVersion: 1, + target, + repositoryRoot: options.repoRoot, + projectRoot: discovery.projectRoot, + projectBoundary: discovery.projectBoundary, + sourceRoots: discovery.sourceRoots, + layout: discovery.layout, + evidence, + targetRuntime: config.target, + managers: config.managers, + ...(config.buildSystem === undefined ? {} : { buildSystem: config.buildSystem }), + ...(environment.interpreter === undefined ? {} : { interpreter: environment.interpreter }), + tools: environment.tools, + projectKey, + contextFingerprint, + outcome: contextOutcome(reasons), + reasons + }); +} + +interface ResolvedProjectInputs { + config: Awaited>; + environment: Awaited>; +} + +async function resolveProjectInputs( + options: ResolvePythonProjectContextsOptions, + projectRoot: string, + visibleFiles: readonly string[] +): Promise { + const config = await readPythonStaticProjectConfig(options.workspace, projectRoot, visibleFiles); + const environment = config.reasons.some(isPathRefusalReason) + ? refusedEnvironment() + : await resolvePythonProjectEnvironment({ + repoRoot: options.repoRoot, + projectRoot, + workspace: options.workspace, + target: config.target, + managers: config.managers, + toolConfigs: config.toolConfigs, + ...(config.buildSystem === undefined ? {} : { buildSystem: config.buildSystem }), + ...(options.interpreterArgv === undefined ? {} : { interpreterArgv: options.interpreterArgv }), + ...(options.toolArgv === undefined ? {} : { toolArgv: options.toolArgv }), + ...(options.env === undefined ? {} : { env: options.env }), + ...(options.platform === undefined ? {} : { platform: options.platform }), + ...(options.architecture === undefined ? {} : { architecture: options.architecture }), + ...(options.processProbe === undefined ? {} : { processProbe: options.processProbe }), + ...(options.timeoutMs === undefined ? {} : { timeoutMs: options.timeoutMs }) + }); + return { config, environment }; +} + +function refusedEnvironment(): Awaited> { + return { tools: [], reasons: [], fingerprintInput: { refused: true } }; +} + +function isPathRefusalReason(reason: PythonProjectContextReason): boolean { + return reason.code === "symlink_refused" || reason.code === "path_refused" || reason.code === "ambiguous_path"; +} + +function normalizeTargets(targets: readonly string[]): readonly string[] { + if (!Array.isArray(targets) || targets.length === 0) throw new Error("Python project context targets must be a non-empty array"); + return [...new Set(targets.map((target) => { + try { + const normalized = validateRepoRelativePath(target.replaceAll("\\", "/")); + if (!/\.pyi?$/u.test(normalized)) throw new Error(`Python project context target must be .py or .pyi: ${normalized}`); + return normalized; + } catch (error) { + throw new PythonProjectContextResolutionError(error instanceof Error ? error.message : String(error)); + } + }))].sort(); +} + +function contextOutcome(reasons: readonly PythonProjectContextReason[]): PythonProjectContextOutcome { + if (reasons.length === 0) return "resolved"; + if (reasons.some((reason) => ["conflicting_managers", "conflicting_targets", "symlink_refused", "path_refused", "ambiguous_path"].includes(reason.code))) { + return "ambiguous"; + } + if (reasons.some((reason) => ["interpreter_unavailable", "incompatible_interpreter", "unsupported_target", "unsupported_platform"].includes(reason.code))) { + return "unsupported"; + } + return "degraded"; +} + +function deduplicateReasons(reasons: readonly PythonProjectContextReason[]): readonly PythonProjectContextReason[] { + const byKey = new Map(); + for (const reason of reasons) byKey.set(`${reason.code}\0${reason.path ?? ""}\0${reason.tool ?? ""}\0${reason.message}`, reason); + return [...byKey.values()].sort((left, right) => + `${left.code}\0${left.path ?? ""}\0${left.tool ?? ""}`.localeCompare(`${right.code}\0${right.path ?? ""}\0${right.tool ?? ""}`) + ); +} + +function mergeEvidence( + discovered: PythonProjectContext["evidence"], + config: Awaited> +): PythonProjectContext["evidence"] { + const entries = [...discovered]; + for (const path of config.contents.keys()) { + const lock = /(?:\.lock|Pipfile\.lock)$/u.test(path); + const requirements = /requirements.*\.txt$/u.test(path); + const build = path.endsWith("pyproject.toml") && config.buildSystem?.configFile === path; + entries.push({ path, role: lock ? "lock" : requirements ? "requirements" : build ? "build" : "config" }); + } + const byKey = new Map(entries.map((entry) => [`${entry.path}\0${entry.role}`, entry])); + return [...byKey.values()].sort((left, right) => `${left.path}\0${left.role}`.localeCompare(`${right.path}\0${right.role}`)); +} diff --git a/packages/validation-python/src/project-discovery.ts b/packages/validation-python/src/project-discovery.ts new file mode 100644 index 0000000..8789095 --- /dev/null +++ b/packages/validation-python/src/project-discovery.ts @@ -0,0 +1,130 @@ +import type { + PythonProjectContextReason, + PythonProjectFileEvidence, + PythonProjectLayoutEvidence +} from "@the-open-engine/opcore-contracts"; +import { pythonBoundaryFileNames } from "./static-config.js"; +import type { PythonProjectWorkspace } from "./project-workspace.js"; + +export interface PythonProjectDiscovery { + projectRoot: string; + projectBoundary: string; + sourceRoots: readonly string[]; + layout: PythonProjectLayoutEvidence; + evidence: readonly PythonProjectFileEvidence[]; + reasons: readonly PythonProjectContextReason[]; +} + +export async function discoverPythonProject( + workspace: PythonProjectWorkspace, + target: string, + visibleFiles: readonly string[] +): Promise { + const ancestors = ancestorDirectories(dirname(target)); + let projectRoot = "."; + let boundaryFiles: string[] = []; + for (const directory of ancestors) { + const direct = directChildren(directory, visibleFiles); + const markers = direct.filter(isBoundaryMarker); + if (markers.length === 0) continue; + projectRoot = directory; + boundaryFiles = markers; + break; + } + + const reasons: PythonProjectContextReason[] = []; + if (boundaryFiles.length === 0) { + reasons.push({ code: "missing_config", message: `No Python project boundary/config marker owns ${target}` }); + } + for (const path of [target, ...boundaryFiles]) { + const resolved = await workspace.realpath(path); + if (resolved.unavailable) { + reasons.push({ code: "ambiguous_path", path, message: `Python project path realpath evidence is unavailable: ${path}` }); + } else if (resolved.symlink || resolved.path !== path) { + reasons.push({ code: "symlink_refused", path, message: `Symlinked Python project path is ambiguous: ${path}` }); + } + } + const layout = await layoutEvidence(workspace, projectRoot, target); + const evidence: PythonProjectFileEvidence[] = [ + ...boundaryFiles.map((path) => ({ path, role: "boundary" as const })), + ...layout.paths.filter((path) => path !== ".").map((path) => ({ path, role: "layout" as const })) + ].sort(compareEvidence); + return { projectRoot, projectBoundary: projectRoot, sourceRoots: sourceRoots(projectRoot, target), layout, evidence, reasons }; +} + +async function layoutEvidence( + workspace: PythonProjectWorkspace, + projectRoot: string, + target: string +): Promise { + const roots = sourceRoots(projectRoot, target); + const kinds = new Set<"flat" | "src" | "namespace" | "stub" | "package">(); + if (roots.some((root) => basename(root) === "src")) kinds.add("src"); + else kinds.add("flat"); + if (target.endsWith(".pyi")) kinds.add("stub"); + const sourceRoot = roots.find((root) => target === root || target.startsWith(`${root}/`)) ?? projectRoot; + const packageDirs = packageDirectoriesWithin(sourceRoot, dirname(target)); + let packageMarker = false; + for (const directory of packageDirs) { + if (await workspace.exists(`${directory}/__init__.py`) || await workspace.exists(`${directory}/__init__.pyi`)) { + packageMarker = true; + break; + } + } + if (packageMarker) kinds.add("package"); + else if (packageDirs.length > 0) kinds.add("namespace"); + return { kinds: [...kinds].sort(), paths: roots }; +} + +function packageDirectoriesWithin(sourceRoot: string, targetDirectory: string): readonly string[] { + if (targetDirectory === sourceRoot) return []; + const prefix = sourceRoot === "." ? "" : `${sourceRoot}/`; + if (!targetDirectory.startsWith(prefix)) return []; + const relative = targetDirectory.slice(prefix.length); + if (relative.length === 0) return []; + const parts = relative.split("/").filter(Boolean); + const directories: string[] = []; + for (let length = parts.length; length > 0; length -= 1) { + const child = parts.slice(0, length).join("/"); + directories.push(sourceRoot === "." ? child : `${sourceRoot}/${child}`); + } + return directories; +} + +function sourceRoots(projectRoot: string, target: string): readonly string[] { + const src = projectRoot === "." ? "src" : `${projectRoot}/src`; + if (target.startsWith(`${src}/`)) return [src]; + return [projectRoot]; +} + +function isBoundaryMarker(path: string): boolean { + const name = basename(path); + return pythonBoundaryFileNames.includes(name as (typeof pythonBoundaryFileNames)[number]) || /^requirements.*\.txt$/u.test(name); +} + +function directChildren(root: string, files: readonly string[]): readonly string[] { + const prefix = root === "." ? "" : `${root}/`; + return files.filter((path) => path.startsWith(prefix) && !path.slice(prefix.length).includes("/")); +} + +function ancestorDirectories(start: string): readonly string[] { + if (start === ".") return ["."]; + const parts = start.split("/").filter(Boolean); + const values: string[] = []; + for (let length = parts.length; length > 0; length -= 1) values.push(parts.slice(0, length).join("/")); + values.push("."); + return values; +} + +function dirname(path: string): string { + const index = path.lastIndexOf("/"); + return index < 0 ? "." : path.slice(0, index); +} + +function basename(path: string): string { + return path.slice(path.lastIndexOf("/") + 1); +} + +function compareEvidence(left: PythonProjectFileEvidence, right: PythonProjectFileEvidence): number { + return `${left.path}\0${left.role}`.localeCompare(`${right.path}\0${right.role}`); +} diff --git a/packages/validation-python/src/project-fingerprint.ts b/packages/validation-python/src/project-fingerprint.ts new file mode 100644 index 0000000..c879835 --- /dev/null +++ b/packages/validation-python/src/project-fingerprint.ts @@ -0,0 +1,79 @@ +import { createHash } from "node:crypto"; + +export function pythonProjectDigest(value: unknown): string { + return `sha256:${createHash("sha256").update(stableJson(value), "utf8").digest("hex")}`; +} + +export function normalizePythonProjectFingerprintInput( + value: unknown, + repoRoot: string, + platform: string +): unknown { + const normalizedRoot = normalizeRoot(repoRoot, platform); + return normalizeFingerprintValue(value, normalizedRoot, platform); +} + +function stableJson(value: unknown): string { + return JSON.stringify(sortJson(value)); +} + +function sortJson(value: unknown): unknown { + if (Array.isArray(value)) return value.map(sortJson); + if (value === null || typeof value !== "object") return value; + return Object.fromEntries( + Object.entries(value as Record) + .filter(([, entry]) => entry !== undefined) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, entry]) => [key, sortJson(entry)]) + ); +} + +function normalizeFingerprintValue(value: unknown, repoRoot: string, platform: string): unknown { + if (Array.isArray(value)) { + return value.map((entry) => normalizeFingerprintValue(entry, repoRoot, platform)); + } + if (value === null || typeof value !== "object") { + return typeof value === "string" ? replaceRepoRoot(value, repoRoot, platform) : value; + } + return Object.fromEntries( + Object.entries(value as Record) + .map(([key, entry]) => [key, normalizeFingerprintValue(entry, repoRoot, platform)]) + ); +} + +function normalizeRoot(repoRoot: string, platform: string): string { + const normalized = normalizeSeparators(repoRoot, platform); + return normalized.length > 1 ? normalized.replace(/\/+$/u, "") : normalized; +} + +function replaceRepoRoot(value: string, repoRoot: string, platform: string): string { + const normalized = normalizeSeparators(value, platform); + if (repoRoot.length === 0) return normalized; + if (repoRoot === "/") { + return normalized.startsWith("/") ? `$REPO${normalized}` : normalized; + } + const comparableValue = platform === "win32" ? normalized.toLowerCase() : normalized; + const comparableRoot = platform === "win32" ? repoRoot.toLowerCase() : repoRoot; + let cursor = 0; + let result = ""; + while (cursor < normalized.length) { + const index = comparableValue.indexOf(comparableRoot, cursor); + if (index < 0) return result + normalized.slice(cursor); + const preceding = index === 0 ? undefined : normalized[index - 1]; + const following = normalized[index + repoRoot.length]; + const startsPath = preceding === undefined || /[\s=,:;"'(\[]/u.test(preceding); + const endsRoot = following === undefined || following === "/"; + if (!startsPath || !endsRoot) { + result += normalized.slice(cursor, index + repoRoot.length); + cursor = index + repoRoot.length; + continue; + } + result += `${normalized.slice(cursor, index)}$REPO`; + cursor = index + repoRoot.length; + } + return result; +} + +function normalizeSeparators(value: string, platform: string): string { + return platform === "win32" ? value.replaceAll("\\", "/") : value; +} diff --git a/packages/validation-python/src/project-workspace.ts b/packages/validation-python/src/project-workspace.ts new file mode 100644 index 0000000..914f339 --- /dev/null +++ b/packages/validation-python/src/project-workspace.ts @@ -0,0 +1,167 @@ +import { access, lstat, readFile, readdir, realpath } from "node:fs/promises"; +import { realpathSync } from "node:fs"; +import { relative, resolve, sep } from "node:path"; +import { validateRepoRelativePath } from "@the-open-engine/opcore-contracts"; +import type { ValidationFileView } from "@the-open-engine/opcore-validation"; + +export interface PythonProjectWorkspaceRealpath { + path: string; + symlink: boolean; + unavailable?: boolean; +} + +export interface PythonProjectWorkspace { + read(path: string): Promise; + list(): Promise; + exists(path: string): Promise; + realpath(path: string): Promise; + executableExists(path: string): Promise; +} + +export function createValidationFileViewPythonWorkspace( + fileView: ValidationFileView, + executableExists: PythonProjectWorkspace["executableExists"] = nodeExecutableExists, + fullWorkspace?: PythonProjectWorkspace +): PythonProjectWorkspace { + return { + read: async (path) => { + const result = await fileView.readAfter(validateRepoRelativePath(path)); + return result.status === "found" ? result.content : undefined; + }, + list: async () => { + const candidates = [...new Set([ + ...await fileView.listVisibleFiles(), + ...(fullWorkspace === undefined ? [] : await fullWorkspace.list()) + ])].sort(); + const visible: string[] = []; + for (const path of candidates) { + if (await fileView.exists(path)) visible.push(path); + } + return visible; + }, + exists: (path) => fileView.exists(validateRepoRelativePath(path)), + realpath: async (path) => { + const normalized = validateRepoRelativePath(path); + if (fullWorkspace === undefined) return { path: normalized, symlink: false, unavailable: true }; + const baseline = await fullWorkspace.realpath(normalized); + if (!fileView.hasOverlay(normalized)) return baseline; + if (baseline.symlink) return baseline; + if (baseline.unavailable && await fullWorkspace.exists(normalized)) return baseline; + return { path: normalized, symlink: false }; + }, + executableExists + }; +} + +export function createNodePythonProjectWorkspace(repoRoot: string): PythonProjectWorkspace { + const canonicalRoot = realpathSync(resolve(repoRoot)); + let listed: Promise | undefined; + return { + read: async (path) => { + const absolute = resolveRepoPath(canonicalRoot, path); + try { + return await readFile(absolute, "utf8"); + } catch (error) { + if (isMissing(error)) return undefined; + throw error; + } + }, + list: () => { + listed ??= listNodeWorkspace(canonicalRoot); + return listed; + }, + exists: async (path) => { + try { + await access(resolveRepoPath(canonicalRoot, path)); + return true; + } catch (error) { + if (isMissing(error)) return false; + throw error; + } + }, + realpath: async (path) => { + const normalized = validateRepoRelativePath(path); + const absolute = resolveRepoPath(canonicalRoot, normalized); + try { + const info = await lstat(absolute); + const resolved = await realpath(absolute); + const relativePath = repoRelativeOrUndefined(canonicalRoot, resolved); + if (relativePath === undefined) return { path: normalized, symlink: true }; + return { path: relativePath, symlink: info.isSymbolicLink() || relativePath !== normalized }; + } catch (error) { + if (isMissing(error)) return { path: normalized, symlink: false }; + throw error; + } + }, + executableExists: nodeExecutableExists + }; +} + +async function listNodeWorkspace(repoRoot: string): Promise { + const entries = await readdir(repoRoot, { recursive: true }); + const paths: string[] = []; + for (const entry of entries) { + const normalized = String(entry).replaceAll("\\", "/"); + if (skipPath(normalized)) continue; + const absolute = resolve(repoRoot, normalized); + let info: Awaited>; + try { + info = await lstat(absolute); + } catch (error) { + if (isMissing(error)) continue; + throw error; + } + if (info.isFile() || info.isSymbolicLink()) paths.push(validateRepoRelativePath(normalized)); + } + return [...new Set(paths)].sort(); +} + +async function nodeExecutableExists(path: string): Promise { + if (!isPathLike(path)) return true; + try { + await access(path); + return true; + } catch (error) { + if (isMissing(error)) return false; + throw error; + } +} + +function resolveRepoPath(repoRoot: string, path: string): string { + const normalized = validateRepoRelativePath(path); + const absolute = resolve(repoRoot, normalized); + toRepoRelative(repoRoot, absolute); + return absolute; +} + +function toRepoRelative(repoRoot: string, absolute: string): string { + const value = repoRelativeOrUndefined(repoRoot, absolute); + if (value === undefined) { + throw new Error(`Python project workspace path escapes repository: ${absolute}`); + } + return value; +} + +function repoRelativeOrUndefined(repoRoot: string, absolute: string): string | undefined { + const value = relative(repoRoot, absolute); + if (value === "" || value === ".." || value.startsWith(`..${sep}`)) return undefined; + return validateRepoRelativePath(value.replaceAll("\\", "/")); +} + +function skipPath(path: string): boolean { + const skipped = new Set([ + ".git", "node_modules", "target", "dist", ".ace", ".agents", ".claude", ".codex", ".gemini", + ".opencode", ".rox-cache", ".robustness-engine-cache", ".venv", "venv", "env", "__pycache__", + ".eggs", "build", ".tox", ".mypy_cache", ".pytest_cache", ".ruff_cache", "site-packages" + ]); + return path.split("/").some((segment) => skipped.has(segment) || segment.endsWith(".egg-info") || segment.endsWith(".dist-info")); +} + +function isPathLike(command: string): boolean { + return command.includes("/") || command.includes("\\") || /^[A-Za-z]:/u.test(command); +} + +function isMissing(error: unknown): boolean { + const code = (error as { code?: unknown } | undefined)?.code; + return code === "ENOENT" || code === "ENOTDIR"; +} diff --git a/packages/validation-python/src/source-files.ts b/packages/validation-python/src/source-files.ts index c55afce..2d87bed 100644 --- a/packages/validation-python/src/source-files.ts +++ b/packages/validation-python/src/source-files.ts @@ -1,5 +1,8 @@ +import type { PythonProjectContext } from "@the-open-engine/opcore-contracts"; import type { ValidationCheckContext, ValidationCheckResult, ValidationFileView } from "@the-open-engine/opcore-validation"; import { normalizeValidationFileViewPath } from "@the-open-engine/opcore-validation"; +import { resolvePythonProjectContexts, type ResolvePythonProjectContextsOptions } from "./project-context.js"; +import { createValidationFileViewPythonWorkspace, type PythonProjectWorkspace } from "./project-workspace.js"; export const pythonSourceExtensions = [".py", ".pyi"] as const; @@ -27,7 +30,45 @@ interface ParsedPythonImport { member?: string; } -const sourceSetCache = new WeakMap>(); +export type PythonProjectContextResolver = (context: ValidationCheckContext) => Promise; + +export function createPythonProjectContextResolver( + options: Omit & { + contexts?: readonly PythonProjectContext[]; + nodeWorkspace?: PythonProjectWorkspace; + } = {} +): PythonProjectContextResolver { + const cache = new WeakMap>(); + return (context) => { + const existing = cache.get(context.fileView); + if (existing !== undefined) return existing; + const targets = pythonInputSet(context); + const reusable = context.fileView.overlays.length === 0 && options.contexts !== undefined && + targets.every((target) => options.contexts?.some((candidate) => candidate.target === target)); + const promise = targets.length === 0 + ? Promise.resolve([]) + : reusable + ? Promise.resolve(options.contexts?.filter((candidate) => targets.includes(candidate.target)) ?? []) + : resolvePythonProjectContexts({ + repoRoot: context.request.repo.repoRoot ?? process.cwd(), + targets, + workspace: createValidationFileViewPythonWorkspace(context.fileView, undefined, options.nodeWorkspace), + ...withoutContexts(options) + }); + cache.set(context.fileView, promise); + return promise; + }; +} + +function withoutContexts( + options: Omit & { + contexts?: readonly PythonProjectContext[]; + nodeWorkspace?: PythonProjectWorkspace; + } +): Omit { + const { contexts: _contexts, nodeWorkspace: _nodeWorkspace, ...resolverOptions } = options; + return resolverOptions; +} export function isPythonSourcePath(path: string): boolean { return pythonSourceExtensions.some((extension) => path.endsWith(extension)); @@ -63,15 +104,17 @@ export function skippedPythonInputResult(context: ValidationCheckContext): Valid }; } -export async function materializePythonSources(context: ValidationCheckContext): Promise { - const cached = sourceSetCache.get(context.fileView); - if (cached !== undefined) return cached; - const promise = materializePythonSourcesUncached(context); - sourceSetCache.set(context.fileView, promise); - return promise; +export async function materializePythonSources( + context: ValidationCheckContext, + projectContexts: readonly PythonProjectContext[] = [] +): Promise { + return materializePythonSourcesUncached(context, projectContexts); } -async function materializePythonSourcesUncached(context: ValidationCheckContext): Promise { +async function materializePythonSourcesUncached( + context: ValidationCheckContext, + projectContexts: readonly PythonProjectContext[] +): Promise { const initialPaths = pythonInputSet(context); const rootPaths: string[] = []; const pending = [...initialPaths]; @@ -91,7 +134,7 @@ async function materializePythonSourcesUncached(context: ValidationCheckContext) if (initialPaths.includes(path)) rootPaths.push(path); for (const parsedImport of parsePythonImports(result.content)) { - const resolvedPath = await resolvePythonImport(context, path, parsedImport); + const resolvedPath = await resolvePythonImport(context, path, parsedImport, projectContexts); if (resolvedPath === undefined) continue; repoImports.push({ fromPath: path, specifier: parsedImport.specifier, resolvedPath }); if (!visited.has(resolvedPath) && !sourceFileByPath.has(resolvedPath)) pending.push(resolvedPath); @@ -142,11 +185,14 @@ function parsePythonImports(content: string): readonly ParsedPythonImport[] { async function resolvePythonImport( context: ValidationCheckContext, fromPath: string, - parsedImport: ParsedPythonImport + parsedImport: ParsedPythonImport, + projectContexts: readonly PythonProjectContext[] ): Promise { - const moduleBase = moduleBasePath(fromPath, parsedImport.specifier); - if (moduleBase === undefined) return undefined; - return resolveModulePath(context, moduleBase); + for (const moduleBase of moduleBasePaths(fromPath, parsedImport.specifier, projectContexts)) { + const resolved = await resolveModulePath(context, moduleBase); + if (resolved !== undefined) return resolved; + } + return undefined; } async function resolveModulePath(context: ValidationCheckContext, moduleBase: string): Promise { @@ -163,19 +209,37 @@ async function resolveModulePath(context: ValidationCheckContext, moduleBase: st return undefined; } -function moduleBasePath(fromPath: string, specifier: string): string | undefined { +function moduleBasePaths( + fromPath: string, + specifier: string, + projectContexts: readonly PythonProjectContext[] +): readonly string[] { const leadingDots = /^\.*/u.exec(specifier)?.[0].length ?? 0; const moduleSpecifier = specifier.slice(leadingDots); const moduleParts = moduleSpecifier.length === 0 ? [] : moduleSpecifier.split("."); - if (leadingDots === 0) return moduleParts.join("/"); + if (leadingDots === 0) { + const module = moduleParts.join("/"); + const owning = owningContext(fromPath, projectContexts); + const roots = owning?.sourceRoots ?? ["."]; + return roots.map((root) => root === "." ? module : `${root}/${module}`); + } const baseParts = fromPath.split("/"); baseParts.pop(); for (let index = 1; index < leadingDots; index += 1) { - if (baseParts.length === 0) return undefined; + if (baseParts.length === 0) return []; baseParts.pop(); } - return [...baseParts, ...moduleParts].filter((part) => part.length > 0).join("/"); + return [[...baseParts, ...moduleParts].filter((part) => part.length > 0).join("/")]; +} + +function owningContext(path: string, contexts: readonly PythonProjectContext[]): PythonProjectContext | undefined { + return [...contexts] + .filter((context) => context.target === path || context.projectRoot === "." || path.startsWith(`${context.projectRoot}/`)) + .sort((left, right) => { + const exact = Number(right.target === path) - Number(left.target === path); + return exact !== 0 ? exact : right.projectRoot.length - left.projectRoot.length; + })[0]; } function moduleCandidates(moduleBase: string): readonly string[] { diff --git a/packages/validation-python/src/static-config.ts b/packages/validation-python/src/static-config.ts new file mode 100644 index 0000000..d43a982 --- /dev/null +++ b/packages/validation-python/src/static-config.ts @@ -0,0 +1,323 @@ +import type { + PythonProjectBuildSystem, + PythonProjectContextReason, + PythonProjectManagerEvidence, + PythonProjectTarget +} from "@the-open-engine/opcore-contracts"; +import { parse as parseToml } from "smol-toml"; +import type { PythonProjectWorkspace } from "./project-workspace.js"; +import { pythonVersionSatisfiesConstraint } from "./version-constraint.js"; + +export const pythonBoundaryFileNames = [ + "pyproject.toml", "Pipfile", "Pipfile.lock", "poetry.lock", "pdm.lock", "uv.lock", "setup.cfg", "setup.py" +] as const; + +export const pythonToolConfigPrecedence = { + pyright: ["pyrightconfig.json", "pyproject.toml"], + ruff: ["ruff.toml", ".ruff.toml", "pyproject.toml"], + mypy: ["mypy.ini", ".mypy.ini", "pyproject.toml", "setup.cfg", "tox.ini"], + pytest: ["pytest.ini", "pyproject.toml", "tox.ini", "setup.cfg"] +} as const; + +export interface PythonStaticProjectConfig { + contents: ReadonlyMap; + managers: readonly PythonProjectManagerEvidence[]; + target: PythonProjectTarget; + toolConfigs: Readonly>; + buildSystem?: PythonProjectBuildSystem; + reasons: readonly PythonProjectContextReason[]; +} + +export async function readPythonStaticProjectConfig( + workspace: PythonProjectWorkspace, + projectRoot: string, + visibleFiles: readonly string[] +): Promise { + const direct = directChildren(projectRoot, visibleFiles); + const relevant = direct.filter(isRelevantPythonConfig); + const contents = new Map(); + const tomlDocuments = new Map(); + const reasons: PythonProjectContextReason[] = []; + for (const path of relevant) { + const resolved = await workspace.realpath(path); + if (resolved.unavailable) { + reasons.push({ code: "ambiguous_path", path, message: `Python config realpath evidence is unavailable: ${path}` }); + continue; + } + if (resolved.symlink || resolved.path !== path) { + reasons.push({ code: "symlink_refused", path, message: `Symlinked Python config path is ambiguous: ${path}` }); + continue; + } + const value = await workspace.read(path); + if (value === undefined) continue; + contents.set(path, value); + if (path.endsWith(".json") || basename(path) === "Pipfile.lock") { + try { + const parsed = JSON.parse(value) as unknown; + if (!isTable(parsed)) throw new Error("JSON config root must be an object"); + } catch { + reasons.push({ code: "invalid_config", path, message: `Python project config is malformed: ${path}` }); + } + } + if (isTomlConfig(path)) { + try { + const parsed = parseToml(value); + if (isTable(parsed)) tomlDocuments.set(path, parsed); + } catch { + reasons.push({ code: "invalid_config", path, message: `Python project config is malformed: ${path}` }); + } + } + if (path.endsWith(".ini") || path.endsWith(".cfg")) { + try { + validateIni(value); + } catch { + reasons.push({ code: "invalid_config", path, message: `Python project config is malformed: ${path}` }); + } + } + } + + const managers = managerEvidence(projectRoot, direct, tomlDocuments); + if (managers.length > 1) { + reasons.push({ + code: "conflicting_managers", + message: `Conflicting Python dependency managers at ${projectRoot}: ${managers.map((entry) => entry.kind).join(", ")}` + }); + } + const target = targetEvidence(projectRoot, contents, tomlDocuments, reasons); + const toolConfigs = { + mypy: selectConfig(projectRoot, direct, tomlDocuments, "mypy", pythonToolConfigPrecedence.mypy), + pyright: selectConfig(projectRoot, direct, tomlDocuments, "pyright", pythonToolConfigPrecedence.pyright), + ruff: selectConfig(projectRoot, direct, tomlDocuments, "ruff", pythonToolConfigPrecedence.ruff), + pytest: selectConfig(projectRoot, direct, tomlDocuments, "pytest", pythonToolConfigPrecedence.pytest) + }; + const pyproject = joinRoot(projectRoot, "pyproject.toml"); + const buildSystem = parseBuildSystem(pyproject, tomlDocuments.get(pyproject)); + return { contents, managers, target, toolConfigs, ...(buildSystem === undefined ? {} : { buildSystem }), reasons }; +} + +function managerEvidence( + projectRoot: string, + direct: readonly string[], + tomlDocuments: ReadonlyMap +): readonly PythonProjectManagerEvidence[] { + const names = new Set(direct.map(basename)); + const pyprojectPath = joinRoot(projectRoot, "pyproject.toml"); + const pyprojectDocument = tomlDocuments.get(pyprojectPath); + const managers: PythonProjectManagerEvidence[] = []; + const requirements = direct.filter((path) => /^requirements.*\.txt$/u.test(basename(path))); + if (requirements.length > 0 || names.has("setup.py")) { + managers.push({ kind: "pip", configFiles: requirements.length > 0 ? requirements : [joinRoot(projectRoot, "setup.py")], lockFiles: [] }); + } + if (names.has("uv.lock") || tableAt(pyprojectDocument, ["tool", "uv"]) !== undefined) { + managers.push({ kind: "uv", configFiles: tableAt(pyprojectDocument, ["tool", "uv"]) === undefined ? [] : [pyprojectPath], lockFiles: names.has("uv.lock") ? [joinRoot(projectRoot, "uv.lock")] : [] }); + } + if (names.has("poetry.lock") || tableAt(pyprojectDocument, ["tool", "poetry"]) !== undefined) { + managers.push({ kind: "poetry", configFiles: tableAt(pyprojectDocument, ["tool", "poetry"]) === undefined ? [] : [pyprojectPath], lockFiles: names.has("poetry.lock") ? [joinRoot(projectRoot, "poetry.lock")] : [] }); + } + if (names.has("pdm.lock") || tableAt(pyprojectDocument, ["tool", "pdm"]) !== undefined) { + managers.push({ kind: "pdm", configFiles: tableAt(pyprojectDocument, ["tool", "pdm"]) === undefined ? [] : [pyprojectPath], lockFiles: names.has("pdm.lock") ? [joinRoot(projectRoot, "pdm.lock")] : [] }); + } + if (names.has("Pipfile") || names.has("Pipfile.lock")) { + managers.push({ kind: "pipenv", configFiles: names.has("Pipfile") ? [joinRoot(projectRoot, "Pipfile")] : [], lockFiles: names.has("Pipfile.lock") ? [joinRoot(projectRoot, "Pipfile.lock")] : [] }); + } + return managers.sort((left, right) => left.kind.localeCompare(right.kind)); +} + +function targetEvidence( + projectRoot: string, + contents: ReadonlyMap, + tomlDocuments: ReadonlyMap, + reasons: PythonProjectContextReason[] +): PythonProjectTarget { + const declarations: { source: string; kind: "requires" | "version" | "platform" | "implementation"; value: string }[] = []; + for (const [path, content] of contents) { + const document = tomlDocuments.get(path); + const requires = stringAt(document, ["project", "requires-python"]) ?? + stringAt(document, ["tool", "poetry", "dependencies", "python"]) ?? + valueForNonTomlConfig(path, content, "requires-python"); + if (requires !== undefined) declarations.push({ source: path, kind: "requires", value: requires }); + const version = stringAt(document, ["tool", "pyright", "pythonVersion"]) ?? + stringAt(document, ["requires", "python_version"]) ?? + valueForNonTomlConfig(path, content, "pythonVersion") ?? valueForNonTomlConfig(path, content, "python_version"); + if (version !== undefined) declarations.push({ source: path, kind: "version", value: version }); + const platform = stringAt(document, ["tool", "pyright", "pythonPlatform"]) ?? + valueForNonTomlConfig(path, content, "pythonPlatform") ?? valueForNonTomlConfig(path, content, "platform"); + if (platform !== undefined) declarations.push({ source: path, kind: "platform", value: platform }); + const implementation = stringAt(document, ["tool", "pyright", "pythonImplementation"]) ?? + valueForNonTomlConfig(path, content, "pythonImplementation"); + if (implementation !== undefined) declarations.push({ source: path, kind: "implementation", value: implementation }); + } + const conflicts: string[] = []; + for (const kind of ["requires", "version", "platform", "implementation"] as const) { + const values = [...new Set(declarations.filter((entry) => entry.kind === kind).map((entry) => entry.value))]; + if (values.length > 1) conflicts.push(`${kind}:${values.join("|")}`); + } + if (conflicts.length > 0) { + reasons.push({ code: "conflicting_targets", message: `Conflicting Python targets at ${projectRoot}: ${conflicts.join(", ")}` }); + } + const requiresPython = declarations.find((entry) => entry.kind === "requires")?.value; + const version = declarations.find((entry) => entry.kind === "version")?.value; + const platform = declarations.find((entry) => entry.kind === "platform")?.value; + const implementation = declarations.find((entry) => entry.kind === "implementation")?.value; + if (requiresPython !== undefined && version !== undefined && !constraintAllowsVersion(requiresPython, version)) { + const conflict = `requires-version:${requiresPython}|${version}`; + conflicts.push(conflict); + reasons.push({ code: "conflicting_targets", message: `Conflicting Python targets at ${projectRoot}: ${conflict}` }); + } + return { + ...(requiresPython === undefined ? {} : { requiresPython }), + ...(version === undefined ? {} : { version }), + ...(platform === undefined ? {} : { platform }), + ...(implementation === undefined ? {} : { implementation }), + conflicts + }; +} + +function parseBuildSystem(path: string, document: TomlTable | undefined): PythonStaticProjectConfig["buildSystem"] { + const section = tableAt(document, ["build-system"]); + if (section === undefined) return undefined; + const backend = stringAt(section, ["build-backend"]); + const requires = stringArrayAt(section, ["requires"]).sort(); + return { configFile: path, ...(backend === undefined ? {} : { backend }), requires }; +} + +function selectConfig( + projectRoot: string, + direct: readonly string[], + tomlDocuments: ReadonlyMap, + tool: "mypy" | "pyright" | "ruff" | "pytest", + candidates: readonly string[] +): string | undefined { + const names = new Set(direct.map(basename)); + const section = { + mypy: "tool.mypy", + pyright: "tool.pyright", + ruff: "tool.ruff", + pytest: "tool.pytest.ini_options" + }[tool]; + const name = candidates.find((candidate) => { + if (!names.has(candidate)) return false; + if (candidate !== "pyproject.toml") return true; + return tableAt(tomlDocuments.get(joinRoot(projectRoot, candidate)), section.split(".")) !== undefined; + }); + return name === undefined ? undefined : joinRoot(projectRoot, name); +} + +function isRelevantPythonConfig(path: string): boolean { + const name = basename(path); + return pythonBoundaryFileNames.includes(name as (typeof pythonBoundaryFileNames)[number]) || + /^requirements.*\.txt$/u.test(name) || + ["pyrightconfig.json", "ruff.toml", ".ruff.toml", "mypy.ini", ".mypy.ini", "pytest.ini", "tox.ini"].includes(name); +} + +function isTomlConfig(path: string): boolean { + return path.endsWith(".toml") || ["Pipfile", "poetry.lock", "pdm.lock", "uv.lock"].includes(basename(path)); +} + +function validateIni(content: string): void { + const sections = new Set(); + const options = new Set(); + let section: string | undefined; + let continuationAllowed = false; + for (const [index, rawLine] of content.replace(/^\uFEFF/u, "").split(/\r?\n/u).entries()) { + const trimmed = rawLine.trim(); + if (trimmed.length === 0 || trimmed.startsWith("#") || trimmed.startsWith(";")) continue; + if (/^\s/u.test(rawLine)) { + if (!continuationAllowed) throw new Error(`Unexpected INI continuation at line ${index + 1}`); + continue; + } + const sectionMatch = /^\[([^\[\]]+)\](?:\s*[#;].*)?$/u.exec(trimmed); + if (sectionMatch !== null) { + section = sectionMatch[1].trim().toLowerCase(); + if (section.length === 0 || sections.has(section)) throw new Error(`Invalid INI section at line ${index + 1}`); + sections.add(section); + continuationAllowed = false; + continue; + } + if (section === undefined) throw new Error(`INI option precedes a section at line ${index + 1}`); + const delimiterIndex = firstIniDelimiter(rawLine); + if (delimiterIndex <= 0 || rawLine.slice(0, delimiterIndex).trim().length === 0) { + throw new Error(`Invalid INI option at line ${index + 1}`); + } + const option = `${section}\0${rawLine.slice(0, delimiterIndex).trim().toLowerCase()}`; + if (options.has(option)) throw new Error(`Duplicate INI option at line ${index + 1}`); + options.add(option); + continuationAllowed = true; + } + if (sections.size === 0) throw new Error("INI config has no sections"); +} + +function firstIniDelimiter(line: string): number { + const equals = line.indexOf("="); + const colon = line.indexOf(":"); + if (equals < 0) return colon; + if (colon < 0) return equals; + return Math.min(equals, colon); +} + +function directChildren(root: string, files: readonly string[]): readonly string[] { + const prefix = root === "." ? "" : `${root}/`; + return files.filter((path) => path.startsWith(prefix) && !path.slice(prefix.length).includes("/")); +} + +function valueForKey(content: string, key: string): string | undefined { + const escaped = key.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&"); + const match = new RegExp(`^\\s*["']?${escaped}["']?\\s*(?:=|:)\\s*["']([^"']+)["']`, "mu").exec(content); + return match?.[1]?.trim(); +} + +function constraintAllowsVersion(constraint: string, version: string): boolean { + return pythonVersionSatisfiesConstraint(version, constraint); +} + +function valueForNonTomlConfig(path: string, content: string, key: string): string | undefined { + if (path.endsWith(".toml") || basename(path) === "Pipfile") return undefined; + if (path.endsWith(".json")) { + try { + const parsed = JSON.parse(content) as unknown; + return isTable(parsed) && typeof parsed[key] === "string" ? parsed[key].trim() : undefined; + } catch { + return undefined; + } + } + return valueForKey(content, key); +} + +type TomlTable = Record; + +function isTable(value: unknown): value is TomlTable { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function valueAt(value: unknown, path: readonly string[]): unknown { + let current = value; + for (const segment of path) { + if (!isTable(current)) return undefined; + current = current[segment]; + } + return current; +} + +function tableAt(value: unknown, path: readonly string[]): TomlTable | undefined { + const selected = valueAt(value, path); + return isTable(selected) ? selected : undefined; +} + +function stringAt(value: unknown, path: readonly string[]): string | undefined { + const selected = valueAt(value, path); + return typeof selected === "string" && selected.trim().length > 0 ? selected.trim() : undefined; +} + +function stringArrayAt(value: unknown, path: readonly string[]): string[] { + const selected = valueAt(value, path); + if (!Array.isArray(selected)) return []; + return selected.filter((entry): entry is string => typeof entry === "string" && entry.length > 0); +} + +function joinRoot(root: string, name: string): string { + return root === "." ? name : `${root}/${name}`; +} + +function basename(path: string): string { + return path.slice(path.lastIndexOf("/") + 1); +} diff --git a/packages/validation-python/src/syntax-check.ts b/packages/validation-python/src/syntax-check.ts index 0a8b901..47ec01a 100644 --- a/packages/validation-python/src/syntax-check.ts +++ b/packages/validation-python/src/syntax-check.ts @@ -1,7 +1,9 @@ import type { ValidationCheckOutcome, ValidationDiagnostic, - ValidationDiagnosticToolProvenance + ValidationDiagnosticToolProvenance, + PythonInterpreterProvenance, + PythonProjectContext } from "@the-open-engine/opcore-contracts"; import type { ValidationCheckDefinition, ValidationCheckResult } from "@the-open-engine/opcore-validation"; import { PYTHON_SYNTAX_CHECK_ID } from "./check-ids.js"; @@ -16,19 +18,17 @@ import { } from "./compiler-protocol.js"; import { diagnostic, sortDiagnostics } from "./diagnostics.js"; import { runTool } from "./process.js"; -import { readPythonAfterSources, skippedPythonInputResult } from "./source-files.js"; +import { readPythonAfterSources, skippedPythonInputResult, type PythonProjectContextResolver } from "./source-files.js"; import { type PythonValidationToolchainOptions } from "./toolchain.js"; -import { - resolvePythonInterpreter, - type PythonInterpreterResolution, - type ResolvedPythonInterpreter -} from "./toolchain-resolver.js"; export interface PythonSyntaxCheckOptions extends PythonValidationToolchainOptions { timeoutMs?: number; } -export function createSyntaxCheck(options: PythonSyntaxCheckOptions = {}): ValidationCheckDefinition { +export function createSyntaxCheck( + options: PythonSyntaxCheckOptions = {}, + resolveContexts?: PythonProjectContextResolver +): ValidationCheckDefinition { return { id: PYTHON_SYNTAX_CHECK_ID, owner: pythonCheckOwner, @@ -39,27 +39,57 @@ export function createSyntaxCheck(options: PythonSyntaxCheckOptions = {}): Valid const skipped = skippedPythonInputResult(context); if (skipped !== undefined) return skipped; - const repoRoot = options.repoRoot ?? context.request.repo.repoRoot ?? process.cwd(); - const interpreter = resolvePythonInterpreter({ - repoRoot, - env: options.env, - pythonCommand: options.pythonCommand, - targetPythonVersion: options.targetPythonVersion - }); - if (!interpreter.available) return resolutionFailure(interpreter); - const sources = await readPythonAfterSources(context); - return compileSources(interpreter, sources, options); + if (resolveContexts === undefined) return missingContextResult(sources.map((source) => source.path)); + const resolvedContexts = await resolveContexts(context); + const missing = sources.map((source) => source.path).filter((path) => !resolvedContexts.some((candidate) => candidate.target === path)); + if (resolvedContexts.length === 0 || missing.length > 0) return missingContextResult(missing); + const unresolved = resolvedContexts.find((candidate) => candidate.interpreter === undefined || hasInterpreterFailure(candidate)); + if (unresolved !== undefined) return resolutionFailure(unresolved); + const contexts = groupProjectContexts(resolvedContexts); + const diagnostics: ValidationDiagnostic[] = []; + for (const project of contexts) { + if (project.interpreter === undefined) return resolutionFailure(project); + const selected = sources.filter((source) => project.targets.includes(source.path)); + const result = await compileSources(project.interpreter, selected, options); + diagnostics.push(...(result.diagnostics ?? [])); + if (result.outcome !== "passed" && result.outcome !== "findings") return result; + } + return { outcome: diagnostics.length === 0 ? "passed" : "findings", diagnostics: sortDiagnostics(diagnostics) }; } }; } +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)); +} + async function compileSources( - interpreter: ResolvedPythonInterpreter, + interpreter: PythonInterpreterProvenance, sources: Awaited>, options: PythonSyntaxCheckOptions ): Promise { - const result = runTool(interpreter.command, ["-I", "-B", "-c", pythonCompileScript], { + const result = runTool(interpreter.executable, [...interpreter.argv.slice(1), "-I", "-B", "-c", pythonCompileScript], { cwd: interpreter.cwd, env: options.env, timeoutMs: options.timeoutMs ?? 10000, @@ -80,20 +110,27 @@ async function compileSources( }; } -function resolutionFailure(interpreter: Exclude): ValidationCheckResult { - const outcome = interpreter.outcome; +function resolutionFailure(context: PythonProjectContext): ValidationCheckResult { + const primary = context.reasons.find((reason) => reason.tool === "python") ?? context.reasons[0]; + const outcome = primary?.code === "probe_timeout" ? "timeout" : + primary?.code === "invalid_config" || context.outcome === "ambiguous" ? "invalid_config" : + primary?.code === "incompatible_interpreter" || primary?.code === "unsupported_target" || primary?.code === "unsupported_platform" + ? "unsupported_target" : + primary?.code === "interpreter_unavailable" || primary?.code === "tool_unavailable" ? "tool_unavailable" : "tool_failure"; + const message = primary?.message ?? `Python project context is unresolved for ${context.target}`; const code = resolutionFailureCode(outcome); const unsupported = outcome === "tool_unavailable" || outcome === "invalid_config" || outcome === "unsupported_target"; return { outcome, - failureMessage: interpreter.failureMessage, + failureMessage: message, diagnostics: [ diagnostic({ category: "infrastructure", severity: unsupported ? "info" : "error", code, - message: interpreter.failureMessage, - tool: unresolvedToolProvenance(interpreter) + message, + path: context.target, + ...(context.interpreter === undefined ? {} : { tool: compilerToolProvenance(context.interpreter) }) }) ] }; @@ -118,7 +155,7 @@ function checkFailure( }; } -function findingDiagnostic(finding: PythonCompilerFinding, interpreter: ResolvedPythonInterpreter): ValidationDiagnostic { +function findingDiagnostic(finding: PythonCompilerFinding, interpreter: PythonInterpreterProvenance): ValidationDiagnostic { return diagnostic({ category: "syntax", path: finding.path, @@ -144,7 +181,7 @@ function compilerFindingCode(kind: PythonCompilerErrorKind): string { return codes[kind]; } -function resolutionFailureCode(outcome: Exclude): string { +function resolutionFailureCode(outcome: Exclude): string { const codes = { tool_unavailable: "PY_SYNTAX_TOOL_UNAVAILABLE", invalid_config: "PY_SYNTAX_INVALID_CONFIG", @@ -155,12 +192,8 @@ function resolutionFailureCode(outcome: Exclude) { - return { - name: "python", - command: interpreter.command, - ...(interpreter.version === undefined ? {} : { version: interpreter.version }), - source: interpreter.source, - cwd: interpreter.cwd - }; +function hasInterpreterFailure(context: PythonProjectContext): boolean { + return context.outcome === "ambiguous" || context.outcome === "unsupported" || context.reasons.some((reason) => reason.tool === "python" || + reason.code === "invalid_config" || + ["incompatible_interpreter", "unsupported_target", "unsupported_platform", "symlink_refused", "path_refused", "ambiguous_path"].includes(reason.code)); } diff --git a/packages/validation-python/src/toolchain-resolver.ts b/packages/validation-python/src/toolchain-resolver.ts deleted file mode 100644 index 89ce7af..0000000 --- a/packages/validation-python/src/toolchain-resolver.ts +++ /dev/null @@ -1,265 +0,0 @@ -import { existsSync } from "node:fs"; -import { isAbsolute, join } from "node:path"; -import { runTool } from "./process.js"; -import { hasExactProtocolKeys, isProtocolRecord } from "./protocol-validation.js"; - -export type PythonToolResolutionSource = "configured" | "virtualenv-env" | "repo-venv" | "node-modules" | "path"; - -export interface PythonToolResolution { - tool: string; - available: boolean; - command: string; - args: readonly string[]; - cwd: string; - source: PythonToolResolutionSource; - version?: string; - configFile?: string; - failureMessage?: string; -} - -export interface PythonToolResolverOptions { - repoRoot: string; - env?: Record; - pythonCommand?: string; - targetPythonVersion?: string; -} - -export type PythonInterpreterResolutionOutcome = - | "resolved" - | "tool_unavailable" - | "invalid_config" - | "timeout" - | "unsupported_target" - | "tool_failure"; - -interface PythonInterpreterResolutionBase { - tool: "python"; - available: boolean; - outcome: PythonInterpreterResolutionOutcome; - command: string; - args: readonly string[]; - cwd: string; - source: PythonToolResolutionSource; - version?: string; - targetVersion?: string; - failureMessage?: string; -} - -export interface ResolvedPythonInterpreter extends PythonInterpreterResolutionBase { - available: true; - outcome: "resolved"; - version: string; -} - -export interface UnresolvedPythonInterpreter extends PythonInterpreterResolutionBase { - available: false; - outcome: Exclude; - failureMessage: string; -} - -export type PythonInterpreterResolution = ResolvedPythonInterpreter | UnresolvedPythonInterpreter; - -const pythonVirtualEnvDirs = [".venv", "venv", "env"] as const; - -const pythonConfigFileCandidates: Record = { - mypy: ["mypy.ini", "setup.cfg", "tox.ini", "pyproject.toml"], - pyright: ["pyrightconfig.json", "pyproject.toml"], - ruff: ["ruff.toml", ".ruff.toml", "pyproject.toml"], - pytest: ["pytest.ini", "tox.ini", "setup.cfg", "pyproject.toml"] -}; - -interface PythonToolCandidate { - command: string; - source: PythonToolResolutionSource; -} - -const pythonInterpreterProbeScript = [ - "import json, platform, sys", - "print(json.dumps({'protocol':'opcore.python.interpreter.v1','executable':sys.executable,'version':platform.python_version()}))" -].join("\n"); - -export function resolvePythonInterpreter(options: PythonToolResolverOptions): PythonInterpreterResolution { - const invalidTarget = validateTargetVersion(options.targetPythonVersion); - if (invalidTarget !== undefined) return interpreterFailure(options, "invalid_config", invalidTarget); - - for (const candidate of pythonInterpreterCandidates(options)) { - if (candidate.source !== "path" && candidate.source !== "configured" && !existsSync(candidate.command)) continue; - const resolution = probePythonInterpreter(candidate, options); - if (resolution.outcome === "resolved") return targetCompatibleResolution(resolution, options.targetPythonVersion); - if (candidate.source !== "path") return resolution; - return resolution; - } - return interpreterFailure(options, "tool_unavailable", "No Python interpreter is available"); -} - -function pythonInterpreterCandidates(options: PythonToolResolverOptions): readonly PythonToolCandidate[] { - if (options.pythonCommand !== undefined) return [{ command: options.pythonCommand, source: "configured" }]; - const candidates: PythonToolCandidate[] = []; - const virtualEnv = options.env?.VIRTUAL_ENV; - if (virtualEnv) candidates.push({ command: join(virtualEnv, "bin", "python"), source: "virtualenv-env" }); - for (const dir of pythonVirtualEnvDirs) { - candidates.push({ command: join(options.repoRoot, dir, "bin", "python"), source: "repo-venv" }); - } - candidates.push({ command: "python3", source: "path" }); - return candidates; -} - -function probePythonInterpreter( - candidate: PythonToolCandidate, - options: PythonToolResolverOptions -): PythonInterpreterResolution { - const args = ["-I", "-B", "-c", pythonInterpreterProbeScript]; - const result = runTool(candidate.command, args, { env: options.env, cwd: options.repoRoot }); - if (!result.ok) { - const outcome = result.termination === "timeout" - ? "timeout" - : result.termination === "spawn_error" && isMissingCommand(result.failureMessage) - ? "tool_unavailable" - : "tool_failure"; - return interpreterFailure(options, outcome, result.failureMessage ?? "Python interpreter probe failed", candidate); - } - const parsed = parseInterpreterProbe(result.stdout); - if (parsed === undefined) { - return interpreterFailure(options, "tool_failure", "Python interpreter probe returned malformed output", candidate); - } - return { - tool: "python", - available: true, - outcome: "resolved", - command: parsed.executable, - args, - cwd: options.repoRoot, - source: candidate.source, - version: parsed.version, - ...(options.targetPythonVersion === undefined ? {} : { targetVersion: options.targetPythonVersion }) - }; -} - -function parseInterpreterProbe(stdout: string): { executable: string; version: string } | undefined { - let parsed: unknown; - try { - parsed = JSON.parse(stdout.trim()); - } catch { - return undefined; - } - if (!isProtocolRecord(parsed) || !hasExactProtocolKeys(parsed, ["protocol", "executable", "version"])) return undefined; - if (parsed.protocol !== "opcore.python.interpreter.v1") return undefined; - if (typeof parsed.executable !== "string" || !isAbsolute(parsed.executable)) return undefined; - if (typeof parsed.version !== "string" || parsePythonVersion(parsed.version) === undefined) return undefined; - return { executable: parsed.executable, version: parsed.version }; -} - -function targetCompatibleResolution( - resolution: ResolvedPythonInterpreter, - targetVersion: string | undefined -): PythonInterpreterResolution { - if (targetVersion === undefined) return resolution; - const interpreter = parsePythonVersion(resolution.version); - const target = parsePythonVersion(targetVersion); - if (interpreter !== undefined && target !== undefined && interpreter.major === target.major && interpreter.minor === target.minor) { - return resolution; - } - return { - ...resolution, - available: false, - outcome: "unsupported_target", - failureMessage: `Selected Python ${resolution.version} cannot judge declared target Python ${targetVersion}; major/minor versions must match` - }; -} - -function interpreterFailure( - options: PythonToolResolverOptions, - outcome: UnresolvedPythonInterpreter["outcome"], - failureMessage: string, - candidate: PythonToolCandidate = { command: options.pythonCommand ?? "python3", source: options.pythonCommand === undefined ? "path" : "configured" } -): UnresolvedPythonInterpreter { - return { - tool: "python", - available: false, - outcome, - command: candidate.command, - args: [], - cwd: options.repoRoot, - source: candidate.source, - ...(options.targetPythonVersion === undefined ? {} : { targetVersion: options.targetPythonVersion }), - failureMessage - }; -} - -function validateTargetVersion(targetVersion: string | undefined): string | undefined { - if (targetVersion === undefined) return undefined; - return parsePythonVersion(targetVersion) === undefined - ? `Configured Python target version must be major.minor or major.minor.patch: ${targetVersion}` - : undefined; -} - -function parsePythonVersion(value: string): { major: number; minor: number } | undefined { - const match = /^(?\d+)\.(?\d+)(?:\.\d+)?$/u.exec(value.trim()); - if (match?.groups === undefined) return undefined; - return { major: Number(match.groups.major), minor: Number(match.groups.minor) }; -} - -function isMissingCommand(message: string | undefined): boolean { - return message?.includes("ENOENT") === true || message?.includes("not found") === true; -} - -export function resolvePythonTool( - tool: string, - fallbackCommand: string, - versionArgs: readonly string[], - options: PythonToolResolverOptions -): PythonToolResolution { - const repoRoot = options.repoRoot; - const binaryName = tool === "python" ? "python" : tool; - const configFile = findPythonConfigFile(repoRoot, tool); - const candidates: PythonToolCandidate[] = []; - - const virtualEnv = options.env?.VIRTUAL_ENV; - if (virtualEnv) { - candidates.push({ command: join(virtualEnv, "bin", binaryName), source: "virtualenv-env" }); - } - for (const dir of pythonVirtualEnvDirs) { - candidates.push({ command: join(repoRoot, dir, "bin", binaryName), source: "repo-venv" }); - } - candidates.push({ command: join(repoRoot, "node_modules", ".bin", binaryName), source: "node-modules" }); - candidates.push({ command: fallbackCommand, source: "path" }); - - for (const candidate of candidates) { - if (candidate.source !== "path" && !existsSync(candidate.command)) continue; - const result = runTool(candidate.command, versionArgs, { env: options.env, cwd: repoRoot }); - if (result.ok) { - const version = (result.stdout || result.stderr).trim().split(/\r?\n/, 1)[0]; - return { - tool, - available: true, - command: candidate.command, - args: versionArgs, - cwd: repoRoot, - source: candidate.source, - ...(version.length > 0 ? { version } : {}), - ...(configFile !== undefined ? { configFile } : {}) - }; - } - } - - const failureResult = runTool(fallbackCommand, versionArgs, { env: options.env, cwd: repoRoot }); - return { - tool, - available: false, - command: fallbackCommand, - args: versionArgs, - cwd: repoRoot, - source: "path", - ...(configFile !== undefined ? { configFile } : {}), - failureMessage: failureResult.failureMessage ?? `${tool} unavailable` - }; -} - -export function findPythonConfigFile(repoRoot: string, tool: string): string | undefined { - const candidates = pythonConfigFileCandidates[tool] ?? ["pyproject.toml"]; - for (const name of candidates) { - const candidatePath = join(repoRoot, name); - if (existsSync(candidatePath)) return candidatePath; - } - return undefined; -} diff --git a/packages/validation-python/src/toolchain.ts b/packages/validation-python/src/toolchain.ts index 6be6d48..cd363ad 100644 --- a/packages/validation-python/src/toolchain.ts +++ b/packages/validation-python/src/toolchain.ts @@ -1,59 +1,46 @@ import type { + PythonProjectContext, + PythonProjectToolKind, ValidationAdapterDegradedCheckStatus, ValidationAdapterRuntimeStatus, ValidationAdapterToolchainStatus } from "@the-open-engine/opcore-contracts"; import { PYTHON_SYNTAX_CHECK_ID, PYTHON_TYPES_CHECK_ID, pythonValidationCheckIds } from "./check-ids.js"; import { validationPythonAdapterName } from "./check-constants.js"; -import { - resolvePythonInterpreter, - resolvePythonTool, - type PythonInterpreterResolution, - type PythonToolResolution -} from "./toolchain-resolver.js"; +import type { PythonProjectProcessProbe } from "./environment-resolution.js"; +import type { PythonProjectWorkspace } from "./project-workspace.js"; export interface PythonValidationToolchainOptions { repoRoot?: string; env?: Record; - pythonCommand?: string; - targetPythonVersion?: string; + interpreterArgv?: readonly string[]; + toolArgv?: Partial>; + platform?: string; + architecture?: string; + processProbe?: PythonProjectProcessProbe; + timeoutMs?: number; + contexts?: readonly PythonProjectContext[]; + nodeWorkspace?: PythonProjectWorkspace; } export function createPythonValidationAdapterStatus( options: PythonValidationToolchainOptions = {} ): ValidationAdapterRuntimeStatus { - const checkIds = [...pythonValidationCheckIds]; - const toolchain = probePythonToolchain(options); + const toolchain = options.contexts === undefined ? unresolvedContextToolchain() : toolchainFromContexts(options.contexts); const missing = new Set(toolchain.filter((tool) => !tool.available).map((tool) => tool.tool)); const degradedChecks = createPythonDegradedChecks(missing); return { adapter: validationPythonAdapterName, - status: degradedChecks.length > 0 ? "degraded" : "available", - checkIds, + status: degradedChecks.length > 0 || options.contexts?.some((context) => context.outcome !== "resolved") + ? "degraded" + : "available", + checkIds: [...pythonValidationCheckIds], toolchain, degradedChecks, tempWorkspaceRequired: false }; } -export function probePythonToolchain( - options: PythonValidationToolchainOptions = {} -): readonly ValidationAdapterToolchainStatus[] { - const resolverOptions = { - repoRoot: options.repoRoot ?? process.cwd(), - env: options.env, - pythonCommand: options.pythonCommand, - targetPythonVersion: options.targetPythonVersion - }; - return [ - toInterpreterToolchainStatus(resolvePythonInterpreter(resolverOptions)), - toToolchainStatus(resolvePythonTool("mypy", "mypy", ["--version"], resolverOptions)), - toToolchainStatus(resolvePythonTool("pyright", "pyright", ["--version"], resolverOptions)), - toToolchainStatus(resolvePythonTool("ruff", "ruff", ["--version"], resolverOptions)), - toToolchainStatus(resolvePythonTool("pytest", "pytest", ["--version"], resolverOptions)) - ]; -} - export function createPythonDegradedChecks(missing: ReadonlySet): readonly ValidationAdapterDegradedCheckStatus[] { const degraded: ValidationAdapterDegradedCheckStatus[] = []; if (missing.has("mypy") && missing.has("pyright")) { @@ -70,34 +57,54 @@ export function createPythonDegradedChecks(missing: ReadonlySet): readon checkId: PYTHON_SYNTAX_CHECK_ID, status: "unsupported_request", reason: "required_tool_unavailable", - requiredTool: "python3", - message: "No compatible Python interpreter is available; python.syntax cannot compile the selected after-state, so results are reported as unsupported instead of a false pass." + requiredTool: "python", + message: "No compatible Python interpreter is available; python.syntax cannot compile the selected after-state." }); } return degraded; } -function toInterpreterToolchainStatus(resolution: PythonInterpreterResolution): ValidationAdapterToolchainStatus { - return { - tool: resolution.tool, - available: resolution.available, - command: resolution.command, - cwd: resolution.cwd, - source: resolution.source, - ...(resolution.version !== undefined ? { version: resolution.version } : {}), - ...(resolution.failureMessage !== undefined ? { failureMessage: resolution.failureMessage } : {}) - }; +function toolchainFromContexts(contexts: readonly PythonProjectContext[]): readonly ValidationAdapterToolchainStatus[] { + const entries: ValidationAdapterToolchainStatus[] = []; + for (const context of contexts) { + if (context.interpreter !== undefined) { + entries.push({ + tool: "python", + available: !context.reasons.some((reason) => reason.tool === "python"), + command: context.interpreter.argv.join(" "), + cwd: context.interpreter.cwd, + source: context.interpreter.source, + ...(context.interpreter.version === undefined ? {} : { version: context.interpreter.version }) + }); + } else { + entries.push({ tool: "python", available: false, failureMessage: reasonMessage(context, "python") }); + } + for (const tool of context.tools) { + entries.push({ + tool: tool.tool, + available: tool.available, + command: tool.argv.join(" "), + cwd: tool.cwd, + source: tool.source, + ...(tool.version === undefined ? {} : { version: tool.version }), + ...(tool.configFile === undefined ? {} : { configFile: tool.configFile }), + ...(tool.available ? {} : { failureMessage: reasonMessage(context, tool.tool) }) + }); + } + } + const byIdentity = new Map(); + for (const entry of entries) byIdentity.set(`${entry.tool}\0${entry.command ?? ""}\0${entry.cwd ?? ""}`, entry); + return [...byIdentity.values()].sort((left, right) => `${left.tool}\0${left.command ?? ""}`.localeCompare(`${right.tool}\0${right.command ?? ""}`)); } -function toToolchainStatus(resolution: PythonToolResolution): ValidationAdapterToolchainStatus { - return { - tool: resolution.tool, - available: resolution.available, - command: [resolution.command, ...resolution.args].join(" "), - cwd: resolution.cwd, - source: resolution.source, - ...(resolution.version !== undefined ? { version: resolution.version } : {}), - ...(resolution.configFile !== undefined ? { configFile: resolution.configFile } : {}), - ...(resolution.failureMessage !== undefined ? { failureMessage: resolution.failureMessage } : {}) - }; +function unresolvedContextToolchain(): readonly ValidationAdapterToolchainStatus[] { + return ["python", "mypy", "pyright", "ruff", "pytest"].map((tool) => ({ + tool, + available: false, + failureMessage: "Canonical Python project contexts are required before reporting toolchain availability" + })); +} + +function reasonMessage(context: PythonProjectContext, tool: string): string { + return context.reasons.find((reason) => reason.tool === tool)?.message ?? `${tool} unavailable for ${context.projectRoot}`; } diff --git a/packages/validation-python/src/type-check.ts b/packages/validation-python/src/type-check.ts index 02aac80..b8367b3 100644 --- a/packages/validation-python/src/type-check.ts +++ b/packages/validation-python/src/type-check.ts @@ -1,16 +1,30 @@ -import type { ValidationCheckDefinition } from "@the-open-engine/opcore-validation"; -import type { ValidationCheckOutcome, ValidationDiagnostic } from "@the-open-engine/opcore-contracts"; -import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; -import { mkdir, copyFile } from "node:fs/promises"; +import type { + PythonProjectContext, + PythonProjectToolProvenance, + ValidationCheckOutcome, + ValidationDiagnostic +} from "@the-open-engine/opcore-contracts"; +import type { + ValidationCheckContext, + ValidationCheckDefinition, + ValidationCheckResult +} from "@the-open-engine/opcore-validation"; +import { mkdtempSync, 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 { PYTHON_TYPES_CHECK_ID } from "./check-ids.js"; import { pythonCheckAdapter, pythonCheckOwner, supportedPythonValidationScopes } from "./check-constants.js"; import { diagnostic, sortDiagnostics } from "./diagnostics.js"; import { runTool } from "./process.js"; -import { materializePythonSources, type PythonMaterializedSourceSet } from "./source-files.js"; -import { type PythonValidationToolchainOptions } from "./toolchain.js"; -import { resolvePythonTool, type PythonToolResolution } from "./toolchain-resolver.js"; +import { + materializePythonSources, + pythonInputSet, + skippedPythonInputResult, + type PythonMaterializedSourceFile, + type PythonProjectContextResolver +} from "./source-files.js"; +import type { PythonValidationToolchainOptions } from "./toolchain.js"; export interface PythonTypeCheckOptions extends PythonValidationToolchainOptions { timeoutMs?: number; @@ -18,18 +32,19 @@ export interface PythonTypeCheckOptions extends PythonValidationToolchainOptions interface MaterializedPythonTypeWorkspace { root: string; - cleanup: () => void; + projectCwd: string; + cleanup(): void; } -const pythonToolConfigFiles = [ - "mypy.ini", - "setup.cfg", - "tox.ini", - "pyproject.toml", - "pyrightconfig.json" -] as const; +interface PythonProjectGroup { + context: PythonProjectContext; + targets: readonly string[]; +} -export function createTypeCheck(options: PythonTypeCheckOptions = {}): ValidationCheckDefinition { +export function createTypeCheck( + options: PythonTypeCheckOptions = {}, + resolveContexts?: PythonProjectContextResolver +): ValidationCheckDefinition { return { id: PYTHON_TYPES_CHECK_ID, owner: pythonCheckOwner, @@ -37,95 +52,129 @@ export function createTypeCheck(options: PythonTypeCheckOptions = {}): Validatio defaultSeverity: "warning", supportedScopes: supportedPythonValidationScopes, run: async (context) => { - const sourceSet = await materializePythonSources(context); + const skipped = skippedPythonInputResult(context); + if (skipped !== undefined) return skipped; + if (resolveContexts === undefined) return missingContextResult(pythonInputSet(context)); + const resolvedContexts = await resolveContexts(context); + const selectedTargets = pythonInputSet(context); + const missing = selectedTargets.filter((path) => !resolvedContexts.some((candidate) => candidate.target === path)); + if (resolvedContexts.length === 0 || missing.length > 0) return missingContextResult(missing); + const sourceSet = await materializePythonSources(context, resolvedContexts); if (sourceSet.files.length === 0) return { diagnostics: [] }; - const repoRoot = context.request.repo.repoRoot ?? process.cwd(); - const checker = selectTypeChecker({ ...options, repoRoot }); - if (checker === undefined) { - return { - outcome: "tool_unavailable", - failureMessage: "Neither mypy nor pyright is available.", - diagnostics: [ - { - category: "types", - severity: "info", - code: "PYTHON_TYPES_UNSUPPORTED", - message: "Python type validation requires mypy or pyright; neither tool is available." - } - ] - }; - } - - const workspace = await materializePythonTypeWorkspace(repoRoot, sourceSet); - try { - const result = runTool(checker.command, checkerArgs(checker, sourceSet), { - cwd: workspace.root, - env: options.env, - timeoutMs: options.timeoutMs ?? 30000, - allowedExitCodes: [0, 1] - }); - if (!result.ok) { - const outcome = result.termination === "timeout" ? "timeout" : "tool_failure"; - return typeToolFailure(checker, outcome, result.failureMessage ?? `${checker.tool} invocation failed`); + const unresolved = resolvedContexts.find(isUnresolvedTypeContext); + if (unresolved !== undefined) return unresolvedProjectResult(unresolved); + const projects = groupProjectContexts(resolvedContexts); + const diagnostics: ValidationDiagnostic[] = []; + for (const project of projects) { + const checker = selectTypeChecker(project.context); + if (checker === undefined) return missingTypeChecker(project.context); + const files = sourceSet.files.filter((file) => owningProject(file.path, projects)?.context.projectKey === project.context.projectKey); + const workspace = await materializePythonTypeWorkspace(context, project, files); + try { + const args = [ + ...checker.argv.slice(1), + ...project.targets.map((path) => relativeProjectPath(path, project.context.projectRoot)) + ]; + const result = runTool(checker.executable, args, { + cwd: workspace.projectCwd, + env: options.env, + timeoutMs: options.timeoutMs ?? 30000, + allowedExitCodes: [0, 1] + }); + if (!result.ok) { + const outcome = result.termination === "timeout" ? "timeout" : "tool_failure"; + return typeToolFailure(checker, outcome, result.failureMessage ?? `${checker.tool} invocation failed`); + } + const parsed = parseTypeCheckerDiagnostics( + checker, + result.stdout, + result.stderr, + workspace.projectCwd, + workspace.root + ); + diagnostics.push(...parsed); + if (result.exitCode !== 0 && parsed.length === 0) { + return typeToolFailure(checker, "tool_failure", `${checker.tool} exited ${result.exitCode} without parseable diagnostics`); + } + } finally { + workspace.cleanup(); } - const diagnostics = sortDiagnostics(parseTypeCheckerDiagnostics(checker, result.stdout, result.stderr, workspace.root)); - if (result.exitCode !== 0 && diagnostics.length === 0) { - return typeToolFailure(checker, "tool_failure", `${checker.tool} exited ${result.exitCode} without parseable diagnostics`); - } - return { - outcome: diagnostics.some((entry) => entry.severity === "error") ? "findings" : "passed", - diagnostics - }; - } finally { - workspace.cleanup(); } + const sorted = sortDiagnostics(diagnostics); + return { outcome: sorted.some((entry) => entry.severity === "error") ? "findings" : "passed", diagnostics: sorted }; } }; } -function selectTypeChecker(options: Required> & PythonValidationToolchainOptions): PythonToolResolution | undefined { - const resolverOptions = { repoRoot: options.repoRoot, env: options.env, pythonCommand: options.pythonCommand }; - const mypy = resolvePythonTool("mypy", "mypy", ["--version"], resolverOptions); - const pyright = resolvePythonTool("pyright", "pyright", ["--version"], resolverOptions); - if (!mypy.available && !pyright.available) return undefined; - if (pyright.available && !mypy.available) return pyright; - if (mypy.available && !pyright.available) return mypy; - if (pyright.configFile?.endsWith("pyrightconfig.json") && !mypy.configFile?.endsWith("mypy.ini")) return pyright; - return mypy; +function 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 + }] + }; +} + +function groupProjectContexts(contexts: readonly PythonProjectContext[]): readonly PythonProjectGroup[] { + 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((group) => ({ context: group.context, targets: [...new Set(group.targets)].sort() })) + .sort((left, right) => left.context.projectRoot.localeCompare(right.context.projectRoot)); } -function checkerArgs(checker: PythonToolResolution, sourceSet: PythonMaterializedSourceSet): readonly string[] { - if (checker.tool === "pyright") return [...sourceSet.rootPaths]; - return [...sourceSet.rootPaths]; +function isUnresolvedTypeContext(context: PythonProjectContext): boolean { + return context.outcome === "ambiguous" || context.outcome === "unsupported" || context.interpreter === undefined || + context.reasons.some((reason) => reason.code === "invalid_config"); +} + +function selectTypeChecker(context: PythonProjectContext): PythonProjectToolProvenance | undefined { + const mypy = context.tools.find((tool) => tool.tool === "mypy" && tool.available); + const pyright = context.tools.find((tool) => tool.tool === "pyright" && tool.available); + if (context.tools.some((tool) => tool.tool === "pyright" && tool.configFile?.endsWith("pyrightconfig.json"))) { + return pyright ?? mypy; + } + return mypy ?? pyright; } async function materializePythonTypeWorkspace( - repoRoot: string, - sourceSet: PythonMaterializedSourceSet + validation: ValidationCheckContext, + project: PythonProjectGroup, + files: readonly PythonMaterializedSourceFile[] ): Promise { const tempRoot = mkdtempSync(join(tmpdir(), "opcore-python-types-")); const root = join(tempRoot, "repo"); try { await mkdir(root, { recursive: true }); - await copyPythonConfigFiles(repoRoot, root); - for (const source of sourceSet.files) { - const absolutePath = resolveRepoPath(root, source.path); - await mkdir(dirname(absolutePath), { recursive: true }); - writeFileSync(absolutePath, source.content); + for (const source of files) await writeMaterializedFile(root, source.path, source.content); + for (const evidence of project.context.evidence) { + if (evidence.role === "layout" || evidence.role === "boundary" && !isConfigPath(evidence.path)) continue; + const result = await validation.fileView.readAfter(evidence.path); + if (result.status === "found") await writeMaterializedFile(root, evidence.path, result.content); } - return { root, cleanup: () => rmSync(tempRoot, { recursive: true, force: true }) }; + const projectCwd = project.context.projectRoot === "." ? root : resolveRepoPath(root, project.context.projectRoot); + await mkdir(projectCwd, { recursive: true }); + return { root, projectCwd, cleanup: () => rmSync(tempRoot, { recursive: true, force: true }) }; } catch (error) { rmSync(tempRoot, { recursive: true, force: true }); throw error; } } -async function copyPythonConfigFiles(repoRoot: string, root: string): Promise { - for (const file of pythonToolConfigFiles) { - const source = join(repoRoot, file); - if (!existsSync(source)) continue; - await copyFile(source, join(root, file)); - } +async function 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 { @@ -137,56 +186,92 @@ function resolveRepoPath(root: string, path: string): string { return absolutePath; } +function unresolvedProjectResult(context: PythonProjectContext): ValidationCheckResult { + const reason = context.reasons.find((entry) => entry.code === "invalid_config") ?? + context.reasons.find((entry) => entry.tool === "python") ?? context.reasons[0]; + const diagnostics: ValidationDiagnostic[] = [diagnostic({ + category: "infrastructure", + severity: "info", + code: context.outcome === "ambiguous" ? "PYTHON_CONTEXT_AMBIGUOUS" : "PYTHON_CONTEXT_UNSUPPORTED", + message: reason?.message ?? `Python project context is unresolved for ${context.target}`, + path: context.target + })]; + if (selectTypeChecker(context) === undefined) { + diagnostics.push({ + category: "types", + severity: "info", + code: "PYTHON_TYPES_UNSUPPORTED", + message: `Python type validation requires mypy or pyright for project ${context.projectRoot}; neither tool is available.` + }); + } + return { + outcome: reason?.code === "invalid_config" || context.outcome === "ambiguous" ? "invalid_config" : "unsupported_target", + failureMessage: reason?.message ?? `Python project context is unresolved for ${context.target}`, + diagnostics + }; +} + +function missingTypeChecker(context: PythonProjectContext): ValidationCheckResult { + return { + outcome: "tool_unavailable", + failureMessage: `Neither mypy nor pyright is available for ${context.projectRoot}.`, + diagnostics: [{ + category: "types", + severity: "info", + code: "PYTHON_TYPES_UNSUPPORTED", + message: `Python type validation requires mypy or pyright for project ${context.projectRoot}; neither tool is available.` + }] + }; +} + function typeToolFailure( - checker: PythonToolResolution, + checker: PythonProjectToolProvenance, outcome: Extract, message: string -) { +): ValidationCheckResult { return { outcome, failureMessage: message, - diagnostics: [ - diagnostic({ - category: "infrastructure", - code: outcome === "timeout" ? "PYTHON_TYPES_TOOL_TIMEOUT" : "PYTHON_TYPES_TOOL_FAILED", - message: `${checker.tool} could not run: ${message}`, - tool: checkerProvenance(checker) - }) - ] + diagnostics: [diagnostic({ + category: "infrastructure", + code: outcome === "timeout" ? "PYTHON_TYPES_TOOL_TIMEOUT" : "PYTHON_TYPES_TOOL_FAILED", + message: `${checker.tool} could not run: ${message}`, + tool: checkerProvenance(checker) + })] }; } function parseTypeCheckerDiagnostics( - checker: PythonToolResolution, + checker: PythonProjectToolProvenance, stdout: string, stderr: string, + checkerCwd: string, workspaceRoot: string ): readonly ValidationDiagnostic[] { const text = [stdout, stderr].filter((part) => part.trim().length > 0).join("\n"); const diagnostics: ValidationDiagnostic[] = []; for (const line of text.split(/\r?\n/u)) { const parsed = checker.tool === "pyright" - ? parsePyrightLine(line, workspaceRoot, checker) - : parseMypyLine(line, workspaceRoot, checker); + ? parsePyrightLine(line, checkerCwd, workspaceRoot, checker) + : parseMypyLine(line, checkerCwd, workspaceRoot, checker); if (parsed !== undefined) diagnostics.push(parsed); } - return diagnostics; + return sortDiagnostics(diagnostics); } function parseMypyLine( line: string, + checkerCwd: string, workspaceRoot: string, - checker: PythonToolResolution + checker: PythonProjectToolProvenance ): ValidationDiagnostic | undefined { const match = /^(?.+?):(?\d+)(?::(?\d+))?:\s+(?error|warning|note):\s+(?.+?)(?:\s+\[(?[^\]]+)\])?$/u.exec(line.trim()); if (match?.groups === undefined) return undefined; - const severity = match.groups.severity === "error" ? "error" : "warning"; - const code = match.groups.code !== undefined ? `MYPY_${normalizeDiagnosticCode(match.groups.code)}` : "MYPY_TYPE_ERROR"; return diagnostic({ category: "types", - severity, - path: repoRelativeDiagnosticPath(match.groups.path, workspaceRoot), - code, + severity: match.groups.severity === "error" ? "error" : "warning", + path: repoRelativeDiagnosticPath(match.groups.path, checkerCwd, workspaceRoot), + code: match.groups.code === undefined ? "MYPY_TYPE_ERROR" : `MYPY_${normalizeDiagnosticCode(match.groups.code)}`, message: match.groups.message, line: parsePositiveInteger(match.groups.line), column: parsePositiveInteger(match.groups.column), @@ -196,18 +281,17 @@ function parseMypyLine( function parsePyrightLine( line: string, + checkerCwd: string, workspaceRoot: string, - checker: PythonToolResolution + checker: PythonProjectToolProvenance ): ValidationDiagnostic | undefined { const match = /^\s*(?.+?):(?\d+):(?\d+)\s+-\s+(?error|warning|information):\s+(?.+?)(?:\s+\((?[^)]+)\))?$/u.exec(line.trim()); if (match?.groups === undefined) return undefined; - const severity = match.groups.severity === "error" ? "error" : match.groups.severity === "warning" ? "warning" : "info"; - const code = match.groups.code !== undefined ? `PYRIGHT_${normalizeDiagnosticCode(match.groups.code)}` : "PYRIGHT_TYPE_ERROR"; return diagnostic({ category: "types", - severity, - path: repoRelativeDiagnosticPath(match.groups.path, workspaceRoot), - code, + severity: match.groups.severity === "error" ? "error" : match.groups.severity === "warning" ? "warning" : "info", + path: repoRelativeDiagnosticPath(match.groups.path, checkerCwd, workspaceRoot), + code: match.groups.code === undefined ? "PYRIGHT_TYPE_ERROR" : `PYRIGHT_${normalizeDiagnosticCode(match.groups.code)}`, message: match.groups.message, line: parsePositiveInteger(match.groups.line), column: parsePositiveInteger(match.groups.column), @@ -215,14 +299,24 @@ function parsePyrightLine( }); } -function repoRelativeDiagnosticPath(path: string, workspaceRoot: string): string { - const absolute = resolve(workspaceRoot, path); +function checkerProvenance(checker: PythonProjectToolProvenance) { + return { + name: checker.tool, + command: checker.argv.join(" "), + ...(checker.version === undefined ? {} : { version: checker.version }), + source: checker.source, + cwd: checker.cwd + }; +} + +function repoRelativeDiagnosticPath(path: string, checkerCwd: string, workspaceRoot: string): string { + const absolute = resolve(checkerCwd, path); const relativePath = relative(workspaceRoot, absolute).replaceAll("\\", "/"); return relativePath.length > 0 && !relativePath.startsWith("..") ? relativePath : path.replaceAll("\\", "/"); } function normalizeDiagnosticCode(code: string): string { - return code.replace(/([a-z])([A-Z])/g, "$1_$2").replace(/[^A-Za-z0-9]+/g, "_").replace(/^_+|_+$/g, "").toUpperCase(); + return code.replace(/([a-z])([A-Z])/gu, "$1_$2").replace(/[^A-Za-z0-9]+/gu, "_").replace(/^_+|_+$/gu, "").toUpperCase(); } function parsePositiveInteger(value: string | undefined): number | undefined { @@ -231,12 +325,16 @@ function parsePositiveInteger(value: string | undefined): number | undefined { return Number.isInteger(parsed) && parsed > 0 ? parsed : undefined; } -function checkerProvenance(checker: Pick & Partial) { - return { - name: checker.tool, - command: checker.command ?? checker.tool, - ...(checker.version === undefined ? {} : { version: checker.version }), - ...(checker.source === undefined ? {} : { source: checker.source }), - ...(checker.cwd === undefined ? {} : { cwd: checker.cwd }) - }; +function relativeProjectPath(path: string, projectRoot: string): string { + return projectRoot === "." ? path : path.slice(`${projectRoot}/`.length); +} + +function owningProject(path: string, projects: readonly PythonProjectGroup[]): PythonProjectGroup | undefined { + return [...projects] + .filter((project) => project.context.projectRoot === "." || path.startsWith(`${project.context.projectRoot}/`)) + .sort((left, right) => right.context.projectRoot.length - left.context.projectRoot.length)[0]; +} + +function isConfigPath(path: string): boolean { + return /(?:\.toml|\.ini|\.cfg|\.lock|Pipfile|requirements.*\.txt)$/u.test(path); } diff --git a/packages/validation-python/src/version-constraint.ts b/packages/validation-python/src/version-constraint.ts new file mode 100644 index 0000000..4b77daa --- /dev/null +++ b/packages/validation-python/src/version-constraint.ts @@ -0,0 +1,102 @@ +interface ParsedPythonPrerelease { + kind: "a" | "b" | "rc"; + number: number; +} + +interface ParsedPythonVersion { + release: readonly [number, number, number]; + precision: number; + prerelease?: ParsedPythonPrerelease; +} + +interface ParsedPythonConstraintClause extends ParsedPythonVersion { + operator: "===" | "==" | "!=" | ">=" | "<=" | ">" | "<" | "~="; + wildcard: boolean; +} + +export function isSupportedPythonVersionConstraint(constraint: string): boolean { + const clauses = constraint.split(",").map((part) => part.trim()).filter(Boolean); + return clauses.length > 0 && clauses.every((clause) => parseConstraintClause(clause) !== undefined); +} + +export function pythonVersionSatisfiesConstraint(version: string, constraint: string): boolean { + const current = parsePythonVersion(version); + if (current === undefined) return false; + const clauses = constraint.split(",").map((part) => part.trim()).filter(Boolean); + if (clauses.length === 0) return false; + const targets = clauses.map(parseConstraintClause); + if (targets.some((target) => target === undefined)) return false; + const parsedTargets = targets as ParsedPythonConstraintClause[]; + if (current.prerelease !== undefined && !parsedTargets.some((target) => target.prerelease !== undefined)) return false; + return parsedTargets.every((target) => { + const comparison = compareVersion(current, target); + switch (target.operator) { + case ">=": return comparison >= 0; + case ">": return comparison > 0; + case "<=": return comparison <= 0; + case "<": return comparison < 0; + case "!=": return !equalVersion(current, target); + case "~=": { + const compatiblePrefixLength = target.precision - 1; + return comparison >= 0 && releasePrefixMatches(current.release, target.release, compatiblePrefixLength); + } + case "===": return comparison === 0; + case "==": return equalVersion(current, target); + } + }); +} + +function parseConstraintClause(value: string): ParsedPythonConstraintClause | undefined { + const match = /^(?===|==|!=|>=|<=|>|<|~=)?\s*(?\d+(?:\.\d+){1,2}(?:(?:a|b|rc)\d+)?)(?\.\*)?$/u.exec(value); + if (match?.groups === undefined) return undefined; + const parsed = parsePythonVersion(match.groups.version); + if (parsed === undefined) return undefined; + const operator = (match.groups.operator ?? "==") as ParsedPythonConstraintClause["operator"]; + const wildcard = match.groups.wildcard !== undefined; + if (wildcard && operator !== "==" && operator !== "!=") return undefined; + return { ...parsed, operator, wildcard }; +} + +function parsePythonVersion(value: string): ParsedPythonVersion | undefined { + const match = /^(?\d+)\.(?\d+)(?:\.(?\d+))?(?:(?a|b|rc)(?\d+))?(?:\+[A-Za-z0-9]+(?:[._-][A-Za-z0-9]+)*)?$/u.exec(value.trim()); + if (match?.groups === undefined) return undefined; + const prereleaseKind = match.groups.prereleaseKind as ParsedPythonPrerelease["kind"] | undefined; + return { + release: [Number(match.groups.major), Number(match.groups.minor), Number(match.groups.patch ?? 0)], + precision: match.groups.patch === undefined ? 2 : 3, + ...(prereleaseKind === undefined + ? {} + : { prerelease: { kind: prereleaseKind, number: Number(match.groups.prereleaseNumber) } }) + }; +} + +function equalVersion(current: ParsedPythonVersion, target: ParsedPythonConstraintClause): boolean { + if (target.wildcard) return releasePrefixMatches(current.release, target.release, target.precision); + return compareVersion(current, target) === 0; +} + +function releasePrefixMatches(left: readonly number[], right: readonly number[], length: number): boolean { + for (let index = 0; index < length; index += 1) { + if (left[index] !== right[index]) return false; + } + return true; +} + +function compareVersion(left: ParsedPythonVersion, right: ParsedPythonVersion): number { + const releaseComparison = compareRelease(left.release, right.release); + if (releaseComparison !== 0) return releaseComparison; + if (left.prerelease === undefined || right.prerelease === undefined) { + if (left.prerelease === undefined && right.prerelease === undefined) return 0; + return left.prerelease === undefined ? 1 : -1; + } + const rank = { a: 0, b: 1, rc: 2 } as const; + const kindComparison = rank[left.prerelease.kind] - rank[right.prerelease.kind]; + return kindComparison !== 0 ? kindComparison : left.prerelease.number - right.prerelease.number; +} + +function compareRelease(left: readonly number[], right: readonly number[]): number { + for (let index = 0; index < 3; index += 1) { + if (left[index] !== right[index]) return (left[index] ?? 0) - (right[index] ?? 0); + } + return 0; +} diff --git a/packages/validation/src/aggregation.ts b/packages/validation/src/aggregation.ts index 9b29b14..0276773 100644 --- a/packages/validation/src/aggregation.ts +++ b/packages/validation/src/aggregation.ts @@ -9,7 +9,8 @@ import type { ValidationResult, ValidationResultManifest, ValidationResultStatus, - ValidationSkippedCheck + ValidationSkippedCheck, + PythonProjectContext } from "@the-open-engine/opcore-contracts"; import { GRAPH_SCHEMA_VERSION, validateValidationResultPayload } from "@the-open-engine/opcore-contracts"; @@ -28,6 +29,7 @@ export interface AggregateValidationResultsArgs extends CreateValidationManifest status?: ValidationResultStatus; failure?: ValidationFailure; refusal?: EditRefusal; + pythonProjectContexts?: readonly PythonProjectContext[]; } const failureStatusPriority: readonly ValidationCheckRunStatus[] = [ @@ -61,11 +63,18 @@ export function aggregateValidationResults(args: AggregateValidationResultsArgs) }; if (args.graphStatus !== undefined) result.graphStatus = args.graphStatus; if (args.refusal !== undefined) result.refusal = args.refusal; + if (args.pythonProjectContexts !== undefined) result.pythonProjectContexts = deduplicatePythonProjectContexts(args.pythonProjectContexts); const failure = args.failure ?? failureForStatus(status); if (failure !== undefined) result.failure = failure; return validateValidationResultPayload(result); } +function deduplicatePythonProjectContexts(contexts: readonly PythonProjectContext[]): readonly PythonProjectContext[] { + const byTarget = new Map(); + for (const context of contexts) byTarget.set(context.target, context); + return [...byTarget.values()].sort((left, right) => left.target.localeCompare(right.target)); +} + function deriveStatus( diagnostics: readonly ValidationDiagnostic[], runs: readonly ValidationCheckRunSummary[], diff --git a/packages/validation/src/registry.ts b/packages/validation/src/registry.ts index 5ce7c8c..ac28368 100644 --- a/packages/validation/src/registry.ts +++ b/packages/validation/src/registry.ts @@ -3,6 +3,7 @@ import type { ValidationCheckOutcome, ValidationCheckRunStatus, ValidationDiagnostic, + PythonProjectContext, GraphProviderStatus, ValidationRequest, ValidationScopeKind @@ -35,6 +36,7 @@ export interface ValidationCheckResult { status?: ValidationCheckRunStatus; outcome?: ValidationCheckOutcome; failureMessage?: string; + pythonProjectContexts?: readonly PythonProjectContext[]; } export interface ValidationCheckDefinition { diff --git a/packages/validation/src/runner.ts b/packages/validation/src/runner.ts index 2643749..2d60403 100644 --- a/packages/validation/src/runner.ts +++ b/packages/validation/src/runner.ts @@ -8,6 +8,7 @@ import type { ValidationFailureCategory, ValidationRequest, ValidationResult, + PythonProjectContext, ValidationScopeKind, ValidationSkippedCheck } from "@the-open-engine/opcore-contracts"; @@ -93,6 +94,7 @@ interface CheckExecution { diagnostics: ValidationDiagnostic[]; diagnosticsByCheck: Map; skippedChecks: ValidationSkippedCheck[]; + pythonProjectContexts: PythonProjectContext[]; failureResult?: ValidationResult; } @@ -128,6 +130,7 @@ interface SingleCheckOutcome { failureMessage?: string; failureStatus?: Extract; providerError?: ValidationGraphProviderError; + pythonProjectContexts: readonly PythonProjectContext[]; } export function createValidationRunner(options: CreateValidationRunnerOptions): ValidationRunner { @@ -240,7 +243,8 @@ async function runIntroducedValidationIncremental(args: PreparedValidationArgs): runs: [], diagnostics: [], diagnosticsByCheck: new Map(), - skippedChecks: [] + skippedChecks: [], + pythonProjectContexts: [] }; const quietOptions = withoutCheckCompleteOptions(args.options); for (const check of args.selectedChecks) { @@ -284,6 +288,7 @@ function mergeCheckExecution(target: CheckExecution, source: CheckExecution): vo target.runs.push(...source.runs); target.diagnostics.push(...source.diagnostics); target.skippedChecks.push(...source.skippedChecks); + mergePythonProjectContexts(target.pythonProjectContexts, source.pythonProjectContexts); for (const [checkId, diagnostics] of source.diagnosticsByCheck) { target.diagnosticsByCheck.set(checkId, diagnostics); } @@ -386,7 +391,8 @@ async function executeSelectedChecks(args: ExecuteChecksArgs): Promise [context.target, context])); + for (const context of source) byTarget.set(context.target, context); + target.splice(0, target.length, ...[...byTarget.values()].sort((left, right) => left.target.localeCompare(right.target))); +} + function isValidationDiagnosticArray( result: ValidationCheckResult | readonly ValidationDiagnostic[] ): result is readonly ValidationDiagnostic[] { diff --git a/scripts/check-python-resolver-matrix.mjs b/scripts/check-python-resolver-matrix.mjs new file mode 100644 index 0000000..d900d3d --- /dev/null +++ b/scripts/check-python-resolver-matrix.mjs @@ -0,0 +1,333 @@ +#!/usr/bin/env node +import { execFileSync } from "node:child_process"; +import { chmod, mkdir, mkdtemp, readFile, readdir, rm, symlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { isDeepStrictEqual } from "node:util"; +import { + createNodePythonProjectWorkspace, + createPythonValidationChecks, + PYTHON_SYNTAX_CHECK_ID, + PYTHON_TYPES_CHECK_ID, + resolvePythonProjectContext +} from "../packages/validation-python/dist/index.js"; +import { + createNodeValidationWorkspace, + createValidationRunner +} from "../packages/validation/dist/index.js"; + +const fixtureRoot = new URL("../packages/fixtures/validation-python/project-context/", import.meta.url); +const matrix = JSON.parse(await readFile(new URL("resolver-matrix.json", fixtureRoot), "utf8")); +const files = await loadFixtureFiles(fixtureRoot); +const tempRoot = await mkdtemp(join(tmpdir(), "opcore-python-resolver-matrix-")); + +try { + await materialize(tempRoot, files); + const rows = []; + for (const specification of matrix.rows) { + const context = await resolveSpecification(specification); + const repeated = await resolveSpecification(specification); + const actual = summarize(context); + const fingerprintStable = context.projectKey === repeated.projectKey && + context.contextFingerprint === repeated.contextFingerprint; + const invariants = contextInvariants(context, fingerprintStable); + rows.push({ + id: specification.id, + execution: specification.execution, + target: specification.target, + expected: specification.expected, + actual, + matchesExpected: matchesExpected(specification.expected, actual), + invariants, + provenance: { + hostPlatform: process.platform, + hostArchitecture: process.arch, + interpreter: context.interpreter ?? null, + tools: context.tools, + projectKey: context.projectKey, + contextFingerprint: context.contextFingerprint + } + }); + } + const validation = await verifyNestedValidationExecution(); + const output = { + schemaVersion: 1, + contract: "opcore.python.project-context.v1", + fixture: "packages/fixtures/validation-python/project-context/resolver-matrix.json", + rows, + validation, + allMatched: rows.every((row) => row.matchesExpected && Object.values(row.invariants).every(Boolean)) && + validation.matchesExpected + }; + process.stdout.write(`${JSON.stringify(output, null, process.argv.includes("--json") ? 0 : 2)}\n`); + if (!output.allMatched) process.exitCode = 1; +} finally { + await rm(tempRoot, { recursive: true, force: true }); +} + +function resolveSpecification(specification) { + return specification.execution === "real" + ? resolveReal(specification) + : resolveSimulated(specification); +} + +async function resolveReal(specification) { + const missingTools = Object.fromEntries( + ["mypy", "pyright", "ruff", "pytest"].map((tool) => [tool, [`/opcore-missing-tools/${tool}`]]) + ); + return resolvePythonProjectContext({ + repoRoot: tempRoot, + target: specification.target, + workspace: createNodePythonProjectWorkspace(tempRoot), + interpreterArgv: [process.env.OPCORE_PYTHON ?? "python3"], + toolArgv: missingTools + }); +} + +async function resolveSimulated(specification) { + const windows = specification.platform === "win32"; + const repoRoot = windows ? "C:\\fixture" : "/fixture"; + const projectRoot = specification.expected.projectRoot; + const projectPath = projectRoot === "." ? "" : `${projectRoot}/`; + const environmentRoot = windows + ? `C:\\fixture\\${projectPath.replaceAll("/", "\\")}\.venv`.replace("\\\\.venv", "\\.venv") + : `/fixture/${projectPath}.venv`.replace("//", "/"); + const interpreter = windows + ? specification.windowsInterpreterLayout === "environment-root" + ? `${environmentRoot}\\python.exe` + : `${environmentRoot}\\Scripts\\python.exe` + : `${environmentRoot}/bin/python`; + const workspace = inMemoryWorkspace(files, (path) => { + if (path === interpreter) return true; + if (windows) return path.startsWith(`${environmentRoot}\\Scripts\\`) && path.endsWith(".exe"); + return path.startsWith(`${environmentRoot}/bin/`); + }); + return resolvePythonProjectContext({ + repoRoot, + target: specification.target, + workspace, + platform: windows ? "win32" : "linux", + architecture: windows ? "x64" : "x86_64", + processProbe: simulatedProbe({ + interpreter, + windows, + version: specification.interpreterVersion ?? "3.12.4" + }) + }); +} + +function simulatedProbe({ interpreter, windows, version }) { + return { + run(command, args, options) { + const script = args[args.indexOf("-c") + 1] ?? ""; + const buildProbe = script.includes("opcore.python.project-context.build.v1"); + const interpreterProbe = args.includes("-c") && !buildProbe; + const stdout = buildProbe + ? JSON.stringify({ protocol: "opcore.python.project-context.build.v1", available: true, version: "1.0.0" }) + : interpreterProbe + ? JSON.stringify({ + protocol: "opcore.python.project-context.interpreter.v1", + executable: interpreter, + version, + implementation: "CPython", + platform: windows ? "win32" : "linux", + architecture: windows ? "AMD64" : "x86_64", + abi: `cpython-${version.split(".").slice(0, 2).join("")}`, + soabi: windows ? "cp312-win_amd64" : "cpython-312-x86_64-linux-gnu" + }) + : `${command} 1.0.0`; + return { + command, + args, + cwd: options.cwd, + allowedExitCodes: [0], + exitCode: 0, + signal: null, + stdout, + stderr: "", + termination: "exited", + ok: true + }; + } + }; +} + +function summarize(context) { + return { + target: context.target, + repositoryRoot: context.repositoryRoot, + projectRoot: context.projectRoot, + projectBoundary: context.projectBoundary, + sourceRoots: context.sourceRoots, + layout: context.layout, + evidence: context.evidence, + targetRuntime: context.targetRuntime, + managers: context.managers, + ...(context.buildSystem === undefined ? {} : { buildSystem: context.buildSystem }), + interpreter: context.interpreter ?? null, + tools: context.tools, + projectKey: context.projectKey, + contextFingerprint: context.contextFingerprint, + outcome: context.outcome, + reasons: context.reasons.map((reason) => ({ + code: reason.code, + ...(reason.path === undefined ? {} : { path: reason.path }), + ...(reason.tool === undefined ? {} : { tool: reason.tool }) + })) + }; +} + +function contextInvariants(context, fingerprintStable) { + return { + projectKeySha256: /^sha256:[a-f0-9]{64}$/u.test(context.projectKey), + contextFingerprintSha256: /^sha256:[a-f0-9]{64}$/u.test(context.contextFingerprint), + fingerprintStable, + interpreterArgvExact: context.interpreter === undefined || context.interpreter.argv[0] === context.interpreter.executable, + interpreterCwdExact: context.interpreter === undefined || context.interpreter.cwd === projectCwd(context), + toolArgvExact: context.tools.every((tool) => tool.argv[0] === tool.executable), + toolCwdExact: context.tools.every((tool) => tool.cwd === projectCwd(context)) + }; +} + +function projectCwd(context) { + if (context.projectRoot === ".") return context.repositoryRoot; + return context.repositoryRoot.includes("\\") + ? `${context.repositoryRoot}\\${context.projectRoot.replaceAll("/", "\\")}` + : `${context.repositoryRoot}/${context.projectRoot}`; +} + +function matchesExpected(expected, actual) { + if (typeof expected === "string" && expected.startsWith("<") && expected.endsWith(">")) { + if (expected === "") return typeof actual === "string" && /^sha256:[a-f0-9]{64}$/u.test(actual); + if (expected === "") return typeof actual === "string" && (/^\//u.test(actual) || /^[A-Za-z]:[\\/]/u.test(actual)); + if (expected === "") return typeof actual === "string" && /^\d+(?:\.\d+)+/u.test(actual); + if (expected === "") return actual === process.platform; + if (expected === "") return actual === process.arch; + } + if (typeof expected === "string" && expected.includes("")) { + return actual === expected.replaceAll("", tempRoot); + } + if (Array.isArray(expected)) { + return Array.isArray(actual) && expected.length === actual.length && + expected.every((entry, index) => matchesExpected(entry, actual[index])); + } + if (expected && typeof expected === "object") { + return actual && typeof actual === "object" && !Array.isArray(actual) && + Object.entries(expected).every(([key, value]) => matchesExpected(value, actual[key])); + } + return isDeepStrictEqual(expected, actual); +} + +async function verifyNestedValidationExecution() { + if (process.platform === "win32") { + return { execution: "skipped", reason: "POSIX wrapper row is not applicable on Windows", matchesExpected: true }; + } + const environmentRoot = join(tempRoot, "services/api/.venv/bin"); + const interpreter = join(environmentRoot, "python"); + const mypy = join(environmentRoot, "mypy"); + const logPath = join(tempRoot, "python-execution.log"); + const hostPython = process.env.OPCORE_PYTHON ?? "python3"; + const hostPythonExecutable = execFileSync(hostPython, ["-I", "-B", "-c", "import sys; print(sys.executable)"], { + encoding: "utf8" + }).trim(); + const hostPythonVersion = execFileSync(hostPython, ["-I", "-B", "-c", "import platform; print(platform.python_version())"], { + encoding: "utf8" + }).trim(); + const targetPythonVersion = hostPythonVersion.split(".").slice(0, 2).join("."); + const pyprojectPath = join(tempRoot, "services/api/pyproject.toml"); + const pyproject = await readFile(pyprojectPath, "utf8"); + await writeFile( + pyprojectPath, + pyproject.replace(/requires-python\s*=\s*"[^"]+"/u, `requires-python = ">=${targetPythonVersion}"`), + "utf8" + ); + await writeFile( + join(tempRoot, "services/api/pyrightconfig.json"), + `${JSON.stringify({ pythonVersion: targetPythonVersion })}\n`, + "utf8" + ); + await mkdir(environmentRoot, { recursive: true }); + await symlink(hostPythonExecutable, interpreter); + await writeFile(mypy, toolWrapper({ logPath }), "utf8"); + await chmod(mypy, 0o755); + + const target = "services/api/src/acme/api.py"; + const result = await createValidationRunner({ + workspace: createNodeValidationWorkspace({ repoRoot: tempRoot }), + checks: createPythonValidationChecks({ + env: { ...process.env }, + nodeWorkspace: createNodePythonProjectWorkspace(tempRoot) + }) + }).runValidation({ + requestId: "python-resolver-matrix-nested-execution", + repo: { repoRoot: tempRoot }, + scope: { kind: "files", files: [target] }, + graph: { mode: "optional", provider: "opcore-graph" }, + checks: [PYTHON_SYNTAX_CHECK_ID, PYTHON_TYPES_CHECK_ID], + overlays: [] + }); + const log = await readFile(logPath, "utf8").catch((error) => { + if (error?.code === "ENOENT") return ""; + throw error; + }); + const lines = log.split(/\r?\n/u).filter((line) => line.trim().length > 0).map((line) => JSON.parse(line)); + const types = lines.find((line) => line.kind === "types"); + const expectedProjectCwd = join(tempRoot, "services/api"); + const context = result.pythonProjectContexts?.find((candidate) => candidate.target === target); + const syntaxRun = result.manifest?.runs?.find((run) => run.checkId === PYTHON_SYNTAX_CHECK_ID); + const typeRun = result.manifest?.runs?.find((run) => run.checkId === PYTHON_TYPES_CHECK_ID); + const matches = result.status === "passed" && + syntaxRun?.status === "passed" && typeRun?.status === "passed" && + context?.interpreter?.executable === hostPythonExecutable && context?.interpreter?.cwd === expectedProjectCwd && + types?.executable === mypy && types?.cwd.endsWith("/repo/services/api") && + types?.argv.includes("src/acme/api.py"); + return { + execution: "real", + hostPlatform: process.platform, + hostArchitecture: process.arch, + hostInterpreter: hostPython, + hostInterpreterVersion: hostPythonVersion, + target, + projectRoot: "services/api", + selectedInterpreter: context?.interpreter ?? null, + selectedTypeTool: mypy, + validationStatus: result.status, + checkRuns: result.manifest?.runs ?? [], + pythonProjectContexts: result.pythonProjectContexts ?? [], + processExecutions: lines, + matchesExpected: matches + }; +} + +function toolWrapper({ logPath }) { + return `#!/bin/sh\nif [ "$1" = "--version" ]; then printf '%s\\n' 'mypy 1.0.0'; exit 0; fi\n'${process.execPath}' -e 'const fs=require("fs"); fs.appendFileSync(process.argv[1], JSON.stringify({kind:"types",executable:process.argv[2],cwd:process.cwd(),argv:process.argv.slice(3)})+"\\n")' '${logPath}' "$0" "$@"\nexit 0\n`; +} + +function inMemoryWorkspace(contents, executableExists) { + const paths = [...contents.keys()].sort(); + return { + read: async (path) => contents.get(path), + list: async () => paths, + exists: async (path) => contents.has(path), + realpath: async (path) => ({ path, symlink: false }), + executableExists: async (path) => executableExists(path) + }; +} + +async function loadFixtureFiles(root) { + const contents = new Map(); + for (const entry of await readdir(root, { recursive: true })) { + const path = String(entry).replaceAll("\\", "/"); + if (!path.endsWith(".fixture")) continue; + contents.set(path.slice(0, -".fixture".length), await readFile(new URL(path, root), "utf8")); + } + return contents; +} + +async function materialize(root, contents) { + for (const [path, content] of contents) { + const absolute = join(root, path); + await mkdir(dirname(absolute), { recursive: true }); + await writeFile(absolute, content, "utf8"); + } +} diff --git a/scripts/license-report.mjs b/scripts/license-report.mjs index f0b86b0..bf202a7 100644 --- a/scripts/license-report.mjs +++ b/scripts/license-report.mjs @@ -40,7 +40,7 @@ for (const [lockPath, entry] of Object.entries(packages)) { if (!lockPath.startsWith("packages/")) continue; const bundled = entry.bundleDependencies ?? entry.bundledDependencies ?? []; for (const name of bundled) { - const runtimeEntry = bundledPackageMetadata(name); + const runtimeEntry = bundledPackageMetadata(name, lockPath); bundledRuntime.push({ name, version: runtimeEntry.version, @@ -142,8 +142,11 @@ function formatPackage(entry) { return `${entry.name}@${entry.version}`; } -function bundledPackageMetadata(name) { - const runtimeEntry = packages[`node_modules/${name}`]; +function bundledPackageMetadata(name, ownerLockPath) { + const rootRuntimePath = `node_modules/${name}`; + const ownerRuntimePath = `${ownerLockPath}/node_modules/${name}`; + const runtimePath = packages[rootRuntimePath] === undefined ? ownerRuntimePath : rootRuntimePath; + const runtimeEntry = packages[runtimePath]; if (!runtimeEntry) { return { version: "workspace-bundled", license: "UNKNOWN" }; } @@ -159,6 +162,6 @@ function bundledPackageMetadata(name) { return { version: runtimeEntry.version ?? "unknown", license: runtimeEntry.license ?? "UNKNOWN", - source: `node_modules/${name}` + source: runtimePath }; } diff --git a/scripts/release-package-dirs.mjs b/scripts/release-package-dirs.mjs index 31d1298..025fb61 100644 --- a/scripts/release-package-dirs.mjs +++ b/scripts/release-package-dirs.mjs @@ -76,6 +76,7 @@ export const bundledExternalRuntimePackageNames = Object.freeze([ "path-browserify", "picomatch", "semver", + "smol-toml", "tinyglobby", "ts-api-utils", "ts-morph", diff --git a/scripts/stage-opcore-bundle.mjs b/scripts/stage-opcore-bundle.mjs index 780cb25..cd29a01 100644 --- a/scripts/stage-opcore-bundle.mjs +++ b/scripts/stage-opcore-bundle.mjs @@ -1,6 +1,6 @@ #!/usr/bin/env node import { spawnSync } from "node:child_process"; -import { cpSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join, relative } from "node:path"; import { fileURLToPath } from "node:url"; @@ -24,11 +24,25 @@ export function stageOpcoreBundle(options = {}) { } for (const packageName of bundledExternalRuntimePackageNames) { - const packageDir = join(repoRoot, "node_modules", ...packageName.split("/")); + const packageDir = externalRuntimePackageDir(packageName); stagePackage(packageName, packageDir, opcoreNodeModulesDir, { disableLifecycleScripts: true }); } } +export function externalRuntimePackageDir(packageName) { + const suffix = ["node_modules", ...packageName.split("/")]; + const candidates = [ + join(repoRoot, ...suffix), + join(repoRoot, "packages", "opcore", ...suffix), + join(repoRoot, "packages", "validation-python", ...suffix) + ]; + const packageDir = candidates.find((candidate) => existsSync(join(candidate, "package.json"))); + if (packageDir === undefined) { + throw new Error(`Bundled external runtime package is not installed: ${packageName}`); + } + return packageDir; +} + export function clearOpcoreBundle(options = {}) { const targetOpcorePackageDir = options.opcorePackageDir ?? opcorePackageDir; rmSync(join(targetOpcorePackageDir, "node_modules"), { recursive: true, force: true }); diff --git a/scripts/write-asp-provider-manifest.mjs b/scripts/write-asp-provider-manifest.mjs index 027864d..8b3e7b9 100644 --- a/scripts/write-asp-provider-manifest.mjs +++ b/scripts/write-asp-provider-manifest.mjs @@ -1,11 +1,11 @@ #!/usr/bin/env node import { chmod, mkdir, unlink, writeFile } from "node:fs/promises"; -import { dirname, join } from "node:path"; +import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { createOpcoreAspProviderManifest, createOpcoreAspServerManifest } from "../packages/asp-provider/dist/manifest.js"; const repoRoot = fileURLToPath(new URL("..", import.meta.url)); -const packageRoot = join(repoRoot, "packages/asp-provider"); +const packageRoot = requestedPackageRoot(process.argv.slice(2)) ?? join(repoRoot, "packages/asp-provider"); const canonicalManifestPath = join(packageRoot, "dist/manifests/asp-server.json"); const provisionalManifestPath = join(packageRoot, "dist/manifests/opcore-asp-provider.provisional.json"); const legacyProviderManifestFile = ["lattice", "asp", "provider.provisional.json"].join("-"); @@ -26,3 +26,11 @@ await writeFile(provisionalManifestPath, `${JSON.stringify(provisionalManifest, await chmod(join(packageRoot, "dist/index.js"), 0o755); process.stdout.write(`wrote ${canonicalManifestPath}\n`); process.stdout.write(`wrote ${provisionalManifestPath}\n`); + +function requestedPackageRoot(args) { + const index = args.indexOf("--package-root"); + if (index < 0) return undefined; + const value = args[index + 1]; + if (typeof value !== "string" || value.length === 0) throw new Error("--package-root requires a path"); + return resolve(value); +} diff --git a/tests/asp-provider.test.mjs b/tests/asp-provider.test.mjs index 003eb55..6da66a7 100644 --- a/tests/asp-provider.test.mjs +++ b/tests/asp-provider.test.mjs @@ -2,10 +2,11 @@ import { describe, it } from "node:test"; import assert from "node:assert/strict"; import { createHash } from "node:crypto"; import { spawn, spawnSync } from "node:child_process"; -import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; +import { createAspHostValidationWorkspace } from "../packages/asp-provider/dist/workspace.js"; const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); const providerBin = join(repoRoot, "packages/asp-provider/dist/index.js"); @@ -49,6 +50,38 @@ const allCheckIds = [ ]; describe("Opcore ASP provider", () => { + it("requires positive host file-kind evidence before treating realpaths as safe", async () => { + const baseline = { rev: "tree:realpath-evidence", stampedAt: "2026-07-15T00:00:00.000Z" }; + const workspaceFor = (entries) => createAspHostValidationWorkspace( + { + request: async (method) => { + assert.equal(method, "workspace/listTree"); + return { entries, truncated: false }; + } + }, + { workspace: { baseline } }, + { baseline, grantedPermissions: { read: ["**/*"], write: false, network: false } }, + { baseline, changes: [] } + ).pythonWorkspace; + + assert.deepEqual( + await workspaceFor([]).realpath("app.py"), + { path: "app.py", symlink: false, unavailable: true } + ); + assert.deepEqual( + await workspaceFor([{ path: "app.py", blobId: "blob:missing-kind" }]).realpath("app.py"), + { path: "app.py", symlink: false, unavailable: true } + ); + assert.deepEqual( + await workspaceFor([{ path: "app.py", blobId: "blob:file", kind: "file" }]).realpath("app.py"), + { path: "app.py", symlink: false } + ); + assert.deepEqual( + await workspaceFor([{ path: "app.py", blobId: "blob:symlink", kind: "symlink" }]).realpath("app.py"), + { path: "app.py", symlink: true } + ); + }); + it("handles initialize/initialized/evaluate over stdio without mutating host files", { timeout: 60000 }, async () => { assert.equal(existsSync(providerBin), true, "run npm run build before asp-provider tests"); const repo = mkdtempSync(join(tmpdir(), "opcore-asp-provider-repo-")); @@ -268,6 +301,7 @@ describe("Opcore ASP provider", () => { writeFileSync(join(repo, "src/length.ts"), "export const ok = 1;\n"); const host = createHostWorkspace({ + ".opcore/config": readFileSync(join(repo, ".opcore/config"), "utf8"), "tsconfig.json": "{}\n", "src/length.ts": "export const ok = 1;\n" }); @@ -309,6 +343,60 @@ describe("Opcore ASP provider", () => { } }); + it("resolves nested Python context exclusively from host after-state callbacks", { timeout: 60000 }, async () => { + assert.equal(existsSync(providerBin), true, "run npm run build before asp-provider tests"); + const repo = mkdtempSync(join(tmpdir(), "opcore-asp-provider-python-context-")); + try { + mkdirSync(join(repo, "services", "api", "src"), { recursive: true }); + writeFileSync( + join(repo, "services", "api", "pyproject.toml"), + "[project]\nname='disk-must-not-win'\nrequires-python='>=99'\n" + ); + writeFileSync(join(repo, "services", "api", "src", "app.py"), "DISK = 'must-not-win'\n"); + const host = createHostWorkspace({ + "pyproject.toml": "[project]\nname='root'\nrequires-python='>=3.8'\n", + "root.py": "ROOT = 1\n", + "services/api/pyproject.toml": "[project]\nname='api'\nrequires-python='>=3.8'\n[tool.uv]\npackage=true\n", + "services/api/uv.lock": "version=1\n", + "services/api/src/app.py": "VALUE = 1\n" + }); + const peer = spawnProvider(host); + try { + await peer.request("initialize", { + protocolVersion: "asp/0.1", + host: { name: "fake-host", version: "0.1.0-test" }, + hostCapabilities: { readBlob: true, listTree: true, putBlob: false }, + workspace: { root: repo, baseline: host.baseline }, + assuranceMode: "gated" + }); + peer.notify("initialized", { + grantedPermissions: { read: ["**/*"], write: false, network: false }, + baseline: host.baseline + }); + const assessment = await peer.request("check/evaluate", { + callSite: "interactive", + changeset: host.changeset([ + host.modify("services/api/src/app.py", "VALUE = 2 # type: ignore\n") + ]), + comparison: "all", + checks: ["python.source-hygiene"] + }); + const contextEvidence = assessment.evidence.find((entry) => entry.kind === "python_project_context"); + assert.equal(contextEvidence.data.schemaId, "opcore.python.project-context.v1"); + assert.equal(contextEvidence.data.target, "services/api/src/app.py"); + assert.equal(contextEvidence.data.projectRoot, "services/api"); + assert.match(contextEvidence.data.projectKey, /^sha256:[a-f0-9]{64}$/); + assert.match(contextEvidence.data.contextFingerprint, /^sha256:[a-f0-9]{64}$/); + assert.notEqual(contextEvidence.data.outcome, "unsupported"); + assert.equal(JSON.stringify(contextEvidence).includes("disk-must-not-win"), false); + } 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"); @@ -384,21 +472,32 @@ describe("Opcore ASP provider", () => { }); it("removes stale legacy generated manifests before packaging", () => { - const manifestDir = join(repoRoot, "packages/asp-provider/dist/manifests"); - const legacyManifestPath = join(manifestDir, ["lattice", "asp", "provider.provisional.json"].join("-")); - const provisionalManifestPath = join(manifestDir, "opcore-asp-provider.provisional.json"); - const canonicalManifestPath = join(manifestDir, "asp-server.json"); - writeFileSync(legacyManifestPath, "{}\n"); - - const result = spawnSync(process.execPath, ["scripts/write-asp-provider-manifest.mjs"], { - cwd: repoRoot, - encoding: "utf8" - }); + const temp = mkdtempSync(join(tmpdir(), "opcore-asp-manifest-")); + try { + const packageRoot = join(temp, "asp-provider"); + cpSync(join(repoRoot, "packages/asp-provider"), packageRoot, { + recursive: true, + filter: (source) => !source.split(/[\\/]/u).includes("node_modules") + }); + const manifestDir = join(packageRoot, "dist/manifests"); + const legacyManifestPath = join(manifestDir, ["lattice", "asp", "provider.provisional.json"].join("-")); + const provisionalManifestPath = join(manifestDir, "opcore-asp-provider.provisional.json"); + const canonicalManifestPath = join(manifestDir, "asp-server.json"); + writeFileSync(legacyManifestPath, "{}\n"); + + const result = spawnSync( + process.execPath, + ["scripts/write-asp-provider-manifest.mjs", "--package-root", packageRoot], + { cwd: repoRoot, encoding: "utf8" } + ); - assert.equal(result.status, 0, result.stderr); - assert.equal(existsSync(legacyManifestPath), false); - assert.equal(existsSync(provisionalManifestPath), true); - assert.equal(existsSync(canonicalManifestPath), true); + assert.equal(result.status, 0, result.stderr); + assert.equal(existsSync(legacyManifestPath), false); + assert.equal(existsSync(provisionalManifestPath), true); + assert.equal(existsSync(canonicalManifestPath), true); + } finally { + rmSync(temp, { recursive: true, force: true }); + } }); }); diff --git a/tests/contracts.test.mjs b/tests/contracts.test.mjs index d1fea02..e59d2a5 100644 --- a/tests/contracts.test.mjs +++ b/tests/contracts.test.mjs @@ -3,6 +3,7 @@ import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import { GRAPH_SCHEMA_VERSION, + PYTHON_PROJECT_CONTEXT_SCHEMA_ID, commandLatencyTelemetryArtifactPolicy, commandTimingDegradationReasons, commandTimingProcessStates, @@ -100,6 +101,7 @@ import { validateRequiredContextDocPolicy, validateValidationRequestPayload, validatePreWriteValidationReceipt, + validatePythonProjectContext, validateValidationResultPayload, validateValidationStatusPayload } from "../packages/contracts/dist/index.js"; @@ -861,6 +863,88 @@ describe("Opcore shared contracts", () => { ); }); + it("validates canonical Python project contexts and rejects invalid outcomes and provenance", () => { + const context = validPythonProjectContext(); + assert.equal(validatePythonProjectContext(context).schemaId, PYTHON_PROJECT_CONTEXT_SCHEMA_ID); + assert.equal( + validateValidationResultPayload(validValidationResult({ pythonProjectContexts: [context] })).pythonProjectContexts[0].projectRoot, + "services/api" + ); + assert.throws(() => validatePythonProjectContext({ ...context, outcome: "ready" }), /outcome/); + assert.throws( + () => validatePythonProjectContext({ ...context, interpreter: { ...context.interpreter, source: "guessed" } }), + /source/ + ); + for (const field of ["implementation", "platform", "architecture", "abi", "soabi"]) { + assert.throws( + () => validatePythonProjectContext({ ...context, interpreter: { ...context.interpreter, [field]: 42 } }), + new RegExp(field) + ); + } + for (const field of ["abi", "soabi"]) { + const interpreter = { ...context.interpreter }; + delete interpreter[field]; + assert.throws(() => validatePythonProjectContext({ ...context, interpreter }), new RegExp(field)); + } + assert.throws( + () => validatePythonProjectContext({ ...context, buildSystem: { ...context.buildSystem, requires: [""] } }), + /buildSystem requires/ + ); + assert.throws( + () => validatePythonProjectContext({ ...context, interpreter: { ...context.interpreter, version: "garbage" } }), + /version/ + ); + assert.throws( + () => validatePythonProjectContext({ + ...context, + tools: [{ + tool: "mypy", + available: true, + executable: "/repo/.venv/bin/mypy", + argv: ["/repo/.venv/bin/mypy"], + cwd: "/repo", + source: "project_local_environment" + }] + }), + /version/ + ); + assert.throws(() => validatePythonProjectContext({ ...context, contextFingerprint: "secret" }), /fingerprint/i); + }); + + it("rejects schema-forbidden Python project-context properties recursively", () => { + const context = validPythonProjectContext(); + const invalidContexts = [ + { ...context, unexpected: true }, + { ...context, layout: { ...context.layout, unexpected: true } }, + { ...context, evidence: [{ ...context.evidence[0], unexpected: true }] }, + { ...context, targetRuntime: { ...context.targetRuntime, unexpected: true } }, + { ...context, managers: [{ ...context.managers[0], unexpected: true }] }, + { ...context, buildSystem: { ...context.buildSystem, unexpected: true } }, + { ...context, interpreter: { ...context.interpreter, unexpected: true } }, + { + ...context, + tools: [{ + tool: "mypy", + available: true, + executable: "/repo/services/api/.venv/bin/mypy", + argv: ["/repo/services/api/.venv/bin/mypy"], + cwd: "/repo/services/api", + source: "project_local_environment", + version: "1.8.0", + unexpected: true + }] + }, + { + ...context, + outcome: "degraded", + reasons: [{ code: "tool_unavailable", tool: "mypy", message: "mypy is unavailable", unexpected: true }] + } + ]; + for (const invalid of invalidContexts) { + assert.throws(() => validatePythonProjectContext(invalid), /unexpected|properties/i); + } + }); + it("validates validation result manifest metadata", () => { const manifest = { schemaVersion: GRAPH_SCHEMA_VERSION, @@ -3559,6 +3643,12 @@ function validManagedToolDescriptor(overrides = {}) { graphModes: ["optional", "required"], hypothetical: true, statusSurfaces: ["status", "doctor"], + pythonProjectContext: { + schemaId: "opcore.python.project-context.v1", + outcomes: ["resolved", "degraded", "unsupported", "ambiguous"], + readOnly: true, + installs: false + }, writeGate: { initScopes: ["repo", "global"], harnesses: ["claude-code", "codex"], @@ -3621,6 +3711,45 @@ function validManagedToolDescriptor(overrides = {}) { }; } +function validPythonProjectContext(overrides = {}) { + return { + schemaId: "opcore.python.project-context.v1", + schemaVersion: 1, + target: "services/api/src/app.py", + repositoryRoot: "/repo", + projectRoot: "services/api", + projectBoundary: "services/api", + sourceRoots: ["services/api/src"], + layout: { kinds: ["src", "package"], paths: ["services/api/src"] }, + evidence: [{ path: "services/api/pyproject.toml", role: "boundary" }], + targetRuntime: { requiresPython: ">=3.12", conflicts: [] }, + managers: [{ kind: "uv", configFiles: ["services/api/pyproject.toml"], lockFiles: ["services/api/uv.lock"] }], + buildSystem: { + configFile: "services/api/pyproject.toml", + backend: "hatchling.build", + requires: ["hatchling>=1"] + }, + interpreter: { + executable: "/repo/services/api/.venv/bin/python", + argv: ["/repo/services/api/.venv/bin/python"], + cwd: "/repo/services/api", + source: "project_local_environment", + version: "3.12.4", + implementation: "CPython", + platform: "linux", + architecture: "x86_64", + abi: "cpython-312", + soabi: "cpython-312-x86_64-linux-gnu" + }, + tools: [], + projectKey: `sha256:${"1".repeat(64)}`, + contextFingerprint: `sha256:${"2".repeat(64)}`, + outcome: "resolved", + reasons: [], + ...overrides + }; +} + function descriptorNativeArtifacts() { return graphCoreNativeSupportedTargets.map((targetPlatform) => { const bundledPackageName = graphCoreNativePackageNameForTarget(targetPlatform); diff --git a/tests/fixtures/package-packlists.json b/tests/fixtures/package-packlists.json index 641d1b8..6490d9e 100644 --- a/tests/fixtures/package-packlists.json +++ b/tests/fixtures/package-packlists.json @@ -382,6 +382,9 @@ "node_modules/@the-open-engine/opcore-validation-python/dist/diagnostics.d.ts", "node_modules/@the-open-engine/opcore-validation-python/dist/diagnostics.d.ts.map", "node_modules/@the-open-engine/opcore-validation-python/dist/diagnostics.js", + "node_modules/@the-open-engine/opcore-validation-python/dist/environment-resolution.d.ts", + "node_modules/@the-open-engine/opcore-validation-python/dist/environment-resolution.d.ts.map", + "node_modules/@the-open-engine/opcore-validation-python/dist/environment-resolution.js", "node_modules/@the-open-engine/opcore-validation-python/dist/graph-requirements.d.ts", "node_modules/@the-open-engine/opcore-validation-python/dist/graph-requirements.d.ts.map", "node_modules/@the-open-engine/opcore-validation-python/dist/graph-requirements.js", @@ -394,6 +397,18 @@ "node_modules/@the-open-engine/opcore-validation-python/dist/process.d.ts", "node_modules/@the-open-engine/opcore-validation-python/dist/process.d.ts.map", "node_modules/@the-open-engine/opcore-validation-python/dist/process.js", + "node_modules/@the-open-engine/opcore-validation-python/dist/project-context.d.ts", + "node_modules/@the-open-engine/opcore-validation-python/dist/project-context.d.ts.map", + "node_modules/@the-open-engine/opcore-validation-python/dist/project-context.js", + "node_modules/@the-open-engine/opcore-validation-python/dist/project-discovery.d.ts", + "node_modules/@the-open-engine/opcore-validation-python/dist/project-discovery.d.ts.map", + "node_modules/@the-open-engine/opcore-validation-python/dist/project-discovery.js", + "node_modules/@the-open-engine/opcore-validation-python/dist/project-fingerprint.d.ts", + "node_modules/@the-open-engine/opcore-validation-python/dist/project-fingerprint.d.ts.map", + "node_modules/@the-open-engine/opcore-validation-python/dist/project-fingerprint.js", + "node_modules/@the-open-engine/opcore-validation-python/dist/project-workspace.d.ts", + "node_modules/@the-open-engine/opcore-validation-python/dist/project-workspace.d.ts.map", + "node_modules/@the-open-engine/opcore-validation-python/dist/project-workspace.js", "node_modules/@the-open-engine/opcore-validation-python/dist/protocol-validation.d.ts", "node_modules/@the-open-engine/opcore-validation-python/dist/protocol-validation.d.ts.map", "node_modules/@the-open-engine/opcore-validation-python/dist/protocol-validation.js", @@ -406,18 +421,21 @@ "node_modules/@the-open-engine/opcore-validation-python/dist/source-hygiene-check.d.ts", "node_modules/@the-open-engine/opcore-validation-python/dist/source-hygiene-check.d.ts.map", "node_modules/@the-open-engine/opcore-validation-python/dist/source-hygiene-check.js", + "node_modules/@the-open-engine/opcore-validation-python/dist/static-config.d.ts", + "node_modules/@the-open-engine/opcore-validation-python/dist/static-config.d.ts.map", + "node_modules/@the-open-engine/opcore-validation-python/dist/static-config.js", "node_modules/@the-open-engine/opcore-validation-python/dist/syntax-check.d.ts", "node_modules/@the-open-engine/opcore-validation-python/dist/syntax-check.d.ts.map", "node_modules/@the-open-engine/opcore-validation-python/dist/syntax-check.js", "node_modules/@the-open-engine/opcore-validation-python/dist/toolchain.d.ts", "node_modules/@the-open-engine/opcore-validation-python/dist/toolchain.d.ts.map", "node_modules/@the-open-engine/opcore-validation-python/dist/toolchain.js", - "node_modules/@the-open-engine/opcore-validation-python/dist/toolchain-resolver.d.ts", - "node_modules/@the-open-engine/opcore-validation-python/dist/toolchain-resolver.d.ts.map", - "node_modules/@the-open-engine/opcore-validation-python/dist/toolchain-resolver.js", "node_modules/@the-open-engine/opcore-validation-python/dist/type-check.d.ts", "node_modules/@the-open-engine/opcore-validation-python/dist/type-check.d.ts.map", "node_modules/@the-open-engine/opcore-validation-python/dist/type-check.js", + "node_modules/@the-open-engine/opcore-validation-python/dist/version-constraint.d.ts", + "node_modules/@the-open-engine/opcore-validation-python/dist/version-constraint.d.ts.map", + "node_modules/@the-open-engine/opcore-validation-python/dist/version-constraint.js", "node_modules/@the-open-engine/opcore-validation-python/package.json", "node_modules/@the-open-engine/opcore-validation-rust/README.md", "node_modules/@the-open-engine/opcore-validation-rust/dist/cargo-check.d.ts", @@ -955,6 +973,28 @@ "node_modules/semver/ranges/subset.js", "node_modules/semver/ranges/to-comparators.js", "node_modules/semver/ranges/valid.js", + "node_modules/smol-toml/dist/date.d.ts", + "node_modules/smol-toml/dist/date.js", + "node_modules/smol-toml/dist/error.d.ts", + "node_modules/smol-toml/dist/error.js", + "node_modules/smol-toml/dist/extract.d.ts", + "node_modules/smol-toml/dist/extract.js", + "node_modules/smol-toml/dist/index.cjs", + "node_modules/smol-toml/dist/index.d.ts", + "node_modules/smol-toml/dist/index.js", + "node_modules/smol-toml/dist/parse.d.ts", + "node_modules/smol-toml/dist/parse.js", + "node_modules/smol-toml/dist/primitive.d.ts", + "node_modules/smol-toml/dist/primitive.js", + "node_modules/smol-toml/dist/stringify.d.ts", + "node_modules/smol-toml/dist/stringify.js", + "node_modules/smol-toml/dist/struct.d.ts", + "node_modules/smol-toml/dist/struct.js", + "node_modules/smol-toml/dist/util.d.ts", + "node_modules/smol-toml/dist/util.js", + "node_modules/smol-toml/LICENSE", + "node_modules/smol-toml/package.json", + "node_modules/smol-toml/README.md", "node_modules/tinyglobby/LICENSE", "node_modules/tinyglobby/README.md", "node_modules/tinyglobby/dist/index.cjs", diff --git a/tests/gate-negative-fixtures.test.mjs b/tests/gate-negative-fixtures.test.mjs index bed3f5f..ad0d351 100644 --- a/tests/gate-negative-fixtures.test.mjs +++ b/tests/gate-negative-fixtures.test.mjs @@ -16,6 +16,7 @@ import { releaseReceiptPackageNames } from "../packages/contracts/dist/index.js"; import { bundledExternalRuntimePackageNames } from "../scripts/release-package-dirs.mjs"; +import { externalRuntimePackageDir } from "../scripts/stage-opcore-bundle.mjs"; const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); const releaseDocsLockTimeoutMs = 900000; @@ -516,8 +517,7 @@ function tempRepo(options = {}) { function copyBundledExternalRuntimePackages(repo) { for (const packageName of bundledExternalRuntimePackageNames) { - const source = join(repoRoot, "node_modules", ...packageName.split("/")); - if (!existsSync(source)) throw new Error(`Missing external runtime package fixture source: node_modules/${packageName}`); + const source = externalRuntimePackageDir(packageName); const destination = join(repo, "node_modules", ...packageName.split("/")); mkdirSync(dirname(destination), { recursive: true }); cpSync(source, destination, { recursive: true }); diff --git a/tests/import-boundaries.test.mjs b/tests/import-boundaries.test.mjs index 4ca5342..80dd82f 100644 --- a/tests/import-boundaries.test.mjs +++ b/tests/import-boundaries.test.mjs @@ -129,7 +129,7 @@ describe("package import boundaries", () => { } if (packageDir === "validation-python") { assert.equal( - ["@the-open-engine/opcore-contracts", "@the-open-engine/opcore-validation"].includes(source), + ["@the-open-engine/opcore-contracts", "@the-open-engine/opcore-validation", "smol-toml"].includes(source), true, `${file} imports ${source}` ); diff --git a/tests/installed-bins.test.mjs b/tests/installed-bins.test.mjs index 65c442d..4c5617b 100644 --- a/tests/installed-bins.test.mjs +++ b/tests/installed-bins.test.mjs @@ -1,7 +1,7 @@ import { describe, it } from "node:test"; import assert from "node:assert/strict"; import { createHash } from "node:crypto"; -import { spawnSync } from "node:child_process"; +import { spawn, spawnSync } from "node:child_process"; import { existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; @@ -23,7 +23,7 @@ const currentNativePackage = graphCoreNativePackageNamesByTarget[currentTarget]; const packageNames = ["opcore"]; describe("installed package bins", () => { - it("installs the packed Opcore package and exposes only canonical Opcore bins", { timeout: 120000 }, () => { + it("installs the packed Opcore package and exposes only canonical Opcore bins", { timeout: 120000 }, async () => { assert.ok(currentNativePackage, `unsupported local graph-core target ${currentTarget}`); const temp = mkdtempSync(join(tmpdir(), "lattice-installed-bins-")); try { @@ -32,6 +32,15 @@ describe("installed package bins", () => { mkdirSync(project); run("npm", ["init", "-y"], { cwd: project }); run("npm", ["install", "--ignore-scripts", ...tarballs], { cwd: project }); + mkdirSync(join(project, "services", "api", "src"), { recursive: true }); + writeFileSync(join(project, "pyproject.toml"), "[project]\nname='root-fixture'\nrequires-python='>=3.8'\n"); + writeFileSync(join(project, "root.py"), "ROOT = 1\n"); + writeFileSync( + join(project, "services", "api", "pyproject.toml"), + "[project]\nname='api-fixture'\nrequires-python='>=3.8'\n[tool.uv]\npackage=true\n" + ); + writeFileSync(join(project, "services", "api", "uv.lock"), "version=1\n"); + writeFileSync(join(project, "services", "api", "src", "app.py"), "VALUE = 1\n"); assert.equal(existsSync(binPath(project, "opcore")), true); assert.equal(existsSync(binPath(project, "opcore-asp-provider")), true); @@ -43,15 +52,46 @@ describe("installed package bins", () => { assert.deepEqual(opcoreStatus.canonicalCommand, ["opcore", "status"]); assert.equal(Object.hasOwn(opcoreStatus, "repoState"), true); assert.equal(Object.hasOwn(opcoreStatus, "validationResult"), false); + const nestedStatusContext = pythonContextFor(opcoreStatus.repoState.validation.pythonProjectContexts, "services/api/src/app.py"); + assert.equal(nestedStatusContext.projectRoot, "services/api"); + assert.deepEqual(nestedStatusContext.managers.map((manager) => manager.kind), ["uv"]); + const aspContext = await evaluateInstalledAspPythonContext(project); + assert.deepEqual(pythonContextIdentity(aspContext), pythonContextIdentity(nestedStatusContext)); const opcoreScan = assertSmoke(project, ["--json"], 0, "opcore"); assert.deepEqual(opcoreScan.canonicalCommand, ["opcore", "scan"]); assert.equal(Object.hasOwn(opcoreScan, "validationResult"), true); + assert.deepEqual( + pythonContextIdentity(pythonContextFor(opcoreScan.validationResult.pythonProjectContexts, "services/api/src/app.py")), + pythonContextIdentity(nestedStatusContext) + ); + const metricReport = JSON.parse(readFileSync(join(project, ".opcore", "report.json"), "utf8")); + assert.deepEqual( + pythonContextIdentity(pythonContextFor(metricReport.validation.pythonProjectContexts, "services/api/src/app.py")), + pythonContextIdentity(nestedStatusContext) + ); + const opcoreCheck = assertSmoke(project, [ + "check", + "files", + "--files", + "root.py,services/api/src/app.py", + "--checks", + "python.source-hygiene", + "--json" + ], 0, "opcore"); + assert.deepEqual( + pythonContextIdentity(pythonContextFor(opcoreCheck.validationResult.pythonProjectContexts, "services/api/src/app.py")), + pythonContextIdentity(nestedStatusContext) + ); const opcoreInit = assertSmoke(project, ["install", "--json"], 0, "opcore"); assert.deepEqual(opcoreInit.canonicalCommand, ["opcore", "install"]); assert.equal(opcoreInit.opcoreInit.mode, "plan"); assert.equal(Object.hasOwn(opcoreInit.opcoreInit, "scan"), true); assert.equal(Array.isArray(opcoreInit.opcoreInit.settings.languages), true); assert.equal(opcoreInit.opcoreInit.timings.scanMs >= 0, true); + assert.deepEqual( + pythonContextIdentity(pythonContextFor(opcoreInit.opcoreInit.settings.python.contexts, "services/api/src/app.py")), + pythonContextIdentity(nestedStatusContext) + ); assert.equal(existsSync(join(project, ".opcore", "config")), false); assert.equal(existsSync(join(project, "AGENTS.md")), false); const opcoreMeasure = assertSmoke(project, ["measure", "--json"], 0, "opcore"); @@ -420,6 +460,12 @@ function assertManagedDescriptor(project) { /(^|[\\/"'\s])\.ace(?:[\\/"'\s]|$)|LATTICE_CURRENT_TOOLS_DIR|\/Users\/tom|(^|[\\/\s])(?:lattice|crg|cix|rox)(?:$|[\\/\s])/i ); const descriptor = validateManagedToolDescriptor(JSON.parse(descriptorText)); + assert.deepEqual(descriptor.capabilities.validation.pythonProjectContext, { + schemaId: "opcore.python.project-context.v1", + outcomes: ["resolved", "degraded", "unsupported", "ambiguous"], + readOnly: true, + installs: false + }); assert.deepEqual( descriptor.commandGroups.map((group) => group.name), ["graph", "inspect", "edit", "check", "validate", "status", "doctor"] @@ -443,6 +489,24 @@ function assertManagedDescriptor(project) { } } +function pythonContextFor(contexts, target) { + const context = contexts?.find((candidate) => candidate.target === target); + assert.ok(context, `missing Python project context for ${target}`); + return context; +} + +function pythonContextIdentity(context) { + return { + target: context.target, + projectRoot: context.projectRoot, + projectKey: context.projectKey, + contextFingerprint: context.contextFingerprint, + outcome: context.outcome, + interpreter: context.interpreter?.argv ?? null, + tools: context.tools.map((tool) => ({ tool: tool.tool, argv: tool.argv, source: tool.source, available: tool.available })) + }; +} + function assertSmoke(project, args, expectedExitCode, bin = "opcore") { return assertCliJson(binPath(project, bin), args, expectedExitCode, project); } @@ -540,6 +604,172 @@ function assertAspProviderInitializeSmoke(project) { assert.deepEqual(response.result.requestedPermissions, { read: ["**/*"], write: false, network: false }); } +async function evaluateInstalledAspPythonContext(project) { + const files = { + "pyproject.toml": readFileSync(join(project, "pyproject.toml"), "utf8"), + "root.py": readFileSync(join(project, "root.py"), "utf8"), + "services/api/pyproject.toml": readFileSync(join(project, "services/api/pyproject.toml"), "utf8"), + "services/api/uv.lock": readFileSync(join(project, "services/api/uv.lock"), "utf8"), + "services/api/src/app.py": readFileSync(join(project, "services/api/src/app.py"), "utf8") + }; + const host = createInstalledAspHost(files); + const child = spawn(binPath(project, "opcore-asp-provider"), ["--stdio"], { + cwd: project, + stdio: ["pipe", "pipe", "pipe"] + }); + const peer = new InstalledAspPeer(child, host).start(); + try { + const initialize = await peer.request("initialize", { + protocolVersion: "asp/0.1", + host: { name: "installed-bin-context", version: "0.1.0-test" }, + hostCapabilities: { readBlob: true, listTree: true, putBlob: false }, + workspace: { root: realpathSync(project), baseline: host.baseline }, + assuranceMode: "gated" + }); + peer.notify("initialized", { + grantedPermissions: initialize.requestedPermissions, + baseline: host.baseline + }); + const assessment = await peer.request("check/evaluate", { + callSite: "interactive", + changeset: host.changeset([ + host.modify("services/api/src/app.py", files["services/api/src/app.py"]) + ]), + comparison: "all", + checks: ["python.source-hygiene"] + }); + const evidence = assessment.evidence?.find((entry) => + entry.kind === "python_project_context" && entry.data?.target === "services/api/src/app.py" + ); + assert.ok(evidence, JSON.stringify(assessment, null, 2)); + return evidence.data; + } finally { + peer.close(); + } +} + +class InstalledAspPeer { + constructor(child, host) { + this.child = child; + this.host = host; + this.nextId = 1; + this.pending = new Map(); + this.buffer = ""; + this.stderr = ""; + } + + start() { + this.child.stdout.setEncoding("utf8"); + this.child.stderr.setEncoding("utf8"); + this.child.stdout.on("data", (chunk) => this.onData(chunk)); + this.child.stderr.on("data", (chunk) => { this.stderr += chunk; }); + this.child.on("exit", (code, signal) => { + const error = new Error(`installed ASP provider exited code=${code} signal=${signal}\nstderr:\n${this.stderr}`); + for (const pending of this.pending.values()) pending.reject(error); + this.pending.clear(); + }); + return this; + } + + request(method, params = {}, timeoutMs = 30000) { + const id = this.nextId++; + this.write({ jsonrpc: "2.0", id, method, params }); + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this.pending.delete(id); + reject(new Error(`installed ASP request timed out: ${method}\nstderr:\n${this.stderr}`)); + }, timeoutMs); + this.pending.set(id, { + resolve(value) { clearTimeout(timer); resolve(value); }, + reject(error) { clearTimeout(timer); reject(error); } + }); + }); + } + + notify(method, params = {}) { + this.write({ jsonrpc: "2.0", method, params }); + } + + close() { + this.child.kill(); + } + + onData(chunk) { + this.buffer += chunk; + while (this.buffer.includes("\n")) { + const index = this.buffer.indexOf("\n"); + const line = this.buffer.slice(0, index).trim(); + this.buffer = this.buffer.slice(index + 1); + if (line.length > 0) this.handleMessage(JSON.parse(line)); + } + } + + handleMessage(message) { + if (Object.hasOwn(message, "id") && !message.method && (Object.hasOwn(message, "result") || Object.hasOwn(message, "error"))) { + const pending = this.pending.get(message.id); + if (!pending) return; + this.pending.delete(message.id); + if (message.error) { + const error = new Error(message.error.message); + error.rpc = message.error; + pending.reject(error); + } else { + pending.resolve(message.result); + } + return; + } + if (message.method === "workspace/listTree") { + this.write({ jsonrpc: "2.0", id: message.id, result: this.host.listTree(message.params) }); + return; + } + if (message.method === "workspace/readBlob") { + this.write({ jsonrpc: "2.0", id: message.id, result: this.host.readBlob(message.params) }); + return; + } + this.write({ jsonrpc: "2.0", id: message.id, error: { code: -32601, message: "method-not-found" } }); + } + + write(message) { + this.child.stdin.write(`${JSON.stringify(message)}\n`); + } +} + +function createInstalledAspHost(files) { + const baseline = { rev: "tree:installed-python-context", stampedAt: "2026-07-15T00:00:00.000Z" }; + const blobs = new Map(); + const entries = Object.entries(files).map(([path, content]) => { + const blobId = installedBlobId(content); + blobs.set(blobId, content); + return { path, blobId, kind: "file" }; + }); + const entryByPath = new Map(entries.map((entry) => [entry.path, entry])); + return { + baseline, + changeset: (changes) => ({ baseline, changes }), + modify(path, content) { + return { + kind: "modify", + path, + before: entryByPath.get(path).blobId, + after: { encoding: "utf-8", bytes: content } + }; + }, + listTree(params = {}) { + const paths = new Set(Array.isArray(params.paths) ? params.paths : []); + return { entries: entries.filter((entry) => paths.size === 0 || paths.has(entry.path)), truncated: false }; + }, + readBlob(params = {}) { + return { + blobs: (params.blobs ?? []).map((id) => ({ id, encoding: "utf-8", bytes: blobs.get(id) })) + }; + } + }; +} + +function installedBlobId(content) { + return `blob:sha256:${createHash("sha256").update(content, "utf8").digest("hex")}`; +} + function binPath(project, bin) { return join(project, "node_modules", ".bin", process.platform === "win32" ? `${bin}.cmd` : bin); } diff --git a/tests/opcore-facade.test.mjs b/tests/opcore-facade.test.mjs index cfdbbc6..17deb8e 100644 --- a/tests/opcore-facade.test.mjs +++ b/tests/opcore-facade.test.mjs @@ -1329,16 +1329,15 @@ describe("opcore public facade", () => { assert.match(result.opcoreInit.settings.languages[0].notes.join(" "), /pyproject\.toml/); assert.match(result.opcoreInit.settings.languages[0].notes.join(" "), /requirements\.txt/); assert.match(result.opcoreInit.settings.languages[0].notes.join(" "), /uv\.lock/); - assert.match(result.opcoreInit.settings.languages[0].notes.join(" "), /\.venv/); assert.deepEqual( result.opcoreInit.settings.python.dependencyManagers.map((entry) => [entry.kind, entry.path]), [ - ["pyproject", "pyproject.toml"], ["requirements", "requirements.txt"], ["uv", "uv.lock"] ] ); - assert.deepEqual(result.opcoreInit.settings.python.virtualEnvironments, [{ kind: "venv", path: ".venv" }]); + assert.deepEqual(result.opcoreInit.settings.python.virtualEnvironments, []); + assert.equal(result.opcoreInit.settings.python.contexts[0].outcome, "ambiguous"); } if (fixture.name === "mixed-cargo-lock-only") { const rust = result.opcoreInit.settings.languages.find((entry) => entry.language === "Rust"); diff --git a/tests/package-identity.test.mjs b/tests/package-identity.test.mjs index d3232a0..651c245 100644 --- a/tests/package-identity.test.mjs +++ b/tests/package-identity.test.mjs @@ -57,6 +57,7 @@ const bundledExternalRuntimePackages = [ "path-browserify", "picomatch", "semver", + "smol-toml", "tinyglobby", "ts-api-utils", "ts-morph", diff --git a/tests/schema-contracts.test.mjs b/tests/schema-contracts.test.mjs index 6029d93..5675a3c 100644 --- a/tests/schema-contracts.test.mjs +++ b/tests/schema-contracts.test.mjs @@ -155,6 +155,45 @@ function validationResultWith(overrides = {}) { }; } +function pythonProjectContextWith(overrides = {}) { + return { + schemaId: "opcore.python.project-context.v1", + schemaVersion: 1, + target: "services/api/src/app.py", + repositoryRoot: "/repo", + projectRoot: "services/api", + projectBoundary: "services/api", + sourceRoots: ["services/api/src"], + layout: { kinds: ["package", "src"], paths: ["services/api/src"] }, + evidence: [{ path: "services/api/pyproject.toml", role: "boundary" }], + targetRuntime: { requiresPython: ">=3.12", conflicts: [] }, + managers: [{ kind: "uv", configFiles: ["services/api/pyproject.toml"], lockFiles: ["services/api/uv.lock"] }], + buildSystem: { + configFile: "services/api/pyproject.toml", + backend: "hatchling.build", + requires: ["hatchling>=1"] + }, + interpreter: { + executable: "/repo/services/api/.venv/bin/python", + argv: ["/repo/services/api/.venv/bin/python"], + cwd: "/repo/services/api", + source: "project_local_environment", + version: "3.12.4", + implementation: "CPython", + platform: "linux", + architecture: "x86_64", + abi: "cpython-312", + soabi: "cpython-312-x86_64-linux-gnu" + }, + tools: [], + projectKey: `sha256:${"1".repeat(64)}`, + contextFingerprint: `sha256:${"2".repeat(64)}`, + outcome: "resolved", + reasons: [], + ...overrides + }; +} + function preWriteValidationReceiptWith(overrides = {}) { return { schemaVersion: 1, @@ -792,6 +831,39 @@ describe("Opcore JSON schema wire constraints", () => { ); }); + it("validates Python project-context wire identity and typed outcome vocabulary", () => { + const context = pythonProjectContextWith(); + assert.equal(isValidDefinition("PythonProjectContext", context), true); + assert.equal(isValidDefinition("ValidationResult", validationResultWith({ pythonProjectContexts: [context] })), true); + assert.equal(isValidDefinition("PythonProjectContext", { ...context, schemaId: "python.context.v0" }), false); + assert.equal(isValidDefinition("PythonProjectContext", { ...context, outcome: "ready" }), false); + assert.equal(isValidDefinition("PythonProjectContext", { ...context, contextFingerprint: "secret" }), false); + assert.equal(isValidDefinition("PythonProjectContext", { + ...context, + interpreter: { ...context.interpreter, version: "garbage" } + }), false); + for (const field of ["abi", "soabi"]) { + const interpreter = { ...context.interpreter }; + delete interpreter[field]; + assert.equal(isValidDefinition("PythonProjectContext", { ...context, interpreter }), false, field); + } + assert.equal(isValidDefinition("PythonProjectContext", { + ...context, + tools: [{ + tool: "mypy", + available: true, + executable: "/repo/.venv/bin/mypy", + argv: ["/repo/.venv/bin/mypy"], + cwd: "/repo", + source: "project_local_environment" + }] + }), false); + assert.equal(isValidDefinition("PythonProjectContext", { + ...context, + buildSystem: { ...context.buildSystem, requires: [""] } + }), false); + }); + it("rejects absolute and parent-directory edit-change paths", () => { assert.equal(isValidDefinition("EditPlan", editPlanWith({ kind: "replace", path: "src/index.ts", content: "" })), true); assert.equal(isValidDefinition("EditPlan", editPlanWith({ kind: "replace", path: "/tmp/a.ts", content: "" })), false); @@ -3377,6 +3449,12 @@ function validManagedToolDescriptor(overrides = {}) { graphModes: ["optional", "required"], hypothetical: true, statusSurfaces: ["status", "doctor"], + pythonProjectContext: { + schemaId: "opcore.python.project-context.v1", + outcomes: ["resolved", "degraded", "unsupported", "ambiguous"], + readOnly: true, + installs: false + }, writeGate: { initScopes: ["repo", "global"], harnesses: ["claude-code", "codex"], diff --git a/tests/validation-python.test.mjs b/tests/validation-python.test.mjs index 5720ac3..83b56f1 100644 --- a/tests/validation-python.test.mjs +++ b/tests/validation-python.test.mjs @@ -1,6 +1,6 @@ import { describe, it } from "node:test"; import assert from "node:assert/strict"; -import { chmodSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { chmodSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join, relative } from "node:path"; import { fileURLToPath } from "node:url"; @@ -13,11 +13,13 @@ import { PYTHON_SYNTAX_CHECK_ID, PYTHON_TYPES_CHECK_ID, createPythonValidationAdapterStatus, - createPythonValidationChecks, - findPythonConfigFile, + createPythonValidationChecks as createCanonicalPythonValidationChecks, + createNodePythonProjectWorkspace, + createSyntaxCheck, + createTypeCheck, isPythonSourcePath, - resolvePythonInterpreter, - resolvePythonTool, + resolvePythonProjectContext, + resolvePythonProjectContexts, validationPythonAdapterName } from "../packages/validation-python/dist/index.js"; @@ -56,6 +58,46 @@ describe("validation-python adapter", () => { assert.equal(registry.byId.get(PYTHON_IMPORT_GRAPH_CHECK_ID)?.requiresGraph, true); }); + it("fails syntax and type checks closed when no canonical context resolver is injected", async () => { + for (const check of [createSyntaxCheck(), createTypeCheck()]) { + const result = await runner({ files: { "app.py": "VALUE = 1\n" }, checks: [check] }).runValidation(request({ + checks: [check.id], + scope: { kind: "files", files: ["app.py"] } + })); + assert.equal(result.status, "infrastructure_failure", check.id); + assert.match(result.diagnostics[0].message, /canonical python project context/i, check.id); + } + }); + + it("fails every target context closed before grouping targets by project", async () => { + const files = { + "pyproject.toml": "[project]\nname='fixture'\n", + "a.py": "VALUE = 1\n", + "z.py": "VALUE = 2\n" + }; + for (const checkId of [PYTHON_SYNTAX_CHECK_ID, PYTHON_TYPES_CHECK_ID]) { + const result = await runner({ + files, + checks: createPythonValidationChecks({ + env: { PATH: "/fixture/bin" }, + nodeWorkspace: projectWorkspace(files, () => true, new Set(["z.py"])), + processProbe: successfulProbe() + }) + }).runValidation(request({ + repo: { repoRoot: "/fixture" }, + checks: [checkId], + scope: { kind: "files", files: ["a.py", "z.py"] } + })); + + assert.notEqual(result.status, "passed", checkId); + assert.deepEqual(result.pythonProjectContexts.map((context) => [context.target, context.outcome]), [ + ["a.py", "resolved"], + ["z.py", "ambiguous"] + ]); + assert.equal(result.diagnostics.some((diagnostic) => diagnostic.path === "z.py"), true, checkId); + } + }); + it("reports missing Python type tooling as degraded instead of a silent pass", async () => { const env = { PATH: "" }; const isolatedRepoRoot = mkdtempSync(join(tmpdir(), "opcore-python-adapter-status-")); @@ -80,12 +122,13 @@ describe("validation-python adapter", () => { ); assert.equal(result.status, "unsupported_request"); - assert.deepEqual(result.diagnostics.map((diagnostic) => diagnostic.code), ["PYTHON_TYPES_UNSUPPORTED"]); + assert.deepEqual(result.diagnostics.map((diagnostic) => diagnostic.code), ["PYTHON_TYPES_UNSUPPORTED", "PYTHON_CONTEXT_UNSUPPORTED"]); }); it("executes repo-local mypy and maps type diagnostics", async () => { const repoRoot = mkdtempSync(join(tmpdir(), "opcore-python-types-mypy-")); try { + writePassingPythonProtocolShim(repoRoot); writeToolShim( repoRoot, "mypy", @@ -127,9 +170,133 @@ describe("validation-python adapter", () => { } }); + it("materializes absolute imports through the owning project's source roots", async () => { + const repoRoot = mkdtempSync(join(tmpdir(), "opcore-python-types-source-root-")); + try { + const projectRoot = join(repoRoot, "services/api"); + mkdirSync(projectRoot, { recursive: true }); + writePassingPythonProtocolShim(projectRoot); + writeToolShim( + projectRoot, + "mypy", + [ + "#!/bin/sh", + "if [ \"$1\" = \"--version\" ]; then echo 'mypy 1.8.0'; exit 0; fi", + "if [ ! -f src/acme/dep.py ]; then echo 'dependency was not materialized' >&2; exit 9; fi", + "exit 0", + "" + ].join("\n") + ); + const files = { + "services/api/pyproject.toml": "[project]\nname='api'\n[tool.mypy]\n", + "services/api/src/acme/app.py": "from acme import dep\nVALUE = dep.VALUE\n", + "services/api/src/acme/dep.py": "VALUE: int = 1\n" + }; + const result = await runner({ + files, + checks: createPythonValidationChecks({ + repoRoot, + env: { PATH: "" }, + nodeWorkspace: projectWorkspace(files, () => true) + }) + }).runValidation(request({ + repo: { repoRoot }, + checks: [PYTHON_TYPES_CHECK_ID], + scope: { kind: "files", files: ["services/api/src/acme/app.py"] } + })); + + assert.equal(result.status, "passed", JSON.stringify(result, null, 2)); + assert.equal(result.pythonProjectContexts[0].projectRoot, "services/api"); + assert.deepEqual(result.pythonProjectContexts[0].sourceRoots, ["services/api/src"]); + } finally { + rmSync(repoRoot, { recursive: true, force: true }); + } + }); + + it("maps nested-project type diagnostics to repository-relative paths", async () => { + const repoRoot = mkdtempSync(join(tmpdir(), "opcore-python-types-nested-path-")); + try { + const projectRoot = join(repoRoot, "services/api"); + mkdirSync(projectRoot, { recursive: true }); + writePassingPythonProtocolShim(projectRoot); + writeToolShim( + projectRoot, + "mypy", + [ + "#!/bin/sh", + "if [ \"$1\" = \"--version\" ]; then echo 'mypy 1.8.0'; exit 0; fi", + "echo \"$1:1: error: nested project type failure [assignment]\"", + "exit 1", + "" + ].join("\n") + ); + const files = { + "services/api/pyproject.toml": "[project]\nname='api'\n[tool.mypy]\n", + "services/api/src/acme/app.py": "value: int = 'wrong'\n" + }; + const result = await runner({ + files, + checks: createPythonValidationChecks({ + repoRoot, + env: { PATH: "" }, + nodeWorkspace: projectWorkspace(files, () => true) + }) + }).runValidation(request({ + repo: { repoRoot }, + checks: [PYTHON_TYPES_CHECK_ID], + scope: { kind: "files", files: ["services/api/src/acme/app.py"] } + })); + + assert.equal(result.status, "policy_failure"); + assert.equal(result.pythonProjectContexts[0].projectRoot, "services/api"); + assert.equal(result.diagnostics[0].path, "services/api/src/acme/app.py"); + } finally { + rmSync(repoRoot, { recursive: true, force: true }); + } + }); + + it("materializes root imports for flat targets in mixed flat/src projects", async () => { + const repoRoot = mkdtempSync(join(tmpdir(), "opcore-python-types-mixed-layout-")); + try { + writePassingPythonProtocolShim(repoRoot); + writeToolShim( + repoRoot, + "mypy", + [ + "#!/bin/sh", + "if [ \"$1\" = \"--version\" ]; then echo 'mypy 1.8.0'; exit 0; fi", + "if [ ! -f helper.py ]; then echo 'root dependency was not materialized' >&2; exit 9; fi", + "exit 0", + "" + ].join("\n") + ); + const files = { + "pyproject.toml": "[project]\nname='mixed'\n[tool.mypy]\n", + "script.py": "import helper\nVALUE = helper.VALUE\n", + "helper.py": "VALUE: int = 1\n", + "src/pkg/__init__.py": "VALUE = 2\n" + }; + const result = await runner({ + files, + checks: createPythonValidationChecks({ env: { PATH: "" }, repoRoot }) + }).runValidation(request({ + repo: { repoRoot }, + checks: [PYTHON_TYPES_CHECK_ID], + scope: { kind: "files", files: ["script.py"] } + })); + + assert.equal(result.status, "passed", JSON.stringify(result, null, 2)); + assert.deepEqual(result.pythonProjectContexts[0].sourceRoots, ["."]); + assert.deepEqual(result.pythonProjectContexts[0].layout, { kinds: ["flat"], paths: ["."] }); + } finally { + rmSync(repoRoot, { recursive: true, force: true }); + } + }); + it("checks overlay after-state content instead of the original Python file", async () => { const repoRoot = mkdtempSync(join(tmpdir(), "opcore-python-types-overlay-")); try { + writePassingPythonProtocolShim(repoRoot); writeToolShim( repoRoot, "mypy", @@ -170,6 +337,7 @@ describe("validation-python adapter", () => { it("prefers pyright when pyright project config is present", async () => { const repoRoot = mkdtempSync(join(tmpdir(), "opcore-python-types-pyright-")); try { + writePassingPythonProtocolShim(repoRoot); writeFileSync(join(repoRoot, "pyrightconfig.json"), "{}\n"); writeToolShim(repoRoot, "mypy", "#!/bin/sh\nif [ \"$1\" = \"--version\" ]; then echo 'mypy 1.8.0'; exit 0; fi\necho 'mypy should not run'; exit 1\n"); writeToolShim( @@ -186,9 +354,14 @@ describe("validation-python adapter", () => { const result = await runner({ files: { - "pkg/app.py": "value: int = 'wrong'\n" + "pkg/app.py": "value: int = 'wrong'\n", + "pyrightconfig.json": "{}\n" }, - checks: createPythonValidationChecks({ env: { PATH: "" }, repoRoot }) + checks: createPythonValidationChecks({ + env: { PATH: "" }, + repoRoot, + nodeWorkspace: createNodePythonProjectWorkspace(repoRoot) + }) }).runValidation( request({ repo: { repoRoot }, @@ -431,45 +604,10 @@ describe("validation-python adapter", () => { } }); - it("reports invalid and incompatible declared target versions explicitly", async () => { - const resolved = resolvePythonInterpreter({ repoRoot: process.cwd() }); - assert.equal(resolved.outcome, "resolved"); - const [major, minor] = resolved.version.split(".").map(Number); - const incompatibleTarget = `${major}.${minor + 1}`; - const invalid = await runner({ - checks: createPythonValidationChecks({ targetPythonVersion: "3.x" }) - }).runValidation(request()); - const unsupported = await runner({ - checks: createPythonValidationChecks({ pythonCommand: resolved.command, targetPythonVersion: incompatibleTarget }) - }).runValidation(request()); - - assert.equal(invalid.status, "unsupported_request"); - assert.equal(invalid.manifest.runs[0].outcome, "invalid_config"); - assert.equal(unsupported.status, "unsupported_request"); - assert.equal(unsupported.manifest.runs[0].outcome, "unsupported_target"); - }); - - it("accepts syntax available only in the selected newer interpreter", async (t) => { - const resolved = resolvePythonInterpreter({ repoRoot: process.cwd() }); - assert.equal(resolved.outcome, "resolved"); - const [major, minor] = resolved.version.split(".").map(Number); - if (major < 3 || (major === 3 && minor < 14)) { - t.skip(`selected Python ${resolved.version} does not support t-strings`); - return; - } - const result = await runner({ - files: { "pkg/app.py": "value = t'hello'\n" }, - checks: createPythonValidationChecks({ - pythonCommand: resolved.command, - targetPythonVersion: `${major}.${minor}` - }) - }).runValidation(request()); - assert.equal(result.status, "passed"); - }); - it("fails python.types when a nonzero checker result contains no parseable diagnostics", async () => { const repoRoot = mkdtempSync(join(tmpdir(), "opcore-python-types-malformed-")); try { + writePassingPythonProtocolShim(repoRoot); writeToolShim(repoRoot, "mypy", "#!/bin/sh\nif [ \"$1\" = \"--version\" ]; then echo 'mypy 1.8.0'; exit 0; fi\necho 'unknown failure'\nexit 1\n"); const result = await runner({ files: { "pkg/app.py": "value: int = 1\n" }, @@ -689,83 +827,858 @@ describe("validation-python adapter", () => { ); assert.equal(result.status, "unsupported_request"); - assert.deepEqual(result.diagnostics.map((diagnostic) => diagnostic.code), ["PYTHON_TYPES_UNSUPPORTED"]); + assert.deepEqual(result.diagnostics.map((diagnostic) => diagnostic.code), ["PYTHON_TYPES_UNSUPPORTED", "PYTHON_CONTEXT_UNSUPPORTED"]); }); }); -describe("python toolchain resolver", () => { - it("resolves an available tool from PATH when no repo-local venv exists", () => { - const repoRoot = mkdtempSync(join(tmpdir(), "opcore-python-resolver-path-")); - try { - const resolution = resolvePythonTool("python", "node", ["--version"], { - repoRoot, - env: { PATH: process.env.PATH } - }); - assert.equal(resolution.available, true); - assert.equal(resolution.source, "path"); - } finally { - rmSync(repoRoot, { recursive: true, force: true }); +describe("Python project-context resolver", () => { + it("resolves nearest nested projects, layouts, managers, and stable fingerprints", async () => { + const files = { + "pyproject.toml": "[project]\nname='root'\nrequires-python='>=3.11'\n", + "src/root/__init__.py": "VALUE = 1\n", + "services/api/pyproject.toml": "[project]\nname='api'\nrequires-python='>=3.12'\n[tool.uv]\npackage=true\n", + "services/api/uv.lock": "version = 1\n", + "services/api/src/acme/app.py": "VALUE = 2\n" + }; + const contexts = await resolvePythonProjectContexts({ + repoRoot: "/fixture", + targets: ["src/root/__init__.py", "services/api/src/acme/app.py"], + workspace: projectWorkspace(files, (command) => !command.includes("/")), + processProbe: successfulProbe() + }); + + assert.deepEqual(contexts.map((context) => context.projectRoot), ["services/api", "."]); + const nested = contexts[0]; + assert.deepEqual(nested.sourceRoots, ["services/api/src"]); + assert.deepEqual(nested.layout.kinds, ["namespace", "src"]); + assert.deepEqual(nested.managers.map((manager) => manager.kind), ["uv"]); + assert.equal(nested.outcome, "resolved"); + assert.match(nested.projectKey, /^sha256:[a-f0-9]{64}$/); + assert.match(nested.contextFingerprint, /^sha256:[a-f0-9]{64}$/); + }); + + it("keeps fingerprints stable across equivalent checkout roots", async () => { + const files = { + "pyproject.toml": "[project]\nname='fixture'\n", + "app.py": "VALUE = 1\n" + }; + const resolveAt = (repoRoot, mode) => resolvePythonProjectContext({ + repoRoot, + target: "app.py", + workspace: projectWorkspace(files, () => true), + processProbe: successfulProbe(), + ...(mode === "active" + ? { env: { VIRTUAL_ENV: `${repoRoot}/.active`, PATH: "/usr/bin" } } + : { + env: { PATH: "/usr/bin" }, + interpreterArgv: [`${repoRoot}/bin/python`, "-X", "dev"], + toolArgv: { mypy: [`${repoRoot}/bin/mypy`, `--config=${repoRoot}/mypy.ini`] } + }) + }); + + for (const mode of ["active", "explicit"]) { + const [left, right] = await Promise.all([ + resolveAt("/checkout-a", mode), + resolveAt("/checkout-b", mode) + ]); + assert.notEqual(left.interpreter?.executable, right.interpreter?.executable, mode); + assert.notEqual(left.interpreter?.cwd, right.interpreter?.cwd, mode); + assert.equal(left.projectKey, right.projectKey, mode); + assert.equal(left.contextFingerprint, right.contextFingerprint, mode); } }); - it("resolves a repo-local .venv/bin executable before falling back to PATH", () => { - const repoRoot = mkdtempSync(join(tmpdir(), "opcore-python-resolver-venv-")); - try { - const venvBin = join(repoRoot, ".venv", "bin"); - mkdirSync(venvBin, { recursive: true }); - const shimPath = join(venvBin, "mypy"); - writeFileSync(shimPath, "#!/bin/sh\necho 'mypy 1.0.0 (compiled: yes)'\nexit 0\n"); - chmodSync(shimPath, 0o755); + it("resolves layout and source roots from the target in mixed flat/src projects", async () => { + const files = { + "pyproject.toml": "[project]\nname='mixed'\n", + "script.py": "VALUE = 1\n", + "helper.py": "VALUE = 2\n", + "src/pkg/__init__.py": "VALUE = 3\n" + }; + const [flat, src] = await Promise.all([ + resolvePythonProjectContext({ + repoRoot: "/fixture", + target: "script.py", + workspace: projectWorkspace(files, () => true), + processProbe: successfulProbe() + }), + resolvePythonProjectContext({ + repoRoot: "/fixture", + target: "src/pkg/__init__.py", + workspace: projectWorkspace(files, () => true), + processProbe: successfulProbe() + }) + ]); - const resolution = resolvePythonTool("mypy", "mypy", ["--version"], { - repoRoot, - env: { PATH: "" } + assert.deepEqual(flat.sourceRoots, ["."]); + assert.deepEqual(flat.layout, { kinds: ["flat"], paths: ["."] }); + assert.deepEqual(src.sourceRoots, ["src"]); + assert.deepEqual(src.layout, { kinds: ["package", "src"], paths: ["src"] }); + }); + + it("applies explicit, active, project-local, then PATH interpreter precedence", async () => { + const files = { "pyproject.toml": "[project]\nname='fixture'\n", "app.py": "VALUE = 1\n" }; + const cases = [ + { + source: "explicit_override", + options: { interpreterArgv: ["/explicit/python"] }, + available: (command) => command === "/explicit/python" || !command.includes("/") + }, + { + source: "active_environment", + options: { env: { VIRTUAL_ENV: "/fixture/.active", PATH: "/redacted" } }, + available: (command) => command.startsWith("/fixture/.active/") || !command.includes("/") + }, + { + source: "project_local_environment", + options: {}, + available: (command) => command.startsWith("/fixture/.venv/") || !command.includes("/") + }, + { + source: "path", + options: { env: { PATH: "/must-not-serialize" } }, + available: (command) => !command.includes("/") + } + ]; + for (const testCase of cases) { + const context = await resolvePythonProjectContext({ + repoRoot: "/fixture", + target: "app.py", + workspace: projectWorkspace(files, testCase.available), + processProbe: successfulProbe(), + ...testCase.options }); - assert.equal(resolution.available, true); - assert.equal(resolution.source, "repo-venv"); - assert.ok(resolution.command.endsWith(join(".venv", "bin", "mypy"))); - } finally { - rmSync(repoRoot, { recursive: true, force: true }); + assert.equal(context.interpreter?.source, testCase.source); + assert.equal(JSON.stringify(context).includes("must-not-serialize"), false); + assert.equal(JSON.stringify(context).includes("/redacted"), false); } + + const launcher = await resolvePythonProjectContext({ + repoRoot: "/fixture", + target: "app.py", + interpreterArgv: ["uv", "run", "python"], + workspace: projectWorkspace(files, () => true), + processProbe: successfulProbe() + }); + assert.equal(launcher.interpreter, undefined); + assert.equal(launcher.reasons.some((reason) => reason.code === "invalid_config" && reason.tool === "python"), true); }); - it("reports a missing tool as unavailable with a failure message", () => { - const repoRoot = mkdtempSync(join(tmpdir(), "opcore-python-resolver-missing-")); + it("refuses shell and sync-capable manager overrides before executing a probe", async () => { + const isolatedRepoRoot = mkdtempSync(join(tmpdir(), "opcore-python-unsafe-override-")); + const marker = join(isolatedRepoRoot, "probe-side-effect"); try { - const resolution = resolvePythonTool("mypy", "mypy", ["--version"], { - repoRoot, - env: { PATH: "" } + writeFileSync(join(isolatedRepoRoot, "pyproject.toml"), "[project]\nname='fixture'\n"); + writeFileSync(join(isolatedRepoRoot, "app.py"), "VALUE = 1\n"); + const context = await resolvePythonProjectContext({ + repoRoot: isolatedRepoRoot, + target: "app.py", + env: { PATH: "" }, + interpreterArgv: ["/bin/sh", "-c", `printf side-effect > ${marker}`], + toolArgv: { mypy: ["uv", "run", "mypy"] }, + workspace: createNodePythonProjectWorkspace(isolatedRepoRoot) }); - assert.equal(resolution.available, false); - assert.equal(resolution.source, "path"); - assert.ok(resolution.failureMessage); + + assert.equal(existsSync(marker), false); + assert.equal(context.interpreter, undefined); + assert.equal(context.tools.find((tool) => tool.tool === "mypy")?.available, false); + assert.equal(context.reasons.some((reason) => reason.code === "invalid_config" && reason.tool === "python"), true); + assert.equal(context.reasons.some((reason) => reason.code === "invalid_config" && reason.tool === "mypy"), true); + assert.notEqual(context.outcome, "resolved"); } finally { - rmSync(repoRoot, { recursive: true, force: true }); + rmSync(isolatedRepoRoot, { recursive: true, force: true }); } }); - it("prefers pyproject.toml when no tool-specific config exists", () => { - const repoRoot = mkdtempSync(join(tmpdir(), "opcore-python-resolver-config-")); + it("accepts active environments only at the nearest project boundary", async () => { + const files = { + "pyproject.toml": "[project]\nname='root'\n", + "app.py": "ROOT = 1\n", + "services/api/pyproject.toml": "[project]\nname='api'\n[tool.uv]\npackage=true\n", + "services/api/uv.lock": "version = 1\n", + "services/api/app.py": "NESTED = 1\n" + }; + for (const [variable, nestedSource] of [ + ["VIRTUAL_ENV", "active_environment"], + ["UV_PROJECT_ENVIRONMENT", "manager_environment"] + ]) { + const environment = variable === "VIRTUAL_ENV" ? "/fixture/services/api/.venv" : "/fixture/services/api/.uv-active"; + const contexts = await resolvePythonProjectContexts({ + repoRoot: "/fixture", + targets: ["app.py", "services/api/app.py"], + env: { PATH: "/usr/bin", [variable]: environment }, + workspace: projectWorkspace(files, (command) => command.startsWith(`${environment}/`) || !command.includes("/")), + processProbe: successfulProbe() + }); + const root = contexts.find((context) => context.target === "app.py"); + const nested = contexts.find((context) => context.target === "services/api/app.py"); + assert.equal(root?.interpreter?.source, "path", variable); + assert.equal(nested?.interpreter?.source, nestedSource, variable); + } + }); + + it("uses inherited active environments when no environment projection is injected", async () => { + const previous = process.env.VIRTUAL_ENV; + process.env.VIRTUAL_ENV = "/fixture/custom-env"; try { - writeFileSync(join(repoRoot, "pyproject.toml"), "[tool.mypy]\n"); - const configFile = findPythonConfigFile(repoRoot, "mypy"); - assert.equal(configFile, join(repoRoot, "pyproject.toml")); + const context = await resolvePythonProjectContext({ + repoRoot: "/fixture", + target: "app.py", + workspace: projectWorkspace( + { "pyproject.toml": "[project]\nname='fixture'\n", "app.py": "VALUE = 1\n" }, + (command) => command.startsWith("/fixture/custom-env/") || !command.includes("/") + ), + processProbe: successfulProbe() + }); + assert.equal(context.interpreter?.source, "active_environment"); + assert.equal(context.interpreter?.executable, "/fixture/custom-env/bin/python"); } finally { - rmSync(repoRoot, { recursive: true, force: true }); + if (previous === undefined) delete process.env.VIRTUAL_ENV; + else process.env.VIRTUAL_ENV = previous; } }); - it("prefers a tool-specific config file over pyproject.toml", () => { - const repoRoot = mkdtempSync(join(tmpdir(), "opcore-python-resolver-config-specific-")); + it("discards failed PATH candidates after a later interpreter succeeds", async () => { + const baseProbe = successfulProbe(); + const context = await resolvePythonProjectContext({ + repoRoot: "/fixture", + target: "app.py", + env: { PATH: "/usr/bin" }, + workspace: projectWorkspace( + { "pyproject.toml": "[project]\nname='fixture'\n", "app.py": "VALUE = 1\n" }, + (command) => !command.includes("/") + ), + processProbe: { + ...baseProbe, + run(command, args, options) { + if (command === "python3") { + return probeResult(command, args, options, { + ok: false, + termination: "spawn_error", + exitCode: null, + failureMessage: "spawn python3 ENOENT" + }); + } + return baseProbe.run(command, args, options); + } + } + }); + + assert.equal(context.interpreter?.executable, "/usr/bin/python"); + assert.equal(context.outcome, "resolved"); + assert.equal(context.reasons.some((reason) => reason.tool === "python"), false); + }); + + it("rejects symlinked PDM interpreter evidence before reading its content", async () => { + const files = { + "pyproject.toml": "[project]\nname='fixture'\n[tool.pdm]\ndistribution=true\n", + "pdm.lock": "version = '4.5'\n", + ".pdm-python": "/external/python\n", + "app.py": "VALUE = 1\n" + }; + const context = await resolvePythonProjectContext({ + repoRoot: "/fixture", + target: "app.py", + env: { PATH: "/usr/bin" }, + workspace: projectWorkspace(files, (command) => !command.includes("/"), new Set([".pdm-python"])), + processProbe: successfulProbe() + }); + + assert.equal(context.outcome, "ambiguous"); + assert.equal(context.interpreter?.source, "path"); + assert.equal( + context.reasons.some((reason) => reason.code === "symlink_refused" && reason.path === ".pdm-python"), + true + ); + }); + + it("honors an explicitly declared zero patch version", async () => { + const context = await resolvePythonProjectContext({ + repoRoot: "/fixture", + target: "app.py", + env: { PATH: "/usr/bin" }, + workspace: projectWorkspace({ + "pyproject.toml": "[project]\nname='fixture'\nrequires-python='==3.12.0'\n", + "app.py": "VALUE = 1\n" + }, (command) => !command.includes("/")), + processProbe: successfulProbe("3.12.1") + }); + + assert.equal(context.outcome, "unsupported"); + assert.equal(context.reasons.some((reason) => reason.code === "incompatible_interpreter"), true); + }); + + it("distinguishes exact release equality from explicit prefix wildcards", async () => { + const resolveConstraint = (requiresPython) => resolvePythonProjectContext({ + repoRoot: "/fixture", + target: "app.py", + env: { PATH: "/usr/bin" }, + workspace: projectWorkspace({ + "pyproject.toml": `[project]\nname='fixture'\nrequires-python='${requiresPython}'\n`, + "app.py": "VALUE = 1\n" + }, (command) => !command.includes("/")), + processProbe: successfulProbe("3.11.9") + }); + + const [exactEqual, wildcardEqual, exactNotEqual, wildcardNotEqual] = await Promise.all([ + resolveConstraint("==3.11"), + resolveConstraint("==3.11.*"), + resolveConstraint("!=3.11"), + resolveConstraint("!=3.11.*") + ]); + assert.equal(exactEqual.outcome, "unsupported"); + assert.equal(wildcardEqual.outcome, "resolved"); + assert.equal(exactNotEqual.outcome, "resolved"); + assert.equal(wildcardNotEqual.outcome, "unsupported"); + }); + + it("accepts standard multiline TOML and reports malformed TOML as invalid_config", async () => { + const valid = await resolvePythonProjectContext({ + repoRoot: "/fixture", + target: "app.py", + workspace: projectWorkspace({ + "pyproject.toml": [ + "[project]", + "name = 'fixture'", + "requires-python = '>=3.12'", + "", + "[build-system]", + "requires = [", + " 'hatchling>=1',", + " 'hatch-vcs>=0.4',", + "]", + "build-backend = 'hatchling.build'", + "" + ].join("\n"), + "app.py": "VALUE = 1\n" + }, () => true), + processProbe: successfulProbe() + }); + assert.equal(valid.reasons.some((reason) => reason.code === "invalid_config"), false); + assert.deepEqual(valid.buildSystem, { + configFile: "pyproject.toml", + backend: "hatchling.build", + requires: ["hatch-vcs>=0.4", "hatchling>=1"] + }); + assert.deepEqual(valid.tools.find((tool) => tool.tool === "build")?.argv.slice(-2), ["-m", "build"]); + + const malformed = await resolvePythonProjectContext({ + repoRoot: "/fixture", + target: "app.py", + workspace: projectWorkspace({ + "pyproject.toml": "[project\nname = 'fixture'\n", + "app.py": "VALUE = 1\n" + }, () => true), + processProbe: successfulProbe() + }); + assert.equal(malformed.outcome, "degraded"); + assert.deepEqual( + malformed.reasons.filter((reason) => reason.code === "invalid_config").map((reason) => reason.path), + ["pyproject.toml"] + ); + }); + + it("reports malformed INI tool configuration as invalid_config", async () => { + const context = await resolvePythonProjectContext({ + repoRoot: "/fixture", + target: "app.py", + env: { PATH: "/usr/bin" }, + workspace: projectWorkspace({ + "pyproject.toml": "[project]\nname='fixture'\n", + "mypy.ini": "[mypy\nstrict = true\n", + "app.py": "VALUE = 1\n" + }, (command) => !command.includes("/")), + processProbe: successfulProbe() + }); + + assert.equal(context.outcome, "degraded"); + assert.equal( + context.reasons.some((reason) => reason.code === "invalid_config" && reason.path === "mypy.ini"), + true + ); + }); + + it("reads Poetry target metadata from TOML AST and reports unavailable build tooling honestly", async () => { + const context = await resolvePythonProjectContext({ + repoRoot: "/fixture", + target: "app.py", + workspace: projectWorkspace({ + "pyproject.toml": [ + "[tool.poetry]", + "name = 'fixture'", + "[tool.poetry.dependencies]", + "python = '>=3.13'", + "[build-system]", + "requires = ['poetry-core>=1']", + "build-backend = 'poetry.core.masonry.api'", + "" + ].join("\n"), + "app.py": "VALUE = 1\n" + }, () => true), + processProbe: buildUnavailableProbe() + }); + + assert.equal(context.targetRuntime.requiresPython, ">=3.13"); + assert.deepEqual(context.buildSystem, { + configFile: "pyproject.toml", + backend: "poetry.core.masonry.api", + requires: ["poetry-core>=1"] + }); + assert.equal(context.tools.find((tool) => tool.tool === "build")?.available, false); + assert.equal(context.reasons.some((reason) => reason.code === "tool_unavailable" && reason.tool === "build"), true); + }); + + it("resolves Windows environment-root python.exe after Scripts/python.exe", async () => { + const environmentRootPython = "C:\\fixture\\.venv\\python.exe"; + const context = await resolvePythonProjectContext({ + repoRoot: "C:\\fixture", + target: "app.py", + platform: "win32", + architecture: "x64", + workspace: projectWorkspace( + { "pyproject.toml": "[project]\nname='fixture'\nrequires-python='>=3.12'\n", "app.py": "VALUE = 1\n" }, + (command) => command === environmentRootPython || /\\Scripts\\(?:mypy|pyright|ruff|pytest)\.exe$/u.test(command) + ), + processProbe: windowsProbe(environmentRootPython) + }); + + assert.equal(context.interpreter?.source, "project_local_environment"); + assert.equal(context.interpreter?.executable, environmentRootPython); + assert.deepEqual(context.interpreter?.argv, [environmentRootPython]); + assert.equal(context.interpreter?.cwd, "C:\\fixture"); + assert.equal(context.outcome, "resolved"); + }); + + it("resolves Python tools from the selected UV manager environment before PATH", async () => { + const managerEnvironment = "/fixture/.uv-python"; + const managerBin = `${managerEnvironment}/bin`; + const context = await resolvePythonProjectContext({ + repoRoot: "/fixture", + target: "app.py", + env: { PATH: "/usr/bin", UV_PROJECT_ENVIRONMENT: managerEnvironment }, + workspace: projectWorkspace( + { + "pyproject.toml": "[project]\nname='fixture'\nrequires-python='>=3.12'\n[tool.uv]\npackage=true\n", + "uv.lock": "version=1\n", + "app.py": "VALUE = 1\n" + }, + (command) => command.startsWith(`${managerBin}/`) || !command.includes("/") + ), + processProbe: successfulProbe() + }); + + assert.equal(context.interpreter?.source, "manager_environment"); + assert.deepEqual( + context.tools.filter((tool) => tool.tool !== "build").map((tool) => ({ + tool: tool.tool, + source: tool.source, + executable: tool.executable + })), + ["mypy", "pyright", "pytest", "ruff"].map((tool) => ({ + tool, + source: "manager_environment", + executable: `${managerBin}/${tool}` + })) + ); + assert.equal(context.outcome, "resolved"); + }); + + it("reports deterministic path, symlink, manager, platform, target, and probe failures", async () => { + const baseFiles = { "pyproject.toml": "[project]\nname='fixture'\nrequires-python='>=3.12'\n", "app.py": "VALUE = 1\n" }; + for (const [mode, expected] of [ + ["timeout", "probe_timeout"], + ["signal", "probe_signal"], + ["spawn", "probe_spawn_failure"], + ["exit", "probe_exit_failure"], + ["malformed", "malformed_probe_output"] + ]) { + const context = await resolvePythonProjectContext({ + repoRoot: "/fixture", + target: "app.py", + interpreterArgv: ["/fixture/python"], + workspace: projectWorkspace(baseFiles, () => true), + processProbe: failingProbe(mode) + }); + assert.equal(context.reasons.some((reason) => reason.code === expected), true, mode); + assert.notEqual(context.outcome, "resolved", mode); + } + + const unsupportedPlatform = await resolvePythonProjectContext({ + repoRoot: "/fixture", + target: "app.py", + platform: "aix", + workspace: projectWorkspace(baseFiles, () => true) + }); + assert.equal(unsupportedPlatform.reasons[0].code, "unsupported_platform"); + + const unsupportedTarget = await resolvePythonProjectContext({ + repoRoot: "/fixture", + target: "app.py", + workspace: projectWorkspace( + { ...baseFiles, "pyproject.toml": "[project]\nname='fixture'\nrequires-python='^3.12'\n" }, + () => true + ), + processProbe: successfulProbe() + }); + assert.equal(unsupportedTarget.reasons.some((reason) => reason.code === "unsupported_target"), true); + + const ambiguous = await resolvePythonProjectContext({ + repoRoot: "/fixture", + target: "app.py", + workspace: projectWorkspace({ + ...baseFiles, + "uv.lock": "version=1\n", + "poetry.lock": "package=[]\n" + }, () => true, new Set(["app.py"])), + processProbe: successfulProbe() + }); + assert.equal(ambiguous.outcome, "ambiguous"); + assert.equal(ambiguous.reasons.some((reason) => reason.code === "conflicting_managers"), true); + assert.equal(ambiguous.reasons.some((reason) => reason.code === "symlink_refused"), true); + + await assert.rejects( + resolvePythonProjectContext({ + repoRoot: "/fixture", + target: "../escape.py", + workspace: projectWorkspace(baseFiles, () => true) + }), + (error) => error?.code === "path_refused" + ); + }); + + it("fails closed when successful interpreter or tool probes return malformed versions", async () => { + const files = { + "pyproject.toml": "[project]\nname='fixture'\nrequires-python='>=3.12'\n", + "app.py": "VALUE = 1\n" + }; + const malformedInterpreter = await resolvePythonProjectContext({ + repoRoot: "/fixture", + target: "app.py", + workspace: projectWorkspace(files, () => true), + processProbe: successfulProbe("garbage") + }); + assert.equal(malformedInterpreter.interpreter, undefined); + assert.equal( + malformedInterpreter.reasons.some((reason) => reason.code === "malformed_probe_output" && reason.tool === "python"), + true + ); + assert.notEqual(malformedInterpreter.outcome, "resolved"); + + const probeWithoutAbi = successfulProbe(); + const incompleteInterpreter = await resolvePythonProjectContext({ + repoRoot: "/fixture", + target: "app.py", + workspace: projectWorkspace(files, () => true), + processProbe: { + ...probeWithoutAbi, + run(command, args, options) { + const script = args[args.indexOf("-c") + 1] ?? ""; + if (!args.includes("-c") || script.includes("opcore.python.project-context.build.v1")) { + return probeWithoutAbi.run(command, args, options); + } + return probeResult(command, args, options, { + stdout: JSON.stringify({ + protocol: "opcore.python.project-context.interpreter.v1", + executable: command.includes("/") ? command : `/usr/bin/${command}`, + version: "3.12.4", + implementation: "CPython", + platform: "linux", + architecture: "x86_64" + }) + }); + } + } + }); + assert.equal(incompleteInterpreter.interpreter, undefined); + assert.equal( + incompleteInterpreter.reasons.some((reason) => reason.code === "malformed_probe_output" && reason.tool === "python"), + true + ); + assert.notEqual(incompleteInterpreter.outcome, "resolved"); + + const validProbe = successfulProbe(); + const malformedTools = await resolvePythonProjectContext({ + repoRoot: "/fixture", + target: "app.py", + workspace: projectWorkspace(files, () => true), + processProbe: { + ...validProbe, + run(command, args, options) { + return args.includes("-c") + ? validProbe.run(command, args, options) + : probeResult(command, args, options, { stdout: "not-a-version" }); + } + } + }); + assert.equal(malformedTools.tools.every((tool) => !tool.available), true); + assert.deepEqual( + malformedTools.reasons.filter((reason) => reason.code === "malformed_probe_output").map((reason) => reason.tool).sort(), + ["mypy", "pyright", "pytest", "ruff"] + ); + assert.notEqual(malformedTools.outcome, "resolved"); + + const malformedBuild = await resolvePythonProjectContext({ + repoRoot: "/fixture", + target: "app.py", + workspace: projectWorkspace({ + ...files, + "pyproject.toml": `${files["pyproject.toml"]}[build-system]\nrequires=['build']\nbuild-backend='example.backend'\n` + }, () => true), + processProbe: { + ...validProbe, + run(command, args, options) { + const script = args[args.indexOf("-c") + 1] ?? ""; + return script.includes("opcore.python.project-context.build.v1") + ? probeResult(command, args, options, { + stdout: JSON.stringify({ + protocol: "opcore.python.project-context.build.v1", + available: true, + version: "not-a-version" + }) + }) + : validProbe.run(command, args, options); + } + } + }); + assert.equal(malformedBuild.tools.find((tool) => tool.tool === "build")?.available, false); + assert.equal( + malformedBuild.reasons.some((reason) => reason.code === "malformed_probe_output" && reason.tool === "build"), + true + ); + assert.notEqual(malformedBuild.outcome, "resolved"); + + const missingBuildVersion = await resolvePythonProjectContext({ + repoRoot: "/fixture", + target: "app.py", + workspace: projectWorkspace({ + ...files, + "pyproject.toml": `${files["pyproject.toml"]}[build-system]\nrequires=['build']\nbuild-backend='example.backend'\n` + }, () => true), + processProbe: { + ...validProbe, + run(command, args, options) { + const script = args[args.indexOf("-c") + 1] ?? ""; + return script.includes("opcore.python.project-context.build.v1") + ? probeResult(command, args, options, { + stdout: JSON.stringify({ + protocol: "opcore.python.project-context.build.v1", + available: true, + version: null + }) + }) + : validProbe.run(command, args, options); + } + } + }); + assert.equal(missingBuildVersion.tools.find((tool) => tool.tool === "build")?.available, false); + assert.equal( + missingBuildVersion.reasons.some((reason) => reason.code === "malformed_probe_output" && reason.tool === "build"), + true + ); + assert.notEqual(missingBuildVersion.outcome, "resolved"); + }); + + it("applies PEP 440 compatible-release precision consistently", async () => { + const compatible = await resolvePythonProjectContext({ + repoRoot: "/fixture", + target: "app.py", + workspace: projectWorkspace({ + "pyproject.toml": "[project]\nname='fixture'\nrequires-python='~=3.11'\n", + "pyrightconfig.json": JSON.stringify({ pythonVersion: "3.12" }), + "app.py": "VALUE = 1\n" + }, () => true), + processProbe: successfulProbe("3.12.4") + }); + assert.equal(compatible.targetRuntime.conflicts.length, 0); + assert.equal(compatible.reasons.some((reason) => reason.code === "incompatible_interpreter"), false); + assert.equal(compatible.outcome, "resolved"); + + const incompatible = await resolvePythonProjectContext({ + repoRoot: "/fixture", + target: "app.py", + workspace: projectWorkspace({ + "pyproject.toml": "[project]\nname='fixture'\nrequires-python='~=3.11.0'\n", + "app.py": "VALUE = 1\n" + }, () => true), + processProbe: successfulProbe("3.12.4") + }); + assert.equal(incompatible.reasons.some((reason) => reason.code === "incompatible_interpreter"), true); + assert.equal(incompatible.outcome, "unsupported"); + }); + + it("does not treat prerelease interpreters as final PEP 440 releases", async () => { + const resolveWithConstraint = (requiresPython) => resolvePythonProjectContext({ + repoRoot: "/fixture", + target: "app.py", + workspace: projectWorkspace({ + "pyproject.toml": `[project]\nname='fixture'\nrequires-python='${requiresPython}'\n`, + "app.py": "VALUE = 1\n" + }, () => true), + processProbe: successfulProbe("3.13.0rc1") + }); + + const finalOnly = await resolveWithConstraint(">=3.13"); + assert.equal(finalOnly.reasons.some((reason) => reason.code === "incompatible_interpreter"), true); + assert.equal(finalOnly.outcome, "unsupported"); + + const explicitPrerelease = await resolveWithConstraint(">=3.13rc1"); + assert.equal(explicitPrerelease.reasons.some((reason) => reason.code === "incompatible_interpreter"), false); + assert.equal(explicitPrerelease.outcome, "resolved"); + + const laterPrerelease = await resolveWithConstraint(">=3.13rc2"); + assert.equal(laterPrerelease.reasons.some((reason) => reason.code === "incompatible_interpreter"), true); + assert.equal(laterPrerelease.outcome, "unsupported"); + + const betaFloor = await resolveWithConstraint(">=3.13b1"); + assert.equal(betaFloor.reasons.some((reason) => reason.code === "incompatible_interpreter"), false); + assert.equal(betaFloor.outcome, "resolved"); + }); + + it("checks requires-python and configured target version independently", async () => { + const context = await resolvePythonProjectContext({ + repoRoot: "/fixture", + target: "app.py", + workspace: projectWorkspace({ + "pyproject.toml": "[project]\nname='fixture'\nrequires-python='>=3.10'\n", + "pyrightconfig.json": JSON.stringify({ pythonVersion: "3.12" }), + "app.py": "VALUE = 1\n" + }, () => true), + processProbe: successfulProbe("3.11.9") + }); + + assert.equal(context.targetRuntime.requiresPython, ">=3.10"); + assert.equal(context.targetRuntime.version, "3.12"); + assert.equal(context.reasons.some((reason) => reason.code === "incompatible_interpreter"), true); + assert.equal(context.outcome, "unsupported"); + }); + + it("returns a typed ambiguous context for a target symlink escaping the repository", async () => { + const repoRoot = mkdtempSync(join(tmpdir(), "opcore-python-symlink-repo-")); + const externalRoot = mkdtempSync(join(tmpdir(), "opcore-python-symlink-external-")); try { - writeFileSync(join(repoRoot, "pyproject.toml"), "[tool.mypy]\n"); - writeFileSync(join(repoRoot, "mypy.ini"), "[mypy]\n"); - const configFile = findPythonConfigFile(repoRoot, "mypy"); - assert.equal(configFile, join(repoRoot, "mypy.ini")); + writeFileSync(join(repoRoot, "pyproject.toml"), "[project]\nname='fixture'\n"); + writeFileSync(join(externalRoot, "outside.py"), "VALUE = 1\n"); + symlinkSync(join(externalRoot, "outside.py"), join(repoRoot, "app.py")); + const context = await resolvePythonProjectContext({ + repoRoot, + target: "app.py", + workspace: createNodePythonProjectWorkspace(repoRoot), + processProbe: successfulProbe() + }); + assert.equal(context.outcome, "ambiguous"); + assert.equal(context.reasons.some((reason) => reason.code === "symlink_refused" && reason.path === "app.py"), true); } finally { rmSync(repoRoot, { recursive: true, force: true }); + rmSync(externalRoot, { recursive: true, force: true }); } }); + + it("preserves baseline symlink refusal when the target has an after-state overlay", async () => { + const files = { + "pyproject.toml": "[project]\nname='fixture'\n", + "app.py": "VALUE = 1\n" + }; + const result = await runner({ + files, + checks: createPythonValidationChecks({ + processProbe: successfulProbe(), + nodeWorkspace: projectWorkspace(files, () => true, new Set(["app.py"])) + }) + }).runValidation(request({ + repo: { repoRoot: "/fixture" }, + checks: [PYTHON_SOURCE_HYGIENE_CHECK_ID], + scope: { kind: "files", files: ["app.py"] }, + overlays: [{ path: "app.py", action: "write", content: "VALUE = 2\n" }] + })); + + assert.equal(result.pythonProjectContexts[0].outcome, "ambiguous"); + assert.equal( + result.pythonProjectContexts[0].reasons.some((reason) => reason.code === "symlink_refused" && reason.path === "app.py"), + true + ); + }); + + it("bounds package ancestry at the owning project and source root", async () => { + const context = await resolvePythonProjectContext({ + repoRoot: "/fixture", + target: "services/api/app.py", + workspace: projectWorkspace({ + "pyproject.toml": "[project]\nname='root'\n", + "services/api/pyproject.toml": "[project]\nname='api'\n", + "services/api/app.py": "VALUE = 1\n" + }, () => true), + processProbe: successfulProbe() + }); + assert.equal(context.projectRoot, "services/api"); + assert.deepEqual(context.layout.kinds, ["flat"]); + }); + + it("matches overlay after-state identity with the equivalent materialized tree", async () => { + const before = { + "pyproject.toml": "[project]\nname='fixture'\nrequires-python='>=3.11'\n", + "app.py": "VALUE = 1\n" + }; + const after = { + "pyproject.toml": "[project]\nname='fixture'\nrequires-python='>=3.12'\n[tool.uv]\npackage=true\n", + "app.py": "VALUE = 2\n" + }; + const validationResult = await runner({ + files: before, + checks: createPythonValidationChecks({ processProbe: successfulProbe() }) + }).runValidation(request({ + repo: { repoRoot: "/fixture" }, + checks: [PYTHON_SOURCE_HYGIENE_CHECK_ID], + scope: { kind: "files", files: ["app.py"] }, + overlays: [ + { path: "pyproject.toml", action: "write", content: after["pyproject.toml"] }, + { path: "app.py", action: "write", content: after["app.py"] } + ] + })); + const materialized = await resolvePythonProjectContext({ + repoRoot: "/fixture", + target: "app.py", + workspace: projectWorkspace(after, (command) => !command.includes("/")), + processProbe: successfulProbe() + }); + + assert.equal(validationResult.pythonProjectContexts.length, 1); + assert.equal(validationResult.pythonProjectContexts[0].contextFingerprint, materialized.contextFingerprint); + assert.equal(validationResult.pythonProjectContexts[0].projectKey, materialized.projectKey); + assert.equal(validationResult.pythonProjectContexts[0].outcome, materialized.outcome); + }); + + it("removes deleted project markers from overlay discovery and matches materialized identity", async () => { + const before = { + "pyproject.toml": "[project]\nname='root'\n", + "services/api/pyproject.toml": "[project]\nname='api'\n", + "services/api/app.py": "VALUE = 1\n" + }; + const result = await runner({ + files: before, + checks: createPythonValidationChecks({ + processProbe: successfulProbe(), + nodeWorkspace: projectWorkspace(before, (command) => !command.includes("/")) + }) + }).runValidation(request({ + repo: { repoRoot: "/fixture" }, + checks: [PYTHON_SOURCE_HYGIENE_CHECK_ID], + scope: { kind: "files", files: ["services/api/app.py"] }, + overlays: [{ path: "services/api/pyproject.toml", action: "delete" }] + })); + const materialized = await resolvePythonProjectContext({ + repoRoot: "/fixture", + target: "services/api/app.py", + workspace: projectWorkspace({ + "pyproject.toml": before["pyproject.toml"], + "services/api/app.py": before["services/api/app.py"] + }, (command) => !command.includes("/")), + processProbe: successfulProbe() + }); + const overlay = result.pythonProjectContexts[0]; + assert.equal(overlay.projectRoot, "."); + assert.equal(overlay.contextFingerprint, materialized.contextFingerprint, JSON.stringify({ overlay, materialized }, null, 2)); + assert.equal(overlay.outcome, materialized.outcome); + }); }); function runner(options = {}) { @@ -776,6 +1689,129 @@ function runner(options = {}) { }); } +function createPythonValidationChecks(options = {}) { + return createCanonicalPythonValidationChecks({ + ...options, + nodeWorkspace: options.nodeWorkspace ?? canonicalTestPythonWorkspace() + }); +} + +function canonicalTestPythonWorkspace() { + return { + read: async () => undefined, + list: async () => [], + exists: async () => false, + realpath: async (path) => ({ path, symlink: false }), + executableExists: async () => false + }; +} + +function projectWorkspace(files, executableExists, symlinks = new Set()) { + const contents = new Map(Object.entries(files)); + return { + read: async (path) => contents.get(path), + list: async () => [...contents.keys()].sort(), + exists: async (path) => contents.has(path), + realpath: async (path) => ({ path, symlink: symlinks.has(path) }), + executableExists: async (path) => executableExists(path) + }; +} + +function successfulProbe(version = "3.12.4") { + return { + resolveExecutable(command) { + return command.includes("/") ? command : `/usr/bin/${command}`; + }, + run(command, args, options) { + const script = args[args.indexOf("-c") + 1] ?? ""; + const build = script.includes("opcore.python.project-context.build.v1"); + const interpreter = args.includes("-c") && !build; + return probeResult(command, args, options, { + stdout: build + ? JSON.stringify({ protocol: "opcore.python.project-context.build.v1", available: true, version: "1.2.2" }) + : interpreter + ? JSON.stringify({ + protocol: "opcore.python.project-context.interpreter.v1", + executable: command.includes("/") ? command : `/usr/bin/${command}`, + version, + implementation: "CPython", + platform: "linux", + architecture: "x86_64", + abi: "cpython-312", + soabi: "cpython-312-x86_64-linux-gnu" + }) + : `${command} 1.0.0` + }); + } + }; +} + +function buildUnavailableProbe() { + const base = successfulProbe(); + return { + ...base, + run(command, args, options) { + const script = args[args.indexOf("-c") + 1] ?? ""; + if (script.includes("opcore.python.project-context.build.v1")) { + return probeResult(command, args, options, { + stdout: JSON.stringify({ protocol: "opcore.python.project-context.build.v1", available: false, version: null }) + }); + } + return base.run(command, args, options); + } + }; +} + +function windowsProbe(interpreter) { + return { + run(command, args, options) { + return probeResult(command, args, options, { + stdout: args.includes("-c") + ? JSON.stringify({ + protocol: "opcore.python.project-context.interpreter.v1", + executable: interpreter, + version: "3.12.4", + implementation: "CPython", + platform: "win32", + architecture: "AMD64", + abi: "cpython-312", + soabi: "cp312-win_amd64" + }) + : `${command} 1.0.0` + }); + } + }; +} + +function failingProbe(mode) { + return { + run(command, args, options) { + if (mode === "malformed") return probeResult(command, args, options, { stdout: "not-json" }); + const base = probeResult(command, args, options, { ok: false }); + if (mode === "timeout") return { ...base, termination: "timeout", exitCode: null, failureMessage: "timeout" }; + if (mode === "signal") return { ...base, termination: "signal", exitCode: null, signal: "SIGTERM", failureMessage: "signal" }; + if (mode === "spawn") return { ...base, termination: "spawn_error", exitCode: null, failureMessage: "permission denied" }; + return { ...base, termination: "exited", exitCode: 9, failureMessage: "exit 9" }; + } + }; +} + +function probeResult(command, args, options, overrides = {}) { + return { + command, + args, + cwd: options.cwd, + allowedExitCodes: [0], + exitCode: 0, + signal: null, + stdout: "", + stderr: "", + termination: "exited", + ok: true, + ...overrides + }; +} + function request(overrides = {}) { return { requestId: "validation-python-1", @@ -846,8 +1882,8 @@ function writePythonProtocolShim(repoRoot, compilerBranch) { [ "#!/bin/sh", "case \"$4\" in", - " *opcore.python.interpreter.v1*)", - protocolResponse("SHIM_PATH", undefined), + " *opcore.python.project-context.interpreter.v1*)", + projectContextInterpreterResponse("SHIM_PATH"), " ;;", " *opcore.python.compile.v1*)", ...compilerBranch, @@ -861,6 +1897,25 @@ function writePythonProtocolShim(repoRoot, compilerBranch) { return shimPath; } +function writePassingPythonProtocolShim(repoRoot) { + const shimPath = writePythonProtocolShim(repoRoot, [protocolResponse("SHIM_PATH", [{ path: "pkg/app.py", status: "passed" }])]); + replaceShimPlaceholder(shimPath); + return shimPath; +} + +function projectContextInterpreterResponse(executable) { + return `printf '%s\\n' '${JSON.stringify({ + protocol: "opcore.python.project-context.interpreter.v1", + executable, + version: "3.12.13", + implementation: "CPython", + platform: "darwin", + architecture: "arm64", + abi: "cpython-312", + soabi: "cpython-312-darwin" + })}'`; +} + function protocolResponse(executable, results) { const payload = results === undefined ? { protocol: "opcore.python.interpreter.v1", executable, version: "3.12.13" } From f5a66fe00788a9f10fd56015a96b5c80d73b26ec Mon Sep 17 00:00:00 2001 From: Tom Dupuis <60640908+tomdps@users.noreply.github.com> Date: Wed, 15 Jul 2026 12:26:33 -0700 Subject: [PATCH 2/5] fix: declare Python resolver crypto dependency --- packages/validation-python/src/node-shims.d.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/packages/validation-python/src/node-shims.d.ts b/packages/validation-python/src/node-shims.d.ts index bec93bb..9180682 100644 --- a/packages/validation-python/src/node-shims.d.ts +++ b/packages/validation-python/src/node-shims.d.ts @@ -21,6 +21,14 @@ declare module "node:child_process" { ): SpawnSyncReturns; } +declare module "node:crypto" { + interface Hash { + update(data: string, inputEncoding?: BufferEncoding): Hash; + digest(encoding: "hex"): string; + } + export function createHash(algorithm: string): Hash; +} + declare module "node:fs" { export function existsSync(path: string): boolean; export function realpathSync(path: string): string; From bebdf56c9d19e834c65d9b2a9aa8601b61cd6ac8 Mon Sep 17 00:00:00 2001 From: Tom Dupuis <60640908+tomdps@users.noreply.github.com> Date: Wed, 15 Jul 2026 12:33:22 -0700 Subject: [PATCH 3/5] fix: keep resolver validation linear-time --- packages/contracts/src/index.ts | 2 +- packages/validation-python/src/project-fingerprint.ts | 7 ++++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 3a58af8..dd5e13f 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -6744,7 +6744,7 @@ function validatePythonExecutableProvenance(value: PythonProjectExecutableProven if (!includesString(pythonProjectExecutableSources, value.source)) throw new Error(`Unknown ${label} source: ${String(value.source)}`); if (value.version !== undefined) { validateNonEmptyString(value.version, `${label} version`); - if (!/^\d+(?:\.\d+)+(?:[-+._A-Za-z0-9]*)?$/u.test(value.version)) { + if (!/^[0-9]+\.[0-9][-+._A-Za-z0-9]*$/u.test(value.version)) { throw new Error(`${label} version must be exact version provenance`); } } diff --git a/packages/validation-python/src/project-fingerprint.ts b/packages/validation-python/src/project-fingerprint.ts index c879835..9a97a38 100644 --- a/packages/validation-python/src/project-fingerprint.ts +++ b/packages/validation-python/src/project-fingerprint.ts @@ -43,7 +43,12 @@ function normalizeFingerprintValue(value: unknown, repoRoot: string, platform: s function normalizeRoot(repoRoot: string, platform: string): string { const normalized = normalizeSeparators(repoRoot, platform); - return normalized.length > 1 ? normalized.replace(/\/+$/u, "") : normalized; + if (normalized.length > 1) { + let end = normalized.length; + while (end > 1 && normalized.charCodeAt(end - 1) === 47) end -= 1; + return end === normalized.length ? normalized : normalized.slice(0, end); + } + return normalized; } function replaceRepoRoot(value: string, repoRoot: string, platform: string): string { From 315710ddb14f837c7b4255661aef72f3ab2f3727 Mon Sep 17 00:00:00 2001 From: Tom Dupuis <60640908+tomdps@users.noreply.github.com> Date: Wed, 15 Jul 2026 15:03:21 -0700 Subject: [PATCH 4/5] fix: harden Python project context resolution --- packages/asp-provider/src/node-shims.d.ts | 2 + packages/asp-provider/src/workspace.ts | 18 +- packages/opcore/src/repo-validation-policy.ts | 10 +- packages/opcore/src/scan.ts | 6 +- packages/validation-policy/src/factory.ts | 5 +- packages/validation-policy/src/types.ts | 2 - .../src/environment-resolution.ts | 97 ++++++- packages/validation-python/src/index.ts | 1 - .../src/project-workspace.ts | 5 +- .../validation-python/src/source-files.ts | 12 +- .../validation-python/src/static-config.ts | 32 ++- .../validation-python/src/syntax-check.ts | 2 +- packages/validation-python/src/type-check.ts | 30 +- .../src/version-constraint.ts | 9 +- tests/asp-provider.test.mjs | 33 ++- tests/validation-python.test.mjs | 267 +++++++++++++++++- 16 files changed, 457 insertions(+), 74 deletions(-) diff --git a/packages/asp-provider/src/node-shims.d.ts b/packages/asp-provider/src/node-shims.d.ts index 8901582..ec8cefc 100644 --- a/packages/asp-provider/src/node-shims.d.ts +++ b/packages/asp-provider/src/node-shims.d.ts @@ -13,6 +13,8 @@ declare module "node:fs" { } declare module "node:fs/promises" { + export function access(path: string): Promise; + export function realpath(path: string): Promise; export function readFile(path: string, encoding: "utf8"): Promise; export function chmod(path: string, mode: number): Promise; export function mkdir(path: string, options: { recursive: true }): Promise; diff --git a/packages/asp-provider/src/workspace.ts b/packages/asp-provider/src/workspace.ts index 1853de7..7b0c5d1 100644 --- a/packages/asp-provider/src/workspace.ts +++ b/packages/asp-provider/src/workspace.ts @@ -1,3 +1,5 @@ +import { access, realpath } from "node:fs/promises"; +import { resolve } from "node:path"; import type { ValidationWorkspace, ValidationWorkspaceFileSet, ValidationWorkspaceReadFileResult } from "@the-open-engine/opcore-validation"; import { calculateValidationFileChecksum } from "@the-open-engine/opcore-validation"; import type { PythonProjectWorkspace } from "@the-open-engine/opcore-validation-python"; @@ -104,7 +106,7 @@ export function createAspHostValidationWorkspace( if (entry === undefined || entry.kind === undefined) return { path, symlink: false, unavailable: true }; return entry.kind === "file" ? { path, symlink: false } : { path, symlink: true }; }, - executableExists: async () => false + executableExists: async (path) => providerExecutableExists(initialize, path) }; return { @@ -126,6 +128,20 @@ function readGlobs(initialized: InitializedParams): readonly string[] { return Array.isArray(read) ? [...read] : ["**/*"]; } +async function providerExecutableExists(initialize: InitializeParams, path: string): Promise { + const root = initialize.workspace?.root; + if (root === undefined) return false; + try { + const [hostRoot, providerCwd] = await Promise.all([realpath(resolve(root)), realpath(process.cwd())]); + if (hostRoot !== providerCwd) return false; + if (!path.includes("/") && !path.includes("\\")) return true; + await access(path); + return true; + } catch { + return false; + } +} + function normalizeTreeEntry(value: unknown): TreeEntry | undefined { if (!value || typeof value !== "object") return undefined; const entry = value as { path?: unknown; blobId?: unknown; kind?: unknown }; diff --git a/packages/opcore/src/repo-validation-policy.ts b/packages/opcore/src/repo-validation-policy.ts index dff56fb..d733c3b 100644 --- a/packages/opcore/src/repo-validation-policy.ts +++ b/packages/opcore/src/repo-validation-policy.ts @@ -4,7 +4,6 @@ import { validationChecksForRepoPolicyAndCoverage as validationChecksForSharedRepoPolicyAndCoverage } from "@the-open-engine/opcore-validation-policy"; import { invokeCloneAnalysis } from "./clone-invoker.js"; -import type { PythonProjectContext } from "@the-open-engine/opcore-contracts"; import { createNodePythonProjectWorkspace } from "@the-open-engine/opcore-validation-python"; const opcoreValidationPolicyOptions = { @@ -22,14 +21,9 @@ export function validationChecksForRepoPolicy(repoRoot: string) { }); } -export function validationChecksForRepoPolicyAndCoverage( - repoRoot: string, - adapters: ReadonlySet, - pythonProjectContexts?: readonly PythonProjectContext[] -) { +export function validationChecksForRepoPolicyAndCoverage(repoRoot: string, adapters: ReadonlySet) { return validationChecksForSharedRepoPolicyAndCoverage(repoRoot, adapters, { ...opcoreValidationPolicyOptions, - pythonWorkspace: createNodePythonProjectWorkspace(repoRoot), - ...(pythonProjectContexts === undefined ? {} : { pythonProjectContexts }) + pythonWorkspace: createNodePythonProjectWorkspace(repoRoot) }); } diff --git a/packages/opcore/src/scan.ts b/packages/opcore/src/scan.ts index d632eac..0d42333 100644 --- a/packages/opcore/src/scan.ts +++ b/packages/opcore/src/scan.ts @@ -142,11 +142,7 @@ function createScanValidationPlan(repoState: OpcoreRepoStatePayload): { }); } return { - checks: validationChecksForRepoPolicyAndCoverage( - repoState.repo.root, - activeAdapters, - repoState.validation.pythonProjectContexts - ), + checks: validationChecksForRepoPolicyAndCoverage(repoState.repo.root, activeAdapters), skippedChecks }; } diff --git a/packages/validation-policy/src/factory.ts b/packages/validation-policy/src/factory.ts index fb8fc86..d8f2fc1 100644 --- a/packages/validation-policy/src/factory.ts +++ b/packages/validation-policy/src/factory.ts @@ -53,10 +53,7 @@ export function createBuiltInValidationChecks( }), ...createRustValidationChecks(rustValidationOptions(config)), ...createPythonValidationChecks( - { - ...(options.pythonProjectContexts === undefined ? {} : { contexts: options.pythonProjectContexts }), - ...(pythonWorkspace === undefined ? {} : { nodeWorkspace: pythonWorkspace }) - } + pythonWorkspace === undefined ? {} : { nodeWorkspace: pythonWorkspace } ), ...createDocsValidationChecks(docsValidationOptions(checksConfig?.docs)), ...cloneChecks(checksConfig?.clone, options) diff --git a/packages/validation-policy/src/types.ts b/packages/validation-policy/src/types.ts index c395fa0..8bcef84 100644 --- a/packages/validation-policy/src/types.ts +++ b/packages/validation-policy/src/types.ts @@ -1,5 +1,4 @@ import type { ValidationCheckDefinition } from "@the-open-engine/opcore-validation"; -import type { PythonProjectContext } from "@the-open-engine/opcore-contracts"; import type { PythonProjectWorkspace } from "@the-open-engine/opcore-validation-python"; import type { CloneNativeInvoker } from "@the-open-engine/opcore-validation-clone"; @@ -114,7 +113,6 @@ export interface OpcoreCheckPack { } export interface OpcoreRepoValidationPolicyOptions { - pythonProjectContexts?: readonly PythonProjectContext[]; pythonWorkspace?: PythonProjectWorkspace; clone?: false | { invoke?: CloneNativeInvoker["invoke"]; diff --git a/packages/validation-python/src/environment-resolution.ts b/packages/validation-python/src/environment-resolution.ts index 10c4897..8b7badd 100644 --- a/packages/validation-python/src/environment-resolution.ts +++ b/packages/validation-python/src/environment-resolution.ts @@ -9,7 +9,7 @@ import type { PythonProjectToolProvenance } from "@the-open-engine/opcore-contracts"; import { existsSync } from "node:fs"; -import { delimiter, isAbsolute, resolve } from "node:path"; +import { delimiter, dirname, isAbsolute, resolve } from "node:path"; import { runTool, type PythonToolRunOptions, type PythonToolRunResult } from "./process.js"; import type { PythonProjectWorkspace } from "./project-workspace.js"; import { isSupportedPythonVersionConstraint, pythonVersionSatisfiesConstraint } from "./version-constraint.js"; @@ -53,6 +53,13 @@ interface ManagerExecutableCandidates { tools: Readonly>>; } +const toolConfigOptions: Readonly, readonly string[]>> = { + mypy: ["--config", "--config-file"], + pyright: ["--project", "-p"], + ruff: ["--config"], + pytest: ["--config-file", "-c"] +}; + const interpreterProbeScript = [ "import json, platform, sys, sysconfig", "print(json.dumps({'protocol':'opcore.python.project-context.interpreter.v1','executable':sys.executable,'version':platform.python_version(),'implementation':platform.python_implementation(),'platform':sys.platform,'architecture':platform.machine(),'abi':getattr(sys.implementation,'cache_tag',None),'soabi':sysconfig.get_config_var('SOABI')}))" @@ -395,7 +402,7 @@ function interpreterCandidates( reasons: PythonProjectContextReason[] ): readonly ExecutableCandidate[] { if (options.interpreterArgv !== undefined) { - const override = validateOverride(options.interpreterArgv, "interpreter", reasons); + const override = validateOverride(options.interpreterArgv, "interpreter", reasons, options); return override === undefined ? [] : [override]; } const candidates: ExecutableCandidate[] = []; @@ -426,7 +433,7 @@ function toolCandidates( ): readonly ExecutableCandidate[] { const override = options.toolArgv?.[tool]; if (override !== undefined) { - const candidate = validateOverride(override, tool, reasons); + const candidate = validateOverride(override, tool, reasons, options); return candidate === undefined ? [] : [candidate]; } const candidates: ExecutableCandidate[] = []; @@ -612,12 +619,13 @@ function firstVersion(stdout: string, stderr: string): string | undefined { function validateOverride( argv: readonly string[], label: "interpreter" | PythonProjectToolKind, - reasons: PythonProjectContextReason[] + reasons: PythonProjectContextReason[], + options?: PythonProjectEnvironmentOptions ): ExecutableCandidate | undefined { if (!Array.isArray(argv) || argv.length === 0 || argv.some((entry) => typeof entry !== "string" || entry.length === 0)) { throw new Error(`Python ${label} argv override must be a non-empty string array`); } - if (!safeOverrideArgv(argv, label)) { + if (!safeOverrideArgv(argv, label, options)) { reasons.push({ code: "invalid_config", tool: label === "interpreter" ? "python" : label, @@ -637,7 +645,11 @@ function activeEnvironmentCompatible(environment: string, options: PythonProject return relativeEnvironment.length > 0 && !relativeEnvironment.includes("/"); } -function safeOverrideArgv(argv: readonly string[], label: "interpreter" | PythonProjectToolKind): boolean { +function safeOverrideArgv( + argv: readonly string[], + label: "interpreter" | PythonProjectToolKind, + options?: PythonProjectEnvironmentOptions +): boolean { const executable = executableBasename(argv[0]); const prefix = argv.slice(1); if (label === "interpreter") { @@ -645,7 +657,8 @@ function safeOverrideArgv(argv: readonly string[], label: "interpreter" | Python return safePythonOptionPrefix(prefix); } if (label === "build" || executable !== label && executable !== `${label}.exe`) return false; - return safeToolOptionPrefix(label, prefix); + return safeToolOptionPrefix(label, prefix) && + (prefix.length === 0 || options !== undefined && safeToolConfigPaths(label, prefix, options)); } function executableBasename(command: string): string { @@ -669,13 +682,7 @@ function safePythonOptionPrefix(args: readonly string[]): boolean { } function safeToolOptionPrefix(tool: Exclude, args: readonly string[]): boolean { - const valuedOptions: Readonly, readonly string[]>> = { - mypy: ["--config", "--config-file"], - pyright: ["--project", "-p"], - ruff: ["--config"], - pytest: ["--config-file", "-c"] - }; - const options = valuedOptions[tool]; + const options = toolConfigOptions[tool]; for (let index = 0; index < args.length; index += 1) { const argument = args[index]; const exact = options.find((option) => argument === option); @@ -691,6 +698,38 @@ function safeToolOptionPrefix(tool: Exclude, arg return true; } +function safeToolConfigPaths( + tool: Exclude, + args: readonly string[], + options: PythonProjectEnvironmentOptions +): boolean { + const configFile = options.toolConfigs[tool]; + if (configFile === undefined) return false; + const expected = canonicalPath(options.repoRoot, configFile, options.platform); + const expectedDirectory = options.platform === "win32" ? windowsDirname(expected) : normalizePath(dirname(expected)); + const optionNames = toolConfigOptions[tool]; + for (let index = 0; index < args.length; index += 1) { + const argument = args[index]; + const exact = optionNames.find((option) => argument === option); + const value = exact === undefined + ? optionNames.map((option) => argument.startsWith(`${option}=`) ? argument.slice(option.length + 1) : undefined) + .find((candidate) => candidate !== undefined) + : args[index += 1]; + if (value === undefined) return false; + const actual = canonicalPath(projectCwd(options), value, options.platform); + if (actual !== expected && (tool !== "pyright" || actual !== expectedDirectory)) return false; + } + return true; +} + + +function canonicalPath(root: string, path: string, platform: string | undefined): string { + const resolved = platform === "win32" ? resolveWindowsPath(root, path) : resolve(root, path); + const normalized = normalizePath(resolved); + return platform === "win32" ? normalized.toLowerCase() : normalized; +} + + function environmentExecutable(environment: string, tool: string, platform: string | undefined): string { const suffix = platform === "win32" ? `Scripts/${tool}.exe` : `bin/${tool}`; return joinAbsolute(environment, suffix, platform); @@ -729,6 +768,36 @@ function projectCwd(options: PythonProjectEnvironmentOptions): string { return options.projectRoot === "." ? options.repoRoot : joinAbsolute(options.repoRoot, options.projectRoot, options.platform); } +function resolveWindowsPath(root: string, path: string): string { + const normalizedPath = path.replaceAll("\\", "/"); + const normalizedRoot = root.replaceAll("\\", "/").replace(/\/+$/u, ""); + const absolute = /^[A-Za-z]:\//u.test(normalizedPath) || normalizedPath.startsWith("//"); + const combined = absolute ? normalizedPath : `${normalizedRoot}/${normalizedPath}`; + const drive = combined.match(/^[A-Za-z]:/u)?.[0] ?? ""; + const unc = drive.length === 0 && combined.startsWith("//"); + const rooted = drive.length > 0 || unc || combined.startsWith("/"); + const body = drive.length > 0 ? combined.slice(drive.length) : unc ? combined.slice(2) : combined; + const segments: string[] = []; + for (const segment of body.split("/")) { + if (segment.length === 0 || segment === ".") continue; + if (segment === "..") { + if (segments.length > 0) segments.pop(); + continue; + } + segments.push(segment); + } + const prefix = drive.length > 0 ? `${drive}/` : unc ? "//" : rooted ? "/" : ""; + return `${prefix}${segments.join("/")}`; +} + +function windowsDirname(path: string): string { + const normalized = normalizePath(path); + const separator = normalized.lastIndexOf("/"); + if (separator < 0) return "."; + if (separator === 2 && /^[A-Za-z]:\//u.test(normalized)) return normalized.slice(0, 3); + return separator === 0 ? "/" : normalized.slice(0, separator); +} + function joinAbsolute(root: string, suffix: string, platform: string | undefined): string { if (platform === "win32") return `${root.replace(/[\\/]+$/u, "")}\\${suffix.replaceAll("/", "\\")}`; return resolve(root, suffix); diff --git a/packages/validation-python/src/index.ts b/packages/validation-python/src/index.ts index c04db83..69dfff6 100644 --- a/packages/validation-python/src/index.ts +++ b/packages/validation-python/src/index.ts @@ -42,7 +42,6 @@ export function createPythonValidationChecks( options: CreatePythonValidationChecksOptions = {} ): readonly ValidationCheckDefinition[] { const resolveContexts = createPythonProjectContextResolver({ - ...(options.contexts === undefined ? {} : { contexts: options.contexts }), ...(options.nodeWorkspace === undefined ? {} : { nodeWorkspace: options.nodeWorkspace }), ...(options.env === undefined ? {} : { env: options.env }), ...(options.interpreterArgv === undefined ? {} : { interpreterArgv: options.interpreterArgv }), diff --git a/packages/validation-python/src/project-workspace.ts b/packages/validation-python/src/project-workspace.ts index 914f339..8d69e09 100644 --- a/packages/validation-python/src/project-workspace.ts +++ b/packages/validation-python/src/project-workspace.ts @@ -20,9 +20,10 @@ export interface PythonProjectWorkspace { export function createValidationFileViewPythonWorkspace( fileView: ValidationFileView, - executableExists: PythonProjectWorkspace["executableExists"] = nodeExecutableExists, + executableExists?: PythonProjectWorkspace["executableExists"], fullWorkspace?: PythonProjectWorkspace ): PythonProjectWorkspace { + const executableAvailable = executableExists ?? fullWorkspace?.executableExists ?? nodeExecutableExists; return { read: async (path) => { const result = await fileView.readAfter(validateRepoRelativePath(path)); @@ -49,7 +50,7 @@ export function createValidationFileViewPythonWorkspace( if (baseline.unavailable && await fullWorkspace.exists(normalized)) return baseline; return { path: normalized, symlink: false }; }, - executableExists + executableExists: executableAvailable }; } diff --git a/packages/validation-python/src/source-files.ts b/packages/validation-python/src/source-files.ts index 2d87bed..48461db 100644 --- a/packages/validation-python/src/source-files.ts +++ b/packages/validation-python/src/source-files.ts @@ -34,7 +34,6 @@ export type PythonProjectContextResolver = (context: ValidationCheckContext) => export function createPythonProjectContextResolver( options: Omit & { - contexts?: readonly PythonProjectContext[]; nodeWorkspace?: PythonProjectWorkspace; } = {} ): PythonProjectContextResolver { @@ -43,30 +42,25 @@ export function createPythonProjectContextResolver( const existing = cache.get(context.fileView); if (existing !== undefined) return existing; const targets = pythonInputSet(context); - const reusable = context.fileView.overlays.length === 0 && options.contexts !== undefined && - targets.every((target) => options.contexts?.some((candidate) => candidate.target === target)); const promise = targets.length === 0 ? Promise.resolve([]) - : reusable - ? Promise.resolve(options.contexts?.filter((candidate) => targets.includes(candidate.target)) ?? []) : resolvePythonProjectContexts({ repoRoot: context.request.repo.repoRoot ?? process.cwd(), targets, workspace: createValidationFileViewPythonWorkspace(context.fileView, undefined, options.nodeWorkspace), - ...withoutContexts(options) + ...withoutNodeWorkspace(options) }); cache.set(context.fileView, promise); return promise; }; } -function withoutContexts( +function withoutNodeWorkspace( options: Omit & { - contexts?: readonly PythonProjectContext[]; nodeWorkspace?: PythonProjectWorkspace; } ): Omit { - const { contexts: _contexts, nodeWorkspace: _nodeWorkspace, ...resolverOptions } = options; + const { nodeWorkspace: _nodeWorkspace, ...resolverOptions } = options; return resolverOptions; } diff --git a/packages/validation-python/src/static-config.ts b/packages/validation-python/src/static-config.ts index d43a982..8c4cb79 100644 --- a/packages/validation-python/src/static-config.ts +++ b/packages/validation-python/src/static-config.ts @@ -134,7 +134,8 @@ function targetEvidence( const document = tomlDocuments.get(path); const requires = stringAt(document, ["project", "requires-python"]) ?? stringAt(document, ["tool", "poetry", "dependencies", "python"]) ?? - valueForNonTomlConfig(path, content, "requires-python"); + valueForNonTomlConfig(path, content, "requires-python") ?? + valueForNonTomlConfig(path, content, "python_requires"); if (requires !== undefined) declarations.push({ source: path, kind: "requires", value: requires }); const version = stringAt(document, ["tool", "pyright", "pythonVersion"]) ?? stringAt(document, ["requires", "python_version"]) ?? @@ -262,8 +263,11 @@ function directChildren(root: string, files: readonly string[]): readonly string function valueForKey(content: string, key: string): string | undefined { const escaped = key.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&"); - const match = new RegExp(`^\\s*["']?${escaped}["']?\\s*(?:=|:)\\s*["']([^"']+)["']`, "mu").exec(content); - return match?.[1]?.trim(); + const match = new RegExp( + `^\\s*["']?${escaped}["']?\\s*(?:=|:)\\s*(?:"([^"]+)"|'([^']+)'|([^#;\\r\\n]+))`, + "mu" + ).exec(content); + return (match?.[1] ?? match?.[2] ?? match?.[3])?.trim(); } function constraintAllowsVersion(constraint: string, version: string): boolean { @@ -280,9 +284,31 @@ function valueForNonTomlConfig(path: string, content: string, key: string): stri return undefined; } } + if (basename(path) === "setup.cfg" && key === "python_requires") { + return valueForIniOption(content, "options", key); + } return valueForKey(content, key); } +function valueForIniOption(content: string, expectedSection: string, key: string): string | undefined { + let section: string | undefined; + for (const rawLine of content.replace(/^\uFEFF/u, "").split(/\r?\n/u)) { + const trimmed = rawLine.trim(); + const sectionMatch = /^\[([^\[\]]+)\](?:\s*[#;].*)?$/u.exec(trimmed); + if (sectionMatch !== null) { + section = sectionMatch[1].trim().toLowerCase(); + continue; + } + if (section !== expectedSection || /^\s/u.test(rawLine)) continue; + const delimiterIndex = firstIniDelimiter(rawLine); + if (delimiterIndex <= 0 || rawLine.slice(0, delimiterIndex).trim().toLowerCase() !== key.toLowerCase()) continue; + const value = rawLine.slice(delimiterIndex + 1).trim(); + const match = /^(?:"([^"]*)"|'([^']*)'|([^#;]*))/u.exec(value); + return (match?.[1] ?? match?.[2] ?? match?.[3])?.trim(); + } + return undefined; +} + type TomlTable = Record; function isTable(value: unknown): value is TomlTable { diff --git a/packages/validation-python/src/syntax-check.ts b/packages/validation-python/src/syntax-check.ts index 47ec01a..b2b5dd9 100644 --- a/packages/validation-python/src/syntax-check.ts +++ b/packages/validation-python/src/syntax-check.ts @@ -21,7 +21,7 @@ import { runTool } from "./process.js"; import { readPythonAfterSources, skippedPythonInputResult, type PythonProjectContextResolver } from "./source-files.js"; import { type PythonValidationToolchainOptions } from "./toolchain.js"; -export interface PythonSyntaxCheckOptions extends PythonValidationToolchainOptions { +export interface PythonSyntaxCheckOptions extends Omit { timeoutMs?: number; } diff --git a/packages/validation-python/src/type-check.ts b/packages/validation-python/src/type-check.ts index b8367b3..51cbf9b 100644 --- a/packages/validation-python/src/type-check.ts +++ b/packages/validation-python/src/type-check.ts @@ -26,7 +26,7 @@ import { } from "./source-files.js"; import type { PythonValidationToolchainOptions } from "./toolchain.js"; -export interface PythonTypeCheckOptions extends PythonValidationToolchainOptions { +export interface PythonTypeCheckOptions extends Omit { timeoutMs?: number; } @@ -72,7 +72,7 @@ export function createTypeCheck( const workspace = await materializePythonTypeWorkspace(context, project, files); try { const args = [ - ...checker.argv.slice(1), + ...materializedCheckerPrefix(checker, project.context.projectRoot), ...project.targets.map((path) => relativeProjectPath(path, project.context.projectRoot)) ]; const result = runTool(checker.executable, args, { @@ -141,9 +141,8 @@ function isUnresolvedTypeContext(context: PythonProjectContext): boolean { function selectTypeChecker(context: PythonProjectContext): PythonProjectToolProvenance | undefined { const mypy = context.tools.find((tool) => tool.tool === "mypy" && tool.available); const pyright = context.tools.find((tool) => tool.tool === "pyright" && tool.available); - if (context.tools.some((tool) => tool.tool === "pyright" && tool.configFile?.endsWith("pyrightconfig.json"))) { - return pyright ?? mypy; - } + if (pyright?.configFile !== undefined) return pyright; + if (mypy?.configFile !== undefined) return mypy; return mypy ?? pyright; } @@ -299,6 +298,27 @@ function parsePyrightLine( }); } +function materializedCheckerPrefix( + checker: PythonProjectToolProvenance, + projectRoot: string +): readonly string[] { + const prefix = [...checker.argv.slice(1)]; + if (checker.configFile === undefined) return prefix; + const options = checker.tool === "mypy" ? ["--config", "--config-file"] : ["--project", "-p"]; + const materializedConfig = relativeProjectPath(checker.configFile, projectRoot); + for (let index = 0; index < prefix.length; index += 1) { + const argument = prefix[index]; + if (options.includes(argument)) { + prefix[index + 1] = materializedConfig; + index += 1; + continue; + } + const option = options.find((candidate) => argument.startsWith(`${candidate}=`)); + if (option !== undefined) prefix[index] = `${option}=${materializedConfig}`; + } + return prefix; +} + function checkerProvenance(checker: PythonProjectToolProvenance) { return { name: checker.tool, diff --git a/packages/validation-python/src/version-constraint.ts b/packages/validation-python/src/version-constraint.ts index 4b77daa..4325b3b 100644 --- a/packages/validation-python/src/version-constraint.ts +++ b/packages/validation-python/src/version-constraint.ts @@ -47,23 +47,24 @@ export function pythonVersionSatisfiesConstraint(version: string, constraint: st } function parseConstraintClause(value: string): ParsedPythonConstraintClause | undefined { - const match = /^(?===|==|!=|>=|<=|>|<|~=)?\s*(?\d+(?:\.\d+){1,2}(?:(?:a|b|rc)\d+)?)(?\.\*)?$/u.exec(value); + const match = /^(?===|==|!=|>=|<=|>|<|~=)?\s*(?\d+(?:\.\d+){0,2}(?:(?:a|b|rc)\d+)?)(?\.\*)?$/u.exec(value); if (match?.groups === undefined) return undefined; const parsed = parsePythonVersion(match.groups.version); if (parsed === undefined) return undefined; const operator = (match.groups.operator ?? "==") as ParsedPythonConstraintClause["operator"]; const wildcard = match.groups.wildcard !== undefined; if (wildcard && operator !== "==" && operator !== "!=") return undefined; + if (operator === "~=" && parsed.precision < 2) return undefined; return { ...parsed, operator, wildcard }; } function parsePythonVersion(value: string): ParsedPythonVersion | undefined { - const match = /^(?\d+)\.(?\d+)(?:\.(?\d+))?(?:(?a|b|rc)(?\d+))?(?:\+[A-Za-z0-9]+(?:[._-][A-Za-z0-9]+)*)?$/u.exec(value.trim()); + const match = /^(?\d+)(?:\.(?\d+))?(?:\.(?\d+))?(?:(?a|b|rc)(?\d+))?(?:\+[A-Za-z0-9]+(?:[._-][A-Za-z0-9]+)*)?$/u.exec(value.trim()); if (match?.groups === undefined) return undefined; const prereleaseKind = match.groups.prereleaseKind as ParsedPythonPrerelease["kind"] | undefined; return { - release: [Number(match.groups.major), Number(match.groups.minor), Number(match.groups.patch ?? 0)], - precision: match.groups.patch === undefined ? 2 : 3, + release: [Number(match.groups.major), Number(match.groups.minor ?? 0), Number(match.groups.patch ?? 0)], + precision: match.groups.minor === undefined ? 1 : match.groups.patch === undefined ? 2 : 3, ...(prereleaseKind === undefined ? {} : { prerelease: { kind: prereleaseKind, number: Number(match.groups.prereleaseNumber) } }) diff --git a/tests/asp-provider.test.mjs b/tests/asp-provider.test.mjs index 6da66a7..12feb6e 100644 --- a/tests/asp-provider.test.mjs +++ b/tests/asp-provider.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 { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { chmodSync, cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; @@ -232,7 +232,7 @@ describe("Opcore ASP provider", () => { } }); - it("propagates Python compiler ranges under the python.syntax rule", { timeout: 60000 }, async () => { + it("reports Python compiler unavailability without executing the provider process PATH", { timeout: 60000 }, async () => { const repo = mkdtempSync(join(tmpdir(), "opcore-asp-provider-python-range-")); try { mkdirSync(join(repo, "pkg")); @@ -257,15 +257,13 @@ describe("Opcore ASP provider", () => { comparison: "all", checks: ["python.syntax"] }); - const syntax = assessment.diagnostics.find((entry) => entry.code.includes("/PY_SYNTAX_ERROR")); - assert.equal(syntax?.code, "opcore/python.syntax/PY_SYNTAX_ERROR"); - assert.deepEqual(syntax?.location, { - path: "pkg/app.py", - range: { - start: { line: 1, column: 8 }, - end: { line: 1, column: 9 } - } - }); + const unavailable = assessment.diagnostics.find((entry) => entry.code.includes("/PY_SYNTAX_TOOL_UNAVAILABLE")); + assert.equal(unavailable?.code, "opcore/opcore.infrastructure/PY_SYNTAX_TOOL_UNAVAILABLE"); + assert.deepEqual(unavailable?.location, { path: "pkg/app.py" }); + assert.equal( + assessment.coverage.degraded.some((entry) => entry.requirement === "python.syntax"), + true + ); assert.equal(readFileSync(join(repo, "pkg/app.py"), "utf8"), "value = 1\n"); } finally { peer.close(); @@ -348,6 +346,11 @@ describe("Opcore ASP provider", () => { const repo = mkdtempSync(join(tmpdir(), "opcore-asp-provider-python-context-")); try { mkdirSync(join(repo, "services", "api", "src"), { recursive: true }); + const providerLocalPythonMarker = join(repo, "provider-local-python-ran"); + const providerLocalPython = join(repo, "services", "api", ".venv", "bin", "python"); + mkdirSync(dirname(providerLocalPython), { recursive: true }); + writeFileSync(providerLocalPython, `#!/bin/sh\n: > ${JSON.stringify(providerLocalPythonMarker)}\nexit 1\n`); + chmodSync(providerLocalPython, 0o755); writeFileSync( join(repo, "services", "api", "pyproject.toml"), "[project]\nname='disk-must-not-win'\nrequires-python='>=99'\n" @@ -387,8 +390,14 @@ describe("Opcore ASP provider", () => { assert.equal(contextEvidence.data.projectRoot, "services/api"); assert.match(contextEvidence.data.projectKey, /^sha256:[a-f0-9]{64}$/); assert.match(contextEvidence.data.contextFingerprint, /^sha256:[a-f0-9]{64}$/); - assert.notEqual(contextEvidence.data.outcome, "unsupported"); + assert.equal(contextEvidence.data.outcome, "unsupported"); + assert.equal( + contextEvidence.data.reasons.some((reason) => reason.code === "tool_unavailable"), + true, + JSON.stringify(contextEvidence.data.reasons) + ); assert.equal(JSON.stringify(contextEvidence).includes("disk-must-not-win"), false); + assert.equal(existsSync(providerLocalPythonMarker), false, "ASP provider must not execute package-local host paths"); } finally { peer.close(); } diff --git a/tests/validation-python.test.mjs b/tests/validation-python.test.mjs index 83b56f1..77d6681 100644 --- a/tests/validation-python.test.mjs +++ b/tests/validation-python.test.mjs @@ -334,7 +334,7 @@ describe("validation-python adapter", () => { } }); - it("prefers pyright when pyright project config is present", async () => { + it("prefers configured pyright when its project config is present and mypy is available", async () => { const repoRoot = mkdtempSync(join(tmpdir(), "opcore-python-types-pyright-")); try { writePassingPythonProtocolShim(repoRoot); @@ -360,7 +360,11 @@ describe("validation-python adapter", () => { checks: createPythonValidationChecks({ env: { PATH: "" }, repoRoot, - nodeWorkspace: createNodePythonProjectWorkspace(repoRoot) + nodeWorkspace: createNodePythonProjectWorkspace(repoRoot), + toolArgv: { + mypy: [join(repoRoot, ".venv", "bin", "mypy")], + pyright: [join(repoRoot, ".venv", "bin", "pyright")] + }, }) }).runValidation( request({ @@ -372,6 +376,95 @@ describe("validation-python adapter", () => { assert.equal(result.status, "policy_failure"); assert.equal(result.diagnostics[0].code, "PYRIGHT_REPORT_ASSIGNMENT_TYPE"); assert.equal(result.diagnostics[0].path, "pkg/app.py"); + assert.equal(result.pythonProjectContexts[0].tools.find((tool) => tool.tool === "pyright")?.source, "explicit_override"); + assert.equal(result.pythonProjectContexts[0].tools.find((tool) => tool.tool === "pyright")?.configFile, "pyrightconfig.json"); + } finally { + rmSync(repoRoot, { recursive: true, force: true }); + } + }); + + it("selects the type checker from overlay after-state config", async () => { + const repoRoot = mkdtempSync(join(tmpdir(), "opcore-python-types-overlay-config-")); + try { + writePassingPythonProtocolShim(repoRoot); + writeToolShim(repoRoot, "mypy", "#!/bin/sh\nif [ \"$1\" = \"--version\" ]; then echo 'mypy 1.8.0'; exit 0; fi\necho 'mypy should not run'; exit 1\n"); + writeToolShim( + repoRoot, + "pyright", + [ + "#!/bin/sh", + "if [ \"$1\" = \"--version\" ]; then echo 'pyright 1.1.0'; exit 0; fi", + "echo \" $1:1:14 - error: overlay config selected Pyright (reportAssignmentType)\"", + "exit 1", + "" + ].join("\n") + ); + const files = { + "mypy.ini": "[mypy]\nstrict = true\n", + "pkg/app.py": "value: int = 'wrong'\n" + }; + const result = await runner({ + files, + checks: createPythonValidationChecks({ + env: { PATH: "" }, + repoRoot, + nodeWorkspace: createNodePythonProjectWorkspace(repoRoot) + }) + }).runValidation(request({ + repo: { repoRoot }, + checks: [PYTHON_TYPES_CHECK_ID], + overlays: [ + { path: "mypy.ini", action: "delete" }, + { path: "pyrightconfig.json", action: "write", content: "{}\n" } + ] + })); + + assert.equal(result.status, "policy_failure"); + assert.equal(result.diagnostics[0].code, "PYRIGHT_REPORT_ASSIGNMENT_TYPE"); + assert.match(result.diagnostics[0].message, /overlay config selected Pyright/); + assert.equal(result.pythonProjectContexts[0].tools.find((tool) => tool.tool === "pyright")?.configFile, "pyrightconfig.json"); + } finally { + rmSync(repoRoot, { recursive: true, force: true }); + } + }); + + it("rewrites explicit checker config paths into the materialized after-state workspace", async () => { + const repoRoot = mkdtempSync(join(tmpdir(), "opcore-python-types-config-path-")); + try { + writePassingPythonProtocolShim(repoRoot); + writeFileSync(join(repoRoot, "mypy.ini"), "[mypy]\nmarker = before\n"); + writeToolShim( + repoRoot, + "mypy", + [ + "#!/bin/sh", + "if [ \"$3\" = \"--version\" ]; then echo 'mypy 1.8.0'; exit 0; fi", + "if [ \"$1\" != \"--config-file\" ] || [ \"$2\" != \"mypy.ini\" ]; then echo \"unexpected config path: $*\" >&2; exit 9; fi", + "/usr/bin/grep -q 'marker = after' \"$2\" || { echo 'overlay config was not materialized' >&2; exit 9; }", + "exit 0", + "" + ].join("\n") + ); + const mypy = join(repoRoot, ".venv", "bin", "mypy"); + const files = { + "mypy.ini": "[mypy]\nmarker = before\n", + "pkg/app.py": "value: int = 1\n" + }; + const result = await runner({ + files, + checks: createPythonValidationChecks({ + env: { PATH: "" }, + repoRoot, + nodeWorkspace: createNodePythonProjectWorkspace(repoRoot), + toolArgv: { mypy: [mypy, "--config-file", join(repoRoot, "mypy.ini")] } + }) + }).runValidation(request({ + repo: { repoRoot }, + checks: [PYTHON_TYPES_CHECK_ID], + overlays: [{ path: "mypy.ini", action: "write", content: "[mypy]\nmarker = after\n" }] + })); + + assert.equal(result.status, "passed", JSON.stringify(result, null, 2)); } finally { rmSync(repoRoot, { recursive: true, force: true }); } @@ -1478,6 +1571,162 @@ describe("Python project-context resolver", () => { assert.notEqual(missingBuildVersion.outcome, "resolved"); }); + it("does not use provider-local executable paths when the injected workspace refuses them", async () => { + const repoRoot = mkdtempSync(join(tmpdir(), "opcore-python-host-workspace-")); + try { + const localPython = join(repoRoot, ".venv", "bin", "python"); + mkdirSync(dirname(localPython), { recursive: true }); + writeFileSync(localPython, "#!/bin/sh\nexit 0\n"); + chmodSync(localPython, 0o755); + const files = { + "pyproject.toml": "[project]\nname='fixture'\n", + "app.py": "VALUE = 1\n" + }; + const result = await runner({ + files, + checks: createPythonValidationChecks({ + nodeWorkspace: projectWorkspace(files, () => false), + processProbe: { + run() { + throw new Error("provider-local executable must not be probed"); + } + } + }) + }).runValidation(request({ + repo: { repoRoot }, + checks: [PYTHON_SOURCE_HYGIENE_CHECK_ID], + scope: { kind: "files", files: ["app.py"] } + })); + + assert.equal(result.pythonProjectContexts[0].interpreter, undefined); + assert.equal(result.pythonProjectContexts[0].outcome, "unsupported"); + } finally { + rmSync(repoRoot, { recursive: true, force: true }); + } + }); + + it("refuses explicit interpreter paths outside the injected workspace trust boundary", async () => { + const repoRoot = mkdtempSync(join(tmpdir(), "opcore-python-explicit-host-path-")); + try { + const localPython = join(repoRoot, ".venv", "bin", "python"); + mkdirSync(dirname(localPython), { recursive: true }); + writeFileSync(localPython, "#!/bin/sh\nexit 0\n"); + chmodSync(localPython, 0o755); + const result = await resolvePythonProjectContext({ + repoRoot, + target: "app.py", + interpreterArgv: [localPython], + workspace: projectWorkspace({ + "pyproject.toml": "[project]\nname='fixture'\n", + "app.py": "VALUE = 1\n" + }, () => false), + processProbe: { + run() { + throw new Error("refused explicit interpreter must not be probed"); + } + } + }); + + assert.equal(result.interpreter, undefined); + assert.equal(result.reasons.some((reason) => reason.code === "interpreter_unavailable"), true); + assert.equal(result.outcome, "unsupported"); + } finally { + rmSync(repoRoot, { recursive: true, force: true }); + } + }); + + it("resolves each validation file view once across Python checks", async () => { + const files = { + "pyproject.toml": "[project]\nname='fixture'\n", + "app.py": "VALUE = 1\n" + }; + const baseWorkspace = projectWorkspace(files, () => false); + let listCalls = 0; + const result = await runner({ + files, + checks: createPythonValidationChecks({ + nodeWorkspace: { + ...baseWorkspace, + list: async () => { + listCalls += 1; + return baseWorkspace.list(); + } + } + }) + }).runValidation(request({ + repo: { repoRoot: "/fixture" }, + checks: [PYTHON_SOURCE_HYGIENE_CHECK_ID, PYTHON_TYPES_CHECK_ID], + scope: { kind: "files", files: ["app.py"] } + })); + + assert.equal(result.pythonProjectContexts.length, 1); + assert.equal(listCalls, 1); + }); + + it("resolves each current file view independently of earlier context evidence", async () => { + const originalFiles = { + "pyproject.toml": "[project]\nname='fixture'\nrequires-python='>=3.11'\n", + "app.py": "VALUE = 1\n" + }; + const stale = await resolvePythonProjectContext({ + repoRoot: "/fixture", + target: "app.py", + workspace: projectWorkspace(originalFiles, () => true), + processProbe: successfulProbe("3.12.4") + }); + const currentFiles = { + ...originalFiles, + "pyproject.toml": "[project]\nname='fixture'\nrequires-python='>=3.13'\n" + }; + const result = await runner({ + files: currentFiles, + checks: createPythonValidationChecks({ + nodeWorkspace: projectWorkspace(currentFiles, () => true), + processProbe: successfulProbe("3.12.4") + }) + }).runValidation(request({ + repo: { repoRoot: "/fixture" }, + checks: [PYTHON_SOURCE_HYGIENE_CHECK_ID], + scope: { kind: "files", files: ["app.py"] } + })); + + assert.equal(result.pythonProjectContexts[0].targetRuntime.requiresPython, ">=3.13"); + assert.equal(result.pythonProjectContexts[0].outcome, "unsupported"); + assert.notEqual(result.pythonProjectContexts[0].contextFingerprint, stale.contextFingerprint); + }); + + it("parses setup.cfg python_requires and major-only PEP 440 range bounds", async () => { + const files = { + "setup.cfg": "[metadata]\nname = fixture\npython_requires = >=99\n[options]\npython_requires = >=3.11,<4\n", + "app.py": "VALUE = 1\n" + }; + const compatible = await resolvePythonProjectContext({ + repoRoot: "/fixture", + target: "app.py", + workspace: projectWorkspace(files, () => true), + processProbe: successfulProbe("3.12.4") + }); + const incompatible = await resolvePythonProjectContext({ + repoRoot: "/fixture", + target: "app.py", + workspace: projectWorkspace(files, () => true), + processProbe: successfulProbe("4.0.0") + }); + const belowFloor = await resolvePythonProjectContext({ + repoRoot: "/fixture", + target: "app.py", + workspace: projectWorkspace(files, () => true), + processProbe: successfulProbe("3.10.14") + }); + + assert.equal(compatible.targetRuntime.requiresPython, ">=3.11,<4"); + assert.equal(compatible.outcome, "resolved"); + assert.equal(incompatible.outcome, "unsupported"); + assert.equal(incompatible.reasons.some((reason) => reason.code === "incompatible_interpreter"), true); + assert.equal(belowFloor.outcome, "unsupported"); + assert.equal(belowFloor.reasons.some((reason) => reason.code === "incompatible_interpreter"), true); + }); + it("applies PEP 440 compatible-release precision consistently", async () => { const compatible = await resolvePythonProjectContext({ repoRoot: "/fixture", @@ -1504,6 +1753,18 @@ describe("Python project-context resolver", () => { }); assert.equal(incompatible.reasons.some((reason) => reason.code === "incompatible_interpreter"), true); assert.equal(incompatible.outcome, "unsupported"); + + const underspecified = await resolvePythonProjectContext({ + repoRoot: "/fixture", + target: "app.py", + workspace: projectWorkspace({ + "pyproject.toml": "[project]\nname='fixture'\nrequires-python='~=3'\n", + "app.py": "VALUE = 1\n" + }, () => true), + processProbe: successfulProbe("3.12.4") + }); + assert.equal(underspecified.reasons.some((reason) => reason.code === "unsupported_target"), true); + assert.equal(underspecified.outcome, "unsupported"); }); it("does not treat prerelease interpreters as final PEP 440 releases", async () => { @@ -1702,7 +1963,7 @@ function canonicalTestPythonWorkspace() { list: async () => [], exists: async () => false, realpath: async (path) => ({ path, symlink: false }), - executableExists: async () => false + executableExists: async (path) => !path.includes("/") && !path.includes("\\") || existsSync(path) }; } From 25084bd632eb657fec4101cf15a447f10f6aa15f Mon Sep 17 00:00:00 2001 From: Tom Dupuis <60640908+tomdps@users.noreply.github.com> Date: Wed, 15 Jul 2026 15:28:05 -0700 Subject: [PATCH 5/5] test: avoid registry audit during package install --- tests/installed-bins.test.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/installed-bins.test.mjs b/tests/installed-bins.test.mjs index 4c5617b..54247b7 100644 --- a/tests/installed-bins.test.mjs +++ b/tests/installed-bins.test.mjs @@ -31,7 +31,7 @@ describe("installed package bins", () => { const project = join(temp, "project"); mkdirSync(project); run("npm", ["init", "-y"], { cwd: project }); - run("npm", ["install", "--ignore-scripts", ...tarballs], { cwd: project }); + run("npm", ["install", "--ignore-scripts", "--no-audit", "--no-fund", ...tarballs], { cwd: project }); mkdirSync(join(project, "services", "api", "src"), { recursive: true }); writeFileSync(join(project, "pyproject.toml"), "[project]\nname='root-fixture'\nrequires-python='>=3.8'\n"); writeFileSync(join(project, "root.py"), "ROOT = 1\n");