From 81b7a2fe3795913303016440bd96834082c34326 Mon Sep 17 00:00:00 2001 From: carlos-alm <127798846+carlos-alm@users.noreply.github.com> Date: Fri, 20 Mar 2026 18:37:53 -0600 Subject: [PATCH 1/6] feat: migrate 17 leaf modules from JavaScript to TypeScript (5.3) Migrate modules with zero internal dependencies to TypeScript with full strict-mode type annotations. These leaf modules can be migrated safely since no internal consumer needs to change their import specifiers. Migrated modules: - shared/: errors, kinds, normalize, paginate - infrastructure/: logger, test-filter, result-formatter - presentation/: colors, table - graph/algorithms/: bfs, tarjan, shortest-path, centrality, leiden/rng - graph/classifiers/: risk, roles - graph/model Adds a Node.js ESM resolver hook (scripts/ts-resolver-loader.js) that falls back from .js to .ts when the .js file doesn't exist. This lets existing .js consumers keep their import specifiers unchanged during the incremental migration. The test runner (scripts/test.js) injects this hook via NODE_OPTIONS so vitest's forked workers resolve .ts files. --- package.json | 6 +- scripts/test.js | 30 ++++++ scripts/ts-resolver-hook.js | 10 ++ scripts/ts-resolver-loader.js | 18 ++++ src/graph/algorithms/{bfs.js => bfs.ts} | 28 ++++-- .../{centrality.js => centrality.ts} | 14 ++- .../algorithms/leiden/{rng.js => rng.ts} | 9 +- .../{shortest-path.js => shortest-path.ts} | 19 ++-- src/graph/algorithms/{tarjan.js => tarjan.ts} | 29 +++--- src/graph/classifiers/{risk.js => risk.ts} | 74 ++++++++++----- src/graph/classifiers/{roles.js => roles.ts} | 48 ++++++---- src/graph/{model.js => model.ts} | 92 ++++++++++--------- src/infrastructure/{logger.js => logger.ts} | 12 +-- ...esult-formatter.js => result-formatter.ts} | 0 .../{test-filter.js => test-filter.ts} | 2 +- src/presentation/{colors.js => colors.ts} | 11 ++- src/presentation/{table.js => table.ts} | 22 +++-- src/shared/{errors.js => errors.ts} | 36 ++++---- src/shared/{kinds.js => kinds.ts} | 46 +++++++--- src/shared/{normalize.js => normalize.ts} | 47 ++++++++-- src/shared/{paginate.js => paginate.ts} | 49 ++++++---- 21 files changed, 397 insertions(+), 205 deletions(-) create mode 100644 scripts/test.js create mode 100644 scripts/ts-resolver-hook.js create mode 100644 scripts/ts-resolver-loader.js rename src/graph/algorithms/{bfs.js => bfs.ts} (62%) rename src/graph/algorithms/{centrality.js => centrality.ts} (50%) rename src/graph/algorithms/leiden/{rng.js => rng.ts} (77%) rename src/graph/algorithms/{shortest-path.js => shortest-path.ts} (61%) rename src/graph/algorithms/{tarjan.js => tarjan.ts} (52%) rename src/graph/classifiers/{risk.js => risk.ts} (58%) rename src/graph/classifiers/{roles.js => roles.ts} (75%) rename src/graph/{model.js => model.ts} (72%) rename src/infrastructure/{logger.js => logger.ts} (53%) rename src/infrastructure/{result-formatter.js => result-formatter.ts} (100%) rename src/infrastructure/{test-filter.js => test-filter.ts} (80%) rename src/presentation/{colors.js => colors.ts} (70%) rename src/presentation/{table.js => table.ts} (68%) rename src/shared/{errors.js => errors.ts} (67%) rename src/shared/{kinds.js => kinds.ts} (57%) rename src/shared/{normalize.js => normalize.ts} (51%) rename src/shared/{paginate.js => paginate.ts} (69%) 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..b9279895 --- /dev/null +++ b/scripts/test.js @@ -0,0 +1,30 @@ +#!/usr/bin/env node +/** + * Test runner wrapper that registers the .js→.ts resolver hook + * before spawning vitest. Works cross-platform (no NODE_OPTIONS needed). + */ + +import { spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import { dirname, resolve } from 'node:path'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +import { pathToFileURL } from 'node:url'; +const hook = pathToFileURL(resolve(__dirname, 'ts-resolver-hook.js')).href; + +const args = process.argv.slice(2); +const vitest = resolve(__dirname, '..', 'node_modules', '.bin', 'vitest'); + +const result = spawnSync(vitest, args, { + stdio: 'inherit', + shell: true, + env: { + ...process.env, + NODE_OPTIONS: [ + `--import ${hook}`, + process.env.NODE_OPTIONS, + ].filter(Boolean).join(' '), + }, +}); + +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..ebd1b289 --- /dev/null +++ b/scripts/ts-resolver-hook.js @@ -0,0 +1,10 @@ +/** + * 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. + */ + +import { register } from '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..550cb822 --- /dev/null +++ b/scripts/ts-resolver-loader.js @@ -0,0 +1,18 @@ +/** + * ESM loader: resolve .js → .ts fallback for incremental migration. + */ + +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; + } + } +} 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)) { From f5f83cbbcc09e21e584d7e83efbf4c74c41565dc Mon Sep 17 00:00:00 2001 From: carlos-alm <127798846+carlos-alm@users.noreply.github.com> Date: Sat, 21 Mar 2026 00:44:58 -0600 Subject: [PATCH 2/6] fix: add load hook and Node 20 compatibility for TS resolver (#553) - Add load hook to ts-resolver-loader.js for .ts file execution outside Vitest (falls back gracefully on Node < 22.6) - Add --experimental-strip-types to NODE_OPTIONS on Node >= 22.6 - Remove shell: true, spawn vitest via node directly (cross-platform) - Add result.error check for spawn failures - Consolidate imports at top of test.js - Guard against NODE_OPTIONS hook duplication Impact: 1 functions changed, 0 affected --- scripts/test.js | 38 +++++++++++++++++++++++++---------- scripts/ts-resolver-loader.js | 25 +++++++++++++++++++++++ 2 files changed, 52 insertions(+), 11 deletions(-) diff --git a/scripts/test.js b/scripts/test.js index b9279895..3690319e 100644 --- a/scripts/test.js +++ b/scripts/test.js @@ -1,30 +1,46 @@ #!/usr/bin/env node /** - * Test runner wrapper that registers the .js→.ts resolver hook - * before spawning vitest. Works cross-platform (no NODE_OPTIONS needed). + * 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 } from 'node:url'; +import { fileURLToPath, pathToFileURL } from 'node:url'; import { dirname, resolve } from 'node:path'; const __dirname = dirname(fileURLToPath(import.meta.url)); -import { pathToFileURL } from 'node:url'; const hook = pathToFileURL(resolve(__dirname, 'ts-resolver-hook.js')).href; const args = process.argv.slice(2); -const vitest = resolve(__dirname, '..', 'node_modules', '.bin', 'vitest'); +const vitestBin = resolve(__dirname, '..', 'node_modules', 'vitest', 'vitest.mjs'); -const result = spawnSync(vitest, args, { +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') + ? '--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', - shell: true, env: { ...process.env, - NODE_OPTIONS: [ - `--import ${hook}`, - process.env.NODE_OPTIONS, - ].filter(Boolean).join(' '), + 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-loader.js b/scripts/ts-resolver-loader.js index 550cb822..1d5cd897 100644 --- a/scripts/ts-resolver-loader.js +++ b/scripts/ts-resolver-loader.js @@ -1,7 +1,14 @@ /** * ESM loader: resolve .js → .ts fallback for incremental migration. + * + * - resolve hook: when a .js specifier is not found, retry with .ts + * - load hook: strip type annotations from .ts files using Node's built-in + * amaro (Node >= 22.6) so the loader works outside of Vitest/Vite too */ +import { readFile } from 'node:fs/promises'; +import { fileURLToPath } from 'node:url'; + export async function resolve(specifier, context, nextResolve) { try { return await nextResolve(specifier, context); @@ -16,3 +23,21 @@ export async function resolve(specifier, context, nextResolve) { } } } + +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; + } + + // Fallback: read the file and return as module source + // This path is reached on Node < 22.6 where --experimental-strip-types + // is unavailable. TypeScript-only syntax will cause a parse error — callers + // should ensure .ts files contain only erasable type annotations. + const source = await readFile(fileURLToPath(url), 'utf-8'); + return { format: 'module', source, shortCircuit: true }; +} From 4586d2b4091c175d0a06cab237a03673c7b37c37 Mon Sep 17 00:00:00 2001 From: carlos-alm <127798846+carlos-alm@users.noreply.github.com> Date: Sat, 21 Mar 2026 01:19:53 -0600 Subject: [PATCH 3/6] fix: node 20 compat and review feedback (#553) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add jsToTsResolver Vite plugin for in-process .js→.ts resolution - Set NODE_OPTIONS in vitest test.env preserving existing values - Load hook now throws clear ERR_TS_UNSUPPORTED on Node < 22.6 instead of returning raw TypeScript source - Use --strip-types on Node >= 23 (--experimental-strip-types deprecated) - Skip child-process tests on Node < 22.6 (no type stripping available) --- scripts/test.js | 4 +-- scripts/ts-resolver-loader.js | 22 ++++++++++------- tests/integration/batch.test.js | 6 ++++- tests/integration/cli.test.js | 8 ++++-- tests/unit/index-exports.test.js | 6 ++++- tests/unit/registry.test.js | 6 ++++- vitest.config.js | 42 ++++++++++++++++++++++++++++++++ 7 files changed, 78 insertions(+), 16 deletions(-) diff --git a/scripts/test.js b/scripts/test.js index 3690319e..ea317a7d 100644 --- a/scripts/test.js +++ b/scripts/test.js @@ -23,8 +23,8 @@ const hookImport = `--import ${hook}`; const existing = process.env.NODE_OPTIONS || ''; const parts = [ existing.includes(hookImport) ? null : hookImport, - supportsStripTypes && !existing.includes('--experimental-strip-types') - ? '--experimental-strip-types' + supportsStripTypes && !existing.includes('--experimental-strip-types') && !existing.includes('--strip-types') + ? (major >= 23 ? '--strip-types' : '--experimental-strip-types') : null, existing || null, ].filter(Boolean); diff --git a/scripts/ts-resolver-loader.js b/scripts/ts-resolver-loader.js index 1d5cd897..84e7da1e 100644 --- a/scripts/ts-resolver-loader.js +++ b/scripts/ts-resolver-loader.js @@ -2,11 +2,11 @@ * ESM loader: resolve .js → .ts fallback for incremental migration. * * - resolve hook: when a .js specifier is not found, retry with .ts - * - load hook: strip type annotations from .ts files using Node's built-in - * amaro (Node >= 22.6) so the loader works outside of Vitest/Vite too + * - 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 { readFile } from 'node:fs/promises'; import { fileURLToPath } from 'node:url'; export async function resolve(specifier, context, nextResolve) { @@ -34,10 +34,14 @@ export async function load(url, context, nextLoad) { if (err.code !== 'ERR_UNKNOWN_FILE_EXTENSION') throw err; } - // Fallback: read the file and return as module source - // This path is reached on Node < 22.6 where --experimental-strip-types - // is unavailable. TypeScript-only syntax will cause a parse error — callers - // should ensure .ts files contain only erasable type annotations. - const source = await readFile(fileURLToPath(url), 'utf-8'); - return { format: 'module', source, shortCircuit: true }; + // 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/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..9271c669 100644 --- a/vitest.config.js +++ b/vitest.config.js @@ -1,9 +1,51 @@ +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 loaderPath = pathToFileURL(resolve(__dirname, 'scripts/ts-resolver-loader.js')).href; +const [major, minor] = process.versions.node.split('.').map(Number); +const supportsStripTypes = major > 22 || (major === 22 && minor >= 6); + +/** + * 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: [ + process.env.NODE_OPTIONS, + supportsStripTypes ? (major >= 23 ? '--strip-types' : '--experimental-strip-types') : '', + `--import ${loaderPath}`, + ].filter(Boolean).join(' '), + }, }, }); From 04e64efbed92b7347f76123f3794c7e3a8aa60c7 Mon Sep 17 00:00:00 2001 From: carlos-alm <127798846+carlos-alm@users.noreply.github.com> Date: Sat, 21 Mar 2026 01:31:14 -0600 Subject: [PATCH 4/6] fix: replace CJS require() with ESM import in formatCycles tests (#553) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit require() bypasses Vite's .js→.ts resolver plugin, causing ERR_MODULE_NOT_FOUND for renamed .ts files on all Node versions. Use the top-level ESM import instead. --- tests/graph/cycles.test.js | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) 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'], From d6366def965228347f1c80f7034d10cfa1f0d7cd Mon Sep 17 00:00:00 2001 From: carlos-alm <127798846+carlos-alm@users.noreply.github.com> Date: Sat, 21 Mar 2026 01:44:02 -0600 Subject: [PATCH 5/6] fix: narrow unknown catch vars to Error in models.ts (#553) After merging main (which includes PR #555's TS migration of models.ts), three catch clauses pass `unknown` to EngineError's `cause: Error` field. Use instanceof checks to satisfy strict typing. --- src/domain/search/models.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) 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; From 21de04f3615eba67eef6874b0312f188d031510c Mon Sep 17 00:00:00 2001 From: carlos-alm <127798846+carlos-alm@users.noreply.github.com> Date: Sat, 21 Mar 2026 01:52:46 -0600 Subject: [PATCH 6/6] fix: correct hook path, add dedup guards, Node 20.6 guard (#553) - vitest.config.js: point to ts-resolver-hook.js (not ts-resolver-loader.js) - vitest.config.js: dedup --strip-types and --import flags - ts-resolver-hook.js: guard module.register() for Node >= 20.6 --- scripts/ts-resolver-hook.js | 9 ++++++--- vitest.config.js | 13 +++++++++---- 2 files changed, 15 insertions(+), 7 deletions(-) diff --git a/scripts/ts-resolver-hook.js b/scripts/ts-resolver-hook.js index ebd1b289..418f833b 100644 --- a/scripts/ts-resolver-hook.js +++ b/scripts/ts-resolver-hook.js @@ -5,6 +5,9 @@ * to install a resolve hook that falls back to .ts when .js is missing. */ -import { register } from 'node:module'; - -register('./ts-resolver-loader.js', import.meta.url); +// 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/vitest.config.js b/vitest.config.js index 9271c669..8779d2a6 100644 --- a/vitest.config.js +++ b/vitest.config.js @@ -4,9 +4,10 @@ import { fileURLToPath, pathToFileURL } from 'node:url'; import { defineConfig } from 'vitest/config'; const __dirname = dirname(fileURLToPath(import.meta.url)); -const loaderPath = pathToFileURL(resolve(__dirname, 'scripts/ts-resolver-loader.js')).href; +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 @@ -42,9 +43,13 @@ export default defineConfig({ exclude: ['**/node_modules/**', '**/.git/**', '.claude/**'], env: { NODE_OPTIONS: [ - process.env.NODE_OPTIONS, - supportsStripTypes ? (major >= 23 ? '--strip-types' : '--experimental-strip-types') : '', - `--import ${loaderPath}`, + 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(' '), }, },