@holmes-lab/holmes-kit 0.1.9 → 0.1.11

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.
Files changed (53) hide show
  1. package/CHANGELOG.md +118 -0
  2. package/README.md +48 -4
  3. package/dist/.build-id +1 -1
  4. package/dist/holmes/cli/doctor.d.ts +39 -0
  5. package/dist/holmes/cli/doctor.js +79 -0
  6. package/dist/holmes/cli/test-platform.d.ts +25 -0
  7. package/dist/holmes/cli/test-platform.js +38 -0
  8. package/dist/holmes/cpg/cpg-scanner.d.ts +41 -0
  9. package/dist/holmes/cpg/cpg-scanner.js +53 -1
  10. package/dist/holmes/cpg/forbidden-edges.d.ts +73 -0
  11. package/dist/holmes/cpg/forbidden-edges.js +140 -0
  12. package/dist/holmes/cpg/hash-cache.js +13 -5
  13. package/dist/holmes/cpg/language-parser-walk.js +70 -4
  14. package/dist/holmes/cpg/proposed-content.d.ts +51 -0
  15. package/dist/holmes/cpg/proposed-content.js +72 -0
  16. package/dist/holmes/cpg/required-calls.d.ts +62 -0
  17. package/dist/holmes/cpg/required-calls.js +93 -0
  18. package/dist/holmes/guardrail/cspec-change.d.ts +23 -0
  19. package/dist/holmes/guardrail/cspec-change.js +70 -0
  20. package/dist/holmes/guardrail/risk-classifier.js +122 -0
  21. package/dist/holmes/guardrail/write-target.d.ts +42 -0
  22. package/dist/holmes/guardrail/write-target.js +69 -18
  23. package/dist/holmes/hooks/pre-tool-use.js +90 -5
  24. package/dist/holmes/hooks/stop.d.ts +17 -0
  25. package/dist/holmes/hooks/stop.js +39 -2
  26. package/dist/holmes/mcp/handlers.d.ts +41 -0
  27. package/dist/holmes/mcp/handlers.js +173 -3
  28. package/dist/holmes/mcp/tool-schemas.js +12 -0
  29. package/dist/holmes/project/dependencies.d.ts +15 -0
  30. package/dist/holmes/project/dependencies.js +58 -0
  31. package/dist/holmes/project/json-state.d.ts +24 -0
  32. package/dist/holmes/project/json-state.js +30 -0
  33. package/dist/holmes/reverse/scan.js +8 -1
  34. package/dist/holmes/review/scope.d.ts +29 -0
  35. package/dist/holmes/review/scope.js +44 -0
  36. package/dist/holmes/rtm/test-scope.d.ts +44 -0
  37. package/dist/holmes/rtm/test-scope.js +92 -2
  38. package/dist/holmes/server/dashboard.d.ts +22 -0
  39. package/dist/holmes/server/dashboard.js +114 -24
  40. package/dist/holmes/spec/approval-blockers.d.ts +21 -5
  41. package/dist/holmes/spec/approval-blockers.js +49 -6
  42. package/dist/holmes/spec/legacy-format.d.ts +14 -0
  43. package/dist/holmes/spec/legacy-format.js +15 -1
  44. package/dist/holmes/spec/nonfunctional.d.ts +70 -0
  45. package/dist/holmes/spec/nonfunctional.js +119 -0
  46. package/dist/holmes/spec/spec-parser.d.ts +25 -0
  47. package/dist/holmes/spec/spec-parser.js +46 -2
  48. package/dist/holmes/spec/spec-types.d.ts +4 -1
  49. package/dist/holmes/spec/spec-types.js +13 -1
  50. package/dist/holmes/testing/effects.d.ts +54 -0
  51. package/dist/holmes/testing/effects.js +107 -0
  52. package/package.json +3 -2
  53. package/playbooks/promote-slice/PLAYBOOK.md +20 -0
package/CHANGELOG.md CHANGED
@@ -5,6 +5,124 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
  <!-- @implements A-SPEC-209 -->
