session-orchestrator 3.20.0 → 3.22.0

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 (202) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.codex-plugin/plugin.json +1 -1
  4. package/.cursor/rules/000-session-orchestrator.mdc +3 -2
  5. package/.cursor/rules/030-wave-execution.mdc +10 -8
  6. package/.cursor/rules/040-discovery.mdc +6 -6
  7. package/.cursor/rules/050-plan.mdc +8 -8
  8. package/CHANGELOG.md +515 -0
  9. package/README.md +16 -11
  10. package/agents/analyst.md +1 -1
  11. package/agents/architect-reviewer.md +1 -1
  12. package/agents/code-implementer.md +4 -2
  13. package/agents/db-specialist.md +1 -1
  14. package/agents/dialectic-deriver.md +1 -1
  15. package/agents/docs-writer.md +1 -1
  16. package/agents/memory-proposal-collector.md +7 -5
  17. package/agents/qa-strategist.md +1 -1
  18. package/agents/security-reviewer.md +1 -1
  19. package/agents/session-reviewer.md +42 -1
  20. package/agents/skill-applied-judge.md +1 -1
  21. package/agents/test-writer.md +1 -1
  22. package/agents/ui-developer.md +1 -1
  23. package/agents/ux-evaluator.md +1 -1
  24. package/commands/eli5.md +33 -0
  25. package/commands/release.md +62 -0
  26. package/commands/test.md +2 -2
  27. package/docs/components.md +6 -5
  28. package/docs/migration-v3.md +9 -6
  29. package/docs/persona-panel.md +3 -1
  30. package/docs/scope-collision-guard.md +167 -0
  31. package/docs/session-config-reference.md +31 -8
  32. package/hooks/_lib/lock-bootstrap.mjs +19 -13
  33. package/hooks/enforce-scope.mjs +103 -3
  34. package/hooks/hooks-codex.json +1 -1
  35. package/hooks/hooks.json +21 -1
  36. package/hooks/on-session-end.mjs +76 -97
  37. package/hooks/on-session-start.mjs +195 -104
  38. package/hooks/on-stop.mjs +127 -12
  39. package/hooks/post-bash-write-verify.mjs +8 -32
  40. package/hooks/pre-auq-clarity.mjs +787 -0
  41. package/hooks/pre-bash-issue-budget.mjs +17 -18
  42. package/hooks/pre-task-scope-disjoint.mjs +1042 -0
  43. package/package.json +3 -1
  44. package/pi/prompts/eli5.md +12 -0
  45. package/pi/prompts/release.md +12 -0
  46. package/scripts/auq-audit.mjs +825 -0
  47. package/scripts/autopilot.mjs +10 -9
  48. package/scripts/emit-session.mjs +42 -0
  49. package/scripts/export-hw-learnings.mjs +61 -2
  50. package/scripts/lib/auq/clarity.mjs +1314 -0
  51. package/scripts/lib/auq/parse.mjs +1006 -0
  52. package/scripts/lib/auq/schema.mjs +1457 -0
  53. package/scripts/lib/autopilot/worktree-pipeline.mjs +5 -5
  54. package/scripts/lib/backlog-scan.mjs +106 -15
  55. package/scripts/lib/build-live-signals.mjs +7 -3
  56. package/scripts/lib/ci-status-banner.mjs +267 -77
  57. package/scripts/lib/config/dispatcher-autonomy-capture.mjs +32 -9
  58. package/scripts/lib/config/vault-integration.mjs +12 -1
  59. package/scripts/lib/dispatcher/rank.mjs +4 -7
  60. package/scripts/lib/gates/gate-full.mjs +3 -3
  61. package/scripts/lib/gates/gate-helpers.mjs +17 -6
  62. package/scripts/lib/git-config-drift.mjs +471 -0
  63. package/scripts/lib/harness-audit/categories/category6.mjs +65 -12
  64. package/scripts/lib/io.mjs +432 -7
  65. package/scripts/lib/issue-budget.mjs +63 -9
  66. package/scripts/lib/learnings/select.mjs +157 -3
  67. package/scripts/lib/memory-cleanup-stamp.mjs +132 -8
  68. package/scripts/lib/mirror-issues-banner.mjs +266 -0
  69. package/scripts/lib/named-vault-resolver.mjs +105 -16
  70. package/scripts/lib/owner-interview.mjs +78 -32
  71. package/scripts/lib/peer-cards/schema.mjs +6 -2
  72. package/scripts/lib/peer-discovery.mjs +73 -22
  73. package/scripts/lib/project-hygiene.mjs +64 -4
  74. package/scripts/lib/reconcile/renderer.mjs +17 -4
  75. package/scripts/lib/reconcile/writer.mjs +69 -30
  76. package/scripts/lib/redact-spans.mjs +89 -0
  77. package/scripts/lib/resource-probe/evaluate.mjs +330 -149
  78. package/scripts/lib/resource-probe/probe-platform.mjs +35 -0
  79. package/scripts/lib/resource-probe.mjs +18 -2
  80. package/scripts/lib/scope-baseline.mjs +77 -17
  81. package/scripts/lib/scope-gate.mjs +658 -0
  82. package/scripts/lib/secret-masker.mjs +262 -0
  83. package/scripts/lib/session-lock.mjs +34 -10
  84. package/scripts/lib/session-registry.mjs +9 -1
  85. package/scripts/lib/spiral-carryover.mjs +23 -2
  86. package/scripts/lib/state-md/mission-status.mjs +164 -58
  87. package/scripts/lib/tmux-layout/vcs-detector.mjs +108 -4
  88. package/scripts/lib/validate/check-agents.mjs +77 -5
  89. package/scripts/lib/validate/check-auq-clarity.mjs +274 -0
  90. package/scripts/lib/validate/check-commands.mjs +2 -20
  91. package/scripts/lib/validate/check-doc-cli-commands.mjs +514 -0
  92. package/scripts/lib/validate/check-hooks-symmetry.mjs +48 -0
  93. package/scripts/lib/validate/check-owner-leakage.mjs +185 -17
  94. package/scripts/lib/validate/check-rules.mjs +153 -9
  95. package/scripts/lib/validate/check-skills.mjs +191 -0
  96. package/scripts/lib/validate/check-test-git-config-target.mjs +665 -0
  97. package/scripts/lib/validate/check-unicode-safety.mjs +22 -2
  98. package/scripts/lib/validate/check-untracked-test-deps.mjs +925 -0
  99. package/scripts/lib/validate/check-unwired-features.mjs +219 -11
  100. package/scripts/lib/validate/check-vcs-repo-flag.mjs +965 -0
  101. package/scripts/lib/validate/frontmatter-block.mjs +61 -0
  102. package/scripts/lib/validate/tier-inference.mjs +46 -8
  103. package/scripts/lib/vault-backfill/glab.mjs +91 -58
  104. package/scripts/lib/vault-backfill/manifest.mjs +28 -8
  105. package/scripts/lib/vault-mirror/namespace.mjs +146 -1
  106. package/scripts/lib/vault-mirror/process.mjs +264 -31
  107. package/scripts/lib/vault-mirror/render-sessions.mjs +115 -4
  108. package/scripts/lib/vault-status/board-writer.mjs +300 -56
  109. package/scripts/lib/vault-status/narrative-mirror.mjs +119 -5
  110. package/scripts/lib/vcs-repo-spec.mjs +680 -30
  111. package/scripts/lib/wave-resource-gate.mjs +67 -73
  112. package/scripts/materialize-wave-scope.mjs +281 -0
  113. package/scripts/print-learnings-index.mjs +30 -3
  114. package/scripts/release.mjs +983 -107
  115. package/scripts/run-quality-gate.mjs +14 -0
  116. package/scripts/site-numbers.mjs +1049 -0
  117. package/scripts/validate-plugin.mjs +64 -0
  118. package/scripts/validate-wave-scope.mjs +286 -12
  119. package/scripts/vault-backfill.mjs +32 -5
  120. package/scripts/vault-mirror.mjs +26 -1
  121. package/skills/_shared/monitor-patterns.md +24 -4
  122. package/skills/_shared/parallel-aware-auq.md +30 -24
  123. package/skills/_shared/parallel-aware-preamble.md +31 -2
  124. package/skills/_shared/state-ownership.md +49 -6
  125. package/skills/bootstrap/SKILL.md +2 -1
  126. package/skills/brainstorm/SKILL.md +18 -18
  127. package/skills/brainstorm/soul.md +12 -0
  128. package/skills/claude-md-drift-check/SKILL.md +9 -1
  129. package/skills/debug/SKILL.md +4 -1
  130. package/skills/discovery/SKILL.md +28 -24
  131. package/skills/discovery/issue-templates.md +4 -4
  132. package/skills/discovery/probes-code.md +2 -2
  133. package/skills/discovery/probes-feature.md +6 -6
  134. package/skills/discovery/probes-infra.md +2 -2
  135. package/skills/discovery/probes-session.md +5 -5
  136. package/skills/dispatcher/SKILL.md +10 -1
  137. package/skills/eli5/SKILL.md +43 -0
  138. package/skills/evolve/SKILL.md +8 -9
  139. package/skills/frontmatter-guard/SKILL.md +9 -1
  140. package/skills/gitlab-ops/SKILL.md +73 -59
  141. package/skills/gitlab-portfolio/SKILL.md +10 -1
  142. package/skills/grill/SKILL.md +6 -6
  143. package/skills/grill/soul.md +16 -0
  144. package/skills/memory-cleanup/SKILL.md +20 -7
  145. package/skills/npm-publish/SKILL.md +23 -51
  146. package/skills/peekaboo-driver/SKILL.md +3 -3
  147. package/skills/persona-panel/SKILL.md +3 -1
  148. package/skills/plan/SKILL.md +18 -16
  149. package/skills/plan/mode-feature.md +1 -1
  150. package/skills/plan/mode-new.md +42 -12
  151. package/skills/plan/soul.md +12 -0
  152. package/skills/reconcile/SKILL.md +3 -3
  153. package/skills/repo-audit/SKILL.md +10 -1
  154. package/skills/session-end/SKILL.md +97 -22
  155. package/skills/session-end/metrics-collection.md +1 -1
  156. package/skills/session-end/phase-3-6-tail.md +37 -2
  157. package/skills/session-end/session-metrics-write.md +4 -10
  158. package/skills/session-plan/SKILL.md +2 -2
  159. package/skills/session-plan/wave-template.md +1 -1
  160. package/skills/session-start/SKILL.md +82 -36
  161. package/skills/session-start/phase-2-5-docs-planning.md +8 -8
  162. package/skills/session-start/phase-4-5-resource-health.md +82 -19
  163. package/skills/session-start/soul.md +110 -0
  164. package/skills/spinout/SKILL.md +5 -1
  165. package/skills/sunset-review/SKILL.md +11 -1
  166. package/skills/test-runner/SKILL.md +2 -2
  167. package/skills/tmux-layout/SKILL.md +7 -2
  168. package/skills/using-orchestrator/SKILL.md +1 -1
  169. package/skills/vault-mirror/SKILL.md +10 -1
  170. package/skills/vault-sync/SKILL.md +10 -1
  171. package/skills/vault-sync/validator.mjs +55 -6
  172. package/skills/wave-executor/wave-loop.md +64 -12
  173. package/skills/write-executable-plan/SKILL.md +6 -6
  174. package/scripts/lib/mission-status-schema.mjs +0 -114
  175. package/scripts/tests/fixtures/fetch-baseline/sample-rule.md +0 -8
  176. package/skills/vault-sync/tests/fixtures/archive-test-vault/90-archive/bad-archived.md +0 -8
  177. package/skills/vault-sync/tests/fixtures/archive-test-vault/_meta/.gitkeep +0 -0
  178. package/skills/vault-sync/tests/fixtures/archive-test-vault/live-note.md +0 -8
  179. package/skills/vault-sync/tests/fixtures/broken-frontmatter-vault/_meta/.gitkeep +0 -0
  180. package/skills/vault-sync/tests/fixtures/broken-frontmatter-vault/bad-type.md +0 -8
  181. package/skills/vault-sync/tests/fixtures/broken-frontmatter-vault/good-note.md +0 -8
  182. package/skills/vault-sync/tests/fixtures/clean-vault/.obsidian/config.md +0 -8
  183. package/skills/vault-sync/tests/fixtures/clean-vault/01-projects/foo/projects-baseline.md +0 -10
  184. package/skills/vault-sync/tests/fixtures/clean-vault/03-daily/daily-2026-04-13.md +0 -8
  185. package/skills/vault-sync/tests/fixtures/clean-vault/README.md +0 -3
  186. package/skills/vault-sync/tests/fixtures/clean-vault/hello-world.md +0 -11
  187. package/skills/vault-sync/tests/fixtures/dangling-link-vault/_meta/.gitkeep +0 -0
  188. package/skills/vault-sync/tests/fixtures/dangling-link-vault/has-dangling.md +0 -9
  189. package/skills/vault-sync/tests/fixtures/dangling-link-vault/real-target.md +0 -8
  190. package/skills/vault-sync/tests/fixtures/empty-vault/_meta/.gitkeep +0 -0
  191. package/skills/vault-sync/tests/fixtures/missing-field-vault/_meta/.gitkeep +0 -0
  192. package/skills/vault-sync/tests/fixtures/missing-field-vault/missing-id.md +0 -7
  193. package/skills/vault-sync/tests/fixtures/nested-tag-vault/03-daily/daily-2026-04-13.md +0 -9
  194. package/skills/vault-sync/tests/fixtures/nested-tag-vault/_meta/.gitkeep +0 -0
  195. package/skills/vault-sync/tests/fixtures/nested-tag-vault/nested-tags-note.md +0 -11
  196. package/skills/vault-sync/tests/fixtures/no-frontmatter-vault/README.md +0 -3
  197. package/skills/vault-sync/tests/fixtures/no-frontmatter-vault/_MOC.md +0 -3
  198. package/skills/vault-sync/tests/fixtures/no-frontmatter-vault/_meta/.gitkeep +0 -0
  199. package/skills/vault-sync/tests/fixtures/with-moc-vault/_MOC.md +0 -11
  200. package/skills/vault-sync/tests/fixtures/with-moc-vault/_meta/.gitkeep +0 -0
  201. package/skills/vault-sync/tests/fixtures/with-moc-vault/hello-world.md +0 -11
  202. package/skills/vault-sync/tests/schema-drift.test.mjs +0 -133
