tlc-claude-code 2.2.1 → 2.3.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/.claude/agents/builder.md +17 -0
- package/.claude/commands/tlc/audit.md +12 -0
- package/.claude/commands/tlc/build.md +67 -24
- package/.claude/commands/tlc/guard.md +9 -0
- package/.claude/commands/tlc/init.md +12 -1
- package/.claude/commands/tlc/review.md +19 -0
- package/CODING-STANDARDS.md +217 -10
- package/package.json +1 -1
- package/server/lib/careful-patterns.js +142 -0
- package/server/lib/careful-patterns.test.js +164 -0
- package/server/lib/field-report.js +92 -0
- package/server/lib/field-report.test.js +195 -0
- package/server/lib/orchestration/worktree-manager.js +133 -0
- package/server/lib/orchestration/worktree-manager.test.js +198 -0
- package/server/lib/overdrive-command.js +31 -9
- package/server/lib/overdrive-command.test.js +25 -26
- package/server/lib/review-fixer.js +107 -0
- package/server/lib/review-fixer.test.js +152 -0
- package/server/lib/scope-checker.js +127 -0
- package/server/lib/scope-checker.test.js +175 -0
- package/server/lib/skill-validator.js +165 -0
- package/server/lib/skill-validator.test.js +289 -0
- package/server/lib/standards/standards-injector.js +6 -0
- package/server/lib/test-selector.js +127 -0
- package/server/lib/test-selector.test.js +172 -0
- package/server/templates/CLAUDE.md +6 -0
- package/server/templates/CODING-STANDARDS.md +356 -10
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Review Fixer Module
|
|
3
|
+
* Classifies review findings as AUTO-FIX (apply immediately) or ASK (batch for user approval).
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Finding types that can be automatically fixed without user input.
|
|
8
|
+
* These are mechanical, low-risk corrections.
|
|
9
|
+
*/
|
|
10
|
+
const AUTO_FIX_TYPES = {
|
|
11
|
+
any_type: 'Type annotation can be inferred or added mechanically',
|
|
12
|
+
missing_return_type: 'Return type can be inferred from implementation',
|
|
13
|
+
console_log: 'Debug logging can be safely removed',
|
|
14
|
+
missing_test_skeleton: 'Test skeleton can be generated from function signature',
|
|
15
|
+
hardcoded_url: 'URL can be extracted to environment variable',
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Finding types that require user decision.
|
|
20
|
+
* These involve judgment, security, or architectural choices.
|
|
21
|
+
*/
|
|
22
|
+
const ASK_TYPES = {
|
|
23
|
+
ownership_missing: 'Ownership assignment requires team decision',
|
|
24
|
+
secrets_in_response: 'Secret handling requires security review',
|
|
25
|
+
architectural_decision: 'Architecture changes need explicit approval',
|
|
26
|
+
ambiguous_security: 'Security implications need human assessment',
|
|
27
|
+
scope_creep: 'Scope change requires product owner approval',
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Classify a review finding into auto-fix or ask category
|
|
32
|
+
* @param {string} type - The finding type identifier
|
|
33
|
+
* @returns {{ action: 'auto-fix' | 'ask', reason: string }} Classification result
|
|
34
|
+
*/
|
|
35
|
+
function classifyFinding(type) {
|
|
36
|
+
if (!type || typeof type !== 'string') {
|
|
37
|
+
return { action: 'ask', reason: 'Invalid or missing finding type' };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (AUTO_FIX_TYPES[type]) {
|
|
41
|
+
return { action: 'auto-fix', reason: AUTO_FIX_TYPES[type] };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (ASK_TYPES[type]) {
|
|
45
|
+
return { action: 'ask', reason: ASK_TYPES[type] };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return { action: 'ask', reason: `Finding type "${type}" is unknown — defaulting to ask for safety` };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Split an array of findings into auto-fix and ask buckets
|
|
53
|
+
* @param {Array<{ type: string }>} findings - Array of finding objects with a type property
|
|
54
|
+
* @returns {{ autoFix: Array, ask: Array }} Separated findings
|
|
55
|
+
*/
|
|
56
|
+
function splitFindings(findings) {
|
|
57
|
+
if (!Array.isArray(findings)) {
|
|
58
|
+
return { autoFix: [], ask: [] };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const autoFix = [];
|
|
62
|
+
const ask = [];
|
|
63
|
+
|
|
64
|
+
for (const finding of findings) {
|
|
65
|
+
const classification = classifyFinding(finding.type);
|
|
66
|
+
if (classification.action === 'auto-fix') {
|
|
67
|
+
autoFix.push(finding);
|
|
68
|
+
} else {
|
|
69
|
+
ask.push(finding);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return { autoFix, ask };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Format a markdown summary report of fix results
|
|
78
|
+
* @param {number} autoFixed - Count of automatically fixed findings
|
|
79
|
+
* @param {number} needsInput - Count of findings needing user input
|
|
80
|
+
* @returns {string} Formatted markdown report
|
|
81
|
+
*/
|
|
82
|
+
function formatFixReport(autoFixed, needsInput) {
|
|
83
|
+
const total = autoFixed + needsInput;
|
|
84
|
+
|
|
85
|
+
const lines = [
|
|
86
|
+
`## Review Fix Report`,
|
|
87
|
+
'',
|
|
88
|
+
`**${total}** findings processed.`,
|
|
89
|
+
'',
|
|
90
|
+
`### Auto-Fixed: ${autoFixed}`,
|
|
91
|
+
'',
|
|
92
|
+
autoFixed > 0
|
|
93
|
+
? `${autoFixed} finding(s) were automatically resolved.`
|
|
94
|
+
: 'No findings were auto-fixed.',
|
|
95
|
+
'',
|
|
96
|
+
`### Needs Input: ${needsInput}`,
|
|
97
|
+
'',
|
|
98
|
+
needsInput > 0
|
|
99
|
+
? `${needsInput} finding(s) require your review and decision.`
|
|
100
|
+
: 'No findings need your input.',
|
|
101
|
+
];
|
|
102
|
+
|
|
103
|
+
return lines.join('\n');
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
module.exports = { classifyFinding, splitFindings, formatFixReport };
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
classifyFinding,
|
|
4
|
+
splitFindings,
|
|
5
|
+
formatFixReport,
|
|
6
|
+
} from './review-fixer.js';
|
|
7
|
+
|
|
8
|
+
describe('review-fixer', () => {
|
|
9
|
+
describe('classifyFinding', () => {
|
|
10
|
+
it('classifies any_type as auto-fix', () => {
|
|
11
|
+
const result = classifyFinding('any_type');
|
|
12
|
+
expect(result.action).toBe('auto-fix');
|
|
13
|
+
expect(result.reason).toBeTypeOf('string');
|
|
14
|
+
expect(result.reason.length).toBeGreaterThan(0);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('classifies ownership_missing as ask', () => {
|
|
18
|
+
const result = classifyFinding('ownership_missing');
|
|
19
|
+
expect(result.action).toBe('ask');
|
|
20
|
+
expect(result.reason).toBeTypeOf('string');
|
|
21
|
+
expect(result.reason.length).toBeGreaterThan(0);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('classifies console_log as auto-fix', () => {
|
|
25
|
+
const result = classifyFinding('console_log');
|
|
26
|
+
expect(result.action).toBe('auto-fix');
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('classifies missing_return_type as auto-fix', () => {
|
|
30
|
+
const result = classifyFinding('missing_return_type');
|
|
31
|
+
expect(result.action).toBe('auto-fix');
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('classifies missing_test_skeleton as auto-fix', () => {
|
|
35
|
+
const result = classifyFinding('missing_test_skeleton');
|
|
36
|
+
expect(result.action).toBe('auto-fix');
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('classifies hardcoded_url as auto-fix', () => {
|
|
40
|
+
const result = classifyFinding('hardcoded_url');
|
|
41
|
+
expect(result.action).toBe('auto-fix');
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('classifies secrets_in_response as ask', () => {
|
|
45
|
+
const result = classifyFinding('secrets_in_response');
|
|
46
|
+
expect(result.action).toBe('ask');
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('classifies architectural_decision as ask', () => {
|
|
50
|
+
const result = classifyFinding('architectural_decision');
|
|
51
|
+
expect(result.action).toBe('ask');
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('classifies ambiguous_security as ask', () => {
|
|
55
|
+
const result = classifyFinding('ambiguous_security');
|
|
56
|
+
expect(result.action).toBe('ask');
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('classifies scope_creep as ask', () => {
|
|
60
|
+
const result = classifyFinding('scope_creep');
|
|
61
|
+
expect(result.action).toBe('ask');
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('defaults unknown types to ask (safe default)', () => {
|
|
65
|
+
const result = classifyFinding('unknown_type');
|
|
66
|
+
expect(result.action).toBe('ask');
|
|
67
|
+
expect(result.reason).toContain('unknown');
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('defaults another unrecognized type to ask', () => {
|
|
71
|
+
const result = classifyFinding('some_random_finding');
|
|
72
|
+
expect(result.action).toBe('ask');
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
describe('splitFindings', () => {
|
|
77
|
+
it('correctly separates auto-fix from ask findings', () => {
|
|
78
|
+
const findings = [
|
|
79
|
+
{ type: 'any_type', file: 'a.js', line: 1 },
|
|
80
|
+
{ type: 'ownership_missing', file: 'b.js', line: 5 },
|
|
81
|
+
{ type: 'console_log', file: 'c.js', line: 10 },
|
|
82
|
+
{ type: 'architectural_decision', file: 'd.js', line: 3 },
|
|
83
|
+
{ type: 'hardcoded_url', file: 'e.js', line: 7 },
|
|
84
|
+
];
|
|
85
|
+
|
|
86
|
+
const result = splitFindings(findings);
|
|
87
|
+
|
|
88
|
+
expect(result.autoFix).toHaveLength(3);
|
|
89
|
+
expect(result.ask).toHaveLength(2);
|
|
90
|
+
|
|
91
|
+
expect(result.autoFix.map(f => f.type)).toContain('any_type');
|
|
92
|
+
expect(result.autoFix.map(f => f.type)).toContain('console_log');
|
|
93
|
+
expect(result.autoFix.map(f => f.type)).toContain('hardcoded_url');
|
|
94
|
+
|
|
95
|
+
expect(result.ask.map(f => f.type)).toContain('ownership_missing');
|
|
96
|
+
expect(result.ask.map(f => f.type)).toContain('architectural_decision');
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('returns empty arrays for empty input', () => {
|
|
100
|
+
const result = splitFindings([]);
|
|
101
|
+
expect(result.autoFix).toEqual([]);
|
|
102
|
+
expect(result.ask).toEqual([]);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('handles all auto-fix findings', () => {
|
|
106
|
+
const findings = [
|
|
107
|
+
{ type: 'any_type', file: 'a.js', line: 1 },
|
|
108
|
+
{ type: 'console_log', file: 'b.js', line: 2 },
|
|
109
|
+
];
|
|
110
|
+
const result = splitFindings(findings);
|
|
111
|
+
expect(result.autoFix).toHaveLength(2);
|
|
112
|
+
expect(result.ask).toHaveLength(0);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('handles all ask findings', () => {
|
|
116
|
+
const findings = [
|
|
117
|
+
{ type: 'ownership_missing', file: 'a.js', line: 1 },
|
|
118
|
+
{ type: 'scope_creep', file: 'b.js', line: 2 },
|
|
119
|
+
];
|
|
120
|
+
const result = splitFindings(findings);
|
|
121
|
+
expect(result.autoFix).toHaveLength(0);
|
|
122
|
+
expect(result.ask).toHaveLength(2);
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
describe('formatFixReport', () => {
|
|
127
|
+
it('includes counts and section headers', () => {
|
|
128
|
+
const report = formatFixReport(3, 2);
|
|
129
|
+
expect(report).toContain('3');
|
|
130
|
+
expect(report).toContain('2');
|
|
131
|
+
expect(report).toContain('Auto-Fix');
|
|
132
|
+
expect(report).toContain('Needs Input');
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('handles zero auto-fixed', () => {
|
|
136
|
+
const report = formatFixReport(0, 4);
|
|
137
|
+
expect(report).toContain('0');
|
|
138
|
+
expect(report).toContain('4');
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('handles zero needing input', () => {
|
|
142
|
+
const report = formatFixReport(5, 0);
|
|
143
|
+
expect(report).toContain('5');
|
|
144
|
+
expect(report).toContain('0');
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('returns valid markdown', () => {
|
|
148
|
+
const report = formatFixReport(2, 1);
|
|
149
|
+
expect(report).toContain('#');
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
});
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scope Checker
|
|
3
|
+
* Compare diff files against plan task files to detect scope drift
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Extract planned file paths from PLAN.md content.
|
|
8
|
+
* Parses all `**Files:**` sections and collects listed file paths.
|
|
9
|
+
* @param {string} planContent - Raw content of a PLAN.md file
|
|
10
|
+
* @returns {string[]} Deduplicated array of planned file paths
|
|
11
|
+
*/
|
|
12
|
+
function extractPlannedFiles(planContent) {
|
|
13
|
+
if (!planContent) return [];
|
|
14
|
+
|
|
15
|
+
const files = [];
|
|
16
|
+
const seen = new Set();
|
|
17
|
+
const lines = planContent.split('\n');
|
|
18
|
+
let inFilesSection = false;
|
|
19
|
+
|
|
20
|
+
for (const line of lines) {
|
|
21
|
+
if (line.trim().startsWith('**Files:**')) {
|
|
22
|
+
inFilesSection = true;
|
|
23
|
+
continue;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
if (inFilesSection) {
|
|
27
|
+
const match = line.match(/^- (.+)/);
|
|
28
|
+
if (match) {
|
|
29
|
+
// Strip backticks, em-dashes, and parenthetical annotations like (new), (modify)
|
|
30
|
+
const filePath = match[1]
|
|
31
|
+
.replace(/`/g, '')
|
|
32
|
+
.split(' — ')[0]
|
|
33
|
+
.split(' -- ')[0]
|
|
34
|
+
.replace(/\s*\((?:new|modify|delete|update|create)\)\s*$/i, '')
|
|
35
|
+
.trim();
|
|
36
|
+
if (!seen.has(filePath)) {
|
|
37
|
+
seen.add(filePath);
|
|
38
|
+
files.push(filePath);
|
|
39
|
+
}
|
|
40
|
+
} else {
|
|
41
|
+
// Non-list line ends the Files section
|
|
42
|
+
inFilesSection = false;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return files;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Check if a file path is a test file.
|
|
52
|
+
* Matches `.test.`, `_test.`, and `test_` patterns.
|
|
53
|
+
* @param {string} filePath - File path to check
|
|
54
|
+
* @returns {boolean} True if the file is a test file
|
|
55
|
+
*/
|
|
56
|
+
function isTestFile(filePath) {
|
|
57
|
+
const name = filePath.split('/').pop() || filePath;
|
|
58
|
+
// Check filename patterns
|
|
59
|
+
const nameMatch = (
|
|
60
|
+
name.includes('.test.') ||
|
|
61
|
+
name.includes('.spec.') ||
|
|
62
|
+
name.includes('_test.') ||
|
|
63
|
+
name.startsWith('test_')
|
|
64
|
+
);
|
|
65
|
+
if (nameMatch) return true;
|
|
66
|
+
|
|
67
|
+
// Check directory-based test paths (tests/, __tests__/, test/, spec/)
|
|
68
|
+
const dirMatch = /(?:^|\/)(?:tests|__tests__|test|spec)\//.test(filePath);
|
|
69
|
+
return dirMatch;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Detect scope drift between diff files and planned files.
|
|
74
|
+
* Returns files present in diff but not in plan (drift),
|
|
75
|
+
* and files in plan but not in diff (missing).
|
|
76
|
+
* Test files are excluded from drift detection.
|
|
77
|
+
* @param {string[]} diffFiles - Files changed in the diff
|
|
78
|
+
* @param {string[]} plannedFiles - Files listed in the plan
|
|
79
|
+
* @returns {{ drift: string[], missing: string[] }} Drift report
|
|
80
|
+
*/
|
|
81
|
+
function detectDrift(diffFiles, plannedFiles) {
|
|
82
|
+
if (!diffFiles || !plannedFiles) return { drift: [], missing: [] };
|
|
83
|
+
|
|
84
|
+
const plannedSet = new Set(plannedFiles);
|
|
85
|
+
const diffSet = new Set(diffFiles);
|
|
86
|
+
|
|
87
|
+
const drift = diffFiles.filter(
|
|
88
|
+
(f) => !plannedSet.has(f) && !isTestFile(f)
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
const missing = plannedFiles.filter((f) => !diffSet.has(f));
|
|
92
|
+
|
|
93
|
+
return { drift, missing };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Format a drift report as markdown.
|
|
98
|
+
* Returns "No drift detected" when both arrays are empty.
|
|
99
|
+
* @param {string[]} drift - Files in diff but not in plan
|
|
100
|
+
* @param {string[]} missing - Files in plan but not in diff
|
|
101
|
+
* @returns {string} Formatted markdown report
|
|
102
|
+
*/
|
|
103
|
+
function formatDriftReport(drift, missing) {
|
|
104
|
+
if ((!drift || drift.length === 0) && (!missing || missing.length === 0)) {
|
|
105
|
+
return 'No drift detected';
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const sections = [];
|
|
109
|
+
|
|
110
|
+
if (drift && drift.length > 0) {
|
|
111
|
+
sections.push(
|
|
112
|
+
'### Scope Drift\n\nFiles changed but not in plan:\n\n' +
|
|
113
|
+
drift.map((f) => `- \`${f}\``).join('\n')
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (missing && missing.length > 0) {
|
|
118
|
+
sections.push(
|
|
119
|
+
'### Missing Work\n\nPlanned files not yet changed:\n\n' +
|
|
120
|
+
missing.map((f) => `- \`${f}\``).join('\n')
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return sections.join('\n\n');
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
module.exports = { extractPlannedFiles, isTestFile, detectDrift, formatDriftReport };
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scope Checker Tests
|
|
3
|
+
* Detect scope drift between plan files and actual diffs
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { describe, it, expect } from 'vitest';
|
|
7
|
+
import {
|
|
8
|
+
extractPlannedFiles,
|
|
9
|
+
detectDrift,
|
|
10
|
+
formatDriftReport,
|
|
11
|
+
} from './scope-checker.js';
|
|
12
|
+
|
|
13
|
+
describe('scope-checker', () => {
|
|
14
|
+
describe('extractPlannedFiles', () => {
|
|
15
|
+
it('extracts file paths from a Files section', () => {
|
|
16
|
+
const plan = '**Files:**\n- src/auth.ts\n- src/user.ts';
|
|
17
|
+
const result = extractPlannedFiles(plan);
|
|
18
|
+
expect(result).toEqual(['src/auth.ts', 'src/user.ts']);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('returns empty array when no Files section exists', () => {
|
|
22
|
+
const plan = 'no files section';
|
|
23
|
+
const result = extractPlannedFiles(plan);
|
|
24
|
+
expect(result).toEqual([]);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('strips parenthetical annotations like (new) and (modify)', () => {
|
|
28
|
+
const plan = '**Files:**\n- src/auth.ts (new)\n- src/user.ts (modify)\n- src/config.ts (delete)';
|
|
29
|
+
const result = extractPlannedFiles(plan);
|
|
30
|
+
expect(result).toEqual(['src/auth.ts', 'src/user.ts', 'src/config.ts']);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('strips backticks from file paths', () => {
|
|
34
|
+
const plan = '**Files:**\n- `server/lib/review-fixer.js`\n- `server/lib/scope-checker.js` — new module';
|
|
35
|
+
const result = extractPlannedFiles(plan);
|
|
36
|
+
expect(result).toEqual(['server/lib/review-fixer.js', 'server/lib/scope-checker.js']);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('collects files from multiple tasks', () => {
|
|
40
|
+
const plan = [
|
|
41
|
+
'## Task 1: Auth',
|
|
42
|
+
'**Files:**',
|
|
43
|
+
'- src/auth.ts',
|
|
44
|
+
'- src/auth.test.ts',
|
|
45
|
+
'',
|
|
46
|
+
'## Task 2: Users',
|
|
47
|
+
'**Files:**',
|
|
48
|
+
'- src/user.ts',
|
|
49
|
+
'- src/user.test.ts',
|
|
50
|
+
].join('\n');
|
|
51
|
+
const result = extractPlannedFiles(plan);
|
|
52
|
+
expect(result).toEqual([
|
|
53
|
+
'src/auth.ts',
|
|
54
|
+
'src/auth.test.ts',
|
|
55
|
+
'src/user.ts',
|
|
56
|
+
'src/user.test.ts',
|
|
57
|
+
]);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('handles file entries with descriptions after em-dash', () => {
|
|
61
|
+
const plan = '**Files:**\n- server/lib/scope-checker.js — compares diff files against plan';
|
|
62
|
+
const result = extractPlannedFiles(plan);
|
|
63
|
+
expect(result).toEqual(['server/lib/scope-checker.js']);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('returns empty array for empty string', () => {
|
|
67
|
+
const result = extractPlannedFiles('');
|
|
68
|
+
expect(result).toEqual([]);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('deduplicates file paths across tasks', () => {
|
|
72
|
+
const plan = [
|
|
73
|
+
'## Task 1',
|
|
74
|
+
'**Files:**',
|
|
75
|
+
'- src/shared.ts',
|
|
76
|
+
'',
|
|
77
|
+
'## Task 2',
|
|
78
|
+
'**Files:**',
|
|
79
|
+
'- src/shared.ts',
|
|
80
|
+
'- src/other.ts',
|
|
81
|
+
].join('\n');
|
|
82
|
+
const result = extractPlannedFiles(plan);
|
|
83
|
+
expect(result).toEqual(['src/shared.ts', 'src/other.ts']);
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
describe('detectDrift', () => {
|
|
88
|
+
it('returns no drift when diff matches plan exactly', () => {
|
|
89
|
+
const result = detectDrift(['src/auth.ts'], ['src/auth.ts']);
|
|
90
|
+
expect(result).toEqual({ drift: [], missing: [] });
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('detects extra files not in plan as drift', () => {
|
|
94
|
+
const result = detectDrift(
|
|
95
|
+
['src/auth.ts', 'src/extra.ts'],
|
|
96
|
+
['src/auth.ts']
|
|
97
|
+
);
|
|
98
|
+
expect(result).toEqual({
|
|
99
|
+
drift: ['src/extra.ts'],
|
|
100
|
+
missing: [],
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('detects planned files not in diff as missing', () => {
|
|
105
|
+
const result = detectDrift(
|
|
106
|
+
['src/auth.ts'],
|
|
107
|
+
['src/auth.ts', 'src/missing.ts']
|
|
108
|
+
);
|
|
109
|
+
expect(result).toEqual({
|
|
110
|
+
drift: [],
|
|
111
|
+
missing: ['src/missing.ts'],
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('excludes test files from drift detection', () => {
|
|
116
|
+
const result = detectDrift(['src/auth.test.ts'], []);
|
|
117
|
+
expect(result).toEqual({ drift: [], missing: [] });
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('excludes _test. pattern from drift detection', () => {
|
|
121
|
+
const result = detectDrift(['src/auth_test.go'], []);
|
|
122
|
+
expect(result).toEqual({ drift: [], missing: [] });
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('excludes test_ prefix from drift detection', () => {
|
|
126
|
+
const result = detectDrift(['test_auth.py'], []);
|
|
127
|
+
expect(result).toEqual({ drift: [], missing: [] });
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('returns empty arrays when both inputs are empty', () => {
|
|
131
|
+
const result = detectDrift([], []);
|
|
132
|
+
expect(result).toEqual({ drift: [], missing: [] });
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('handles both drift and missing simultaneously', () => {
|
|
136
|
+
const result = detectDrift(
|
|
137
|
+
['src/auth.ts', 'src/extra.ts'],
|
|
138
|
+
['src/auth.ts', 'src/missing.ts']
|
|
139
|
+
);
|
|
140
|
+
expect(result).toEqual({
|
|
141
|
+
drift: ['src/extra.ts'],
|
|
142
|
+
missing: ['src/missing.ts'],
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
describe('formatDriftReport', () => {
|
|
148
|
+
it('formats report with both drift and missing sections', () => {
|
|
149
|
+
const report = formatDriftReport(['extra.ts'], ['missing.ts']);
|
|
150
|
+
expect(report).toContain('extra.ts');
|
|
151
|
+
expect(report).toContain('missing.ts');
|
|
152
|
+
expect(report).toContain('Scope Drift');
|
|
153
|
+
expect(report).toContain('Missing');
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it('returns no drift message when both arrays are empty', () => {
|
|
157
|
+
const report = formatDriftReport([], []);
|
|
158
|
+
expect(report).toBe('No drift detected');
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it('formats report with only drift', () => {
|
|
162
|
+
const report = formatDriftReport(['extra.ts'], []);
|
|
163
|
+
expect(report).toContain('extra.ts');
|
|
164
|
+
expect(report).toContain('Scope Drift');
|
|
165
|
+
expect(report).not.toContain('Missing');
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it('formats report with only missing', () => {
|
|
169
|
+
const report = formatDriftReport([], ['missing.ts']);
|
|
170
|
+
expect(report).toContain('missing.ts');
|
|
171
|
+
expect(report).toContain('Missing');
|
|
172
|
+
expect(report).not.toContain('Scope Drift');
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
});
|