8
+ ## [0.1.11] - 2026-08-23
9
+
10
+ ### Added
11
+ - **`doctor` diagnoses the global npm prefix (REQ-243)**: after a Windows adopter hit `EPERM` on
12
+ `npm install -g` twice — once elevated — `doctor` now reads the npm global prefix, computes where
13
+ a global install would land (win32: `<prefix>\\node_modules`; POSIX: `<prefix>/lib/node_modules`),
14
+ and checks writability on the deepest existing ancestor, which is what decides the real mkdir's
15
+ fate. An unwritable prefix is a **WARN** naming the exact directory the install would die in, with
16
+ a user-space prefix prescription — never elevation, which npm's own error text suggests and which
17
+ would run better-sqlite3's install script with system privileges. An unreadable probe is a WARN
18
+ carrying the reason, never a silent pass. The check cannot FAIL: a red doctor would tell a user
19
+ with a healthy local install that the install is broken.
20
+
21
+ ### Unchanged
22
+ - The `-g` EPERM itself is not fixable by any package: it occurs while npm creates the scope
23
+ directory, before a single package file is transferred. The supported regular-account path remains
24
+ the local install (`npm install --save-dev`), which touches no protected directory.
25
+
26
+ <!-- @implements A-SPEC-209 -->
27
+ ## [0.1.10] - 2026-08-23
28
+
29
+ Forty-one commits since 0.1.9. Two of the defects below were found only by installing the packed
30
+ tarball into projects outside this repository — the in-repo suite was green over both the whole time.
31
+
32
+ ### Added
33
+ - **C-SPEC structural constraints are enforced at write time (REQ-224…231)**: `## Forbidden Edges`
34
+ and `## Layer Rules` are parsed and judged against the code property graph, so a banned import or
35
+ an unmet call obligation is refused as it is written rather than found later. A `draft` C-SPEC
36
+ enforces nothing — authority, not spelling, decides. The refusal names the rule, the file and the
37
+ edge, and states what the check does NOT see: control flow, aliased calls, computed specifiers.
38
+ - **Non-Functional obligations are machine-readable (REQ-234/235)**: `- [tag] text` in an H-SPEC's
39
+ `## Non-Functional` declares an obligation; a T-SPEC case opening `- [tag]` answers it. An
40
+ unanswered obligation widens the regression tier. Opt-in: prose obligations are not judged, and the
41
+ tag vocabulary is not a fixed list.
42
+ - **Risk reaches the regression tier (REQ-233)**: a change touching a C-SPEC or an ADR runs full
43
+ regression. Previously the architecture axis of `assessRisk` returned `hard-hitl` while the same
44
+ change received the narrowest possible test run.
45
+ - **`spec_retire` (REQ-208…212)**: the only path to a status nothing could previously reach. The
46
+ 2026-04 harness-era generation and the 23 JOB work orders are gone from the store.
47
+ - **Windows Native promoted to Tier 1 (REQ-220)**: path verdicts are read from syntax rather than
48
+ from `process.platform`, so a drive-letter or UNC path is judged the same way everywhere. See
49
+ *Unverified* below.
50
+ - **Release provenance gate (REQ-242)**: `npm publish` now refuses a dirty tree, a red suite, or a
51
+ `dist/` that does not name HEAD, and says which file or value is at fault.
52
+ - **Runtime dependency set is pinned (REQ-236)**: twenty-seven H-SPECs asserted "no new dependency"
53
+ and nothing checked it. All four declaration surfaces are judged, not just `dependencies`.
54
+
55
+ ### Changed
56
+ Behaviour an existing adopter will notice:
57
+ - **Approval refuses placeholder prose.** A section that is nothing but a `TODO` prompt now blocks
58
+ approval. Already-approved specs are unaffected — the rule runs at approval time only.
59
+ - **A corrupt guard-state file now blocks instead of passing silently.** If `.ax/ledger/stop-guard.json`
60
+ is damaged, the Stop gate evaluates as normal rather than yielding.
61
+ - **The regression tier widens** for changes touching architecture documents and for unmet
62
+ obligations. Expect longer runs on those changes, and a reason that names the cause.
63
+ - **`reverse_scan` reports fewer tests.** Dependencies' own test files are no longer discovered or
64
+ attributed; on a real 235-file Python codebase this removed 254 of 340.
65
+ - **Install guidance leads with a local install.** `npm install -g` on Windows commonly lands in
66
+ `C:\Program Files\nodejs` and fails with `EPERM`; the README now documents a user-space prefix
67
+ instead of elevation, which would run native install scripts with system privileges.
68
+
69
+ ### Fixed
70
+ - **A corrupt state file switched the governance gate off (REQ-237).** Four characters — `null` — in
71
+ a gitignored file made the Stop hook produce no output and exit 0 on a project with eight article
72
+ violations. `try { JSON.parse } catch` sees a parse failure, not a parse that succeeds into the
73
+ wrong shape.
74
+ - **A document that said nothing could be sealed as normative (REQ-240).** An H-SPEC whose every
75
+ prose section read `TODO` received `status: approved` and a digest. The existing check keyed on an
76
+ HTML-comment marker that human editing strips.
77
+ - **A dependency's tests counted as the project's coverage (REQ-241).** Source candidates excluded
78
+ vendor directories; test discovery did not, so three third-party tests were attributed to project
79
+ clusters.
80
+ - **A trailing space switched No-Spec-No-Code off.** `"src/a.ts "` did not match the extension test,
81
+ and an unrecognised action passed.
82
+ - **Shell seal bypass closed.** `sed -i`, `perl -i` and `ex` edited governance files without
83
+ tripping the write gate.
84
+ - **Four ways past a Forbidden Edges ban closed**: dynamic `import()`, `require()`, re-export, and
85
+ `export *`.
86
+ - **The scan walk is linear.** A 2KB deeply-nested file cost 10,558ms on the write path; depth, not
87
+ size, was the driver.
88
+ - **Eleven performance budgets were boilerplate.** Four were measured and all four were false —
89
+ `Standalone execution within <100ms` appeared verbatim on three unrelated subsystems. Numbers now
90
+ cite a measurement or say none was taken.
91
+ - **A timed-out test left the repository broken for every later run**: a jest timeout does not unwind
92
+ an async function, so `dist/.build-id` stayed at the value the test writes.
93
+
94
+ ### Unverified
95
+ - **Windows has never been executed.** Tier 1 removed the *unverifiability* of platform verdicts; it
96
+ did not produce verification. Seventeen assertions are still skipped as POSIX-only and
97
+ `package.json` declares no `os`.
98
+ - **There is no CI.** The suite has never run outside one macOS machine.
99
+ - Transitive dependency growth, control flow, aliased calls and computed module specifiers are
100
+ outside what the constraint engine can see — each stated in the refusal that would otherwise read
101
+ as safety.
102
+
103
+ <!-- @implements A-SPEC-209 -->
104
+ ## [0.1.9] - 2026-08-21
105
+
106
+ ### Added
107
+ - **Quantitative REQ RTM Matrix Grid (REQ-219)**: per-REQ 6-stage counts (H/A/T-SPEC, code files, AST symbols) with an accordion drill-down, replacing the single coverage bar.
108
+ - **REQ-Grouped 6-Stage Pipeline Heatmap (REQ-219)**: trace chains grouped under REQ master blocks with a multi-lens quick filter bar, ending the row repetition of the flat table.
109
+ - **Rich node/edge hover inspector (REQ-219)**: node type, provenance digest, in/out degree and file location on hover across all four graph layouts.
110
+ - **Unmapped Specs tab**: the 155 documents whose `status` falls outside `draft|review|approved|outdated` are now visible and grouped by kind, instead of being invisible while sitting in the headline denominator.
111
+
112
+ ### Changed
113
+ - **Coverage is reported on two axes, not one number**: approval (signed off) and implementation (code anchored) are separate percentages, and specs with a legacy status vocabulary are excluded from both rather than silently scored zero. The fused `coveragePct` field is gone.
114
+ - **Release metadata check is self-consistent**: `packaging.test.ts` asserts that `package.json` version is semver AND has a matching CHANGELOG section, instead of pinning a literal version that went stale on every release.
115
+
116
+ ### Fixed
117
+ - **Fabricated trace chains removed**: rows for REQs with no implementing file were back-filled with `dashboard.ts#startDashboardServer`, producing 64 of 787 traces that named code they do not own. Missing stages now report `null` and are listed in `missingStages`.
118
+ - **Synthesized spec ids removed**: absent H/A/T-SPECs were invented as `H-SPEC-<n>` and rendered indistinguishably from real ones.
119
+ - **Coverage is measured, not asserted**: `status: COVERED` was hardcoded for every matched file; a chain is COVERED only when all six stages resolve.
120
+ - **Mutation score no longer reported as a constant**: `mutationScore: 100` is replaced by `null` with `mutationScoreMeasured: false` — the endpoint generates mutants statically and has no basis for a score.
121
+ - **Findings attribution is exact**: `specRef.includes(numPart)` let `REQ-021` claim a finding filed against `A-SPEC-0219`.
122
+ - **An unscanned audit is distinguished from a clean one**: with no `.ax/ledger/findings.jsonl`, Lens 2 rendered "0 open findings" over every chain. Rows now carry `findingsScanned`.
123
+ - **Silent truncation removed**: `slice(0, 3)` dropped AST symbols in both the API and the drill-down while the badge showed the full count.
124
+ - **Graph truncation is disclosed**: the canvas draws a bounded subgraph (30 of 3544 nodes by default) and now says so with numbers.
125
+
8
126
  ## [0.1.8] - 2026-08-20
