@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/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
|
|
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
|
|
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 —
|
|
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
package/prompts/codewalker.md
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
52
|
-
- `body` — definition or
|
|
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.
|
|
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
|
|
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 };
|