Skip to content

Commit 4b586c6

Browse files
committed
feat(show): show-engine.ts findSymbolsByName + tests (Tracer 1 of 6)
Pure transport-agnostic lookup engine — same shape audit-engine.ts / query-engine.ts use (PRs #33 / #35). findSymbolsByName({db, name, kind?, inPath?}) returns SymbolMatch[] with deterministic order (file_path ASC, line_start ASC) so callers slice for stable disambiguation output. Per Q-3 settled: name match is case-sensitive (exact). Per Q-4 settled: inPath uses a directory-vs-file heuristic — trailing slash OR no extension in trailing segment treats as prefix (LIKE 'src/cli/%'); else exact file match (file_path = ?). Caller normalizes via toProjectRelative before passing. 12 unit tests cover: single match, unknown name, ambiguous (3-match deterministic order), kind filter narrowing, inPath as directory (no slash + with slash), inPath as file (exact + miss), kind+inPath compose AND, returned columns, case-sensitivity. Reuses the symbols table directly. No schema change. Tracer 2 wires the CLI verb on top.
1 parent 24337b3 commit 4b586c6

2 files changed

Lines changed: 240 additions & 0 deletions

File tree

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
2+
3+
import { createTables } from "../db";
4+
import type { CodemapDatabase } from "../db";
5+
import { openCodemapDatabase } from "../sqlite-db";
6+
import { findSymbolsByName } from "./show-engine";
7+
8+
let db: CodemapDatabase;
9+
10+
beforeEach(() => {
11+
db = openCodemapDatabase(":memory:");
12+
createTables(db);
13+
// Seed a `files` row first so `symbols.file_path` foreign keys resolve.
14+
db.run(
15+
"INSERT INTO files (path, content_hash, size, line_count, language, last_modified, indexed_at) VALUES (?, ?, ?, ?, ?, ?, ?), (?, ?, ?, ?, ?, ?, ?), (?, ?, ?, ?, ?, ?, ?)",
16+
[
17+
"src/cli/cmd-show.ts",
18+
"h1",
19+
100,
20+
30,
21+
"ts",
22+
1,
23+
1,
24+
"src/legacy/foo.ts",
25+
"h2",
26+
80,
27+
20,
28+
"ts",
29+
1,
30+
1,
31+
"src/test/fixtures.ts",
32+
"h3",
33+
50,
34+
15,
35+
"ts",
36+
1,
37+
1,
38+
],
39+
);
40+
// Three symbols named `foo` across two files + a kind variation.
41+
db.run(
42+
`INSERT INTO symbols (file_path, name, kind, line_start, line_end, signature, is_exported, is_default_export)
43+
VALUES
44+
('src/cli/cmd-show.ts', 'foo', 'function', 5, 15, 'function foo(): void', 1, 0),
45+
('src/legacy/foo.ts', 'foo', 'function', 1, 50, 'function foo(arg: string): number', 0, 0),
46+
('src/test/fixtures.ts','foo', 'const', 3, 3, 'const foo = 42', 1, 0),
47+
('src/cli/cmd-show.ts', 'bar', 'function', 20, 25,'function bar(): string', 1, 0)`,
48+
);
49+
});
50+
51+
afterEach(() => {
52+
db.close();
53+
});
54+
55+
describe("findSymbolsByName", () => {
56+
it("returns single match for a unique name", () => {
57+
const r = findSymbolsByName(db, { name: "bar" });
58+
expect(r).toHaveLength(1);
59+
expect(r[0]).toMatchObject({
60+
name: "bar",
61+
kind: "function",
62+
file_path: "src/cli/cmd-show.ts",
63+
line_start: 20,
64+
line_end: 25,
65+
});
66+
});
67+
68+
it("returns empty array for unknown name", () => {
69+
expect(findSymbolsByName(db, { name: "no-such-symbol" })).toEqual([]);
70+
});
71+
72+
it("returns all matches for an ambiguous name (deterministic order)", () => {
73+
const r = findSymbolsByName(db, { name: "foo" });
74+
expect(r).toHaveLength(3);
75+
// Ordered by file_path ASC, line_start ASC.
76+
expect(r.map((m) => m.file_path)).toEqual([
77+
"src/cli/cmd-show.ts",
78+
"src/legacy/foo.ts",
79+
"src/test/fixtures.ts",
80+
]);
81+
});
82+
83+
it("filters by kind when set", () => {
84+
const r = findSymbolsByName(db, { name: "foo", kind: "const" });
85+
expect(r).toHaveLength(1);
86+
expect(r[0]!.file_path).toBe("src/test/fixtures.ts");
87+
});
88+
89+
it("kind=function narrows ambiguous name to 2 matches", () => {
90+
const r = findSymbolsByName(db, { name: "foo", kind: "function" });
91+
expect(r).toHaveLength(2);
92+
expect(r.map((m) => m.file_path)).toEqual([
93+
"src/cli/cmd-show.ts",
94+
"src/legacy/foo.ts",
95+
]);
96+
});
97+
98+
it("inPath as directory (no extension) treats as prefix", () => {
99+
const r = findSymbolsByName(db, { name: "foo", inPath: "src/cli" });
100+
expect(r).toHaveLength(1);
101+
expect(r[0]!.file_path).toBe("src/cli/cmd-show.ts");
102+
});
103+
104+
it("inPath with trailing slash treats as prefix", () => {
105+
const r = findSymbolsByName(db, { name: "foo", inPath: "src/legacy/" });
106+
expect(r).toHaveLength(1);
107+
expect(r[0]!.file_path).toBe("src/legacy/foo.ts");
108+
});
109+
110+
it("inPath with file extension treats as exact match", () => {
111+
const r = findSymbolsByName(db, {
112+
name: "foo",
113+
inPath: "src/test/fixtures.ts",
114+
});
115+
expect(r).toHaveLength(1);
116+
expect(r[0]!.kind).toBe("const");
117+
});
118+
119+
it("inPath exact-match misses when path doesn't match", () => {
120+
const r = findSymbolsByName(db, {
121+
name: "foo",
122+
inPath: "src/test/other.ts",
123+
});
124+
expect(r).toEqual([]);
125+
});
126+
127+
it("inPath + kind compose (AND, not OR)", () => {
128+
const r = findSymbolsByName(db, {
129+
name: "foo",
130+
kind: "function",
131+
inPath: "src/cli",
132+
});
133+
expect(r).toHaveLength(1);
134+
expect(r[0]!.file_path).toBe("src/cli/cmd-show.ts");
135+
});
136+
137+
it("returns kind/visibility/parent_name fields", () => {
138+
const r = findSymbolsByName(db, { name: "bar" });
139+
expect(r[0]).toMatchObject({
140+
kind: "function",
141+
visibility: null,
142+
parent_name: null,
143+
is_exported: 1,
144+
});
145+
});
146+
147+
it("name match is case-sensitive", () => {
148+
expect(findSymbolsByName(db, { name: "FOO" })).toEqual([]);
149+
expect(findSymbolsByName(db, { name: "Foo" })).toEqual([]);
150+
});
151+
});

src/application/show-engine.ts

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import type { CodemapDatabase } from "../db";
2+
3+
/**
4+
* One row from the `symbols` table — the canonical match shape returned by
5+
* `findSymbolsByName`. Same columns the CLI / MCP `show` verbs surface in
6+
* their `--json` envelopes, plus the always-present `signature` so an agent
7+
* can disambiguate without a follow-up read.
8+
*/
9+
export interface SymbolMatch {
10+
name: string;
11+
kind: string;
12+
file_path: string;
13+
line_start: number;
14+
line_end: number;
15+
signature: string;
16+
is_exported: number;
17+
parent_name: string | null;
18+
visibility: string | null;
19+
}
20+
21+
export interface FindSymbolsOpts {
22+
/** Exact symbol name (case-sensitive — per plan §9 Q-3). */
23+
name: string;
24+
/** Optional `symbols.kind` filter (e.g. "function", "const", "class"). */
25+
kind?: string | undefined;
26+
/**
27+
* Optional file-scope filter. If `<inPath>` ends with `/` or matches a
28+
* directory shape, treats as prefix (`AND file_path LIKE 'src/cli/%'`);
29+
* otherwise exact match (`AND file_path = 'src/cli/cmd-show.ts'`).
30+
* Caller should normalize via `toProjectRelative` before passing — this
31+
* engine does no path-shape massaging beyond the prefix/exact split.
32+
*/
33+
inPath?: string | undefined;
34+
}
35+
36+
/**
37+
* Pure transport-agnostic lookup — same shape `cmd-show.ts` and the MCP
38+
* `show` tool both call. Mirrors the `audit-engine.ts` / `query-engine.ts`
39+
* pattern from PRs #33 / #35.
40+
*
41+
* Returns rows ordered deterministically (`file_path ASC, line_start ASC`)
42+
* so callers can slice the array and get stable disambiguation output.
43+
*/
44+
export function findSymbolsByName(
45+
db: CodemapDatabase,
46+
opts: FindSymbolsOpts,
47+
): SymbolMatch[] {
48+
const clauses: string[] = ["name = ?"];
49+
const params: (string | number)[] = [opts.name];
50+
51+
if (opts.kind !== undefined && opts.kind.length > 0) {
52+
clauses.push("kind = ?");
53+
params.push(opts.kind);
54+
}
55+
56+
if (opts.inPath !== undefined && opts.inPath.length > 0) {
57+
if (looksLikeDirectory(opts.inPath)) {
58+
const prefix = opts.inPath.endsWith("/")
59+
? opts.inPath
60+
: `${opts.inPath}/`;
61+
clauses.push("file_path LIKE ?");
62+
params.push(`${prefix}%`);
63+
} else {
64+
clauses.push("file_path = ?");
65+
params.push(opts.inPath);
66+
}
67+
}
68+
69+
const sql = `SELECT name, kind, file_path, line_start, line_end, signature,
70+
is_exported, parent_name, visibility
71+
FROM symbols
72+
WHERE ${clauses.join(" AND ")}
73+
ORDER BY file_path ASC, line_start ASC`;
74+
return db.query(sql).all(...params) as SymbolMatch[];
75+
}
76+
77+
// Heuristic: `--in src/cli/` (trailing slash) and `--in src/cli` (no slash, no
78+
// dot) both mean "prefix"; `--in src/cli/cmd-show.ts` (has a file extension
79+
// after the last slash) means "exact file match". Conservative: anything
80+
// ambiguous treats as prefix — over-matching is recoverable (agent narrows
81+
// further); under-matching silently misses results.
82+
function looksLikeDirectory(p: string): boolean {
83+
if (p.endsWith("/")) return true;
84+
const lastSlash = p.lastIndexOf("/");
85+
const tail = lastSlash === -1 ? p : p.slice(lastSlash + 1);
86+
// No `.` in the trailing segment → directory-shaped (e.g. `src/cli`).
87+
// A `.` → file-shaped (e.g. `src/cli/cmd-show.ts`, `cmd-show.ts`).
88+
return !tail.includes(".");
89+
}

0 commit comments

Comments
 (0)