9
127
 
10
128
  ### Added
package/README.md CHANGED
@@ -51,11 +51,53 @@ Holmes-Kit prioritizes **CLI-based AI Coding Agents** where OS-level process hoo
51
51
 
52
52
  ## ⚡ Quickstart (3-Minute Setup)
53
53
 
54
- ### 1. Install CLI
54
+ ### 1. Install
55
+
56
+ Install it into the project you want to govern. This is the path verified end to end — the
57
+ wiring `init` writes uses absolute paths, so a local install works from any directory:
58
+
59
+ ```bash
60
+ npm install --save-dev @holmes-lab/holmes-kit
61
+ ```
62
+
63
+ <details>
64
+ <summary>Prefer one install for many projects? (<code>npm install -g</code>)</summary>
65
+
66
+ A global install works too, but **do not elevate to get it**. Where npm puts global packages is
67
+ your npm configuration, not ours — and on Windows it often points inside the Node.js installation
68
+ directory:
69
+
70
+ ```text
71
+ npm error code EPERM
72
+ npm error path C:\Program Files\nodejs\node_modules\@holmes-lab
73
+ ```
74
+
75
+ Running the install as Administrator would "fix" that by executing this package's native
76
+ dependency install scripts with system privileges. Point npm at a user-writable prefix instead:
77
+
78
+ ```powershell
79
+ npm config set prefix "$env:APPDATA\npm" # Windows
80
+ # then ensure %APPDATA%\npm is on PATH
81
+ ```
55
82
  ```bash
56
- npm install -g @holmes-lab/holmes-kit
83
+ npm config set prefix "$HOME/.npm-global" # macOS / Linux
84
+ export PATH="$HOME/.npm-global/bin:$PATH"
57
85
  ```
58
- *(Prerequisites: Node.js `>= 20.0.0`. Prebuilt binaries are provided automatically for macOS, Linux, and Windows. For minimal Alpine Docker containers, install build tools: `apk add --no-cache python3 make g++`)*
86
+
87
+ A Node version manager (`nvm`, `fnm`, `volta`) does the same thing by keeping the whole
88
+ toolchain under your home directory.
89
+ </details>
90
+
91
+ **Prerequisites** — Node.js `>= 20.0.0`, plus a native binary for two kinds of dependency:
92
+
93
+ | Dependency | How the binary arrives |
94
+ |---|---|
95
+ | 8 tree-sitter grammars | Prebuilt binaries ship inside the packages (`darwin-arm64`, `darwin-x64`, `linux-x64`, `win32-x64`) — nothing is compiled |
96
+ | `better-sqlite3` | Downloaded at install time by `prebuild-install`, **falling back to compiling** if no prebuild matches your platform and Node ABI |
97
+
98
+ Only the second can require a toolchain. If it has to compile, you need a C++ build environment:
99
+ Xcode Command Line Tools on macOS, Visual Studio Build Tools on Windows, or
100
+ `apk add --no-cache python3 make g++` on Alpine.
59
101
 
