@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.
@@ -0,0 +1,290 @@
1
+ /**
2
+ * Analysis orchestrator for codewalker v1.4.
3
+ *
4
+ * Coordinates:
5
+ * - Coverage parsing (lcov.info / coverage-final.json)
6
+ * - Debt scanning (TODO/FIXME/HACK/XXX, @ts-ignore, oversized files, long functions)
7
+ * - Card writing (atomic, under entries/analysis/<kind>/)
8
+ * - DB upsert (idempotent, per-file delete-then-insert)
9
+ *
10
+ * The agent-driven best-practice review (/codewalker review) is a separate path
11
+ * that uses the `review.ts` helpers and the `codewalker_finding` tool.
12
+ */
13
+
14
+ import * as fs from "node:fs";
15
+ import * as path from "node:path";
16
+ import { openDb, upsertFinding, deleteFindingsForFile, rebuildFtsIndexes, searchFindings } from "../db.ts";
17
+ import { parseLcov, parseCoverageJson, coverageSeverity } from "./coverage.ts";
18
+ import { scanDebt, summarizeDebt } from "./debt.ts";
19
+ import { renderAnalysisCard, parseAnalysisCard } from "./cards.ts";
20
+ import type { Finding, FindingKind } from "../types.ts";
21
+
22
+ export interface AnalyzeOptions {
23
+ /** Project root directory (source files live here). */
24
+ projectRoot: string;
25
+ /** Directory where analysis/* cards are written (entries/analysis). */
26
+ analysisDir: string;
27
+ /** Path to the SQLite DB. */
28
+ dbPath: string;
29
+ /** Optional path filter — only analyze files under this path. */
30
+ pathFilter?: string;
31
+ }
32
+
33
+ /** Supported file extensions for debt scanning. */
34
+ const SUPPORTED_EXTENSIONS = new Set([
35
+ ".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs",
36
+ ".py", ".go",
37
+ ]);
38
+
39
+ /**
40
+ * Run mechanical analysis (coverage + debt) on a project.
41
+ *
42
+ * 1. Parse coverage/lcov.info or coverage/coverage-final.json if present.
43
+ * 2. Walk source files and scan for debt markers/heuristics.
44
+ * 3. Write finding cards under entries/analysis/<kind>/.
45
+ * 4. Upsert findings to the DB (idempotent per file).
46
+ */
47
+ export function runAnalyze(options: AnalyzeOptions): { coverage: number; debt: number } {
48
+ const { projectRoot, analysisDir, dbPath, pathFilter } = options;
49
+
50
+ // Ensure analysis dirs exist
51
+ fs.mkdirSync(path.join(analysisDir, "coverage"), { recursive: true });
52
+ fs.mkdirSync(path.join(analysisDir, "debt"), { recursive: true });
53
+
54
+ const db = openDb(dbPath);
55
+ rebuildFtsIndexes(db);
56
+
57
+ const results = { coverage: 0, debt: 0 };
58
+
59
+ try {
60
+ // ── 1. Coverage parsing ─────────────────────────────────
61
+ const coverageDir = path.join(projectRoot, "coverage");
62
+ let coverageFiles: Finding[] = [];
63
+
64
+ if (fs.existsSync(coverageDir)) {
65
+ const lcovPath = path.join(coverageDir, "lcov.info");
66
+ const jsonPath = path.join(coverageDir, "coverage-final.json");
67
+
68
+ if (fs.existsSync(lcovPath)) {
69
+ const lcovText = fs.readFileSync(lcovPath, "utf-8");
70
+ const parsed = parseLcov(lcovText);
71
+ coverageFiles = parsed.map((f) => ({
72
+ finding_kind: "coverage" as FindingKind,
73
+ title: `Low coverage: ${f.file}`,
74
+ severity: coverageSeverity(f.pct),
75
+ file_path: f.file,
76
+ line_start: 0,
77
+ line_end: 0,
78
+ metric: `${f.pct}% (${f.lines_covered}/${f.lines_total} lines)`,
79
+ body: `File "${f.file}" has ${f.pct}% line coverage (${f.lines_covered}/${f.lines_total} lines covered).${
80
+ f.pct < 80 ? " Consider adding tests for uncovered paths." : ""
81
+ }`,
82
+ related: "",
83
+ }));
84
+ } else if (fs.existsSync(jsonPath)) {
85
+ const jsonText = fs.readFileSync(jsonPath, "utf-8");
86
+ const parsed = parseCoverageJson(JSON.parse(jsonText));
87
+ coverageFiles = parsed.map((f) => ({
88
+ finding_kind: "coverage" as FindingKind,
89
+ title: `Low coverage: ${f.file}`,
90
+ severity: coverageSeverity(f.pct),
91
+ file_path: f.file,
92
+ line_start: 0,
93
+ line_end: 0,
94
+ metric: `${f.pct}% (${f.lines_covered}/${f.lines_total} stmts)`,
95
+ body: `File "${f.file}" has ${f.pct}% statement coverage (${f.lines_covered}/${f.lines_total} statements covered).${
96
+ f.pct < 80 ? " Consider adding tests for uncovered paths." : ""
97
+ }`,
98
+ related: "",
99
+ }));
100
+ }
101
+
102
+ // Write coverage cards + upsert
103
+ for (const finding of coverageFiles) {
104
+ writeFindingCard(finding, analysisDir);
105
+ // Delete prior coverage findings for this file, then insert fresh
106
+ deleteFindingsForFile(db, "coverage", finding.file_path ?? "");
107
+ upsertFinding(db, {
108
+ ...finding,
109
+ severity: finding.severity,
110
+ });
111
+ }
112
+ }
113
+
114
+ results.coverage = coverageFiles.length;
115
+
116
+ // ── 2. Debt scanning ────────────────────────────────────
117
+ const sourceFiles = collectSourceFiles(projectRoot, pathFilter);
118
+ const existingSymbols = db.prepare(
119
+ "SELECT name, kind, file_path, line_start, line_end FROM symbols ORDER BY file_path, line_start",
120
+ ).all() as Array<{ name: string; kind: string; file_path: string; line_start: number; line_end: number }>;
121
+
122
+ for (const filePath of sourceFiles) {
123
+ const ext = path.extname(filePath).toLowerCase();
124
+ if (!SUPPORTED_EXTENSIONS.has(ext)) continue;
125
+
126
+ const content = fs.readFileSync(filePath, "utf-8");
127
+ const fileSymbols = existingSymbols.filter(s => s.file_path === filePath);
128
+
129
+ const rawFindings = scanDebt(filePath, content, fileSymbols);
130
+ if (rawFindings.length === 0) continue;
131
+
132
+ // Delete prior debt findings for this file
133
+ deleteFindingsForFile(db, "debt", filePath);
134
+
135
+ // Summarize (group by marker type) and write
136
+ const summarized = summarizeDebt(rawFindings);
137
+ for (const finding of summarized) {
138
+ const dbFinding: Finding = {
139
+ finding_kind: "debt",
140
+ title: finding.title,
141
+ severity: finding.severity,
142
+ file_path: finding.file_path,
143
+ line_start: finding.line_start,
144
+ line_end: finding.line_end,
145
+ metric: finding.metric,
146
+ body: finding.body,
147
+ related: "",
148
+ };
149
+
150
+ writeFindingCard(dbFinding, analysisDir);
151
+ upsertFinding(db, dbFinding);
152
+ }
153
+
154
+ results.debt += summarized.length;
155
+ }
156
+ } finally {
157
+ db.close();
158
+ }
159
+
160
+ return results;
161
+ }
162
+
163
+ /**
164
+ * Rebuild the analysis DB tables from card files alone.
165
+ * Demonstrates the disposable-index property: cards are the source of truth.
166
+ */
167
+ export function rebuildAnalysisDbFromCards(
168
+ dbPath: string,
169
+ analysisDir: string,
170
+ ): void {
171
+ const db = openDb(dbPath);
172
+ rebuildFtsIndexes(db);
173
+
174
+ try {
175
+ db.exec("BEGIN TRANSACTION");
176
+
177
+ // Clear existing analysis
178
+ db.prepare("DELETE FROM analysis").run();
179
+
180
+ // Walk analysis subdirectories (coverage/, debt/, practice/)
181
+ if (fs.existsSync(analysisDir)) {
182
+ for (const kindDir of fs.readdirSync(analysisDir, { withFileTypes: true })) {
183
+ if (!kindDir.isDirectory()) continue;
184
+ const kind = kindDir.name;
185
+ const kindPath = path.join(analysisDir, kind);
186
+ processAnalysisCardsInDir(db, kindPath, kind);
187
+ }
188
+ }
189
+
190
+ db.exec("COMMIT");
191
+ } catch (e) {
192
+ db.exec("ROLLBACK");
193
+ throw e;
194
+ } finally {
195
+ db.close();
196
+ }
197
+ }
198
+
199
+ // ── Internal helpers ──────────────────────────────────────────
200
+
201
+ /** Walk analysis card files in a subdirectory and upsert them to the DB. */
202
+ function processAnalysisCardsInDir(
203
+ db: ReturnType<typeof openDb>,
204
+ dir: string,
205
+ expectedKind: string,
206
+ ): void {
207
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
208
+ if (!entry.isFile() || !entry.name.endsWith(".md")) continue;
209
+ const cardPath = path.join(dir, entry.name);
210
+ const cardText = fs.readFileSync(cardPath, "utf-8");
211
+ const parsed = parseAnalysisCard(cardText);
212
+ if (!parsed) continue;
213
+ if (parsed.finding_kind !== expectedKind) continue;
214
+
215
+ // Parse location into file_path and line_start
216
+ let filePath = parsed.location;
217
+ let lineStart = 0;
218
+ const locMatch = parsed.location.match(/^(.+):(\d+)$/);
219
+ if (locMatch) {
220
+ filePath = locMatch[1] ?? parsed.location;
221
+ lineStart = parseInt(locMatch[2] ?? "0", 10);
222
+ }
223
+
224
+ upsertFinding(db, {
225
+ finding_kind: parsed.finding_kind as FindingKind,
226
+ title: parsed.title,
227
+ severity: parsed.severity || undefined,
228
+ file_path: filePath,
229
+ line_start: lineStart,
230
+ line_end: 0,
231
+ metric: parsed.metric || undefined,
232
+ body: parsed.summary || undefined,
233
+ related: "",
234
+ card_path: cardPath,
235
+ });
236
+ }
237
+ }
238
+
239
+ /** Write an analysis finding card to disk (atomic write). */
240
+ function writeFindingCard(finding: Finding, analysisDir: string): string {
241
+ const kindDir = path.join(analysisDir, finding.finding_kind);
242
+ if (!fs.existsSync(kindDir)) {
243
+ fs.mkdirSync(kindDir, { recursive: true });
244
+ }
245
+
246
+ // Generate slug from the card title
247
+ const slug = finding.title
248
+ .toLowerCase()
249
+ .replace(/[^a-z0-9]+/g, "-")
250
+ .replace(/^-+|-+$/g, "")
251
+ .slice(0, 80) || "finding";
252
+
253
+ const cardPath = path.join(kindDir, `${slug}.md`);
254
+ const card = renderAnalysisCard(finding);
255
+
256
+ // Atomic write
257
+ const tmpPath = cardPath + ".tmp";
258
+ fs.writeFileSync(tmpPath, card, { encoding: "utf-8", mode: 0o600 });
259
+ fs.renameSync(tmpPath, cardPath);
260
+
261
+ return cardPath;
262
+ }
263
+
264
+ /** Collect source files under a project root, optionally filtered by path prefix. */
265
+ function collectSourceFiles(rootDir: string, pathFilter?: string): string[] {
266
+ const files: string[] = [];
267
+ const walk = (dir: string) => {
268
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
269
+ const fullPath = path.join(dir, entry.name);
270
+ if (entry.isDirectory()) {
271
+ if (entry.name === "node_modules" || entry.name === ".git" || entry.name === ".pi" || entry.name.startsWith(".")) continue;
272
+ walk(fullPath);
273
+ } else if (entry.isFile()) {
274
+ const ext = path.extname(entry.name).toLowerCase();
275
+ if (SUPPORTED_EXTENSIONS.has(ext)) {
276
+ // Apply path filter if specified
277
+ if (pathFilter && !fullPath.includes(pathFilter)) continue;
278
+ files.push(fullPath);
279
+ }
280
+ }
281
+ }
282
+ };
283
+ if (fs.existsSync(rootDir)) {
284
+ walk(rootDir);
285
+ }
286
+ return files;
287
+ }
288
+
289
+ // Re-export for testing
290
+ export { collectSourceFiles };
@@ -0,0 +1,156 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { renderAnalysisCard, parseAnalysisCard } from './cards.ts';
3
+
4
+ describe('renderAnalysisCard', () => {
5
+ it('renders a coverage finding as markdown with frontmatter', () => {
6
+ const finding = {
7
+ finding_kind: 'coverage' as const,
8
+ title: 'Low coverage: src/auth/token.ts',
9
+ severity: 'warn' as const,
10
+ file_path: 'src/auth/token.ts',
11
+ line_start: 0,
12
+ line_end: 0,
13
+ metric: '38% (24/63 lines)',
14
+ body: 'Auth token refresh path is under-tested — 38% line coverage.',
15
+ related: 'refreshToken, token.ts:42-71',
16
+ card_path: '',
17
+ };
18
+ const card = renderAnalysisCard(finding);
19
+ expect(card).toContain('---');
20
+ expect(card).toContain('finding_kind: coverage');
21
+ expect(card).toContain('title: Low coverage: src/auth/token.ts');
22
+ expect(card).toContain('severity: warn');
23
+ expect(card).toContain('location: src/auth/token.ts');
24
+ expect(card).toContain('metric: 38% (24/63 lines)');
25
+ expect(card).toContain('summary: Auth token refresh path is under-tested');
26
+ expect(card).toContain('# Low coverage: src/auth/token.ts');
27
+ expect(card).toContain('38% line coverage.');
28
+ });
29
+
30
+ it('renders a debt finding with correct fields', () => {
31
+ const finding = {
32
+ finding_kind: 'debt' as const,
33
+ title: 'TODO: fix this',
34
+ severity: 'info' as const,
35
+ file_path: 'src/auth/handler.ts',
36
+ line_start: 42,
37
+ line_end: 42,
38
+ metric: 'TODO',
39
+ body: 'Need to handle edge case',
40
+ related: '',
41
+ card_path: '',
42
+ };
43
+ const card = renderAnalysisCard(finding);
44
+ expect(card).toContain('finding_kind: debt');
45
+ expect(card).toContain('TODO: fix this');
46
+ expect(card).toContain('location: src/auth/handler.ts:42');
47
+ expect(card).toContain('metric: TODO');
48
+ });
49
+
50
+ it('renders a practice finding', () => {
51
+ const finding = {
52
+ finding_kind: 'practice' as const,
53
+ title: 'Missing error handling',
54
+ severity: 'high' as const,
55
+ file_path: 'src/api/route.ts',
56
+ line_start: 15,
57
+ line_end: 15,
58
+ metric: '',
59
+ body: 'This function does not handle rejected promises.',
60
+ related: 'handleRequest, src/api/route.ts:10-30',
61
+ card_path: '',
62
+ };
63
+ const card = renderAnalysisCard(finding);
64
+ expect(card).toContain('finding_kind: practice');
65
+ expect(card).toContain('severity: high');
66
+ expect(card).toContain('handleRequest');
67
+ });
68
+
69
+ it('sanitizes newlines in frontmatter fields', () => {
70
+ const finding = {
71
+ finding_kind: 'debt' as const,
72
+ title: 'TODO: fix this\nand that',
73
+ severity: 'info' as const,
74
+ file_path: 'src/a.ts',
75
+ line_start: 1,
76
+ line_end: 1,
77
+ metric: 'TODO',
78
+ body: 'Multi\nline\nbody',
79
+ related: 'sym1\nsym2',
80
+ card_path: '',
81
+ };
82
+ const card = renderAnalysisCard(finding);
83
+ // Frontmatter should not contain literal newlines
84
+ const frontmatter = card.split('---')[1] ?? '';
85
+ expect(frontmatter).not.toContain('\n '); // no indented continuation lines
86
+ // Title in frontmatter should be flattened
87
+ expect(frontmatter).toContain('fix this and that');
88
+ });
89
+
90
+ it('uses body first line as summary when body has no explicit summary field', () => {
91
+ const finding = {
92
+ finding_kind: 'coverage' as const,
93
+ title: 'Test',
94
+ severity: 'info' as const,
95
+ file_path: 'src/a.ts',
96
+ line_start: 0,
97
+ line_end: 0,
98
+ metric: '',
99
+ body: 'First line is the summary.\nSecond line.',
100
+ related: '',
101
+ card_path: '',
102
+ };
103
+ const card = renderAnalysisCard(finding);
104
+ expect(card).toContain('summary: First line is the summary.');
105
+ });
106
+ });
107
+
108
+ describe('parseAnalysisCard', () => {
109
+ it('parses a card rendered by renderAnalysisCard', () => {
110
+ const finding = {
111
+ finding_kind: 'coverage' as const,
112
+ title: 'Low coverage: src/auth/token.ts',
113
+ severity: 'warn' as const,
114
+ file_path: 'src/auth/token.ts',
115
+ line_start: 0,
116
+ line_end: 0,
117
+ metric: '38% (24/63 lines)',
118
+ body: 'Auth token refresh path is under-tested.',
119
+ related: 'refreshToken',
120
+ card_path: '',
121
+ };
122
+ const card = renderAnalysisCard(finding);
123
+ const parsed = parseAnalysisCard(card);
124
+ expect(parsed).not.toBeNull();
125
+ expect(parsed!.finding_kind).toBe('coverage');
126
+ expect(parsed!.title).toBe('Low coverage: src/auth/token.ts');
127
+ expect(parsed!.severity).toBe('warn');
128
+ expect(parsed!.location).toBe('src/auth/token.ts');
129
+ expect(parsed!.metric).toBe('38% (24/63 lines)');
130
+ expect(parsed!.summary).toBe('Auth token refresh path is under-tested.');
131
+ });
132
+
133
+ it('returns null for a non-analysis card', () => {
134
+ const text = `---
135
+ title: Something else
136
+ ---
137
+ # Something`;
138
+ const parsed = parseAnalysisCard(text);
139
+ expect(parsed).toBeNull();
140
+ });
141
+
142
+ it('returns null for text without frontmatter', () => {
143
+ const text = '# No frontmatter';
144
+ const parsed = parseAnalysisCard(text);
145
+ expect(parsed).toBeNull();
146
+ });
147
+
148
+ it('returns null when finding_kind is missing from frontmatter', () => {
149
+ const text = `---
150
+ title: Test
151
+ ---
152
+ # Test`;
153
+ const parsed = parseAnalysisCard(text);
154
+ expect(parsed).toBeNull();
155
+ });
156
+ });
@@ -0,0 +1,110 @@
1
+ /**
2
+ * Analysis finding card rendering and parsing for codewalker v1.4.
3
+ *
4
+ * PURE module — no I/O. Renders a Finding into a markdown card
5
+ * with frontmatter head (finding_kind, title, severity, location, metric, summary) and body.
6
+ *
7
+ * Cards live at entries/analysis/<finding_kind>/<slug>.md.
8
+ */
9
+
10
+ import type { Finding, FindingKind } from "../types.ts";
11
+
12
+ /**
13
+ * Render a Finding (coverage gap, debt, or practice) into a markdown card string.
14
+ *
15
+ * Frontmatter head includes finding_kind, title, severity, location, metric, summary.
16
+ * Body starts with `# <title>` and contains the full body text.
17
+ * Summary is the first line of the body.
18
+ */
19
+ export function renderAnalysisCard(finding: {
20
+ finding_kind: FindingKind;
21
+ title: string;
22
+ severity?: string;
23
+ file_path?: string;
24
+ line_start?: number;
25
+ line_end?: number;
26
+ metric?: string;
27
+ body?: string;
28
+ related?: string;
29
+ card_path?: string;
30
+ }): string {
31
+ const lines: string[] = ["---"];
32
+
33
+ addHeadField(lines, "finding_kind", finding.finding_kind);
34
+ addHeadField(lines, "title", finding.title);
35
+ if (finding.severity) addHeadField(lines, "severity", finding.severity);
36
+ if (finding.file_path) {
37
+ const location = finding.file_path + (finding.line_start && finding.line_start > 0 ? `:${finding.line_start}` : "");
38
+ addHeadField(lines, "location", location);
39
+ }
40
+ if (finding.metric) addHeadField(lines, "metric", finding.metric);
41
+ if (finding.related) addHeadField(lines, "related", finding.related);
42
+
43
+ // Summary = first line of body
44
+ const bodyText = finding.body ?? "";
45
+ const summary = bodyText.split("\n")[0]?.trim() || finding.title;
46
+ addHeadField(lines, "summary", summary);
47
+
48
+ lines.push("---");
49
+ lines.push("");
50
+
51
+ // Body
52
+ lines.push(`# ${finding.title}`);
53
+ lines.push("");
54
+
55
+ if (bodyText) {
56
+ lines.push(bodyText);
57
+ }
58
+
59
+ return lines.join("\n") + "\n";
60
+ }
61
+
62
+ /**
63
+ * Parse an analysis finding card from a markdown string.
64
+ * Returns null if the card is invalid or not an analysis card.
65
+ */
66
+ export function parseAnalysisCard(text: string): {
67
+ finding_kind: string;
68
+ title: string;
69
+ severity: string;
70
+ location: string;
71
+ metric: string;
72
+ summary: string;
73
+ } | null {
74
+ const trimmed = text.trim();
75
+ if (!trimmed.startsWith("---")) return null;
76
+
77
+ const endOfFm = trimmed.indexOf("\n---", 3);
78
+ if (endOfFm === -1) return null;
79
+
80
+ const fmRaw = trimmed.slice(3, endOfFm).trim();
81
+
82
+ // Parse frontmatter lines into a record
83
+ const fm: Record<string, string> = {};
84
+ for (const line of fmRaw.split("\n")) {
85
+ const sep = line.indexOf(":");
86
+ if (sep > 0) {
87
+ const key = line.slice(0, sep).trim();
88
+ const value = line.slice(sep + 1).trim();
89
+ fm[key] = value;
90
+ }
91
+ }
92
+
93
+ if (!fm["finding_kind"]) return null;
94
+ if (!fm["title"]) return null;
95
+
96
+ return {
97
+ finding_kind: fm["finding_kind"],
98
+ title: fm["title"],
99
+ severity: fm["severity"] ?? "",
100
+ location: fm["location"] ?? "",
101
+ metric: fm["metric"] ?? "",
102
+ summary: fm["summary"] ?? "",
103
+ };
104
+ }
105
+
106
+ /** Add a key:value line to the frontmatter, sanitizing newlines. */
107
+ function addHeadField(lines: string[], key: string, value: string): void {
108
+ const safe = value.replace(/\n/g, " ").trim();
109
+ lines.push(`${key}: ${safe}`);
110
+ }