@aprimediet/codewalker 1.3.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 +19 -4
- package/package.json +1 -1
- package/prompts/codewalker.md +6 -1
- package/skills/codewalker/SKILL.md +54 -7
- 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/db.test.ts +223 -3
- package/src/db.ts +191 -1
- package/src/format.test.ts +97 -0
- package/src/format.ts +8 -0
- package/src/index.contract.test.ts +31 -0
- package/src/index.ts +227 -14
- package/src/notes-cards.ts +1 -1
- package/src/notes.ts +6 -0
- package/src/project.test.ts +9 -0
- package/src/project.ts +5 -1
- package/src/query.test.ts +76 -1
- package/src/query.ts +11 -6
- package/src/types.ts +31 -3
package/src/index.ts
CHANGED
|
@@ -17,10 +17,13 @@ import { runQuery } from "./query.ts";
|
|
|
17
17
|
import { scan, sync } from "./indexer.ts";
|
|
18
18
|
import { indexLibraries } from "./libs/indexer.ts";
|
|
19
19
|
import { formatCompact } from "./format.ts";
|
|
20
|
-
import { openDb, selectUnenrichedSymbols, updateSymbolSummary, searchNotes, upsertNote } from "./db.ts";
|
|
20
|
+
import { openDb, selectUnenrichedSymbols, updateSymbolSummary, searchNotes, upsertNote, upsertFinding, searchFindings, deleteFindingsForFile } from "./db.ts";
|
|
21
21
|
import { updateCardSummary } from "./cards.ts";
|
|
22
22
|
import { addNote } from "./notes.ts";
|
|
23
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";
|
|
24
27
|
import type { NoteKind } from "./types.ts";
|
|
25
28
|
|
|
26
29
|
export default function codewalkerExtension(pi: ExtensionAPI): void {
|
|
@@ -33,7 +36,8 @@ export default function codewalkerExtension(pi: ExtensionAPI): void {
|
|
|
33
36
|
description:
|
|
34
37
|
"Search the project's code index for symbols (functions, consts, classes, types). " +
|
|
35
38
|
"Returns compact facts (name, kind, file:line, one-line summary) — use this BEFORE grepping/reading files. " +
|
|
36
|
-
"Optionally search libraries (source='libs') or notes/glossary/decisions (source='notes') or
|
|
39
|
+
"Optionally search libraries (source='libs') or notes/glossary/decisions (source='notes') or " +
|
|
40
|
+
"analysis findings (source='analysis') or all sources (source='all').",
|
|
37
41
|
parameters: Type.Object({
|
|
38
42
|
query: Type.String({ description: "Search text — symbol name or concept keywords." }),
|
|
39
43
|
kind: Type.Optional(Type.String({ description: "Filter: function|const|class|type|method|enum|glossary|decision" })),
|
|
@@ -126,18 +130,18 @@ export default function codewalkerExtension(pi: ExtensionAPI): void {
|
|
|
126
130
|
},
|
|
127
131
|
});
|
|
128
132
|
|
|
129
|
-
// -- codewalker_note (v1.3 — write a glossary term or
|
|
133
|
+
// -- codewalker_note (v1.3 — write a glossary term, decision, or convention)
|
|
130
134
|
pi.registerTool({
|
|
131
135
|
name: "codewalker_note",
|
|
132
136
|
label: "Codewalker Note",
|
|
133
137
|
description:
|
|
134
|
-
"Write a glossary term or
|
|
135
|
-
"under entries/{glossary,decisions}/ and the FTS index. Future queries " +
|
|
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 " +
|
|
136
140
|
"will surface this conceptual knowledge alongside code symbols.",
|
|
137
141
|
parameters: Type.Object({
|
|
138
|
-
type: Type.String({ description: "glossary | decision" }),
|
|
139
|
-
title: Type.String({ description: "Glossary term, or
|
|
140
|
-
body: Type.String({ description: "The definition,
|
|
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." }),
|
|
141
145
|
tags: Type.Optional(Type.String({ description: "Comma-separated tags." })),
|
|
142
146
|
related: Type.Optional(Type.String({ description: "Comma-separated symbol names or file:line refs." })),
|
|
143
147
|
}),
|
|
@@ -145,15 +149,18 @@ export default function codewalkerExtension(pi: ExtensionAPI): void {
|
|
|
145
149
|
const p = params as any;
|
|
146
150
|
const type = (p.type as string).toLowerCase();
|
|
147
151
|
|
|
148
|
-
if (type !== "glossary" && type !== "decision") {
|
|
152
|
+
if (type !== "glossary" && type !== "decision" && type !== "convention") {
|
|
149
153
|
return {
|
|
150
|
-
content: [{ type: "text" as const, text: `Invalid note type "${type}". Must be "glossary" or "
|
|
154
|
+
content: [{ type: "text" as const, text: `Invalid note type "${type}". Must be "glossary", "decision", or "convention".` }],
|
|
151
155
|
details: { error: "invalid_type" },
|
|
152
156
|
};
|
|
153
157
|
}
|
|
154
158
|
|
|
155
159
|
const project = await ensureProject(ctx.cwd);
|
|
156
|
-
|
|
160
|
+
let notesDir: string;
|
|
161
|
+
if (type === "glossary") notesDir = project.glossaryDir;
|
|
162
|
+
else if (type === "decision") notesDir = project.decisionsDir;
|
|
163
|
+
else notesDir = project.conventionsDir;
|
|
157
164
|
|
|
158
165
|
addNote(project.dbPath, {
|
|
159
166
|
note_kind: type as NoteKind,
|
|
@@ -164,21 +171,120 @@ export default function codewalkerExtension(pi: ExtensionAPI): void {
|
|
|
164
171
|
card_path: "",
|
|
165
172
|
}, notesDir);
|
|
166
173
|
|
|
174
|
+
const kindLabel = type === "glossary" ? "Glossary term" : type === "decision" ? "Decision" : "Convention";
|
|
167
175
|
return {
|
|
168
|
-
content: [{ type: "text" as const, text: `${
|
|
176
|
+
content: [{ type: "text" as const, text: `${kindLabel} "${p.title}" saved and indexed.` }],
|
|
169
177
|
details: { type, title: p.title },
|
|
170
178
|
};
|
|
171
179
|
},
|
|
172
180
|
});
|
|
173
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
|
+
|
|
174
276
|
// ----------------------------------------------------------------- command
|
|
175
277
|
pi.registerCommand("codewalker", {
|
|
176
278
|
description:
|
|
177
|
-
"codewalker: scan | sync | query <text> | enrich <path> [--max=N] | glossary [query] | decisions [query] | 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" +
|
|
178
280
|
" scan Full (re)build of the code index\n" +
|
|
179
281
|
" sync Git-anchored incremental update\n" +
|
|
180
282
|
" query <text> Search the code index (pass source=notes to include glossary/decisions)\n" +
|
|
181
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" +
|
|
182
288
|
" glossary [query] Search glossary terms\n" +
|
|
183
289
|
" decisions [query] Search decision notes\n" +
|
|
184
290
|
" libs [--dev] Index all direct dependencies (--dev includes devDependencies)\n" +
|
|
@@ -355,13 +461,120 @@ export default function codewalkerExtension(pi: ExtensionAPI): void {
|
|
|
355
461
|
break;
|
|
356
462
|
}
|
|
357
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
|
+
|
|
358
567
|
default: {
|
|
359
568
|
notify(
|
|
360
|
-
"codewalker: scan | sync | query <text> | enrich <path> [--max=N] | glossary [query] | decisions [query] | 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" +
|
|
361
570
|
" scan Full (re)build of the code index\n" +
|
|
362
571
|
" sync Git-anchored incremental update\n" +
|
|
363
572
|
" query <text> Search the code index (pass source=notes to include glossary/decisions)\n" +
|
|
364
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" +
|
|
365
578
|
" glossary [query] Search glossary terms\n" +
|
|
366
579
|
" decisions [query] Search decision notes\n" +
|
|
367
580
|
" libs [--dev] Index all direct dependencies (--dev includes devDependencies)\n" +
|
package/src/notes-cards.ts
CHANGED
|
@@ -73,7 +73,7 @@ export function parseNoteCard(text: string): {
|
|
|
73
73
|
}
|
|
74
74
|
|
|
75
75
|
const noteKind = fm["note_kind"];
|
|
76
|
-
if (noteKind !== "glossary" && noteKind !== "decision") return null;
|
|
76
|
+
if (noteKind !== "glossary" && noteKind !== "decision" && noteKind !== "convention") return null;
|
|
77
77
|
if (!fm["title"]) return null;
|
|
78
78
|
|
|
79
79
|
return {
|
package/src/notes.ts
CHANGED
|
@@ -64,6 +64,7 @@ export function rebuildNotesDbFromCards(
|
|
|
64
64
|
dbPath: string,
|
|
65
65
|
glossaryDir: string,
|
|
66
66
|
decisionsDir: string,
|
|
67
|
+
conventionsDir?: string,
|
|
67
68
|
): void {
|
|
68
69
|
const db = openDb(dbPath);
|
|
69
70
|
|
|
@@ -83,6 +84,11 @@ export function rebuildNotesDbFromCards(
|
|
|
83
84
|
processCardsInDir(db, decisionsDir, "decision");
|
|
84
85
|
}
|
|
85
86
|
|
|
87
|
+
// Process conventions cards (v1.4)
|
|
88
|
+
if (conventionsDir && fs.existsSync(conventionsDir)) {
|
|
89
|
+
processCardsInDir(db, conventionsDir, "convention");
|
|
90
|
+
}
|
|
91
|
+
|
|
86
92
|
db.exec("COMMIT");
|
|
87
93
|
} catch (e) {
|
|
88
94
|
db.exec("ROLLBACK");
|
package/src/project.test.ts
CHANGED
|
@@ -89,6 +89,13 @@ describe('project.ts', () => {
|
|
|
89
89
|
expect(p.glossaryDir).toBe(path.join(p.codewalkerDir, 'entries', 'glossary'));
|
|
90
90
|
expect(p.decisionsDir).toBe(path.join(p.codewalkerDir, 'entries', 'decisions'));
|
|
91
91
|
});
|
|
92
|
+
|
|
93
|
+
it('exposes analysisDir and conventionsDir paths', async () => {
|
|
94
|
+
const mod = await import('./project.ts');
|
|
95
|
+
const p = mod.resolveProject(tmpDir);
|
|
96
|
+
expect(p.analysisDir).toBe(path.join(p.codewalkerDir, 'entries', 'analysis'));
|
|
97
|
+
expect(p.conventionsDir).toBe(path.join(p.codewalkerDir, 'entries', 'conventions'));
|
|
98
|
+
});
|
|
92
99
|
});
|
|
93
100
|
|
|
94
101
|
describe('ensureProject', () => {
|
|
@@ -104,6 +111,8 @@ describe('project.ts', () => {
|
|
|
104
111
|
expect(fs.existsSync(p.libsDir)).toBe(true);
|
|
105
112
|
expect(fs.existsSync(p.glossaryDir)).toBe(true);
|
|
106
113
|
expect(fs.existsSync(p.decisionsDir)).toBe(true);
|
|
114
|
+
expect(fs.existsSync(p.analysisDir)).toBe(true);
|
|
115
|
+
expect(fs.existsSync(p.conventionsDir)).toBe(true);
|
|
107
116
|
// meta.json written
|
|
108
117
|
expect(fs.existsSync(p.metaFile)).toBe(true);
|
|
109
118
|
// meta.json has correct shape
|
package/src/project.ts
CHANGED
|
@@ -33,6 +33,8 @@ export interface ProjectPaths {
|
|
|
33
33
|
libsDir: string;
|
|
34
34
|
glossaryDir: string;
|
|
35
35
|
decisionsDir: string;
|
|
36
|
+
analysisDir: string;
|
|
37
|
+
conventionsDir: string;
|
|
36
38
|
}
|
|
37
39
|
|
|
38
40
|
function piHome(): string {
|
|
@@ -129,6 +131,8 @@ function pathsForId(id: string, root: string, configDir: string, markerPath: str
|
|
|
129
131
|
libsDir: path.join(globalDir, "codewalker", "entries", "libs"),
|
|
130
132
|
glossaryDir: path.join(globalDir, "codewalker", "entries", "glossary"),
|
|
131
133
|
decisionsDir: path.join(globalDir, "codewalker", "entries", "decisions"),
|
|
134
|
+
analysisDir: path.join(globalDir, "codewalker", "entries", "analysis"),
|
|
135
|
+
conventionsDir: path.join(globalDir, "codewalker", "entries", "conventions"),
|
|
132
136
|
};
|
|
133
137
|
}
|
|
134
138
|
|
|
@@ -170,7 +174,7 @@ export async function ensureProject(cwd: string): Promise<ProjectPaths> {
|
|
|
170
174
|
const p = resolveProject(cwd);
|
|
171
175
|
const nowISO = new Date().toISOString();
|
|
172
176
|
|
|
173
|
-
for (const dir of [p.codewalkerDir, p.entriesDir, p.symbolsDir, p.libsDir, p.glossaryDir, p.decisionsDir]) {
|
|
177
|
+
for (const dir of [p.codewalkerDir, p.entriesDir, p.symbolsDir, p.libsDir, p.glossaryDir, p.decisionsDir, p.analysisDir, p.conventionsDir]) {
|
|
174
178
|
fs.mkdirSync(dir, { recursive: true });
|
|
175
179
|
}
|
|
176
180
|
|
package/src/query.test.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
|
2
2
|
import * as fs from 'node:fs';
|
|
3
3
|
import * as path from 'node:path';
|
|
4
4
|
import * as os from 'node:os';
|
|
5
|
-
import { openDb, upsertSymbol, setMeta, getMeta, upsertLibSymbol, upsertNote, searchLibSymbols } from './db.ts';
|
|
5
|
+
import { openDb, upsertSymbol, setMeta, getMeta, upsertLibSymbol, upsertNote, upsertFinding, searchLibSymbols } from './db.ts';
|
|
6
6
|
import { runQuery } from './query.ts';
|
|
7
7
|
|
|
8
8
|
describe('query.ts', () => {
|
|
@@ -242,4 +242,79 @@ describe('query.ts', () => {
|
|
|
242
242
|
expect(sources).toContain('note');
|
|
243
243
|
});
|
|
244
244
|
|
|
245
|
+
// ── analysis source ────────────────────────────────────────
|
|
246
|
+
it('source="analysis" returns only findings', () => {
|
|
247
|
+
const db = openDb(dbPath);
|
|
248
|
+
upsertFinding(db, {
|
|
249
|
+
finding_kind: 'debt', title: 'TODO: fix', severity: 'info',
|
|
250
|
+
file_path: 'src/a.ts', line_start: 1, line_end: 1,
|
|
251
|
+
metric: 'TODO', body: 'Fix this', related: '', card_path: '',
|
|
252
|
+
});
|
|
253
|
+
upsertSymbol(db, {
|
|
254
|
+
name: 'myFunc', kind: 'function', file_path: 'src/a.ts',
|
|
255
|
+
line_start: 1, line_end: 1, signature: '', doc: '', summary: '', card_path: '',
|
|
256
|
+
});
|
|
257
|
+
db.close();
|
|
258
|
+
|
|
259
|
+
const result = runQuery(dbPath, { query: '', source: 'analysis' });
|
|
260
|
+
expect(result.rows).toHaveLength(1);
|
|
261
|
+
expect(result.rows[0]!.source).toBe('analysis');
|
|
262
|
+
expect(result.rows[0]!.finding_kind).toBe('debt');
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it('source="all" includes analysis findings interleaved with code, libs, notes', () => {
|
|
266
|
+
const db = openDb(dbPath);
|
|
267
|
+
upsertSymbol(db, {
|
|
268
|
+
name: 'myFunc', kind: 'function', file_path: 'src/a.ts',
|
|
269
|
+
line_start: 1, line_end: 1, signature: '', doc: '', summary: '', card_path: '',
|
|
270
|
+
});
|
|
271
|
+
upsertFinding(db, {
|
|
272
|
+
finding_kind: 'coverage', title: 'Low coverage: a.ts', severity: 'warn',
|
|
273
|
+
file_path: 'src/a.ts', line_start: 0, line_end: 0,
|
|
274
|
+
metric: '50%', body: 'Half covered', related: '', card_path: '',
|
|
275
|
+
});
|
|
276
|
+
db.close();
|
|
277
|
+
|
|
278
|
+
const result = runQuery(dbPath, { query: '', source: 'all' });
|
|
279
|
+
expect(result.rows.length).toBeGreaterThanOrEqual(2);
|
|
280
|
+
const sources = result.rows.map(r => r.source);
|
|
281
|
+
expect(sources).toContain('analysis');
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
it('source="analysis" with kind filter narrows by finding_kind', () => {
|
|
285
|
+
const db = openDb(dbPath);
|
|
286
|
+
upsertFinding(db, {
|
|
287
|
+
finding_kind: 'coverage', title: 'Coverage gap', severity: 'warn',
|
|
288
|
+
file_path: 'src/a.ts', line_start: 0, line_end: 0,
|
|
289
|
+
metric: '50%', body: '', related: '', card_path: '',
|
|
290
|
+
});
|
|
291
|
+
upsertFinding(db, {
|
|
292
|
+
finding_kind: 'debt', title: 'Debt item', severity: 'high',
|
|
293
|
+
file_path: 'src/b.ts', line_start: 1, line_end: 1,
|
|
294
|
+
metric: 'TODO', body: '', related: '', card_path: '',
|
|
295
|
+
});
|
|
296
|
+
db.close();
|
|
297
|
+
|
|
298
|
+
const result = runQuery(dbPath, { query: '', source: 'analysis', kind: 'coverage' });
|
|
299
|
+
expect(result.rows).toHaveLength(1);
|
|
300
|
+
expect(result.rows[0]!.name).toBe('Coverage gap');
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
it('source="all" with analysis data respects limit', () => {
|
|
304
|
+
const db = openDb(dbPath);
|
|
305
|
+
upsertSymbol(db, {
|
|
306
|
+
name: 'myFunc', kind: 'function', file_path: 'src/a.ts',
|
|
307
|
+
line_start: 1, line_end: 1, signature: '', doc: '', summary: '', card_path: '',
|
|
308
|
+
});
|
|
309
|
+
upsertFinding(db, {
|
|
310
|
+
finding_kind: 'debt', title: 'Debt', severity: 'info',
|
|
311
|
+
file_path: 'src/a.ts', line_start: 1, line_end: 1,
|
|
312
|
+
metric: 'TODO', body: '', related: '', card_path: '',
|
|
313
|
+
});
|
|
314
|
+
db.close();
|
|
315
|
+
|
|
316
|
+
const result = runQuery(dbPath, { query: '', source: 'all', limit: 1 });
|
|
317
|
+
expect(result.rows).toHaveLength(1);
|
|
318
|
+
});
|
|
319
|
+
|
|
245
320
|
});
|
package/src/query.ts
CHANGED
|
@@ -2,16 +2,16 @@
|
|
|
2
2
|
* Query orchestration: wraps DB search with staleness detection.
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
import type { QueryResult, QueryResultRow, StalenessInfo, NoteKind } from "./types.ts";
|
|
6
|
-
import { openDb, searchSymbols, searchLibSymbols, searchNotes, getMeta } from "./db.ts";
|
|
5
|
+
import type { QueryResult, QueryResultRow, StalenessInfo, NoteKind, FindingKind } from "./types.ts";
|
|
6
|
+
import { openDb, searchSymbols, searchLibSymbols, searchNotes, searchFindings, getMeta } from "./db.ts";
|
|
7
7
|
import { getHeadSha, changedFilesSince } from "./git.ts";
|
|
8
8
|
|
|
9
9
|
export interface QueryParams {
|
|
10
10
|
query: string;
|
|
11
11
|
kind?: string;
|
|
12
12
|
limit?: number;
|
|
13
|
-
/** Source scope: "code" (default, only code symbols), "libs" (only lib symbols), "notes" (only notes), or "all" (all
|
|
14
|
-
source?: "code" | "libs" | "notes" | "all";
|
|
13
|
+
/** Source scope: "code" (default, only code symbols), "libs" (only lib symbols), "notes" (only notes), "analysis" (only findings), or "all" (all four). */
|
|
14
|
+
source?: "code" | "libs" | "notes" | "analysis" | "all";
|
|
15
15
|
}
|
|
16
16
|
|
|
17
17
|
/**
|
|
@@ -39,14 +39,19 @@ export function runQuery(
|
|
|
39
39
|
} else if (source === "notes") {
|
|
40
40
|
const noteRows = searchNotes(db, params.query, params.kind as NoteKind | undefined, limit);
|
|
41
41
|
rows = noteRows as unknown as QueryResultRow[];
|
|
42
|
+
} else if (source === "analysis") {
|
|
43
|
+
const kindFilter = params.kind && ["coverage", "debt", "practice"].includes(params.kind) ? params.kind : undefined;
|
|
44
|
+
const analysisRows = searchFindings(db, params.query, kindFilter, limit) as unknown as QueryResultRow[];
|
|
45
|
+
rows = analysisRows as unknown as QueryResultRow[];
|
|
42
46
|
} else if (source === "all") {
|
|
43
|
-
// Run code + lib + note searches, merge, sort by score, apply limit
|
|
47
|
+
// Run code + lib + note + analysis searches, merge, sort by score, apply limit
|
|
44
48
|
const codeRows = searchSymbols(db, params.query, params.kind, limit);
|
|
45
49
|
const libRows = searchLibSymbols(db, params.query, params.kind, limit) as unknown as QueryResultRow[];
|
|
46
50
|
const noteRows = searchNotes(db, params.query, params.kind as NoteKind | undefined, limit * 2) as unknown as QueryResultRow[];
|
|
51
|
+
const analysisRows = searchFindings(db, params.query, undefined, limit * 2) as unknown as QueryResultRow[];
|
|
47
52
|
|
|
48
53
|
// Merge and sort by score ascending (lower bm25 = better match)
|
|
49
|
-
const merged: QueryResultRow[] = [...codeRows, ...libRows, ...noteRows];
|
|
54
|
+
const merged: QueryResultRow[] = [...codeRows, ...libRows, ...noteRows, ...analysisRows];
|
|
50
55
|
merged.sort((a, b) => a.score - b.score);
|
|
51
56
|
rows = merged.slice(0, limit);
|
|
52
57
|
} else {
|
package/src/types.ts
CHANGED
|
@@ -65,7 +65,7 @@ export interface CardHead {
|
|
|
65
65
|
}
|
|
66
66
|
|
|
67
67
|
/** Note kind discriminator for bridge cards. */
|
|
68
|
-
export type NoteKind = "glossary" | "decision";
|
|
68
|
+
export type NoteKind = "glossary" | "decision" | "convention";
|
|
69
69
|
|
|
70
70
|
/** A glossary/decision note (bridge card) for conceptual knowledge. */
|
|
71
71
|
export interface Note {
|
|
@@ -77,6 +77,31 @@ export interface Note {
|
|
|
77
77
|
card_path: string;
|
|
78
78
|
}
|
|
79
79
|
|
|
80
|
+
/** Analysis finding kind discriminator. */
|
|
81
|
+
export type FindingKind = "coverage" | "debt" | "practice";
|
|
82
|
+
|
|
83
|
+
/** A single analysis finding (coverage gap, technical debt, or best-practice finding). */
|
|
84
|
+
export interface Finding {
|
|
85
|
+
finding_kind: FindingKind;
|
|
86
|
+
title: string;
|
|
87
|
+
severity?: "info" | "warn" | "high";
|
|
88
|
+
file_path?: string;
|
|
89
|
+
line_start?: number;
|
|
90
|
+
line_end?: number;
|
|
91
|
+
metric?: string;
|
|
92
|
+
body?: string;
|
|
93
|
+
related?: string;
|
|
94
|
+
card_path?: string;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Per-file coverage data from lcov or coverage-final.json. */
|
|
98
|
+
export interface FileCoverage {
|
|
99
|
+
file: string;
|
|
100
|
+
lines_total: number;
|
|
101
|
+
lines_covered: number;
|
|
102
|
+
pct: number;
|
|
103
|
+
}
|
|
104
|
+
|
|
80
105
|
/** A single row returned from a query. */
|
|
81
106
|
export interface QueryResultRow {
|
|
82
107
|
name: string;
|
|
@@ -88,8 +113,11 @@ export interface QueryResultRow {
|
|
|
88
113
|
summary: string;
|
|
89
114
|
score: number;
|
|
90
115
|
id: number;
|
|
91
|
-
/** Origin fields — code rows omit these; lib / note rows set them. */
|
|
92
|
-
source?: "code" | "lib" | "note";
|
|
116
|
+
/** Origin fields — code rows omit these; lib / note / analysis rows set them. */
|
|
117
|
+
source?: "code" | "lib" | "note" | "analysis";
|
|
118
|
+
finding_kind?: FindingKind;
|
|
119
|
+
severity?: string;
|
|
120
|
+
metric?: string;
|
|
93
121
|
lib?: string;
|
|
94
122
|
version?: string;
|
|
95
123
|
/** Note-specific fields — only for source === "note" rows. */
|