@whitehatd/crag 0.2.2 → 0.2.4
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/README.md +2 -1
- package/package.json +1 -1
- package/src/analyze/ci-extractors.js +317 -0
- package/src/analyze/doc-mining.js +142 -0
- package/src/analyze/gates.js +417 -0
- package/src/analyze/normalize.js +146 -0
- package/src/analyze/stacks.js +453 -0
- package/src/analyze/task-runners.js +146 -0
- package/src/cli-errors.js +55 -0
- package/src/cli.js +10 -2
- package/src/commands/analyze.js +185 -271
- 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 +145 -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/src/commands/analyze.js
CHANGED
|
@@ -5,14 +5,33 @@ 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 { isGateCommand } = require('../governance/yaml-run');
|
|
9
|
+
const { cliWarn, cliError, EXIT_INTERNAL } = require('../cli-errors');
|
|
10
|
+
const { detectStack } = require('../analyze/stacks');
|
|
11
|
+
const { inferGates } = require('../analyze/gates');
|
|
12
|
+
const { normalizeCiGates } = require('../analyze/normalize');
|
|
13
|
+
const { extractCiCommands } = require('../analyze/ci-extractors');
|
|
14
|
+
const { mineTaskTargets } = require('../analyze/task-runners');
|
|
15
|
+
const { mineDocGates } = require('../analyze/doc-mining');
|
|
16
|
+
|
|
17
|
+
// Directories commonly used for test fixtures and examples in workspaces.
|
|
18
|
+
// When analyze auto-wires to workspace detection, these are excluded from
|
|
19
|
+
// member enumeration so we don't emit 79 per-fixture sections for vite.
|
|
20
|
+
const FIXTURE_DIR_PATTERNS = [
|
|
21
|
+
/^playground$/,
|
|
22
|
+
/^fixtures$/,
|
|
23
|
+
/^examples?$/,
|
|
24
|
+
/^demos?$/,
|
|
25
|
+
/^test-fixtures$/,
|
|
26
|
+
/^__fixtures__$/,
|
|
27
|
+
];
|
|
8
28
|
|
|
9
29
|
/**
|
|
10
30
|
* crag analyze — generate governance.md from existing project without interview.
|
|
11
|
-
* Reads CI configs, package manifests, linter configs, git history.
|
|
12
31
|
*/
|
|
13
32
|
function analyze(args) {
|
|
14
33
|
const dryRun = args.includes('--dry-run');
|
|
15
|
-
const
|
|
34
|
+
const workspaceFlag = args.includes('--workspace');
|
|
16
35
|
const merge = args.includes('--merge');
|
|
17
36
|
const cwd = process.cwd();
|
|
18
37
|
|
|
@@ -20,17 +39,26 @@ function analyze(args) {
|
|
|
20
39
|
|
|
21
40
|
const analysis = analyzeProject(cwd);
|
|
22
41
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
42
|
+
// Workspace detection always runs. If a workspace is detected, we either:
|
|
43
|
+
// (a) emit per-member sections when --workspace is passed, OR
|
|
44
|
+
// (b) print a hint so the user knows to opt in.
|
|
45
|
+
const ws = detectWorkspace(cwd);
|
|
46
|
+
if (ws.type !== 'none') {
|
|
47
|
+
analysis.workspaceType = ws.type;
|
|
48
|
+
if (workspaceFlag) {
|
|
49
|
+
const members = filterFixtureMembers(enumerateMembers(ws));
|
|
50
|
+
console.log(` Workspace detected: ${ws.type} (${members.length} real members after fixture filter)\n`);
|
|
28
51
|
analysis.workspace = { type: ws.type, members: [] };
|
|
29
|
-
|
|
30
52
|
for (const member of members) {
|
|
31
53
|
const memberAnalysis = analyzeProject(member.path);
|
|
32
|
-
analysis.workspace.members.push({
|
|
54
|
+
analysis.workspace.members.push({
|
|
55
|
+
name: member.name,
|
|
56
|
+
relativePath: member.relativePath,
|
|
57
|
+
...memberAnalysis,
|
|
58
|
+
});
|
|
33
59
|
}
|
|
60
|
+
} else {
|
|
61
|
+
console.log(` Workspace detected: ${ws.type}. Pass --workspace for per-member gates.\n`);
|
|
34
62
|
}
|
|
35
63
|
}
|
|
36
64
|
|
|
@@ -45,21 +73,29 @@ function analyze(args) {
|
|
|
45
73
|
|
|
46
74
|
const govPath = path.join(cwd, '.claude', 'governance.md');
|
|
47
75
|
const govDir = path.dirname(govPath);
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
76
|
+
try {
|
|
77
|
+
if (!fs.existsSync(govDir)) fs.mkdirSync(govDir, { recursive: true });
|
|
78
|
+
|
|
79
|
+
if (fs.existsSync(govPath) && !merge) {
|
|
80
|
+
const backupPath = govPath + '.bak.' + Date.now();
|
|
81
|
+
try {
|
|
82
|
+
fs.copyFileSync(govPath, backupPath);
|
|
83
|
+
console.log(` Backed up existing governance to ${path.basename(backupPath)}`);
|
|
84
|
+
} catch (err) {
|
|
85
|
+
cliWarn(`could not create backup (continuing anyway): ${err.message}`);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
55
88
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
89
|
+
if (merge && fs.existsSync(govPath)) {
|
|
90
|
+
console.log(' Merge mode: preserving existing governance, appending new gates');
|
|
91
|
+
const existing = fs.readFileSync(govPath, 'utf-8');
|
|
92
|
+
const mergedContent = mergeWithExisting(existing, governance);
|
|
93
|
+
fs.writeFileSync(govPath, mergedContent);
|
|
94
|
+
} else {
|
|
95
|
+
fs.writeFileSync(govPath, governance);
|
|
96
|
+
}
|
|
97
|
+
} catch (err) {
|
|
98
|
+
cliError(`failed to write ${path.relative(cwd, govPath)}: ${err.message}`, EXIT_INTERNAL);
|
|
63
99
|
}
|
|
64
100
|
|
|
65
101
|
console.log(` \x1b[32m✓\x1b[0m Generated ${path.relative(cwd, govPath)}`);
|
|
@@ -67,12 +103,23 @@ function analyze(args) {
|
|
|
67
103
|
console.log(` Run 'crag check' to verify infrastructure.\n`);
|
|
68
104
|
}
|
|
69
105
|
|
|
106
|
+
/**
|
|
107
|
+
* Filter out test fixture directories from workspace member lists.
|
|
108
|
+
* These directories (playground/, fixtures/, etc.) contain dozens of one-off
|
|
109
|
+
* test setups that should not each get their own governance section.
|
|
110
|
+
*/
|
|
111
|
+
function filterFixtureMembers(members) {
|
|
112
|
+
return members.filter(m => {
|
|
113
|
+
const parts = m.relativePath.split(/[\\/]/);
|
|
114
|
+
return !parts.some(p => FIXTURE_DIR_PATTERNS.some(rx => rx.test(p)));
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
|
|
70
118
|
function analyzeProject(dir) {
|
|
71
119
|
const result = {
|
|
72
120
|
name: path.basename(dir),
|
|
73
121
|
description: '',
|
|
74
122
|
stack: [],
|
|
75
|
-
gates: [],
|
|
76
123
|
linters: [],
|
|
77
124
|
formatters: [],
|
|
78
125
|
testers: [],
|
|
@@ -82,218 +129,116 @@ function analyzeProject(dir) {
|
|
|
82
129
|
deployment: [],
|
|
83
130
|
ci: null,
|
|
84
131
|
ciGates: [],
|
|
132
|
+
advisories: [],
|
|
133
|
+
docGates: [],
|
|
134
|
+
taskTargets: { make: [], task: [], just: [] },
|
|
85
135
|
};
|
|
86
136
|
|
|
87
|
-
//
|
|
137
|
+
// Stack detection (languages, frameworks, package managers)
|
|
88
138
|
detectStack(dir, result);
|
|
89
139
|
|
|
90
|
-
//
|
|
91
|
-
|
|
140
|
+
// CI system detection + raw command extraction (all supported CI systems)
|
|
141
|
+
const ci = extractCiCommands(dir);
|
|
142
|
+
result.ci = ci.system;
|
|
143
|
+
result.ciGates = normalizeCiGates(ci.commands.filter(c => isGateCommand(c)));
|
|
144
|
+
|
|
145
|
+
// Task runner target mining
|
|
146
|
+
result.taskTargets = mineTaskTargets(dir);
|
|
92
147
|
|
|
93
|
-
//
|
|
94
|
-
|
|
148
|
+
// Gate inference per language/runtime (consumes _manifests)
|
|
149
|
+
inferGates(dir, result);
|
|
95
150
|
|
|
96
|
-
//
|
|
97
|
-
|
|
151
|
+
// Emit explicit gates for mined task targets
|
|
152
|
+
emitTaskRunnerGates(result);
|
|
98
153
|
|
|
99
|
-
//
|
|
154
|
+
// Documentation-based gate mining (advisory)
|
|
155
|
+
result.docGates = mineDocGates(dir);
|
|
156
|
+
|
|
157
|
+
// Legacy deployment detection
|
|
100
158
|
detectDeployment(dir, result);
|
|
101
159
|
|
|
102
|
-
//
|
|
160
|
+
// Git-derived branch strategy + commit convention
|
|
103
161
|
inferGitPatterns(dir, result);
|
|
104
162
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
if (fs.existsSync(path.join(dir, 'package.json'))) {
|
|
110
|
-
result.stack.push('node');
|
|
111
|
-
try {
|
|
112
|
-
const pkg = JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf-8'));
|
|
113
|
-
result.name = pkg.name || result.name;
|
|
114
|
-
result.description = pkg.description || '';
|
|
115
|
-
|
|
116
|
-
const deps = { ...pkg.dependencies, ...pkg.devDependencies };
|
|
117
|
-
if (deps.next) result.stack.push('next.js');
|
|
118
|
-
if (deps.react && !deps.next) result.stack.push('react');
|
|
119
|
-
if (deps.vue) result.stack.push('vue');
|
|
120
|
-
if (deps.svelte) result.stack.push('svelte');
|
|
121
|
-
if (deps.express) result.stack.push('express');
|
|
122
|
-
if (deps.fastify) result.stack.push('fastify');
|
|
123
|
-
if (deps.typescript) result.stack.push('typescript');
|
|
124
|
-
} catch { /* skip */ }
|
|
163
|
+
// Carry advisories collected during gate inference (hadolint, actionlint)
|
|
164
|
+
if (result._advisories) {
|
|
165
|
+
result.advisories.push(...result._advisories);
|
|
166
|
+
delete result._advisories;
|
|
125
167
|
}
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
if (fs.existsSync(path.join(dir, 'pyproject.toml')) || fs.existsSync(path.join(dir, 'setup.py'))) result.stack.push('python');
|
|
129
|
-
if (fs.existsSync(path.join(dir, 'build.gradle.kts')) || fs.existsSync(path.join(dir, 'build.gradle'))) result.stack.push('java/gradle');
|
|
130
|
-
if (fs.existsSync(path.join(dir, 'pom.xml'))) result.stack.push('java/maven');
|
|
131
|
-
if (fs.existsSync(path.join(dir, 'Dockerfile'))) result.stack.push('docker');
|
|
132
|
-
}
|
|
168
|
+
// Drop the internal manifests attachment before returning
|
|
169
|
+
delete result._manifests;
|
|
133
170
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
const workflowDir = path.join(dir, '.github', 'workflows');
|
|
137
|
-
if (fs.existsSync(workflowDir)) {
|
|
138
|
-
result.ci = 'github-actions';
|
|
139
|
-
try {
|
|
140
|
-
// Walk workflow dir recursively to catch nested workflows
|
|
141
|
-
const walk = (d) => {
|
|
142
|
-
const out = [];
|
|
143
|
-
for (const entry of fs.readdirSync(d, { withFileTypes: true })) {
|
|
144
|
-
const full = path.join(d, entry.name);
|
|
145
|
-
if (entry.isDirectory()) out.push(...walk(full));
|
|
146
|
-
else if (entry.name.endsWith('.yml') || entry.name.endsWith('.yaml')) out.push(full);
|
|
147
|
-
}
|
|
148
|
-
return out;
|
|
149
|
-
};
|
|
150
|
-
for (const file of walk(workflowDir)) {
|
|
151
|
-
const content = fs.readFileSync(file, 'utf-8');
|
|
152
|
-
for (const cmd of extractRunCommands(content)) {
|
|
153
|
-
if (isGateCommand(cmd)) result.ciGates.push(cmd);
|
|
154
|
-
}
|
|
155
|
-
}
|
|
156
|
-
} catch { /* skip */ }
|
|
157
|
-
}
|
|
171
|
+
return result;
|
|
172
|
+
}
|
|
158
173
|
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
174
|
+
function emitTaskRunnerGates(result) {
|
|
175
|
+
const { make, task, just } = result.taskTargets;
|
|
176
|
+
for (const target of make) {
|
|
177
|
+
// Canonical gate name: use `make X` literally
|
|
178
|
+
const cmd = `make ${target}`;
|
|
179
|
+
if (isTestTarget(target)) addUnique(result.testers, cmd);
|
|
180
|
+
else if (isLintTarget(target)) addUnique(result.linters, cmd);
|
|
181
|
+
else if (isBuildTarget(target)) addUnique(result.builders, cmd);
|
|
162
182
|
}
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
result.
|
|
183
|
+
for (const target of task) {
|
|
184
|
+
const cmd = `task ${target}`;
|
|
185
|
+
if (isTestTarget(target)) addUnique(result.testers, cmd);
|
|
186
|
+
else if (isLintTarget(target)) addUnique(result.linters, cmd);
|
|
187
|
+
else if (isBuildTarget(target)) addUnique(result.builders, cmd);
|
|
167
188
|
}
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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
|
-
}
|
|
189
|
+
for (const target of just) {
|
|
190
|
+
const cmd = `just ${target}`;
|
|
191
|
+
if (isTestTarget(target)) addUnique(result.testers, cmd);
|
|
192
|
+
else if (isLintTarget(target)) addUnique(result.linters, cmd);
|
|
193
|
+
else if (isBuildTarget(target)) addUnique(result.builders, cmd);
|
|
210
194
|
}
|
|
211
|
-
|
|
212
|
-
return commands;
|
|
213
195
|
}
|
|
214
196
|
|
|
215
|
-
function
|
|
216
|
-
|
|
217
|
-
if (!fs.existsSync(pkgPath)) return;
|
|
218
|
-
|
|
219
|
-
try {
|
|
220
|
-
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
|
|
221
|
-
const scripts = pkg.scripts || {};
|
|
222
|
-
|
|
223
|
-
const scriptMap = {
|
|
224
|
-
test: 'testers',
|
|
225
|
-
lint: 'linters',
|
|
226
|
-
build: 'builders',
|
|
227
|
-
format: 'formatters',
|
|
228
|
-
typecheck: 'builders',
|
|
229
|
-
check: 'linters',
|
|
230
|
-
};
|
|
231
|
-
|
|
232
|
-
for (const [key, category] of Object.entries(scriptMap)) {
|
|
233
|
-
if (scripts[key]) {
|
|
234
|
-
result[category].push(`npm run ${key}`);
|
|
235
|
-
}
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
// Also check for specific patterns
|
|
239
|
-
if (scripts['lint:fix']) result.formatters.push('npm run lint:fix');
|
|
240
|
-
if (scripts['format:check']) result.linters.push('npm run format:check');
|
|
241
|
-
} catch { /* skip */ }
|
|
197
|
+
function addUnique(arr, item) {
|
|
198
|
+
if (!arr.includes(item)) arr.push(item);
|
|
242
199
|
}
|
|
243
200
|
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
['prettier.config.js', 'prettier'], ['prettier.config.mjs', 'prettier'],
|
|
251
|
-
['ruff.toml', 'ruff'], ['.ruff.toml', 'ruff'],
|
|
252
|
-
['clippy.toml', 'clippy'], ['.clippy.toml', 'clippy'],
|
|
253
|
-
['rustfmt.toml', 'rustfmt'], ['.rustfmt.toml', 'rustfmt'],
|
|
254
|
-
['tsconfig.json', 'typescript'],
|
|
255
|
-
['.mypy.ini', 'mypy'], ['mypy.ini', 'mypy'],
|
|
256
|
-
];
|
|
257
|
-
|
|
258
|
-
for (const [file, tool] of linterConfigs) {
|
|
259
|
-
if (fs.existsSync(path.join(dir, file))) {
|
|
260
|
-
if (!result.linters.includes(tool)) result.linters.push(tool);
|
|
261
|
-
}
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
// Build/task file detection
|
|
265
|
-
const taskFiles = ['Makefile', 'Taskfile.yml', 'justfile'];
|
|
266
|
-
for (const file of taskFiles) {
|
|
267
|
-
if (fs.existsSync(path.join(dir, file))) {
|
|
268
|
-
result.builders.push(`${file} detected`);
|
|
269
|
-
}
|
|
270
|
-
}
|
|
271
|
-
}
|
|
201
|
+
const TEST_TARGETS = new Set(['test', 'tests', 'spec', 'check', 'ci', 'verify', 'validate']);
|
|
202
|
+
const LINT_TARGETS = new Set(['lint', 'format', 'fmt', 'style', 'typecheck', 'type-check', 'types']);
|
|
203
|
+
const BUILD_TARGETS = new Set(['build', 'compile']);
|
|
204
|
+
const isTestTarget = (t) => TEST_TARGETS.has(t);
|
|
205
|
+
const isLintTarget = (t) => LINT_TARGETS.has(t);
|
|
206
|
+
const isBuildTarget = (t) => BUILD_TARGETS.has(t);
|
|
272
207
|
|
|
273
208
|
function detectDeployment(dir, result) {
|
|
274
209
|
if (fs.existsSync(path.join(dir, 'Dockerfile'))) result.deployment.push('docker');
|
|
275
|
-
if (fs.existsSync(path.join(dir, 'docker-compose.yml')) ||
|
|
210
|
+
if (fs.existsSync(path.join(dir, 'docker-compose.yml')) ||
|
|
211
|
+
fs.existsSync(path.join(dir, 'docker-compose.yaml')) ||
|
|
212
|
+
fs.existsSync(path.join(dir, 'compose.yml')) ||
|
|
213
|
+
fs.existsSync(path.join(dir, 'compose.yaml'))) result.deployment.push('docker-compose');
|
|
276
214
|
if (fs.existsSync(path.join(dir, 'vercel.json')) || fs.existsSync(path.join(dir, '.vercel'))) result.deployment.push('vercel');
|
|
277
215
|
if (fs.existsSync(path.join(dir, 'fly.toml'))) result.deployment.push('fly.io');
|
|
278
216
|
if (fs.existsSync(path.join(dir, 'netlify.toml'))) result.deployment.push('netlify');
|
|
279
217
|
if (fs.existsSync(path.join(dir, 'render.yaml'))) result.deployment.push('render');
|
|
218
|
+
if (fs.existsSync(path.join(dir, 'railway.json')) || fs.existsSync(path.join(dir, 'railway.toml'))) result.deployment.push('railway');
|
|
219
|
+
if (fs.existsSync(path.join(dir, 'wrangler.toml'))) result.deployment.push('cloudflare-workers');
|
|
220
|
+
if (fs.existsSync(path.join(dir, 'app.yaml'))) result.deployment.push('gcp-app-engine');
|
|
221
|
+
if (fs.existsSync(path.join(dir, 'serverless.yml')) || fs.existsSync(path.join(dir, 'serverless.yaml'))) result.deployment.push('serverless-framework');
|
|
222
|
+
if (fs.existsSync(path.join(dir, 'template.yaml')) || fs.existsSync(path.join(dir, 'template.yml'))) result.deployment.push('aws-sam');
|
|
280
223
|
|
|
281
|
-
// Kubernetes
|
|
282
224
|
try {
|
|
283
225
|
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
284
226
|
for (const entry of entries) {
|
|
285
|
-
if (entry.isDirectory() && (entry.name === 'k8s' || entry.name === 'kubernetes' || entry.name === 'deploy')) {
|
|
227
|
+
if (entry.isDirectory() && (entry.name === 'k8s' || entry.name === 'kubernetes' || entry.name === 'deploy' || entry.name === 'manifests')) {
|
|
286
228
|
result.deployment.push('kubernetes');
|
|
287
229
|
break;
|
|
288
230
|
}
|
|
289
231
|
}
|
|
290
232
|
} catch { /* skip */ }
|
|
291
233
|
|
|
292
|
-
// Terraform
|
|
293
234
|
try {
|
|
294
235
|
const entries = fs.readdirSync(dir);
|
|
295
236
|
if (entries.some(f => f.endsWith('.tf'))) result.deployment.push('terraform');
|
|
296
237
|
} catch { /* skip */ }
|
|
238
|
+
|
|
239
|
+
if (fs.existsSync(path.join(dir, 'Chart.yaml'))) result.deployment.push('helm');
|
|
240
|
+
if (fs.existsSync(path.join(dir, 'Pulumi.yaml')) || fs.existsSync(path.join(dir, 'Pulumi.yml'))) result.deployment.push('pulumi');
|
|
241
|
+
if (fs.existsSync(path.join(dir, 'ansible.cfg'))) result.deployment.push('ansible');
|
|
297
242
|
}
|
|
298
243
|
|
|
299
244
|
function inferGitPatterns(dir, result) {
|
|
@@ -301,67 +246,43 @@ function inferGitPatterns(dir, result) {
|
|
|
301
246
|
const log = execSync('git log --oneline --all -50', { cwd: dir, encoding: 'utf-8', timeout: 5000, stdio: ['pipe', 'pipe', 'pipe'] });
|
|
302
247
|
const lines = log.trim().split('\n');
|
|
303
248
|
|
|
304
|
-
// Detect conventional commits
|
|
305
249
|
const conventional = lines.filter(l => /\b(feat|fix|docs|chore|style|refactor|test|build|ci|perf|revert)[\(:!]/.test(l));
|
|
306
250
|
result.commitConvention = conventional.length > lines.length * 0.3 ? 'conventional' : 'free-form';
|
|
307
251
|
|
|
308
|
-
// Detect branch strategy
|
|
309
252
|
const branches = execSync('git branch -a --format="%(refname:short)"', { cwd: dir, encoding: 'utf-8', timeout: 5000, stdio: ['pipe', 'pipe', 'pipe'] });
|
|
310
253
|
const branchList = branches.trim().split('\n');
|
|
311
254
|
const featureBranches = branchList.filter(b => /^(feat|fix|docs|chore|feature|hotfix|release)\//.test(b));
|
|
312
255
|
result.branchStrategy = featureBranches.length > 2 ? 'feature-branches' : 'trunk-based';
|
|
313
|
-
} catch {
|
|
256
|
+
} catch (err) {
|
|
257
|
+
if (err && err.code !== 'ENOENT') {
|
|
258
|
+
cliWarn(`could not detect git patterns in ${path.basename(dir)}: ${err.message}`);
|
|
259
|
+
}
|
|
314
260
|
result.branchStrategy = 'unknown';
|
|
315
261
|
result.commitConvention = 'unknown';
|
|
316
262
|
}
|
|
317
263
|
}
|
|
318
264
|
|
|
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
265
|
function generateGovernance(analysis, cwd) {
|
|
333
266
|
const sections = [];
|
|
334
267
|
|
|
335
|
-
// Identity
|
|
336
268
|
sections.push(`# Governance — ${analysis.name}`);
|
|
337
269
|
sections.push(`# Inferred by crag analyze — review and adjust as needed\n`);
|
|
338
270
|
sections.push('## Identity');
|
|
339
271
|
sections.push(`- Project: ${analysis.name}`);
|
|
340
272
|
if (analysis.description) sections.push(`- Description: ${analysis.description}`);
|
|
341
273
|
sections.push(`- Stack: ${analysis.stack.join(', ') || 'unknown'}`);
|
|
274
|
+
if (analysis.workspaceType) sections.push(`- Workspace: ${analysis.workspaceType}`);
|
|
342
275
|
sections.push('');
|
|
343
276
|
|
|
344
|
-
// Gates
|
|
345
277
|
sections.push('## Gates (run in order, stop on failure)');
|
|
346
278
|
|
|
347
|
-
// Group gates by type
|
|
348
279
|
const allGates = new Set();
|
|
349
280
|
|
|
350
|
-
//
|
|
281
|
+
// Lint
|
|
351
282
|
if (analysis.linters.length > 0) {
|
|
352
283
|
sections.push('### Lint');
|
|
353
|
-
for (const
|
|
354
|
-
|
|
355
|
-
switch (linter) {
|
|
356
|
-
case 'eslint': cmd = 'npx eslint . --max-warnings 0'; break;
|
|
357
|
-
case 'biome': cmd = 'npx biome check .'; break;
|
|
358
|
-
case 'ruff': cmd = 'ruff check .'; break;
|
|
359
|
-
case 'clippy': cmd = 'cargo clippy -- -D warnings'; break;
|
|
360
|
-
case 'mypy': cmd = 'mypy .'; break;
|
|
361
|
-
case 'typescript': cmd = 'npx tsc --noEmit'; break;
|
|
362
|
-
default: cmd = null;
|
|
363
|
-
}
|
|
364
|
-
if (cmd && !allGates.has(cmd)) {
|
|
284
|
+
for (const cmd of analysis.linters) {
|
|
285
|
+
if (!allGates.has(cmd)) {
|
|
365
286
|
sections.push(`- ${cmd}`);
|
|
366
287
|
allGates.add(cmd);
|
|
367
288
|
}
|
|
@@ -369,73 +290,73 @@ function generateGovernance(analysis, cwd) {
|
|
|
369
290
|
sections.push('');
|
|
370
291
|
}
|
|
371
292
|
|
|
372
|
-
//
|
|
293
|
+
// Test
|
|
373
294
|
if (analysis.testers.length > 0) {
|
|
374
295
|
sections.push('### Test');
|
|
375
|
-
for (const
|
|
376
|
-
if (!allGates.has(
|
|
377
|
-
sections.push(`- ${
|
|
378
|
-
allGates.add(
|
|
296
|
+
for (const cmd of analysis.testers) {
|
|
297
|
+
if (!allGates.has(cmd)) {
|
|
298
|
+
sections.push(`- ${cmd}`);
|
|
299
|
+
allGates.add(cmd);
|
|
379
300
|
}
|
|
380
301
|
}
|
|
381
302
|
sections.push('');
|
|
382
303
|
}
|
|
383
304
|
|
|
384
|
-
//
|
|
305
|
+
// Build
|
|
385
306
|
if (analysis.builders.length > 0) {
|
|
386
307
|
sections.push('### Build');
|
|
387
|
-
for (const
|
|
388
|
-
if (!
|
|
389
|
-
sections.push(`- ${
|
|
390
|
-
allGates.add(
|
|
308
|
+
for (const cmd of analysis.builders) {
|
|
309
|
+
if (!allGates.has(cmd)) {
|
|
310
|
+
sections.push(`- ${cmd}`);
|
|
311
|
+
allGates.add(cmd);
|
|
391
312
|
}
|
|
392
313
|
}
|
|
393
314
|
sections.push('');
|
|
394
315
|
}
|
|
395
316
|
|
|
396
|
-
//
|
|
317
|
+
// CI — only include gates not already captured by language inference
|
|
397
318
|
const uniqueCiGates = analysis.ciGates.filter(g => !allGates.has(g));
|
|
398
319
|
if (uniqueCiGates.length > 0) {
|
|
399
320
|
sections.push('### CI (inferred from workflow)');
|
|
400
321
|
for (const gate of uniqueCiGates) {
|
|
401
322
|
sections.push(`- ${gate}`);
|
|
323
|
+
allGates.add(gate);
|
|
402
324
|
}
|
|
403
325
|
sections.push('');
|
|
404
326
|
}
|
|
405
327
|
|
|
406
|
-
//
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
sections.push(
|
|
328
|
+
// Documentation-derived gates (advisory — need user confirmation)
|
|
329
|
+
const newDocGates = (analysis.docGates || [])
|
|
330
|
+
.filter(g => !allGates.has(g.command));
|
|
331
|
+
if (newDocGates.length > 0) {
|
|
332
|
+
sections.push('### Contributor docs (ADVISORY — confirm before enforcing)');
|
|
333
|
+
for (const { command, source } of newDocGates) {
|
|
334
|
+
sections.push(`- ${command} # from ${source}`);
|
|
335
|
+
allGates.add(command);
|
|
413
336
|
}
|
|
337
|
+
sections.push('');
|
|
414
338
|
}
|
|
415
339
|
|
|
416
|
-
//
|
|
417
|
-
if (analysis.
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
sections.push(
|
|
340
|
+
// Workspace members — per-member gates
|
|
341
|
+
if (analysis.workspace && analysis.workspace.members.length > 0) {
|
|
342
|
+
for (const member of analysis.workspace.members) {
|
|
343
|
+
if (member.linters.length === 0 && member.testers.length === 0 && member.builders.length === 0) continue;
|
|
344
|
+
const pathAnnotation = member.relativePath ? ` (path: ${member.relativePath.replace(/\\/g, '/')})` : '';
|
|
345
|
+
sections.push(`### ${member.name}${pathAnnotation}`);
|
|
346
|
+
for (const cmd of [...member.linters, ...member.testers, ...member.builders]) {
|
|
347
|
+
sections.push(`- ${cmd}`);
|
|
348
|
+
}
|
|
422
349
|
sections.push('');
|
|
423
350
|
}
|
|
424
351
|
}
|
|
425
352
|
|
|
426
|
-
//
|
|
427
|
-
if (analysis.
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
for (const bin of binFiles) {
|
|
434
|
-
sections.push(`- node --check ${bin}`);
|
|
435
|
-
}
|
|
436
|
-
sections.push('');
|
|
437
|
-
}
|
|
438
|
-
} catch { /* skip */ }
|
|
353
|
+
// Advisories
|
|
354
|
+
if (analysis.advisories && analysis.advisories.length > 0) {
|
|
355
|
+
sections.push('## Advisories (informational, not enforced)');
|
|
356
|
+
for (const a of analysis.advisories) {
|
|
357
|
+
sections.push(`- ${a} # [ADVISORY]`);
|
|
358
|
+
}
|
|
359
|
+
sections.push('');
|
|
439
360
|
}
|
|
440
361
|
|
|
441
362
|
// Branch Strategy
|
|
@@ -458,7 +379,7 @@ function generateGovernance(analysis, cwd) {
|
|
|
458
379
|
// Deployment
|
|
459
380
|
if (analysis.deployment.length > 0) {
|
|
460
381
|
sections.push('## Deployment');
|
|
461
|
-
sections.push(`- Target: ${analysis.deployment.join(', ')}`);
|
|
382
|
+
sections.push(`- Target: ${[...new Set(analysis.deployment)].join(', ')}`);
|
|
462
383
|
if (analysis.ci) sections.push(`- CI: ${analysis.ci}`);
|
|
463
384
|
sections.push('');
|
|
464
385
|
}
|
|
@@ -467,15 +388,11 @@ function generateGovernance(analysis, cwd) {
|
|
|
467
388
|
}
|
|
468
389
|
|
|
469
390
|
function mergeWithExisting(existing, generated) {
|
|
470
|
-
// Simple merge: keep existing content, append new sections that don't exist
|
|
471
391
|
const existingSections = new Set();
|
|
472
392
|
for (const match of existing.matchAll(/^## (.+)$/gm)) {
|
|
473
393
|
existingSections.add(match[1].trim().toLowerCase());
|
|
474
394
|
}
|
|
475
395
|
|
|
476
|
-
// Walk the generated file and collect new sections in their original order.
|
|
477
|
-
// Each new section becomes a self-contained block: the heading line plus all
|
|
478
|
-
// following lines until the next heading or EOF.
|
|
479
396
|
const newBlocks = [];
|
|
480
397
|
const genLines = generated.split('\n');
|
|
481
398
|
let currentBlock = null;
|
|
@@ -484,11 +401,9 @@ function mergeWithExisting(existing, generated) {
|
|
|
484
401
|
for (const line of genLines) {
|
|
485
402
|
const sectionMatch = line.match(/^## (.+)$/);
|
|
486
403
|
if (sectionMatch) {
|
|
487
|
-
// Flush previous block if it was new
|
|
488
404
|
if (currentBlock && blockIsNew) {
|
|
489
405
|
newBlocks.push(currentBlock.trimEnd());
|
|
490
406
|
}
|
|
491
|
-
// Start new block
|
|
492
407
|
const section = sectionMatch[1].trim().toLowerCase();
|
|
493
408
|
blockIsNew = !existingSections.has(section);
|
|
494
409
|
currentBlock = blockIsNew ? line + '\n' : null;
|
|
@@ -496,7 +411,6 @@ function mergeWithExisting(existing, generated) {
|
|
|
496
411
|
currentBlock += line + '\n';
|
|
497
412
|
}
|
|
498
413
|
}
|
|
499
|
-
// Flush final block
|
|
500
414
|
if (currentBlock && blockIsNew) {
|
|
501
415
|
newBlocks.push(currentBlock.trimEnd());
|
|
502
416
|
}
|