@whitehatd/crag 0.2.3 → 0.2.5

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.
@@ -5,16 +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 { extractRunCommands, isGateCommand } = require('../governance/yaml-run');
8
+ const { isGateCommand } = require('../governance/yaml-run');
9
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
+ ];
10
28
 
11
29
  /**
12
30
  * crag analyze — generate governance.md from existing project without interview.
13
- * Reads CI configs, package manifests, linter configs, git history.
14
31
  */
15
32
  function analyze(args) {
16
33
  const dryRun = args.includes('--dry-run');
17
- const workspace = args.includes('--workspace');
34
+ const workspaceFlag = args.includes('--workspace');
18
35
  const merge = args.includes('--merge');
19
36
  const cwd = process.cwd();
20
37
 
@@ -22,17 +39,26 @@ function analyze(args) {
22
39
 
23
40
  const analysis = analyzeProject(cwd);
24
41
 
25
- if (workspace) {
26
- const ws = detectWorkspace(cwd);
27
- if (ws.type !== 'none') {
28
- const members = enumerateMembers(ws);
29
- console.log(` Workspace detected: ${ws.type} (${members.length} members)\n`);
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`);
30
51
  analysis.workspace = { type: ws.type, members: [] };
31
-
32
52
  for (const member of members) {
33
53
  const memberAnalysis = analyzeProject(member.path);
34
- analysis.workspace.members.push({ name: member.name, ...memberAnalysis });
54
+ analysis.workspace.members.push({
55
+ name: member.name,
56
+ relativePath: member.relativePath,
57
+ ...memberAnalysis,
58
+ });
35
59
  }
60
+ } else {
61
+ console.log(` Workspace detected: ${ws.type}. Pass --workspace for per-member gates.\n`);
36
62
  }
37
63
  }
38
64
 
@@ -56,7 +82,6 @@ function analyze(args) {
56
82
  fs.copyFileSync(govPath, backupPath);
57
83
  console.log(` Backed up existing governance to ${path.basename(backupPath)}`);
58
84
  } catch (err) {
59
- // Backup failure shouldn't block the analyze, but warn loudly.
60
85
  cliWarn(`could not create backup (continuing anyway): ${err.message}`);
61
86
  }
62
87
  }
@@ -78,12 +103,23 @@ function analyze(args) {
78
103
  console.log(` Run 'crag check' to verify infrastructure.\n`);
79
104
  }
80
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
+
81
118
  function analyzeProject(dir) {
82
119
  const result = {
83
120
  name: path.basename(dir),
84
121
  description: '',
85
122
  stack: [],
86
- gates: [],
87
123
  linters: [],
88
124
  formatters: [],
89
125
  testers: [],
@@ -93,175 +129,116 @@ function analyzeProject(dir) {
93
129
  deployment: [],
94
130
  ci: null,
95
131
  ciGates: [],
132
+ advisories: [],
133
+ docGates: [],
134
+ taskTargets: { make: [], task: [], just: [] },
96
135
  };
97
136
 
98
- // Detect stack from manifests
137
+ // Stack detection (languages, frameworks, package managers)
99
138
  detectStack(dir, result);
100
139
 
101
- // Extract gates from CI
102
- extractCIGates(dir, result);
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)));
103
144
 
104
- // Extract gates from package.json scripts
105
- extractPackageScripts(dir, result);
145
+ // Task runner target mining
146
+ result.taskTargets = mineTaskTargets(dir);
106
147
 
107
- // Detect linters
108
- detectLinters(dir, result);
148
+ // Gate inference per language/runtime (consumes _manifests)
149
+ inferGates(dir, result);
109
150
 
110
- // Detect deployment
151
+ // Emit explicit gates for mined task targets
152
+ emitTaskRunnerGates(result);
153
+
154
+ // Documentation-based gate mining (advisory)
155
+ result.docGates = mineDocGates(dir);
156
+
157
+ // Legacy deployment detection
111
158
  detectDeployment(dir, result);
112
159
 
113
- // Infer branch strategy and commit convention from git
160
+ // Git-derived branch strategy + commit convention
114
161
  inferGitPatterns(dir, result);
115
162
 
116
- return result;
117
- }
118
-
119
- function detectStack(dir, result) {
120
- if (fs.existsSync(path.join(dir, 'package.json'))) {
121
- result.stack.push('node');
122
- try {
123
- const pkg = JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf-8'));
124
- result.name = pkg.name || result.name;
125
- result.description = pkg.description || '';
126
-
127
- const deps = { ...pkg.dependencies, ...pkg.devDependencies };
128
- if (deps.next) result.stack.push('next.js');
129
- if (deps.react && !deps.next) result.stack.push('react');
130
- if (deps.vue) result.stack.push('vue');
131
- if (deps.svelte) result.stack.push('svelte');
132
- if (deps.express) result.stack.push('express');
133
- if (deps.fastify) result.stack.push('fastify');
134
- if (deps.typescript) result.stack.push('typescript');
135
- } 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;
136
167
  }
137
- if (fs.existsSync(path.join(dir, 'Cargo.toml'))) result.stack.push('rust');
138
- if (fs.existsSync(path.join(dir, 'go.mod'))) result.stack.push('go');
139
- if (fs.existsSync(path.join(dir, 'pyproject.toml')) || fs.existsSync(path.join(dir, 'setup.py'))) result.stack.push('python');
140
- if (fs.existsSync(path.join(dir, 'build.gradle.kts')) || fs.existsSync(path.join(dir, 'build.gradle'))) result.stack.push('java/gradle');
141
- if (fs.existsSync(path.join(dir, 'pom.xml'))) result.stack.push('java/maven');
142
- if (fs.existsSync(path.join(dir, 'Dockerfile'))) result.stack.push('docker');
168
+ // Drop the internal manifests attachment before returning
169
+ delete result._manifests;
170
+
171
+ return result;
143
172
  }
144
173
 
145
- function extractCIGates(dir, result) {
146
- // GitHub Actions
147
- const workflowDir = path.join(dir, '.github', 'workflows');
148
- if (fs.existsSync(workflowDir)) {
149
- result.ci = 'github-actions';
150
- try {
151
- // Walk workflow dir recursively to catch nested workflows
152
- const walk = (d) => {
153
- const out = [];
154
- for (const entry of fs.readdirSync(d, { withFileTypes: true })) {
155
- const full = path.join(d, entry.name);
156
- if (entry.isDirectory()) out.push(...walk(full));
157
- else if (entry.name.endsWith('.yml') || entry.name.endsWith('.yaml')) out.push(full);
158
- }
159
- return out;
160
- };
161
- for (const file of walk(workflowDir)) {
162
- const content = fs.readFileSync(file, 'utf-8');
163
- for (const cmd of extractRunCommands(content)) {
164
- if (isGateCommand(cmd)) result.ciGates.push(cmd);
165
- }
166
- }
167
- } catch { /* skip */ }
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);
168
182
  }
169
-
170
- // GitLab CI
171
- if (fs.existsSync(path.join(dir, '.gitlab-ci.yml'))) {
172
- result.ci = 'gitlab-ci';
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);
173
188
  }
174
-
175
- // Jenkins
176
- if (fs.existsSync(path.join(dir, 'Jenkinsfile'))) {
177
- result.ci = 'jenkins';
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);
178
194
  }
179
195
  }
180
196
 
181
- // extractRunCommands and isGateCommand now live in src/governance/yaml-run.js.
182
-
183
- function extractPackageScripts(dir, result) {
184
- const pkgPath = path.join(dir, 'package.json');
185
- if (!fs.existsSync(pkgPath)) return;
186
-
187
- try {
188
- const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
189
- const scripts = pkg.scripts || {};
190
-
191
- const scriptMap = {
192
- test: 'testers',
193
- lint: 'linters',
194
- build: 'builders',
195
- format: 'formatters',
196
- typecheck: 'builders',
197
- check: 'linters',
198
- };
199
-
200
- for (const [key, category] of Object.entries(scriptMap)) {
201
- if (scripts[key]) {
202
- result[category].push(`npm run ${key}`);
203
- }
204
- }
205
-
206
- // Also check for specific patterns
207
- if (scripts['lint:fix']) result.formatters.push('npm run lint:fix');
208
- if (scripts['format:check']) result.linters.push('npm run format:check');
209
- } catch { /* skip */ }
197
+ function addUnique(arr, item) {
198
+ if (!arr.includes(item)) arr.push(item);
210
199
  }
211
200
 
212
- function detectLinters(dir, result) {
213
- const linterConfigs = [
214
- ['.eslintrc', 'eslint'], ['.eslintrc.js', 'eslint'], ['.eslintrc.json', 'eslint'], ['.eslintrc.cjs', 'eslint'],
215
- ['eslint.config.js', 'eslint'], ['eslint.config.mjs', 'eslint'], ['eslint.config.cjs', 'eslint'],
216
- ['biome.json', 'biome'], ['biome.jsonc', 'biome'],
217
- ['.prettierrc', 'prettier'], ['.prettierrc.js', 'prettier'], ['.prettierrc.json', 'prettier'],
218
- ['prettier.config.js', 'prettier'], ['prettier.config.mjs', 'prettier'],
219
- ['ruff.toml', 'ruff'], ['.ruff.toml', 'ruff'],
220
- ['clippy.toml', 'clippy'], ['.clippy.toml', 'clippy'],
221
- ['rustfmt.toml', 'rustfmt'], ['.rustfmt.toml', 'rustfmt'],
222
- ['tsconfig.json', 'typescript'],
223
- ['.mypy.ini', 'mypy'], ['mypy.ini', 'mypy'],
224
- ];
225
-
226
- for (const [file, tool] of linterConfigs) {
227
- if (fs.existsSync(path.join(dir, file))) {
228
- if (!result.linters.includes(tool)) result.linters.push(tool);
229
- }
230
- }
231
-
232
- // Build/task file detection
233
- const taskFiles = ['Makefile', 'Taskfile.yml', 'justfile'];
234
- for (const file of taskFiles) {
235
- if (fs.existsSync(path.join(dir, file))) {
236
- result.builders.push(`${file} detected`);
237
- }
238
- }
239
- }
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);
240
207
 
241
208
  function detectDeployment(dir, result) {
242
209
  if (fs.existsSync(path.join(dir, 'Dockerfile'))) result.deployment.push('docker');
243
- if (fs.existsSync(path.join(dir, 'docker-compose.yml')) || fs.existsSync(path.join(dir, 'docker-compose.yaml'))) result.deployment.push('docker-compose');
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');
244
214
  if (fs.existsSync(path.join(dir, 'vercel.json')) || fs.existsSync(path.join(dir, '.vercel'))) result.deployment.push('vercel');
245
215
  if (fs.existsSync(path.join(dir, 'fly.toml'))) result.deployment.push('fly.io');
246
216
  if (fs.existsSync(path.join(dir, 'netlify.toml'))) result.deployment.push('netlify');
247
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');
248
223
 
249
- // Kubernetes
250
224
  try {
251
225
  const entries = fs.readdirSync(dir, { withFileTypes: true });
252
226
  for (const entry of entries) {
253
- 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')) {
254
228
  result.deployment.push('kubernetes');
255
229
  break;
256
230
  }
257
231
  }
258
232
  } catch { /* skip */ }
259
233
 
260
- // Terraform
261
234
  try {
262
235
  const entries = fs.readdirSync(dir);
263
236
  if (entries.some(f => f.endsWith('.tf'))) result.deployment.push('terraform');
264
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');
265
242
  }
266
243
 
267
244
  function inferGitPatterns(dir, result) {
@@ -269,19 +246,14 @@ function inferGitPatterns(dir, result) {
269
246
  const log = execSync('git log --oneline --all -50', { cwd: dir, encoding: 'utf-8', timeout: 5000, stdio: ['pipe', 'pipe', 'pipe'] });
270
247
  const lines = log.trim().split('\n');
271
248
 
272
- // Detect conventional commits
273
249
  const conventional = lines.filter(l => /\b(feat|fix|docs|chore|style|refactor|test|build|ci|perf|revert)[\(:!]/.test(l));
274
250
  result.commitConvention = conventional.length > lines.length * 0.3 ? 'conventional' : 'free-form';
275
251
 
276
- // Detect branch strategy
277
252
  const branches = execSync('git branch -a --format="%(refname:short)"', { cwd: dir, encoding: 'utf-8', timeout: 5000, stdio: ['pipe', 'pipe', 'pipe'] });
278
253
  const branchList = branches.trim().split('\n');
279
254
  const featureBranches = branchList.filter(b => /^(feat|fix|docs|chore|feature|hotfix|release)\//.test(b));
280
255
  result.branchStrategy = featureBranches.length > 2 ? 'feature-branches' : 'trunk-based';
281
256
  } 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
257
  if (err && err.code !== 'ENOENT') {
286
258
  cliWarn(`could not detect git patterns in ${path.basename(dir)}: ${err.message}`);
287
259
  }
@@ -293,36 +265,24 @@ function inferGitPatterns(dir, result) {
293
265
  function generateGovernance(analysis, cwd) {
294
266
  const sections = [];
295
267
 
296
- // Identity
297
268
  sections.push(`# Governance — ${analysis.name}`);
298
269
  sections.push(`# Inferred by crag analyze — review and adjust as needed\n`);
299
270
  sections.push('## Identity');
300
271
  sections.push(`- Project: ${analysis.name}`);
301
272
  if (analysis.description) sections.push(`- Description: ${analysis.description}`);
302
273
  sections.push(`- Stack: ${analysis.stack.join(', ') || 'unknown'}`);
274
+ if (analysis.workspaceType) sections.push(`- Workspace: ${analysis.workspaceType}`);
303
275
  sections.push('');
304
276
 
305
- // Gates
306
277
  sections.push('## Gates (run in order, stop on failure)');
307
278
 
308
- // Group gates by type
309
279
  const allGates = new Set();
310
280
 
311
- // From linters
281
+ // Lint
312
282
  if (analysis.linters.length > 0) {
313
283
  sections.push('### Lint');
314
- for (const linter of analysis.linters) {
315
- let cmd;
316
- switch (linter) {
317
- case 'eslint': cmd = 'npx eslint . --max-warnings 0'; break;
318
- case 'biome': cmd = 'npx biome check .'; break;
319
- case 'ruff': cmd = 'ruff check .'; break;
320
- case 'clippy': cmd = 'cargo clippy -- -D warnings'; break;
321
- case 'mypy': cmd = 'mypy .'; break;
322
- case 'typescript': cmd = 'npx tsc --noEmit'; break;
323
- default: cmd = null;
324
- }
325
- if (cmd && !allGates.has(cmd)) {
284
+ for (const cmd of analysis.linters) {
285
+ if (!allGates.has(cmd)) {
326
286
  sections.push(`- ${cmd}`);
327
287
  allGates.add(cmd);
328
288
  }
@@ -330,73 +290,73 @@ function generateGovernance(analysis, cwd) {
330
290
  sections.push('');
331
291
  }
332
292
 
333
- // From testers
293
+ // Test
334
294
  if (analysis.testers.length > 0) {
335
295
  sections.push('### Test');
336
- for (const tester of analysis.testers) {
337
- if (!allGates.has(tester)) {
338
- sections.push(`- ${tester}`);
339
- allGates.add(tester);
296
+ for (const cmd of analysis.testers) {
297
+ if (!allGates.has(cmd)) {
298
+ sections.push(`- ${cmd}`);
299
+ allGates.add(cmd);
340
300
  }
341
301
  }
342
302
  sections.push('');
343
303
  }
344
304
 
345
- // From builders
305
+ // Build
346
306
  if (analysis.builders.length > 0) {
347
307
  sections.push('### Build');
348
- for (const builder of analysis.builders) {
349
- if (!builder.includes('detected') && !allGates.has(builder)) {
350
- sections.push(`- ${builder}`);
351
- allGates.add(builder);
308
+ for (const cmd of analysis.builders) {
309
+ if (!allGates.has(cmd)) {
310
+ sections.push(`- ${cmd}`);
311
+ allGates.add(cmd);
352
312
  }
353
313
  }
354
314
  sections.push('');
355
315
  }
356
316
 
357
- // From CI gates (if not already covered)
317
+ // CI — only include gates not already captured by language inference
358
318
  const uniqueCiGates = analysis.ciGates.filter(g => !allGates.has(g));
359
319
  if (uniqueCiGates.length > 0) {
360
320
  sections.push('### CI (inferred from workflow)');
361
321
  for (const gate of uniqueCiGates) {
362
322
  sections.push(`- ${gate}`);
323
+ allGates.add(gate);
363
324
  }
364
325
  sections.push('');
365
326
  }
366
327
 
367
- // Rust-specific gates
368
- if (analysis.stack.includes('rust')) {
369
- if (!allGates.has('cargo test')) {
370
- sections.push('### Rust');
371
- sections.push('- cargo test');
372
- sections.push('- cargo clippy -- -D warnings');
373
- 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);
374
336
  }
337
+ sections.push('');
375
338
  }
376
339
 
377
- // Go-specific gates
378
- if (analysis.stack.includes('go')) {
379
- if (!allGates.has('go test ./...')) {
380
- sections.push('### Go');
381
- sections.push('- go test ./...');
382
- sections.push('- go vet ./...');
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
+ }
383
349
  sections.push('');
384
350
  }
385
351
  }
386
352
 
387
- // Node.js syntax check for CLI projects
388
- if (analysis.stack.includes('node') && !analysis.stack.includes('next.js') && !analysis.stack.includes('react')) {
389
- try {
390
- const pkg = JSON.parse(fs.readFileSync(path.join(cwd, 'package.json'), 'utf-8'));
391
- if (pkg.bin) {
392
- const binFiles = typeof pkg.bin === 'string' ? [pkg.bin] : Object.values(pkg.bin);
393
- sections.push('### Syntax');
394
- for (const bin of binFiles) {
395
- sections.push(`- node --check ${bin}`);
396
- }
397
- sections.push('');
398
- }
399
- } 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('');
400
360
  }
401
361
 
402
362
  // Branch Strategy
@@ -419,7 +379,7 @@ function generateGovernance(analysis, cwd) {
419
379
  // Deployment
420
380
  if (analysis.deployment.length > 0) {
421
381
  sections.push('## Deployment');
422
- sections.push(`- Target: ${analysis.deployment.join(', ')}`);
382
+ sections.push(`- Target: ${[...new Set(analysis.deployment)].join(', ')}`);
423
383
  if (analysis.ci) sections.push(`- CI: ${analysis.ci}`);
424
384
  sections.push('');
425
385
  }
@@ -428,15 +388,11 @@ function generateGovernance(analysis, cwd) {
428
388
  }
429
389
 
430
390
  function mergeWithExisting(existing, generated) {
431
- // Simple merge: keep existing content, append new sections that don't exist
432
391
  const existingSections = new Set();
433
392
  for (const match of existing.matchAll(/^## (.+)$/gm)) {
434
393
  existingSections.add(match[1].trim().toLowerCase());
435
394
  }
436
395
 
437
- // Walk the generated file and collect new sections in their original order.
438
- // Each new section becomes a self-contained block: the heading line plus all
439
- // following lines until the next heading or EOF.
440
396
  const newBlocks = [];
441
397
  const genLines = generated.split('\n');
442
398
  let currentBlock = null;
@@ -445,11 +401,9 @@ function mergeWithExisting(existing, generated) {
445
401
  for (const line of genLines) {
446
402
  const sectionMatch = line.match(/^## (.+)$/);
447
403
  if (sectionMatch) {
448
- // Flush previous block if it was new
449
404
  if (currentBlock && blockIsNew) {
450
405
  newBlocks.push(currentBlock.trimEnd());
451
406
  }
452
- // Start new block
453
407
  const section = sectionMatch[1].trim().toLowerCase();
454
408
  blockIsNew = !existingSections.has(section);
455
409
  currentBlock = blockIsNew ? line + '\n' : null;
@@ -457,7 +411,6 @@ function mergeWithExisting(existing, generated) {
457
411
  currentBlock += line + '\n';
458
412
  }
459
413
  }
460
- // Flush final block
461
414
  if (currentBlock && blockIsNew) {
462
415
  newBlocks.push(currentBlock.trimEnd());
463
416
  }
@@ -60,28 +60,84 @@ function extractRunCommands(content) {
60
60
  */
61
61
  function isGateCommand(cmd) {
62
62
  const patterns = [
63
+ // Node ecosystem
63
64
  /\bnpm (run |ci|test|install)/,
64
65
  /\bnpx /,
65
66
  /\bnode /,
66
- /\bcargo (test|build|check|clippy)/,
67
+ /\byarn (test|lint|build|check)/,
68
+ /\bpnpm (run |test|lint|build|check|install|i\b)/,
69
+ /\bbun (test|run)/,
70
+ /\bdeno (test|lint|fmt|check)/,
71
+ // Rust
72
+ /\bcargo (test|build|check|clippy|fmt)/,
67
73
  /\brustfmt/,
74
+ // Go
68
75
  /\bgo (test|build|vet)/,
69
76
  /\bgolangci-lint/,
77
+ // Python — direct + modern runner wrappers
70
78
  /\bpytest/,
71
79
  /\bpython -m/,
72
80
  /\bruff/,
73
81
  /\bmypy/,
74
82
  /\bflake8/,
83
+ /\bblack\b/,
84
+ /\bisort\b/,
85
+ /\bpylint\b/,
86
+ /\btox\s+(run|r)/,
87
+ /\buv run /,
88
+ /\bpoetry run /,
89
+ /\bpdm run /,
90
+ /\bhatch run /,
91
+ /\brye run /,
92
+ /\bnox\b/,
93
+ // JVM
75
94
  /\bgradle/,
76
95
  /\bmvn /,
77
96
  /\bmaven/,
97
+ /\.\/gradlew/,
98
+ /\.\/mvnw/,
99
+ // Ruby
100
+ /\bbundle exec /,
101
+ /\brake\b/,
102
+ /\brspec\b/,
103
+ /\brubocop/,
104
+ // PHP
105
+ /\bcomposer (test|lint|run|validate)/,
106
+ /\bvendor\/bin\/(phpunit|phpcs|phpstan|psalm|pest|php-cs-fixer|rector)/,
107
+ // .NET
108
+ /\bdotnet (test|build|format)/,
109
+ // Swift
110
+ /\bswift (test|build)/,
111
+ /\bswiftlint/,
112
+ // Elixir
113
+ /\bmix (test|format|credo|dialyzer)/,
114
+ // Node linters
78
115
  /\beslint/,
79
116
  /\bbiome/,
80
117
  /\bprettier/,
81
118
  /\btsc/,
82
- /\bdocker (build|compose)/,
119
+ /\bxo\b/,
120
+ // Task runners
83
121
  /\bmake /,
84
122
  /\bjust /,
123
+ /\btask /,
124
+ // Containers / infra
125
+ /\bdocker (build|compose)/,
126
+ /\bterraform (fmt|validate|plan)/,
127
+ /\btflint/,
128
+ /\bhelm (lint|template)/,
129
+ /\bkubeconform/,
130
+ /\bkubeval/,
131
+ /\bhadolint/,
132
+ /\bactionlint/,
133
+ /\bmarkdownlint/,
134
+ /\byamllint/,
135
+ /\bbuf (lint|build)/,
136
+ /\bspectral lint/,
137
+ /\bshellcheck/,
138
+ /\bsemgrep/,
139
+ /\btrivy/,
140
+ /\bgitleaks/,
85
141
  ];
86
142
  return patterns.some((p) => p.test(cmd));
87
143
  }