diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 62a289f..b80f9f8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -107,11 +107,16 @@ jobs: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} RELEASE_SHA: ${{ github.event.workflow_run.head_sha }} run: | + release_notes="docs/release/v${RELEASE_VERSION}.md" + if [[ ! -f "$release_notes" ]]; then + echo "::error::Missing release notes: $release_notes" + exit 1 + fi if gh release view "v${RELEASE_VERSION}" >/dev/null 2>&1; then echo "GitHub release v${RELEASE_VERSION} already exists." else gh release create "v${RELEASE_VERSION}" \ --target "$RELEASE_SHA" \ --title "Opcore ${RELEASE_VERSION}" \ - --notes "Initial Opcore alpha release." + --notes-file "$release_notes" fi diff --git a/AGENTS.md b/AGENTS.md index 7c79da0..5a317ac 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -83,7 +83,7 @@ Opcore is the code-intelligence and robustness monorepo for graph context, edit - `npm run test:ci` routes through `scripts/run-test-ci.mjs`: it runs the parallel-safe Node test files first, then runs `tests/native-packaging-policy.test.mjs` separately with receipt gates skipped - WHY: the native packaging policy test intentionally mutates native package artifacts while exercising aggregate dry-run failures, so folding it back into parallel `node --test tests/*.test.mjs` reintroduces a race. - Graph-owned transitional `opcore graph serve --repo ` starts the graph package stdio/MCP bridge over graph-core JSONL; `--repo` defaults to cwd, supports ping/status/query/search/shutdown, injects missing nested query repos, and returns typed startup/frame/provider failures. - #126 ships graph-core through bundled internal Opcore native packages `@the-open-engine/opcore-graph-core-darwin-arm64`, `@the-open-engine/opcore-graph-core-darwin-x64`, and `@the-open-engine/opcore-graph-core-linux-x64`; `packages/graph` resolves only matching package metadata and never probes `packages/graph/dist/native`, sibling checkouts, `.ace/runtime`, or PATH. -- Release flow is `dev -> main`: CI runs on `dev` and `main`, PRs to `main` must come from `dev`, and `.github/workflows/release.yml` auto-publishes npm package version `0.1.0` with dist-tag `latest` after the `CI` workflow succeeds on `main`. The CI native jobs upload tarred native package directories so `opcore-graph-core` execute bits survive artifact transfer; aggregate CI and release publish must set `OPCORE_REQUIRE_ALL_NATIVE_PACKAGES=1` after extracting all three native artifacts, and `scripts/release-dry-run.mjs` then validates package-local executable binaries/checksums without rebuilding graph-core - WHY: aggregate and publish proof must consume runnable per-target artifacts produced by native jobs, not a local Linux rebuild or non-executable download. +- Release flow is `dev -> main`: CI runs on `dev` and `main`, PRs to `main` must come from `dev`, and `.github/workflows/release.yml` auto-publishes npm package version `0.2.0` with dist-tag `latest` after the `CI` workflow succeeds on `main`. Each release must provide readable notes at `docs/release/v.md`; the workflow uses that file verbatim for the GitHub release. The CI native jobs upload tarred native package directories so `opcore-graph-core` execute bits survive artifact transfer; aggregate CI and release publish must set `OPCORE_REQUIRE_ALL_NATIVE_PACKAGES=1` after extracting all three native artifacts, and `scripts/release-dry-run.mjs` then validates package-local executable binaries/checksums without rebuilding graph-core - WHY: aggregate and publish proof must consume runnable per-target artifacts produced by native jobs, not a local Linux rebuild or non-executable download. - #19 keeps `OPCORE_GRAPH_WATCH_PATHS` as the only watch env default and ignores `CRG_WATCH_PATHS` - WHY: Opcore watch roots must not inherit old-tool scoping accidentally. - #19 graph discovery excludes generated/private/dependency roots even without repo ignore files: `.git`, `node_modules`, `.pnpm`, `vendor`, `dist`, `target`, `.ace`, `.agents`, `.claude`, `.codex`, `.gemini`, `.lattice`, `.opencode`, `.rox-cache`, and `.robustness-engine-cache` - WHY: cache/vendor/provider mirror changes must not create graph facts, freshness changes, validation input, or FTS rows. - #17/#19/#21 source/coverage policy is reconciled across graph-core, validation, and Opcore status/metrics: graph-extractable TypeScript, JavaScript, Python `.py`/`.pyi`, and Rust `.rs`; validation-supported TS/JS variants, Python source/stubs, Rust source/includes, and `Cargo.toml`; retained `Cargo.lock`; unsupported/degraded stacks and missing Python tools are counted honestly. @@ -103,7 +103,7 @@ Opcore is the code-intelligence and robustness monorepo for graph context, edit - #31 latency telemetry contracts live in `packages/contracts`: `CommandTiming`, `RepoShapeFingerprint`, `CommandLatencyRecord`, `LatencyBudget`, and `LatencyBudgetResult` must stay source-safe, schema/validator/test aligned, and `.opcore/telemetry.jsonl` must remain ring-buffer bounded to 500 records or 1 MiB. Telemetry `bin` is the normalized public bin name and `canonicalCommand` is sanitized command identity, not raw argv or path operands. - #32 router timing wraps `routeOpcoreCommand` and advanced `routeCommand`; JSON `CommandRouterResult` payloads carry `timing.durationMs`, normalized phases, and process-local `processState` where first routed command in the Node process is `cold` and later routes are `warm`. Only `opcore` scan writes bounded `.opcore/telemetry.jsonl` capped at 500 records or 1 MiB; `opcore status` and `opcore measure` stay read-only and surface timing in JSON only. - #130 Opcore metrics aggregate named, drillable signals from supplied validation and graph evidence, write only `.opcore/report.json` plus append-only `.opcore/history.jsonl`, and expose read-only `opcore measure [--repo ] [--json]` deltas through `opcoreMeasure` - WHY: reports must show honest coverage/signals/degradations/history without scores, source edits, setup writes, validation runs, or graph builds. -- #151 clone detection is owned by the existing `opcore-graph-core clone` subcommand and `@the-open-engine/opcore-validation-clone`. The native subcommand speaks `opcore.clone.v1`, writes committed-only `.opcore/clone/clone.db` indexes for unscoped clean baseline analysis, and must keep scoped or hypothetical overlay analysis ephemeral. The validation adapter owns `clone.duplication`, uses `ValidationCheckContext.fileView.readAfter` and `fileView.visibleFiles` to materialize state-consistent peer overlays, requiresGraph:false, emits line-free duplicate-code diagnostics, and must avoid SAST/security language, blended scores, new daemons, and new native packages - WHY: clone parity needs a small proofable native path without widening launch claims or runtime surface. +- #151/#214 clone detection is owned by the existing `opcore-graph-core clone` subcommand and `@the-open-engine/opcore-validation-clone`. The native subcommand speaks `opcore.clone.v1`, writes committed-only `.opcore/clone/clone.db` indexes for unscoped clean baseline analysis, and must keep scoped or hypothetical overlay analysis ephemeral. The validation adapter owns `clone.duplication`, sends committed source candidates as sparse `paths`/`sourcePaths` metadata, includes content only for true write overlays, uses `sourceReadMode`/`sourceTreeRef` for staged/tree/before-state reads, requiresGraph:false, emits line-free duplicate-code diagnostics, and must avoid SAST/security language, blended scores, new daemons, and new native packages - WHY: clone parity needs a small proofable native path without widening launch claims, graph requirements, or Node memory to full repo source bytes. - #34 Opcore latency budgets live in `docs/performance/latency-budgets.json` and are checked by `npm run latency:check`; the checker validates source-safe `CommandLatencyRecord` JSONL and emits per-command/per-phase `LatencyBudgetResult` evidence in default non-blocking trend mode, with `--fail-on-over` reserved for explicit blocking promotion - WHY: performance budgets must be drillable regression evidence, not an opaque score or live measurement runner. - #36 `opcore measure` reads bounded `.opcore/telemetry.jsonl` plus latency budgets and emits `opcoreMeasure.latency.findings[]` for slower or over-budget command/phase observations only; it must not write artifacts, run checks, or emit `ok` latency rows - WHY: slow actions need drillable evidence without turning measure into a runner or score. - #137 `opcore graph serve` writes one bounded `CommandLatencyRecord` per forwarded child frame through a product-CLI-injected telemetry writer, with canonical commands shaped as `opcore graph serve ` and per-op phase ids such as `serve_query`; `npm run latency:check` includes serve and inspect budgets in non-blocking trend mode - WHY: long-lived serve/inspect performance claims need before/after evidence without reintroducing a daemon or bypassing the existing telemetry contract. @@ -169,11 +169,11 @@ Opcore is the code-intelligence and robustness monorepo for graph context, edit - #139 keeps `opcore graph serve` state process-local: a serve session caches read-only GraphStore connections, freshness status, snapshots, and GraphIndex by canonical repo root plus snapshot `generated_at`/DB mtime; stale status clears cached graph data; search must not force snapshot loads, and no socket/background daemon is introduced - WHY: repeated frames need hot queries without changing one-shot or hypothetical overlay behavior. - Validation contracts are owned by `@the-open-engine/opcore-validation` without runner or CLI behavior: ValidationScope supports files, changed, staged, all, repo, and package; hypothetical overlays are write/delete only, with rename-style edit preflight represented as delete old path plus write new path; `ValidationRequest.reportMode` defaults to `all`, while edit/pre-write validation should use `introduced` so only after-state diagnostics absent from before-state fingerprints block edits; graph config distinguishes optional and required provider modes with typed required_missing, stale, schema_mismatch, daemon_unavailable, incompatible_provider, and provider_error failures; @the-open-engine/opcore-validation owns request normalization and result skeleton helpers while depending only on @the-open-engine/opcore-contracts. - #55 adds the dependency-injected validation runner, check registry, scope resolver, aggregation helpers, check manifest metadata, run summaries, skipped-check records, and timing metadata; it still must not import graph, edit, CLI, validation-typescript, graph-core/native, or raw SQLite internals. -- #56 adds the validation file view: `@the-open-engine/opcore-validation` composes normalized `ValidationRequest` overlays, resolved scope files, and injected workspace `readFile` access so `opcore validate` checks can read hypothetical after-state writes/deletes, before-state comparisons for introduced report mode, and before-state checksums without mutating the worktree. `checksumBefore` conflicts return refused/conflict before checks run. +- #56 adds the validation file view: `@the-open-engine/opcore-validation` composes normalized `ValidationRequest` overlays, resolved scope files, and injected workspace `readFile` access so `opcore validate` checks can read hypothetical after-state writes/deletes, before-state comparisons for introduced report mode, and before-state checksums without mutating the worktree. `ValidationFileView.defaultReadState` is check-visible so adapters with out-of-band readers can bind their before/after source mode to the runner pass. `checksumBefore` conflicts return refused/conflict before checks run. - #26 adds the validation-owned GraphProvider consumer boundary: `ValidationGraphProviderClient`, cached `ValidationGraphQuerySession`, graph requirement preloading, status/query failure mapping, and helper access for metadata, file checksums, IMPORTS_FROM, CALLS, and TESTED_BY facts. - #57 adds the TypeScript validation adapter: `@the-open-engine/opcore-validation-typescript` exports package-owned syntax, type, import-graph, dead-code, and relevant-tests check definitions. Syntax/type checks use the validation file view and an overlay-aware compiler host, including tsconfig path aliases for repo files and deterministic node_modules/package declaration resolution for external package imports; graph checks require #26 graph sessions and batched IMPORTS_FROM, CALLS, and TESTED_BY fact requirements. - #20 adds the Rust validation adapter: `@the-open-engine/opcore-validation-rust` exports package-owned source hygiene, fmt, cargo-check, clippy, rustdoc, import-graph, dead-code, graph-signals, unused-deps, file-length, and function-metrics checks. `rust.graph-signals` is graph-provider-backed evidence from `ValidationGraphProviderClient` only (untested public Rust surface, dead public exports, module orphans/cycles); it does not replace mechanical cargo/rustdoc/clippy/Rox guardrails. #61 makes the five retained Rust rows native when supporting tools are available: rustdoc diagnostics block through `cargo doc`, import-graph reports unresolved modules/use paths/orphans/cycles from fileView, dead-code combines Cargo `dead_code` with orphan-source evidence, unused-deps parses cargo-udeps with workspace/package scoping, and function-metrics parses rust-code-analysis JSON object/array output. Missing `rustdoc`, `cargo-depgraph`, `cargo-udeps`, or `rust-code-analysis-cli` stays degraded or unsupported with `requiredTool` and retained `currentUsage`; no generic retained row remains for available tools. Rust checks materialize temporary workspaces from `ValidationCheckContext.fileView` after-state content for Cargo tools, add no validation daemon or hidden cache, never shell out to Rox, and keep current external Rust guardrails active until #29/#30 accept replacement receipts. #138 shares cargo-check JSON with dead-code, forbids a second dead-code cargo compile or `-Ddead_code` cache-busting flags, and injects a persistent `CARGO_TARGET_DIR` outside the worktree through `runTool`; cache keys must derive from generic repo, scope, overlay, platform, and toolchain inputs and cleanup must remain TTL/size bounded - WHY: Rust validation must be fast without leaking hypothetical after-state into real worktrees or tuning behavior to one repository. -- #151 adds clone detection through the existing `opcore-graph-core` binary as a `clone` subcommand plus `@the-open-engine/opcore-validation-clone`. `CloneAnalysisRequest`/`CloneAnalysisResult` use `opcore.clone.v1`; committed analysis may refresh `.opcore/clone/clone.db`, while hypothetical overlays are analyzed only in memory. `clone.duplication` is a validation adapter check with injected native invocation, no graph requirement, no line-level identity, no daemon, and no SAST/security or score claim. +- #151/#214 adds clone detection through the existing `opcore-graph-core` binary as a `clone` subcommand plus `@the-open-engine/opcore-validation-clone`. `CloneAnalysisRequest`/`CloneAnalysisResult` use `opcore.clone.v1`; committed analysis may refresh `.opcore/clone/clone.db`, while scoped or hypothetical analysis is ephemeral. The sparse request shape sends committed candidates as `paths`/`sourcePaths` plus `sourceReadMode`/`sourceTreeRef`, with full content only in write overlays. `clone.duplication` is a validation adapter check with injected native invocation, no graph requirement, no line-level identity, no daemon, and no SAST/security or score claim. - #27 adds canonical validation CLI surfaces: `opcore check` implements `files`, `staged`, `changed`, `tree`, `all`, and `manifest`; `tree` reads committed Git tree content from `--tree ` and scopes files from `--changed-from ` without consuming dirty worktree files. `opcore validate` implements `--request-file`, `hypothetical --request-file`, `pre-write --request-file --timeout-ms --json`, and `manifest`; runtime-owned `opcore status --json` includes `repoState`, while `opcore doctor --json` includes typed `runtimeInfo`, `opcoreDoctor`, and transitional `validationStatus` payloads with adapter routes, check ids, manifest entries, graph status, and daemon readiness metadata. #58 defines `opcore validate pre-write --request-file --timeout-ms 30000 --json` as the hook-safe, fail-closed pre-write contract with typed `PreWriteValidationReceipt` output. - #69 removes public runtime lifecycle command groups: top-level start and stop are unsupported unless a later architecture decision adopts them. Runtime readiness remains `opcore status` and `opcore doctor`; graph daemon lifecycle/status remains graph-owned under `opcore graph`. - #118 adds bundled `@the-open-engine/opcore-asp-provider` internals and the public `opcore-asp-provider --stdio` bin from the `opcore` package as an independently launchable ASP Core check provider facade. It handles `initialize`, `initialized`, and `check/evaluate`, maps ASP create/modify/delete/rename changesets into validation overlays through host `workspace/listTree` and `workspace/readBlob` callbacks, runs the same TypeScript and Rust validation checks as Opcore validation composition, reports degraded/unsupported coverage for missing graph/toolchain/provider surfaces, emits provider-owned diagnostics, binds `validAsOf` to baseline/changeset/read blobs, and strips host-owned decision/authority/apply fields. It does not add a `opcore asp` router group and must not use ACE descriptors, `.ace/runtime`, `rox`, `crg`, or `cix` as implementation paths. diff --git a/CLAUDE.md b/CLAUDE.md index 7c79da0..5a317ac 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -83,7 +83,7 @@ Opcore is the code-intelligence and robustness monorepo for graph context, edit - `npm run test:ci` routes through `scripts/run-test-ci.mjs`: it runs the parallel-safe Node test files first, then runs `tests/native-packaging-policy.test.mjs` separately with receipt gates skipped - WHY: the native packaging policy test intentionally mutates native package artifacts while exercising aggregate dry-run failures, so folding it back into parallel `node --test tests/*.test.mjs` reintroduces a race. - Graph-owned transitional `opcore graph serve --repo ` starts the graph package stdio/MCP bridge over graph-core JSONL; `--repo` defaults to cwd, supports ping/status/query/search/shutdown, injects missing nested query repos, and returns typed startup/frame/provider failures. - #126 ships graph-core through bundled internal Opcore native packages `@the-open-engine/opcore-graph-core-darwin-arm64`, `@the-open-engine/opcore-graph-core-darwin-x64`, and `@the-open-engine/opcore-graph-core-linux-x64`; `packages/graph` resolves only matching package metadata and never probes `packages/graph/dist/native`, sibling checkouts, `.ace/runtime`, or PATH. -- Release flow is `dev -> main`: CI runs on `dev` and `main`, PRs to `main` must come from `dev`, and `.github/workflows/release.yml` auto-publishes npm package version `0.1.0` with dist-tag `latest` after the `CI` workflow succeeds on `main`. The CI native jobs upload tarred native package directories so `opcore-graph-core` execute bits survive artifact transfer; aggregate CI and release publish must set `OPCORE_REQUIRE_ALL_NATIVE_PACKAGES=1` after extracting all three native artifacts, and `scripts/release-dry-run.mjs` then validates package-local executable binaries/checksums without rebuilding graph-core - WHY: aggregate and publish proof must consume runnable per-target artifacts produced by native jobs, not a local Linux rebuild or non-executable download. +- Release flow is `dev -> main`: CI runs on `dev` and `main`, PRs to `main` must come from `dev`, and `.github/workflows/release.yml` auto-publishes npm package version `0.2.0` with dist-tag `latest` after the `CI` workflow succeeds on `main`. Each release must provide readable notes at `docs/release/v.md`; the workflow uses that file verbatim for the GitHub release. The CI native jobs upload tarred native package directories so `opcore-graph-core` execute bits survive artifact transfer; aggregate CI and release publish must set `OPCORE_REQUIRE_ALL_NATIVE_PACKAGES=1` after extracting all three native artifacts, and `scripts/release-dry-run.mjs` then validates package-local executable binaries/checksums without rebuilding graph-core - WHY: aggregate and publish proof must consume runnable per-target artifacts produced by native jobs, not a local Linux rebuild or non-executable download. - #19 keeps `OPCORE_GRAPH_WATCH_PATHS` as the only watch env default and ignores `CRG_WATCH_PATHS` - WHY: Opcore watch roots must not inherit old-tool scoping accidentally. - #19 graph discovery excludes generated/private/dependency roots even without repo ignore files: `.git`, `node_modules`, `.pnpm`, `vendor`, `dist`, `target`, `.ace`, `.agents`, `.claude`, `.codex`, `.gemini`, `.lattice`, `.opencode`, `.rox-cache`, and `.robustness-engine-cache` - WHY: cache/vendor/provider mirror changes must not create graph facts, freshness changes, validation input, or FTS rows. - #17/#19/#21 source/coverage policy is reconciled across graph-core, validation, and Opcore status/metrics: graph-extractable TypeScript, JavaScript, Python `.py`/`.pyi`, and Rust `.rs`; validation-supported TS/JS variants, Python source/stubs, Rust source/includes, and `Cargo.toml`; retained `Cargo.lock`; unsupported/degraded stacks and missing Python tools are counted honestly. @@ -103,7 +103,7 @@ Opcore is the code-intelligence and robustness monorepo for graph context, edit - #31 latency telemetry contracts live in `packages/contracts`: `CommandTiming`, `RepoShapeFingerprint`, `CommandLatencyRecord`, `LatencyBudget`, and `LatencyBudgetResult` must stay source-safe, schema/validator/test aligned, and `.opcore/telemetry.jsonl` must remain ring-buffer bounded to 500 records or 1 MiB. Telemetry `bin` is the normalized public bin name and `canonicalCommand` is sanitized command identity, not raw argv or path operands. - #32 router timing wraps `routeOpcoreCommand` and advanced `routeCommand`; JSON `CommandRouterResult` payloads carry `timing.durationMs`, normalized phases, and process-local `processState` where first routed command in the Node process is `cold` and later routes are `warm`. Only `opcore` scan writes bounded `.opcore/telemetry.jsonl` capped at 500 records or 1 MiB; `opcore status` and `opcore measure` stay read-only and surface timing in JSON only. - #130 Opcore metrics aggregate named, drillable signals from supplied validation and graph evidence, write only `.opcore/report.json` plus append-only `.opcore/history.jsonl`, and expose read-only `opcore measure [--repo ] [--json]` deltas through `opcoreMeasure` - WHY: reports must show honest coverage/signals/degradations/history without scores, source edits, setup writes, validation runs, or graph builds. -- #151 clone detection is owned by the existing `opcore-graph-core clone` subcommand and `@the-open-engine/opcore-validation-clone`. The native subcommand speaks `opcore.clone.v1`, writes committed-only `.opcore/clone/clone.db` indexes for unscoped clean baseline analysis, and must keep scoped or hypothetical overlay analysis ephemeral. The validation adapter owns `clone.duplication`, uses `ValidationCheckContext.fileView.readAfter` and `fileView.visibleFiles` to materialize state-consistent peer overlays, requiresGraph:false, emits line-free duplicate-code diagnostics, and must avoid SAST/security language, blended scores, new daemons, and new native packages - WHY: clone parity needs a small proofable native path without widening launch claims or runtime surface. +- #151/#214 clone detection is owned by the existing `opcore-graph-core clone` subcommand and `@the-open-engine/opcore-validation-clone`. The native subcommand speaks `opcore.clone.v1`, writes committed-only `.opcore/clone/clone.db` indexes for unscoped clean baseline analysis, and must keep scoped or hypothetical overlay analysis ephemeral. The validation adapter owns `clone.duplication`, sends committed source candidates as sparse `paths`/`sourcePaths` metadata, includes content only for true write overlays, uses `sourceReadMode`/`sourceTreeRef` for staged/tree/before-state reads, requiresGraph:false, emits line-free duplicate-code diagnostics, and must avoid SAST/security language, blended scores, new daemons, and new native packages - WHY: clone parity needs a small proofable native path without widening launch claims, graph requirements, or Node memory to full repo source bytes. - #34 Opcore latency budgets live in `docs/performance/latency-budgets.json` and are checked by `npm run latency:check`; the checker validates source-safe `CommandLatencyRecord` JSONL and emits per-command/per-phase `LatencyBudgetResult` evidence in default non-blocking trend mode, with `--fail-on-over` reserved for explicit blocking promotion - WHY: performance budgets must be drillable regression evidence, not an opaque score or live measurement runner. - #36 `opcore measure` reads bounded `.opcore/telemetry.jsonl` plus latency budgets and emits `opcoreMeasure.latency.findings[]` for slower or over-budget command/phase observations only; it must not write artifacts, run checks, or emit `ok` latency rows - WHY: slow actions need drillable evidence without turning measure into a runner or score. - #137 `opcore graph serve` writes one bounded `CommandLatencyRecord` per forwarded child frame through a product-CLI-injected telemetry writer, with canonical commands shaped as `opcore graph serve ` and per-op phase ids such as `serve_query`; `npm run latency:check` includes serve and inspect budgets in non-blocking trend mode - WHY: long-lived serve/inspect performance claims need before/after evidence without reintroducing a daemon or bypassing the existing telemetry contract. @@ -169,11 +169,11 @@ Opcore is the code-intelligence and robustness monorepo for graph context, edit - #139 keeps `opcore graph serve` state process-local: a serve session caches read-only GraphStore connections, freshness status, snapshots, and GraphIndex by canonical repo root plus snapshot `generated_at`/DB mtime; stale status clears cached graph data; search must not force snapshot loads, and no socket/background daemon is introduced - WHY: repeated frames need hot queries without changing one-shot or hypothetical overlay behavior. - Validation contracts are owned by `@the-open-engine/opcore-validation` without runner or CLI behavior: ValidationScope supports files, changed, staged, all, repo, and package; hypothetical overlays are write/delete only, with rename-style edit preflight represented as delete old path plus write new path; `ValidationRequest.reportMode` defaults to `all`, while edit/pre-write validation should use `introduced` so only after-state diagnostics absent from before-state fingerprints block edits; graph config distinguishes optional and required provider modes with typed required_missing, stale, schema_mismatch, daemon_unavailable, incompatible_provider, and provider_error failures; @the-open-engine/opcore-validation owns request normalization and result skeleton helpers while depending only on @the-open-engine/opcore-contracts. - #55 adds the dependency-injected validation runner, check registry, scope resolver, aggregation helpers, check manifest metadata, run summaries, skipped-check records, and timing metadata; it still must not import graph, edit, CLI, validation-typescript, graph-core/native, or raw SQLite internals. -- #56 adds the validation file view: `@the-open-engine/opcore-validation` composes normalized `ValidationRequest` overlays, resolved scope files, and injected workspace `readFile` access so `opcore validate` checks can read hypothetical after-state writes/deletes, before-state comparisons for introduced report mode, and before-state checksums without mutating the worktree. `checksumBefore` conflicts return refused/conflict before checks run. +- #56 adds the validation file view: `@the-open-engine/opcore-validation` composes normalized `ValidationRequest` overlays, resolved scope files, and injected workspace `readFile` access so `opcore validate` checks can read hypothetical after-state writes/deletes, before-state comparisons for introduced report mode, and before-state checksums without mutating the worktree. `ValidationFileView.defaultReadState` is check-visible so adapters with out-of-band readers can bind their before/after source mode to the runner pass. `checksumBefore` conflicts return refused/conflict before checks run. - #26 adds the validation-owned GraphProvider consumer boundary: `ValidationGraphProviderClient`, cached `ValidationGraphQuerySession`, graph requirement preloading, status/query failure mapping, and helper access for metadata, file checksums, IMPORTS_FROM, CALLS, and TESTED_BY facts. - #57 adds the TypeScript validation adapter: `@the-open-engine/opcore-validation-typescript` exports package-owned syntax, type, import-graph, dead-code, and relevant-tests check definitions. Syntax/type checks use the validation file view and an overlay-aware compiler host, including tsconfig path aliases for repo files and deterministic node_modules/package declaration resolution for external package imports; graph checks require #26 graph sessions and batched IMPORTS_FROM, CALLS, and TESTED_BY fact requirements. - #20 adds the Rust validation adapter: `@the-open-engine/opcore-validation-rust` exports package-owned source hygiene, fmt, cargo-check, clippy, rustdoc, import-graph, dead-code, graph-signals, unused-deps, file-length, and function-metrics checks. `rust.graph-signals` is graph-provider-backed evidence from `ValidationGraphProviderClient` only (untested public Rust surface, dead public exports, module orphans/cycles); it does not replace mechanical cargo/rustdoc/clippy/Rox guardrails. #61 makes the five retained Rust rows native when supporting tools are available: rustdoc diagnostics block through `cargo doc`, import-graph reports unresolved modules/use paths/orphans/cycles from fileView, dead-code combines Cargo `dead_code` with orphan-source evidence, unused-deps parses cargo-udeps with workspace/package scoping, and function-metrics parses rust-code-analysis JSON object/array output. Missing `rustdoc`, `cargo-depgraph`, `cargo-udeps`, or `rust-code-analysis-cli` stays degraded or unsupported with `requiredTool` and retained `currentUsage`; no generic retained row remains for available tools. Rust checks materialize temporary workspaces from `ValidationCheckContext.fileView` after-state content for Cargo tools, add no validation daemon or hidden cache, never shell out to Rox, and keep current external Rust guardrails active until #29/#30 accept replacement receipts. #138 shares cargo-check JSON with dead-code, forbids a second dead-code cargo compile or `-Ddead_code` cache-busting flags, and injects a persistent `CARGO_TARGET_DIR` outside the worktree through `runTool`; cache keys must derive from generic repo, scope, overlay, platform, and toolchain inputs and cleanup must remain TTL/size bounded - WHY: Rust validation must be fast without leaking hypothetical after-state into real worktrees or tuning behavior to one repository. -- #151 adds clone detection through the existing `opcore-graph-core` binary as a `clone` subcommand plus `@the-open-engine/opcore-validation-clone`. `CloneAnalysisRequest`/`CloneAnalysisResult` use `opcore.clone.v1`; committed analysis may refresh `.opcore/clone/clone.db`, while hypothetical overlays are analyzed only in memory. `clone.duplication` is a validation adapter check with injected native invocation, no graph requirement, no line-level identity, no daemon, and no SAST/security or score claim. +- #151/#214 adds clone detection through the existing `opcore-graph-core` binary as a `clone` subcommand plus `@the-open-engine/opcore-validation-clone`. `CloneAnalysisRequest`/`CloneAnalysisResult` use `opcore.clone.v1`; committed analysis may refresh `.opcore/clone/clone.db`, while scoped or hypothetical analysis is ephemeral. The sparse request shape sends committed candidates as `paths`/`sourcePaths` plus `sourceReadMode`/`sourceTreeRef`, with full content only in write overlays. `clone.duplication` is a validation adapter check with injected native invocation, no graph requirement, no line-level identity, no daemon, and no SAST/security or score claim. - #27 adds canonical validation CLI surfaces: `opcore check` implements `files`, `staged`, `changed`, `tree`, `all`, and `manifest`; `tree` reads committed Git tree content from `--tree ` and scopes files from `--changed-from ` without consuming dirty worktree files. `opcore validate` implements `--request-file`, `hypothetical --request-file`, `pre-write --request-file --timeout-ms --json`, and `manifest`; runtime-owned `opcore status --json` includes `repoState`, while `opcore doctor --json` includes typed `runtimeInfo`, `opcoreDoctor`, and transitional `validationStatus` payloads with adapter routes, check ids, manifest entries, graph status, and daemon readiness metadata. #58 defines `opcore validate pre-write --request-file --timeout-ms 30000 --json` as the hook-safe, fail-closed pre-write contract with typed `PreWriteValidationReceipt` output. - #69 removes public runtime lifecycle command groups: top-level start and stop are unsupported unless a later architecture decision adopts them. Runtime readiness remains `opcore status` and `opcore doctor`; graph daemon lifecycle/status remains graph-owned under `opcore graph`. - #118 adds bundled `@the-open-engine/opcore-asp-provider` internals and the public `opcore-asp-provider --stdio` bin from the `opcore` package as an independently launchable ASP Core check provider facade. It handles `initialize`, `initialized`, and `check/evaluate`, maps ASP create/modify/delete/rename changesets into validation overlays through host `workspace/listTree` and `workspace/readBlob` callbacks, runs the same TypeScript and Rust validation checks as Opcore validation composition, reports degraded/unsupported coverage for missing graph/toolchain/provider surfaces, emits provider-owned diagnostics, binds `validAsOf` to baseline/changeset/read blobs, and strips host-owned decision/authority/apply fields. It does not add a `opcore asp` router group and must not use ACE descriptors, `.ace/runtime`, `rox`, `crg`, or `cix` as implementation paths. diff --git a/crates/graph-core/src/clone/analysis.rs b/crates/graph-core/src/clone/analysis.rs index d9e7842..d2b4dad 100644 --- a/crates/graph-core/src/clone/analysis.rs +++ b/crates/graph-core/src/clone/analysis.rs @@ -1,10 +1,14 @@ -use super::{CloneAnalysisRequest, CloneError, CloneFinding, CloneOverlay, CloneReportMode}; +use super::{ + CloneAnalysisRequest, CloneError, CloneFinding, CloneOverlay, CloneReportMode, + CloneSourceReadMode, +}; use crate::extraction::{ discover_sources_for_options, normalize_repo_relative_path, ExtractionOptions, SourceLanguage, }; use sha2::{Digest, Sha256}; use std::collections::{BTreeMap, BTreeSet}; use std::path::Path; +use std::process::Command; #[derive(Debug, Clone)] pub(super) struct CloneSource { @@ -34,7 +38,10 @@ pub(super) fn clone_sources( repo_root: &Path, request: &CloneAnalysisRequest, ) -> Result, CloneError> { - let mut sources = discovered_clone_sources(repo_root)?; + let mut sources = match request.source_paths.as_deref() { + Some(paths) => sparse_clone_sources(repo_root, request, paths)?, + None => discovered_clone_sources(repo_root)?, + }; apply_overlays(&mut sources, repo_root, &request.overlays)?; if !request.exclude.is_empty() { sources.retain(|source| { @@ -203,6 +210,100 @@ fn discovered_clone_sources(repo_root: &Path) -> Result, CloneE Ok(sources) } +fn sparse_clone_sources( + repo_root: &Path, + request: &CloneAnalysisRequest, + paths: &[String], +) -> Result, CloneError> { + let mut sources = Vec::new(); + let read_mode = request + .source_read_mode + .unwrap_or(CloneSourceReadMode::Disk); + let introduced_paths = request.paths.iter().cloned().collect::>(); + for path in paths { + let path = normalize_repo_relative_path(path, "clone request source path") + .map_err(|message| CloneError::InvalidRequest(message.to_string()))?; + let Some(language) = SourceLanguage::from_path(Path::new(&path)) else { + continue; + }; + let Some(content) = read_sparse_source( + repo_root, + &path, + read_mode, + request.source_tree_ref.as_deref(), + )? + else { + continue; + }; + let introduced = introduced_paths.contains(&path); + sources.push(CloneSource { + path, + language: language.as_str().to_string(), + sha256: sha256_hex(content.as_bytes()), + content, + introduced, + }); + } + Ok(sources) +} + +fn read_sparse_source( + repo_root: &Path, + path: &str, + read_mode: CloneSourceReadMode, + source_tree_ref: Option<&str>, +) -> Result, CloneError> { + match read_mode { + CloneSourceReadMode::Disk => read_disk_sparse_source(repo_root, path), + CloneSourceReadMode::GitIndex => read_git_sparse_source(repo_root, &format!(":{path}")), + CloneSourceReadMode::GitTree => { + let tree_ref = source_tree_ref.ok_or_else(|| { + CloneError::InvalidRequest( + "sourceTreeRef is required when sourceReadMode is gitTree".to_string(), + ) + })?; + read_git_sparse_source(repo_root, &format!("{tree_ref}:{path}")) + } + } +} + +fn read_disk_sparse_source(repo_root: &Path, path: &str) -> Result, CloneError> { + match std::fs::read_to_string(repo_root.join(path)) { + Ok(content) => Ok(Some(content)), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(error) => Err(CloneError::Canonicalize(error)), + } +} + +fn read_git_sparse_source(repo_root: &Path, spec: &str) -> Result, CloneError> { + let output = Command::new("git") + .arg("-C") + .arg(repo_root) + .arg("show") + .arg(spec) + .output() + .map_err(CloneError::Canonicalize)?; + if output.status.success() { + return Ok(Some(String::from_utf8_lossy(&output.stdout).into_owned())); + } + let stderr = String::from_utf8_lossy(&output.stderr); + if missing_git_sparse_source(spec, &stderr) { + return Ok(None); + } + Err(CloneError::InvalidRequest(format!( + "clone sparse git source read failed for {spec}: {}", + stderr.trim() + ))) +} + +fn missing_git_sparse_source(spec: &str, stderr: &str) -> bool { + stderr.contains("exists on disk, but not in") + || stderr.contains("does not exist") + || stderr.contains("exists, but not") + || ((spec.starts_with("HEAD:") || spec.starts_with("@:")) + && stderr.contains("invalid object name")) +} + fn apply_overlays( sources: &mut Vec, repo_root: &Path, diff --git a/crates/graph-core/src/clone/mod.rs b/crates/graph-core/src/clone/mod.rs index 6dda74b..02b4e98 100644 --- a/crates/graph-core/src/clone/mod.rs +++ b/crates/graph-core/src/clone/mod.rs @@ -44,6 +44,12 @@ pub struct CloneAnalysisRequest { pub report_mode: CloneReportMode, #[serde(skip_serializing_if = "Vec::is_empty", default)] pub paths: Vec, + #[serde(skip_serializing_if = "Option::is_none", default)] + pub source_paths: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub source_read_mode: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub source_tree_ref: Option, #[serde(skip_serializing_if = "Vec::is_empty", default)] pub overlays: Vec, #[serde(skip_serializing_if = "Option::is_none")] @@ -69,6 +75,14 @@ pub enum CloneReportMode { Introduced, } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum CloneSourceReadMode { + Disk, + GitIndex, + GitTree, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(tag = "action", rename_all = "camelCase")] pub enum CloneOverlay { @@ -207,6 +221,7 @@ fn validate_request(request: &CloneAnalysisRequest) -> Result<(), CloneError> { } validate_positive_options(request)?; validate_request_paths(request)?; + validate_source_read_mode(request)?; validate_modes(&request.modes)?; Ok(()) } @@ -246,6 +261,12 @@ fn validate_request_paths(request: &CloneAnalysisRequest) -> Result<(), CloneErr normalize_repo_relative_path(path, "clone request path") .map_err(|message| CloneError::InvalidRequest(message.to_string()))?; } + if let Some(paths) = &request.source_paths { + for path in paths { + normalize_repo_relative_path(path, "clone request source path") + .map_err(|message| CloneError::InvalidRequest(message.to_string()))?; + } + } for (index, partition) in request.partitions.iter().enumerate() { if partition.is_empty() { return Err(CloneError::InvalidRequest(format!( @@ -266,6 +287,29 @@ fn validate_request_paths(request: &CloneAnalysisRequest) -> Result<(), CloneErr Ok(()) } +fn validate_source_read_mode(request: &CloneAnalysisRequest) -> Result<(), CloneError> { + match request + .source_read_mode + .unwrap_or(CloneSourceReadMode::Disk) + { + CloneSourceReadMode::GitTree => { + if request.source_tree_ref.as_deref().is_none_or(str::is_empty) { + return Err(CloneError::InvalidRequest( + "sourceTreeRef is required when sourceReadMode is gitTree".to_string(), + )); + } + } + CloneSourceReadMode::Disk | CloneSourceReadMode::GitIndex => { + if request.source_tree_ref.is_some() { + return Err(CloneError::InvalidRequest( + "sourceTreeRef is only valid when sourceReadMode is gitTree".to_string(), + )); + } + } + } + Ok(()) +} + fn validate_modes(modes: &[String]) -> Result<(), CloneError> { for mode in modes { if mode.is_empty() { diff --git a/crates/graph-core/src/clone/tests.rs b/crates/graph-core/src/clone/tests.rs index 57b8de9..06b9db2 100644 --- a/crates/graph-core/src/clone/tests.rs +++ b/crates/graph-core/src/clone/tests.rs @@ -156,6 +156,51 @@ fn scoped_no_overlay_requests_are_ephemeral() -> TestResult { Ok(()) } +#[test] +fn sparse_path_list_request_reports_committed_peer_and_applies_write_delete_overlays() -> TestResult +{ + let fixture = clone_fixture()?; + let duplicate = duplicate_block(); + write_source(fixture.path(), "src/peer.ts", &duplicate)?; + write_source(fixture.path(), "src/deleted.ts", &duplicate)?; + init_git_snapshot(fixture.path(), &["src/peer.ts", "src/deleted.ts"])?; + + let mut clone_request = request( + fixture.path(), + CloneReportMode::Introduced, + vec![ + CloneOverlay::Write { + path: "src/new.ts".to_string(), + content: duplicate, + checksum_before: None, + }, + CloneOverlay::Delete { + path: "src/deleted.ts".to_string(), + checksum_before: None, + }, + ], + )?; + clone_request.paths = vec!["src/new.ts".to_string(), "src/deleted.ts".to_string()]; + clone_request.source_paths = Some(vec![ + "src/peer.ts".to_string(), + "src/deleted.ts".to_string(), + "src/new.ts".to_string(), + ]); + + let result = analyze_clones(clone_request)?; + + assert!(!result.persisted); + assert!(result.db_path.is_none()); + assert!(result.findings.iter().any(|finding| { + finding.path == "src/new.ts" && finding.peer_path == "src/peer.ts" && finding.introduced + })); + assert!(result + .findings + .iter() + .all(|finding| finding.path != "src/deleted.ts" && finding.peer_path != "src/deleted.ts")); + Ok(()) +} + #[test] fn clone_exclude_patterns_remove_sources_from_analysis() -> TestResult { let fixture = clone_fixture()?; @@ -263,6 +308,9 @@ fn request( }, report_mode, paths: Vec::new(), + source_paths: None, + source_read_mode: None, + source_tree_ref: None, overlays, window_size: None, min_lines: Some(5), diff --git a/docs/release/v0.2.0.md b/docs/release/v0.2.0.md new file mode 100644 index 0000000..351ca89 --- /dev/null +++ b/docs/release/v0.2.0.md @@ -0,0 +1,27 @@ +Opcore 0.2.0 makes the default robustness loop lighter on large repositories and strengthens validation correctness. This release reduces several major sources of unnecessary in-memory data while keeping coverage and degraded checks explicit. + +## Highlights + +- Bounds product scan validation output to summaries, counts, failed check IDs, and diagnostic samples instead of retaining and serializing an unrestricted result payload. +- Makes validation file views lazy so path-specific checks do not enumerate every visible file unless they need the complete file universe. +- Sends committed clone-analysis inputs as path references and reserves content-bearing overlays for changed or hypothetical files. +- Replaces the Python syntax heuristic with parser-backed `ast.parse` validation, including structured locations and an honest unsupported result when no Python interpreter is available. +- Adds native validation-policy parity and tightens the package guardrails around it. + +## Memory scope + +These changes materially reduce memory pressure in scan and validation paths, but they do not complete the broader large-monorepo memory program. Graph build/query working sets, inspect/edit language-service isolation, bounded history and sidecar payloads, and regression harness work remain tracked separately. + +## Upgrade + +```sh +npm install --global opcore@0.2.0 +``` + +Or run it without a global install: + +```sh +npx opcore@0.2.0 --version +``` + +Full comparison: https://github.com/the-open-engine/opcore/compare/v0.1.0...v0.2.0 diff --git a/packages/contracts/schemas/opcore-contracts.schema.json b/packages/contracts/schemas/opcore-contracts.schema.json index 4de6c21..63b2944 100644 --- a/packages/contracts/schemas/opcore-contracts.schema.json +++ b/packages/contracts/schemas/opcore-contracts.schema.json @@ -2039,6 +2039,23 @@ "$ref": "#/$defs/RepoRelativePath" } }, + "sourcePaths": { + "type": "array", + "items": { + "$ref": "#/$defs/RepoRelativePath" + } + }, + "sourceReadMode": { + "enum": [ + "disk", + "gitIndex", + "gitTree" + ] + }, + "sourceTreeRef": { + "type": "string", + "minLength": 1 + }, "overlays": { "type": "array", "items": { diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 16ede30..87116ed 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -1061,6 +1061,9 @@ export type HypotheticalOverlay = export const cloneReportModes = ["all", "introduced"] as const; export type CloneReportMode = (typeof cloneReportModes)[number]; +export const cloneSourceReadModes = ["disk", "gitIndex", "gitTree"] as const; +export type CloneSourceReadMode = (typeof cloneSourceReadModes)[number]; + export interface CloneAnalysisRequest { protocol: typeof CLONE_PROTOCOL; requestId?: string; @@ -1068,6 +1071,9 @@ export interface CloneAnalysisRequest { repo: RepoIdentity; reportMode: CloneReportMode; paths?: readonly string[]; + sourcePaths?: readonly string[]; + sourceReadMode?: CloneSourceReadMode; + sourceTreeRef?: string; overlays: readonly HypotheticalOverlay[]; windowSize?: number; minLines?: number; @@ -6251,6 +6257,9 @@ export function validateCloneAnalysisRequest(request: CloneAnalysisRequest): Clo validateRepoIdentity(request.repo); validateCloneReportMode(request.reportMode, "Clone analysis request reportMode"); if (request.paths !== undefined) validateRepoRelativePaths(request.paths, "Clone analysis request paths"); + if (request.sourcePaths !== undefined) validateRepoRelativePaths(request.sourcePaths, "Clone analysis request sourcePaths"); + if (request.sourceReadMode !== undefined) validateCloneSourceReadMode(request.sourceReadMode, "Clone analysis request sourceReadMode"); + if (request.sourceTreeRef !== undefined) validateNonEmptyString(request.sourceTreeRef, "Clone analysis request sourceTreeRef"); validateHypotheticalOverlays(request.overlays); if (request.windowSize !== undefined) validatePositiveInteger(request.windowSize, "Clone analysis request windowSize"); if (request.minLines !== undefined) validatePositiveInteger(request.minLines, "Clone analysis request minLines"); @@ -6262,6 +6271,12 @@ export function validateCloneAnalysisRequest(request: CloneAnalysisRequest): Clo return request; } +function validateCloneSourceReadMode(value: unknown, label: string): asserts value is CloneSourceReadMode { + if (!cloneSourceReadModes.includes(value as CloneSourceReadMode)) { + throw new Error(`${label} must be one of ${cloneSourceReadModes.join(", ")}`); + } +} + function validateCloneAnalysisPartitions(partitions: readonly (readonly string[])[]): void { if (!Array.isArray(partitions)) throw new Error("Clone analysis request partitions must be an array"); for (const [index, partition] of partitions.entries()) { diff --git a/packages/opcore-graph-core-darwin-arm64/metadata.json b/packages/opcore-graph-core-darwin-arm64/metadata.json index b853cc4..65279a0 100644 --- a/packages/opcore-graph-core-darwin-arm64/metadata.json +++ b/packages/opcore-graph-core-darwin-arm64/metadata.json @@ -4,6 +4,6 @@ "targetPlatform": "darwin-arm64", "binaryPath": "opcore-graph-core", "checksumPath": "opcore-graph-core.sha256", - "checksumSha256": "c222610c492e628fa48ff50be22310e5bf8ebcd62bffbde1903d1fa91c45f6cb", + "checksumSha256": "3fb851c8e057f085c0dd1fb15a7e3a9796a6176974a8fb6e1f34a5222b1271ae", "buildProfile": "release" } diff --git a/packages/opcore-graph-core-darwin-arm64/opcore-graph-core b/packages/opcore-graph-core-darwin-arm64/opcore-graph-core index 086789d..d94c85e 100755 Binary files a/packages/opcore-graph-core-darwin-arm64/opcore-graph-core and b/packages/opcore-graph-core-darwin-arm64/opcore-graph-core differ diff --git a/packages/opcore-graph-core-darwin-arm64/opcore-graph-core.sha256 b/packages/opcore-graph-core-darwin-arm64/opcore-graph-core.sha256 index 463bb56..c00a462 100644 --- a/packages/opcore-graph-core-darwin-arm64/opcore-graph-core.sha256 +++ b/packages/opcore-graph-core-darwin-arm64/opcore-graph-core.sha256 @@ -1 +1 @@ -c222610c492e628fa48ff50be22310e5bf8ebcd62bffbde1903d1fa91c45f6cb opcore-graph-core +3fb851c8e057f085c0dd1fb15a7e3a9796a6176974a8fb6e1f34a5222b1271ae opcore-graph-core diff --git a/packages/validation-clone/src/clone-check.ts b/packages/validation-clone/src/clone-check.ts index 751db58..0a62d90 100644 --- a/packages/validation-clone/src/clone-check.ts +++ b/packages/validation-clone/src/clone-check.ts @@ -2,6 +2,7 @@ import type { CloneAnalysisRequest, CloneAnalysisResult, CloneFinding, + CloneSourceReadMode, HypotheticalOverlay, ValidationDiagnostic } from "@the-open-engine/opcore-contracts"; @@ -20,7 +21,7 @@ import { cloneCheckOwner, supportedCloneValidationScopes } from "./check-constants.js"; -import { cloneInputPaths, cloneOverlayPaths, skippedCloneInputResult } from "./source-files.js"; +import { cloneInputPaths, cloneSourcePaths, isCloneSourcePath, skippedCloneInputResult } from "./source-files.js"; export interface CloneNativeInvoker { invoke: (request: CloneAnalysisRequest) => CloneAnalysisResult | Promise; @@ -87,8 +88,8 @@ async function cloneAnalysisRequest( options: Pick ): Promise { const paths = [...cloneInputPaths(context)]; - const overlayPaths = await cloneOverlayPaths(context, paths); - const overlays = await cloneOverlays(context, overlayPaths); + const sourcePaths = await cloneSourcePaths(context, paths); + const overlays = cloneOverlays(context); return { protocol: CLONE_PROTOCOL, ...(context.request.requestId !== undefined ? { requestId: context.request.requestId } : {}), @@ -96,6 +97,8 @@ async function cloneAnalysisRequest( repo: context.request.repo, reportMode: context.request.reportMode ?? "all", paths, + sourcePaths, + ...cloneSourceReadOptions(context), overlays, ...(options.windowSize !== undefined ? { windowSize: options.windowSize } : {}), ...(options.minLines !== undefined ? { minLines: options.minLines } : {}), @@ -107,30 +110,39 @@ async function cloneAnalysisRequest( }; } -async function cloneOverlays( - context: ValidationCheckContext, - paths: readonly string[] -): Promise { - const overlays: HypotheticalOverlay[] = []; - for (const path of paths) { - const overlay = context.fileView.overlayFor(path); - const after = await context.fileView.readAfter(path); - if (after.status === "found") { - overlays.push({ - path, +function cloneOverlays(context: ValidationCheckContext): readonly HypotheticalOverlay[] { + return context.fileView.overlays.filter((overlay) => isCloneSourcePath(overlay.path)).map((overlay) => { + if (overlay.action === "write") { + if (overlay.content === undefined) { + throw new Error(`Clone write overlay is missing content for ${overlay.path}`); + } + return { + path: overlay.path, action: "write", - content: after.content, - ...(overlay?.checksumBefore !== undefined ? { checksumBefore: overlay.checksumBefore } : {}) - }); - } else { - overlays.push({ - path, - action: "delete", - ...(overlay?.checksumBefore !== undefined ? { checksumBefore: overlay.checksumBefore } : {}) - }); + content: overlay.content, + ...(overlay.checksumBefore !== undefined ? { checksumBefore: overlay.checksumBefore } : {}) + }; } + return { + path: overlay.path, + action: "delete", + ...(overlay.checksumBefore !== undefined ? { checksumBefore: overlay.checksumBefore } : {}) + }; + }); +} + +function cloneSourceReadOptions(context: ValidationCheckContext): { + sourceReadMode?: CloneSourceReadMode; + sourceTreeRef?: string; +} { + if (context.fileView.defaultReadState === "before" && context.scope.kind === "changed" && context.scope.baseRef !== undefined) { + return { sourceReadMode: "gitTree", sourceTreeRef: context.scope.baseRef }; + } + if (context.scope.kind === "staged") return { sourceReadMode: "gitIndex" }; + if (context.scope.kind === "tree" && context.scope.treeRef !== undefined) { + return { sourceReadMode: "gitTree", sourceTreeRef: context.scope.treeRef }; } - return overlays; + return { sourceReadMode: "disk" }; } function cloneFindingDiagnostic(finding: CloneFinding): ValidationDiagnostic { diff --git a/packages/validation-clone/src/source-files.ts b/packages/validation-clone/src/source-files.ts index 93990db..9ff0dbe 100644 --- a/packages/validation-clone/src/source-files.ts +++ b/packages/validation-clone/src/source-files.ts @@ -15,10 +15,10 @@ export function cloneInputPaths(context: ValidationCheckContext): readonly strin ); } -export async function cloneOverlayPaths(context: ValidationCheckContext, paths: readonly string[]): Promise { +export async function cloneSourcePaths(context: ValidationCheckContext, paths: readonly string[]): Promise { const visibleFiles = await context.fileView.listVisibleFiles(); return uniqueSorted( - [...visibleFiles, ...paths] + [...visibleFiles, ...paths, ...context.fileView.overlays.map((overlay) => overlay.path)] .map((path) => normalizeValidationFileViewPath(path)) .filter(isCloneSourcePath) ); diff --git a/packages/validation-python/src/index.ts b/packages/validation-python/src/index.ts index 362f651..7690a3c 100644 --- a/packages/validation-python/src/index.ts +++ b/packages/validation-python/src/index.ts @@ -3,7 +3,7 @@ import { createDeadCodeCheck } from "./dead-code-check.js"; import { createImportGraphCheck } from "./import-graph-check.js"; import { createRelevantTestsCheck } from "./relevant-tests-check.js"; import { createSourceHygieneCheck } from "./source-hygiene-check.js"; -import { createSyntaxCheck } from "./syntax-check.js"; +import { createSyntaxCheck, type PythonSyntaxCheckOptions } from "./syntax-check.js"; import { createTypeCheck, type PythonTypeCheckOptions } from "./type-check.js"; export { @@ -19,15 +19,16 @@ export { export { validationPythonAdapterName } from "./check-constants.js"; export { isPythonSourcePath } from "./source-files.js"; export { createPythonValidationAdapterStatus, probePythonToolchain, type PythonValidationToolchainOptions } from "./toolchain.js"; +export { createSyntaxCheck, type PythonSyntaxCheckOptions } from "./syntax-check.js"; export { createTypeCheck, type PythonTypeCheckOptions } from "./type-check.js"; -export interface CreatePythonValidationChecksOptions extends PythonTypeCheckOptions {} +export interface CreatePythonValidationChecksOptions extends PythonTypeCheckOptions, PythonSyntaxCheckOptions {} export function createPythonValidationChecks( options: CreatePythonValidationChecksOptions = {} ): readonly ValidationCheckDefinition[] { return [ - createSyntaxCheck(), + createSyntaxCheck(options), createSourceHygieneCheck(), createTypeCheck(options), createImportGraphCheck(), diff --git a/packages/validation-python/src/process.ts b/packages/validation-python/src/process.ts index 954e100..b4e5b72 100644 --- a/packages/validation-python/src/process.ts +++ b/packages/validation-python/src/process.ts @@ -12,6 +12,7 @@ export interface PythonToolRunOptions { cwd?: string; env?: Record; timeoutMs?: number; + input?: string; } export function runTool( @@ -23,8 +24,9 @@ export function runTool( cwd: options.cwd, env: options.env, encoding: "utf8", - stdio: ["ignore", "pipe", "pipe"], - timeout: options.timeoutMs ?? 10000 + stdio: [options.input !== undefined ? "pipe" : "ignore", "pipe", "pipe"], + timeout: options.timeoutMs ?? 10000, + input: options.input }); const failureMessage = result.error?.message ?? diff --git a/packages/validation-python/src/syntax-check.ts b/packages/validation-python/src/syntax-check.ts index 28cb847..02b7e91 100644 --- a/packages/validation-python/src/syntax-check.ts +++ b/packages/validation-python/src/syntax-check.ts @@ -3,12 +3,41 @@ import type { ValidationCheckDefinition } from "@the-open-engine/opcore-validati import { PYTHON_SYNTAX_CHECK_ID } from "./check-ids.js"; import { pythonCheckAdapter, pythonCheckOwner, supportedPythonValidationScopes } from "./check-constants.js"; import { diagnostic, sortDiagnostics } from "./diagnostics.js"; +import { runTool } from "./process.js"; import { readPythonAfterSources, skippedPythonInputResult } from "./source-files.js"; +import { pythonAvailable, type PythonValidationToolchainOptions } from "./toolchain.js"; const compoundStatementPattern = /^(?:async\s+def|def|class|if|elif|else\b|for|while|try\b|except|finally\b|with|match|case)\b/u; -export function createSyntaxCheck(): ValidationCheckDefinition { +const PY_AST_CHECK_SCRIPT = ` +import ast +import json +import sys + +source = sys.stdin.read() +try: + ast.parse(source) + print(json.dumps({"ok": True})) +except SyntaxError as error: + print(json.dumps({ + "ok": False, + "message": error.msg, + "line": error.lineno, + "column": error.offset + })) +except (ValueError, RecursionError) as error: + print(json.dumps({ + "ok": False, + "message": str(error), + "line": None, + "column": None + })) +`; + +export interface PythonSyntaxCheckOptions extends PythonValidationToolchainOptions {} + +export function createSyntaxCheck(options: PythonSyntaxCheckOptions = {}): ValidationCheckDefinition { return { id: PYTHON_SYNTAX_CHECK_ID, owner: pythonCheckOwner, @@ -18,16 +47,86 @@ export function createSyntaxCheck(): ValidationCheckDefinition { run: async (context) => { const skipped = skippedPythonInputResult(context); if (skipped !== undefined) return skipped; + + const pythonCommand = options.pythonCommand ?? "python3"; + const parserAvailable = pythonAvailable({ env: options.env, pythonCommand }); + if (!parserAvailable) { + return { + status: "unsupported_request", + diagnostics: [ + diagnostic({ + category: "syntax", + severity: "info", + code: "PY_SYNTAX_PARSER_UNAVAILABLE", + message: + "Python syntax validation requires a Python interpreter (python3); none is available, so results are reported as unsupported instead of a false pass." + }) + ] + }; + } + const diagnostics: ValidationDiagnostic[] = []; for (const source of await readPythonAfterSources(context)) { - diagnostics.push(...syntaxDiagnostics(source.path, source.content)); + const parserDiagnostics = parseWithPython(source.path, source.content, pythonCommand, options.env); + diagnostics.push(...parserDiagnostics); + if (parserDiagnostics.length === 0) diagnostics.push(...heuristicSyntaxDiagnostics(source.path, source.content)); } return { diagnostics: sortDiagnostics(diagnostics) }; } }; } -function syntaxDiagnostics(path: string, content: string): readonly ValidationDiagnostic[] { +function parseWithPython( + path: string, + content: string, + pythonCommand: string, + env: Record | undefined +): readonly ValidationDiagnostic[] { + const result = runTool(pythonCommand, ["-c", PY_AST_CHECK_SCRIPT], { + input: content, + env, + timeoutMs: 10000 + }); + + let parsed: { ok: boolean; message?: string; line?: number | null; column?: number | null } | undefined; + try { + parsed = JSON.parse(result.stdout.trim()); + } catch { + parsed = undefined; + } + + if (parsed === undefined) { + return [ + diagnostic({ + category: "syntax", + path, + code: "PY_SYNTAX_PARSE_ERROR", + message: "Unable to parse Python source with the configured Python interpreter." + }) + ]; + } + + if (parsed.ok) return []; + + return [ + diagnostic({ + category: "syntax", + path, + code: "PY_SYNTAX_PARSE_ERROR", + message: buildParseErrorMessage(parsed) + }) + ]; +} + +function buildParseErrorMessage(parsed: { message?: string; line?: number | null; column?: number | null }): string { + const reason = parsed.message ?? "invalid syntax"; + if (parsed.line !== null && parsed.line !== undefined && parsed.column !== null && parsed.column !== undefined) { + return `${reason} (line ${parsed.line}, column ${parsed.column})`; + } + return reason; +} + +function heuristicSyntaxDiagnostics(path: string, content: string): readonly ValidationDiagnostic[] { const diagnostics: ValidationDiagnostic[] = []; diagnostics.push(...missingColonDiagnostics(path, content)); diagnostics.push(...delimiterDiagnostics(path, content)); diff --git a/packages/validation-python/src/toolchain.ts b/packages/validation-python/src/toolchain.ts index 78fa7d1..2cee745 100644 --- a/packages/validation-python/src/toolchain.ts +++ b/packages/validation-python/src/toolchain.ts @@ -3,7 +3,7 @@ import type { ValidationAdapterRuntimeStatus, ValidationAdapterToolchainStatus } from "@the-open-engine/opcore-contracts"; -import { PYTHON_TYPES_CHECK_ID, pythonValidationCheckIds } from "./check-ids.js"; +import { PYTHON_SYNTAX_CHECK_ID, PYTHON_TYPES_CHECK_ID, pythonValidationCheckIds } from "./check-ids.js"; import { validationPythonAdapterName } from "./check-constants.js"; import { runTool } from "./process.js"; @@ -33,6 +33,7 @@ export function probePythonToolchain( options: PythonValidationToolchainOptions = {} ): readonly ValidationAdapterToolchainStatus[] { return [ + probeTool("python", options.pythonCommand ?? "python3", ["--version"], options.env), probeTool("mypy", "mypy", ["--version"], options.env), probeTool("pyright", "pyright", ["--version"], options.env), probeTool("ruff", "ruff", ["--version"], options.env), @@ -55,6 +56,15 @@ export function createPythonDegradedChecks(missing: ReadonlySet): readon message: "Neither mypy nor pyright is available; Python type validation is reported as degraded instead of passing silently." }); } + if (missing.has("python")) { + degraded.push({ + checkId: PYTHON_SYNTAX_CHECK_ID, + status: "unsupported_request", + reason: "required_tool_unavailable", + requiredTool: "python3", + message: "No Python interpreter (python3) is available; python.syntax cannot be parser-validated, so results are reported as unsupported instead of a false pass." + }); + } return degraded; } diff --git a/packages/validation/src/overlays.ts b/packages/validation/src/overlays.ts index f80546d..eada86e 100644 --- a/packages/validation/src/overlays.ts +++ b/packages/validation/src/overlays.ts @@ -81,6 +81,7 @@ export interface CreateValidationFileViewArgs { export interface ValidationFileView { readonly overlays: readonly ValidationOverlayEntry[]; readonly scopeFiles: readonly string[]; + readonly defaultReadState: ValidationFileReadState; listVisibleFiles: () => Promise; readFile: (path: string, options?: ValidationFileReadOptions) => Promise; readBefore: (path: string) => Promise; @@ -162,6 +163,7 @@ export async function createValidationFileView(args: CreateValidationFileViewArg return { overlays, scopeFiles, + defaultReadState, listVisibleFiles, readFile: (path, options = {}) => options.state === "before" || (options.state === undefined && defaultReadState === "before") ? readBefore(path) : readAfter(path), diff --git a/scripts/stage-opcore-bundle.mjs b/scripts/stage-opcore-bundle.mjs index 64db72a..780cb25 100644 --- a/scripts/stage-opcore-bundle.mjs +++ b/scripts/stage-opcore-bundle.mjs @@ -90,7 +90,7 @@ function packFiles(packageName, packageDir, options = {}) { `npm pack --dry-run returned an error for bundled package ${packageName}:\n${JSON.stringify(parsed.error, null, 2)}` ); } - const pack = Array.isArray(parsed) ? parsed[0] : parsed; + const pack = npmPackResultForPackage(parsed, packageName); const files = pack?.files?.map((entry) => entry.path) ?? []; if (files.length === 0) { throw new Error( @@ -103,6 +103,15 @@ function packFiles(packageName, packageDir, options = {}) { } } +export function npmPackResultForPackage(parsed, packageName) { + const candidates = Array.isArray(parsed) + ? parsed + : parsed?.files + ? [parsed] + : Object.values(parsed ?? {}); + return candidates.find((candidate) => candidate?.name === packageName) ?? candidates[0]; +} + function scriptlessPackageCopy(packageDir) { const stageRoot = mkdtempSync(join(tmpdir(), "opcore-packlist-")); const stagedPackageDir = join(stageRoot, "package"); diff --git a/tests/native-packaging-policy.test.mjs b/tests/native-packaging-policy.test.mjs index fb22b40..5d2babc 100644 --- a/tests/native-packaging-policy.test.mjs +++ b/tests/native-packaging-policy.test.mjs @@ -7,6 +7,7 @@ import { bundledReleasePackageNames, publicReleasePackageNames } from "../scripts/release-package-dirs.mjs"; +import { npmPackResultForPackage } from "../scripts/stage-opcore-bundle.mjs"; const nativeTargets = { "darwin-arm64": { os: "darwin", cpu: "arm64", rustTarget: "aarch64-apple-darwin" }, @@ -19,6 +20,18 @@ const nativePackageDir = (target) => `packages/opcore-graph-core-${target}`; const readJson = (path) => JSON.parse(readFileSync(path, "utf8")); describe("native graph-core packaging policy", () => { + it("accepts npm pack JSON from direct, array, and workspace-keyed output", () => { + const packageName = "@the-open-engine/opcore-asp-provider"; + const expected = { name: packageName, files: [{ path: "package.json" }] }; + assert.equal(npmPackResultForPackage(expected, packageName), expected); + assert.equal(npmPackResultForPackage([expected], packageName), expected); + assert.equal(npmPackResultForPackage({ [packageName]: expected }, packageName), expected); + assert.equal( + npmPackResultForPackage({ unrelated: { name: "unrelated", files: [] }, [packageName]: expected }, packageName), + expected + ); + }); + it("keeps npm publishing strict single-package while bundling internal native artifacts", () => { assert.deepEqual(publicReleasePackageNames, ["opcore"]); for (const target of Object.keys(nativeTargets)) { @@ -109,6 +122,9 @@ describe("native graph-core packaging policy", () => { assert.match(workflow, new RegExp(`name: opcore-graph-core-${target}`)); assert.match(workflow, new RegExp(`tar -xzf "\\$\\{RUNNER_TEMP\\}/opcore-graph-core-${target}/opcore-graph-core-${target}\\.tgz" -C packages/opcore-graph-core-${target}`)); } + assert.match(workflow, /release_notes="docs\/release\/v\$\{RELEASE_VERSION\}\.md"/); + assert.match(workflow, /--notes-file "\$release_notes"/); + assert.doesNotMatch(workflow, /Initial Opcore alpha release/); }); it("fails aggregate release dry-run before packing when downloaded native binary mode is not executable", () => { diff --git a/tests/opcore-facade.test.mjs b/tests/opcore-facade.test.mjs index a2cb689..fbad91e 100644 --- a/tests/opcore-facade.test.mjs +++ b/tests/opcore-facade.test.mjs @@ -104,12 +104,22 @@ describe("opcore public facade", () => { assert.equal(existsSync(join(temp, ".lattice")), false); initGitFixture(temp); + const duplicate = cloneDuplicateBlock(); + writeFixtureFile(temp, "src/peer.ts", duplicate); + writeFixtureFile(temp, "src/clone.ts", duplicate); const explicitClone = await routeOpcoreCommand( ["check", "--all", "--repo", temp, "--checks", "clone.duplication", "--json"], "opcore" ); assert.ok(explicitClone.validationResult); assert.equal(explicitClone.validationResult.manifest.checks.includes("clone.duplication"), true); + assert.equal( + explicitClone.validationResult.diagnostics.some( + (diagnostic) => diagnostic.code === "CLONE_DUPLICATE" && diagnostic.path === "src/clone.ts" + ), + true, + JSON.stringify(explicitClone.validationResult.diagnostics, null, 2) + ); const explicitCloneSkip = explicitClone.validationResult.manifest.skippedChecks?.find( (skip) => skip.checkId === "clone.duplication" ); @@ -334,7 +344,7 @@ describe("opcore public facade", () => { })), [] ); - assert.deepEqual(degradedPythonTools, ["mypy", "pyright", "pytest", "ruff"]); + assert.deepEqual(degradedPythonTools, ["mypy", "pyright", "python", "pytest", "ruff"].sort()); } finally { rmSync(temp, { recursive: true, force: true }); } @@ -1982,6 +1992,20 @@ function runPendingInitChildUntilIdle(script, cwd) { }); } +function cloneDuplicateBlock() { + return [ + "export function duplicated() {", + " const one = 1;", + " const two = 2;", + " const three = 3;", + " const four = one + two;", + " const five = three + four;", + " return five + four + three + two + one;", + "}", + "" + ].join("\n"); +} + function writeFixtureFile(root, path, content) { const absolute = join(root, path); mkdirSync(dirname(absolute), { recursive: true }); diff --git a/tests/validation-clone.test.mjs b/tests/validation-clone.test.mjs index 48ee54c..29b1c99 100644 --- a/tests/validation-clone.test.mjs +++ b/tests/validation-clone.test.mjs @@ -61,6 +61,70 @@ it("passes clone policy fields into the native request", async () => { assert.deepEqual(captured.request.modes, ["staged", "changed", "files"]); }); +it("sends committed clone inputs as paths without reading their after-state content", async () => { + const committedFiles = new Map([ + ["src/a.ts", "export const a = 1;\n"], + ["src/b.ts", "export const b = 2;\n"], + ["src/c.ts", "export const c = 3;\n"] + ]); + let afterStateReads = 0; + const captured = {}; + const result = await createValidationRunner({ + workspace: { + readFile: (path, context) => { + if (context?.state === "after") afterStateReads += 1; + return committedFiles.has(path) ? { status: "found", content: committedFiles.get(path) } : { status: "missing" }; + }, + listFiles: () => ({ files: [...committedFiles.keys()] }), + listChangedFiles: () => ({ files: [...committedFiles.keys()] }), + listStagedFiles: () => ({ files: [...committedFiles.keys()] }), + listRepoFiles: () => ({ files: [...committedFiles.keys()] }), + listTreeFiles: () => ({ files: [...committedFiles.keys()] }), + listPackageFiles: (_name, root) => ({ files: [...committedFiles.keys()].filter((path) => path.startsWith(`${root}/`)) }) + }, + checks: createCloneValidationChecks({ + invoke: capturingCloneInvoker(captured) + }) + }).runValidation({ + requestId: "validation-clone-sparse-committed-paths", + repo: { + repoId: "clone-test" + }, + scope: { + kind: "files", + files: [...committedFiles.keys()] + }, + graph: { + mode: "optional", + provider: "opcore-graph" + }, + checks: [CLONE_DUPLICATION_CHECK_ID], + overlays: [] + }); + + assert.equal(result.status, "passed"); + assert.deepEqual(captured.request.paths, ["src/a.ts", "src/b.ts", "src/c.ts"]); + assert.deepEqual(captured.request.sourcePaths, ["src/a.ts", "src/b.ts", "src/c.ts"]); + assert.deepEqual(captured.request.overlays, []); + assert.equal(afterStateReads, 0); +}); + +it("sends only write and delete overlays alongside committed clone paths", async () => { + const captured = {}; + const result = await runner( + createCloneValidationChecks({ + invoke: capturingCloneInvoker(captured) + }) + ).runValidation(validationCloneRequest()); + + assertCloneDiagnosticResult(result); + assertCapturedCloneRequest(captured.request); + assert.deepEqual( + captured.request.overlays.map((overlay) => Object.hasOwn(overlay, "content")), + [true, false] + ); +}); + function capturingCloneInvoker(captured) { return (request) => { captured.request = request; @@ -111,7 +175,7 @@ function validationCloneRequest() { }, scope: { kind: "files", - files: ["src/a.ts", "src/b.ts"] + files: ["src/a.ts", "src/b.ts", "src/deleted.ts"] }, graph: { mode: "optional", @@ -125,6 +189,11 @@ function validationCloneRequest() { action: "write", checksumBefore: calculateValidationFileChecksum("export const before = 1;\n"), content: "export const after = 1;\n" + }, + { + path: "src/deleted.ts", + action: "delete", + checksumBefore: calculateValidationFileChecksum("export const deleted = 1;\n") } ] }; @@ -133,7 +202,8 @@ function validationCloneRequest() { function assertCapturedCloneRequest(request) { assert.equal(request.protocol, CLONE_PROTOCOL); assert.equal(request.reportMode, "introduced"); - assert.deepEqual(request.paths, ["src/a.ts", "src/b.ts"]); + assert.deepEqual(request.paths, ["src/a.ts", "src/b.ts", "src/deleted.ts"]); + assert.deepEqual(request.sourcePaths, ["src/a.ts", "src/b.ts", "src/deleted.ts"]); assert.deepEqual(request.overlays, [ { path: "src/a.ts", @@ -142,9 +212,9 @@ function assertCapturedCloneRequest(request) { checksumBefore: calculateValidationFileChecksum("export const before = 1;\n") }, { - path: "src/b.ts", - action: "write", - content: "export const peer = 1;\n" + path: "src/deleted.ts", + action: "delete", + checksumBefore: calculateValidationFileChecksum("export const deleted = 1;\n") } ]); } @@ -243,6 +313,47 @@ it("validates changed-scope after-state clones without persisting dirty or untra } }); +it("validates changed-scope clone inputs in unborn Git repos", async () => { + const temp = mkdtempSync(join(tmpdir(), "opcore-validation-clone-unborn-")); + try { + mkdirSync(join(temp, "src"), { recursive: true }); + const duplicateBlock = cloneDuplicateBlock(); + writeFileSync(join(temp, "src/peer.ts"), duplicateBlock); + writeFileSync(join(temp, "src/changed.ts"), duplicateBlock); + git(temp, ["init", "-q"]); + + const result = await createValidationRunner({ + workspace: createNodeValidationWorkspace({ repoRoot: temp }), + checks: createCloneValidationChecks({ invoke: invokeCloneAnalysis }) + }).runValidation({ + requestId: "validation-clone-unborn-changed-after-state", + repo: { + repoRoot: temp + }, + scope: { + kind: "changed", + baseRef: "HEAD" + }, + graph: { + mode: "optional", + provider: "opcore-graph" + }, + reportMode: "introduced", + checks: [CLONE_DUPLICATION_CHECK_ID], + overlays: [] + }); + + assert.equal(result.status, "policy_failure", JSON.stringify(result, null, 2)); + assert.ok( + result.diagnostics.some((diagnostic) => diagnostic.path === "src/changed.ts" && diagnostic.code === "CLONE_DUPLICATE"), + JSON.stringify(result.diagnostics, null, 2) + ); + assert.equal(existsSync(join(temp, ".opcore/clone/clone.db")), false); + } finally { + rmSync(temp, { recursive: true, force: true }); + } +}); + it("validates staged after-state clones against staged peers instead of dirty disk peers", async () => { const temp = mkdtempSync(join(tmpdir(), "opcore-validation-clone-staged-")); try { @@ -343,7 +454,8 @@ it("validates tree after-state clones against tree peers instead of dirty disk p function runner(checks) { const files = new Map([ ["src/a.ts", "export const before = 1;\n"], - ["src/b.ts", "export const peer = 1;\n"] + ["src/b.ts", "export const peer = 1;\n"], + ["src/deleted.ts", "export const deleted = 1;\n"] ]); return createValidationRunner({ workspace: { @@ -412,6 +524,9 @@ function capturedCloneRequestSummaries(requests) { return requests.map((request) => ({ requestId: request.requestId, paths: request.paths, + sourcePaths: request.sourcePaths, + sourceReadMode: request.sourceReadMode, + sourceTreeRef: request.sourceTreeRef, overlays: request.overlays })); } @@ -421,24 +536,18 @@ function expectedAfterStateCloneRequests() { { requestId: "validation-clone-staged-after-state", paths: ["src/staged.ts"], - overlays: [ - { - path: "src/staged.ts", - action: "write", - content: "export const value = 'staged';\n" - } - ] + sourcePaths: ["src/staged.ts"], + sourceReadMode: "gitIndex", + sourceTreeRef: undefined, + overlays: [] }, { requestId: "validation-clone-tree-after-state", paths: ["src/tree.ts"], - overlays: [ - { - path: "src/tree.ts", - action: "write", - content: "export const value = 'tree';\n" - } - ] + sourcePaths: ["src/tree.ts"], + sourceReadMode: "gitTree", + sourceTreeRef: "feature", + overlays: [] } ]; } diff --git a/tests/validation-python.test.mjs b/tests/validation-python.test.mjs index b084bdd..cc938cd 100644 --- a/tests/validation-python.test.mjs +++ b/tests/validation-python.test.mjs @@ -78,11 +78,58 @@ describe("validation-python adapter", () => { assert.equal(result.status, "policy_failure"); assert.deepEqual( result.diagnostics.map((diagnostic) => diagnostic.code), - ["PY_SYNTAX_MISSING_COLON", "PY_SYNTAX_UNCLOSED_DELIMITER"] + ["PY_SYNTAX_PARSE_ERROR"] ); assert.equal(result.diagnostics[0].path, "pkg/app.py"); }); + it("fails python.syntax for invalid Python grammar the heuristics miss", async () => { + const result = await runner({ + files: { + "pkg/app.py": "x = 1 2\n" + } + }).runValidation( + request({ + checks: [PYTHON_SYNTAX_CHECK_ID] + }) + ); + + assert.equal(result.status, "policy_failure"); + assert.deepEqual(result.diagnostics.map((diagnostic) => diagnostic.code), ["PY_SYNTAX_PARSE_ERROR"]); + assert.equal(result.diagnostics[0].path, "pkg/app.py"); + }); + + it("reports python.syntax as unsupported when no Python interpreter is available", async () => { + const result = await runner({ + files: { + "pkg/app.py": "x = 1 2\n" + }, + checks: createPythonValidationChecks({ env: { PATH: "" } }) + }).runValidation( + request({ + checks: [PYTHON_SYNTAX_CHECK_ID] + }) + ); + + assert.equal(result.status, "unsupported_request"); + assert.deepEqual(result.diagnostics.map((diagnostic) => diagnostic.code), ["PY_SYNTAX_PARSER_UNAVAILABLE"]); + }); + + it("passes python.syntax for valid multi-line Python with no false positives", async () => { + const result = await runner({ + files: { + "pkg/app.py": "def public_api():\n if True:\n return 1\n return 0\n" + } + }).runValidation( + request({ + checks: [PYTHON_SYNTAX_CHECK_ID] + }) + ); + + assert.equal(result.status, "passed"); + assert.deepEqual(result.diagnostics, []); + }); + it("reports source-hygiene diagnostics from overlay after-state content", async () => { const result = await runner({ files: { @@ -272,7 +319,7 @@ describe("validation-python adapter", () => { assert.equal(result.status, "policy_failure"); assert.deepEqual( result.diagnostics.map((diagnostic) => diagnostic.code).sort(), - ["PY_IMPORT_GRAPH_MISSING_EDGE", "PY_SOURCE_TYPE_IGNORE", "PY_SYNTAX_MISSING_COLON"] + ["PY_IMPORT_GRAPH_MISSING_EDGE", "PY_SOURCE_TYPE_IGNORE", "PY_SYNTAX_PARSE_ERROR"] ); });