@whitehatd/crag 0.2.1 → 0.2.3
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/package.json +6 -2
- package/src/cli-errors.js +55 -0
- package/src/cli.js +10 -2
- package/src/commands/analyze.js +33 -72
- package/src/commands/check.js +67 -34
- package/src/commands/compile.js +69 -22
- package/src/commands/diff.js +10 -43
- package/src/commands/init.js +55 -31
- package/src/compile/atomic-write.js +12 -4
- package/src/compile/github-actions.js +17 -11
- package/src/compile/husky.js +6 -5
- package/src/compile/pre-commit.js +13 -5
- package/src/governance/gate-to-shell.js +11 -1
- package/src/governance/parse.js +41 -3
- package/src/governance/yaml-run.js +89 -0
- package/src/skills/post-start-validation.md +1 -1
- package/src/skills/pre-start-context.md +1 -1
- package/src/update/integrity.js +20 -7
- package/src/update/skill-sync.js +1 -1
package/package.json
CHANGED
|
@@ -1,12 +1,16 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@whitehatd/crag",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.3",
|
|
4
4
|
"description": "The bedrock layer for AI coding agents. One governance.md. Any project. Never stale.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"crag": "bin/crag.js"
|
|
7
7
|
},
|
|
8
8
|
"scripts": {
|
|
9
|
-
"test": "node test/all.js"
|
|
9
|
+
"test": "node test/all.js",
|
|
10
|
+
"sync-hashes": "node scripts/sync-skill-hashes.js",
|
|
11
|
+
"release:patch": "node scripts/bump-version.js patch",
|
|
12
|
+
"release:minor": "node scripts/bump-version.js minor",
|
|
13
|
+
"release:major": "node scripts/bump-version.js major"
|
|
10
14
|
},
|
|
11
15
|
"keywords": [
|
|
12
16
|
"claude-code",
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Exit code convention for crag commands:
|
|
5
|
+
* 0 — success
|
|
6
|
+
* EXIT_USER=1 — user-recoverable error (missing governance.md, bad flag,
|
|
7
|
+
* invalid argument, file not found in expected location,
|
|
8
|
+
* gate assertion failure in `check`, drift in `diff`)
|
|
9
|
+
* EXIT_INTERNAL=2 — internal / environmental error the user likely cannot
|
|
10
|
+
* fix by re-running with different input (EACCES on write,
|
|
11
|
+
* disk full, unexpected stat failure, bug in crag itself)
|
|
12
|
+
*
|
|
13
|
+
* Scripts can use `if [ $? -eq 1 ]` to distinguish user mistakes from
|
|
14
|
+
* infrastructure problems.
|
|
15
|
+
*/
|
|
16
|
+
const EXIT_USER = 1;
|
|
17
|
+
const EXIT_INTERNAL = 2;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Print a formatted error message and exit with the given code.
|
|
21
|
+
* Matches the style of other crag output (two-space indent, red marker).
|
|
22
|
+
*/
|
|
23
|
+
function cliError(message, exitCode) {
|
|
24
|
+
const code = exitCode === EXIT_INTERNAL ? EXIT_INTERNAL : EXIT_USER;
|
|
25
|
+
console.error(` \x1b[31m✗\x1b[0m Error: ${message}`);
|
|
26
|
+
process.exit(code);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Print a warning but do NOT exit. Use for non-fatal conditions.
|
|
31
|
+
*/
|
|
32
|
+
function cliWarn(message) {
|
|
33
|
+
console.warn(` \x1b[33m!\x1b[0m Warning: ${message}`);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Read a file with a clear error message on failure.
|
|
38
|
+
* Throws a structured error that command wrappers can catch and exit with.
|
|
39
|
+
*/
|
|
40
|
+
function readFileOrExit(fs, filePath, label) {
|
|
41
|
+
try {
|
|
42
|
+
return fs.readFileSync(filePath, 'utf-8');
|
|
43
|
+
} catch (err) {
|
|
44
|
+
const what = label || filePath;
|
|
45
|
+
if (err.code === 'ENOENT') {
|
|
46
|
+
cliError(`${what} not found: ${filePath}`, EXIT_USER);
|
|
47
|
+
} else if (err.code === 'EACCES' || err.code === 'EPERM') {
|
|
48
|
+
cliError(`permission denied reading ${what}: ${filePath}`, EXIT_INTERNAL);
|
|
49
|
+
} else {
|
|
50
|
+
cliError(`failed to read ${what}: ${err.message}`, EXIT_INTERNAL);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
module.exports = { EXIT_USER, EXIT_INTERNAL, cliError, cliWarn, readFileOrExit };
|
package/src/cli.js
CHANGED
|
@@ -8,6 +8,7 @@ const { diff } = require('./commands/diff');
|
|
|
8
8
|
const { upgrade } = require('./commands/upgrade');
|
|
9
9
|
const { workspace } = require('./commands/workspace');
|
|
10
10
|
const { checkOnce } = require('./update/version-check');
|
|
11
|
+
const { EXIT_USER } = require('./cli-errors');
|
|
11
12
|
|
|
12
13
|
function printUsage() {
|
|
13
14
|
console.log(`
|
|
@@ -48,6 +49,13 @@ function printUsage() {
|
|
|
48
49
|
crag analyze --workspace Analyze all workspace members
|
|
49
50
|
crag analyze --merge Merge with existing governance
|
|
50
51
|
|
|
52
|
+
Check options:
|
|
53
|
+
crag check Human-readable infrastructure report
|
|
54
|
+
crag check --json Machine-readable JSON output
|
|
55
|
+
|
|
56
|
+
Compile options:
|
|
57
|
+
crag compile --target <t> --dry-run Preview output paths without writing
|
|
58
|
+
|
|
51
59
|
Upgrade options:
|
|
52
60
|
crag upgrade --check Show what would change
|
|
53
61
|
crag upgrade --workspace Update all workspace members
|
|
@@ -81,7 +89,7 @@ function run(args) {
|
|
|
81
89
|
switch (command) {
|
|
82
90
|
case 'init': init(); break;
|
|
83
91
|
case 'install': install(); break;
|
|
84
|
-
case 'check': check(); break;
|
|
92
|
+
case 'check': check(args); break;
|
|
85
93
|
case 'compile': compile(args); break;
|
|
86
94
|
case 'analyze': analyze(args); break;
|
|
87
95
|
case 'diff': diff(args); break;
|
|
@@ -95,7 +103,7 @@ function run(args) {
|
|
|
95
103
|
default:
|
|
96
104
|
console.error(` Unknown command: ${command}`);
|
|
97
105
|
printUsage();
|
|
98
|
-
process.exit(
|
|
106
|
+
process.exit(EXIT_USER);
|
|
99
107
|
}
|
|
100
108
|
}
|
|
101
109
|
|
package/src/commands/analyze.js
CHANGED
|
@@ -5,6 +5,8 @@ const fs = require('fs');
|
|
|
5
5
|
const path = require('path');
|
|
6
6
|
const { detectWorkspace } = require('../workspace/detect');
|
|
7
7
|
const { enumerateMembers } = require('../workspace/enumerate');
|
|
8
|
+
const { extractRunCommands, isGateCommand } = require('../governance/yaml-run');
|
|
9
|
+
const { cliWarn, cliError, EXIT_INTERNAL } = require('../cli-errors');
|
|
8
10
|
|
|
9
11
|
/**
|
|
10
12
|
* crag analyze — generate governance.md from existing project without interview.
|
|
@@ -45,21 +47,30 @@ function analyze(args) {
|
|
|
45
47
|
|
|
46
48
|
const govPath = path.join(cwd, '.claude', 'governance.md');
|
|
47
49
|
const govDir = path.dirname(govPath);
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
50
|
+
try {
|
|
51
|
+
if (!fs.existsSync(govDir)) fs.mkdirSync(govDir, { recursive: true });
|
|
52
|
+
|
|
53
|
+
if (fs.existsSync(govPath) && !merge) {
|
|
54
|
+
const backupPath = govPath + '.bak.' + Date.now();
|
|
55
|
+
try {
|
|
56
|
+
fs.copyFileSync(govPath, backupPath);
|
|
57
|
+
console.log(` Backed up existing governance to ${path.basename(backupPath)}`);
|
|
58
|
+
} catch (err) {
|
|
59
|
+
// Backup failure shouldn't block the analyze, but warn loudly.
|
|
60
|
+
cliWarn(`could not create backup (continuing anyway): ${err.message}`);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
55
63
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
64
|
+
if (merge && fs.existsSync(govPath)) {
|
|
65
|
+
console.log(' Merge mode: preserving existing governance, appending new gates');
|
|
66
|
+
const existing = fs.readFileSync(govPath, 'utf-8');
|
|
67
|
+
const mergedContent = mergeWithExisting(existing, governance);
|
|
68
|
+
fs.writeFileSync(govPath, mergedContent);
|
|
69
|
+
} else {
|
|
70
|
+
fs.writeFileSync(govPath, governance);
|
|
71
|
+
}
|
|
72
|
+
} catch (err) {
|
|
73
|
+
cliError(`failed to write ${path.relative(cwd, govPath)}: ${err.message}`, EXIT_INTERNAL);
|
|
63
74
|
}
|
|
64
75
|
|
|
65
76
|
console.log(` \x1b[32m✓\x1b[0m Generated ${path.relative(cwd, govPath)}`);
|
|
@@ -167,50 +178,7 @@ function extractCIGates(dir, result) {
|
|
|
167
178
|
}
|
|
168
179
|
}
|
|
169
180
|
|
|
170
|
-
|
|
171
|
-
* Extract commands from YAML `run:` steps, handling both inline and block-scalar forms:
|
|
172
|
-
* run: npm test
|
|
173
|
-
* run: |
|
|
174
|
-
* npm test
|
|
175
|
-
* npm build
|
|
176
|
-
* run: >-
|
|
177
|
-
* npm test
|
|
178
|
-
*/
|
|
179
|
-
function extractRunCommands(content) {
|
|
180
|
-
const commands = [];
|
|
181
|
-
const lines = content.split(/\r?\n/);
|
|
182
|
-
|
|
183
|
-
for (let i = 0; i < lines.length; i++) {
|
|
184
|
-
const line = lines[i];
|
|
185
|
-
const m = line.match(/^(\s*)-?\s*run:\s*(.*)$/);
|
|
186
|
-
if (!m) continue;
|
|
187
|
-
|
|
188
|
-
const baseIndent = m[1].length;
|
|
189
|
-
const rest = m[2].trim();
|
|
190
|
-
|
|
191
|
-
// Block scalar: | or |- or |+ or > or >- or >+
|
|
192
|
-
if (/^[|>][+-]?\s*$/.test(rest)) {
|
|
193
|
-
// Collect following lines with greater indent
|
|
194
|
-
const blockLines = [];
|
|
195
|
-
for (let j = i + 1; j < lines.length; j++) {
|
|
196
|
-
const ln = lines[j];
|
|
197
|
-
if (ln.trim() === '') { blockLines.push(''); continue; }
|
|
198
|
-
const indentMatch = ln.match(/^(\s*)/);
|
|
199
|
-
if (indentMatch[1].length <= baseIndent) break;
|
|
200
|
-
blockLines.push(ln.slice(baseIndent + 2));
|
|
201
|
-
}
|
|
202
|
-
for (const bl of blockLines) {
|
|
203
|
-
const trimmed = bl.trim();
|
|
204
|
-
if (trimmed && !trimmed.startsWith('#')) commands.push(trimmed);
|
|
205
|
-
}
|
|
206
|
-
} else if (rest && !rest.startsWith('#')) {
|
|
207
|
-
// Inline: remove surrounding quotes if any
|
|
208
|
-
commands.push(rest.replace(/^["']|["']$/g, ''));
|
|
209
|
-
}
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
return commands;
|
|
213
|
-
}
|
|
181
|
+
// extractRunCommands and isGateCommand now live in src/governance/yaml-run.js.
|
|
214
182
|
|
|
215
183
|
function extractPackageScripts(dir, result) {
|
|
216
184
|
const pkgPath = path.join(dir, 'package.json');
|
|
@@ -310,25 +278,18 @@ function inferGitPatterns(dir, result) {
|
|
|
310
278
|
const branchList = branches.trim().split('\n');
|
|
311
279
|
const featureBranches = branchList.filter(b => /^(feat|fix|docs|chore|feature|hotfix|release)\//.test(b));
|
|
312
280
|
result.branchStrategy = featureBranches.length > 2 ? 'feature-branches' : 'trunk-based';
|
|
313
|
-
} catch {
|
|
281
|
+
} catch (err) {
|
|
282
|
+
// git may not be installed, or this may not be a git repo — non-fatal,
|
|
283
|
+
// just report and leave defaults. Matches the "best-effort" semantics of
|
|
284
|
+
// analyze (it infers what it can from whatever exists).
|
|
285
|
+
if (err && err.code !== 'ENOENT') {
|
|
286
|
+
cliWarn(`could not detect git patterns in ${path.basename(dir)}: ${err.message}`);
|
|
287
|
+
}
|
|
314
288
|
result.branchStrategy = 'unknown';
|
|
315
289
|
result.commitConvention = 'unknown';
|
|
316
290
|
}
|
|
317
291
|
}
|
|
318
292
|
|
|
319
|
-
function isGateCommand(cmd) {
|
|
320
|
-
const gatePatterns = [
|
|
321
|
-
/npm (run |ci|test|install)/, /npx /, /node /,
|
|
322
|
-
/cargo (test|build|check|clippy)/, /rustfmt/,
|
|
323
|
-
/go (test|build|vet)/, /golangci-lint/,
|
|
324
|
-
/pytest/, /python -m/, /ruff/, /mypy/, /flake8/,
|
|
325
|
-
/gradle/, /mvn /, /maven/,
|
|
326
|
-
/eslint/, /biome/, /prettier/, /tsc/,
|
|
327
|
-
/docker (build|compose)/, /make /, /just /,
|
|
328
|
-
];
|
|
329
|
-
return gatePatterns.some(p => p.test(cmd));
|
|
330
|
-
}
|
|
331
|
-
|
|
332
293
|
function generateGovernance(analysis, cwd) {
|
|
333
294
|
const sections = [];
|
|
334
295
|
|
package/src/commands/check.js
CHANGED
|
@@ -2,54 +2,87 @@
|
|
|
2
2
|
|
|
3
3
|
const fs = require('fs');
|
|
4
4
|
const path = require('path');
|
|
5
|
+
const { EXIT_USER } = require('../cli-errors');
|
|
5
6
|
|
|
6
|
-
|
|
7
|
+
const CORE_CHECKS = [
|
|
8
|
+
['.claude/skills/pre-start-context/SKILL.md', 'Pre-start skill (universal)'],
|
|
9
|
+
['.claude/skills/post-start-validation/SKILL.md', 'Post-start skill (universal)'],
|
|
10
|
+
['.claude/governance.md', 'Governance rules'],
|
|
11
|
+
['.claude/hooks/drift-detector.sh', 'Drift detector hook'],
|
|
12
|
+
['.claude/hooks/circuit-breaker.sh', 'Circuit breaker hook'],
|
|
13
|
+
['.claude/agents/test-runner.md', 'Test runner agent'],
|
|
14
|
+
['.claude/agents/security-reviewer.md', 'Security reviewer agent'],
|
|
15
|
+
['.claude/ci-playbook.md', 'CI playbook'],
|
|
16
|
+
['.claude/settings.local.json', 'Settings with hooks'],
|
|
17
|
+
];
|
|
18
|
+
|
|
19
|
+
const OPTIONAL_CHECKS = [
|
|
20
|
+
['.claude/.session-name', 'Session name (remote access)'],
|
|
21
|
+
['.claude/hooks/pre-compact-snapshot.sh', 'Pre-compact hook (MemStack)'],
|
|
22
|
+
['.claude/hooks/post-compact-recovery.sh', 'Post-compact hook (MemStack)'],
|
|
23
|
+
['.claude/rules/knowledge.md', 'MemStack knowledge rule'],
|
|
24
|
+
['.claude/rules/diary.md', 'MemStack diary rule'],
|
|
25
|
+
['.claude/rules/echo.md', 'MemStack echo rule'],
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Probe the filesystem and return a structured report of crag infrastructure.
|
|
30
|
+
* Exported for testing — the CLI wraps this and prints.
|
|
31
|
+
*/
|
|
32
|
+
function runChecks(cwd) {
|
|
33
|
+
const core = CORE_CHECKS.map(([file, name]) => ({
|
|
34
|
+
file,
|
|
35
|
+
name,
|
|
36
|
+
present: fs.existsSync(path.join(cwd, file)),
|
|
37
|
+
}));
|
|
38
|
+
const optional = OPTIONAL_CHECKS.map(([file, name]) => ({
|
|
39
|
+
file,
|
|
40
|
+
name,
|
|
41
|
+
present: fs.existsSync(path.join(cwd, file)),
|
|
42
|
+
}));
|
|
43
|
+
const missing = core.filter((c) => !c.present).length;
|
|
44
|
+
return {
|
|
45
|
+
cwd,
|
|
46
|
+
core,
|
|
47
|
+
optional,
|
|
48
|
+
missing,
|
|
49
|
+
total: core.length,
|
|
50
|
+
complete: missing === 0,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function check(args = []) {
|
|
7
55
|
const cwd = process.cwd();
|
|
8
|
-
const
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
['.claude/ci-playbook.md', 'CI playbook'],
|
|
17
|
-
['.claude/settings.local.json', 'Settings with hooks'],
|
|
18
|
-
];
|
|
19
|
-
|
|
20
|
-
const optional = [
|
|
21
|
-
['.claude/.session-name', 'Session name (remote access)'],
|
|
22
|
-
['.claude/hooks/pre-compact-snapshot.sh', 'Pre-compact hook (MemStack)'],
|
|
23
|
-
['.claude/hooks/post-compact-recovery.sh', 'Post-compact hook (MemStack)'],
|
|
24
|
-
['.claude/rules/knowledge.md', 'MemStack knowledge rule'],
|
|
25
|
-
['.claude/rules/diary.md', 'MemStack diary rule'],
|
|
26
|
-
['.claude/rules/echo.md', 'MemStack echo rule'],
|
|
27
|
-
];
|
|
56
|
+
const report = runChecks(cwd);
|
|
57
|
+
|
|
58
|
+
// --json: machine-readable output, no colors, no prose
|
|
59
|
+
if (args.includes('--json')) {
|
|
60
|
+
console.log(JSON.stringify(report, null, 2));
|
|
61
|
+
if (!report.complete) process.exit(EXIT_USER);
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
28
64
|
|
|
29
65
|
console.log(`\n Checking crag infrastructure in ${cwd}\n`);
|
|
30
66
|
|
|
31
67
|
console.log(` Core:`);
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
const icon = exists ? '\x1b[32m✓\x1b[0m' : '\x1b[31m✗\x1b[0m';
|
|
36
|
-
console.log(` ${icon} ${name}`);
|
|
37
|
-
if (!exists) missing++;
|
|
68
|
+
for (const c of report.core) {
|
|
69
|
+
const icon = c.present ? '\x1b[32m✓\x1b[0m' : '\x1b[31m✗\x1b[0m';
|
|
70
|
+
console.log(` ${icon} ${c.name}`);
|
|
38
71
|
}
|
|
39
72
|
|
|
40
73
|
console.log(`\n Optional:`);
|
|
41
|
-
for (const
|
|
42
|
-
const
|
|
43
|
-
|
|
44
|
-
console.log(` ${icon} ${name}`);
|
|
74
|
+
for (const o of report.optional) {
|
|
75
|
+
const icon = o.present ? '\x1b[32m✓\x1b[0m' : '\x1b[90m○\x1b[0m';
|
|
76
|
+
console.log(` ${icon} ${o.name}`);
|
|
45
77
|
}
|
|
46
78
|
|
|
47
|
-
console.log(`\n ${
|
|
48
|
-
if (missing > 0) {
|
|
79
|
+
console.log(`\n ${report.total - report.missing}/${report.total} core files present.`);
|
|
80
|
+
if (report.missing > 0) {
|
|
49
81
|
console.log(` Run 'crag init' to generate missing files.\n`);
|
|
82
|
+
process.exit(EXIT_USER);
|
|
50
83
|
} else {
|
|
51
84
|
console.log(` Infrastructure complete.\n`);
|
|
52
85
|
}
|
|
53
86
|
}
|
|
54
87
|
|
|
55
|
-
module.exports = { check };
|
|
88
|
+
module.exports = { check, runChecks, CORE_CHECKS, OPTIONAL_CHECKS };
|
package/src/commands/compile.js
CHANGED
|
@@ -15,6 +15,7 @@ const { generateContinue } = require('../compile/continue');
|
|
|
15
15
|
const { generateWindsurf } = require('../compile/windsurf');
|
|
16
16
|
const { generateZed } = require('../compile/zed');
|
|
17
17
|
const { generateCody } = require('../compile/cody');
|
|
18
|
+
const { cliError, readFileOrExit, EXIT_USER, EXIT_INTERNAL } = require('../cli-errors');
|
|
18
19
|
|
|
19
20
|
// All supported compile targets in dispatch order.
|
|
20
21
|
// Grouped: CI (3) + AI agent native (3) + AI agent extras (6)
|
|
@@ -36,16 +37,19 @@ const ALL_TARGETS = [
|
|
|
36
37
|
function compile(args) {
|
|
37
38
|
const targetIdx = args.indexOf('--target');
|
|
38
39
|
const target = targetIdx !== -1 ? args[targetIdx + 1] : args[1];
|
|
40
|
+
const dryRun = args.includes('--dry-run');
|
|
39
41
|
const cwd = process.cwd();
|
|
40
42
|
const govPath = path.join(cwd, '.claude', 'governance.md');
|
|
41
43
|
|
|
42
44
|
if (!fs.existsSync(govPath)) {
|
|
43
|
-
|
|
44
|
-
process.exit(1);
|
|
45
|
+
cliError('no .claude/governance.md found. Run crag init or crag analyze first.', EXIT_USER);
|
|
45
46
|
}
|
|
46
47
|
|
|
47
|
-
const content = fs
|
|
48
|
+
const content = readFileOrExit(fs, govPath, 'governance.md');
|
|
48
49
|
const parsed = parseGovernance(content);
|
|
50
|
+
if (parsed.warnings && parsed.warnings.length > 0) {
|
|
51
|
+
for (const w of parsed.warnings) console.warn(` \x1b[33m!\x1b[0m ${w}`);
|
|
52
|
+
}
|
|
49
53
|
const flat = flattenGates(parsed.gates);
|
|
50
54
|
const gateCount = Object.values(flat).flat().length;
|
|
51
55
|
|
|
@@ -68,37 +72,80 @@ function compile(args) {
|
|
|
68
72
|
console.log(' crag compile --target zed .zed/rules.md');
|
|
69
73
|
console.log(' crag compile --target cody .sourcegraph/cody-instructions.md\n');
|
|
70
74
|
console.log(' Combined:');
|
|
71
|
-
console.log(' crag compile --target all All 12 targets at once
|
|
75
|
+
console.log(' crag compile --target all All 12 targets at once');
|
|
76
|
+
console.log(' crag compile --target <t> --dry-run Preview paths without writing\n');
|
|
72
77
|
return;
|
|
73
78
|
}
|
|
74
79
|
|
|
75
80
|
const targets = target === 'all' ? ALL_TARGETS : [target];
|
|
76
81
|
|
|
77
82
|
console.log(`\n Compiling governance.md → ${targets.join(', ')}`);
|
|
78
|
-
console.log(` ${gateCount} gates, ${parsed.runtimes.length} runtimes detected\n`);
|
|
83
|
+
console.log(` ${gateCount} gates, ${parsed.runtimes.length} runtimes detected${dryRun ? ' (dry-run)' : ''}\n`);
|
|
79
84
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
case 'gemini': generateGeminiMd(cwd, parsed); break;
|
|
88
|
-
case 'copilot': generateCopilot(cwd, parsed); break;
|
|
89
|
-
case 'cline': generateCline(cwd, parsed); break;
|
|
90
|
-
case 'continue': generateContinue(cwd, parsed); break;
|
|
91
|
-
case 'windsurf': generateWindsurf(cwd, parsed); break;
|
|
92
|
-
case 'zed': generateZed(cwd, parsed); break;
|
|
93
|
-
case 'cody': generateCody(cwd, parsed); break;
|
|
94
|
-
default:
|
|
85
|
+
// --dry-run: print the planned output paths without writing files.
|
|
86
|
+
if (dryRun) {
|
|
87
|
+
for (const t of targets) {
|
|
88
|
+
const outPath = planOutputPath(cwd, t);
|
|
89
|
+
if (outPath) {
|
|
90
|
+
console.log(` \x1b[36mplan\x1b[0m ${path.relative(cwd, outPath)}`);
|
|
91
|
+
} else {
|
|
95
92
|
console.error(` Unknown target: ${t}`);
|
|
96
93
|
console.error(` Valid targets: ${ALL_TARGETS.join(', ')}, all, list`);
|
|
97
|
-
process.exit(
|
|
94
|
+
process.exit(EXIT_USER);
|
|
95
|
+
}
|
|
98
96
|
}
|
|
97
|
+
console.log('\n Dry-run complete — no files written.\n');
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
try {
|
|
102
|
+
for (const t of targets) {
|
|
103
|
+
switch (t) {
|
|
104
|
+
case 'github': generateGitHubActions(cwd, parsed); break;
|
|
105
|
+
case 'husky': generateHusky(cwd, parsed); break;
|
|
106
|
+
case 'pre-commit': generatePreCommitConfig(cwd, parsed); break;
|
|
107
|
+
case 'agents-md': generateAgentsMd(cwd, parsed); break;
|
|
108
|
+
case 'cursor': generateCursorRules(cwd, parsed); break;
|
|
109
|
+
case 'gemini': generateGeminiMd(cwd, parsed); break;
|
|
110
|
+
case 'copilot': generateCopilot(cwd, parsed); break;
|
|
111
|
+
case 'cline': generateCline(cwd, parsed); break;
|
|
112
|
+
case 'continue': generateContinue(cwd, parsed); break;
|
|
113
|
+
case 'windsurf': generateWindsurf(cwd, parsed); break;
|
|
114
|
+
case 'zed': generateZed(cwd, parsed); break;
|
|
115
|
+
case 'cody': generateCody(cwd, parsed); break;
|
|
116
|
+
default:
|
|
117
|
+
console.error(` Unknown target: ${t}`);
|
|
118
|
+
console.error(` Valid targets: ${ALL_TARGETS.join(', ')}, all, list`);
|
|
119
|
+
process.exit(EXIT_USER);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
} catch (err) {
|
|
123
|
+
cliError(`compile failed: ${err.message}`, EXIT_INTERNAL);
|
|
99
124
|
}
|
|
100
125
|
|
|
101
126
|
console.log('\n Done. Governance is now executable infrastructure.\n');
|
|
102
127
|
}
|
|
103
128
|
|
|
104
|
-
|
|
129
|
+
/**
|
|
130
|
+
* Map a target name to its destination path relative to cwd.
|
|
131
|
+
* Used by --dry-run to show what would be written.
|
|
132
|
+
*/
|
|
133
|
+
function planOutputPath(cwd, target) {
|
|
134
|
+
const map = {
|
|
135
|
+
'github': path.join(cwd, '.github', 'workflows', 'gates.yml'),
|
|
136
|
+
'husky': path.join(cwd, '.husky', 'pre-commit'),
|
|
137
|
+
'pre-commit': path.join(cwd, '.pre-commit-config.yaml'),
|
|
138
|
+
'agents-md': path.join(cwd, 'AGENTS.md'),
|
|
139
|
+
'cursor': path.join(cwd, '.cursor', 'rules', 'governance.mdc'),
|
|
140
|
+
'gemini': path.join(cwd, 'GEMINI.md'),
|
|
141
|
+
'copilot': path.join(cwd, '.github', 'copilot-instructions.md'),
|
|
142
|
+
'cline': path.join(cwd, '.clinerules'),
|
|
143
|
+
'continue': path.join(cwd, '.continuerules'),
|
|
144
|
+
'windsurf': path.join(cwd, '.windsurfrules'),
|
|
145
|
+
'zed': path.join(cwd, '.zed', 'rules.md'),
|
|
146
|
+
'cody': path.join(cwd, '.sourcegraph', 'cody-instructions.md'),
|
|
147
|
+
};
|
|
148
|
+
return map[target] || null;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
module.exports = { compile, ALL_TARGETS, planOutputPath };
|
package/src/commands/diff.js
CHANGED
|
@@ -4,6 +4,8 @@ const { execSync } = require('child_process');
|
|
|
4
4
|
const fs = require('fs');
|
|
5
5
|
const path = require('path');
|
|
6
6
|
const { parseGovernance, flattenGates } = require('../governance/parse');
|
|
7
|
+
const { extractRunCommands, isGateCommand } = require('../governance/yaml-run');
|
|
8
|
+
const { cliError, EXIT_USER, EXIT_INTERNAL, readFileOrExit } = require('../cli-errors');
|
|
7
9
|
|
|
8
10
|
/**
|
|
9
11
|
* crag diff — compare governance.md against codebase reality.
|
|
@@ -13,12 +15,14 @@ function diff(args) {
|
|
|
13
15
|
const govPath = path.join(cwd, '.claude', 'governance.md');
|
|
14
16
|
|
|
15
17
|
if (!fs.existsSync(govPath)) {
|
|
16
|
-
|
|
17
|
-
process.exit(1);
|
|
18
|
+
cliError('no .claude/governance.md found. Run crag init or crag analyze first.', EXIT_USER);
|
|
18
19
|
}
|
|
19
20
|
|
|
20
|
-
const content = fs
|
|
21
|
+
const content = readFileOrExit(fs, govPath, 'governance.md');
|
|
21
22
|
const parsed = parseGovernance(content);
|
|
23
|
+
if (parsed.warnings && parsed.warnings.length > 0) {
|
|
24
|
+
for (const w of parsed.warnings) console.warn(` \x1b[33m!\x1b[0m ${w}`);
|
|
25
|
+
}
|
|
22
26
|
const flat = flattenGates(parsed.gates);
|
|
23
27
|
|
|
24
28
|
console.log(`\n Governance vs Reality — ${parsed.name || 'project'}\n`);
|
|
@@ -169,46 +173,8 @@ function extractCIGateCommands(cwd) {
|
|
|
169
173
|
return gates;
|
|
170
174
|
}
|
|
171
175
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
*/
|
|
175
|
-
function extractRunCommands(content) {
|
|
176
|
-
const commands = [];
|
|
177
|
-
const lines = content.split(/\r?\n/);
|
|
178
|
-
|
|
179
|
-
for (let i = 0; i < lines.length; i++) {
|
|
180
|
-
const line = lines[i];
|
|
181
|
-
const m = line.match(/^(\s*)-?\s*run:\s*(.*)$/);
|
|
182
|
-
if (!m) continue;
|
|
183
|
-
|
|
184
|
-
const baseIndent = m[1].length;
|
|
185
|
-
const rest = m[2].trim();
|
|
186
|
-
|
|
187
|
-
if (/^[|>][+-]?\s*$/.test(rest)) {
|
|
188
|
-
// Block scalar
|
|
189
|
-
for (let j = i + 1; j < lines.length; j++) {
|
|
190
|
-
const ln = lines[j];
|
|
191
|
-
if (ln.trim() === '') continue;
|
|
192
|
-
const indentMatch = ln.match(/^(\s*)/);
|
|
193
|
-
if (indentMatch[1].length <= baseIndent) break;
|
|
194
|
-
const trimmed = ln.trim();
|
|
195
|
-
if (trimmed && !trimmed.startsWith('#')) commands.push(trimmed);
|
|
196
|
-
}
|
|
197
|
-
} else if (rest && !rest.startsWith('#')) {
|
|
198
|
-
commands.push(rest.replace(/^["']|["']$/g, ''));
|
|
199
|
-
}
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
return commands;
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
function isGateCommand(cmd) {
|
|
206
|
-
const patterns = [
|
|
207
|
-
/npm (run|test|ci)/, /npx /, /node /, /cargo /, /go (test|build|vet)/,
|
|
208
|
-
/pytest/, /ruff/, /mypy/, /eslint/, /biome/, /prettier/, /tsc/, /gradle/,
|
|
209
|
-
];
|
|
210
|
-
return patterns.some(p => p.test(cmd));
|
|
211
|
-
}
|
|
176
|
+
// extractRunCommands and isGateCommand now live in src/governance/yaml-run.js
|
|
177
|
+
// and are re-exported below for backward compatibility with existing tests.
|
|
212
178
|
|
|
213
179
|
/**
|
|
214
180
|
* Normalize commands to a canonical form for equality comparison.
|
|
@@ -287,3 +253,4 @@ function hasNpmScript(cwd, script) {
|
|
|
287
253
|
}
|
|
288
254
|
|
|
289
255
|
module.exports = { diff, normalizeCmd, extractRunCommands, isGateCommand };
|
|
256
|
+
|
package/src/commands/init.js
CHANGED
|
@@ -2,46 +2,66 @@
|
|
|
2
2
|
|
|
3
3
|
const { execSync, spawn } = require('child_process');
|
|
4
4
|
const fs = require('fs');
|
|
5
|
+
const os = require('os');
|
|
5
6
|
const path = require('path');
|
|
7
|
+
const { cliError, EXIT_USER, EXIT_INTERNAL } = require('../cli-errors');
|
|
6
8
|
|
|
7
9
|
const SRC = path.join(__dirname, '..');
|
|
8
10
|
const AGENT_SRC = path.join(SRC, 'crag-agent.md');
|
|
9
11
|
const PRE_START_SRC = path.join(SRC, 'skills', 'pre-start-context.md');
|
|
10
12
|
const POST_START_SRC = path.join(SRC, 'skills', 'post-start-validation.md');
|
|
11
|
-
|
|
13
|
+
|
|
14
|
+
// Use os.homedir() as the authoritative source — fall back only if it returns
|
|
15
|
+
// empty (extremely rare, e.g. stripped-down containers). Never pass undefined
|
|
16
|
+
// to path.join, which throws ERR_INVALID_ARG_TYPE.
|
|
17
|
+
function resolveHomeDir() {
|
|
18
|
+
const h = os.homedir();
|
|
19
|
+
if (h && h.length > 0) return h;
|
|
20
|
+
return process.env.HOME || process.env.USERPROFILE || os.tmpdir();
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const GLOBAL_AGENT_DIR = path.join(resolveHomeDir(), '.claude', 'agents');
|
|
12
24
|
const GLOBAL_AGENT_PATH = path.join(GLOBAL_AGENT_DIR, 'crag-project.md');
|
|
13
25
|
|
|
14
26
|
function install() {
|
|
15
|
-
|
|
16
|
-
fs.
|
|
27
|
+
try {
|
|
28
|
+
if (!fs.existsSync(GLOBAL_AGENT_DIR)) {
|
|
29
|
+
fs.mkdirSync(GLOBAL_AGENT_DIR, { recursive: true });
|
|
30
|
+
}
|
|
31
|
+
fs.copyFileSync(AGENT_SRC, GLOBAL_AGENT_PATH);
|
|
32
|
+
console.log(` Installed crag-project agent to ${GLOBAL_AGENT_PATH}`);
|
|
33
|
+
console.log(` Run /crag-project from any Claude Code session.`);
|
|
34
|
+
} catch (err) {
|
|
35
|
+
cliError(`failed to install global agent: ${err.message}`, EXIT_INTERNAL);
|
|
17
36
|
}
|
|
18
|
-
fs.copyFileSync(AGENT_SRC, GLOBAL_AGENT_PATH);
|
|
19
|
-
console.log(` Installed crag-project agent to ${GLOBAL_AGENT_PATH}`);
|
|
20
|
-
console.log(` Run /crag-project from any Claude Code session.`);
|
|
21
37
|
}
|
|
22
38
|
|
|
23
39
|
function installSkills(targetDir) {
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
40
|
+
try {
|
|
41
|
+
const skillDirs = [
|
|
42
|
+
path.join(targetDir, '.claude', 'skills', 'pre-start-context'),
|
|
43
|
+
path.join(targetDir, '.claude', 'skills', 'post-start-validation'),
|
|
44
|
+
path.join(targetDir, '.agents', 'workflows'),
|
|
45
|
+
];
|
|
46
|
+
|
|
47
|
+
for (const dir of skillDirs) {
|
|
48
|
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
49
|
+
}
|
|
33
50
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
51
|
+
// Copy skills
|
|
52
|
+
fs.copyFileSync(PRE_START_SRC, path.join(targetDir, '.claude', 'skills', 'pre-start-context', 'SKILL.md'));
|
|
53
|
+
fs.copyFileSync(POST_START_SRC, path.join(targetDir, '.claude', 'skills', 'post-start-validation', 'SKILL.md'));
|
|
37
54
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
55
|
+
// Workflow copies (remove name: line)
|
|
56
|
+
for (const [src, name] of [[PRE_START_SRC, 'pre-start-context.md'], [POST_START_SRC, 'post-start-validation.md']]) {
|
|
57
|
+
const content = fs.readFileSync(src, 'utf-8').replace(/^name:.*\n/m, '');
|
|
58
|
+
fs.writeFileSync(path.join(targetDir, '.agents', 'workflows', name), content);
|
|
59
|
+
}
|
|
43
60
|
|
|
44
|
-
|
|
61
|
+
console.log(` Installed universal skills to ${targetDir}`);
|
|
62
|
+
} catch (err) {
|
|
63
|
+
cliError(`failed to install skills: ${err.message}`, EXIT_INTERNAL);
|
|
64
|
+
}
|
|
45
65
|
}
|
|
46
66
|
|
|
47
67
|
function init() {
|
|
@@ -53,7 +73,7 @@ function init() {
|
|
|
53
73
|
} catch {
|
|
54
74
|
console.error(' Error: Claude Code CLI not found (or did not respond in 5s).');
|
|
55
75
|
console.error(' Install: https://claude.com/claude-code');
|
|
56
|
-
process.exit(
|
|
76
|
+
process.exit(EXIT_USER);
|
|
57
77
|
}
|
|
58
78
|
|
|
59
79
|
// Pre-flight: warn if not a git repo (non-blocking, just informative)
|
|
@@ -86,14 +106,18 @@ function init() {
|
|
|
86
106
|
console.log(` The universal skills are already installed.\n`);
|
|
87
107
|
console.log(` >>> Type "go" and press Enter to start the interview <<<\n`);
|
|
88
108
|
|
|
89
|
-
|
|
109
|
+
// On Windows, `shell: true` defaults to cmd.exe which can't always resolve
|
|
110
|
+
// the `claude` binary the way PowerShell / Git Bash can. Use an explicit
|
|
111
|
+
// shell path on Windows (bash from Git for Windows is the common install).
|
|
112
|
+
const spawnOpts = {
|
|
90
113
|
stdio: 'inherit',
|
|
91
|
-
shell: true,
|
|
92
|
-
}
|
|
114
|
+
shell: process.platform === 'win32' ? (process.env.SHELL || 'bash') : true,
|
|
115
|
+
};
|
|
116
|
+
const claude = spawn('claude', ['--agent', 'crag-project'], spawnOpts);
|
|
93
117
|
|
|
94
118
|
claude.on('error', (err) => {
|
|
95
119
|
console.error(`\n Error launching claude: ${err.message}`);
|
|
96
|
-
process.exit(
|
|
120
|
+
process.exit(EXIT_USER);
|
|
97
121
|
});
|
|
98
122
|
|
|
99
123
|
claude.on('exit', (code, signal) => {
|
|
@@ -101,7 +125,7 @@ function init() {
|
|
|
101
125
|
console.log(`\n crag setup complete. Run 'crag check' to verify.`);
|
|
102
126
|
} else if (signal) {
|
|
103
127
|
console.error(`\n Interview terminated by signal: ${signal}`);
|
|
104
|
-
process.exit(
|
|
128
|
+
process.exit(EXIT_USER);
|
|
105
129
|
} else if (code !== null) {
|
|
106
130
|
console.error(`\n Interview exited with code ${code}`);
|
|
107
131
|
process.exit(code);
|
|
@@ -109,4 +133,4 @@ function init() {
|
|
|
109
133
|
});
|
|
110
134
|
}
|
|
111
135
|
|
|
112
|
-
module.exports = { init, install, installSkills };
|
|
136
|
+
module.exports = { init, install, installSkills, resolveHomeDir };
|
|
@@ -1,31 +1,39 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
+
const crypto = require('crypto');
|
|
3
4
|
const fs = require('fs');
|
|
4
5
|
const path = require('path');
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* Write `content` to `filePath` atomically:
|
|
8
9
|
* 1. Ensure parent directory exists
|
|
9
|
-
* 2. Write to a sibling tempfile
|
|
10
|
+
* 2. Write to a sibling tempfile (unpredictable suffix — crypto-random)
|
|
10
11
|
* 3. Rename tempfile over destination
|
|
11
12
|
*
|
|
12
13
|
* If any step fails, the tempfile is cleaned up and the original destination
|
|
13
14
|
* remains untouched. Prevents partial-write state if the process is killed
|
|
14
15
|
* mid-write or the filesystem runs out of space.
|
|
16
|
+
*
|
|
17
|
+
* The random suffix makes the temp filename unpredictable, blocking
|
|
18
|
+
* race-condition attacks on shared filesystems where an attacker could
|
|
19
|
+
* pre-create a symlink at the expected temp path.
|
|
15
20
|
*/
|
|
16
21
|
function atomicWrite(filePath, content) {
|
|
17
22
|
const dir = path.dirname(filePath);
|
|
18
23
|
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
19
24
|
|
|
20
|
-
const
|
|
25
|
+
const suffix = crypto.randomBytes(8).toString('hex');
|
|
26
|
+
const tmp = `${filePath}.tmp.${suffix}`;
|
|
21
27
|
|
|
22
28
|
try {
|
|
23
|
-
|
|
29
|
+
// wx flag: fail if the temp path somehow already exists (defense-in-depth
|
|
30
|
+
// on top of the unpredictable suffix).
|
|
31
|
+
fs.writeFileSync(tmp, content, { flag: 'wx' });
|
|
24
32
|
fs.renameSync(tmp, filePath);
|
|
25
33
|
} catch (err) {
|
|
26
34
|
// Best-effort cleanup
|
|
27
35
|
try { if (fs.existsSync(tmp)) fs.unlinkSync(tmp); } catch {}
|
|
28
|
-
throw err;
|
|
36
|
+
throw new Error(`atomicWrite failed for ${filePath}: ${err.message}`);
|
|
29
37
|
}
|
|
30
38
|
}
|
|
31
39
|
|
|
@@ -5,6 +5,7 @@ const path = require('path');
|
|
|
5
5
|
const { gateToShell } = require('../governance/gate-to-shell');
|
|
6
6
|
const { flattenGatesRich } = require('../governance/parse');
|
|
7
7
|
const { atomicWrite } = require('./atomic-write');
|
|
8
|
+
const { yamlScalar } = require('../update/integrity');
|
|
8
9
|
|
|
9
10
|
/**
|
|
10
11
|
* Extract the major Node version from package.json engines.node field.
|
|
@@ -95,7 +96,10 @@ function generateGitHubActions(cwd, parsed) {
|
|
|
95
96
|
setupSteps += ' - name: Setup Python\n';
|
|
96
97
|
setupSteps += ' uses: actions/setup-python@v5\n';
|
|
97
98
|
setupSteps += ` with:\n python-version: '${pythonVersion}'\n`;
|
|
98
|
-
|
|
99
|
+
// Explicit `shell: bash` so redirects work on Windows runners (which default to cmd.exe).
|
|
100
|
+
setupSteps += ' - name: Install Python deps (best-effort)\n';
|
|
101
|
+
setupSteps += ' shell: bash\n';
|
|
102
|
+
setupSteps += ' run: pip install -r requirements.txt 2>/dev/null || true\n';
|
|
99
103
|
}
|
|
100
104
|
if (parsed.runtimes.includes('java')) {
|
|
101
105
|
setupSteps += ' - name: Setup Java\n';
|
|
@@ -108,13 +112,10 @@ function generateGitHubActions(cwd, parsed) {
|
|
|
108
112
|
setupSteps += ` with:\n go-version: '${goVersion}'\n`;
|
|
109
113
|
}
|
|
110
114
|
|
|
111
|
-
//
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
.replace(/\r/g, '\\r')
|
|
116
|
-
.replace(/\n/g, '\\n')
|
|
117
|
-
.replace(/\t/g, '\\t');
|
|
115
|
+
// GHA expression escape for strings inside hashFiles('...'):
|
|
116
|
+
// single quotes are doubled. The value is already validated to be a
|
|
117
|
+
// relative in-repo path by the parser, but we escape defensively.
|
|
118
|
+
const ghaExprEscape = (s) => String(s).replace(/'/g, "''");
|
|
118
119
|
|
|
119
120
|
let gateSteps = '';
|
|
120
121
|
for (const gate of flattenGatesRich(parsed.gates)) {
|
|
@@ -122,12 +123,17 @@ function generateGitHubActions(cwd, parsed) {
|
|
|
122
123
|
const label = gate.cmd.length > 60 ? gate.cmd.substring(0, 57) + '...' : gate.cmd;
|
|
123
124
|
const prefix = gate.classification !== 'MANDATORY' ? `[${gate.classification}] ` : '';
|
|
124
125
|
const condExpr = gate.condition ? ` (if: ${gate.condition})` : '';
|
|
125
|
-
|
|
126
|
+
// Route gate.path through yamlScalar — it will quote if the path contains
|
|
127
|
+
// YAML-sensitive characters (colon, #, quotes, etc.).
|
|
128
|
+
const workDir = gate.path ? `\n working-directory: ${yamlScalar(gate.path)}` : '';
|
|
126
129
|
const contOnErr = (gate.classification === 'OPTIONAL' || gate.classification === 'ADVISORY')
|
|
127
130
|
? '\n continue-on-error: true' : '';
|
|
128
131
|
const ifGuard = gate.condition
|
|
129
|
-
? `\n if: hashFiles('${gate.condition}') != ''` : '';
|
|
130
|
-
|
|
132
|
+
? `\n if: hashFiles('${ghaExprEscape(gate.condition)}') != ''` : '';
|
|
133
|
+
// Use yamlScalar for the name field so user input can never break the YAML
|
|
134
|
+
// structure even if it contains quotes, newlines, colons, or control chars.
|
|
135
|
+
const stepName = `${prefix}${gate.section}: ${label}${condExpr}`;
|
|
136
|
+
gateSteps += ` - name: ${yamlScalar(stepName)}${ifGuard}${workDir}${contOnErr}\n`;
|
|
131
137
|
gateSteps += ` run: |\n ${shell.replace(/\n/g, '\n ')}\n`;
|
|
132
138
|
}
|
|
133
139
|
|
package/src/compile/husky.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
const fs = require('fs');
|
|
4
4
|
const path = require('path');
|
|
5
|
-
const { gateToShell } = require('../governance/gate-to-shell');
|
|
5
|
+
const { gateToShell, shellEscapeDoubleQuoted } = require('../governance/gate-to-shell');
|
|
6
6
|
const { flattenGatesRich } = require('../governance/parse');
|
|
7
7
|
const { atomicWrite } = require('./atomic-write');
|
|
8
8
|
|
|
@@ -22,9 +22,10 @@ function generateHusky(cwd, parsed) {
|
|
|
22
22
|
body += `# ${section}\n`;
|
|
23
23
|
for (const gate of gates) {
|
|
24
24
|
const shell = gateToShell(gate.cmd);
|
|
25
|
-
//
|
|
26
|
-
|
|
27
|
-
const
|
|
25
|
+
// Escape path/condition for the double-quoted shell context.
|
|
26
|
+
// The escaper handles \ before " so the replacements don't overlap.
|
|
27
|
+
const quotedPath = gate.path ? shellEscapeDoubleQuoted(gate.path) : null;
|
|
28
|
+
const quotedCond = gate.condition ? shellEscapeDoubleQuoted(gate.condition) : null;
|
|
28
29
|
|
|
29
30
|
// Build the core command (with cd if path-scoped)
|
|
30
31
|
const coreCmd = quotedPath ? `(cd "${quotedPath}" && ${shell})` : shell;
|
|
@@ -32,7 +33,7 @@ function generateHusky(cwd, parsed) {
|
|
|
32
33
|
// Build failure handler based on classification
|
|
33
34
|
let onFail;
|
|
34
35
|
if (gate.classification === 'OPTIONAL' || gate.classification === 'ADVISORY') {
|
|
35
|
-
const escLabel = shell
|
|
36
|
+
const escLabel = shellEscapeDoubleQuoted(shell);
|
|
36
37
|
onFail = `echo " [${gate.classification}] Gate failed: ${escLabel}"`;
|
|
37
38
|
} else {
|
|
38
39
|
onFail = 'exit 1';
|
|
@@ -2,9 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
const fs = require('fs');
|
|
4
4
|
const path = require('path');
|
|
5
|
-
const { gateToShell } = require('../governance/gate-to-shell');
|
|
5
|
+
const { gateToShell, shellEscapeDoubleQuoted, shellEscapeSingleQuoted } = require('../governance/gate-to-shell');
|
|
6
6
|
const { flattenGatesRich } = require('../governance/parse');
|
|
7
7
|
const { atomicWrite } = require('./atomic-write');
|
|
8
|
+
const { yamlScalar } = require('../update/integrity');
|
|
8
9
|
|
|
9
10
|
function generatePreCommitConfig(cwd, parsed) {
|
|
10
11
|
const gates = flattenGatesRich(parsed.gates);
|
|
@@ -17,16 +18,23 @@ function generatePreCommitConfig(cwd, parsed) {
|
|
|
17
18
|
const truncated = name.length > 60 ? name.substring(0, 57) + '...' : name;
|
|
18
19
|
|
|
19
20
|
let shell = gateToShell(gate.cmd);
|
|
20
|
-
|
|
21
|
-
|
|
21
|
+
// gate.path and gate.condition come from user-authored governance.md.
|
|
22
|
+
// Parser rejects absolute paths and `..`, but contents still need to be
|
|
23
|
+
// escaped for the surrounding double-quoted shell context.
|
|
24
|
+
if (gate.path) {
|
|
25
|
+
shell = `cd "${shellEscapeDoubleQuoted(gate.path)}" && ${shell}`;
|
|
26
|
+
}
|
|
27
|
+
if (gate.condition) {
|
|
28
|
+
shell = `[ -e "${shellEscapeDoubleQuoted(gate.condition)}" ] && (${shell}) || true`;
|
|
29
|
+
}
|
|
22
30
|
// For OPTIONAL/ADVISORY: never fail the hook
|
|
23
31
|
if (gate.classification === 'OPTIONAL' || gate.classification === 'ADVISORY') {
|
|
24
32
|
shell = `(${shell}) || echo "[${gate.classification}] failed — continuing"`;
|
|
25
33
|
}
|
|
26
34
|
|
|
27
35
|
hooks += ` - id: ${id}\n`;
|
|
28
|
-
hooks += ` name:
|
|
29
|
-
hooks += ` entry: bash -c '${shell
|
|
36
|
+
hooks += ` name: ${yamlScalar(truncated)}\n`;
|
|
37
|
+
hooks += ` entry: bash -c '${shellEscapeSingleQuoted(shell)}'\n`;
|
|
30
38
|
hooks += ' language: system\n';
|
|
31
39
|
hooks += ' pass_filenames: false\n';
|
|
32
40
|
hooks += ' always_run: true\n';
|
|
@@ -3,11 +3,21 @@
|
|
|
3
3
|
/**
|
|
4
4
|
* Escape a string for safe inclusion inside double quotes in a shell command.
|
|
5
5
|
* Escapes: backslash, backtick, dollar sign, double quote.
|
|
6
|
+
* Backslash MUST be replaced first so its replacement isn't re-escaped.
|
|
6
7
|
*/
|
|
7
8
|
function shellEscapeDoubleQuoted(s) {
|
|
8
9
|
return String(s).replace(/[\\`"$]/g, '\\$&');
|
|
9
10
|
}
|
|
10
11
|
|
|
12
|
+
/**
|
|
13
|
+
* Escape a string for safe inclusion inside single quotes in a shell command.
|
|
14
|
+
* Single quotes cannot be escaped inside single quotes — the standard pattern
|
|
15
|
+
* is to close the quote, emit an escaped quote, and reopen: 'foo'\''bar'.
|
|
16
|
+
*/
|
|
17
|
+
function shellEscapeSingleQuoted(s) {
|
|
18
|
+
return String(s).replace(/'/g, "'\\''");
|
|
19
|
+
}
|
|
20
|
+
|
|
11
21
|
/**
|
|
12
22
|
* Convert human-readable gate descriptions to shell commands.
|
|
13
23
|
* e.g. Verify src/skills/pre-start-context.md contains "discovers any project"
|
|
@@ -25,4 +35,4 @@ function gateToShell(cmd) {
|
|
|
25
35
|
return cmd;
|
|
26
36
|
}
|
|
27
37
|
|
|
28
|
-
module.exports = { gateToShell, shellEscapeDoubleQuoted };
|
|
38
|
+
module.exports = { gateToShell, shellEscapeDoubleQuoted, shellEscapeSingleQuoted };
|
package/src/governance/parse.js
CHANGED
|
@@ -14,6 +14,30 @@
|
|
|
14
14
|
// Protects against ReDoS on catastrophic-backtracking-prone regex.
|
|
15
15
|
const MAX_CONTENT_SIZE = 256 * 1024; // 256 KB
|
|
16
16
|
|
|
17
|
+
/**
|
|
18
|
+
* Validate an annotation path (used for `path:` and `if:` on gate sections).
|
|
19
|
+
*
|
|
20
|
+
* Rejects:
|
|
21
|
+
* - Absolute paths (/, C:\, \\server\share)
|
|
22
|
+
* - Parent traversal (..)
|
|
23
|
+
* - Newlines or null bytes (defense against injection into generated YAML/shell)
|
|
24
|
+
*
|
|
25
|
+
* These paths are interpolated into shell commands and YAML scalars downstream
|
|
26
|
+
* (husky, pre-commit, github-actions), so the parser is the single chokepoint
|
|
27
|
+
* where untrusted path strings from governance.md become structured data.
|
|
28
|
+
*/
|
|
29
|
+
function isValidAnnotationPath(p) {
|
|
30
|
+
if (typeof p !== 'string' || p.length === 0) return false;
|
|
31
|
+
if (p.length > 512) return false;
|
|
32
|
+
if (/[\n\r\x00]/.test(p)) return false;
|
|
33
|
+
// POSIX absolute or Windows drive-letter / UNC
|
|
34
|
+
if (p.startsWith('/') || /^[A-Za-z]:[\\/]/.test(p) || p.startsWith('\\\\')) return false;
|
|
35
|
+
// Parent traversal (match as a path segment, not as substring of a name)
|
|
36
|
+
const segments = p.split(/[\\/]/);
|
|
37
|
+
if (segments.includes('..')) return false;
|
|
38
|
+
return true;
|
|
39
|
+
}
|
|
40
|
+
|
|
17
41
|
/**
|
|
18
42
|
* Extract a markdown section body by heading name.
|
|
19
43
|
* Starts after the first line matching `## <name>` (with optional trailing text),
|
|
@@ -90,8 +114,22 @@ function parseGovernance(content) {
|
|
|
90
114
|
if (sub) {
|
|
91
115
|
section = sub[1].trim().toLowerCase();
|
|
92
116
|
sectionMeta = { path: null, condition: null };
|
|
93
|
-
if (sub[2] === 'path')
|
|
94
|
-
|
|
117
|
+
if (sub[2] === 'path') {
|
|
118
|
+
const raw = sub[3].trim();
|
|
119
|
+
if (isValidAnnotationPath(raw)) {
|
|
120
|
+
sectionMeta.path = raw;
|
|
121
|
+
} else {
|
|
122
|
+
result.warnings.push(`Invalid path annotation in section "${sub[1].trim()}": ${JSON.stringify(raw)} (must be a relative in-repo path without "..")`);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
if (sub[2] === 'if') {
|
|
126
|
+
const raw = sub[3].trim();
|
|
127
|
+
if (isValidAnnotationPath(raw)) {
|
|
128
|
+
sectionMeta.condition = raw;
|
|
129
|
+
} else {
|
|
130
|
+
result.warnings.push(`Invalid if annotation in section "${sub[1].trim()}": ${JSON.stringify(raw)} (must be a relative in-repo path without "..")`);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
95
133
|
result.gates[section] = {
|
|
96
134
|
commands: [],
|
|
97
135
|
path: sectionMeta.path,
|
|
@@ -179,4 +217,4 @@ function flattenGatesRich(gates) {
|
|
|
179
217
|
return out;
|
|
180
218
|
}
|
|
181
219
|
|
|
182
|
-
module.exports = { parseGovernance, flattenGates, flattenGatesRich, extractSection };
|
|
220
|
+
module.exports = { parseGovernance, flattenGates, flattenGatesRich, extractSection, isValidAnnotationPath };
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Shared YAML `run:` command extraction for GitHub Actions workflows.
|
|
5
|
+
*
|
|
6
|
+
* Both `crag analyze` and `crag diff` need to enumerate the shell commands
|
|
7
|
+
* inside CI workflows to either generate gates from them or compare them
|
|
8
|
+
* against governance. This module is the single source of truth so a fix to
|
|
9
|
+
* the parser benefits both commands.
|
|
10
|
+
*
|
|
11
|
+
* Handles:
|
|
12
|
+
* run: npm test (inline)
|
|
13
|
+
* run: "npm test" (inline, quoted)
|
|
14
|
+
* run: | (literal block scalar)
|
|
15
|
+
* npm test
|
|
16
|
+
* npm run build
|
|
17
|
+
* run: >- (folded block scalar)
|
|
18
|
+
* npm test
|
|
19
|
+
*
|
|
20
|
+
* Comment-only lines and blank lines inside blocks are skipped.
|
|
21
|
+
*/
|
|
22
|
+
function extractRunCommands(content) {
|
|
23
|
+
const commands = [];
|
|
24
|
+
const lines = String(content).split(/\r?\n/);
|
|
25
|
+
|
|
26
|
+
for (let i = 0; i < lines.length; i++) {
|
|
27
|
+
const line = lines[i];
|
|
28
|
+
const m = line.match(/^(\s*)-?\s*run:\s*(.*)$/);
|
|
29
|
+
if (!m) continue;
|
|
30
|
+
|
|
31
|
+
const baseIndent = m[1].length;
|
|
32
|
+
const rest = m[2].trim();
|
|
33
|
+
|
|
34
|
+
if (/^[|>][+-]?\s*$/.test(rest)) {
|
|
35
|
+
// Block scalar: collect following lines with greater indent than the key
|
|
36
|
+
for (let j = i + 1; j < lines.length; j++) {
|
|
37
|
+
const ln = lines[j];
|
|
38
|
+
if (ln.trim() === '') continue;
|
|
39
|
+
const indentMatch = ln.match(/^(\s*)/);
|
|
40
|
+
if (indentMatch[1].length <= baseIndent) break;
|
|
41
|
+
const trimmed = ln.trim();
|
|
42
|
+
if (trimmed && !trimmed.startsWith('#')) commands.push(trimmed);
|
|
43
|
+
}
|
|
44
|
+
} else if (rest && !rest.startsWith('#')) {
|
|
45
|
+
// Inline: strip surrounding single/double quotes if present
|
|
46
|
+
commands.push(rest.replace(/^["']|["']$/g, ''));
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return commands;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Classify a shell command as a "gate" — i.e., a quality check that belongs
|
|
55
|
+
* in governance.md (test, lint, typecheck, build, etc.) as opposed to
|
|
56
|
+
* deployment, git operations, or environment setup.
|
|
57
|
+
*
|
|
58
|
+
* This is a heuristic and intentionally conservative: false positives
|
|
59
|
+
* (extra gates) are easier to spot than false negatives (missing gates).
|
|
60
|
+
*/
|
|
61
|
+
function isGateCommand(cmd) {
|
|
62
|
+
const patterns = [
|
|
63
|
+
/\bnpm (run |ci|test|install)/,
|
|
64
|
+
/\bnpx /,
|
|
65
|
+
/\bnode /,
|
|
66
|
+
/\bcargo (test|build|check|clippy)/,
|
|
67
|
+
/\brustfmt/,
|
|
68
|
+
/\bgo (test|build|vet)/,
|
|
69
|
+
/\bgolangci-lint/,
|
|
70
|
+
/\bpytest/,
|
|
71
|
+
/\bpython -m/,
|
|
72
|
+
/\bruff/,
|
|
73
|
+
/\bmypy/,
|
|
74
|
+
/\bflake8/,
|
|
75
|
+
/\bgradle/,
|
|
76
|
+
/\bmvn /,
|
|
77
|
+
/\bmaven/,
|
|
78
|
+
/\beslint/,
|
|
79
|
+
/\bbiome/,
|
|
80
|
+
/\bprettier/,
|
|
81
|
+
/\btsc/,
|
|
82
|
+
/\bdocker (build|compose)/,
|
|
83
|
+
/\bmake /,
|
|
84
|
+
/\bjust /,
|
|
85
|
+
];
|
|
86
|
+
return patterns.some((p) => p.test(cmd));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
module.exports = { extractRunCommands, isGateCommand };
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: post-start-validation
|
|
3
|
-
version: 0.2.
|
|
3
|
+
version: 0.2.2
|
|
4
4
|
source_hash: 5a64dfe68b13577dff818fa63ddb6185be360c80b100f205bc586aac39e19e80
|
|
5
5
|
description: Universal validation and knowledge capture. Detects what changed, runs governance gates, captures knowledge, verifies deployment. Works for any project.
|
|
6
6
|
---
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: pre-start-context
|
|
3
|
-
version: 0.2.
|
|
3
|
+
version: 0.2.2
|
|
4
4
|
source_hash: b7be8434b99d5b189c904263e783d573c82109218725cc31fbd4fa1bf81538b6
|
|
5
5
|
description: Universal context loader. Discovers any project's stack, architecture, and state at runtime. Reads governance.md for project-specific rules. Works for any language, framework, or deployment target.
|
|
6
6
|
---
|
package/src/update/integrity.js
CHANGED
|
@@ -47,8 +47,18 @@ function readFrontmatter(filePath) {
|
|
|
47
47
|
}
|
|
48
48
|
|
|
49
49
|
/**
|
|
50
|
-
* Format a value for YAML frontmatter.
|
|
50
|
+
* Format a value for YAML frontmatter or YAML block scalars.
|
|
51
51
|
* Quotes strings that contain special characters or could be ambiguous.
|
|
52
|
+
*
|
|
53
|
+
* Rules roughly follow YAML 1.2 plain-scalar constraints:
|
|
54
|
+
* - Leading/trailing whitespace → must quote
|
|
55
|
+
* - Special markers anywhere: : # & * ! | > ' " % @ `
|
|
56
|
+
* - Leading flow indicators: [ ] { } , (would start a flow sequence/map)
|
|
57
|
+
* - Leading dash + space looks like a block sequence entry
|
|
58
|
+
* - Leading ? or ! looks like a YAML tag or complex-key marker
|
|
59
|
+
* - Reserved words that coerce to other types: true/false/null/yes/no/~
|
|
60
|
+
* - Number-like strings
|
|
61
|
+
* - Empty string
|
|
52
62
|
*/
|
|
53
63
|
function yamlScalar(value) {
|
|
54
64
|
if (value == null) return '';
|
|
@@ -60,15 +70,18 @@ function yamlScalar(value) {
|
|
|
60
70
|
return `|\n${indented}`;
|
|
61
71
|
}
|
|
62
72
|
|
|
63
|
-
//
|
|
64
|
-
//
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
73
|
+
// Control characters (tabs, etc.) must be quoted so they survive round-trip.
|
|
74
|
+
// eslint-disable-next-line no-control-regex
|
|
75
|
+
if (/[\x00-\x1f]/.test(str)) {
|
|
76
|
+
return `"${str.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\t/g, '\\t').replace(/\r/g, '\\r')}"`;
|
|
77
|
+
}
|
|
78
|
+
|
|
69
79
|
const needsQuoting =
|
|
70
80
|
/^[\s]|[\s]$/.test(str) ||
|
|
71
81
|
/[:#&*!|>'"%@`]/.test(str) ||
|
|
82
|
+
/^[\[\]{},]/.test(str) ||
|
|
83
|
+
/^- /.test(str) ||
|
|
84
|
+
/^[?!]/.test(str) ||
|
|
72
85
|
/^(true|false|null|yes|no|~)$/i.test(str) ||
|
|
73
86
|
/^-?\d+(\.\d+)?$/.test(str) ||
|
|
74
87
|
str === '';
|
package/src/update/skill-sync.js
CHANGED