60
102
  ### 2. Initialize in Your Project
61
103
  ```bash
@@ -152,9 +194,11 @@ Holmes-Kit embeds native AST & Code Property Graph (D-CPG) analyzers to track ca
152
194
  | **macOS** | Apple Silicon (arm64) / Intel (x64) | ✅ Tier 1 | macOS 12+ (Full hook enforcement) |
153
195
  | **Linux** | x86_64 / arm64 | ✅ Tier 1 | Ubuntu, Debian, Fedora, Arch, RHEL |
154
196
  | **Windows (WSL2)** | x86_64 | ✅ Tier 1 | WSL2 Ubuntu/Debian recommended |
155
- | **Windows Native** | x86_64 | ✅ Tier 2 | Windows 10/11 (Node.js 20+ with C++ build tools) |
197
+ | **Windows Native** | x86_64 | ✅ Tier 1 | Windows 10/11 (Node.js 20+ with C++ build tools). Gate parity verified from CI — see [ADR-015](.ax/decisions/ADR-015_platform-tier-is-decided-by-executed-verification.md) for the graduation criteria and the residual risks (NTFS 8.3 names, reserved device names, 260-char paths) |
156
198
 
157
199
  > **Runtime Requirement**: Node.js `>= 20.0.0` (LTS recommended)
200
+ >
201
+ > **How a tier is decided**: by the verification that actually executes, not by declaration. A platform is Tier 1 only while its gate verdicts are exercised by the suite; if that stops being true it is demoted and the demotion is recorded. See [ADR-015](.ax/decisions/ADR-015_platform-tier-is-decided-by-executed-verification.md).
158
202
 
159
203
  ---
160
204
 
package/dist/.build-id CHANGED
@@ -1 +1 @@
1
- ef6b2ad-mt1pqqxf
1
+ c3422e5-mt570gca
@@ -28,6 +28,45 @@ export interface DoctorOptions {
28
28
  * real key-related failure.
29
29
  */
30
30
  export declare const STRIPPED_FOR_PROBE: readonly ["HOLMES_APPROVAL", "HOLMES_SPECS"];
31
+ /**
32
+ * Where a global install actually lands for a given prefix.
33
+ *
34
+ * @implements A-SPEC-243
35
+ * The shapes differ, not just the separators: win32 puts global modules directly under the prefix,
36
+ * POSIX puts them under `lib/`. The win32 result is the exact directory the reported EPERM died in
37
+ * — npm mkdir-ing `@holmes-lab` inside `C:\Program Files\nodejs\node_modules`, before a single
38
+ * package file was transferred. That is why no package content can fix that failure, and why the
39
+ * only lever left is naming the condition here.
40
+ */
41
+ export declare function globalInstallDir(prefix: string, platform: string): string;
42
+ /** What the global-prefix probe saw. `unreadable` carries WHY npm could not be consulted. */
43
+ export interface PrefixProbe {
44
+ prefix?: unknown;
45
+ dir?: string;
46
+ writable?: boolean;
47
+ platform?: string;
48
+ unreadable?: string;
49
+ }
50
+ /**
51
+ * Judge the global prefix. Pure — the probing lives with the caller.
52
+ *
53
+ * @implements A-SPEC-243
54
+ * Never FAIL: the `-g` path is optional, and a red doctor tells a user with a healthy local
55
+ * install that the install is broken — a false signal that comes back as distrust of the check.
56
+ *
57
+ * Never elevation: npm's own EPERM text ends with "try running the command again as
58
+ * root/Administrator", and pointing the opposite way is this check's reason to exist — elevation
59
+ * runs better-sqlite3's install script (`prebuild-install || node-gyp rebuild`) with system
60
+ * privileges. The prescription is a user-space prefix, per platform.
61
+ *
62
+ * An unreadable probe is a WARN carrying the reason, not a silent pass: a silent pass reads as
63
+ * "checked, no problem", which translates what was not seen into a verdict.
64
+ */
65
+ export declare function prefixVerdict(input: PrefixProbe): {
66
+ level: Level;
67
+ detail: string;
68
+ fix?: string;
69
+ };
31
70
  /** The parent environment minus the variables that legitimately change a gate decision. Pure. */
32
71
  export declare function probeEnv(parent: NodeJS.ProcessEnv): NodeJS.ProcessEnv;
33
72
  /** Seam for the gate probe's child process. Default is a real `spawnSync`; tests inject a fake. */
@@ -34,6 +34,8 @@ var __importStar = (this && this.__importStar) || (function () {
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.STRIPPED_FOR_PROBE = void 0;
37
+ exports.globalInstallDir = globalInstallDir;
38
+ exports.prefixVerdict = prefixVerdict;
37
39
  exports.probeEnv = probeEnv;
38
40
  exports.runDoctor = runDoctor;
39
41
  exports.formatChecks = formatChecks;
@@ -63,6 +65,56 @@ const GRAMMARS = [
63
65
  * real key-related failure.
64
66
  */
65
67
  exports.STRIPPED_FOR_PROBE = ['HOLMES_APPROVAL', 'HOLMES_SPECS'];
68
+ /**
69
+ * Where a global install actually lands for a given prefix.
70
+ *
71
+ * @implements A-SPEC-243
72
+ * The shapes differ, not just the separators: win32 puts global modules directly under the prefix,
73
+ * POSIX puts them under `lib/`. The win32 result is the exact directory the reported EPERM died in
74
+ * — npm mkdir-ing `@holmes-lab` inside `C:\Program Files\nodejs\node_modules`, before a single
75
+ * package file was transferred. That is why no package content can fix that failure, and why the
76
+ * only lever left is naming the condition here.
77
+ */
78
+ function globalInstallDir(prefix, platform) {
79
+ return platform === 'win32'
80
+ ? `${prefix.replace(/[\\/]+$/, '')}\\node_modules`
81
+ : `${prefix.replace(/\/+$/, '')}/lib/node_modules`;
82
+ }
83
+ /**
84
+ * Judge the global prefix. Pure — the probing lives with the caller.
85
+ *
86
+ * @implements A-SPEC-243
87
+ * Never FAIL: the `-g` path is optional, and a red doctor tells a user with a healthy local
88
+ * install that the install is broken — a false signal that comes back as distrust of the check.
89
+ *
90
+ * Never elevation: npm's own EPERM text ends with "try running the command again as
91
+ * root/Administrator", and pointing the opposite way is this check's reason to exist — elevation
92
+ * runs better-sqlite3's install script (`prebuild-install || node-gyp rebuild`) with system
93
+ * privileges. The prescription is a user-space prefix, per platform.
94
+ *
95
+ * An unreadable probe is a WARN carrying the reason, not a silent pass: a silent pass reads as
96
+ * "checked, no problem", which translates what was not seen into a verdict.
97
+ */
98
+ function prefixVerdict(input) {
99
+ const reason = input.unreadable ?? (typeof input.prefix !== 'string' || input.prefix === ''
100
+ ? `npm 이 돌려준 prefix 를 해석할 수 없습니다 (${String(input.prefix)})`
101
+ : undefined);
102
+ if (reason !== undefined) {
103
+ return { level: 'WARN', detail: `전역 prefix 를 판독하지 못했습니다 — ${reason}. 로컬 설치(--save-dev)는 이 판정과 무관하게 동작합니다.` };
104
+ }
105
+ const prefix = input.prefix;
106
+ if (input.writable) {
107
+ return { level: 'PASS', detail: `전역 prefix(${prefix})가 현재 계정으로 쓰기 가능합니다 — npm install -g 가 권한 문제 없이 동작합니다.` };
108
+ }
109
+ const fix = input.platform === 'win32'
110
+ ? 'npm config set prefix "%APPDATA%\\npm" 실행 후 %APPDATA%\\npm 을 PATH 에 추가하고 터미널을 다시 여세요. 로컬 설치(npm install --save-dev)는 이 설정 없이도 동작합니다.'
111
+ : 'npm config set prefix "$HOME/.npm-global" 실행 후 $HOME/.npm-global/bin 을 PATH 에 추가하세요. 로컬 설치(npm install --save-dev)는 이 설정 없이도 동작합니다.';
112
+ return {
113
+ level: 'WARN',
114
+ detail: `전역 prefix(${prefix})가 현재 계정으로 쓰기 불가입니다 — npm install -g 는 ${input.dir ?? ''} 에 디렉터리를 만들다 EPERM 으로 죽습니다. 패키지가 도착하기 전 단계라 어떤 패키지도 이를 고칠 수 없습니다.`,
115
+ fix,
116
+ };
117
+ }
66
118
  /** The parent environment minus the variables that legitimately change a gate decision. Pure. */
67
119
  function probeEnv(parent) {
68
120
  const out = { ...parent };
@@ -451,6 +503,33 @@ async function runDoctor(packageRoot, target, opts, extraChecks) {
451
503
  catch { /* an unreadable settings file is `target wiring`'s business, not this check's */ }
452
504
  }
453
505
  add('gate blind spots', 'PASS', (0, blind_spots_1.blindSpotSummary)());
506
+ // @implements A-SPEC-243 — the writability of the deepest EXISTING ancestor is what decides the
507
+ // real mkdir's fate, since the scope directory being created does not exist yet.
508
+ {
509
+ let verdict;
510
+ try {
511
+ const prefix = (0, node_child_process_1.execFileSync)('npm', ['config', 'get', 'prefix'], { encoding: 'utf8' }).trim();
512
+ const dir = globalInstallDir(prefix, process.platform);
513
+ let probe = dir;
514
+ while (!fs.existsSync(probe)) {
515
+ const parent = path.dirname(probe);
516
+ if (parent === probe)
517
+ break;
518
+ probe = parent;
519
+ }
520
+ let writable = false;
521
+ try {
522
+ fs.accessSync(probe, fs.constants.W_OK);
523
+ writable = true;
524
+ }
525
+ catch { /* not writable */ }
526
+ verdict = prefixVerdict({ prefix, dir, writable, platform: process.platform });
527
+ }
528
+ catch (e) {
529
+ verdict = prefixVerdict({ unreadable: e instanceof Error ? e.message.split('\n')[0] : String(e) });
530
+ }
531
+ add('global prefix', verdict.level, verdict.detail, verdict.fix);
532
+ }
454
533
  add('environment', 'PASS', present);
455
534
  if (extraChecks) {
456
535
  checks.push(...extraChecks);
@@ -0,0 +1,25 @@
1
+ /**
2
+ * A test that cannot run here says so, instead of returning early and being counted as a pass.
3
+ *
4
+ * @implements A-SPEC-220.3
5
+ * `if (process.platform === 'win32') return;` inside a test body is an unexecution that reports as
6
+ * green. Five of them in `playbook-skills.test.ts` are why the Windows platform tier was, in
7
+ * substance, "no evidence" rather than "no feature" (ADR-015). Choosing `it.skip` moves the fact
8
+ * into the report where a reader can see it.
9
+ *
10
+ * `reason` is required and must be non-empty: a skip without a stated reason is the same silence in
11
+ * different syntax. There is no minimum length, because a length rule is a promise this cannot keep.
12
+ *
13
+ * An unrecognised platform gets `it`, not `it.skip` — leaning toward running. An assertion that
14
+ * cannot hold there surfaces as a failure, which is information; skipping it is not.
15
+ */
16
+ export declare function posixOnly(reason: string, platform?: string): typeof it | typeof it.skip;
17
+ /**
18
+ * As `posixOnly`, and additionally skipped when the process is root.
19
+ *
20
+ * @implements A-SPEC-220.3
21
+ * `chmod`-based assertions are vacuous for root, which bypasses the permission bits being tested.
22
+ * That is a property of the RUNNING process, knowable at definition time just like the platform, so
23
+ * it belongs in the same choice rather than in a second early return inside the body.
24
+ */
25
+ export declare function posixOnlyNonRoot(reason: string, platform?: string, uid?: number | undefined): typeof it | typeof it.skip;
@@ -0,0 +1,38 @@
1
+ "use strict";
2
+ // @implements A-SPEC-220.3
3
+ Object.defineProperty(exports, "__esModule", { value: true });
4
+ exports.posixOnly = posixOnly;
5
+ exports.posixOnlyNonRoot = posixOnlyNonRoot;
6
+ /**
7
+ * A test that cannot run here says so, instead of returning early and being counted as a pass.
8
+ *
9
+ * @implements A-SPEC-220.3
10
+ * `if (process.platform === 'win32') return;` inside a test body is an unexecution that reports as
11
+ * green. Five of them in `playbook-skills.test.ts` are why the Windows platform tier was, in
12
+ * substance, "no evidence" rather than "no feature" (ADR-015). Choosing `it.skip` moves the fact
13
+ * into the report where a reader can see it.
14
+ *
15
+ * `reason` is required and must be non-empty: a skip without a stated reason is the same silence in
16
+ * different syntax. There is no minimum length, because a length rule is a promise this cannot keep.
17
+ *
18
+ * An unrecognised platform gets `it`, not `it.skip` — leaning toward running. An assertion that
19
+ * cannot hold there surfaces as a failure, which is information; skipping it is not.
20
+ */
21
+ function posixOnly(reason, platform = process.platform) {
22
+ if (typeof reason !== 'string' || reason.trim().length === 0) {
23
+ throw new Error('posixOnly(reason): 사유 없는 스킵은 문법만 바뀐 침묵입니다 — 왜 이 단언이 Windows에서 무의미한지 적으십시오.');
24
+ }
25
+ return platform === 'win32' ? it.skip : it;
26
+ }
27
+ /**
28
+ * As `posixOnly`, and additionally skipped when the process is root.
29
+ *
30
+ * @implements A-SPEC-220.3
31
+ * `chmod`-based assertions are vacuous for root, which bypasses the permission bits being tested.
32
+ * That is a property of the RUNNING process, knowable at definition time just like the platform, so
33
+ * it belongs in the same choice rather than in a second early return inside the body.
34
+ */
35
+ function posixOnlyNonRoot(reason, platform = process.platform, uid = typeof process.getuid === 'function' ? process.getuid() : undefined) {
36
+ const base = posixOnly(reason, platform);
37
+ return uid === 0 ? it.skip : base;
38
+ }
@@ -49,6 +49,27 @@ export interface ScannedFile {
49
49
  * below catches the case that actually matters through evidence rather than by name.
50
50
  */
51
51
  export declare const SKIP: Set<string>;
52
+ /**
53
+ * Does this repo-relative path lie inside a directory that never holds the project's own source?
54
+ *
55
+ * @implements A-SPEC-241
56
+ * The walker above consults `SKIP` while descending, so a path it never produced is never judged.
57
+ * Anything that arrives from ELSEWHERE — `git ls-files`, a caller's list — has had no such filter
58
+ * applied, and that asymmetry shipped: measured 2026-08-23 on a real 235-file Python codebase,
59
+ * `reverse_scan`'s source candidates were correctly 149 while its TEST discovery returned 340 files
60
+ * of which 254 (75%) were dependencies' own tests under `node_modules`. Three of them were attributed
61
+ * to a project cluster, so a reverse-engineered T-SPEC would have cited a third-party package's test
62
+ * as its own coverage — exactly what `SKIP`'s comment above warns about.
63
+ *
64
+ * SEGMENTS, not prefixes or substrings. A prefix test claims `node_modules_backup/`; a substring
65
+ * test claims `src/my_vendor_code/`. The last segment is a FILE name and is not consulted: a file
66
+ * called `dist` is not a build directory.
67
+ *
68
+ * Reading `SKIP` rather than restating it is the point — one definition of what counts as vendor,
69
+ * whichever code asks. Two copies of `isTestFile` once let a Python file be excluded from the graph
70
+ * as a test AND gated as production at the same time.
71
+ */
72
+ export declare function isUnderSkippedDir(p: unknown): boolean;
52
73
  /** Single source of truth for which file extensions CpgScanner ingests (REQ-124 gate 2). */
53
74
  export declare const SCANNABLE_EXTENSIONS: readonly [".ts", ".mts", ".cts", ".tsx", ".jsx", ".js", ".mjs", ".cjs", ".py", ".cs", ".java", ".go", ".rs", ".cpp", ".cc", ".cxx", ".hpp", ".hh", ".h"];
54
75
  export { TEST_FILE_PATTERNS, isTestFile } from './test-files';
@@ -65,5 +86,25 @@ export declare class CpgScanner {
65
86
  file: string;
66
87
  reason: string;
67
88
  }[];
89
+ /**
90
+ * The production graph: every scannable file that is NOT a test.
91
+ *
92
+ * The exclusion is load-bearing, not incidental — change impact, RTM coverage, clustering and
93
+ * reverse-engineered drafts all read this return, and test symbols entering it corrupts all four.
94
+ * `scanTests` exists precisely so nothing has to widen this one.
95
+ */
68
96
  scan(rootDir: string, repoRoot?: string): ScannedFile[];
97
+ /**
98
+ * The test corpus, for structural-constraint checking only.
99
+ *
100
+ * @implements A-SPEC-229
101
+ * Measured 2026-08-22: `scan()` returned 326 files and none of them a test, while 166 existed on
102
+ * disk — so every C-SPEC rule was reading half the source. One of the three `filterGoverned`
103
+ * bypasses (`tspec-mirror.test.ts`) lived in exactly that half and no rule could see it.
104
+ *
105
+ * A SEPARATE entry point rather than an option on `scan()`: the two corpora must never merge by
106
+ * accident, and a caller that wants both should have to say so.
107
+ */
108
+ scanTests(rootDir: string, repoRoot?: string): ScannedFile[];
109
+ private walkScan;
69
110
  }
@@ -34,6 +34,7 @@ var __importStar = (this && this.__importStar) || (function () {
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.CpgScanner = exports.isTestFile = exports.TEST_FILE_PATTERNS = exports.SCANNABLE_EXTENSIONS = exports.SKIP = void 0;
37
+ exports.isUnderSkippedDir = isUnderSkippedDir;
37
38
  // @implements A-SPEC-208
38
39
  const fs = __importStar(require("node:fs"));
39
40
  const path = __importStar(require("node:path"));
@@ -64,6 +65,33 @@ exports.SKIP = new Set([
64
65
  // VCS + generic tool output
65
66
  '.git', '.hg', '.svn', 'coverage', '.idea', '.vscode',
66
67
  ]);
68
+ /**
69
+ * Does this repo-relative path lie inside a directory that never holds the project's own source?
70
+ *
71
+ * @implements A-SPEC-241
72
+ * The walker above consults `SKIP` while descending, so a path it never produced is never judged.
73
+ * Anything that arrives from ELSEWHERE — `git ls-files`, a caller's list — has had no such filter
74
+ * applied, and that asymmetry shipped: measured 2026-08-23 on a real 235-file Python codebase,
75
+ * `reverse_scan`'s source candidates were correctly 149 while its TEST discovery returned 340 files
76
+ * of which 254 (75%) were dependencies' own tests under `node_modules`. Three of them were attributed
77
+ * to a project cluster, so a reverse-engineered T-SPEC would have cited a third-party package's test
78
+ * as its own coverage — exactly what `SKIP`'s comment above warns about.
79
+ *
80
+ * SEGMENTS, not prefixes or substrings. A prefix test claims `node_modules_backup/`; a substring
81
+ * test claims `src/my_vendor_code/`. The last segment is a FILE name and is not consulted: a file
82
+ * called `dist` is not a build directory.
83
+ *
84
+ * Reading `SKIP` rather than restating it is the point — one definition of what counts as vendor,
85
+ * whichever code asks. Two copies of `isTestFile` once let a Python file be excluded from the graph
86
+ * as a test AND gated as production at the same time.
87
+ */
88
+ function isUnderSkippedDir(p) {
89
+ if (typeof p !== 'string' || p === '')
90
+ return false;
91
+ const segments = p.split('/');
92
+ segments.pop(); // the file name is not a directory
93
+ return segments.some((seg) => exports.SKIP.has(seg));
94
+ }
67
95
  /**
68
96
  * Evidence-based virtualenv detection. A Python virtualenv is defined by containing `pyvenv.cfg`,
69
97
  * whatever the user named the directory (`env`, `.env311`, `myproject-venv`, …), so name matching
@@ -126,7 +154,31 @@ class CpgScanner {
126
154
  scanSkipped() {
127
155
  return [...this.skipped].sort((a, b) => (a.file < b.file ? -1 : a.file > b.file ? 1 : 0));
128
156
  }
157
+ /**
158
+ * The production graph: every scannable file that is NOT a test.
159
+ *
160
+ * The exclusion is load-bearing, not incidental — change impact, RTM coverage, clustering and
161
+ * reverse-engineered drafts all read this return, and test symbols entering it corrupts all four.
162
+ * `scanTests` exists precisely so nothing has to widen this one.
163
+ */
129
164
  scan(rootDir, repoRoot = rootDir) {
165
+ return this.walkScan(rootDir, repoRoot, (rel) => !(0, test_files_2.isTestFile)(rel));
166
+ }
167
+ /**
168
+ * The test corpus, for structural-constraint checking only.
169
+ *
170
+ * @implements A-SPEC-229
171
+ * Measured 2026-08-22: `scan()` returned 326 files and none of them a test, while 166 existed on
172
+ * disk — so every C-SPEC rule was reading half the source. One of the three `filterGoverned`
173
+ * bypasses (`tspec-mirror.test.ts`) lived in exactly that half and no rule could see it.
174
+ *
175
+ * A SEPARATE entry point rather than an option on `scan()`: the two corpora must never merge by
176
+ * accident, and a caller that wants both should have to say so.
177
+ */
178
+ scanTests(rootDir, repoRoot = rootDir) {
179
+ return this.walkScan(rootDir, repoRoot, (rel) => (0, test_files_2.isTestFile)(rel));
180
+ }
181
+ walkScan(rootDir, repoRoot, include) {
130
182
  this.skipped = [];
131
183
  const out = [];
132
184
  const walk = (d) => {
@@ -146,7 +198,7 @@ class CpgScanner {
146
198
  continue;
147
199
  walk(p);
148
200
  }
149
- else if (SCANNABLE_RE.test(e.name) && !(0, test_files_2.isTestFile)(path.relative(rootDir, p).split(path.sep).join('/'))) {
201
+ else if (SCANNABLE_RE.test(e.name) && include(path.relative(rootDir, p).split(path.sep).join('/'))) {
150
202
  // Test detection needs the path, not the basename: a Rust integration test is `tests/x.rs`
151
203
  // and a JVM one is `src/test/java/X.java` — nothing in the filename says "test". Relative
152
204
  // to the SCAN ROOT, so a repository that merely lives under some `/…/test/` directory on
@@ -0,0 +1,73 @@
1
+ import { ScannedFile } from './cpg-scanner';
2
+ /**
3
+ * Which half of the source a rule judges.
4
+ *
5
+ * @implements A-SPEC-229
6
+ * The rule declares this, not the caller. If it were decided by which file list someone passed in,
7
+ * the same rule would mean different things depending on who invoked it — that is configuration,
8
+ * not a rule. The default is `production` so the ten rules already approved in C-SPEC-224 keep
9
+ * their meaning exactly.
10
+ */
11
+ export type RuleCorpus = 'production' | 'tests';
12
+ /** Split a leading `tests:` marker off a source prefix. */
13
+ export declare function splitCorpus(prefix: string): {
14
+ corpus: RuleCorpus;
15
+ sourcePrefix: string;
16
+ };
17
+ /**
18
+ * One prohibition, read from a C-SPEC's `## Forbidden Edges`.
19
+ *
20
+ * @implements A-SPEC-224
21
+ * The two kinds are not a taxonomy choice — they are the two things the D-CPG can actually see.
22
+ * Measured 2026-08-22 on this repository: 23,854 `calls` edges and 1,646 `imports` edges, and
23
+ * nothing else. Property access (`process.platform`) produces no edge at all, so no rule shape
24
+ * expresses it; offering one would recreate the "documented but unenforced" state this slice exists
25
+ * to remove.
26
+ */
27
+ export interface ForbiddenEdgeRule {
28
+ kind: 'import' | 'call';
29
+ /** Which corpus this rule judges. @implements A-SPEC-229 */
30
+ corpus: RuleCorpus;
31
+ /** Repo-relative prefix of the files the rule judges. */
32
+ sourcePrefix: string;
33
+ /** Module specifier (import) or callee name (call). */
34
+ target: string;
35
+ /** The rule's own text, so a violation can quote what it broke. */
36
+ text: string;
37
+ }
38
+ export interface ForbiddenEdgeViolation {
39
+ rule: string;
40
+ sourcePath: string;
41
+ /** The enclosing definition the edge leaves from — `<module>` for a top-level import. */
42
+ from: string;
43
+ to: string;
44
+ kind: 'import' | 'call';
45
+ }
46
+ /**
47
+ * Read the rules out of a `## Forbidden Edges` section.
48
+ *
49
+ * @implements A-SPEC-224
50
+ * A malformed list item is RETURNED, never dropped. A typo that silently parses to nothing reads as
51
+ * "the rule passed" — the same shape as a gate that concludes there is nothing to govern, which
52
+ * this repository has now paid for in A-SPEC-191 §17, §29 and again in A-SPEC-222.2.
53
+ *
54
+ * Prose and blank lines are neither: a section is allowed to explain itself, and treating every
55
+ * sentence as a broken rule would make the report useless.
56
+ */
57
+ export declare function parseForbiddenEdges(section: string): {
58
+ rules: ForbiddenEdgeRule[];
59
+ malformed: string[];
60
+ };
61
+ /**
62
+ * Judge a scan against a set of prohibitions.
63
+ *
64
+ * @implements A-SPEC-224
65
+ * Pure: no filesystem, no `process`. That is what lets the same function be exercised by synthetic
66
+ * fixtures AND by the real corpus — and the real corpus is the one that matters, because a rule
67
+ * checked only against fixtures stays green while the repository drifts.
68
+ *
69
+ * `import` matches by PREFIX so one rule covers a directory and survives an extension-less
70
+ * specifier. `call` matches EXACTLY, because its target is a bare callee name and a prefix there
71
+ * would make `list` catch `listFiles`.
72
+ */
73
+ export declare function findForbiddenEdgeViolations(files: ScannedFile[], rules: ForbiddenEdgeRule[]): ForbiddenEdgeViolation[];