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.
- package/LICENSE +21 -0
- package/README.md +131 -0
- package/bin/faberun.mjs +25 -0
- package/integrations/claude-code/statusline-bench.sh +42 -0
- package/integrations/claude-code/statusline.sh +80 -0
- package/package.json +33 -0
- package/skills/faberun/SKILL.md +24 -0
- package/skills/faberun/references/contract.md +380 -0
- package/skills/faberun/references/engineering.md +29 -0
- package/skills/faberun/references/handoffs.md +26 -0
- package/skills/faberun/references/operations.md +184 -0
- package/skills/faberun/references/rules.md +35 -0
- package/skills/faberun/references/workflow.md +23 -0
- package/skills/init-agentkit/SKILL.md +108 -0
- package/skills/init-agentkit/scripts/install-agentkit.sh +127 -0
- package/skills/init-agentkit/templates/.claude/commands/create-adr.md +44 -0
- package/skills/init-agentkit/templates/.github/workflows/quality.yml +43 -0
- package/skills/init-agentkit/templates/.sentrux/baseline.json +9 -0
- package/skills/init-agentkit/templates/.sentrux/rules.toml +21 -0
- package/skills/init-agentkit/templates/AGENTS.md +110 -0
- package/skills/init-agentkit/templates/docs/ABSTRACTIONS.md +30 -0
- package/skills/init-agentkit/templates/docs/ARCHITECTURE.md +31 -0
- package/skills/init-agentkit/templates/docs/GETTING-STARTED.md +44 -0
- package/skills/init-agentkit/templates/docs/VISION.md +33 -0
- package/skills/init-agentkit/templates/docs/adr/0001-record-architecture-decisions.md +36 -0
- package/skills/init-agentkit/templates/docs/adr/0002-root-managed-ai-guidance.md +37 -0
- package/skills/init-agentkit/templates/docs/adr/0003-sentrux-structural-quality-gates.md +49 -0
- package/skills/init-agentkit/templates/docs/adr/README.md +52 -0
- package/skills/init-agentkit/templates/docs/sentrux.md +66 -0
- package/skills/init-agentkit/templates/githooks/commit-msg +22 -0
- package/skills/init-agentkit/templates/githooks/pre-commit +32 -0
- package/src/campaign/brief.mjs +394 -0
- package/src/campaign/chain.mjs +555 -0
- package/src/campaign/handoff.mjs +516 -0
- package/src/campaign/index.mjs +300 -0
- package/src/campaign/journal.mjs +347 -0
- package/src/campaign/layout.mjs +51 -0
- package/src/campaign/metrics-evals.mjs +25 -0
- package/src/campaign/metrics.mjs +517 -0
- package/src/campaign/projection.mjs +250 -0
- package/src/campaign/record.mjs +102 -0
- package/src/campaign/unpark.mjs +56 -0
- package/src/cli/brand.mjs +205 -0
- package/src/cli/campaign.mjs +730 -0
- package/src/cli/contract.mjs +67 -0
- package/src/cli/init.mjs +170 -0
- package/src/cli/launch.mjs +239 -0
- package/src/cli/seat.mjs +139 -0
- package/src/cli/setup.mjs +294 -0
- package/src/cli/skills.mjs +105 -0
- package/src/cli/update.mjs +216 -0
- package/src/cli.mjs +525 -0
- package/src/contract/articles.mjs +12 -0
- package/src/contract/assert.mjs +162 -0
- package/src/contract/definition-of-done.mjs +97 -0
- package/src/contract/final-verification.mjs +96 -0
- package/src/contract/index.mjs +641 -0
- package/src/contract/judge-envelope.mjs +25 -0
- package/src/contract/review-modes.mjs +151 -0
- package/src/contract/runtime.mjs +204 -0
- package/src/contract/schema-version.mjs +25 -0
- package/src/contract/scope-findings.mjs +77 -0
- package/src/contract/snapshot.mjs +639 -0
- package/src/contract/task-packet.mjs +495 -0
- package/src/contract/untrusted.mjs +75 -0
- package/src/contract/verification.mjs +185 -0
- package/src/contract/worker-result.mjs +138 -0
- package/src/engine/assignment.mjs +63 -0
- package/src/engine/backoff.mjs +492 -0
- package/src/engine/bulk-read.mjs +361 -0
- package/src/engine/cancel.mjs +177 -0
- package/src/engine/detach.mjs +101 -0
- package/src/engine/dispatch.mjs +752 -0
- package/src/engine/failover.mjs +192 -0
- package/src/engine/gate.mjs +183 -0
- package/src/engine/judge-gate.mjs +517 -0
- package/src/engine/lifecycle.mjs +772 -0
- package/src/engine/live-preflight.mjs +299 -0
- package/src/engine/mutation.mjs +146 -0
- package/src/engine/notify-queue.mjs +327 -0
- package/src/engine/process-identity.mjs +72 -0
- package/src/engine/process.mjs +774 -0
- package/src/engine/prompts.mjs +289 -0
- package/src/engine/recover.mjs +300 -0
- package/src/engine/result-file.mjs +222 -0
- package/src/engine/resume.mjs +635 -0
- package/src/engine/retry.mjs +334 -0
- package/src/engine/review.mjs +228 -0
- package/src/engine/run-command.mjs +287 -0
- package/src/engine/run-identity.mjs +411 -0
- package/src/engine/runtime-discovery.mjs +235 -0
- package/src/engine/scheduler.mjs +526 -0
- package/src/engine/scope.mjs +378 -0
- package/src/engine/settle.mjs +207 -0
- package/src/engine/state.mjs +148 -0
- package/src/engine/supervise.mjs +713 -0
- package/src/engine/verify.mjs +167 -0
- package/src/harnesses/agy/index.mjs +62 -0
- package/src/harnesses/catalogue.mjs +509 -0
- package/src/harnesses/claude/index.mjs +90 -0
- package/src/harnesses/codex/index.mjs +87 -0
- package/src/harnesses/dsh/closed-packet.patch.yml +42 -0
- package/src/harnesses/dsh/index.mjs +210 -0
- package/src/harnesses/dsh/runner.mjs +259 -0
- package/src/harnesses/exec-jsonl/index.mjs +788 -0
- package/src/harnesses/index.mjs +508 -0
- package/src/harnesses/protocol.mjs +531 -0
- package/src/harnesses/replay/bin.mjs +386 -0
- package/src/harnesses/replay/index.mjs +238 -0
- package/src/harnesses/zcode/index.mjs +276 -0
- package/src/host/config.mjs +87 -0
- package/src/host/home.mjs +149 -0
- package/src/host/package.mjs +23 -0
- package/src/host/preflight.mjs +520 -0
- package/src/host/tool-policy-decisions.mjs +341 -0
- package/src/host/tool-policy-hook.mjs +270 -0
- package/src/notify/index.mjs +359 -0
- package/src/notify/os-macos.mjs +81 -0
- package/src/repo/declared-paths.mjs +220 -0
- package/src/repo/integrate.mjs +546 -0
- package/src/repo/scope-closure.mjs +665 -0
- package/src/repo/signal-block.mjs +16 -0
- package/src/repo/signal.mjs +222 -0
- package/src/repo/source-identity.mjs +295 -0
- package/src/repo/workspace.mjs +557 -0
- package/src/repo/worktree.mjs +352 -0
- package/src/report/final.mjs +200 -0
- package/src/report/metrics-report.mjs +99 -0
- package/src/report/next.mjs +383 -0
- package/src/report/render.mjs +716 -0
- package/src/run/disk-gc.mjs +251 -0
- package/src/run/lock.mjs +329 -0
- package/src/run/node-store.mjs +62 -0
- package/src/run/operations.mjs +286 -0
- package/src/run/store.mjs +187 -0
- package/src/run/usage.mjs +337 -0
- package/src/seat/harnesses.mjs +83 -0
- package/src/seat/index.mjs +239 -0
- package/src/seat/tmux.mjs +208 -0
- package/src/util.mjs +0 -0
- package/src/web/api.mjs +371 -0
- package/src/web/boundary.mjs +88 -0
- package/src/web/index.html +299 -0
- 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
|
+
}
|