From c70893551f1bc8ab426a91aecfff5f3e91ac968a Mon Sep 17 00:00:00 2001 From: Tom Dupuis <60640908+tomdps@users.noreply.github.com> Date: Fri, 17 Jul 2026 00:52:01 -0700 Subject: [PATCH] =?UTF-8?q?feat:=20implement=20#197=20-=20Hypothetical=20(?= =?UTF-8?q?validate=20pre-write)=20evaluation=20must=20be=20comprehensive?= =?UTF-8?q?=20=E2=80=94=20graph-backed=20checks=20are=20silently=20skipped?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 2 + CLAUDE.md | 2 + docs/architecture/runtime-cli-ard.md | 6 +- .../src/validation-composition.ts | 10 +- packages/asp-provider/src/workspace.ts | 6 +- packages/graph/README.md | 2 + packages/graph/src/ephemeral-snapshot.ts | 219 +++++++++++ packages/graph/src/index.ts | 25 ++ packages/graph/src/python-import-analysis.ts | 43 ++- .../src/advanced/validation-composition.ts | 18 +- packages/opcore/src/validation-composition.ts | 16 +- .../opcore/src/validation-graph-session.ts | 16 + packages/validation-policy/src/path-policy.ts | 9 + .../validation-python/src/import-analysis.ts | 25 ++ .../validation-python/src/source-files.ts | 14 +- .../src/import-graph-check.ts | 7 +- packages/validation/README.md | 2 + packages/validation/src/command-adapter.ts | 4 +- packages/validation/src/graph-client.ts | 101 +++++ packages/validation/src/index.ts | 11 +- packages/validation/src/node-shims.d.ts | 10 + packages/validation/src/overlays.ts | 54 ++- packages/validation/src/registry.ts | 4 + packages/validation/src/runner.ts | 173 +++++---- packages/validation/src/scope.ts | 1 + packages/validation/src/workspace.ts | 45 ++- tests/asp-provider.test.mjs | 23 +- tests/edit-cli.test.mjs | 4 +- tests/fixtures/package-packlists.json | 6 + tests/graph-ephemeral-snapshot.test.mjs | 350 ++++++++++++++++++ tests/python-import-composition.test.mjs | 34 ++ tests/validation-overlays.test.mjs | 16 + tests/validation-python.test.mjs | 42 ++- tests/validation-runner.test.mjs | 97 ++++- 34 files changed, 1259 insertions(+), 138 deletions(-) create mode 100644 packages/graph/src/ephemeral-snapshot.ts create mode 100644 packages/opcore/src/validation-graph-session.ts create mode 100644 tests/graph-ephemeral-snapshot.test.mjs diff --git a/AGENTS.md b/AGENTS.md index 473ee37..9ab3a58 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -92,6 +92,7 @@ Opcore is the code-intelligence and robustness monorepo for graph context, edit - #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. - #209 makes Rust graph-core the sole parser/resolver for Python repo imports. `@the-open-engine/opcore-graph` materializes supplied `.py`/`.pyi` after-state files only in an isolated temporary repo and returns canonical directed `IMPORTS_FROM` file edges; validation-python owns only the structural analyzer contract, visible-file enumeration, cached target/transitive closure, and edge consumption. Opcore, advanced validation, validation-policy, and ASP inject the graph adapter; missing/failed/malformed analysis is an infrastructure failure, never empty success - WHY: a second TypeScript import grammar/resolver diverges on multiline syntax, overlays, packages, stubs, namespaces, and src layouts. +- #197 makes hypothetical graph evaluation exact-state: validation creates one `ValidationFileView` per before/after state and owns one disposable graph session for that view; graph materializes the complete visible TS/TSX/JS/JSX, Python `.py`/`.pyi`, and Rust `.rs` universe into a bounded isolated root, builds graph-core once, shares the immutable session across checks, and removes it on every exit. Introduced mode must use distinct before/after snapshots, ASP listings must preserve host truncation, and exact-state construction/query failure is non-pass even when persistent graph mode is optional - WHY: a persistent target-repo graph or incomplete listing cannot describe hypothetical file contents and must never produce a false clean pre-write result. - #19 requires graph status to preserve real WAL checkpoint evidence from the latest pipeline summary and release gates to fail missing/fabricated WAL evidence - WHY: freshness and checkpoint pressure must remain host-visible provider facts. - #19 treats `opcore graph serve` as the stdio/MCP hot-query replacement, not a Unix socket, with parallel independent serve sessions as the supported concurrency evidence. - #19 keeps current external CRG receipts as non-implementation compatibility evidence only - WHY: old CRG remains a guardrail until downstream cutover issues consume the Opcore proof. @@ -126,6 +127,7 @@ Opcore is the code-intelligence and robustness monorepo for graph context, edit - ALWAYS emit boolean `attributes.exported` on supported TS/JS graph symbol nodes and file-node `attributes.exports[]` for default/re-export/barrel forms - WHY: dead-export metrics must distinguish unsupported export coverage from zero dead exports. - ALWAYS make validation checks read file content through `ValidationCheckContext.fileView` - WHY: validation overlays are hypothetical and checks must see the same before/after file state without mutating disk. - ALWAYS make validation checks consume GraphProvider facts through `ValidationCheckContext.graph` and injected `ValidationGraphProviderClient` sessions - WHY: validation must depend only on public contracts, not graph package internals, CLI execution, or store layouts. +- ALWAYS bind every hypothetical graph-backed check to the disposable exact-state graph session owned by its `ValidationFileView`, and share one session per state across all selected checks - WHY: request-scoped or persistent sessions make graph facts disagree with the contents read by the check; optional graph policy must not hide exact-state failures. - ALWAYS make Python import-dependent validation consume an injected `PythonImportAnalyzer` backed by graph-core and derive closure from its directed after-state edges - WHY: validation-owned parsing or module candidates create a second semantic owner and cannot represent overlays consistently. - ALWAYS preserve validation diagnostic source ranges, tool provenance, and fine-grained check outcomes across contracts, manifests, sorting/fingerprints, reporting, and ASP mapping when an adapter supplies them - WHY: transport layers must not collapse actionable compiler evidence or turn tool failures into clean passes. - Native repo validation policy lives under `.opcore/config` `validation`; active checks, thresholds, path policy, docs policy, clone policy, TypeScript policy, Rust command gates, and check packs must be parsed and composed through `packages/validation-policy`. diff --git a/CLAUDE.md b/CLAUDE.md index 473ee37..9ab3a58 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -92,6 +92,7 @@ Opcore is the code-intelligence and robustness monorepo for graph context, edit - #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. - #209 makes Rust graph-core the sole parser/resolver for Python repo imports. `@the-open-engine/opcore-graph` materializes supplied `.py`/`.pyi` after-state files only in an isolated temporary repo and returns canonical directed `IMPORTS_FROM` file edges; validation-python owns only the structural analyzer contract, visible-file enumeration, cached target/transitive closure, and edge consumption. Opcore, advanced validation, validation-policy, and ASP inject the graph adapter; missing/failed/malformed analysis is an infrastructure failure, never empty success - WHY: a second TypeScript import grammar/resolver diverges on multiline syntax, overlays, packages, stubs, namespaces, and src layouts. +- #197 makes hypothetical graph evaluation exact-state: validation creates one `ValidationFileView` per before/after state and owns one disposable graph session for that view; graph materializes the complete visible TS/TSX/JS/JSX, Python `.py`/`.pyi`, and Rust `.rs` universe into a bounded isolated root, builds graph-core once, shares the immutable session across checks, and removes it on every exit. Introduced mode must use distinct before/after snapshots, ASP listings must preserve host truncation, and exact-state construction/query failure is non-pass even when persistent graph mode is optional - WHY: a persistent target-repo graph or incomplete listing cannot describe hypothetical file contents and must never produce a false clean pre-write result. - #19 requires graph status to preserve real WAL checkpoint evidence from the latest pipeline summary and release gates to fail missing/fabricated WAL evidence - WHY: freshness and checkpoint pressure must remain host-visible provider facts. - #19 treats `opcore graph serve` as the stdio/MCP hot-query replacement, not a Unix socket, with parallel independent serve sessions as the supported concurrency evidence. - #19 keeps current external CRG receipts as non-implementation compatibility evidence only - WHY: old CRG remains a guardrail until downstream cutover issues consume the Opcore proof. @@ -126,6 +127,7 @@ Opcore is the code-intelligence and robustness monorepo for graph context, edit - ALWAYS emit boolean `attributes.exported` on supported TS/JS graph symbol nodes and file-node `attributes.exports[]` for default/re-export/barrel forms - WHY: dead-export metrics must distinguish unsupported export coverage from zero dead exports. - ALWAYS make validation checks read file content through `ValidationCheckContext.fileView` - WHY: validation overlays are hypothetical and checks must see the same before/after file state without mutating disk. - ALWAYS make validation checks consume GraphProvider facts through `ValidationCheckContext.graph` and injected `ValidationGraphProviderClient` sessions - WHY: validation must depend only on public contracts, not graph package internals, CLI execution, or store layouts. +- ALWAYS bind every hypothetical graph-backed check to the disposable exact-state graph session owned by its `ValidationFileView`, and share one session per state across all selected checks - WHY: request-scoped or persistent sessions make graph facts disagree with the contents read by the check; optional graph policy must not hide exact-state failures. - ALWAYS make Python import-dependent validation consume an injected `PythonImportAnalyzer` backed by graph-core and derive closure from its directed after-state edges - WHY: validation-owned parsing or module candidates create a second semantic owner and cannot represent overlays consistently. - ALWAYS preserve validation diagnostic source ranges, tool provenance, and fine-grained check outcomes across contracts, manifests, sorting/fingerprints, reporting, and ASP mapping when an adapter supplies them - WHY: transport layers must not collapse actionable compiler evidence or turn tool failures into clean passes. - Native repo validation policy lives under `.opcore/config` `validation`; active checks, thresholds, path policy, docs policy, clone policy, TypeScript policy, Rust command gates, and check packs must be parsed and composed through `packages/validation-policy`. diff --git a/docs/architecture/runtime-cli-ard.md b/docs/architecture/runtime-cli-ard.md index fc2b7e0..7daad30 100644 --- a/docs/architecture/runtime-cli-ard.md +++ b/docs/architecture/runtime-cli-ard.md @@ -58,6 +58,8 @@ Canonical `opcore graph`, `opcore edit`, `opcore check`, and `opcore validate` c #58 adds `opcore validate pre-write --request-file --timeout-ms 30000 --json` for hook integration. The route is validation-owned, file-based, fail-closed, overlay-only, and emits a typed `PreWriteValidationReceipt` with timing, repo, scope, checks, graph, overlay, status, and failure summary data. +#197 makes hypothetical graph-backed validation state-exact. `packages/validation` creates the before/after `ValidationFileView`, acquires one disposable exact graph session for each state, shares that immutable session across every selected check and streaming/fail-fast path, and disposes it on every exit. `reportMode:"introduced"` uses distinct before and after snapshots. `packages/graph` owns bounded temporary source materialization, one graph-core build, bound queries, and recursive cleanup; product, advanced, and ASP composition inject that implementation through validation-owned types. Exact materialization, build, listing, or query failures are non-pass even under optional persistent-graph policy. ASP continues to obtain all source content through host callbacks and preserves `workspace/listTree.truncated`. The target source tree, persistent `.opcore/graph`, configuration, lockfiles, environments, and caches remain untouched. + #30 adds `ReleaseCutoverReceipt` and `npm run cutover:check` as the installed-artifact cutover proof. The gate packs the public release packages, installs them into a clean temporary project, clears current-tool environment resolution, excludes local wrapper/sibling paths, verifies installed canonical bins (`opcore` and `opcore-asp-provider`), binds every command receipt id to its expected canonical command/status/exit, and fails on old-tool/private-path markers or advertised `not_implemented` release commands. #120 adds `AspDogfoodReceipt` and `npm run asp-dogfood:check` as advisory/shadow evidence that the independent ASP manager can install/enroll Opcore as a Core check provider and record host-owned decisions separately from provider assessments. The receipt uses temporary `ASP_HOME` state, records `opcore-asp-provider --stdio` manifest/bin evidence, co-records retained current-tool guardrails, and keeps `oldToolReplacementClaimed: false`; it is not a cutover, host authority claim, public standard-readiness claim, or `opcore asp` route. @@ -72,9 +74,9 @@ There is no public `opcore asp` router group in this release. ASP Core check int |---|---| | `packages/contracts` | Public wire contracts, schemas, command payloads, adapter request/result envelopes, validation shapes, graph query contracts, and generated TypeScript types. | | `crates/graph-core` | Planned Rust graph core for source extraction, parser integration, SQLite/WAL persistence, freshness metadata, watch daemon state, hot query execution, and clone index analysis through the existing native binary. | -| `packages/graph` | npm facade/package track for graph commands, public graph command adapter, native graph-core loading, and graph JSON output. It must not duplicate graph-core internals or depend on the aggregate CLI. | +| `packages/graph` | npm facade/package track for graph commands, public graph command adapter, native graph-core loading, graph JSON output, and bounded disposable exact-state source snapshots. It must not duplicate graph-core internals or depend on the aggregate CLI. | | `packages/edit` | Edit planner, public edit command adapter, patch/tree edits, symbol-aware orchestration, graph-backed discovery integration, and whole-plan validation. It consumes contracts and graph queries, not graph internals or the aggregate CLI. | -| `packages/validation` | Mechanical checks, public check/validate command adapters, validation manifests, failure policy, hypothetical validation, graph-aware rule orchestration, and `check`/`validate` reporting. It consumes contracts and adapters, not edit internals or the aggregate CLI. | +| `packages/validation` | Mechanical checks, public check/validate command adapters, validation manifests, failure policy, hypothetical validation, file-view-state graph-session lifecycle, graph-aware rule orchestration, and `check`/`validate` reporting. It consumes contracts and injected graph adapters, not graph internals, edit internals, or the aggregate CLI. | | `packages/validation-rust` | Rust validation adapter rules, temporary workspace materialization from validation file views, and Cargo/native-tool-backed checks exposed to validation through contracts. It does not own host decisions or current external guardrail replacement policy. | | `packages/validation-typescript` | TypeScript adapter rules, compiler-backed checks, and TypeScript graph-aware validations exposed to validation through contracts. | | `packages/validation-clone` | Duplicate-code validation adapter rules over `ValidationCheckContext.fileView`, with an injected native clone invoker supplied by CLI composition. It does not own graph-provider status, daemon lifecycle, host decisions, SAST/security policy, or scoring. | diff --git a/packages/asp-provider/src/validation-composition.ts b/packages/asp-provider/src/validation-composition.ts index f3382a8..b9cc202 100644 --- a/packages/asp-provider/src/validation-composition.ts +++ b/packages/asp-provider/src/validation-composition.ts @@ -23,6 +23,8 @@ import { GRAPH_SCHEMA_VERSION, validateProviderStatus } from "@the-open-engine/o import { createValidationCheckManifest, createValidationRunner, + createStateAwareValidationGraphSessionFactory, + createValidationExactGraphSnapshotFactory, type ValidationCheckDefinition, type ValidationGraphProviderClient, type ValidationWorkspace @@ -38,6 +40,7 @@ import { graphProviderQuery, graphProviderReviewContext, graphProviderStatus, + createEphemeralGraphSnapshot, graphPythonImportAnalyzer } from "@the-open-engine/opcore-graph"; import type { PythonProjectWorkspace } from "@the-open-engine/opcore-validation-python"; @@ -55,10 +58,15 @@ export function createAspProviderValidationRunner(workspace: ValidationWorkspace return { async runValidation(request) { const config = await readHostConfig(workspace, request); + const graphProviderClient = createAspValidationGraphProviderClient(); return createValidationRunner({ workspace, checks: createBuiltInValidationChecks(config, { ...aspProviderPolicyOptions, pythonWorkspace }), - graphProviderClient: createAspValidationGraphProviderClient() + graphProviderClient, + graphSessionFactory: createStateAwareValidationGraphSessionFactory({ + persistentClient: graphProviderClient, + exactSnapshotFactory: createValidationExactGraphSnapshotFactory(createEphemeralGraphSnapshot) + }) }).runValidation(request); } }; diff --git a/packages/asp-provider/src/workspace.ts b/packages/asp-provider/src/workspace.ts index 7b0c5d1..cf87e8a 100644 --- a/packages/asp-provider/src/workspace.ts +++ b/packages/asp-provider/src/workspace.ts @@ -31,6 +31,7 @@ export function createAspHostValidationWorkspace( const entryByPath = new Map(); const blobTextById = new Map(); const readSet = new Set(); + let fullTreeTruncated = false; async function listTree(paths?: readonly string[]): Promise { const params: JsonObject = { @@ -43,6 +44,7 @@ export function createAspHostValidationWorkspace( truncated?: boolean; }; const entries = (result.entries ?? []).map(normalizeTreeEntry).filter((entry): entry is TreeEntry => entry !== undefined); + if (paths === undefined) fullTreeTruncated = result.truncated === true; for (const entry of entries) entryByPath.set(entry.path, entry); return entries; } @@ -74,7 +76,8 @@ export function createAspHostValidationWorkspace( async function listRepoFiles(): Promise { const entries = await listTree(); return { - files: entries.map((entry) => ({ path: entry.path, status: "unchanged" as const })) + files: entries.map((entry) => ({ path: entry.path, status: "unchanged" as const })), + ...(fullTreeTruncated ? { truncated: true, message: "Host workspace/listTree returned a truncated file universe" } : {}) }; } @@ -85,6 +88,7 @@ export function createAspHostValidationWorkspace( listPackageFiles: async (_packageName, packageRoot) => { const files = await listRepoFiles(); return { + ...files, files: files.files.filter((file) => { const path = typeof file === "string" ? file : file.path; return path === packageRoot || path.startsWith(`${packageRoot}/`); diff --git a/packages/graph/README.md b/packages/graph/README.md index 47f00c6..91ceead 100644 --- a/packages/graph/README.md +++ b/packages/graph/README.md @@ -3,3 +3,5 @@ Opcore graph provider package for repository graph extraction, query, search, and impact surfaces. The package also owns canonical Python repo-import analysis. `analyzePythonImports` accepts only supplied `.py`/`.pyi` after-state files, materializes them in an isolated temporary repository, runs the installed graph-core build/query path, returns sorted directed `IMPORTS_FROM` file edges, and always removes temporary state. It never reads or writes the target worktree. + +`createEphemeralGraphSnapshot` materializes a complete, bounded validation-visible source universe under an isolated root, builds graph-core once, binds query metadata to the logical target repository, and provides idempotent recursive disposal. Incomplete/truncated listings and file, depth, byte, build, or query failures are loud; target sources and persistent graph artifacts are never mutated. diff --git a/packages/graph/src/ephemeral-snapshot.ts b/packages/graph/src/ephemeral-snapshot.ts new file mode 100644 index 0000000..0e284f5 --- /dev/null +++ b/packages/graph/src/ephemeral-snapshot.ts @@ -0,0 +1,219 @@ +import type { + GraphDetectChangesRequest, + GraphDetectChangesResult, + GraphFactQueryRequest, + GraphFactQueryResult, + GraphImpactRequest, + GraphImpactResult, + GraphNamedQueryRequest, + GraphNamedQueryResult, + GraphPipelineResult, + GraphProviderMode, + GraphProviderStatus, + GraphReviewContextRequest, + GraphReviewContextResult, + RepoIdentity +} from "@the-open-engine/opcore-contracts"; +import { validateRepoRelativePath } from "@the-open-engine/opcore-contracts"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; + +export interface EphemeralGraphSourceUniverse { + paths: readonly string[]; + complete: boolean; + message?: string; +} + +export interface EphemeralGraphSourceReadResult { + status: "found" | "missing" | "deleted"; + content?: string; +} + +export interface EphemeralGraphSnapshotLimits { + maxFiles?: number; + maxDepth?: number; + maxBytes?: number; +} + +export interface CreateEphemeralGraphSnapshotOptions { + logicalRepo: RepoIdentity; + sourceUniverse: EphemeralGraphSourceUniverse; + readFile: (path: string) => EphemeralGraphSourceReadResult | Promise; + limits?: EphemeralGraphSnapshotLimits; +} + +export interface EphemeralGraphOperations { + build(repo: RepoIdentity): GraphPipelineResult; + factQuery(repo: RepoIdentity, request: GraphFactQueryRequest): GraphFactQueryResult; + namedQuery?(repo: RepoIdentity, request: GraphNamedQueryRequest): GraphNamedQueryResult; + impact?(repo: RepoIdentity, request: GraphImpactRequest): GraphImpactResult; + reviewContext?(repo: RepoIdentity, request: GraphReviewContextRequest): GraphReviewContextResult; + detectChanges?(repo: RepoIdentity, request: GraphDetectChangesRequest): GraphDetectChangesResult; +} + +export interface EphemeralGraphSnapshot { + readonly logicalRepo: RepoIdentity; + readonly materializedPaths: readonly string[]; + status(mode: GraphProviderMode): GraphProviderStatus; + factQuery(request: GraphFactQueryRequest): GraphFactQueryResult; + namedQuery(request: GraphNamedQueryRequest): GraphNamedQueryResult; + impact(request: GraphImpactRequest): GraphImpactResult; + reviewContext(request: GraphReviewContextRequest): GraphReviewContextResult; + detectChanges(request: GraphDetectChangesRequest): GraphDetectChangesResult; + dispose(): void; +} + +const defaultLimits: Required = { + maxFiles: 50_000, + maxDepth: 64, + maxBytes: 256 * 1024 * 1024 +}; + +export async function createEphemeralGraphSnapshotWithOperations( + options: CreateEphemeralGraphSnapshotOptions, + operations: EphemeralGraphOperations +): Promise { + const paths = normalizeUniverse(options.sourceUniverse); + const limits = normalizeLimits(options.limits); + const sourcePaths = paths.filter(isSupportedGraphSourcePath); + enforcePathLimits(sourcePaths, limits); + const materialized = await materializeSnapshotSources(sourcePaths, options.readFile, limits); + let disposed = false; + try { + const build = operations.build(materialized.repo); + if (build.status.state !== "available") throw snapshotOperationFailure("build", build.status); + return bindSnapshot(options.logicalRepo, materialized, build.status, operations, () => { + if (disposed) return; + disposed = true; + rmSync(materialized.tempRoot, { recursive: true, force: true }); + }); + } catch (error) { + rmSync(materialized.tempRoot, { recursive: true, force: true }); + throw error; + } +} + +interface MaterializedSnapshotSources { + tempRoot: string; + repo: RepoIdentity; + paths: readonly string[]; +} + +async function materializeSnapshotSources( + paths: readonly string[], + readFile: CreateEphemeralGraphSnapshotOptions["readFile"], + limits: Required +): Promise { + const tempRoot = mkdtempSync(join(tmpdir(), "opcore-graph-snapshot-")); + const repoRoot = join(tempRoot, "repo"); + try { + mkdirSync(repoRoot, { recursive: true }); + let byteCount = 0; + const materializedPaths: string[] = []; + for (const path of paths) { + const source = await readFile(path); + if (source.status === "missing" || source.status === "deleted") continue; + if (source.status !== "found" || typeof source.content !== "string") { + throw new Error(`Ephemeral graph source reader returned an invalid result for ${path}`); + } + byteCount += new TextEncoder().encode(source.content).byteLength; + if (byteCount > limits.maxBytes) { + throw new Error(`Ephemeral graph snapshot exceeds maxBytes (${limits.maxBytes})`); + } + writeSourceFile(repoRoot, path, source.content); + materializedPaths.push(path); + } + return { tempRoot, repo: { repoRoot }, paths: materializedPaths }; + } catch (error) { + rmSync(tempRoot, { recursive: true, force: true }); + throw error; + } +} + +function bindSnapshot( + logicalRepo: RepoIdentity, + materialized: MaterializedSnapshotSources, + status: GraphProviderStatus, + operations: EphemeralGraphOperations, + dispose: () => void +): EphemeralGraphSnapshot { + const { repo } = materialized; + return { + logicalRepo, + materializedPaths: materialized.paths, + status: (mode) => bindStatus(status, mode, logicalRepo), + factQuery: (request) => bindResult(operations.factQuery(repo, request), request, logicalRepo), + namedQuery: (request) => bindResult(requireOperation(operations.namedQuery, "namedQuery")(repo, request), request, logicalRepo), + impact: (request) => bindResult(requireOperation(operations.impact, "impact")(repo, request), request, logicalRepo), + reviewContext: (request) => bindResult(requireOperation(operations.reviewContext, "reviewContext")(repo, request), request, logicalRepo), + detectChanges: (request) => bindResult(requireOperation(operations.detectChanges, "detectChanges")(repo, request), request, logicalRepo), + dispose + }; +} + +function normalizeUniverse(universe: EphemeralGraphSourceUniverse): readonly string[] { + if (!universe || !Array.isArray(universe.paths)) throw new Error("Ephemeral graph source universe is invalid"); + if (!universe.complete) throw new Error(universe.message ?? "Ephemeral graph source universe is incomplete"); + const seen = new Set(); + for (const candidate of universe.paths) { + const path = validateRepoRelativePath(candidate); + if (seen.has(path)) throw new Error(`Ephemeral graph source universe contains duplicate path: ${path}`); + seen.add(path); + } + return [...seen].sort(); +} + +function normalizeLimits(limits: EphemeralGraphSnapshotLimits | undefined): Required { + const normalized = { ...defaultLimits, ...limits }; + for (const [name, value] of Object.entries(normalized)) { + if (!Number.isSafeInteger(value) || value <= 0) throw new Error(`Ephemeral graph snapshot ${name} must be a positive integer`); + } + return normalized; +} + +function enforcePathLimits(paths: readonly string[], limits: Required): void { + if (paths.length > limits.maxFiles) throw new Error(`Ephemeral graph snapshot exceeds maxFiles (${limits.maxFiles})`); + const tooDeep = paths.find((path) => path.split("/").length > limits.maxDepth); + if (tooDeep !== undefined) throw new Error(`Ephemeral graph snapshot path exceeds maxDepth (${limits.maxDepth}): ${tooDeep}`); +} + +function writeSourceFile(repoRoot: string, path: string, content: string): void { + const absolutePath = resolve(repoRoot, validateRepoRelativePath(path)); + if (absolutePath !== repoRoot && !absolutePath.startsWith(`${repoRoot}/`)) { + throw new Error(`Ephemeral graph source path escapes snapshot root: ${path}`); + } + mkdirSync(dirname(absolutePath), { recursive: true }); + writeFileSync(absolutePath, content); +} + +function isSupportedGraphSourcePath(path: string): boolean { + return /\.(?:tsx?|[cm]ts|jsx?|pyi?|rs)$/u.test(path); +} + +function bindStatus(status: GraphProviderStatus, mode: GraphProviderMode, logicalRepo: RepoIdentity): GraphProviderStatus { + return { ...status, mode, ...(status.state === "available" || status.state === "stale" ? { repo: logicalRepo } : {}) } as GraphProviderStatus; +} + +function bindResult( + result: Result, + request: { requestId?: string; mode: GraphProviderMode }, + logicalRepo: RepoIdentity +): Result { + return { + ...result, + requestId: request.requestId, + status: bindStatus(result.status, request.mode, logicalRepo), + ...(result.metadata === undefined ? {} : { metadata: { ...result.metadata, repo: logicalRepo } }) + } as Result; +} + +function snapshotOperationFailure(operation: string, status: GraphProviderStatus): Error { + const message = "failure" in status ? status.failure.message : status.message; + return new Error(`Ephemeral graph snapshot ${operation} failed (${status.state}): ${message ?? "no failure message"}`); +} + +function requireOperation(operation: Operation | undefined, name: string): Operation { + if (operation === undefined) throw new Error(`Ephemeral graph snapshot operation is unavailable: ${name}`); + return operation; +} diff --git a/packages/graph/src/index.ts b/packages/graph/src/index.ts index dca0cb3..5cba8c8 100644 --- a/packages/graph/src/index.ts +++ b/packages/graph/src/index.ts @@ -27,6 +27,11 @@ import { } from "./python-import-analysis.js"; import { graphServeRouterResult, isServeTransportArgv, runGraphServeCli } from "./serve.js"; import { invokeGraphCoreSidecar } from "./sidecar.js"; +import { + createEphemeralGraphSnapshotWithOperations, + type CreateEphemeralGraphSnapshotOptions, + type EphemeralGraphSnapshot +} from "./ephemeral-snapshot.js"; declare const process: { argv: string[]; @@ -47,6 +52,13 @@ export { invokeGraphCoreSidecar } from "./sidecar.js"; export { graphServeRouterResult, isServeTransportArgv, runGraphServeCli } from "./serve.js"; export type { GraphServeFrameTimingEvent, GraphServeTelemetry } from "./serve.js"; export type { PythonImportAnalysisEdge, PythonImportAnalysisFile } from "./python-import-analysis.js"; +export type { + CreateEphemeralGraphSnapshotOptions, + EphemeralGraphSnapshot, + EphemeralGraphSnapshotLimits, + EphemeralGraphSourceReadResult, + EphemeralGraphSourceUniverse +} from "./ephemeral-snapshot.js"; export const graphProviderName = "opcore-graph"; export const graphProviderSchemaVersion = 1; @@ -62,6 +74,19 @@ export function analyzePythonImports( export const graphPythonImportAnalyzer = { analyze: analyzePythonImports } as const; +export function createEphemeralGraphSnapshot( + options: CreateEphemeralGraphSnapshotOptions +): Promise { + return createEphemeralGraphSnapshotWithOperations(options, { + build: (repo) => graphProviderBuild(repo), + factQuery: (repo, request) => graphProviderQuery(repo, request.selector), + namedQuery: (repo, request) => graphProviderNamedQuery(repo, request), + impact: (repo, request) => graphProviderImpact(repo, request), + reviewContext: (repo, request) => graphProviderReviewContext(repo, request), + detectChanges: (repo, request) => graphProviderDetectChanges(repo, request) + }); +} + export function graphProviderStatus( repo: RepoIdentity | string = { repoRoot: process.cwd() }, options: { paths?: readonly string[]; watchPaths?: readonly string[] } = {} diff --git a/packages/graph/src/python-import-analysis.ts b/packages/graph/src/python-import-analysis.ts index 0aaf643..cc029e4 100644 --- a/packages/graph/src/python-import-analysis.ts +++ b/packages/graph/src/python-import-analysis.ts @@ -1,8 +1,6 @@ import type { GraphFactEdge, GraphFactQueryResult, GraphPipelineResult, RepoIdentity } from "@the-open-engine/opcore-contracts"; import { validateRepoRelativePath } from "@the-open-engine/opcore-contracts"; -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { dirname, join, resolve } from "node:path"; +import { createEphemeralGraphSnapshotWithOperations } from "./ephemeral-snapshot.js"; export interface PythonImportAnalysisFile { path: string; @@ -24,19 +22,30 @@ export async function analyzePythonImportsWithGraph( graph: PythonImportAnalysisGraph ): Promise { const normalizedFiles = normalizeSuppliedFiles(files); - const tempRoot = mkdtempSync(join(tmpdir(), "opcore-python-import-analysis-")); - const repoRoot = join(tempRoot, "repo"); + const fileByPath = new Map(normalizedFiles.map((file) => [file.path, file])); + const snapshot = await createEphemeralGraphSnapshotWithOperations({ + logicalRepo: { repoRoot: "." }, + sourceUniverse: { paths: normalizedFiles.map((file) => file.path), complete: true }, + readFile: (path) => { + const file = fileByPath.get(path); + return file === undefined ? { status: "missing" } : { status: "found", content: file.content }; + } + }, { + build: graph.build, + factQuery: (repo) => graph.query(repo) + }); try { - mkdirSync(repoRoot, { recursive: true }); - for (const file of normalizedFiles) writeSuppliedFile(repoRoot, file); - const repo = { repoRoot }; - const build = graph.build(repo); - if (build.status.state !== "available") throw graphAnalysisFailure("build", build.status); - const query = graph.query(repo); + const query = snapshot.factQuery({ + requestId: "python-import-analysis", + repo: { repoRoot: "." }, + schemaVersion: 1, + mode: "required", + selector: { kind: "edges", edgeKinds: ["IMPORTS_FROM"] } + }); if (query.status.state !== "available" || !("edges" in query)) throw graphAnalysisFailure("query", query.status); return importEdges(query.edges, new Set(normalizedFiles.map((file) => file.path))); } finally { - rmSync(tempRoot, { recursive: true, force: true }); + snapshot.dispose(); } } @@ -58,16 +67,6 @@ function normalizeSuppliedFiles(files: readonly PythonImportAnalysisFile[]): rea return [...byPath.values()].sort((left, right) => left.path.localeCompare(right.path)); } -function writeSuppliedFile(repoRoot: string, file: PythonImportAnalysisFile): void { - const absolutePath = resolveRepoPath(repoRoot, file.path); - mkdirSync(dirname(absolutePath), { recursive: true }); - writeFileSync(absolutePath, file.content); -} - -function resolveRepoPath(repoRoot: string, path: string): string { - return resolve(repoRoot, validateRepoRelativePath(path)); -} - function importEdges( edges: readonly GraphFactEdge[], suppliedPaths: ReadonlySet diff --git a/packages/opcore/src/advanced/validation-composition.ts b/packages/opcore/src/advanced/validation-composition.ts index 5be63ae..78d1089 100644 --- a/packages/opcore/src/advanced/validation-composition.ts +++ b/packages/opcore/src/advanced/validation-composition.ts @@ -13,7 +13,8 @@ import { createValidationRunner, createValidationStatusPayload, type ValidationCommandAdapterOptions, - type ValidationGraphProviderClient + type ValidationGraphProviderClient, + type ValidationGraphSessionFactory } from "@the-open-engine/opcore-validation"; import { createPythonValidationAdapterStatus, createPythonValidationChecks } from "@the-open-engine/opcore-validation-python"; import { createRustValidationAdapterStatus, createRustValidationChecks } from "@the-open-engine/opcore-validation-rust"; @@ -30,6 +31,7 @@ import { validationChecksForRepoPolicy } from "../repo-validation-policy.js"; import { commonSkippedPathSegments } from "../source-policy.js"; +import { createOpcoreGraphSessionFactory } from "../validation-graph-session.js"; export const checkCommandAdapter = createCliCheckCommandAdapter(); export const validateCommandAdapter = createCliValidateCommandAdapter(); @@ -55,13 +57,15 @@ export function createCliValidateCommandAdapter( export const editValidationRunner = { runValidation(request: ValidationRequest): Promise { const repoRoot = request.repo.repoRoot ?? process.cwd(); + const graphProviderClient = createCliValidationGraphProviderClient(); return createValidationRunner({ workspace: createNodeValidationWorkspace({ repoRoot, skippedPathSegments: commonSkippedPathSegments }), checks: validationChecksForRepoPolicy(repoRoot), - graphProviderClient: createCliValidationGraphProviderClient() + graphProviderClient, + graphSessionFactory: createCliValidationGraphSessionFactory(graphProviderClient) }).runValidation(request); } }; @@ -88,9 +92,11 @@ export function createDefaultValidationStatusPayload(options: { } function defaultValidationAdapterOptions(repoRoot = process.cwd()): ValidationCommandAdapterOptions { + const graphProviderClient = createCliValidationGraphProviderClient(); return { checksFactory: validationChecksForRepoPolicy, - graphProviderClient: createCliValidationGraphProviderClient(), + graphProviderClient, + graphSessionFactory: createCliValidationGraphSessionFactory(graphProviderClient), defaultRepoRoot: repoRoot, workspaceFactory: (repoRoot) => createNodeValidationWorkspace({ @@ -100,6 +106,12 @@ function defaultValidationAdapterOptions(repoRoot = process.cwd()): ValidationCo }; } +export function createCliValidationGraphSessionFactory( + persistentClient: ValidationGraphProviderClient = createCliValidationGraphProviderClient() +): ValidationGraphSessionFactory { + return createOpcoreGraphSessionFactory(persistentClient); +} + function createCliValidationGraphProviderClient(): ValidationGraphProviderClient { return { status: (request) => cliGraphStatus(request.repo, request.graph.mode), diff --git a/packages/opcore/src/validation-composition.ts b/packages/opcore/src/validation-composition.ts index 6607d43..31f000d 100644 --- a/packages/opcore/src/validation-composition.ts +++ b/packages/opcore/src/validation-composition.ts @@ -13,6 +13,7 @@ import { createValidationStatusPayload, type ValidationCommandAdapterOptions, type ValidationGraphProviderClient, + type ValidationGraphSessionFactory, type ValidationRuntimePolicy } from "@the-open-engine/opcore-validation"; import { createPythonValidationAdapterStatus, createPythonValidationChecks } from "@the-open-engine/opcore-validation-python"; @@ -30,6 +31,7 @@ import { validationChecksForRepoPolicy } from "./repo-validation-policy.js"; import { commonSkippedPathSegments } from "./source-policy.js"; +import { createOpcoreGraphSessionFactory } from "./validation-graph-session.js"; declare const process: { cwd(): string; @@ -53,13 +55,15 @@ export function createOpcoreCheckCommandAdapter( export const opcoreValidationRunner = { runValidation(request: ValidationRequest): Promise { const repoRoot = request.repo.repoRoot ?? process.cwd(); + const graphProviderClient = createOpcoreValidationGraphProviderClient(); return createValidationRunner({ workspace: createNodeValidationWorkspace({ repoRoot, skippedPathSegments: commonSkippedPathSegments }), checks: validationChecksForRepoPolicy(repoRoot), - graphProviderClient: createOpcoreValidationGraphProviderClient(), + graphProviderClient, + graphSessionFactory: createOpcoreValidationGraphSessionFactory(graphProviderClient), runtime: opcorePublicValidationRuntimePolicy }).runValidation(request); } @@ -86,9 +90,11 @@ export function createDefaultValidationStatusPayload(options: { } function defaultValidationAdapterOptions(repoRoot = process.cwd()): ValidationCommandAdapterOptions { + const graphProviderClient = createOpcoreValidationGraphProviderClient(); return { checksFactory: validationChecksForRepoPolicy, - graphProviderClient: createOpcoreValidationGraphProviderClient(), + graphProviderClient, + graphSessionFactory: createOpcoreValidationGraphSessionFactory(graphProviderClient), runtime: opcorePublicValidationRuntimePolicy, defaultRepoRoot: repoRoot, workspaceFactory: (repoRoot) => @@ -99,6 +105,12 @@ function defaultValidationAdapterOptions(repoRoot = process.cwd()): ValidationCo }; } +export function createOpcoreValidationGraphSessionFactory( + persistentClient: ValidationGraphProviderClient = createOpcoreValidationGraphProviderClient() +): ValidationGraphSessionFactory { + return createOpcoreGraphSessionFactory(persistentClient); +} + export function createOpcoreValidationGraphProviderClient(): ValidationGraphProviderClient { return { status: (request) => opcoreGraphStatus(request.repo, request.graph.mode), diff --git a/packages/opcore/src/validation-graph-session.ts b/packages/opcore/src/validation-graph-session.ts new file mode 100644 index 0000000..69277ac --- /dev/null +++ b/packages/opcore/src/validation-graph-session.ts @@ -0,0 +1,16 @@ +import { createEphemeralGraphSnapshot } from "@the-open-engine/opcore-graph"; +import { + createStateAwareValidationGraphSessionFactory, + createValidationExactGraphSnapshotFactory, + type ValidationGraphProviderClient, + type ValidationGraphSessionFactory +} from "@the-open-engine/opcore-validation"; + +export function createOpcoreGraphSessionFactory( + persistentClient: ValidationGraphProviderClient +): ValidationGraphSessionFactory { + return createStateAwareValidationGraphSessionFactory({ + persistentClient, + exactSnapshotFactory: createValidationExactGraphSnapshotFactory(createEphemeralGraphSnapshot) + }); +} diff --git a/packages/validation-policy/src/path-policy.ts b/packages/validation-policy/src/path-policy.ts index 2703ae6..fa0a8ce 100644 --- a/packages/validation-policy/src/path-policy.ts +++ b/packages/validation-policy/src/path-policy.ts @@ -23,10 +23,19 @@ export function withFilteredFileView paths.filter((path) => pathPolicyIncludes(path, policy))); return filteredVisibleFilesPromise; }; + const listVisibleFileUniverse = async () => { + const universe = await fileView.listVisibleFileUniverse(); + return { ...universe, files: universe.files.filter((path) => pathPolicyIncludes(path, policy)) }; + }; const filteredFileView: ValidationFileView = { ...fileView, scopeFiles: fileView.scopeFiles.filter((path) => pathPolicyIncludes(path, policy)), listVisibleFiles, + listVisibleFileUniverse, + listCompleteVisibleFiles: async () => { + await fileView.listCompleteVisibleFiles(); + return (await listVisibleFileUniverse()).files; + }, overlays: filteredOverlays, readFile: (path, options) => pathPolicyIncludes(path, policy) ? fileView.readFile(path, options) : Promise.resolve(missingRead(path, options?.state ?? "after")), diff --git a/packages/validation-python/src/import-analysis.ts b/packages/validation-python/src/import-analysis.ts index 9b3d61c..ee00fab 100644 --- a/packages/validation-python/src/import-analysis.ts +++ b/packages/validation-python/src/import-analysis.ts @@ -10,6 +10,11 @@ export interface PythonImportEdge { toPath: string; } +export interface GraphImportEdge { + from: string; + to: string; +} + export interface PythonImportAnalyzer { analyze(files: readonly PythonImportSourceFile[]): Promise; } @@ -46,6 +51,26 @@ export function validatePythonImportEdges( return [...byKey.values()].sort(comparePythonImportEdges); } +export function pythonImportEdgesFromGraph( + edges: readonly GraphImportEdge[], + pythonPaths: ReadonlySet +): readonly PythonImportEdge[] { + const imports: PythonImportEdge[] = []; + for (const edge of edges) { + const fromPath = graphFileEndpointPath(edge.from); + const toPath = graphFileEndpointPath(edge.to); + if (fromPath !== undefined && toPath !== undefined && pythonPaths.has(fromPath) && pythonPaths.has(toPath)) { + imports.push({ fromPath, toPath }); + } + } + return imports; +} + +function graphFileEndpointPath(endpoint: string): string | undefined { + if (!endpoint.startsWith("file:")) return undefined; + return normalizeValidationFileViewPath(endpoint.slice("file:".length)); +} + function comparePythonImportEdges(left: PythonImportEdge, right: PythonImportEdge): number { return `${left.fromPath}\0${left.toPath}`.localeCompare(`${right.fromPath}\0${right.toPath}`); } diff --git a/packages/validation-python/src/source-files.ts b/packages/validation-python/src/source-files.ts index 344be36..0bcece9 100644 --- a/packages/validation-python/src/source-files.ts +++ b/packages/validation-python/src/source-files.ts @@ -2,6 +2,7 @@ import type { PythonProjectContext } from "@the-open-engine/opcore-contracts"; import type { ValidationCheckContext, ValidationCheckResult, ValidationFileView } from "@the-open-engine/opcore-validation"; import { normalizeValidationFileViewPath } from "@the-open-engine/opcore-validation"; import { + pythonImportEdgesFromGraph, requirePythonImportAnalyzer, validatePythonImportEdges, type PythonImportAnalyzer, @@ -173,13 +174,16 @@ async function materializePythonSourcesUncached( const rootPaths = pythonInputSet(context).filter((path) => allSourceByPath.has(path)); if (rootPaths.length === 0) return emptySourceSet(); - const analyzer = requirePythonImportAnalyzer(importAnalyzer); const repoImports = validatePythonImportEdges( - await analyzer.analyze(allSources), + context.graph?.identity.kind === "exact" + ? pythonImportEdgesFromGraph(await context.graph.importsFrom(), new Set(allSourceByPath.keys())) + : await requirePythonImportAnalyzer(importAnalyzer).analyze(allSources), new Set(allSourceByPath.keys()) ); const selectedPaths = await expandSourceClosure(context, rootPaths, repoImports, allSourceByPath, resolveContexts); - const files = selectedPaths.map((path) => allSourceByPath.get(path)).filter(isDefined); + const files = selectedPaths + .map((path) => allSourceByPath.get(path)) + .filter((file): file is PythonMaterializedSourceFile => file !== undefined); const sourceFileByPath = new Map(files.map((file) => [file.path, file])); const selectedPathSet = new Set(selectedPaths); return { @@ -290,10 +294,6 @@ function emptySourceSet(): PythonMaterializedSourceSet { }; } -function isDefined(value: T | undefined): value is T { - return value !== undefined; -} - export function uniqueSorted(values: readonly string[]): readonly string[] { return [...new Set(values)].sort(); } diff --git a/packages/validation-typescript/src/import-graph-check.ts b/packages/validation-typescript/src/import-graph-check.ts index 023be7d..85ef1a0 100644 --- a/packages/validation-typescript/src/import-graph-check.ts +++ b/packages/validation-typescript/src/import-graph-check.ts @@ -13,6 +13,7 @@ export function createImportGraphCheck(): ValidationCheckDefinition { defaultSeverity: "warning", supportedScopes: supportedTypeScriptValidationScopes, requiresGraph: false, + graphUsage: "optional", run: async (context) => { const sourceSet = await materializeTypeScriptSources(context); const diagnostics = [ @@ -33,7 +34,11 @@ async function retainedMissingEdgeDiagnostics( try { edges = await context.graph.importsFrom(); } catch (error) { - if (error instanceof ValidationGraphProviderError && context.request.graph.mode !== "required") return []; + if ( + error instanceof ValidationGraphProviderError && + context.request.graph.mode !== "required" && + context.graph.identity.kind !== "exact" + ) return []; throw error; } return relativeImports diff --git a/packages/validation/README.md b/packages/validation/README.md index 8e4e893..3c381c0 100644 --- a/packages/validation/README.md +++ b/packages/validation/README.md @@ -1,3 +1,5 @@ # @the-open-engine/opcore-validation Opcore validation runner package for scopes, overlays, checks, and command adapters. + +Hypothetical graph-backed checks use one disposable exact-state graph session per `ValidationFileView`. Introduced reporting owns separate before/after sessions, shares each session across checks, fails closed on exact-state provider errors, and rejects incomplete visible-file universes. diff --git a/packages/validation/src/command-adapter.ts b/packages/validation/src/command-adapter.ts index 99f4bff..8e615e7 100644 --- a/packages/validation/src/command-adapter.ts +++ b/packages/validation/src/command-adapter.ts @@ -28,7 +28,7 @@ import { type ValidationClock } from "./runner.js"; import { createNodeValidationWorkspace } from "./workspace.js"; -import type { ValidationGraphProviderClient } from "./graph-client.js"; +import type { ValidationGraphProviderClient, ValidationGraphSessionFactory } from "./graph-client.js"; import type { ValidationWorkspace } from "./scope.js"; import type { ValidationRuntimePolicy } from "./registry.js"; import { readFile } from "node:fs/promises"; @@ -45,6 +45,7 @@ export interface ValidationCommandAdapterOptions { workspace?: ValidationWorkspace; workspaceFactory?: (repoRoot: string) => ValidationWorkspace; graphProviderClient?: ValidationGraphProviderClient; + graphSessionFactory?: ValidationGraphSessionFactory; clock?: ValidationClock; defaultRepoRoot?: string; runtime?: ValidationRuntimePolicy; @@ -149,6 +150,7 @@ async function runRequest( workspace: workspaceForRequest(request, parsed, options), registry: registryForOptions(options, repoRootForRequest(request, parsed, options)), graphProviderClient: options.graphProviderClient, + graphSessionFactory: options.graphSessionFactory, clock: options.clock, runtime: options.runtime }; diff --git a/packages/validation/src/graph-client.ts b/packages/validation/src/graph-client.ts index efbb301..8b48cf1 100644 --- a/packages/validation/src/graph-client.ts +++ b/packages/validation/src/graph-client.ts @@ -41,6 +41,7 @@ import { validateRepoRelativePath } from "@the-open-engine/opcore-contracts"; import { defaultValidationGraphProvider, missingGraphStatus } from "./request.js"; +import type { ValidationFileView } from "./overlays.js"; type MaybePromise = T | Promise; @@ -129,8 +130,10 @@ export interface ValidationGraphFactView { } export interface ValidationGraphQuerySession extends ValidationGraphFactView { + readonly identity: ValidationGraphSessionIdentity; readonly status: GraphProviderStatus; readonly queryCapable: boolean; + dispose: () => MaybePromise; preload: (requirements: readonly ValidationGraphQueryRequirement[]) => Promise; facts: (selector: GraphFactQuerySelector) => Promise; namedQuery: (query: ValidationGraphNamedQueryInput) => Promise; @@ -140,16 +143,112 @@ export interface ValidationGraphQuerySession extends ValidationGraphFactView { factView: () => ValidationGraphFactView; } +export type ValidationGraphSessionIdentity = + | { kind: "persistent" } + | { kind: "exact"; state: "before" | "after" }; + export interface CreateValidationGraphQuerySessionArgs { request: ValidationRequest; client?: ValidationGraphProviderClient; status?: GraphProviderStatus; + fileView?: ValidationFileView; + identity?: ValidationGraphSessionIdentity; } export type ValidationGraphSessionFactory = ( args: CreateValidationGraphQuerySessionArgs ) => MaybePromise; +export interface ValidationExactGraphSnapshot { + status: GraphProviderStatus; + client: ValidationGraphProviderClient; + dispose: () => MaybePromise; +} + +export type ValidationExactGraphSnapshotFactory = (args: { + request: ValidationRequest; + fileView: ValidationFileView; + state: "before" | "after"; +}) => MaybePromise; + +export interface ValidationEphemeralGraphSnapshot { + status: (mode: ValidationRequest["graph"]["mode"]) => GraphProviderStatus; + factQuery: ValidationGraphProviderClient["factQuery"]; + namedQuery: ValidationGraphProviderClient["namedQuery"]; + impact: ValidationGraphProviderClient["impact"]; + reviewContext: ValidationGraphProviderClient["reviewContext"]; + detectChanges: ValidationGraphProviderClient["detectChanges"]; + dispose: () => MaybePromise; +} + +export type ValidationEphemeralGraphSnapshotProvider = (args: { + logicalRepo: RepoIdentity; + sourceUniverse: { paths: readonly string[]; complete: boolean; message?: string }; + readFile: (path: string) => MaybePromise<{ status: "found" | "missing" | "deleted"; content?: string }>; +}) => MaybePromise; + +export function createValidationExactGraphSnapshotFactory( + provider: ValidationEphemeralGraphSnapshotProvider +): ValidationExactGraphSnapshotFactory { + return async ({ request, fileView }) => { + const universe = await fileView.listVisibleFileUniverse(); + const snapshot = await provider({ + logicalRepo: request.repo, + sourceUniverse: { paths: universe.files, complete: universe.complete, message: universe.message }, + readFile: (path) => fileView.readFile(path) + }); + const client: ValidationGraphProviderClient = { + status: (candidate) => snapshot.status(candidate.graph.mode), + factQuery: snapshot.factQuery, + namedQuery: snapshot.namedQuery, + impact: snapshot.impact, + reviewContext: snapshot.reviewContext, + detectChanges: snapshot.detectChanges + }; + return { + status: snapshot.status(request.graph.mode), + client, + dispose: snapshot.dispose + }; + }; +} + +export function createStateAwareValidationGraphSessionFactory(options: { + persistentClient?: ValidationGraphProviderClient; + exactSnapshotFactory: ValidationExactGraphSnapshotFactory; +}): ValidationGraphSessionFactory { + return async (args) => { + if (args.identity?.kind !== "exact") { + return createValidationGraphQuerySession({ ...args, client: args.client ?? options.persistentClient }); + } + if (args.fileView === undefined) throw new Error("Exact validation graph session requires its ValidationFileView"); + const snapshot = await options.exactSnapshotFactory({ + request: args.request, + fileView: args.fileView, + state: args.identity.state + }); + try { + const session = await createValidationGraphQuerySession({ + ...args, + client: snapshot.client, + status: snapshot.status + }); + let disposed = false; + return { + ...session, + dispose: async () => { + if (disposed) return; + disposed = true; + await snapshot.dispose(); + } + }; + } catch (error) { + await snapshot.dispose(); + throw error; + } + }; +} + export class ValidationGraphProviderError extends Error { readonly operation: ValidationGraphOperation; readonly status: GraphProviderNonAvailableStatus; @@ -376,8 +475,10 @@ export async function createValidationGraphQuerySession( } return { + identity: args.identity ?? { kind: "persistent" }, status, queryCapable, + dispose: () => {}, preload, facts, namedQuery, diff --git a/packages/validation/src/index.ts b/packages/validation/src/index.ts index e8fdc69..1e2c117 100644 --- a/packages/validation/src/index.ts +++ b/packages/validation/src/index.ts @@ -86,6 +86,8 @@ export { } from "./request.js"; export { createValidationGraphQuerySession, + createStateAwareValidationGraphSessionFactory, + createValidationExactGraphSnapshotFactory, resolveValidationGraphProviderStatus, ValidationGraphProviderError, ValidationGraphRequirementError, @@ -104,7 +106,12 @@ export { type ValidationGraphQuerySession, type ValidationGraphReviewContextInput, type ValidationGraphReviewContextRequirement, - type ValidationGraphSessionFactory + type ValidationGraphSessionFactory, + type ValidationGraphSessionIdentity, + type ValidationExactGraphSnapshot, + type ValidationExactGraphSnapshotFactory, + type ValidationEphemeralGraphSnapshot, + type ValidationEphemeralGraphSnapshotProvider } from "./graph-client.js"; export { createValidationCheckManifest, @@ -121,6 +128,7 @@ export { } from "./registry.js"; export { ValidationOverlayConflictError, + ValidationFileUniverseError, calculateValidationFileChecksum, createValidationFileView, findValidationOverlayEntry, @@ -137,6 +145,7 @@ export { type ValidationFileReadStatus, type ValidationFileSourceMetadata, type ValidationFileView, + type ValidationVisibleFileUniverse, type ValidationOverlayEntry } from "./overlays.js"; export { diff --git a/packages/validation/src/node-shims.d.ts b/packages/validation/src/node-shims.d.ts index fe72b6e..0dff37e 100644 --- a/packages/validation/src/node-shims.d.ts +++ b/packages/validation/src/node-shims.d.ts @@ -29,6 +29,16 @@ declare module "node:fs/promises" { export function readFile(path: string, encoding: "utf8"): Promise; } +declare module "node:fs" { + export interface Dirent { + name: string; + isDirectory(): boolean; + isFile(): boolean; + isSymbolicLink(): boolean; + } + export function readdirSync(path: string, options: { withFileTypes: true }): Dirent[]; +} + declare module "node:path" { export const sep: string; export function relative(from: string, to: string): string; diff --git a/packages/validation/src/overlays.ts b/packages/validation/src/overlays.ts index eada86e..907f1f3 100644 --- a/packages/validation/src/overlays.ts +++ b/packages/validation/src/overlays.ts @@ -83,6 +83,8 @@ export interface ValidationFileView { readonly scopeFiles: readonly string[]; readonly defaultReadState: ValidationFileReadState; listVisibleFiles: () => Promise; + listVisibleFileUniverse: () => Promise; + listCompleteVisibleFiles: () => Promise; readFile: (path: string, options?: ValidationFileReadOptions) => Promise; readBefore: (path: string) => Promise; readAfter: (path: string) => Promise; @@ -91,6 +93,19 @@ export interface ValidationFileView { overlayFor: (path: string) => ValidationOverlayEntry | undefined; } +export interface ValidationVisibleFileUniverse { + files: readonly string[]; + complete: boolean; + message?: string; +} + +export class ValidationFileUniverseError extends Error { + constructor(message: string) { + super(message); + this.name = "ValidationFileUniverseError"; + } +} + export class ValidationOverlayConflictError extends Error { readonly path: string; readonly expected: string; @@ -135,10 +150,18 @@ export async function createValidationFileView(args: CreateValidationFileViewArg const overlayByPath = new Map(overlays.map((overlay) => [overlay.path, overlay])); const scopeFiles = uniqueSorted(args.scope.files.map(normalizeValidationFileViewPath)); const defaultReadState = args.defaultReadState ?? "after"; - let visibleFilesPromise: Promise | undefined; + let visibleFileUniversePromise: Promise | undefined; + const listVisibleFileUniverse = (): Promise => { + visibleFileUniversePromise ??= resolveVisibleFileUniverse(args.workspace, args.scope, defaultReadState, scopeFiles, overlays); + return visibleFileUniversePromise; + }; const listVisibleFiles = (): Promise => { - visibleFilesPromise ??= resolveVisibleFiles(args.workspace, args.scope, defaultReadState, scopeFiles, overlays); - return visibleFilesPromise; + return listVisibleFileUniverse().then((universe) => universe.files); + }; + const listCompleteVisibleFiles = async (): Promise => { + const universe = await listVisibleFileUniverse(); + if (!universe.complete) throw new ValidationFileUniverseError(universe.message ?? "Complete validation file listing is unavailable"); + return universe.files; }; const readBefore = (path: string): Promise => readWorkspacePath(args.workspace, args.scope, normalizeValidationFileViewPath(path), "before"); @@ -165,6 +188,8 @@ export async function createValidationFileView(args: CreateValidationFileViewArg scopeFiles, defaultReadState, listVisibleFiles, + listVisibleFileUniverse, + listCompleteVisibleFiles, readFile: (path, options = {}) => options.state === "before" || (options.state === undefined && defaultReadState === "before") ? readBefore(path) : readAfter(path), readBefore, @@ -293,18 +318,31 @@ function validateWorkspaceReader(workspace: ValidationWorkspace): void { } } -async function resolveVisibleFiles( +async function resolveVisibleFileUniverse( workspace: ValidationWorkspace, scope: ResolvedValidationScope, state: ValidationFileReadState, scopeFiles: readonly string[], overlays: readonly ValidationOverlayEntry[] -): Promise { +): Promise { const overlayFiles = overlays.map((overlay) => overlay.path); - if (workspace.listFiles === undefined) return uniqueSorted([...scopeFiles, ...overlayFiles]); + const fallbackFiles = uniqueSorted([...scopeFiles, ...overlayFiles]); + if (workspace.listFiles === undefined) { + return { files: fallbackFiles, complete: false, message: "Validation workspace cannot list the complete visible file universe" }; + } const fileSet = await workspace.listFiles({ scope, state }); - if (fileSet.unavailable) return uniqueSorted([...scopeFiles, ...overlayFiles]); - return uniqueSorted([...fileSetPaths(fileSet), ...scopeFiles, ...overlayFiles]); + if (fileSet.unavailable) { + return { + files: fallbackFiles, + complete: false, + message: fileSet.message ?? "Validation workspace file listing is unavailable" + }; + } + const files = uniqueSorted([...fileSetPaths(fileSet), ...scopeFiles, ...overlayFiles]); + if (fileSet.truncated) { + return { files, complete: false, message: fileSet.message ?? "Validation workspace file listing was truncated" }; + } + return { files, complete: true }; } function fileSetPaths(fileSet: ValidationWorkspaceFileSet): readonly string[] { diff --git a/packages/validation/src/registry.ts b/packages/validation/src/registry.ts index ac28368..83a5f6c 100644 --- a/packages/validation/src/registry.ts +++ b/packages/validation/src/registry.ts @@ -47,6 +47,7 @@ export interface ValidationCheckDefinition { supportedScopes: readonly ValidationScopeKind[]; defaultScopes?: readonly ValidationScopeKind[]; requiresGraph?: boolean; + graphUsage?: "none" | "optional" | "required"; graphRequirements?: ( context: ValidationCheckContext ) => readonly ValidationGraphQueryRequirement[] | Promise; @@ -176,6 +177,9 @@ function validateValidationCheckDefinition(definition: ValidationCheckDefinition if (definition.requiresGraph !== undefined && typeof definition.requiresGraph !== "boolean") { throw new ValidationCheckRegistryError("Validation check requiresGraph must be boolean"); } + if (definition.graphUsage !== undefined && !["none", "optional", "required"].includes(definition.graphUsage)) { + throw new ValidationCheckRegistryError("Validation check graphUsage must be none, optional, or required"); + } if (definition.graphRequirements !== undefined && typeof definition.graphRequirements !== "function") { throw new ValidationCheckRegistryError("Validation check graphRequirements must be a function"); } diff --git a/packages/validation/src/runner.ts b/packages/validation/src/runner.ts index 2d60403..be40a2b 100644 --- a/packages/validation/src/runner.ts +++ b/packages/validation/src/runner.ts @@ -114,14 +114,15 @@ interface ExecuteChecksArgs { interface PreparedValidationArgs { request: ValidationRequest; scope: ResolvedValidationScope; - graph: ValidationGraphQuerySession; selectedChecks: readonly ValidationCheckDefinition[]; totalStartedAt: number; options: RunnerRuntimeOptions; } -interface ExecuteValidationRequestArgs extends PreparedValidationArgs { - defaultReadState?: "before"; +interface ValidationExecutionState { + request: ValidationRequest; + fileView: ValidationFileView; + graph: ValidationGraphQuerySession; } interface SingleCheckOutcome { @@ -181,14 +182,10 @@ async function runPreparedValidation( } const unsupportedResult = unsupportedScopeResult(selectedChecks, scope.kind, totalStartedAt, options.clock); if (unsupportedResult !== undefined) return unsupportedResult; - const graph = await resolveGraphSession(request, selectedChecks, options); - const graphFailure = requiredGraphFailureResult(request, graph.status, selectedChecks, totalStartedAt, options.clock); - if (graphFailure !== undefined) return graphFailure; if (request.reportMode === "introduced") { return await runIntroducedValidation({ request, scope, - graph, selectedChecks, totalStartedAt, options @@ -197,7 +194,6 @@ async function runPreparedValidation( return await runCurrentValidation({ request, scope, - graph, selectedChecks, totalStartedAt, options @@ -208,37 +204,59 @@ async function runPreparedValidation( } async function runCurrentValidation(args: PreparedValidationArgs): Promise { - const execution = await executeValidationRequest(args); - if (execution.failureResult !== undefined) return execution.failureResult; - return finalValidationResult(args.selectedChecks, execution, args.graph.status, args.totalStartedAt, args.options.clock); + const state = await acquireValidationState(args, args.request, "after", exactGraphRequired(args)); + try { + const graphFailure = requiredGraphFailureResult(state.graph, args.request, args.selectedChecks, args.totalStartedAt, args.options.clock); + if (graphFailure !== undefined) return graphFailure; + const execution = await executeValidationState(args, state, args.selectedChecks, args.options); + if (execution.failureResult !== undefined) return execution.failureResult; + return finalValidationResult(args.selectedChecks, execution, state.graph.status, args.totalStartedAt, args.options.clock); + } finally { + await state.graph.dispose(); + } } async function runIntroducedValidation(args: PreparedValidationArgs): Promise { - if (args.options.failFast || args.options.onCheckComplete !== undefined) { - return runIntroducedValidationIncremental(args); - } const beforeRequest: ValidationRequest = { ...args.request, overlays: [] }; - const beforeExecution = await executeValidationRequest({ - ...args, - request: beforeRequest, - defaultReadState: "before" - }); - if (beforeExecution.failureResult !== undefined) return beforeExecution.failureResult; - const execution = await executeValidationRequest(args); - if (execution.failureResult !== undefined) return execution.failureResult; - return finalValidationResult( - args.selectedChecks, - introducedExecution(beforeExecution, execution), - args.graph.status, - args.totalStartedAt, - args.options.clock - ); + const exact = exactGraphRequired(args); + const beforeState = await acquireValidationState(args, beforeRequest, "before", exact); + try { + const beforeFailure = requiredGraphFailureResult(beforeState.graph, beforeRequest, args.selectedChecks, args.totalStartedAt, args.options.clock); + if (beforeFailure !== undefined) return beforeFailure; + const afterState = await acquireValidationState(args, args.request, "after", exact); + try { + const afterFailure = requiredGraphFailureResult(afterState.graph, args.request, args.selectedChecks, args.totalStartedAt, args.options.clock); + if (afterFailure !== undefined) return afterFailure; + if (args.options.failFast || args.options.onCheckComplete !== undefined) { + return runIntroducedValidationIncremental(args, beforeState, afterState); + } + const beforeExecution = await executeValidationState(args, beforeState, args.selectedChecks, args.options); + if (beforeExecution.failureResult !== undefined) return beforeExecution.failureResult; + const execution = await executeValidationState(args, afterState, args.selectedChecks, args.options); + if (execution.failureResult !== undefined) return execution.failureResult; + return finalValidationResult( + args.selectedChecks, + introducedExecution(beforeExecution, execution), + afterState.graph.status, + args.totalStartedAt, + args.options.clock + ); + } finally { + await afterState.graph.dispose(); + } + } finally { + await beforeState.graph.dispose(); + } } -async function runIntroducedValidationIncremental(args: PreparedValidationArgs): Promise { +async function runIntroducedValidationIncremental( + args: PreparedValidationArgs, + beforeState: ValidationExecutionState, + afterState: ValidationExecutionState +): Promise { const execution: CheckExecution = { runs: [], diagnostics: [], @@ -249,22 +267,9 @@ async function runIntroducedValidationIncremental(args: PreparedValidationArgs): const quietOptions = withoutCheckCompleteOptions(args.options); for (const check of args.selectedChecks) { const selectedChecks = [check]; - const beforeExecution = await executeValidationRequest({ - ...args, - request: { - ...args.request, - overlays: [] - }, - selectedChecks, - defaultReadState: "before", - options: quietOptions - }); + const beforeExecution = await executeValidationState(args, beforeState, selectedChecks, quietOptions); if (beforeExecution.failureResult !== undefined) return beforeExecution.failureResult; - const afterExecution = await executeValidationRequest({ - ...args, - selectedChecks, - options: quietOptions - }); + const afterExecution = await executeValidationState(args, afterState, selectedChecks, quietOptions); if (afterExecution.failureResult !== undefined) return afterExecution.failureResult; const introduced = introducedExecution(beforeExecution, afterExecution); mergeCheckExecution(execution, introduced); @@ -273,7 +278,7 @@ async function runIntroducedValidationIncremental(args: PreparedValidationArgs): break; } } - return finalValidationResult(args.selectedChecks, execution, args.graph.status, args.totalStartedAt, args.options.clock); + return finalValidationResult(args.selectedChecks, execution, afterState.graph.status, args.totalStartedAt, args.options.clock); } function withoutCheckCompleteOptions(options: RunnerRuntimeOptions): RunnerRuntimeOptions { @@ -325,24 +330,39 @@ async function emitIntroducedCheckComplete( } } -async function executeValidationRequest(args: ExecuteValidationRequestArgs): Promise { +async function acquireValidationState( + args: PreparedValidationArgs, + request: ValidationRequest, + defaultReadState: "before" | "after", + exact: boolean +): Promise { const fileView = await createValidationFileView({ - request: args.request, + request, scope: args.scope, workspace: args.options.workspace, - ...(args.defaultReadState === undefined ? {} : { defaultReadState: args.defaultReadState }) + defaultReadState }); + const graph = await resolveGraphSession(request, args.selectedChecks, args.options, fileView, exact ? { kind: "exact", state: defaultReadState } : { kind: "persistent" }); + return { request, fileView, graph }; +} + +async function executeValidationState( + args: PreparedValidationArgs, + state: ValidationExecutionState, + selectedChecks: readonly ValidationCheckDefinition[], + options: RunnerRuntimeOptions +): Promise { return executeSelectedChecks({ - request: args.request, + request: state.request, scope: args.scope, - graph: args.graph, - fileView, - selectedChecks: args.selectedChecks, + graph: state.graph, + fileView: state.fileView, + selectedChecks, totalStartedAt: args.totalStartedAt, - clock: args.options.clock, - runtime: args.options.runtime, - failFast: args.options.failFast, - onCheckComplete: args.options.onCheckComplete + clock: options.clock, + runtime: options.runtime, + failFast: options.failFast, + onCheckComplete: options.onCheckComplete }); } @@ -366,13 +386,14 @@ function unsupportedScopeResult( } function requiredGraphFailureResult( + graph: ValidationGraphQuerySession, request: ValidationRequest, - graphStatus: GraphProviderStatus, selectedChecks: readonly ValidationCheckDefinition[], totalStartedAt: number, clock: ValidationClock ): ValidationResult | undefined { - if (request.graph.mode !== "required" || graphStatus.state === "available") return undefined; + const graphStatus = graph.status; + if (graphStatus.state === "available" || (request.graph.mode !== "required" && graph.identity.kind !== "exact")) return undefined; return aggregateForChecks(selectedChecks, { generatedAt: clock.isoNow(), durationMs: elapsed(totalStartedAt, clock.nowMs()), @@ -428,7 +449,7 @@ async function executeSelectedChecks(args: ExecuteChecksArgs): Promise { + if (identity.kind === "exact" && options.graphSessionFactory === undefined) { + return createValidationGraphQuerySession({ + request, + fileView, + identity, + status: missingGraphStatus(request.graph.mode, request.graph.provider ?? defaultValidationGraphProvider) + }); + } const factory = options.graphSessionFactory ?? createValidationGraphQuerySession; - const graph = await factory({ request, client: options.graphProviderClient }); - const hasGraphRequiredChecks = selectedChecks.some((check) => check.requiresGraph === true); + const graph = await factory({ request, client: options.graphProviderClient, fileView, identity }); + const hasGraphRequiredChecks = selectedChecks.some(checkUsesGraph); if (hasGraphRequiredChecks && !graph.queryCapable && graph.status.state === "available") { + await graph.dispose(); return createValidationGraphQuerySession({ request, + fileView, + identity, status: missingGraphStatus(request.graph.mode, request.graph.provider ?? defaultValidationGraphProvider) }); } return graph; } +function exactGraphRequired(args: PreparedValidationArgs): boolean { + return ( + (args.request.overlays.length > 0 || args.request.reportMode === "introduced") && + args.selectedChecks.some(checkUsesGraph) + ); +} + +function checkUsesGraph(check: ValidationCheckDefinition): boolean { + return check.graphUsage === "optional" || check.graphUsage === "required" || check.requiresGraph === true; +} + function normalizeCheckResult(result: ValidationCheckResult | readonly ValidationDiagnostic[] | void): ValidationCheckResult { if (result === undefined) return { diagnostics: [] }; if (isValidationDiagnosticArray(result)) return { diagnostics: result }; diff --git a/packages/validation/src/scope.ts b/packages/validation/src/scope.ts index a33fd3a..b34c5a3 100644 --- a/packages/validation/src/scope.ts +++ b/packages/validation/src/scope.ts @@ -19,6 +19,7 @@ export interface ValidationWorkspaceFile { export interface ValidationWorkspaceFileSet { files: readonly (string | ValidationWorkspaceFile)[]; unavailable?: boolean; + truncated?: boolean; message?: string; cause?: string; } diff --git a/packages/validation/src/workspace.ts b/packages/validation/src/workspace.ts index 350897c..d5cfb9c 100644 --- a/packages/validation/src/workspace.ts +++ b/packages/validation/src/workspace.ts @@ -8,6 +8,7 @@ import type { } from "./scope.js"; import { validateRepoRelativePath } from "@the-open-engine/opcore-contracts"; import { spawnSync } from "node:child_process"; +import { readdirSync } from "node:fs"; import { readFile } from "node:fs/promises"; import { relative, resolve, sep } from "node:path"; @@ -23,7 +24,7 @@ export function createNodeValidationWorkspace(options: CreateNodeValidationWorks const skippedPathSegments = new Set(options.skippedPathSegments ?? []); return { readFile: (path, context) => readWorkspaceFile(repoRoot, path, context), - listFiles: (context) => filterFileSet(listWorkspaceFiles(repoRoot, context), skippedPathSegments), + listFiles: (context) => filterFileSet(listWorkspaceFiles(repoRoot, context, skippedPathSegments), skippedPathSegments), listChangedFiles: (baseRef) => filterFileSet(listChangedFiles(repoRoot, baseRef), skippedPathSegments), listTreeFiles: (treeRef, changedFrom) => filterFileSet(listTreeFiles(repoRoot, treeRef, changedFrom), skippedPathSegments), listStagedFiles: () => @@ -133,7 +134,11 @@ function listTreeFiles(repoRoot: string, treeRef: string, changedFrom: string): return listDiffFiles(repoRoot, ["diff", "--name-status", "-z", "--find-renames", base.treeSha, tree.treeSha, "--"]); } -function listWorkspaceFiles(repoRoot: string, context: ValidationWorkspaceListFilesContext): ValidationWorkspaceFileSet { +function listWorkspaceFiles( + repoRoot: string, + context: ValidationWorkspaceListFilesContext, + skippedPathSegments: ReadonlySet +): ValidationWorkspaceFileSet { const state = context.state ?? "after"; const beforeRef = beforeStateTreeRef(repoRoot, { scope: context.scope, state }); if (beforeRef !== undefined) return mergeFileSets(listTreeSnapshotFiles(repoRoot, beforeRef), listRepoFiles(repoRoot)); @@ -141,9 +146,40 @@ function listWorkspaceFiles(repoRoot: string, context: ValidationWorkspaceListFi return mergeFileSets(listTreeSnapshotFiles(repoRoot, context.scope.treeRef), listRepoFiles(repoRoot)); } if (context.scope.kind === "package" && context.scope.packageRoot !== undefined) { - return filterPackageFileSet(listRepoFiles(repoRoot), context.scope.packageRoot); + const files = repoFilesOrDiskFiles(repoRoot, skippedPathSegments); + return filterPackageFileSet(files, context.scope.packageRoot); + } + return repoFilesOrDiskFiles(repoRoot, skippedPathSegments); +} + +function repoFilesOrDiskFiles(repoRoot: string, skippedPathSegments: ReadonlySet): ValidationWorkspaceFileSet { + const tracked = listRepoFiles(repoRoot); + return tracked.unavailable ? listDiskFiles(repoRoot, skippedPathSegments) : tracked; +} + +function listDiskFiles(repoRoot: string, skippedPathSegments: ReadonlySet): ValidationWorkspaceFileSet { + const files: string[] = []; + try { + const visit = (absoluteDirectory: string, relativeDirectory: string): void => { + for (const entry of readdirSync(absoluteDirectory, { withFileTypes: true })) { + if (skippedPathSegments.has(entry.name)) continue; + const relativePath = relativeDirectory.length === 0 ? entry.name : `${relativeDirectory}/${entry.name}`; + const absolutePath = resolve(absoluteDirectory, entry.name); + if (entry.isDirectory()) visit(absolutePath, relativePath); + else if (entry.isFile()) files.push(validateRepoRelativePath(relativePath)); + else if (entry.isSymbolicLink()) throw new Error(`Disk validation listing does not follow symlink: ${relativePath}`); + } + }; + visit(repoRoot, ""); + return { files: files.sort() }; + } catch (error) { + return { + files: [], + unavailable: true, + message: "Disk validation file listing is unavailable", + cause: error instanceof Error ? error.message : String(error) + }; } - return listRepoFiles(repoRoot); } function listTreeSnapshotFiles(repoRoot: string, treeRef: string): ValidationWorkspaceFileSet { @@ -191,6 +227,7 @@ function filterPackageFileSet(fileSet: ValidationWorkspaceFileSet, packageRoot: const root = validateRepoRelativePath(packageRoot); const prefix = `${root}/`; return { + ...fileSet, files: fileSet.files.filter((file) => { const path = typeof file === "string" ? file : file.path; return path === root || path.startsWith(prefix); diff --git a/tests/asp-provider.test.mjs b/tests/asp-provider.test.mjs index 12feb6e..86fa5e1 100644 --- a/tests/asp-provider.test.mjs +++ b/tests/asp-provider.test.mjs @@ -50,6 +50,27 @@ const allCheckIds = [ ]; describe("Opcore ASP provider", () => { + it("preserves host listTree truncation for exact-state validation", async () => { + const baseline = { rev: "tree:truncated", stampedAt: "2026-07-16T00:00:00.000Z" }; + const host = createAspHostValidationWorkspace( + { request: async () => ({ entries: [{ path: "src/app.ts", blobId: "blob:app", kind: "file" }], truncated: true }) }, + { workspace: { baseline } }, + { baseline, grantedPermissions: { read: ["**/*"], write: false, network: false } }, + { baseline, changes: [] } + ); + + const listing = await host.workspace.listFiles({ + scope: { kind: "files", files: ["src/app.ts"], workspaceFiles: [{ path: "src/app.ts" }] }, + state: "after" + }); + assert.equal(listing.truncated, true); + assert.match(listing.message, /truncated/); + + const packageListing = await host.workspace.listPackageFiles("app", "src"); + assert.equal(packageListing.truncated, true); + assert.match(packageListing.message, /truncated/); + }); + it("requires positive host file-kind evidence before treating realpaths as safe", async () => { const baseline = { rev: "tree:realpath-evidence", stampedAt: "2026-07-15T00:00:00.000Z" }; const workspaceFor = (entries) => createAspHostValidationWorkspace( @@ -157,7 +178,7 @@ describe("Opcore ASP provider", () => { assert.ok(assessment.diagnostics.every((diagnostic) => diagnostic.code.startsWith(`${diagnostic.source}/`))); assert.ok(assessment.diagnostics.every((diagnostic) => diagnostic.fingerprint.startsWith("sha256:"))); assert.ok(assessment.coverage.degraded.some((entry) => entry.reason === "unsupported" && /comparison/.test(entry.requirement))); - assert.ok(assessment.coverage.degraded.some((entry) => entry.reason === "unavailable" && entry.requirement === "typescript.import-graph")); + assert.ok(!assessment.coverage.degraded.some((entry) => entry.reason === "unavailable" && entry.requirement === "typescript.import-graph")); assert.equal(assessment.coverage.exhaustive, false); assertCommandTiming(assessment.timing, { expectedPhases: [ diff --git a/tests/edit-cli.test.mjs b/tests/edit-cli.test.mjs index 5183b00..67c8f9d 100644 --- a/tests/edit-cli.test.mjs +++ b/tests/edit-cli.test.mjs @@ -54,8 +54,8 @@ describe("opcore edit CLI", () => { assert.equal(routed.status, "error"); assert.equal(routed.exitCode, 1); assert.equal(routed.editResult.refusal.category, "validation_failed"); - assert.equal(routed.editResult.validation.status, "provider_failure"); - assert.equal(routed.editResult.validation.graphStatus.state, "stale"); + assert.equal(routed.editResult.validation.status, "policy_failure"); + assert.equal(routed.editResult.validation.graphStatus.state, "available"); assert.equal(routed.editResult.applied, false); assert.equal(readFileSync(join(repo, "src/a.ts"), "utf8"), "old\n"); }); diff --git a/tests/fixtures/package-packlists.json b/tests/fixtures/package-packlists.json index a946aea..1bdcf98 100644 --- a/tests/fixtures/package-packlists.json +++ b/tests/fixtures/package-packlists.json @@ -166,6 +166,9 @@ "dist/validation-composition.d.ts", "dist/validation-composition.d.ts.map", "dist/validation-composition.js", + "dist/validation-graph-session.d.ts", + "dist/validation-graph-session.d.ts.map", + "dist/validation-graph-session.js", "node_modules/@the-open-engine/opcore-asp-provider/README.md", "node_modules/@the-open-engine/opcore-asp-provider/dist/digests.d.ts", "node_modules/@the-open-engine/opcore-asp-provider/dist/digests.d.ts.map", @@ -290,6 +293,9 @@ "node_modules/@the-open-engine/opcore-graph/dist/artifact.d.ts", "node_modules/@the-open-engine/opcore-graph/dist/artifact.d.ts.map", "node_modules/@the-open-engine/opcore-graph/dist/artifact.js", + "node_modules/@the-open-engine/opcore-graph/dist/ephemeral-snapshot.d.ts", + "node_modules/@the-open-engine/opcore-graph/dist/ephemeral-snapshot.d.ts.map", + "node_modules/@the-open-engine/opcore-graph/dist/ephemeral-snapshot.js", "node_modules/@the-open-engine/opcore-graph/dist/index.d.ts", "node_modules/@the-open-engine/opcore-graph/dist/index.d.ts.map", "node_modules/@the-open-engine/opcore-graph/dist/index.js", diff --git a/tests/graph-ephemeral-snapshot.test.mjs b/tests/graph-ephemeral-snapshot.test.mjs new file mode 100644 index 0000000..a86190a --- /dev/null +++ b/tests/graph-ephemeral-snapshot.test.mjs @@ -0,0 +1,350 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs"; +import { createHash } from "node:crypto"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { createEphemeralGraphSnapshot, graphProviderBuild, graphProviderQuery } from "../packages/graph/dist/index.js"; +import { createEphemeralGraphSnapshotWithOperations } from "../packages/graph/dist/ephemeral-snapshot.js"; +import { + createStateAwareValidationGraphSessionFactory, + createValidationExactGraphSnapshotFactory, + createValidationRunner +} from "../packages/validation/dist/index.js"; + +describe("ephemeral graph snapshots", () => { + it("materializes supported sources, builds once, binds logical metadata, and disposes idempotently", async () => { + let builds = 0; + let materializedRepo; + const logicalRepo = { repoId: "target", repoRoot: "/target/repo" }; + const available = graphStatus(); + const snapshot = await createEphemeralGraphSnapshotWithOperations({ + logicalRepo, + sourceUniverse: { + paths: ["README.md", "src/a.py", "src/b.ts", "src/common.cts", "src/module.mts"], + complete: true + }, + readFile: (path) => ({ status: "found", content: `content:${path}` }) + }, { + build: (repo) => { + builds += 1; + materializedRepo = repo.repoRoot; + assert.equal(readFileSync(join(materializedRepo, "src/a.py"), "utf8"), "content:src/a.py"); + assert.equal(readFileSync(join(materializedRepo, "src/common.cts"), "utf8"), "content:src/common.cts"); + assert.equal(readFileSync(join(materializedRepo, "src/module.mts"), "utf8"), "content:src/module.mts"); + assert.equal(existsSync(join(materializedRepo, "README.md")), false); + return { status: available }; + }, + factQuery: (_repo, request) => ({ + requestId: request.requestId, + status: available, + metadata: { + schemaVersion: 1, + provider: "opcore-graph", + repo: { repoRoot: materializedRepo }, + generatedAt: "2026-07-16T00:00:00.000Z", + freshness: { generatedAt: "2026-07-16T00:00:00.000Z", ageMs: 0, stale: false }, + nodeKinds: [], + edgeKinds: [] + }, + nodes: [], + edges: [] + }) + }); + + const result = snapshot.factQuery({ + requestId: "exact-query", + repo: logicalRepo, + schemaVersion: 1, + mode: "optional", + selector: { kind: "nodes" } + }); + assert.equal(builds, 1); + assert.deepEqual(snapshot.materializedPaths, ["src/a.py", "src/b.ts", "src/common.cts", "src/module.mts"]); + assert.deepEqual(result.metadata.repo, logicalRepo); + assert.equal(result.status.mode, "optional"); + snapshot.dispose(); + snapshot.dispose(); + assert.equal(existsSync(materializedRepo), false); + }); + + it("rejects incomplete universes and removes materialized state after build errors", async () => { + await assert.rejects(createEphemeralGraphSnapshotWithOperations({ + logicalRepo: { repoId: "target" }, + sourceUniverse: { paths: ["src/a.py"], complete: false, message: "tree truncated" }, + readFile: () => ({ status: "found", content: "VALUE = 1\n" }) + }, operations()), /tree truncated/); + + let snapshotRepo; + await assert.rejects(createEphemeralGraphSnapshotWithOperations({ + logicalRepo: { repoId: "target" }, + sourceUniverse: { paths: ["src/a.py"], complete: true }, + readFile: () => ({ status: "found", content: "VALUE = 1\n" }) + }, operations({ + build: (repo) => { + snapshotRepo = repo.repoRoot; + throw new Error("forced build failure"); + } + })), /forced build failure/); + assert.equal(existsSync(snapshotRepo), false); + + for (const testCase of [ + { paths: ["a.py", "b.py"], limits: { maxFiles: 1 }, message: /maxFiles/ }, + { paths: ["a/b/c.py"], limits: { maxDepth: 2 }, message: /maxDepth/ }, + { paths: ["a.py"], limits: { maxBytes: 1 }, message: /maxBytes/ } + ]) { + await assert.rejects(createEphemeralGraphSnapshotWithOperations({ + logicalRepo: { repoId: "target" }, + sourceUniverse: { paths: testCase.paths, complete: true }, + readFile: () => ({ status: "found", content: "VALUE = 1\n" }), + limits: testCase.limits + }, operations()), testCase.message); + } + }); + + it("matches real on-disk Python import edges for add, remove, retarget, and rename-style states without touching the target", async () => { + const target = mkdtempSync(join(tmpdir(), "opcore-graph-snapshot-target-")); + try { + writeTree(target, new Map([ + ["pkg/__init__.py", ""], + ["pkg/app.py", "from pkg import old\n"], + ["pkg/old.py", "VALUE = 'old'\n"], + [".opcore/graph/sentinel", "persistent-graph\n"] + ])); + const targetBefore = targetEvidence(target); + const states = [ + new Map([ + ["pkg/__init__.py", ""], + ["pkg/app.py", "from pkg import new\n"], + ["pkg/old.py", "VALUE = 'old'\n"], + ["pkg/new.py", "VALUE = 'new'\n"] + ]), + new Map([ + ["pkg/__init__.py", ""], + ["pkg/app.py", "VALUE = 1\n"] + ]), + new Map([ + ["pkg/__init__.py", ""], + ["pkg/app.py", "from pkg import renamed\n"], + ["pkg/renamed.py", "VALUE = 'renamed'\n"] + ]) + ]; + + for (const state of states) { + assert.deepEqual(await ephemeralImportEdges(target, state), materializedImportEdges(state)); + } + assert.deepEqual(targetEvidence(target), targetBefore); + } finally { + rmSync(target, { recursive: true, force: true }); + } + }); + + it("produces the same introduced graph diagnostic as the equivalent materialized repository and preserves the persistent target graph", async () => { + const target = mkdtempSync(join(tmpdir(), "opcore-exact-validation-target-")); + const materialized = mkdtempSync(join(tmpdir(), "opcore-exact-validation-after-")); + try { + const beforeFiles = new Map([ + ["pkg/__init__.py", ""], + ["pkg/app.py", "from pkg import old\n"], + ["pkg/old.py", "VALUE = 'old'\n"] + ]); + const afterFiles = new Map([ + ["pkg/__init__.py", ""], + ["pkg/app.py", "from pkg import new\n"], + ["pkg/old.py", "VALUE = 'old'\n"], + ["pkg/new.py", "VALUE = 'new'\n"] + ]); + writeTree(target, beforeFiles); + writeTree(materialized, afterFiles); + assert.equal(graphProviderBuild({ repoRoot: target }).status.state, "available"); + assert.equal(graphProviderBuild({ repoRoot: materialized }).status.state, "available"); + const targetGraphBefore = directoryEvidence(join(target, ".opcore/graph")); + const targetSourceBefore = readFileSync(join(target, "pkg/app.py"), "utf8"); + const client = realGraphClient(); + const check = newImportCheck(); + const exact = await createValidationRunner({ + workspace: validationWorkspace(beforeFiles), + checks: [check], + graphProviderClient: client, + graphSessionFactory: createStateAwareValidationGraphSessionFactory({ + persistentClient: client, + exactSnapshotFactory: createValidationExactGraphSnapshotFactory(createEphemeralGraphSnapshot) + }) + }).runValidation(validationRequest(target, { + reportMode: "introduced", + overlays: [ + { path: "pkg/app.py", action: "write", content: afterFiles.get("pkg/app.py") }, + { path: "pkg/new.py", action: "write", content: afterFiles.get("pkg/new.py") } + ] + })); + const onDisk = await createValidationRunner({ + workspace: validationWorkspace(afterFiles), + checks: [check], + graphProviderClient: client + }).runValidation(validationRequest(materialized)); + + assert.equal(exact.status, "policy_failure", JSON.stringify(exact, null, 2)); + assert.deepEqual(exact.diagnostics, onDisk.diagnostics); + assert.equal(readFileSync(join(target, "pkg/app.py"), "utf8"), targetSourceBefore); + assert.deepEqual(directoryEvidence(join(target, ".opcore/graph")), targetGraphBefore); + } finally { + rmSync(target, { recursive: true, force: true }); + rmSync(materialized, { recursive: true, force: true }); + } + }); +}); + +function newImportCheck() { + return { + id: "python.new-import", + owner: "validation", + adapter: "python", + defaultSeverity: "error", + supportedScopes: ["files"], + requiresGraph: true, + graphRequirements: () => [{ operation: "factQuery", selector: { kind: "edges", edgeKinds: ["IMPORTS_FROM"] } }], + run: async (context) => ({ + diagnostics: (await context.graph.importsFrom()) + .filter((edge) => edge.from === "file:pkg/app.py" && edge.to === "file:pkg/new.py") + .map(() => ({ + category: "graph", + severity: "error", + path: "pkg/app.py", + code: "PY_NEW_IMPORT", + message: "pkg/app.py newly imports pkg/new.py" + })) + }) + }; +} + +function validationRequest(repoRoot, overrides = {}) { + return { + requestId: "exact-validation", + repo: { repoRoot }, + scope: { kind: "files", files: ["pkg/app.py"] }, + graph: { mode: "required", provider: "opcore-graph" }, + overlays: [], + checks: ["python.new-import"], + reportMode: "all", + ...overrides + }; +} + +function validationWorkspace(files) { + return { + readFile: (path) => files.has(path) ? { status: "found", content: files.get(path) } : { status: "missing" }, + listFiles: () => ({ files: [...files.keys()] }) + }; +} + +function realGraphClient() { + return { + status: (request) => ({ ...graphProviderBuildStatus(request.repo), mode: request.graph.mode }), + factQuery: (request) => graphProviderQuery(request.repo, request.selector), + namedQuery: () => { throw new Error("unused namedQuery"); }, + impact: () => { throw new Error("unused impact"); }, + reviewContext: () => { throw new Error("unused reviewContext"); }, + detectChanges: () => { throw new Error("unused detectChanges"); } + }; +} + +function graphProviderBuildStatus(repo) { + const query = graphProviderQuery(repo, { kind: "nodes", limit: 1 }); + return query.status; +} + +async function ephemeralImportEdges(target, files) { + const snapshot = await createEphemeralGraphSnapshot({ + logicalRepo: { repoRoot: target }, + sourceUniverse: { paths: [...files.keys()], complete: true }, + readFile: (path) => files.has(path) ? { status: "found", content: files.get(path) } : { status: "missing" } + }); + try { + const result = snapshot.factQuery({ + requestId: "ephemeral-imports", + repo: { repoRoot: target }, + schemaVersion: 1, + mode: "required", + selector: { kind: "edges", edgeKinds: ["IMPORTS_FROM"] } + }); + assert.equal(result.status.state, "available"); + return importEdgePairs(result.edges); + } finally { + snapshot.dispose(); + } +} + +function materializedImportEdges(files) { + const root = mkdtempSync(join(tmpdir(), "opcore-graph-snapshot-parity-")); + try { + writeTree(root, files); + assert.equal(graphProviderBuild({ repoRoot: root }).status.state, "available"); + const result = graphProviderQuery({ repoRoot: root }, { kind: "edges", edgeKinds: ["IMPORTS_FROM"] }); + assert.equal(result.status.state, "available"); + return importEdgePairs(result.edges); + } finally { + rmSync(root, { recursive: true, force: true }); + } +} + +function importEdgePairs(edges) { + return edges + .filter((edge) => edge.kind === "IMPORTS_FROM" && edge.from.startsWith("file:pkg/") && edge.to.startsWith("file:pkg/")) + .map((edge) => `${edge.from}->${edge.to}`) + .sort(); +} + +function writeTree(root, files) { + for (const [path, content] of files) { + const absolute = join(root, path); + mkdirSync(dirname(absolute), { recursive: true }); + writeFileSync(absolute, content); + } +} + +function targetEvidence(root) { + return { + app: readFileSync(join(root, "pkg/app.py"), "utf8"), + old: readFileSync(join(root, "pkg/old.py"), "utf8"), + graph: readFileSync(join(root, ".opcore/graph/sentinel"), "utf8") + }; +} + +function directoryEvidence(root) { + const evidence = {}; + for (const path of walk(root)) { + const relativePath = path.slice(root.length + 1); + evidence[relativePath] = createHash("sha256").update(readFileSync(path)).digest("hex"); + } + return evidence; +} + +function walk(root) { + const paths = []; + for (const entry of readdirSync(root, { withFileTypes: true })) { + const path = join(root, entry.name); + if (entry.isDirectory()) paths.push(...walk(path)); + else if (entry.isFile() && statSync(path).isFile()) paths.push(path); + } + return paths.sort(); +} + +function operations(overrides = {}) { + return { + build: () => ({ status: graphStatus() }), + factQuery: () => { throw new Error("unused query"); }, + ...overrides + }; +} + +function graphStatus() { + return { + state: "available", + mode: "required", + provider: "opcore-graph", + schemaVersion: 1, + freshness: { generatedAt: "2026-07-16T00:00:00.000Z", ageMs: 0, stale: false }, + nodes_by_kind: {}, + edges_by_kind: {} + }; +} diff --git a/tests/python-import-composition.test.mjs b/tests/python-import-composition.test.mjs index 5a72bfd..5740c9b 100644 --- a/tests/python-import-composition.test.mjs +++ b/tests/python-import-composition.test.mjs @@ -2,7 +2,10 @@ import { describe, it } from "node:test"; import assert from "node:assert/strict"; import { defaultAspProviderValidationChecks } from "../packages/asp-provider/dist/validation-composition.js"; import { defaultValidationChecks as defaultOpcoreValidationChecks } from "../packages/opcore/dist/repo-validation-policy.js"; +import { createOpcoreValidationGraphSessionFactory } from "../packages/opcore/dist/validation-composition.js"; +import { createCliValidationGraphSessionFactory } from "../packages/opcore/dist/advanced/validation-composition.js"; import { createBuiltInValidationChecks } from "../packages/validation-policy/dist/index.js"; +import { createValidationFileView } from "../packages/validation/dist/index.js"; describe("Python import analyzer composition", () => { it("forwards the structural analyzer through validation-policy", async () => { @@ -25,6 +28,37 @@ describe("Python import analyzer composition", () => { await importGraphRequirements(defaultOpcoreValidationChecks); await importGraphRequirements(defaultAspProviderValidationChecks); }); + + it("injects exact overlay graph snapshots into product and advanced validation composition", async () => { + const request = { + requestId: "composition-exact", + repo: { repoId: "composition-exact" }, + scope: { kind: "files", files: ["pkg/app.py"] }, + graph: { mode: "required", provider: "opcore-graph" }, + overlays: [{ path: "pkg/new.py", action: "write", content: "VALUE = 1\n" }], + checks: ["python.import-graph"] + }; + const files = new Map([ + ["pkg/__init__.py", ""], + ["pkg/app.py", "from pkg import new\n"] + ]); + const workspace = { + readFile: (path) => files.has(path) ? { status: "found", content: files.get(path) } : { status: "missing" }, + listFiles: () => ({ files: [...files.keys()] }) + }; + const scope = { kind: "files", files: ["pkg/app.py", "pkg/new.py"], workspaceFiles: [{ path: "pkg/app.py" }, { path: "pkg/new.py" }] }; + const fileView = await createValidationFileView({ request, scope, workspace }); + + for (const factory of [createOpcoreValidationGraphSessionFactory(), createCliValidationGraphSessionFactory()]) { + const session = await factory({ request, fileView, identity: { kind: "exact", state: "after" } }); + try { + assert.equal(session.identity.kind, "exact"); + assert.ok((await session.importsFrom()).some((edge) => edge.from === "file:pkg/app.py" && edge.to === "file:pkg/new.py")); + } finally { + await session.dispose(); + } + } + }); }); async function importGraphRequirements(checks) { diff --git a/tests/validation-overlays.test.mjs b/tests/validation-overlays.test.mjs index c423503..3b8d9a0 100644 --- a/tests/validation-overlays.test.mjs +++ b/tests/validation-overlays.test.mjs @@ -108,6 +108,22 @@ describe("validation overlays", () => { assert.equal(listFileCalls, 1); }); + it("preserves unavailable and truncated full-listing evidence for exact snapshots", async () => { + for (const fileSet of [ + { files: ["src/index.ts"], unavailable: true, message: "host listing unavailable" }, + { files: ["src/index.ts"], truncated: true, message: "host listing truncated" } + ]) { + const workspace = testWorkspace(); + workspace.listFiles = () => fileSet; + const view = await createValidationFileView({ request: request(), scope: scope(["src/index.ts"]), workspace }); + const universe = await view.listVisibleFileUniverse(); + + assert.equal(universe.complete, false); + assert.match(universe.message, /host listing/); + await assert.rejects(view.listCompleteVisibleFiles(), /host listing/); + } + }); + it("distinguishes delete overlays from missing workspace files", async () => { const workspace = testWorkspace({ "src/remove.ts": "export const remove = true;" diff --git a/tests/validation-python.test.mjs b/tests/validation-python.test.mjs index 897254c..bb9acd2 100644 --- a/tests/validation-python.test.mjs +++ b/tests/validation-python.test.mjs @@ -4,7 +4,7 @@ import { chmodSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSyn import { tmpdir } from "node:os"; import { dirname, join, relative } from "node:path"; import { fileURLToPath } from "node:url"; -import { createValidationCheckRegistry, createValidationRunner } from "../packages/validation/dist/index.js"; +import { createValidationCheckRegistry, createValidationGraphQuerySession, createValidationRunner } from "../packages/validation/dist/index.js"; import { PYTHON_DEAD_CODE_CHECK_ID, PYTHON_IMPORT_GRAPH_CHECK_ID, @@ -796,7 +796,7 @@ describe("validation-python adapter", () => { assert.match(result.diagnostics[0].message, /pkg\/app\.py -> pkg\/dep\.py/); }); - it("uses one canonical analyzer pass over the complete after-state for import graph validation", async () => { + it("reuses the exact after-state graph session instead of a second analyzer pass", async () => { const analysisCalls = []; const importAnalyzer = { async analyze(files) { @@ -807,6 +807,9 @@ describe("validation-python adapter", () => { const graphEdges = [ { kind: "IMPORTS_FROM", from: "file:pkg/app.py", to: "file:pkg/new.py" } ]; + const client = graphClient({ + factQuery: (query) => availableFactResult(query, [], graphEdges) + }); const result = await runner({ files: { "pkg/__init__.py": "", @@ -814,9 +817,8 @@ describe("validation-python adapter", () => { "pkg/old.py": "OLD = True\n" }, checks: createPythonValidationChecks({ importAnalyzer }), - graphProviderClient: graphClient({ - factQuery: (query) => availableFactResult(query, [], graphEdges) - }) + graphProviderClient: client, + graphSessionFactory: exactGraphSessionFactory(client) }).runValidation(request({ checks: [PYTHON_IMPORT_GRAPH_CHECK_ID], scope: { kind: "files", files: ["pkg/app.py"] }, @@ -833,24 +835,19 @@ describe("validation-python adapter", () => { assert.equal(result.status, "passed", JSON.stringify(result, null, 2)); assert.deepEqual(result.diagnostics, []); - assert.equal(analysisCalls.length, 1); - assert.deepEqual(analysisCalls[0].map((file) => file.path), [ - "pkg/__init__.py", - "pkg/app.py", - "pkg/new.py", - "pkg/old.py" - ]); - assert.equal(analysisCalls[0].find((file) => file.path === "pkg/app.py").content, "from pkg import (\n new,\n)\n"); + assert.equal(analysisCalls.length, 0); }); it("fails import graph validation closed when the canonical analyzer is missing", async () => { const checks = createCanonicalPythonValidationChecks({ nodeWorkspace: canonicalTestPythonWorkspace() }); + const client = graphClient(); const result = await runner({ files: { "pkg/app.py": "VALUE = 1\n" }, checks, - graphProviderClient: graphClient() + graphProviderClient: client, + graphSessionFactory: exactGraphSessionFactory(client) }).runValidation(request({ checks: [PYTHON_IMPORT_GRAPH_CHECK_ID], scope: { kind: "files", files: ["pkg/app.py"] } @@ -918,6 +915,7 @@ describe("validation-python adapter", () => { it("excludes deleted Python files from canonical after-state analysis", async () => { const calls = []; + const client = graphClient(); const result = await runner({ files: { "pkg/app.py": "from .dep import VALUE\n", @@ -931,7 +929,8 @@ describe("validation-python adapter", () => { } } }), - graphProviderClient: graphClient() + graphProviderClient: client, + graphSessionFactory: exactGraphSessionFactory(client) }).runValidation(request({ checks: [PYTHON_IMPORT_GRAPH_CHECK_ID], scope: { kind: "files", files: ["pkg/app.py"] }, @@ -939,7 +938,7 @@ describe("validation-python adapter", () => { })); assert.equal(result.status, "passed"); - assert.deepEqual(calls[0].map((file) => file.path), ["pkg/app.py"]); + assert.equal(calls.length, 0); assert.deepEqual(result.diagnostics, []); }); @@ -2274,7 +2273,16 @@ function runner(options = {}) { return createValidationRunner({ workspace: workspace(options), checks: options.checks ?? createPythonValidationChecks(), - graphProviderClient: options.graphProviderClient + graphProviderClient: options.graphProviderClient, + graphSessionFactory: options.graphSessionFactory + }); +} + +function exactGraphSessionFactory(client) { + return (args) => createValidationGraphQuerySession({ + ...args, + client, + status: availableStatus(args.request.graph.mode, args.request.repo) }); } diff --git a/tests/validation-runner.test.mjs b/tests/validation-runner.test.mjs index ea36f15..34ab213 100644 --- a/tests/validation-runner.test.mjs +++ b/tests/validation-runner.test.mjs @@ -1,6 +1,6 @@ import { describe, it } from "node:test"; import assert from "node:assert/strict"; -import { createValidationRunner } from "../packages/validation/dist/index.js"; +import { createValidationGraphQuerySession, createValidationRunner } from "../packages/validation/dist/index.js"; describe("validation runner", () => { it("returns invalid_payload for malformed requests without throwing validator errors", async () => { @@ -761,6 +761,101 @@ describe("validation runner", () => { assert.equal(required.graphStatus.state, "schema_mismatch"); assert.equal(required.failure.cause, "schema_mismatch"); }); + + it("builds and disposes one exact graph session per introduced file-view state and shares it across checks", async () => { + const factoryStates = []; + const disposedStates = []; + const observations = []; + const exactFactory = async (args) => { + factoryStates.push(args.identity.state); + const session = await createValidationGraphQuerySession({ + ...args, + status: availableStatus(args.request.graph.mode, args.request.repo), + client: graphClient() + }); + return { ...session, dispose: () => disposedStates.push(args.identity.state) }; + }; + const checks = ["graph.first", "graph.second"].map((id) => check(id, { + requiresGraph: true, + run: (context) => { + observations.push(`${id}:${context.graph.identity.state}`); + return { diagnostics: [] }; + } + })); + + const result = await runner(checks, { graphSessionFactory: exactFactory, onCheckComplete: () => {} }).runValidation(request({ + checks: undefined, + reportMode: "introduced", + overlays: [{ path: "src/index.ts", action: "write", content: "export const value = 'after';" }] + })); + + assert.equal(result.status, "passed"); + assert.deepEqual(factoryStates, ["before", "after"]); + assert.deepEqual(observations, [ + "graph.first:before", + "graph.first:after", + "graph.second:before", + "graph.second:after" + ]); + assert.deepEqual(disposedStates, ["after", "before"]); + }); + + it("uses separate exact graph sessions for introduced file-view states without overlays", async () => { + const identities = []; + const result = await runner([ + check("graph.required", { requiresGraph: true }) + ], { + graphSessionFactory: async (args) => { + identities.push(args.identity); + return createValidationGraphQuerySession({ + ...args, + status: availableStatus(args.request.graph.mode, args.request.repo), + client: graphClient() + }); + } + }).runValidation(request({ + checks: ["graph.required"], + reportMode: "introduced" + })); + + assert.equal(result.status, "passed"); + assert.deepEqual(identities, [ + { kind: "exact", state: "before" }, + { kind: "exact", state: "after" } + ]); + }); + + it("fails closed for exact-state graph query errors even when persistent graph mode is optional", async () => { + let disposed = 0; + const result = await runner([ + check("graph.optional", { + graphUsage: "optional", + run: async (context) => { + await context.graph.facts({ kind: "nodes" }); + } + }), + check("syntax") + ], { + graphSessionFactory: async (args) => { + const session = await createValidationGraphQuerySession({ + ...args, + status: availableStatus("optional", args.request.repo), + client: graphClient({ + factQuery: () => ({ status: graphFailure("error", "query_failed", "optional") }) + }) + }); + return { ...session, dispose: () => { disposed += 1; } }; + } + }).runValidation(request({ + checks: undefined, + overlays: [{ path: "src/index.ts", action: "write", content: "export const value = 'after';" }] + })); + + assert.equal(result.status, "provider_failure"); + assert.equal(result.ok, false); + assert.equal(result.failure.category, "provider_failure"); + assert.equal(disposed, 1); + }); }); function runner(checks, options = {}) {