@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.
@@ -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 workspace = args.includes('--workspace');
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
- if (workspace) {
24
- const ws = detectWorkspace(cwd);
25
- if (ws.type !== 'none') {
26
- const members = enumerateMembers(ws);
27
- 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`);
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({ name: member.name, ...memberAnalysis });
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
- if (!fs.existsSync(govDir)) fs.mkdirSync(govDir, { recursive: true });
49
-
50
- if (fs.existsSync(govPath) && !merge) {
51
- const backupPath = govPath + '.bak.' + Date.now();
52
- fs.copyFileSync(govPath, backupPath);
53
- console.log(` Backed up existing governance to ${path.basename(backupPath)}`);
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
- if (merge && fs.existsSync(govPath)) {
57
- console.log(' Merge mode: preserving existing governance, appending new gates');
58
- const existing = fs.readFileSync(govPath, 'utf-8');
59
- const mergedContent = mergeWithExisting(existing, governance);
60
- fs.writeFileSync(govPath, mergedContent);
61
- } else {
62
- fs.writeFileSync(govPath, governance);
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
- // Detect stack from manifests
137
+ // Stack detection (languages, frameworks, package managers)
88
138
  detectStack(dir, result);
89
139
 
90
- // Extract gates from CI
91
- 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)));
144
+
145
+ // Task runner target mining
146
+ result.taskTargets = mineTaskTargets(dir);
92
147
 
93
- // Extract gates from package.json scripts
94
- extractPackageScripts(dir, result);
148
+ // Gate inference per language/runtime (consumes _manifests)
149
+ inferGates(dir, result);
95
150
 
96
- // Detect linters
97
- detectLinters(dir, result);
151
+ // Emit explicit gates for mined task targets
152
+ emitTaskRunnerGates(result);
98
153
 
99
- // Detect deployment
154
+ // Documentation-based gate mining (advisory)
155
+ result.docGates = mineDocGates(dir);
156
+
157
+ // Legacy deployment detection
100
158
  detectDeployment(dir, result);
101
159
 
102
- // Infer branch strategy and commit convention from git
160
+ // Git-derived branch strategy + commit convention
103
161
  inferGitPatterns(dir, result);
104
162
 
105
- return result;
106
- }
107
-
108
- function detectStack(dir, result) {
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
- if (fs.existsSync(path.join(dir, 'Cargo.toml'))) result.stack.push('rust');
127
- if (fs.existsSync(path.join(dir, 'go.mod'))) result.stack.push('go');
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
- function extractCIGates(dir, result) {
135
- // GitHub Actions
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
- // GitLab CI
160
- if (fs.existsSync(path.join(dir, '.gitlab-ci.yml'))) {
161
- result.ci = 'gitlab-ci';
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
- // Jenkins
165
- if (fs.existsSync(path.join(dir, 'Jenkinsfile'))) {
166
- result.ci = 'jenkins';
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
- * 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
- }
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 extractPackageScripts(dir, result) {
216
- const pkgPath = path.join(dir, 'package.json');
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
- function detectLinters(dir, result) {
245
- const linterConfigs = [
246
- ['.eslintrc', 'eslint'], ['.eslintrc.js', 'eslint'], ['.eslintrc.json', 'eslint'], ['.eslintrc.cjs', 'eslint'],
247
- ['eslint.config.js', 'eslint'], ['eslint.config.mjs', 'eslint'], ['eslint.config.cjs', 'eslint'],
248
- ['biome.json', 'biome'], ['biome.jsonc', 'biome'],
249
- ['.prettierrc', 'prettier'], ['.prettierrc.js', 'prettier'], ['.prettierrc.json', 'prettier'],
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')) || 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');
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
- // From linters
281
+ // Lint
351
282
  if (analysis.linters.length > 0) {
352
283
  sections.push('### Lint');
353
- for (const linter of analysis.linters) {
354
- let cmd;
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
- // From testers
293
+ // Test
373
294
  if (analysis.testers.length > 0) {
374
295
  sections.push('### Test');
375
- for (const tester of analysis.testers) {
376
- if (!allGates.has(tester)) {
377
- sections.push(`- ${tester}`);
378
- allGates.add(tester);
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
- // From builders
305
+ // Build
385
306
  if (analysis.builders.length > 0) {
386
307
  sections.push('### Build');
387
- for (const builder of analysis.builders) {
388
- if (!builder.includes('detected') && !allGates.has(builder)) {
389
- sections.push(`- ${builder}`);
390
- allGates.add(builder);
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
- // From CI gates (if not already covered)
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
- // Rust-specific gates
407
- if (analysis.stack.includes('rust')) {
408
- if (!allGates.has('cargo test')) {
409
- sections.push('### Rust');
410
- sections.push('- cargo test');
411
- sections.push('- cargo clippy -- -D warnings');
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
- // Go-specific gates
417
- if (analysis.stack.includes('go')) {
418
- if (!allGates.has('go test ./...')) {
419
- sections.push('### Go');
420
- sections.push('- go test ./...');
421
- 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
+ }
422
349
  sections.push('');
423
350
  }
424
351
  }
425
352
 
426
- // Node.js syntax check for CLI projects
427
- if (analysis.stack.includes('node') && !analysis.stack.includes('next.js') && !analysis.stack.includes('react')) {
428
- try {
429
- const pkg = JSON.parse(fs.readFileSync(path.join(cwd, 'package.json'), 'utf-8'));
430
- if (pkg.bin) {
431
- const binFiles = typeof pkg.bin === 'string' ? [pkg.bin] : Object.values(pkg.bin);
432
- sections.push('### Syntax');
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
  }