diff --git a/apps/spark-daemon/src/lens/language-toolchains.test.ts b/apps/spark-daemon/src/lens/language-toolchains.test.ts new file mode 100644 index 00000000..f1012a21 --- /dev/null +++ b/apps/spark-daemon/src/lens/language-toolchains.test.ts @@ -0,0 +1,92 @@ +import { CARGO_CHECK_PROVIDER_ID, type ProviderVersion } from "@zendev-lab/spark-lens"; +import { describe, expect, test } from "vitest"; + +import { + inspectPythonRustProfiles, + parseBasedPyrightDiagnostics, + parseCargoDiagnostics, + parseRuffDiagnostics, + parseTyConciseDiagnostics, +} from "./language-toolchains.ts"; + +const version = "1.0.0" as ProviderVersion; + +describe("Python and Rust Lens toolchains", () => { + test("reports missing providers without auto-installing and finds the local Rust toolchain", async () => { + const health = await inspectPythonRustProfiles(process.cwd()); + expect(health.python.providers.find((provider) => provider.providerId === "ty")).toMatchObject({ + available: false, + }); + expect( + health.rust.providers.find((provider) => provider.providerId === "cargo-check"), + ).toMatchObject({ + available: true, + source: "system", + requiresExplicitTrust: true, + }); + }); + + test("normalizes ty, BasedPyright, Ruff, and Cargo diagnostics", () => { + expect( + parseTyConciseDiagnostics( + "src/app.py:3:5: error[invalid-return-type]: Expected str", + version, + 10, + )[0], + ).toMatchObject({ providerId: "ty", line: 2, character: 4 }); + expect( + parseBasedPyrightDiagnostics( + JSON.stringify({ + generalDiagnostics: [ + { + file: "src/app.py", + severity: "error", + message: "Type mismatch", + rule: "reportAssignmentType", + range: { start: { line: 4, character: 2 } }, + }, + ], + }), + version, + 10, + )[0], + ).toMatchObject({ providerId: "basedpyright", line: 4, character: 2 }); + expect( + parseRuffDiagnostics( + JSON.stringify([ + { + filename: "src/app.py", + code: "F401", + message: "unused import", + location: { row: 2, column: 1 }, + }, + ]), + version, + 10, + )[0], + ).toMatchObject({ providerId: "ruff", line: 1, character: 0 }); + expect( + parseCargoDiagnostics( + JSON.stringify({ + reason: "compiler-message", + message: { + level: "error", + message: "mismatched types", + code: { code: "E0308" }, + spans: [ + { + is_primary: true, + file_name: "src/lib.rs", + line_start: 7, + column_start: 3, + }, + ], + }, + }), + CARGO_CHECK_PROVIDER_ID, + version, + 10, + )[0], + ).toMatchObject({ providerId: "cargo-check", line: 6, character: 2, code: "E0308" }); + }); +}); diff --git a/apps/spark-daemon/src/lens/language-toolchains.ts b/apps/spark-daemon/src/lens/language-toolchains.ts new file mode 100644 index 00000000..6614fe09 --- /dev/null +++ b/apps/spark-daemon/src/lens/language-toolchains.ts @@ -0,0 +1,260 @@ +import { createHash } from "node:crypto"; +import { constants } from "node:fs"; +import { access, readFile } from "node:fs/promises"; +import { delimiter, join, resolve } from "node:path"; + +import { + BASEDPYRIGHT_PROVIDER_ID, + CARGO_CHECK_PROVIDER_ID, + CLIPPY_PROVIDER_ID, + NEXTEST_PROVIDER_ID, + PYTHON_LENS_PROFILE, + RUFF_PROVIDER_ID, + RUSTFMT_PROVIDER_ID, + RUST_ANALYZER_PROVIDER_ID, + RUST_LENS_PROFILE, + TY_PROVIDER_ID, + type DiagnosticFinding, + type ProviderId, + type ProviderVersion, +} from "@zendev-lab/spark-lens"; + +export interface DiscoveredLensExecutable { + providerId: ProviderId; + command: string; + source: "project_local" | "system"; + executableDigest: string; + requiresExplicitTrust: true; + role: string; +} + +export interface MissingLensExecutable { + providerId: ProviderId; + role: string; + available: false; + error: string; +} + +export async function inspectPythonRustProfiles(workspaceRoot: string) { + const [python, rust] = await Promise.all([ + inspectProfile(workspaceRoot, [ + [TY_PROVIDER_ID, ["ty"], "semantic owner"], + [BASEDPYRIGHT_PROVIDER_ID, ["basedpyright"], "diagnostic verifier"], + [RUFF_PROVIDER_ID, ["ruff"], "lint/code-action/formatter owner"], + ]), + inspectProfile(workspaceRoot, [ + [RUST_ANALYZER_PROVIDER_ID, ["rust-analyzer"], "semantic owner"], + [CARGO_CHECK_PROVIDER_ID, ["cargo"], "compiler verifier"], + [CLIPPY_PROVIDER_ID, ["cargo-clippy"], "lint contributor"], + [RUSTFMT_PROVIDER_ID, ["rustfmt"], "formatter owner"], + [NEXTEST_PROVIDER_ID, ["cargo-nextest"], "test obligation provider"], + ]), + ]); + return { + python: { profile: PYTHON_LENS_PROFILE, providers: python }, + rust: { profile: RUST_LENS_PROFILE, providers: rust }, + }; +} + +async function inspectProfile( + workspaceRoot: string, + specs: ReadonlyArray, +): Promise> { + return await Promise.all( + specs.map(async ([providerId, names, role]) => { + const executable = await discoverExecutable(workspaceRoot, names); + if (!executable) { + return { + providerId, + role, + available: false, + error: `${names.join(" or ")} was not found; Spark does not auto-install providers`, + }; + } + return { + providerId, + role, + available: true, + ...executable, + executableDigest: createHash("sha256") + .update(await readFile(executable.command)) + .digest("hex"), + requiresExplicitTrust: true, + }; + }), + ); +} + +async function discoverExecutable( + workspaceRoot: string, + names: readonly string[], +): Promise<{ command: string; source: "project_local" | "system" } | undefined> { + for (const name of names) { + for (const candidate of [ + join(resolve(workspaceRoot), ".venv", process.platform === "win32" ? "Scripts" : "bin", name), + join(resolve(workspaceRoot), "node_modules", ".bin", name), + ]) { + if (await isExecutable(candidate)) return { command: candidate, source: "project_local" }; + } + for (const directory of (process.env.PATH ?? "").split(delimiter).filter(Boolean)) { + const candidate = join(directory, name); + if (await isExecutable(candidate)) return { command: candidate, source: "system" }; + } + } + return undefined; +} + +async function isExecutable(path: string): Promise { + try { + await access(path, constants.X_OK); + return true; + } catch { + return false; + } +} + +export function parseTyConciseDiagnostics( + output: string, + providerVersion: ProviderVersion, + durationMs: number, +): DiagnosticFinding[] { + const findings: DiagnosticFinding[] = []; + const pattern = /^(.+?):(\d+):(\d+):\s+(error|warning)(?:\[([^\]]+)\])?:\s+(.+)$/gmu; + for (const match of output.matchAll(pattern)) { + findings.push( + finding({ + providerId: TY_PROVIDER_ID, + providerVersion, + durationMs, + path: match[1]!, + line: Number(match[2]) - 1, + character: Number(match[3]) - 1, + severity: match[4] === "warning" ? "warning" : "error", + code: match[5] ?? "ty", + message: match[6]!, + }), + ); + } + return findings; +} + +export function parseBasedPyrightDiagnostics( + output: string, + providerVersion: ProviderVersion, + durationMs: number, +): DiagnosticFinding[] { + const parsed = JSON.parse(output) as { + generalDiagnostics?: Array<{ + file?: string; + severity?: string; + message?: string; + rule?: string; + range?: { start?: { line?: number; character?: number } }; + }>; + }; + return (parsed.generalDiagnostics ?? []).map((diagnostic) => + finding({ + providerId: BASEDPYRIGHT_PROVIDER_ID, + providerVersion, + durationMs, + path: diagnostic.file ?? "", + line: diagnostic.range?.start?.line ?? 0, + character: diagnostic.range?.start?.character ?? 0, + severity: diagnostic.severity === "warning" ? "warning" : "error", + code: diagnostic.rule ?? "basedpyright", + message: diagnostic.message ?? "BasedPyright diagnostic", + }), + ); +} + +export function parseRuffDiagnostics( + output: string, + providerVersion: ProviderVersion, + durationMs: number, +): DiagnosticFinding[] { + const parsed = JSON.parse(output) as Array<{ + filename?: string; + code?: string; + message?: string; + location?: { row?: number; column?: number }; + }>; + return parsed.map((diagnostic) => + finding({ + providerId: RUFF_PROVIDER_ID, + providerVersion, + durationMs, + path: diagnostic.filename ?? "", + line: Math.max(0, (diagnostic.location?.row ?? 1) - 1), + character: Math.max(0, (diagnostic.location?.column ?? 1) - 1), + severity: "warning", + code: diagnostic.code ?? "ruff", + message: diagnostic.message ?? "Ruff diagnostic", + }), + ); +} + +export function parseCargoDiagnostics( + output: string, + providerId: ProviderId, + providerVersion: ProviderVersion, + durationMs: number, +): DiagnosticFinding[] { + const findings: DiagnosticFinding[] = []; + for (const line of output.split("\n")) { + if (!line.trim()) continue; + let message: { + reason?: string; + message?: { + level?: string; + message?: string; + code?: { code?: string }; + spans?: Array<{ + is_primary?: boolean; + file_name?: string; + line_start?: number; + column_start?: number; + }>; + }; + }; + try { + message = JSON.parse(line) as typeof message; + } catch { + continue; + } + if (message.reason !== "compiler-message" || !message.message) continue; + const primary = + message.message.spans?.find((span) => span.is_primary) ?? message.message.spans?.[0]; + findings.push( + finding({ + providerId, + providerVersion, + durationMs, + path: primary?.file_name ?? "", + line: Math.max(0, (primary?.line_start ?? 1) - 1), + character: Math.max(0, (primary?.column_start ?? 1) - 1), + severity: + message.message.level === "error" + ? "error" + : message.message.level === "warning" + ? "warning" + : "info", + code: message.message.code?.code ?? "rustc", + message: message.message.message ?? "Rust compiler diagnostic", + }), + ); + } + return findings; +} + +function finding(input: Omit): DiagnosticFinding { + return { + ...input, + fingerprint: [ + input.path ?? "", + input.line ?? 0, + input.character ?? 0, + input.code ?? "", + input.message, + ].join(":"), + }; +} diff --git a/apps/spark-daemon/src/lens/typescript-verification.ts b/apps/spark-daemon/src/lens/typescript-verification.ts index 77941473..9ab26827 100644 --- a/apps/spark-daemon/src/lens/typescript-verification.ts +++ b/apps/spark-daemon/src/lens/typescript-verification.ts @@ -29,6 +29,7 @@ import { type CommandDiagnosticValue, } from "./typescript-providers.ts"; import { inspectTypeScriptLspProfile } from "./typescript-lsp-profile.ts"; +import { inspectPythonRustProfiles } from "./language-toolchains.ts"; export interface RunTypeScriptDiagnosticsInput { cwd: string; @@ -59,6 +60,7 @@ export class TypeScriptLensVerificationService { routeDigest: TYPESCRIPT_DUAL_ROUTE_DIGEST, providers: await inspectTypeScriptToolchain(root.cwd), lspProfile: await inspectTypeScriptLspProfile(root.cwd), + languageProfiles: await inspectPythonRustProfiles(root.cwd), }; } diff --git a/packages/spark-lens/src/index.ts b/packages/spark-lens/src/index.ts index 5d407693..90444ff0 100644 --- a/packages/spark-lens/src/index.ts +++ b/packages/spark-lens/src/index.ts @@ -1,5 +1,6 @@ export * from "./revision.ts"; export * from "./routes.ts"; +export * from "./language-profiles.ts"; export * from "./trust.ts"; export * from "./types.ts"; export * from "./typescript-profile.ts"; diff --git a/packages/spark-lens/src/kernel.test.ts b/packages/spark-lens/src/kernel.test.ts index 9aab6660..675887c4 100644 --- a/packages/spark-lens/src/kernel.test.ts +++ b/packages/spark-lens/src/kernel.test.ts @@ -15,6 +15,10 @@ import { isWorkspaceRevisionCurrent, providersForRoute, OXFMT_PROVIDER_ID, + PYTHON_LENS_PROFILE, + RUFF_PROVIDER_ID, + RUSTFMT_PROVIDER_ID, + RUST_LENS_PROFILE, TYPESCRIPT_7_PROVIDER_ID, TYPESCRIPT_LSP_PROFILE, type Observation, @@ -81,6 +85,21 @@ describe("provider trust and the TypeScript profile", () => { owner: TYPESCRIPT_7_PROVIDER_ID, }); }); + + test("keeps Python and Rust formatter ownership exclusive", () => { + expect(PYTHON_LENS_PROFILE.routes).toContainEqual({ + kind: "exclusive", + capability: "format", + owner: RUFF_PROVIDER_ID, + }); + expect(RUST_LENS_PROFILE.routes).toContainEqual({ + kind: "exclusive", + capability: "format", + owner: RUSTFMT_PROVIDER_ID, + }); + expect(PYTHON_LENS_PROFILE.verificationObligations).toHaveLength(3); + expect(RUST_LENS_PROFILE.verificationObligations.length).toBeGreaterThanOrEqual(4); + }); }); describe("workspace revisions", () => { diff --git a/packages/spark-lens/src/language-profiles.ts b/packages/spark-lens/src/language-profiles.ts new file mode 100644 index 00000000..ee8e7a88 --- /dev/null +++ b/packages/spark-lens/src/language-profiles.ts @@ -0,0 +1,60 @@ +import { capabilityRoute, type CapabilityRoute } from "./routes.ts"; +import type { ProviderId } from "./types.ts"; + +export interface LensLanguageProfile { + id: string; + language: "python" | "rust"; + semanticDiagnostics: CapabilityRoute; + lintDiagnostics: CapabilityRoute; + routes: readonly CapabilityRoute[]; + verificationObligations: readonly ProviderId[]; +} + +export const TY_PROVIDER_ID = "ty" as ProviderId; +export const BASEDPYRIGHT_PROVIDER_ID = "basedpyright" as ProviderId; +export const RUFF_PROVIDER_ID = "ruff" as ProviderId; + +export const PYTHON_LENS_PROFILE: LensLanguageProfile = { + id: "python-ty-basedpyright-ruff-v1", + language: "python", + semanticDiagnostics: capabilityRoute.verify("diagnostics", TY_PROVIDER_ID, [ + BASEDPYRIGHT_PROVIDER_ID, + ]), + lintDiagnostics: capabilityRoute.merge("diagnostics", [RUFF_PROVIDER_ID]), + routes: [ + capabilityRoute.fallback("navigate", TY_PROVIDER_ID, [BASEDPYRIGHT_PROVIDER_ID]), + capabilityRoute.exclusive("completion", TY_PROVIDER_ID), + capabilityRoute.exclusive("rename", TY_PROVIDER_ID), + capabilityRoute.exclusive("format", RUFF_PROVIDER_ID), + capabilityRoute.merge("code_action", [TY_PROVIDER_ID, RUFF_PROVIDER_ID]), + ], + verificationObligations: [TY_PROVIDER_ID, BASEDPYRIGHT_PROVIDER_ID, RUFF_PROVIDER_ID], +}; + +export const RUST_ANALYZER_PROVIDER_ID = "rust-analyzer" as ProviderId; +export const CARGO_CHECK_PROVIDER_ID = "cargo-check" as ProviderId; +export const CLIPPY_PROVIDER_ID = "clippy" as ProviderId; +export const RUSTFMT_PROVIDER_ID = "rustfmt" as ProviderId; +export const NEXTEST_PROVIDER_ID = "nextest" as ProviderId; + +export const RUST_LENS_PROFILE: LensLanguageProfile = { + id: "rust-analyzer-cargo-v1", + language: "rust", + semanticDiagnostics: capabilityRoute.verify("diagnostics", RUST_ANALYZER_PROVIDER_ID, [ + CARGO_CHECK_PROVIDER_ID, + ]), + lintDiagnostics: capabilityRoute.merge("diagnostics", [CLIPPY_PROVIDER_ID]), + routes: [ + capabilityRoute.exclusive("navigate", RUST_ANALYZER_PROVIDER_ID), + capabilityRoute.exclusive("completion", RUST_ANALYZER_PROVIDER_ID), + capabilityRoute.exclusive("rename", RUST_ANALYZER_PROVIDER_ID), + capabilityRoute.exclusive("format", RUSTFMT_PROVIDER_ID), + capabilityRoute.exclusive("test", NEXTEST_PROVIDER_ID), + ], + verificationObligations: [ + RUST_ANALYZER_PROVIDER_ID, + CARGO_CHECK_PROVIDER_ID, + CLIPPY_PROVIDER_ID, + NEXTEST_PROVIDER_ID, + ], +};