@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,158 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { parseLcov, parseCoverageJson, coverageSeverity } from './coverage.ts';
|
|
3
|
+
|
|
4
|
+
describe('parseLcov', () => {
|
|
5
|
+
it('parses a simple lcov record with SF, DA, LF, LH', () => {
|
|
6
|
+
const input = `SF:src/auth/token.ts
|
|
7
|
+
DA:1,1
|
|
8
|
+
DA:2,1
|
|
9
|
+
DA:3,0
|
|
10
|
+
DA:4,1
|
|
11
|
+
LF:4
|
|
12
|
+
LH:3
|
|
13
|
+
end_of_record`;
|
|
14
|
+
const results = parseLcov(input);
|
|
15
|
+
expect(results).toHaveLength(1);
|
|
16
|
+
expect(results[0]!.file).toContain('src/auth/token.ts');
|
|
17
|
+
expect(results[0]!.lines_total).toBe(4);
|
|
18
|
+
expect(results[0]!.lines_covered).toBe(3);
|
|
19
|
+
expect(results[0]!.pct).toBeCloseTo(75, 1);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('parses multiple records', () => {
|
|
23
|
+
const input = `SF:src/a.ts
|
|
24
|
+
DA:1,1
|
|
25
|
+
LF:1
|
|
26
|
+
LH:1
|
|
27
|
+
end_of_record
|
|
28
|
+
SF:src/b.ts
|
|
29
|
+
DA:1,0
|
|
30
|
+
LF:1
|
|
31
|
+
LH:0
|
|
32
|
+
end_of_record`;
|
|
33
|
+
const results = parseLcov(input);
|
|
34
|
+
expect(results).toHaveLength(2);
|
|
35
|
+
expect(results[0]!.pct).toBe(100);
|
|
36
|
+
expect(results[1]!.pct).toBe(0);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('handles empty input gracefully', () => {
|
|
40
|
+
const results = parseLcov('');
|
|
41
|
+
expect(results).toEqual([]);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('handles malformed input without throwing', () => {
|
|
45
|
+
const results = parseLcov('not even close to lcov format');
|
|
46
|
+
expect(results).toEqual([]);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('handles records with 0 total lines', () => {
|
|
50
|
+
const input = `SF:src/empty.ts
|
|
51
|
+
DA:1,1
|
|
52
|
+
LF:0
|
|
53
|
+
LH:0
|
|
54
|
+
end_of_record`;
|
|
55
|
+
const results = parseLcov(input);
|
|
56
|
+
expect(results).toHaveLength(1);
|
|
57
|
+
expect(results[0]!.pct).toBe(100); // no lines = implicitly 100%
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('skips records with empty SF (no file path)', () => {
|
|
61
|
+
const input = `SF:
|
|
62
|
+
DA:1,0
|
|
63
|
+
LF:1
|
|
64
|
+
LH:0
|
|
65
|
+
end_of_record`;
|
|
66
|
+
const results = parseLcov(input);
|
|
67
|
+
expect(results).toHaveLength(0);
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
describe('parseCoverageJson', () => {
|
|
72
|
+
it('parses a coverage-final.json with per-file data', () => {
|
|
73
|
+
const input = {
|
|
74
|
+
'src/auth/token.ts': {
|
|
75
|
+
path: 'src/auth/token.ts',
|
|
76
|
+
statementMap: {},
|
|
77
|
+
fnMap: {},
|
|
78
|
+
branchMap: {},
|
|
79
|
+
s: { '0': 1, '1': 1, '2': 0 },
|
|
80
|
+
f: {},
|
|
81
|
+
b: {},
|
|
82
|
+
},
|
|
83
|
+
};
|
|
84
|
+
const results = parseCoverageJson(input);
|
|
85
|
+
expect(results).toHaveLength(1);
|
|
86
|
+
expect(results[0]!.file).toContain('src/auth/token.ts');
|
|
87
|
+
expect(results[0]!.lines_total).toBe(3);
|
|
88
|
+
expect(results[0]!.lines_covered).toBe(2);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('handles empty input', () => {
|
|
92
|
+
const results = parseCoverageJson({});
|
|
93
|
+
expect(results).toEqual([]);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('handles null/undefined input gracefully', () => {
|
|
97
|
+
const results = parseCoverageJson(null as any);
|
|
98
|
+
expect(results).toEqual([]);
|
|
99
|
+
const results2 = parseCoverageJson(undefined as any);
|
|
100
|
+
expect(results2).toEqual([]);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('handles files with no statements', () => {
|
|
104
|
+
const input = {
|
|
105
|
+
'src/empty.ts': {
|
|
106
|
+
path: 'src/empty.ts',
|
|
107
|
+
statementMap: {},
|
|
108
|
+
fnMap: {},
|
|
109
|
+
branchMap: {},
|
|
110
|
+
s: {},
|
|
111
|
+
f: {},
|
|
112
|
+
b: {},
|
|
113
|
+
},
|
|
114
|
+
};
|
|
115
|
+
const results = parseCoverageJson(input);
|
|
116
|
+
expect(results).toHaveLength(1);
|
|
117
|
+
expect(results[0]!.pct).toBe(100);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('calculates percentage correctly', () => {
|
|
121
|
+
const input = {
|
|
122
|
+
'src/test.ts': {
|
|
123
|
+
path: 'src/test.ts',
|
|
124
|
+
statementMap: { '0': { start: { line: 1, column: 0 }, end: { line: 1, column: 1 } } },
|
|
125
|
+
fnMap: {},
|
|
126
|
+
branchMap: {},
|
|
127
|
+
s: { '0': 0 },
|
|
128
|
+
f: {},
|
|
129
|
+
b: {},
|
|
130
|
+
},
|
|
131
|
+
};
|
|
132
|
+
const results = parseCoverageJson(input);
|
|
133
|
+
expect(results).toHaveLength(1);
|
|
134
|
+
expect(results[0]!.pct).toBe(0);
|
|
135
|
+
expect(results[0]!.lines_total).toBe(1);
|
|
136
|
+
expect(results[0]!.lines_covered).toBe(0);
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
describe('coverageSeverity', () => {
|
|
141
|
+
it('returns "high" for <50%', () => {
|
|
142
|
+
expect(coverageSeverity(0)).toBe('high');
|
|
143
|
+
expect(coverageSeverity(25)).toBe('high');
|
|
144
|
+
expect(coverageSeverity(49.9)).toBe('high');
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('returns "warn" for 50-80%', () => {
|
|
148
|
+
expect(coverageSeverity(50)).toBe('warn');
|
|
149
|
+
expect(coverageSeverity(65)).toBe('warn');
|
|
150
|
+
expect(coverageSeverity(79.9)).toBe('warn');
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it('returns "info" for >=80%', () => {
|
|
154
|
+
expect(coverageSeverity(80)).toBe('info');
|
|
155
|
+
expect(coverageSeverity(95)).toBe('info');
|
|
156
|
+
expect(coverageSeverity(100)).toBe('info');
|
|
157
|
+
});
|
|
158
|
+
});
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Coverage data parser for codewalker v1.4.
|
|
3
|
+
*
|
|
4
|
+
* PURE module — no I/O. Parses lcov.info and coverage-final.json artifacts
|
|
5
|
+
* that already exist on disk. Never runs a coverage tool.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { FileCoverage } from "../types.ts";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Parse an lcov.info string into per-file coverage data.
|
|
12
|
+
* Supports SF:, DA:, LF:, LH: and end_of_record markers.
|
|
13
|
+
*/
|
|
14
|
+
export function parseLcov(text: string): FileCoverage[] {
|
|
15
|
+
if (!text.trim()) return [];
|
|
16
|
+
|
|
17
|
+
const results: FileCoverage[] = [];
|
|
18
|
+
let current: Record<string, any> | null = null;
|
|
19
|
+
|
|
20
|
+
for (const line of text.split("\n")) {
|
|
21
|
+
const trimmed = line.trim();
|
|
22
|
+
if (!trimmed) continue;
|
|
23
|
+
|
|
24
|
+
if (trimmed.startsWith("SF:")) {
|
|
25
|
+
// Start a new record
|
|
26
|
+
current = { file: trimmed.slice(3).trim(), lines: [], lf: 0, lh: 0 };
|
|
27
|
+
} else if (current) {
|
|
28
|
+
if (trimmed.startsWith("DA:")) {
|
|
29
|
+
const parts = trimmed.slice(3).split(",");
|
|
30
|
+
const lineNo = parseInt(parts[0] ?? "0", 10);
|
|
31
|
+
const hit = parseInt(parts[1] ?? "0", 10);
|
|
32
|
+
if (!isNaN(lineNo)) {
|
|
33
|
+
current.lines.push({ line: lineNo, hit });
|
|
34
|
+
}
|
|
35
|
+
} else if (trimmed.startsWith("LF:")) {
|
|
36
|
+
current.lf = parseInt(trimmed.slice(3).trim(), 10);
|
|
37
|
+
} else if (trimmed.startsWith("LH:")) {
|
|
38
|
+
current.lh = parseInt(trimmed.slice(3).trim(), 10);
|
|
39
|
+
} else if (trimmed === "end_of_record") {
|
|
40
|
+
if (current.file) {
|
|
41
|
+
const total = current.lf || current.lines.length;
|
|
42
|
+
const covered = current.lh || current.lines.filter((l: any) => l.hit > 0).length;
|
|
43
|
+
const pct = total > 0 ? (covered / total) * 100 : 100;
|
|
44
|
+
results.push({
|
|
45
|
+
file: current.file,
|
|
46
|
+
lines_total: total,
|
|
47
|
+
lines_covered: covered,
|
|
48
|
+
pct: Math.round(pct * 10) / 10,
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
current = null;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return results;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Parse a coverage-final.json object (istanbul/nyc format) into per-file coverage data.
|
|
61
|
+
*/
|
|
62
|
+
export function parseCoverageJson(
|
|
63
|
+
data: Record<string, any> | null | undefined,
|
|
64
|
+
): FileCoverage[] {
|
|
65
|
+
if (!data || typeof data !== "object") return [];
|
|
66
|
+
|
|
67
|
+
const results: FileCoverage[] = [];
|
|
68
|
+
|
|
69
|
+
for (const [filePath, fileData] of Object.entries(data)) {
|
|
70
|
+
if (!fileData || typeof fileData !== "object") continue;
|
|
71
|
+
const s = (fileData as any).s;
|
|
72
|
+
if (!s || typeof s !== "object") continue;
|
|
73
|
+
|
|
74
|
+
const statements = Object.values(s) as number[];
|
|
75
|
+
const total = statements.length;
|
|
76
|
+
const covered = statements.filter((v) => v > 0).length;
|
|
77
|
+
const pct = total > 0 ? (covered / total) * 100 : 100;
|
|
78
|
+
|
|
79
|
+
results.push({
|
|
80
|
+
file: filePath,
|
|
81
|
+
lines_total: total,
|
|
82
|
+
lines_covered: covered,
|
|
83
|
+
pct: Math.round(pct * 10) / 10,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return results;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Map a coverage percentage to a severity level.
|
|
92
|
+
* <50% → high, 50-80% → warn, >=80% → info.
|
|
93
|
+
*/
|
|
94
|
+
export function coverageSeverity(pct: number): "info" | "warn" | "high" {
|
|
95
|
+
if (pct < 50) return "high";
|
|
96
|
+
if (pct < 80) return "warn";
|
|
97
|
+
return "info";
|
|
98
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { scanDebt, LARGE_FILE_LINES, LONG_FN_LINES } from './debt.ts';
|
|
3
|
+
import type { DebtFinding } from './debt.ts';
|
|
4
|
+
|
|
5
|
+
describe('scanDebt', () => {
|
|
6
|
+
it('finds TODO markers with correct line numbers', () => {
|
|
7
|
+
const content = `// normal line
|
|
8
|
+
const x = 1;
|
|
9
|
+
// TODO: fix this
|
|
10
|
+
function y() {} // TODO: refactor later
|
|
11
|
+
`;
|
|
12
|
+
const findings = scanDebt('src/a.ts', content, []);
|
|
13
|
+
const todos = findings.filter(f => f.marker === 'TODO');
|
|
14
|
+
expect(todos).toHaveLength(2);
|
|
15
|
+
expect(todos[0]!.line_start).toBe(3);
|
|
16
|
+
expect(todos[1]!.line_start).toBe(4);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('finds FIXME markers', () => {
|
|
20
|
+
const content = `// FIXME: this is broken
|
|
21
|
+
const x = 1;
|
|
22
|
+
`;
|
|
23
|
+
const findings = scanDebt('src/a.ts', content, []);
|
|
24
|
+
const fixmes = findings.filter(f => f.marker === 'FIXME');
|
|
25
|
+
expect(fixmes).toHaveLength(1);
|
|
26
|
+
expect(fixmes[0]!.line_start).toBe(1);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('finds HACK markers', () => {
|
|
30
|
+
const content = `// HACK: workaround for edge case
|
|
31
|
+
const x = 1;
|
|
32
|
+
`;
|
|
33
|
+
const findings = scanDebt('src/a.ts', content, []);
|
|
34
|
+
const hacks = findings.filter(f => f.marker === 'HACK');
|
|
35
|
+
expect(hacks).toHaveLength(1);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('finds XXX markers', () => {
|
|
39
|
+
const content = `// XXX: this needs attention
|
|
40
|
+
const x = 1;
|
|
41
|
+
`;
|
|
42
|
+
const findings = scanDebt('src/a.ts', content, []);
|
|
43
|
+
const xxxs = findings.filter(f => f.marker === 'XXX');
|
|
44
|
+
expect(xxxs).toHaveLength(1);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('counts @ts-ignore occurrences', () => {
|
|
48
|
+
const content = `// @ts-ignore
|
|
49
|
+
const x: any = 1;
|
|
50
|
+
// @ts-ignore — next line
|
|
51
|
+
const y: any = 2;
|
|
52
|
+
`;
|
|
53
|
+
const findings = scanDebt('src/a.ts', content, []);
|
|
54
|
+
const tsIgnores = findings.filter(f => f.marker === '@ts-ignore');
|
|
55
|
+
expect(tsIgnores).toHaveLength(2);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('counts @ts-nocheck occurrences', () => {
|
|
59
|
+
const content = `// @ts-nocheck
|
|
60
|
+
const x = 1;
|
|
61
|
+
`;
|
|
62
|
+
const findings = scanDebt('src/a.ts', content, []);
|
|
63
|
+
const tsNocheck = findings.filter(f => f.marker === '@ts-nocheck');
|
|
64
|
+
expect(tsNocheck).toHaveLength(1);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('flags a file as oversized when lines exceed LARGE_FILE_LINES', () => {
|
|
68
|
+
const content = Array.from({ length: LARGE_FILE_LINES + 50 }, (_, i) =>
|
|
69
|
+
`line ${i + 1}`
|
|
70
|
+
).join('\n');
|
|
71
|
+
const findings = scanDebt('src/large.ts', content, []);
|
|
72
|
+
const oversize = findings.filter(f => f.marker === 'oversized-file');
|
|
73
|
+
expect(oversize).toHaveLength(1);
|
|
74
|
+
expect(oversize[0]!.severity).toBe('warn');
|
|
75
|
+
expect(oversize[0]!.metric).toContain(String(LARGE_FILE_LINES + 50));
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('does NOT flag a file under the limit', () => {
|
|
79
|
+
const content = Array.from({ length: 10 }, (_, i) => `line ${i + 1}`).join('\n');
|
|
80
|
+
const findings = scanDebt('src/small.ts', content, []);
|
|
81
|
+
const oversize = findings.filter(f => f.marker === 'oversized-file');
|
|
82
|
+
expect(oversize).toHaveLength(0);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('flags functions longer than LONG_FN_LINES using existing symbol spans', () => {
|
|
86
|
+
const symbols = [
|
|
87
|
+
{ name: 'longFunc', kind: 'function', file_path: 'src/big.ts', line_start: 1, line_end: LONG_FN_LINES + 50 },
|
|
88
|
+
{ name: 'shortFunc', kind: 'function', file_path: 'src/big.ts', line_start: 200, line_end: 210 },
|
|
89
|
+
];
|
|
90
|
+
const content = '';
|
|
91
|
+
const findings = scanDebt('src/big.ts', content, symbols);
|
|
92
|
+
const longFn = findings.filter(f => f.marker === 'long-function');
|
|
93
|
+
expect(longFn).toHaveLength(1);
|
|
94
|
+
expect(longFn[0]!.title).toContain('longFunc');
|
|
95
|
+
expect(longFn[0]!.severity).toBe('warn');
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('returns empty array for clean file', () => {
|
|
99
|
+
const content = `const x = 1;
|
|
100
|
+
const y = 2;
|
|
101
|
+
function foo() { return x + y; }
|
|
102
|
+
`;
|
|
103
|
+
const findings = scanDebt('src/clean.ts', content, []);
|
|
104
|
+
expect(findings).toHaveLength(0);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('handles empty content', () => {
|
|
108
|
+
const findings = scanDebt('src/empty.ts', '', []);
|
|
109
|
+
expect(findings).toEqual([]);
|
|
110
|
+
});
|
|
111
|
+
});
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Technical debt scanner for codewalker v1.4.
|
|
3
|
+
*
|
|
4
|
+
* PURE module — no I/O. Scans source file content for:
|
|
5
|
+
* - TODO/FIXME/HACK/XXX markers
|
|
6
|
+
* - @ts-ignore / @ts-nocheck usage
|
|
7
|
+
* - Oversized files (>400 lines)
|
|
8
|
+
* - Long functions (>120 lines, derived from existing symbol rows)
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** Default threshold for oversized file warning (lines). */
|
|
12
|
+
export const LARGE_FILE_LINES = 400;
|
|
13
|
+
|
|
14
|
+
/** Default threshold for long function warning (lines). */
|
|
15
|
+
export const LONG_FN_LINES = 120;
|
|
16
|
+
|
|
17
|
+
/** A single debt finding. */
|
|
18
|
+
export interface DebtFinding {
|
|
19
|
+
title: string;
|
|
20
|
+
file_path: string;
|
|
21
|
+
line_start: number;
|
|
22
|
+
line_end: number;
|
|
23
|
+
marker: string;
|
|
24
|
+
severity: "info" | "warn" | "high";
|
|
25
|
+
metric: string;
|
|
26
|
+
body: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** A symbol row passed for function-length analysis. */
|
|
30
|
+
interface SymbolSpan {
|
|
31
|
+
name: string;
|
|
32
|
+
kind: string;
|
|
33
|
+
file_path: string;
|
|
34
|
+
line_start: number;
|
|
35
|
+
line_end: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Word-boundary regex for each marker type.
|
|
39
|
+
const MARKER_RE = /\b(TODO|FIXME|HACK|XXX)\b(?:\s*[:-]?\s*(.*))?/g;
|
|
40
|
+
const TS_IGNORE_RE = /\/\/\s*@ts-ignore\b/g;
|
|
41
|
+
const TS_NOCHECK_RE = /\/\/\s*@ts-nocheck\b/g;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Scan a file's content for debt markers and heuristics.
|
|
45
|
+
*
|
|
46
|
+
* @param filePath - Absolute or relative file path (for the finding).
|
|
47
|
+
* @param content - The full file content string.
|
|
48
|
+
* @param symbols - Existing symbol rows for this file (for function-length analysis).
|
|
49
|
+
* @returns An array of debt findings.
|
|
50
|
+
*/
|
|
51
|
+
export function scanDebt(
|
|
52
|
+
filePath: string,
|
|
53
|
+
content: string,
|
|
54
|
+
symbols: SymbolSpan[],
|
|
55
|
+
): DebtFinding[] {
|
|
56
|
+
const findings: DebtFinding[] = [];
|
|
57
|
+
const lines = content ? content.split("\n") : [];
|
|
58
|
+
|
|
59
|
+
// --- Scan markers in content ---
|
|
60
|
+
for (let i = 0; i < lines.length; i++) {
|
|
61
|
+
const line = lines[i] ?? "";
|
|
62
|
+
|
|
63
|
+
// TODO/FIXME/HACK/XXX
|
|
64
|
+
MARKER_RE.lastIndex = 0;
|
|
65
|
+
let m: RegExpExecArray | null;
|
|
66
|
+
while ((m = MARKER_RE.exec(line)) !== null) {
|
|
67
|
+
const marker = m[1]!;
|
|
68
|
+
const detail = (m[2] ?? "").trim();
|
|
69
|
+
const severity = marker === "FIXME" || marker === "HACK" ? "warn" : "info";
|
|
70
|
+
findings.push({
|
|
71
|
+
title: `${marker}: ${detail || line.trim().slice(0, 50)}`,
|
|
72
|
+
file_path: filePath,
|
|
73
|
+
line_start: i + 1,
|
|
74
|
+
line_end: i + 1,
|
|
75
|
+
marker,
|
|
76
|
+
severity,
|
|
77
|
+
metric: marker,
|
|
78
|
+
body: line.trim(),
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// @ts-ignore
|
|
83
|
+
TS_IGNORE_RE.lastIndex = 0;
|
|
84
|
+
if (TS_IGNORE_RE.test(line)) {
|
|
85
|
+
findings.push({
|
|
86
|
+
title: `@ts-ignore at line ${i + 1}`,
|
|
87
|
+
file_path: filePath,
|
|
88
|
+
line_start: i + 1,
|
|
89
|
+
line_end: i + 1,
|
|
90
|
+
marker: "@ts-ignore",
|
|
91
|
+
severity: "warn",
|
|
92
|
+
metric: "@ts-ignore",
|
|
93
|
+
body: line.trim(),
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// @ts-nocheck
|
|
98
|
+
TS_NOCHECK_RE.lastIndex = 0;
|
|
99
|
+
if (TS_NOCHECK_RE.test(line)) {
|
|
100
|
+
findings.push({
|
|
101
|
+
title: `@ts-nocheck in ${filePath}`,
|
|
102
|
+
file_path: filePath,
|
|
103
|
+
line_start: i + 1,
|
|
104
|
+
line_end: i + 1,
|
|
105
|
+
marker: "@ts-nocheck",
|
|
106
|
+
severity: "high",
|
|
107
|
+
metric: "@ts-nocheck",
|
|
108
|
+
body: line.trim(),
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// --- Oversized file heuristic ---
|
|
114
|
+
if (lines.length > LARGE_FILE_LINES) {
|
|
115
|
+
findings.push({
|
|
116
|
+
title: `Oversized file: ${lines.length} lines`,
|
|
117
|
+
file_path: filePath,
|
|
118
|
+
line_start: 0,
|
|
119
|
+
line_end: lines.length,
|
|
120
|
+
marker: "oversized-file",
|
|
121
|
+
severity: "warn",
|
|
122
|
+
metric: `${lines.length} lines`,
|
|
123
|
+
body: `File has ${lines.length} lines, exceeding the ${LARGE_FILE_LINES}-line threshold. Consider splitting into smaller modules.`,
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// --- Long function heuristic (from existing symbols) ---
|
|
128
|
+
for (const sym of symbols) {
|
|
129
|
+
if (sym.file_path !== filePath) continue;
|
|
130
|
+
const fnLength = sym.line_end - sym.line_start;
|
|
131
|
+
if (fnLength > LONG_FN_LINES) {
|
|
132
|
+
findings.push({
|
|
133
|
+
title: `Long function: ${sym.name} (${fnLength} lines)`,
|
|
134
|
+
file_path: filePath,
|
|
135
|
+
line_start: sym.line_start,
|
|
136
|
+
line_end: sym.line_end,
|
|
137
|
+
marker: "long-function",
|
|
138
|
+
severity: "warn",
|
|
139
|
+
metric: `fn length ${fnLength}`,
|
|
140
|
+
body: `Function "${sym.name}" spans ${fnLength} lines (limit: ${LONG_FN_LINES}). Consider refactoring.`,
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return findings;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Group and summarize debt findings for a file.
|
|
150
|
+
* Returns at most one finding per marker type with aggregated counts.
|
|
151
|
+
*/
|
|
152
|
+
export function summarizeDebt(findings: DebtFinding[]): DebtFinding[] {
|
|
153
|
+
if (findings.length === 0) return [];
|
|
154
|
+
|
|
155
|
+
const groups = new Map<string, DebtFinding[]>();
|
|
156
|
+
for (const f of findings) {
|
|
157
|
+
const key = f.marker;
|
|
158
|
+
if (!groups.has(key)) groups.set(key, []);
|
|
159
|
+
groups.get(key)!.push(f);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const result: DebtFinding[] = [];
|
|
163
|
+
for (const [, group] of groups) {
|
|
164
|
+
const first = group[0]!;
|
|
165
|
+
if (group.length === 1) {
|
|
166
|
+
result.push(first);
|
|
167
|
+
} else {
|
|
168
|
+
result.push({
|
|
169
|
+
...first,
|
|
170
|
+
title: `${first.marker}: ${group.length} occurrences`,
|
|
171
|
+
metric: `${first.marker} x${group.length}`,
|
|
172
|
+
body: group.map(f => ` line ${f.line_start}: ${f.body}`).join("\n"),
|
|
173
|
+
line_start: group[0]!.line_start,
|
|
174
|
+
line_end: group[group.length - 1]!.line_end,
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return result;
|
|
180
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
validateReviewPath,
|
|
4
|
+
checkReviewCap,
|
|
5
|
+
selectFilesForReview,
|
|
6
|
+
formatReviewWorklist,
|
|
7
|
+
DEFAULT_REVIEW_CAP,
|
|
8
|
+
} from './review.ts';
|
|
9
|
+
|
|
10
|
+
describe('validateReviewPath', () => {
|
|
11
|
+
it('accepts a non-empty path', () => {
|
|
12
|
+
const result = validateReviewPath('src/auth');
|
|
13
|
+
expect(result.valid).toBe(true);
|
|
14
|
+
expect(result.error).toBeUndefined();
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('rejects empty string', () => {
|
|
18
|
+
const result = validateReviewPath('');
|
|
19
|
+
expect(result.valid).toBe(false);
|
|
20
|
+
expect(result.error).toContain('Specify a path');
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('rejects whitespace-only', () => {
|
|
24
|
+
const result = validateReviewPath(' ');
|
|
25
|
+
expect(result.valid).toBe(false);
|
|
26
|
+
expect(result.error).toContain('Specify a path');
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('rejects undefined/null', () => {
|
|
30
|
+
const result = validateReviewPath(undefined as any);
|
|
31
|
+
expect(result.valid).toBe(false);
|
|
32
|
+
expect(result.error).toContain('Specify a path');
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
describe('checkReviewCap', () => {
|
|
37
|
+
it('returns ok when count within cap', () => {
|
|
38
|
+
const result = checkReviewCap(5, 25);
|
|
39
|
+
expect(result.ok).toBe(true);
|
|
40
|
+
expect(result.count).toBe(5);
|
|
41
|
+
expect(result.skipped).toBe(0);
|
|
42
|
+
expect(result.error).toBeUndefined();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('rejects when count exceeds cap', () => {
|
|
46
|
+
const result = checkReviewCap(100, 25);
|
|
47
|
+
expect(result.ok).toBe(false);
|
|
48
|
+
expect(result.count).toBe(100);
|
|
49
|
+
expect(result.skipped).toBe(75);
|
|
50
|
+
expect(result.error).toContain('Narrow your path');
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('uses default cap when not specified', () => {
|
|
54
|
+
const result = checkReviewCap(50);
|
|
55
|
+
expect(result.ok).toBe(false);
|
|
56
|
+
expect(result.error).toContain('--max');
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('accepts edge case at exactly cap', () => {
|
|
60
|
+
const result = checkReviewCap(25, 25);
|
|
61
|
+
expect(result.ok).toBe(true);
|
|
62
|
+
expect(result.count).toBe(25);
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
describe('selectFilesForReview', () => {
|
|
67
|
+
const files = [
|
|
68
|
+
'src/auth/token.ts',
|
|
69
|
+
'src/auth/session.ts',
|
|
70
|
+
'src/api/route.ts',
|
|
71
|
+
'src/api/handler.ts',
|
|
72
|
+
'src/db/query.ts',
|
|
73
|
+
];
|
|
74
|
+
|
|
75
|
+
it('selects files under a path prefix', () => {
|
|
76
|
+
const result = selectFilesForReview(files, 'src/auth', 25);
|
|
77
|
+
expect(result).toHaveLength(2);
|
|
78
|
+
expect(result).toContain('src/auth/token.ts');
|
|
79
|
+
expect(result).toContain('src/auth/session.ts');
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('respects the cap', () => {
|
|
83
|
+
const result = selectFilesForReview(files, 'src/', 3);
|
|
84
|
+
expect(result).toHaveLength(3);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('returns empty array for non-matching prefix', () => {
|
|
88
|
+
const result = selectFilesForReview(files, 'src/nonexistent', 25);
|
|
89
|
+
expect(result).toEqual([]);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('returns all matching files when under cap', () => {
|
|
93
|
+
const result = selectFilesForReview(files, 'src/api', 25);
|
|
94
|
+
expect(result).toHaveLength(2);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('returns exact cap when match count equals cap', () => {
|
|
98
|
+
const result = selectFilesForReview(files, '', 5);
|
|
99
|
+
expect(result).toHaveLength(5);
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
describe('formatReviewWorklist', () => {
|
|
104
|
+
it('formats a worklist with file list and instructions', () => {
|
|
105
|
+
const files = ['src/auth/token.ts', 'src/auth/session.ts'];
|
|
106
|
+
const result = formatReviewWorklist(files, 'src/auth');
|
|
107
|
+
expect(result).toContain('2 file(s) under');
|
|
108
|
+
expect(result).toContain('src/auth');
|
|
109
|
+
expect(result).toContain('src/auth/token.ts');
|
|
110
|
+
expect(result).toContain('src/auth/session.ts');
|
|
111
|
+
expect(result).toContain('codewalker_finding');
|
|
112
|
+
expect(result).toContain('conventions');
|
|
113
|
+
expect(result).toContain('decisions');
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('handles empty file list', () => {
|
|
117
|
+
const result = formatReviewWorklist([], 'src/auth');
|
|
118
|
+
expect(result).toContain('No files found');
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('includes the max files info when files hit the cap', () => {
|
|
122
|
+
const files = Array.from({ length: 25 }, (_, i) => `src/a${i}.ts`);
|
|
123
|
+
const result = formatReviewWorklist(files, 'src/');
|
|
124
|
+
expect(result).toContain('25');
|
|
125
|
+
expect(result).toContain('selected for review');
|
|
126
|
+
});
|
|
127
|
+
});
|