@aprimediet/codewalker 1.2.0 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +45 -5
- package/package.json +1 -1
- package/prompts/codewalker.md +8 -1
- package/skills/codewalker/SKILL.md +165 -28
- package/src/analyze/analyzer.test.ts +214 -0
- package/src/analyze/analyzer.ts +290 -0
- package/src/analyze/cards.test.ts +156 -0
- package/src/analyze/cards.ts +110 -0
- package/src/analyze/coverage.test.ts +158 -0
- package/src/analyze/coverage.ts +98 -0
- package/src/analyze/debt.test.ts +111 -0
- package/src/analyze/debt.ts +180 -0
- package/src/analyze/review.test.ts +127 -0
- package/src/analyze/review.ts +127 -0
- package/src/cards.test.ts +123 -1
- package/src/cards.ts +53 -0
- package/src/db.test.ts +484 -8
- package/src/db.ts +398 -2
- package/src/enrich.test.ts +102 -0
- package/src/enrich.ts +107 -0
- package/src/format.test.ts +148 -0
- package/src/format.ts +13 -0
- package/src/index.contract.test.ts +62 -0
- package/src/index.ts +427 -9
- package/src/indexer.heal.test.ts +90 -0
- package/src/indexer.ts +9 -1
- package/src/notes-cards.test.ts +99 -0
- package/src/notes-cards.ts +92 -0
- package/src/notes.test.ts +172 -0
- package/src/notes.ts +151 -0
- package/src/project.test.ts +21 -1
- package/src/project.ts +9 -1
- package/src/query.test.ts +152 -1
- package/src/query.ts +15 -6
- package/src/types.ts +46 -2
package/src/index.ts
CHANGED
|
@@ -8,6 +8,8 @@
|
|
|
8
8
|
* - `/codewalker` command (human-facing) with subcommands scan, sync, query, libs, lib
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
+
import * as fs from "node:fs";
|
|
12
|
+
import * as path from "node:path";
|
|
11
13
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
12
14
|
import { Type } from "typebox";
|
|
13
15
|
import { resolveProject, ensureProject } from "./project.ts";
|
|
@@ -15,21 +17,32 @@ import { runQuery } from "./query.ts";
|
|
|
15
17
|
import { scan, sync } from "./indexer.ts";
|
|
16
18
|
import { indexLibraries } from "./libs/indexer.ts";
|
|
17
19
|
import { formatCompact } from "./format.ts";
|
|
20
|
+
import { openDb, selectUnenrichedSymbols, updateSymbolSummary, searchNotes, upsertNote, upsertFinding, searchFindings, deleteFindingsForFile } from "./db.ts";
|
|
21
|
+
import { updateCardSummary } from "./cards.ts";
|
|
22
|
+
import { addNote } from "./notes.ts";
|
|
23
|
+
import { formatEnrichWorklist, validateEnrichPath, checkEnrichCap } from "./enrich.ts";
|
|
24
|
+
import { runAnalyze, collectSourceFiles } from "./analyze/analyzer.ts";
|
|
25
|
+
import { renderAnalysisCard } from "./analyze/cards.ts";
|
|
26
|
+
import { validateReviewPath, checkReviewCap, selectFilesForReview, formatReviewWorklist } from "./analyze/review.ts";
|
|
27
|
+
import type { NoteKind } from "./types.ts";
|
|
18
28
|
|
|
19
29
|
export default function codewalkerExtension(pi: ExtensionAPI): void {
|
|
20
|
-
// -----------------------------------------------------------------
|
|
30
|
+
// ----------------------------------------------------------------- tools
|
|
31
|
+
|
|
32
|
+
// -- codewalker_query (v1.1 — extended with source='notes'|'all')
|
|
21
33
|
pi.registerTool({
|
|
22
34
|
name: "codewalker_query",
|
|
23
35
|
label: "Codewalker Query",
|
|
24
36
|
description:
|
|
25
37
|
"Search the project's code index for symbols (functions, consts, classes, types). " +
|
|
26
38
|
"Returns compact facts (name, kind, file:line, one-line summary) — use this BEFORE grepping/reading files. " +
|
|
27
|
-
"Optionally search libraries (source='libs') or
|
|
39
|
+
"Optionally search libraries (source='libs') or notes/glossary/decisions (source='notes') or " +
|
|
40
|
+
"analysis findings (source='analysis') or all sources (source='all').",
|
|
28
41
|
parameters: Type.Object({
|
|
29
42
|
query: Type.String({ description: "Search text — symbol name or concept keywords." }),
|
|
30
|
-
kind: Type.Optional(Type.String({ description: "Filter: function|const|class|type|method|enum" })),
|
|
43
|
+
kind: Type.Optional(Type.String({ description: "Filter: function|const|class|type|method|enum|glossary|decision" })),
|
|
31
44
|
limit: Type.Optional(Type.Number({ description: "Max hits (default 10)." })),
|
|
32
|
-
source: Type.Optional(Type.String({ description: "Where to search: code | libs | all (default code)." })),
|
|
45
|
+
source: Type.Optional(Type.String({ description: "Where to search: code | libs | notes | all (default code)." })),
|
|
33
46
|
}),
|
|
34
47
|
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
35
48
|
const p = params as any;
|
|
@@ -40,7 +53,7 @@ export default function codewalkerExtension(pi: ExtensionAPI): void {
|
|
|
40
53
|
query: p.query as string,
|
|
41
54
|
kind: p.kind as string | undefined,
|
|
42
55
|
limit: p.limit as number | undefined,
|
|
43
|
-
source: (p.source as "code" | "libs" | "all") ?? "code",
|
|
56
|
+
source: (p.source as "code" | "libs" | "notes" | "all") ?? "code",
|
|
44
57
|
},
|
|
45
58
|
project.root,
|
|
46
59
|
);
|
|
@@ -53,13 +66,227 @@ export default function codewalkerExtension(pi: ExtensionAPI): void {
|
|
|
53
66
|
},
|
|
54
67
|
});
|
|
55
68
|
|
|
69
|
+
// -- codewalker_enrich (v1.3 — write a semantic summary back to a symbol)
|
|
70
|
+
pi.registerTool({
|
|
71
|
+
name: "codewalker_enrich",
|
|
72
|
+
label: "Codewalker Enrich",
|
|
73
|
+
description:
|
|
74
|
+
"Write a one-line semantic summary back to a symbol's card and DB index. " +
|
|
75
|
+
"Call this AFTER reading the symbol's source span. The summary (≤120 chars) " +
|
|
76
|
+
"is cached so future queries surface meaning, not just names.",
|
|
77
|
+
parameters: Type.Object({
|
|
78
|
+
card: Type.String({ description: "card_path of the symbol (from the enrich worklist)." }),
|
|
79
|
+
summary: Type.String({ description: "One-line (≤120 char) plain-English summary of what it does." }),
|
|
80
|
+
}),
|
|
81
|
+
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
82
|
+
const p = params as any;
|
|
83
|
+
const card = p.card as string;
|
|
84
|
+
const summary = p.summary as string;
|
|
85
|
+
const project = resolveProject(ctx.cwd);
|
|
86
|
+
|
|
87
|
+
// Resolve the card path (may be absolute or relative to codewalker dir)
|
|
88
|
+
let cardPath = card;
|
|
89
|
+
if (!path.isAbsolute(cardPath)) {
|
|
90
|
+
cardPath = path.resolve(project.codewalkerDir, card);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Check the card file exists
|
|
94
|
+
if (!fs.existsSync(cardPath)) {
|
|
95
|
+
return {
|
|
96
|
+
content: [{ type: "text" as const, text: `No card file found at ${cardPath}.` }],
|
|
97
|
+
details: { error: "card_not_found" },
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Read and update the card
|
|
102
|
+
const cardContent = fs.readFileSync(cardPath, "utf-8");
|
|
103
|
+
const updated = updateCardSummary(cardContent, summary);
|
|
104
|
+
|
|
105
|
+
// Atomic write back to the card file
|
|
106
|
+
const tmpPath = cardPath + ".tmp";
|
|
107
|
+
fs.writeFileSync(tmpPath, updated, { encoding: "utf-8", mode: 0o600 });
|
|
108
|
+
fs.renameSync(tmpPath, cardPath);
|
|
109
|
+
|
|
110
|
+
// Update DB
|
|
111
|
+
const db = openDb(project.dbPath);
|
|
112
|
+
let updatedRow = false;
|
|
113
|
+
try {
|
|
114
|
+
updatedRow = updateSymbolSummary(db, cardPath, summary);
|
|
115
|
+
} finally {
|
|
116
|
+
db.close();
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (!updatedRow) {
|
|
120
|
+
return {
|
|
121
|
+
content: [{ type: "text" as const, text: `Card ${path.basename(cardPath)} updated but no matching symbol row found in DB. Run /codewalker scan first.` }],
|
|
122
|
+
details: { card_updated: true, db_updated: false },
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return {
|
|
127
|
+
content: [{ type: "text" as const, text: `Summary written to ${path.basename(cardPath)} and indexed.` }],
|
|
128
|
+
details: { card_updated: true, db_updated: true, card_path: cardPath, summary },
|
|
129
|
+
};
|
|
130
|
+
},
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
// -- codewalker_note (v1.3 — write a glossary term, decision, or convention)
|
|
134
|
+
pi.registerTool({
|
|
135
|
+
name: "codewalker_note",
|
|
136
|
+
label: "Codewalker Note",
|
|
137
|
+
description:
|
|
138
|
+
"Write a glossary term, decision, or convention note. Persists to a markdown card " +
|
|
139
|
+
"under entries/{glossary,decisions,conventions}/ and the FTS index. Future queries " +
|
|
140
|
+
"will surface this conceptual knowledge alongside code symbols.",
|
|
141
|
+
parameters: Type.Object({
|
|
142
|
+
type: Type.String({ description: "glossary | decision | convention" }),
|
|
143
|
+
title: Type.String({ description: "Glossary term, decision title, or convention name." }),
|
|
144
|
+
body: Type.String({ description: "The definition, decision + rationale, or coding convention." }),
|
|
145
|
+
tags: Type.Optional(Type.String({ description: "Comma-separated tags." })),
|
|
146
|
+
related: Type.Optional(Type.String({ description: "Comma-separated symbol names or file:line refs." })),
|
|
147
|
+
}),
|
|
148
|
+
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
149
|
+
const p = params as any;
|
|
150
|
+
const type = (p.type as string).toLowerCase();
|
|
151
|
+
|
|
152
|
+
if (type !== "glossary" && type !== "decision" && type !== "convention") {
|
|
153
|
+
return {
|
|
154
|
+
content: [{ type: "text" as const, text: `Invalid note type "${type}". Must be "glossary", "decision", or "convention".` }],
|
|
155
|
+
details: { error: "invalid_type" },
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const project = await ensureProject(ctx.cwd);
|
|
160
|
+
let notesDir: string;
|
|
161
|
+
if (type === "glossary") notesDir = project.glossaryDir;
|
|
162
|
+
else if (type === "decision") notesDir = project.decisionsDir;
|
|
163
|
+
else notesDir = project.conventionsDir;
|
|
164
|
+
|
|
165
|
+
addNote(project.dbPath, {
|
|
166
|
+
note_kind: type as NoteKind,
|
|
167
|
+
title: (p.title as string).trim(),
|
|
168
|
+
body: (p.body as string).trim(),
|
|
169
|
+
tags: (p.tags as string ?? "").trim(),
|
|
170
|
+
related: (p.related as string ?? "").trim(),
|
|
171
|
+
card_path: "",
|
|
172
|
+
}, notesDir);
|
|
173
|
+
|
|
174
|
+
const kindLabel = type === "glossary" ? "Glossary term" : type === "decision" ? "Decision" : "Convention";
|
|
175
|
+
return {
|
|
176
|
+
content: [{ type: "text" as const, text: `${kindLabel} "${p.title}" saved and indexed.` }],
|
|
177
|
+
details: { type, title: p.title },
|
|
178
|
+
};
|
|
179
|
+
},
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
// -- codewalker_finding (v1.4 — write an analysis finding)
|
|
183
|
+
pi.registerTool({
|
|
184
|
+
name: "codewalker_finding",
|
|
185
|
+
label: "Codewalker Finding",
|
|
186
|
+
description:
|
|
187
|
+
"Write an analysis finding (coverage, debt, or best-practice). " +
|
|
188
|
+
"Persists to a markdown card under entries/analysis/<kind>/ and the FTS index. " +
|
|
189
|
+
"Future queries will surface this finding alongside code symbols. " +
|
|
190
|
+
"Use kind='practice' for agent-driven best-practice findings.",
|
|
191
|
+
parameters: Type.Object({
|
|
192
|
+
kind: Type.String({ description: "coverage | debt | practice" }),
|
|
193
|
+
title: Type.String({ description: "Short finding label." }),
|
|
194
|
+
file: Type.Optional(Type.String({ description: "File or file:line the finding is about." })),
|
|
195
|
+
severity: Type.Optional(Type.String({ description: "info | warn | high (default 'info')." })),
|
|
196
|
+
body: Type.String({ description: "The finding detail + why it matters, grounded in conventions/decisions." }),
|
|
197
|
+
metric: Type.Optional(Type.String({ description: "Optional metric string, e.g. '42%', 'fn length 180'." })),
|
|
198
|
+
related: Type.Optional(Type.String({ description: "Comma-separated symbol names or file:line refs." })),
|
|
199
|
+
}),
|
|
200
|
+
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
201
|
+
const p = params as any;
|
|
202
|
+
const kind = (p.kind as string).toLowerCase();
|
|
203
|
+
|
|
204
|
+
if (!["coverage", "debt", "practice"].includes(kind)) {
|
|
205
|
+
return {
|
|
206
|
+
content: [{ type: "text" as const, text: `Invalid kind "${kind}". Must be coverage, debt, or practice.` }],
|
|
207
|
+
details: { error: "invalid_kind" },
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const validSeverities = ["info", "warn", "high"];
|
|
212
|
+
const severity = (p.severity as string ?? "info").toLowerCase();
|
|
213
|
+
if (!validSeverities.includes(severity)) {
|
|
214
|
+
return {
|
|
215
|
+
content: [{ type: "text" as const, text: `Invalid severity "${severity}". Must be info, warn, or high.` }],
|
|
216
|
+
details: { error: "invalid_severity" },
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Parse file/file:line into file_path, line_start
|
|
221
|
+
let filePath = (p.file as string ?? "").trim();
|
|
222
|
+
let lineStart = 0;
|
|
223
|
+
const locMatch = filePath.match(/^(.+):(\d+)$/);
|
|
224
|
+
if (locMatch) {
|
|
225
|
+
filePath = locMatch[1]!;
|
|
226
|
+
lineStart = parseInt(locMatch[2]!, 10);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const project = await ensureProject(ctx.cwd);
|
|
230
|
+
|
|
231
|
+
const finding = {
|
|
232
|
+
finding_kind: kind as "coverage" | "debt" | "practice",
|
|
233
|
+
title: (p.title as string).trim(),
|
|
234
|
+
severity,
|
|
235
|
+
file_path: filePath,
|
|
236
|
+
line_start: lineStart,
|
|
237
|
+
line_end: lineStart, // when only line_start known
|
|
238
|
+
metric: (p.metric as string) ?? "",
|
|
239
|
+
body: (p.body as string).trim(),
|
|
240
|
+
related: (p.related as string ?? "").trim(),
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
// Render and write card
|
|
244
|
+
const kindDir = path.join(project.analysisDir, kind);
|
|
245
|
+
if (!fs.existsSync(kindDir)) {
|
|
246
|
+
fs.mkdirSync(kindDir, { recursive: true });
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const slug = finding.title
|
|
250
|
+
.toLowerCase()
|
|
251
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
252
|
+
.replace(/^-+|-+$/g, "")
|
|
253
|
+
.slice(0, 80) || "finding";
|
|
254
|
+
const cardPath = path.join(kindDir, `${slug}.md`);
|
|
255
|
+
const card = renderAnalysisCard(finding);
|
|
256
|
+
|
|
257
|
+
const tmpPath = cardPath + ".tmp";
|
|
258
|
+
fs.writeFileSync(tmpPath, card, { encoding: "utf-8", mode: 0o600 });
|
|
259
|
+
fs.renameSync(tmpPath, cardPath);
|
|
260
|
+
|
|
261
|
+
// Upsert DB row
|
|
262
|
+
const db = openDb(project.dbPath);
|
|
263
|
+
try {
|
|
264
|
+
upsertFinding(db, { ...finding, card_path: cardPath });
|
|
265
|
+
} finally {
|
|
266
|
+
db.close();
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
return {
|
|
270
|
+
content: [{ type: "text" as const, text: `Finding "${finding.title}" saved.` }],
|
|
271
|
+
details: { kind, title: finding.title, card_path: cardPath },
|
|
272
|
+
};
|
|
273
|
+
},
|
|
274
|
+
});
|
|
275
|
+
|
|
56
276
|
// ----------------------------------------------------------------- command
|
|
57
277
|
pi.registerCommand("codewalker", {
|
|
58
278
|
description:
|
|
59
|
-
"codewalker: scan | sync | query <text> | libs [--dev] | lib <pkg> [query] | help\n" +
|
|
279
|
+
"codewalker: scan | sync | query <text> | enrich <path> [--max=N] | analyze [path] | review <path> [--max=N] | findings [query] [--kind=KIND] | conventions [query] | glossary [query] | decisions [query] | libs [--dev] | lib <pkg> [query] | help\n" +
|
|
60
280
|
" scan Full (re)build of the code index\n" +
|
|
61
281
|
" sync Git-anchored incremental update\n" +
|
|
62
|
-
" query <text> Search the code index
|
|
282
|
+
" query <text> Search the code index (pass source=notes to include glossary/decisions)\n" +
|
|
283
|
+
" enrich <path> Select unenriched symbols under <path> and write summaries\n" +
|
|
284
|
+
" analyze [path] Mechanical coverage + debt analysis (reads lcov.info/coverage-final.json if present)\n" +
|
|
285
|
+
" review <path> Agent-driven best-practice review against conventions/decisions (capped at 25 files)\n" +
|
|
286
|
+
" findings [query] Search analysis findings (--kind=coverage|debt|practice to filter)\n" +
|
|
287
|
+
" conventions [q] Search coding conventions\n" +
|
|
288
|
+
" glossary [query] Search glossary terms\n" +
|
|
289
|
+
" decisions [query] Search decision notes\n" +
|
|
63
290
|
" libs [--dev] Index all direct dependencies (--dev includes devDependencies)\n" +
|
|
64
291
|
" lib <pkg> [query] Search a specific library's API symbols\n" +
|
|
65
292
|
" help Show this help",
|
|
@@ -113,6 +340,87 @@ export default function codewalkerExtension(pi: ExtensionAPI): void {
|
|
|
113
340
|
break;
|
|
114
341
|
}
|
|
115
342
|
|
|
343
|
+
// ── v1.3: enrich ───────────────────────────────────
|
|
344
|
+
case "enrich": {
|
|
345
|
+
const enrichPath = tokens[1];
|
|
346
|
+
const pathCheck = validateEnrichPath(enrichPath);
|
|
347
|
+
if (!pathCheck.valid) {
|
|
348
|
+
notify(pathCheck.error!, "error");
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// Parse optional --max=N
|
|
353
|
+
const maxToken = tokens.find(t => t.startsWith("--max="));
|
|
354
|
+
const cap = maxToken ? parseInt(maxToken.slice(6), 10) : 40;
|
|
355
|
+
|
|
356
|
+
// Select unenriched symbols
|
|
357
|
+
const db = openDb(project.dbPath);
|
|
358
|
+
let symbols;
|
|
359
|
+
try {
|
|
360
|
+
symbols = selectUnenrichedSymbols(db, enrichPath!, cap + 1); // get one extra to detect overflow
|
|
361
|
+
} finally {
|
|
362
|
+
db.close();
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// Cap check
|
|
366
|
+
const capCheck = checkEnrichCap(symbols.length, cap);
|
|
367
|
+
if (!capCheck.ok) {
|
|
368
|
+
notify(capCheck.error!, "error");
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
if (symbols.length === 0) {
|
|
373
|
+
notify(`No unenriched symbols found under "${enrichPath}".`);
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// Format the worklist
|
|
378
|
+
const worklist = formatEnrichWorklist(symbols, enrichPath!);
|
|
379
|
+
|
|
380
|
+
// With UI, drive the agent; without UI, print the worklist
|
|
381
|
+
if ((ctx as any).hasUI) {
|
|
382
|
+
notify(worklist);
|
|
383
|
+
try {
|
|
384
|
+
(ctx as any).sendUserMessage?.(worklist);
|
|
385
|
+
} catch {
|
|
386
|
+
// sendUserMessage may not be available in all contexts
|
|
387
|
+
}
|
|
388
|
+
} else {
|
|
389
|
+
console.log(worklist);
|
|
390
|
+
}
|
|
391
|
+
break;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// ── v1.3: glossary ─────────────────────────────────
|
|
395
|
+
case "glossary": {
|
|
396
|
+
const q = tokens.slice(1).join(" ");
|
|
397
|
+
const db = openDb(project.dbPath);
|
|
398
|
+
let rows;
|
|
399
|
+
try {
|
|
400
|
+
rows = searchNotes(db, q || "", "glossary", 20);
|
|
401
|
+
} finally {
|
|
402
|
+
db.close();
|
|
403
|
+
}
|
|
404
|
+
const text = formatCompact(rows as any, null);
|
|
405
|
+
notify(text || "No glossary terms found.");
|
|
406
|
+
break;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// ── v1.3: decisions ────────────────────────────────
|
|
410
|
+
case "decisions": {
|
|
411
|
+
const q = tokens.slice(1).join(" ");
|
|
412
|
+
const db = openDb(project.dbPath);
|
|
413
|
+
let rows;
|
|
414
|
+
try {
|
|
415
|
+
rows = searchNotes(db, q || "", "decision", 20);
|
|
416
|
+
} finally {
|
|
417
|
+
db.close();
|
|
418
|
+
}
|
|
419
|
+
const text = formatCompact(rows as any, null);
|
|
420
|
+
notify(text || "No decision notes found.");
|
|
421
|
+
break;
|
|
422
|
+
}
|
|
423
|
+
|
|
116
424
|
case "libs": {
|
|
117
425
|
const includeDev = tokens.includes("--dev");
|
|
118
426
|
notify(`Indexing libraries${includeDev ? " (including devDependencies)" : ""}…`);
|
|
@@ -153,12 +461,122 @@ export default function codewalkerExtension(pi: ExtensionAPI): void {
|
|
|
153
461
|
break;
|
|
154
462
|
}
|
|
155
463
|
|
|
464
|
+
// ── v1.4: analyze ──────────────────────────────────
|
|
465
|
+
case "analyze": {
|
|
466
|
+
const analyzePath = tokens[1] ?? project.root;
|
|
467
|
+
notify(`Running analysis${analyzePath !== project.root ? ` on ${analyzePath}` : ""}…`);
|
|
468
|
+
const result = runAnalyze({
|
|
469
|
+
projectRoot: project.root,
|
|
470
|
+
analysisDir: project.analysisDir,
|
|
471
|
+
dbPath: project.dbPath,
|
|
472
|
+
pathFilter: analyzePath !== project.root ? analyzePath : undefined,
|
|
473
|
+
});
|
|
474
|
+
const parts: string[] = [];
|
|
475
|
+
if (result.coverage > 0) parts.push(`${result.coverage} coverage`);
|
|
476
|
+
else parts.push("no coverage data (run your coverage tool first)");
|
|
477
|
+
if (result.debt > 0) parts.push(`${result.debt} debt`);
|
|
478
|
+
else parts.push("no debt");
|
|
479
|
+
notify(`Analysis complete: ${parts.join(", ")} finding(s).`);
|
|
480
|
+
break;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
// ── v1.4: review ───────────────────────────────────
|
|
484
|
+
case "review": {
|
|
485
|
+
const reviewPath = tokens[1];
|
|
486
|
+
const pathCheck = validateReviewPath(reviewPath);
|
|
487
|
+
if (!pathCheck.valid) {
|
|
488
|
+
notify(pathCheck.error!, "error");
|
|
489
|
+
return;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
// Parse optional --max=N
|
|
493
|
+
const maxToken = tokens.find(t => t.startsWith("--max="));
|
|
494
|
+
const cap = maxToken ? parseInt(maxToken.slice(6), 10) : 25;
|
|
495
|
+
|
|
496
|
+
// Walk source files under the review path
|
|
497
|
+
const allFiles = collectSourceFiles(project.root, reviewPath);
|
|
498
|
+
|
|
499
|
+
// Cap check
|
|
500
|
+
const capCheck = checkReviewCap(allFiles.length, cap);
|
|
501
|
+
if (!capCheck.ok) {
|
|
502
|
+
notify(capCheck.error!, "error");
|
|
503
|
+
return;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
// Select files (respect cap)
|
|
507
|
+
const selectedFiles = selectFilesForReview(allFiles, reviewPath!, cap);
|
|
508
|
+
|
|
509
|
+
if (selectedFiles.length === 0) {
|
|
510
|
+
notify(`No source files found under "${reviewPath}".`);
|
|
511
|
+
return;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
// Format the worklist
|
|
515
|
+
const worklist = formatReviewWorklist(selectedFiles, reviewPath!);
|
|
516
|
+
|
|
517
|
+
// With UI, drive the agent; without UI, print the worklist
|
|
518
|
+
if ((ctx as any).hasUI) {
|
|
519
|
+
notify(worklist);
|
|
520
|
+
try {
|
|
521
|
+
(ctx as any).sendUserMessage?.(worklist);
|
|
522
|
+
} catch {
|
|
523
|
+
// sendUserMessage may not be available in all contexts
|
|
524
|
+
}
|
|
525
|
+
} else {
|
|
526
|
+
console.log(worklist);
|
|
527
|
+
}
|
|
528
|
+
break;
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
// ── v1.4: findings ─────────────────────────────────
|
|
532
|
+
case "findings": {
|
|
533
|
+
const q = tokens.slice(1).join(" ");
|
|
534
|
+
// Parse optional --kind=
|
|
535
|
+
const kindToken = tokens.find(t => t.startsWith("--kind="));
|
|
536
|
+
const kindFilter = kindToken ? kindToken.slice(7) : undefined;
|
|
537
|
+
// Strip --kind from the query
|
|
538
|
+
const cleanQuery = tokens.filter(t => !t.startsWith("--")).slice(1).join(" ");
|
|
539
|
+
|
|
540
|
+
const db = openDb(project.dbPath);
|
|
541
|
+
let rows;
|
|
542
|
+
try {
|
|
543
|
+
rows = searchFindings(db, cleanQuery, kindFilter, 20);
|
|
544
|
+
} finally {
|
|
545
|
+
db.close();
|
|
546
|
+
}
|
|
547
|
+
const text = formatCompact(rows as any, null);
|
|
548
|
+
notify(text || "No findings found.");
|
|
549
|
+
break;
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
// ── v1.4: conventions ──────────────────────────────
|
|
553
|
+
case "conventions": {
|
|
554
|
+
const q = tokens.slice(1).join(" ");
|
|
555
|
+
const db = openDb(project.dbPath);
|
|
556
|
+
let rows;
|
|
557
|
+
try {
|
|
558
|
+
rows = searchNotes(db, q || "", "convention", 20);
|
|
559
|
+
} finally {
|
|
560
|
+
db.close();
|
|
561
|
+
}
|
|
562
|
+
const text = formatCompact(rows as any, null);
|
|
563
|
+
notify(text || "No conventions found.");
|
|
564
|
+
break;
|
|
565
|
+
}
|
|
566
|
+
|
|
156
567
|
default: {
|
|
157
568
|
notify(
|
|
158
|
-
"codewalker: scan | sync | query <text> | libs [--dev] | lib <pkg> [query] | help\n" +
|
|
569
|
+
"codewalker: scan | sync | query <text> | enrich <path> [--max=N] | analyze [path] | review <path> [--max=N] | findings [query] [--kind=KIND] | conventions [query] | glossary [query] | decisions [query] | libs [--dev] | lib <pkg> [query] | help\n" +
|
|
159
570
|
" scan Full (re)build of the code index\n" +
|
|
160
571
|
" sync Git-anchored incremental update\n" +
|
|
161
|
-
" query <text> Search the code index
|
|
572
|
+
" query <text> Search the code index (pass source=notes to include glossary/decisions)\n" +
|
|
573
|
+
" enrich <path> Select unenriched symbols under <path> and write summaries\n" +
|
|
574
|
+
" analyze [path] Mechanical coverage + debt analysis (reads lcov.info/coverage-final.json if present)\n" +
|
|
575
|
+
" review <path> Agent-driven best-practice review against conventions/decisions (capped at 25 files)\n" +
|
|
576
|
+
" findings [query] Search analysis findings (--kind=coverage|debt|practice to filter)\n" +
|
|
577
|
+
" conventions [q] Search coding conventions\n" +
|
|
578
|
+
" glossary [query] Search glossary terms\n" +
|
|
579
|
+
" decisions [query] Search decision notes\n" +
|
|
162
580
|
" libs [--dev] Index all direct dependencies (--dev includes devDependencies)\n" +
|
|
163
581
|
" lib <pkg> [query] Search a specific library's API symbols\n" +
|
|
164
582
|
" help Show this help",
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Regression test for the "database disk image is malformed" crash on `/codewalker scan`.
|
|
3
|
+
*
|
|
4
|
+
* A DB written by an older (pre-trigger, manual-FTS-sync) build can have a `symbols_fts`
|
|
5
|
+
* external-content index that is silently out of sync with the `symbols` table. v1.3's bootstrap
|
|
6
|
+
* adds the FTS-sync triggers, but those don't reconcile the already-stale index — so the per-row
|
|
7
|
+
* DELETEs in scan() fire `symbols_ad` 'delete' commands against mismatched `old.*` values and
|
|
8
|
+
* corrupt the index. scan()/sync() guard against this by calling rebuildFtsIndexes() first, which
|
|
9
|
+
* re-derives every `*_fts` from its content table (the FTS5 'rebuild' command).
|
|
10
|
+
*
|
|
11
|
+
* The exact on-disk corruption is b-tree-state dependent and not portably synthesizable, so this
|
|
12
|
+
* test pins the *fix mechanism*: rebuildFtsIndexes() reconciles a deliberately mismatched FTS.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { describe, it, expect } from "vitest";
|
|
16
|
+
import * as fs from "node:fs";
|
|
17
|
+
import * as os from "node:os";
|
|
18
|
+
import * as path from "node:path";
|
|
19
|
+
import { openDb, rebuildFtsIndexes } from "./db.ts";
|
|
20
|
+
import { scan } from "./indexer.ts";
|
|
21
|
+
|
|
22
|
+
function tmpDir(): string {
|
|
23
|
+
return fs.mkdtempSync(path.join(os.tmpdir(), "cw-heal-"));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function matchCount(db: ReturnType<typeof openDb>, table: string, term: string): number {
|
|
27
|
+
return (db.prepare(`SELECT count(*) c FROM ${table} WHERE ${table} MATCH ?`).get(term) as { c: number }).c;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
describe("rebuildFtsIndexes heals a stale/mismatched FTS index", () => {
|
|
31
|
+
it("makes content searchable and drops stale tokens for all three FTS tables", () => {
|
|
32
|
+
const dir = tmpDir();
|
|
33
|
+
const db = openDb(path.join(dir, "index.db"));
|
|
34
|
+
|
|
35
|
+
// Seed base-table rows directly via FTS-bypassing inserts would still fire triggers, so to
|
|
36
|
+
// simulate a *stale* index we insert the base row, then overwrite the FTS shadow with wrong
|
|
37
|
+
// tokens (as a pre-trigger build would have left it).
|
|
38
|
+
db.prepare(
|
|
39
|
+
"INSERT INTO symbols (name,kind,file_path,line_start,line_end,signature,doc,summary,card_path) VALUES (?,?,?,?,?,?,?,?,?)",
|
|
40
|
+
).run("realSymbol", "function", "/a.ts", 1, 2, "sig", "doc", "", "/c.md");
|
|
41
|
+
db.prepare(
|
|
42
|
+
"INSERT INTO lib_symbols (lib,version,name,kind,signature,doc,summary,card_path) VALUES (?,?,?,?,?,?,?,?)",
|
|
43
|
+
).run("hono", "1.0.0", "realLibSymbol", "function", "sig", "doc", "", "/c.md");
|
|
44
|
+
db.prepare(
|
|
45
|
+
"INSERT INTO notes (note_kind,title,body,tags,related,card_path,created_at) VALUES (?,?,?,?,?,?,?)",
|
|
46
|
+
).run("glossary", "realTerm", "body", "", "", "/c.md", "now");
|
|
47
|
+
|
|
48
|
+
// Corrupt the shadow indexes: replace the synced tokens with bogus ones.
|
|
49
|
+
db.exec("INSERT INTO symbols_fts(symbols_fts) VALUES('delete-all')");
|
|
50
|
+
db.exec("INSERT INTO lib_symbols_fts(lib_symbols_fts) VALUES('delete-all')");
|
|
51
|
+
db.exec("INSERT INTO notes_fts(notes_fts) VALUES('delete-all')");
|
|
52
|
+
db.prepare("INSERT INTO symbols_fts(rowid,name,signature,doc,summary) VALUES (1,?,?,?,?)").run("WRONG", "x", "y", "z");
|
|
53
|
+
db.prepare("INSERT INTO lib_symbols_fts(rowid,name,signature,doc,summary) VALUES (1,?,?,?,?)").run("WRONG", "x", "y", "z");
|
|
54
|
+
db.prepare("INSERT INTO notes_fts(rowid,title,body,tags) VALUES (1,?,?,?)").run("WRONG", "y", "z");
|
|
55
|
+
|
|
56
|
+
// Stale precondition: real content not findable, bogus token is.
|
|
57
|
+
expect(matchCount(db, "symbols_fts", "realSymbol")).toBe(0);
|
|
58
|
+
expect(matchCount(db, "symbols_fts", "WRONG")).toBe(1);
|
|
59
|
+
|
|
60
|
+
rebuildFtsIndexes(db);
|
|
61
|
+
|
|
62
|
+
// After heal: real content searchable, bogus tokens gone — across all three tables.
|
|
63
|
+
expect(matchCount(db, "symbols_fts", "realSymbol")).toBe(1);
|
|
64
|
+
expect(matchCount(db, "symbols_fts", "WRONG")).toBe(0);
|
|
65
|
+
expect(matchCount(db, "lib_symbols_fts", "realLibSymbol")).toBe(1);
|
|
66
|
+
expect(matchCount(db, "lib_symbols_fts", "WRONG")).toBe(0);
|
|
67
|
+
expect(matchCount(db, "notes_fts", "realTerm")).toBe(1);
|
|
68
|
+
expect(matchCount(db, "notes_fts", "WRONG")).toBe(0);
|
|
69
|
+
|
|
70
|
+
db.close();
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("scan() runs the heal and leaves symbols_fts consistent with symbols", async () => {
|
|
74
|
+
const dir = tmpDir();
|
|
75
|
+
const dbPath = path.join(dir, "index.db");
|
|
76
|
+
const entriesDir = path.join(dir, "entries");
|
|
77
|
+
const symbolsDir = path.join(entriesDir, "symbols");
|
|
78
|
+
fs.mkdirSync(symbolsDir, { recursive: true });
|
|
79
|
+
|
|
80
|
+
await scan({ projectRoot: process.cwd(), globalCodewalkerDir: dir, dbPath, entriesDir, symbolsDir });
|
|
81
|
+
|
|
82
|
+
const db = openDb(dbPath);
|
|
83
|
+
const symbols = (db.prepare("SELECT count(*) c FROM symbols").get() as { c: number }).c;
|
|
84
|
+
const fts = (db.prepare("SELECT count(*) c FROM symbols_fts").get() as { c: number }).c;
|
|
85
|
+
db.close();
|
|
86
|
+
|
|
87
|
+
expect(symbols).toBeGreaterThan(0);
|
|
88
|
+
expect(fts).toBe(symbols);
|
|
89
|
+
});
|
|
90
|
+
});
|
package/src/indexer.ts
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
import * as fs from "node:fs";
|
|
10
10
|
import * as path from "node:path";
|
|
11
|
-
import { openDb, upsertSymbol, deleteFileSymbols, deleteFile, setMeta, getMeta } from "./db.ts";
|
|
11
|
+
import { openDb, upsertSymbol, deleteFileSymbols, deleteFile, setMeta, getMeta, rebuildFtsIndexes } from "./db.ts";
|
|
12
12
|
import { detectCtags, runCtags, runCtagsOnFile } from "./extract/ctags.ts";
|
|
13
13
|
import { parseCtagsOutput } from "./extract/ctags-parse.ts";
|
|
14
14
|
import { extractRegex } from "./extract/regex.ts";
|
|
@@ -83,6 +83,11 @@ export async function scan(options: ScanOptions): Promise<void> {
|
|
|
83
83
|
const db = openDb(dbPath);
|
|
84
84
|
|
|
85
85
|
try {
|
|
86
|
+
// Heal any stale/legacy FTS index before the per-row deletes below fire their triggers.
|
|
87
|
+
// Without this, a DB from an older build can corrupt mid-scan ("database disk image is
|
|
88
|
+
// malformed"). See rebuildFtsIndexes() for the full rationale.
|
|
89
|
+
rebuildFtsIndexes(db);
|
|
90
|
+
|
|
86
91
|
// Start transaction
|
|
87
92
|
db.exec("BEGIN TRANSACTION");
|
|
88
93
|
|
|
@@ -150,6 +155,9 @@ export async function sync(options: ScanOptions): Promise<void> {
|
|
|
150
155
|
const db = openDb(dbPath);
|
|
151
156
|
|
|
152
157
|
try {
|
|
158
|
+
// Heal a stale/legacy FTS index before any trigger-driven deletes (see rebuildFtsIndexes).
|
|
159
|
+
rebuildFtsIndexes(db);
|
|
160
|
+
|
|
153
161
|
const lastCommit = getMeta(db, "last_indexed_commit");
|
|
154
162
|
let changedFiles: string[] = [];
|
|
155
163
|
|