-
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathshow-engine.ts
More file actions
281 lines (262 loc) · 9.67 KB
/
Copy pathshow-engine.ts
File metadata and controls
281 lines (262 loc) · 9.67 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
import { existsSync, readFileSync } from "node:fs";
import { join } from "node:path";
import type { CodemapDatabase } from "../db";
import { hashContent } from "../hash";
/**
* One row from the `symbols` table — the canonical match shape returned by
* `findSymbolsByName`. Same columns the CLI / MCP `show` verbs surface in
* their `--json` envelopes, plus the always-present `signature` so an agent
* can disambiguate without a follow-up read.
*/
export interface SymbolMatch {
name: string;
kind: string;
file_path: string;
line_start: number;
line_end: number;
signature: string;
is_exported: number;
parent_name: string | null;
visibility: string | null;
}
export interface FindSymbolsOpts {
/** Exact symbol name (case-sensitive — per plan §9 Q-3). */
name: string;
/** Optional `symbols.kind` filter (e.g. "function", "const", "class"). */
kind?: string | undefined;
/**
* Optional file-scope filter. If `<inPath>` ends with `/` or matches a
* directory shape, treats as prefix (`AND file_path LIKE 'src/cli/%'`);
* otherwise exact match (`AND file_path = 'src/cli/cmd-show.ts'`).
* Caller should normalize via `toProjectRelative` before passing — this
* engine does no path-shape massaging beyond the prefix/exact split.
*/
inPath?: string | undefined;
}
/**
* Pure transport-agnostic lookup — same shape `cmd-show.ts` and the MCP
* `show` tool both call. Mirrors the `audit-engine.ts` / `query-engine.ts`
* pattern from PRs #33 / #35.
*
* Returns rows ordered deterministically (`file_path ASC, line_start ASC`)
* so callers can slice the array and get stable disambiguation output.
*/
export function findSymbolsByName(
db: CodemapDatabase,
opts: FindSymbolsOpts,
): SymbolMatch[] {
const clauses: string[] = ["name = ?"];
const params: (string | number)[] = [opts.name];
if (opts.kind !== undefined && opts.kind.length > 0) {
clauses.push("kind = ?");
params.push(opts.kind);
}
if (opts.inPath !== undefined && opts.inPath.length > 0) {
if (looksLikeDirectory(opts.inPath)) {
const prefix = opts.inPath.endsWith("/")
? opts.inPath
: `${opts.inPath}/`;
// Escape user input so `src/__tests__` doesn't over-match via SQL
// LIKE's `_`-matches-any-char rule. Trailing `%` stays a wildcard.
clauses.push("file_path LIKE ? ESCAPE '\\'");
params.push(`${escapeLikeLiteral(prefix)}%`);
} else {
clauses.push("file_path = ?");
params.push(opts.inPath);
}
}
const sql = `SELECT name, kind, file_path, line_start, line_end, signature,
is_exported, parent_name, visibility
FROM symbols
WHERE ${clauses.join(" AND ")}
ORDER BY file_path ASC, line_start ASC`;
return db.query(sql).all(...params) as SymbolMatch[];
}
/**
* Escape SQLite LIKE meta-characters (`_`, `%`) and the escape character
* itself so a user-supplied path matches literally. Used with
* `file_path LIKE ? ESCAPE '\'`.
*/
export function escapeLikeLiteral(s: string): string {
return s.replace(/[\\_%]/g, (c) => `\\${c}`);
}
/** Directory-shaped path prefix vs exact file (mirrors `--in` / `path:` semantics). */
export function looksLikeDirectory(p: string): boolean {
if (p.endsWith("/")) return true;
const lastSlash = p.lastIndexOf("/");
const tail = lastSlash === -1 ? p : p.slice(lastSlash + 1);
return !tail.includes(".");
}
/**
* Result of reading a symbol's source content from disk. `source` is the
* file lines from `match.line_start..match.line_end` joined by newlines.
* `stale` is true when the file's current content_hash differs from
* `match`'s recorded hash (per Q-6 settled — read + flag, no auto-reindex).
* `missing` is true when the file no longer exists on disk.
*/
export interface ReadSourceResult {
source: string | undefined;
stale: boolean;
missing: boolean;
}
export interface ReadSymbolSourceOpts {
match: SymbolMatch;
projectRoot: string;
/**
* The indexed `content_hash` for `match.file_path` — same value
* `cmd-validate.ts` reads. Pass `undefined` if the caller doesn't want
* stale detection (always returns `stale: false`); pass the value from
* `SELECT content_hash FROM files WHERE path = ?` to enable it.
*/
indexedContentHash?: string | undefined;
}
/**
* Read a symbol's source text from disk and compare against the indexed
* hash for staleness. Per plan §9 Q-6 (settled): read + flag — agent
* decides whether to act on possibly-shifted line ranges. No auto-reindex
* (read tool, no side-effects); no refusal (data is already on disk).
*
* Same FS-read pattern `cmd-validate.ts` uses — `readFileSync(abs, "utf8")`
* + `hashContent(source) !== indexedHash`. Reuses `hashContent` from
* `src/hash.ts`. Line slicing is 1-indexed inclusive, matching the
* `symbols.line_start` / `line_end` column convention.
*/
export function readSymbolSource(opts: ReadSymbolSourceOpts): ReadSourceResult {
const abs = join(opts.projectRoot, opts.match.file_path);
if (!existsSync(abs)) {
return { source: undefined, stale: true, missing: true };
}
const content = readFileSync(abs, "utf8");
const stale =
opts.indexedContentHash !== undefined &&
hashContent(content) !== opts.indexedContentHash;
const lines = content.split("\n");
// line_start / line_end are 1-indexed inclusive in the symbols table;
// slice() is 0-indexed half-open, so subtract 1 from the start and use
// line_end as the exclusive upper bound.
const start = Math.max(0, opts.match.line_start - 1);
const end = Math.min(lines.length, opts.match.line_end);
const source = lines.slice(start, end).join("\n");
return { source, stale, missing: false };
}
/**
* Convenience: look up a file's indexed content_hash (same query
* `cmd-validate.ts` uses). Returns `undefined` for unindexed paths so the
* caller can decide what staleness means in that case.
*/
export function getIndexedContentHash(
db: CodemapDatabase,
filePath: string,
): string | undefined {
const row = db
.query("SELECT content_hash FROM files WHERE path = ?")
.get(filePath) as { content_hash: string } | null;
return row?.content_hash;
}
/**
* The catalog envelope returned by `show` — same shape both the CLI's
* `--json` mode and the MCP `show` tool surface (per plan §4 uniformity
* + Q-2 settled). Single match → `{matches: [{...}]}`; multi-match adds
* a structured `disambiguation` block so agents narrow without scanning
* every row.
*/
export interface ShowResult {
matches: SymbolMatch[];
disambiguation?: {
n: number;
by_kind: Record<string, number>;
files: string[];
hint: string;
};
/** Set when FTS was requested but unavailable (e.g. empty source_fts). */
warning?: string;
}
/**
* Build the `ShowResult` envelope from a list of matches. Single-match
* → `{matches}` only. Multi-match → adds a `disambiguation` block with
* structured aids so agents narrow without scanning every row.
*/
export function buildShowResult(matches: SymbolMatch[]): ShowResult {
if (matches.length <= 1) return { matches };
const byKind: Record<string, number> = {};
for (const m of matches) byKind[m.kind] = (byKind[m.kind] ?? 0) + 1;
const files = Array.from(new Set(matches.map((m) => m.file_path))).sort();
return {
matches,
disambiguation: {
n: matches.length,
by_kind: byKind,
files,
hint: "Multiple matches. Narrow with --kind <kind>, --in <path>, or --query 'kind:… name:… path:…'.",
},
};
}
/**
* Per-match payload returned by `snippet` — extends the `show` row shape
* with the source text and stale-flag fields. Same row shape as
* `findSymbolsByName` returns plus three additive fields:
* `source` (the file lines from line_start..line_end),
* `stale` (true when the file's content_hash drifted since indexing),
* `missing` (true when the file no longer exists on disk).
*/
export interface SnippetMatch extends SymbolMatch {
source: string | undefined;
stale: boolean;
missing: boolean;
}
/**
* The catalog envelope returned by `snippet` — same shape as `show`'s
* `ShowResult` (per Q-2 + Q-5: snippet adds source/stale/missing on each
* row but keeps the {matches, disambiguation?} envelope). Single match
* → `{matches: [{...}]}`; multi-match adds the structured disambiguation
* block.
*/
export interface SnippetResult {
matches: SnippetMatch[];
disambiguation?: {
n: number;
by_kind: Record<string, number>;
files: string[];
hint: string;
};
warning?: string;
}
/**
* Build the `SnippetResult` envelope from matches + per-match source reads.
* Mirrors `buildShowResult` but enriches each match with `source` / `stale`
* / `missing` fields read fresh from disk per plan §9 Q-6 (read + flag,
* no auto-reindex).
*/
export function buildSnippetResult(opts: {
db: CodemapDatabase;
matches: SymbolMatch[];
projectRoot: string;
}): SnippetResult {
const enriched: SnippetMatch[] = opts.matches.map((m) => {
const indexedHash = getIndexedContentHash(opts.db, m.file_path);
const read = readSymbolSource({
match: m,
projectRoot: opts.projectRoot,
indexedContentHash: indexedHash,
});
return {
...m,
source: read.source,
stale: read.stale,
missing: read.missing,
};
});
if (enriched.length <= 1) return { matches: enriched };
const byKind: Record<string, number> = {};
for (const m of enriched) byKind[m.kind] = (byKind[m.kind] ?? 0) + 1;
const files = Array.from(new Set(enriched.map((m) => m.file_path))).sort();
return {
matches: enriched,
disambiguation: {
n: enriched.length,
by_kind: byKind,
files,
hint: "Multiple matches. Narrow with --kind <kind>, --in <path>, or --query 'kind:… name:… path:…'.",
},
};
}