tlc-claude-code 2.2.1 → 2.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/.claude/agents/builder.md +17 -0
- package/.claude/commands/tlc/audit.md +12 -0
- package/.claude/commands/tlc/autofix.md +31 -0
- package/.claude/commands/tlc/build.md +98 -24
- package/.claude/commands/tlc/coverage.md +31 -0
- package/.claude/commands/tlc/discuss.md +31 -0
- package/.claude/commands/tlc/docs.md +31 -0
- package/.claude/commands/tlc/edge-cases.md +31 -0
- package/.claude/commands/tlc/guard.md +9 -0
- package/.claude/commands/tlc/init.md +12 -1
- package/.claude/commands/tlc/plan.md +31 -0
- package/.claude/commands/tlc/quick.md +31 -0
- package/.claude/commands/tlc/review.md +50 -0
- package/.claude/hooks/tlc-session-init.sh +14 -3
- package/CODING-STANDARDS.md +217 -10
- package/bin/setup-autoupdate.js +316 -87
- package/bin/setup-autoupdate.test.js +454 -34
- package/package.json +1 -1
- package/scripts/project-docs.js +1 -1
- package/server/lib/careful-patterns.js +142 -0
- package/server/lib/careful-patterns.test.js +164 -0
- package/server/lib/cli-dispatcher.js +98 -0
- package/server/lib/cli-dispatcher.test.js +249 -0
- package/server/lib/command-router.js +171 -0
- package/server/lib/command-router.test.js +336 -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/prompt-packager.js +98 -0
- package/server/lib/prompt-packager.test.js +185 -0
- package/server/lib/review-fixer.js +107 -0
- package/server/lib/review-fixer.test.js +152 -0
- package/server/lib/routing-command.js +159 -0
- package/server/lib/routing-command.test.js +290 -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/task-router-config.js +142 -0
- package/server/lib/task-router-config.test.js +428 -0
- package/server/lib/test-selector.js +127 -0
- package/server/lib/test-selector.test.js +172 -0
- package/server/setup.sh +271 -271
- package/server/templates/CLAUDE.md +6 -0
- package/server/templates/CODING-STANDARDS.md +356 -10
|
@@ -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
|
+
});
|
|
@@ -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
|
`;
|