faberun 0.3.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 (144) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +131 -0
  3. package/bin/faberun.mjs +25 -0
  4. package/integrations/claude-code/statusline-bench.sh +42 -0
  5. package/integrations/claude-code/statusline.sh +80 -0
  6. package/package.json +33 -0
  7. package/skills/faberun/SKILL.md +24 -0
  8. package/skills/faberun/references/contract.md +380 -0
  9. package/skills/faberun/references/engineering.md +29 -0
  10. package/skills/faberun/references/handoffs.md +26 -0
  11. package/skills/faberun/references/operations.md +184 -0
  12. package/skills/faberun/references/rules.md +35 -0
  13. package/skills/faberun/references/workflow.md +23 -0
  14. package/skills/init-agentkit/SKILL.md +108 -0
  15. package/skills/init-agentkit/scripts/install-agentkit.sh +127 -0
  16. package/skills/init-agentkit/templates/.claude/commands/create-adr.md +44 -0
  17. package/skills/init-agentkit/templates/.github/workflows/quality.yml +43 -0
  18. package/skills/init-agentkit/templates/.sentrux/baseline.json +9 -0
  19. package/skills/init-agentkit/templates/.sentrux/rules.toml +21 -0
  20. package/skills/init-agentkit/templates/AGENTS.md +110 -0
  21. package/skills/init-agentkit/templates/docs/ABSTRACTIONS.md +30 -0
  22. package/skills/init-agentkit/templates/docs/ARCHITECTURE.md +31 -0
  23. package/skills/init-agentkit/templates/docs/GETTING-STARTED.md +44 -0
  24. package/skills/init-agentkit/templates/docs/VISION.md +33 -0
  25. package/skills/init-agentkit/templates/docs/adr/0001-record-architecture-decisions.md +36 -0
  26. package/skills/init-agentkit/templates/docs/adr/0002-root-managed-ai-guidance.md +37 -0
  27. package/skills/init-agentkit/templates/docs/adr/0003-sentrux-structural-quality-gates.md +49 -0
  28. package/skills/init-agentkit/templates/docs/adr/README.md +52 -0
  29. package/skills/init-agentkit/templates/docs/sentrux.md +66 -0
  30. package/skills/init-agentkit/templates/githooks/commit-msg +22 -0
  31. package/skills/init-agentkit/templates/githooks/pre-commit +32 -0
  32. package/src/campaign/brief.mjs +394 -0
  33. package/src/campaign/chain.mjs +555 -0
  34. package/src/campaign/handoff.mjs +516 -0
  35. package/src/campaign/index.mjs +300 -0
  36. package/src/campaign/journal.mjs +347 -0
  37. package/src/campaign/layout.mjs +51 -0
  38. package/src/campaign/metrics-evals.mjs +25 -0
  39. package/src/campaign/metrics.mjs +517 -0
  40. package/src/campaign/projection.mjs +250 -0
  41. package/src/campaign/record.mjs +102 -0
  42. package/src/campaign/unpark.mjs +56 -0
  43. package/src/cli/brand.mjs +205 -0
  44. package/src/cli/campaign.mjs +730 -0
  45. package/src/cli/contract.mjs +67 -0
  46. package/src/cli/init.mjs +170 -0
  47. package/src/cli/launch.mjs +239 -0
  48. package/src/cli/seat.mjs +139 -0
  49. package/src/cli/setup.mjs +294 -0
  50. package/src/cli/skills.mjs +105 -0
  51. package/src/cli/update.mjs +216 -0
  52. package/src/cli.mjs +525 -0
  53. package/src/contract/articles.mjs +12 -0
  54. package/src/contract/assert.mjs +162 -0
  55. package/src/contract/definition-of-done.mjs +97 -0
  56. package/src/contract/final-verification.mjs +96 -0
  57. package/src/contract/index.mjs +641 -0
  58. package/src/contract/judge-envelope.mjs +25 -0
  59. package/src/contract/review-modes.mjs +151 -0
  60. package/src/contract/runtime.mjs +204 -0
  61. package/src/contract/schema-version.mjs +25 -0
  62. package/src/contract/scope-findings.mjs +77 -0
  63. package/src/contract/snapshot.mjs +639 -0
  64. package/src/contract/task-packet.mjs +495 -0
  65. package/src/contract/untrusted.mjs +75 -0
  66. package/src/contract/verification.mjs +185 -0
  67. package/src/contract/worker-result.mjs +138 -0
  68. package/src/engine/assignment.mjs +63 -0
  69. package/src/engine/backoff.mjs +492 -0
  70. package/src/engine/bulk-read.mjs +361 -0
  71. package/src/engine/cancel.mjs +177 -0
  72. package/src/engine/detach.mjs +101 -0
  73. package/src/engine/dispatch.mjs +752 -0
  74. package/src/engine/failover.mjs +192 -0
  75. package/src/engine/gate.mjs +183 -0
  76. package/src/engine/judge-gate.mjs +517 -0
  77. package/src/engine/lifecycle.mjs +772 -0
  78. package/src/engine/live-preflight.mjs +299 -0
  79. package/src/engine/mutation.mjs +146 -0
  80. package/src/engine/notify-queue.mjs +327 -0
  81. package/src/engine/process-identity.mjs +72 -0
  82. package/src/engine/process.mjs +774 -0
  83. package/src/engine/prompts.mjs +289 -0
  84. package/src/engine/recover.mjs +300 -0
  85. package/src/engine/result-file.mjs +222 -0
  86. package/src/engine/resume.mjs +635 -0
  87. package/src/engine/retry.mjs +334 -0
  88. package/src/engine/review.mjs +228 -0
  89. package/src/engine/run-command.mjs +287 -0
  90. package/src/engine/run-identity.mjs +411 -0
  91. package/src/engine/runtime-discovery.mjs +235 -0
  92. package/src/engine/scheduler.mjs +526 -0
  93. package/src/engine/scope.mjs +378 -0
  94. package/src/engine/settle.mjs +207 -0
  95. package/src/engine/state.mjs +148 -0
  96. package/src/engine/supervise.mjs +713 -0
  97. package/src/engine/verify.mjs +167 -0
  98. package/src/harnesses/agy/index.mjs +62 -0
  99. package/src/harnesses/catalogue.mjs +509 -0
  100. package/src/harnesses/claude/index.mjs +90 -0
  101. package/src/harnesses/codex/index.mjs +87 -0
  102. package/src/harnesses/dsh/closed-packet.patch.yml +42 -0
  103. package/src/harnesses/dsh/index.mjs +210 -0
  104. package/src/harnesses/dsh/runner.mjs +259 -0
  105. package/src/harnesses/exec-jsonl/index.mjs +788 -0
  106. package/src/harnesses/index.mjs +508 -0
  107. package/src/harnesses/protocol.mjs +531 -0
  108. package/src/harnesses/replay/bin.mjs +386 -0
  109. package/src/harnesses/replay/index.mjs +238 -0
  110. package/src/harnesses/zcode/index.mjs +276 -0
  111. package/src/host/config.mjs +87 -0
  112. package/src/host/home.mjs +149 -0
  113. package/src/host/package.mjs +23 -0
  114. package/src/host/preflight.mjs +520 -0
  115. package/src/host/tool-policy-decisions.mjs +341 -0
  116. package/src/host/tool-policy-hook.mjs +270 -0
  117. package/src/notify/index.mjs +359 -0
  118. package/src/notify/os-macos.mjs +81 -0
  119. package/src/repo/declared-paths.mjs +220 -0
  120. package/src/repo/integrate.mjs +546 -0
  121. package/src/repo/scope-closure.mjs +665 -0
  122. package/src/repo/signal-block.mjs +16 -0
  123. package/src/repo/signal.mjs +222 -0
  124. package/src/repo/source-identity.mjs +295 -0
  125. package/src/repo/workspace.mjs +557 -0
  126. package/src/repo/worktree.mjs +352 -0
  127. package/src/report/final.mjs +200 -0
  128. package/src/report/metrics-report.mjs +99 -0
  129. package/src/report/next.mjs +383 -0
  130. package/src/report/render.mjs +716 -0
  131. package/src/run/disk-gc.mjs +251 -0
  132. package/src/run/lock.mjs +329 -0
  133. package/src/run/node-store.mjs +62 -0
  134. package/src/run/operations.mjs +286 -0
  135. package/src/run/store.mjs +187 -0
  136. package/src/run/usage.mjs +337 -0
  137. package/src/seat/harnesses.mjs +83 -0
  138. package/src/seat/index.mjs +239 -0
  139. package/src/seat/tmux.mjs +208 -0
  140. package/src/util.mjs +0 -0
  141. package/src/web/api.mjs +371 -0
  142. package/src/web/boundary.mjs +88 -0
  143. package/src/web/index.html +299 -0
  144. package/src/web/server.mjs +552 -0
