@whitehatd/crag 0.2.6 → 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 +59 -22
- package/package.json +1 -1
- package/src/analyze/ci-extractors.js +95 -4
- package/src/analyze/stacks.js +111 -1
- package/src/cli.js +3 -0
- package/src/commands/analyze.js +26 -0
- package/src/commands/doctor.js +586 -0
package/README.md
CHANGED
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
[](./LICENSE)
|
|
7
7
|
[](https://nodejs.org)
|
|
8
8
|
[](./package.json)
|
|
9
|
-
[](./test)
|
|
10
|
+
[](./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 [
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
|
53
|
-
|
|
|
54
|
-
|
|
|
55
|
-
|
|
|
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
|
-
|
|
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
|
-
|
|
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:**
|
|
334
|
-
- **Benchmark:**
|
|
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:**
|
|
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
|
-
- **
|
|
342
|
-
-
|
|
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
|
@@ -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 —
|
|
120
|
-
|
|
121
|
-
|
|
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 };
|
package/src/analyze/stacks.js
CHANGED
|
@@ -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':
|
package/src/commands/analyze.js
CHANGED
|
@@ -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 };
|