diff --git a/package.json b/package.json index 2c029854..01115f82 100644 --- a/package.json +++ b/package.json @@ -33,9 +33,9 @@ "build:wasm": "node scripts/build-wasm.js", "typecheck": "tsc --noEmit", "verify-imports": "node scripts/verify-imports.js", - "test": "vitest run", - "test:watch": "vitest", - "test:coverage": "vitest run --coverage", + "test": "node scripts/test.js run", + "test:watch": "node scripts/test.js", + "test:coverage": "node scripts/test.js run --coverage", "lint": "biome check src/ tests/", "lint:fix": "biome check --write src/ tests/", "format": "biome format --write src/ tests/", diff --git a/scripts/test.js b/scripts/test.js new file mode 100644 index 00000000..ea317a7d --- /dev/null +++ b/scripts/test.js @@ -0,0 +1,46 @@ +#!/usr/bin/env node +/** + * Test runner wrapper that configures Node.js for the TypeScript migration + * before spawning vitest. Adds --experimental-strip-types on Node >= 22.6 + * so child processes can execute .ts files natively. + */ + +import { spawnSync } from 'node:child_process'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { dirname, resolve } from 'node:path'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const hook = pathToFileURL(resolve(__dirname, 'ts-resolver-hook.js')).href; + +const args = process.argv.slice(2); +const vitestBin = resolve(__dirname, '..', 'node_modules', 'vitest', 'vitest.mjs'); + +const [major, minor] = process.versions.node.split('.').map(Number); +const supportsStripTypes = major > 22 || (major === 22 && minor >= 6); + +// Build NODE_OPTIONS: resolver hook + type stripping (Node >= 22.6) +const hookImport = `--import ${hook}`; +const existing = process.env.NODE_OPTIONS || ''; +const parts = [ + existing.includes(hookImport) ? null : hookImport, + supportsStripTypes && !existing.includes('--experimental-strip-types') && !existing.includes('--strip-types') + ? (major >= 23 ? '--strip-types' : '--experimental-strip-types') + : null, + existing || null, +].filter(Boolean); + +// Spawn vitest via node directly — avoids shell: true and works cross-platform +const result = spawnSync(process.execPath, [vitestBin, ...args], { + stdio: 'inherit', + env: { + ...process.env, + NODE_OPTIONS: parts.join(' '), + }, +}); + +if (result.error) { + process.stderr.write(`[test runner] Failed to spawn vitest: ${result.error.message}\n`); + process.exit(1); +} + +process.exit(result.status ?? 1); diff --git a/scripts/ts-resolver-hook.js b/scripts/ts-resolver-hook.js new file mode 100644 index 00000000..418f833b --- /dev/null +++ b/scripts/ts-resolver-hook.js @@ -0,0 +1,13 @@ +/** + * Node.js module resolution hook for incremental TypeScript migration. + * + * Registered via --import. Uses the module.register() API (Node >= 20.6) + * to install a resolve hook that falls back to .ts when .js is missing. + */ + +// module.register() requires Node >= 20.6.0 +const [_major, _minor] = process.versions.node.split('.').map(Number); +if (_major > 20 || (_major === 20 && _minor >= 6)) { + const { register } = await import('node:module'); + register('./ts-resolver-loader.js', import.meta.url); +} diff --git a/scripts/ts-resolver-loader.js b/scripts/ts-resolver-loader.js new file mode 100644 index 00000000..84e7da1e --- /dev/null +++ b/scripts/ts-resolver-loader.js @@ -0,0 +1,47 @@ +/** + * ESM loader: resolve .js → .ts fallback for incremental migration. + * + * - resolve hook: when a .js specifier is not found, retry with .ts + * - load hook: delegates to Node's built-in type stripping (Node >= 22.6). + * On older Node versions, throws a clear error instead of returning + * unparseable TypeScript source. + */ + +import { fileURLToPath } from 'node:url'; + +export async function resolve(specifier, context, nextResolve) { + try { + return await nextResolve(specifier, context); + } catch (err) { + if (err.code !== 'ERR_MODULE_NOT_FOUND' || !specifier.endsWith('.js')) throw err; + + const tsSpecifier = specifier.replace(/\.js$/, '.ts'); + try { + return await nextResolve(tsSpecifier, context); + } catch { + throw err; + } + } +} + +export async function load(url, context, nextLoad) { + if (!url.endsWith('.ts')) return nextLoad(url, context); + + // On Node >= 22.6 with --experimental-strip-types, Node handles .ts natively + try { + return await nextLoad(url, context); + } catch (err) { + if (err.code !== 'ERR_UNKNOWN_FILE_EXTENSION') throw err; + } + + // Node < 22.6 cannot strip TypeScript syntax. Throw a clear error instead + // of returning raw TS source that would produce a confusing SyntaxError. + const filePath = fileURLToPath(url); + throw Object.assign( + new Error( + `Cannot load TypeScript file ${filePath} on Node ${process.versions.node}. ` + + `TypeScript type stripping requires Node >= 22.6 with --experimental-strip-types.`, + ), + { code: 'ERR_TS_UNSUPPORTED' }, + ); +} diff --git a/src/domain/search/models.ts b/src/domain/search/models.ts index 235274f1..6a7ef666 100644 --- a/src/domain/search/models.ts +++ b/src/domain/search/models.ts @@ -143,7 +143,7 @@ export async function loadTransformers(): Promise { } catch (loadErr) { throw new EngineError( `${pkg} was installed but failed to load. Please check your environment.`, - { cause: loadErr }, + { cause: loadErr instanceof Error ? loadErr : undefined }, ); } } @@ -181,7 +181,8 @@ async function loadModel(modelKey?: string): Promise<{ extractor: unknown; confi await // biome-ignore lint/complexity/noBannedTypes: dynamically loaded transformers pipeline is untyped (pipeline as Function)('feature-extraction', config.name, pipelineOpts); } catch (err: unknown) { - const msg = (err as Error).message || String(err); + const cause = err instanceof Error ? err : undefined; + const msg = cause?.message || String(err); if (msg.includes('Unauthorized') || msg.includes('401') || msg.includes('gated')) { throw new EngineError( `Model "${config.name}" requires authentication.\n` + @@ -189,13 +190,13 @@ async function loadModel(modelKey?: string): Promise<{ extractor: unknown; confi `Options:\n` + ` 1. Set HF_TOKEN env var: export HF_TOKEN=hf_...\n` + ` 2. Use a public model instead: codegraph embed --model minilm`, - { cause: err }, + { cause }, ); } throw new EngineError( `Failed to load model "${config.name}": ${msg}\n` + `Try a different model: codegraph embed --model minilm`, - { cause: err }, + { cause }, ); } activeModel = config.name; diff --git a/src/graph/algorithms/bfs.js b/src/graph/algorithms/bfs.ts similarity index 62% rename from src/graph/algorithms/bfs.js rename to src/graph/algorithms/bfs.ts index 9ecb25d1..5ca442b0 100644 --- a/src/graph/algorithms/bfs.js +++ b/src/graph/algorithms/bfs.ts @@ -1,18 +1,26 @@ +import type { CodeGraph } from '../model.js'; + +export interface BfsOpts { + maxDepth?: number; + direction?: 'forward' | 'backward' | 'both'; +} + /** * Breadth-first traversal on a CodeGraph. * - * @param {import('../model.js').CodeGraph} graph - * @param {string|string[]} startIds - One or more starting node IDs - * @param {{ maxDepth?: number, direction?: 'forward'|'backward'|'both' }} [opts] - * @returns {Map} nodeId → depth from nearest start node + * @returns nodeId → depth from nearest start node */ -export function bfs(graph, startIds, opts = {}) { +export function bfs( + graph: CodeGraph, + startIds: string | string[], + opts: BfsOpts = {}, +): Map { const maxDepth = opts.maxDepth ?? Infinity; const direction = opts.direction ?? 'forward'; const starts = Array.isArray(startIds) ? startIds : [startIds]; - const depths = new Map(); - const queue = []; + const depths = new Map(); + const queue: string[] = []; for (const id of starts) { const key = String(id); @@ -24,11 +32,11 @@ export function bfs(graph, startIds, opts = {}) { let head = 0; while (head < queue.length) { - const current = queue[head++]; - const depth = depths.get(current); + const current = queue[head++]!; + const depth = depths.get(current)!; if (depth >= maxDepth) continue; - let neighbors; + let neighbors: string[]; if (direction === 'forward') { neighbors = graph.successors(current); } else if (direction === 'backward') { diff --git a/src/graph/algorithms/centrality.js b/src/graph/algorithms/centrality.ts similarity index 50% rename from src/graph/algorithms/centrality.js rename to src/graph/algorithms/centrality.ts index c7d7c911..465aeb0c 100644 --- a/src/graph/algorithms/centrality.js +++ b/src/graph/algorithms/centrality.ts @@ -1,11 +1,15 @@ +import type { CodeGraph } from '../model.js'; + +export interface FanInOut { + fanIn: number; + fanOut: number; +} + /** * Fan-in / fan-out centrality for all nodes in a CodeGraph. - * - * @param {import('../model.js').CodeGraph} graph - * @returns {Map} */ -export function fanInOut(graph) { - const result = new Map(); +export function fanInOut(graph: CodeGraph): Map { + const result = new Map(); for (const id of graph.nodeIds()) { result.set(id, { fanIn: graph.inDegree(id), diff --git a/src/graph/algorithms/leiden/rng.js b/src/graph/algorithms/leiden/rng.ts similarity index 77% rename from src/graph/algorithms/leiden/rng.js rename to src/graph/algorithms/leiden/rng.ts index 9d20fcb6..5727b5c4 100644 --- a/src/graph/algorithms/leiden/rng.js +++ b/src/graph/algorithms/leiden/rng.ts @@ -1,11 +1,12 @@ +export interface Rng { + nextDouble(): number; +} + /** * Seeded PRNG (mulberry32). * Drop-in replacement for ngraph.random — only nextDouble() is needed. - * - * @param {number} [seed] - * @returns {{ nextDouble(): number }} */ -export function createRng(seed = 42) { +export function createRng(seed: number = 42): Rng { let s = seed | 0; return { nextDouble() { diff --git a/src/graph/algorithms/shortest-path.js b/src/graph/algorithms/shortest-path.ts similarity index 61% rename from src/graph/algorithms/shortest-path.js rename to src/graph/algorithms/shortest-path.ts index c594559f..e297607e 100644 --- a/src/graph/algorithms/shortest-path.js +++ b/src/graph/algorithms/shortest-path.ts @@ -1,35 +1,34 @@ +import type { CodeGraph } from '../model.js'; + /** * BFS-based shortest path on a CodeGraph. * - * @param {import('../model.js').CodeGraph} graph - * @param {string} fromId - * @param {string} toId - * @returns {string[]|null} Path from fromId to toId (inclusive), or null if unreachable + * @returns Path from fromId to toId (inclusive), or null if unreachable */ -export function shortestPath(graph, fromId, toId) { +export function shortestPath(graph: CodeGraph, fromId: string, toId: string): string[] | null { const from = String(fromId); const to = String(toId); if (!graph.hasNode(from) || !graph.hasNode(to)) return null; if (from === to) return [from]; - const parent = new Map(); + const parent = new Map(); parent.set(from, null); const queue = [from]; let head = 0; while (head < queue.length) { - const current = queue[head++]; + const current = queue[head++]!; for (const neighbor of graph.successors(current)) { if (parent.has(neighbor)) continue; parent.set(neighbor, current); if (neighbor === to) { // Reconstruct path - const path = []; - let node = to; + const path: string[] = []; + let node: string | null = to; while (node !== null) { path.push(node); - node = parent.get(node); + node = parent.get(node) ?? null; } return path.reverse(); } diff --git a/src/graph/algorithms/tarjan.js b/src/graph/algorithms/tarjan.ts similarity index 52% rename from src/graph/algorithms/tarjan.js rename to src/graph/algorithms/tarjan.ts index 958d5f37..f2619ee1 100644 --- a/src/graph/algorithms/tarjan.js +++ b/src/graph/algorithms/tarjan.ts @@ -1,19 +1,20 @@ +import type { CodeGraph } from '../model.js'; + /** * Tarjan's strongly connected components algorithm. * Operates on a CodeGraph instance. * - * @param {import('../model.js').CodeGraph} graph - * @returns {string[][]} SCCs with length > 1 (cycles) + * @returns SCCs with length > 1 (cycles) */ -export function tarjan(graph) { +export function tarjan(graph: CodeGraph): string[][] { let index = 0; - const stack = []; - const onStack = new Set(); - const indices = new Map(); - const lowlinks = new Map(); - const sccs = []; + const stack: string[] = []; + const onStack = new Set(); + const indices = new Map(); + const lowlinks = new Map(); + const sccs: string[][] = []; - function strongconnect(v) { + function strongconnect(v: string): void { indices.set(v, index); lowlinks.set(v, index); index++; @@ -23,17 +24,17 @@ export function tarjan(graph) { for (const w of graph.successors(v)) { if (!indices.has(w)) { strongconnect(w); - lowlinks.set(v, Math.min(lowlinks.get(v), lowlinks.get(w))); + lowlinks.set(v, Math.min(lowlinks.get(v)!, lowlinks.get(w)!)); } else if (onStack.has(w)) { - lowlinks.set(v, Math.min(lowlinks.get(v), indices.get(w))); + lowlinks.set(v, Math.min(lowlinks.get(v)!, indices.get(w)!)); } } if (lowlinks.get(v) === indices.get(v)) { - const scc = []; - let w; + const scc: string[] = []; + let w: string | undefined; do { - w = stack.pop(); + w = stack.pop()!; onStack.delete(w); scc.push(w); } while (w !== v); diff --git a/src/graph/classifiers/risk.js b/src/graph/classifiers/risk.ts similarity index 58% rename from src/graph/classifiers/risk.js rename to src/graph/classifiers/risk.ts index 0ef67c08..3ffc5508 100644 --- a/src/graph/classifiers/risk.js +++ b/src/graph/classifiers/risk.ts @@ -2,11 +2,21 @@ * Risk scoring — pure logic, no DB. */ +import type { Role } from '../../types.js'; + +export interface RiskWeights { + fanIn: number; + complexity: number; + churn: number; + role: number; + mi: number; +} + // Weights sum to 1.0. Complexity gets the highest weight because cognitive load // is the strongest predictor of defect density. Fan-in and churn are next as // they reflect coupling and volatility. Role adds architectural context, and MI // (maintainability index) is a weaker composite signal, so it gets the least. -export const DEFAULT_WEIGHTS = { +export const DEFAULT_WEIGHTS: RiskWeights = { fanIn: 0.25, complexity: 0.3, churn: 0.2, @@ -18,7 +28,7 @@ export const DEFAULT_WEIGHTS = { // dependency graph, utilities are widely imported, entry points are API // surfaces. Adapters bridge subsystems but are replaceable. Leaves, dead // code, and test-only symbols have minimal downstream impact. -export const ROLE_WEIGHTS = { +export const ROLE_WEIGHTS: Record = { core: 1.0, utility: 0.9, entry: 0.8, @@ -35,7 +45,7 @@ export const ROLE_WEIGHTS = { const DEFAULT_ROLE_WEIGHT = 0.5; /** Min-max normalize an array of numbers. All-equal → all zeros. */ -export function minMaxNormalize(values) { +export function minMaxNormalize(values: number[]): number[] { const min = Math.min(...values); const max = Math.max(...values); if (max === min) return values.map(() => 0); @@ -43,21 +53,41 @@ export function minMaxNormalize(values) { return values.map((v) => (v - min) / range); } -function round4(n) { +function round4(n: number): number { return Math.round(n * 10000) / 10000; } +export interface RiskItem { + fan_in: number; + cognitive: number; + churn: number; + mi: number; + role: Role | string | null; +} + +export interface RiskResult { + normFanIn: number; + normComplexity: number; + normChurn: number; + normMI: number; + roleWeight: number; + riskScore: number; +} + +export interface ScoreRiskOpts { + roleWeights?: Record; + defaultRoleWeight?: number; +} + /** * Score risk for a list of items. - * - * @param {{ fan_in: number, cognitive: number, churn: number, mi: number, role: string|null }[]} items - * @param {object} [weights] - Override DEFAULT_WEIGHTS - * @param {{ roleWeights?: object, defaultRoleWeight?: number }} [opts] - Optional role weight overrides - * @returns {{ normFanIn: number, normComplexity: number, normChurn: number, normMI: number, roleWeight: number, riskScore: number }[]} - * Parallel array with risk metrics for each input item. */ -export function scoreRisk(items, weights = {}, opts = {}) { - const w = { ...DEFAULT_WEIGHTS, ...weights }; +export function scoreRisk( + items: RiskItem[], + weights: Partial = {}, + opts: ScoreRiskOpts = {}, +): RiskResult[] { + const w: RiskWeights = { ...DEFAULT_WEIGHTS, ...weights }; const rw = opts.roleWeights || ROLE_WEIGHTS; const drw = opts.defaultRoleWeight ?? DEFAULT_ROLE_WEIGHT; @@ -73,19 +103,19 @@ export function scoreRisk(items, weights = {}, opts = {}) { const normMIs = normMIsRaw.map((v) => round4(1 - v)); return items.map((r, i) => { - const roleWeight = rw[r.role] ?? drw; + const roleWeight = (r.role != null ? rw[r.role] : undefined) ?? drw; + const nfi = normFanIns[i] ?? 0; + const nci = normCognitives[i] ?? 0; + const nch = normChurns[i] ?? 0; + const nmi = normMIs[i] ?? 0; const riskScore = - w.fanIn * normFanIns[i] + - w.complexity * normCognitives[i] + - w.churn * normChurns[i] + - w.role * roleWeight + - w.mi * normMIs[i]; + w.fanIn * nfi + w.complexity * nci + w.churn * nch + w.role * roleWeight + w.mi * nmi; return { - normFanIn: round4(normFanIns[i]), - normComplexity: round4(normCognitives[i]), - normChurn: round4(normChurns[i]), - normMI: round4(normMIs[i]), + normFanIn: round4(nfi), + normComplexity: round4(nci), + normChurn: round4(nch), + normMI: round4(nmi), roleWeight, riskScore: round4(riskScore), }; diff --git a/src/graph/classifiers/roles.js b/src/graph/classifiers/roles.ts similarity index 75% rename from src/graph/classifiers/roles.js rename to src/graph/classifiers/roles.ts index 62229e59..1f8aa88c 100644 --- a/src/graph/classifiers/roles.js +++ b/src/graph/classifiers/roles.ts @@ -10,7 +10,9 @@ * dead-unresolved — genuinely unreferenced callables (the real dead code) */ -export const FRAMEWORK_ENTRY_PREFIXES = ['route:', 'event:', 'command:']; +import type { DeadSubRole, Role } from '../../types.js'; + +export const FRAMEWORK_ENTRY_PREFIXES: readonly string[] = ['route:', 'event:', 'command:']; // ── Dead sub-classification helpers ──────────────────────────────── @@ -19,7 +21,7 @@ const LEAF_KINDS = new Set(['parameter', 'property', 'constant']); const FFI_EXTENSIONS = new Set(['.rs', '.c', '.cpp', '.h', '.go', '.java', '.cs']); /** Path patterns indicating framework-dispatched entry points. */ -const ENTRY_PATH_PATTERNS = [ +const ENTRY_PATH_PATTERNS: readonly RegExp[] = [ /cli[/\\]commands[/\\]/, /mcp[/\\]/, /routes?[/\\]/, @@ -27,13 +29,15 @@ const ENTRY_PATH_PATTERNS = [ /middleware[/\\]/, ]; +export interface ClassifiableNode { + kind?: string; + file?: string; +} + /** * Refine a "dead" classification into a sub-category. - * - * @param {{ kind?: string, file?: string }} node - * @returns {'dead-leaf'|'dead-entry'|'dead-ffi'|'dead-unresolved'} */ -function classifyDeadSubRole(node) { +function classifyDeadSubRole(node: ClassifiableNode): DeadSubRole { // Leaf kinds are dead by definition — they can't have callers if (node.kind && LEAF_KINDS.has(node.kind)) return 'dead-leaf'; @@ -46,7 +50,7 @@ function classifyDeadSubRole(node) { if (dotIdx !== -1 && FFI_EXTENSIONS.has(node.file.slice(dotIdx))) return 'dead-ffi'; // Framework-dispatched entry points (CLI commands, MCP tools, routes) - if (ENTRY_PATH_PATTERNS.some((p) => p.test(node.file))) return 'dead-entry'; + if (ENTRY_PATH_PATTERNS.some((p) => p.test(node.file!))) return 'dead-entry'; } return 'dead-unresolved'; @@ -54,19 +58,28 @@ function classifyDeadSubRole(node) { // ── Helpers ──────────────────────────────────────────────────────── -function median(sorted) { +function median(sorted: number[]): number { if (sorted.length === 0) return 0; const mid = Math.floor(sorted.length / 2); - return sorted.length % 2 === 0 ? (sorted[mid - 1] + sorted[mid]) / 2 : sorted[mid]; + return sorted.length % 2 === 0 ? (sorted[mid - 1]! + sorted[mid]!) / 2 : sorted[mid]!; +} + +export interface RoleClassificationNode { + id: string; + name: string; + kind?: string; + file?: string; + fanIn: number; + fanOut: number; + isExported: boolean; + testOnlyFanIn?: number; + productionFanIn?: number; } /** * Classify nodes into architectural roles based on fan-in/fan-out metrics. - * - * @param {{ id: string, name: string, kind?: string, file?: string, fanIn: number, fanOut: number, isExported: boolean, testOnlyFanIn?: number }[]} nodes - * @returns {Map} nodeId → role */ -export function classifyRoles(nodes) { +export function classifyRoles(nodes: RoleClassificationNode[]): Map { if (nodes.length === 0) return new Map(); const nonZeroFanIn = nodes @@ -81,19 +94,22 @@ export function classifyRoles(nodes) { const medFanIn = median(nonZeroFanIn); const medFanOut = median(nonZeroFanOut); - const result = new Map(); + const result = new Map(); for (const node of nodes) { const highIn = node.fanIn >= medFanIn && node.fanIn > 0; const highOut = node.fanOut >= medFanOut && node.fanOut > 0; const hasProdFanIn = typeof node.productionFanIn === 'number'; - let role; + let role: Role; const isFrameworkEntry = FRAMEWORK_ENTRY_PREFIXES.some((p) => node.name.startsWith(p)); if (isFrameworkEntry) { role = 'entry'; } else if (node.fanIn === 0 && !node.isExported) { - role = node.testOnlyFanIn > 0 ? 'test-only' : classifyDeadSubRole(node); + role = + node.testOnlyFanIn != null && node.testOnlyFanIn > 0 + ? 'test-only' + : classifyDeadSubRole(node); } else if (node.fanIn === 0 && node.isExported) { role = 'entry'; } else if (hasProdFanIn && node.fanIn > 0 && node.productionFanIn === 0) { diff --git a/src/graph/model.js b/src/graph/model.ts similarity index 72% rename from src/graph/model.js rename to src/graph/model.ts index 8672155b..60ce5bc2 100644 --- a/src/graph/model.js +++ b/src/graph/model.ts @@ -5,29 +5,40 @@ * Node IDs are always strings. DB integer IDs should be stringified before use. */ +export interface NodeAttrs { + [key: string]: unknown; +} + +export interface EdgeAttrs { + [key: string]: unknown; +} + +export interface CodeGraphOpts { + directed?: boolean; +} + export class CodeGraph { - /** - * @param {{ directed?: boolean }} [opts] - */ - constructor(opts = {}) { + private _directed: boolean; + private _nodes: Map; + private _successors: Map>; + private _predecessors: Map>; + + constructor(opts: CodeGraphOpts = {}) { this._directed = opts.directed !== false; - /** @type {Map} */ this._nodes = new Map(); - /** @type {Map>} node → (target → edgeAttrs) */ this._successors = new Map(); - /** @type {Map>} node → (source → edgeAttrs) */ this._predecessors = new Map(); } - get directed() { + get directed(): boolean { return this._directed; } - get nodeCount() { + get nodeCount(): number { return this._nodes.size; } - get edgeCount() { + get edgeCount(): number { let count = 0; for (const targets of this._successors.values()) count += targets.size; // Undirected graphs store each edge twice (a→b and b→a) @@ -36,7 +47,7 @@ export class CodeGraph { // ─── Node operations ──────────────────────────────────────────── - addNode(id, attrs = {}) { + addNode(id: string | number, attrs: NodeAttrs = {}): this { const key = String(id); this._nodes.set(key, attrs); if (!this._successors.has(key)) this._successors.set(key, new Map()); @@ -44,65 +55,62 @@ export class CodeGraph { return this; } - hasNode(id) { + hasNode(id: string | number): boolean { return this._nodes.has(String(id)); } - getNodeAttrs(id) { + getNodeAttrs(id: string | number): NodeAttrs | undefined { return this._nodes.get(String(id)); } - /** @returns {IterableIterator<[string, object]>} */ - nodes() { + nodes(): IterableIterator<[string, NodeAttrs]> { return this._nodes.entries(); } - /** @returns {string[]} */ - nodeIds() { + nodeIds(): string[] { return [...this._nodes.keys()]; } // ─── Edge operations ──────────────────────────────────────────── - addEdge(source, target, attrs = {}) { + addEdge(source: string | number, target: string | number, attrs: EdgeAttrs = {}): this { const src = String(source); const tgt = String(target); // Auto-add nodes if missing if (!this._nodes.has(src)) this.addNode(src); if (!this._nodes.has(tgt)) this.addNode(tgt); - this._successors.get(src).set(tgt, attrs); - this._predecessors.get(tgt).set(src, attrs); + this._successors.get(src)!.set(tgt, attrs); + this._predecessors.get(tgt)!.set(src, attrs); if (!this._directed) { - this._successors.get(tgt).set(src, attrs); - this._predecessors.get(src).set(tgt, attrs); + this._successors.get(tgt)!.set(src, attrs); + this._predecessors.get(src)!.set(tgt, attrs); } return this; } - hasEdge(source, target) { + hasEdge(source: string | number, target: string | number): boolean { const src = String(source); const tgt = String(target); - return this._successors.has(src) && this._successors.get(src).has(tgt); + return this._successors.has(src) && this._successors.get(src)!.has(tgt); } - getEdgeAttrs(source, target) { + getEdgeAttrs(source: string | number, target: string | number): EdgeAttrs | undefined { const src = String(source); const tgt = String(target); return this._successors.get(src)?.get(tgt); } - /** @yields {[string, string, object]} source, target, attrs */ - *edges() { - const seen = this._directed ? null : new Set(); + *edges(): Generator<[string, string, EdgeAttrs]> { + const seen = this._directed ? null : new Set(); for (const [src, targets] of this._successors) { for (const [tgt, attrs] of targets) { if (!this._directed) { // \0 is safe as separator — node IDs are file paths/symbols, never contain null bytes const key = src < tgt ? `${src}\0${tgt}` : `${tgt}\0${src}`; - if (seen.has(key)) continue; - seen.add(key); + if (seen!.has(key)) continue; + seen!.add(key); } yield [src, tgt, attrs]; } @@ -112,23 +120,23 @@ export class CodeGraph { // ─── Adjacency ────────────────────────────────────────────────── /** Direct successors of a node (outgoing edges). */ - successors(id) { + successors(id: string | number): string[] { const key = String(id); const map = this._successors.get(key); return map ? [...map.keys()] : []; } /** Direct predecessors of a node (incoming edges). */ - predecessors(id) { + predecessors(id: string | number): string[] { const key = String(id); const map = this._predecessors.get(key); return map ? [...map.keys()] : []; } /** All neighbors (union of successors + predecessors). */ - neighbors(id) { + neighbors(id: string | number): string[] { const key = String(id); - const set = new Set(); + const set = new Set(); const succ = this._successors.get(key); if (succ) for (const k of succ.keys()) set.add(k); const pred = this._predecessors.get(key); @@ -136,12 +144,12 @@ export class CodeGraph { return [...set]; } - outDegree(id) { + outDegree(id: string | number): number { const map = this._successors.get(String(id)); return map ? map.size : 0; } - inDegree(id) { + inDegree(id: string | number): number { const map = this._predecessors.get(String(id)); return map ? map.size : 0; } @@ -149,7 +157,7 @@ export class CodeGraph { // ─── Filtering ────────────────────────────────────────────────── /** Return a new graph containing only nodes matching the predicate. */ - subgraph(predicate) { + subgraph(predicate: (id: string, attrs: NodeAttrs) => boolean): CodeGraph { const g = new CodeGraph({ directed: this._directed }); for (const [id, attrs] of this._nodes) { if (predicate(id, attrs)) g.addNode(id, { ...attrs }); @@ -163,7 +171,7 @@ export class CodeGraph { } /** Return a new graph containing only edges matching the predicate. */ - filterEdges(predicate) { + filterEdges(predicate: (source: string, target: string, attrs: EdgeAttrs) => boolean): CodeGraph { const g = new CodeGraph({ directed: this._directed }); for (const [id, attrs] of this._nodes) { g.addNode(id, { ...attrs }); @@ -179,8 +187,8 @@ export class CodeGraph { // ─── Conversion ───────────────────────────────────────────────── /** Convert to flat edge array for native Rust interop. */ - toEdgeArray() { - const result = []; + toEdgeArray(): { source: string; target: string }[] { + const result: { source: string; target: string }[] = []; for (const [source, target] of this.edges()) { result.push({ source, target }); } @@ -189,7 +197,7 @@ export class CodeGraph { // ─── Utilities ────────────────────────────────────────────────── - clone() { + clone(): CodeGraph { const g = new CodeGraph({ directed: this._directed }); for (const [id, attrs] of this._nodes) { g.addNode(id, { ...attrs }); @@ -201,7 +209,7 @@ export class CodeGraph { } /** Merge another graph into this one. Nodes/edges from other override on conflict. */ - merge(other) { + merge(other: CodeGraph): this { for (const [id, attrs] of other.nodes()) { this.addNode(id, attrs); } diff --git a/src/infrastructure/logger.js b/src/infrastructure/logger.ts similarity index 53% rename from src/infrastructure/logger.js rename to src/infrastructure/logger.ts index c6d356c0..13adf3e2 100644 --- a/src/infrastructure/logger.js +++ b/src/infrastructure/logger.ts @@ -1,24 +1,24 @@ let verbose = false; -export function setVerbose(v) { +export function setVerbose(v: boolean): void { verbose = v; } -export function isVerbose() { +export function isVerbose(): boolean { return verbose; } -export function warn(msg) { +export function warn(msg: string): void { process.stderr.write(`[codegraph WARN] ${msg}\n`); } -export function debug(msg) { +export function debug(msg: string): void { if (verbose) process.stderr.write(`[codegraph DEBUG] ${msg}\n`); } -export function info(msg) { +export function info(msg: string): void { process.stderr.write(`[codegraph] ${msg}\n`); } -export function error(msg) { +export function error(msg: string): void { process.stderr.write(`[codegraph ERROR] ${msg}\n`); } diff --git a/src/infrastructure/result-formatter.js b/src/infrastructure/result-formatter.ts similarity index 100% rename from src/infrastructure/result-formatter.js rename to src/infrastructure/result-formatter.ts diff --git a/src/infrastructure/test-filter.js b/src/infrastructure/test-filter.ts similarity index 80% rename from src/infrastructure/test-filter.js rename to src/infrastructure/test-filter.ts index c8064e64..19e558ea 100644 --- a/src/infrastructure/test-filter.js +++ b/src/infrastructure/test-filter.ts @@ -2,6 +2,6 @@ export const TEST_PATTERN = /\.(test|spec)\.|__test__|__tests__|\.stories\./; /** Check whether a file path looks like a test file. */ -export function isTestFile(filePath) { +export function isTestFile(filePath: string): boolean { return TEST_PATTERN.test(filePath); } diff --git a/src/presentation/colors.js b/src/presentation/colors.ts similarity index 70% rename from src/presentation/colors.js rename to src/presentation/colors.ts index 5bfc0da9..57171d80 100644 --- a/src/presentation/colors.js +++ b/src/presentation/colors.ts @@ -6,7 +6,9 @@ * without creating a cross-layer dependency. */ -export const DEFAULT_NODE_COLORS = { +import type { AnyNodeKind, CoreRole } from '../types.js'; + +export const DEFAULT_NODE_COLORS: Record = { function: '#4CAF50', method: '#66BB6A', class: '#2196F3', @@ -18,9 +20,12 @@ export const DEFAULT_NODE_COLORS = { record: '#EC407A', module: '#78909C', file: '#90A4AE', + parameter: '#B0BEC5', + property: '#B0BEC5', + constant: '#B0BEC5', }; -export const DEFAULT_ROLE_COLORS = { +export const DEFAULT_ROLE_COLORS: Partial> = { entry: '#e8f5e9', core: '#e3f2fd', utility: '#f5f5f5', @@ -28,7 +33,7 @@ export const DEFAULT_ROLE_COLORS = { leaf: '#fffde7', }; -export const COMMUNITY_COLORS = [ +export const COMMUNITY_COLORS: readonly string[] = [ '#4CAF50', '#2196F3', '#FF9800', diff --git a/src/presentation/table.js b/src/presentation/table.ts similarity index 68% rename from src/presentation/table.js rename to src/presentation/table.ts index 4fdba379..f8b42d37 100644 --- a/src/presentation/table.js +++ b/src/presentation/table.ts @@ -4,16 +4,22 @@ * Pure data → formatted string transforms. No I/O — callers handle printing. */ +export interface ColumnDef { + header: string; + width: number; + align?: 'left' | 'right'; +} + +export interface FormatTableOpts { + columns: ColumnDef[]; + rows: string[][]; + indent?: number; +} + /** * Format a table with aligned columns. - * - * @param {object} opts - * @param {Array<{ header: string, width: number, align?: 'left'|'right' }>} opts.columns - * @param {string[][]} opts.rows - Each row is an array of string cell values - * @param {number} [opts.indent=2] - Leading spaces per line - * @returns {string} Formatted table string (header + separator + data rows) */ -export function formatTable({ columns, rows, indent = 2 }) { +export function formatTable({ columns, rows, indent = 2 }: FormatTableOpts): string { const prefix = ' '.repeat(indent); const header = columns .map((c) => (c.align === 'right' ? c.header.padStart(c.width) : c.header.padEnd(c.width))) @@ -33,7 +39,7 @@ export function formatTable({ columns, rows, indent = 2 }) { /** * Truncate a string from the end, appending '\u2026' if truncated. */ -export function truncEnd(str, maxLen) { +export function truncEnd(str: string, maxLen: number): string { if (str.length <= maxLen) return str; return `${str.slice(0, maxLen - 1)}\u2026`; } diff --git a/src/shared/errors.js b/src/shared/errors.ts similarity index 67% rename from src/shared/errors.js rename to src/shared/errors.ts index 0a398446..45baeafe 100644 --- a/src/shared/errors.js +++ b/src/shared/errors.ts @@ -6,21 +6,17 @@ * MCP returns structured { isError, code } responses. */ -export class CodegraphError extends Error { - /** @type {string} */ - code; +export interface CodegraphErrorOpts { + code?: string; + file?: string; + cause?: Error; +} - /** @type {string|undefined} */ - file; +export class CodegraphError extends Error { + code: string; + file: string | undefined; - /** - * @param {string} message - * @param {object} [opts] - * @param {string} [opts.code] - * @param {string} [opts.file] - Related file path, if applicable - * @param {Error} [opts.cause] - Original error that triggered this one - */ - constructor(message, { code = 'CODEGRAPH_ERROR', file, cause } = {}) { + constructor(message: string, { code = 'CODEGRAPH_ERROR', file, cause }: CodegraphErrorOpts = {}) { super(message, { cause }); this.name = 'CodegraphError'; this.code = code; @@ -29,49 +25,49 @@ export class CodegraphError extends Error { } export class ParseError extends CodegraphError { - constructor(message, opts = {}) { + constructor(message: string, opts: CodegraphErrorOpts = {}) { super(message, { code: 'PARSE_FAILED', ...opts }); this.name = 'ParseError'; } } export class DbError extends CodegraphError { - constructor(message, opts = {}) { + constructor(message: string, opts: CodegraphErrorOpts = {}) { super(message, { code: 'DB_ERROR', ...opts }); this.name = 'DbError'; } } export class ConfigError extends CodegraphError { - constructor(message, opts = {}) { + constructor(message: string, opts: CodegraphErrorOpts = {}) { super(message, { code: 'CONFIG_INVALID', ...opts }); this.name = 'ConfigError'; } } export class ResolutionError extends CodegraphError { - constructor(message, opts = {}) { + constructor(message: string, opts: CodegraphErrorOpts = {}) { super(message, { code: 'RESOLUTION_FAILED', ...opts }); this.name = 'ResolutionError'; } } export class EngineError extends CodegraphError { - constructor(message, opts = {}) { + constructor(message: string, opts: CodegraphErrorOpts = {}) { super(message, { code: 'ENGINE_UNAVAILABLE', ...opts }); this.name = 'EngineError'; } } export class AnalysisError extends CodegraphError { - constructor(message, opts = {}) { + constructor(message: string, opts: CodegraphErrorOpts = {}) { super(message, { code: 'ANALYSIS_FAILED', ...opts }); this.name = 'AnalysisError'; } } export class BoundaryError extends CodegraphError { - constructor(message, opts = {}) { + constructor(message: string, opts: CodegraphErrorOpts = {}) { super(message, { code: 'BOUNDARY_VIOLATION', ...opts }); this.name = 'BoundaryError'; } diff --git a/src/shared/kinds.js b/src/shared/kinds.ts similarity index 57% rename from src/shared/kinds.js rename to src/shared/kinds.ts index 205d0cfb..3b9ec050 100644 --- a/src/shared/kinds.js +++ b/src/shared/kinds.ts @@ -1,6 +1,17 @@ +import type { + CoreEdgeKind, + CoreSymbolKind, + DeadSubRole, + EdgeKind, + ExtendedSymbolKind, + Role, + StructuralEdgeKind, + SymbolKind, +} from '../types.js'; + // ── Symbol kind constants ─────────────────────────────────────────── // Original 10 kinds — used as default query scope -export const CORE_SYMBOL_KINDS = [ +export const CORE_SYMBOL_KINDS: readonly CoreSymbolKind[] = [ 'function', 'method', 'class', @@ -11,26 +22,29 @@ export const CORE_SYMBOL_KINDS = [ 'trait', 'record', 'module', -]; +] as const; // Sub-declaration kinds (Phase 1) -export const EXTENDED_SYMBOL_KINDS = [ +export const EXTENDED_SYMBOL_KINDS: readonly ExtendedSymbolKind[] = [ 'parameter', 'property', 'constant', // Phase 2 (reserved, not yet extracted): // 'constructor', 'namespace', 'decorator', 'getter', 'setter', -]; +] as const; // Full set for --kind validation and MCP enum -export const EVERY_SYMBOL_KIND = [...CORE_SYMBOL_KINDS, ...EXTENDED_SYMBOL_KINDS]; +export const EVERY_SYMBOL_KIND: readonly SymbolKind[] = [ + ...CORE_SYMBOL_KINDS, + ...EXTENDED_SYMBOL_KINDS, +]; // Backward compat: ALL_SYMBOL_KINDS stays as the core 10 -export const ALL_SYMBOL_KINDS = CORE_SYMBOL_KINDS; +export const ALL_SYMBOL_KINDS: readonly CoreSymbolKind[] = CORE_SYMBOL_KINDS; // ── Edge kind constants ───────────────────────────────────────────── // Core edge kinds — coupling and dependency relationships -export const CORE_EDGE_KINDS = [ +export const CORE_EDGE_KINDS: readonly CoreEdgeKind[] = [ 'imports', 'imports-type', 'dynamic-imports', @@ -39,19 +53,27 @@ export const CORE_EDGE_KINDS = [ 'extends', 'implements', 'contains', -]; +] as const; // Structural edge kinds — parent/child and type relationships -export const STRUCTURAL_EDGE_KINDS = ['parameter_of', 'receiver']; +export const STRUCTURAL_EDGE_KINDS: readonly StructuralEdgeKind[] = [ + 'parameter_of', + 'receiver', +] as const; // Full set for MCP enum and validation -export const EVERY_EDGE_KIND = [...CORE_EDGE_KINDS, ...STRUCTURAL_EDGE_KINDS]; +export const EVERY_EDGE_KIND: readonly EdgeKind[] = [...CORE_EDGE_KINDS, ...STRUCTURAL_EDGE_KINDS]; // Dead sub-categories — refine the coarse "dead" bucket export const DEAD_ROLE_PREFIX = 'dead'; -export const DEAD_SUB_ROLES = ['dead-leaf', 'dead-entry', 'dead-ffi', 'dead-unresolved']; +export const DEAD_SUB_ROLES: readonly DeadSubRole[] = [ + 'dead-leaf', + 'dead-entry', + 'dead-ffi', + 'dead-unresolved', +] as const; -export const VALID_ROLES = [ +export const VALID_ROLES: readonly Role[] = [ 'entry', 'core', 'utility', diff --git a/src/shared/normalize.js b/src/shared/normalize.ts similarity index 51% rename from src/shared/normalize.js rename to src/shared/normalize.ts index 06356a0d..0abb0b01 100644 --- a/src/shared/normalize.js +++ b/src/shared/normalize.ts @@ -1,9 +1,16 @@ -export function getFileHash(db, file) { - const row = db.prepare('SELECT hash FROM file_hashes WHERE file = ?').get(file); +/** Minimal DB handle — avoids importing better-sqlite3 types directly. */ +interface DbHandle { + prepare(sql: string): { get(...params: unknown[]): unknown }; +} + +export function getFileHash(db: DbHandle, file: string): string | null { + const row = db.prepare('SELECT hash FROM file_hashes WHERE file = ?').get(file) as + | { hash: string } + | undefined; return row ? row.hash : null; } -export function kindIcon(kind) { +export function kindIcon(kind: string): string { switch (kind) { case 'function': return 'f'; @@ -28,21 +35,41 @@ export function kindIcon(kind) { } } +export interface NormalizedSymbol { + name: string; + kind: string; + file: string; + line: number; + endLine: number | null; + role: string | null; + fileHash: string | null; +} + +interface RawSymbolRow { + name: string; + kind: string; + file: string; + line: number; + end_line?: number | null; + endLine?: number | null; + role?: string | null; +} + /** * Normalize a raw DB/query row into the stable 7-field symbol shape. - * @param {object} row - Raw row (from SELECT * or explicit columns) - * @param {object} [db] - Open DB handle; when null, fileHash will be null - * @param {Map} [hashCache] - Optional per-file cache to avoid repeated getFileHash calls - * @returns {{ name: string, kind: string, file: string, line: number, endLine: number|null, role: string|null, fileHash: string|null }} */ -export function normalizeSymbol(row, db, hashCache) { - let fileHash = null; +export function normalizeSymbol( + row: RawSymbolRow, + db?: DbHandle | null, + hashCache?: Map, +): NormalizedSymbol { + let fileHash: string | null = null; if (db) { if (hashCache) { if (!hashCache.has(row.file)) { hashCache.set(row.file, getFileHash(db, row.file)); } - fileHash = hashCache.get(row.file); + fileHash = hashCache.get(row.file) ?? null; } else { fileHash = getFileHash(db, row.file); } diff --git a/src/shared/paginate.js b/src/shared/paginate.ts similarity index 69% rename from src/shared/paginate.js rename to src/shared/paginate.ts index e5392a48..c17b04c4 100644 --- a/src/shared/paginate.js +++ b/src/shared/paginate.ts @@ -5,8 +5,26 @@ * change between pages; offset/limit is simpler and maps directly to SQL. */ +export interface PaginationMeta { + total: number; + offset: number; + limit: number; + hasMore: boolean; + returned: number; +} + +export interface PaginatedResult { + items: T[]; + pagination?: PaginationMeta; +} + +export interface PaginateOpts { + limit?: number; + offset?: number; +} + /** Default limits applied by MCP tool handlers (not by the programmatic API). */ -export const MCP_DEFAULTS = { +export const MCP_DEFAULTS: Record = { list_functions: 100, query: 10, where: 50, @@ -33,10 +51,8 @@ export const MCP_DEFAULTS = { /** * Get MCP page-size defaults, optionally merged with config overrides. - * @param {object} [configDefaults] - Override map from config.mcp.defaults - * @returns {object} */ -export function getMcpDefaults(configDefaults) { +export function getMcpDefaults(configDefaults?: Record): Record { if (!configDefaults) return MCP_DEFAULTS; return { ...MCP_DEFAULTS, ...configDefaults }; } @@ -48,12 +64,8 @@ export const MCP_MAX_LIMIT = 1000; * Paginate an array. * * When `limit` is undefined the input is returned unchanged (no-op). - * - * @param {any[]} items - * @param {{ limit?: number, offset?: number }} opts - * @returns {{ items: any[], pagination?: { total: number, offset: number, limit: number, hasMore: boolean, returned: number } }} */ -export function paginate(items, { limit, offset } = {}) { +export function paginate(items: T[], { limit, offset }: PaginateOpts = {}): PaginatedResult { if (limit === undefined) { return { items }; } @@ -78,13 +90,12 @@ export function paginate(items, { limit, offset } = {}) { * * When `limit` is undefined the result is returned unchanged (backward compat). * When active, `_pagination` metadata is added to the result. - * - * @param {object} result - The result object (e.g. `{ count: 42, functions: [...] }`) - * @param {string} field - The array field name to paginate (e.g. `'functions'`) - * @param {{ limit?: number, offset?: number }} opts - * @returns {object} - Result with paginated field + `_pagination` (if active) */ -export function paginateResult(result, field, { limit, offset } = {}) { +export function paginateResult>( + result: T, + field: string, + { limit, offset }: PaginateOpts = {}, +): T & { _pagination?: PaginationMeta } { if (limit === undefined) { return result; } @@ -100,11 +111,11 @@ export function paginateResult(result, field, { limit, offset } = {}) { * * Emits a `_meta` line with pagination info (if present), then one JSON * line per item in the named array field. - * - * @param {object} data - Result object (may contain `_pagination`) - * @param {string} field - Array field name to stream (e.g. `'results'`) */ -export function printNdjson(data, field) { +export function printNdjson( + data: Record & { _pagination?: PaginationMeta }, + field: string, +): void { if (data._pagination) console.log(JSON.stringify({ _meta: data._pagination })); const items = data[field]; if (Array.isArray(items)) { diff --git a/tests/graph/cycles.test.js b/tests/graph/cycles.test.js index 2dd8a2ea..18014f8c 100644 --- a/tests/graph/cycles.test.js +++ b/tests/graph/cycles.test.js @@ -5,7 +5,7 @@ import Database from 'better-sqlite3'; import { describe, expect, it } from 'vitest'; import { initSchema } from '../../src/db/index.js'; -import { findCycles, findCyclesJS } from '../../src/domain/graph/cycles.js'; +import { findCycles, findCyclesJS, formatCycles } from '../../src/domain/graph/cycles.js'; import { isNativeAvailable, loadNative } from '../../src/infrastructure/native.js'; const hasNative = isNativeAvailable(); @@ -123,13 +123,11 @@ describe('findCycles — function-level', () => { describe('formatCycles', () => { it('returns no-cycles message for empty array', () => { - const { formatCycles } = require('../../src/domain/graph/cycles.js'); const output = formatCycles([]); expect(output.toLowerCase()).toMatch(/no.*circular/); }); it('formats a single cycle with all member files', () => { - const { formatCycles } = require('../../src/domain/graph/cycles.js'); const output = formatCycles([['a.js', 'b.js']]); expect(output).toContain('a.js'); expect(output).toContain('b.js'); @@ -137,7 +135,6 @@ describe('formatCycles', () => { }); it('formats multiple cycles with distinct labels', () => { - const { formatCycles } = require('../../src/domain/graph/cycles.js'); const output = formatCycles([ ['a.js', 'b.js'], ['x.js', 'y.js', 'z.js'], diff --git a/tests/integration/batch.test.js b/tests/integration/batch.test.js index e903645c..216c98e3 100644 --- a/tests/integration/batch.test.js +++ b/tests/integration/batch.test.js @@ -25,6 +25,10 @@ import { splitTargets, } from '../../src/features/batch.js'; +// Child processes load .ts files natively — requires Node >= 22.6 type stripping +const [_major, _minor] = process.versions.node.split('.').map(Number); +const canStripTypes = _major > 22 || (_major === 22 && _minor >= 6); + // ─── Helpers ─────────────────────────────────────────────────────────── function insertNode(db, name, kind, file, line) { @@ -208,7 +212,7 @@ describe('batchData — complexity (dbOnly signature)', () => { // ─── CLI smoke test ────────────────────────────────────────────────── -describe('batch CLI', () => { +describe.skipIf(!canStripTypes)('batch CLI', () => { const cliPath = path.resolve( path.dirname(new URL(import.meta.url).pathname.replace(/^\/([A-Z]:)/i, '$1')), '../../src/cli.js', diff --git a/tests/integration/cli.test.js b/tests/integration/cli.test.js index a366cd8c..52cc8ff1 100644 --- a/tests/integration/cli.test.js +++ b/tests/integration/cli.test.js @@ -9,6 +9,10 @@ import os from 'node:os'; import path from 'node:path'; import { afterAll, beforeAll, describe, expect, test } from 'vitest'; +// All tests spawn child processes that load .ts files — requires Node >= 22.6 +const [_major, _minor] = process.versions.node.split('.').map(Number); +const canStripTypes = _major > 22 || (_major === 22 && _minor >= 6); + const CLI = path.resolve('src/cli.js'); const FIXTURE_FILES = { @@ -65,7 +69,7 @@ afterAll(() => { if (tmpHome) fs.rmSync(tmpHome, { recursive: true, force: true }); }); -describe('CLI smoke tests', () => { +describe.skipIf(!canStripTypes)('CLI smoke tests', () => { // ─── Build ─────────────────────────────────────────────────────────── test('build creates graph.db', () => { expect(fs.existsSync(dbPath)).toBe(true); @@ -237,7 +241,7 @@ describe('CLI smoke tests', () => { // ─── Registry CLI ─────────────────────────────────────────────────────── -describe('Registry CLI commands', () => { +describe.skipIf(!canStripTypes)('Registry CLI commands', () => { let tmpHome; /** Run CLI with isolated HOME to avoid touching real registry */ diff --git a/tests/unit/index-exports.test.js b/tests/unit/index-exports.test.js index 919af2e2..caa5a5fa 100644 --- a/tests/unit/index-exports.test.js +++ b/tests/unit/index-exports.test.js @@ -4,6 +4,10 @@ import { dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { describe, expect, it } from 'vitest'; +// CJS require goes through Node's native loader — needs Node >= 22.6 for .ts +const [_major, _minor] = process.versions.node.split('.').map(Number); +const canStripTypes = _major > 22 || (_major === 22 && _minor >= 6); + const __dirname = dirname(fileURLToPath(import.meta.url)); const pkg = JSON.parse(readFileSync(resolve(__dirname, '../../package.json'), 'utf8')); @@ -22,7 +26,7 @@ describe('index.js re-exports', () => { expect(typeof mod).toBe('object'); }); - it('CJS wrapper resolves to the same exports', async () => { + it.skipIf(!canStripTypes)('CJS wrapper resolves to the same exports', async () => { const require = createRequire(import.meta.url); const cjs = await require('../../src/index.cjs'); const esm = await import('../../src/index.js'); diff --git a/tests/unit/registry.test.js b/tests/unit/registry.test.js index 9cc0d1aa..c4456c1d 100644 --- a/tests/unit/registry.test.js +++ b/tests/unit/registry.test.js @@ -15,6 +15,10 @@ import { unregisterRepo, } from '../../src/infrastructure/registry.js'; +// Child processes load .ts files natively — requires Node >= 22.6 type stripping +const [_major, _minor] = process.versions.node.split('.').map(Number); +const canStripTypes = _major > 22 || (_major === 22 && _minor >= 6); + let tmpDir; let registryPath; @@ -34,7 +38,7 @@ describe('REGISTRY_PATH', () => { expect(REGISTRY_PATH).toBe(path.join(os.homedir(), '.codegraph', 'registry.json')); }); - it('respects CODEGRAPH_REGISTRY_PATH env var', () => { + it.skipIf(!canStripTypes)('respects CODEGRAPH_REGISTRY_PATH env var', () => { const customPath = path.join(tmpDir, 'custom', 'registry.json'); const result = execFileSync( 'node', diff --git a/vitest.config.js b/vitest.config.js index a92ad038..8779d2a6 100644 --- a/vitest.config.js +++ b/vitest.config.js @@ -1,9 +1,56 @@ +import { existsSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; import { defineConfig } from 'vitest/config'; +const __dirname = dirname(fileURLToPath(import.meta.url)); +const hookPath = pathToFileURL(resolve(__dirname, 'scripts/ts-resolver-hook.js')).href; +const [major, minor] = process.versions.node.split('.').map(Number); +const supportsStripTypes = major > 22 || (major === 22 && minor >= 6); +const existing = process.env.NODE_OPTIONS || ''; + +/** + * During the JS → TS migration, some .js files import from modules that have + * already been renamed to .ts. Vite only auto-resolves .js→.ts when the + * *importer* is itself a .ts file. This plugin fills the gap: when a .js + * import target doesn't exist on disk, it tries the .ts counterpart. + */ +function jsToTsResolver() { + return { + name: 'js-to-ts-resolver', + enforce: 'pre', + resolveId(source, importer) { + if (!importer || !source.endsWith('.js')) return null; + if (!source.startsWith('.') && !source.startsWith('/')) return null; + const importerPath = importer.startsWith('file://') + ? fileURLToPath(importer) + : importer; + const fsPath = resolve(dirname(importerPath), source); + if (!existsSync(fsPath)) { + const tsPath = fsPath.replace(/\.js$/, '.ts'); + if (existsSync(tsPath)) return tsPath; + } + return null; + }, + }; +} + export default defineConfig({ + plugins: [jsToTsResolver()], test: { globals: true, testTimeout: 30000, exclude: ['**/node_modules/**', '**/.git/**', '.claude/**'], + env: { + NODE_OPTIONS: [ + existing, + supportsStripTypes && + !existing.includes('--experimental-strip-types') && + !existing.includes('--strip-types') + ? (major >= 23 ? '--strip-types' : '--experimental-strip-types') + : '', + existing.includes(hookPath) ? '' : `--import ${hookPath}`, + ].filter(Boolean).join(' '), + }, }, });