Skip to content

Commit ae54ce0

Browse files
fix: config overrides, resolver paths, diff preview, impact, FTS delete (#114)
* fix: config overrides, resolver paths, diff preview, impact, FTS delete - Honor explicit empty include / excludeDirNames in config - Reject resolver targets outside project root (prefix-safe) - Allow empty after_pattern in diff preview (deletions) - Pick deterministic impact call-site file_path at min depth - deleteFileData also removes source_fts rows * fix(test): restore config.test.ts dropped in #114 Re-add parse/load/resolve coverage from main; keep empty include/exclude tests.
1 parent 126066d commit ae54ce0

12 files changed

Lines changed: 166 additions & 16 deletions
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@stainless-code/codemap": patch
3+
---
4+
5+
Fix diff preview deletions, config empty-array overrides, resolver path containment, impact call-site selection, and FTS cleanup on file delete.

src/application/impact-engine.test.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,24 @@ describe("findImpact — symbol target via calls graph", () => {
7070
seedCall("src/c.ts", "c", "d");
7171
});
7272

73+
it("picks a deterministic call-site file when the same callee has multiple callers at the same depth", () => {
74+
seedFile("src/z.ts");
75+
seedFile("src/x.ts");
76+
seedSymbol("x", "src/x.ts");
77+
seedSymbol("z", "src/z.ts");
78+
seedCall("src/z.ts", "z", "x");
79+
seedCall("src/a.ts", "a", "x");
80+
const r = findImpact(db, { target: "x", direction: "up" });
81+
const callers = r.matches
82+
.filter((m) => m.depth === 1)
83+
.map((m) => ({ name: m.name, file_path: m.file_path }))
84+
.sort((a, b) => String(a.name).localeCompare(String(b.name)));
85+
expect(callers).toEqual([
86+
{ name: "a", file_path: "src/a.ts" },
87+
{ name: "z", file_path: "src/z.ts" },
88+
]);
89+
});
90+
7391
it("walks down (callees) from `a` reaches b, c, d", () => {
7492
const r = findImpact(db, { target: "a", direction: "down" });
7593
const names = r.matches.map((m) => m.name).sort();

src/application/impact-engine.ts

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -299,10 +299,16 @@ function walkCalls(db: CodemapDatabase, opts: WalkOpts): ImpactNode[] {
299299
WHERE walk.depth < ?
300300
AND instr(walk.path, ',' || c.${joinToCol} || ',') = 0
301301
)
302-
SELECT node, MIN(depth) AS depth, file_path
303-
FROM walk
304-
WHERE depth > 0
305-
GROUP BY node
302+
SELECT node, depth, file_path
303+
FROM (
304+
SELECT node, depth, file_path,
305+
ROW_NUMBER() OVER (
306+
PARTITION BY node ORDER BY depth ASC, file_path ASC
307+
) AS rn
308+
FROM walk
309+
WHERE depth > 0
310+
)
311+
WHERE rn = 1
306312
ORDER BY depth ASC, node ASC
307313
LIMIT ?
308314
`;

src/application/output-formatters.test.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -595,6 +595,38 @@ describe("formatDiff / formatDiffJson", () => {
595595
expect(readFileSync(outsidePath, "utf8")).toBe(before);
596596
});
597597

598+
it("includes deletion rows with empty after_pattern", () => {
599+
writeFileSync(join(workDir, "src/a.ts"), "FIXME(team): todo\n");
600+
const payload = JSON.parse(
601+
formatDiffJson({
602+
projectRoot: workDir,
603+
rows: [
604+
{
605+
file_path: "src/a.ts",
606+
line_start: 1,
607+
before_pattern: "FIXME(team): ",
608+
after_pattern: "",
609+
},
610+
],
611+
}),
612+
);
613+
expect(payload.summary.hunks).toBe(1);
614+
expect(payload.files[0].hunks).toHaveLength(1);
615+
const out = formatDiff({
616+
projectRoot: workDir,
617+
rows: [
618+
{
619+
file_path: "src/a.ts",
620+
line_start: 1,
621+
before_pattern: "FIXME(team): ",
622+
after_pattern: "",
623+
},
624+
],
625+
});
626+
expect(out).toContain("-FIXME(team): todo");
627+
expect(out).toContain("+todo");
628+
});
629+
598630
it("marks missing rows when source file is gone", () => {
599631
const payload = JSON.parse(
600632
formatDiffJson({

src/application/output-formatters.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -438,7 +438,7 @@ export function buildDiffJson(opts: DiffOpts): DiffJsonPayload {
438438
const filePath = readString(row, "file_path");
439439
const lineStart = readPositiveInt(row, "line_start");
440440
const before = readString(row, "before_pattern");
441-
const after = readString(row, "after_pattern");
441+
const after = readDiffAfterPattern(row, "after_pattern");
442442
if (
443443
filePath === undefined ||
444444
lineStart === undefined ||
@@ -599,6 +599,15 @@ function readString(
599599
return typeof value === "string" && value.length > 0 ? value : undefined;
600600
}
601601

602+
/** `after_pattern` may be `""` for deletions; other diff fields stay non-empty. */
603+
function readDiffAfterPattern(
604+
row: Record<string, unknown>,
605+
key: string,
606+
): string | undefined {
607+
const value = row[key];
608+
return typeof value === "string" ? value : undefined;
609+
}
610+
602611
function readPositiveInt(
603612
row: Record<string, unknown>,
604613
key: string,
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { describe, expect, it } from "bun:test";
2+
import { mkdtempSync } from "node:fs";
3+
import { tmpdir } from "node:os";
4+
import { join } from "node:path";
5+
6+
import { projectRelativePathFromResolved } from "./path-containment";
7+
8+
describe("projectRelativePathFromResolved", () => {
9+
it("rejects sibling paths that only share a string prefix with the root", () => {
10+
const base = mkdtempSync(join(tmpdir(), "codemap-root-"));
11+
const root = join(base, "app");
12+
const outside = join(base, "application", "x.ts");
13+
expect(projectRelativePathFromResolved(root, outside)).toBeNull();
14+
});
15+
16+
it("returns a project-relative path for files inside the root", () => {
17+
const root = mkdtempSync(join(tmpdir(), "codemap-in-"));
18+
const inside = join(root, "src", "a.ts");
19+
expect(projectRelativePathFromResolved(root, inside)).toBe("src/a.ts");
20+
});
21+
});

src/application/path-containment.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,3 +33,18 @@ export function pathEscapesProjectRoot(
3333
const resolvedRoot = resolve(projectRoot);
3434
return isAbsolute(filePath) || !isWithinProjectRoot(resolvedRoot, filePath);
3535
}
36+
37+
/**
38+
* Map an absolute resolved path to a project-relative path, or `null` when the
39+
* target is outside the root (rejects string-prefix siblings like
40+
* `/repo/app` vs `/repo/application`).
41+
*/
42+
export function projectRelativePathFromResolved(
43+
projectRoot: string,
44+
resolvedAbsolute: string,
45+
): string | null {
46+
const resolvedRoot = resolve(projectRoot);
47+
const abs = resolve(resolvedAbsolute);
48+
if (!isWithinProjectRoot(resolvedRoot, abs)) return null;
49+
return canonicalizeProjectFilePath(resolvedRoot, abs);
50+
}

src/config.test.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { tmpdir } from "node:os";
44
import { join } from "node:path";
55

66
import {
7+
DEFAULT_EXCLUDE_DIR_NAMES,
78
DEFAULT_INCLUDE_PATTERNS,
89
defineConfig,
910
loadUserConfig,
@@ -105,6 +106,18 @@ describe("resolveCodemapConfig", () => {
105106
expect(r2.excludeDirNames.has("node_modules")).toBe(false);
106107
});
107108

109+
it("honors explicit empty include (no default patterns)", () => {
110+
const r = resolveCodemapConfig(dir, { include: [] });
111+
expect(r.include).toEqual([]);
112+
expect(r.include).not.toEqual(DEFAULT_INCLUDE_PATTERNS);
113+
});
114+
115+
it("honors explicit empty excludeDirNames (no default exclusions)", () => {
116+
const r = resolveCodemapConfig(dir, { excludeDirNames: [] });
117+
expect([...r.excludeDirNames]).toEqual([]);
118+
expect(r.excludeDirNames).not.toEqual(DEFAULT_EXCLUDE_DIR_NAMES);
119+
});
120+
108121
it("defaults boundaries to []", () => {
109122
const r = resolveCodemapConfig(dir, undefined);
110123
expect(r.boundaries).toEqual([]);

src/config.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -263,11 +263,12 @@ export function resolveCodemapConfig(
263263
const databasePath = parsed?.databasePath
264264
? resolve(absRoot, parsed.databasePath)
265265
: join(stateDir, STATE_DB_NAME);
266-
const include = parsed?.include?.length
267-
? [...parsed.include]
268-
: [...DEFAULT_INCLUDE_PATTERNS];
266+
const include =
267+
parsed?.include !== undefined
268+
? [...parsed.include]
269+
: [...DEFAULT_INCLUDE_PATTERNS];
269270
const excludeDirNames = new Set<string>(
270-
parsed?.excludeDirNames?.length
271+
parsed?.excludeDirNames !== undefined
271272
? parsed.excludeDirNames
272273
: DEFAULT_EXCLUDE_DIR_NAMES,
273274
);

src/db.test.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
createIndexes,
88
createSchema,
99
createTables,
10+
deleteFileData,
1011
deleteQueryBaseline,
1112
dropAll,
1213
getMeta,
@@ -18,6 +19,7 @@ import {
1819
SCHEMA_VERSION,
1920
setMeta,
2021
upsertQueryBaseline,
22+
upsertSourceFts,
2123
} from "./db";
2224
import { openCodemapDatabase } from "./sqlite-db";
2325

@@ -68,6 +70,36 @@ describe("SQLite layer (in-memory)", () => {
6870
}
6971
});
7072

73+
it("deleteFileData removes matching source_fts rows", () => {
74+
const db = openCodemapDatabase(":memory:");
75+
try {
76+
createSchema(db);
77+
insertFile(db, {
78+
path: "src/a.ts",
79+
content_hash: "abc",
80+
size: 1,
81+
line_count: 1,
82+
language: "ts",
83+
last_modified: 0,
84+
indexed_at: 0,
85+
});
86+
upsertSourceFts(db, "src/a.ts", "export const a = 1;");
87+
deleteFileData(db, "src/a.ts");
88+
expect(
89+
db
90+
.query("SELECT COUNT(*) AS n FROM files WHERE path = ?")
91+
.get("src/a.ts") as { n: number },
92+
).toEqual({ n: 0 });
93+
expect(
94+
db
95+
.query("SELECT COUNT(*) AS n FROM source_fts WHERE file_path = ?")
96+
.get("src/a.ts") as { n: number },
97+
).toEqual({ n: 0 });
98+
} finally {
99+
closeDb(db);
100+
}
101+
});
102+
71103
it("symbols.visibility round-trips with index hit on WHERE visibility = ?", () => {
72104
const db = openCodemapDatabase(":memory:");
73105
try {

0 commit comments

Comments
 (0)