@whitehatd/crag 0.0.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,165 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const { gateToShell } = require('../governance/gate-to-shell');
6
+ const { flattenGatesRich } = require('../governance/parse');
7
+ const { atomicWrite } = require('./atomic-write');
8
+
9
+ /**
10
+ * Extract the major Node version from package.json engines.node field.
11
+ * Handles formats: ">=18.0.0", "^18", "18.x", "18.0.0", ">=18 <21".
12
+ * Returns a string like "18" or null if not found.
13
+ */
14
+ function detectNodeVersion(cwd) {
15
+ try {
16
+ const pkgPath = path.join(cwd, 'package.json');
17
+ if (!fs.existsSync(pkgPath)) return null;
18
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
19
+ const engines = pkg.engines?.node;
20
+ if (!engines || typeof engines !== 'string') return null;
21
+ const m = engines.match(/(\d+)/);
22
+ return m ? m[1] : null;
23
+ } catch { return null; }
24
+ }
25
+
26
+ /**
27
+ * Detect Python version from pyproject.toml requires-python field.
28
+ */
29
+ function detectPythonVersion(cwd) {
30
+ try {
31
+ const pyprojectPath = path.join(cwd, 'pyproject.toml');
32
+ if (!fs.existsSync(pyprojectPath)) return null;
33
+ const content = fs.readFileSync(pyprojectPath, 'utf-8');
34
+ const m = content.match(/requires-python\s*=\s*["'][^0-9]*(\d+\.\d+)/);
35
+ return m ? m[1] : null;
36
+ } catch { return null; }
37
+ }
38
+
39
+ /**
40
+ * Detect Java version from build.gradle.kts or pom.xml.
41
+ */
42
+ function detectJavaVersion(cwd) {
43
+ try {
44
+ const gradle = path.join(cwd, 'build.gradle.kts');
45
+ if (fs.existsSync(gradle)) {
46
+ const content = fs.readFileSync(gradle, 'utf-8');
47
+ const m = content.match(/JavaVersion\.VERSION_(\d+)|languageVersion\s*=\s*JavaLanguageVersion\.of\((\d+)\)|jvmToolchain\((\d+)\)/);
48
+ if (m) return m[1] || m[2] || m[3];
49
+ }
50
+ const pom = path.join(cwd, 'pom.xml');
51
+ if (fs.existsSync(pom)) {
52
+ const content = fs.readFileSync(pom, 'utf-8');
53
+ const m = content.match(/<maven\.compiler\.source>(\d+)|<java\.version>(\d+)/);
54
+ if (m) return m[1] || m[2];
55
+ }
56
+ } catch { /* skip */ }
57
+ return null;
58
+ }
59
+
60
+ /**
61
+ * Detect Go version from go.mod.
62
+ */
63
+ function detectGoVersion(cwd) {
64
+ try {
65
+ const goMod = path.join(cwd, 'go.mod');
66
+ if (!fs.existsSync(goMod)) return null;
67
+ const content = fs.readFileSync(goMod, 'utf-8');
68
+ const m = content.match(/^go\s+(\d+\.\d+)/m);
69
+ return m ? m[1] : null;
70
+ } catch { return null; }
71
+ }
72
+
73
+ function generateGitHubActions(cwd, parsed) {
74
+ const dir = path.join(cwd, '.github', 'workflows');
75
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
76
+
77
+ // Detect versions from project files, fall back to current LTS defaults
78
+ const nodeVersion = detectNodeVersion(cwd) || '22';
79
+ const pythonVersion = detectPythonVersion(cwd) || '3.12';
80
+ const javaVersion = detectJavaVersion(cwd) || '21';
81
+ const goVersion = detectGoVersion(cwd) || '1.22';
82
+
83
+ let setupSteps = '';
84
+ if (parsed.runtimes.includes('node')) {
85
+ setupSteps += ' - name: Setup Node.js\n';
86
+ setupSteps += ' uses: actions/setup-node@v4\n';
87
+ setupSteps += ` with:\n node-version: '${nodeVersion}'\n`;
88
+ setupSteps += ' - run: npm ci\n';
89
+ }
90
+ if (parsed.runtimes.includes('rust')) {
91
+ setupSteps += ' - name: Setup Rust\n';
92
+ setupSteps += ' uses: dtolnay/rust-toolchain@stable\n';
93
+ }
94
+ if (parsed.runtimes.includes('python')) {
95
+ setupSteps += ' - name: Setup Python\n';
96
+ setupSteps += ' uses: actions/setup-python@v5\n';
97
+ setupSteps += ` with:\n python-version: '${pythonVersion}'\n`;
98
+ setupSteps += ' - run: pip install -r requirements.txt 2>/dev/null || true\n';
99
+ }
100
+ if (parsed.runtimes.includes('java')) {
101
+ setupSteps += ' - name: Setup Java\n';
102
+ setupSteps += ' uses: actions/setup-java@v4\n';
103
+ setupSteps += ` with:\n distribution: temurin\n java-version: '${javaVersion}'\n`;
104
+ }
105
+ if (parsed.runtimes.includes('go')) {
106
+ setupSteps += ' - name: Setup Go\n';
107
+ setupSteps += ' uses: actions/setup-go@v5\n';
108
+ setupSteps += ` with:\n go-version: '${goVersion}'\n`;
109
+ }
110
+
111
+ // Escape for YAML double-quoted scalar: \, ", and control chars.
112
+ const yamlDqEscape = (s) => String(s)
113
+ .replace(/\\/g, '\\\\')
114
+ .replace(/"/g, '\\"')
115
+ .replace(/\r/g, '\\r')
116
+ .replace(/\n/g, '\\n')
117
+ .replace(/\t/g, '\\t');
118
+
119
+ let gateSteps = '';
120
+ for (const gate of flattenGatesRich(parsed.gates)) {
121
+ const shell = gateToShell(gate.cmd);
122
+ const label = gate.cmd.length > 60 ? gate.cmd.substring(0, 57) + '...' : gate.cmd;
123
+ const prefix = gate.classification !== 'MANDATORY' ? `[${gate.classification}] ` : '';
124
+ const condExpr = gate.condition ? ` (if: ${gate.condition})` : '';
125
+ const workDir = gate.path ? `\n working-directory: ${gate.path}` : '';
126
+ const contOnErr = (gate.classification === 'OPTIONAL' || gate.classification === 'ADVISORY')
127
+ ? '\n continue-on-error: true' : '';
128
+ const ifGuard = gate.condition
129
+ ? `\n if: hashFiles('${gate.condition}') != ''` : '';
130
+ gateSteps += ` - name: "${prefix}${yamlDqEscape(gate.section)}: ${yamlDqEscape(label)}${yamlDqEscape(condExpr)}"${ifGuard}${workDir}${contOnErr}\n`;
131
+ gateSteps += ` run: |\n ${shell.replace(/\n/g, '\n ')}\n`;
132
+ }
133
+
134
+ const yaml = [
135
+ '# Generated from governance.md by crag',
136
+ '# Regenerate: crag compile --target github',
137
+ 'name: Governance Gates',
138
+ '',
139
+ 'on:',
140
+ ' push:',
141
+ ' branches: [main, master]',
142
+ ' pull_request:',
143
+ ' branches: [main, master]',
144
+ '',
145
+ 'jobs:',
146
+ ' gates:',
147
+ ' name: Governance Gates',
148
+ ' runs-on: ubuntu-latest',
149
+ ' steps:',
150
+ ' - uses: actions/checkout@v4',
151
+ setupSteps + gateSteps,
152
+ ].join('\n');
153
+
154
+ const outPath = path.join(dir, 'gates.yml');
155
+ atomicWrite(outPath, yaml);
156
+ console.log(` \x1b[32m✓\x1b[0m ${path.relative(cwd, outPath)}`);
157
+ }
158
+
159
+ module.exports = {
160
+ generateGitHubActions,
161
+ detectNodeVersion,
162
+ detectPythonVersion,
163
+ detectJavaVersion,
164
+ detectGoVersion,
165
+ };
@@ -0,0 +1,66 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const { gateToShell } = require('../governance/gate-to-shell');
6
+ const { flattenGatesRich } = require('../governance/parse');
7
+ const { atomicWrite } = require('./atomic-write');
8
+
9
+ function generateHusky(cwd, parsed) {
10
+ const dir = path.join(cwd, '.husky');
11
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
12
+
13
+ // Group gates by section for readable output
14
+ const sections = new Map();
15
+ for (const gate of flattenGatesRich(parsed.gates)) {
16
+ if (!sections.has(gate.section)) sections.set(gate.section, []);
17
+ sections.get(gate.section).push(gate);
18
+ }
19
+
20
+ let body = '';
21
+ for (const [section, gates] of sections) {
22
+ body += `# ${section}\n`;
23
+ for (const gate of gates) {
24
+ const shell = gateToShell(gate.cmd);
25
+ // Quote path/condition for shell safety
26
+ const quotedPath = gate.path ? gate.path.replace(/"/g, '\\"') : null;
27
+ const quotedCond = gate.condition ? gate.condition.replace(/"/g, '\\"') : null;
28
+
29
+ // Build the core command (with cd if path-scoped)
30
+ const coreCmd = quotedPath ? `(cd "${quotedPath}" && ${shell})` : shell;
31
+
32
+ // Build failure handler based on classification
33
+ let onFail;
34
+ if (gate.classification === 'OPTIONAL' || gate.classification === 'ADVISORY') {
35
+ const escLabel = shell.replace(/"/g, '\\"');
36
+ onFail = `echo " [${gate.classification}] Gate failed: ${escLabel}"`;
37
+ } else {
38
+ onFail = 'exit 1';
39
+ }
40
+
41
+ // Wrap in conditional if section has `if:` annotation — skip cleanly if file missing
42
+ if (quotedCond) {
43
+ body += `if [ -e "${quotedCond}" ]; then ${coreCmd} || ${onFail}; fi\n`;
44
+ } else {
45
+ body += `${coreCmd} || ${onFail}\n`;
46
+ }
47
+ }
48
+ body += '\n';
49
+ }
50
+
51
+ const script = [
52
+ '#!/bin/sh',
53
+ '# Generated from governance.md by crag',
54
+ '# Regenerate: crag compile --target husky',
55
+ 'set -e',
56
+ '',
57
+ body.trim(),
58
+ '',
59
+ ].join('\n');
60
+
61
+ const outPath = path.join(dir, 'pre-commit');
62
+ atomicWrite(outPath, script);
63
+ console.log(` \x1b[32m✓\x1b[0m ${path.relative(cwd, outPath)}`);
64
+ }
65
+
66
+ module.exports = { generateHusky };
@@ -0,0 +1,50 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const { gateToShell } = require('../governance/gate-to-shell');
6
+ const { flattenGatesRich } = require('../governance/parse');
7
+ const { atomicWrite } = require('./atomic-write');
8
+
9
+ function generatePreCommitConfig(cwd, parsed) {
10
+ const gates = flattenGatesRich(parsed.gates);
11
+
12
+ let hooks = '';
13
+ gates.forEach((gate, i) => {
14
+ const id = `gate-${i + 1}`;
15
+ const prefix = gate.classification !== 'MANDATORY' ? `[${gate.classification}] ` : '';
16
+ const name = `${prefix}${gate.section}: ${gate.cmd}`;
17
+ const truncated = name.length > 60 ? name.substring(0, 57) + '...' : name;
18
+
19
+ let shell = gateToShell(gate.cmd);
20
+ if (gate.path) shell = `cd "${gate.path}" && ${shell}`;
21
+ if (gate.condition) shell = `[ -e "${gate.condition}" ] && (${shell}) || true`;
22
+ // For OPTIONAL/ADVISORY: never fail the hook
23
+ if (gate.classification === 'OPTIONAL' || gate.classification === 'ADVISORY') {
24
+ shell = `(${shell}) || echo "[${gate.classification}] failed — continuing"`;
25
+ }
26
+
27
+ hooks += ` - id: ${id}\n`;
28
+ hooks += ` name: "${truncated.replace(/"/g, '\\"')}"\n`;
29
+ hooks += ` entry: bash -c '${shell.replace(/'/g, "'\\''")}'\n`;
30
+ hooks += ' language: system\n';
31
+ hooks += ' pass_filenames: false\n';
32
+ hooks += ' always_run: true\n';
33
+ });
34
+
35
+ const yaml = [
36
+ '# Generated from governance.md by crag',
37
+ '# Regenerate: crag compile --target pre-commit',
38
+ 'repos:',
39
+ ' - repo: local',
40
+ ' hooks:',
41
+ hooks.trimEnd(),
42
+ '',
43
+ ].join('\n');
44
+
45
+ const outPath = path.join(cwd, '.pre-commit-config.yaml');
46
+ atomicWrite(outPath, yaml);
47
+ console.log(` \x1b[32m✓\x1b[0m ${path.relative(cwd, outPath)}`);
48
+ }
49
+
50
+ module.exports = { generatePreCommitConfig };
@@ -0,0 +1,76 @@
1
+ 'use strict';
2
+
3
+ const path = require('path');
4
+ const { flattenGatesRich } = require('../governance/parse');
5
+ const { atomicWrite } = require('./atomic-write');
6
+
7
+ /**
8
+ * Compile governance.md to Windsurf rules.
9
+ * Output: .windsurfrules
10
+ *
11
+ * Windsurf (by Codeium) is an AI-native IDE with a Cascade agent mode.
12
+ * It reads `.windsurfrules` at the workspace root for project-level guidance.
13
+ *
14
+ * Reference:
15
+ * https://docs.windsurf.com/windsurf/cascade/memories#rules
16
+ */
17
+ function generateWindsurf(cwd, parsed) {
18
+ const gates = flattenGatesRich(parsed.gates);
19
+
20
+ const gatesList = gates.length === 0
21
+ ? '(none defined)'
22
+ : gates
23
+ .map((g, i) => {
24
+ const prefix = g.classification !== 'MANDATORY' ? ` [${g.classification}]` : '';
25
+ const scope = g.path ? ` (in ${g.path})` : '';
26
+ return `${i + 1}. \`${g.cmd}\`${scope}${prefix}`;
27
+ })
28
+ .join('\n');
29
+
30
+ const content = `# Windsurf Rules — ${parsed.name || 'project'}
31
+
32
+ Generated from governance.md by crag. Regenerate: \`crag compile --target windsurf\`
33
+
34
+ ## Project
35
+
36
+ ${parsed.description || '(No description)'}
37
+
38
+ ## Runtimes
39
+
40
+ ${parsed.runtimes.join(', ') || 'polyglot — detected at runtime'}
41
+
42
+ ## Cascade Behavior
43
+
44
+ When Windsurf's Cascade agent operates on this project:
45
+
46
+ - **Always read governance.md first.** It is the single source of truth for quality gates and policies.
47
+ - **Run all mandatory gates before proposing changes.** Stop on first failure.
48
+ - **Respect classifications.** OPTIONAL gates warn but don't block. ADVISORY gates are informational.
49
+ - **Respect path scopes.** Gates with a \`path:\` annotation must run from that directory.
50
+ - **No destructive commands.** Never run rm -rf, dd, DROP TABLE, force-push to main, curl|bash, docker system prune.
51
+ - **No secrets.** Reject any code matching \`sk_live\`, \`sk_test\`, \`AKIA\`, or plaintext credentials.
52
+ - **Conventional commits.** Every commit must follow \`<type>(<scope>): <description>\`.
53
+
54
+ ## Quality Gates (run in order)
55
+
56
+ ${gatesList}
57
+
58
+ ## Rules of Engagement
59
+
60
+ 1. **Minimal changes.** Don't rewrite files that weren't asked to change.
61
+ 2. **No new dependencies** without explicit approval.
62
+ 3. **Prefer editing** existing files over creating new ones.
63
+ 4. **Always explain** non-obvious changes in commit messages.
64
+ 5. **Ask before** destructive operations (delete, rename, migrate schema).
65
+
66
+ ---
67
+
68
+ **Tool:** crag — https://www.npmjs.com/package/@whitehatd/crag
69
+ `;
70
+
71
+ const outPath = path.join(cwd, '.windsurfrules');
72
+ atomicWrite(outPath, content);
73
+ console.log(` \x1b[32m✓\x1b[0m ${path.relative(cwd, outPath)}`);
74
+ }
75
+
76
+ module.exports = { generateWindsurf };
@@ -0,0 +1,86 @@
1
+ 'use strict';
2
+
3
+ const path = require('path');
4
+ const { flattenGatesRich } = require('../governance/parse');
5
+ const { atomicWrite } = require('./atomic-write');
6
+
7
+ /**
8
+ * Compile governance.md to Zed Editor assistant prompts.
9
+ * Output: .zed/rules.md (read by Zed's AI assistant as project context)
10
+ *
11
+ * Zed is a high-performance multiplayer code editor with an integrated
12
+ * AI assistant. Project-level rules live in `.zed/` configuration.
13
+ *
14
+ * Reference:
15
+ * https://zed.dev/docs/assistant/assistant
16
+ */
17
+ function generateZed(cwd, parsed) {
18
+ const gates = flattenGatesRich(parsed.gates);
19
+
20
+ const gatesList = gates.length === 0
21
+ ? '_(none defined)_'
22
+ : gates
23
+ .map((g) => {
24
+ const tags = [];
25
+ if (g.classification !== 'MANDATORY') tags.push(g.classification);
26
+ if (g.path) tags.push(`path:${g.path}`);
27
+ if (g.condition) tags.push(`if:${g.condition}`);
28
+ return `- \`${g.cmd}\`${tags.length > 0 ? ` _(${tags.join(', ')})_` : ''}`;
29
+ })
30
+ .join('\n');
31
+
32
+ const content = `# Zed Assistant Rules — ${parsed.name || 'project'}
33
+
34
+ > Generated from governance.md by crag. Regenerate: \`crag compile --target zed\`
35
+
36
+ ## Project Summary
37
+
38
+ ${parsed.description || '(No description)'}
39
+
40
+ **Runtimes:** ${parsed.runtimes.join(', ') || 'polyglot'}
41
+
42
+ ## Rules for Zed AI Assistant
43
+
44
+ When suggesting edits or running the inline assistant:
45
+
46
+ ### 1. Quality Gates
47
+
48
+ These must pass before any commit. Run them via Zed's terminal integration:
49
+
50
+ ${gatesList}
51
+
52
+ ### 2. Classification Semantics
53
+
54
+ - **MANDATORY** — stop if this fails
55
+ - **OPTIONAL** — warn and continue
56
+ - **ADVISORY** — log and continue (informational)
57
+
58
+ ### 3. Scope Rules
59
+
60
+ - \`path:dir/\` — run the gate from that directory
61
+ - \`if:file\` — skip the gate's section when the file does not exist
62
+
63
+ ### 4. Behavior Boundaries
64
+
65
+ - All file operations must stay within this repository.
66
+ - Never run destructive system commands (\`rm -rf /\`, \`DROP TABLE\`, \`curl|bash\`, force-push to main).
67
+ - Never commit hardcoded secrets. Grep for \`sk_live\`, \`sk_test\`, \`AKIA\`, \`password=\` before commit.
68
+ - Use conventional commits (\`feat:\`, \`fix:\`, \`docs:\`, etc.).
69
+
70
+ ### 5. Authoritative Source
71
+
72
+ When these rules conflict with ad-hoc instructions, **governance.md wins**. It is the single source of truth for this project's policies.
73
+
74
+ ---
75
+
76
+ **Generated by crag** — https://www.npmjs.com/package/@whitehatd/crag
77
+
78
+ To update these rules, edit \`.claude/governance.md\` and re-run \`crag compile --target zed\`.
79
+ `;
80
+
81
+ const outPath = path.join(cwd, '.zed', 'rules.md');
82
+ atomicWrite(outPath, content);
83
+ console.log(` \x1b[32m✓\x1b[0m ${path.relative(cwd, outPath)}`);
84
+ }
85
+
86
+ module.exports = { generateZed };