@@ -0,0 +1,925 @@
1
+ #!/usr/bin/env node
2
+ // check-untracked-test-deps.mjs — STATIC guard against the "a test depends on a
3
+ // path that is not in the repository" class.
4
+ //
5
+ // THE CLASS. A test reads a path that exists on the developer's disk but is not
6
+ // tracked by git (gitignored, or simply never `git add`-ed). Locally it is green;
7
+ // on CI — which starts from a fresh clone — the path is absent, so the test is
8
+ // either red (fatal read) or silently degraded to zero coverage. Third recorded
9
+ // instance in this repo (learnings `fcfd01dc` conf 0.80, `ea56c14b` conf 0.75).
10
+ //
11
+ // WHY THE OBVIOUS SCAN DOES NOT WORK. The instance that turned CI red —
12
+ // `tests/scripts/site-numbers.test.mjs` — never names `.orchestrator` at all.
13
+ // The dependency is TRANSITIVE: the test calls `collect(REPO_ROOT)`, and
14
+ // `scripts/site-numbers.mjs` does `join(root, '.orchestrator', 'metrics',
15
+ // 'sessions.jsonl')` with `root` as a runtime parameter. A scan over `tests/**`
16
+ // alone scores zero on exactly this incident. The scan must follow the IMPORT
17
+ // CLOSURE.
18
+ //
19
+ // THE TWO RULES. Four rule variants were measured against the ground truth
20
+ // (582 files / 14,039 tests, tracked tree vs tracked+store; 27 status diffs in
21
+ // 2 files):
22
+ //
23
+ // R0 closure names an ignored path 227 hits, 2 TP, 225 FP
24
+ // R1 R0 + test contains a repo-root expression 68 hits, 1 TP, 67 FP
25
+ // R3 test contains a bare ignored path literal 48 hits, 1 TP, 47 FP
26
+ // R2 ← implemented 1 hit, 1 TP, 0 FP
27
+ // R4 ← implemented 1 hit, 1 TP, 0 FP
28
+ //
29
+ // MEASURED DIVERGENCE, recorded rather than smoothed over: this implementation
30
+ // of R2 finds TWO test files against the live tree, not one. The second —
31
+ // `tests/lib/validate/check-learning-provenance.test.mjs` — reaches the same
32
+ // gitignored store and is a fully documented accommodation (it branches on the
33
+ // store's absence). It is invisible to the differential measurement above by
34
+ // construction: that measurement asks "does the test's STATUS change with and
35
+ // without the store", and a test that accommodates correctly changes status in
36
+ // neither direction. The two measurements are both right about different
37
+ // populations. The static scan is the wider one; the ignore marker is how an
38
+ // accommodation is declared.
39
+ //
40
+ // R2: the test passes a STATICALLY RESOLVABLE REAL repo root
41
+ // (`fileURLToPath(new URL('../../', import.meta.url))`, `resolve(__dirname,
42
+ // '../..')`, `process.cwd()`) as an argument to a LOCALLY IMPORTED
43
+ // function whose import closure names an untracked path.
44
+ // R4: the test reads an untracked path CWD-RELATIVE — `readFileSync`/
45
+ // `existsSync`/… on a bare literal, or on an identifier bound to a bare
46
+ // literal, with NO `join`/`resolve` wrapping.
47
+ //
48
+ // The false-positive avoidance falls out of the RULE FORM, it is not a
49
+ // heuristic: R2 demands a STATICALLY resolvable real root, which a
50
+ // `mkdtempSync(...)` root can never be; R4 demands the ABSENCE of any
51
+ // `join`/`resolve` wrapping, which every tmp-fixture target has.
52
+ // R1/R3, at 98-99% false positives, are precisely the shape of check this repo
53
+ // has had to demote to WARN-only three times — they are deliberately NOT built.
54
+ //
55
+ // ORACLE. "Untracked" is `git check-ignore` OR (exists on disk AND absent from
56
+ // `git ls-files`). `check-ignore` alone misses a file that is simply never
57
+ // `git add`-ed. MEASURED PITFALL: `git check-ignore --stdin` aborts with `fatal`
58
+ // on the first path beginning with `../` and DISCARDS THE REST OF THE STREAM —
59
+ // in one intermediate run that swallowed 44 of 45 hits, and it recurred here
60
+ // with a JSON fixture blob that merely happened to contain a slash. Two layers,
61
+ // because a charset filter alone is an arms race: escaping/absolute/exotic specs
62
+ // are filtered out before the batch (`normalizeCandidate`), AND the batch
63
+ // BISECTS on a fatal (`checkIgnoreBatch`), so one refused spec costs one dropped
64
+ // candidate instead of the whole result set.
65
+ //
66
+ // WHY STATIC (termination, not cost). `scripts/validate-plugin.mjs` runs in NO
67
+ // CI job and NOT in the pre-push gate; it is reached only via two tests that
68
+ // spawn it (`tests/scripts/orchestrators-e2e.test.mjs`,
69
+ // `tests/agents/persona-reviewers.test.mjs`). A check that spawned `vitest`
70
+ // would run from a vitest test that spawns validate-plugin that spawns the
71
+ // check: unbounded recursion. This check spawns `git` only.
72
+ //
73
+ // ESCAPE HATCH. A line carrying the inline marker `check-untracked-test-deps:ignore`
74
+ // is exempt (same shape as `check-dead-bridge:ignore`). Use it only for a
75
+ // DOCUMENTED accommodation, and always with a rationale beside it.
76
+ //
77
+ // NAMED CEILING (BV-004) — what R2+R4 provably does NOT catch:
78
+ // 1. SUBPROCESS ARGV — `spawnSync(node, [SCRIPT, REPO_ROOT])`. This is the
79
+ // MOST LIKELY form of the next instance; in the current incident it only
80
+ // narrowly failed to be the trigger.
81
+ // 2. MODULE-SCOPE ROOT — the module resolves `process.cwd()` itself, so the
82
+ // test passes no root argument at all and R2's conjunct never arms.
83
+ // 3. COMPOSED FILENAMES — `join(root, '.orchestrator', 'metrics',
84
+ // `${name}.jsonl`)`: the directory itself is tracked/unignored, only
85
+ // `*.jsonl` inside it is ignored, so the assembled candidate misses.
86
+ // 4. NON-STATIC IMPORTS — `require()`, `import(variable)`: not in the closure.
87
+ // 5. ENVIRONMENT DEPENDENCIES — `$HOME`, the vault dir, `.env.local`: not a
88
+ // repo-relative path, so the oracle cannot judge them.
89
+ // 6. UNTRACKED DIRECTORIES that no ignore rule names. The oracle's
90
+ // exists-but-untracked half is FILES ONLY (see `resolveUntrackedOracle`),
91
+ // because `git ls-files` lists no directories at all — judging directories
92
+ // by it condemns `.git`. So a closure naming a bare `.orchestrator/metrics`
93
+ // is a miss, while `.orchestrator/metrics/sessions.jsonl` is caught.
94
+ // Additionally, code inside a TEMPLATE LITERAL is masked as data (this is what
95
+ // keeps a test that embeds fixture source from flagging itself), so a
96
+ // genuine `readFileSync(`${d}/x`)` is a miss; and a candidate `git check-ignore`
97
+ // refuses outright is dropped by the bisect rather than guessed at.
98
+ // REVISIT TRIGGER: any further instance of the class that slips through R2+R4.
99
+ //
100
+ // Usage: check-untracked-test-deps.mjs <repo-root>
101
+ // Output: ` PASS: …` / ` FAIL: …` lines (exactly two leading spaces), then
102
+ // `Results: N passed, M failed`. Exit 0 = clean, 1 = finding(s), 2 = tool error.
103
+ //
104
+ // Import-safety: importing this module MUST NOT execute anything — the isMain
105
+ // guard at the bottom is the only side-effecting path.
106
+
107
+ import { pathToFileURL } from 'node:url';
108
+ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
109
+ import { spawnSync } from 'node:child_process';
110
+ import path from 'node:path';
111
+
112
+ // ---------------------------------------------------------------------------
113
+ // Constants
114
+ // ---------------------------------------------------------------------------
115
+
116
+ /** Inline exemption marker — mirrors `check-dead-bridge:ignore`. */
117
+ export const IGNORE_MARKER = 'check-untracked-test-deps:ignore';
118
+
119
+ /** Directory (repo-relative) walked for test files. */
120
+ const TESTS_DIR = 'tests';
121
+
122
+ /** Test-file suffix. */
123
+ const TEST_SUFFIX = '.test.mjs';
124
+
125
+ /** Extensions a local import may resolve to. */
126
+ const MODULE_EXTS = ['.mjs', '.js', '.cjs'];
127
+
128
+ /**
129
+ * Vitest `resolve.alias` entries that point INTO this repo. Kept as a tiny
130
+ * literal map rather than parsing `vitest.config.mjs`: there is exactly one
131
+ * alias, and a parser for a config that has never had a second entry is the
132
+ * speculative-abstraction anti-pattern (`build-value.md` BV-001).
133
+ */
134
+ const IMPORT_ALIASES = { '@lib': 'scripts/lib' };
135
+
136
+ /** Depth cap for the import-closure walk (cycles are also visited-guarded). */
137
+ const MAX_CLOSURE_DEPTH = 6;
138
+
139
+ /** Callees whose string-literal arguments assemble into a path (R2 closure). */
140
+ const PATH_JOIN_FNS = new Set(['join', 'resolve', 'path.join', 'path.resolve']);
141
+
142
+ /** Filesystem readers whose FIRST argument is a path (R4). */
143
+ const FS_READ_FNS = new Set([
144
+ 'readFileSync', 'readdirSync', 'existsSync', 'statSync', 'lstatSync',
145
+ 'openSync', 'accessSync', 'realpathSync', 'readlinkSync', 'createReadStream',
146
+ 'readFile', 'readdir', 'stat', 'lstat', 'access', 'open',
147
+ ]);
148
+
149
+ /** An expression that resolves to a REAL, static location (R2 numerator). */
150
+ const REAL_ROOT_EXPR_RE = /import\.meta\.url|__dirname|process\s*\.\s*cwd\s*\(/;
151
+
152
+ /**
153
+ * …unless it is a throwaway temp directory. This single negation is what makes
154
+ * R2's zero-false-positive property structural rather than tuned.
155
+ */
156
+ const TMP_ROOT_RE = /mkdtemp|tmpdir/;
157
+
158
+ /** Upper bound on a plausible repo-relative path. */
159
+ const MAX_CANDIDATE_LEN = 200;
160
+
161
+ /** Characters a path in THIS repo can be built from (positive allowlist). */
162
+ const SANE_PATH_RE = /^[A-Za-z0-9._@+\-/ ]+$/;
163
+
164
+ /** Words that look like a call but are not one. */
165
+ const NON_CALLEES = new Set([
166
+ 'if', 'for', 'while', 'switch', 'catch', 'function', 'return', 'typeof',
167
+ 'await', 'new', 'do', 'else', 'in', 'of', 'delete', 'void', 'yield', 'import',
168
+ ]);
169
+
170
+ // ---------------------------------------------------------------------------
171
+ // Source masking — string/template/comment/regex interiors become spaces
172
+ // ---------------------------------------------------------------------------
173
+
174
+ /**
175
+ * Blank out the INTERIOR of every string literal, template literal, comment and
176
+ * regex literal, preserving byte offsets and newlines. Delimiters are kept so
177
+ * paren/comma balance and identifier boundaries survive.
178
+ *
179
+ * This is what separates CODE from DATA. A test that embeds fixture source in a
180
+ * template literal (this check's own test file does) must not be scanned as
181
+ * though that fixture were its own body — masking makes that structural instead
182
+ * of relying on a self-exemption marker.
183
+ *
184
+ * @param {string} text
185
+ * @returns {string} same-length masked source
186
+ */
187
+ export function maskSource(text) {
188
+ const out = text.split('');
189
+ const n = out.length;
190
+ let i = 0;
191
+ // Last significant (non-space) char before `i`, used for the regex/division
192
+ // disambiguation below.
193
+ let prev = '';
194
+
195
+ const blank = (from, to) => {
196
+ for (let k = from; k < to && k < n; k++) if (out[k] !== '\n') out[k] = ' ';
197
+ };
198
+
199
+ while (i < n) {
200
+ const c = text[i];
201
+ const next = text[i + 1];
202
+
203
+ // Line comment.
204
+ if (c === '/' && next === '/') {
205
+ let j = i;
206
+ while (j < n && text[j] !== '\n') j++;
207
+ blank(i, j);
208
+ i = j;
209
+ continue;
210
+ }
211
+ // Block comment.
212
+ if (c === '/' && next === '*') {
213
+ let j = i + 2;
214
+ while (j < n && !(text[j] === '*' && text[j + 1] === '/')) j++;
215
+ blank(i, Math.min(j + 2, n));
216
+ i = Math.min(j + 2, n);
217
+ continue;
218
+ }
219
+ // Single/double-quoted string — keep the quotes, blank the interior.
220
+ if (c === "'" || c === '"') {
221
+ let j = i + 1;
222
+ while (j < n && text[j] !== c) {
223
+ if (text[j] === '\\') j++;
224
+ if (text[j] === '\n') break; // unterminated — bail at EOL
225
+ j++;
226
+ }
227
+ blank(i + 1, j);
228
+ i = Math.min(j + 1, n);
229
+ prev = c;
230
+ continue;
231
+ }
232
+ // Template literal — blank everything including `${…}` substitutions.
233
+ if (c === '`') {
234
+ let j = i + 1;
235
+ while (j < n && text[j] !== '`') {
236
+ if (text[j] === '\\') j++;
237
+ j++;
238
+ }
239
+ blank(i + 1, j);
240
+ i = Math.min(j + 1, n);
241
+ prev = c;
242
+ continue;
243
+ }
244
+ // Regex literal — only when the previous significant char cannot end an
245
+ // expression, AND a closing unescaped `/` exists on the SAME line. The
246
+ // same-line bound keeps a misread division from swallowing the file.
247
+ if (c === '/' && (prev === '' || '(,=:[!&|?{};+-*%~^<>'.includes(prev))) {
248
+ let j = i + 1;
249
+ let inClass = false;
250
+ let closed = -1;
251
+ while (j < n && text[j] !== '\n') {
252
+ if (text[j] === '\\') { j += 2; continue; }
253
+ if (text[j] === '[') inClass = true;
254
+ else if (text[j] === ']') inClass = false;
255
+ else if (text[j] === '/' && !inClass) { closed = j; break; }
256
+ j++;
257
+ }
258
+ if (closed > i) {
259
+ blank(i + 1, closed);
260
+ i = closed + 1;
261
+ prev = '/';
262
+ continue;
263
+ }
264
+ }
265
+
266
+ if (!/\s/.test(c)) prev = c;
267
+ i++;
268
+ }
269
+ return out.join('');
270
+ }
271
+
272
+ // ---------------------------------------------------------------------------
273
+ // Tiny source scanners (offset-aligned against the masked text)
274
+ // ---------------------------------------------------------------------------
275
+
276
+ /**
277
+ * Build a line-number lookup for a source text.
278
+ * @param {string} text
279
+ * @returns {(index: number) => number} 1-based line number for a char offset
280
+ */
281
+ function lineLookup(text) {
282
+ const starts = [0];
283
+ for (let i = 0; i < text.length; i++) if (text[i] === '\n') starts.push(i + 1);
284
+ return (index) => {
285
+ let lo = 0;
286
+ let hi = starts.length - 1;
287
+ while (lo < hi) {
288
+ const mid = (lo + hi + 1) >> 1;
289
+ if (starts[mid] <= index) lo = mid;
290
+ else hi = mid - 1;
291
+ }
292
+ return lo + 1;
293
+ };
294
+ }
295
+
296
+ /**
297
+ * Split the argument list of the call whose `(` sits at `openIdx`. Operates on
298
+ * MASKED text (string interiors already blanked), so nothing inside a literal
299
+ * can affect the paren/comma balance.
300
+ *
301
+ * @param {string} masked
302
+ * @param {number} openIdx index of the opening `(`
303
+ * @returns {Array<{start: number, end: number}> | null} null when unbalanced
304
+ */
305
+ function argRanges(masked, openIdx) {
306
+ const ranges = [];
307
+ let depth = 0;
308
+ let start = openIdx + 1;
309
+ for (let i = openIdx; i < masked.length; i++) {
310
+ const c = masked[i];
311
+ if (c === '(' || c === '[' || c === '{') depth++;
312
+ else if (c === ')' || c === ']' || c === '}') {
313
+ depth--;
314
+ if (depth === 0) {
315
+ if (i > start || ranges.length > 0) ranges.push({ start, end: i });
316
+ return ranges;
317
+ }
318
+ } else if (c === ',' && depth === 1) {
319
+ ranges.push({ start, end: i });
320
+ start = i + 1;
321
+ }
322
+ }
323
+ return null;
324
+ }
325
+
326
+ const CALL_RE = /(?<![\w$.])((?:[A-Za-z_$][\w$]*\s*\.\s*)?[A-Za-z_$][\w$]*)\s*\(/g;
327
+
328
+ /**
329
+ * Enumerate every call expression in a source file.
330
+ *
331
+ * @param {string} original raw source (argument VALUES are sliced from here)
332
+ * @param {string} masked masked source (call STRUCTURE is found here)
333
+ * @returns {Array<{callee: string, args: string[], index: number}>}
334
+ */
335
+ function scanCalls(original, masked) {
336
+ const calls = [];
337
+ CALL_RE.lastIndex = 0;
338
+ let m;
339
+ while ((m = CALL_RE.exec(masked)) !== null) {
340
+ const callee = m[1].replace(/\s+/g, '');
341
+ if (NON_CALLEES.has(callee)) continue;
342
+ const openIdx = m.index + m[0].length - 1;
343
+ const ranges = argRanges(masked, openIdx);
344
+ if (!ranges) continue;
345
+ calls.push({
346
+ callee,
347
+ args: ranges.map((r) => original.slice(r.start, r.end).trim()),
348
+ index: m.index,
349
+ });
350
+ }
351
+ return calls;
352
+ }
353
+
354
+ /** A bare single/double-quoted string literal, whole-argument. */
355
+ const WHOLE_STRING_RE = /^(['"])((?:[^'"\\]|\\.)*)\1$/;
356
+
357
+ /** A bare identifier, whole-argument. */
358
+ const WHOLE_IDENT_RE = /^[A-Za-z_$][\w$]*$/;
359
+
360
+ /**
361
+ * @param {string} arg trimmed argument source
362
+ * @returns {string | null} the literal's value, or null when not a bare literal
363
+ */
364
+ function asStringLiteral(arg) {
365
+ const m = WHOLE_STRING_RE.exec(arg);
366
+ return m ? m[2] : null;
367
+ }
368
+
369
+ // ---------------------------------------------------------------------------
370
+ // Path-candidate extraction
371
+ // ---------------------------------------------------------------------------
372
+
373
+ /**
374
+ * Normalize a raw path candidate to a repo-relative form, or return null when
375
+ * it cannot be one.
376
+ *
377
+ * A candidate must LOOK repo-relative: it either contains a `/` or begins with
378
+ * a `.` (a dotfile/dotdir). A single bare segment such as `package.json` or
379
+ * `node_modules` is rejected — joined onto an unknown base it is a filename,
380
+ * not a repo-relative path, and attributing it to the repo root is what would
381
+ * manufacture false positives.
382
+ *
383
+ * @param {string} raw
384
+ * @returns {string | null}
385
+ */
386
+ export function normalizeCandidate(raw) {
387
+ if (typeof raw !== 'string') return null;
388
+ let p = raw.trim();
389
+ if (!p || p.length > MAX_CANDIDATE_LEN) return null;
390
+ // Positive charset, not a blocklist. A test corpus contains string literals
391
+ // that merely HAPPEN to carry a slash — a JSON fixture, a prose sentence, a
392
+ // regex source. One such literal reaching `git check-ignore --stdin` aborts
393
+ // the batch and discards the rest of the stream (measured: a `[{"number":63,
394
+ // …}]` fixture blob did exactly that here). Anything outside this charset is
395
+ // not a path this repo could contain, so it never enters the batch.
396
+ if (!SANE_PATH_RE.test(p)) return null;
397
+ if (p.startsWith('/') || /^[A-Za-z]:[\\/]/.test(p)) return null;
398
+ if (p.startsWith('~')) return null;
399
+ p = p.replace(/\\/g, '/').replace(/\/{2,}/g, '/').replace(/\/+$/, '');
400
+ while (p.startsWith('./')) p = p.slice(2);
401
+ if (!p || p === '.') return null;
402
+ if (p.split('/').some((seg) => seg === '..')) return null; // check-ignore fatal
403
+ if (!p.includes('/') && !p.startsWith('.')) return null;
404
+ return p;
405
+ }
406
+
407
+ /**
408
+ * Extract every path candidate a module NAMES: `join(...)`/`resolve(...)`
409
+ * assembled from string-literal segments, plus bare path-shaped literals.
410
+ *
411
+ * @param {string} original
412
+ * @param {string} masked
413
+ * @returns {Array<{candidate: string, line: number}>}
414
+ */
415
+ function extractPathCandidates(original, masked) {
416
+ const lineOf = lineLookup(original);
417
+ /** @type {Array<{candidate: string, line: number}>} */
418
+ const out = [];
419
+ const seen = new Set();
420
+ const push = (raw, index) => {
421
+ const candidate = normalizeCandidate(raw);
422
+ if (!candidate) return;
423
+ const line = lineOf(index);
424
+ const key = `${candidate}@${line}`;
425
+ if (seen.has(key)) return;
426
+ seen.add(key);
427
+ out.push({ candidate, line });
428
+ };
429
+
430
+ // (a) join()/resolve() assembled from the ordered string-literal arguments.
431
+ for (const call of scanCalls(original, masked)) {
432
+ if (!PATH_JOIN_FNS.has(call.callee)) continue;
433
+ const segs = call.args.map(asStringLiteral).filter((s) => s !== null);
434
+ if (segs.length === 0) continue;
435
+ push(segs.join('/'), call.index);
436
+ }
437
+
438
+ // (b) bare path-shaped string literals anywhere in the module.
439
+ const litRe = /(['"])((?:[^'"\\\n]|\\.)*)\1/g;
440
+ let m;
441
+ while ((m = litRe.exec(masked)) !== null) {
442
+ // masked blanks the interior — read the value from the original at the
443
+ // same offsets.
444
+ const value = original.slice(m.index + 1, m.index + m[0].length - 1);
445
+ push(value, m.index);
446
+ }
447
+
448
+ return out;
449
+ }
450
+
451
+ /**
452
+ * Extract ONLY the bare path-shaped string literals of a source file (no
453
+ * join/resolve assembly). Exported so the false-positive regression test can
454
+ * build the R3 candidate corpus mechanically instead of hand-listing it.
455
+ *
456
+ * @param {string} original
457
+ * @param {string} masked
458
+ * @returns {Array<{candidate: string, line: number}>}
459
+ */
460
+ export function extractLiteralCandidates(original, masked) {
461
+ const lineOf = lineLookup(original);
462
+ const out = [];
463
+ const seen = new Set();
464
+ const litRe = /(['"])((?:[^'"\\\n]|\\.)*)\1/g;
465
+ let m;
466
+ while ((m = litRe.exec(masked)) !== null) {
467
+ const value = original.slice(m.index + 1, m.index + m[0].length - 1);
468
+ const candidate = normalizeCandidate(value);
469
+ if (!candidate || seen.has(candidate)) continue;
470
+ seen.add(candidate);
471
+ out.push({ candidate, line: lineOf(m.index) });
472
+ }
473
+ return out;
474
+ }
475
+
476
+ // ---------------------------------------------------------------------------
477
+ // Import resolution + closure
478
+ // ---------------------------------------------------------------------------
479
+
480
+ const IMPORT_RE = /import\s+([\s\S]*?)\s+from\s+(['"])([^'"]+)\2/g;
481
+ const BARE_IMPORT_RE = /import\s+(['"])([^'"]+)\1/g;
482
+
483
+ /**
484
+ * Resolve an import specifier to an absolute file path INSIDE the repo, or null
485
+ * for bare/external/unresolvable specifiers.
486
+ *
487
+ * @param {string} repoRoot
488
+ * @param {string} fromFile absolute path of the importing file
489
+ * @param {string} spec
490
+ * @returns {string | null}
491
+ */
492
+ function resolveLocalImport(repoRoot, fromFile, spec) {
493
+ let base = null;
494
+ if (spec.startsWith('./') || spec.startsWith('../')) {
495
+ base = path.resolve(path.dirname(fromFile), spec);
496
+ } else {
497
+ for (const [alias, target] of Object.entries(IMPORT_ALIASES)) {
498
+ if (spec === alias || spec.startsWith(`${alias}/`)) {
499
+ base = path.resolve(repoRoot, target, spec.slice(alias.length + 1));
500
+ break;
501
+ }
502
+ }
503
+ }
504
+ if (!base) return null;
505
+ const rel = path.relative(repoRoot, base);
506
+ if (rel.startsWith('..') || path.isAbsolute(rel)) return null;
507
+
508
+ const tryPaths = [base, ...MODULE_EXTS.map((e) => base + e),
509
+ ...MODULE_EXTS.map((e) => path.join(base, `index${e}`))];
510
+ for (const p of tryPaths) {
511
+ try {
512
+ if (existsSync(p) && statSync(p).isFile()) return p;
513
+ } catch { /* unreadable — treat as unresolvable */ }
514
+ }
515
+ return null;
516
+ }
517
+
518
+ /**
519
+ * Parse the static imports of a source file into
520
+ * `{ bindings: Map<name, absPath>, modules: Set<absPath> }`.
521
+ *
522
+ * Structure is matched on the MASKED text (so an `import` word inside a comment
523
+ * or a fixture template literal is invisible), but the specifier VALUE is
524
+ * sliced from the ORIGINAL at the same offsets — masking blanks string
525
+ * interiors, and the specifier is a string interior.
526
+ *
527
+ * @param {string} repoRoot
528
+ * @param {string} file absolute path of the importing file
529
+ * @param {string} original
530
+ * @param {string} masked
531
+ * @returns {{bindings: Map<string, string>, modules: Set<string>}}
532
+ */
533
+ function parseImports(repoRoot, file, original, masked) {
534
+ const bindings = new Map();
535
+ const modules = new Set();
536
+
537
+ const addModule = (spec) => {
538
+ const abs = resolveLocalImport(repoRoot, file, spec);
539
+ if (abs) modules.add(abs);
540
+ return abs;
541
+ };
542
+ /** Slice the specifier value out of the original using the masked match. */
543
+ const specOf = (m, group) => {
544
+ const end = m.index + m[0].length - 1; // closing quote
545
+ return original.slice(end - m[group].length, end);
546
+ };
547
+
548
+ IMPORT_RE.lastIndex = 0;
549
+ let m;
550
+ while ((m = IMPORT_RE.exec(masked)) !== null) {
551
+ const clause = m[1];
552
+ const abs = addModule(specOf(m, 3));
553
+ if (!abs) continue;
554
+ // `{ a, b as c }` — named; `* as ns` — namespace; `d` — default.
555
+ const named = clause.match(/\{([\s\S]*?)\}/);
556
+ if (named) {
557
+ for (const part of named[1].split(',')) {
558
+ const t = part.trim();
559
+ if (!t) continue;
560
+ const asMatch = t.match(/^([\w$]+)\s+as\s+([\w$]+)$/);
561
+ bindings.set(asMatch ? asMatch[2] : t.replace(/\s.*$/, ''), abs);
562
+ }
563
+ }
564
+ const ns = clause.match(/\*\s+as\s+([\w$]+)/);
565
+ if (ns) bindings.set(ns[1], abs);
566
+ const head = clause.replace(/\{[\s\S]*?\}/g, '').replace(/\*\s+as\s+[\w$]+/g, '')
567
+ .split(',')[0].trim();
568
+ if (head && /^[\w$]+$/.test(head)) bindings.set(head, abs);
569
+ }
570
+
571
+ BARE_IMPORT_RE.lastIndex = 0;
572
+ while ((m = BARE_IMPORT_RE.exec(masked)) !== null) addModule(specOf(m, 2));
573
+
574
+ return { bindings, modules };
575
+ }
576
+
577
+ /**
578
+ * Breadth-first import closure of a file, with the shortest chain to each
579
+ * reached module.
580
+ *
581
+ * @param {string} repoRoot
582
+ * @param {string} entry absolute path
583
+ * @param {(abs: string) => {original: string, masked: string} | null} readSource
584
+ * @returns {Map<string, string[]>} absPath → chain of absPaths from entry
585
+ */
586
+ function importClosure(repoRoot, entry, readSource) {
587
+ /** @type {Map<string, string[]>} */
588
+ const reached = new Map();
589
+ let frontier = [{ file: entry, chain: [entry] }];
590
+ const visited = new Set([entry]);
591
+
592
+ for (let depth = 0; depth < MAX_CLOSURE_DEPTH && frontier.length > 0; depth++) {
593
+ const nextFrontier = [];
594
+ for (const { file, chain } of frontier) {
595
+ const src = readSource(file);
596
+ if (!src) continue;
597
+ const { modules } = parseImports(repoRoot, file, src.original, src.masked);
598
+ for (const mod of modules) {
599
+ if (visited.has(mod)) continue;
600
+ visited.add(mod);
601
+ const nextChain = [...chain, mod];
602
+ reached.set(mod, nextChain);
603
+ nextFrontier.push({ file: mod, chain: nextChain });
604
+ }
605
+ }
606
+ frontier = nextFrontier;
607
+ }
608
+ return reached;
609
+ }
610
+
611
+ // ---------------------------------------------------------------------------
612
+ // Oracle — "untracked" = ignored OR (exists on disk AND not in git ls-files)
613
+ // ---------------------------------------------------------------------------
614
+
615
+ /**
616
+ * Ask `git check-ignore` which of `specs` are ignored, adding them to `out`.
617
+ *
618
+ * BISECTING, on purpose. `git check-ignore --stdin` aborts with `fatal` on the
619
+ * first spec it refuses (one escaping `../`, one over-long or exotic string) and
620
+ * DISCARDS THE REST OF THE STREAM — in one intermediate run that swallowed 44 of
621
+ * 45 hits, and it recurred here with a JSON fixture blob. A charset filter alone
622
+ * is an arms race against whatever literal the next test file contains; halving
623
+ * the batch on a fatal isolates the offender in O(log n) spawns and costs at
624
+ * most one dropped candidate instead of the whole result set.
625
+ *
626
+ * @param {string} repoRoot
627
+ * @param {string[]} specs
628
+ * @param {Set<string>} out
629
+ */
630
+ function checkIgnoreBatch(repoRoot, specs, out) {
631
+ if (specs.length === 0) return;
632
+ const ci = spawnSync('git', ['check-ignore', '--stdin'], {
633
+ cwd: repoRoot, input: specs.join('\n'), encoding: 'utf8',
634
+ maxBuffer: 64 * 1024 * 1024,
635
+ });
636
+ // 0 = at least one ignored (listed on stdout); 1 = none ignored; else fatal.
637
+ if (ci.status === 0) {
638
+ for (const line of (ci.stdout || '').split('\n')) {
639
+ const p = line.trim();
640
+ if (p) out.add(p);
641
+ }
642
+ return;
643
+ }
644
+ if (ci.status === 1) return;
645
+ if (specs.length === 1) {
646
+ process.stderr.write(
647
+ ` WARN: git check-ignore rejected "${specs[0]}" — candidate dropped (${(ci.stderr || '').trim().split('\n')[0]})\n`,
648
+ );
649
+ return;
650
+ }
651
+ const mid = specs.length >> 1;
652
+ checkIgnoreBatch(repoRoot, specs.slice(0, mid), out);
653
+ checkIgnoreBatch(repoRoot, specs.slice(mid), out);
654
+ }
655
+
656
+ /**
657
+ * Batch-classify repo-relative candidate paths as untracked.
658
+ *
659
+ * MEASURED PITFALL: `git check-ignore --stdin` aborts with `fatal` on the first
660
+ * spec beginning with `../` and DISCARDS the remainder of the stream (44 of 45
661
+ * hits swallowed in one intermediate run). `normalizeCandidate` already rejects
662
+ * `..` segments and absolute specs; this function re-filters defensively so a
663
+ * caller that bypasses normalization cannot re-open the hole.
664
+ *
665
+ * @param {string} repoRoot
666
+ * @param {Iterable<string>} candidates
667
+ * @returns {{untracked: Set<string>, error: string | null}}
668
+ */
669
+ export function resolveUntrackedOracle(repoRoot, candidates) {
670
+ const all = [...new Set(candidates)].filter(
671
+ (c) => typeof c === 'string' && c && !c.startsWith('/') && !c.startsWith('-')
672
+ && !c.includes('\0') && !c.split('/').some((s) => s === '..'),
673
+ );
674
+ const untracked = new Set();
675
+ if (all.length === 0) return { untracked, error: null };
676
+
677
+ const ls = spawnSync('git', ['ls-files', '-z'], {
678
+ cwd: repoRoot, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024,
679
+ });
680
+ if (ls.status !== 0) {
681
+ return { untracked, error: `git ls-files failed: ${(ls.stderr || '').trim()}` };
682
+ }
683
+ const tracked = new Set((ls.stdout || '').split('\0').filter(Boolean));
684
+
685
+ checkIgnoreBatch(repoRoot, all, untracked);
686
+
687
+ // Second half of the oracle: a FILE that exists on disk but is absent from
688
+ // `git ls-files` — the never-`git add`-ed case `check-ignore` cannot see.
689
+ //
690
+ // Deliberately FILES ONLY. `git ls-files` enumerates files, so every
691
+ // DIRECTORY is trivially "absent" from it — judging directories by this
692
+ // branch condemns `.git` (present in every clone by construction), plus every
693
+ // directory whose tracked children happen to sit one level deeper. Measured:
694
+ // that variant produced 31 findings across 8 test files, of which the
695
+ // directory candidates (`.git`, `.claude`, `docs/prd`, `skills/bootstrap`, …)
696
+ // were all false. A directory can therefore only be condemned by
697
+ // `check-ignore`, which does judge directories correctly.
698
+ for (const c of all) {
699
+ if (untracked.has(c)) continue;
700
+ if (tracked.has(c)) continue;
701
+ try {
702
+ const abs = path.join(repoRoot, c);
703
+ if (existsSync(abs) && statSync(abs).isFile()) untracked.add(c);
704
+ } catch { /* unreadable — not classifiable */ }
705
+ }
706
+ return { untracked, error: null };
707
+ }
708
+
709
+ // ---------------------------------------------------------------------------
710
+ // Scan
711
+ // ---------------------------------------------------------------------------
712
+
713
+ /**
714
+ * Recursively collect `*.test.mjs` under `absDir`.
715
+ * @param {string} absDir
716
+ * @returns {string[]}
717
+ */
718
+ function walkTests(absDir) {
719
+ const out = [];
720
+ let entries;
721
+ try {
722
+ if (!existsSync(absDir) || !statSync(absDir).isDirectory()) return out;
723
+ entries = readdirSync(absDir, { withFileTypes: true });
724
+ } catch { return out; }
725
+ for (const ent of entries) {
726
+ const full = path.join(absDir, ent.name);
727
+ try {
728
+ if (ent.isDirectory()) out.push(...walkTests(full));
729
+ else if (ent.isFile() && full.endsWith(TEST_SUFFIX)) out.push(full);
730
+ } catch { /* skip unreadable entry */ }
731
+ }
732
+ return out.sort();
733
+ }
734
+
735
+ /**
736
+ * Scan a repository and return every R2/R4 finding.
737
+ *
738
+ * @param {string} repoRoot absolute repo root
739
+ * @returns {{findings: Array<{rule: 'R2'|'R4', file: string, line: number, candidate: string, message: string}>, error: string | null}}
740
+ */
741
+ export function scanUntrackedTestDeps(repoRoot) {
742
+ /** @type {Map<string, {original: string, masked: string, lines: string[]}>} */
743
+ const cache = new Map();
744
+ const readSource = (abs) => {
745
+ if (cache.has(abs)) return cache.get(abs);
746
+ let original;
747
+ try { original = readFileSync(abs, 'utf8'); } catch { return null; }
748
+ const entry = { original, masked: maskSource(original), lines: original.split(/\r?\n/) };
749
+ cache.set(abs, entry);
750
+ return entry;
751
+ };
752
+ const rel = (abs) => path.relative(repoRoot, abs).split(path.sep).join('/');
753
+
754
+ const testFiles = walkTests(path.join(repoRoot, TESTS_DIR));
755
+ /** @type {Array<{rule: 'R2'|'R4', file: string, line: number, candidate: string, message: string}>} */
756
+ const raw = [];
757
+
758
+ for (const testFile of testFiles) {
759
+ const src = readSource(testFile);
760
+ if (!src) continue;
761
+ const lineOf = lineLookup(src.original);
762
+ const calls = scanCalls(src.original, src.masked);
763
+
764
+ // ---- R4: cwd-relative read from a bare literal (or an identifier bound
765
+ // to one), with NO join/resolve wrapping. ------------------------------
766
+ /** @type {Map<string, {value: string, line: number}>} */
767
+ const literalBindings = new Map();
768
+ const bindRe = /(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*(['"])((?:[^'"\\\n]|\\.)*)\2/g;
769
+ let bm;
770
+ while ((bm = bindRe.exec(src.masked)) !== null) {
771
+ const valueStart = bm.index + bm[0].length - bm[3].length - 1;
772
+ const value = src.original.slice(valueStart, valueStart + bm[3].length);
773
+ literalBindings.set(bm[1], { value, line: lineOf(bm.index) });
774
+ }
775
+
776
+ for (const call of calls) {
777
+ const bare = call.callee.includes('.') ? call.callee.split('.').pop() : call.callee;
778
+ if (!FS_READ_FNS.has(bare)) continue;
779
+ const first = call.args[0];
780
+ if (!first) continue;
781
+ // The absence of any wrapping IS the rule: a `(` in the argument means a
782
+ // join/resolve/anything-else wrapper, which every tmp fixture target has.
783
+ if (first.includes('(')) continue;
784
+ let value = asStringLiteral(first);
785
+ let bindLine = null;
786
+ if (value === null && WHOLE_IDENT_RE.test(first) && literalBindings.has(first)) {
787
+ value = literalBindings.get(first).value;
788
+ bindLine = literalBindings.get(first).line;
789
+ }
790
+ const candidate = normalizeCandidate(value ?? '');
791
+ if (!candidate) continue;
792
+ const line = lineOf(call.index);
793
+ // Marker on the read line OR on the binding line exempts the finding.
794
+ if (hasMarker(src.lines, line) || (bindLine && hasMarker(src.lines, bindLine))) continue;
795
+ raw.push({
796
+ rule: 'R4',
797
+ file: testFile,
798
+ line,
799
+ candidate,
800
+ message: `${rel(testFile)}:${line} — reads "${candidate}" CWD-RELATIVE via ${bare}() (no join/resolve): the path is NOT in the repository, so this is green locally and absent on a fresh CI clone`,
801
+ });
802
+ }
803
+
804
+ // ---- R2: a statically resolvable REAL root passed to a locally imported
805
+ // function whose import closure names an untracked path. ---------------
806
+ const rootVars = new Set();
807
+ const rootBindRe = /(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*([^\n;]+)/g;
808
+ let rm;
809
+ while ((rm = rootBindRe.exec(src.masked)) !== null) {
810
+ const rhsStart = rm.index + rm[0].length - rm[2].length;
811
+ const rhs = src.original.slice(rhsStart, rhsStart + rm[2].length);
812
+ if (REAL_ROOT_EXPR_RE.test(rhs) && !TMP_ROOT_RE.test(rhs)) rootVars.add(rm[1]);
813
+ }
814
+ const isRootArg = (arg) => {
815
+ const t = arg.trim();
816
+ if (!t) return false;
817
+ if (WHOLE_IDENT_RE.test(t)) return rootVars.has(t);
818
+ return REAL_ROOT_EXPR_RE.test(t) && !TMP_ROOT_RE.test(t);
819
+ };
820
+
821
+ const { bindings } = parseImports(repoRoot, testFile, src.original, src.masked);
822
+ /** @type {Array<{callee: string, line: number}>} */
823
+ const armed = [];
824
+ for (const call of calls) {
825
+ const head = call.callee.includes('.') ? call.callee.split('.')[0] : call.callee;
826
+ if (!bindings.has(head)) continue;
827
+ if (!call.args.some(isRootArg)) continue;
828
+ armed.push({ callee: call.callee, line: lineOf(call.index) });
829
+ }
830
+ if (armed.length === 0) continue;
831
+
832
+ const closure = importClosure(repoRoot, testFile, readSource);
833
+ /** @type {Set<string>} */
834
+ const reported = new Set();
835
+ for (const [mod, chain] of closure) {
836
+ const modSrc = readSource(mod);
837
+ if (!modSrc) continue;
838
+ for (const { candidate, line: modLine } of extractPathCandidates(modSrc.original, modSrc.masked)) {
839
+ if (reported.has(candidate)) continue;
840
+ const site = armed.find((a) => !hasMarker(src.lines, a.line));
841
+ if (!site) continue;
842
+ reported.add(candidate);
843
+ const chainStr = chain.map(rel).join(' → ');
844
+ raw.push({
845
+ rule: 'R2',
846
+ file: testFile,
847
+ line: site.line,
848
+ candidate,
849
+ message: `${rel(testFile)}:${site.line} — passes a real repo root to \`${site.callee}()\`; its import closure reaches ${rel(mod)}:${modLine}, which names "${candidate}" — a path that is NOT in the repository (chain: ${chainStr})`,
850
+ });
851
+ }
852
+ }
853
+ }
854
+
855
+ const { untracked, error } = resolveUntrackedOracle(repoRoot, raw.map((f) => f.candidate));
856
+ return { findings: raw.filter((f) => untracked.has(f.candidate)), error };
857
+ }
858
+
859
+ /**
860
+ * @param {string[]} lines
861
+ * @param {number} lineNo 1-based
862
+ * @returns {boolean}
863
+ */
864
+ function hasMarker(lines, lineNo) {
865
+ const l = lines[lineNo - 1];
866
+ return typeof l === 'string' && l.includes(IGNORE_MARKER);
867
+ }
868
+
869
+ // ---------------------------------------------------------------------------
870
+ // CLI
871
+ // ---------------------------------------------------------------------------
872
+
873
+ /**
874
+ * Run the check against a repo root, printing the validate-plugin line
875
+ * vocabulary.
876
+ *
877
+ * @param {string} repoRoot
878
+ * @returns {number} 0 = clean, 1 = finding(s), 2 = tool error
879
+ */
880
+ export function runCheckUntrackedTestDeps(repoRoot) {
881
+ console.log('--- Check: untracked test dependencies (R2 transitive / R4 cwd-relative) ---');
882
+
883
+ const gitCheck = spawnSync('git', ['rev-parse', '--is-inside-work-tree'], {
884
+ cwd: repoRoot, encoding: 'utf8',
885
+ });
886
+ if (gitCheck.status !== 0) {
887
+ console.log(` FAIL: not a git repository: ${repoRoot} — the untracked oracle needs git`);
888
+ console.log('');
889
+ console.log('Results: 0 passed, 1 failed');
890
+ return 2;
891
+ }
892
+
893
+ const { findings, error } = scanUntrackedTestDeps(repoRoot);
894
+ if (error) {
895
+ console.log(` FAIL: oracle error — ${error}`);
896
+ console.log('');
897
+ console.log('Results: 0 passed, 1 failed');
898
+ return 2;
899
+ }
900
+
901
+ const rules = ['R2', 'R4'];
902
+ const hit = new Set(findings.map((f) => f.rule));
903
+ for (const r of rules) {
904
+ if (hit.has(r)) continue;
905
+ const what = r === 'R2'
906
+ ? 'no test passes a real repo root into an import closure that names an untracked path'
907
+ : 'no test reads an untracked path cwd-relative';
908
+ console.log(` PASS: ${r} — ${what}`);
909
+ }
910
+ for (const f of findings) console.log(` FAIL: [${f.rule}] ${f.message}`);
911
+
912
+ console.log('');
913
+ console.log(`Results: ${rules.length - hit.size} passed, ${findings.length} failed`);
914
+ return findings.length > 0 ? 1 : 0;
915
+ }
916
+
917
+ const isMain = import.meta.url === pathToFileURL(process.argv[1] || '').href;
918
+ if (isMain) {
919
+ const root = process.argv[2];
920
+ if (!root) {
921
+ console.error('Usage: check-untracked-test-deps.mjs <repo-root>');
922
+ process.exit(2);
923
+ }
924
+ process.exit(runCheckUntrackedTestDeps(path.resolve(root)));
925
+ }