@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
|
@@ -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
|
+
}
|