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,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Skill Validator — Tier 1 (Static)
|
|
3
|
+
*
|
|
4
|
+
* Parses skill .md files and validates that referenced commands,
|
|
5
|
+
* modules, and skills actually exist. Fast, free, static analysis.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const path = require('path');
|
|
9
|
+
const realFs = require('fs');
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Extract module require references from code blocks in markdown content.
|
|
13
|
+
* Finds `require('./lib/...')` and `require('./server/lib/...')` patterns
|
|
14
|
+
* inside fenced code blocks only.
|
|
15
|
+
*
|
|
16
|
+
* @param {string} content - Markdown content to parse
|
|
17
|
+
* @returns {string[]} Array of module paths found in code blocks
|
|
18
|
+
*/
|
|
19
|
+
function extractModuleRefs(content) {
|
|
20
|
+
if (!content) return [];
|
|
21
|
+
|
|
22
|
+
const codeBlockRegex = /```(?:\w+)?\n([\s\S]*?)```/g;
|
|
23
|
+
const requireRegex = /require\(['"](\.\/(lib|server\/lib)\/[^'"]+)['"]\)/g;
|
|
24
|
+
const refs = new Set();
|
|
25
|
+
|
|
26
|
+
let blockMatch;
|
|
27
|
+
while ((blockMatch = codeBlockRegex.exec(content)) !== null) {
|
|
28
|
+
const blockContent = blockMatch[1];
|
|
29
|
+
let reqMatch;
|
|
30
|
+
while ((reqMatch = requireRegex.exec(blockContent)) !== null) {
|
|
31
|
+
refs.add(reqMatch[1]);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return [...refs];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Extract skill references from content.
|
|
40
|
+
* Finds `/tlc:...` and `Skill(skill="tlc:...")` patterns.
|
|
41
|
+
*
|
|
42
|
+
* @param {string} content - Content to parse
|
|
43
|
+
* @returns {string[]} Array of skill names (e.g., ['tlc:build', 'tlc:review'])
|
|
44
|
+
*/
|
|
45
|
+
function extractSkillRefs(content) {
|
|
46
|
+
if (!content) return [];
|
|
47
|
+
|
|
48
|
+
const refs = new Set();
|
|
49
|
+
|
|
50
|
+
// Match /tlc:name patterns (word chars plus hyphens)
|
|
51
|
+
const slashRegex = /\/tlc:([\w-]+)/g;
|
|
52
|
+
let match;
|
|
53
|
+
while ((match = slashRegex.exec(content)) !== null) {
|
|
54
|
+
refs.add(`tlc:${match[1]}`);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Match Skill(skill="tlc:name") or Skill(skill='tlc:name')
|
|
58
|
+
const skillCallRegex = /Skill\(skill=["']tlc:([\w-]+)["']\)/g;
|
|
59
|
+
while ((match = skillCallRegex.exec(content)) !== null) {
|
|
60
|
+
refs.add(`tlc:${match[1]}`);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return [...refs];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Validate that referenced module files exist on disk.
|
|
68
|
+
*
|
|
69
|
+
* @param {string[]} refs - Array of module paths from extractModuleRefs
|
|
70
|
+
* @param {string} projectDir - Project root directory
|
|
71
|
+
* @param {Object} options - Options
|
|
72
|
+
* @param {Object} [options.fs] - Filesystem implementation (for testability)
|
|
73
|
+
* @returns {Array<{type: string, ref: string}>} Array of validation errors
|
|
74
|
+
*/
|
|
75
|
+
function validateModuleRefs(refs, projectDir, options = {}) {
|
|
76
|
+
if (!refs || refs.length === 0) return [];
|
|
77
|
+
|
|
78
|
+
const fsImpl = options.fs || realFs;
|
|
79
|
+
const errors = [];
|
|
80
|
+
|
|
81
|
+
for (const ref of refs) {
|
|
82
|
+
// Skill docs use paths like require('./lib/...') which resolve relative to server/
|
|
83
|
+
// Try both project root and server/ subdirectory
|
|
84
|
+
const candidates = [
|
|
85
|
+
path.resolve(projectDir, ref),
|
|
86
|
+
path.resolve(projectDir, 'server', ref),
|
|
87
|
+
];
|
|
88
|
+
|
|
89
|
+
const found = candidates.some((absPath) => {
|
|
90
|
+
const absPathJs = absPath.endsWith('.js') ? absPath : `${absPath}.js`;
|
|
91
|
+
return fsImpl.existsSync(absPath) || fsImpl.existsSync(absPathJs);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
if (!found) {
|
|
95
|
+
errors.push({ type: 'missing-module', ref });
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return errors;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Validate that referenced skill files exist on disk.
|
|
104
|
+
* Maps `tlc:build` to `build.md` in the skill directory.
|
|
105
|
+
*
|
|
106
|
+
* @param {string[]} refs - Array of skill names from extractSkillRefs
|
|
107
|
+
* @param {string} skillDir - Directory containing skill .md files
|
|
108
|
+
* @param {Object} options - Options
|
|
109
|
+
* @param {Object} [options.fs] - Filesystem implementation (for testability)
|
|
110
|
+
* @returns {Array<{type: string, ref: string}>} Array of validation errors
|
|
111
|
+
*/
|
|
112
|
+
function validateSkillRefs(refs, skillDir, options = {}) {
|
|
113
|
+
if (!refs || refs.length === 0) return [];
|
|
114
|
+
|
|
115
|
+
const fsImpl = options.fs || realFs;
|
|
116
|
+
const errors = [];
|
|
117
|
+
|
|
118
|
+
for (const ref of refs) {
|
|
119
|
+
// tlc:build -> build.md
|
|
120
|
+
const name = ref.replace(/^tlc:/, '');
|
|
121
|
+
const filePath = path.join(skillDir, `${name}.md`);
|
|
122
|
+
|
|
123
|
+
if (!fsImpl.existsSync(filePath)) {
|
|
124
|
+
errors.push({ type: 'missing-skill', ref });
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return errors;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Validate a skill markdown file by running all static checks.
|
|
133
|
+
*
|
|
134
|
+
* @param {string} content - Skill file markdown content
|
|
135
|
+
* @param {Object} options - Validation options
|
|
136
|
+
* @param {string} options.projectDir - Project root directory
|
|
137
|
+
* @param {string} options.skillDir - Directory containing skill .md files
|
|
138
|
+
* @param {Object} [options.fs] - Filesystem implementation (for testability)
|
|
139
|
+
* @returns {{valid: boolean, errors: Array<{type: string, ref: string}>}} Validation result
|
|
140
|
+
*/
|
|
141
|
+
function validateSkill(content, options = {}) {
|
|
142
|
+
if (!content) return { valid: true, errors: [] };
|
|
143
|
+
if (!options.projectDir || !options.skillDir) {
|
|
144
|
+
return { valid: false, errors: [{ type: 'config', ref: 'projectDir and skillDir are required' }] };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const { projectDir, skillDir, fs: fsImpl } = options;
|
|
148
|
+
const fsOpt = fsImpl ? { fs: fsImpl } : {};
|
|
149
|
+
|
|
150
|
+
const moduleRefs = extractModuleRefs(content);
|
|
151
|
+
const skillRefs = extractSkillRefs(content);
|
|
152
|
+
|
|
153
|
+
const moduleErrors = validateModuleRefs(moduleRefs, projectDir, fsOpt);
|
|
154
|
+
const skillErrors = validateSkillRefs(skillRefs, skillDir, fsOpt);
|
|
155
|
+
|
|
156
|
+
const errors = [...moduleErrors, ...skillErrors];
|
|
157
|
+
|
|
158
|
+
return {
|
|
159
|
+
valid: errors.length === 0,
|
|
160
|
+
errors,
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
module.exports = { extractModuleRefs, extractSkillRefs, validateModuleRefs, validateSkillRefs, validateSkill };
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Skill Validator Tests
|
|
3
|
+
*
|
|
4
|
+
* Static analysis for skill .md files — validates that referenced
|
|
5
|
+
* commands, modules, and skills actually exist.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { describe, it, expect } from 'vitest';
|
|
9
|
+
import {
|
|
10
|
+
extractModuleRefs,
|
|
11
|
+
extractSkillRefs,
|
|
12
|
+
validateModuleRefs,
|
|
13
|
+
validateSkillRefs,
|
|
14
|
+
validateSkill,
|
|
15
|
+
} from './skill-validator.js';
|
|
16
|
+
|
|
17
|
+
describe('skill-validator', () => {
|
|
18
|
+
describe('extractModuleRefs', () => {
|
|
19
|
+
it('extracts require from code block', () => {
|
|
20
|
+
const content = "```js\nrequire('./lib/foo');\n```";
|
|
21
|
+
expect(extractModuleRefs(content)).toEqual(['./lib/foo']);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('returns empty array when no code blocks', () => {
|
|
25
|
+
expect(extractModuleRefs('no code blocks here')).toEqual([]);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('extracts multiple requires from code blocks', () => {
|
|
29
|
+
const content = [
|
|
30
|
+
'```js',
|
|
31
|
+
"const a = require('./lib/foo');",
|
|
32
|
+
"const b = require('./lib/bar');",
|
|
33
|
+
'```',
|
|
34
|
+
'',
|
|
35
|
+
'Some text in between.',
|
|
36
|
+
'',
|
|
37
|
+
'```js',
|
|
38
|
+
"const c = require('./server/lib/baz');",
|
|
39
|
+
'```',
|
|
40
|
+
].join('\n');
|
|
41
|
+
expect(extractModuleRefs(content)).toEqual([
|
|
42
|
+
'./lib/foo',
|
|
43
|
+
'./lib/bar',
|
|
44
|
+
'./server/lib/baz',
|
|
45
|
+
]);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('ignores requires outside code blocks', () => {
|
|
49
|
+
const content = "require('./lib/should-not-match');\nSome text.";
|
|
50
|
+
expect(extractModuleRefs(content)).toEqual([]);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('handles code blocks with language specifiers', () => {
|
|
54
|
+
const content = "```javascript\nrequire('./lib/utils');\n```";
|
|
55
|
+
expect(extractModuleRefs(content)).toEqual(['./lib/utils']);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('returns empty array for empty string', () => {
|
|
59
|
+
expect(extractModuleRefs('')).toEqual([]);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('deduplicates repeated refs', () => {
|
|
63
|
+
const content = [
|
|
64
|
+
'```js',
|
|
65
|
+
"require('./lib/foo');",
|
|
66
|
+
"require('./lib/foo');",
|
|
67
|
+
'```',
|
|
68
|
+
].join('\n');
|
|
69
|
+
expect(extractModuleRefs(content)).toEqual(['./lib/foo']);
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
describe('extractSkillRefs', () => {
|
|
74
|
+
it('extracts /tlc: skill references', () => {
|
|
75
|
+
expect(extractSkillRefs('Run /tlc:build to start')).toEqual([
|
|
76
|
+
'tlc:build',
|
|
77
|
+
]);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('extracts Skill() call references', () => {
|
|
81
|
+
expect(extractSkillRefs('Skill(skill="tlc:review")')).toEqual([
|
|
82
|
+
'tlc:review',
|
|
83
|
+
]);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('returns empty array when no skills referenced', () => {
|
|
87
|
+
expect(extractSkillRefs('no skills here')).toEqual([]);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('extracts multiple skill references', () => {
|
|
91
|
+
const content =
|
|
92
|
+
'Use /tlc:build then /tlc:review. Also Skill(skill="tlc:plan").';
|
|
93
|
+
const refs = extractSkillRefs(content);
|
|
94
|
+
expect(refs).toContain('tlc:build');
|
|
95
|
+
expect(refs).toContain('tlc:review');
|
|
96
|
+
expect(refs).toContain('tlc:plan');
|
|
97
|
+
expect(refs).toHaveLength(3);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('deduplicates repeated skill refs', () => {
|
|
101
|
+
const content = 'Run /tlc:build and /tlc:build again';
|
|
102
|
+
expect(extractSkillRefs(content)).toEqual(['tlc:build']);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('returns empty array for empty string', () => {
|
|
106
|
+
expect(extractSkillRefs('')).toEqual([]);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('handles single-quoted Skill() calls', () => {
|
|
110
|
+
expect(extractSkillRefs("Skill(skill='tlc:deploy')")).toEqual([
|
|
111
|
+
'tlc:deploy',
|
|
112
|
+
]);
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
describe('validateModuleRefs', () => {
|
|
117
|
+
it('returns no errors when files exist', () => {
|
|
118
|
+
const mockFs = {
|
|
119
|
+
existsSync: () => true,
|
|
120
|
+
};
|
|
121
|
+
const errors = validateModuleRefs(['./lib/foo'], '/project', {
|
|
122
|
+
fs: mockFs,
|
|
123
|
+
});
|
|
124
|
+
expect(errors).toEqual([]);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('returns error when file is missing', () => {
|
|
128
|
+
const mockFs = {
|
|
129
|
+
existsSync: () => false,
|
|
130
|
+
};
|
|
131
|
+
const errors = validateModuleRefs(['./lib/foo'], '/project', {
|
|
132
|
+
fs: mockFs,
|
|
133
|
+
});
|
|
134
|
+
expect(errors).toEqual([
|
|
135
|
+
{ type: 'missing-module', ref: './lib/foo' },
|
|
136
|
+
]);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('checks with .js extension fallback', () => {
|
|
140
|
+
const checked = [];
|
|
141
|
+
const mockFs = {
|
|
142
|
+
existsSync: (p) => {
|
|
143
|
+
checked.push(p);
|
|
144
|
+
return false;
|
|
145
|
+
},
|
|
146
|
+
};
|
|
147
|
+
validateModuleRefs(['./lib/foo'], '/project', { fs: mockFs });
|
|
148
|
+
// Should check both with and without .js
|
|
149
|
+
expect(checked.some((p) => p.endsWith('foo'))).toBe(true);
|
|
150
|
+
expect(checked.some((p) => p.endsWith('foo.js'))).toBe(true);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it('returns no errors for empty refs', () => {
|
|
154
|
+
const errors = validateModuleRefs([], '/project', {
|
|
155
|
+
fs: { existsSync: () => false },
|
|
156
|
+
});
|
|
157
|
+
expect(errors).toEqual([]);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it('reports multiple missing modules', () => {
|
|
161
|
+
const mockFs = { existsSync: () => false };
|
|
162
|
+
const errors = validateModuleRefs(
|
|
163
|
+
['./lib/a', './lib/b'],
|
|
164
|
+
'/project',
|
|
165
|
+
{ fs: mockFs }
|
|
166
|
+
);
|
|
167
|
+
expect(errors).toHaveLength(2);
|
|
168
|
+
expect(errors[0].ref).toBe('./lib/a');
|
|
169
|
+
expect(errors[1].ref).toBe('./lib/b');
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
describe('validateSkillRefs', () => {
|
|
174
|
+
it('returns no errors when skill files exist', () => {
|
|
175
|
+
const mockFs = {
|
|
176
|
+
existsSync: () => true,
|
|
177
|
+
};
|
|
178
|
+
const errors = validateSkillRefs(['tlc:build'], '/skills', {
|
|
179
|
+
fs: mockFs,
|
|
180
|
+
});
|
|
181
|
+
expect(errors).toEqual([]);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it('returns error when skill file is missing', () => {
|
|
185
|
+
const mockFs = {
|
|
186
|
+
existsSync: () => false,
|
|
187
|
+
};
|
|
188
|
+
const errors = validateSkillRefs(['tlc:build'], '/skills', {
|
|
189
|
+
fs: mockFs,
|
|
190
|
+
});
|
|
191
|
+
expect(errors).toEqual([
|
|
192
|
+
{ type: 'missing-skill', ref: 'tlc:build' },
|
|
193
|
+
]);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it('maps skill name to correct file path', () => {
|
|
197
|
+
const checked = [];
|
|
198
|
+
const mockFs = {
|
|
199
|
+
existsSync: (p) => {
|
|
200
|
+
checked.push(p);
|
|
201
|
+
return true;
|
|
202
|
+
},
|
|
203
|
+
};
|
|
204
|
+
validateSkillRefs(['tlc:build'], '/skills', { fs: mockFs });
|
|
205
|
+
expect(checked[0]).toMatch(/build\.md$/);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it('returns no errors for empty refs', () => {
|
|
209
|
+
const errors = validateSkillRefs([], '/skills', {
|
|
210
|
+
fs: { existsSync: () => false },
|
|
211
|
+
});
|
|
212
|
+
expect(errors).toEqual([]);
|
|
213
|
+
});
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
describe('validateSkill', () => {
|
|
217
|
+
it('returns valid for clean content', () => {
|
|
218
|
+
const result = validateSkill('Just plain text, no refs.', {
|
|
219
|
+
projectDir: '/project',
|
|
220
|
+
skillDir: '/skills',
|
|
221
|
+
fs: { existsSync: () => true },
|
|
222
|
+
});
|
|
223
|
+
expect(result).toEqual({ valid: true, errors: [] });
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it('returns invalid with errors for broken module refs', () => {
|
|
227
|
+
const content = "```js\nrequire('./lib/nonexistent');\n```";
|
|
228
|
+
const result = validateSkill(content, {
|
|
229
|
+
projectDir: '/project',
|
|
230
|
+
skillDir: '/skills',
|
|
231
|
+
fs: { existsSync: () => false },
|
|
232
|
+
});
|
|
233
|
+
expect(result.valid).toBe(false);
|
|
234
|
+
expect(result.errors).toHaveLength(1);
|
|
235
|
+
expect(result.errors[0]).toEqual({
|
|
236
|
+
type: 'missing-module',
|
|
237
|
+
ref: './lib/nonexistent',
|
|
238
|
+
});
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it('returns invalid with errors for broken skill refs', () => {
|
|
242
|
+
const content = 'Run /tlc:nonexistent to start.';
|
|
243
|
+
const result = validateSkill(content, {
|
|
244
|
+
projectDir: '/project',
|
|
245
|
+
skillDir: '/skills',
|
|
246
|
+
fs: { existsSync: () => false },
|
|
247
|
+
});
|
|
248
|
+
expect(result.valid).toBe(false);
|
|
249
|
+
expect(result.errors).toHaveLength(1);
|
|
250
|
+
expect(result.errors[0]).toEqual({
|
|
251
|
+
type: 'missing-skill',
|
|
252
|
+
ref: 'tlc:nonexistent',
|
|
253
|
+
});
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
it('aggregates both module and skill errors', () => {
|
|
257
|
+
const content = [
|
|
258
|
+
'```js',
|
|
259
|
+
"require('./lib/missing');",
|
|
260
|
+
'```',
|
|
261
|
+
'Use /tlc:fake to run.',
|
|
262
|
+
].join('\n');
|
|
263
|
+
const result = validateSkill(content, {
|
|
264
|
+
projectDir: '/project',
|
|
265
|
+
skillDir: '/skills',
|
|
266
|
+
fs: { existsSync: () => false },
|
|
267
|
+
});
|
|
268
|
+
expect(result.valid).toBe(false);
|
|
269
|
+
expect(result.errors).toHaveLength(2);
|
|
270
|
+
expect(result.errors.find((e) => e.type === 'missing-module')).toBeTruthy();
|
|
271
|
+
expect(result.errors.find((e) => e.type === 'missing-skill')).toBeTruthy();
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
it('returns valid when all refs exist', () => {
|
|
275
|
+
const content = [
|
|
276
|
+
'```js',
|
|
277
|
+
"require('./lib/real');",
|
|
278
|
+
'```',
|
|
279
|
+
'Use /tlc:build to run.',
|
|
280
|
+
].join('\n');
|
|
281
|
+
const result = validateSkill(content, {
|
|
282
|
+
projectDir: '/project',
|
|
283
|
+
skillDir: '/skills',
|
|
284
|
+
fs: { existsSync: () => true },
|
|
285
|
+
});
|
|
286
|
+
expect(result).toEqual({ valid: true, errors: [] });
|
|
287
|
+
});
|
|
288
|
+
});
|
|
289
|
+
});
|
|
@@ -66,6 +66,12 @@ This project follows TLC (Test-Led Coding) standards. See [CODING-STANDARDS.md](
|
|
|
66
66
|
2. No hardcoded URLs or config - Use environment variables
|
|
67
67
|
3. No magic strings - Define constants in \`constants/\` folder
|
|
68
68
|
4. JSDoc required on all public members
|
|
69
|
+
5. No direct \`process.env\` - All config through a validated config module
|
|
70
|
+
6. Ownership checks on every data-access endpoint - Auth alone is not enough
|
|
71
|
+
7. Never expose secrets in responses - API keys and tokens are write-only
|
|
72
|
+
8. Hash sensitive data at rest - OTPs, reset tokens, session secrets
|
|
73
|
+
9. Escape all HTML output - No raw interpolation of user values
|
|
74
|
+
10. Use DI - Never manually instantiate services with \`new\`
|
|
69
75
|
|
|
70
76
|
See [CODING-STANDARDS.md](./CODING-STANDARDS.md) for complete standards.
|
|
71
77
|
`;
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Diff-Based Test Selection
|
|
3
|
+
* Map test files to their source dependencies and select only affected tests
|
|
4
|
+
* when source files change.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Parse `// @depends path/to/file.js` comments from test file content.
|
|
9
|
+
* Supports multiple @depends lines and comma-separated paths on a single line.
|
|
10
|
+
*
|
|
11
|
+
* @param {string} testContent - The raw content of a test file
|
|
12
|
+
* @returns {string[]} Array of dependency paths extracted from @depends comments
|
|
13
|
+
*/
|
|
14
|
+
function parseDependsComment(testContent) {
|
|
15
|
+
if (!testContent) return [];
|
|
16
|
+
|
|
17
|
+
const results = [];
|
|
18
|
+
const lines = testContent.split('\n');
|
|
19
|
+
|
|
20
|
+
for (const line of lines) {
|
|
21
|
+
const trimmed = line.trim();
|
|
22
|
+
// Only match lines that start with a comment marker
|
|
23
|
+
if (!trimmed.startsWith('//')) continue;
|
|
24
|
+
|
|
25
|
+
const match = trimmed.match(/^\/\/\s*@depends\s+(.+)$/);
|
|
26
|
+
if (!match) continue;
|
|
27
|
+
|
|
28
|
+
const pathsPart = match[1];
|
|
29
|
+
const paths = pathsPart.split(',').map((p) => p.trim()).filter(Boolean);
|
|
30
|
+
results.push(...paths);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return results;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Read each test file and extract @depends comments to build a mapping
|
|
38
|
+
* from test files to their source dependencies.
|
|
39
|
+
*
|
|
40
|
+
* @param {string[]} testFiles - Array of test file paths
|
|
41
|
+
* @param {object} options - Options object
|
|
42
|
+
* @param {function} options.readFile - Async function to read file contents (path) => string
|
|
43
|
+
* @returns {Promise<Map<string, string[]>>} Map from test file path to dependency paths
|
|
44
|
+
*/
|
|
45
|
+
async function buildTestMap(testFiles, options = {}) {
|
|
46
|
+
if (!testFiles || testFiles.length === 0) return new Map();
|
|
47
|
+
|
|
48
|
+
const { readFile } = options;
|
|
49
|
+
if (!readFile) {
|
|
50
|
+
throw new Error('options.readFile is required');
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const map = new Map();
|
|
54
|
+
|
|
55
|
+
for (const testFile of testFiles) {
|
|
56
|
+
try {
|
|
57
|
+
const content = await readFile(testFile);
|
|
58
|
+
const deps = parseDependsComment(content);
|
|
59
|
+
map.set(testFile, deps);
|
|
60
|
+
} catch {
|
|
61
|
+
// If we can't read the file, treat as no-depends (conservative)
|
|
62
|
+
map.set(testFile, []);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return map;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Given a list of changed source files and a test map, return the test files
|
|
71
|
+
* whose dependencies include any changed file. Tests with no @depends
|
|
72
|
+
* annotation are always included (conservative approach).
|
|
73
|
+
*
|
|
74
|
+
* @param {string[]} changedFiles - Array of changed source file paths
|
|
75
|
+
* @param {Map<string, string[]>} testMap - Map from test file to dependency paths
|
|
76
|
+
* @returns {string[]} Array of affected test file paths
|
|
77
|
+
*/
|
|
78
|
+
function getAffectedTests(changedFiles, testMap) {
|
|
79
|
+
if (!testMap || testMap.size === 0) return [];
|
|
80
|
+
if (!changedFiles || !Array.isArray(changedFiles)) {
|
|
81
|
+
// Conservative fallback: run ALL tests when diff is unavailable
|
|
82
|
+
return [...testMap.keys()];
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const changedSet = new Set(changedFiles);
|
|
86
|
+
const affected = [];
|
|
87
|
+
|
|
88
|
+
for (const [testFile, deps] of testMap) {
|
|
89
|
+
// Always include test files that were themselves changed in the diff
|
|
90
|
+
if (changedSet.has(testFile)) {
|
|
91
|
+
affected.push(testFile);
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// No @depends means always include (conservative)
|
|
96
|
+
if (deps.length === 0) {
|
|
97
|
+
affected.push(testFile);
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Include if any dependency is in the changed set
|
|
102
|
+
const isAffected = deps.some((dep) => changedSet.has(dep));
|
|
103
|
+
if (isAffected) {
|
|
104
|
+
affected.push(testFile);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return affected;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Format a human-readable summary of test selection results.
|
|
113
|
+
*
|
|
114
|
+
* @param {number} affected - Number of affected (selected) tests
|
|
115
|
+
* @param {number} total - Total number of tests
|
|
116
|
+
* @returns {string} Human-readable selection summary
|
|
117
|
+
*/
|
|
118
|
+
function formatSelection(affected, total) {
|
|
119
|
+
if (affected === total) {
|
|
120
|
+
return `Running all ${total} tests`;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const skipped = total - affected;
|
|
124
|
+
return `Running ${affected} of ${total} tests (${skipped} skipped — dependencies unchanged)`;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
module.exports = { parseDependsComment, buildTestMap, getAffectedTests, formatSelection };
|