@whitehatd/crag 0.2.5 → 0.2.7

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 CHANGED
@@ -6,7 +6,8 @@
6
6
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](./LICENSE)
7
7
  [![Node](https://img.shields.io/node/v/%40whitehatd%2Fcrag)](https://nodejs.org)
8
8
  [![Zero dependencies](https://img.shields.io/badge/dependencies-0-brightgreen)](./package.json)
9
- [![323 tests](https://img.shields.io/badge/tests-323%20passing-brightgreen)](./test)
9
+ [![357 tests](https://img.shields.io/badge/tests-357%20passing-brightgreen)](./test)
10
+ [![40/40 grade A](https://img.shields.io/badge/benchmark-40%2F40%20grade%20A-brightgreen)](./benchmarks/results.md)
10
11
 
11
12
  > **One `governance.md`. Any project. Generated in 250 ms.**
12
13
  >
@@ -19,7 +20,7 @@ npx @whitehatd/crag analyze # Generate governance.md from an existing
19
20
  npx @whitehatd/crag compile --target all # Turn it into CI + 12 downstream configs
20
21
  ```
21
22
 
22
- Zero dependencies. Node 18+. Validated across [20 OSS projects](#validation-on-real-repos).
23
+ Zero dependencies. Node 18+. Validated across [40 OSS projects](#validation-on-real-repos) (100% grade A).
23
24
 
24
25
  ---
25
26
 
@@ -37,22 +38,46 @@ The result is a single source of truth for your rules that stays in lock-step wi
37
38
 
38
39
  ## Validation on real repos
39
40
 
40
- Unlike most tools in this space, crag ships with a reproducible cross-repo benchmark. `crag analyze --dry-run` was run against 20 diverse open-source projects:
41
+ Unlike most tools in this space, crag ships with a reproducible cross-repo benchmark. `crag analyze --dry-run` was run against **40 diverse open-source projects** in two tiers:
41
42
 
42
- | Grade | Count | Meaning |
43
- |---|---:|---|
44
- | **A** — ship-ready governance | **17 / 20 (85%)** | Stack + test + lint + build gates captured. Minimal noise. Ready to commit with light review. |
45
- | **B** — usable after cleanup | 3 / 20 (15%) | Stack correct, some gates need pruning or adding. Still faster than writing from scratch. |
46
- | **C** — rework needed | **0 / 20 (0%)** | — |
43
+ - **Tier 1** 20 well-known libraries across 7 language families (Node, Python, Rust, Go, Java, Ruby, PHP).
44
+ - **Tier 2** — 20 dense repos picked for **density over storage**: multi-language, matrix-heavy CI, polyglot microservices, and workspace layouts (pnpm monorepos, Cargo workspaces, Gradle multi-module).
47
45
 
48
- Repos tested: `expressjs/express`, `chalk/chalk`, `fastify/fastify`, `axios/axios`, `prettier/prettier`, `vitejs/vite`, `psf/requests`, `pallets/flask`, `pallets/click`, `tiangolo/fastapi`, `BurntSushi/ripgrep`, `clap-rs/clap`, `rust-lang/mdBook`, `tokio-rs/axum`, `spf13/cobra`, `gin-gonic/gin`, `charmbracelet/bubbletea`, `spring-projects/spring-petclinic`, `sinatra/sinatra` (Ruby), `slimphp/Slim` (PHP).
46
+ | Grade | Tier 1 | Tier 2 | Combined | Meaning |
47
+ |---|---:|---:|---:|---|
48
+ | **A** — ship-ready governance | **20 / 20** | **20 / 20** | **40 / 40 (100%)** | Stack + test + lint + build gates captured. Minimal noise. Ready to commit with light review. |
49
+ | **B** — usable after cleanup | 0 | 0 | **0 / 40 (0%)** | — |
50
+ | **C** — rework needed | 0 | 0 | **0 / 40 (0%)** | — |
51
+
52
+ **Tier 1 repos:** `expressjs/express`, `chalk/chalk`, `fastify/fastify`, `axios/axios`, `prettier/prettier`, `vitejs/vite`, `psf/requests`, `pallets/flask`, `pallets/click`, `tiangolo/fastapi`, `BurntSushi/ripgrep`, `clap-rs/clap`, `rust-lang/mdBook`, `tokio-rs/axum`, `spf13/cobra`, `gin-gonic/gin`, `charmbracelet/bubbletea`, `spring-projects/spring-petclinic`, `sinatra/sinatra`, `slimphp/Slim`.
53
+
54
+ **Tier 2 repos:** `GoogleCloudPlatform/microservices-demo` (11-language polyglot), `vercel/turbo`, `swc-project/swc`, `dagger/dagger` (8-stack density max), `cloudflare/workers-sdk`, `tailwindlabs/tailwindcss`, `rust-lang/cargo`, `rust-lang/rust-analyzer`, `denoland/deno`, `nushell/nushell`, `withastro/astro`, `nrwl/nx` (7-stack pnpm monorepo), `mastodon/mastodon` (6-stack Rails+Node+Docker), `phoenixframework/phoenix_live_view`, `celery/celery`, `laravel/framework`, `grafana/k6`, `prometheus/prometheus`, `nats-io/nats-server`, `open-telemetry/opentelemetry-collector`.
55
+
56
+ ### Full-capability run on the 10 hardest repos
57
+
58
+ Beyond `analyze`, the 10 densest repos across both tiers were exercised against every command in crag's surface — **80 / 80 operations succeeded**:
59
+
60
+ | Command | Result |
61
+ |---|---|
62
+ | `crag analyze --dry-run` | 10 / 10 ✓ |
63
+ | `crag analyze` (writes governance.md) | 10 / 10 ✓ |
64
+ | `crag analyze --workspace --dry-run` | 10 / 10 ✓ (vite: 79 members in 322 ms) |
65
+ | `crag workspace --json` | 10 / 10 ✓ |
66
+ | `crag diff` | 10 / 10 ✓ |
67
+ | `crag doctor --ci` | 10 / 10 ✓ |
68
+ | `crag compile --target github` | 10 / 10 ✓ |
69
+ | `crag compile --target agents-md` | 10 / 10 ✓ |
70
+
71
+ ### Coverage
49
72
 
50
73
  | Metric | Value |
51
74
  |---|---|
52
- | Mean `crag analyze` time | **238 ms** per repo |
53
- | Success rate | **20 / 20 exit 0** |
54
- | Language families covered | Node, Python, Rust, Go, Java, Ruby, PHP |
55
- | Zero-gate failures | **0** |
75
+ | Repos tested | **40** |
76
+ | Mean `crag analyze` time | **≈ 250 ms** per repo |
77
+ | Grade A | **40 / 40 (100%)** |
78
+ | Language families detected | Node, TypeScript, React, Next.js, Astro, Hono, Python, Rust, Go, Java (Maven + Gradle), Kotlin, Ruby (+Rails/Sinatra), PHP (+Laravel/Symfony/Slim), Elixir (+Phoenix), .NET, Swift, C# |
79
+ | CI systems parsed | 10 — GitHub Actions, GitLab CI, CircleCI, Travis, Azure Pipelines, Buildkite, Drone, Woodpecker, Bitbucket, Jenkins |
80
+ | Workspace types detected | pnpm, npm, cargo, go, gradle, maven, bazel, nx, turbo, git-submodules, independent-repos, subservices |
56
81
 
57
82
  Full methodology, grading rubric, per-repo results, and raw outputs: [`benchmarks/results.md`](./benchmarks/results.md).
58
83
 
@@ -151,9 +176,16 @@ Real target mining — not placeholders. Canonical test/lint/build targets extra
151
176
 
152
177
  ### Workspaces
153
178
 
154
- Eleven workspace types, enumerated at discovery time:
179
+ Twelve workspace types, enumerated at discovery time:
180
+
181
+ pnpm · npm/yarn · Cargo · Go · Gradle · Maven · Nx · Turborepo · Bazel · git submodules · independent nested repos · **subservices** (polyglot microservices under `src/*`, `services/*`, `packages/*`, `apps/*`, etc. — no root manifest required)
182
+
183
+ ### Nested stack detection
184
+
185
+ When a project has no root-level manifest but ships code under conventional container directories, crag scans one level down for per-service manifests and merges the detected stacks. This handles:
155
186
 
156
- pnpm · npm/yarn · Cargo · Go · Gradle · Maven · Nx · Turborepo · Bazel · git submodules · independent nested repos
187
+ - **Polyglot microservice monorepos** e.g. `src/frontend/go.mod`, `src/cartservice/*.csproj`, `src/emailservice/pyproject.toml` detected as a 6-stack `subservices` workspace
188
+ - **Auxiliary subdirectory stacks** — e.g. `web/ui/package.json` (React UI), `editors/code/package.json` (VSCode extension), `sdk/typescript/package.json` (SDK) — surfaced as additional stacks alongside the root language
157
189
 
158
190
  ### Frameworks
159
191
 
@@ -183,6 +215,10 @@ crag compile --target all # Compile to all 12 targets at once
183
215
  crag compile # List available targets
184
216
 
185
217
  crag diff # Compare governance against codebase reality
218
+ crag doctor # Deep diagnostic — governance integrity, drift, hook validity, security
219
+ crag doctor --ci # CI mode: skips checks that need runtime infrastructure
220
+ crag doctor --json # Machine-readable output
221
+ crag doctor --strict # Treat warnings as failures
186
222
  crag upgrade # Update universal skills to latest version
187
223
  crag upgrade --check # Dry-run: show what would change
188
224
  crag check # Verify Claude Code infrastructure is in place
@@ -199,21 +235,20 @@ crag version / crag help
199
235
  The only file you maintain. 20–30 lines. Everything else is universal or generated.
200
236
 
201
237
  ```markdown
202
- # Governance — StructuAI
238
+ # Governance — example-app
203
239
 
204
240
  ## Identity
205
- - Project: StructuAI
206
- - Description: AI-powered Minecraft schematic describer
241
+ - Project: example-app
242
+ - Description: Example project using crag
207
243
 
208
244
  ## Gates (run in order, stop on failure)
209
- ### Frontend (path: frontend/)
210
- - npx eslint frontend/ --max-warnings 0
211
- - cd frontend && npx vite build
245
+ ### Frontend (path: web/)
246
+ - npx eslint web/ --max-warnings 0
247
+ - cd web && npx vite build
212
248
 
213
249
  ### Backend
214
- - node --check scripts/api-server.js
215
- - cargo clippy --manifest-path source/decode/Cargo.toml
216
- - cargo test --manifest-path source/decode/Cargo.toml
250
+ - cargo clippy --all-targets -- -D warnings
251
+ - cargo test
217
252
 
218
253
  ### Infrastructure
219
254
  - docker compose config --quiet
@@ -223,7 +258,6 @@ The only file you maintain. 20–30 lines. Everything else is universal or gener
223
258
  - Auto-commit after gates pass
224
259
 
225
260
  ## Security
226
- - Schematic file uploads only (validate server-side)
227
261
  - No hardcoded secrets or API keys
228
262
  ```
229
263
 
@@ -303,7 +337,7 @@ npx @whitehatd/crag <command>
303
337
 
304
338
  - **Node.js 18+** — uses built-in `https`, `crypto`, `fs`, `child_process`. No runtime dependencies.
305
339
  - **Git** — for branch strategy inference and the discovery cache.
306
- - **Claude Code CLI** — only needed for the interactive `crag init` flow. `analyze`, `compile`, `diff`, `upgrade`, `workspace`, `check` all run standalone.
340
+ - **Claude Code CLI** — only needed for the interactive `crag init` flow. `analyze`, `compile`, `diff`, `doctor`, `upgrade`, `workspace`, `check` all run standalone.
307
341
 
308
342
  The package is published under `@whitehatd/crag` but the binary name is plain `crag` after install.
309
343
 
@@ -332,17 +366,18 @@ To skip a release on a specific push, put `crag:skip-release` on its own line in
332
366
  ## Honest status
333
367
 
334
368
  - **Published:** 2026-04-04 as `@whitehatd/crag` on npm. Scoped public package.
335
- - **Tests:** 323 unit tests, passing on Ubuntu/macOS/Windows × Node 18/20/22.
336
- - **Benchmark:** 17/20 grade A, 0/20 grade C across the 20 reference repos. Reproducible via `benchmarks/results.md`.
369
+ - **Tests:** 357 unit tests, passing on Ubuntu/macOS/Windows × Node 18/20/22.
370
+ - **Benchmark:** **40/40 grade A** across 20 tier-1 + 20 tier-2 reference repos. **80/80 operations succeeded** on the 10 hardest repos across every command (analyze, analyze --workspace, workspace, diff, doctor --ci, compile). Reproducible via `benchmarks/results.md`.
337
371
  - **Languages fully supported:** Node, Deno, Bun, Python, Rust, Go, Java, Kotlin, Ruby, PHP, .NET, Swift, Elixir (+ Terraform/Helm/K8s infra gates).
338
- - **CI systems parsed:** 9 (GitHub Actions, GitLab CI, CircleCI, Travis, Azure Pipelines, Buildkite, Drone, Woodpecker, Bitbucket).
372
+ - **CI systems parsed:** **10** GitHub Actions, GitLab CI, CircleCI, Travis, Azure Pipelines, Buildkite, Drone, Woodpecker, Bitbucket, **Jenkins** (declarative + scripted pipelines).
339
373
  - **Compile targets:** 12 (GitHub Actions, husky, pre-commit, AGENTS.md, Cursor, Gemini, Copilot, Cline, Continue, Windsurf, Zed, Cody).
374
+ - **Nested stack detection:** Scans `src/*`, `services/*`, `packages/*`, `apps/*`, `sdk/*`, `web/*`, `ui/*`, `editors/*`, `extensions/*`, `clients/*` one level deep for per-service manifests. Handles polyglot microservice monorepos (`microservices-demo`) and auxiliary subdirectories (`prometheus/web/ui`, `rust-analyzer/editors/code`, `dagger/sdk/typescript`).
375
+ - **`crag doctor`:** Deep diagnostic command — validates governance integrity, skill currency, hook validity, drift vs git, and runs a security smoke test (8 secret patterns: Stripe, AWS, GitHub PAT/OAuth, Slack, PEM keys). Wired into crag's own CI via `--ci` mode.
340
376
 
341
377
  ### Known limitations
342
378
 
343
- - **FastAPI and similar repos** where CI runs via `uv run ./scripts/*.py` data-pipeline scripts: crag captures the script invocations as gates. A user reviewing the output should prune the ones that aren't quality checks.
344
- - **Complex CI matrix template expansions** (clap's `make test-${{matrix.features}}` pattern): line-based extraction captures one variant per template; multi-line YAML join is not implemented yet.
345
- - **Jenkinsfile** (Groovy): CI system is detected but step extraction is not attempted.
379
+ - **Kotlin via `.kt` source files only (no Gradle kotlin plugin)** isn't detected. Most Kotlin projects use Gradle + the plugin, so this is rare in practice.
380
+ - **`crag analyze --workspace` still needs to be opted in.** Workspaces are auto-detected and reported, but per-member governance is only emitted when the flag is passed an intentional guard against surprising enumeration on fixture-heavy monorepos.
346
381
  - **No telemetry, no network calls** beyond the optional `crag upgrade --check` npm registry ping (24h cached, 3s timeout, graceful offline).
347
382
 
348
383
  ### What crag does not do
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@whitehatd/crag",
3
- "version": "0.2.5",
3
+ "version": "0.2.7",
4
4
  "description": "The bedrock layer for AI coding agents. One governance.md. Any project. Never stale.",
5
5
  "bin": {
6
6
  "crag": "bin/crag.js"
@@ -13,6 +13,7 @@
13
13
  * - Drone (.drone.yml)
14
14
  * - Woodpecker (.woodpecker.yml, .woodpecker/*.yml)
15
15
  * - Bitbucket (bitbucket-pipelines.yml)
16
+ * - Jenkins (Jenkinsfile — Groovy declarative + scripted)
16
17
  *
17
18
  * Each extractor returns a list of raw shell command strings. The CI
18
19
  * normalizer (normalize.js) dedups and filters them uniformly regardless
@@ -21,7 +22,7 @@
21
22
 
22
23
  const fs = require('fs');
23
24
  const path = require('path');
24
- const { extractRunCommands } = require('../governance/yaml-run');
25
+ const { extractRunCommands, stripYamlQuotes } = require('../governance/yaml-run');
25
26
  const { safeRead } = require('./stacks');
26
27
 
27
28
  /**
@@ -116,9 +117,15 @@ function extractCiCommands(dir) {
116
117
  commands.push(...extractBitbucketCommands(safeRead(bbFile)));
117
118
  }
118
119
 
119
- // Jenkins — Jenkinsfiles are Groovy, not YAML. We do not try to parse them.
120
- if (fs.existsSync(path.join(dir, 'Jenkinsfile'))) {
121
- primary = primary || 'jenkins';
120
+ // Jenkins — Jenkinsfile is Groovy, not YAML. We parse `sh/bat/pwsh`
121
+ // step invocations from declarative and scripted pipelines.
122
+ const jenkinsFiles = ['Jenkinsfile', 'jenkins/Jenkinsfile', 'ci/Jenkinsfile'];
123
+ for (const jf of jenkinsFiles) {
124
+ const p = path.join(dir, jf);
125
+ if (fs.existsSync(p)) {
126
+ primary = primary || 'jenkins';
127
+ commands.push(...extractJenkinsfileCommands(safeRead(p)));
128
+ }
122
129
  }
123
130
 
124
131
  return { system: primary, commands };
@@ -165,7 +172,7 @@ function extractCircleCommands(content) {
165
172
  const rest = inline[1].trim();
166
173
  if (rest && !rest.startsWith('#') && !rest.startsWith('|') && !rest.startsWith('>') &&
167
174
  !rest.startsWith('{') && !rest.startsWith('name:') && !rest.startsWith('command:')) {
168
- commands.push(rest.replace(/^["']|["']$/g, ''));
175
+ commands.push(stripYamlQuotes(rest));
169
176
  }
170
177
  }
171
178
  // Nested: command: ...
@@ -173,7 +180,7 @@ function extractCircleCommands(content) {
173
180
  if (cmdMatch) {
174
181
  const rest = cmdMatch[1].trim();
175
182
  if (rest && !rest.startsWith('|') && !rest.startsWith('>') && !rest.startsWith('#')) {
176
- commands.push(rest.replace(/^["']|["']$/g, ''));
183
+ commands.push(stripYamlQuotes(rest));
177
184
  } else if (rest === '|' || rest === '>-' || rest.startsWith('|') || rest.startsWith('>')) {
178
185
  // Block scalar — collect following lines with greater indent
179
186
  const baseIndent = (line.match(/^(\s*)/) || ['', ''])[1].length;
@@ -220,7 +227,7 @@ function extractAzureCommands(content) {
220
227
  commands.push(inner.trim());
221
228
  }
222
229
  } else if (rest && !rest.startsWith('#')) {
223
- commands.push(rest.replace(/^["']|["']$/g, ''));
230
+ commands.push(stripYamlQuotes(rest));
224
231
  }
225
232
  }
226
233
  return commands;
@@ -253,6 +260,90 @@ function extractBitbucketCommands(content) {
253
260
  return extractYamlListField(content, ['script']);
254
261
  }
255
262
 
263
+ // --- Jenkinsfile (Groovy) --------------------------------------------------
264
+
265
+ /**
266
+ * Extract shell commands from a Jenkinsfile (declarative or scripted).
267
+ *
268
+ * Supported step invocations:
269
+ * sh 'cmd' (Unix inline, single-quoted)
270
+ * sh "cmd" (Unix inline, double-quoted)
271
+ * sh '''...''' (Unix multi-line, triple-single-quoted)
272
+ * sh """...""" (Unix multi-line, triple-double-quoted)
273
+ * sh(script: 'cmd') (map form — rare but valid)
274
+ * bat 'cmd' (Windows batch)
275
+ * bat '''...''' (Windows multi-line)
276
+ * pwsh 'cmd' | powershell 'cmd' (PowerShell)
277
+ *
278
+ * Skipped constructs (not gates):
279
+ * credentials(...) (Jenkins credentials binding)
280
+ * environment { ... } (env block — not commands)
281
+ * withCredentials { ... } (wrapper, but inner steps still parsed)
282
+ *
283
+ * Multi-line strings are split on newlines and each non-empty line returned
284
+ * as a separate command, matching the convention of other CI extractors.
285
+ */
286
+ function extractJenkinsfileCommands(content) {
287
+ const commands = [];
288
+ const text = String(content);
289
+ const lines = text.split(/\r?\n/);
290
+
291
+ for (let i = 0; i < lines.length; i++) {
292
+ const line = lines[i];
293
+
294
+ // Skip comment lines
295
+ const trimmed = line.trim();
296
+ if (trimmed.startsWith('//') || trimmed.startsWith('#')) continue;
297
+
298
+ // Map form: sh(script: 'cmd') or sh(script: "cmd")
299
+ const mapMatch = line.match(/\b(sh|bat|pwsh|powershell)\s*\(\s*script\s*:\s*(['"])([^'"]*?)\2\s*\)/);
300
+ if (mapMatch) {
301
+ const cmd = mapMatch[3].trim();
302
+ if (cmd) commands.push(cmd);
303
+ continue;
304
+ }
305
+
306
+ // Inline: step 'cmd' or step "cmd" on a single line
307
+ // Matches: sh 'foo', sh "foo", bat 'bar', pwsh "baz"
308
+ const inlineMatch = line.match(/\b(sh|bat|pwsh|powershell)\s*\(?(['"])([^'"]*?)\2\)?/);
309
+ if (inlineMatch) {
310
+ // But only if the string is NOT the start of a triple-quoted block
311
+ // (triple quotes: the char after the match end should not be another
312
+ // matching quote)
313
+ const afterIdx = inlineMatch.index + inlineMatch[0].length;
314
+ const beforeIdx = line.indexOf(inlineMatch[2] + inlineMatch[3] + inlineMatch[2]);
315
+ if (line[afterIdx] !== inlineMatch[2] && line[beforeIdx - 1] !== inlineMatch[2]) {
316
+ const cmd = inlineMatch[3].trim();
317
+ if (cmd) commands.push(cmd);
318
+ }
319
+ }
320
+
321
+ // Multi-line triple-quoted: sh ''' ... ''' or sh """ ... """
322
+ const tripleMatch = line.match(/\b(sh|bat|pwsh|powershell)\s*\(?\s*(?:script:\s*)?('''|""")\s*$/);
323
+ if (tripleMatch) {
324
+ const delim = tripleMatch[2];
325
+ // Collect lines until the closing triple delimiter
326
+ for (let j = i + 1; j < lines.length; j++) {
327
+ const inner = lines[j];
328
+ if (inner.trim().startsWith(delim) || inner.includes(delim)) {
329
+ i = j; // skip past the closing delimiter
330
+ break;
331
+ }
332
+ const innerTrimmed = inner.trim();
333
+ if (innerTrimmed && !innerTrimmed.startsWith('#') && !innerTrimmed.startsWith('//')) {
334
+ commands.push(innerTrimmed);
335
+ }
336
+ }
337
+ continue;
338
+ }
339
+
340
+ // Triple-quote on a previous line was handled by the loop above — no
341
+ // additional handling needed here.
342
+ }
343
+
344
+ return commands;
345
+ }
346
+
256
347
  // --- Generic YAML list field extractor -------------------------------------
257
348
 
258
349
  /**
@@ -287,7 +378,7 @@ function extractYamlListField(content, fields) {
287
378
  if (innerIndent <= baseIndent) break;
288
379
  const listItem = inner.match(/^\s*-\s*(.+)$/);
289
380
  if (listItem) {
290
- commands.push(listItem[1].trim().replace(/^["']|["']$/g, ''));
381
+ commands.push(stripYamlQuotes(listItem[1].trim()));
291
382
  }
292
383
  }
293
384
  } else if (/^[|>][+-]?\s*$/.test(rest)) {
@@ -303,15 +394,15 @@ function extractYamlListField(content, fields) {
303
394
  // Inline list: script: [cmd1, cmd2]
304
395
  const inner = rest.slice(1, rest.indexOf(']') === -1 ? rest.length : rest.indexOf(']'));
305
396
  for (const item of inner.split(',')) {
306
- const trimmed = item.trim().replace(/^["']|["']$/g, '');
397
+ const trimmed = stripYamlQuotes(item.trim());
307
398
  if (trimmed) commands.push(trimmed);
308
399
  }
309
400
  } else if (!rest.startsWith('#')) {
310
- commands.push(rest.replace(/^["']|["']$/g, ''));
401
+ commands.push(stripYamlQuotes(rest));
311
402
  }
312
403
  }
313
404
 
314
405
  return commands;
315
406
  }
316
407
 
317
- module.exports = { extractCiCommands, walkYaml, extractYamlListField };
408
+ module.exports = { extractCiCommands, walkYaml, extractYamlListField, extractJenkinsfileCommands };
@@ -94,6 +94,22 @@ function isNoise(cmd) {
94
94
  // License checkers are typically gates, but their exact invocation is
95
95
  // long and project-specific. Keep them.
96
96
 
97
+ // Dev/maintenance scripts under a `scripts/` directory are one-off tasks,
98
+ // not gates. FastAPI runs its doc, sponsor, people, translation pipelines
99
+ // this way via `uv run ./scripts/*.py`. These are publishing automations,
100
+ // not quality checks.
101
+ if (/^(uv|poetry|pdm|hatch|rye|pipenv)\s+run\s+(\.\/)?scripts\//.test(trimmed)) return true;
102
+ if (/^python3?\s+(\.\/)?scripts\//.test(trimmed)) return true;
103
+ if (/^node\s+(\.\/)?scripts\//.test(trimmed)) return true;
104
+ if (/^(bash|sh)\s+(\.\/)?scripts\//.test(trimmed)) return true;
105
+ if (/^npx\s+(tsx?|ts-node)\s+(\.\/)?scripts\//.test(trimmed)) return true;
106
+
107
+ // Shell control-flow fragments leaked from block scalars. When a `run: |`
108
+ // wraps a multi-line if/for/while/case, our line-based extractor pulls out
109
+ // the control keyword line as a pseudo-command.
110
+ if (/^(if|then|else|elif|fi|for|do|done|while|until|case|esac)\s/.test(trimmed)) return true;
111
+ if (/^(then|else|fi|do|done|esac)$/.test(trimmed)) return true;
112
+
97
113
  return false;
98
114
  }
99
115
 
@@ -85,8 +85,17 @@ function parseSimpleToml(content) {
85
85
  * Detect languages, frameworks, and package managers in `dir`.
86
86
  * Mutates `result.stack`, `result.name`, `result.description`, and attaches
87
87
  * `result._manifests` for downstream gate detection.
88
+ *
89
+ * When `recursive` is true (default), also scans conventional subservice
90
+ * directories (`src/`, `services/`, `packages/`, `apps/`, `cmd/`, `sdk/`,
91
+ * `web/`, `ui/`, `editors/`, `extensions/`, `projects/`, `microservices/`)
92
+ * to pick up polyglot microservice layouts (e.g. GoogleCloudPlatform's
93
+ * microservices-demo, or auxiliary subdirectories like prometheus's
94
+ * `web/ui` React app and rust-analyzer's `editors/code` VSCode extension).
95
+ * The recursive call is one level only to prevent infinite recursion.
88
96
  */
89
- function detectStack(dir, result) {
97
+ function detectStack(dir, result, options = {}) {
98
+ const { recursive = true } = options;
90
99
  result._manifests = result._manifests || {};
91
100
 
92
101
  detectNode(dir, result);
@@ -104,6 +113,107 @@ function detectStack(dir, result) {
104
113
  detectPhp(dir, result);
105
114
  detectDocker(dir, result);
106
115
  detectInfrastructure(dir, result);
116
+
117
+ if (recursive) {
118
+ detectNestedStacks(dir, result);
119
+ }
120
+ }
121
+
122
+ /**
123
+ * Scan conventional subservice/auxiliary directories one level deep for
124
+ * manifests. Adds unique detected stacks to `result.stack` and stores the
125
+ * per-subservice breakdown in `result._manifests.subservices`.
126
+ *
127
+ * Two cases this handles:
128
+ *
129
+ * 1. Polyglot microservices monorepo (no root manifests):
130
+ * root/src/adservice/pom.xml → java/maven
131
+ * root/src/cartservice/x.csproj → dotnet
132
+ * root/src/frontend/go.mod → go
133
+ * root/src/emailservice/pyproject.toml → python
134
+ * → result.stack = [java/maven, dotnet, go, python, ...]
135
+ * → workspaceType = subservices
136
+ *
137
+ * 2. Auxiliary subdirectory stack (root manifests present):
138
+ * root/go.mod → go (primary)
139
+ * root/web/ui/package.json → node, react, typescript (aux)
140
+ * → result.stack = [go, node, react, typescript]
141
+ * → subservices list records the breakdown for reporting
142
+ *
143
+ * Scans two depths below each container: container/manifest (depth 1) and
144
+ * container/<child>/manifest (depth 2). Does NOT recurse further.
145
+ */
146
+ function detectNestedStacks(dir, result) {
147
+ const containerDirs = [
148
+ 'src', 'services', 'packages', 'apps', 'cmd', 'projects', 'microservices',
149
+ 'sdk', 'sdks', 'web', 'ui', 'editors', 'extensions', 'clients',
150
+ ];
151
+
152
+ const rootHadStacks = result.stack.length > 0;
153
+ const subservices = [];
154
+
155
+ for (const container of containerDirs) {
156
+ const containerPath = path.join(dir, container);
157
+ if (!fs.existsSync(containerPath)) continue;
158
+
159
+ // Depth 1: container itself has a manifest (e.g. web/ui/package.json where
160
+ // the container is `ui` directly, or sdk/package.json)
161
+ scanOneSubdir(containerPath, container, result, subservices);
162
+
163
+ // Depth 2: container/<child>/manifest (the common microservices pattern)
164
+ let children;
165
+ try {
166
+ children = fs.readdirSync(containerPath, { withFileTypes: true })
167
+ .filter(e => e.isDirectory())
168
+ .map(e => e.name);
169
+ } catch { continue; }
170
+
171
+ // Cap children scan at a reasonable number to avoid pathological
172
+ // enumeration on huge directories (monorepos with hundreds of packages)
173
+ const capped = children.slice(0, 64);
174
+ for (const child of capped) {
175
+ const childPath = path.join(containerPath, child);
176
+ const relPath = container + '/' + child;
177
+ scanOneSubdir(childPath, relPath, result, subservices);
178
+ }
179
+ }
180
+
181
+ if (subservices.length > 0) {
182
+ result._manifests.subservices = subservices;
183
+ if (!rootHadStacks) {
184
+ // Root had no manifests — this is a subservice-only project (e.g.
185
+ // microservices-demo). Flag for downstream reporting.
186
+ result._manifests.workspaceType = result._manifests.workspaceType || 'subservices';
187
+ }
188
+ }
189
+ }
190
+
191
+ /**
192
+ * Run non-recursive stack detection on `subdir` and merge unique stacks into
193
+ * the main `result`. Records the subservice in `subservices` if any stacks
194
+ * were detected.
195
+ */
196
+ function scanOneSubdir(subdir, relPath, result, subservices) {
197
+ if (!fs.existsSync(subdir)) return;
198
+ try {
199
+ const st = fs.statSync(subdir);
200
+ if (!st.isDirectory()) return;
201
+ } catch { return; }
202
+
203
+ const subResult = { stack: [], _manifests: {} };
204
+ detectStack(subdir, subResult, { recursive: false });
205
+
206
+ if (subResult.stack.length === 0) return;
207
+
208
+ subservices.push({
209
+ name: path.basename(subdir),
210
+ path: relPath,
211
+ stacks: subResult.stack,
212
+ });
213
+
214
+ for (const s of subResult.stack) {
215
+ if (!result.stack.includes(s)) result.stack.push(s);
216
+ }
107
217
  }
108
218
 
109
219
  // --- Node ------------------------------------------------------------------
package/src/cli.js CHANGED
@@ -5,6 +5,7 @@ const { check } = require('./commands/check');
5
5
  const { compile } = require('./commands/compile');
6
6
  const { analyze } = require('./commands/analyze');
7
7
  const { diff } = require('./commands/diff');
8
+ const { doctor } = require('./commands/doctor');
8
9
  const { upgrade } = require('./commands/upgrade');
9
10
  const { workspace } = require('./commands/workspace');
10
11
  const { checkOnce } = require('./update/version-check');
@@ -19,6 +20,7 @@ function printUsage() {
19
20
  crag init Interview → generate governance, hooks, agents
20
21
  crag analyze Generate governance from existing project (no interview)
21
22
  crag check Verify infrastructure is complete
23
+ crag doctor Deep diagnostic: governance integrity, drift, hook validity, security
22
24
  crag compile Compile governance.md → CI, hooks, AGENTS.md, Cursor, Gemini
23
25
  crag diff Compare governance against codebase reality
24
26
  crag upgrade Update universal skills to latest version
@@ -93,6 +95,7 @@ function run(args) {
93
95
  case 'compile': compile(args); break;
94
96
  case 'analyze': analyze(args); break;
95
97
  case 'diff': diff(args); break;
98
+ case 'doctor': doctor(args.slice(1)); break;
96
99
  case 'upgrade': upgrade(args); break;
97
100
  case 'workspace': workspace(args); break;
98
101
  case 'version': case '--version': case '-v':
@@ -60,6 +60,27 @@ function analyze(args) {
60
60
  } else {
61
61
  console.log(` Workspace detected: ${ws.type}. Pass --workspace for per-member gates.\n`);
62
62
  }
63
+ } else if (analysis.subservices && analysis.subservices.length > 0) {
64
+ // No canonical workspace marker, but recursive stack detection found
65
+ // subservice manifests under src/ / services/ / packages/ / apps/ / etc.
66
+ // Treat as an independent-subservices workspace.
67
+ const count = analysis.subservices.length;
68
+ analysis.workspaceType = 'subservices';
69
+ if (workspaceFlag) {
70
+ console.log(` Subservices detected: ${count} service${count === 1 ? '' : 's'} across src/ services/ packages/ apps/ directories\n`);
71
+ analysis.workspace = { type: 'subservices', members: [] };
72
+ for (const sub of analysis.subservices) {
73
+ const subPath = path.join(cwd, sub.path);
74
+ const subAnalysis = analyzeProject(subPath);
75
+ analysis.workspace.members.push({
76
+ name: sub.name,
77
+ relativePath: sub.path,
78
+ ...subAnalysis,
79
+ });
80
+ }
81
+ } else {
82
+ console.log(` Subservices detected: ${count} service${count === 1 ? '' : 's'}. Pass --workspace for per-service gates.\n`);
83
+ }
63
84
  }
64
85
 
65
86
  const governance = generateGovernance(analysis, cwd);
@@ -165,6 +186,11 @@ function analyzeProject(dir) {
165
186
  result.advisories.push(...result._advisories);
166
187
  delete result._advisories;
167
188
  }
189
+ // Promote subservices (from recursive stack detection) to a public field
190
+ // so analyze command can print a hint and --workspace can enumerate them.
191
+ if (result._manifests && result._manifests.subservices) {
192
+ result.subservices = result._manifests.subservices;
193
+ }
168
194
  // Drop the internal manifests attachment before returning
169
195
  delete result._manifests;
170
196
 
@@ -0,0 +1,586 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * crag doctor — deep diagnostic command.
5
+ *
6
+ * Where `crag check` verifies file presence, `crag doctor` validates
7
+ * content, governance integrity, skill currency, hook validity, drift,
8
+ * and environment. It's the command you run when something feels off
9
+ * and you need a second opinion about your crag setup.
10
+ *
11
+ * Each check returns one of three statuses:
12
+ * pass (green ✓) — everything is correct
13
+ * warn (yellow !) — advisory, non-blocking
14
+ * fail (red ✗) — blocks a clean crag setup, needs fixing
15
+ *
16
+ * Exit codes:
17
+ * 0 — no failures (warnings allowed)
18
+ * 1 — one or more failures
19
+ */
20
+
21
+ const fs = require('fs');
22
+ const path = require('path');
23
+ const { execSync } = require('child_process');
24
+ const { parseGovernance, flattenGates } = require('../governance/parse');
25
+ const { isModified, readFrontmatter } = require('../update/integrity');
26
+ const { EXIT_USER, EXIT_INTERNAL } = require('../cli-errors');
27
+
28
+ const GREEN = '\x1b[32m';
29
+ const YELLOW = '\x1b[33m';
30
+ const RED = '\x1b[31m';
31
+ const DIM = '\x1b[2m';
32
+ const BOLD = '\x1b[1m';
33
+ const RESET = '\x1b[0m';
34
+
35
+ const ICON_PASS = `${GREEN}✓${RESET}`;
36
+ const ICON_WARN = `${YELLOW}!${RESET}`;
37
+ const ICON_FAIL = `${RED}✗${RESET}`;
38
+
39
+ /**
40
+ * CLI entry point.
41
+ *
42
+ * Flags:
43
+ * --json Machine-readable output
44
+ * --ci Skip checks that require runtime infrastructure (skills,
45
+ * hooks, file presence). Useful in CI where .claude/ is
46
+ * either absent or freshly generated via `crag analyze`.
47
+ * --strict Treat warnings as failures (exit 1 on any warn).
48
+ */
49
+ function doctor(args) {
50
+ const cwd = process.cwd();
51
+ const jsonOutput = args.includes('--json');
52
+ const ciMode = args.includes('--ci');
53
+ const strict = args.includes('--strict');
54
+
55
+ const report = runDiagnostics(cwd, { ciMode });
56
+
57
+ const exitCode = computeExitCode(report, strict);
58
+
59
+ if (jsonOutput) {
60
+ console.log(JSON.stringify(report, null, 2));
61
+ process.exit(exitCode);
62
+ }
63
+
64
+ printReport(report, { ciMode, strict });
65
+ process.exit(exitCode);
66
+ }
67
+
68
+ function computeExitCode(report, strict) {
69
+ if (report.fail > 0) return 1;
70
+ if (strict && report.warn > 0) return 1;
71
+ return 0;
72
+ }
73
+
74
+ /**
75
+ * Run all diagnostics and return a structured report.
76
+ * Exported for testing.
77
+ *
78
+ * When `ciMode` is true, skips sections that rely on runtime infrastructure
79
+ * (Infrastructure, Skills, Hooks) — those checks require files that only
80
+ * exist on a developer's machine or after `crag init`/`crag compile`, not
81
+ * in a bare CI runner.
82
+ */
83
+ function runDiagnostics(cwd, options = {}) {
84
+ const { ciMode = false } = options;
85
+
86
+ const sections = ciMode ? [
87
+ diagnoseGovernance(cwd),
88
+ diagnoseDrift(cwd),
89
+ diagnoseSecurity(cwd),
90
+ diagnoseEnvironment(cwd),
91
+ ] : [
92
+ diagnoseInfrastructure(cwd),
93
+ diagnoseGovernance(cwd),
94
+ diagnoseSkills(cwd),
95
+ diagnoseHooks(cwd),
96
+ diagnoseDrift(cwd),
97
+ diagnoseSecurity(cwd),
98
+ diagnoseEnvironment(cwd),
99
+ ];
100
+
101
+ let pass = 0, warn = 0, fail = 0;
102
+ for (const section of sections) {
103
+ for (const check of section.checks) {
104
+ if (check.status === 'pass') pass++;
105
+ else if (check.status === 'warn') warn++;
106
+ else if (check.status === 'fail') fail++;
107
+ }
108
+ }
109
+
110
+ return { cwd, sections, pass, warn, fail, ciMode };
111
+ }
112
+
113
+ // ============================================================================
114
+ // Section: Infrastructure
115
+ // ============================================================================
116
+
117
+ function diagnoseInfrastructure(cwd) {
118
+ const checks = [];
119
+ const CORE = [
120
+ ['.claude/skills/pre-start-context/SKILL.md', 'Pre-start skill'],
121
+ ['.claude/skills/post-start-validation/SKILL.md', 'Post-start skill'],
122
+ ['.claude/governance.md', 'Governance file'],
123
+ ['.claude/hooks/sandbox-guard.sh', 'Sandbox guard hook'],
124
+ ['.claude/settings.local.json', 'Settings with hooks'],
125
+ ];
126
+ const OPTIONAL = [
127
+ ['.claude/hooks/drift-detector.sh', 'Drift detector (optional)'],
128
+ ['.claude/hooks/circuit-breaker.sh', 'Circuit breaker (optional)'],
129
+ ['.claude/agents/test-runner.md', 'Test runner agent (optional)'],
130
+ ['.claude/agents/security-reviewer.md', 'Security reviewer agent (optional)'],
131
+ ['.claude/ci-playbook.md', 'CI playbook (optional)'],
132
+ ];
133
+
134
+ for (const [file, name] of CORE) {
135
+ const present = fs.existsSync(path.join(cwd, file));
136
+ checks.push({
137
+ name,
138
+ status: present ? 'pass' : 'fail',
139
+ detail: present ? null : `missing: ${file}`,
140
+ fix: present ? null : `run 'crag init' or 'crag analyze' then 'crag compile --target all'`,
141
+ });
142
+ }
143
+
144
+ for (const [file, name] of OPTIONAL) {
145
+ const present = fs.existsSync(path.join(cwd, file));
146
+ checks.push({
147
+ name,
148
+ status: present ? 'pass' : 'warn',
149
+ detail: present ? null : `missing: ${file}`,
150
+ fix: null,
151
+ });
152
+ }
153
+
154
+ return { title: 'Infrastructure', checks };
155
+ }
156
+
157
+ // ============================================================================
158
+ // Section: Governance integrity
159
+ // ============================================================================
160
+
161
+ function diagnoseGovernance(cwd) {
162
+ const checks = [];
163
+ const govPath = path.join(cwd, '.claude', 'governance.md');
164
+
165
+ if (!fs.existsSync(govPath)) {
166
+ checks.push({
167
+ name: 'governance.md exists',
168
+ status: 'fail',
169
+ detail: '.claude/governance.md not found',
170
+ fix: `run 'crag analyze' to generate one`,
171
+ });
172
+ return { title: 'Governance', checks };
173
+ }
174
+
175
+ let content;
176
+ try {
177
+ content = fs.readFileSync(govPath, 'utf-8');
178
+ } catch (err) {
179
+ checks.push({
180
+ name: 'governance.md readable',
181
+ status: 'fail',
182
+ detail: `cannot read: ${err.message}`,
183
+ fix: 'check file permissions',
184
+ });
185
+ return { title: 'Governance', checks };
186
+ }
187
+
188
+ // Parse
189
+ const parsed = parseGovernance(content);
190
+ const warnings = parsed.warnings || [];
191
+ checks.push({
192
+ name: 'parses cleanly',
193
+ status: warnings.length === 0 ? 'pass' : 'warn',
194
+ detail: warnings.length === 0 ? null : `${warnings.length} parser warning${warnings.length === 1 ? '' : 's'}: ${warnings[0]}${warnings.length > 1 ? '...' : ''}`,
195
+ fix: warnings.length > 0 ? 'review parser warnings in governance.md structure' : null,
196
+ });
197
+
198
+ // Required sections
199
+ const requiredSections = ['Identity', 'Gates', 'Branch Strategy', 'Security'];
200
+ for (const section of requiredSections) {
201
+ const present = new RegExp(`^##\\s+${section}`, 'm').test(content);
202
+ checks.push({
203
+ name: `has ${section} section`,
204
+ status: present ? 'pass' : 'fail',
205
+ detail: present ? null : `missing ## ${section}`,
206
+ fix: present ? null : `add a '## ${section}' section to governance.md`,
207
+ });
208
+ }
209
+
210
+ // Gate count
211
+ const gates = flattenGates(parsed.gates);
212
+ const gateCount = Object.values(gates).flat().length;
213
+ checks.push({
214
+ name: 'has gate commands',
215
+ status: gateCount > 0 ? 'pass' : 'fail',
216
+ detail: `${gateCount} gate${gateCount === 1 ? '' : 's'} declared`,
217
+ fix: gateCount === 0 ? 'add gate commands under ### Lint / ### Test / ### Build' : null,
218
+ });
219
+
220
+ // Project identity
221
+ checks.push({
222
+ name: 'project identity set',
223
+ status: parsed.name ? 'pass' : 'warn',
224
+ detail: parsed.name ? `name: ${parsed.name}` : 'no project name in - Project:',
225
+ fix: parsed.name ? null : `add '- Project: <name>' under ## Identity`,
226
+ });
227
+
228
+ return { title: 'Governance', checks };
229
+ }
230
+
231
+ // ============================================================================
232
+ // Section: Skill currency
233
+ // ============================================================================
234
+
235
+ function diagnoseSkills(cwd) {
236
+ const checks = [];
237
+ const skills = ['pre-start-context', 'post-start-validation'];
238
+
239
+ for (const skill of skills) {
240
+ const skillPath = path.join(cwd, '.claude', 'skills', skill, 'SKILL.md');
241
+ if (!fs.existsSync(skillPath)) {
242
+ checks.push({
243
+ name: `${skill} installed`,
244
+ status: 'fail',
245
+ detail: `${skillPath} missing`,
246
+ fix: `run 'crag upgrade' to install`,
247
+ });
248
+ continue;
249
+ }
250
+
251
+ const parsed = readFrontmatter(skillPath);
252
+ if (!parsed || !parsed.version) {
253
+ checks.push({
254
+ name: `${skill} frontmatter`,
255
+ status: 'warn',
256
+ detail: parsed ? 'no version field in frontmatter' : 'no frontmatter found',
257
+ fix: `run 'crag upgrade --force' to refresh`,
258
+ });
259
+ continue;
260
+ }
261
+
262
+ // Self-integrity check: does the installed body hash match the stored
263
+ // source_hash? (isModified returns true when body was locally modified
264
+ // OR when the hash is missing)
265
+ const modified = isModified(skillPath);
266
+
267
+ checks.push({
268
+ name: `${skill} v${parsed.version}`,
269
+ status: modified ? 'warn' : 'pass',
270
+ detail: modified ? 'locally modified since install (body hash differs from source_hash)' : 'integrity verified',
271
+ fix: modified ? `run 'crag upgrade --check' to see what changed, or 'crag upgrade --force' to reset` : null,
272
+ });
273
+ }
274
+
275
+ return { title: 'Skills', checks };
276
+ }
277
+
278
+ // ============================================================================
279
+ // Section: Hooks
280
+ // ============================================================================
281
+
282
+ function diagnoseHooks(cwd) {
283
+ const checks = [];
284
+ const hooksDir = path.join(cwd, '.claude', 'hooks');
285
+
286
+ if (!fs.existsSync(hooksDir)) {
287
+ checks.push({
288
+ name: 'hooks directory',
289
+ status: 'warn',
290
+ detail: '.claude/hooks/ does not exist',
291
+ fix: `run 'crag compile --target github' or 'crag init' to generate`,
292
+ });
293
+ return { title: 'Hooks', checks };
294
+ }
295
+
296
+ // Sandbox guard is the most important hook
297
+ const sandboxPath = path.join(hooksDir, 'sandbox-guard.sh');
298
+ if (fs.existsSync(sandboxPath)) {
299
+ const content = fs.readFileSync(sandboxPath, 'utf-8');
300
+ const hasShebang = content.startsWith('#!');
301
+ const hasRtkMarker = /#\s*rtk-hook-version:/m.test(content.split('\n').slice(0, 5).join('\n'));
302
+
303
+ checks.push({
304
+ name: 'sandbox-guard.sh installed',
305
+ status: hasShebang ? 'pass' : 'fail',
306
+ detail: hasShebang ? null : 'missing shebang line (#!/usr/bin/env bash)',
307
+ fix: hasShebang ? null : `run 'crag compile --target github' to regenerate`,
308
+ });
309
+
310
+ checks.push({
311
+ name: 'sandbox-guard has rtk-hook-version marker',
312
+ status: hasRtkMarker ? 'pass' : 'warn',
313
+ detail: hasRtkMarker ? null : '# rtk-hook-version marker not in first 5 lines (causes "Hook outdated" warnings)',
314
+ fix: hasRtkMarker ? null : `add '# rtk-hook-version: 3' near the top of the hook file`,
315
+ });
316
+
317
+ // On Unix, check executable bit
318
+ if (process.platform !== 'win32') {
319
+ try {
320
+ const stat = fs.statSync(sandboxPath);
321
+ const executable = (stat.mode & 0o111) !== 0;
322
+ checks.push({
323
+ name: 'sandbox-guard executable',
324
+ status: executable ? 'pass' : 'fail',
325
+ detail: executable ? null : `mode ${(stat.mode & 0o777).toString(8)} (not executable)`,
326
+ fix: executable ? null : `run 'chmod +x .claude/hooks/sandbox-guard.sh'`,
327
+ });
328
+ } catch { /* skip */ }
329
+ }
330
+ } else {
331
+ checks.push({
332
+ name: 'sandbox-guard.sh installed',
333
+ status: 'fail',
334
+ detail: 'no sandbox-guard.sh (hard-block of destructive commands is disabled)',
335
+ fix: `run 'crag compile --target github' to generate the hook`,
336
+ });
337
+ }
338
+
339
+ // Settings references hooks
340
+ const settingsPath = path.join(cwd, '.claude', 'settings.local.json');
341
+ if (fs.existsSync(settingsPath)) {
342
+ try {
343
+ const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8'));
344
+ const hasHooks = settings.hooks && typeof settings.hooks === 'object' && Object.keys(settings.hooks).length > 0;
345
+ checks.push({
346
+ name: 'settings.local.json wires hooks',
347
+ status: hasHooks ? 'pass' : 'warn',
348
+ detail: hasHooks ? `${Object.keys(settings.hooks).length} hook event(s) configured` : 'no hooks section — hooks will not fire',
349
+ fix: hasHooks ? null : `run 'crag compile --target github' to wire hooks into settings`,
350
+ });
351
+
352
+ // Check for hardcoded paths in the `hooks` section only. User-local
353
+ // permissions allowlist (`permissions.allow`) can legitimately contain
354
+ // machine-specific paths like /c/Users/... and should not be flagged.
355
+ if (hasHooks) {
356
+ const hookJson = JSON.stringify(settings.hooks);
357
+ const hookHardcoded = /[A-Z]:[\\/]|\/home\/|\/Users\//.test(hookJson);
358
+ if (hookHardcoded) {
359
+ checks.push({
360
+ name: 'hook commands use $CLAUDE_PROJECT_DIR',
361
+ status: 'warn',
362
+ detail: 'hardcoded absolute paths in hooks section — prefer $CLAUDE_PROJECT_DIR',
363
+ fix: `replace hardcoded paths in settings.local.json hooks with $CLAUDE_PROJECT_DIR/...`,
364
+ });
365
+ }
366
+ }
367
+ } catch (err) {
368
+ checks.push({
369
+ name: 'settings.local.json valid JSON',
370
+ status: 'fail',
371
+ detail: `parse error: ${err.message}`,
372
+ fix: `check syntax of .claude/settings.local.json`,
373
+ });
374
+ }
375
+ }
376
+
377
+ return { title: 'Hooks', checks };
378
+ }
379
+
380
+ // ============================================================================
381
+ // Section: Drift (reuses crag diff logic)
382
+ // ============================================================================
383
+
384
+ function diagnoseDrift(cwd) {
385
+ const checks = [];
386
+ const govPath = path.join(cwd, '.claude', 'governance.md');
387
+ if (!fs.existsSync(govPath)) {
388
+ return { title: 'Drift', checks: [{ name: 'drift check skipped', status: 'warn', detail: 'no governance.md', fix: null }] };
389
+ }
390
+
391
+ const content = fs.readFileSync(govPath, 'utf-8');
392
+
393
+ // Branch strategy alignment
394
+ const govBranchStrategy = content.includes('Feature branches') || content.includes('feature branches')
395
+ ? 'feature-branches'
396
+ : content.includes('Trunk-based') || content.includes('trunk-based')
397
+ ? 'trunk-based'
398
+ : null;
399
+
400
+ if (govBranchStrategy) {
401
+ try {
402
+ const branches = execSync('git branch -a --format="%(refname:short)"', {
403
+ cwd, encoding: 'utf-8', timeout: 5000, stdio: ['pipe', 'pipe', 'pipe'],
404
+ });
405
+ const list = branches.trim().split('\n');
406
+ const featureBranches = list.filter(b => /^(feat|fix|docs|chore|feature|hotfix)\//.test(b));
407
+ const actualStrategy = featureBranches.length > 2 ? 'feature-branches' : 'trunk-based';
408
+
409
+ checks.push({
410
+ name: 'branch strategy matches git',
411
+ status: actualStrategy === govBranchStrategy ? 'pass' : 'warn',
412
+ detail: actualStrategy === govBranchStrategy
413
+ ? `${govBranchStrategy} (${featureBranches.length} feature branches)`
414
+ : `governance says ${govBranchStrategy}, git shows ${actualStrategy}`,
415
+ fix: actualStrategy === govBranchStrategy ? null
416
+ : `update governance.md to '${actualStrategy === 'trunk-based' ? 'Trunk-based development' : 'Feature branches'}'`,
417
+ });
418
+ } catch { /* skip — not a git repo or git unavailable */ }
419
+ }
420
+
421
+ // Commit convention alignment
422
+ const govConvention = content.includes('Conventional commits') || content.includes('conventional commits')
423
+ ? 'conventional'
424
+ : content.includes('Free-form') || content.includes('free-form')
425
+ ? 'free-form'
426
+ : null;
427
+
428
+ if (govConvention) {
429
+ try {
430
+ const log = execSync('git log --oneline -20', { cwd, encoding: 'utf-8', timeout: 5000, stdio: ['pipe', 'pipe', 'pipe'] });
431
+ const lines = log.trim().split('\n');
432
+ const conventional = lines.filter(l => /\b(feat|fix|docs|chore|style|refactor|test|build|ci|perf|revert)[\(:!]/.test(l));
433
+ const actual = conventional.length > lines.length * 0.3 ? 'conventional' : 'free-form';
434
+
435
+ checks.push({
436
+ name: 'commit convention matches git',
437
+ status: actual === govConvention ? 'pass' : 'warn',
438
+ detail: actual === govConvention ? `${govConvention} (${conventional.length}/${lines.length} recent commits match)` : `governance says ${govConvention}, git shows ${actual}`,
439
+ fix: actual === govConvention ? null : `update governance.md to '${actual === 'conventional' ? 'Conventional commits' : 'Free-form commits'}'`,
440
+ });
441
+ } catch { /* skip */ }
442
+ }
443
+
444
+ return { title: 'Drift', checks };
445
+ }
446
+
447
+ // ============================================================================
448
+ // Section: Security smoke
449
+ // ============================================================================
450
+
451
+ function diagnoseSecurity(cwd) {
452
+ const checks = [];
453
+
454
+ // governance.md should not contain literal secrets
455
+ const govPath = path.join(cwd, '.claude', 'governance.md');
456
+ if (fs.existsSync(govPath)) {
457
+ const content = fs.readFileSync(govPath, 'utf-8');
458
+ const SECRET_PATTERNS = [
459
+ { pattern: /sk_live_[a-zA-Z0-9]{16,}/, label: 'Stripe live secret key' },
460
+ { pattern: /sk_test_[a-zA-Z0-9]{16,}/, label: 'Stripe test secret key' },
461
+ { pattern: /AKIA[A-Z0-9]{16}/, label: 'AWS access key ID' },
462
+ { pattern: /ghp_[a-zA-Z0-9]{36}/, label: 'GitHub personal access token' },
463
+ { pattern: /gho_[a-zA-Z0-9]{36}/, label: 'GitHub OAuth token' },
464
+ { pattern: /ghu_[a-zA-Z0-9]{36}/, label: 'GitHub user token' },
465
+ { pattern: /xox[baprs]-[a-zA-Z0-9-]{10,}/, label: 'Slack token' },
466
+ { pattern: /-----BEGIN (RSA|EC|DSA|OPENSSH) PRIVATE KEY-----/, label: 'Private key' },
467
+ ];
468
+ const leaks = [];
469
+ for (const { pattern, label } of SECRET_PATTERNS) {
470
+ if (pattern.test(content)) leaks.push(label);
471
+ }
472
+
473
+ checks.push({
474
+ name: 'governance.md secret-free',
475
+ status: leaks.length === 0 ? 'pass' : 'fail',
476
+ detail: leaks.length === 0 ? null : `found: ${leaks.join(', ')}`,
477
+ fix: leaks.length === 0 ? null : 'remove the secret and rotate it immediately',
478
+ });
479
+ }
480
+
481
+ // Root-level .env files should not be tracked in git. This is the most
482
+ // common place for real secret leaks. Subdirectory .env files are typically
483
+ // build config (React CRA PUBLIC_URL, Vite VITE_*), monorepo package
484
+ // overrides, or test fixtures — too many legitimate uses to flag blindly.
485
+ // For those, rely on the secret-pattern scan below if they ever matter.
486
+ try {
487
+ const tracked = execSync('git ls-files', {
488
+ cwd, encoding: 'utf-8', timeout: 5000, stdio: ['pipe', 'pipe', 'pipe'],
489
+ });
490
+ const files = tracked.split('\n');
491
+
492
+ // Only flag root-level .env / .env.local / .env.production (not sample).
493
+ const risky = files.filter(f => /^\.env(\.local|\.production)?$/.test(f));
494
+
495
+ checks.push({
496
+ name: 'root .env files not tracked',
497
+ status: risky.length === 0 ? 'pass' : 'fail',
498
+ detail: risky.length === 0 ? null : `tracked: ${risky.join(', ')}`,
499
+ fix: risky.length === 0 ? null : `git rm --cached ${risky[0]} && add to .gitignore, then rotate any secrets inside`,
500
+ });
501
+ } catch { /* skip — not a git repo */ }
502
+
503
+ return { title: 'Security', checks };
504
+ }
505
+
506
+ // ============================================================================
507
+ // Section: Environment
508
+ // ============================================================================
509
+
510
+ function diagnoseEnvironment(cwd) {
511
+ const checks = [];
512
+
513
+ // Node version
514
+ const nodeVersion = process.version;
515
+ checks.push({
516
+ name: 'node version',
517
+ status: 'pass',
518
+ detail: nodeVersion,
519
+ fix: null,
520
+ });
521
+
522
+ // crag's own engines requirement
523
+ try {
524
+ const cragPkg = JSON.parse(fs.readFileSync(path.join(__dirname, '..', '..', 'package.json'), 'utf-8'));
525
+ const required = cragPkg.engines && cragPkg.engines.node;
526
+ if (required) {
527
+ const minMatch = required.match(/>=\s*(\d+)/);
528
+ if (minMatch) {
529
+ const minMajor = parseInt(minMatch[1], 10);
530
+ const currentMajor = parseInt(nodeVersion.replace(/^v/, '').split('.')[0], 10);
531
+ checks.push({
532
+ name: `node >= ${minMajor}`,
533
+ status: currentMajor >= minMajor ? 'pass' : 'fail',
534
+ detail: currentMajor >= minMajor ? null : `installed v${currentMajor}, need >= ${minMajor}`,
535
+ fix: currentMajor >= minMajor ? null : `upgrade Node.js to version ${minMajor} or later`,
536
+ });
537
+ }
538
+ }
539
+ } catch { /* skip */ }
540
+
541
+ // Git presence
542
+ try {
543
+ const gitVersion = execSync('git --version', { encoding: 'utf-8', timeout: 2000, stdio: ['pipe', 'pipe', 'pipe'] }).trim();
544
+ checks.push({ name: 'git available', status: 'pass', detail: gitVersion, fix: null });
545
+ } catch {
546
+ checks.push({ name: 'git available', status: 'fail', detail: 'git not on PATH', fix: 'install git' });
547
+ }
548
+
549
+ return { title: 'Environment', checks };
550
+ }
551
+
552
+ // ============================================================================
553
+ // Output
554
+ // ============================================================================
555
+
556
+ function printReport(report, { ciMode = false, strict = false } = {}) {
557
+ const modeLabel = ciMode ? ' (--ci mode)' : '';
558
+ console.log(`\n ${BOLD}crag doctor${RESET}${modeLabel} — ${report.cwd}\n`);
559
+
560
+ for (const section of report.sections) {
561
+ console.log(` ${BOLD}${section.title}${RESET}`);
562
+ for (const check of section.checks) {
563
+ const icon = check.status === 'pass' ? ICON_PASS
564
+ : check.status === 'warn' ? ICON_WARN
565
+ : ICON_FAIL;
566
+ console.log(` ${icon} ${check.name}${check.detail ? ` ${DIM}— ${check.detail}${RESET}` : ''}`);
567
+ if (check.fix && check.status !== 'pass') {
568
+ console.log(` ${DIM}fix:${RESET} ${check.fix}`);
569
+ }
570
+ }
571
+ console.log();
572
+ }
573
+
574
+ // Summary
575
+ const total = report.pass + report.warn + report.fail;
576
+ const summary = ` ${report.pass}/${total} pass, ${report.warn} warn, ${report.fail} fail`;
577
+ if (report.fail > 0) {
578
+ console.log(`${RED}${BOLD}${summary}${RESET}\n`);
579
+ } else if (report.warn > 0) {
580
+ console.log(`${YELLOW}${BOLD}${summary}${RESET}\n`);
581
+ } else {
582
+ console.log(`${GREEN}${BOLD}${summary}${RESET}\n`);
583
+ }
584
+ }
585
+
586
+ module.exports = { doctor, runDiagnostics };
@@ -1,5 +1,18 @@
1
1
  'use strict';
2
2
 
3
+ /**
4
+ * Strip surrounding matching quotes from a YAML scalar value.
5
+ *
6
+ * Only strips when the ENTIRE string is wrapped in matching single or double
7
+ * quotes. Previously we used `replace(/^["']|["']$/g, '')` which stripped a
8
+ * trailing quote even when no leading quote existed — that truncated commands
9
+ * like `make test-X ARGS='--workspace --benches'` to `make test-X ARGS='--workspace --benches`.
10
+ */
11
+ function stripYamlQuotes(str) {
12
+ const m = str.match(/^(['"])(.*)\1$/);
13
+ return m ? m[2] : str;
14
+ }
15
+
3
16
  /**
4
17
  * Shared YAML `run:` command extraction for GitHub Actions workflows.
5
18
  *
@@ -42,8 +55,7 @@ function extractRunCommands(content) {
42
55
  if (trimmed && !trimmed.startsWith('#')) commands.push(trimmed);
43
56
  }
44
57
  } else if (rest && !rest.startsWith('#')) {
45
- // Inline: strip surrounding single/double quotes if present
46
- commands.push(rest.replace(/^["']|["']$/g, ''));
58
+ commands.push(stripYamlQuotes(rest));
47
59
  }
48
60
  }
49
61
 
@@ -142,4 +154,4 @@ function isGateCommand(cmd) {
142
154
  return patterns.some((p) => p.test(cmd));
143
155
  }
144
156
 
145
- module.exports = { extractRunCommands, isGateCommand };
157
+ module.exports = { extractRunCommands, isGateCommand, stripYamlQuotes };