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.
@@ -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 };