@whitehatd/crag 0.2.6 → 0.2.8

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
@@ -301,7 +337,7 @@ npx @whitehatd/crag <command>
301
337
 
302
338
  - **Node.js 18+** — uses built-in `https`, `crypto`, `fs`, `child_process`. No runtime dependencies.
303
339
  - **Git** — for branch strategy inference and the discovery cache.
304
- - **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.
305
341
 
306
342
  The package is published under `@whitehatd/crag` but the binary name is plain `crag` after install.
307
343
 
@@ -330,17 +366,18 @@ To skip a release on a specific push, put `crag:skip-release` on its own line in
330
366
  ## Honest status
331
367
 
332
368
  - **Published:** 2026-04-04 as `@whitehatd/crag` on npm. Scoped public package.
333
- - **Tests:** 323 unit tests, passing on Ubuntu/macOS/Windows × Node 18/20/22.
334
- - **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`.
335
371
  - **Languages fully supported:** Node, Deno, Bun, Python, Rust, Go, Java, Kotlin, Ruby, PHP, .NET, Swift, Elixir (+ Terraform/Helm/K8s infra gates).
336
- - **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).
337
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.
338
376
 
339
377
  ### Known limitations
340
378
 
341
- - **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.
342
- - **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.
343
- - **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.
344
381
  - **No telemetry, no network calls** beyond the optional `crag upgrade --check` npm registry ping (24h cached, 3s timeout, graceful offline).
345
382
 
346
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.6",
3
+ "version": "0.2.8",
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
@@ -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 };
@@ -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
  /**
@@ -314,4 +405,4 @@ function extractYamlListField(content, fields) {
314
405
  return commands;
315
406
  }
316
407
 
317
- module.exports = { extractCiCommands, walkYaml, extractYamlListField };
408
+ module.exports = { extractCiCommands, walkYaml, extractYamlListField, extractJenkinsfileCommands };
@@ -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,647 @@
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, extractSection } = 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
+ /**
385
+ * Detect the branch strategy declared in a governance.md document.
386
+ *
387
+ * Scopes the text scan to the `## Branch Strategy` section (avoiding false
388
+ * matches against unrelated prose). Within that section, the FIRST keyword
389
+ * to appear wins — this matches human reading order where the opening
390
+ * statement is the rule and later lines are qualifications.
391
+ *
392
+ * Returns 'feature-branches', 'trunk-based', or null.
393
+ *
394
+ * Exported for unit testing.
395
+ */
396
+ function detectBranchStrategy(content) {
397
+ if (typeof content !== 'string' || content.length === 0) return null;
398
+ const section = extractSection(content, 'Branch Strategy');
399
+ const scope = section || content; // fall back to whole file if section absent
400
+ const featureIdx = scope.search(/[Ff]eature branches/);
401
+ const trunkIdx = scope.search(/[Tt]runk-based/);
402
+ if (featureIdx === -1 && trunkIdx === -1) return null;
403
+ if (featureIdx === -1) return 'trunk-based';
404
+ if (trunkIdx === -1) return 'feature-branches';
405
+ return featureIdx < trunkIdx ? 'feature-branches' : 'trunk-based';
406
+ }
407
+
408
+ /**
409
+ * Given the raw output of `git branch -a --format="%(refname:short)"`, return
410
+ * the list of unique feature branches (by short name, after stripping any
411
+ * remote prefix).
412
+ *
413
+ * This is the piece that decides whether a repo is practicing feature-branch
414
+ * development. A repo whose LOCAL branches have all been merged+deleted but
415
+ * whose REMOTE still has open feat/* branches is absolutely still practicing
416
+ * feature-branch development — so we count remote-tracking refs as equivalent
417
+ * to local branches, and we dedupe.
418
+ *
419
+ * Exported for unit testing — avoids the need to set up a real git fixture.
420
+ */
421
+ const FEATURE_PREFIXES = ['feat', 'fix', 'docs', 'chore', 'feature', 'hotfix'];
422
+
423
+ function countFeatureBranches(gitBranchOutput) {
424
+ if (typeof gitBranchOutput !== 'string' || gitBranchOutput.length === 0) {
425
+ return [];
426
+ }
427
+ const rawList = gitBranchOutput
428
+ .split('\n')
429
+ .map(b => b.trim())
430
+ .filter(Boolean)
431
+ // Skip symbolic refs like "origin/HEAD" (no payload branch underneath).
432
+ .filter(b => !b.endsWith('/HEAD'));
433
+
434
+ const prefixGroup = FEATURE_PREFIXES.join('|');
435
+ const remoteStripRe = new RegExp(`^[A-Za-z0-9_.-]+/((?:${prefixGroup})/.+)$`);
436
+ const featureRe = new RegExp(`^(${prefixGroup})/`);
437
+
438
+ const normalized = rawList.map(b => {
439
+ const m = b.match(remoteStripRe);
440
+ return m ? m[1] : b;
441
+ });
442
+ const unique = [...new Set(normalized)];
443
+ return unique.filter(b => featureRe.test(b));
444
+ }
445
+
446
+ function diagnoseDrift(cwd) {
447
+ const checks = [];
448
+ const govPath = path.join(cwd, '.claude', 'governance.md');
449
+ if (!fs.existsSync(govPath)) {
450
+ return { title: 'Drift', checks: [{ name: 'drift check skipped', status: 'warn', detail: 'no governance.md', fix: null }] };
451
+ }
452
+
453
+ const content = fs.readFileSync(govPath, 'utf-8');
454
+
455
+ // Branch strategy alignment — detect from the `## Branch Strategy` section
456
+ // rather than the whole file so unrelated prose ("feature branches in each
457
+ // sub-repo") doesn't override a workspace wrapper's actual trunk-based
458
+ // policy. Within that section, the FIRST keyword to appear wins: this
459
+ // matches the human reading order where the opening bullet states the rule.
460
+ const govBranchStrategy = detectBranchStrategy(content);
461
+
462
+ if (govBranchStrategy) {
463
+ try {
464
+ const branches = execSync('git branch -a --format="%(refname:short)"', {
465
+ cwd, encoding: 'utf-8', timeout: 5000, stdio: ['pipe', 'pipe', 'pipe'],
466
+ });
467
+ const featureBranches = countFeatureBranches(branches);
468
+ const actualStrategy = featureBranches.length > 2 ? 'feature-branches' : 'trunk-based';
469
+
470
+ checks.push({
471
+ name: 'branch strategy matches git',
472
+ status: actualStrategy === govBranchStrategy ? 'pass' : 'warn',
473
+ detail: actualStrategy === govBranchStrategy
474
+ ? `${govBranchStrategy} (${featureBranches.length} feature branches)`
475
+ : `governance says ${govBranchStrategy}, git shows ${actualStrategy}`,
476
+ fix: actualStrategy === govBranchStrategy ? null
477
+ : `update governance.md to '${actualStrategy === 'trunk-based' ? 'Trunk-based development' : 'Feature branches'}'`,
478
+ });
479
+ } catch { /* skip — not a git repo or git unavailable */ }
480
+ }
481
+
482
+ // Commit convention alignment
483
+ const govConvention = content.includes('Conventional commits') || content.includes('conventional commits')
484
+ ? 'conventional'
485
+ : content.includes('Free-form') || content.includes('free-form')
486
+ ? 'free-form'
487
+ : null;
488
+
489
+ if (govConvention) {
490
+ try {
491
+ const log = execSync('git log --oneline -20', { cwd, encoding: 'utf-8', timeout: 5000, stdio: ['pipe', 'pipe', 'pipe'] });
492
+ const lines = log.trim().split('\n');
493
+ const conventional = lines.filter(l => /\b(feat|fix|docs|chore|style|refactor|test|build|ci|perf|revert)[\(:!]/.test(l));
494
+ const actual = conventional.length > lines.length * 0.3 ? 'conventional' : 'free-form';
495
+
496
+ checks.push({
497
+ name: 'commit convention matches git',
498
+ status: actual === govConvention ? 'pass' : 'warn',
499
+ detail: actual === govConvention ? `${govConvention} (${conventional.length}/${lines.length} recent commits match)` : `governance says ${govConvention}, git shows ${actual}`,
500
+ fix: actual === govConvention ? null : `update governance.md to '${actual === 'conventional' ? 'Conventional commits' : 'Free-form commits'}'`,
501
+ });
502
+ } catch { /* skip */ }
503
+ }
504
+
505
+ return { title: 'Drift', checks };
506
+ }
507
+
508
+ // ============================================================================
509
+ // Section: Security smoke
510
+ // ============================================================================
511
+
512
+ function diagnoseSecurity(cwd) {
513
+ const checks = [];
514
+
515
+ // governance.md should not contain literal secrets
516
+ const govPath = path.join(cwd, '.claude', 'governance.md');
517
+ if (fs.existsSync(govPath)) {
518
+ const content = fs.readFileSync(govPath, 'utf-8');
519
+ const SECRET_PATTERNS = [
520
+ { pattern: /sk_live_[a-zA-Z0-9]{16,}/, label: 'Stripe live secret key' },
521
+ { pattern: /sk_test_[a-zA-Z0-9]{16,}/, label: 'Stripe test secret key' },
522
+ { pattern: /AKIA[A-Z0-9]{16}/, label: 'AWS access key ID' },
523
+ { pattern: /ghp_[a-zA-Z0-9]{36}/, label: 'GitHub personal access token' },
524
+ { pattern: /gho_[a-zA-Z0-9]{36}/, label: 'GitHub OAuth token' },
525
+ { pattern: /ghu_[a-zA-Z0-9]{36}/, label: 'GitHub user token' },
526
+ { pattern: /xox[baprs]-[a-zA-Z0-9-]{10,}/, label: 'Slack token' },
527
+ { pattern: /-----BEGIN (RSA|EC|DSA|OPENSSH) PRIVATE KEY-----/, label: 'Private key' },
528
+ ];
529
+ const leaks = [];
530
+ for (const { pattern, label } of SECRET_PATTERNS) {
531
+ if (pattern.test(content)) leaks.push(label);
532
+ }
533
+
534
+ checks.push({
535
+ name: 'governance.md secret-free',
536
+ status: leaks.length === 0 ? 'pass' : 'fail',
537
+ detail: leaks.length === 0 ? null : `found: ${leaks.join(', ')}`,
538
+ fix: leaks.length === 0 ? null : 'remove the secret and rotate it immediately',
539
+ });
540
+ }
541
+
542
+ // Root-level .env files should not be tracked in git. This is the most
543
+ // common place for real secret leaks. Subdirectory .env files are typically
544
+ // build config (React CRA PUBLIC_URL, Vite VITE_*), monorepo package
545
+ // overrides, or test fixtures — too many legitimate uses to flag blindly.
546
+ // For those, rely on the secret-pattern scan below if they ever matter.
547
+ try {
548
+ const tracked = execSync('git ls-files', {
549
+ cwd, encoding: 'utf-8', timeout: 5000, stdio: ['pipe', 'pipe', 'pipe'],
550
+ });
551
+ const files = tracked.split('\n');
552
+
553
+ // Only flag root-level .env / .env.local / .env.production (not sample).
554
+ const risky = files.filter(f => /^\.env(\.local|\.production)?$/.test(f));
555
+
556
+ checks.push({
557
+ name: 'root .env files not tracked',
558
+ status: risky.length === 0 ? 'pass' : 'fail',
559
+ detail: risky.length === 0 ? null : `tracked: ${risky.join(', ')}`,
560
+ fix: risky.length === 0 ? null : `git rm --cached ${risky[0]} && add to .gitignore, then rotate any secrets inside`,
561
+ });
562
+ } catch { /* skip — not a git repo */ }
563
+
564
+ return { title: 'Security', checks };
565
+ }
566
+
567
+ // ============================================================================
568
+ // Section: Environment
569
+ // ============================================================================
570
+
571
+ function diagnoseEnvironment(cwd) {
572
+ const checks = [];
573
+
574
+ // Node version
575
+ const nodeVersion = process.version;
576
+ checks.push({
577
+ name: 'node version',
578
+ status: 'pass',
579
+ detail: nodeVersion,
580
+ fix: null,
581
+ });
582
+
583
+ // crag's own engines requirement
584
+ try {
585
+ const cragPkg = JSON.parse(fs.readFileSync(path.join(__dirname, '..', '..', 'package.json'), 'utf-8'));
586
+ const required = cragPkg.engines && cragPkg.engines.node;
587
+ if (required) {
588
+ const minMatch = required.match(/>=\s*(\d+)/);
589
+ if (minMatch) {
590
+ const minMajor = parseInt(minMatch[1], 10);
591
+ const currentMajor = parseInt(nodeVersion.replace(/^v/, '').split('.')[0], 10);
592
+ checks.push({
593
+ name: `node >= ${minMajor}`,
594
+ status: currentMajor >= minMajor ? 'pass' : 'fail',
595
+ detail: currentMajor >= minMajor ? null : `installed v${currentMajor}, need >= ${minMajor}`,
596
+ fix: currentMajor >= minMajor ? null : `upgrade Node.js to version ${minMajor} or later`,
597
+ });
598
+ }
599
+ }
600
+ } catch { /* skip */ }
601
+
602
+ // Git presence
603
+ try {
604
+ const gitVersion = execSync('git --version', { encoding: 'utf-8', timeout: 2000, stdio: ['pipe', 'pipe', 'pipe'] }).trim();
605
+ checks.push({ name: 'git available', status: 'pass', detail: gitVersion, fix: null });
606
+ } catch {
607
+ checks.push({ name: 'git available', status: 'fail', detail: 'git not on PATH', fix: 'install git' });
608
+ }
609
+
610
+ return { title: 'Environment', checks };
611
+ }
612
+
613
+ // ============================================================================
614
+ // Output
615
+ // ============================================================================
616
+
617
+ function printReport(report, { ciMode = false, strict = false } = {}) {
618
+ const modeLabel = ciMode ? ' (--ci mode)' : '';
619
+ console.log(`\n ${BOLD}crag doctor${RESET}${modeLabel} — ${report.cwd}\n`);
620
+
621
+ for (const section of report.sections) {
622
+ console.log(` ${BOLD}${section.title}${RESET}`);
623
+ for (const check of section.checks) {
624
+ const icon = check.status === 'pass' ? ICON_PASS
625
+ : check.status === 'warn' ? ICON_WARN
626
+ : ICON_FAIL;
627
+ console.log(` ${icon} ${check.name}${check.detail ? ` ${DIM}— ${check.detail}${RESET}` : ''}`);
628
+ if (check.fix && check.status !== 'pass') {
629
+ console.log(` ${DIM}fix:${RESET} ${check.fix}`);
630
+ }
631
+ }
632
+ console.log();
633
+ }
634
+
635
+ // Summary
636
+ const total = report.pass + report.warn + report.fail;
637
+ const summary = ` ${report.pass}/${total} pass, ${report.warn} warn, ${report.fail} fail`;
638
+ if (report.fail > 0) {
639
+ console.log(`${RED}${BOLD}${summary}${RESET}\n`);
640
+ } else if (report.warn > 0) {
641
+ console.log(`${YELLOW}${BOLD}${summary}${RESET}\n`);
642
+ } else {
643
+ console.log(`${GREEN}${BOLD}${summary}${RESET}\n`);
644
+ }
645
+ }
646
+
647
+ module.exports = { doctor, runDiagnostics, countFeatureBranches, detectBranchStrategy };
@@ -107,8 +107,40 @@ function parseGovernance(content) {
107
107
  if (gatesBody) {
108
108
  let section = 'default';
109
109
  let sectionMeta = { path: null, condition: null };
110
+ // Fenced code blocks (```bash / ```sh / ```shell) are treated as an
111
+ // alternative command carrier: every non-blank, non-comment line inside
112
+ // is extracted as a MANDATORY command for the current section. This
113
+ // matches the very common markdown pattern of documenting gate commands
114
+ // in a bash fence instead of a bullet list.
115
+ let inCodeBlock = false;
110
116
 
111
117
  for (const line of gatesBody.split('\n')) {
118
+ // Fence toggle. Accepts ``` , ```bash , ```sh , ```shell (and any other
119
+ // language tag) — we treat all fenced blocks inside ## Gates as gate
120
+ // command carriers. A non-shell language tag would be unusual here.
121
+ if (/^\s*```/.test(line)) {
122
+ inCodeBlock = !inCodeBlock;
123
+ continue;
124
+ }
125
+
126
+ if (inCodeBlock) {
127
+ const cmd = line.trim();
128
+ // Skip blanks and pure comments. Do NOT strip inline comments from
129
+ // the tail of a command — shell parsers treat `#` mid-line as a
130
+ // comment only when preceded by whitespace, and losing the rest of
131
+ // the line could silently drop logic like `echo "# header"`.
132
+ if (!cmd || cmd.startsWith('#')) continue;
133
+ if (!result.gates[section]) {
134
+ result.gates[section] = {
135
+ commands: [],
136
+ path: sectionMeta.path,
137
+ condition: sectionMeta.condition,
138
+ };
139
+ }
140
+ result.gates[section].commands.push({ cmd, classification: 'MANDATORY' });
141
+ continue;
142
+ }
143
+
112
144
  // Match ### Section or ### Section (path: dir/) or ### Section (if: file)
113
145
  const sub = line.match(/^### (.+?)(?:\s*\((?:(path|if):\s*(.+?))\))?\s*$/);
114
146
  if (sub) {