clckernel 1.2.6
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/AGENTS.md +124 -0
- package/README.es.md +166 -0
- package/README.md +167 -0
- package/bin/cli.js +12 -0
- package/docs/Journal/001-adr-clckernel-governance.md +18 -0
- package/package.json +44 -0
- package/src/adapters/astro.js +67 -0
- package/src/adapters/base.js +109 -0
- package/src/adapters/django.js +70 -0
- package/src/adapters/fastapi.js +69 -0
- package/src/adapters/go.js +50 -0
- package/src/adapters/laravel.js +52 -0
- package/src/adapters/nextjs.js +72 -0
- package/src/adapters/rails.js +54 -0
- package/src/adapters/rust.js +51 -0
- package/src/catalog.js +217 -0
- package/src/config.js +250 -0
- package/src/detector.js +85 -0
- package/src/doctor.js +125 -0
- package/src/generator.js +473 -0
- package/src/index.js +52 -0
- package/src/technologies/detector.js +243 -0
- package/src/technologies/guards/_shared.js +66 -0
- package/src/ui.js +101 -0
- package/src/yaml.js +173 -0
- package/test/adapters/astro.test.js +102 -0
- package/test/adapters/base.test.js +86 -0
- package/test/adapters/django.test.js +95 -0
- package/test/adapters/fastapi.test.js +88 -0
- package/test/adapters/go.test.js +81 -0
- package/test/adapters/laravel.test.js +106 -0
- package/test/adapters/nextjs.test.js +113 -0
- package/test/adapters/rails.test.js +108 -0
- package/test/adapters/rust.test.js +83 -0
- package/test/catalog.test.js +170 -0
- package/test/config.test.js +512 -0
- package/test/detector.test.js +345 -0
- package/test/doctor.test.js +302 -0
- package/test/generator.test.js +419 -0
- package/test/guards_new.test.js +95 -0
- package/test/helpers.js +36 -0
- package/test/integration/cli-flow.test.js +270 -0
- package/test/technologies/celery_guard.test.js +185 -0
- package/test/technologies/detector.test.js +342 -0
- package/test/technologies/docker_guard.test.js +187 -0
- package/test/technologies/integration.test.js +123 -0
- package/test/technologies/postgres_guard.test.js +87 -0
- package/test/technologies/redis_guard.test.js +128 -0
- package/tools/audit.js +219 -0
- package/tools/audit.py +273 -0
- package/tools/celery_guard.py +209 -0
- package/tools/check_a11y.js +109 -0
- package/tools/check_api_contracts.js +139 -0
- package/tools/check_architecture.js +139 -0
- package/tools/check_architecture.py +264 -0
- package/tools/check_custom.js +163 -0
- package/tools/check_custom.py +395 -0
- package/tools/check_db_efficiency.py +190 -0
- package/tools/check_migrations.py +223 -0
- package/tools/check_performance.js +142 -0
- package/tools/check_responsive.js +131 -0
- package/tools/check_scope.py +180 -0
- package/tools/check_seo.js +139 -0
- package/tools/check_storybook.js +108 -0
- package/tools/check_ui_reuse.js +135 -0
- package/tools/docker_guard.py +210 -0
- package/tools/postgres_guard.py +192 -0
- package/tools/redis_guard.py +197 -0
- package/tools/scan_secrets.js +109 -0
- package/tools/scan_secrets.py +153 -0
- package/tools/verify_tdd.py +212 -0
|
@@ -0,0 +1,512 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLC Forge — Config tests for the extracted zero-dependency YAML parser.
|
|
3
|
+
* Covers: parser reuse (src/yaml.js), unsupported anchor/alias syntax,
|
|
4
|
+
* existing custom-rules behavior via tools/check_custom.js subprocess,
|
|
5
|
+
* and `../src/yaml.js` resolution from a generated-style layout.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const { describe, it } = require('node:test');
|
|
9
|
+
const assert = require('node:assert');
|
|
10
|
+
const { spawn, spawnSync } = require('child_process');
|
|
11
|
+
const path = require('path');
|
|
12
|
+
const fs = require('fs');
|
|
13
|
+
const { createTmpProject, teardown } = require('./helpers');
|
|
14
|
+
|
|
15
|
+
const { parseYaml } = require('../src/yaml.js');
|
|
16
|
+
|
|
17
|
+
const {
|
|
18
|
+
loadConfig,
|
|
19
|
+
validateConfig,
|
|
20
|
+
resolveConfig,
|
|
21
|
+
ConfigError,
|
|
22
|
+
HARNESS_PHASES,
|
|
23
|
+
ENV_SEVERITIES,
|
|
24
|
+
} = require('../src/config.js');
|
|
25
|
+
const { generateHarness } = require('../src/generator.js');
|
|
26
|
+
|
|
27
|
+
const CHECK_CUSTOM = path.join(__dirname, '..', 'tools', 'check_custom.js');
|
|
28
|
+
|
|
29
|
+
const VALID_CONFIG = `rules:
|
|
30
|
+
- name: no-debugger
|
|
31
|
+
pattern: 'debugger'
|
|
32
|
+
message: 'No debugger statements'
|
|
33
|
+
severity: error
|
|
34
|
+
scope: '**/*.js'
|
|
35
|
+
meta: {owner: team, tier: 1}
|
|
36
|
+
- name: no-todo
|
|
37
|
+
pattern: 'TODO'
|
|
38
|
+
message: 'No TODOs'
|
|
39
|
+
severity: warning
|
|
40
|
+
exclude: ['**/docs/**']
|
|
41
|
+
`;
|
|
42
|
+
|
|
43
|
+
const EXPECTED_CONFIG = {
|
|
44
|
+
rules: [
|
|
45
|
+
{
|
|
46
|
+
name: 'no-debugger',
|
|
47
|
+
pattern: 'debugger',
|
|
48
|
+
message: 'No debugger statements',
|
|
49
|
+
severity: 'error',
|
|
50
|
+
scope: '**/*.js',
|
|
51
|
+
meta: { owner: 'team', tier: 1 },
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
name: 'no-todo',
|
|
55
|
+
pattern: 'TODO',
|
|
56
|
+
message: 'No TODOs',
|
|
57
|
+
severity: 'warning',
|
|
58
|
+
exclude: ['**/docs/**'],
|
|
59
|
+
},
|
|
60
|
+
],
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Run tools/check_custom.js (or a copy at the given path) against a
|
|
65
|
+
* target directory. Resolves with { parsed, exitCode }.
|
|
66
|
+
* @param {string} script - Absolute path to check_custom.js
|
|
67
|
+
* @param {string} targetDir - Directory to scan
|
|
68
|
+
* @returns {Promise<{parsed: object, exitCode: number}>}
|
|
69
|
+
*/
|
|
70
|
+
function runCheckCustom(script, targetDir) {
|
|
71
|
+
return new Promise((resolve, reject) => {
|
|
72
|
+
const proc = spawn(process.execPath, [script, targetDir], { cwd: targetDir });
|
|
73
|
+
let stdout = '';
|
|
74
|
+
proc.stdout.on('data', (d) => (stdout += d));
|
|
75
|
+
proc.on('close', (code) => {
|
|
76
|
+
try {
|
|
77
|
+
resolve({ parsed: JSON.parse(stdout), exitCode: code });
|
|
78
|
+
} catch {
|
|
79
|
+
reject(new Error(`Failed to parse output: ${stdout}`));
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
proc.on('error', reject);
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Initialize a git repo with an initial commit, so
|
|
88
|
+
* `git diff --name-only HEAD~1` fails and check_custom
|
|
89
|
+
* falls back to `git diff --name-only --cached`.
|
|
90
|
+
* @param {string} dir - Target directory
|
|
91
|
+
*/
|
|
92
|
+
function initGitRepo(dir) {
|
|
93
|
+
spawnSync('git', ['init'], { cwd: dir });
|
|
94
|
+
spawnSync('git', ['config', 'user.email', 'test@test.com'], { cwd: dir });
|
|
95
|
+
spawnSync('git', ['config', 'user.name', 'Test'], { cwd: dir });
|
|
96
|
+
spawnSync('git', ['add', '.'], { cwd: dir });
|
|
97
|
+
spawnSync('git', ['commit', '-m', 'initial'], { cwd: dir });
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
describe('config: src/yaml.js parser reuse', () => {
|
|
101
|
+
it('parses a valid .clc-forge.yml with scalars, block sequence, flow mapping', () => {
|
|
102
|
+
assert.deepStrictEqual(parseYaml(VALID_CONFIG), EXPECTED_CONFIG);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('parses quoted and unquoted scalars into their typed values', () => {
|
|
106
|
+
const cfg = 'enabled: true\ncount: 42\nratio: 1.5\nnothing: null\n';
|
|
107
|
+
assert.deepStrictEqual(parseYaml(cfg), {
|
|
108
|
+
enabled: true,
|
|
109
|
+
count: 42,
|
|
110
|
+
ratio: 1.5,
|
|
111
|
+
nothing: null,
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('strips comments and skips blank lines', () => {
|
|
116
|
+
const cfg = '# top comment\nkey: value # trailing\n\nother: x\n';
|
|
117
|
+
assert.deepStrictEqual(parseYaml(cfg), { key: 'value', other: 'x' });
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
describe('config: unsupported YAML syntax', () => {
|
|
122
|
+
it('throws on anchor syntax (&name)', () => {
|
|
123
|
+
assert.throws(
|
|
124
|
+
() => parseYaml('defaults: &defaults\n a: 1\n'),
|
|
125
|
+
/Unsupported YAML syntax at line 1: anchors\/aliases are not supported/
|
|
126
|
+
);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('throws on alias syntax (*name)', () => {
|
|
130
|
+
assert.throws(
|
|
131
|
+
() => parseYaml('db: *defaults\n'),
|
|
132
|
+
/Unsupported YAML syntax at line 1: anchors\/aliases are not supported/
|
|
133
|
+
);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('throws on anchor inside a sequence item', () => {
|
|
137
|
+
assert.throws(
|
|
138
|
+
() => parseYaml('rules:\n - &item name: x\n'),
|
|
139
|
+
/Unsupported YAML syntax at line 2: anchors\/aliases are not supported/
|
|
140
|
+
);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('reports the exact line number for nested content', () => {
|
|
144
|
+
assert.throws(
|
|
145
|
+
() => parseYaml('rules:\n - name: a\n template: &tpl shared\n'),
|
|
146
|
+
/Unsupported YAML syntax at line 3/
|
|
147
|
+
);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('does not false-positive on regex patterns or globs', () => {
|
|
151
|
+
const cfg = `rules:
|
|
152
|
+
- name: r
|
|
153
|
+
pattern: 'a*b'
|
|
154
|
+
message: 'm'
|
|
155
|
+
scope: '**/*.js'
|
|
156
|
+
exclude: ['**/docs/**', '*.min.js']
|
|
157
|
+
`;
|
|
158
|
+
assert.deepStrictEqual(parseYaml(cfg), {
|
|
159
|
+
rules: [
|
|
160
|
+
{
|
|
161
|
+
name: 'r',
|
|
162
|
+
pattern: 'a*b',
|
|
163
|
+
message: 'm',
|
|
164
|
+
scope: '**/*.js',
|
|
165
|
+
exclude: ['**/docs/**', '*.min.js'],
|
|
166
|
+
},
|
|
167
|
+
],
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
describe('config: check_custom behavior preserved (subprocess)', () => {
|
|
173
|
+
it('produces the same output shape (name, exitCode, skipped, data)', async () => {
|
|
174
|
+
const dir = createTmpProject({
|
|
175
|
+
'.clc-forge.yml': VALID_CONFIG,
|
|
176
|
+
'src/app.js': 'module.exports = 1;\n',
|
|
177
|
+
});
|
|
178
|
+
initGitRepo(dir);
|
|
179
|
+
|
|
180
|
+
try {
|
|
181
|
+
// Introduce a violation, then stage it so check_custom's
|
|
182
|
+
// fallback diff (`git diff --name-only --cached`) sees it.
|
|
183
|
+
fs.writeFileSync(path.join(dir, 'src/app.js'), 'debugger;\nmodule.exports = 1;\n');
|
|
184
|
+
spawnSync('git', ['add', 'src/app.js'], { cwd: dir });
|
|
185
|
+
|
|
186
|
+
const { parsed, exitCode } = await runCheckCustom(CHECK_CUSTOM, dir);
|
|
187
|
+
|
|
188
|
+
assert.strictEqual(parsed.name, 'check_custom');
|
|
189
|
+
assert.strictEqual(parsed.skipped, false);
|
|
190
|
+
assert.strictEqual(parsed.exitCode, 1);
|
|
191
|
+
assert.strictEqual(exitCode, 1);
|
|
192
|
+
assert.ok(parsed.data, 'data key present');
|
|
193
|
+
assert.ok(parsed.data.violations, 'violations key present');
|
|
194
|
+
assert.strictEqual(parsed.data.violations['src/app.js'].length, 1);
|
|
195
|
+
assert.strictEqual(parsed.data.violations['src/app.js'][0].rule, 'no-debugger');
|
|
196
|
+
assert.strictEqual(
|
|
197
|
+
parsed.data.violations['src/app.js'][0].message,
|
|
198
|
+
'No debugger statements'
|
|
199
|
+
);
|
|
200
|
+
assert.strictEqual(parsed.data.violations['src/app.js'][0].severity, 'error');
|
|
201
|
+
assert.strictEqual(parsed.data.violations['src/app.js'][0].lineNo, 1);
|
|
202
|
+
assert.strictEqual(parsed.data.message, '1 custom rule violation(s) in 1 file(s)');
|
|
203
|
+
} finally {
|
|
204
|
+
teardown(dir);
|
|
205
|
+
}
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it('ships src/yaml.js to generated projects so check_custom resolves', async () => {
|
|
209
|
+
const { autoDetectStack } = require('../src/detector');
|
|
210
|
+
const { generateHarness } = require('../src/generator');
|
|
211
|
+
|
|
212
|
+
// Real end-to-end path: detect + generate a Next.js project.
|
|
213
|
+
const dir = createTmpProject({
|
|
214
|
+
'package.json': JSON.stringify({ dependencies: { next: '14.0.0' } }),
|
|
215
|
+
});
|
|
216
|
+
const config = autoDetectStack(dir);
|
|
217
|
+
assert.strictEqual(config.framework, 'Next.js (App Router)');
|
|
218
|
+
generateHarness(dir, config);
|
|
219
|
+
|
|
220
|
+
try {
|
|
221
|
+
// The generated project must ship src/yaml.js next to tools/check_custom.js.
|
|
222
|
+
assert.ok(fs.existsSync(path.join(dir, 'src', 'yaml.js')), 'src/yaml.js shipped');
|
|
223
|
+
|
|
224
|
+
const generatedCheck = path.join(dir, 'tools', 'check_custom.js');
|
|
225
|
+
assert.ok(fs.existsSync(generatedCheck), 'tools/check_custom.js shipped');
|
|
226
|
+
|
|
227
|
+
fs.writeFileSync(path.join(dir, '.clc-forge.yml'), VALID_CONFIG);
|
|
228
|
+
fs.mkdirSync(path.join(dir, 'src', 'app'), { recursive: true });
|
|
229
|
+
fs.writeFileSync(path.join(dir, 'src', 'app.js'), 'module.exports = 1;\n');
|
|
230
|
+
initGitRepo(dir);
|
|
231
|
+
|
|
232
|
+
fs.writeFileSync(path.join(dir, 'src', 'app.js'), 'debugger;\nmodule.exports = 1;\n');
|
|
233
|
+
spawnSync('git', ['add', 'src/app.js'], { cwd: dir });
|
|
234
|
+
|
|
235
|
+
const { parsed, exitCode } = await runCheckCustom(generatedCheck, dir);
|
|
236
|
+
|
|
237
|
+
assert.strictEqual(parsed.name, 'check_custom');
|
|
238
|
+
assert.strictEqual(parsed.skipped, false);
|
|
239
|
+
assert.strictEqual(parsed.exitCode, 1);
|
|
240
|
+
assert.strictEqual(exitCode, 1);
|
|
241
|
+
assert.strictEqual(parsed.data.violations['src/app.js'][0].rule, 'no-debugger');
|
|
242
|
+
} finally {
|
|
243
|
+
teardown(dir);
|
|
244
|
+
}
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it('returns skipped:true with parse error message on unsupported anchor/alias config', async () => {
|
|
248
|
+
const dir = createTmpProject({
|
|
249
|
+
'.clc-forge.yml': 'rules:\n - name: r\n template: &tpl shared\n',
|
|
250
|
+
'src/app.js': 'module.exports = 1;\n',
|
|
251
|
+
});
|
|
252
|
+
initGitRepo(dir);
|
|
253
|
+
|
|
254
|
+
try {
|
|
255
|
+
fs.writeFileSync(path.join(dir, 'src/app.js'), 'debugger;\n');
|
|
256
|
+
spawnSync('git', ['add', 'src/app.js'], { cwd: dir });
|
|
257
|
+
|
|
258
|
+
const { parsed, exitCode } = await runCheckCustom(CHECK_CUSTOM, dir);
|
|
259
|
+
|
|
260
|
+
assert.strictEqual(parsed.name, 'check_custom');
|
|
261
|
+
assert.strictEqual(parsed.skipped, true);
|
|
262
|
+
assert.strictEqual(parsed.exitCode, 0);
|
|
263
|
+
assert.ok(
|
|
264
|
+
parsed.data.message.includes('Config parse error: Unsupported YAML syntax'),
|
|
265
|
+
`unexpected message: ${parsed.data.message}`
|
|
266
|
+
);
|
|
267
|
+
} finally {
|
|
268
|
+
teardown(dir);
|
|
269
|
+
}
|
|
270
|
+
});
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
275
|
+
// A-03: .clc-forge.yml loader / validator / resolver (extended schema)
|
|
276
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
277
|
+
|
|
278
|
+
const FULL_VALID_YAML = `active_guards:
|
|
279
|
+
- check_custom
|
|
280
|
+
- scan_secrets
|
|
281
|
+
phases: [research, plan, test-scenarios, hit, tdd-red, green, audit, rdd-review, handover, close]
|
|
282
|
+
gate_mode: advisory
|
|
283
|
+
severities:
|
|
284
|
+
check_custom: warning
|
|
285
|
+
scan_secrets: error
|
|
286
|
+
layers: [domain, application]
|
|
287
|
+
scope: 'src'
|
|
288
|
+
exclude_paths: ['**/*.min.js']
|
|
289
|
+
rules:
|
|
290
|
+
- name: no-debugger
|
|
291
|
+
pattern: 'debugger'
|
|
292
|
+
severity: error
|
|
293
|
+
`;
|
|
294
|
+
|
|
295
|
+
function assertConfigError(fn, pathPattern, fixPattern) {
|
|
296
|
+
assert.throws(fn, (err) => {
|
|
297
|
+
assert.ok(err instanceof ConfigError, `expected ConfigError, got ${err.constructor.name}: ${err.message}`);
|
|
298
|
+
assert.ok(err instanceof Error, 'ConfigError must extend Error');
|
|
299
|
+
assert.match(err.path, pathPattern);
|
|
300
|
+
assert.ok(typeof err.fix === 'string' && err.fix.length > 0, 'fix message present');
|
|
301
|
+
if (fixPattern) assert.match(err.fix, fixPattern);
|
|
302
|
+
return true;
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
describe('config A-03: loadConfig', () => {
|
|
307
|
+
it('parses yaml into a raw object and returns {} for empty input', () => {
|
|
308
|
+
const raw = loadConfig(FULL_VALID_YAML);
|
|
309
|
+
assert.deepStrictEqual(raw.active_guards, ['check_custom', 'scan_secrets']);
|
|
310
|
+
assert.deepStrictEqual(raw.phases, HARNESS_PHASES);
|
|
311
|
+
assert.deepStrictEqual(raw.severities, { check_custom: 'warning', scan_secrets: 'error' });
|
|
312
|
+
assert.strictEqual(raw.scope, 'src');
|
|
313
|
+
assert.deepStrictEqual(raw.exclude_paths, ['**/*.min.js']);
|
|
314
|
+
assert.strictEqual(raw.rules.length, 1);
|
|
315
|
+
assert.deepStrictEqual(loadConfig(''), {});
|
|
316
|
+
assert.deepStrictEqual(loadConfig(undefined), {});
|
|
317
|
+
assert.deepStrictEqual(loadConfig(null), {});
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
it('throws ConfigError on parse failure (unsupported anchor syntax)', () => {
|
|
321
|
+
assertConfigError(() => loadConfig('base: &anchor\n a: 1\n'), /<root>/, /parse/i);
|
|
322
|
+
});
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
describe('config A-03: validateConfig', () => {
|
|
326
|
+
it('accepts full valid and empty configs, returning them unchanged', () => {
|
|
327
|
+
const raw = loadConfig(FULL_VALID_YAML);
|
|
328
|
+
assert.strictEqual(validateConfig(raw), raw);
|
|
329
|
+
assert.deepStrictEqual(validateConfig({}), {});
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
it('rejects unknown guard names in active_guards and severities', () => {
|
|
333
|
+
assertConfigError(
|
|
334
|
+
() => validateConfig({ active_guards: ['check_custom', 'does_not_exist'] }),
|
|
335
|
+
/active_guards\[1\]/, /does_not_exist/
|
|
336
|
+
);
|
|
337
|
+
assertConfigError(() => validateConfig({ severities: { bogus_guard: 'error' } }), /severities\.bogus_guard/);
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
it('rejects invalid severity and gate_mode values', () => {
|
|
341
|
+
assertConfigError(
|
|
342
|
+
() => validateConfig({ severities: { check_custom: 'fatal' } }),
|
|
343
|
+
/severities\.check_custom/, /to one of: error, warning, advisory/
|
|
344
|
+
);
|
|
345
|
+
assertConfigError(() => validateConfig({ gate_mode: 'soft' }), /gate_mode/, /to one of: hard, advisory/);
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
it('rejects unknown/out-of-order/duplicate phases; accepts ordered subset', () => {
|
|
349
|
+
assertConfigError(() => validateConfig({ phases: ['research', 'plan', 'nonsense'] }), /phases\[2\]/, /nonsense/);
|
|
350
|
+
assertConfigError(() => validateConfig({ phases: ['research', 'audit', 'plan'] }), /phases\[2\]/, /out of order/);
|
|
351
|
+
assertConfigError(() => validateConfig({ phases: ['research', 'research'] }), /phases\[1\]/);
|
|
352
|
+
assert.deepStrictEqual(
|
|
353
|
+
validateConfig({ phases: ['research', 'plan', 'tdd-red', 'green'] }).phases,
|
|
354
|
+
['research', 'plan', 'tdd-red', 'green']
|
|
355
|
+
);
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
it('validates scope / exclude_paths / layers types', () => {
|
|
359
|
+
assert.strictEqual(validateConfig({ scope: 'src' }).scope, 'src');
|
|
360
|
+
assert.deepStrictEqual(validateConfig({ scope: ['src', 'lib'] }).scope, ['src', 'lib']);
|
|
361
|
+
assert.deepStrictEqual(validateConfig({ exclude_paths: ['**/*.min.js'] }).exclude_paths, ['**/*.min.js']);
|
|
362
|
+
assert.deepStrictEqual(validateConfig({ layers: ['domain'] }).layers, ['domain']);
|
|
363
|
+
assertConfigError(() => validateConfig({ scope: [42] }), /scope\[0\]/);
|
|
364
|
+
assertConfigError(() => validateConfig({ exclude_paths: 'src' }), /exclude_paths/);
|
|
365
|
+
assertConfigError(() => validateConfig({ layers: [12] }), /layers\[0\]/);
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
it('preserves legacy rules with unique required names', () => {
|
|
369
|
+
assert.strictEqual(validateConfig(loadConfig(FULL_VALID_YAML)).rules[0].name, 'no-debugger');
|
|
370
|
+
assertConfigError(
|
|
371
|
+
() => validateConfig({ rules: [{ name: 'a', pattern: 'x' }, { name: 'a', pattern: 'y' }] }),
|
|
372
|
+
/rules\[1\]\.name/, /duplicate/
|
|
373
|
+
);
|
|
374
|
+
assertConfigError(() => validateConfig({ rules: [{ pattern: 'x' }] }), /rules\[0\]/, /name/);
|
|
375
|
+
assertConfigError(() => validateConfig({ rules: [{ name: 'a', severity: 'fatal' }] }), /rules\[0\]\.severity/);
|
|
376
|
+
});
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
const DETECTED = { projectType: 'backend', framework: 'FastAPI', orm: 'Framework Native', testRunner: 'Pytest', techTools: [] };
|
|
380
|
+
|
|
381
|
+
describe('config A-03: resolveConfig', () => {
|
|
382
|
+
it('resolves a valid full config with catalog defaults merged', () => {
|
|
383
|
+
const resolved = resolveConfig(loadConfig(FULL_VALID_YAML), DETECTED);
|
|
384
|
+
assert.deepStrictEqual(resolved.activeGuards, ['check_custom', 'scan_secrets']);
|
|
385
|
+
assert.strictEqual(resolved.gateMode, 'advisory');
|
|
386
|
+
assert.deepStrictEqual(resolved.phases, HARNESS_PHASES);
|
|
387
|
+
assert.strictEqual(resolved.scope, 'src');
|
|
388
|
+
assert.deepStrictEqual(resolved.excludePaths, ['**/*.min.js']);
|
|
389
|
+
assert.deepStrictEqual(resolved.layers, ['domain', 'application']);
|
|
390
|
+
assert.strictEqual(resolved.framework, 'FastAPI');
|
|
391
|
+
// Config severities override catalog defaults.
|
|
392
|
+
assert.strictEqual(resolved.effectiveSeverities.check_custom, 'warning');
|
|
393
|
+
assert.strictEqual(resolved.effectiveSeverities.scan_secrets, 'error');
|
|
394
|
+
// __catalog pipeline with override + advisory downgrade-everything.
|
|
395
|
+
assert.strictEqual(resolved.__catalog.length, 2);
|
|
396
|
+
assert.strictEqual(resolved.__catalog[0].name, 'check_custom');
|
|
397
|
+
assert.strictEqual(resolved.__catalog[0].severity, 'warning');
|
|
398
|
+
assert.strictEqual(resolved.__catalog[0].file, 'check_custom.js+check_custom.py');
|
|
399
|
+
assert.strictEqual(resolved.__catalog[0].gate_mode, 'advisory');
|
|
400
|
+
assert.strictEqual(resolved.__catalog[1].gate_mode, 'advisory');
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
it('defaults active_guards to catalog rows applicable to the detected stack', () => {
|
|
404
|
+
const resolved = resolveConfig({}, { projectType: 'frontend', framework: 'Next.js (App Router)', orm: 'Framework Native', testRunner: 'Vitest', techTools: [] });
|
|
405
|
+
const names = new Set(resolved.activeGuards);
|
|
406
|
+
assert.ok(names.has('scan_secrets'), 'always-applicable guard active');
|
|
407
|
+
assert.ok(names.has('check_a11y'), 'frontend-applicable guard active');
|
|
408
|
+
assert.ok(!names.has('check_migrations'), 'backend-only guard NOT active');
|
|
409
|
+
assert.ok(!names.has('celery_guard'), 'tech guard NOT active without techTools');
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
it('activates tech guards whose names are detected in techTools', () => {
|
|
413
|
+
const resolved = resolveConfig({}, { ...DETECTED, framework: 'Django', techTools: [{ name: 'celery_guard', path: 'celery_guard.py', command: 'python3 tools/celery_guard.py' }] });
|
|
414
|
+
assert.ok(resolved.activeGuards.includes('celery_guard'));
|
|
415
|
+
assert.ok(resolved.activeGuards.includes('check_migrations'));
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
it('applies severity precedence: env override > config severities > catalog default', () => {
|
|
419
|
+
const prev = process.env[ENV_SEVERITIES];
|
|
420
|
+
try {
|
|
421
|
+
// catalog default for check_ui_reuse is 'warning'.
|
|
422
|
+
assert.strictEqual(resolveConfig({}, { projectType: 'frontend' }).effectiveSeverities.check_ui_reuse, 'warning');
|
|
423
|
+
// env override wins over config severities and catalog default.
|
|
424
|
+
process.env[ENV_SEVERITIES] = JSON.stringify({ check_ui_reuse: 'error' });
|
|
425
|
+
assert.strictEqual(
|
|
426
|
+
resolveConfig({ severities: { check_ui_reuse: 'warning' } }, { projectType: 'frontend' }).effectiveSeverities.check_ui_reuse,
|
|
427
|
+
'error'
|
|
428
|
+
);
|
|
429
|
+
// unknown guard / invalid value / malformed JSON in env are ignored.
|
|
430
|
+
process.env[ENV_SEVERITIES] = JSON.stringify({ bogus_guard: 'error' });
|
|
431
|
+
assert.strictEqual(resolveConfig({}, { projectType: 'frontend' }).effectiveSeverities.check_ui_reuse, 'warning');
|
|
432
|
+
process.env[ENV_SEVERITIES] = JSON.stringify({ check_ui_reuse: 'fatal' });
|
|
433
|
+
assert.strictEqual(resolveConfig({}, { projectType: 'frontend' }).effectiveSeverities.check_ui_reuse, 'warning');
|
|
434
|
+
process.env[ENV_SEVERITIES] = '{not json';
|
|
435
|
+
assert.strictEqual(resolveConfig({}, { projectType: 'frontend' }).effectiveSeverities.check_ui_reuse, 'warning');
|
|
436
|
+
} finally {
|
|
437
|
+
if (prev === undefined) delete process.env[ENV_SEVERITIES];
|
|
438
|
+
else process.env[ENV_SEVERITIES] = prev;
|
|
439
|
+
}
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
it('does not silently fall back on invalid raw config', () => {
|
|
443
|
+
assertConfigError(() => resolveConfig({ active_guards: ['nope'] }, {}), /active_guards\[0\]/);
|
|
444
|
+
});
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
describe('config A-03: generateHarness integration', () => {
|
|
448
|
+
it('generateHarness(config) writes .clc-forge.resolved.json metadata', () => {
|
|
449
|
+
const dir = createTmpProject({});
|
|
450
|
+
try {
|
|
451
|
+
const resolved = generateHarness(dir, loadConfig(FULL_VALID_YAML));
|
|
452
|
+
const meta = JSON.parse(fs.readFileSync(path.join(dir, '.clc-forge.resolved.json'), 'utf-8'));
|
|
453
|
+
assert.deepStrictEqual(meta.activeGuards, ['check_custom', 'scan_secrets']);
|
|
454
|
+
assert.strictEqual(meta.gateMode, 'advisory');
|
|
455
|
+
assert.deepStrictEqual(meta.phases, HARNESS_PHASES);
|
|
456
|
+
assert.strictEqual(meta.scope, 'src');
|
|
457
|
+
assert.deepStrictEqual(meta.excludePaths, ['**/*.min.js']);
|
|
458
|
+
assert.deepStrictEqual(meta.layers, ['domain', 'application']);
|
|
459
|
+
assert.strictEqual(meta.framework, 'unknown');
|
|
460
|
+
assert.strictEqual(meta.__catalog.length, 2);
|
|
461
|
+
assert.deepStrictEqual(resolved.activeGuards, meta.activeGuards);
|
|
462
|
+
} finally {
|
|
463
|
+
teardown(dir);
|
|
464
|
+
}
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
it('generateHarness with a pre-resolved config does not re-resolve', () => {
|
|
468
|
+
const dir = createTmpProject({});
|
|
469
|
+
try {
|
|
470
|
+
const resolved = resolveConfig(loadConfig(FULL_VALID_YAML), { ...DETECTED });
|
|
471
|
+
resolved.__clcForgeConfigPath = true;
|
|
472
|
+
generateHarness(dir, resolved);
|
|
473
|
+
const meta = JSON.parse(fs.readFileSync(path.join(dir, '.clc-forge.resolved.json'), 'utf-8'));
|
|
474
|
+
assert.strictEqual(meta.framework, 'FastAPI', 'detected metadata preserved (no re-resolve)');
|
|
475
|
+
} finally {
|
|
476
|
+
teardown(dir);
|
|
477
|
+
}
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
it('generateHarness(null/undefined/adapter-config) keeps the legacy path, no metadata', () => {
|
|
481
|
+
const dir = createTmpProject({});
|
|
482
|
+
try {
|
|
483
|
+
generateHarness(dir, null);
|
|
484
|
+
assert.ok(fs.existsSync(path.join(dir, 'AGENTS.md')), 'AGENTS.md still written');
|
|
485
|
+
assert.ok(fs.existsSync(path.join(dir, 'tools', 'scan_secrets.py')), 'fallback tools still installed');
|
|
486
|
+
assert.strictEqual(fs.existsSync(path.join(dir, '.clc-forge.resolved.json')), false, 'no metadata for the legacy/null path');
|
|
487
|
+
} finally {
|
|
488
|
+
teardown(dir);
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
const dir2 = createTmpProject({ 'package.json': JSON.stringify({ dependencies: { next: '14.0.0' } }) });
|
|
492
|
+
try {
|
|
493
|
+
const { autoDetectStack } = require('../src/detector');
|
|
494
|
+
generateHarness(dir2, autoDetectStack(dir2));
|
|
495
|
+
assert.strictEqual(fs.existsSync(path.join(dir2, '.clc-forge.resolved.json')), false, 'adapter-detected config does not emit metadata');
|
|
496
|
+
generateHarness(dir2, undefined);
|
|
497
|
+
assert.strictEqual(fs.existsSync(path.join(dir2, '.clc-forge.resolved.json')), false);
|
|
498
|
+
} finally {
|
|
499
|
+
teardown(dir2);
|
|
500
|
+
}
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
it('generateHarness with an invalid config throws ConfigError (no partial harness)', () => {
|
|
504
|
+
const dir = createTmpProject({});
|
|
505
|
+
try {
|
|
506
|
+
assertConfigError(() => generateHarness(dir, { active_guards: ['not_a_guard'] }), /active_guards\[0\]/);
|
|
507
|
+
assert.strictEqual(fs.existsSync(path.join(dir, '.clc-forge.resolved.json')), false);
|
|
508
|
+
} finally {
|
|
509
|
+
teardown(dir);
|
|
510
|
+
}
|
|
511
|
+
});
|
|
512
|
+
});
|