diff --git a/crates/graph-core/src/extraction/facts.rs b/crates/graph-core/src/extraction/facts.rs index 3fb0923..a82770c 100644 --- a/crates/graph-core/src/extraction/facts.rs +++ b/crates/graph-core/src/extraction/facts.rs @@ -586,6 +586,8 @@ fn module_parts_for_path(path: &str) -> Vec { let without_extension = path .strip_suffix(".py") .or_else(|| path.strip_suffix(".pyi")) + .or_else(|| path.strip_suffix(".mts")) + .or_else(|| path.strip_suffix(".cts")) .or_else(|| path.strip_suffix(".ts")) .or_else(|| path.strip_suffix(".tsx")) .or_else(|| path.strip_suffix(".js")) diff --git a/crates/graph-core/src/extraction/facts/collector.rs b/crates/graph-core/src/extraction/facts/collector.rs index d87b560..2c89de8 100644 --- a/crates/graph-core/src/extraction/facts/collector.rs +++ b/crates/graph-core/src/extraction/facts/collector.rs @@ -6,8 +6,9 @@ use crate::protocol::{GraphFactEdge, GraphFactNode}; use oxc_ast::ast::{ Argument, BindingPattern, CallExpression, Class, ExportAllDeclaration, ExportDefaultDeclaration, ExportDefaultDeclarationKind, ExportNamedDeclaration, Expression, - Function, ImportDeclaration, ImportDeclarationSpecifier, ModuleExportName, NewExpression, - TSInterfaceDeclaration, TSTypeAliasDeclaration, TSTypeName, VariableDeclaration, + Function, ImportDeclaration, ImportDeclarationSpecifier, ImportExpression, ModuleExportName, + NewExpression, TSImportType, TSInterfaceDeclaration, TSTypeAliasDeclaration, TSTypeName, + VariableDeclaration, }; use oxc_ast_visit::{walk, Visit}; use oxc_syntax::scope::ScopeFlags; @@ -317,6 +318,24 @@ impl<'a> Visit<'a> for FileFactCollector { }); } + fn visit_import_expression(&mut self, import: &ImportExpression<'a>) { + if let Expression::StringLiteral(source) = &import.source { + self.imports.push(ImportFact { + specifier: source.value.to_string(), + bindings: Vec::new(), + }); + } + walk::walk_import_expression(self, import); + } + + fn visit_ts_import_type(&mut self, import: &TSImportType<'a>) { + self.imports.push(ImportFact { + specifier: import.source.value.to_string(), + bindings: Vec::new(), + }); + walk::walk_ts_import_type(self, import); + } + fn visit_export_named_declaration(&mut self, export: &ExportNamedDeclaration<'a>) { let source = export .source @@ -530,6 +549,7 @@ impl<'a> Visit<'a> for FileFactCollector { fn visit_ts_type_alias_declaration(&mut self, declaration: &TSTypeAliasDeclaration<'a>) { self.add_declaration("type", "Type", declaration.id.name.as_ref()); + walk::walk_ts_type_alias_declaration(self, declaration); } fn visit_ts_interface_declaration(&mut self, declaration: &TSInterfaceDeclaration<'a>) { @@ -543,10 +563,14 @@ impl<'a> Visit<'a> for FileFactCollector { }); } } + walk::walk_ts_interface_declaration(self, declaration); } fn visit_variable_declaration(&mut self, declaration: &VariableDeclaration<'a>) { for declarator in &declaration.declarations { + if let Some(type_annotation) = &declarator.type_annotation { + self.visit_ts_type_annotation(type_annotation); + } if let Some(name) = binding_name(&declarator.id) { if self.current_context.is_none() { let Some(init) = declarator.init.as_ref() else { diff --git a/crates/graph-core/src/extraction/language.rs b/crates/graph-core/src/extraction/language.rs index ceae7df..2dc2106 100644 --- a/crates/graph-core/src/extraction/language.rs +++ b/crates/graph-core/src/extraction/language.rs @@ -13,7 +13,7 @@ pub enum SourceLanguage { impl SourceLanguage { pub fn from_path(path: &Path) -> Option { match path.extension().and_then(|extension| extension.to_str()) { - Some("ts") => Some(Self::TypeScript), + Some("ts") | Some("mts") | Some("cts") => Some(Self::TypeScript), Some("tsx") => Some(Self::TypeScriptJsx), Some("js") => Some(Self::JavaScript), Some("jsx") => Some(Self::JavaScriptJsx), @@ -38,9 +38,10 @@ impl SourceLanguage { pub fn unsupported_source_extension(path: &Path) -> Option { let extension = path.extension()?.to_str()?; match extension { - "mjs" | "cjs" | "mts" | "cts" | "vue" | "svelte" | "go" | "java" | "rb" | "php" - | "swift" | "kt" | "kts" | "scala" | "lua" | "cs" | "c" | "cc" | "cpp" | "h" - | "hpp" => Some(extension.to_string()), + "mjs" | "cjs" | "vue" | "svelte" | "go" | "java" | "rb" | "php" | "swift" | "kt" + | "kts" | "scala" | "lua" | "cs" | "c" | "cc" | "cpp" | "h" | "hpp" => { + Some(extension.to_string()) + } _ => None, } } diff --git a/crates/graph-core/src/extraction/tsconfig.rs b/crates/graph-core/src/extraction/tsconfig.rs index 7e5b415..b17a1e0 100644 --- a/crates/graph-core/src/extraction/tsconfig.rs +++ b/crates/graph-core/src/extraction/tsconfig.rs @@ -355,16 +355,51 @@ fn resolve_candidate( } fn resolution_candidates(path: &str) -> Vec { - let mut candidates = vec![path.to_string()]; - if Path::new(path).extension().is_none() { - for extension in [".ts", ".tsx", ".js", ".jsx"] { - candidates.push(format!("{path}{extension}")); - } - for extension in ["ts", "tsx", "js", "jsx"] { - candidates.push(format!("{path}/index.{extension}")); + let extension = Path::new(path).extension().and_then(|value| value.to_str()); + match extension { + Some("js") => vec![ + replace_extension(path, "ts"), + replace_extension(path, "tsx"), + replace_extension(path, "d.ts"), + path.to_string(), + replace_extension(path, "jsx"), + ], + Some("jsx") => vec![ + replace_extension(path, "tsx"), + replace_extension(path, "ts"), + replace_extension(path, "d.ts"), + path.to_string(), + replace_extension(path, "js"), + ], + Some("mjs") => vec![ + replace_extension(path, "mts"), + replace_extension(path, "d.mts"), + path.to_string(), + ], + Some("cjs") => vec![ + replace_extension(path, "cts"), + replace_extension(path, "d.cts"), + path.to_string(), + ], + Some(_) => vec![path.to_string()], + None => { + let mut candidates = vec![path.to_string()]; + for extension in ["ts", "tsx", "mts", "cts", "js", "jsx", "d.ts"] { + candidates.push(format!("{path}.{extension}")); + } + for extension in ["ts", "tsx", "mts", "cts", "js", "jsx", "d.ts"] { + candidates.push(format!("{path}/index.{extension}")); + } + candidates } } - candidates +} + +fn replace_extension(path: &str, extension: &str) -> String { + Path::new(path) + .with_extension(extension) + .to_string_lossy() + .replace('\\', "/") } fn normalize_relative(path: &Path) -> Result { diff --git a/packages/contracts/schemas/opcore-contracts.schema.json b/packages/contracts/schemas/opcore-contracts.schema.json index 63b2944..93a2197 100644 --- a/packages/contracts/schemas/opcore-contracts.schema.json +++ b/packages/contracts/schemas/opcore-contracts.schema.json @@ -3326,6 +3326,18 @@ "failureMessage": { "type": "string", "minLength": 1 + }, + "cwd": { + "type": "string", + "minLength": 1 + }, + "configFile": { + "type": "string", + "minLength": 1 + }, + "source": { + "type": "string", + "minLength": 1 } } } diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 87116ed..d386e00 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -1256,6 +1256,9 @@ export interface ValidationAdapterToolchainStatus { command?: string; version?: string; failureMessage?: string; + cwd?: string; + configFile?: string; + source?: string; } export interface ValidationAdapterDegradedCheckStatus { @@ -6619,6 +6622,11 @@ function validateValidationAdapterToolchainStatus(status: ValidationAdapterToolc if (status.failureMessage !== undefined) { validateNonEmptyString(status.failureMessage, "Validation adapter toolchain status failureMessage"); } + if (status.cwd !== undefined) validateNonEmptyString(status.cwd, "Validation adapter toolchain status cwd"); + if (status.configFile !== undefined) { + validateNonEmptyString(status.configFile, "Validation adapter toolchain status configFile"); + } + if (status.source !== undefined) validateNonEmptyString(status.source, "Validation adapter toolchain status source"); return status; } diff --git a/packages/fixtures/source-extraction/node-next/node-next.expected.json b/packages/fixtures/source-extraction/node-next/node-next.expected.json new file mode 100644 index 0000000..c36e798 --- /dev/null +++ b/packages/fixtures/source-extraction/node-next/node-next.expected.json @@ -0,0 +1,82 @@ +{ + "schemaVersion": 1, + "fixture": "source-extraction-node-next", + "fileNodeIds": [ + "file:src/alias-consumer.ts", + "file:src/alias/value.ts", + "file:src/common-dep.cts", + "file:src/common.cts", + "file:src/dep.ts", + "file:src/dynamic-target.ts", + "file:src/dynamic.ts", + "file:src/import-type.ts", + "file:src/main.ts", + "file:src/module-dep.mts", + "file:src/module.mts", + "file:src/nonliteral.ts", + "file:src/types.ts" + ], + "moduleEdgeTriples": [ + [ + "DEPENDS_ON", + "file:src/alias-consumer.ts", + "file:src/alias/value.ts" + ], + [ + "DEPENDS_ON", + "file:src/common.cts", + "file:src/common-dep.cts" + ], + [ + "DEPENDS_ON", + "file:src/dynamic.ts", + "file:src/dynamic-target.ts" + ], + [ + "DEPENDS_ON", + "file:src/import-type.ts", + "file:src/types.ts" + ], + [ + "DEPENDS_ON", + "file:src/main.ts", + "file:src/dep.ts" + ], + [ + "DEPENDS_ON", + "file:src/module.mts", + "file:src/module-dep.mts" + ], + [ + "IMPORTS_FROM", + "file:src/alias-consumer.ts", + "file:src/alias/value.ts" + ], + [ + "IMPORTS_FROM", + "file:src/common.cts", + "file:src/common-dep.cts" + ], + [ + "IMPORTS_FROM", + "file:src/dynamic.ts", + "file:src/dynamic-target.ts" + ], + [ + "IMPORTS_FROM", + "file:src/import-type.ts", + "file:src/types.ts" + ], + [ + "IMPORTS_FROM", + "file:src/main.ts", + "file:src/dep.ts" + ], + [ + "IMPORTS_FROM", + "file:src/module.mts", + "file:src/module-dep.mts" + ] + ], + "diagnostics": [] +} diff --git a/packages/fixtures/source-extraction/node-next/src/alias-consumer.ts b/packages/fixtures/source-extraction/node-next/src/alias-consumer.ts new file mode 100644 index 0000000..21f095a --- /dev/null +++ b/packages/fixtures/source-extraction/node-next/src/alias-consumer.ts @@ -0,0 +1,3 @@ +import { aliasValue } from "@fixture/value.js"; + +export const consumedAlias = aliasValue; diff --git a/packages/fixtures/source-extraction/node-next/src/alias/value.ts b/packages/fixtures/source-extraction/node-next/src/alias/value.ts new file mode 100644 index 0000000..a3f7a52 --- /dev/null +++ b/packages/fixtures/source-extraction/node-next/src/alias/value.ts @@ -0,0 +1 @@ +export const aliasValue = "alias"; diff --git a/packages/fixtures/source-extraction/node-next/src/common-dep.cts b/packages/fixtures/source-extraction/node-next/src/common-dep.cts new file mode 100644 index 0000000..8f4c902 --- /dev/null +++ b/packages/fixtures/source-extraction/node-next/src/common-dep.cts @@ -0,0 +1 @@ +export const commonDependency = "common"; diff --git a/packages/fixtures/source-extraction/node-next/src/common.cts b/packages/fixtures/source-extraction/node-next/src/common.cts new file mode 100644 index 0000000..cb916f6 --- /dev/null +++ b/packages/fixtures/source-extraction/node-next/src/common.cts @@ -0,0 +1,3 @@ +import { commonDependency } from "./common-dep.cjs"; + +export const commonValue = commonDependency; diff --git a/packages/fixtures/source-extraction/node-next/src/dep.ts b/packages/fixtures/source-extraction/node-next/src/dep.ts new file mode 100644 index 0000000..58f8fa6 --- /dev/null +++ b/packages/fixtures/source-extraction/node-next/src/dep.ts @@ -0,0 +1 @@ +export const dependency = "typescript"; diff --git a/packages/fixtures/source-extraction/node-next/src/dynamic-target.ts b/packages/fixtures/source-extraction/node-next/src/dynamic-target.ts new file mode 100644 index 0000000..1a3dd9d --- /dev/null +++ b/packages/fixtures/source-extraction/node-next/src/dynamic-target.ts @@ -0,0 +1 @@ +export const dynamicValue = "dynamic"; diff --git a/packages/fixtures/source-extraction/node-next/src/dynamic.ts b/packages/fixtures/source-extraction/node-next/src/dynamic.ts new file mode 100644 index 0000000..909611f --- /dev/null +++ b/packages/fixtures/source-extraction/node-next/src/dynamic.ts @@ -0,0 +1,3 @@ +export async function loadDynamic() { + return import("./dynamic-target.js"); +} diff --git a/packages/fixtures/source-extraction/node-next/src/import-type.ts b/packages/fixtures/source-extraction/node-next/src/import-type.ts new file mode 100644 index 0000000..94571ca --- /dev/null +++ b/packages/fixtures/source-extraction/node-next/src/import-type.ts @@ -0,0 +1 @@ +export type ImportedShape = import("./types.js").Shape; diff --git a/packages/fixtures/source-extraction/node-next/src/main.ts b/packages/fixtures/source-extraction/node-next/src/main.ts new file mode 100644 index 0000000..1516722 --- /dev/null +++ b/packages/fixtures/source-extraction/node-next/src/main.ts @@ -0,0 +1,3 @@ +import { dependency } from "./dep.js"; + +export const mainValue = dependency; diff --git a/packages/fixtures/source-extraction/node-next/src/module-dep.mts b/packages/fixtures/source-extraction/node-next/src/module-dep.mts new file mode 100644 index 0000000..f42b2c5 --- /dev/null +++ b/packages/fixtures/source-extraction/node-next/src/module-dep.mts @@ -0,0 +1 @@ +export const moduleDependency = "module"; diff --git a/packages/fixtures/source-extraction/node-next/src/module.mts b/packages/fixtures/source-extraction/node-next/src/module.mts new file mode 100644 index 0000000..cd067de --- /dev/null +++ b/packages/fixtures/source-extraction/node-next/src/module.mts @@ -0,0 +1,3 @@ +import { moduleDependency } from "./module-dep.mjs"; + +export const moduleValue = moduleDependency; diff --git a/packages/fixtures/source-extraction/node-next/src/nonliteral.ts b/packages/fixtures/source-extraction/node-next/src/nonliteral.ts new file mode 100644 index 0000000..a76e155 --- /dev/null +++ b/packages/fixtures/source-extraction/node-next/src/nonliteral.ts @@ -0,0 +1,3 @@ +export async function loadFrom(specifier: string) { + return import(specifier); +} diff --git a/packages/fixtures/source-extraction/node-next/src/types.ts b/packages/fixtures/source-extraction/node-next/src/types.ts new file mode 100644 index 0000000..4a1aad2 --- /dev/null +++ b/packages/fixtures/source-extraction/node-next/src/types.ts @@ -0,0 +1,3 @@ +export interface Shape { + value: string; +} diff --git a/packages/fixtures/source-extraction/node-next/tsconfig.json b/packages/fixtures/source-extraction/node-next/tsconfig.json new file mode 100644 index 0000000..4f6666a --- /dev/null +++ b/packages/fixtures/source-extraction/node-next/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "baseUrl": ".", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "paths": { + "@fixture/*": ["src/alias/*"] + }, + "target": "ES2022" + }, + "include": ["src/**/*"] +} diff --git a/packages/opcore-graph-core-linux-x64/metadata.json b/packages/opcore-graph-core-linux-x64/metadata.json index 7d5c449..57958d3 100644 --- a/packages/opcore-graph-core-linux-x64/metadata.json +++ b/packages/opcore-graph-core-linux-x64/metadata.json @@ -4,6 +4,6 @@ "targetPlatform": "linux-x64", "binaryPath": "opcore-graph-core", "checksumPath": "opcore-graph-core.sha256", - "checksumSha256": "70cb6d213d74594f337a67522c879b77767872dc4601063686f1abe70886127b", + "checksumSha256": "c3a771435a7a8172a9e5f0bb20b0d111453efc70901e95308094664f77e7bdf1", "buildProfile": "release" } diff --git a/packages/opcore-graph-core-linux-x64/opcore-graph-core b/packages/opcore-graph-core-linux-x64/opcore-graph-core index c6fcb9e..d347eb9 100755 Binary files a/packages/opcore-graph-core-linux-x64/opcore-graph-core and b/packages/opcore-graph-core-linux-x64/opcore-graph-core differ diff --git a/packages/opcore-graph-core-linux-x64/opcore-graph-core.sha256 b/packages/opcore-graph-core-linux-x64/opcore-graph-core.sha256 index 354e83f..9ef64d0 100644 --- a/packages/opcore-graph-core-linux-x64/opcore-graph-core.sha256 +++ b/packages/opcore-graph-core-linux-x64/opcore-graph-core.sha256 @@ -1 +1 @@ -70cb6d213d74594f337a67522c879b77767872dc4601063686f1abe70886127b opcore-graph-core +c3a771435a7a8172a9e5f0bb20b0d111453efc70901e95308094664f77e7bdf1 opcore-graph-core diff --git a/packages/opcore/src/advanced/validation-composition.ts b/packages/opcore/src/advanced/validation-composition.ts index 392d201..81802f5 100644 --- a/packages/opcore/src/advanced/validation-composition.ts +++ b/packages/opcore/src/advanced/validation-composition.ts @@ -76,7 +76,7 @@ export function createDefaultValidationStatusPayload(options: { const graphMode = options.graphMode ?? "optional"; return createValidationStatusPayload({ checks: validationChecksForRepoPolicy(options.repoRoot), - adapters: [createRustValidationAdapterStatus(), createPythonValidationAdapterStatus()], + adapters: [createRustValidationAdapterStatus(), createPythonValidationAdapterStatus({ repoRoot: options.repoRoot })], graphMode, graphStatus: cliGraphStatus({ repoRoot: options.repoRoot }, graphMode) }); diff --git a/packages/opcore/src/status-args.ts b/packages/opcore/src/status-args.ts new file mode 100644 index 0000000..a10ea1c --- /dev/null +++ b/packages/opcore/src/status-args.ts @@ -0,0 +1,85 @@ +declare const process: { + cwd(): string; +}; + +const helpArgs = new Set(["--help", "-h", "help"]); + +export interface OpcoreStatusArgs { + repo: string; + showAspLine: boolean; +} + +export function parseOpcoreStatusArgs( + args: readonly string[] +): { ok: true; repo: string; showAspLine: boolean } | { ok: false; message: string } { + let parsed: OpcoreStatusArgs = { repo: process.cwd(), showAspLine: false }; + for (let index = 0; index < args.length; index += 1) { + const arg = args[index]; + if (helpArgs.has(arg)) return { ok: false, message: opcoreStatusHelpMessage() }; + if (arg === "--repo") { + const value = args[index + 1]; + if (!value || value.startsWith("--")) return { ok: false, message: "opcore status: --repo requires a path" }; + parsed = { ...parsed, repo: value }; + index += 1; + continue; + } + if (arg.startsWith("--repo=")) { + const value = arg.slice("--repo=".length); + if (!value) return { ok: false, message: "opcore status: --repo requires a path" }; + parsed = { ...parsed, repo: value }; + continue; + } + if (arg === "--verbose" || arg === "--asp") { + parsed = { ...parsed, showAspLine: true }; + continue; + } + return { ok: false, message: `opcore status: unsupported argument ${arg}` }; + } + return { ok: true, ...parsed }; +} + +export function parseOpcoreRepoArgs( + args: readonly string[], + command: string +): { ok: true; repo: string } | { ok: false; message: string } { + let repo = process.cwd(); + for (let index = 0; index < args.length; index += 1) { + const arg = args[index]; + if (helpArgs.has(arg)) return { ok: false, message: opcoreStatusHelpMessage() }; + if (arg === "--repo") { + const value = args[index + 1]; + if (!value || value.startsWith("--")) return { ok: false, message: `${command}: --repo requires a path` }; + repo = value; + index += 1; + continue; + } + if (arg.startsWith("--repo=")) { + const value = arg.slice("--repo=".length); + if (!value) return { ok: false, message: `${command}: --repo requires a path` }; + repo = value; + continue; + } + return { ok: false, message: `${command}: unsupported argument ${arg}` }; + } + return { ok: true, repo }; +} + +export function opcoreStatusHelpMessage(): string { + return [ + "Usage: opcore status [--repo ] [--verbose] [--json]", + "Flags:", + " --repo Repository root to inspect.", + " --verbose Include non-enrolled ASP state in human output.", + " --asp Include ASP state in human output.", + " --json Emit structured JSON.", + "Defaults:", + " --repo defaults to the current working directory; status is read-only.", + "Examples:", + " opcore status --repo . --json", + "Exit codes: 0 status produced, 1 invalid repo or status error, 64 unsupported." + ].join("\n"); +} + +export function isStatusHelpArg(arg: string): boolean { + return helpArgs.has(arg); +} diff --git a/packages/opcore/src/status-census.ts b/packages/opcore/src/status-census.ts new file mode 100644 index 0000000..23fe37d --- /dev/null +++ b/packages/opcore/src/status-census.ts @@ -0,0 +1,149 @@ +import { readdirSync, statSync } from "node:fs"; +import { join, sep } from "node:path"; +import { commonSkippedPathSegments } from "./source-policy.js"; +import { errorCode, errorMessage } from "./status-errors.js"; +import { gitFailureMessage, parseGitStatus, runGit, type GitState } from "./status-git.js"; +import type { RepoResolution } from "./status-repo.js"; + +const skippedPathSegments = new Set([ + ...commonSkippedPathSegments, + ".venv", + "venv", + "env", + "__pycache__", + ".eggs", + "build", + ".tox", + ".mypy_cache", + ".pytest_cache", + ".ruff_cache", + "site-packages" +]); +const skippedPathSegmentSuffixes = [".egg-info", ".dist-info"]; + +export interface CensusTraversalFailure { + path: string; + message: string; +} + +export interface FileCensus { + files: readonly string[]; + git: GitState; + traversalFailures: readonly CensusTraversalFailure[]; +} + +interface RecursiveCensus { + root: string; + files: string[]; + traversalFailures: CensusTraversalFailure[]; + stack: string[]; +} + +export function readRepoCensus(resolution: RepoResolution): FileCensus { + if (!resolution.git) return nonGitCensus(resolution.root); + const traversalFailures: CensusTraversalFailure[] = []; + const statusResult = runGit(resolution.root, ["status", "--porcelain=v1", "--branch"]); + const git = statusResult.status === 0 ? parseGitStatus(statusResult.stdout) : { available: true }; + if (statusResult.status !== 0) { + traversalFailures.push({ path: ".", message: `git status failed: ${gitFailureMessage(statusResult)}` }); + } + const filesResult = runGit(resolution.root, ["ls-files", "-co", "--exclude-standard"]); + if (filesResult.status !== 0) { + traversalFailures.push({ path: ".", message: `git file census failed: ${gitFailureMessage(filesResult)}` }); + } + const files = filesResult.stdout + .split(/\r?\n/) + .map((line) => line.trim()) + .filter((line) => line.length > 0 && !hasSkippedSegment(line)) + .filter((file) => fileExistsForCensus(resolution.root, file, traversalFailures)); + return { files, git, traversalFailures: uniqueTraversalFailures(traversalFailures) }; +} + +export function formatTraversalFailurePaths(failures: readonly CensusTraversalFailure[]): string { + const paths = [...new Set(failures.map((failure) => failure.path))].sort(); + const visible = paths.slice(0, 5).join(", "); + return paths.length > 5 ? `${visible}, +${paths.length - 5} more` : visible; +} + +function nonGitCensus(root: string): FileCensus { + const census = readFilesRecursive(root); + return { + files: census.files, + git: { available: false }, + traversalFailures: uniqueTraversalFailures(census.traversalFailures) + }; +} + +function readFilesRecursive(root: string): { files: string[]; traversalFailures: CensusTraversalFailure[] } { + const census: RecursiveCensus = { root, files: [], traversalFailures: [], stack: [root] }; + while (census.stack.length > 0) { + const current = census.stack.pop(); + if (current) scanDirectory(census, current); + } + return { files: census.files.sort(), traversalFailures: census.traversalFailures }; +} + +function scanDirectory(census: RecursiveCensus, current: string): void { + let entries: ReturnType; + try { + entries = readdirSync(current, { withFileTypes: true }); + } catch (error) { + census.traversalFailures.push(traversalFailure(census.root, current, error)); + return; + } + for (const entry of entries) scanDirectoryEntry(census, current, entry); +} + +function scanDirectoryEntry( + census: RecursiveCensus, + current: string, + entry: ReturnType[number] +): void { + if (isSkippedPathSegment(entry.name)) return; + const absolute = join(current, entry.name); + const relative = absolute.slice(census.root.length + 1).split(sep).join("/"); + if (hasSkippedSegment(relative)) return; + if (entry.isDirectory()) census.stack.push(absolute); + else if (entry.isFile()) census.files.push(relative); +} + +function fileExistsForCensus( + root: string, + file: string, + traversalFailures: CensusTraversalFailure[] +): boolean { + try { + return statSync(join(root, file)).isFile(); + } catch (error) { + const code = errorCode(error); + if (code !== "ENOENT" && code !== "ENOTDIR") { + traversalFailures.push({ path: file, message: errorMessage(error) }); + } + return false; + } +} + +function traversalFailure(root: string, absolutePath: string, error: unknown): CensusTraversalFailure { + const relativePath = absolutePath === root ? "." : absolutePath.slice(root.length + 1).split(sep).join("/"); + return { path: relativePath, message: errorMessage(error) }; +} + +function uniqueTraversalFailures(failures: readonly CensusTraversalFailure[]): CensusTraversalFailure[] { + const seen = new Set(); + const unique: CensusTraversalFailure[] = []; + for (const failure of failures) { + const key = `${failure.path}\0${failure.message}`; + if (seen.has(key)) continue; + seen.add(key); + unique.push(failure); + } + return unique; +} + +function hasSkippedSegment(path: string): boolean { + return path.split(/[\\/]+/).some((segment) => isSkippedPathSegment(segment)); +} + +function isSkippedPathSegment(segment: string): boolean { + return skippedPathSegments.has(segment) || skippedPathSegmentSuffixes.some((suffix) => segment.endsWith(suffix)); +} diff --git a/packages/opcore/src/status-coverage.ts b/packages/opcore/src/status-coverage.ts new file mode 100644 index 0000000..c250257 --- /dev/null +++ b/packages/opcore/src/status-coverage.ts @@ -0,0 +1,175 @@ +import type { OpcoreRepoStatePayload } from "@the-open-engine/opcore-contracts"; +import { basename, extname } from "node:path"; + +type SourcePolicyState = "supported" | "retained" | "unsupported"; + +interface SourcePolicy { + language: string; + unsupportedStack?: string; + graphSupported: boolean; + validationSupported: boolean; + retained: boolean; + state: SourcePolicyState; +} + +interface CoverageAccumulator { + graphCounts: Map; + validationCounts: Map; + unsupportedCounts: Map; + languageCounts: Map; + graphSupportedFiles: number; + validationSupportedFiles: number; + retainedFiles: number; +} + +function supportedPolicy(language: string, graphSupported: boolean, validationSupported: boolean): SourcePolicy { + return { language, graphSupported, validationSupported, retained: false, state: "supported" }; +} + +function retainedPolicy(language: string): SourcePolicy { + return { language, graphSupported: false, validationSupported: false, retained: true, state: "retained" }; +} + +function unsupportedPolicy(language: string, unsupportedStack = language): SourcePolicy { + return { language, unsupportedStack, graphSupported: false, validationSupported: false, retained: false, state: "unsupported" }; +} + +// Keep this status policy in lockstep with crates/graph-core/src/extraction/language.rs. +const sourcePolicies = new Map([ + [".ts", supportedPolicy("TypeScript", true, true)], + [".tsx", supportedPolicy("TypeScript", true, true)], + [".js", supportedPolicy("JavaScript", true, true)], + [".jsx", supportedPolicy("JavaScript", true, true)], + [".mts", supportedPolicy("TypeScript", true, true)], + [".cts", supportedPolicy("TypeScript", true, true)], + [".rs", supportedPolicy("Rust", true, true)], + [".inc", supportedPolicy("Rust", false, true)], + ["Cargo.toml", supportedPolicy("Rust", false, true)], + ["Cargo.lock", retainedPolicy("Rust")], + [".py", supportedPolicy("Python", true, true)], + [".pyi", supportedPolicy("Python", true, true)], + [".mjs", unsupportedPolicy("JavaScript", "ESM JavaScript")], + [".cjs", unsupportedPolicy("JavaScript", "CommonJS JavaScript")], + [".vue", unsupportedPolicy("Vue")], + [".svelte", unsupportedPolicy("Svelte")], + [".go", unsupportedPolicy("Go")], + [".java", unsupportedPolicy("Java")], + [".rb", unsupportedPolicy("Ruby")], + [".php", unsupportedPolicy("PHP")], + [".swift", unsupportedPolicy("Swift")], + [".kt", unsupportedPolicy("Kotlin")], + [".kts", unsupportedPolicy("Kotlin")], + [".scala", unsupportedPolicy("Scala")], + [".lua", unsupportedPolicy("Lua")], + [".cs", unsupportedPolicy("C#")], + [".c", unsupportedPolicy("C")], + [".cc", unsupportedPolicy("C++")], + [".cpp", unsupportedPolicy("C++")], + [".h", unsupportedPolicy("C/C++ Header")], + [".hpp", unsupportedPolicy("C++ Header")] +]); + +export function computeCoverage(files: readonly string[]): OpcoreRepoStatePayload["coverage"] { + const accumulator = createAccumulator(); + for (const file of files) recordFileCoverage(accumulator, file); + return coverageResult(accumulator, files.length); +} + +function createAccumulator(): CoverageAccumulator { + return { + graphCounts: new Map(), + validationCounts: new Map(), + unsupportedCounts: new Map(), + languageCounts: new Map(), + graphSupportedFiles: 0, + validationSupportedFiles: 0, + retainedFiles: 0 + }; +} + +function recordFileCoverage(accumulator: CoverageAccumulator, file: string): void { + const kind = fileKind(file); + const policy = sourcePolicies.get(kind); + if (!policy) return; + if (policy.graphSupported) { + accumulator.graphSupportedFiles += 1; + increment(accumulator.graphCounts, kind); + } + if (policy.validationSupported) { + accumulator.validationSupportedFiles += 1; + increment(accumulator.validationCounts, kind); + } + if (policy.retained) accumulator.retainedFiles += 1; + if (!policy.graphSupported && !policy.validationSupported && !policy.retained) { + recordUnsupported(accumulator.unsupportedCounts, kind, policy, file); + } + recordLanguage(accumulator.languageCounts, policy); +} + +function recordUnsupported( + counts: CoverageAccumulator["unsupportedCounts"], + kind: string, + policy: SourcePolicy, + file: string +): void { + const current = counts.get(kind) ?? { language: policy.unsupportedStack ?? policy.language, count: 0, examples: [] }; + current.count += 1; + if (current.examples.length < 3) current.examples.push(file); + counts.set(kind, current); +} + +function recordLanguage(counts: CoverageAccumulator["languageCounts"], policy: SourcePolicy): void { + const validationSupported = policy.validationSupported || policy.retained; + const current = counts.get(policy.language) ?? { + files: 0, + graphSupported: policy.graphSupported, + validationSupported + }; + current.files += 1; + current.graphSupported ||= policy.graphSupported; + current.validationSupported ||= validationSupported; + counts.set(policy.language, current); +} + +function coverageResult( + accumulator: CoverageAccumulator, + totalFiles: number +): OpcoreRepoStatePayload["coverage"] { + return { + totalFiles, + languages: [...accumulator.languageCounts.entries()] + .sort(([left], [right]) => left.localeCompare(right)) + .map(([language, value]) => ({ language, ...value })), + graph: { + supportedFiles: accumulator.graphSupportedFiles, + extensions: countEntries(accumulator.graphCounts) + }, + validation: { + supportedFiles: accumulator.validationSupportedFiles, + retainedFiles: accumulator.retainedFiles, + extensions: countEntries(accumulator.validationCounts) + }, + unsupported: { + totalFiles: [...accumulator.unsupportedCounts.values()].reduce((sum, entry) => sum + entry.count, 0), + stacks: [...accumulator.unsupportedCounts.entries()] + .sort(([left], [right]) => left.localeCompare(right)) + .map(([extension, value]) => ({ extension, ...value })) + } + }; +} + +function fileKind(file: string): string { + const name = basename(file); + if (name === "Cargo.toml" || name === "Cargo.lock") return name; + return extname(name); +} + +function increment(map: Map, key: string): void { + map.set(key, (map.get(key) ?? 0) + 1); +} + +function countEntries(map: Map): { extension: string; count: number }[] { + return [...map.entries()] + .sort(([left], [right]) => left.localeCompare(right)) + .map(([extension, count]) => ({ extension, count })); +} diff --git a/packages/opcore/src/status-errors.ts b/packages/opcore/src/status-errors.ts new file mode 100644 index 0000000..7386c2f --- /dev/null +++ b/packages/opcore/src/status-errors.ts @@ -0,0 +1,11 @@ +export function errorCode(error: unknown): string | undefined { + return typeof error === "object" && error !== null && "code" in error && typeof error.code === "string" + ? error.code + : undefined; +} + +export function errorMessage(error: unknown): string { + return typeof error === "object" && error !== null && "message" in error && typeof error.message === "string" + ? error.message + : String(error); +} diff --git a/packages/opcore/src/status-format.ts b/packages/opcore/src/status-format.ts new file mode 100644 index 0000000..572e5ea --- /dev/null +++ b/packages/opcore/src/status-format.ts @@ -0,0 +1,49 @@ +import type { OpcoreRepoStatePayload } from "@the-open-engine/opcore-contracts"; + +export interface OpcoreStatusDisplayOptions { + includeAspLine: boolean; +} + +export function formatOpcoreStatus( + repoState: OpcoreRepoStatePayload, + options: OpcoreStatusDisplayOptions +): string { + const lines = [ + "opcore status", + `Repo: ${repoState.repo.root} (${formatGitState(repoState.repo.git)})`, + formatCoverageLine(repoState.coverage), + `Graph: ${repoState.graph.state}; ${repoState.graph.action}`, + formatValidationLine(repoState.validation), + `Activation: ${repoState.activation.level}; ${repoState.activation.summary}`, + "Next:", + ...repoState.nextActions.slice(0, 2).map((action) => ` ${action}`) + ]; + if (options.includeAspLine) lines.splice(5, 0, formatAspStatusLine(repoState.activation.asp)); + return lines.join("\n"); +} + +function formatCoverageLine(coverage: OpcoreRepoStatePayload["coverage"]): string { + const unsupported = coverage.unsupported.stacks.length === 0 + ? "none" + : coverage.unsupported.stacks.map((stack) => `${stack.language} ${stack.count}`).join(", "); + return `Coverage: files=${coverage.totalFiles} graph=${coverage.graph.supportedFiles} validation=${coverage.validation.supportedFiles} retained=${coverage.validation.retainedFiles} unsupported=${unsupported}`; +} + +function formatValidationLine(validation: OpcoreRepoStatePayload["validation"]): string { + const adapters = validation.adapters.length === 0 + ? "none" + : validation.adapters.map((adapter) => `${adapter.adapter}:${adapter.status}`).join(", "); + const degradedTools = validation.degradedToolchains.length === 0 + ? "none" + : validation.degradedToolchains.map((tool) => tool.tool).join(", "); + return `Validation: checks=${validation.checkCount} adapters=${adapters} degradedTools=${degradedTools}`; +} + +function formatGitState(git: OpcoreRepoStatePayload["repo"]["git"]): string { + if (!git.available) return "non-Git repo"; + return `git ${git.branch ?? "unknown"} changed=${git.changed ?? 0} staged=${git.staged ?? 0} unstaged=${git.unstaged ?? 0} untracked=${git.untracked ?? 0}`; +} + +function formatAspStatusLine(asp: OpcoreRepoStatePayload["activation"]["asp"]): string { + return `ASP: ${asp.state}${asp.paths.length > 0 ? ` (${asp.paths.join(", ")})` : ""}`; +} diff --git a/packages/opcore/src/status-git.ts b/packages/opcore/src/status-git.ts new file mode 100644 index 0000000..28379fc --- /dev/null +++ b/packages/opcore/src/status-git.ts @@ -0,0 +1,81 @@ +import { spawnSync } from "node:child_process"; + +export interface GitState { + available: boolean; + branch?: string; + changed?: number; + staged?: number; + unstaged?: number; + untracked?: number; + conflicted?: number; + clean?: boolean; +} + +export interface GitResult { + status: number | null; + stdout: string; + stderr: string; +} + +interface GitStatusCounts { + staged: number; + unstaged: number; + untracked: number; + conflicted: number; +} + +export function runGit(cwd: string, args: readonly string[]): GitResult { + const result = spawnSync("git", args, { + cwd, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"] + }); + return { + status: result.status, + stdout: result.stdout ?? "", + stderr: result.stderr ?? "" + }; +} + +export function parseGitStatus(stdout: string): GitState { + const lines = stdout.split(/\r?\n/).filter((line) => line.length > 0); + const branchLine = lines.find((line) => line.startsWith("## ")); + const statusLines = lines.filter((line) => !line.startsWith("## ")); + const counts = countGitStatuses(statusLines); + const branch = parseBranch(branchLine); + return { + available: true, + ...(branch ? { branch } : {}), + changed: statusLines.length, + ...counts, + clean: statusLines.length === 0 + }; +} + +export function gitFailureMessage(result: GitResult): string { + const detail = result.stderr.trim() || result.stdout.trim(); + return detail.length > 0 ? detail : `exit ${result.status ?? "unknown"}`; +} + +function countGitStatuses(lines: readonly string[]): GitStatusCounts { + const counts: GitStatusCounts = { staged: 0, unstaged: 0, untracked: 0, conflicted: 0 }; + for (const line of lines) countGitStatus(line, counts); + return counts; +} + +function countGitStatus(line: string, counts: GitStatusCounts): void { + const x = line[0] ?? " "; + const y = line[1] ?? " "; + const code = `${x}${y}`; + if (code === "??") { + counts.untracked += 1; + return; + } + if (["DD", "AU", "UD", "UA", "DU", "AA", "UU"].includes(code)) counts.conflicted += 1; + if (x !== " " && x !== "?") counts.staged += 1; + if (y !== " " && y !== "?") counts.unstaged += 1; +} + +function parseBranch(branchLine: string | undefined): string | undefined { + return branchLine?.slice(3).split("...")[0]?.split(" ")[0]?.trim() || undefined; +} diff --git a/packages/opcore/src/status-repo.ts b/packages/opcore/src/status-repo.ts new file mode 100644 index 0000000..5d4a3d3 --- /dev/null +++ b/packages/opcore/src/status-repo.ts @@ -0,0 +1,84 @@ +import { readdirSync, realpathSync, statSync } from "node:fs"; +import { resolve } from "node:path"; +import { errorCode, errorMessage } from "./status-errors.js"; +import { runGit } from "./status-git.js"; + +export interface RepoResolution { + requestedPath: string; + root: string; + git: boolean; +} + +export function resolveRepo( + repoArg: string, + command: string +): { ok: true; resolution: RepoResolution } | { ok: false; message: string } { + const requestedPath = resolve(repoArg); + const requestedDirectory = readDirectoryMetadata(requestedPath, command); + if (!requestedDirectory.ok) return { ok: false, message: requestedDirectory.message }; + if (!requestedDirectory.stat.isDirectory()) { + return { ok: false, message: `${command}: invalid repo ${requestedPath} is not a directory` }; + } + const gitRoot = runGit(requestedPath, ["rev-parse", "--show-toplevel"]); + if (gitRoot.status !== 0 || gitRoot.stdout.trim().length === 0) { + return { + ok: true, + resolution: { + requestedPath: requestedDirectory.realpath, + root: requestedDirectory.realpath, + git: false + } + }; + } + return resolveGitRepo(requestedDirectory.realpath, gitRoot.stdout.trim(), command); +} + +function resolveGitRepo( + requestedPath: string, + rootPath: string, + command: string +): { ok: true; resolution: RepoResolution } | { ok: false; message: string } { + const rootDirectory = readDirectoryMetadata(rootPath, command); + if (!rootDirectory.ok) return { ok: false, message: rootDirectory.message }; + if (!rootDirectory.stat.isDirectory()) { + return { ok: false, message: `${command}: invalid repo ${rootPath} is not a directory` }; + } + return { + ok: true, + resolution: { + requestedPath, + root: rootDirectory.realpath, + git: true + } + }; +} + +function readDirectoryMetadata( + path: string, + command: string +): { ok: true; stat: ReturnType; realpath: string } | { ok: false; message: string } { + let stat: ReturnType; + try { + stat = statSync(path); + } catch (error) { + return { ok: false, message: invalidRepoMessage(path, error, "does not exist", command) }; + } + if (stat.isDirectory()) { + try { + readdirSync(path, { withFileTypes: true }); + } catch (error) { + return { ok: false, message: `${command}: invalid repo ${path} is unreadable: ${errorMessage(error)}` }; + } + } + try { + return { ok: true, stat, realpath: realpathSync(path) }; + } catch (error) { + return { ok: false, message: `${command}: invalid repo ${path} cannot be resolved: ${errorMessage(error)}` }; + } +} + +function invalidRepoMessage(path: string, error: unknown, notFoundFallback: string, command: string): string { + const code = errorCode(error); + if (code === "ENOENT" || code === "ENOTDIR") return `${command}: invalid repo ${path} ${notFoundFallback}`; + return `${command}: invalid repo ${path}: ${errorMessage(error)}`; +} diff --git a/packages/opcore/src/status-state.ts b/packages/opcore/src/status-state.ts new file mode 100644 index 0000000..2d96e1b --- /dev/null +++ b/packages/opcore/src/status-state.ts @@ -0,0 +1,163 @@ +import type { GraphProviderStatus, OpcoreRepoStatePayload } from "@the-open-engine/opcore-contracts"; +import { existsSync } from "node:fs"; +import { join } from "node:path"; +import { + formatTraversalFailurePaths, + readRepoCensus, + type CensusTraversalFailure +} from "./status-census.js"; +import { computeCoverage } from "./status-coverage.js"; +import type { RepoResolution } from "./status-repo.js"; +import { validationPolicySummary, validationSummary } from "./status-validation.js"; +import { createDefaultValidationStatusPayload } from "./validation-composition.js"; + +interface StatusWarningOptions { + coverage: OpcoreRepoStatePayload["coverage"]; + validation: OpcoreRepoStatePayload["validation"]; + graphStatus: GraphProviderStatus; + traversalFailures: readonly CensusTraversalFailure[]; +} + +export function createRepoState(resolution: RepoResolution): OpcoreRepoStatePayload { + const validationStatus = createDefaultValidationStatusPayload({ + repoRoot: resolution.root, + graphMode: "optional" + }); + const census = readRepoCensus(resolution); + const coverage = computeCoverage(census.files); + const graphStatus = validationStatus.graph.status; + const policy = validationPolicySummary(resolution.root, validationStatus.adapterRegistry.checkIds); + const validation = validationSummary(validationStatus, coverage, policy); + const blockers = statusBlockers(graphStatus, census.traversalFailures); + const level = activationLevel(graphStatus, validation.ready, blockers); + const warningOptions = { coverage, validation, graphStatus, traversalFailures: census.traversalFailures }; + + return { + schemaVersion: 1, + repo: { + root: resolution.root, + requestedPath: resolution.requestedPath, + git: census.git + }, + coverage, + graph: graphSummary(graphStatus), + validation, + activation: { + ready: level === "ready", + level, + summary: activationSummary(level, graphStatus, coverage.unsupported.totalFiles, census.traversalFailures), + asp: discoverAspEnrollment(resolution.root) + }, + warnings: statusWarnings(warningOptions), + blockers, + nextActions: nextCommands(resolution.root, graphStatus, census.traversalFailures) + }; +} + +function graphSummary(graphStatus: GraphProviderStatus): OpcoreRepoStatePayload["graph"] { + return { + state: graphStatus.state, + mode: graphStatus.mode, + provider: graphStatus.provider, + action: graphAction(graphStatus), + ...(graphStatus.message ? { message: graphStatus.message } : {}), + status: graphStatus + }; +} + +function activationLevel( + graphStatus: GraphProviderStatus, + validationReady: boolean, + blockers: readonly string[] +): OpcoreRepoStatePayload["activation"]["level"] { + if (graphStatus.state === "available" && validationReady && blockers.length === 0) return "ready"; + return blockers.length > 0 ? "blocked" : "degraded"; +} + +function discoverAspEnrollment(repoRoot: string): OpcoreRepoStatePayload["activation"]["asp"] { + const candidates = [".asp/asp.json", ".asp/local.json", "asp-server.json"]; + const paths = candidates.filter((path) => existsSync(join(repoRoot, path))); + return { state: paths.length > 0 ? "enrolled" : "not_enrolled", paths }; +} + +function statusWarnings(options: StatusWarningOptions): string[] { + const warnings: string[] = []; + if (options.traversalFailures.length > 0) { + warnings.push(`Unreadable repo paths: ${formatTraversalFailurePaths(options.traversalFailures)}; coverage may be incomplete.`); + } + if (options.coverage.unsupported.totalFiles > 0) { + const stacks = options.coverage.unsupported.stacks.map((stack) => `${stack.language} (${stack.count})`).join(", "); + warnings.push(`Unsupported stacks: ${stacks}`); + } + if (options.validation.degradedToolchains.length > 0) { + const tools = options.validation.degradedToolchains.map((tool) => tool.tool).join(", "); + warnings.push(`Degraded validation tools: ${tools}`); + } + if (graphRefreshRecommended(options.graphStatus)) { + warnings.push(`Graph is ${options.graphStatus.state}; graph-backed scan/check work needs ${graphAction(options.graphStatus)}`); + } + return warnings; +} + +function statusBlockers( + graphStatus: GraphProviderStatus, + traversalFailures: readonly CensusTraversalFailure[] +): string[] { + const blockers: string[] = []; + if (traversalFailures.length > 0) { + blockers.push(`Unreadable repo paths prevent complete coverage census: ${formatTraversalFailurePaths(traversalFailures)}`); + } + if (graphStatus.state === "error" || graphStatus.state === "daemon_unavailable") { + blockers.push(graphStatus.message ?? graphStatus.failure.message); + } + if (graphStatus.state === "schema_mismatch") blockers.push("Graph metadata schema mismatch requires rebuild."); + return blockers; +} + +function nextCommands( + repoRoot: string, + graphStatus: GraphProviderStatus, + traversalFailures: readonly CensusTraversalFailure[] +): string[] { + if (traversalFailures.length > 0) { + return [ + `Fix permissions for unreadable repo paths: ${formatTraversalFailurePaths(traversalFailures)}`, + `opcore status --repo ${repoRoot} --json` + ]; + } + if (graphStatus.state === "available") { + return [`opcore check --changed --repo ${repoRoot} --json`, `opcore --repo ${repoRoot} --json`]; + } + if (graphRefreshRecommended(graphStatus)) { + return [`opcore graph build --repo ${repoRoot} --json`, `opcore --repo ${repoRoot} --json`]; + } + return [`opcore --repo ${repoRoot} --json`, `opcore status --repo ${repoRoot} --json`]; +} + +function graphRefreshRecommended(status: GraphProviderStatus): boolean { + return status.state === "stale" || + status.state === "schema_mismatch" || + status.state === "skipped" || + status.state === "required_missing"; +} + +function graphAction(status: GraphProviderStatus): string { + if (status.state === "available") return "Graph is ready."; + if (status.state === "warming") return "Wait for graph warmup."; + if (status.state === "stale") return "refresh graph evidence before graph-backed checks."; + if (status.state === "schema_mismatch") return "refresh graph metadata."; + if (status.state === "skipped" || status.state === "required_missing") return "graph evidence is unavailable."; + return status.message ?? status.failure.message; +} + +function activationSummary( + level: OpcoreRepoStatePayload["activation"]["level"], + graphStatus: GraphProviderStatus, + unsupportedFiles: number, + traversalFailures: readonly CensusTraversalFailure[] +): string { + if (level === "ready") return unsupportedFiles > 0 ? "Repo is ready with unsupported stacks reported." : "Repo is ready."; + if (traversalFailures.length > 0) return "Repo activation is blocked: unreadable repo paths prevent complete coverage census."; + if (level === "blocked") return `Repo activation is blocked: ${graphAction(graphStatus)}`; + return `Repo activation is degraded: ${graphAction(graphStatus)}`; +} diff --git a/packages/opcore/src/status-validation.ts b/packages/opcore/src/status-validation.ts new file mode 100644 index 0000000..4f950dc --- /dev/null +++ b/packages/opcore/src/status-validation.ts @@ -0,0 +1,61 @@ +import type { + OpcoreRepoStatePayload, + OpcoreValidationPolicySummary, + ValidationAdapterRuntimeStatus +} from "@the-open-engine/opcore-contracts"; +import { existsSync } from "node:fs"; +import { join } from "node:path"; +import { readOpcoreRepoConfig } from "./repo-validation-config.js"; +import { relevantDegradedToolchainsForCoverage } from "./scan-presentation.js"; +import type { createDefaultValidationStatusPayload } from "./validation-composition.js"; + +export function validationSummary( + validationStatus: ReturnType, + coverage: OpcoreRepoStatePayload["coverage"], + policy: OpcoreValidationPolicySummary +): OpcoreRepoStatePayload["validation"] { + const adapters = validationStatus.adapterRegistry.adapters ?? []; + const degraded = adapters.flatMap((adapter) => degradedToolchains(adapter)); + return { + ready: validationStatus.ready, + checkCount: validationStatus.adapterRegistry.checkIds.length, + policy, + adapters: adapters.map((adapter) => ({ + adapter: adapter.adapter, + status: adapter.status, + checkCount: adapter.checkIds.length, + degradedChecks: (adapter.degradedChecks ?? []).map((check) => check.checkId), + missingTools: (adapter.toolchain ?? []).filter((tool) => !tool.available).map((tool) => tool.tool) + })), + degradedToolchains: relevantDegradedToolchainsForCoverage(coverage, degraded) + }; +} + +export function validationPolicySummary( + repoRoot: string, + configuredChecks: readonly string[] +): OpcoreValidationPolicySummary { + const configFileExists = existsSync(join(repoRoot, ".opcore", "config")); + const config = readOpcoreRepoConfig(repoRoot); + return { + path: ".opcore/config", + state: configFileExists ? "loaded" : "missing", + adapters: [...(config.validation.adapters ?? [])], + packs: [...config.validation.checks.packs], + disabledChecks: [...config.validation.checks.disabled], + defaultChecks: [...config.validation.checks.defaults], + configuredChecks: [...configuredChecks] + }; +} + +function degradedToolchains( + adapter: ValidationAdapterRuntimeStatus +): OpcoreRepoStatePayload["validation"]["degradedToolchains"] { + return (adapter.toolchain ?? []) + .filter((tool) => !tool.available) + .map((tool) => ({ + adapter: adapter.adapter, + tool: tool.tool, + ...(tool.failureMessage ? { failureMessage: tool.failureMessage } : {}) + })); +} diff --git a/packages/opcore/src/status.ts b/packages/opcore/src/status.ts index ed98f57..0c0c047 100644 --- a/packages/opcore/src/status.ts +++ b/packages/opcore/src/status.ts @@ -1,187 +1,39 @@ import type { CommandRouterResult, - GraphProviderStatus, OpcoreRepoStatePayload, - OpcoreValidationPolicySummary, - ParsedCommandArgv, - ValidationAdapterRuntimeStatus + ParsedCommandArgv } from "@the-open-engine/opcore-contracts"; import { createCommandRouterResult } from "@the-open-engine/opcore-contracts"; -import { spawnSync } from "node:child_process"; -import { existsSync, readdirSync, realpathSync, statSync } from "node:fs"; -import { basename, extname, join, resolve, sep } from "node:path"; -import { readOpcoreRepoConfig } from "./repo-validation-config.js"; -import { relevantDegradedToolchainsForCoverage } from "./scan-presentation.js"; -import { commonSkippedPathSegments } from "./source-policy.js"; -import { createDefaultValidationStatusPayload } from "./validation-composition.js"; -export { commonSkippedPathSegments } from "./source-policy.js"; - -declare const process: { - cwd(): string; -}; - -const helpArgs = new Set(["--help", "-h", "help"]); -const skippedPathSegments = new Set([ - ...commonSkippedPathSegments, - ".venv", - "venv", - "env", - "__pycache__", - ".eggs", - "build", - ".tox", - ".mypy_cache", - ".pytest_cache", - ".ruff_cache", - "site-packages" -]); -const skippedPathSegmentSuffixes = [".egg-info", ".dist-info"]; - -type SourcePolicyState = "supported" | "extraction_pending" | "retained" | "unsupported"; - -interface SourcePolicy { - language: string; - unsupportedStack?: string; - graphSupported: boolean; - validationSupported: boolean; - retained: boolean; - state: SourcePolicyState; -} - -function supportedPolicy(language: string, graphSupported: boolean, validationSupported: boolean): SourcePolicy { - return { language, graphSupported, validationSupported, retained: false, state: "supported" }; -} - -function retainedPolicy(language: string): SourcePolicy { - return { language, graphSupported: false, validationSupported: false, retained: true, state: "retained" }; -} - -function extractionPendingPolicy(language: string): SourcePolicy { - return { language, graphSupported: false, validationSupported: false, retained: false, state: "extraction_pending" }; -} - -function unsupportedPolicy(language: string, unsupportedStack = language): SourcePolicy { - return { language, unsupportedStack, graphSupported: false, validationSupported: false, retained: false, state: "unsupported" }; -} - -// Keep this status policy in lockstep with crates/graph-core/src/extraction/language.rs. -const sourcePolicies = new Map([ - [".ts", supportedPolicy("TypeScript", true, true)], - [".tsx", supportedPolicy("TypeScript", true, true)], - [".js", supportedPolicy("JavaScript", true, true)], - [".jsx", supportedPolicy("JavaScript", true, true)], - [".mts", supportedPolicy("TypeScript", false, true)], - [".cts", supportedPolicy("TypeScript", false, true)], - [".rs", supportedPolicy("Rust", true, true)], - [".inc", supportedPolicy("Rust", false, true)], - ["Cargo.toml", supportedPolicy("Rust", false, true)], - ["Cargo.lock", retainedPolicy("Rust")], - [".py", supportedPolicy("Python", true, true)], - [".pyi", supportedPolicy("Python", true, true)], - [".mjs", unsupportedPolicy("JavaScript", "ESM JavaScript")], - [".cjs", unsupportedPolicy("JavaScript", "CommonJS JavaScript")], - [".vue", unsupportedPolicy("Vue")], - [".svelte", unsupportedPolicy("Svelte")], - [".go", unsupportedPolicy("Go")], - [".java", unsupportedPolicy("Java")], - [".rb", unsupportedPolicy("Ruby")], - [".php", unsupportedPolicy("PHP")], - [".swift", unsupportedPolicy("Swift")], - [".kt", unsupportedPolicy("Kotlin")], - [".kts", unsupportedPolicy("Kotlin")], - [".scala", unsupportedPolicy("Scala")], - [".lua", unsupportedPolicy("Lua")], - [".cs", unsupportedPolicy("C#")], - [".c", unsupportedPolicy("C")], - [".cc", unsupportedPolicy("C++")], - [".cpp", unsupportedPolicy("C++")], - [".h", unsupportedPolicy("C/C++ Header")], - [".hpp", unsupportedPolicy("C++ Header")] -]); - -export interface RepoResolution { - requestedPath: string; - root: string; - git: boolean; -} - -interface GitState { - available: boolean; - branch?: string; - changed?: number; - staged?: number; - unstaged?: number; - untracked?: number; - conflicted?: number; - clean?: boolean; -} +import { + isStatusHelpArg, + opcoreStatusHelpMessage, + parseOpcoreStatusArgs +} from "./status-args.js"; +import { errorMessage } from "./status-errors.js"; +import { formatOpcoreStatus } from "./status-format.js"; +import { resolveRepo } from "./status-repo.js"; +import { createRepoState } from "./status-state.js"; -interface FileCensus { - files: readonly string[]; - git: GitState; - traversalFailures: readonly CensusTraversalFailure[]; -} - -interface CensusTraversalFailure { - path: string; - message: string; -} - -interface OpcoreStatusDisplayOptions { - includeAspLine: boolean; -} +export { commonSkippedPathSegments } from "./source-policy.js"; +export { parseOpcoreRepoArgs, parseOpcoreStatusArgs } from "./status-args.js"; +export { formatOpcoreStatus } from "./status-format.js"; +export { resolveRepo, type RepoResolution } from "./status-repo.js"; +export { createRepoState } from "./status-state.js"; +export { validationPolicySummary } from "./status-validation.js"; export function routeOpcoreStatus(argv: readonly string[], parsed: ParsedCommandArgv): CommandRouterResult { const rest = parsed.args.slice(1); - if (rest.some((arg) => helpArgs.has(arg))) { - return createCommandRouterResult({ - bin: "opcore", - argv, - canonicalCommand: ["opcore", "status", "help"], - owner: "runtime", - status: "ok", - json: parsed.json, - message: opcoreStatusHelpMessage() - }); - } + if (rest.some((arg) => isStatusHelpArg(arg))) return statusHelpResult(argv, parsed); const parsedStatus = parseOpcoreStatusArgs(rest); - if (!parsedStatus.ok) { - return createCommandRouterResult({ - bin: "opcore", - argv, - canonicalCommand: ["opcore", "status"], - owner: "runtime", - status: "error", - json: parsed.json, - message: parsedStatus.message - }); - } + if (!parsedStatus.ok) return statusErrorResult(argv, parsed, parsedStatus.message); const resolution = resolveRepo(parsedStatus.repo, "opcore status"); - if (!resolution.ok) { - return createCommandRouterResult({ - bin: "opcore", - argv, - canonicalCommand: ["opcore", "status"], - owner: "runtime", - status: "error", - json: parsed.json, - message: resolution.message - }); - } + if (!resolution.ok) return statusErrorResult(argv, parsed, resolution.message); let repoState: OpcoreRepoStatePayload; try { repoState = createRepoState(resolution.resolution); } catch (error) { - return createCommandRouterResult({ - bin: "opcore", - argv, - canonicalCommand: ["opcore", "status"], - owner: "runtime", - status: "error", - json: parsed.json, - message: errorMessage(error) - }); + return statusErrorResult(argv, parsed, errorMessage(error)); } const includeAspLine = parsedStatus.showAspLine || repoState.activation.asp.state === "enrolled"; return createCommandRouterResult({ @@ -196,632 +48,30 @@ export function routeOpcoreStatus(argv: readonly string[], parsed: ParsedCommand }); } -export function parseOpcoreStatusArgs( - args: readonly string[] -): { ok: true; repo: string; showAspLine: boolean } | { ok: false; message: string } { - let repo = process.cwd(); - let showAspLine = false; - for (let index = 0; index < args.length; index += 1) { - const arg = args[index]; - if (helpArgs.has(arg)) return { ok: false, message: opcoreStatusHelpMessage() }; - if (arg === "--repo") { - const value = args[index + 1]; - if (!value || value.startsWith("--")) return { ok: false, message: "opcore status: --repo requires a path" }; - repo = value; - index += 1; - continue; - } - if (arg.startsWith("--repo=")) { - const value = arg.slice("--repo=".length); - if (!value) return { ok: false, message: "opcore status: --repo requires a path" }; - repo = value; - continue; - } - if (arg === "--verbose" || arg === "--asp") { - showAspLine = true; - continue; - } - return { ok: false, message: `opcore status: unsupported argument ${arg}` }; - } - return { ok: true, repo, showAspLine }; -} - -export function parseOpcoreRepoArgs(args: readonly string[], command: string): { ok: true; repo: string } | { ok: false; message: string } { - let repo = process.cwd(); - for (let index = 0; index < args.length; index += 1) { - const arg = args[index]; - if (helpArgs.has(arg)) return { ok: false, message: opcoreStatusHelpMessage() }; - if (arg === "--repo") { - const value = args[index + 1]; - if (!value || value.startsWith("--")) return { ok: false, message: `${command}: --repo requires a path` }; - repo = value; - index += 1; - continue; - } - if (arg.startsWith("--repo=")) { - const value = arg.slice("--repo=".length); - if (!value) return { ok: false, message: `${command}: --repo requires a path` }; - repo = value; - continue; - } - return { ok: false, message: `${command}: unsupported argument ${arg}` }; - } - return { ok: true, repo }; -} - -export function resolveRepo(repoArg: string, command: string): { ok: true; resolution: RepoResolution } | { ok: false; message: string } { - const requestedPath = resolve(repoArg); - const requestedDirectory = readDirectoryMetadata(requestedPath, command); - if (!requestedDirectory.ok) { - return { ok: false, message: requestedDirectory.message }; - } - if (!requestedDirectory.stat.isDirectory()) { - return { ok: false, message: `${command}: invalid repo ${requestedPath} is not a directory` }; - } - const gitRoot = runGit(requestedPath, ["rev-parse", "--show-toplevel"]); - if (gitRoot.status === 0 && gitRoot.stdout.trim().length > 0) { - const rootPath = gitRoot.stdout.trim(); - const rootDirectory = readDirectoryMetadata(rootPath, command); - if (!rootDirectory.ok) { - return { ok: false, message: rootDirectory.message }; - } - if (!rootDirectory.stat.isDirectory()) { - return { ok: false, message: `${command}: invalid repo ${rootPath} is not a directory` }; - } - return { - ok: true, - resolution: { - requestedPath: requestedDirectory.realpath, - root: rootDirectory.realpath, - git: true - } - }; - } - return { - ok: true, - resolution: { - requestedPath: requestedDirectory.realpath, - root: requestedDirectory.realpath, - git: false - } - }; -} - -function readDirectoryMetadata( - path: string, - command: string -): { ok: true; stat: ReturnType; realpath: string } | { ok: false; message: string } { - let stat: ReturnType; - try { - stat = statSync(path); - } catch (error) { - return { ok: false, message: invalidRepoMessage(path, error, "does not exist", command) }; - } - if (stat.isDirectory()) { - try { - readdirSync(path, { withFileTypes: true }); - } catch (error) { - return { ok: false, message: `${command}: invalid repo ${path} is unreadable: ${errorMessage(error)}` }; - } - } - try { - return { ok: true, stat, realpath: realpathSync(path) }; - } catch (error) { - return { ok: false, message: `${command}: invalid repo ${path} cannot be resolved: ${errorMessage(error)}` }; - } -} - -export function createRepoState(resolution: RepoResolution): OpcoreRepoStatePayload { - const validationStatus = createDefaultValidationStatusPayload({ - repoRoot: resolution.root, - graphMode: "optional" +function statusHelpResult(argv: readonly string[], parsed: ParsedCommandArgv): CommandRouterResult { + return createCommandRouterResult({ + bin: "opcore", + argv, + canonicalCommand: ["opcore", "status", "help"], + owner: "runtime", + status: "ok", + json: parsed.json, + message: opcoreStatusHelpMessage() }); - const census = readRepoCensus(resolution); - const coverage = computeCoverage(census.files); - const graphStatus = validationStatus.graph.status; - const policy = validationPolicySummary(resolution.root, validationStatus.adapterRegistry.checkIds); - const validation = validationSummary(validationStatus, coverage, policy); - const asp = discoverAspEnrollment(resolution.root); - const warnings = statusWarnings({ coverage, validation, graphStatus, traversalFailures: census.traversalFailures }); - const blockers = statusBlockers(graphStatus, census.traversalFailures); - const nextActions = nextCommands(resolution.root, graphStatus, census.traversalFailures); - const ready = graphStatus.state === "available" && validation.ready && blockers.length === 0; - const level: OpcoreRepoStatePayload["activation"]["level"] = ready ? "ready" : blockers.length > 0 ? "blocked" : "degraded"; - - return { - schemaVersion: 1, - repo: { - root: resolution.root, - requestedPath: resolution.requestedPath, - git: census.git - }, - coverage, - graph: { - state: graphStatus.state, - mode: graphStatus.mode, - provider: graphStatus.provider, - action: graphAction(graphStatus), - ...(graphStatus.message ? { message: graphStatus.message } : {}), - status: graphStatus - }, - validation, - activation: { - ready, - level, - summary: activationSummary(level, graphStatus, coverage.unsupported.totalFiles, census.traversalFailures), - asp - }, - warnings, - blockers, - nextActions - }; } -function readRepoCensus(resolution: RepoResolution): FileCensus { - if (resolution.git) { - const traversalFailures: CensusTraversalFailure[] = []; - const statusResult = runGit(resolution.root, ["status", "--porcelain=v1", "--branch"]); - const status = statusResult.status === 0 - ? parseGitStatus(statusResult.stdout) - : { available: true }; - if (statusResult.status !== 0) { - traversalFailures.push({ - path: ".", - message: `git status failed: ${gitFailureMessage(statusResult)}` - }); - } - const filesResult = runGit(resolution.root, ["ls-files", "-co", "--exclude-standard"]); - if (filesResult.status !== 0) { - traversalFailures.push({ - path: ".", - message: `git file census failed: ${gitFailureMessage(filesResult)}` - }); - } - const files = filesResult.stdout - .split(/\r?\n/) - .map((line) => line.trim()) - .filter((line) => line.length > 0 && !hasSkippedSegment(line)) - .filter((file) => fileExistsForCensus(resolution.root, file, traversalFailures)); - return { files, git: status, traversalFailures: uniqueTraversalFailures(traversalFailures) }; - } - const census = readFilesRecursive(resolution.root); - return { - files: census.files, - git: { - available: false - }, - traversalFailures: uniqueTraversalFailures(census.traversalFailures) - }; -} - -function parseGitStatus(stdout: string): GitState { - const lines = stdout.split(/\r?\n/).filter((line) => line.length > 0); - const branchLine = lines.find((line) => line.startsWith("## ")); - const statusLines = lines.filter((line) => !line.startsWith("## ")); - let branch: string | undefined; - if (branchLine) { - branch = branchLine.slice(3).split("...")[0]?.split(" ")[0]?.trim() || undefined; - } - let staged = 0; - let unstaged = 0; - let untracked = 0; - let conflicted = 0; - for (const line of statusLines) { - const x = line[0] ?? " "; - const y = line[1] ?? " "; - const code = `${x}${y}`; - if (code === "??") { - untracked += 1; - continue; - } - if (["DD", "AU", "UD", "UA", "DU", "AA", "UU"].includes(code)) conflicted += 1; - if (x !== " " && x !== "?") staged += 1; - if (y !== " " && y !== "?") unstaged += 1; - } - return { - available: true, - ...(branch ? { branch } : {}), - changed: statusLines.length, - staged, - unstaged, - untracked, - conflicted, - clean: statusLines.length === 0 - }; -} - -function readFilesRecursive(root: string): { files: string[]; traversalFailures: CensusTraversalFailure[] } { - const files: string[] = []; - const traversalFailures: CensusTraversalFailure[] = []; - const stack = [root]; - while (stack.length > 0) { - const current = stack.pop(); - if (!current) continue; - let entries: ReturnType; - try { - entries = readdirSync(current, { withFileTypes: true }); - } catch (error) { - traversalFailures.push(traversalFailure(root, current, error)); - continue; - } - for (const entry of entries) { - if (isSkippedPathSegment(entry.name)) continue; - const absolute = join(current, entry.name); - const relative = absolute.slice(root.length + 1).split(sep).join("/"); - if (hasSkippedSegment(relative)) continue; - if (entry.isDirectory()) { - stack.push(absolute); - } else if (entry.isFile()) { - files.push(relative); - } - } - } - return { files: files.sort(), traversalFailures }; -} - -function computeCoverage(files: readonly string[]): OpcoreRepoStatePayload["coverage"] { - const graphCounts = new Map(); - const validationCounts = new Map(); - const unsupportedCounts = new Map(); - const languageCounts = new Map(); - let graphSupportedFiles = 0; - let validationSupportedFiles = 0; - let retainedFiles = 0; - - for (const file of files) { - const kind = fileKind(file); - const policy = sourcePolicyForFile(file); - const language = policy?.language; - const graphSupported = policy?.graphSupported ?? false; - const validationSupported = policy?.validationSupported ?? false; - const retained = policy?.retained ?? false; - - if (graphSupported) { - graphSupportedFiles += 1; - increment(graphCounts, kind); - } - if (validationSupported) { - validationSupportedFiles += 1; - increment(validationCounts, kind); - } - if (retained) retainedFiles += 1; - if (policy && !graphSupported && !validationSupported && !retained) { - const current = unsupportedCounts.get(kind) ?? { language: policy.unsupportedStack ?? policy.language, count: 0, examples: [] }; - current.count += 1; - if (current.examples.length < 3) current.examples.push(file); - unsupportedCounts.set(kind, current); - } - if (language) { - const current = languageCounts.get(language) ?? { - files: 0, - graphSupported, - validationSupported: validationSupported || retained - }; - current.files += 1; - current.graphSupported = current.graphSupported || graphSupported; - current.validationSupported = current.validationSupported || validationSupported || retained; - languageCounts.set(language, current); - } - } - - return { - totalFiles: files.length, - languages: [...languageCounts.entries()] - .sort(([left], [right]) => left.localeCompare(right)) - .map(([language, value]) => ({ language, ...value })), - graph: { - supportedFiles: graphSupportedFiles, - extensions: countEntries(graphCounts) - }, - validation: { - supportedFiles: validationSupportedFiles, - retainedFiles, - extensions: countEntries(validationCounts) - }, - unsupported: { - totalFiles: [...unsupportedCounts.values()].reduce((sum, entry) => sum + entry.count, 0), - stacks: [...unsupportedCounts.entries()] - .sort(([left], [right]) => left.localeCompare(right)) - .map(([extension, value]) => ({ extension, ...value })) - } - }; -} - -function validationSummary( - validationStatus: ReturnType, - coverage: OpcoreRepoStatePayload["coverage"], - policy: OpcoreValidationPolicySummary -): OpcoreRepoStatePayload["validation"] { - const adapters = validationStatus.adapterRegistry.adapters ?? []; - const degraded = adapters.flatMap((adapter) => degradedToolchains(adapter)); - return { - ready: validationStatus.ready, - checkCount: validationStatus.adapterRegistry.checkIds.length, - policy, - adapters: adapters.map((adapter) => ({ - adapter: adapter.adapter, - status: adapter.status, - checkCount: adapter.checkIds.length, - degradedChecks: (adapter.degradedChecks ?? []).map((check) => check.checkId), - missingTools: (adapter.toolchain ?? []).filter((tool) => !tool.available).map((tool) => tool.tool) - })), - degradedToolchains: relevantDegradedToolchainsForCoverage(coverage, degraded) - }; -} - -export function validationPolicySummary( - repoRoot: string, - configuredChecks: readonly string[] -): OpcoreValidationPolicySummary { - const configFileExists = existsSync(join(repoRoot, ".opcore", "config")); - const config = readOpcoreRepoConfig(repoRoot); - return { - path: ".opcore/config", - state: configFileExists ? "loaded" : "missing", - adapters: [...(config.validation.adapters ?? [])], - packs: [...config.validation.checks.packs], - disabledChecks: [...config.validation.checks.disabled], - defaultChecks: [...config.validation.checks.defaults], - configuredChecks: [...configuredChecks] - }; -} - -function degradedToolchains(adapter: ValidationAdapterRuntimeStatus): OpcoreRepoStatePayload["validation"]["degradedToolchains"] { - return (adapter.toolchain ?? []) - .filter((tool) => !tool.available) - .map((tool) => ({ - adapter: adapter.adapter, - tool: tool.tool, - ...(tool.failureMessage ? { failureMessage: tool.failureMessage } : {}) - })); -} - -function discoverAspEnrollment(repoRoot: string): OpcoreRepoStatePayload["activation"]["asp"] { - const candidates = [".asp/asp.json", ".asp/local.json", "asp-server.json"]; - const paths = candidates.filter((path) => existsSync(join(repoRoot, path))); - return { - state: paths.length > 0 ? "enrolled" : "not_enrolled", - paths - }; -} - -function statusWarnings(options: { - coverage: OpcoreRepoStatePayload["coverage"]; - validation: OpcoreRepoStatePayload["validation"]; - graphStatus: GraphProviderStatus; - traversalFailures: readonly CensusTraversalFailure[]; -}): string[] { - const warnings: string[] = []; - if (options.traversalFailures.length > 0) { - warnings.push(`Unreadable repo paths: ${formatTraversalFailurePaths(options.traversalFailures)}; coverage may be incomplete.`); - } - if (options.coverage.unsupported.totalFiles > 0) { - warnings.push( - `Unsupported stacks: ${options.coverage.unsupported.stacks.map((stack) => `${stack.language} (${stack.count})`).join(", ")}` - ); - } - if (options.validation.degradedToolchains.length > 0) { - warnings.push(`Degraded validation tools: ${options.validation.degradedToolchains.map((tool) => tool.tool).join(", ")}`); - } - if (options.graphStatus.state === "skipped" || options.graphStatus.state === "stale" || options.graphStatus.state === "schema_mismatch") { - warnings.push(`Graph is ${options.graphStatus.state}; graph-backed scan/check work needs ${graphAction(options.graphStatus)}`); - } - return warnings; -} - -function statusBlockers(graphStatus: GraphProviderStatus, traversalFailures: readonly CensusTraversalFailure[]): string[] { - const blockers: string[] = []; - if (traversalFailures.length > 0) { - blockers.push(`Unreadable repo paths prevent complete coverage census: ${formatTraversalFailurePaths(traversalFailures)}`); - } - if (graphStatus.state === "error" || graphStatus.state === "daemon_unavailable") { - blockers.push(graphStatus.message ?? graphStatus.failure.message); - } - if (graphStatus.state === "schema_mismatch") { - blockers.push("Graph metadata schema mismatch requires rebuild."); - } - return blockers; -} - -function nextCommands(repoRoot: string, graphStatus: GraphProviderStatus, traversalFailures: readonly CensusTraversalFailure[]): string[] { - if (traversalFailures.length > 0) { - return [ - `Fix permissions for unreadable repo paths: ${formatTraversalFailurePaths(traversalFailures)}`, - `opcore status --repo ${repoRoot} --json` - ]; - } - if (graphStatus.state === "available") { - return [`opcore check --changed --repo ${repoRoot} --json`, `opcore --repo ${repoRoot} --json`]; - } - if (graphRefreshRecommended(graphStatus)) { - return [`opcore graph build --repo ${repoRoot} --json`, `opcore --repo ${repoRoot} --json`]; - } - return [`opcore --repo ${repoRoot} --json`, `opcore status --repo ${repoRoot} --json`]; -} - -function graphRefreshRecommended(status: GraphProviderStatus): boolean { - return status.state === "stale" || - status.state === "schema_mismatch" || - status.state === "skipped" || - status.state === "required_missing"; -} - -function graphAction(status: GraphProviderStatus): string { - if (status.state === "available") return "Graph is ready."; - if (status.state === "warming") return "Wait for graph warmup."; - if (status.state === "stale") return "refresh graph evidence before graph-backed checks."; - if (status.state === "schema_mismatch") return "refresh graph metadata."; - if (status.state === "skipped" || status.state === "required_missing") return "graph evidence is unavailable."; - return status.message ?? status.failure.message; -} - -function activationSummary( - level: OpcoreRepoStatePayload["activation"]["level"], - graphStatus: GraphProviderStatus, - unsupportedFiles: number, - traversalFailures: readonly CensusTraversalFailure[] -): string { - if (level === "ready") return unsupportedFiles > 0 ? "Repo is ready with unsupported stacks reported." : "Repo is ready."; - if (traversalFailures.length > 0) return "Repo activation is blocked: unreadable repo paths prevent complete coverage census."; - if (level === "blocked") return `Repo activation is blocked: ${graphAction(graphStatus)}`; - return `Repo activation is degraded: ${graphAction(graphStatus)}`; -} - -export function formatOpcoreStatus( - repoState: OpcoreRepoStatePayload, - options: OpcoreStatusDisplayOptions -): string { - const unsupported = repoState.coverage.unsupported.stacks.length === 0 - ? "none" - : repoState.coverage.unsupported.stacks.map((stack) => `${stack.language} ${stack.count}`).join(", "); - const adapters = repoState.validation.adapters.length === 0 - ? "none" - : repoState.validation.adapters.map((adapter) => `${adapter.adapter}:${adapter.status}`).join(", "); - const degradedTools = repoState.validation.degradedToolchains.length === 0 - ? "none" - : repoState.validation.degradedToolchains.map((tool) => tool.tool).join(", "); - const git = repoState.repo.git.available - ? `git ${repoState.repo.git.branch ?? "unknown"} changed=${repoState.repo.git.changed ?? 0} staged=${repoState.repo.git.staged ?? 0} unstaged=${repoState.repo.git.unstaged ?? 0} untracked=${repoState.repo.git.untracked ?? 0}` - : "non-Git repo"; - const lines = [ - "opcore status", - `Repo: ${repoState.repo.root} (${git})`, - `Coverage: files=${repoState.coverage.totalFiles} graph=${repoState.coverage.graph.supportedFiles} validation=${repoState.coverage.validation.supportedFiles} retained=${repoState.coverage.validation.retainedFiles} unsupported=${unsupported}`, - `Graph: ${repoState.graph.state}; ${repoState.graph.action}`, - `Validation: checks=${repoState.validation.checkCount} adapters=${adapters} degradedTools=${degradedTools}`, - `Activation: ${repoState.activation.level}; ${repoState.activation.summary}`, - "Next:", - ...repoState.nextActions.slice(0, 2).map((action) => ` ${action}`) - ]; - if (options.includeAspLine) { - lines.splice(5, 0, formatAspStatusLine(repoState.activation.asp)); - } - return lines.join("\n"); -} - -function formatAspStatusLine(asp: OpcoreRepoStatePayload["activation"]["asp"]): string { - return `ASP: ${asp.state}${asp.paths.length > 0 ? ` (${asp.paths.join(", ")})` : ""}`; -} - -function opcoreStatusHelpMessage(): string { - return [ - "Usage: opcore status [--repo ] [--verbose] [--json]", - "Flags:", - " --repo Repository root to inspect.", - " --verbose Include non-enrolled ASP state in human output.", - " --asp Include ASP state in human output.", - " --json Emit structured JSON.", - "Defaults:", - " --repo defaults to the current working directory; status is read-only.", - "Examples:", - " opcore status --repo . --json", - "Exit codes: 0 status produced, 1 invalid repo or status error, 64 unsupported." - ].join("\n"); -} - -function runGit(cwd: string, args: readonly string[]): { status: number | null; stdout: string; stderr: string } { - const result = spawnSync("git", args, { - cwd, - encoding: "utf8", - stdio: ["ignore", "pipe", "pipe"] +function statusErrorResult( + argv: readonly string[], + parsed: ParsedCommandArgv, + message: string +): CommandRouterResult { + return createCommandRouterResult({ + bin: "opcore", + argv, + canonicalCommand: ["opcore", "status"], + owner: "runtime", + status: "error", + json: parsed.json, + message }); - return { - status: result.status, - stdout: result.stdout ?? "", - stderr: result.stderr ?? "" - }; -} - -function fileExistsForCensus(root: string, file: string, traversalFailures: CensusTraversalFailure[]): boolean { - try { - return statSync(join(root, file)).isFile(); - } catch (error) { - const code = errorCode(error); - if (code !== "ENOENT" && code !== "ENOTDIR") { - traversalFailures.push({ - path: file, - message: errorMessage(error) - }); - } - return false; - } -} - -function traversalFailure(root: string, absolutePath: string, error: unknown): CensusTraversalFailure { - const relativePath = absolutePath === root ? "." : absolutePath.slice(root.length + 1).split(sep).join("/"); - return { - path: relativePath, - message: errorMessage(error) - }; -} - -function uniqueTraversalFailures(failures: readonly CensusTraversalFailure[]): CensusTraversalFailure[] { - const seen = new Set(); - const unique: CensusTraversalFailure[] = []; - for (const failure of failures) { - const key = `${failure.path}\0${failure.message}`; - if (seen.has(key)) continue; - seen.add(key); - unique.push(failure); - } - return unique; -} - -function formatTraversalFailurePaths(failures: readonly CensusTraversalFailure[]): string { - const paths = [...new Set(failures.map((failure) => failure.path))].sort(); - const visible = paths.slice(0, 5).join(", "); - return paths.length > 5 ? `${visible}, +${paths.length - 5} more` : visible; -} - -function gitFailureMessage(result: { status: number | null; stdout: string; stderr: string }): string { - const detail = result.stderr.trim() || result.stdout.trim(); - return detail.length > 0 ? detail : `exit ${result.status ?? "unknown"}`; -} - -function hasSkippedSegment(path: string): boolean { - return path.split(/[\\/]+/).some((segment) => isSkippedPathSegment(segment)); -} - -function isSkippedPathSegment(segment: string): boolean { - return skippedPathSegments.has(segment) || skippedPathSegmentSuffixes.some((suffix) => segment.endsWith(suffix)); -} - -function fileKind(file: string): string { - const name = basename(file); - if (name === "Cargo.toml" || name === "Cargo.lock") return name; - return extname(name); -} - -function sourcePolicyForFile(file: string): SourcePolicy | undefined { - return sourcePolicies.get(fileKind(file)); -} - -function increment(map: Map, key: string): void { - map.set(key, (map.get(key) ?? 0) + 1); -} - -function countEntries(map: Map): { extension: string; count: number }[] { - return [...map.entries()] - .sort(([left], [right]) => left.localeCompare(right)) - .map(([extension, count]) => ({ extension, count })); -} - -function invalidRepoMessage(path: string, error: unknown, notFoundFallback: string, command: string): string { - const code = errorCode(error); - if (code === "ENOENT" || code === "ENOTDIR") { - return `${command}: invalid repo ${path} ${notFoundFallback}`; - } - return `${command}: invalid repo ${path}: ${errorMessage(error)}`; -} - -function errorCode(error: unknown): string | undefined { - return typeof error === "object" && error !== null && "code" in error && typeof error.code === "string" - ? error.code - : undefined; -} - -function errorMessage(error: unknown): string { - return typeof error === "object" && error !== null && "message" in error && typeof error.message === "string" - ? error.message - : String(error); } diff --git a/packages/opcore/src/validation-composition.ts b/packages/opcore/src/validation-composition.ts index 7137132..351995c 100644 --- a/packages/opcore/src/validation-composition.ts +++ b/packages/opcore/src/validation-composition.ts @@ -71,7 +71,7 @@ export function createDefaultValidationStatusPayload(options: { const graphMode = options.graphMode ?? "optional"; return createValidationStatusPayload({ checks: validationChecksForRepoPolicy(options.repoRoot), - adapters: [createRustValidationAdapterStatus(), createPythonValidationAdapterStatus()], + adapters: [createRustValidationAdapterStatus(), createPythonValidationAdapterStatus({ repoRoot: options.repoRoot })], graphMode, graphStatus: opcoreGraphStatus({ repoRoot: options.repoRoot }, graphMode) }); diff --git a/packages/validation-policy/src/factory.ts b/packages/validation-policy/src/factory.ts index de4a162..314a5e6 100644 --- a/packages/validation-policy/src/factory.ts +++ b/packages/validation-policy/src/factory.ts @@ -51,7 +51,7 @@ export function createBuiltInValidationChecks( deadCode: checksConfig?.typescript?.deadCode }), ...createRustValidationChecks(rustValidationOptions(config)), - ...createPythonValidationChecks(), + ...createPythonValidationChecks(repoRoot !== undefined ? { repoRoot } : {}), ...createDocsValidationChecks(docsValidationOptions(checksConfig?.docs)), ...cloneChecks(checksConfig?.clone, options) ]; diff --git a/packages/validation-python/src/index.ts b/packages/validation-python/src/index.ts index 7690a3c..1ad50be 100644 --- a/packages/validation-python/src/index.ts +++ b/packages/validation-python/src/index.ts @@ -19,6 +19,13 @@ export { export { validationPythonAdapterName } from "./check-constants.js"; export { isPythonSourcePath } from "./source-files.js"; export { createPythonValidationAdapterStatus, probePythonToolchain, type PythonValidationToolchainOptions } from "./toolchain.js"; +export { + resolvePythonTool, + findPythonConfigFile, + type PythonToolResolution, + type PythonToolResolutionSource, + type PythonToolResolverOptions +} from "./toolchain-resolver.js"; export { createSyntaxCheck, type PythonSyntaxCheckOptions } from "./syntax-check.js"; export { createTypeCheck, type PythonTypeCheckOptions } from "./type-check.js"; diff --git a/packages/validation-python/src/node-shims.d.ts b/packages/validation-python/src/node-shims.d.ts index ef0a42a..a0d053b 100644 --- a/packages/validation-python/src/node-shims.d.ts +++ b/packages/validation-python/src/node-shims.d.ts @@ -22,12 +22,18 @@ declare module "node:child_process" { } declare module "node:fs" { + export function existsSync(path: string): boolean; export function mkdirSync(path: string, options?: { recursive?: boolean }): void; export function mkdtempSync(prefix: string): string; export function rmSync(path: string, options?: { recursive?: boolean; force?: boolean }): void; export function writeFileSync(path: string, data: string): void; } +declare module "node:fs/promises" { + export function copyFile(source: string, destination: string): Promise; + export function mkdir(path: string, options?: { recursive?: boolean }): Promise; +} + declare module "node:os" { export function tmpdir(): string; } @@ -35,6 +41,9 @@ declare module "node:os" { declare module "node:path" { export function dirname(path: string): string; export function join(...paths: string[]): string; + export function relative(from: string, to: string): string; + export function resolve(...paths: string[]): string; + export const sep: string; } type BufferEncoding = "utf8"; @@ -45,4 +54,5 @@ interface Buffer { declare const process: { env: Record; + cwd(): string; }; diff --git a/packages/validation-python/src/toolchain-resolver.ts b/packages/validation-python/src/toolchain-resolver.ts new file mode 100644 index 0000000..8ed7522 --- /dev/null +++ b/packages/validation-python/src/toolchain-resolver.ts @@ -0,0 +1,98 @@ +import { existsSync } from "node:fs"; +import { join } from "node:path"; +import { runTool } from "./process.js"; + +export type PythonToolResolutionSource = "virtualenv-env" | "repo-venv" | "node-modules" | "path"; + +export interface PythonToolResolution { + tool: string; + available: boolean; + command: string; + args: readonly string[]; + cwd: string; + source: PythonToolResolutionSource; + version?: string; + configFile?: string; + failureMessage?: string; +} + +export interface PythonToolResolverOptions { + repoRoot: string; + env?: Record; + pythonCommand?: string; +} + +const pythonVirtualEnvDirs = [".venv", "venv", "env"] as const; + +const pythonConfigFileCandidates: Record = { + mypy: ["mypy.ini", "setup.cfg", "tox.ini", "pyproject.toml"], + pyright: ["pyrightconfig.json", "pyproject.toml"], + ruff: ["ruff.toml", ".ruff.toml", "pyproject.toml"], + pytest: ["pytest.ini", "tox.ini", "setup.cfg", "pyproject.toml"] +}; + +interface PythonToolCandidate { + command: string; + source: PythonToolResolutionSource; +} + +export function resolvePythonTool( + tool: string, + fallbackCommand: string, + versionArgs: readonly string[], + options: PythonToolResolverOptions +): PythonToolResolution { + const repoRoot = options.repoRoot; + const binaryName = tool === "python" ? "python" : tool; + const configFile = findPythonConfigFile(repoRoot, tool); + const candidates: PythonToolCandidate[] = []; + + const virtualEnv = options.env?.VIRTUAL_ENV; + if (virtualEnv) { + candidates.push({ command: join(virtualEnv, "bin", binaryName), source: "virtualenv-env" }); + } + for (const dir of pythonVirtualEnvDirs) { + candidates.push({ command: join(repoRoot, dir, "bin", binaryName), source: "repo-venv" }); + } + candidates.push({ command: join(repoRoot, "node_modules", ".bin", binaryName), source: "node-modules" }); + candidates.push({ command: fallbackCommand, source: "path" }); + + for (const candidate of candidates) { + if (candidate.source !== "path" && !existsSync(candidate.command)) continue; + const result = runTool(candidate.command, versionArgs, { env: options.env, cwd: repoRoot }); + if (result.ok) { + const version = (result.stdout || result.stderr).trim().split(/\r?\n/, 1)[0]; + return { + tool, + available: true, + command: candidate.command, + args: versionArgs, + cwd: repoRoot, + source: candidate.source, + ...(version.length > 0 ? { version } : {}), + ...(configFile !== undefined ? { configFile } : {}) + }; + } + } + + const failureResult = runTool(fallbackCommand, versionArgs, { env: options.env, cwd: repoRoot }); + return { + tool, + available: false, + command: fallbackCommand, + args: versionArgs, + cwd: repoRoot, + source: "path", + ...(configFile !== undefined ? { configFile } : {}), + failureMessage: failureResult.failureMessage ?? `${tool} unavailable` + }; +} + +export function findPythonConfigFile(repoRoot: string, tool: string): string | undefined { + const candidates = pythonConfigFileCandidates[tool] ?? ["pyproject.toml"]; + for (const name of candidates) { + const candidatePath = join(repoRoot, name); + if (existsSync(candidatePath)) return candidatePath; + } + return undefined; +} diff --git a/packages/validation-python/src/toolchain.ts b/packages/validation-python/src/toolchain.ts index 2cee745..4cb07a7 100644 --- a/packages/validation-python/src/toolchain.ts +++ b/packages/validation-python/src/toolchain.ts @@ -5,9 +5,10 @@ import type { } from "@the-open-engine/opcore-contracts"; import { PYTHON_SYNTAX_CHECK_ID, PYTHON_TYPES_CHECK_ID, pythonValidationCheckIds } from "./check-ids.js"; import { validationPythonAdapterName } from "./check-constants.js"; -import { runTool } from "./process.js"; +import { resolvePythonTool, type PythonToolResolution } from "./toolchain-resolver.js"; export interface PythonValidationToolchainOptions { + repoRoot?: string; env?: Record; pythonCommand?: string; } @@ -32,17 +33,25 @@ export function createPythonValidationAdapterStatus( export function probePythonToolchain( options: PythonValidationToolchainOptions = {} ): readonly ValidationAdapterToolchainStatus[] { + const resolverOptions = { + repoRoot: options.repoRoot ?? process.cwd(), + env: options.env, + pythonCommand: options.pythonCommand + }; 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), - probeTool("pytest", "pytest", ["--version"], options.env) + toToolchainStatus(resolvePythonTool("python", options.pythonCommand ?? "python3", ["--version"], resolverOptions)), + toToolchainStatus(resolvePythonTool("mypy", "mypy", ["--version"], resolverOptions)), + toToolchainStatus(resolvePythonTool("pyright", "pyright", ["--version"], resolverOptions)), + toToolchainStatus(resolvePythonTool("ruff", "ruff", ["--version"], resolverOptions)), + toToolchainStatus(resolvePythonTool("pytest", "pytest", ["--version"], resolverOptions)) ]; } export function pythonAvailable(options: PythonValidationToolchainOptions = {}): boolean { - return probeTool("python", options.pythonCommand ?? "python3", ["--version"], options.env).available; + return resolvePythonTool("python", options.pythonCommand ?? "python3", ["--version"], { + repoRoot: options.repoRoot ?? process.cwd(), + env: options.env + }).available; } export function createPythonDegradedChecks(missing: ReadonlySet): readonly ValidationAdapterDegradedCheckStatus[] { @@ -68,26 +77,15 @@ export function createPythonDegradedChecks(missing: ReadonlySet): readon return degraded; } -function probeTool( - tool: string, - command: string, - args: readonly string[], - env: Record | undefined -): ValidationAdapterToolchainStatus { - const result = runTool(command, args, { env }); - if (result.ok) { - const version = (result.stdout || result.stderr).trim().split(/\r?\n/, 1)[0]; - return { - tool, - available: true, - command: [command, ...args].join(" "), - ...(version.length > 0 ? { version } : {}) - }; - } +function toToolchainStatus(resolution: PythonToolResolution): ValidationAdapterToolchainStatus { return { - tool, - available: false, - command: [command, ...args].join(" "), - failureMessage: result.failureMessage ?? `${tool} unavailable` + tool: resolution.tool, + available: resolution.available, + command: [resolution.command, ...resolution.args].join(" "), + cwd: resolution.cwd, + source: resolution.source, + ...(resolution.version !== undefined ? { version: resolution.version } : {}), + ...(resolution.configFile !== undefined ? { configFile: resolution.configFile } : {}), + ...(resolution.failureMessage !== undefined ? { failureMessage: resolution.failureMessage } : {}) }; } diff --git a/packages/validation-python/src/type-check.ts b/packages/validation-python/src/type-check.ts index 4035329..7cd1bbf 100644 --- a/packages/validation-python/src/type-check.ts +++ b/packages/validation-python/src/type-check.ts @@ -1,10 +1,33 @@ import type { ValidationCheckDefinition } from "@the-open-engine/opcore-validation"; +import type { ValidationDiagnostic } from "@the-open-engine/opcore-contracts"; +import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { mkdir, copyFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join, relative, resolve, sep } from "node:path"; import { PYTHON_TYPES_CHECK_ID } from "./check-ids.js"; import { pythonCheckAdapter, pythonCheckOwner, supportedPythonValidationScopes } from "./check-constants.js"; -import { materializePythonSources } from "./source-files.js"; -import { probePythonToolchain, type PythonValidationToolchainOptions } from "./toolchain.js"; +import { diagnostic, sortDiagnostics } from "./diagnostics.js"; +import { runTool } from "./process.js"; +import { materializePythonSources, type PythonMaterializedSourceSet } from "./source-files.js"; +import { type PythonValidationToolchainOptions } from "./toolchain.js"; +import { resolvePythonTool, type PythonToolResolution } from "./toolchain-resolver.js"; -export interface PythonTypeCheckOptions extends PythonValidationToolchainOptions {} +export interface PythonTypeCheckOptions extends PythonValidationToolchainOptions { + timeoutMs?: number; +} + +interface MaterializedPythonTypeWorkspace { + root: string; + cleanup: () => void; +} + +const pythonToolConfigFiles = [ + "mypy.ini", + "setup.cfg", + "tox.ini", + "pyproject.toml", + "pyrightconfig.json" +] as const; export function createTypeCheck(options: PythonTypeCheckOptions = {}): ValidationCheckDefinition { return { @@ -16,9 +39,9 @@ export function createTypeCheck(options: PythonTypeCheckOptions = {}): Validatio run: async (context) => { const sourceSet = await materializePythonSources(context); if (sourceSet.files.length === 0) return { diagnostics: [] }; - const toolchain = probePythonToolchain(options); - const hasTypeChecker = toolchain.some((tool) => (tool.tool === "mypy" || tool.tool === "pyright") && tool.available); - if (!hasTypeChecker) { + const repoRoot = context.request.repo.repoRoot ?? process.cwd(); + const checker = selectTypeChecker({ ...options, repoRoot }); + if (checker === undefined) { return { status: "unsupported_request", diagnostics: [ @@ -31,17 +54,149 @@ export function createTypeCheck(options: PythonTypeCheckOptions = {}): Validatio ] }; } - return { - status: "skipped", - diagnostics: [ - { - category: "types", - severity: "info", - code: "PYTHON_TYPES_DEFERRED", - message: "Python type validation tool execution is deferred to a follow-up adapter hardening pass." - } - ] - }; + + const workspace = await materializePythonTypeWorkspace(repoRoot, sourceSet); + try { + const result = runTool(checker.command, checkerArgs(checker, sourceSet), { + cwd: workspace.root, + env: options.env, + timeoutMs: options.timeoutMs ?? 30000 + }); + if (result.failureMessage !== undefined || result.exitCode === null) { + return unsupportedToolFailure(checker, result.failureMessage ?? `${checker.tool} invocation failed`); + } + return { + diagnostics: sortDiagnostics(parseTypeCheckerDiagnostics(checker, result.stdout, result.stderr, workspace.root)) + }; + } finally { + workspace.cleanup(); + } } }; } + +function selectTypeChecker(options: Required> & PythonValidationToolchainOptions): PythonToolResolution | undefined { + const resolverOptions = { repoRoot: options.repoRoot, env: options.env, pythonCommand: options.pythonCommand }; + const mypy = resolvePythonTool("mypy", "mypy", ["--version"], resolverOptions); + const pyright = resolvePythonTool("pyright", "pyright", ["--version"], resolverOptions); + if (!mypy.available && !pyright.available) return undefined; + if (pyright.available && !mypy.available) return pyright; + if (mypy.available && !pyright.available) return mypy; + if (pyright.configFile?.endsWith("pyrightconfig.json") && !mypy.configFile?.endsWith("mypy.ini")) return pyright; + return mypy; +} + +function checkerArgs(checker: PythonToolResolution, sourceSet: PythonMaterializedSourceSet): readonly string[] { + if (checker.tool === "pyright") return [...sourceSet.rootPaths]; + return [...sourceSet.rootPaths]; +} + +async function materializePythonTypeWorkspace( + repoRoot: string, + sourceSet: PythonMaterializedSourceSet +): Promise { + const tempRoot = mkdtempSync(join(tmpdir(), "opcore-python-types-")); + const root = join(tempRoot, "repo"); + try { + await mkdir(root, { recursive: true }); + await copyPythonConfigFiles(repoRoot, root); + for (const source of sourceSet.files) { + const absolutePath = resolveRepoPath(root, source.path); + await mkdir(dirname(absolutePath), { recursive: true }); + writeFileSync(absolutePath, source.content); + } + return { root, cleanup: () => rmSync(tempRoot, { recursive: true, force: true }) }; + } catch (error) { + rmSync(tempRoot, { recursive: true, force: true }); + throw error; + } +} + +async function copyPythonConfigFiles(repoRoot: string, root: string): Promise { + for (const file of pythonToolConfigFiles) { + const source = join(repoRoot, file); + if (!existsSync(source)) continue; + await copyFile(source, join(root, file)); + } +} + +function resolveRepoPath(root: string, path: string): string { + const absolutePath = resolve(root, path); + const relativePath = relative(root, absolutePath); + if (relativePath === "" || relativePath.startsWith("..") || relativePath.split(sep).includes("..")) { + throw new Error(`Repo-relative path escapes materialized Python workspace: ${path}`); + } + return absolutePath; +} + +function unsupportedToolFailure(checker: PythonToolResolution, message: string) { + return { + status: "unsupported_request" as const, + diagnostics: [ + diagnostic({ + category: "types", + severity: "info", + code: "PYTHON_TYPES_TOOL_FAILED", + message: `${checker.tool} could not run: ${message}` + }) + ] + }; +} + +function parseTypeCheckerDiagnostics( + checker: PythonToolResolution, + stdout: string, + stderr: string, + workspaceRoot: string +): readonly ValidationDiagnostic[] { + const text = [stdout, stderr].filter((part) => part.trim().length > 0).join("\n"); + const diagnostics: ValidationDiagnostic[] = []; + for (const line of text.split(/\r?\n/u)) { + const parsed = checker.tool === "pyright" ? parsePyrightLine(line, workspaceRoot) : parseMypyLine(line, workspaceRoot); + if (parsed !== undefined) diagnostics.push(parsed); + } + return diagnostics; +} + +function parseMypyLine(line: string, workspaceRoot: string): ValidationDiagnostic | undefined { + const match = /^(?.+?):(?\d+)(?::(?\d+))?:\s+(?error|warning|note):\s+(?.+?)(?:\s+\[(?[^\]]+)\])?$/u.exec(line.trim()); + if (match?.groups === undefined) return undefined; + const severity = match.groups.severity === "error" ? "error" : "warning"; + const code = match.groups.code !== undefined ? `MYPY_${normalizeDiagnosticCode(match.groups.code)}` : "MYPY_TYPE_ERROR"; + return diagnostic({ + category: "types", + severity, + path: repoRelativeDiagnosticPath(match.groups.path, workspaceRoot), + code, + message: withLocation(match.groups.message, match.groups.line, match.groups.column) + }); +} + +function parsePyrightLine(line: string, workspaceRoot: string): ValidationDiagnostic | undefined { + const match = /^\s*(?.+?):(?\d+):(?\d+)\s+-\s+(?error|warning|information):\s+(?.+?)(?:\s+\((?[^)]+)\))?$/u.exec(line.trim()); + if (match?.groups === undefined) return undefined; + const severity = match.groups.severity === "error" ? "error" : match.groups.severity === "warning" ? "warning" : "info"; + const code = match.groups.code !== undefined ? `PYRIGHT_${normalizeDiagnosticCode(match.groups.code)}` : "PYRIGHT_TYPE_ERROR"; + return diagnostic({ + category: "types", + severity, + path: repoRelativeDiagnosticPath(match.groups.path, workspaceRoot), + code, + message: withLocation(match.groups.message, match.groups.line, match.groups.column) + }); +} + +function repoRelativeDiagnosticPath(path: string, workspaceRoot: string): string { + const absolute = resolve(workspaceRoot, path); + const relativePath = relative(workspaceRoot, absolute).replaceAll("\\", "/"); + return relativePath.length > 0 && !relativePath.startsWith("..") ? relativePath : path.replaceAll("\\", "/"); +} + +function withLocation(message: string, line: string | undefined, column: string | undefined): string { + if (line === undefined) return message; + return column === undefined ? `${message} (line ${line})` : `${message} (line ${line}, column ${column})`; +} + +function normalizeDiagnosticCode(code: string): string { + return code.replace(/([a-z])([A-Z])/g, "$1_$2").replace(/[^A-Za-z0-9]+/g, "_").replace(/^_+|_+$/g, "").toUpperCase(); +} diff --git a/packages/validation-typescript/src/dead-code-analysis.ts b/packages/validation-typescript/src/dead-code-analysis.ts new file mode 100644 index 0000000..ec619d4 --- /dev/null +++ b/packages/validation-typescript/src/dead-code-analysis.ts @@ -0,0 +1,213 @@ +import type { GraphEdgeKind, GraphFactEdge, GraphFactNode, ValidationDiagnostic } from "@the-open-engine/opcore-contracts"; +import type { GraphFactExportMetadata } from "@the-open-engine/opcore-validation"; +import { + graphFactBooleanAttribute, + graphFactHasIncomingTargetEdge, + graphFactNodePath, + graphFactSymbolAliasSet, + graphFactUnsupportedExportLabels +} from "@the-open-engine/opcore-validation"; + +const typeReferenceEdgeKinds = ["INHERITS", "IMPLEMENTS"] as const satisfies readonly GraphEdgeKind[]; + +export interface UnusedSourceFileEvidence { + readonly fileNodes: readonly GraphFactNode[]; + readonly scopedPaths: ReadonlySet; + readonly contains: readonly GraphFactEdge[]; + readonly importsFrom: readonly GraphFactEdge[]; + readonly filePathsWithUnsupportedExportMetadata: ReadonlySet; + readonly reachableFileAliases: ReadonlySet; +} + +export interface TypeExportReferenceEvidence { + readonly nodes: readonly GraphFactNode[]; + readonly typeReferences: readonly GraphFactEdge[]; + readonly incomingTypeReferences: ReadonlySet; + readonly importsFrom: readonly GraphFactEdge[]; + readonly fileReachabilitySupported: boolean; + readonly typeReferenceSupported: boolean; +} + +export interface PartitionedTypeExports { + readonly unused: readonly GraphFactNode[]; + readonly unsupported: readonly GraphFactNode[]; +} + +export function isExportedSymbol(node: GraphFactNode): boolean { + if (node.kind === "File" || node.kind === "file") return false; + return graphFactBooleanAttribute(node, ["exported", "isExported", "export", "public"]); +} + +export function hasCallUsageCoverage(node: GraphFactNode): boolean { + return node.kind === "Function" || node.kind === "Class"; +} + +export function hasTypeUsageCoverage(node: GraphFactNode): boolean { + return node.kind === "Type" || node.kind === "TypeAlias" || node.kind === "Interface"; +} + +export function unsupportedExportUsageDiagnostic(nodes: readonly GraphFactNode[]): ValidationDiagnostic { + const kinds = [...new Set(nodes.map((node) => node.kind))].sort().join(", "); + return { + category: "graph", + severity: "info", + code: "TS_DEAD_CODE_UNSUPPORTED", + message: `Graph facts include exported ${kinds} symbols, but TypeScript dead-code validation only has CALLS usage coverage for Function/Class exports.` + }; +} + +export function unsupportedFileReachabilityDiagnostic(): ValidationDiagnostic { + return { + category: "graph", + severity: "info", + code: "TS_DEAD_CODE_UNSUPPORTED", + message: "Graph provider capability handshake does not include CONTAINS and IMPORTS_FROM coverage required for TypeScript unused-file validation." + }; +} + +export function unsupportedTypeReferenceDiagnostic(nodes: readonly GraphFactNode[]): ValidationDiagnostic { + return { + category: "graph", + severity: "info", + code: "TS_DEAD_CODE_UNSUPPORTED", + message: `Graph facts include exported Type symbols in referenced files without symbol-level type reference evidence: ${symbolLabels(nodes)}.` + }; +} + +export function unsupportedFileExportMetadataDiagnostic( + exports: readonly GraphFactExportMetadata[] +): ValidationDiagnostic { + const labels = graphFactUnsupportedExportLabels(exports); + return { + category: "graph", + severity: "info", + code: "TS_DEAD_CODE_UNSUPPORTED", + message: `Graph facts include unsupported TypeScript export metadata without resolved symbols: ${labels}.` + }; +} + +export function unusedSourceFiles(evidence: UnusedSourceFileEvidence): readonly GraphFactNode[] { + return evidence.fileNodes + .filter((node) => isUnusedSourceFile(node, evidence)) + .sort((left, right) => (graphFactNodePath(left) ?? left.id).localeCompare(graphFactNodePath(right) ?? right.id)); +} + +function isUnusedSourceFile(node: GraphFactNode, evidence: UnusedSourceFileEvidence): boolean { + const path = graphFactNodePath(node); + if (path === undefined || !evidence.scopedPaths.has(path)) return false; + if (hasReachableFile(path, node, evidence.reachableFileAliases)) return false; + if (evidence.filePathsWithUnsupportedExportMetadata.has(path)) return false; + if (!hasFileContainsEdge(path, node, evidence.contains)) return false; + return !hasIncomingFileImport(path, node, evidence.importsFrom); +} + +export function unusedFileDiagnostic(node: GraphFactNode): ValidationDiagnostic { + const path = graphFactNodePath(node) ?? node.path ?? node.id; + return { + category: "graph", + severity: "warning", + path, + code: "TS_DEAD_CODE_UNUSED_FILE", + message: `Source file has no incoming IMPORTS_FROM graph evidence: ${path}` + }; +} + +export function partitionTypeExportsByReferenceSupport( + evidence: TypeExportReferenceEvidence +): PartitionedTypeExports { + const unused: GraphFactNode[] = []; + const unsupported: GraphFactNode[] = []; + for (const node of evidence.nodes) { + if (hasIncomingTypeReference(node, evidence.typeReferences, evidence.incomingTypeReferences)) continue; + const path = graphFactNodePath(node); + if (path !== undefined && evidence.fileReachabilitySupported && !hasIncomingFileImport(path, undefined, evidence.importsFrom)) { + unused.push(node); + continue; + } + if (!evidence.typeReferenceSupported || path !== undefined) unsupported.push(node); + } + return { unused: unused.sort(compareSymbols), unsupported: unsupported.sort(compareSymbols) }; +} + +export function unusedTypeExportDiagnostic(node: GraphFactNode): ValidationDiagnostic { + return { + category: "graph", + severity: "warning", + path: graphFactNodePath(node), + code: "TS_DEAD_CODE_UNUSED_EXPORT", + message: `Exported type has no incoming graph reference evidence: ${node.name ?? node.id}` + }; +} + +export function isScopedSymbol(node: GraphFactNode, scopedPaths: ReadonlySet): boolean { + const filePath = graphFactNodePath(node); + return filePath !== undefined && scopedPaths.has(filePath); +} + +export function hasReachableSymbol(node: GraphFactNode, reachableSymbolAliases: ReadonlySet): boolean { + for (const alias of graphFactSymbolAliasSet(node)) { + if (reachableSymbolAliases.has(alias)) return true; + } + return false; +} + +export function isTypeReferenceEdge(edge: GraphFactEdge): boolean { + return (typeReferenceEdgeKinds as readonly string[]).includes(edge.kind); +} + +export function supportsAllEdgeKinds( + handshakeEdgeKinds: readonly string[] | undefined, + edgeKinds: readonly GraphEdgeKind[] +): boolean { + if (handshakeEdgeKinds === undefined) return true; + return edgeKinds.every((edgeKind) => handshakeEdgeKinds.includes(edgeKind)); +} + +export function supportsAnyEdgeKind( + handshakeEdgeKinds: readonly string[] | undefined, + edgeKinds: readonly GraphEdgeKind[] +): boolean { + if (handshakeEdgeKinds === undefined) return true; + return edgeKinds.some((edgeKind) => handshakeEdgeKinds.includes(edgeKind)); +} + +function hasFileContainsEdge(path: string, node: GraphFactNode, contains: readonly GraphFactEdge[]): boolean { + const aliases = fileAliases(path, node); + return contains.some((edge) => aliases.has(edge.from)); +} + +function hasIncomingFileImport(path: string, node: GraphFactNode | undefined, importsFrom: readonly GraphFactEdge[]): boolean { + const aliases = fileAliases(path, node); + return importsFrom.some((edge) => aliases.has(edge.to)); +} + +function fileAliases(path: string, node: GraphFactNode | undefined): ReadonlySet { + const aliases = new Set([path, `file:${path}`]); + if (node !== undefined) aliases.add(node.id); + return aliases; +} + +function hasReachableFile(path: string, node: GraphFactNode, reachableFileAliases: ReadonlySet): boolean { + for (const alias of fileAliases(path, node)) { + if (reachableFileAliases.has(alias)) return true; + } + return false; +} + +function hasIncomingTypeReference( + node: GraphFactNode, + typeReferences: readonly GraphFactEdge[], + incomingTypeReferences: ReadonlySet +): boolean { + return graphFactHasIncomingTargetEdge(node, typeReferences, incomingTypeReferences); +} + +function symbolLabels(nodes: readonly GraphFactNode[]): string { + return nodes.map((node) => node.name ?? node.id).slice(0, 5).join(", "); +} + +function compareSymbols(left: GraphFactNode, right: GraphFactNode): number { + const leftLabel = `${graphFactNodePath(left) ?? ""}\0${left.name ?? left.id}`; + const rightLabel = `${graphFactNodePath(right) ?? ""}\0${right.name ?? right.id}`; + return leftLabel.localeCompare(rightLabel); +} diff --git a/packages/validation-typescript/src/dead-code-check.ts b/packages/validation-typescript/src/dead-code-check.ts index 4c34a60..45494a0 100644 --- a/packages/validation-typescript/src/dead-code-check.ts +++ b/packages/validation-typescript/src/dead-code-check.ts @@ -1,24 +1,9 @@ -import type { GraphEdgeKind, GraphFactEdge, GraphFactNode, ValidationDiagnostic } from "@the-open-engine/opcore-contracts"; -import type { GraphFactExportMetadata, ValidationCheckDefinition } from "@the-open-engine/opcore-validation"; -import { - graphFactBooleanAttribute, - graphFactHasExportMetadata, - graphFactHasIncomingTargetEdge, - graphFactNodePath, - graphFactSymbolAliasSet, - graphFactUnsupportedExportLabels, - graphFactUnsupportedFileExportMetadata -} from "@the-open-engine/opcore-validation"; +import type { ValidationCheckDefinition } from "@the-open-engine/opcore-validation"; import { TYPE_SCRIPT_DEAD_CODE_CHECK_ID } from "./check-ids.js"; import { typeScriptCheckAdapter, typeScriptCheckOwner, supportedTypeScriptValidationScopes } from "./check-constants.js"; -import { deadCodeEntrypointReachability, type TypeScriptDeadCodeOptions } from "./dead-code-entrypoints.js"; +import type { TypeScriptDeadCodeOptions } from "./dead-code-entrypoints.js"; import { deadCodeGraphRequirements } from "./graph-requirements.js"; -import { materializeTypeScriptSources } from "./source-files.js"; - -const callEdgeKinds = ["CALLS"] as const satisfies readonly GraphEdgeKind[]; -const fileReachabilityEdgeKinds = ["CONTAINS", "IMPORTS_FROM"] as const satisfies readonly GraphEdgeKind[]; -const typeReferenceEdgeKinds = ["INHERITS", "IMPLEMENTS"] as const satisfies readonly GraphEdgeKind[]; -const deadCodeEdgeKinds = [...callEdgeKinds, ...fileReachabilityEdgeKinds, ...typeReferenceEdgeKinds] as const satisfies readonly GraphEdgeKind[]; +import { runTypeScriptDeadCodeCheck } from "./dead-code-run.js"; export function createDeadCodeCheck(options: TypeScriptDeadCodeOptions = {}): ValidationCheckDefinition { return { @@ -29,320 +14,6 @@ export function createDeadCodeCheck(options: TypeScriptDeadCodeOptions = {}): Va supportedScopes: supportedTypeScriptValidationScopes, requiresGraph: true, graphRequirements: deadCodeGraphRequirements, - run: async (context) => { - const sourceSet = await materializeTypeScriptSources(context); - const [symbolFacts, edges, fileNodes] = await Promise.all([ - context.graph.facts({ kind: "symbols" }), - context.graph.edgesByKind(deadCodeEdgeKinds), - context.graph.fileNodes(sourceSet.rootPaths) - ]); - const calls = edges.filter((edge) => edge.kind === "CALLS"); - const contains = edges.filter((edge) => edge.kind === "CONTAINS"); - const importsFrom = edges.filter((edge) => edge.kind === "IMPORTS_FROM"); - const typeReferences = edges.filter((edge) => isTypeReferenceEdge(edge)); - const scopedPaths = new Set(sourceSet.rootPaths); - const scopedSymbols = symbolFacts.nodes.filter((node) => isScopedSymbol(node, scopedPaths)); - const entrypointReachability = deadCodeEntrypointReachability(options, [...symbolFacts.nodes, ...fileNodes], edges); - const unsupportedFileExports = fileNodes.flatMap(graphFactUnsupportedFileExportMetadata); - const filePathsWithUnsupportedExportMetadata = new Set( - fileNodes - .filter((node) => graphFactUnsupportedFileExportMetadata(node).length > 0) - .map(graphFactNodePath) - .filter(isDefined) - ); - const handshakeEdgeKinds = context.graphStatus.state === "available" ? context.graphStatus.handshake?.edgeKinds : undefined; - const fileReachabilitySupported = supportsAllEdgeKinds(handshakeEdgeKinds, fileReachabilityEdgeKinds); - const callUsageSupported = supportsAllEdgeKinds(handshakeEdgeKinds, callEdgeKinds); - const typeReferenceSupported = supportsAnyEdgeKind(handshakeEdgeKinds, typeReferenceEdgeKinds); - const unusedFiles = fileReachabilitySupported - ? unusedSourceFiles( - fileNodes, - scopedPaths, - contains, - importsFrom, - filePathsWithUnsupportedExportMetadata, - entrypointReachability.reachableFileAliases - ) - : []; - if (!scopedSymbols.some(graphFactHasExportMetadata) && unsupportedFileExports.length === 0 && unusedFiles.length === 0) { - return { - diagnostics: [ - { - category: "graph", - severity: "info", - code: "TS_DEAD_CODE_UNSUPPORTED", - message: "Graph facts do not include exported symbol metadata required for TypeScript dead-code validation." - } - ] - }; - } - - const exportedSymbols = scopedSymbols.filter(isExportedSymbol); - const callCoveredExports = exportedSymbols.filter(hasCallUsageCoverage); - const callCoveredExportsRequiringCallUsage = callCoveredExports.filter( - (node) => !hasReachableSymbol(node, entrypointReachability.reachableSymbolAliases) - ); - const typeCoveredExports = exportedSymbols.filter(hasTypeUsageCoverage); - const unsupportedExports = exportedSymbols.filter( - (node) => - !hasCallUsageCoverage(node) && - !hasTypeUsageCoverage(node) && - !hasReachableSymbol(node, entrypointReachability.reachableSymbolAliases) - ); - if ( - callCoveredExportsRequiringCallUsage.length > 0 && - !callUsageSupported - ) { - return { - diagnostics: [ - { - category: "graph", - severity: "info", - code: "TS_DEAD_CODE_UNSUPPORTED", - message: "Graph provider capability handshake does not include CALLS edge coverage required for TypeScript dead-code validation." - } - ] - }; - } - - const incomingCalls = new Set(calls.map((edge) => edge.to)); - const incomingTypeReferences = new Set(typeReferences.map((edge) => edge.to)); - const diagnostics: ValidationDiagnostic[] = []; - if (unsupportedFileExports.length > 0) { - diagnostics.push(unsupportedFileExportMetadataDiagnostic(unsupportedFileExports)); - } - if (unsupportedExports.length > 0) { - diagnostics.push(unsupportedExportUsageDiagnostic(unsupportedExports)); - } - if (!fileReachabilitySupported && fileNodes.length > 0) { - diagnostics.push(unsupportedFileReachabilityDiagnostic()); - } - const typeExportsBySupport = partitionTypeExportsByReferenceSupport( - typeCoveredExports, - typeReferences, - incomingTypeReferences, - importsFrom, - fileReachabilitySupported, - typeReferenceSupported - ); - if (typeExportsBySupport.unsupported.length > 0) { - diagnostics.push(unsupportedTypeReferenceDiagnostic(typeExportsBySupport.unsupported)); - } - diagnostics.push(...unusedFiles.map(unusedFileDiagnostic)); - diagnostics.push( - ...callCoveredExports - .filter(() => callUsageSupported) - .filter((node) => !hasReachableSymbol(node, entrypointReachability.reachableSymbolAliases)) - .filter((node) => !graphFactHasIncomingTargetEdge(node, calls, incomingCalls)) - .map((node): ValidationDiagnostic => ({ - category: "graph", - severity: "warning", - path: graphFactNodePath(node), - code: "TS_DEAD_CODE_UNUSED_EXPORT", - message: `Exported symbol has no incoming CALLS graph evidence: ${node.name ?? node.id}` - })) - ); - diagnostics.push( - ...typeExportsBySupport.unused - .filter((node) => !hasReachableSymbol(node, entrypointReachability.reachableSymbolAliases)) - .map(unusedTypeExportDiagnostic) - ); - return { diagnostics }; - } - }; -} - -function isExportedSymbol(node: GraphFactNode): boolean { - if (node.kind === "File" || node.kind === "file") return false; - return graphFactBooleanAttribute(node, ["exported", "isExported", "export", "public"]); -} - -function hasCallUsageCoverage(node: GraphFactNode): boolean { - return node.kind === "Function" || node.kind === "Class"; -} - -function hasTypeUsageCoverage(node: GraphFactNode): boolean { - return node.kind === "Type" || node.kind === "TypeAlias" || node.kind === "Interface"; -} - -function unsupportedExportUsageDiagnostic(nodes: readonly GraphFactNode[]): ValidationDiagnostic { - const kinds = [...new Set(nodes.map((node) => node.kind))].sort().join(", "); - return { - category: "graph", - severity: "info", - code: "TS_DEAD_CODE_UNSUPPORTED", - message: `Graph facts include exported ${kinds} symbols, but TypeScript dead-code validation only has CALLS usage coverage for Function/Class exports.` - }; -} - -function unsupportedFileReachabilityDiagnostic(): ValidationDiagnostic { - return { - category: "graph", - severity: "info", - code: "TS_DEAD_CODE_UNSUPPORTED", - message: "Graph provider capability handshake does not include CONTAINS and IMPORTS_FROM coverage required for TypeScript unused-file validation." + run: (context) => runTypeScriptDeadCodeCheck(context, options) }; } - -function unsupportedTypeReferenceDiagnostic(nodes: readonly GraphFactNode[]): ValidationDiagnostic { - return { - category: "graph", - severity: "info", - code: "TS_DEAD_CODE_UNSUPPORTED", - message: `Graph facts include exported Type symbols in referenced files without symbol-level type reference evidence: ${symbolLabels(nodes)}.` - }; -} - -function unsupportedFileExportMetadataDiagnostic(exports: readonly GraphFactExportMetadata[]): ValidationDiagnostic { - const labels = graphFactUnsupportedExportLabels(exports); - return { - category: "graph", - severity: "info", - code: "TS_DEAD_CODE_UNSUPPORTED", - message: `Graph facts include unsupported TypeScript export metadata without resolved symbols: ${labels}.` - }; -} - -function unusedSourceFiles( - fileNodes: readonly GraphFactNode[], - scopedPaths: ReadonlySet, - contains: readonly GraphFactEdge[], - importsFrom: readonly GraphFactEdge[], - filePathsWithUnsupportedExportMetadata: ReadonlySet, - reachableFileAliases: ReadonlySet -): readonly GraphFactNode[] { - return fileNodes - .filter((node) => { - const path = graphFactNodePath(node); - if (path === undefined || !scopedPaths.has(path)) return false; - if (hasReachableFile(path, node, reachableFileAliases)) return false; - if (filePathsWithUnsupportedExportMetadata.has(path)) return false; - if (!hasFileContainsEdge(path, node, contains)) return false; - if (hasIncomingFileImport(path, node, importsFrom)) return false; - return true; - }) - .sort((left, right) => (graphFactNodePath(left) ?? left.id).localeCompare(graphFactNodePath(right) ?? right.id)); -} - -function unusedFileDiagnostic(node: GraphFactNode): ValidationDiagnostic { - const path = graphFactNodePath(node) ?? node.path ?? node.id; - return { - category: "graph", - severity: "warning", - path, - code: "TS_DEAD_CODE_UNUSED_FILE", - message: `Source file has no incoming IMPORTS_FROM graph evidence: ${path}` - }; -} - -interface PartitionedTypeExports { - unused: readonly GraphFactNode[]; - unsupported: readonly GraphFactNode[]; -} - -function partitionTypeExportsByReferenceSupport( - nodes: readonly GraphFactNode[], - typeReferences: readonly GraphFactEdge[], - incomingTypeReferences: ReadonlySet, - importsFrom: readonly GraphFactEdge[], - fileReachabilitySupported: boolean, - typeReferenceSupported: boolean -): PartitionedTypeExports { - const unused: GraphFactNode[] = []; - const unsupported: GraphFactNode[] = []; - for (const node of nodes) { - if (hasIncomingTypeReference(node, typeReferences, incomingTypeReferences)) continue; - const path = graphFactNodePath(node); - if (path !== undefined && fileReachabilitySupported && !hasIncomingFileImport(path, undefined, importsFrom)) { - unused.push(node); - continue; - } - if (!typeReferenceSupported || path !== undefined) unsupported.push(node); - } - return { - unused: unused.sort(compareSymbols), - unsupported: unsupported.sort(compareSymbols) - }; -} - -function unusedTypeExportDiagnostic(node: GraphFactNode): ValidationDiagnostic { - return { - category: "graph", - severity: "warning", - path: graphFactNodePath(node), - code: "TS_DEAD_CODE_UNUSED_EXPORT", - message: `Exported type has no incoming graph reference evidence: ${node.name ?? node.id}` - }; -} - -function isScopedSymbol(node: GraphFactNode, scopedPaths: ReadonlySet): boolean { - const filePath = graphFactNodePath(node); - return filePath !== undefined && scopedPaths.has(filePath); -} - -function hasFileContainsEdge(path: string, node: GraphFactNode, contains: readonly GraphFactEdge[]): boolean { - const aliases = fileAliases(path, node); - return contains.some((edge) => aliases.has(edge.from)); -} - -function hasIncomingFileImport(path: string, node: GraphFactNode | undefined, importsFrom: readonly GraphFactEdge[]): boolean { - const aliases = fileAliases(path, node); - return importsFrom.some((edge) => aliases.has(edge.to)); -} - -function fileAliases(path: string, node: GraphFactNode | undefined): ReadonlySet { - const aliases = new Set([path, `file:${path}`]); - if (node !== undefined) aliases.add(node.id); - return aliases; -} - -function hasReachableFile(path: string, node: GraphFactNode | undefined, reachableFileAliases: ReadonlySet): boolean { - for (const alias of fileAliases(path, node)) { - if (reachableFileAliases.has(alias)) return true; - } - return false; -} - -function hasReachableSymbol(node: GraphFactNode, reachableSymbolAliases: ReadonlySet): boolean { - for (const alias of graphFactSymbolAliasSet(node)) { - if (reachableSymbolAliases.has(alias)) return true; - } - return false; -} - -function hasIncomingTypeReference( - node: GraphFactNode, - typeReferences: readonly GraphFactEdge[], - incomingTypeReferences: ReadonlySet -): boolean { - return graphFactHasIncomingTargetEdge(node, typeReferences, incomingTypeReferences); -} - -function isTypeReferenceEdge(edge: GraphFactEdge): boolean { - return (typeReferenceEdgeKinds as readonly string[]).includes(edge.kind); -} - -function supportsAllEdgeKinds(handshakeEdgeKinds: readonly string[] | undefined, edgeKinds: readonly GraphEdgeKind[]): boolean { - if (handshakeEdgeKinds === undefined) return true; - return edgeKinds.every((edgeKind) => handshakeEdgeKinds.includes(edgeKind)); -} - -function supportsAnyEdgeKind(handshakeEdgeKinds: readonly string[] | undefined, edgeKinds: readonly GraphEdgeKind[]): boolean { - if (handshakeEdgeKinds === undefined) return true; - return edgeKinds.some((edgeKind) => handshakeEdgeKinds.includes(edgeKind)); -} - -function symbolLabels(nodes: readonly GraphFactNode[]): string { - return nodes - .map((node) => node.name ?? node.id) - .slice(0, 5) - .join(", "); -} - -function compareSymbols(left: GraphFactNode, right: GraphFactNode): number { - return `${graphFactNodePath(left) ?? ""}\0${left.name ?? left.id}`.localeCompare(`${graphFactNodePath(right) ?? ""}\0${right.name ?? right.id}`); -} - -function isDefined(value: T | undefined): value is T { - return value !== undefined; -} diff --git a/packages/validation-typescript/src/dead-code-entrypoints.ts b/packages/validation-typescript/src/dead-code-entrypoints.ts index 8a0d791..f5884b2 100644 --- a/packages/validation-typescript/src/dead-code-entrypoints.ts +++ b/packages/validation-typescript/src/dead-code-entrypoints.ts @@ -23,10 +23,11 @@ const symbolReachabilityEdgeKinds = new Set(["CALLS", "INHERITS", "IMPLEMENTS"]) export function deadCodeEntrypointReachability( options: TypeScriptDeadCodeOptions | undefined, nodes: readonly GraphFactNode[], - edges: readonly GraphFactEdge[] + edges: readonly GraphFactEdge[], + automaticEntrypointPaths: readonly string[] = [] ): TypeScriptDeadCodeEntrypointReachability { const entrypointPaths = uniqueSortedStrings( - (options?.entrypoints ?? []) + (options?.entrypoints ?? automaticEntrypointPaths) .map((entrypoint) => normalizeEntrypointPath(entrypoint)) .filter((entrypoint): entrypoint is string => entrypoint !== undefined) ); @@ -39,7 +40,7 @@ export function deadCodeEntrypointReachability( const reachableSymbolAliases = collectReachableSymbols(fileReachability.pendingSymbols, symbolNodesByAlias, symbolReferences); return { - configured: entrypointPaths.length > 0, + configured: options?.entrypoints !== undefined, entrypointPaths, reachableFileAliases: fileReachability.reachableFileAliases, reachableSymbolAliases diff --git a/packages/validation-typescript/src/dead-code-import-evidence.ts b/packages/validation-typescript/src/dead-code-import-evidence.ts new file mode 100644 index 0000000..4edee42 --- /dev/null +++ b/packages/validation-typescript/src/dead-code-import-evidence.ts @@ -0,0 +1,68 @@ +import type { GraphFactEdge, GraphFactNode, ValidationDiagnostic } from "@the-open-engine/opcore-contracts"; +import { graphFactNodePath } from "@the-open-engine/opcore-validation"; +import type { TypeScriptRelativeImport } from "./source-files.js"; + +export interface CompilerImportEvidence { + readonly edges: readonly GraphFactEdge[]; + readonly missingGraphTargets: ReadonlySet; +} + +export function compilerImportEvidence( + compilerImports: readonly TypeScriptRelativeImport[], + graphImports: readonly GraphFactEdge[], + nodes: readonly GraphFactNode[] +): CompilerImportEvidence { + const aliasesByPath = fileAliasesByPath(nodes); + const edges = compilerImports.map( + (entry): GraphFactEdge => ({ + kind: "IMPORTS_FROM", + from: `file:${entry.fromPath}`, + to: `file:${entry.resolvedPath}` + }) + ); + const missingGraphTargets = new Set( + compilerImports + .filter((entry) => !hasImportEdge(entry.fromPath, entry.resolvedPath, graphImports, aliasesByPath)) + .map((entry) => entry.resolvedPath) + ); + return { edges, missingGraphTargets }; +} + +export function missingImportEvidenceDiagnostic(paths: ReadonlySet): ValidationDiagnostic { + const labels = [...paths].sort().slice(0, 5).join(", "); + return { + category: "graph", + severity: "info", + code: "TS_DEAD_CODE_UNSUPPORTED", + message: `Compiler-resolved imports are missing from graph IMPORTS_FROM evidence; dead-export findings are suppressed for affected targets: ${labels}.` + }; +} + +export function nodeHasPath(node: GraphFactNode, paths: ReadonlySet): boolean { + const path = graphFactNodePath(node); + return path !== undefined && paths.has(path); +} + +function hasImportEdge( + fromPath: string, + toPath: string, + edges: readonly GraphFactEdge[], + aliasesByPath: ReadonlyMap> +): boolean { + const fromAliases = aliasesByPath.get(fromPath) ?? new Set([fromPath, `file:${fromPath}`]); + const toAliases = aliasesByPath.get(toPath) ?? new Set([toPath, `file:${toPath}`]); + return edges.some((edge) => fromAliases.has(edge.from) && toAliases.has(edge.to)); +} + +function fileAliasesByPath(nodes: readonly GraphFactNode[]): ReadonlyMap> { + const aliases = new Map>(); + for (const node of nodes) { + if (node.kind !== "File" && node.kind !== "file") continue; + const path = graphFactNodePath(node); + if (path === undefined) continue; + const pathAliases = aliases.get(path) ?? new Set([path, `file:${path}`]); + pathAliases.add(node.id); + aliases.set(path, pathAliases); + } + return aliases; +} diff --git a/packages/validation-typescript/src/dead-code-roots.ts b/packages/validation-typescript/src/dead-code-roots.ts new file mode 100644 index 0000000..21c0105 --- /dev/null +++ b/packages/validation-typescript/src/dead-code-roots.ts @@ -0,0 +1,212 @@ +import type { GraphFactNode } from "@the-open-engine/opcore-contracts"; +import type { ValidationCheckContext } from "@the-open-engine/opcore-validation"; +import { + graphFactNodePath, + isPlainObject, + joinRepoRelativePaths, + uniqueSortedStrings +} from "@the-open-engine/opcore-validation"; +import ts from "typescript"; +import type { TypeScriptDeadCodeOptions } from "./dead-code-entrypoints.js"; +import { isTypeScriptSourcePath, type TypeScriptMaterializedSourceSet } from "./source-files.js"; + +interface TypeScriptOutputMapping { + readonly rootDir: string; + readonly outDir: string; +} + +interface PackageEntrypointResolution { + readonly context: ValidationCheckContext; + readonly packageRoot: string; + readonly target: string; + readonly sourcePaths: ReadonlySet; + readonly outputMapping: TypeScriptOutputMapping | undefined; +} + +export async function discoverTypeScriptDeadCodeRoots( + context: ValidationCheckContext, + options: TypeScriptDeadCodeOptions, + sourceSet: TypeScriptMaterializedSourceSet, + nodes: readonly GraphFactNode[] +): Promise { + if (options.entrypoints !== undefined) return []; + const sourcePaths = knownSourcePaths(sourceSet, nodes); + const testRoots = sourcePaths.filter((path) => isConventionalTestPath(path)); + const graphTestRoots = nodes.filter(isGraphTestNode).map(graphFactNodePath).filter(isDefined); + const packageRoots = await discoverPackageEntrypoints(context, sourcePaths); + return uniqueSortedStrings([...testRoots, ...graphTestRoots, ...packageRoots]); +} + +function knownSourcePaths( + sourceSet: TypeScriptMaterializedSourceSet, + nodes: readonly GraphFactNode[] +): readonly string[] { + return uniqueSortedStrings([ + ...sourceSet.paths, + ...nodes.map(graphFactNodePath).filter(isDefined).filter(isTypeScriptSourcePath) + ]); +} + +async function discoverPackageEntrypoints( + context: ValidationCheckContext, + sourcePaths: readonly string[] +): Promise { + const sourcePathSet = new Set(sourcePaths); + const entrypoints: string[] = []; + for (const packageRoot of candidatePackageRoots(sourcePaths)) { + const manifest = await readPackageManifest(context, packageRoot); + if (manifest === undefined) continue; + const outputMapping = await readTypeScriptOutputMapping(context, packageRoot); + for (const target of packageEntrypointTargets(manifest)) { + const entrypoint = await resolvePackageEntrypoint({ + context, + packageRoot, + target, + sourcePaths: sourcePathSet, + outputMapping + }); + if (entrypoint !== undefined) entrypoints.push(entrypoint); + } + } + return uniqueSortedStrings(entrypoints); +} + +function candidatePackageRoots(sourcePaths: readonly string[]): readonly string[] { + const roots = new Set(); + for (const path of sourcePaths) { + const directoryParts = path.split("/").slice(0, -1); + for (let length = 0; length <= directoryParts.length; length += 1) { + roots.add(directoryParts.slice(0, length).join("/")); + } + } + return [...roots].sort((left, right) => left.localeCompare(right)); +} + +async function readPackageManifest( + context: ValidationCheckContext, + packageRoot: string +): Promise | undefined> { + const result = await context.fileView.readAfter(pathWithinRoot(packageRoot, "package.json")); + if (result.status !== "found") return undefined; + try { + const parsed: unknown = JSON.parse(result.content); + return isPlainObject(parsed) ? parsed : undefined; + } catch { + return undefined; + } +} + +function packageEntrypointTargets(manifest: Record): readonly string[] { + return uniqueSortedStrings([ + ...(typeof manifest.main === "string" ? [manifest.main] : []), + ...stringLeaves(manifest.exports), + ...stringLeaves(manifest.bin) + ]); +} + +function stringLeaves(value: unknown): readonly string[] { + if (typeof value === "string") return [value]; + if (Array.isArray(value)) return value.flatMap(stringLeaves); + if (!isPlainObject(value)) return []; + return Object.keys(value).sort().flatMap((key) => stringLeaves(value[key])); +} + +async function readTypeScriptOutputMapping( + context: ValidationCheckContext, + packageRoot: string +): Promise { + const configPath = pathWithinRoot(packageRoot, "tsconfig.json"); + const result = await context.fileView.readAfter(configPath); + if (result.status !== "found") return undefined; + const parsed = ts.parseConfigFileTextToJson(configPath, result.content); + if (parsed.error !== undefined || !isPlainObject(parsed.config)) return undefined; + const compilerOptions = parsed.config.compilerOptions; + if (!isPlainObject(compilerOptions)) return undefined; + const rootDir = safePathWithinPackage(packageRoot, compilerOptions.rootDir); + const outDir = safePathWithinPackage(packageRoot, compilerOptions.outDir); + if (rootDir === undefined || outDir === undefined || rootDir === outDir) return undefined; + return { rootDir, outDir }; +} + +async function resolvePackageEntrypoint(resolution: PackageEntrypointResolution): Promise { + const targetPath = safePathWithinPackage(resolution.packageRoot, resolution.target); + if (targetPath === undefined) return undefined; + const mappedSource = await resolveOutputSource(resolution.context, targetPath, resolution.outputMapping); + if (mappedSource !== undefined) return mappedSource; + if (!resolution.sourcePaths.has(targetPath) || !isTypeScriptSourcePath(targetPath)) return undefined; + return (await resolution.context.fileView.exists(targetPath)) ? targetPath : undefined; +} + +async function resolveOutputSource( + context: ValidationCheckContext, + targetPath: string, + mapping: TypeScriptOutputMapping | undefined +): Promise { + if (mapping === undefined) return undefined; + const relativeOutputPath = descendantPath(mapping.outDir, targetPath); + if (relativeOutputPath === undefined) return undefined; + const sourceBase = joinRepoRelativePaths([mapping.rootDir, relativeOutputPath]); + if (sourceBase === undefined) return undefined; + const matchingSources: string[] = []; + for (const candidate of emittedSourceCandidates(sourceBase)) { + if (await context.fileView.exists(candidate)) matchingSources.push(candidate); + } + return matchingSources.length === 1 ? matchingSources[0] : undefined; +} + +function emittedSourceCandidates(path: string): readonly string[] { + if (path.endsWith(".d.mts")) return [replaceSuffix(path, ".d.mts", ".mts")]; + if (path.endsWith(".d.cts")) return [replaceSuffix(path, ".d.cts", ".cts")]; + if (path.endsWith(".d.ts")) { + return [replaceSuffix(path, ".d.ts", ".ts"), replaceSuffix(path, ".d.ts", ".tsx")]; + } + if (path.endsWith(".mjs")) return [replaceSuffix(path, ".mjs", ".mts"), path]; + if (path.endsWith(".cjs")) return [replaceSuffix(path, ".cjs", ".cts"), path]; + if (path.endsWith(".jsx")) { + return [replaceSuffix(path, ".jsx", ".tsx"), replaceSuffix(path, ".jsx", ".ts"), path]; + } + if (path.endsWith(".js")) { + return [replaceSuffix(path, ".js", ".ts"), replaceSuffix(path, ".js", ".tsx"), path]; + } + return isTypeScriptSourcePath(path) ? [path] : []; +} + +function safePathWithinPackage(packageRoot: string, value: unknown): string | undefined { + if (!isSafePackagePathValue(value)) return undefined; + const relativePath = value.startsWith("./") ? value.slice(2) : value; + if (relativePath.length === 0) return undefined; + if (relativePath.startsWith("/")) return undefined; + if (relativePath.split("/").includes("..")) return undefined; + return joinRepoRelativePaths([packageRoot, relativePath]); +} + +function isSafePackagePathValue(value: unknown): value is string { + if (typeof value !== "string") return false; + if (value.length === 0 || value.trim() !== value) return false; + return !["\\", "\0", "*", "?", "[", "]"].some((character) => value.includes(character)); +} + +function descendantPath(parent: string, path: string): string | undefined { + if (path === parent) return ""; + return path.startsWith(`${parent}/`) ? path.slice(parent.length + 1) : undefined; +} + +function pathWithinRoot(root: string, path: string): string { + return root.length === 0 ? path : `${root}/${path}`; +} + +function replaceSuffix(path: string, suffix: string, replacement: string): string { + return `${path.slice(0, -suffix.length)}${replacement}`; +} + +function isGraphTestNode(node: GraphFactNode): boolean { + return node.kind === "Test" || node.attributes?.isTest === true; +} + +function isConventionalTestPath(path: string): boolean { + return /(?:^|\/)__tests__\//u.test(path) || /\.(?:test|spec)\.[cm]?[jt]sx?$/u.test(path); +} + +function isDefined(value: T | undefined): value is T { + return value !== undefined; +} diff --git a/packages/validation-typescript/src/dead-code-run.ts b/packages/validation-typescript/src/dead-code-run.ts new file mode 100644 index 0000000..2c29d13 --- /dev/null +++ b/packages/validation-typescript/src/dead-code-run.ts @@ -0,0 +1,190 @@ +import type { GraphEdgeKind, GraphFactNode, ValidationDiagnostic } from "@the-open-engine/opcore-contracts"; +import type { ValidationCheckContext, ValidationCheckResult } from "@the-open-engine/opcore-validation"; +import { + graphFactHasExportMetadata, + graphFactHasIncomingTargetEdge, + graphFactNodePath, + graphFactUnsupportedFileExportMetadata +} from "@the-open-engine/opcore-validation"; +import { + hasCallUsageCoverage, + hasReachableSymbol, + hasTypeUsageCoverage, + isExportedSymbol, + isScopedSymbol, + isTypeReferenceEdge, + partitionTypeExportsByReferenceSupport, + supportsAllEdgeKinds, + supportsAnyEdgeKind, + unsupportedExportUsageDiagnostic, + unsupportedFileExportMetadataDiagnostic, + unsupportedFileReachabilityDiagnostic, + unsupportedTypeReferenceDiagnostic, + unusedFileDiagnostic, + unusedSourceFiles, + unusedTypeExportDiagnostic +} from "./dead-code-analysis.js"; +import { deadCodeEntrypointReachability, type TypeScriptDeadCodeOptions } from "./dead-code-entrypoints.js"; +import { + compilerImportEvidence, + missingImportEvidenceDiagnostic, + nodeHasPath +} from "./dead-code-import-evidence.js"; +import { discoverTypeScriptDeadCodeRoots } from "./dead-code-roots.js"; +import { materializeTypeScriptSources } from "./source-files.js"; + +const callEdgeKinds = ["CALLS"] as const satisfies readonly GraphEdgeKind[]; +const fileReachabilityEdgeKinds = ["CONTAINS", "IMPORTS_FROM"] as const satisfies readonly GraphEdgeKind[]; +const typeReferenceEdgeKinds = ["INHERITS", "IMPLEMENTS"] as const satisfies readonly GraphEdgeKind[]; +export const deadCodeEdgeKinds = [ + ...callEdgeKinds, + ...fileReachabilityEdgeKinds, + ...typeReferenceEdgeKinds +] as const satisfies readonly GraphEdgeKind[]; + +export async function runTypeScriptDeadCodeCheck( + context: ValidationCheckContext, + options: TypeScriptDeadCodeOptions +): Promise { + const state = await loadDeadCodeState(context, options); + return unsupportedCoverageResult(state) ?? { diagnostics: deadCodeDiagnostics(state) }; +} + +async function loadDeadCodeState(context: ValidationCheckContext, options: TypeScriptDeadCodeOptions) { + const sourceSet = await materializeTypeScriptSources(context); + const [symbolFacts, edges, fileNodes] = await Promise.all([ + context.graph.facts({ kind: "symbols" }), + context.graph.edgesByKind(deadCodeEdgeKinds), + context.graph.fileNodes(sourceSet.rootPaths) + ]); + const calls = edges.filter((edge) => edge.kind === "CALLS"); + const contains = edges.filter((edge) => edge.kind === "CONTAINS"); + const graphImports = edges.filter((edge) => edge.kind === "IMPORTS_FROM"); + const typeReferences = edges.filter(isTypeReferenceEdge); + const scopedPaths = new Set(sourceSet.rootPaths); + const scopedSymbols = symbolFacts.nodes.filter((node) => isScopedSymbol(node, scopedPaths)); + const allNodes = [...symbolFacts.nodes, ...fileNodes]; + const compilerImports = compilerImportEvidence(sourceSet.relativeImports, graphImports, allNodes); + const importsFrom = [...graphImports, ...compilerImports.edges]; + const effectiveEdges = [...edges, ...compilerImports.edges]; + const automaticRoots = await discoverTypeScriptDeadCodeRoots(context, options, sourceSet, allNodes); + const reachability = deadCodeEntrypointReachability(options, allNodes, effectiveEdges, automaticRoots); + const unsupportedFileExports = fileNodes.flatMap(graphFactUnsupportedFileExportMetadata); + const unsupportedFilePaths = new Set( + fileNodes + .filter((node) => graphFactUnsupportedFileExportMetadata(node).length > 0) + .map(graphFactNodePath) + .filter(isDefined) + ); + const handshakeEdgeKinds = context.graphStatus.state === "available" ? context.graphStatus.handshake?.edgeKinds : undefined; + const fileReachabilitySupported = supportsAllEdgeKinds(handshakeEdgeKinds, fileReachabilityEdgeKinds); + const callUsageSupported = supportsAllEdgeKinds(handshakeEdgeKinds, callEdgeKinds); + const typeReferenceSupported = supportsAnyEdgeKind(handshakeEdgeKinds, typeReferenceEdgeKinds); + const unusedFiles = fileReachabilitySupported + ? unusedSourceFiles({ + fileNodes, + scopedPaths, + contains, + importsFrom, + filePathsWithUnsupportedExportMetadata: unsupportedFilePaths, + reachableFileAliases: reachability.reachableFileAliases + }) + : []; + return { + calls, + fileNodes, + importsFrom, + scopedSymbols, + typeReferences, + reachability, + unsupportedFileExports, + missingGraphImportTargets: compilerImports.missingGraphTargets, + fileReachabilitySupported, + callUsageSupported, + typeReferenceSupported, + unusedFiles + }; +} + +type DeadCodeState = Awaited>; + +function unsupportedCoverageResult(state: DeadCodeState): ValidationCheckResult | undefined { + const lacksExportEvidence = + !state.scopedSymbols.some(graphFactHasExportMetadata) && + state.unsupportedFileExports.length === 0 && + state.unusedFiles.length === 0; + if (lacksExportEvidence) return unsupportedResult("Graph facts do not include exported symbol metadata required for TypeScript dead-code validation."); + const uncoveredCallable = state.scopedSymbols + .filter(isExportedSymbol) + .filter(hasCallUsageCoverage) + .some( + (node) => + !hasReachableSymbol(node, state.reachability.reachableSymbolAliases) && + !nodeHasPath(node, state.missingGraphImportTargets) + ); + if (uncoveredCallable && !state.callUsageSupported) { + return unsupportedResult("Graph provider capability handshake does not include CALLS edge coverage required for TypeScript dead-code validation."); + } + return undefined; +} + +function deadCodeDiagnostics(state: DeadCodeState): readonly ValidationDiagnostic[] { + const exportedSymbols = state.scopedSymbols.filter(isExportedSymbol); + const callCoveredExports = exportedSymbols.filter(hasCallUsageCoverage); + const typeCoveredExports = exportedSymbols.filter(hasTypeUsageCoverage); + const unsupportedExports = exportedSymbols.filter( + (node) => + !hasCallUsageCoverage(node) && + !hasTypeUsageCoverage(node) && + !hasReachableSymbol(node, state.reachability.reachableSymbolAliases) + ); + const typeExports = partitionTypeExportsByReferenceSupport({ + nodes: typeCoveredExports.filter((node) => !nodeHasPath(node, state.missingGraphImportTargets)), + typeReferences: state.typeReferences, + incomingTypeReferences: new Set(state.typeReferences.map((edge) => edge.to)), + importsFrom: state.importsFrom, + fileReachabilitySupported: state.fileReachabilitySupported, + typeReferenceSupported: state.typeReferenceSupported + }); + const diagnostics: ValidationDiagnostic[] = []; + if (state.unsupportedFileExports.length > 0) diagnostics.push(unsupportedFileExportMetadataDiagnostic(state.unsupportedFileExports)); + if (unsupportedExports.length > 0) diagnostics.push(unsupportedExportUsageDiagnostic(unsupportedExports)); + if (state.missingGraphImportTargets.size > 0) diagnostics.push(missingImportEvidenceDiagnostic(state.missingGraphImportTargets)); + if (!state.fileReachabilitySupported && state.fileNodes.length > 0) diagnostics.push(unsupportedFileReachabilityDiagnostic()); + if (typeExports.unsupported.length > 0) diagnostics.push(unsupportedTypeReferenceDiagnostic(typeExports.unsupported)); + diagnostics.push(...state.unusedFiles.map(unusedFileDiagnostic)); + diagnostics.push(...unusedCallableDiagnostics(callCoveredExports, state)); + diagnostics.push( + ...typeExports.unused + .filter((node) => !hasReachableSymbol(node, state.reachability.reachableSymbolAliases)) + .map(unusedTypeExportDiagnostic) + ); + return diagnostics; +} + +function unusedCallableDiagnostics( + nodes: readonly GraphFactNode[], + state: DeadCodeState +): readonly ValidationDiagnostic[] { + const incomingCalls = new Set(state.calls.map((edge) => edge.to)); + return nodes + .filter(() => state.callUsageSupported) + .filter((node) => !hasReachableSymbol(node, state.reachability.reachableSymbolAliases)) + .filter((node) => !nodeHasPath(node, state.missingGraphImportTargets)) + .filter((node) => !graphFactHasIncomingTargetEdge(node, state.calls, incomingCalls)) + .map((node) => ({ + category: "graph", + severity: "warning", + path: graphFactNodePath(node), + code: "TS_DEAD_CODE_UNUSED_EXPORT", + message: `Exported symbol has no incoming CALLS graph evidence: ${node.name ?? node.id}` + })); +} + +function unsupportedResult(message: string): ValidationCheckResult { + return { diagnostics: [{ category: "graph", severity: "info", code: "TS_DEAD_CODE_UNSUPPORTED", message }] }; +} + +function isDefined(value: T | undefined): value is T { + return value !== undefined; +} diff --git a/scripts/generate-release-receipt.mjs b/scripts/generate-release-receipt.mjs index 7b9b176..3b5cb2a 100644 --- a/scripts/generate-release-receipt.mjs +++ b/scripts/generate-release-receipt.mjs @@ -119,6 +119,8 @@ function collectOnePackageEvidence(packageName, packlists, descriptor, stagedPac const packageSource = packageSourceFor(packageName, stagedPackages); const manifest = JSON.parse(readFileSync(join(packageSource.repoRoot, packageSource.packageRoot, "package.json"), "utf8")); if (manifest.name !== packageName) throw new Error(`${packageRoot}/package.json name mismatch: ${manifest.name}`); + const bins = manifest.bin ?? {}; + validateNoOldPublicIdentity(packageName, manifest, bins); const pack = runJson("npm", ["pack", "--json", "--pack-destination", join(repoRoot, packDestination)], { cwd: packageSource.packageDir })[0]; @@ -137,8 +139,6 @@ function collectOnePackageEvidence(packageName, packlists, descriptor, stagedPac const tarballPath = `${packDestination}/${pack.filename}`; const tarballAbsolutePath = join(repoRoot, tarballPath); if (!existsSync(tarballAbsolutePath)) throw new Error(`npm pack did not write tarball: ${tarballPath}`); - const bins = manifest.bin ?? {}; - validateNoOldPublicIdentity(packageName, manifest, bins); const nativeArtifacts = packageName === "opcore" ? collectNativeArtifacts(packageSource.repoRoot, packageSource.packageRoot, packageName, descriptor) : []; diff --git a/tests/fixtures/package-packlists.json b/tests/fixtures/package-packlists.json index 6981181..cc151c0 100644 --- a/tests/fixtures/package-packlists.json +++ b/tests/fixtures/package-packlists.json @@ -124,6 +124,33 @@ "dist/source-policy.d.ts", "dist/source-policy.d.ts.map", "dist/source-policy.js", + "dist/status-args.d.ts", + "dist/status-args.d.ts.map", + "dist/status-args.js", + "dist/status-census.d.ts", + "dist/status-census.d.ts.map", + "dist/status-census.js", + "dist/status-coverage.d.ts", + "dist/status-coverage.d.ts.map", + "dist/status-coverage.js", + "dist/status-errors.d.ts", + "dist/status-errors.d.ts.map", + "dist/status-errors.js", + "dist/status-format.d.ts", + "dist/status-format.d.ts.map", + "dist/status-format.js", + "dist/status-git.d.ts", + "dist/status-git.d.ts.map", + "dist/status-git.js", + "dist/status-repo.d.ts", + "dist/status-repo.d.ts.map", + "dist/status-repo.js", + "dist/status-state.d.ts", + "dist/status-state.d.ts.map", + "dist/status-state.js", + "dist/status-validation.d.ts", + "dist/status-validation.d.ts.map", + "dist/status-validation.js", "dist/status.d.ts", "dist/status.d.ts.map", "dist/status.js", @@ -379,6 +406,9 @@ "node_modules/@the-open-engine/opcore-validation-python/dist/toolchain.d.ts", "node_modules/@the-open-engine/opcore-validation-python/dist/toolchain.d.ts.map", "node_modules/@the-open-engine/opcore-validation-python/dist/toolchain.js", + "node_modules/@the-open-engine/opcore-validation-python/dist/toolchain-resolver.d.ts", + "node_modules/@the-open-engine/opcore-validation-python/dist/toolchain-resolver.d.ts.map", + "node_modules/@the-open-engine/opcore-validation-python/dist/toolchain-resolver.js", "node_modules/@the-open-engine/opcore-validation-python/dist/type-check.d.ts", "node_modules/@the-open-engine/opcore-validation-python/dist/type-check.d.ts.map", "node_modules/@the-open-engine/opcore-validation-python/dist/type-check.js", @@ -458,12 +488,24 @@ "node_modules/@the-open-engine/opcore-validation-typescript/dist/compiler-options.d.ts", "node_modules/@the-open-engine/opcore-validation-typescript/dist/compiler-options.d.ts.map", "node_modules/@the-open-engine/opcore-validation-typescript/dist/compiler-options.js", + "node_modules/@the-open-engine/opcore-validation-typescript/dist/dead-code-analysis.d.ts", + "node_modules/@the-open-engine/opcore-validation-typescript/dist/dead-code-analysis.d.ts.map", + "node_modules/@the-open-engine/opcore-validation-typescript/dist/dead-code-analysis.js", "node_modules/@the-open-engine/opcore-validation-typescript/dist/dead-code-check.d.ts", "node_modules/@the-open-engine/opcore-validation-typescript/dist/dead-code-check.d.ts.map", "node_modules/@the-open-engine/opcore-validation-typescript/dist/dead-code-check.js", "node_modules/@the-open-engine/opcore-validation-typescript/dist/dead-code-entrypoints.d.ts", "node_modules/@the-open-engine/opcore-validation-typescript/dist/dead-code-entrypoints.d.ts.map", "node_modules/@the-open-engine/opcore-validation-typescript/dist/dead-code-entrypoints.js", + "node_modules/@the-open-engine/opcore-validation-typescript/dist/dead-code-import-evidence.d.ts", + "node_modules/@the-open-engine/opcore-validation-typescript/dist/dead-code-import-evidence.d.ts.map", + "node_modules/@the-open-engine/opcore-validation-typescript/dist/dead-code-import-evidence.js", + "node_modules/@the-open-engine/opcore-validation-typescript/dist/dead-code-roots.d.ts", + "node_modules/@the-open-engine/opcore-validation-typescript/dist/dead-code-roots.d.ts.map", + "node_modules/@the-open-engine/opcore-validation-typescript/dist/dead-code-roots.js", + "node_modules/@the-open-engine/opcore-validation-typescript/dist/dead-code-run.d.ts", + "node_modules/@the-open-engine/opcore-validation-typescript/dist/dead-code-run.d.ts.map", + "node_modules/@the-open-engine/opcore-validation-typescript/dist/dead-code-run.js", "node_modules/@the-open-engine/opcore-validation-typescript/dist/diagnostics.d.ts", "node_modules/@the-open-engine/opcore-validation-typescript/dist/diagnostics.d.ts.map", "node_modules/@the-open-engine/opcore-validation-typescript/dist/diagnostics.js", diff --git a/tests/graph-extraction-conformance.test.mjs b/tests/graph-extraction-conformance.test.mjs index a88a5f3..532ce05 100644 --- a/tests/graph-extraction-conformance.test.mjs +++ b/tests/graph-extraction-conformance.test.mjs @@ -12,6 +12,10 @@ const expected = JSON.parse(readFileSync(resolve(sourceFixtureRoot, "wave1.expec const pythonFixtureRoot = resolve(repoRoot, "../packages/fixtures/source-extraction/python"); const pythonExpected = JSON.parse(readFileSync(resolve(pythonFixtureRoot, "python.expected.json"), "utf8")); const rustOnlyFixtureRoot = resolve(repoRoot, "../packages/fixtures/source-extraction/rust-only"); +const nodeNextFixtureRoot = resolve(repoRoot, "../packages/fixtures/source-extraction/node-next"); +const nodeNextExpected = JSON.parse( + readFileSync(resolve(nodeNextFixtureRoot, "node-next.expected.json"), "utf8") +); describe("graph source extraction conformance", () => { it("extracts Wave 1 TS/JS/TSX/JSX facts through the GraphProvider wrapper", () => { @@ -55,6 +59,30 @@ describe("graph source extraction conformance", () => { }); }); + it("resolves NodeNext source variants and literal module references", () => { + withFixtureCopy(nodeNextFixtureRoot, "node-next", (fixtureRoot) => { + assert.equal(graphProviderBuild({ repoRoot: fixtureRoot }).status.state, "available"); + const result = graphProviderQuery({ repoRoot: fixtureRoot }); + const fileNodeIds = result.nodes + .filter((node) => node.kind === "File") + .map((node) => node.id) + .sort(); + const moduleEdges = edgeTriples(result.edges).filter( + ([kind]) => kind === "IMPORTS_FROM" || kind === "DEPENDS_ON" + ); + + assert.equal(result.status.state, "available"); + assert.deepEqual(fileNodeIds, nodeNextExpected.fileNodeIds); + assert.deepEqual(moduleEdges, nodeNextExpected.moduleEdgeTriples.sort(compareTuple)); + assert.deepEqual(result.diagnostics ?? [], nodeNextExpected.diagnostics); + assert.equal( + moduleEdges.some(([, from]) => from === "file:src/nonliteral.ts"), + false, + "a nonliteral dynamic import must not fabricate a module edge" + ); + }); + }); + it("extracts Rust facts through the GraphProvider wrapper", () => { const rustOnlyExpected = JSON.parse(readFileSync(resolve(rustOnlyFixtureRoot, "rust-only.expected.json"), "utf8")); diff --git a/tests/import-boundaries.test.mjs b/tests/import-boundaries.test.mjs index 6d48441..4ca5342 100644 --- a/tests/import-boundaries.test.mjs +++ b/tests/import-boundaries.test.mjs @@ -50,7 +50,7 @@ describe("package import boundaries", () => { }); it("keeps router and package adapter imports on public package boundaries", () => { - for (const file of sourceFiles("packages")) { + for (const file of packageSourceFiles()) { const packageDir = packageDirFor(file); for (const source of imports(file)) { if (source.startsWith(".")) { @@ -218,6 +218,10 @@ describe("package import boundaries", () => { }); }); +function packageSourceFiles() { + return [...packageNamesByDir.keys()].flatMap((packageDir) => sourceFiles(`packages/${packageDir}/src`)); +} + function sourceFiles(dir) { const entries = readdirSync(dir, { withFileTypes: true }); const files = []; diff --git a/tests/opcore-facade.test.mjs b/tests/opcore-facade.test.mjs index fbad91e..cfdbbc6 100644 --- a/tests/opcore-facade.test.mjs +++ b/tests/opcore-facade.test.mjs @@ -257,6 +257,33 @@ describe("opcore public facade", () => { } }); + it("reports TypeScript module formats as graph and validation supported", () => { + const temp = mkdtempSync(join(tmpdir(), "opcore-typescript-module-status-")); + try { + writeFixtureFile(temp, "src/app.ts", "export const app = 1;\n"); + writeFixtureFile(temp, "src/esm.mts", "export const esm = 1;\n"); + writeFixtureFile(temp, "src/commonjs.cts", "export const commonjs = 1;\n"); + + const result = parseJson(runOpcore(["status", "--repo", temp, "--json"], temp, 0).stdout); + const coverage = result.repoState.coverage; + + assert.deepEqual(coverage.languages, [ + { language: "TypeScript", files: 3, graphSupported: true, validationSupported: true } + ]); + assert.equal(coverage.graph.supportedFiles, 3); + assert.deepEqual(Object.fromEntries(coverage.graph.extensions.map((entry) => [entry.extension, entry.count])), { + ".cts": 1, + ".mts": 1, + ".ts": 1 + }); + assert.equal(coverage.validation.supportedFiles, 3); + assert.equal(coverage.unsupported.totalFiles, 0); + assert.deepEqual(coverage.unsupported.stacks, []); + } finally { + rmSync(temp, { recursive: true, force: true }); + } + }); + it("hides ASP state from non-enrolled human status", () => { const temp = mkdtempSync(join(tmpdir(), "opcore-status-no-asp-")); try { diff --git a/tests/validation-python.test.mjs b/tests/validation-python.test.mjs index cc938cd..a0fa9f0 100644 --- a/tests/validation-python.test.mjs +++ b/tests/validation-python.test.mjs @@ -1,6 +1,7 @@ import { describe, it } from "node:test"; import assert from "node:assert/strict"; -import { readdirSync, readFileSync } from "node:fs"; +import { chmodSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; import { dirname, join, relative } from "node:path"; import { fileURLToPath } from "node:url"; import { createValidationCheckRegistry, createValidationRunner } from "../packages/validation/dist/index.js"; @@ -13,6 +14,8 @@ import { PYTHON_TYPES_CHECK_ID, createPythonValidationAdapterStatus, createPythonValidationChecks, + findPythonConfigFile, + resolvePythonTool, validationPythonAdapterName } from "../packages/validation-python/dist/index.js"; @@ -43,10 +46,15 @@ describe("validation-python adapter", () => { it("reports missing Python type tooling as degraded instead of a silent pass", async () => { const env = { PATH: "" }; - const status = createPythonValidationAdapterStatus({ env }); - assert.equal(status.status, "degraded"); - assert.equal(status.degradedChecks?.[0]?.checkId, PYTHON_TYPES_CHECK_ID); - assert.equal(status.degradedChecks?.[0]?.requiredTool, "mypy or pyright"); + const isolatedRepoRoot = mkdtempSync(join(tmpdir(), "opcore-python-adapter-status-")); + try { + const status = createPythonValidationAdapterStatus({ env, repoRoot: isolatedRepoRoot }); + assert.equal(status.status, "degraded"); + assert.equal(status.degradedChecks?.[0]?.checkId, PYTHON_TYPES_CHECK_ID); + assert.equal(status.degradedChecks?.[0]?.requiredTool, "mypy or pyright"); + } finally { + rmSync(isolatedRepoRoot, { recursive: true, force: true }); + } const result = await runner({ files: { @@ -63,6 +71,124 @@ describe("validation-python adapter", () => { assert.deepEqual(result.diagnostics.map((diagnostic) => diagnostic.code), ["PYTHON_TYPES_UNSUPPORTED"]); }); + it("executes repo-local mypy and maps type diagnostics", async () => { + const repoRoot = mkdtempSync(join(tmpdir(), "opcore-python-types-mypy-")); + try { + writeToolShim( + repoRoot, + "mypy", + [ + "#!/bin/sh", + "if [ \"$1\" = \"--version\" ]; then echo 'mypy 1.8.0'; exit 0; fi", + "while IFS= read -r line; do", + " case \"$line\" in", + " *\"'wrong'\"*) echo \"$1:1: error: Incompatible types in assignment (expression has type \\\"str\\\", variable has type \\\"int\\\") [assignment]\"; exit 1 ;;", + " esac", + "done < \"$1\"", + "exit 0", + "" + ].join("\n") + ); + + const result = await runner({ + files: { + "pkg/app.py": "value: int = 'wrong'\n" + }, + checks: createPythonValidationChecks({ env: { PATH: "" }, repoRoot }) + }).runValidation( + request({ + repo: { repoRoot }, + checks: [PYTHON_TYPES_CHECK_ID] + }) + ); + + assert.equal(result.status, "policy_failure"); + assert.equal(result.diagnostics[0].path, "pkg/app.py"); + assert.equal(result.diagnostics[0].category, "types"); + assert.equal(result.diagnostics[0].code, "MYPY_ASSIGNMENT"); + assert.match(result.diagnostics[0].message, /Incompatible types in assignment/); + } finally { + rmSync(repoRoot, { recursive: true, force: true }); + } + }); + + it("checks overlay after-state content instead of the original Python file", async () => { + const repoRoot = mkdtempSync(join(tmpdir(), "opcore-python-types-overlay-")); + try { + writeToolShim( + repoRoot, + "mypy", + [ + "#!/bin/sh", + "if [ \"$1\" = \"--version\" ]; then echo 'mypy 1.8.0'; exit 0; fi", + "while IFS= read -r line; do", + " case \"$line\" in", + " *\"'wrong'\"*) echo \"$1:1: error: overlay type failure [assignment]\"; exit 1 ;;", + " esac", + "done < \"$1\"", + "exit 0", + "" + ].join("\n") + ); + + const result = await runner({ + files: { + "pkg/app.py": "value: int = 1\n" + }, + checks: createPythonValidationChecks({ env: { PATH: "" }, repoRoot }) + }).runValidation( + request({ + repo: { repoRoot }, + checks: [PYTHON_TYPES_CHECK_ID], + overlays: [{ path: "pkg/app.py", action: "write", content: "value: int = 'wrong'\n" }] + }) + ); + + assert.equal(result.status, "policy_failure"); + assert.deepEqual(result.diagnostics.map((diagnostic) => diagnostic.code), ["MYPY_ASSIGNMENT"]); + assert.match(result.diagnostics[0].message, /overlay type failure/); + } finally { + rmSync(repoRoot, { recursive: true, force: true }); + } + }); + + it("prefers pyright when pyright project config is present", async () => { + const repoRoot = mkdtempSync(join(tmpdir(), "opcore-python-types-pyright-")); + try { + writeFileSync(join(repoRoot, "pyrightconfig.json"), "{}\n"); + writeToolShim(repoRoot, "mypy", "#!/bin/sh\nif [ \"$1\" = \"--version\" ]; then echo 'mypy 1.8.0'; exit 0; fi\necho 'mypy should not run'; exit 1\n"); + writeToolShim( + repoRoot, + "pyright", + [ + "#!/bin/sh", + "if [ \"$1\" = \"--version\" ]; then echo 'pyright 1.1.0'; exit 0; fi", + "echo \" $1:1:14 - error: Type \\\"Literal['wrong']\\\" is not assignable to declared type \\\"int\\\" (reportAssignmentType)\"", + "exit 1", + "" + ].join("\n") + ); + + const result = await runner({ + files: { + "pkg/app.py": "value: int = 'wrong'\n" + }, + checks: createPythonValidationChecks({ env: { PATH: "" }, repoRoot }) + }).runValidation( + request({ + repo: { repoRoot }, + checks: [PYTHON_TYPES_CHECK_ID] + }) + ); + + assert.equal(result.status, "policy_failure"); + assert.equal(result.diagnostics[0].code, "PYRIGHT_REPORT_ASSIGNMENT_TYPE"); + assert.equal(result.diagnostics[0].path, "pkg/app.py"); + } finally { + rmSync(repoRoot, { recursive: true, force: true }); + } + }); + it("reports syntax diagnostics from overlay after-state content", async () => { const result = await runner({ files: { @@ -339,6 +465,81 @@ describe("validation-python adapter", () => { }); }); +describe("python toolchain resolver", () => { + it("resolves an available tool from PATH when no repo-local venv exists", () => { + const repoRoot = mkdtempSync(join(tmpdir(), "opcore-python-resolver-path-")); + try { + const resolution = resolvePythonTool("python", "node", ["--version"], { + repoRoot, + env: { PATH: process.env.PATH } + }); + assert.equal(resolution.available, true); + assert.equal(resolution.source, "path"); + } finally { + rmSync(repoRoot, { recursive: true, force: true }); + } + }); + + it("resolves a repo-local .venv/bin executable before falling back to PATH", () => { + const repoRoot = mkdtempSync(join(tmpdir(), "opcore-python-resolver-venv-")); + try { + const venvBin = join(repoRoot, ".venv", "bin"); + mkdirSync(venvBin, { recursive: true }); + const shimPath = join(venvBin, "mypy"); + writeFileSync(shimPath, "#!/bin/sh\necho 'mypy 1.0.0 (compiled: yes)'\nexit 0\n"); + chmodSync(shimPath, 0o755); + + const resolution = resolvePythonTool("mypy", "mypy", ["--version"], { + repoRoot, + env: { PATH: "" } + }); + assert.equal(resolution.available, true); + assert.equal(resolution.source, "repo-venv"); + assert.ok(resolution.command.endsWith(join(".venv", "bin", "mypy"))); + } finally { + rmSync(repoRoot, { recursive: true, force: true }); + } + }); + + it("reports a missing tool as unavailable with a failure message", () => { + const repoRoot = mkdtempSync(join(tmpdir(), "opcore-python-resolver-missing-")); + try { + const resolution = resolvePythonTool("mypy", "mypy", ["--version"], { + repoRoot, + env: { PATH: "" } + }); + assert.equal(resolution.available, false); + assert.equal(resolution.source, "path"); + assert.ok(resolution.failureMessage); + } finally { + rmSync(repoRoot, { recursive: true, force: true }); + } + }); + + it("prefers pyproject.toml when no tool-specific config exists", () => { + const repoRoot = mkdtempSync(join(tmpdir(), "opcore-python-resolver-config-")); + try { + writeFileSync(join(repoRoot, "pyproject.toml"), "[tool.mypy]\n"); + const configFile = findPythonConfigFile(repoRoot, "mypy"); + assert.equal(configFile, join(repoRoot, "pyproject.toml")); + } finally { + rmSync(repoRoot, { recursive: true, force: true }); + } + }); + + it("prefers a tool-specific config file over pyproject.toml", () => { + const repoRoot = mkdtempSync(join(tmpdir(), "opcore-python-resolver-config-specific-")); + try { + writeFileSync(join(repoRoot, "pyproject.toml"), "[tool.mypy]\n"); + writeFileSync(join(repoRoot, "mypy.ini"), "[mypy]\n"); + const configFile = findPythonConfigFile(repoRoot, "mypy"); + assert.equal(configFile, join(repoRoot, "mypy.ini")); + } finally { + rmSync(repoRoot, { recursive: true, force: true }); + } + }); +}); + function runner(options = {}) { return createValidationRunner({ workspace: workspace(options), @@ -399,6 +600,14 @@ function walkFiles(root) { return paths.sort(); } +function writeToolShim(repoRoot, name, content) { + const bin = join(repoRoot, ".venv", "bin"); + mkdirSync(bin, { recursive: true }); + const shimPath = join(bin, name); + writeFileSync(shimPath, content); + chmodSync(shimPath, 0o755); +} + function graphClient(overrides = {}) { return { status: (validationRequest) => availableStatus(validationRequest.graph.mode, validationRequest.repo), diff --git a/tests/validation-typescript.test.mjs b/tests/validation-typescript.test.mjs index b1f2ed5..ce03504 100644 --- a/tests/validation-typescript.test.mjs +++ b/tests/validation-typescript.test.mjs @@ -1638,6 +1638,238 @@ describe("validation-typescript adapter", () => { ); }); + it("treats graph-backed and conventional TypeScript test files as automatic execution roots", async () => { + const nodes = [ + fileNode("src/scenario.ts"), + fileNode("src/widget.part-2.test.ts"), + fileNode("src/scenario-helper.ts"), + fileNode("src/widget-helper.ts"), + fileNode("src/orphan.ts"), + { + id: "test:src/scenario.ts#runs scenario", + kind: "Test", + path: "src/scenario.ts", + name: "runs scenario" + }, + exportedFunctionNode("src/scenario-helper.ts", "scenarioHelper"), + exportedFunctionNode("src/widget-helper.ts", "widgetHelper"), + exportedFunctionNode("src/orphan.ts", "orphan") + ]; + const edges = [ + containsEdge("src/scenario.ts", "test:src/scenario.ts#runs scenario"), + containsEdge("src/scenario-helper.ts", "function:src/scenario-helper.ts#scenarioHelper"), + containsEdge("src/widget-helper.ts", "function:src/widget-helper.ts#widgetHelper"), + containsEdge("src/orphan.ts", "function:src/orphan.ts#orphan"), + importEdge("src/scenario.ts", "src/scenario-helper.ts"), + importEdge("src/widget.part-2.test.ts", "src/widget-helper.ts") + ]; + const files = { + "src/scenario.ts": "import { scenarioHelper } from './scenario-helper.js';\ntest('runs scenario', scenarioHelper);\n", + "src/widget.part-2.test.ts": "import { widgetHelper } from './widget-helper.js';\ntest('widget', widgetHelper);\n", + "src/scenario-helper.ts": "export function scenarioHelper() { return 1; }\n", + "src/widget-helper.ts": "export function widgetHelper() { return 2; }\n", + "src/orphan.ts": "export function orphan() { return 3; }\n" + }; + + const result = await runner({ + files, + graphProviderClient: graphClient({ + status: (validationRequest) => ({ + ...availableStatus(validationRequest.graph.mode, validationRequest.repo), + handshake: graphHandshake() + }), + factQuery: (query) => availableFactResult(query, nodes, edges) + }) + }).runValidation( + request({ + checks: [TYPE_SCRIPT_DEAD_CODE_CHECK_ID], + scope: { kind: "files", files: Object.keys(files) } + }) + ); + + assert.equal(result.status, "passed"); + assert.deepEqual( + result.diagnostics.map((diagnostic) => [diagnostic.code, diagnostic.path]), + [ + ["TS_DEAD_CODE_UNUSED_EXPORT", "src/orphan.ts"], + ["TS_DEAD_CODE_UNUSED_FILE", "src/orphan.ts"] + ] + ); + }); + + it("maps proven package public and CLI outputs back to TypeScript source roots", async () => { + const nodes = [ + fileNode("packages/app/src/index.ts"), + fileNode("packages/app/src/cli.ts"), + fileNode("packages/app/src/public.ts"), + fileNode("packages/app/src/orphan.ts"), + exportedFunctionNode("packages/app/src/index.ts", "main"), + exportedFunctionNode("packages/app/src/cli.ts", "runCli"), + exportedFunctionNode("packages/app/src/public.ts", "publicApi"), + exportedFunctionNode("packages/app/src/orphan.ts", "orphan") + ]; + const edges = [ + containsEdge("packages/app/src/index.ts", "function:packages/app/src/index.ts#main"), + containsEdge("packages/app/src/cli.ts", "function:packages/app/src/cli.ts#runCli"), + containsEdge("packages/app/src/public.ts", "function:packages/app/src/public.ts#publicApi"), + containsEdge("packages/app/src/orphan.ts", "function:packages/app/src/orphan.ts#orphan") + ]; + const sourceFiles = { + "packages/app/src/index.ts": "export function main() { return 1; }\n", + "packages/app/src/cli.ts": "export function runCli() { return 2; }\n", + "packages/app/src/public.ts": "export function publicApi() { return 3; }\n", + "packages/app/src/orphan.ts": "export function orphan() { return 4; }\n" + }; + const files = { + ...sourceFiles, + "packages/app/package.json": JSON.stringify({ + main: "./dist/index.js", + exports: { + ".": { default: "./dist/index.js", types: "./dist/index.d.ts" }, + "./public": "./src/public.ts" + }, + bin: { app: "./dist/cli.js" } + }), + "packages/app/tsconfig.json": JSON.stringify({ compilerOptions: { rootDir: "src", outDir: "dist" } }) + }; + + const result = await runner({ + files, + graphProviderClient: graphClient({ + status: (validationRequest) => ({ + ...availableStatus(validationRequest.graph.mode, validationRequest.repo), + handshake: graphHandshake() + }), + factQuery: (query) => availableFactResult(query, nodes, edges) + }) + }).runValidation( + request({ + checks: [TYPE_SCRIPT_DEAD_CODE_CHECK_ID], + scope: { kind: "files", files: Object.keys(sourceFiles) } + }) + ); + + assert.equal(result.status, "passed"); + assert.deepEqual( + result.diagnostics.map((diagnostic) => [diagnostic.code, diagnostic.path]), + [ + ["TS_DEAD_CODE_UNUSED_EXPORT", "packages/app/src/orphan.ts"], + ["TS_DEAD_CODE_UNUSED_FILE", "packages/app/src/orphan.ts"] + ] + ); + }); + + it("keeps orphan warnings when package entrypoint metadata is malformed", async () => { + const path = "packages/app/src/orphan.ts"; + const nodes = [fileNode(path), exportedFunctionNode(path, "orphan")]; + const edges = [containsEdge(path, `function:${path}#orphan`)]; + const result = await runner({ + files: { + [path]: "export function orphan() { return 1; }\n", + "packages/app/package.json": "{ malformed", + "packages/app/tsconfig.json": JSON.stringify({ compilerOptions: { rootDir: "src", outDir: "dist" } }) + }, + graphProviderClient: graphClient({ + status: (validationRequest) => ({ + ...availableStatus(validationRequest.graph.mode, validationRequest.repo), + handshake: graphHandshake() + }), + factQuery: (query) => availableFactResult(query, nodes, edges) + }) + }).runValidation( + request({ checks: [TYPE_SCRIPT_DEAD_CODE_CHECK_ID], scope: { kind: "files", files: [path] } }) + ); + + assert.equal(result.status, "passed"); + assert.deepEqual( + result.diagnostics.map((diagnostic) => diagnostic.code), + ["TS_DEAD_CODE_UNUSED_EXPORT", "TS_DEAD_CODE_UNUSED_FILE"] + ); + }); + + it("keeps orphan warnings when a package output maps to ambiguous source files", async () => { + const paths = ["packages/app/src/index.ts", "packages/app/src/index.tsx"]; + const nodes = paths.flatMap((path, index) => [fileNode(path), exportedFunctionNode(path, `entry${index}`)]); + const edges = paths.map((path, index) => containsEdge(path, `function:${path}#entry${index}`)); + const result = await runner({ + files: { + "packages/app/src/index.ts": "export function entry0() { return 1; }\n", + "packages/app/src/index.tsx": "export function entry1() { return 2; }\n", + "packages/app/package.json": JSON.stringify({ main: "./dist/index.js" }), + "packages/app/tsconfig.json": JSON.stringify({ compilerOptions: { rootDir: "src", outDir: "dist" } }) + }, + graphProviderClient: graphClient({ + status: (validationRequest) => ({ + ...availableStatus(validationRequest.graph.mode, validationRequest.repo), + handshake: graphHandshake() + }), + factQuery: (query) => availableFactResult(query, nodes, edges) + }) + }).runValidation( + request({ checks: [TYPE_SCRIPT_DEAD_CODE_CHECK_ID], scope: { kind: "files", files: paths } }) + ); + + assert.equal(result.status, "passed"); + assert.deepEqual( + result.diagnostics.map((diagnostic) => [diagnostic.code, diagnostic.path]), + [ + ["TS_DEAD_CODE_UNUSED_EXPORT", "packages/app/src/index.ts"], + ["TS_DEAD_CODE_UNUSED_FILE", "packages/app/src/index.ts"], + ["TS_DEAD_CODE_UNUSED_EXPORT", "packages/app/src/index.tsx"], + ["TS_DEAD_CODE_UNUSED_FILE", "packages/app/src/index.tsx"] + ] + ); + }); + + it("does not cascade missing graph imports into dead-code findings for compiler-resolved targets", async () => { + const sourceFiles = { + "src/index.ts": "import { dependency } from './dependency.js';\nvoid dependency();\n", + "src/dependency.ts": "export function dependency() { return 1; }\n", + "src/orphan.ts": "export function orphan() { return 2; }\n" + }; + const nodes = [ + fileNode("src/index.ts"), + fileNode("src/dependency.ts"), + fileNode("src/orphan.ts"), + exportedFunctionNode("src/dependency.ts", "dependency"), + exportedFunctionNode("src/orphan.ts", "orphan") + ]; + const edges = [ + containsEdge("src/dependency.ts", "function:src/dependency.ts#dependency"), + containsEdge("src/orphan.ts", "function:src/orphan.ts#orphan") + ]; + const result = await runner({ + files: { + ...sourceFiles, + "package.json": JSON.stringify({ main: "./src/index.ts" }) + }, + graphProviderClient: graphClient({ + status: (validationRequest) => ({ + ...availableStatus(validationRequest.graph.mode, validationRequest.repo), + handshake: graphHandshake() + }), + factQuery: (query) => availableFactResult(query, nodes, edges) + }) + }).runValidation( + request({ + checks: [TYPE_SCRIPT_IMPORT_GRAPH_CHECK_ID, TYPE_SCRIPT_DEAD_CODE_CHECK_ID], + scope: { kind: "files", files: Object.keys(sourceFiles) } + }) + ); + + assert.equal(result.status, "passed"); + assert.equal(result.diagnostics.some((diagnostic) => diagnostic.path === "src/dependency.ts"), false); + assert.deepEqual( + result.diagnostics.map((diagnostic) => [diagnostic.code, diagnostic.path]), + [ + ["TS_DEAD_CODE_UNSUPPORTED", undefined], + ["TS_IMPORT_GRAPH_MISSING_EDGE", "src/index.ts"], + ["TS_DEAD_CODE_UNUSED_EXPORT", "src/orphan.ts"], + ["TS_DEAD_CODE_UNUSED_FILE", "src/orphan.ts"] + ] + ); + }); + it("reports unreferenced source files and unused exported types from graph facts", async () => { const nodes = [ { @@ -2690,6 +2922,37 @@ function availableFactResult(query, nodes, edges, metadataOverrides = {}) { }; } +function fileNode(path) { + return { + id: `file:${path}`, + kind: "File", + path, + attributes: { language: "typescript" } + }; +} + +function exportedFunctionNode(path, name) { + return { + id: `function:${path}#${name}`, + kind: "Function", + path, + name, + attributes: { + exported: true, + exportKind: "named", + exportName: name + } + }; +} + +function containsEdge(path, target) { + return { kind: "CONTAINS", from: `file:${path}`, to: target }; +} + +function importEdge(fromPath, toPath) { + return { kind: "IMPORTS_FROM", from: `file:${fromPath}`, to: `file:${toPath}` }; +} + function freshness(overrides = {}) { return { generatedAt: "2026-06-05T00:00:00.000Z",