@@ -0,0 +1,665 @@
1
+ /**
2
+ * Whether the scope a packet declared actually closes: every repository file
3
+ * that a declared write drags along must be declared or explicitly dismissed.
4
+ *
5
+ * A `writeFiles` list names the files a node may change, but changing a module
6
+ * obliges its importers, changing a declared symbol obliges the files that
7
+ * mention it, and adding a file to a directory obliges the tests that assert
8
+ * what that directory contains. None of those obligations is visible in the
9
+ * packet, and three incidents in two campaigns came from exactly that gap.
10
+ * Three mechanical detectors point at the dragged-along files, and
11
+ * `validateContract` refuses until each is declared. What counts as declared
12
+ * depends on what the detector asks. The directory enumerator asks what a
13
+ * listing will show, so `readFiles` answers it. The import and symbol detectors
14
+ * ask who may *break*, and reading a broken file cannot repair it, so only
15
+ * `writeFiles`, a covering `writeRoots`, or `scopeAcknowledged` answers those.
16
+ *
17
+ * It reads the target repository to validate a contract, which is why it sits
18
+ * in `repo/` beside declared-paths.mjs. It also carries the runtime import edge
19
+ * parser that used to live in `test/repo/source-shape.test.mjs`; the shape gate
20
+ * refuses two copies of that body, so there is one home and both import it.
21
+ *
22
+ * Calibration, measured 2026-09-13 against the six recorded contracts under
23
+ * `.runs/campaigns/<campaign>/control` (p1, p2, p3, sp1, sp2, sp2b; the running
24
+ * sp25 contract is excluded), found 26 findings. The three incidents that cost
25
+ * the hours are all in that set:
26
+ * p3 bulk-read -> test/installer.test.mjs (directory enumerator)
27
+ * sp1 lossy-notify -> src/engine/scheduler.mjs (reverse import)
28
+ * sp2 seat-switch -> test/cli/cli.test.mjs (reverse import of the
29
+ * entry point the test runs)
30
+ * The first pass was wider: a bare "every importer of a written module" rule
31
+ * reported 23 files for p3 bulk-read alone, most of them importers of the broad
32
+ * `cli.mjs` surface, and an ancestor-based directory rule then re-reported
33
+ * `installer.test.mjs` for every deep write under `skills/mine`. The narrowing
34
+ * kept all three: detector 1 only counts an importer that takes one of the
35
+ * packet's declared `symbols` (a packet that declares no symbols declares no
36
+ * surface, and the detector abstains), the one test it follows through an entry
37
+ * point is the test named after that entry, and detector 3 only fires when the
38
+ * written file names the direct child the enumerator reads.
39
+ *
40
+ * A fourth obligation only exists between nodes, so it is checked per contract
41
+ * rather than per packet. Node A writes a test that points at a path node B
42
+ * also writes; B's correct implementation changes that test, but B's packet
43
+ * does not permit it. seat-switch is the recorded instance: seat-lifecycle
44
+ * wrote `test/cli/cli.test.mjs` with an assertion that fixed the usage line,
45
+ * and seat-switch had to change `src/cli.mjs` without holding the test. Each
46
+ * packet read alone is fine; the pair is impossible. `crossNodeScopeFindings`
47
+ * reads the pair together and refuses the contract.
48
+ */
49
+ import { errorCode } from "../util.mjs";
50
+ import { readdirSync, readFileSync } from "node:fs";
51
+ import { basename, dirname, join, relative, resolve, sep } from "node:path";
52
+
53
+ /** @typedef {import("../contract/index.mjs").TaskPacket} TaskPacket */
54
+ /** @typedef {import("../contract/index.mjs").ValidatedNode} ValidatedNode */
55
+ /** @typedef {{path: string, detector: "imports"|"symbols"|"directory", reason: string}} ScopeClosureFinding */
56
+ /** @typedef {{path: string, detector: "cross-node", reason: string, nodeIndex: number, nodeId: string}} CrossNodeScopeFinding */
57
+
58
+ /**
59
+ * Directories that hold dependencies, run state, or historical worktree copies
60
+ * rather than repository source. Walking them would report every vendored
61
+ * importer and every `.runs/` worktree as a scope violation.
62
+ */
63
+ const SKIPPED_DIRECTORIES = new Set([
64
+ ".git",
65
+ ".runs",
66
+ "node_modules",
67
+ ".claude",
68
+ ".codex",
69
+ ".venv",
70
+ "venv",
71
+ "dist",
72
+ "coverage",
73
+ ]);
74
+
75
+ const IMPORT_FROM = /\b(?:import|export)\s+([\s\S]*?)\s+from\s*"(\.[^"]+)"/gu;
76
+ const SIDE_EFFECT_IMPORT = /\bimport\s*"(\.[^"]+)"/gu;
77
+ const EXECUTED_URL = /new URL\(\s*"(\.[^"]+)"\s*,\s*import\.meta\.url\s*\)/gu;
78
+ const DIRECTORY_CALL = /\b(readdirSync|readdir|globSync|glob)\s*\(/gu;
79
+ const CONSTANT = /\bconst\s+([A-Za-z_$][\w$]*)\s*=\s*([^;\n]+);/gu;
80
+
81
+ /**
82
+ * Runtime import edges only, keyed by `root`-relative module path. A JSDoc
83
+ * `import("./x.mjs").Type` is erased before the module loads; counting those
84
+ * reports cycles that do not exist, so only real `import`/`export ... from`
85
+ * statements are edges.
86
+ *
87
+ * @param {string} root absolute directory to walk
88
+ * @returns {Map<string, string[]>}
89
+ */
90
+ export function runtimeImportGraph(root) {
91
+ /** @type {Map<string, string[]>} */
92
+ const graph = new Map();
93
+ for (const [path, source] of repositorySources(root)) {
94
+ /** @type {Set<string>} */
95
+ const specifiers = new Set();
96
+ for (const record of parseImportRecords(source.text)) specifiers.add(record.specifier);
97
+ graph.set(
98
+ path,
99
+ [...specifiers].map((specifier) => resolveSpecifier(source.absolute, root, specifier)).sort(),
100
+ );
101
+ }
102
+ return graph;
103
+ }
104
+
105
+ /**
106
+ * Files a node's declared writes drag into the same change, minus those the
107
+ * packet already declares or dismisses.
108
+ *
109
+ * @param {ValidatedNode} node
110
+ * @param {number} index
111
+ * @param {string} cwd
112
+ * @returns {ScopeClosureFinding[]}
113
+ */
114
+ export function scopeClosureFindings(node, index, cwd) {
115
+ const packet = node.taskPacket;
116
+ const writeFiles = [...(packet.writeFiles ?? [])];
117
+ if (writeFiles.length === 0) return [];
118
+ const declared = declaredPaths(packet);
119
+ const repairable = repairablePaths(packet);
120
+ const sources = repositorySources(cwd);
121
+ const findings = [
122
+ ...reverseImportFindings(packet, repairable, sources, cwd),
123
+ ...symbolMentionFindings(packet, repairable, sources),
124
+ ...directoryEnumeratorFindings(writeFiles, declared, sources, cwd),
125
+ ];
126
+ // Each detector has already filtered against the set its own question calls
127
+ // for; a second filter here would re-admit a `readFiles` path the repair
128
+ // detectors just refused.
129
+ /** @type {Map<string, ScopeClosureFinding>} */
130
+ const byPath = new Map();
131
+ for (const finding of findings) {
132
+ if (byPath.has(finding.path)) continue;
133
+ byPath.set(finding.path, finding);
134
+ }
135
+ return [...byPath.values()];
136
+ }
137
+
138
+ /**
139
+ * Every path a packet covers at all: what it may read, what it may write, its
140
+ * write roots, and what its author acknowledged. This answers "is the worker
141
+ * allowed to look at it", which is the right question for a detector about
142
+ * what a directory listing will show.
143
+ *
144
+ * @param {TaskPacket} packet
145
+ * @returns {Set<string>}
146
+ */
147
+ function declaredPaths(packet) {
148
+ return new Set([
149
+ ...(packet.readFiles ?? []),
150
+ ...(packet.writeFiles ?? []),
151
+ ...(packet.writeRoots ?? []),
152
+ ...(packet.scopeAcknowledged ?? []),
153
+ ]);
154
+ }
155
+
156
+ /**
157
+ * The paths a packet can actually *repair*. `readFiles` is deliberately absent:
158
+ * a file that imports a symbol this node rewrites may break, and reading it
159
+ * cannot fix it — only a write, a covering write root, or the author saying out
160
+ * loud that the breakage is accepted. Detector 4 has always drawn the line here
161
+ * for the cross-node case; the import and symbol detectors draw it too, because
162
+ * the obligation is identical. Declaring the file in `readFiles` used to satisfy
163
+ * them, which is how a node shipped instructions naming a module as a reader it
164
+ * must account for while its packet withheld permission to touch it.
165
+ *
166
+ * @param {TaskPacket} packet
167
+ * @returns {Set<string>}
168
+ */
169
+ function repairablePaths(packet) {
170
+ return new Set([
171
+ ...(packet.writeFiles ?? []),
172
+ ...(packet.writeRoots ?? []),
173
+ ...(packet.scopeAcknowledged ?? []),
174
+ ]);
175
+ }
176
+
177
+ /**
178
+ * DETECTOR 4, the obligation no single packet can show. Node A writes a test
179
+ * whose content points at a path node B writes, so B's correct implementation
180
+ * changes that test -- and if B's packet does not permit the test, B is
181
+ * structurally stuck: the fix breaks a test B may not touch. `readFiles` does
182
+ * not count, because reading the test cannot repair it; only `writeFiles`, a
183
+ * covering `writeRoots`, or `scopeAcknowledged` does.
184
+ *
185
+ * The reference is read through the same parser the per-node detectors use --
186
+ * runtime imports and executed `new URL`s -- not by pattern-matching the test's
187
+ * prose, which is why a test that only asserts on a string surface abstains
188
+ * here and node-local detector 2 covers the symbol-name case.
189
+ *
190
+ * @param {ValidatedNode[]} nodes all nodes of one contract
191
+ * @param {string} cwd
192
+ * @returns {CrossNodeScopeFinding[]} one finding per (node, test) pair, naming
193
+ * the node whose packet must change
194
+ */
195
+ export function crossNodeScopeFindings(nodes, cwd) {
196
+ const sources = repositorySources(cwd);
197
+ /** @type {CrossNodeScopeFinding[]} */
198
+ const findings = [];
199
+ const seen = new Set();
200
+ for (const [writerIndex, writer] of nodes.entries()) {
201
+ for (const testPath of writer.taskPacket.writeFiles ?? []) {
202
+ if (!isTestPath(testPath)) continue;
203
+ const source = sources.get(testPath);
204
+ if (!source) continue;
205
+ const references = referencePaths(source, cwd);
206
+ if (references.size === 0) continue;
207
+ for (const [targetIndex, target] of nodes.entries()) {
208
+ if (targetIndex === writerIndex) continue;
209
+ const targetWrites = new Set(target.taskPacket.writeFiles ?? []);
210
+ const rootCovers = (target.taskPacket.writeRoots ?? []).some(
211
+ (root) => testPath === root || testPath.startsWith(`${root}/`),
212
+ );
213
+ const declared = new Set([...(target.taskPacket.scopeAcknowledged ?? []), ...targetWrites]);
214
+ if (rootCovers || declared.has(testPath)) continue;
215
+ const reference = [...references].find((path) => targetWrites.has(path));
216
+ if (reference === undefined) continue;
217
+ const key = `${targetIndex}:${testPath}`;
218
+ if (seen.has(key)) continue;
219
+ seen.add(key);
220
+ findings.push({
221
+ path: testPath,
222
+ detector: "cross-node",
223
+ nodeIndex: targetIndex,
224
+ nodeId: target.id,
225
+ reason: `this node writes ${reference}, which ${writer.id} wrote a test against; declare ${testPath} in writeFiles or scopeAcknowledged so this node may change the test its work breaks`,
226
+ });
227
+ }
228
+ }
229
+ }
230
+ return findings;
231
+ }
232
+
233
+ /**
234
+ * DETECTOR 1. An importer of a written module must change with it, so it is in
235
+ * scope unless declared. A packet that names `symbols` narrows the rule to
236
+ * importers that take one of those symbols -- the surface the node announced it
237
+ * changes -- because the broad entry modules (`cli.mjs`, `harnesses/index.mjs`)
238
+ * are imported by half the tree for reasons nothing here touches. A test that
239
+ * executes an entry point importing a written module is the same obligation one
240
+ * hop out, and is the only reason a test with no direct import is in scope.
241
+ *
242
+ * @param {ValidatedNode["taskPacket"]} packet
243
+ * @param {Set<string>} declared
244
+ * @param {Map<string, {absolute: string, text: string}>} sources
245
+ * @param {string} cwd
246
+ * @returns {ScopeClosureFinding[]}
247
+ */
248
+ function reverseImportFindings(packet, declared, sources, cwd) {
249
+ const writeFiles = packet.writeFiles ?? [];
250
+ const written = new Set(writeFiles);
251
+ const symbols = new Set(packet.symbols ?? []);
252
+ /** @type {Map<string, string[]>} */
253
+ const importersOf = new Map();
254
+ for (const [importer, source] of sources) {
255
+ for (const record of parseImportRecords(source.text)) {
256
+ const target = resolveSpecifier(source.absolute, cwd, record.specifier);
257
+ importersOf.set(target, [...(importersOf.get(target) ?? []), importer]);
258
+ }
259
+ }
260
+
261
+ /** @type {ScopeClosureFinding[]} */
262
+ const findings = [];
263
+ for (const target of writeFiles) {
264
+ for (const importer of importersOf.get(target) ?? []) {
265
+ if (declared.has(importer) || symbols.size === 0) continue;
266
+ const source = sources.get(importer);
267
+ if (!source) continue;
268
+ const takesDeclared = parseImportRecords(source.text).some(
269
+ (record) =>
270
+ resolveSpecifier(source.absolute, cwd, record.specifier) === target &&
271
+ [...record.names].some((name) => symbols.has(name)),
272
+ );
273
+ if (!takesDeclared) continue;
274
+ findings.push({ path: importer, detector: "imports", reason: `imports ${target}, which this node writes` });
275
+ }
276
+ }
277
+
278
+ for (const [path, source] of sources) {
279
+ if (declared.has(path) || !isTestPath(path)) continue;
280
+ const stem = basename(path).replace(/\.test\.mjs$/u, "");
281
+ for (const executed of executedReferencePaths(source, cwd)) {
282
+ if (declared.has(executed)) continue;
283
+ if (basename(executed).replace(/\.mjs$/u, "") !== stem) continue;
284
+ const entry = sources.get(executed);
285
+ if (!entry) continue;
286
+ for (const record of parseImportRecords(entry.text)) {
287
+ const target = resolveSpecifier(entry.absolute, cwd, record.specifier);
288
+ if (!written.has(target)) continue;
289
+ findings.push({ path, detector: "imports", reason: `runs ${executed}, which imports ${target}` });
290
+ }
291
+ }
292
+ }
293
+ return findings;
294
+ }
295
+
296
+ /**
297
+ * DETECTOR 2. `symbols` is the packet's own statement of the surface it will
298
+ * touch, so any code that mentions one of those names is dragged along.
299
+ *
300
+ * Comments are removed first: a mention in prose is not a reference. Strings
301
+ * are NOT removed, and that is deliberate. Stripping them missed the five
302
+ * fixtures in `test/helpers.mjs` that build fake worker programs inside
303
+ * template literals, and removing `changedFiles` from the protocol broke every
304
+ * one of them -- a real breakage the packet could not see. This repository
305
+ * embeds generated programs in string literals often enough that the AGENTS.md
306
+ * extraction-hazard note calls it out by name; a checker that cannot read them
307
+ * is blind exactly where this tree is dangerous.
308
+ *
309
+ * The cost is false positives from prose inside strings. That is the right
310
+ * trade for a check whose answer is "declare it or acknowledge it": an extra
311
+ * path to dismiss costs a line, a missed one costs a blocked phase.
312
+ *
313
+ * @param {ValidatedNode["taskPacket"]} packet
314
+ * @param {Set<string>} declared
315
+ * @param {Map<string, {absolute: string, text: string}>} sources
316
+ * @returns {ScopeClosureFinding[]}
317
+ */
318
+ function symbolMentionFindings(packet, declared, sources) {
319
+ const symbols = packet.symbols ?? [];
320
+ if (symbols.length === 0) return [];
321
+ /** @type {ScopeClosureFinding[]} */
322
+ const findings = [];
323
+ for (const [path, source] of sources) {
324
+ if (declared.has(path)) continue;
325
+ const code = stripComments(source.text);
326
+ const symbol = symbols.find((name) => new RegExp(`\\b${escapeRegExp(name)}\\b`, "u").test(code));
327
+ if (symbol) findings.push({ path, detector: "symbols", reason: `mentions ${symbol}, which this node declares it will change` });
328
+ }
329
+ return findings;
330
+ }
331
+
332
+ /**
333
+ * DETECTOR 3. A file written into a directory changes what that directory
334
+ * contains, so any test that enumerates it -- directly, or by running a module
335
+ * that does -- asserts a set that is about to change. `test/installer.test.mjs`
336
+ * asserted "2 installed" and broke when a third skill directory appeared; it
337
+ * never calls `readdirSync` itself, it runs `bin/skills.mjs`, which does. The
338
+ * enumerating module is not the finding; the test that depends on it is.
339
+ *
340
+ * @param {string[]} writeFiles
341
+ * @param {Set<string>} declared
342
+ * @param {Map<string, {absolute: string, text: string}>} sources
343
+ * @param {string} cwd
344
+ * @returns {ScopeClosureFinding[]}
345
+ */
346
+ function directoryEnumeratorFindings(writeFiles, declared, sources, cwd) {
347
+ const affected = [...new Set(writeFiles.map((path) => dirname(path).split(sep).join("/")))];
348
+ /** @type {Map<string, string[]>} */
349
+ const referrersOf = new Map();
350
+ for (const [path, source] of sources) {
351
+ for (const reference of referencePaths(source, cwd)) {
352
+ referrersOf.set(reference, [...(referrersOf.get(reference) ?? []), path]);
353
+ }
354
+ }
355
+
356
+ /** @type {ScopeClosureFinding[]} */
357
+ const findings = [];
358
+ for (const [path, source] of sources) {
359
+ for (const directory of directoryScans(source, cwd)) {
360
+ // `readdir` sees direct children only: a write changes the set when the
361
+ // scanned directory is the written file's own directory, or its parent
362
+ // (the written file names the new entry). A write deep inside an existing
363
+ // subtree leaves the listing unchanged.
364
+ if (!affected.some((target) => directory === target || directory === dirname(target))) continue;
365
+ if (!declared.has(path) && isTestPath(path)) {
366
+ findings.push({ path, detector: "directory", reason: `enumerates ${directory || "."} while this node writes beneath it` });
367
+ }
368
+ for (const referrer of referrersOf.get(path) ?? []) {
369
+ if (!declared.has(referrer) && isTestPath(referrer)) {
370
+ findings.push({ path: referrer, detector: "directory", reason: `runs ${path}, which enumerates ${directory || "."}` });
371
+ }
372
+ }
373
+ }
374
+ }
375
+ return findings;
376
+ }
377
+
378
+ /**
379
+ * Every `.mjs` under `root` except dependency, run-state, and worktree
380
+ * directories, keyed by `root`-relative path.
381
+ *
382
+ * @param {string} root
383
+ * @returns {Map<string, {absolute: string, text: string}>}
384
+ */
385
+ function repositorySources(root) {
386
+ /** @type {Map<string, {absolute: string, text: string}>} */
387
+ const sources = new Map();
388
+ for (const absolute of walkMjs(root)) {
389
+ sources.set(relativeKey(root, absolute), { absolute, text: readFileSync(absolute, "utf8") });
390
+ }
391
+ return sources;
392
+ }
393
+
394
+ /**
395
+ * @param {string} directory
396
+ * @returns {Generator<string>}
397
+ */
398
+ function* walkMjs(directory) {
399
+ let entries;
400
+ try {
401
+ entries = readdirSync(directory, { withFileTypes: true });
402
+ } catch (error) {
403
+ if (errorCode(error) === "ENOENT" || errorCode(error) === "ENOTDIR") return;
404
+ throw error;
405
+ }
406
+ for (const entry of entries) {
407
+ if (entry.isDirectory()) {
408
+ if (SKIPPED_DIRECTORIES.has(entry.name)) continue;
409
+ yield* walkMjs(join(directory, entry.name));
410
+ } else if (entry.isFile() && entry.name.endsWith(".mjs")) {
411
+ yield join(directory, entry.name);
412
+ }
413
+ }
414
+ }
415
+
416
+ /**
417
+ * Relative specifiers, their imported binding names, and side-effect imports.
418
+ *
419
+ * @param {string} text
420
+ * @returns {{specifier: string, names: Set<string>}[]}
421
+ */
422
+ function parseImportRecords(text) {
423
+ const source = stripComments(text);
424
+ /** @type {{specifier: string, names: Set<string>}[]} */
425
+ const records = [];
426
+ for (const match of source.matchAll(IMPORT_FROM)) {
427
+ records.push({ specifier: match[2], names: bindingNames(match[1]) });
428
+ }
429
+ for (const match of source.matchAll(SIDE_EFFECT_IMPORT)) {
430
+ records.push({ specifier: match[1], names: new Set() });
431
+ }
432
+ return records;
433
+ }
434
+
435
+ /**
436
+ * The names a clause imports, including re-exported source names. A renamed
437
+ * `{ a as b }` contributes `a`, because that is the surface the written module
438
+ * declares.
439
+ *
440
+ * @param {string} clause
441
+ * @returns {Set<string>}
442
+ */
443
+ function bindingNames(clause) {
444
+ /** @type {Set<string>} */
445
+ const names = new Set();
446
+ const braces = clause.match(/\{([\s\S]*?)\}/u);
447
+ if (braces) {
448
+ for (const part of braces[1].split(",")) {
449
+ const name = part.trim().split(/\s+as\s+/u)[0].trim();
450
+ if (name) names.add(name);
451
+ }
452
+ }
453
+ const head = clause.replace(/\{[\s\S]*?\}/u, "").replace(/,/gu, " ").trim().split(/\s+/u)[0];
454
+ if (head && head !== "*" && !head.startsWith("*")) names.add(head);
455
+ return names;
456
+ }
457
+
458
+ /**
459
+ * @param {string} text
460
+ * @returns {string}
461
+ */
462
+ function stripComments(text) {
463
+ return text.replace(/\/\*(?:[^*]|\*(?!\/))*\*\//gu, " ").replace(/\/\/[^\n]*/gu, " ");
464
+ }
465
+
466
+
467
+
468
+ /**
469
+ * Modules a file points at, by import or by an executed `new URL`. Both are
470
+ * edges for the directory detector; only the executed ones count as "runs".
471
+ *
472
+ * @param {{absolute: string, text: string}} source
473
+ * @param {string} cwd
474
+ * @returns {Set<string>}
475
+ */
476
+ function referencePaths(source, cwd) {
477
+ /** @type {Set<string>} */
478
+ const paths = new Set();
479
+ for (const record of parseImportRecords(source.text)) paths.add(resolveSpecifier(source.absolute, cwd, record.specifier));
480
+ for (const path of executedReferencePaths(source, cwd)) paths.add(path);
481
+ return paths;
482
+ }
483
+
484
+ /**
485
+ * @param {{absolute: string, text: string}} source
486
+ * @param {string} cwd
487
+ * @returns {string[]}
488
+ */
489
+ function executedReferencePaths(source, cwd) {
490
+ /** @type {string[]} */
491
+ const paths = [];
492
+ for (const match of source.text.matchAll(EXECUTED_URL)) {
493
+ paths.push(relativeKey(cwd, resolve(dirname(source.absolute), match[1])));
494
+ }
495
+ return paths;
496
+ }
497
+
498
+ /**
499
+ * Directories a file enumerates through `readdirSync`/`readdir`/`glob`, resolved
500
+ * from literals, `join(...)` chains, and the constants those chains are built
501
+ * from. An argument that cannot be resolved statically is not guessed.
502
+ *
503
+ * @param {{absolute: string, text: string}} source
504
+ * @param {string} cwd
505
+ * @returns {string[]}
506
+ */
507
+ function directoryScans(source, cwd) {
508
+ /** @type {string[]} */
509
+ const directories = [];
510
+ for (const match of source.text.matchAll(DIRECTORY_CALL)) {
511
+ const args = argumentText(source.text, match.index + match[0].length);
512
+ for (const part of splitTopLevel(args)) {
513
+ const directory = resolvePathExpression(part, source, cwd, new Set());
514
+ if (directory !== null) directories.push(directory);
515
+ }
516
+ }
517
+ return directories;
518
+ }
519
+
520
+ /**
521
+ * The text between an opening parenthesis and its match.
522
+ *
523
+ * @param {string} text
524
+ * @param {number} start index just past the opening parenthesis
525
+ * @returns {string}
526
+ */
527
+ function argumentText(text, start) {
528
+ let depth = 1;
529
+ let index = start;
530
+ while (index < text.length && depth > 0) {
531
+ const char = text[index];
532
+ if (char === "(") depth += 1;
533
+ else if (char === ")") {
534
+ depth -= 1;
535
+ if (depth === 0) break;
536
+ }
537
+ index += 1;
538
+ }
539
+ return text.slice(start, index);
540
+ }
541
+
542
+ /**
543
+ * @param {string} text
544
+ * @returns {string[]}
545
+ */
546
+ function splitTopLevel(text) {
547
+ /** @type {string[]} */
548
+ const parts = [];
549
+ let depth = 0;
550
+ let current = "";
551
+ for (const char of text) {
552
+ if (char === "(" || char === "[" || char === "{") depth += 1;
553
+ if (char === ")" || char === "]" || char === "}") depth -= 1;
554
+ if (char === "," && depth === 0) {
555
+ parts.push(current);
556
+ current = "";
557
+ } else {
558
+ current += char;
559
+ }
560
+ }
561
+ if (current.trim()) parts.push(current);
562
+ return parts.map((part) => part.trim()).filter(Boolean);
563
+ }
564
+
565
+ /**
566
+ * The text of a plain string literal, or null for anything else. Inside a
567
+ * `join(...)` chain a literal is a path segment, not a path from the file.
568
+ *
569
+ * @param {string} expression
570
+ * @returns {string|null}
571
+ */
572
+ function literalValue(expression) {
573
+ const expr = expression.trim();
574
+ const single = /^"([^"]*)"$/u.exec(expr);
575
+ if (single) return single[1];
576
+ const template = /^`([^`$]*)`$/u.exec(expr);
577
+ return template ? template[1] : null;
578
+ }
579
+
580
+ /**
581
+ * @param {string} expression
582
+ * @param {{absolute: string, text: string}} source
583
+ * @param {string} cwd
584
+ * @param {Set<string>} seen constants already expanded, to stop a cycle
585
+ * @returns {string|null}
586
+ */
587
+ function resolvePathExpression(expression, source, cwd, seen) {
588
+ const expr = expression.trim();
589
+ const literal = /^"([^"]*)"$/u.exec(expr);
590
+ if (literal) return relativeKey(cwd, resolve(dirname(source.absolute), literal[1]));
591
+ const template = /^`([^`$]*)`$/u.exec(expr);
592
+ if (template) return relativeKey(cwd, resolve(dirname(source.absolute), template[1]));
593
+ const url = /^fileURLToPath\(\s*new URL\(\s*"([^"]+)"\s*,\s*import\.meta\.url\s*\)\s*\)$/u.exec(expr);
594
+ if (url) return relativeKey(cwd, resolve(dirname(source.absolute), url[1]));
595
+ if (expr === "process.cwd()") return ".";
596
+ const call = /^(?:join|resolve)\(([\s\S]*)\)$/u.exec(expr);
597
+ if (call) {
598
+ let combined = null;
599
+ for (const part of splitTopLevel(call[1])) {
600
+ const segment = literalValue(part);
601
+ if (combined !== null && segment !== null) {
602
+ combined = relativeKey(cwd, resolve(cwd, combined, segment));
603
+ continue;
604
+ }
605
+ const value = resolvePathExpression(part, source, cwd, seen);
606
+ if (value === null) return null;
607
+ combined = combined === null ? value : relativeKey(cwd, resolve(cwd, combined, value));
608
+ }
609
+ return combined;
610
+ }
611
+ if (/^[A-Za-z_$][\w$]*$/u.test(expr)) {
612
+ if (seen.has(expr)) return null;
613
+ const value = constantsIn(source.text).get(expr);
614
+ if (value === undefined) return null;
615
+ seen.add(expr);
616
+ return resolvePathExpression(value, source, cwd, seen);
617
+ }
618
+ return null;
619
+ }
620
+
621
+ /**
622
+ * @param {string} text
623
+ * @returns {Map<string, string>}
624
+ */
625
+ function constantsIn(text) {
626
+ /** @type {Map<string, string>} */
627
+ const constants = new Map();
628
+ for (const match of text.matchAll(CONSTANT)) constants.set(match[1], match[2].trim());
629
+ return constants;
630
+ }
631
+
632
+ /**
633
+ * @param {string} importer absolute path of the importing file
634
+ * @param {string} root
635
+ * @param {string} specifier
636
+ * @returns {string}
637
+ */
638
+ function resolveSpecifier(importer, root, specifier) {
639
+ return relativeKey(root, resolve(dirname(importer), specifier));
640
+ }
641
+
642
+ /**
643
+ * @param {string} root
644
+ * @param {string} absolute
645
+ * @returns {string}
646
+ */
647
+ function relativeKey(root, absolute) {
648
+ return relative(root, absolute).split(sep).join("/");
649
+ }
650
+
651
+ /**
652
+ * @param {string} path
653
+ * @returns {boolean}
654
+ */
655
+ function isTestPath(path) {
656
+ return /(?:^|\/)test\//u.test(path) || /\.test\.mjs$/u.test(path);
657
+ }
658
+
659
+ /**
660
+ * @param {string} value
661
+ * @returns {string}
662
+ */
663
+ function escapeRegExp(value) {
664
+ return value.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
665
+ }
@@ -0,0 +1,16 @@
1
+ export const SIGNAL_START = "<!-- faberun-active:start (managed by faberun — read, never edit) -->";
2
+ export const SIGNAL_END = "<!-- faberun-active:end -->";
3
+
4
+ /**
5
+ * Replace only a complete runner-managed block with a stable marker. Guidance
6
+ * outside the block remains part of source identity.
7
+ *
8
+ * @param {string} text
9
+ * @returns {string}
10
+ */
11
+ export function normalizeManagedSignalBlock(text) {
12
+ const start = text.indexOf(SIGNAL_START);
13
+ const end = text.indexOf(SIGNAL_END, start + SIGNAL_START.length);
14
+ if (start < 0 || end < start) return text;
15
+ return `${text.slice(0, start)}${SIGNAL_START}\n${SIGNAL_END}${text.slice(end + SIGNAL_END.length)}`;
16
+ }