diff --git a/.changeset/fix-high-bugs-batch.md b/.changeset/fix-high-bugs-batch.md new file mode 100644 index 0000000..dd87873 --- /dev/null +++ b/.changeset/fix-high-bugs-batch.md @@ -0,0 +1,5 @@ +--- +"codemap": patch +--- + +Fix high-severity bugs (describe.each parent stack, git porcelain -z paths, CLI symlink entry, pr-comment TTY stdin), medium bugs (changed-since -z paths, perf baseline RUNS guard, qualified typeof, decorator args_text, for-of binding refs, HTTP body drain, benchmark regex validation, jsx INSERT RETURNING id, sqlite stmt cache on close), and low bugs (CLI parse guards, incremental delete transaction, apply summary.files, extension case, pnpm # paths, coverage db.transaction, impact walk delimiter, worker errors, pointer dedup, benchmark readAll visibility). diff --git a/scripts/check-perf-baseline.ts b/scripts/check-perf-baseline.ts index ab733f4..6a1b782 100644 --- a/scripts/check-perf-baseline.ts +++ b/scripts/check-perf-baseline.ts @@ -7,7 +7,14 @@ import type { IndexPerformanceReport } from "../src/application/types"; // 25% threshold + 10ms noise floor gate real regressions, not jitter; docs/benchmark.md § Perf baseline. const NOISE_FLOOR_MS = Number(process.env.CODEMAP_PERF_NOISE_FLOOR_MS ?? 10); const REGRESSION_PCT = Number(process.env.CODEMAP_PERF_REGRESSION_PCT ?? 25); -const RUNS = Number(process.env.CODEMAP_PERF_RUNS ?? 3); +const RUNS_RAW = Number(process.env.CODEMAP_PERF_RUNS ?? 3); +if (!Number.isInteger(RUNS_RAW) || RUNS_RAW < 1) { + console.error( + `CODEMAP_PERF_RUNS must be a positive integer (got ${process.env.CODEMAP_PERF_RUNS ?? RUNS_RAW})`, + ); + process.exit(2); +} +const RUNS = RUNS_RAW; const REPO_ROOT = resolve(import.meta.dirname, ".."); const INDEXER = join(REPO_ROOT, "src/index.ts"); diff --git a/src/adapters/builtin.test.ts b/src/adapters/builtin.test.ts index 0294cfa..27b4e36 100644 --- a/src/adapters/builtin.test.ts +++ b/src/adapters/builtin.test.ts @@ -5,6 +5,7 @@ import { BUILTIN_ADAPTERS, getAdapterForExtension } from "./builtin"; describe("getAdapterForExtension", () => { it("resolves TS/JS extensions to builtin.ts-js", () => { expect(getAdapterForExtension(".ts")?.id).toBe("builtin.ts-js"); + expect(getAdapterForExtension(".TS")?.id).toBe("builtin.ts-js"); expect(getAdapterForExtension(".tsx")?.id).toBe("builtin.ts-js"); }); diff --git a/src/adapters/builtin.ts b/src/adapters/builtin.ts index e83727f..f8653f8 100644 --- a/src/adapters/builtin.ts +++ b/src/adapters/builtin.ts @@ -109,7 +109,8 @@ function buildAdapterIndex( // First-match-wins: skip ext if an earlier adapter already claimed it. for (const a of adapters) { for (const ext of a.extensions) { - if (!index.has(ext)) index.set(ext, a); + const key = ext.toLowerCase(); + if (!index.has(key)) index.set(key, a); } } return index; @@ -129,5 +130,5 @@ export function getAdapterForExtension( index = buildAdapterIndex(adapters); adapterIndexCache.set(adapters, index); } - return index.get(ext); + return index.get(ext.toLowerCase()); } diff --git a/src/agents-init.test.ts b/src/agents-init.test.ts index 2ae2aa9..fe72b03 100644 --- a/src/agents-init.test.ts +++ b/src/agents-init.test.ts @@ -369,6 +369,31 @@ describe("upsertCodemapPointerFile", () => { } }); + it("replaces ALL managed sections when file has two blocks", () => { + const dir = mkdtempSync(join(tmpdir(), "codemap-pointer-")); + const p = join(dir, "NOTE.md"); + try { + writeFileSync( + p, + `${wrapPointerTest("FIRST")}\nOther content\n${wrapPointerTest("SECOND")}`, + "utf-8", + ); + upsertCodemapPointerFile( + p, + "UPDATED\n\nstill https://github.com/stainless-code/codemap\n`.agents/skills/codemap`\n`codemap query`", + "NOTE.md", + false, + ); + const out = readFileSync(p, "utf-8"); + expect(out).toContain("UPDATED"); + expect(out).not.toContain("FIRST"); + expect(out).not.toContain("SECOND"); + expect(out.match(new RegExp(CODMAP_POINTER_BEGIN, "g"))?.length).toBe(1); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + it("--force replaces entire file with managed section", () => { const dir = mkdtempSync(join(tmpdir(), "codemap-pointer-")); const p = join(dir, "AGENTS.md"); diff --git a/src/agents-init.ts b/src/agents-init.ts index eccdd74..71d9dc1 100644 --- a/src/agents-init.ts +++ b/src/agents-init.ts @@ -197,10 +197,10 @@ function escapeRegexChars(s: string): string { return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } -function codemapPointerBlockRegex(): RegExp { +function codemapPointerBlockRegex(flags = "m"): RegExp { return new RegExp( `${escapeRegexChars(CODMAP_POINTER_BEGIN)}\\s*[\\s\\S]*?${escapeRegexChars(CODMAP_POINTER_END)}`, - "m", + flags, ); } @@ -247,10 +247,15 @@ export function upsertCodemapPointerFile( } const content = readFileSync(path, "utf-8"); - const re = codemapPointerBlockRegex(); - if (content.match(re)) { - const next = content.replace(re, wrapped); + if (content.match(codemapPointerBlockRegex())) { + const stripped = content + .replace(codemapPointerBlockRegex("gm"), "") + .replace(/\n{3,}/g, "\n\n") + .trimEnd(); + const sep = stripped.length === 0 || stripped.endsWith("\n") ? "" : "\n\n"; + const next = + stripped.length === 0 ? wrapped : `${stripped}${sep}${wrapped}`; if (next === content) { console.log(` Codemap section in ${label} already up to date`); return; diff --git a/src/application/apply-engine.test.ts b/src/application/apply-engine.test.ts index 0091a14..5041b3e 100644 --- a/src/application/apply-engine.test.ts +++ b/src/application/apply-engine.test.ts @@ -753,6 +753,7 @@ describe("applyDiffPayload", () => { }); expect(result.summary.rows).toBe(0); // row silently dropped + expect(result.summary.files).toBe(0); expect(result.files).toEqual([]); }); }); diff --git a/src/application/apply-engine.ts b/src/application/apply-engine.ts index 1794038..0077539 100644 --- a/src/application/apply-engine.ts +++ b/src/application/apply-engine.ts @@ -133,6 +133,7 @@ export function applyDiffPayload(opts: ApplyDiffPayloadOpts): ApplyJsonPayload { // would split phase 2 mid-loop and leak Q2 (c) all-or-nothing. const seenLines = new Map>(); let validRows = 0; + const distinctInputFiles = new Set(); for (const row of rows) { const filePath = readString(row, "file_path"); @@ -151,6 +152,11 @@ export function applyDiffPayload(opts: ApplyDiffPayloadOpts): ApplyJsonPayload { continue; } validRows++; + distinctInputFiles.add( + isAbsolute(filePath) || !isWithinProjectRoot(resolvedRoot, filePath) + ? filePath + : canonicalizeProjectFilePath(resolvedRoot, filePath), + ); // Path-containment guard — without it `file_path: "../escape.ts"` would // write sibling-of-root files (CLI + MCP + HTTP all share this engine). @@ -261,20 +267,6 @@ export function applyDiffPayload(opts: ApplyDiffPayloadOpts): ApplyJsonPayload { } const filesWithConflicts = new Set(conflicts.map((c) => c.file_path)).size; - const distinctInputFiles = new Set(); - for (const row of rows) { - const filePath = readString(row, "file_path"); - if (filePath === undefined) continue; - // Count distinct disk targets, not distinct spellings — same as the - // dedup applied to the cache + pending keys. - if (isAbsolute(filePath) || !isWithinProjectRoot(resolvedRoot, filePath)) { - distinctInputFiles.add(filePath); - } else { - distinctInputFiles.add( - canonicalizeProjectFilePath(resolvedRoot, filePath), - ); - } - } // Q2 (c) — any conflict aborts the run; dry-run never writes. Same Q5 envelope. if (dryRun || conflicts.length > 0) { diff --git a/src/application/coverage-engine.ts b/src/application/coverage-engine.ts index 4f19e9f..b7f3b4f 100644 --- a/src/application/coverage-engine.ts +++ b/src/application/coverage-engine.ts @@ -144,8 +144,7 @@ export function upsertCoverageRows(opts: UpsertOpts): IngestResult { // we're ingesting (a re-ingest is a full replace per file, not a merge), // then bulk-insert the new aggregates. Idempotent across re-runs. let pruned = 0; - db.run("BEGIN"); - try { + const persist = db.transaction(() => { for (const file_path of filesSeen) { db.run("DELETE FROM coverage WHERE file_path = ?", [file_path]); } @@ -203,12 +202,8 @@ export function upsertCoverageRows(opts: UpsertOpts): IngestResult { "coverage_last_ingested_format", format, ]); - - db.run("COMMIT"); - } catch (err) { - db.run("ROLLBACK"); - throw err; - } + }); + persist(); return { ingested: { symbols: buckets.size, files: filesSeen.size }, diff --git a/src/application/get-changed-files.test.ts b/src/application/get-changed-files.test.ts index 0368d24..6ee2811 100644 --- a/src/application/get-changed-files.test.ts +++ b/src/application/get-changed-files.test.ts @@ -75,4 +75,119 @@ describe("getChangedFiles", () => { closeDb(db); } }); + + it("indexes modified files whose paths contain spaces (porcelain -z)", () => { + mkdirSync(join(projectRoot, "src"), { recursive: true }); + writeFileSync( + join(projectRoot, "src/my module.ts"), + "export const x = 1;\n", + ); + const base = commitAll("add spaced file"); + + writeFileSync( + join(projectRoot, "src/my module.ts"), + "export const x = 2;\n", + ); + + const db = openDb(); + try { + createTables(db); + db.run( + "INSERT INTO files (path, content_hash, size, line_count, language, last_modified, indexed_at) VALUES ('src/my module.ts', 'old', 1, 1, 'typescript', 1, 1)", + ); + setMeta(db, "last_indexed_commit", base); + + const delta = getChangedFiles(db); + expect(delta).not.toBeNull(); + expect(delta!.changed).toContain("src/my module.ts"); + expect(delta!.deleted).not.toContain("src/my module.ts"); + } finally { + closeDb(db); + } + }); + + it("detects committed changes to paths with spaces (diff --name-status -z)", () => { + mkdirSync(join(projectRoot, "src"), { recursive: true }); + writeFileSync( + join(projectRoot, "src/my module.ts"), + "export const x = 1;\n", + ); + writeFileSync(join(projectRoot, "src/other.ts"), "export const o = 1;\n"); + const base = commitAll("add spaced and other"); + + writeFileSync( + join(projectRoot, "src/my module.ts"), + "export const x = 2;\n", + ); + commitAll("commit spaced change"); + + const db = openDb(); + try { + createTables(db); + db.run( + "INSERT INTO files (path, content_hash, size, line_count, language, last_modified, indexed_at) VALUES ('src/my module.ts', 'old', 1, 1, 'typescript', 1, 1)", + ); + db.run( + "INSERT INTO files (path, content_hash, size, line_count, language, last_modified, indexed_at) VALUES ('src/other.ts', 'old', 1, 1, 'typescript', 1, 1)", + ); + setMeta(db, "last_indexed_commit", base); + + const delta = getChangedFiles(db); + expect(delta).not.toBeNull(); + expect(delta!.changed).toContain("src/my module.ts"); + expect(delta!.deleted).not.toContain("src/my module.ts"); + } finally { + closeDb(db); + } + }); + + it("includes uppercase extensions in incremental candidates", () => { + mkdirSync(join(projectRoot, "src"), { recursive: true }); + writeFileSync(join(projectRoot, "src/File.TS"), "export const x = 1;\n"); + const base = commitAll("add uppercase ext"); + + writeFileSync(join(projectRoot, "src/File.TS"), "export const x = 2;\n"); + + const db = openDb(); + try { + createTables(db); + setMeta(db, "last_indexed_commit", base); + + const delta = getChangedFiles(db); + expect(delta).not.toBeNull(); + expect(delta!.changed).toContain("src/File.TS"); + } finally { + closeDb(db); + } + }); + + it("returns deleted and changed paths in the same delta", () => { + mkdirSync(join(projectRoot, "src"), { recursive: true }); + writeFileSync(join(projectRoot, "src/a.ts"), "export const a = 1;\n"); + writeFileSync(join(projectRoot, "src/b.ts"), "export const b = 1;\n"); + const base = commitAll("add a and b"); + + git(["rm", "src/a.ts"]); + commitAll("delete a"); + writeFileSync(join(projectRoot, "src/b.ts"), "export const b = 2;\n"); + + const db = openDb(); + try { + createTables(db); + db.run( + "INSERT INTO files (path, content_hash, size, line_count, language, last_modified, indexed_at) VALUES ('src/a.ts', 'old', 1, 1, 'typescript', 1, 1)", + ); + db.run( + "INSERT INTO files (path, content_hash, size, line_count, language, last_modified, indexed_at) VALUES ('src/b.ts', 'old', 1, 1, 'typescript', 1, 1)", + ); + setMeta(db, "last_indexed_commit", base); + + const delta = getChangedFiles(db); + expect(delta).not.toBeNull(); + expect(delta!.deleted).toContain("src/a.ts"); + expect(delta!.changed).toContain("src/b.ts"); + } finally { + closeDb(db); + } + }); }); diff --git a/src/application/http-server.ts b/src/application/http-server.ts index 5e2d136..2521165 100644 --- a/src/application/http-server.ts +++ b/src/application/http-server.ts @@ -346,6 +346,7 @@ async function readJsonBody( const buf = chunk as Buffer; total += buf.length; if (total > MAX_BYTES) { + req.resume(); return { ok: false, error: `codemap serve: request body exceeds ${MAX_BYTES} bytes.`, diff --git a/src/application/impact-engine.test.ts b/src/application/impact-engine.test.ts index c320412..2171999 100644 --- a/src/application/impact-engine.test.ts +++ b/src/application/impact-engine.test.ts @@ -188,6 +188,20 @@ describe("findImpact — cycle detection", () => { const names = r.matches.map((m) => m.name).sort(); expect(names).toEqual(["b", "c"]); }); + + it("does not treat comma-containing symbol names as prefix matches", () => { + seedFile("src/a.ts"); + seedFile("src/ab.ts"); + seedFile("src/c.ts"); + seedSymbol("a", "src/a.ts"); + seedSymbol("a,b", "src/ab.ts"); + seedSymbol("c", "src/c.ts"); + seedCall("src/ab.ts", "a,b", "c"); + seedCall("src/a.ts", "a", "c"); + const r = findImpact(db, { target: "c", direction: "up", depth: 3 }); + const names = r.matches.map((m) => m.name).sort(); + expect(names).toEqual(["a", "a,b"]); + }); }); describe("findImpact — limit termination", () => { diff --git a/src/application/impact-engine.ts b/src/application/impact-engine.ts index 6d443cf..c95d527 100644 --- a/src/application/impact-engine.ts +++ b/src/application/impact-engine.ts @@ -290,14 +290,14 @@ function walkCalls(db: CodemapDatabase, opts: WalkOpts): ImpactNode[] { // Seed depth = 0; `WHERE depth > 0` filters seed; `< depthLimit` is the cap. const sql = ` WITH RECURSIVE walk(node, depth, path, file_path) AS ( - SELECT ?, 0, ',' || ? || ',', NULL + SELECT ?, 0, char(30) || ? || char(30), NULL UNION ALL SELECT c.${joinToCol}, walk.depth + 1, - walk.path || c.${joinToCol} || ',', c.file_path + walk.path || c.${joinToCol} || char(30), c.file_path FROM calls c JOIN walk ON c.${joinFromCol} = walk.node WHERE walk.depth < ? - AND instr(walk.path, ',' || c.${joinToCol} || ',') = 0 + AND instr(walk.path, char(30) || c.${joinToCol} || char(30)) = 0 ) SELECT node, depth, file_path FROM ( @@ -359,14 +359,14 @@ function walkFileGraph(db: CodemapDatabase, opts: WalkOpts): ImpactNode[] { const sql = ` WITH RECURSIVE walk(node, depth, path) AS ( - SELECT ?, 0, ',' || ? || ',' + SELECT ?, 0, char(30) || ? || char(30) UNION ALL SELECT c.${joinToCol}, walk.depth + 1, - walk.path || c.${joinToCol} || ',' + walk.path || c.${joinToCol} || char(30) FROM ${table} c JOIN walk ON c.${joinFromCol} = walk.node WHERE walk.depth < ? - AND instr(walk.path, ',' || c.${joinToCol} || ',') = 0 + AND instr(walk.path, char(30) || c.${joinToCol} || char(30)) = 0 ${filterNonNull} ) SELECT node, MIN(depth) AS depth diff --git a/src/application/index-engine.ts b/src/application/index-engine.ts index b3118e3..187545f 100644 --- a/src/application/index-engine.ts +++ b/src/application/index-engine.ts @@ -91,11 +91,11 @@ const TS_EXTENSIONS = new Set([ const CSS_EXTENSIONS = new Set([".css"]); function langFromExt(ext: string): string { - return LANG_MAP[ext] ?? "text"; + return LANG_MAP[ext.toLowerCase()] ?? "text"; } function fileCategory(path: string): "ts" | "css" | "text" { - const ext = extname(path); + const ext = extname(path).toLowerCase(); if (TS_EXTENSIONS.has(ext)) return "ts"; if (CSS_EXTENSIONS.has(ext)) return "css"; return "text"; @@ -190,14 +190,14 @@ export function getChangedFiles(db: CodemapDatabase): { const diffResult = spawnSync( "git", - ["diff", "--name-status", "--no-renames", `${lastCommit}..HEAD`], + ["diff", "--name-status", "-z", "--no-renames", `${lastCommit}..HEAD`], { cwd: root, }, ); const statusResult = spawnSync( "git", - ["status", "--porcelain", "--no-renames"], + ["status", "--porcelain", "-z", "--no-renames"], { cwd: root, }, @@ -205,30 +205,29 @@ export function getChangedFiles(db: CodemapDatabase): { const diffDeletedFromCommit: string[] = []; const diffFiles: string[] = []; - for (const line of diffResult.stdout + // --name-status -z records are STATUS NUL path NUL pairs (paths unquoted). + const diffRecords = diffResult.stdout .toString() - .trim() - .split("\n") - .filter(Boolean)) { - const tab = line.indexOf("\t"); - if (tab === -1) continue; - const status = line.slice(0, tab); - const path = line.slice(tab + 1); + .split("\0") + .filter(Boolean); + for (let i = 0; i < diffRecords.length; i += 2) { + const status = diffRecords[i]; + const path = diffRecords[i + 1]; + if (path === undefined) continue; if (status === "D") diffDeletedFromCommit.push(path); else diffFiles.push(path); } - // Porcelain lines are `XY path` (two status chars + space); skip the prefix to get the path. + // Porcelain -z records are NUL-terminated; paths are unquoted (no C-style quoting). const statusFiles = statusResult.stdout .toString() - .trim() - .split("\n") + .split("\0") .filter(Boolean) - .map((line: string) => line.slice(3).trim()); + .map((line: string) => line.slice(3)); const existingHashes = getAllFileHashes(db); const allCandidates = [...new Set([...diffFiles, ...statusFiles])].filter( (f) => { - const ext = extname(f); + const ext = extname(f).toLowerCase(); return ext in LANG_MAP || existingHashes.has(f); }, ); @@ -425,6 +424,8 @@ export async function indexFiles( sourceCache?: ChangedSourceCache; /** When set, incremental branch skips its own `getAllFileHashes(db)` call. */ existingHashes?: Map; + /** When set, incremental branch deletes these paths inside the same transaction as re-indexing. */ + deletedPaths?: string[]; }, ): Promise { const quiet = options?.quiet ?? false; @@ -486,6 +487,10 @@ export async function indexFiles( const sourceCache = options?.sourceCache; const transaction = db.transaction(() => { + const deleted = options?.deletedPaths ?? []; + if (deleted.length > 0) { + deleteFilesFromIndex(db, deleted, quiet); + } for (const relPath of filePaths) { const absPath = join(root, relPath); let source: string; diff --git a/src/application/jsx-persist.ts b/src/application/jsx-persist.ts index fdfa0ba..fa99b0b 100644 --- a/src/application/jsx-persist.ts +++ b/src/application/jsx-persist.ts @@ -10,13 +10,15 @@ export function persistJsxElementsAndAttributes( if (!elements.length) return; const idMap = new Map(); for (const el of elements) { - db.run( - `INSERT INTO jsx_elements ( + const row = db + .query<{ id: number }>( + `INSERT INTO jsx_elements ( file_path, component_name, line_start, line_end, column_start, column_end, is_self_closing, is_fragment, namespace_prefix, parent_element_id, children_count, is_lowercase - ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`, - [ + ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?) RETURNING id`, + ) + .get( el.file_path, el.component_name, el.line_start, @@ -29,12 +31,8 @@ export function persistJsxElementsAndAttributes( null, el.children_count, el.is_lowercase, - ], - ); - idMap.set( - el._local_id, - db.query<{ id: number }>("SELECT last_insert_rowid() AS id").get()!.id, - ); + ); + idMap.set(el._local_id, row!.id); } for (const el of elements) { if (el._parent_local_id == null) continue; diff --git a/src/application/run-index.test.ts b/src/application/run-index.test.ts index 0062e5d..b4f8a9c 100644 --- a/src/application/run-index.test.ts +++ b/src/application/run-index.test.ts @@ -1,14 +1,44 @@ -import { describe, expect, test } from "bun:test"; -import { mkdtempSync, writeFileSync } from "node:fs"; +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { spawnSync } from "node:child_process"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { resolveCodemapConfig } from "../config"; +import { closeDb, createTables, openDb, setMeta } from "../db"; +import { hashContent } from "../hash"; import { configureResolver } from "../resolver"; import { initCodemap } from "../runtime"; import { openCodemapDatabase } from "../sqlite-db"; import { runCodemapIndex } from "./run-index"; +let projectRoot: string; + +function fixtureEnv(): NodeJS.ProcessEnv { + const e: NodeJS.ProcessEnv = {}; + for (const [k, v] of Object.entries(process.env)) { + if (k.startsWith("GIT_") || k.startsWith("HUSKY")) continue; + e[k] = v; + } + e.GIT_AUTHOR_DATE = "2026-01-01T00:00:00Z"; + e.GIT_COMMITTER_DATE = "2026-01-01T00:00:00Z"; + return e; +} + +function git(args: string[]): string { + const r = spawnSync("git", args, { cwd: projectRoot, env: fixtureEnv() }); + if (r.status !== 0) { + throw new Error(`git ${args.join(" ")}: ${r.stderr.toString().trim()}`); + } + return r.stdout.toString().trim(); +} + +function commitAll(message: string): string { + git(["add", "."]); + git(["commit", "-m", message, "--no-gpg-sign"]); + return git(["rev-parse", "HEAD"]); +} + describe("runCodemapIndex", () => { test("incremental on empty DB creates schema first (no missing meta table)", async () => { const root = mkdtempSync(join(tmpdir(), "codemap-run-index-")); @@ -25,4 +55,60 @@ describe("runCodemapIndex", () => { db.close(); } }); + + describe("incremental git repo", () => { + beforeEach(() => { + projectRoot = mkdtempSync(join(tmpdir(), "codemap-run-index-git-")); + git(["init", "-q", "-b", "main"]); + git(["config", "user.email", "t@example.com"]); + git(["config", "user.name", "T"]); + git(["config", "commit.gpgsign", "false"]); + initCodemap(resolveCodemapConfig(projectRoot, undefined)); + configureResolver(projectRoot, null); + }); + + afterEach(() => { + rmSync(projectRoot, { recursive: true, force: true }); + }); + + test("deletes and re-indexes in one incremental run when both change", async () => { + mkdirSync(join(projectRoot, "src"), { recursive: true }); + writeFileSync(join(projectRoot, "src/a.ts"), "export const a = 1;\n"); + writeFileSync(join(projectRoot, "src/b.ts"), "export const b = 1;\n"); + const base = commitAll("add a and b"); + + git(["rm", "src/a.ts"]); + commitAll("delete a"); + const bSource = "export const b = 2;\n"; + writeFileSync(join(projectRoot, "src/b.ts"), bSource); + + const db = openDb(); + try { + createTables(db); + db.run( + "INSERT INTO files (path, content_hash, size, line_count, language, last_modified, indexed_at) VALUES ('src/a.ts', 'old', 1, 1, 'typescript', 1, 1)", + ); + db.run( + "INSERT INTO files (path, content_hash, size, line_count, language, last_modified, indexed_at) VALUES ('src/b.ts', 'old', 1, 1, 'typescript', 1, 1)", + ); + setMeta(db, "last_indexed_commit", base); + + await runCodemapIndex(db, { mode: "incremental", quiet: true }); + + const aRow = db + .query<{ path: string }>("SELECT path FROM files WHERE path = ?") + .get("src/a.ts"); + expect(aRow).toBeFalsy(); + + const bRow = db + .query<{ content_hash: string }>( + "SELECT content_hash FROM files WHERE path = ?", + ) + .get("src/b.ts"); + expect(bRow?.content_hash).toBe(hashContent(bSource)); + } finally { + closeDb(db); + } + }); + }); }); diff --git a/src/application/run-index.ts b/src/application/run-index.ts index 1f1228a..c73688c 100644 --- a/src/application/run-index.ts +++ b/src/application/run-index.ts @@ -187,7 +187,6 @@ export async function runCodemapIndex( ` Incremental: ${diff.changed.length} changed, ${diff.deleted.length} deleted`, ); } - deleteFilesFromIndex(db, diff.deleted, quiet); if (diff.changed.length > 0) { const indexedPaths = diff.existingPaths; for (const f of diff.changed) indexedPaths.add(f); @@ -195,6 +194,7 @@ export async function runCodemapIndex( quiet, sourceCache: diff.sourceCache, existingHashes: diff.existingHashes, + deletedPaths: diff.deleted, }); return { mode: "incremental", @@ -205,6 +205,7 @@ export async function runCodemapIndex( }; } if (diff.deleted.length > 0) { + deleteFilesFromIndex(db, diff.deleted, quiet); setMeta(db, "last_indexed_commit", getCurrentCommit()); if (!quiet) console.log(" Index updated (deletions only)"); return { diff --git a/src/benchmark-common.ts b/src/benchmark-common.ts index 04d114c..fd28341 100644 --- a/src/benchmark-common.ts +++ b/src/benchmark-common.ts @@ -21,17 +21,24 @@ export function globFilesFiltered(patterns: string[], cwd: string): string[] { export function readAll( paths: string[], cwd: string, -): { totalBytes: number; contents: Map } { +): { + totalBytes: number; + contents: Map; + unreadable: string[]; +} { let totalBytes = 0; const contents = new Map(); + const unreadable: string[] = []; for (const p of paths) { try { const content = readFileSync(join(cwd, p), "utf-8"); totalBytes += Buffer.byteLength(content); contents.set(p, content); - } catch {} + } catch { + unreadable.push(p); + } } - return { totalBytes, contents }; + return { totalBytes, contents, unreadable }; } export function traditionalFanoutImportLines(): { diff --git a/src/benchmark-config.test.ts b/src/benchmark-config.test.ts index 7da7739..187e677 100644 --- a/src/benchmark-config.test.ts +++ b/src/benchmark-config.test.ts @@ -1,9 +1,15 @@ import { describe, expect, it } from "bun:test"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { assertReadOnlyIndexedSql, collectGlobalRegexMatches, + loadScenariosFromConfigFile, } from "./benchmark-config"; +import { closeDb } from "./db"; +import { openCodemapDatabase } from "./sqlite-db"; describe("assertReadOnlyIndexedSql", () => { it("allows SELECT", () => { @@ -50,3 +56,35 @@ describe("collectGlobalRegexMatches", () => { ]); }); }); + +describe("loadScenariosFromConfigFile", () => { + it("rejects invalid traditional.regex at load time", () => { + const dir = mkdtempSync(join(tmpdir(), "codemap-bench-cfg-")); + const path = join(dir, "scenarios.json"); + writeFileSync( + path, + JSON.stringify({ + scenarios: [ + { + name: "bad-regex", + indexedSql: "SELECT 1", + traditional: { + globs: ["**/*"], + regex: "(unclosed", + mode: "files", + }, + }, + ], + }), + ); + const db = openCodemapDatabase(":memory:"); + try { + expect(() => loadScenariosFromConfigFile(db, path)).toThrow( + /valid regex/, + ); + } finally { + closeDb(db); + rmSync(dir, { recursive: true, force: true }); + } + }); +}); diff --git a/src/benchmark-config.ts b/src/benchmark-config.ts index 7fa0273..0d54c06 100644 --- a/src/benchmark-config.ts +++ b/src/benchmark-config.ts @@ -102,7 +102,12 @@ function traditionalFromSpec(spec: TraditionalSpec): () => { return () => { const cwd = getProjectRoot(); const files = globFilesFiltered(globs, cwd); - const { totalBytes, contents } = readAll(files, cwd); + const { totalBytes, contents, unreadable } = readAll(files, cwd); + if (unreadable.length > 0) { + console.error( + `[benchmark] skipped ${unreadable.length} unreadable file(s): ${unreadable.slice(0, 3).join(", ")}${unreadable.length > 3 ? "…" : ""}`, + ); + } const results: unknown[] = []; if (mode === "matches") { for (const [path, content] of contents) { @@ -163,6 +168,14 @@ function parseConfigJson(raw: string): BenchmarkConfigFile { `benchmark config: ${e.name}: traditional.regex required`, ); } + try { + new RegExp(t.regex as string); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + throw new Error( + `benchmark config: ${e.name}: traditional.regex is not a valid regex: ${msg}`, + ); + } if (t.mode !== "files" && t.mode !== "matches") { throw new Error( `benchmark config: ${e.name}: traditional.mode must be "files" or "matches"`, diff --git a/src/cli/cmd-audit.ts b/src/cli/cmd-audit.ts index 17c61e8..593d574 100644 --- a/src/cli/cmd-audit.ts +++ b/src/cli/cmd-audit.ts @@ -233,7 +233,10 @@ function consumeFlagValue( ) { return { kind: "error", - message: `codemap audit: "${flagName}" requires a value.`, + message: + next !== undefined && next.startsWith("-") + ? `codemap audit: "${flagName}" value looks like another flag (${next}). Use ${flagName}=${next} if the value starts with '-'.` + : `codemap audit: "${flagName}" requires a value.`, }; } return { kind: "value", value: next, next: i + 2 }; diff --git a/src/cli/cmd-pr-comment.test.ts b/src/cli/cmd-pr-comment.test.ts new file mode 100644 index 0000000..db79d6f --- /dev/null +++ b/src/cli/cmd-pr-comment.test.ts @@ -0,0 +1,51 @@ +import { afterEach, describe, expect, it, spyOn } from "bun:test"; + +import { parsePrCommentRest, runPrCommentCmd } from "./cmd-pr-comment"; + +describe("parsePrCommentRest", () => { + it("parses input path and flags", () => { + expect(parsePrCommentRest(["pr-comment", "audit.json", "--json"])).toEqual({ + kind: "run", + inputPath: "audit.json", + shape: undefined, + json: true, + }); + }); + + it("errors on missing input file", () => { + expect(parsePrCommentRest(["pr-comment"])).toMatchObject({ + kind: "error", + }); + }); +}); + +describe("runPrCommentCmd stdin guard", () => { + afterEach(() => { + process.exitCode = 0; + }); + + it("rejects - when stdin is a TTY", async () => { + const stderr = spyOn(console, "error").mockImplementation(() => {}); + const desc = Object.getOwnPropertyDescriptor(process.stdin, "isTTY"); + Object.defineProperty(process.stdin, "isTTY", { + value: true, + configurable: true, + }); + try { + await runPrCommentCmd({ + root: process.cwd(), + configFile: undefined, + inputPath: "-", + shape: undefined, + json: false, + }); + expect(stderr.mock.calls[0]?.[0]).toContain("stdin is a TTY"); + expect(process.exitCode).toBe(1); + process.exitCode = undefined; + } finally { + stderr.mockRestore(); + if (desc) Object.defineProperty(process.stdin, "isTTY", desc); + else Reflect.deleteProperty(process.stdin, "isTTY"); + } + }); +}); diff --git a/src/cli/cmd-pr-comment.ts b/src/cli/cmd-pr-comment.ts index 6696d5a..52eea7f 100644 --- a/src/cli/cmd-pr-comment.ts +++ b/src/cli/cmd-pr-comment.ts @@ -126,6 +126,14 @@ export function parsePrCommentRest(rest: string[]): ParsedPrCommentRest { export async function runPrCommentCmd(opts: PrCommentOpts): Promise { try { + if (opts.inputPath === "-" && process.stdin.isTTY) { + emitPrCommentError( + "stdin is a TTY — pipe a JSON file to stdin or pass a file path", + opts.json, + ); + return; + } + await bootstrapCodemap(opts); const raw = diff --git a/src/cli/cmd-query.test.ts b/src/cli/cmd-query.test.ts index 7a22c4d..cb1eb55 100644 --- a/src/cli/cmd-query.test.ts +++ b/src/cli/cmd-query.test.ts @@ -285,6 +285,24 @@ describe("parseQueryRest", () => { if (r.kind === "error") expect(r.message).toContain("non-empty name"); }); + it("errors when --save-baseline has empty two-token name", () => { + const r = parseQueryRest(["query", "--save-baseline", "", "-r", "fan-out"]); + expect(r.kind).toBe("error"); + if (r.kind === "error") expect(r.message).toContain("non-empty name"); + }); + + it("errors when --baseline has empty two-token name", () => { + const r = parseQueryRest(["query", "--baseline", "", "-r", "fan-out"]); + expect(r.kind).toBe("error"); + if (r.kind === "error") expect(r.message).toContain("non-empty name"); + }); + + it("errors when --params= has empty value", () => { + const r = parseQueryRest(["query", "-r", "fan-out", "--params="]); + expect(r.kind).toBe("error"); + if (r.kind === "error") expect(r.message).toContain("--params"); + }); + it("parses --baseline= with ad-hoc SQL", () => { const r = parseQueryRest(["query", "--baseline=pre-refactor", "SELECT 1"]); if (r.kind !== "run") throw new Error("expected run"); diff --git a/src/cli/cmd-query.ts b/src/cli/cmd-query.ts index b3c31ba..323a37f 100644 --- a/src/cli/cmd-query.ts +++ b/src/cli/cmd-query.ts @@ -223,6 +223,13 @@ export function parseQueryRest(rest: string[]): continue; } const next = rest[i + 1]; + if (next === "") { + return { + kind: "error", + message: + 'codemap: "--save-baseline=" requires a non-empty name. Drop the "=" to use the recipe id as the default name.', + }; + } if (next !== undefined && !next.startsWith("-")) { saveBaseline = next; i += 2; @@ -248,6 +255,13 @@ export function parseQueryRest(rest: string[]): continue; } const next = rest[i + 1]; + if (next === "") { + return { + kind: "error", + message: + 'codemap: "--baseline=" requires a non-empty name. Drop the "=" to use the recipe id as the default name.', + }; + } if (next !== undefined && !next.startsWith("-")) { baseline = next; i += 2; @@ -308,7 +322,7 @@ export function parseQueryRest(rest: string[]): if (a === "--params" || a.startsWith("--params=")) { const eq = a.indexOf("="); const v = eq !== -1 ? a.slice(eq + 1) : rest[i + 1]; - if (v === undefined || v.startsWith("-")) { + if (v === undefined || v === "" || v.startsWith("-")) { return { kind: "error", message: diff --git a/src/extractors/behavioral.test.ts b/src/extractors/behavioral.test.ts index c801cb7..afc3fd4 100644 --- a/src/extractors/behavioral.test.ts +++ b/src/extractors/behavioral.test.ts @@ -116,6 +116,36 @@ const { a } = obj; const aRefs = data.references.filter((r) => r.name === "a"); expect(aRefs.some((r) => r.is_write === 1)).toBe(true); }); + + it("marks for-of destructuring bindings as writes without spurious reads", () => { + const src = ` +for (const { a } of items) {} +`; + const data = extractFileData("/proj/forof.ts", src, "forof.ts"); + const aRefs = data.references.filter((r) => r.name === "a"); + expect(aRefs.some((r) => r.is_write === 1)).toBe(true); + expect(aRefs.some((r) => r.is_write === 0)).toBe(false); + }); +}); + +describe("decorator args_text", () => { + it("is null for zero-arg calls and omits callee for multi-arg", () => { + const src = ` +function log() {} +function route() {} +class C { + @log() + m1() {} + @route('GET', '/users') + m2() {} +} +`; + const data = extractFileData("/proj/dec.ts", src, "dec.ts"); + const logDec = data.decorators.find((d) => d.name === "log"); + const routeDec = data.decorators.find((d) => d.name === "route"); + expect(logDec?.args_text).toBeNull(); + expect(routeDec?.args_text).toBe("'GET', '/users'"); + }); }); describe("module side effects", () => { @@ -141,6 +171,17 @@ test.each([[1]])("case %p", () => {}); expect(data.testSuites[0]?.name).toBe("case %p"); expect(data.testSuites[0]?.kind).toBe("test"); }); + + it("describe.each does not corrupt sibling parent_index", () => { + const src = ` +import { describe, it } from "bun:test"; +describe.each([[1]])("group %p", () => { it("inner", () => {}); }); +it("sibling", () => {}); +`; + const data = extractFileData("/proj/t.ts", src, "t.ts"); + const sibling = data.testSuites.find((r) => r.name === "sibling"); + expect(sibling?.parent_index).toBeNull(); + }); }); describe("runtimeMarkers process.env", () => { diff --git a/src/extractors/behavioral.ts b/src/extractors/behavioral.ts index c75b825..729e678 100644 --- a/src/extractors/behavioral.ts +++ b/src/extractors/behavioral.ts @@ -238,8 +238,8 @@ export const behavioralExtractor: TierExtractor = { const lineStartOffset = lineMap[line - 1] ?? 0; const expr = dec.expression; const argsText = - expr?.type === "CallExpression" - ? source.slice(expr.arguments[0]?.start ?? expr.start, expr.end) + expr?.type === "CallExpression" && expr.arguments?.length + ? source.slice(expr.arguments[0].start, expr.end - 1) : null; decorators.push({ file_path: relPath, diff --git a/src/extractors/references.ts b/src/extractors/references.ts index 4e2cbfd..eccc676 100644 --- a/src/extractors/references.ts +++ b/src/extractors/references.ts @@ -119,6 +119,7 @@ export const referencesExtractor: TierExtractor = { if (decl.init) markPatternWrites(decl.id, true); else if (decl.id?.type === "Identifier") suppressedReads.add(decl.id.start); + else markPatternWrites(decl.id, true); } } else if (node.left?.type === "Identifier") { writePositions.add(node.left.start); @@ -130,6 +131,7 @@ export const referencesExtractor: TierExtractor = { if (decl.init) markPatternWrites(decl.id, true); else if (decl.id?.type === "Identifier") suppressedReads.add(decl.id.start); + else markPatternWrites(decl.id, true); } } else if (node.left?.type === "Identifier") { writePositions.add(node.left.start); diff --git a/src/extractors/runtime-markers.ts b/src/extractors/runtime-markers.ts index dba7433..3f20bb2 100644 --- a/src/extractors/runtime-markers.ts +++ b/src/extractors/runtime-markers.ts @@ -100,12 +100,7 @@ export const runtimeMarkersExtractor: TierExtractor = { node.property?.type === "Literal" && typeof node.property.value === "string" ) { - emit( - "process-env", - node.object.start, - node.property.end, - node.property.value, - ); + emit("process-env", node.start, node.end, node.property.value); } }, }); diff --git a/src/extractors/tests.ts b/src/extractors/tests.ts index 4e668a2..7f27dc7 100644 --- a/src/extractors/tests.ts +++ b/src/extractors/tests.ts @@ -122,7 +122,13 @@ export const testsExtractor: TierExtractor = { is_todo: parsed.modifier === "todo" ? 1 : 0, framework, }); - if (parsed.kind === "describe") parentStack.push(idx); + if ( + parsed.kind === "describe" || + parsed.kind === "suite" || + parsed.kind === "context" + ) { + parentStack.push(idx); + } return; } if (!parsed) return; @@ -153,7 +159,10 @@ export const testsExtractor: TierExtractor = { } }, "CallExpression:exit"(node: any) { - const parsed = parseTestCallee(node.callee); + let parsed = parseTestCallee(node.callee); + if (!parsed && node.callee?.type === "CallExpression") { + parsed = parseTestCallee(node.callee.callee); + } if (!parsed) return; if ( parsed.kind === "describe" || diff --git a/src/extractors/type-stringify.test.ts b/src/extractors/type-stringify.test.ts new file mode 100644 index 0000000..cf5f70f --- /dev/null +++ b/src/extractors/type-stringify.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from "bun:test"; + +import { stringifyTypeNode } from "./type-stringify"; + +describe("stringifyTypeNode TSTypeQuery", () => { + it("stringifies qualified typeof names", () => { + const node = { + type: "TSTypeQuery", + exprName: { + type: "TSQualifiedName", + left: { type: "Identifier", name: "A" }, + right: { type: "Identifier", name: "B" }, + }, + }; + expect(stringifyTypeNode(node)).toBe("typeof A.B"); + }); +}); diff --git a/src/extractors/type-stringify.ts b/src/extractors/type-stringify.ts index e8c0832..59ce3e5 100644 --- a/src/extractors/type-stringify.ts +++ b/src/extractors/type-stringify.ts @@ -95,10 +95,8 @@ export function stringifyTypeNode(node: any): string | null { return null; } case "TSTypeQuery": { - const exprName = node.exprName; - const n = - typeof exprName?.name === "string" ? exprName.name : exprName?.name; - return n ? `typeof ${n}` : null; + const name = qualifiedNameOf(node.exprName); + return name ? `typeof ${name}` : null; } case "TSTypeOperator": { const inner = stringifyTypeNode(node.typeAnnotation); diff --git a/src/git-changed.test.ts b/src/git-changed.test.ts index a793bd7..b812e46 100644 --- a/src/git-changed.test.ts +++ b/src/git-changed.test.ts @@ -1,4 +1,8 @@ -import { describe, expect, it } from "bun:test"; +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { spawnSync } from "node:child_process"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { filterRowsByChangedFiles, @@ -6,6 +10,33 @@ import { PATH_COLUMNS, } from "./git-changed"; +let projectRoot: string; + +function fixtureEnv(): NodeJS.ProcessEnv { + const e: NodeJS.ProcessEnv = {}; + for (const [k, v] of Object.entries(process.env)) { + if (k.startsWith("GIT_") || k.startsWith("HUSKY")) continue; + e[k] = v; + } + e.GIT_AUTHOR_DATE = "2026-01-01T00:00:00Z"; + e.GIT_COMMITTER_DATE = "2026-01-01T00:00:00Z"; + return e; +} + +function git(args: string[]): string { + const r = spawnSync("git", args, { cwd: projectRoot, env: fixtureEnv() }); + if (r.status !== 0) { + throw new Error(`git ${args.join(" ")}: ${r.stderr.toString().trim()}`); + } + return r.stdout.toString().trim(); +} + +function commitAll(message: string): string { + git(["add", "."]); + git(["commit", "-m", message, "--no-gpg-sign"]); + return git(["rev-parse", "HEAD"]); +} + describe("filterRowsByChangedFiles", () => { const changed = new Set(["src/a.ts", "src/b.tsx"]); @@ -88,4 +119,35 @@ describe("getFilesChangedSince", () => { expect(r.ok).toBe(false); if (!r.ok) expect(r.error).toContain("cannot resolve"); }); + + describe("temp git repo", () => { + beforeEach(() => { + projectRoot = mkdtempSync(join(tmpdir(), "codemap-git-changed-")); + git(["init", "-q", "-b", "main", "--template="]); + git(["config", "user.email", "t@example.com"]); + git(["config", "user.name", "T"]); + git(["config", "commit.gpgsign", "false"]); + }); + + afterEach(() => { + rmSync(projectRoot, { recursive: true, force: true }); + }); + + it("includes paths with spaces from porcelain -z output", () => { + mkdirSync(join(projectRoot, "src"), { recursive: true }); + writeFileSync( + join(projectRoot, "src/my module.ts"), + "export const x = 1;\n", + ); + const base = commitAll("add spaced file"); + writeFileSync( + join(projectRoot, "src/my module.ts"), + "export const x = 2;\n", + ); + + const r = getFilesChangedSince(base, projectRoot); + expect(r.ok).toBe(true); + if (r.ok) expect(r.files.has("src/my module.ts")).toBe(true); + }); + }); }); diff --git a/src/git-changed.ts b/src/git-changed.ts index adbb140..acc313d 100644 --- a/src/git-changed.ts +++ b/src/git-changed.ts @@ -2,8 +2,8 @@ import { spawnSync } from "node:child_process"; /** * Files changed between `ref` and the working tree — union of: - * - `git diff --name-only ...HEAD` (committed deltas since the merge base) - * - `git status --porcelain --no-renames` (staged + unstaged changes not in the diff) + * - `git diff --name-only -z ...HEAD` (committed deltas since the merge base) + * - `git status --porcelain -z --no-renames` (staged + unstaged changes not in the diff) * * Paths are returned **project-relative, POSIX-style** (matching how `files.path` * is stored in the index). The `ref` can be any committish (`origin/main`, @@ -35,9 +35,13 @@ export function getFilesChangedSince( }; } - const diff = spawnSync("git", ["diff", "--name-only", `${ref}...HEAD`], { - cwd: root, - }); + const diff = spawnSync( + "git", + ["diff", "--name-only", "-z", `${ref}...HEAD`], + { + cwd: root, + }, + ); if (diff.status !== 0) { const stderr = diff.stderr.toString().trim(); return { @@ -46,9 +50,13 @@ export function getFilesChangedSince( }; } - const status = spawnSync("git", ["status", "--porcelain", "--no-renames"], { - cwd: root, - }); + const status = spawnSync( + "git", + ["status", "--porcelain", "-z", "--no-renames"], + { + cwd: root, + }, + ); if (status.status !== 0) { const stderr = status.stderr.toString().trim(); return { @@ -57,14 +65,13 @@ export function getFilesChangedSince( }; } - const diffFiles = diff.stdout.toString().trim().split("\n").filter(Boolean); - // Porcelain rows are `XY path` (two status chars + space); slice past the prefix. + const diffFiles = diff.stdout.toString().split("\0").filter(Boolean); + // Porcelain -z records are NUL-terminated; paths are unquoted (no C-style quoting). const statusFiles = status.stdout .toString() - .trim() - .split("\n") + .split("\0") .filter(Boolean) - .map((line) => line.slice(3).trim()); + .map((line) => line.slice(3)); return { ok: true, files: new Set([...diffFiles, ...statusFiles]) }; } diff --git a/src/group-by.test.ts b/src/group-by.test.ts index 0ef2d86..5f8a3c6 100644 --- a/src/group-by.test.ts +++ b/src/group-by.test.ts @@ -172,6 +172,20 @@ describe("discoverWorkspaceRoots", () => { } }); + it("preserves # inside quoted pnpm package paths", () => { + const dir = mkdtempSync(join(tmpdir(), "codemap-ws-")); + try { + mkdirSync(join(dir, "packages/core#legacy"), { recursive: true }); + writeFileSync( + join(dir, "pnpm-workspace.yaml"), + ["packages:", ' - "packages/core#legacy"'].join("\n"), + ); + expect(discoverWorkspaceRoots(dir)).toContain("packages/core#legacy"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + it("returns [] when no workspaces are declared", () => { const dir = mkdtempSync(join(tmpdir(), "codemap-ws-")); try { diff --git a/src/group-by.ts b/src/group-by.ts index e9f6d71..c591760 100644 --- a/src/group-by.ts +++ b/src/group-by.ts @@ -216,11 +216,28 @@ export function discoverWorkspaceRoots(root: string): string[] { // Tiny YAML extractor — reads the `packages:` list from pnpm-workspace.yaml without // a YAML dep. Handles `- "pkg/*"` and `- pkg/*` shapes only; that's the documented // pnpm format. If a project uses fancier YAML, they can fall back to --group-by directory. +function stripInlineYamlComment(line: string): string { + let quote: '"' | "'" | null = null; + for (let i = 0; i < line.length; i++) { + const c = line[i]; + if (quote) { + if (c === quote) quote = null; + continue; + } + if (c === '"' || c === "'") { + quote = c; + continue; + } + if (c === "#") return line.slice(0, i); + } + return line; +} + function parsePnpmPackages(body: string): string[] { const out: string[] = []; let inPackages = false; for (const raw of body.split("\n")) { - const line = raw.replace(/#.*$/, ""); + const line = stripInlineYamlComment(raw); if (/^packages:\s*$/.test(line)) { inPackages = true; continue; diff --git a/src/index-entry.test.ts b/src/index-entry.test.ts new file mode 100644 index 0000000..ba7299e --- /dev/null +++ b/src/index-entry.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "bun:test"; +import { spawnSync } from "node:child_process"; +import { mkdtempSync, rmSync, symlinkSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +describe("CLI entry via symlink", () => { + it("runs when argv[1] is a symlink to src/index.ts", () => { + const dir = mkdtempSync(join(tmpdir(), "codemap-entry-")); + const link = join(dir, "codemap-link"); + const target = join(import.meta.dir, "index.ts"); + symlinkSync(target, link); + try { + const r = spawnSync("bun", [link, "--help"], { + cwd: join(import.meta.dir, ".."), + encoding: "utf8", + }); + expect(r.status).toBe(0); + expect(r.stdout + r.stderr).toContain("codemap"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +}); diff --git a/src/index.ts b/src/index.ts index e64db5b..c0a5d1d 100755 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,4 @@ +import { realpathSync } from "node:fs"; import { resolve } from "node:path"; import { fileURLToPath } from "node:url"; @@ -30,7 +31,10 @@ function isMainModule(): boolean { const arg1 = process.argv[1]; if (!arg1) return false; try { - return fileURLToPath(import.meta.url) === resolve(arg1); + const modulePath = fileURLToPath(import.meta.url); + const argPath = resolve(arg1); + if (modulePath === argPath) return true; + return realpathSync(modulePath) === realpathSync(argPath); } catch { return false; } diff --git a/src/parse-worker-core.ts b/src/parse-worker-core.ts index cf9feb5..f27b8cc 100644 --- a/src/parse-worker-core.ts +++ b/src/parse-worker-core.ts @@ -68,7 +68,7 @@ export function parseWorkerInput(input: WorkerInput): WorkerOutput { } const lineCount = countLines(source); - const ext = extname(relPath); + const ext = extname(relPath).toLowerCase(); const language = LANG_MAP[ext] ?? "text"; const parsed: ParsedFile = { diff --git a/src/sqlite-db.ts b/src/sqlite-db.ts index a4adb66..e007ee0 100644 --- a/src/sqlite-db.ts +++ b/src/sqlite-db.ts @@ -106,6 +106,7 @@ function openRaw(path: string): SqliteInner { return rawDb.transaction(fn); }, close() { + stmtCache.clear(); rawDb.close(); }, }; diff --git a/src/worker-pool.test.ts b/src/worker-pool.test.ts index 88b8a2f..ef892c1 100644 --- a/src/worker-pool.test.ts +++ b/src/worker-pool.test.ts @@ -1,6 +1,9 @@ import { describe, expect, test } from "bun:test"; -import { parseParseWorkerCountOverride } from "./worker-pool"; +import { + parseFilesParallel, + parseParseWorkerCountOverride, +} from "./worker-pool"; describe("parseParseWorkerCountOverride", () => { test("accepts valid decimal integers", () => { @@ -22,3 +25,9 @@ describe("parseParseWorkerCountOverride", () => { expect(parseParseWorkerCountOverride("")).toBeNull(); }); }); + +describe("parseFilesParallel", () => { + test("resolves immediately for an empty file list", async () => { + await expect(parseFilesParallel([])).resolves.toEqual([]); + }); +}); diff --git a/src/worker-pool.ts b/src/worker-pool.ts index 3f3e289..46cd6f5 100644 --- a/src/worker-pool.ts +++ b/src/worker-pool.ts @@ -51,6 +51,7 @@ const IS_BUN = typeof Bun !== "undefined"; const NODE_WORKER_PATH = IS_BUN ? "" : fileURLToPath(WORKER_URL_NODE); export function parseFilesParallel(filePaths: string[]): Promise { + if (filePaths.length === 0) return Promise.resolve([]); const chunkSize = Math.ceil(filePaths.length / WORKER_COUNT); const chunks: string[][] = []; for (let i = 0; i < filePaths.length; i += chunkSize) { @@ -77,7 +78,7 @@ export function parseFilesParallel(filePaths: string[]): Promise { worker.terminate(); }; worker.onerror = (event: ErrorEvent) => { - reject(new Error(event.message)); + reject(event.error ?? new Error(event.message)); worker.terminate(); }; worker.postMessage(input);