@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 CHANGED
@@ -30,9 +30,17 @@ node_modules ──→ [.d.ts extract] ──→ LibSymbol[]
30
30
 
31
31
  agent knowledge ──→ codewalker_enrich / codewalker_note
32
32
 
33
- cards (enrichment, glossary, decisions)
33
+ cards (enrichment, glossary, decisions, conventions)
34
34
 
35
35
  index.db (notes + FTS5, unified query)
36
+
37
+ coverage/*.info ──→ [coverage parser] ─┐
38
+ source files ─────→ [debt scanner] ────┤
39
+ agent review ─────→ codewalker_finding ─┤
40
+
41
+ analysis/*.md cards
42
+
43
+ index.db (analysis + FTS5, unified query)
36
44
  ```
37
45
 
38
46
  - **Cards are the source of truth** — markdown in `~/.pi/projects/<id>/codewalker/entries/`.
@@ -40,7 +48,9 @@ agent knowledge ──→ codewalker_enrich / codewalker_note
40
48
  - **ctags primary, regex fallback** — ctags used when available, regex for TS/JS/Py/Go.
41
49
  - **Library layer** — extracts API surface from `node_modules` `.d.ts` files (version-pinned).
42
50
  - **Semantic + bridge layer** — agent-driven enrichment, glossary terms, and decision notes.
51
+ - **Analysis layer** — mechanical coverage and debt scanning + agent-driven best-practice review.
43
52
  - **Git-anchored** — stale index detected per query.
53
+ - **Report, don't gate** — all analysis findings are advisory cards, never a build failure.
44
54
 
45
55
  ## Commands
46
56
 
@@ -50,6 +60,10 @@ agent knowledge ──→ codewalker_enrich / codewalker_note
50
60
  | `/codewalker sync` | Git-anchored incremental — reindexes only changed files |
51
61
  | `/codewalker query <text>` | Search the index (compact results) |
52
62
  | `/codewalker enrich <path>` | Select unenriched symbols under `<path>` and write summaries |
63
+ | `/codewalker analyze [path]` | Mechanical coverage + debt analysis (reads lcov.info/coverage-final.json if present) |
64
+ | `/codewalker review <path>` | Agent-driven best-practice review against conventions/decisions (capped at 25 files) |
65
+ | `/codewalker findings [query]` | Search analysis findings with optional `--kind=coverage|debt|practice` filter |
66
+ | `/codewalker conventions [query]` | Search coding conventions |
53
67
  | `/codewalker glossary [query]` | Search glossary terms |
54
68
  | `/codewalker decisions [query]` | Search decision notes |
55
69
  | `/codewalker libs [--dev]` | Index all direct dependencies from node_modules |
@@ -61,9 +75,10 @@ The model can call:
61
75
 
62
76
  | Tool | Description |
63
77
  |------|-------------|
64
- | `codewalker_query` | Search code symbols, libraries, and notes with FTS5 |
78
+ | `codewalker_query` | Search code symbols, libraries, notes, and analysis findings with FTS5 |
65
79
  | `codewalker_enrich` | Write a one-line semantic summary back to a symbol's card + DB |
66
- | `codewalker_note` | Write a glossary term or decision note (bridge cards) |
80
+ | `codewalker_note` | Write a glossary term, decision note, or coding convention |
81
+ | `codewalker_finding` | Write a coverage, debt, or best-practice analysis finding |
67
82
 
68
83
  ## Install
69
84
 
@@ -81,7 +96,7 @@ pi -e ./node_modules/@aprimediet/codewalker/index.ts
81
96
 
82
97
  ```bash
83
98
  npm install
84
- npm test # vitest — 216+ tests across 19 test files
99
+ npm test # vitest — 295+ tests across 25 test files
85
100
  npm run test:watch # watch mode
86
101
  ```
87
102
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aprimediet/codewalker",
3
- "version": "1.3.0",
3
+ "version": "1.4.0",
4
4
  "type": "module",
5
5
  "description": "Queryable, token-economical project & code index for the pi coding agent.",
6
6
  "keywords": ["pi-package"],
@@ -1,9 +1,14 @@
1
1
  You have access to the codewalker code index for this project. Before reading or grepping
2
2
  files to find symbols (functions, consts, classes, types), use `codewalker_query` to
3
3
  look them up. The query returns compact facts — name, kind, file:line, and a one-line
4
- summary. Use `source='all'` to also surface glossary terms and decision notes.
4
+ summary. Use `source='all'` to also surface glossary terms, decision notes, and analysis
5
+ findings.
5
6
 
6
7
  - If the index is stale (shown in the result), run `/codewalker sync`.
7
8
  - For a full index, run `/codewalker scan`.
8
9
  - After reading an unfamiliar symbol, call `codewalker_enrich` to cache a summary.
9
10
  - When you discover a design decision or domain term, write it with `codewalker_note`.
11
+ - When you learn a coding convention, record it with `codewalker_note type=convention`.
12
+ - Run `/codewalker analyze` for a health snapshot (coverage gaps, debt markers).
13
+ - Use `/codewalker review <path>` to review files against conventions and decisions,
14
+ writing findings with `codewalker_finding`. Findings are advisory — report, don't gate.
@@ -43,16 +43,30 @@ Parameters:
43
43
 
44
44
  ### `codewalker_note` — Save domain knowledge
45
45
 
46
- Write a glossary term or design decision note. Persists as a markdown card + FTS index
47
- so future queries find it.
46
+ Write a glossary term, design decision, or coding convention. Persists as a markdown
47
+ card + FTS index so future queries find it.
48
48
 
49
49
  Parameters:
50
- - `type` — `glossary` | `decision`
51
- - `title` — glossary term or decision title
52
- - `body` — definition or rationale
50
+ - `type` — `glossary` | `decision` | `convention`
51
+ - `title` — glossary term, decision title, or convention name
52
+ - `body` — definition, rationale, or convention description
53
53
  - `tags` — optional comma-separated tags
54
54
  - `related` — optional comma-separated symbol names or `file:line` refs
55
55
 
56
+ ### `codewalker_finding` — Write an analysis finding
57
+
58
+ Write a coverage, debt, or best-practice finding. Persists as a markdown card under
59
+ `entries/analysis/<kind>/` + FTS index. Called by the agent during a review pass.
60
+
61
+ Parameters:
62
+ - `kind` — `coverage` | `debt` | `practice`
63
+ - `title` — short finding label
64
+ - `file` — optional file or `file:line` the finding is about
65
+ - `severity` — `info` | `warn` | `high` (default `info`)
66
+ - `body` — finding detail + why it matters, grounded in conventions/decisions
67
+ - `metric` — optional metric string, e.g. `'42%'`, `'fn length 180'`
68
+ - `related` — optional comma-separated symbol names or `file:line` refs
69
+
56
70
  ---
57
71
 
58
72
  ## Commands (human-facing)
@@ -63,6 +77,10 @@ Parameters:
63
77
  | `/codewalker sync` | Git-anchored incremental update (fast) |
64
78
  | `/codewalker query <text>` | Search code symbols by name or keyword |
65
79
  | `/codewalker enrich <path> [--max=N]` | List unenriched symbols under `path` for annotation |
80
+ | `/codewalker analyze [path]` | Mechanical coverage + debt analysis (reads lcov.info if present) |
81
+ | `/codewalker review <path> [--max=N]` | Agent-driven best-practice review against conventions (capped 25 files) |
82
+ | `/codewalker findings [query] [--kind=KIND]` | Search analysis findings |
83
+ | `/codewalker conventions [query]` | Search coding conventions |
66
84
  | `/codewalker glossary [query]` | Search glossary terms |
67
85
  | `/codewalker decisions [query]` | Search decision notes |
68
86
  | `/codewalker libs [--dev]` | Index all direct npm dependencies (--dev includes devDeps) |
@@ -103,7 +121,33 @@ codewalker_note(type="decision", title="why X", body="rationale")
103
121
  ```
104
122
  These become searchable via `codewalker_query` with `source='notes'` or `source='all'`.
105
123
 
106
- ### 6. Exploring library APIs
124
+ ### 6. Capturing coding conventions
125
+ When you learn a project-specific coding convention, record it so reviews can measure
126
+ against it:
127
+ ```
128
+ codewalker_note(type="convention", title="Use functional components",
129
+ body="All React components must be pure functions, not classes.")
130
+ ```
131
+ Then search them with `/codewalker conventions [query]`.
132
+
133
+ ### 7. Running a health snapshot
134
+ ```
135
+ /codewalker analyze
136
+ ```
137
+ This parses `coverage/lcov.info` (if present) and scans source files for
138
+ TODO/FIXME/HACK markers, `@ts-ignore`, oversized files, and long functions.
139
+ Results are queryable via `/codewalker findings [query]` or
140
+ `codewalker_query source='analysis'`.
141
+
142
+ ### 8. Reviewing a subsystem against conventions
143
+ ```
144
+ /codewalker review src/auth
145
+ ```
146
+ This selects files under `src/auth` (capped at 25) and produces a worklist for the
147
+ agent to review each file against the project's conventions and decisions. The agent
148
+ calls `codewalker_finding` to write findings back.
149
+
150
+ ### 9. Exploring library APIs
107
151
  ```
108
152
  /codewalker libs # index dependencies
109
153
  /codewalker lib express # search express exports
@@ -130,4 +174,7 @@ knowledge your future self will thank you for.
130
174
  - **Cards as source of truth**: every symbol and note is stored as a markdown card file.
131
175
  The DB is rebuilt from cards on `scan` — cards are the durable artifact
132
176
  - **Source filter**: use `source='libs'` to search only library APIs, `source='notes'`
133
- for glossary/decisions, `source='all'` for everything at once
177
+ for glossary/decisions, `source='analysis'` for findings, `source='all'` for everything
178
+ at once
179
+ - **Report, don't gate**: analysis findings are advisory cards and FTS rows, never a CI
180
+ gate or build failure
@@ -0,0 +1,214 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
+ import * as fs from 'node:fs';
3
+ import * as path from 'node:path';
4
+ import * as os from 'node:os';
5
+ import { runAnalyze, rebuildAnalysisDbFromCards } from './analyzer.ts';
6
+ import { openDb, searchFindings } from '../db.ts';
7
+
8
+ describe('analyzer.ts', () => {
9
+ let tmpDir: string;
10
+ let projectRoot: string;
11
+ let analysisDir: string;
12
+ let dbPath: string;
13
+
14
+ beforeEach(() => {
15
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cw-analyze-'));
16
+ projectRoot = path.join(tmpDir, 'project');
17
+ analysisDir = path.join(tmpDir, 'codewalker', 'entries', 'analysis');
18
+ const dbDir = path.join(tmpDir, 'codewalker');
19
+ dbPath = path.join(dbDir, 'index.db');
20
+ fs.mkdirSync(analysisDir, { recursive: true });
21
+ fs.mkdirSync(dbDir, { recursive: true });
22
+ });
23
+
24
+ afterEach(() => {
25
+ fs.rmSync(tmpDir, { recursive: true, force: true });
26
+ });
27
+
28
+ it('runAnalyze with no coverage file still produces debt findings from source scan', () => {
29
+ // Create a source file with debt markers
30
+ const srcDir = path.join(projectRoot, 'src');
31
+ fs.mkdirSync(srcDir, { recursive: true });
32
+ fs.writeFileSync(
33
+ path.join(srcDir, 'a.ts'),
34
+ '// normal line\nconst x = 1;\n// TODO: fix this\nfunction y() { return x; }\n',
35
+ 'utf-8'
36
+ );
37
+
38
+ runAnalyze({
39
+ projectRoot,
40
+ analysisDir,
41
+ dbPath,
42
+ });
43
+
44
+ // Should have debt findings
45
+ const db = openDb(dbPath);
46
+ const findings = searchFindings(db, '');
47
+ expect(findings.length).toBeGreaterThan(0);
48
+
49
+ // At least one should be a debt finding
50
+ const debtFindings = findings.filter(f => f.finding_kind === 'debt');
51
+ expect(debtFindings.length).toBeGreaterThan(0);
52
+
53
+ // Cards should be written
54
+ const debtDir = path.join(analysisDir, 'debt');
55
+ expect(fs.existsSync(debtDir)).toBe(true);
56
+ const cardFiles = fs.readdirSync(debtDir).filter(f => f.endsWith('.md'));
57
+ expect(cardFiles.length).toBeGreaterThan(0);
58
+
59
+ db.close();
60
+ });
61
+
62
+ it('runAnalyze parses coverage/lcov.info if present', () => {
63
+ const srcDir = path.join(projectRoot, 'src');
64
+ fs.mkdirSync(srcDir, { recursive: true });
65
+ fs.writeFileSync(path.join(srcDir, 'token.ts'), 'line1\nline2\nline3\n', 'utf-8');
66
+
67
+ // Write coverage file
68
+ const coverageDir = path.join(projectRoot, 'coverage');
69
+ fs.mkdirSync(coverageDir, { recursive: true });
70
+ fs.writeFileSync(
71
+ path.join(coverageDir, 'lcov.info'),
72
+ 'SF:src/token.ts\nDA:1,1\nDA:2,0\nDA:3,1\nLF:3\nLH:2\nend_of_record\n',
73
+ 'utf-8'
74
+ );
75
+
76
+ runAnalyze({
77
+ projectRoot,
78
+ analysisDir,
79
+ dbPath,
80
+ });
81
+
82
+ // Should have coverage findings
83
+ const db = openDb(dbPath);
84
+ const findings = searchFindings(db, '');
85
+ const coverageFindings = findings.filter(f => f.finding_kind === 'coverage');
86
+ expect(coverageFindings.length).toBeGreaterThan(0);
87
+
88
+ // Card files should be written
89
+ const coverageDir2 = path.join(analysisDir, 'coverage');
90
+ expect(fs.existsSync(coverageDir2)).toBe(true);
91
+
92
+ db.close();
93
+ });
94
+
95
+ it('runAnalyze parses coverage/coverage-final.json if lcov.info is absent', () => {
96
+ const srcDir = path.join(projectRoot, 'src');
97
+ fs.mkdirSync(srcDir, { recursive: true });
98
+ fs.writeFileSync(path.join(srcDir, 'token.ts'), 'line1\nline2\n', 'utf-8');
99
+
100
+ // Write coverage-final.json
101
+ const coverageDir = path.join(projectRoot, 'coverage');
102
+ fs.mkdirSync(coverageDir, { recursive: true });
103
+ fs.writeFileSync(
104
+ path.join(coverageDir, 'coverage-final.json'),
105
+ JSON.stringify({
106
+ 'src/token.ts': {
107
+ path: 'src/token.ts',
108
+ statementMap: { '0': { start: { line: 1, column: 0 }, end: { line: 1, column: 5 } }, '1': { start: { line: 2, column: 0 }, end: { line: 2, column: 5 } } },
109
+ fnMap: {}, branchMap: {},
110
+ s: { '0': 1, '1': 0 },
111
+ f: {}, b: {},
112
+ },
113
+ }),
114
+ 'utf-8'
115
+ );
116
+
117
+ runAnalyze({
118
+ projectRoot,
119
+ analysisDir,
120
+ dbPath,
121
+ });
122
+
123
+ const db = openDb(dbPath);
124
+ const findings = searchFindings(db, '');
125
+ const coverageFindings = findings.filter(f => f.finding_kind === 'coverage');
126
+ expect(coverageFindings.length).toBeGreaterThan(0);
127
+ db.close();
128
+ });
129
+
130
+ it('re-running analyze on same project does not duplicate findings', () => {
131
+ const srcDir = path.join(projectRoot, 'src');
132
+ fs.mkdirSync(srcDir, { recursive: true });
133
+ fs.writeFileSync(path.join(srcDir, 'a.ts'), '// TODO: once\n', 'utf-8');
134
+
135
+ runAnalyze({ projectRoot, analysisDir, dbPath });
136
+ runAnalyze({ projectRoot, analysisDir, dbPath }); // second run
137
+
138
+ const db = openDb(dbPath);
139
+ const findings = searchFindings(db, '');
140
+ // All findings should be unique — no duplicates per (finding_kind, file_path, title)
141
+ const titles = findings.map(f => f.name);
142
+ const uniqueTitles = new Set(titles);
143
+ expect(titles.length).toBe(uniqueTitles.size);
144
+ db.close();
145
+ });
146
+
147
+ it('rebuildAnalysisDbFromCards reconstructs the DB from card files alone', () => {
148
+ // First run to create cards
149
+ const srcDir = path.join(projectRoot, 'src');
150
+ fs.mkdirSync(srcDir, { recursive: true });
151
+ fs.writeFileSync(path.join(srcDir, 'a.ts'), '// TODO: rebuild this\nconst x = 1;\n', 'utf-8');
152
+
153
+ runAnalyze({ projectRoot, analysisDir, dbPath });
154
+
155
+ // Verify there are cards on disk
156
+ const debtDir = path.join(analysisDir, 'debt');
157
+ const cardFiles = fs.existsSync(debtDir) ? fs.readdirSync(debtDir).filter(f => f.endsWith('.md')) : [];
158
+ expect(cardFiles.length).toBeGreaterThan(0);
159
+
160
+ // Destroy DB and rebuild
161
+ const db = openDb(dbPath);
162
+ db.prepare('DELETE FROM analysis').run();
163
+ const beforeRebuild = searchFindings(db, '');
164
+ expect(beforeRebuild).toHaveLength(0);
165
+ db.close();
166
+
167
+ // Rebuild from cards
168
+ rebuildAnalysisDbFromCards(dbPath, analysisDir);
169
+
170
+ // Verify findings are back
171
+ const db2 = openDb(dbPath);
172
+ const afterRebuild = searchFindings(db2, '');
173
+ expect(afterRebuild.length).toBeGreaterThan(0);
174
+ expect(afterRebuild[0]!.finding_kind).toBe('debt');
175
+ db2.close();
176
+ });
177
+
178
+ it('runAnalyze reports when no coverage data is found without erroring', () => {
179
+ // No coverage files, no source files
180
+ const srcDir = path.join(projectRoot, 'src');
181
+ fs.mkdirSync(srcDir, { recursive: true });
182
+
183
+ // Should not throw
184
+ expect(() => runAnalyze({ projectRoot, analysisDir, dbPath })).not.toThrow();
185
+
186
+ // With no source files either, findings should be empty
187
+ const db = openDb(dbPath);
188
+ const findings = searchFindings(db, '');
189
+ expect(findings).toHaveLength(0);
190
+ db.close();
191
+ });
192
+
193
+ it('runAnalyze respects a custom path filter for debt scanning', () => {
194
+ const srcDir = path.join(projectRoot, 'src');
195
+ fs.mkdirSync(srcDir, { recursive: true });
196
+ fs.writeFileSync(path.join(srcDir, 'a.ts'), '// TODO: file a\n', 'utf-8');
197
+ fs.writeFileSync(path.join(srcDir, 'b.ts'), '// TODO: file b\n', 'utf-8');
198
+
199
+ runAnalyze({
200
+ projectRoot,
201
+ analysisDir,
202
+ dbPath,
203
+ pathFilter: 'src/a.ts',
204
+ });
205
+
206
+ const db = openDb(dbPath);
207
+ const findings = searchFindings(db, '');
208
+ const aFindings = findings.filter(f => f.file_path?.endsWith('a.ts'));
209
+ const bFindings = findings.filter(f => f.file_path?.endsWith('b.ts'));
210
+ expect(aFindings.length).toBeGreaterThan(0);
211
+ expect(bFindings).toHaveLength(0);
212
+ db.close();
213
+ });
214
+ });
@@ -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 };