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