candor-ts 0.26.0 → 0.28.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/scan.mjs CHANGED
@@ -28,11 +28,11 @@ import { fileURLToPath } from "node:url";
28
28
  import { createRequire } from "node:module";
29
29
  import { execFileSync } from "node:child_process";
30
30
  import { parsePolicy, evaluatePolicy, scopeMatches, parseUnknownAliases, parseNetPartners, discoverConfigText,
31
- reasonClass, discoverConfigPath, policyVocabularyAnchor, policyErrorText, policyErrorUnevaluated, policyUnreadable, fatalPolicyErrors, refusalVerdict,
31
+ reasonClass, discoverConfigPath, policyVocabularyAnchor, policyErrorText, policyRefusalUnevaluated, policyUnreadable, policyZeroRules, fatalPolicyErrors, refusalVerdict,
32
32
  netClassResolver, resolveReasonClasses } from "./policy.mjs";
33
33
  import { unverifiedHoleRule, ruleUpgrade, byCodePoint, claimsToHaveJudgedNothing, reportCorruptKeys, entryCorruptKeys } from "./query-core.mjs";
34
34
  import { printAgents } from "./contract.mjs";
35
- import { isTestPath, kappa, kappaKnows, commandHeadEffects, hostLiteral, tablesInSql,
35
+ import { isTestPath, kappa, kappaKnows, fsKind, commandHeadEffects, hostLiteral, tablesInSql,
36
36
  modelHostEffects, isModelHost, isModelSdkPackage, netClassesOf } from "./scan-core.mjs";
37
37
  import { emitSurface } from "./surface.mjs";
38
38
 
@@ -44,7 +44,18 @@ const ENGINE_DIR = path.dirname(fileURLToPath(import.meta.url));
44
44
  // literal stamped into the envelope's `spec` field, so the doc lines and the report can never drift.
45
45
  // Reused, never re-littered.
46
46
  const PKG_VERSION = JSON.parse(fs.readFileSync(path.join(ENGINE_DIR, "package.json"), "utf8")).version;
47
- const SPEC_VERSION = "0.26";
47
+ const SPEC_VERSION = "0.28";
48
+ /** The `deps` / `CANDOR_DEPS` separator set — ASCII whitespace plus `:` and `,`.
49
+ *
50
+ * ONE CONSTANT BECAUSE TWO SPELLINGS WERE A SILENT GREEN. The §3.3.1 sink-over-input guard and the
51
+ * dep-chain loader each carried their own regex; they disagreed on `\n`, so a newline-separated
52
+ * `CANDOR_DEPS` was one unresolvable token to the guard and two real paths to the loader. A
53
+ * `--gate-json` naming one of those reports was therefore unguarded: arming overwrote it, the scan
54
+ * finished, and the operator's dep report ended up holding this run's `{"ok": true}` at exit 0.
55
+ *
56
+ * NOT JS `\s`, which includes U+00A0: these are PATHS, and a non-breaking space inside one is part of
57
+ * the path, not a separator — java, rust and swift all treat it that way. */
58
+ const DEP_SEPARATORS = /[ \t\n\r:,]+/;
48
59
 
49
60
  // --version: a print-and-exit MODE, handled before the main arg walk so it never depends on a target.
50
61
  // Fully OFFLINE — candor never phones home. Staying current is the AGENT's job: read the installed
@@ -119,6 +130,765 @@ See https://github.com/tombaldwin/candor`);
119
130
  // value-consuming skip handles, nor produce a "lying unknown flag" error for a real flag given first.
120
131
  const usage = "usage: candor-ts <dir | file.ts | tsconfig.json> [--out <prefix>] [--json] [--policy <file>] [--gate-json <file>] [--allow-js] [--workspace] [--agents] [--version] [--help]";
121
132
  const argv = process.argv.slice(2);
133
+ // Declared HERE, above the sink guard, because the guard calls `loadCandorConfig` and that reads
134
+ // these: left below, they were in the temporal dead zone, the call threw, and the `catch` around it
135
+ // swallowed the throw — so the config channel the guard exists to enumerate was silently empty and a
136
+ // config-declared policy was destroyed at exit 0 again. A `catch` that hides a programming error is a
137
+ // fail-open with a reason attached.
138
+ const CONFIG_KEYS = new Set(["policy", "baseline", "strict", "no-ambient", "closed-world", "taint", "deps", "unknown-alias", "net-partner", "unknown-ratchet", "engine"]);
139
+ const CONFIG_KEYS_IMPLEMENTED = new Set(["policy", "baseline", "deps", "unknown-ratchet", "engine"]);
140
+
141
+ // ── SPEC §3.3.1 ⟨0.27⟩ ARM FIRST, AND NEVER OVER AN INPUT.
142
+ //
143
+ // This pre-pass learns the sink and this run's inputs with NO side effects, before the parse loop
144
+ // below, for two reasons the loop cannot serve:
145
+ //
146
+ // (1) the loop's own `unknown flag` exit(2) runs BEFORE the arming did, so `--frobnicate --gate-json G`
147
+ // exited leaving the PREVIOUS run's green document at G. §3.3 names an unknown flag as a
148
+ // broken-gate-config exit-2 cause, which MUST leave a refusal — the contract cannot depend on
149
+ // argv order, and it did.
150
+ // (2) arming WRITES, so a sink that names the policy DESTROYS it. Measured: `--policy P --gate-json P`
151
+ // on violating code exited 0 with `ok: true` — the armed JSON replaced P, every line of it parsed
152
+ // as an unknown rule, and the gate ran over zero rules. A machine-readable all-clear produced by
153
+ // deleting the question.
154
+ const preScan = (av) => {
155
+ let gate = null, policy = null, target = null, out = null, refused = false;
156
+ for (let i = 0; i < av.length; i++) {
157
+ const a = av[i], v = av[i + 1];
158
+ if (a === "--gate-json" || a === "--policy" || a === "--out") {
159
+ // ⟨0.28⟩ A MISSING/FLAG-SHAPED VALUE IS WHERE THE PARSE LOOP EXITS 2 — and this pre-pass used to
160
+ // disagree about what happens NEXT. The loop refuses `--policy --out X` at `--policy` and never
161
+ // parses another token; this pre-pass skipped on and read `--out X` as a fresh flag, so X was
162
+ // ARMED on an argv the loop never accepts — SPEC §3.3.1 (1)'s precondition ("`--out` has been
163
+ // parsed and accepted") was false, and X's previous reports became permanent placeholders.
164
+ // So report-set arming honours only an `--out` consumed BEFORE the first token the loop refuses
165
+ // at (`--out p --zzz` still arms p: the loop accepted that pair before it died). `gate`, `policy`
166
+ // and `target` keep collecting past the breakage, each for its own reason: the gate sink must be
167
+ // armed with the refusal whatever the argv order (⟨0.27⟩ (1)) and arming it writes a refusal, not
168
+ // a placeholder over a report set; policy/target only feed the input GUARDS, where over-collection
169
+ // can only protect a file more — and the run is exiting 2 regardless.
170
+ if (v === undefined || (v !== "-" && v.startsWith("--"))) { refused = true; continue; }
171
+ // ⟨0.28⟩ THE LAST `--out` WINS, BECAUSE THAT IS WHAT THE PARSE LOOP HONOURS — every assignment here
172
+ // overwrites, deliberately. candor-swift's arm of this rung caught the reference engine returning
173
+ // the FIRST: measured on `--out p1 --out p2 --zzz-not-a-flag`, p1 was armed and p2 — the prefix the
174
+ // run would actually have written — stayed STALE, so the rung did nothing for that argv while
175
+ // neutralising a set nobody was going to replace. A pre-pass that disagrees with the loop it exists
176
+ // to run ahead of arms the wrong thing.
177
+ if (a === "--gate-json") gate = v; else if (a === "--policy") policy = v; else if (!refused) out = v;
178
+ i++;
179
+ continue;
180
+ }
181
+ // The scan TARGET, needed to discover the `.candor/config` whose `policy` key may name an input
182
+ // this sink must not overwrite.
183
+ if (!a.startsWith("-") && target === null) target = a;
184
+ }
185
+ return { gate, policy, target, out };
186
+ };
187
+
188
+ // SPEC §3.3.1 ⟨0.28⟩ — every `--gate-json` this argv names. `preScan` keeps only the last, which is what
189
+ // the parse loop honours and exactly the behaviour this rung refuses: measured, three engines wrote the
190
+ // verdict to the LAST path and left the first holding a previous run's `{"ok": true}` while the gate
191
+ // fired — the ⟨0.27⟩ stale green, reached by a spelling nobody had considered.
192
+ const allGateSinks = (av) => {
193
+ const out = [];
194
+ for (let i = 0; i < av.length; i++) {
195
+ if (av[i] !== "--gate-json") continue;
196
+ const v = av[i + 1];
197
+ if (v === undefined || (v !== "-" && v.startsWith("--"))) continue;
198
+ out.push(v); i++;
199
+ }
200
+ return out;
201
+ };
202
+
203
+ // Every path this run READS, whatever channel it arrived through (SPEC §3.3.1 ⟨0.27⟩).
204
+ //
205
+ // THE FIRST VERSION OF THIS GUARD KEYED ON THE FLAG. With the policy declared by `.candor/config` — the
206
+ // checked-in form, i.e. the one a CI job actually has — `--gate-json <that policy>` destroyed it and
207
+ // exited 0 with `"ok": true` in ALL FOUR ENGINES. A policy does not change what it is according to how
208
+ // the operator handed it over. The config is read LENIENTLY (no exit, no diagnostic): this runs before
209
+ // the real config load and must not pre-empt its refusal.
210
+ // Artifact identity, not string identity: `--policy /w/P --gate-json ./P` from /w is one file, and the
211
+ // engine that already had this guard compared path spellings and lost to exactly that. realpath resolves
212
+ // `.`, `..` and symlinks; for a sink that does not exist yet its parent is resolved instead.
213
+ const sameArtifact = (a, b) => {
214
+ if (!a || !b || a === "-" || b === "-") return false;
215
+ // ⟨0.28⟩ DEVICE+INODE FIRST. Path equality alone called two HARDLINKS to one inode two sinks and
216
+ // refused a legal command — the mirror of the stale green. And a symlink whose target does not exist
217
+ // YET still names that target, which `realpathSync` cannot resolve, so resolve it explicitly.
218
+ try {
219
+ const sa = fs.statSync(a), sb = fs.statSync(b);
220
+ if (sa.dev === sb.dev && sa.ino === sb.ino) return true;
221
+ } catch { /* one of them is not there yet — fall through */ }
222
+ try {
223
+ const ra = resolveSinkArtifact(a), rb = resolveSinkArtifact(b);
224
+ if (ra !== a || rb !== b) {
225
+ if (path.resolve(ra) === path.resolve(rb)) return true;
226
+ }
227
+ } catch { /* fall through to the path forms below */ }
228
+ const resolve = (p) => {
229
+ try { return fs.realpathSync(p); } catch { /* not there yet — resolve the parent */ }
230
+ try { return path.join(fs.realpathSync(path.dirname(path.resolve(p))), path.basename(p)); } catch { return null; }
231
+ };
232
+ const x = resolve(a);
233
+ return x !== null && x === resolve(b);
234
+ };
235
+
236
+ // ⟨0.28⟩ SPEC §3.3.1 — EVERY `--out` THIS ARGV NAMES, in order, duplicates kept. `preScan` keeps only the
237
+ // LAST (what the parse loop honours); this is the same walk, and it exists because **a repeated `--out`
238
+ // is the same rule as a repeated `--gate-json`** — the question the rung that settled the verdict sink
239
+ // filed as "deferred" for the report sink, on no stated ground except which sink was in front of the
240
+ // author.
241
+ //
242
+ // ONE WALK, not a second parser. The value rules are `preScan`'s exactly, including the `refused` latch:
243
+ // a `--out` named after the first token the parse loop will refuse at was never accepted, so it is not a
244
+ // sink. Two walks with two value rules is how the repeated form and the single form come to disagree
245
+ // about which tokens are prefixes at all — and candor-swift's arm of this rung caught the reference
246
+ // engine arming the FIRST prefix while the run wrote the LAST.
247
+ const allOutPrefixes = (av) => {
248
+ const out = [];
249
+ let refused = false;
250
+ for (let i = 0; i < av.length; i++) {
251
+ const a = av[i], v = av[i + 1];
252
+ if (a !== "--gate-json" && a !== "--policy" && a !== "--out") continue;
253
+ if (v === undefined || (v !== "-" && v.startsWith("--"))) { refused = true; continue; }
254
+ if (a === "--out" && !refused) out.push(v);
255
+ i++;
256
+ }
257
+ return out;
258
+ };
259
+ // Two spellings of ONE prefix are ONE report set — the §3.3.1 artifact rule applied to the prefix. A
260
+ // prefix is not itself a file, but `sameArtifact` resolves the PARENT of a path that does not exist, so
261
+ // `p` and `./p` from p's own directory collapse. Order-preserving, first spelling kept; `--out` never
262
+ // accepts `-`, so the stream arm of that helper is inert here.
263
+ const distinctOutPrefixes = (all) => {
264
+ const out = [];
265
+ for (const p of all) if (!out.some((k) => k === p || sameArtifact(k, p))) out.push(p);
266
+ return out;
267
+ };
268
+
269
+ // ⟨0.28⟩ SPEC §3.3.1 — **A SINK THAT LIES UNDER THE SCAN TARGET AND BEARS AN EXTENSION THIS ENGINE
270
+ // PARSES IS REFUSED.** This is the residual the exact-artifact ruling deliberately left: registering
271
+ // the target in `runInputs` catches `--gate-json <the target>` and cannot catch `--gate-json
272
+ // src/main.ts` while scanning `tsconfig.json` or `.`, because the file set the run will parse is not
273
+ // known at the moment arming happens (arming precedes the file walk, and deferring it would uncover
274
+ // the argv-error exits the arming rule exists for).
275
+ //
276
+ // MEASURED HERE BEFORE THIS FIX, and this engine had the worst artifact of the four:
277
+ //
278
+ // $ node scan.mjs tsconfig.json --gate-json src/main.ts
279
+ // candor-ts: 1 source file(s) failed to parse — NOT analyzed …
280
+ // candor-ts: wrote 0 effectful functions (1 analyzed, 1 files) to .candor/report.json exit 0
281
+ // $ cat src/main.ts
282
+ // { "spec": "0.28", "ok": false, … } ← the operator's SOURCE, unrecoverably replaced
283
+ //
284
+ // Unrecoverable loss of the operator's own code, reported as SUCCESS — the run destroyed the file,
285
+ // then dutifully disclosed the parse failure it had itself caused.
286
+ //
287
+ // NOT CONTAINMENT IN GENERAL, and that control is the whole difference between this and the fix the
288
+ // ruling explicitly rejects. `<dir>/.candor/report.json` is under the target and is not a source
289
+ // file, so the recommended layout stays permitted; a general containment rule was tried in this repo
290
+ // for the dep-directory case and "took 33 tests with it" (see `runInputs`). Extension is the whole of
291
+ // the predicate, and an engine knows its own source extensions before it knows its file list — which
292
+ // is exactly what makes this checkable at the instant arming happens.
293
+ //
294
+ // THE JS FAMILY IS IN THE SET UNCONDITIONALLY, not gated on `--allow-js`. `--allow-js` is only one of
295
+ // the two ways a `.js` reaches the parse set: a tsconfig carrying `allowJs` puts them in
296
+ // `parsed.fileNames`, and the tsconfig is not read at parse time either. Erring here costs a sink
297
+ // spelled `verdict.mjs`; erring the other way costs the operator's source.
298
+ // "UNDER THE TARGET" IS UNDER THE TARGET'S ROOT DIR, AND THAT IS NOT THE SAME STRING. The measured
299
+ // reproduction is `--gate-json src/main.ts` while scanning **`tsconfig.json`** — a FILE target, whose
300
+ // parsed set is everything the config names and therefore lives under its DIRECTORY, not under the
301
+ // token. A first version of this predicate compared against the target path itself and the defect
302
+ // reproduced unchanged (measured, same fixture, source still destroyed at exit 0). So the containment
303
+ // root is computed by the SAME three-way test the run below roots itself with (search `usedTsconfig`):
304
+ // a tsconfig-shaped file roots at its directory, any other single file is exactly itself (and is
305
+ // already covered by the exact-artifact registration in `runInputs`), a directory is itself.
306
+ //
307
+ // Over-approximate on the tsconfig arm, deliberately: the config's `include` may not name every `.ts`
308
+ // under that directory, so a sink spelled `<dir>/excluded/verdict.ts` is refused though the run would
309
+ // not have parsed it. The cost is a verdict path that has to be renamed; the cost of the other
310
+ // direction is the operator's source.
311
+ const PARSED_SOURCE_EXT = /\.[mc]?[tj]sx?$/i;
312
+ const targetContainmentRoot = (target) => {
313
+ let t;
314
+ try { t = fs.realpathSync(target); } catch { return null; } // a missing target refuses on its own terms
315
+ try {
316
+ if (fs.statSync(t).isFile()) return /tsconfig.*\.json$/i.test(path.basename(t)) ? path.dirname(t) : t;
317
+ } catch { return null; }
318
+ return t;
319
+ };
320
+ const sinkIsParsedSourceUnderTarget = (sink, target) => {
321
+ if (!sink || sink === "-" || !target) return false;
322
+ if (!PARSED_SOURCE_EXT.test(path.basename(sink))) return false;
323
+ const t = targetContainmentRoot(target);
324
+ if (t === null) return false;
325
+ // The sink may not exist yet — resolve its parent and re-append the name, the shape `sameArtifact` uses.
326
+ let s;
327
+ try { s = fs.realpathSync(sink); }
328
+ catch {
329
+ try { s = path.join(fs.realpathSync(path.dirname(path.resolve(sink))), path.basename(sink)); }
330
+ catch { return false; }
331
+ }
332
+ return s === t || s.startsWith(t.endsWith(path.sep) ? t : t + path.sep);
333
+ };
334
+
335
+ const runInputs = (target, policyFlag) => {
336
+ const out = [];
337
+ // ⟨0.28⟩ THE SCAN TARGET ITSELF — SPEC §3.3.1 (3) lists "the target's own source tree" among the
338
+ // inputs arming must not touch, and this list did not: `--gate-json app.ts` on the file being scanned
339
+ // armed the refusal verdict OVER the operator's source, the run then parsed the wreckage it had just
340
+ // made ("1 source file(s) failed to parse"), and exited 0. Silent destruction of the one file the run
341
+ // exists to describe, reported as success. Measured live, and the same gap was in all four engines.
342
+ //
343
+ // EXACT ARTIFACT, NEVER CONTAINMENT. Registering the target as one more `[path, label]` pair means
344
+ // `sameArtifact` refuses only a sink that IS the target — a report or verdict written into `.candor/`
345
+ // INSIDE a directory target is ordinary usage and still permitted. Making `sameArtifact`
346
+ // directory-aware was tried for the dep-directory case below and rejected (it refused the ordinary
347
+ // in-tree layout and took 33 tests with it); the same reasoning holds here.
348
+ if (target) out.push([target, "the scan target"]);
349
+ if (policyFlag) out.push([policyFlag, "--policy"]);
350
+ for (const [v, label] of [["CANDOR_POLICY", "CANDOR_POLICY"], ["CANDOR_BASELINE", "CANDOR_BASELINE"],
351
+ ["CANDOR_CONFIG", "CANDOR_CONFIG"]]) {
352
+ if (process.env[v]) out.push([process.env[v], label]);
353
+ }
354
+ // ONE DEFINITION, shared with the loader — see DEP_SEPARATORS. This comment used to claim it was
355
+ // "the separator set the dep loader accepts" while spelling a DIFFERENT set one screen away, and the
356
+ // gap between the two was a silent green: a newline-separated `CANDOR_DEPS` registered here as one
357
+ // unresolvable token, so the guard protected nothing, while the loader split it into real paths.
358
+ // `--gate-json` naming one of those reports then DESTROYED it and the run exited 0 with `ok: true`
359
+ // written over the operator's input — §3.3.1's own words, "a machine-readable all-clear produced by
360
+ // deleting the question". Measured live before this change.
361
+ for (const d of (process.env.CANDOR_DEPS ?? "").split(DEP_SEPARATORS).filter(Boolean)) {
362
+ out.push([d, "a CANDOR_DEPS report"]);
363
+ // A DIRECTORY DEP IS EVERY REPORT INSIDE IT. `deps` accepts a directory — `--workspace` writes
364
+ // `.candor/deps/` and hands that back, so it is the common spelling — and the loader then walks it
365
+ // and reads each `*.json`. Registering only the DIRECTORY left those files unnamed, so
366
+ // `--gate-json <depdir>/lib.json` was unguarded: arming destroyed the operator's dep report, the
367
+ // run chained the wreckage and exited 0 with `ok: true` over it. Measured in all four engines.
368
+ //
369
+ // EXPANDED HERE, not by making `sameArtifact` directory-aware. That was tried and is far too
370
+ // broad: the scan TARGET is an input too, and a verdict written into the tree being scanned is
371
+ // ordinary usage — the general rule refused it and took 33 tests with it. Only a DEP directory has
372
+ // its CONTENTS read, so only a dep directory expands.
373
+ try {
374
+ if (fs.statSync(d).isDirectory()) {
375
+ for (const f of fs.readdirSync(d)) {
376
+ if (f.endsWith(".json") && !f.endsWith(".callgraph.json") && !f.endsWith(".hierarchy.json")
377
+ && !f.endsWith(".locs.json")) {
378
+ out.push([path.join(d, f), "a CANDOR_DEPS report"]);
379
+ }
380
+ }
381
+ }
382
+ } catch { /* not a directory, or unreadable — the token itself is still registered above */ }
383
+ }
384
+ // …AND THE CONFIG'S OWN KEYS, THROUGH THE ENGINE'S OWN LOADER AND ITS OWN DISCOVERY. This used to
385
+ // re-derive both, and a review took it apart: the home directory was computed as parent-of-parent
386
+ // unconditionally where the loader only steps out of a trailing `.candor/` segment, so an out-of-tree
387
+ // CANDOR_CONFIG had its relative values anchored one level too high and the guard protected a path
388
+ // the run never reads. A second parser is a second set of holes; `loadCandorConfig` is called inside a
389
+ // try so it can still refuse for real a moment later.
390
+ const cfgFile = discoverConfigFile(target ?? ".");
391
+ if (cfgFile) {
392
+ out.push([cfgFile, "the discovered .candor/config"]);
393
+ try {
394
+ const cfg = loadCandorConfig(target ?? ".", { lenient: true });
395
+ for (const key of ["policy", "baseline"]) {
396
+ if (cfg[key]) out.push([cfg[key], `the config's \`${key}\``]);
397
+ }
398
+ for (const one of (cfg.deps ?? "").split(":").filter(Boolean)) {
399
+ out.push([one, "the config's `deps`"]);
400
+ }
401
+ } catch { /* lenient: the real load refuses on its own terms */ }
402
+ }
403
+ return out;
404
+ };
405
+
406
+ // ⟨0.27⟩ THE STREAM SINK'S ANALOG OF ARMING — SPEC §3.1's stream-sink clause. `--gate-json -` cannot be
407
+ // armed (a stream has no stale previous document, and a placeholder would put TWO documents in a
408
+ // consumer's pipe), but the document-on-every-exit rule applies in full: an exit-2 cause that fires
409
+ // before the gate tail — an unknown flag, a valueless gate-adjacent flag, a missing target — must still
410
+ // leave the fail-closed refusal as the stream's only content. Measured: an unhonourable policy wrote the
411
+ // refusal to stdout while an unknown flag exited 2 leaving stdout EMPTY — the same operator mistake,
412
+ // answered or not according to which early exit fired, and an empty stream throws the consumer back to
413
+ // scraping stderr. File sinks need nothing here: the arming above already left a refusal in place.
414
+ // DECLARED HERE, above the pre-arg-loop `{ … }` block: those guards can themselves exit 2, and the
415
+ // helper has to exist BEFORE its first caller — a `const` doesn't hoist, so left below where it used to
416
+ // live (before the arg loop but after this block) it was in the temporal dead zone at every gate-input
417
+ // refusal, and stdout stayed empty on every one of them. Measured while writing this move.
418
+ const preGateSink = preScan(argv).gate;
419
+ // ⟨0.28⟩ SPEC §3.3.1 (4) — THE SAME RULE ONE HOP UPSTREAM, FOR THE REPORT STREAM. `--json` is the
420
+ // stdout REPORT sink; on any exit-2 the fail-closed ⟨0.21⟩ Row-1 report is written to stdout as the
421
+ // stream's only content. Measured 2026-08-10 across all four engines: `--json --zzz-not-a-flag` exited
422
+ // 2 with stdout ZERO BYTES, so a downstream JSON consumer parsing stdout throws and is thrown back to
423
+ // scraping stderr — the same defect ⟨0.27⟩ closed on the verdict stream, arriving through the report
424
+ // sink because that rule was written for the verdict sink and no engine extended it. Detected here
425
+ // pre-arg-loop by `argv.includes("--json")`: this flag is stdout-only, and the value-consuming flags
426
+ // exit 2 rather than swallow it as their value, so a `--json` token in argv IS the request.
427
+ const preWantJson = argv.includes("--json");
428
+ // `reportStreamWritten` latch: mirrors the rust reference's `REPORT_STREAM_WRITTEN` OnceLock. Set
429
+ // once, at the successful `wantJson` print further down the file (search for this comment tag), so a
430
+ // later exit-2 site does not put a SECOND JSON document on the stream — two documents on one pipe
431
+ // parses as neither.
432
+ let reportStreamWritten = false;
433
+ // The ⟨0.21⟩ Row-1 manifest-carrying empty: `functions: []` + `analyzed.count: 0` + `unanalyzed`
434
+ // naming the cause. A ⟨0.24⟩ consumer already reads this as *nothing was judged, no purity licence*
435
+ // and a `gate --report` records the file `invisible` — no new reader logic. The version/toolchain
436
+ // match the ordinary envelope so a consumer's provenance check reads the same.
437
+ // ONE BUILDER FOR BOTH SINKS: the stream form below and the ⟨0.28⟩ `--out` file armer emit the SAME
438
+ // bytes for the same reason string. Two spellings of one document is how a consumer ends up with two
439
+ // shapes to recognise, and the file form is the one a `gate --report` parses.
440
+ const failClosedReportDoc = (reason) => {
441
+ const esc = (s) => String(s).replace(/\\/g, "\\\\").replace(/"/g, "\\\"").replace(/[\n\r]/g, " ");
442
+ return `{\n "candor": {\n "version": "candor-ts-${PKG_VERSION}",\n "toolchain": "node-${process.versions.node}",\n "spec": "${SPEC_VERSION}"\n },\n "functions": [],\n "analyzed": { "count": 0 },\n "unanalyzed": [\n { "path": "<run>", "reason": "${esc(reason)}" }\n ]\n}`;
443
+ };
444
+ const refuseEarlyToStream = (why) => {
445
+ if (preGateSink === "-") console.log(JSON.stringify(refusalVerdict(SPEC_VERSION, why, null), null, 1));
446
+ else if (preWantJson && !reportStreamWritten) {
447
+ // Skipped when stdout is already claimed by `--gate-json -` (the two-stream case is refused
448
+ // earlier with a verdict on stdout).
449
+ console.log(failClosedReportDoc(`refused: ${why}`));
450
+ reportStreamWritten = true;
451
+ }
452
+ };
453
+
454
+ // ── SPEC §3.3.1 ⟨0.28⟩ — ARM THE `--out <prefix>` REPORT SET, AND HAND BACK WHAT THE RUN DID NOT OWN.
455
+ //
456
+ // The verdict sink arms by writing to a path the run is about to own. A report PREFIX cannot: at parse
457
+ // time the run does not know which files it will write (this engine writes `<prefix>.json`, a workspace
458
+ // engine fans out to one per member), so the set it DOES know is the one the PREVIOUS run left on disk —
459
+ // and that is exactly the set at risk of being read as current after this run fails. Measured on this
460
+ // engine: `node scan.mjs <target> --out p --zzz-not-a-flag` exited 2 with `p.json` byte-identical to the
461
+ // previous good run, and a downstream `gate --report p.json` then reads a green report the failed run
462
+ // never produced.
463
+ //
464
+ // Armed from the pre-pass, before the arg loop's own unknown-flag exit — the exit this rung is most
465
+ // often reached through. Each report the run does write overwrites its placeholder a moment later.
466
+ //
467
+ // ⟨0.28⟩ AND THE §2.2 SIDECARS GO WITH THE REPORT — DELETED, NOT EMPTIED. That question is no longer
468
+ // open: an armed report beside a LIVE sidecar is a pair that contradicts itself, and §2.2 gives the
469
+ // sidecar no provenance of its own to arbitrate with. See `removeArmedReportSidecars` below.
470
+ const OUT_ARM_DOC = failClosedReportDoc(
471
+ "armed: this report was written when the run STARTED and was never replaced, so the run failed, "
472
+ + "crashed or was killed before it could describe this package. It is NOT a claim about any code; "
473
+ + "see the run's stderr for the cause.") + "\n";
474
+ /** `[path, bytes-before-arming]` for every report armed under the out prefix. */
475
+ const outArmed = [];
476
+ /** `[reportPath, sidecarPath, bytes-before-deletion]` for every §2.2 sidecar deleted while arming. */
477
+ const outArmedSidecars = [];
478
+
479
+ // ⟨0.28⟩ THIS REPORT'S §2.2 SIDECARS, DELETED WITH IT.
480
+ //
481
+ // `callers`/`whatif`/`rewire` are answered FROM THE SIDECAR, not from the report: a currently-pure
482
+ // function is absent from the report by §2 rule 3, so only `<stem>.callgraph.json` records it. Leaving
483
+ // the sidecar live beside an armed report is therefore not an untidy pair, it is the cardinal sin one
484
+ // file over. MEASURED ON THIS ENGINE — baseline `f` pure, reached by `g`; the new version gives `f` an
485
+ // `fs.readFileSync` and adds a second caller `h`; the run exits 2 on an unknown flag with the report
486
+ // armed to the ⟨0.21⟩ Row-1 empty:
487
+ //
488
+ // callers f -> exit 0, direct: ["src.app.g"], transitive: ["src.app.g", "src.app.main"]
489
+ //
490
+ // Confident, exit 0, and WRONG: `h` calls `f` too, and an agent reads that as safe-to-edit. After this
491
+ // change the same query has no graph to answer from, and a recovering run answers `g` AND `h`.
492
+ //
493
+ // THE CONSUMER HALF IS NOW IMPLEMENTED TOO, and this paragraph used to record that it was not — which is
494
+ // exactly how a limitation written as a comment reads as handled and stops being measured. Deleting the
495
+ // sidecar removes the confidently WRONG answer; it does not by itself produce an honest one. The absence
496
+ // arm here was an EMPTY caller set at exit 0 — human-fine, machine-silent, the split that makes a defect
497
+ // a cardinal sin. `callers` (query.mjs) now emits an `unanswerable` key AND exits 2 over a pair with no
498
+ // §2.2 sidecar; conformance PART 37 row (e) pins it. `impact` and `path` FOLLOWED — the sibling-route
499
+ // habit caught early for once, and the line that separates them from the list below is not "graph verb"
500
+ // but WHERE THE TARGET RESOLVES: both resolve theirs over the §2.2 call graph, so with no sidecar every
501
+ // answer is vacuous, and each vacuum spells itself as the reassurance the verb exists to give
502
+ // (`affectedCount: 0` = safe to change; `path: []` = it does not reach that effect). rust and java exit 2
503
+ // on both. STILL OPEN, named so it stays measured: the report-only DESCRIPTIVE verbs
504
+ // (`map`/`show`/`blindspots`/`where`/`reachable`/`containment`/`tour`) answer flat at exit 0 over an
505
+ // armed report. That is a rung of its own — it wants the ⟨0.21⟩ manifest forwarded into descriptive-verb
506
+ // JSON — not a patch to this one.
507
+ //
508
+ // DELETED RATHER THAN `{}`, and NOT by reading the report's own anti-deletion rule (§3.3.1) across. That
509
+ // rule exists because a consumer treating a missing REPORT as "nothing to report" fails open. No sidecar
510
+ // consumer has that failure mode: §2.2 makes the sidecar OPTIONAL, so every consumer was forced to define
511
+ // an absence arm from the start and every specified arm is safe (over-listing §3.1, `origin: "unknown"`,
512
+ // refusal on a corrupt one). And ⟨0.24⟩ has already RULED empty ≡ absent ≡ unparseable for the hierarchy.
513
+ // `{}` is a file this family has declared meaningless.
514
+ //
515
+ // THE GUESS RUNS OPPOSITE TO THE ARMER'S, deliberately. The armer above identifies its own report
516
+ // POSITIVELY by content, because there a miss leaves a stale report and an over-reach destroys a file.
517
+ // Here a miss merely leaves a sidecar behind — the pre-rung state, and the ⟨0.28⟩ pairing rule catches it
518
+ // consumer-side — while an over-reach deletes something that is not ours. So this one goes by the §2.2
519
+ // reserved segment NAMES scoped to the report's own stem. Both directions chosen so the WRONG guess costs
520
+ // least; neither is a template for the other.
521
+ //
522
+ // `gate` AND `encountered-*` ARE NOT TAKEN, though §2.2 reserves them in the same family-wide list. A
523
+ // gate verdict is the VERDICT sink's document — separately armed, separately named by the operator — and
524
+ // destroying it from the report sink is precisely the cross-sink harm §3.3.1 measures, failing OPEN in
525
+ // the way `armGateJsonFailClosed` refuses to. `encountered-*` is a prefix family rather than a segment
526
+ // and belongs to no report's pair. Exclusion by argument, not by a shorter list.
527
+ const REPORT_SIDECAR_SEGMENTS = ["callgraph", "hierarchy", "locs", "calibrated", "layerreach"];
528
+ const removeArmedReportSidecars = (report, prefix, inputs) => {
529
+ const stem = report.replace(/\.json$/i, "");
530
+ for (const seg of REPORT_SIDECAR_SEGMENTS) {
531
+ const side = `${stem}.${seg}.json`;
532
+ // lstat, not existsSync: existsSync FOLLOWS a symlink, so a DANGLING link read as "absent" and was
533
+ // skipped without the symlink disclosure below — but it is still the operator's link, and the
534
+ // symlink arm must see it. A path with nothing there at all falls out through the catch.
535
+ let lst;
536
+ try { lst = fs.lstatSync(side); } catch { continue; }
537
+ // THE INPUT EXEMPTION COVERS THE SIDECARS, asked of the same `runInputs`/`sameArtifact` the report
538
+ // and verdict sink guards use — "do not touch what this run READS" does not stop at the report half.
539
+ // A `--policy`, a chained `CANDOR_DEPS` report or the discovered config can perfectly well be named
540
+ // `<stem>.locs.json`, and deleting it silently would be worse than the pair it repairs.
541
+ const hit = inputs.find(([other]) => sameArtifact(side, other));
542
+ if (hit) {
543
+ console.error(`candor-ts: --out ${prefix} armed the report ${report}, but its \`${seg}\` sidecar `
544
+ + `${side} is what this run READS as ${hit[1]} — leaving it in place. That report and that `
545
+ + `sidecar are now a MISMATCHED PAIR: read the sidecar as unanswerable until a run completes.`);
546
+ continue;
547
+ }
548
+ // A SYMLINKED SIDECAR IS LEFT ALONE, AND SAID SO — the ruling the rust reference pinned in 8094169,
549
+ // raised by candor-swift's arm of this rung. This engine used to resolve the link and delete its
550
+ // TARGET, arguing the stale bytes live there; measured, a failing run (`--out out --zzz`) deleted
551
+ // `../shared/callgraph.json` — a file OUTSIDE this prefix, the canonical copy in a shared-artifact
552
+ // CI layout — unrecoverably, because a failed run never restores and every other link to it now
553
+ // dangles. And even the SUCCESS path cannot make this honest: the disarm hands bytes back with
554
+ // `writeSinkAtomic`, so a delete-then-restore cycle converts the operator's LINK into a regular
555
+ // file — a third state neither the pre-run tree nor the armed tree ever had. The link is not ours
556
+ // to consume. Leaving it is the cheap side of the trade: the ⟨0.28⟩ pairing rule already makes a
557
+ // sidecar beside an armed report unanswerable consumer-side, whereas a severed link (or a destroyed
558
+ // shared target) is not recoverable at all.
559
+ if (lst.isSymbolicLink()) {
560
+ console.error(`candor-ts: ${side} is a §2.2 sidecar of the armed report ${report} but is a SYMLINK `
561
+ + `— leaving it, because removing it would sever the operator's layout (and a restore would hand `
562
+ + `back a regular file where a link was). Its report is armed, so treat the pair as unanswerable `
563
+ + `until a run completes (SPEC §3.3.1 ⟨0.28⟩).`);
564
+ continue;
565
+ }
566
+ // Remember the bytes first, so a report the run turns out not to own can hand its sidecars back too.
567
+ let prev;
568
+ try { prev = fs.readFileSync(side); } catch { prev = null; }
569
+ try { fs.rmSync(side, { force: true }); if (prev !== null) outArmedSidecars.push([report, side, prev]); }
570
+ catch (e) {
571
+ console.error(`candor-ts: could not remove ${side} beside the armed report ${report} (${e.message}) `
572
+ + `— it is a STALE half-pair: any callers/whatif answer computed from it describes the last run `
573
+ + `that completed, not this tree.`);
574
+ }
575
+ }
576
+ };
577
+
578
+ const armOutPrefixFailClosed = (prefix, inputs) => {
579
+ if (!prefix) return;
580
+ const abs = path.resolve(prefix);
581
+ const dir = path.dirname(abs), stem = path.basename(abs);
582
+ if (!stem) return;
583
+ let names;
584
+ try { names = fs.readdirSync(dir); } catch { return; } // no previous run under this prefix: nothing to arm
585
+ for (const name of names.sort()) {
586
+ if (!name.startsWith(`${stem}.`) || !name.endsWith(".json")) continue;
587
+ const full = path.join(dir, name);
588
+ try { if (!fs.statSync(full).isFile()) continue; } catch { continue; }
589
+ // THE ⟨0.27⟩ (2) INPUT EXEMPTION APPLIES TO THIS WRITER TOO, AND IT IS ASKED FIRST. Arming happens
590
+ // before the run knows its answer, so a prefix whose expansion collides with something this run READS
591
+ // would destroy it — the same hazard that made `--policy P --gate-json P` a machine-readable
592
+ // all-clear. A policy or a chained dep report can perfectly well be named `<prefix>.something.json`.
593
+ // Same resolver as every other sink guard (`sameArtifact`: device+inode, then the resolved path).
594
+ //
595
+ // ORDER MATTERS, and identification-first had it backwards (candor-swift's arm of this rung caught
596
+ // it). A `--policy <prefix>.policy.json` holding ordinary rule lines is not JSON, so it fails
597
+ // identification and gets skipped SILENTLY — the operator never learns their policy sat in the arming
598
+ // path, and losing a disclosure is the thing this project does not do. The exemption also has to
599
+ // OUTRANK identification for the case where the colliding input IS a valid report (a chained
600
+ // `CANDOR_DEPS` dep report under the same prefix): "do not touch what this run reads" is the stronger
601
+ // claim, whatever the file turns out to be.
602
+ const hit = inputs.find(([other]) => sameArtifact(full, other));
603
+ if (hit) {
604
+ console.error(`candor-ts: --out ${prefix} would arm over ${full}, which this run READS as ${hit[1]} `
605
+ + `— leaving it untouched. Give the report set its own prefix.`);
606
+ continue;
607
+ }
608
+ // …AND THEN ONLY FILES POSITIVELY IDENTIFIED AS THIS ENGINE'S OWN §2 REPORT — never a name denylist.
609
+ //
610
+ // The first version excluded `.callgraph`/`.hierarchy`/`.locs` by suffix and armed everything else
611
+ // under the prefix. SPEC §2.2 ⟨0.24⟩ (the "reserved set, family-wide" paragraph) lists SEVEN reserved
612
+ // trailing segments — `callgraph`, `hierarchy`, `calibrated`, `layerreach`, `locs`, `gate` and the
613
+ // `encountered-*` family — and records that the engines were already drifting on it, one carving out
614
+ // six and another two. This carved out three. Measured on the rust reference: the armer overwrote
615
+ // `<prefix>.calibrated.json`, `.layerreach.json`, `.encountered-hosts.json` and — worst —
616
+ // `<prefix>.gate.json`, a GATE VERDICT, each replaced by a report-shaped placeholder. A run whose
617
+ // report sink is armed was silently destroying the verdict sink's document beside it.
618
+ //
619
+ // THE MECHANISM WAS WRONG, NOT JUST THE LIST. This project's denylist-over-allowlist rule is about
620
+ // CLASSIFYING, where over-approximating is the safe direction. For a WRITER it inverts:
621
+ // over-approximating destroys a file. §2.2 can call an incomplete denylist "loud" because an
622
+ // unregistered suffix there merely falls back into a candidate set and gets disclosed; in an armer it
623
+ // is silent and destructive. So this writes only what it recognises as its own report — a JSON object
624
+ // carrying both a `candor` envelope and `functions` — which needs no list and cannot drift as the
625
+ // reserved family grows. (The placeholder itself carries both, so an already-armed file re-arms.)
626
+ let doc;
627
+ try { doc = JSON.parse(fs.readFileSync(full, "utf8")); } catch { continue; }
628
+ if (!doc || typeof doc !== "object" || Array.isArray(doc) || doc.candor === undefined || doc.functions === undefined) continue;
629
+ // Remember the bytes BEFORE overwriting, so a run that completes can hand back anything it turned
630
+ // out not to own (see disarmUnwrittenOutReports).
631
+ let prev;
632
+ try { prev = fs.readFileSync(full); } catch { continue; }
633
+ // THE SIDECARS FOLLOW ONLY IF THE REPORT ACTUALLY ARMED (raised on candor-java's arm of this rung;
634
+ // the rust reference deleted them unconditionally in f23a993 and was corrected by ff8cc09). A write that
635
+ // FAILS leaves the PREVIOUS run's report on disk; removing its sidecars there would produce a
636
+ // stale-report/no-callgraph pair no run has ever written — strictly worse than the pre-rung state,
637
+ // because the half that survives is the one a gate reads while the half that made `callers`
638
+ // answerable is gone. A pair degrades together or not at all.
639
+ let armed = false;
640
+ try { writeSinkAtomic(full, OUT_ARM_DOC); outArmed.push([full, prev]); armed = true; }
641
+ catch (e) {
642
+ console.error(`candor-ts: could not arm the report ${full} fail-closed (${e.message}) — leaving it `
643
+ + `AND its §2.2 sidecars exactly as they are; if this run does not complete, that path may still `
644
+ + `hold a PREVIOUS run's report`);
645
+ }
646
+ if (armed) removeArmedReportSidecars(full, prefix, inputs);
647
+ }
648
+ };
649
+
650
+ // HAND BACK WHAT THIS RUN TURNED OUT NOT TO OWN. Arming cannot know at parse time which files the run
651
+ // will write, so it arms the whole previous set. Once the run has finished writing, a file STILL holding
652
+ // the placeholder is one the run never claimed — a leftover from a package that is no longer in the scan.
653
+ //
654
+ // THAT IS NOT AN INCOMPLETE ANALYSIS, AND LEAVING THE PLACEHOLDER THERE ASSERTS ONE. The rust reference's
655
+ // first version kept them and described it as closing the orphaned-report defect for free; it did not. A
656
+ // placeholder's non-empty `unanalyzed` is the ⟨0.21⟩ incomplete-analysis trigger, so a COMPLETE scan
657
+ // began refusing with exit 2 and went on refusing until someone deleted the leftover by hand. The run did
658
+ // not fail to analyze that package; the package is not there. Claiming an incompleteness the run never
659
+ // experienced is the mirror of the staleness this rung exists to close.
660
+ //
661
+ // So the previous bytes go back and THE ORPHAN IS LEFT EXACTLY AS FOUND — still an open defect (a report
662
+ // for code that is gone still reaches a gate over the prefix), deliberately: it is pre-existing, it has
663
+ // its own wire question (delete it? mark it not-in-scan? a prefix can legitimately be shared), and
664
+ // resolving it inside a staleness fix would be deciding it by accident. Deleting the placeholder instead
665
+ // of restoring is rejected for §3.3.1's own reason: a consumer treating a missing file as "nothing to
666
+ // report" fails open by another route.
667
+ const disarmUnwrittenOutReports = () => {
668
+ const armed = Buffer.from(OUT_ARM_DOC);
669
+ for (const [file, prev] of outArmed) {
670
+ let now;
671
+ try { now = fs.readFileSync(file); } catch { continue; }
672
+ if (!now.equals(armed)) continue; // this run rewrote it — a real report
673
+ try { writeSinkAtomic(file, prev); }
674
+ catch (e) {
675
+ console.error(`candor-ts: could not restore ${file}, which this run armed but did not write `
676
+ + `(${e.message}) — it still holds the fail-closed placeholder`);
677
+ continue;
678
+ }
679
+ // ⟨0.28⟩ …AND THIS REPORT'S SIDECARS COME BACK WITH IT. A report the run turned out not to own is an
680
+ // ORPHAN, left exactly as found — and "as found" included its sidecars. Handing back the report while
681
+ // leaving them deleted is a THIRD state neither the pre-run tree nor the armed tree ever had: it
682
+ // degrades every `callers`/`whatif` answer over that package to the absence arm with nothing on disk
683
+ // saying why. This engine really does reach here (unlike candor-java, which arms exactly the one path
684
+ // the operator named): the armer arms the whole PREVIOUS set under the prefix, and this run writes
685
+ // only `<prefix>.json`, so any other report the previous run left there is armed and handed back.
686
+ for (const [owner, side, sprev] of outArmedSidecars) {
687
+ if (owner !== file || fs.existsSync(side)) continue; // not this report's, or the run rewrote it
688
+ try { writeSinkAtomic(side, sprev); }
689
+ catch (e) {
690
+ console.error(`candor-ts: could not restore ${side}, the §2.2 sidecar of ${file}, which this run `
691
+ + `armed but did not write (${e.message}) — that report is now missing a sidecar it had before `
692
+ + `this run started`);
693
+ }
694
+ }
695
+ }
696
+ };
697
+
698
+ {
699
+ const { gate, policy, target: preTarget, out: preOut } = preScan(argv);
700
+ // ⟨0.28⟩ The DUPLICATE case is decided below, and the single-sink guards here must not pre-empt it:
701
+ // they act on `gate` alone — the LAST sink — so `--gate-json - --gate-json <the policy>` exited on the
702
+ // policy before the STREAM could be told anything (measured: exit 2, stdout zero bytes).
703
+ const _named0 = allGateSinks(argv);
704
+ const _distinct0 = [];
705
+ for (const g of _named0) {
706
+ if (!_distinct0.some((k) => k === g || (k !== "-" && g !== "-" && sameArtifact(k, g)))) _distinct0.push(g);
707
+ }
708
+ const singleSink = _distinct0.length < 2;
709
+ // ⟨0.28⟩ `--json` BESIDE `--gate-json -`: a report and a verdict cannot share one stream. Decided here,
710
+ // in the pre-pass, so the refusal is stdout's ONLY content — refusing after the report has gone out is
711
+ // the defect, not the fix. On this engine `--json` is stdout-only, so the sink alone decides it.
712
+ if (gate === "-" && argv.includes("--json")) {
713
+ console.error("candor-ts: --json and --gate-json - both name STDOUT — refusing (exit 2). `--json` "
714
+ + "writes the REPORT there and `--gate-json -` the VERDICT, so this would put two JSON documents on "
715
+ + "one stream and a consumer parsing it gets neither. Send one to a file, or run the scan twice.");
716
+ console.log(JSON.stringify(refusalVerdict(SPEC_VERSION,
717
+ "--json and --gate-json - both name stdout — a report and a verdict cannot share one stream", null), null, 1));
718
+ process.exit(2);
719
+ }
720
+ for (const [other, flag] of (gate && singleSink ? runInputs(preTarget, policy) : [])) {
721
+ if (gate && sameArtifact(gate, other)) {
722
+ console.error(`candor-ts: --gate-json ${gate} names the SAME FILE as ${flag} ${other} — refusing `
723
+ + `(exit 2). The verdict is armed before the policy is read, so this would overwrite your policy `
724
+ + `and then gate on the wreckage. Nothing was written; give the verdict its own path.`);
725
+ refuseEarlyToStream(`--gate-json ${gate} names the same file as ${flag} ${other}`);
726
+ process.exit(2);
727
+ }
728
+ }
729
+ // ⟨0.28⟩ …AND THE TARGET EXPANDS TO THE FILES THE RUN WILL PARSE (see `sinkIsParsedSourceUnderTarget`).
730
+ if (gate && singleSink && sinkIsParsedSourceUnderTarget(gate, preTarget)) {
731
+ console.error(`candor-ts: --gate-json ${gate} lies UNDER the scan target ${preTarget} and bears an `
732
+ + `extension this engine parses — refusing (exit 2). Nothing was written there. The verdict is `
733
+ + `armed at parse time, BEFORE the file walk, so this would replace a source file this run is `
734
+ + `about to read and then scan the wreckage. A non-source sink under the target `
735
+ + `(${path.join(targetContainmentRoot(preTarget) ?? ".", ".candor", "verdict.json")}, say) is the `
736
+ + `recommended layout and stays permitted.`);
737
+ refuseEarlyToStream(`--gate-json ${gate} is a source file under the scan target ${preTarget}`);
738
+ process.exit(2);
739
+ }
740
+ // `.candor/config` is never a verdict sink, wherever it is. The per-input checks above can only name
741
+ // inputs the run was TOLD about; the config is DISCOVERED by walking up from the target, so by the
742
+ // time its path is known the arming has already destroyed it. A check on the SHAPE needs no
743
+ // discovery, so it runs before the first write and covers a config found anywhere up the tree.
744
+ if (gate && singleSink && gate !== "-") {
745
+ const abs = path.resolve(gate);
746
+ if (path.basename(abs) === "config" && path.basename(path.dirname(abs)) === ".candor") {
747
+ console.error(`candor-ts: --gate-json ${gate} is a .candor/config — refusing (exit 2). The verdict `
748
+ + `is armed before the config is read, so this would destroy the config that configures this `
749
+ + `run. Nothing was written; give the verdict its own path.`);
750
+ refuseEarlyToStream(`--gate-json ${gate} is a .candor/config`); // ⟨0.28⟩ report stream too
751
+ process.exit(2);
752
+ }
753
+ }
754
+ if (gate && singleSink && sameArtifact(gate, process.env.CANDOR_CONFIG)) {
755
+ console.error(`candor-ts: --gate-json ${gate} names the SAME FILE as CANDOR_CONFIG — refusing (exit 2).`);
756
+ refuseEarlyToStream(`--gate-json ${gate} names the same file as CANDOR_CONFIG`); // ⟨0.28⟩
757
+ process.exit(2);
758
+ }
759
+ // ⟨0.28⟩ A REPEATED `--gate-json` IS REFUSED, AND EVERY PATH NAMED GETS THE REFUSAL. Placed after the
760
+ // input-collision guards above and before arming: a sink that is an INPUT is refused having written
761
+ // nothing, and that exemption outranks this one — this write must not be the thing that destroys a
762
+ // policy. Two spellings of one path are ONE sink (the same artifact rule), so `--gate-json P
763
+ // --gate-json ./P` from P's own directory is not a duplicate.
764
+ const named = allGateSinks(argv);
765
+ const distinct = [];
766
+ for (const g of named) {
767
+ if (!distinct.some((k) => k === g || (k !== "-" && g !== "-" && sameArtifact(k, g)))) distinct.push(g);
768
+ }
769
+ // EVERY named sink gets the input checks, not just the one the parse honours. The first draft checked
770
+ // only `gate` (the last), so with `--policy P --gate-json P --gate-json B` the guard never saw P and
771
+ // this refusal DESTROYED the policy — measured against the other three engines, which kept it. The
772
+ // input exemption outranks this refusal: a sink that is an input is refused having written nothing.
773
+ // ⟨0.28⟩ THE INPUT EXEMPTION COVERS THE PATH, NOT THE RUN. Refusing the whole run on the first
774
+ // offending sink left the OTHER named sink holding whatever it held — measured: exit 2, the policy
775
+ // correctly intact, and an innocent sink still publishing a previous run's `{"ok": true}` to its
776
+ // reader. The offending path gets nothing; every other one gets the refusal.
777
+ const offending = new Set();
778
+ if (distinct.length > 1) {
779
+ for (const g of distinct) {
780
+ let bad = false;
781
+ for (const [other, flag] of runInputs(preTarget, policy)) {
782
+ if (sameArtifact(g, other)) {
783
+ console.error(`candor-ts: --gate-json ${g} names the SAME FILE as ${flag} ${other} — refusing `
784
+ + `(exit 2). Nothing was written there.`);
785
+ bad = true;
786
+ }
787
+ }
788
+ if (g !== "-") {
789
+ const abs = path.resolve(g);
790
+ if (path.basename(abs) === "config" && path.basename(path.dirname(abs)) === ".candor") {
791
+ console.error(`candor-ts: --gate-json ${g} is a .candor/config — refusing (exit 2). Nothing was written there.`);
792
+ bad = true;
793
+ }
794
+ }
795
+ if (sameArtifact(g, process.env.CANDOR_CONFIG)) {
796
+ console.error(`candor-ts: --gate-json ${g} names the SAME FILE as CANDOR_CONFIG — refusing (exit 2).`);
797
+ bad = true;
798
+ }
799
+ // ⟨0.28⟩ THE SAME PREDICATE ON THE DUPLICATE ROUTE. Asked here as well as above so a SECOND
800
+ // `--gate-json` cannot smuggle the duplicate-refusal document over source the single-sink route
801
+ // refuses to touch — this rung has paid six times for one rule with two spellings. The path is
802
+ // exempt (nothing written there); every innocent sibling sink still gets the refusal.
803
+ if (sinkIsParsedSourceUnderTarget(g, preTarget)) {
804
+ console.error(`candor-ts: --gate-json ${g} lies UNDER the scan target ${preTarget} and bears an `
805
+ + `extension this engine parses — refusing (exit 2). Nothing was written there.`);
806
+ bad = true;
807
+ }
808
+ if (bad) offending.add(g);
809
+ }
810
+ if (offending.size === distinct.length) {
811
+ refuseEarlyToStream(`every named --gate-json path collides with an input`); // ⟨0.28⟩ report stream
812
+ process.exit(2);
813
+ }
814
+ }
815
+ if (distinct.length > 1) {
816
+ const list = distinct.join(", ");
817
+ console.error(`candor-ts: --gate-json given more than once (${list}) — refusing (exit 2). A gate `
818
+ + `publishes ONE verdict. Naming two sinks says where it goes twice, and the reader of the path `
819
+ + `that loses cannot tell it lost. Name one, or run the gate twice.`);
820
+ const doc = JSON.stringify(refusalVerdict(SPEC_VERSION,
821
+ `--gate-json was given more than once (${list}) — a run publishes one verdict to one sink`, null), null, 1);
822
+ for (const g of distinct) {
823
+ if (offending.has(g)) continue; // the exemption is scoped to this path
824
+ if (g === "-") console.log(doc);
825
+ else try { writeSinkAtomic(g, doc + "\n"); }
826
+ catch (e) { console.error(`candor-ts: could not write the refusal to --gate-json ${g} (${e.message})`); }
827
+ }
828
+ // ⟨0.28⟩ report stream: only fire the helper when stdout wasn't ALREADY claimed by a `--gate-json -`
829
+ // verdict written in the loop above — the helper would otherwise write a second document to stdout
830
+ // (its verdict arm keys on `preGateSink === "-"` and doesn't know one was just printed). When `-`
831
+ // isn't in the list, refuseEarlyToStream's report arm handles the `--json` case; when it is, the
832
+ // verdict is already there and `--json` was refused earlier (line 333), so nothing more is owed.
833
+ if (!distinct.includes("-")) refuseEarlyToStream(`--gate-json was given more than once (${list})`);
834
+ process.exit(2);
835
+ }
836
+ if (gate && gate !== "-") armGateJsonFailClosed(gate);
837
+ // ⟨0.28⟩ …AND ARM THE REPORT SET, still before the arg loop below can exit on an unknown flag.
838
+ //
839
+ // **ONLY AN EXPLICITLY NAMED `--out`, NEVER THE DEFAULT PREFIX.** The first version of this armed the
840
+ // default `<target>/.candor/report` too, reasoning that an operator who passes no `--out` still has
841
+ // yesterday's report there to go stale. That is right about staleness and wrong about OWNERSHIP, and
842
+ // the difference DESTROYS DATA: measured, `scan.mjs <repo> --zzz-not-a-flag` overwrote a COMMITTED
843
+ // `.candor/report.json` with the placeholder — and committed reports and baselines are the pattern
844
+ // this project recommends and ships in CI. A run that dies in argv parsing was never going to write
845
+ // there, and it had not been told it owned that path. (It bit for real: this engine's first version
846
+ // left candor-rust's committed report dirty while running a conformance probe.)
847
+ //
848
+ // ⟨0.27⟩'s arming rule never had to face this because `--gate-json` has no default — every verdict
849
+ // sink is NAMED. So "arm at the instant the sink is known" presumes a sink the operator named, and
850
+ // that presumption is explicit here: with `--out p` the operator has declared p is this run's output
851
+ // and arming is right even when p is checked in; with no flag there is no such declaration. The legacy
852
+ // positional prefix (`scan.mjs f.ts out`) is left alone for the same reason it was never armed here.
853
+ //
854
+ // `--json` publishes the report to STDOUT and writes no files at all, so there is no file sink to arm
855
+ // in that form — it is the stream rule above, and arming under it would rewrite files this run is
856
+ // never going to touch.
857
+ //
858
+ // ⟨0.28⟩ **AND A REPEATED `--out` IS THE SAME RULE — refused at exit 2, with the fail-closed report
859
+ // written to EVERY prefix named.** `--out A --out B` says where the reports go, twice; the two
860
+ // statements cannot both be honoured. MEASURED here 2026-08-12 before this: last-wins at exit 0, with
861
+ // `A.json` byte-identical to the previous good run — a stale green whose reader has no way to learn it
862
+ // lost, and a `gate --report A` over it answers from a scan that never ran.
863
+ //
864
+ // "The fail-closed report written to every prefix named" means, under THIS sink's own arming rules,
865
+ // ARMING each prefix: the set at risk is the one the PREVIOUS run left there, which is what
866
+ // `armOutPrefixFailClosed` rewrites to the ⟨0.21⟩ Row-1 no-claim shape (asking the input exemption
867
+ // first, per file, and taking the §2.2 sidecars with each report). The run exits before scanning, so
868
+ // `disarmUnwrittenOutReports` never runs and the placeholders STAND — which is the fail-closed reading
869
+ // a run that scanned nothing is entitled to.
870
+ //
871
+ // AFTER the gate-sink arming above, so a `--gate-json` file sink already holds its armed document and
872
+ // `refuseEarlyToStream` covers the `-` and `--json` streams. And skipped under `--json` for the same
873
+ // reason the single-prefix arm below is: that form writes no files at all, so arming would rewrite
874
+ // files this run was never going to touch — the refusal itself still fires.
875
+ const namedOuts = distinctOutPrefixes(allOutPrefixes(argv));
876
+ if (namedOuts.length > 1) {
877
+ const list = namedOuts.join(", ");
878
+ console.error(`candor-ts: --out given more than once (${list}) — refusing (exit 2). A run writes ONE `
879
+ + `report set to ONE prefix. Naming two says where the reports go twice, and the reader of the `
880
+ + `prefix that loses cannot tell it lost — it goes on holding a previous run's reports as if they `
881
+ + `were current${preWantJson ? "" : ", so every prefix named now holds the fail-closed empty in "
882
+ + "place of them"}. Name one, or run the scan twice.`);
883
+ if (!preWantJson) {
884
+ const inputs = runInputs(preTarget, policy);
885
+ for (const p of namedOuts) armOutPrefixFailClosed(p, inputs);
886
+ }
887
+ refuseEarlyToStream(`--out was given more than once (${list}) — a run writes one report set to one prefix`);
888
+ process.exit(2);
889
+ }
890
+ if (preOut && !preWantJson) armOutPrefixFailClosed(preOut, runInputs(preTarget, policy));
891
+ }
122
892
  let target = null, outPrefix = null, policyPath = process.env.CANDOR_POLICY ?? null, gateJsonPath = null, allowJs = false, wantAgents = false, wantJson = false, wantWorkspace = false, wantDepInits = false;
123
893
  for (let i = 0; i < argv.length; i++) {
124
894
  const a = argv[i];
@@ -132,7 +902,11 @@ for (let i = 0; i < argv.length; i++) {
132
902
  else if (a === "--dep-inits") wantDepInits = true;
133
903
  else if (a === "--out" || a === "--policy" || a === "--gate-json") {
134
904
  const v = argv[i + 1];
135
- if (v === undefined || v.startsWith("--")) { console.error(`candor-ts: ${a} requires a value (${usage})`); process.exit(2); }
905
+ if (v === undefined || v.startsWith("--")) {
906
+ console.error(`candor-ts: ${a} requires a value (${usage})`);
907
+ refuseEarlyToStream(`${a} requires a value`);
908
+ process.exit(2);
909
+ }
136
910
  if (a === "--out") outPrefix = v; else if (a === "--policy") policyPath = v; else gateJsonPath = v;
137
911
  i++;
138
912
  }
@@ -140,13 +914,27 @@ for (let i = 0; i < argv.length; i++) {
140
914
  // (SPEC §6.2/§7). `-h`/`-V`/`--help`/`--version` are print-and-exit modes consumed above, so by here
141
915
  // a single-dash token (`-x`, the typo `-policy`) can only be a mistake; treating it as the scan
142
916
  // target would silently scan the wrong thing.
143
- else if (a.startsWith("-")) { console.error(`candor-ts: unknown flag ${a} (${usage})`); process.exit(2); }
917
+ else if (a.startsWith("-")) {
918
+ console.error(`candor-ts: unknown flag ${a} (${usage})`);
919
+ // ⟨0.27⟩ §3.3 names an unknown flag as a broken-gate-config exit-2 cause; the stream sink gets the
920
+ // refusal document too (see refuseEarlyToStream — the file sink is already armed).
921
+ refuseEarlyToStream(`unknown flag ${a}`);
922
+ process.exit(2);
923
+ }
144
924
  else if (target === null) target = a;
145
925
  else if (outPrefix === null) outPrefix = a; // legacy positional prefix
146
- else { console.error(`candor-ts: unexpected extra argument ${a} (${usage})`); process.exit(2); }
926
+ else {
927
+ console.error(`candor-ts: unexpected extra argument ${a} (${usage})`);
928
+ refuseEarlyToStream(`unexpected extra argument ${a}`);
929
+ process.exit(2);
930
+ }
147
931
  }
148
932
  if (wantAgents) { printAgents(); process.exit(0); }
149
- if (target === null) { console.error(usage); process.exit(2); }
933
+ if (target === null) {
934
+ console.error(usage);
935
+ refuseEarlyToStream("no scan target");
936
+ process.exit(2);
937
+ }
150
938
 
151
939
  // ---- .candor/config (candor-spec §config; the checked-in alternative to the CANDOR_* env vars) -----
152
940
  // Discovery is anchored to the SCAN TARGET (walk up from the target dir to the repo root's
@@ -156,14 +944,15 @@ if (target === null) { console.error(usage); process.exit(2); }
156
944
  // never vanish silently (the §6.2 unreadable-policy posture). Only genuine absence is an empty config.
157
945
  // Keys are the shared FAMILY vocabulary; a key OUTSIDE it warns (typo protection: a misspelt `policy`
158
946
  // must not silently drop the gate).
159
- const CONFIG_KEYS = new Set(["policy", "baseline", "strict", "no-ambient", "closed-world", "taint", "deps", "unknown-alias", "net-partner", "unknown-ratchet"]);
947
+ // ⟨0.27⟩ `engine` (SPEC §3.4) is RECOGNIZED and IMPLEMENTED here — see enforceEnginePin. It must be in
948
+ // BOTH sets: missing from the vocabulary it is reported as unknown, and missing from IMPLEMENTED it is
949
+ // disclosed as inert — both tell an operator their pin was ignored while the engine is enforcing it.
160
950
  // The subset this engine actually wires to a mode — `policy` (the gate), `baseline` (AS-EFF-005),
161
951
  // `deps` (the cross-package report chain) and `unknown-ratchet` (the baseline guard's opt-in). The rest
162
952
  // of the vocabulary is spec-inert HERE: it drives other engines' gates. But a checked-in enforcement key
163
953
  // that silently does nothing is a DECLARED-GATE-SILENTLY-OFF — the reader believes the gate is on — so
164
954
  // an inert recognized key DISCLOSES loudly (stderr only; verdict/report/exit code untouched) instead of
165
955
  // staying mute. Same posture + message shape as candor-scan's CONFIG_KEYS_IMPLEMENTED.
166
- const CONFIG_KEYS_IMPLEMENTED = new Set(["policy", "baseline", "deps", "unknown-ratchet"]);
167
956
  // The ANCHOR a config file's RELATIVE path values (policy/deps) resolve against: the repo the config
168
957
  // belongs to — the parent of its `.candor/` directory (the standard layout; candor-init scaffolds
169
958
  // `policy arch.policy` meaning the repo root's), else the config file's own directory. NEVER the
@@ -174,28 +963,139 @@ function configAnchor(file) {
174
963
  const dir = path.dirname(path.resolve(file));
175
964
  return path.basename(dir) === ".candor" ? path.dirname(dir) : dir;
176
965
  }
177
- function loadCandorConfig(targetPath) {
966
+ // ⟨0.27⟩ SPEC §3.4 `engine` — THE ENGINE↔BASELINE COUPLING, enforced instead of hoped for.
967
+ //
968
+ // The committed baseline is a snapshot of what ONE engine build reported, and an engine swap is
969
+ // baseline-invalidating. What a PIN adds over the provenance checks already in place is that it is
970
+ // DECLARATIVE — a build id is a hash nobody can write down, so the intended version lived in CI config,
971
+ // decoupled from the baseline it is married to. It also tells tooling which engine to FETCH, and it
972
+ // reaches a run with NO baseline configured at all.
973
+ //
974
+ // TWO OF THE FIVE VERDICTS MUST NOT CHANGE THE EXIT CODE: an ABSENT pin (the key is opt-in by
975
+ // construction) and an UNDETERMINED one, where §3.1's unanswerable-condition rule applies — disclosed,
976
+ // never scored, INCLUDING as satisfied. Exit 2 on a mismatch, never 1: unevaluable, not violating.
977
+ //
978
+ // A pin qualified for another implementation is not ours to check — one config serves the whole family,
979
+ // and the family versions as a LADDER, so a bare version in a polyglot repo would fail whichever engine
980
+ // had not yet caught up.
981
+ const ENGINE_IMPLS = new Set(["java", "rust", "ts", "swift", "agents"]);
982
+ function enginePinFor(text, implName) {
983
+ let wild = null, qual = null, bad = false;
984
+ for (const rawLine of (text ?? "").split("\n")) {
985
+ const line = rawLine.split("#")[0].trim();
986
+ if (!line) continue;
987
+ const parts = line.split(/\s+/);
988
+ if (parts[0].toLowerCase() !== "engine") continue;
989
+ const rest = parts.slice(1);
990
+ // Two lines that DISAGREE about the same key are kept BOTH, so they cannot parse as a version and
991
+ // surface as malformed. One silently discarding the other is the failure this key exists to stop.
992
+ const slot = (cur, v) => (cur !== null && cur !== v ? `${cur} / ${v}` : v);
993
+ // A KNOWN QUALIFIER DECIDES OWNERSHIP BEFORE ARITY. Checking the one-token case first made `engine swift` a WILDCARD pin whose version is the literal "swift" -> MALFORMED -> exit 2 in every engine, so one operator forgetting a version on a qualified line killed the whole family. SPEC 3.4 says the skip is whole-line 'whatever follows it' -- and nothing following it is a case of that too.
994
+ if (rest.length && ENGINE_IMPLS.has(rest[0].toLowerCase())) {
995
+ if (rest[0].toLowerCase() === implName) { if (rest.length === 2) qual = slot(qual, rest[1]); else bad = true; }
996
+ continue; // another impl's line, whatever follows it
997
+ }
998
+ if (rest.length === 0) bad = true;
999
+ else if (rest.length === 1) wild = slot(wild, rest[0]);
1000
+ else bad = true;
1001
+ }
1002
+ if (bad) return "<unreadable>";
1003
+ // AN UNREADABLE UNQUALIFIED LINE IS NOT HIDDEN BY A QUALIFIED PIN. `qual ?? wild` returned the qualifi
1004
+ // ed value, so `engine garbage` beside a good qualified line passed SILENTLY here while candor-java exited
1005
+ // 2 — the exact mirror of the bug just fixed in java, four engines the other way. Unreadability is a property of the LINE; precedence only decides which VERSION applies.
1006
+ if (wild !== null && normalizePinVersion(wild) === null) return wild;
1007
+ return qual ?? wild;
1008
+ }
1009
+ function normalizePinVersion(raw) {
1010
+ const s = String(raw ?? "").trim().replace(/^[vV]/, "");
1011
+ if (!/^\d+\.\d+(\.\d+)?$/.test(s)) return null;
1012
+ return s.split(".").length === 2 ? `${s}.0` : s;
1013
+ }
1014
+ function enforceEnginePin(targetPath) {
1015
+ const pin = enginePinFor(discoverConfigText(targetPath), "ts");
1016
+ if (pin === null || pin === undefined) return; // ABSENT
1017
+ const want = normalizePinVersion(pin);
1018
+ if (want === null) {
1019
+ console.error(`candor-ts: .candor/config has an \`engine\` line that is not an engine version.`);
1020
+ console.error(` want \`engine <version>\` (e.g. \`engine v${PKG_VERSION}\`) or \`engine <impl> <version>\``);
1021
+ console.error(` (e.g. \`engine ts v${PKG_VERSION}\`) for a repo scanned by more than one engine.`);
1022
+ console.error(` Failing (exit 2) rather than ignoring it: a pin that cannot be read is a`);
1023
+ console.error(` guard the operator believes is on.`);
1024
+ process.exit(2);
1025
+ }
1026
+ const running = normalizePinVersion(PKG_VERSION) ?? String(PKG_VERSION ?? "").trim();
1027
+ if (!running || running === "unknown") { // UNDETERMINED — disclose, never score
1028
+ console.error(`candor-ts: .candor/config pins engine ${pin}, and this build does not know its own release,`);
1029
+ console.error(` so the pin CANNOT be checked. Disclosed, not scored — neither passed nor failed.`);
1030
+ return;
1031
+ }
1032
+ if (want === running) return; // MATCH
1033
+ console.error(`candor-ts: .candor/config pins engine ${pin} but this build is candor-ts ${PKG_VERSION}.`);
1034
+ console.error(` The pin and the committed baseline move together — a newer engine resolves more`);
1035
+ console.error(` dispatch, so its report is not comparable with a baseline the pinned engine wrote.`);
1036
+ console.error(` Either run the pinned engine, or update the pin and regenerate the baseline in the`);
1037
+ console.error(` same change. Exit 2 (unevaluable), not 1 — this is not a policy violation.`);
1038
+ // ONE call, not two: an insertion script matched both pin branches to this single exit, and the
1039
+ // duplicate put TWO documents on the stream — which parses as neither. The other branch (a build that
1040
+ // cannot determine its own release) RETURNS rather than exiting: disclosed, not scored, so no refusal
1041
+ // belongs there.
1042
+ refuseEarlyToStream(`.candor/config pins engine ${pin}, which this build does not satisfy`);
1043
+ process.exit(2);
1044
+ }
1045
+
1046
+ // WHICH config file this run reads, with NO side effects (SPEC §3.4). Extracted so the §3.3.1 sink
1047
+ // guard asks the same question the loader answers instead of re-deriving the walk — a review took the
1048
+ // guard's own copy apart on exactly that divergence.
1049
+ function discoverConfigFile(targetPath) {
1050
+ const env = process.env.CANDOR_CONFIG;
1051
+ if (env) {
1052
+ try { return fs.statSync(env).isFile() ? env : null; } catch { return null; }
1053
+ }
1054
+ let dir = path.resolve(targetPath ?? ".");
1055
+ try { if (!fs.statSync(dir).isDirectory()) dir = path.dirname(dir); } catch { dir = path.dirname(dir); }
1056
+ for (let d = dir; ; d = path.dirname(d)) {
1057
+ const cand = path.join(d, ".candor", "config");
1058
+ if (fs.existsSync(cand)) return cand;
1059
+ if (path.dirname(d) === d) break; // filesystem root
1060
+ }
1061
+ return fs.existsSync(".candor/config") ? ".candor/config" : null;
1062
+ }
1063
+
1064
+ /// `lenient: true` THROWS where this would otherwise `process.exit(2)`.
1065
+ ///
1066
+ /// The collision pre-pass needs the config's DECLARED input paths before the sink is armed, and it
1067
+ /// wrapped this call in a try under the comment "the real load refuses on its own terms" — which
1068
+ /// assumes the failure arrives as an exception. It does not: `process.exit` is not catchable, so an
1069
+ /// unreadable config killed the run INSIDE that try, before arming, leaving a pre-seeded green verdict
1070
+ /// intact at the file sink. SPEC §3.3's own words for that: "a refusal that writes nothing leaves the
1071
+ /// previous run's green document on disk." java answers the same input with `refused: true`.
1072
+ ///
1073
+ /// Nothing is lost by leniency here. If the config cannot be read it declares no inputs anyone can
1074
+ /// name, so the collision check over them is vacuous; arming then proceeds and the REAL load refuses a
1075
+ /// moment later — now with the sink armed, so the refusal reaches it. A second parser was the
1076
+ /// alternative, and a second parser is a second set of holes.
1077
+ function loadCandorConfig(targetPath, { lenient = false } = {}) {
178
1078
  let file = process.env.CANDOR_CONFIG ?? null;
179
1079
  if (file !== null) {
180
1080
  if (!fs.existsSync(file) || !fs.statSync(file).isFile()) {
1081
+ if (lenient) throw new Error(`CANDOR_CONFIG set but ${file} is not a readable file`);
181
1082
  console.error(`candor-ts: CANDOR_CONFIG set but ${file} is not a readable file — failing (exit 2)`);
1083
+ // The config is the EARLIEST exit-2 cause, and the one the stream sink is least likely to be
1084
+ // armed for — which is exactly why it was the last one still leaving stdout empty. Found by
1085
+ // PART 36 (b11), a row written before this line was.
1086
+ refuseEarlyToStream(`CANDOR_CONFIG set but ${file} is not a readable file`);
182
1087
  process.exit(2);
183
1088
  }
184
1089
  } else {
185
- let dir = path.resolve(targetPath);
186
- try { if (!fs.statSync(dir).isDirectory()) dir = path.dirname(dir); } catch { dir = path.dirname(dir); }
187
- for (let d = dir; ; d = path.dirname(d)) {
188
- const cand = path.join(d, ".candor", "config");
189
- if (fs.existsSync(cand)) { file = cand; break; }
190
- if (path.dirname(d) === d) break; // filesystem root
191
- }
192
- if (file === null && fs.existsSync(".candor/config")) file = ".candor/config";
1090
+ file = discoverConfigFile(targetPath);
193
1091
  if (file === null) return {};
194
1092
  }
195
1093
  let text;
196
1094
  try { text = fs.readFileSync(file, "utf8"); }
197
1095
  catch (e) {
1096
+ if (lenient) throw new Error(`config ${file} exists but could not be read (${e.message})`);
198
1097
  console.error(`candor-ts: config ${file} exists but could not be read (${e.message}) — failing (exit 2)`);
1098
+ refuseEarlyToStream(`config ${file} exists but could not be read`);
199
1099
  process.exit(2);
200
1100
  }
201
1101
  const cfg = {};
@@ -230,10 +1130,78 @@ function loadCandorConfig(targetPath) {
230
1130
  const anchor = configAnchor(file);
231
1131
  if (cfg.policy) cfg.policy = path.resolve(anchor, cfg.policy);
232
1132
  if (cfg.baseline) cfg.baseline = path.resolve(anchor, cfg.baseline);
233
- if (cfg.deps) cfg.deps = cfg.deps.split(/[\s:,]+/).filter(Boolean).map((t) => path.resolve(anchor, t)).join(":");
1133
+ // ASCII whitespace ONLY, like java and swift: these are PATHS, and JS `\s` includes U+00A0, so a dep
1134
+ // path containing a non-breaking space split into two halves that were then both "skipped" — a green
1135
+ // run with the dep silently unchained, where java and rust loaded it.
1136
+ if (cfg.deps) cfg.deps = cfg.deps.split(/[ \t:,]+/).filter(Boolean).map((t) => path.resolve(anchor, t)).join(":");
234
1137
  return cfg;
235
1138
  }
1139
+ // ⟨0.24⟩/⟨0.27⟩ ARM THE VERDICT FAIL-CLOSED. Every exit path then leaves a refusal behind unless the run
1140
+ // got far enough to replace it with a real verdict. A review found the pin refusal leaving the PREVIOUS
1141
+ // run's document on disk — a CI wrapper reading the artifact instead of the exit code then reports a pass
1142
+ // over a run that refused. candor-java's `armGateJson` is the model; the wording is about the RUN, not
1143
+ // about the code.
1144
+ //
1145
+ // A `function` declaration, not a `const`: it is CALLED from the pre-pass above the arg loop, and only a
1146
+ // hoisted declaration can be. The write is inlined rather than calling `writeAtomic` for the same reason
1147
+ // in reverse — that helper is a `const` declared ~4700 lines below, so calling it would be a
1148
+ // temporal-dead-zone throw.
1149
+ // ⟨0.28⟩ RESOLVE THE SINK TO ITS FINAL ARTIFACT BEFORE WRITING, and preserve the operator's layout.
1150
+ // `renameSync` REPLACES a symlink rather than following it, so an `artifacts/verdict.json` linked into a
1151
+ // shared directory kept a previous run's `{"ok": true}` while this run's document landed on the link — a
1152
+ // stale green with a single `--gate-json` and no operator mistake. And rename gives the destination a NEW
1153
+ // inode, so a multiply-linked target strands its other name with the previous document; there the write
1154
+ // goes in place, trading the atomicity window for not publishing a stale verdict at a name the operator
1155
+ // wired up. SPEC §3.3.1 states identity about ARTIFACTS; this family had it in the comparison only.
1156
+ function resolveSinkArtifact(p) {
1157
+ let cur = p;
1158
+ for (let i = 0; i < 32; i++) {
1159
+ let st;
1160
+ try { st = fs.lstatSync(cur); } catch { return cur; }
1161
+ if (!st.isSymbolicLink()) return cur;
1162
+ let t;
1163
+ try { t = fs.readlinkSync(cur); } catch { return cur; }
1164
+ cur = path.isAbsolute(t) ? t : path.join(path.dirname(cur), t);
1165
+ }
1166
+ return cur;
1167
+ }
1168
+
1169
+ function writeSinkAtomic(p, text) {
1170
+ const target = resolveSinkArtifact(p);
1171
+ try {
1172
+ if (fs.statSync(target).nlink > 1) { fs.writeFileSync(target, text); return; }
1173
+ } catch { /* not there yet — the ordinary temp+rename path is right */ }
1174
+ const tmp = `${target}.${process.pid}.tmp`;
1175
+ fs.writeFileSync(tmp, text);
1176
+ fs.renameSync(tmp, target);
1177
+ }
1178
+
1179
+ function armGateJsonFailClosed(p) {
1180
+ try {
1181
+ writeSinkAtomic(p, JSON.stringify({
1182
+ spec: SPEC_VERSION, ok: false, refused: true,
1183
+ reason: "the gate did not complete — this document was written when the run STARTED and was never "
1184
+ + "replaced by a verdict, so the run failed, crashed or was killed before it could decide. It is "
1185
+ + "NOT a verdict about the code; see the run's stderr for the cause.",
1186
+ }, null, 1) + "\n");
1187
+ } catch (e) {
1188
+ console.error(`candor-ts: could not arm --gate-json ${p} fail-closed (${e.message}) — if this run `
1189
+ + `does not complete, that path may still hold a PREVIOUS run's verdict`);
1190
+ }
1191
+ }
1192
+ // MOVED ABOVE THE CONFIG LOAD. `loadCandorConfig` is ITSELF an exit-2 cause (an unusable
1193
+ // CANDOR_CONFIG, or a committed `.candor/config` that cannot be read), and arming after it left a
1194
+ // config refusal exiting 2 with the PREVIOUS run's green still on disk — while the comment below
1195
+ // said "BEFORE ANYTHING THAT CAN EXIT". The rule only holds if the arming really is first.
1196
+ // ⟨0.24⟩ ARM THE VERDICT FAIL-CLOSED BEFORE ANYTHING THAT CAN EXIT. A review found the pin refusal
1197
+ // leaving the PREVIOUS run's `--gate-json` document on disk — a CI wrapper reading the artifact instead
1198
+ // of the exit code then reports a pass over a run that refused. Arming at the START makes this a CLASS
1199
+ // fix: every exit path leaves a refusal unless the run got far enough to replace it. candor-java's
1200
+ // `armGateJson` is the model, and the wording is about the RUN, not about the code.
1201
+ // (armed by the pre-pass above, before the arg loop — see SPEC §3.3.1 ⟨0.27⟩. Arming HERE was still
1202
+ // after the loop's unknown-flag exit, so the contract depended on argv order.)
236
1203
  const candorConfig = loadCandorConfig(target);
1204
+ enforceEnginePin(target); // ⟨0.27⟩ §3.4 — AFTER the arming, so its exit 2 cannot leave a stale verdict
237
1205
  // precedence: the --policy flag / CANDOR_POLICY env already populated policyPath; the config is the floor.
238
1206
  // A BARE `policy` line ("" value) means configured-with-empty → the unreadable-policy path fails loud.
239
1207
  if (policyPath === null && candorConfig.policy !== undefined) policyPath = candorConfig.policy;
@@ -241,7 +1209,12 @@ if (policyPath === null && candorConfig.policy !== undefined) policyPath = cando
241
1209
  // (path-valued keys are already resolved against the config's anchor above). No CLI flag — matching
242
1210
  // candor-java, the reference engine (env/config only). A BARE `baseline` line ("") fails loud below.
243
1211
  let baselinePath = process.env.CANDOR_BASELINE ?? null;
244
- if (baselinePath === null && candorConfig.baseline !== undefined) baselinePath = candorConfig.baseline;
1212
+ // WHICH SOURCE supplied it decides what a MISSING file means: `CANDOR_BASELINE` is set unconditionally
1213
+ // by the adopt workflow, so an absent path there is "the ratchet is not adopted yet"; a checked-in
1214
+ // `baseline` line DECLARES this repo has one, so an absent path there was deleted or never committed —
1215
+ // and the guard passing green over it is a gate that silently stopped gating.
1216
+ let baselineFromConfig = false;
1217
+ if (baselinePath === null && candorConfig.baseline !== undefined) { baselinePath = candorConfig.baseline; baselineFromConfig = true; }
245
1218
  // ⟨unknown-ratchet⟩ OPT-IN (config `unknown-ratchet` / CANDOR_UNKNOWN_RATCHET, default OFF): flip an
246
1219
  // Unknown-ONLY gain vs the baseline from advisory to an AS-EFF-005 failure (exit 1). Env-override truthy
247
1220
  // semantics mirror candor-java's Config.flag exactly — env var PRESENCE means on (env can't express off);
@@ -290,9 +1263,20 @@ function fromTsconfig(cfgPath, baseDir) {
290
1263
  return names.filter((f) => !isTestPath(path.relative(baseDir, f)));
291
1264
  }
292
1265
  const stat = fs.existsSync(target) ? fs.statSync(target) : null;
293
- if (!stat) { console.error(`candor-ts: no such path: ${target}`); process.exit(2); }
1266
+ if (!stat) {
1267
+ console.error(`candor-ts: no such path: ${target}`);
1268
+ // The SAME two lines as every other early exit in this file. `refuseEarlyToStream` WRITES AND
1269
+ // RETURNS — it is not a `Never`, and calling it INSTEAD of the exit lets the run continue past its
1270
+ // own refusal (measured, while getting this wrong: an unreadable dep wrote its refusal and then
1271
+ // exited 0). rust's equivalent is typed `-> !`, which is why the same slip could not happen there.
1272
+ refuseEarlyToStream(`no such path: ${target}`);
1273
+ process.exit(2);
1274
+ }
1275
+ let usedTsconfig = null; // the tsconfig this run actually read, so a later disclosure can name the
1276
+ // cause that APPLIES rather than the one that usually does.
294
1277
  if (stat.isFile() && /tsconfig.*\.json$/.test(path.basename(target))) {
295
1278
  rootDir = path.dirname(path.resolve(target));
1279
+ usedTsconfig = path.resolve(target);
296
1280
  fileNames = fromTsconfig(path.resolve(target), rootDir);
297
1281
  } else if (stat.isFile()) {
298
1282
  rootDir = path.dirname(path.resolve(target));
@@ -301,6 +1285,7 @@ if (stat.isFile() && /tsconfig.*\.json$/.test(path.basename(target))) {
301
1285
  rootDir = path.resolve(target);
302
1286
  const tsconfig = path.join(rootDir, "tsconfig.json");
303
1287
  if (fs.existsSync(tsconfig) && !allowJs) {
1288
+ usedTsconfig = tsconfig;
304
1289
  fileNames = fromTsconfig(tsconfig, rootDir);
305
1290
  } else {
306
1291
  fileNames = [];
@@ -315,7 +1300,13 @@ if (stat.isFile() && /tsconfig.*\.json$/.test(path.basename(target))) {
315
1300
  })(rootDir);
316
1301
  }
317
1302
  }
318
- if (fileNames.length === 0) { console.error(`candor-ts: no TypeScript sources under ${target}`); process.exit(2); }
1303
+ if (fileNames.length === 0) {
1304
+ console.error(`candor-ts: no TypeScript sources under ${target}`);
1305
+ // An empty scan is an exit-2 cause like any other: a consumer reading the stream after it must not
1306
+ // get nothing. §3.1 exempts no cause, and this one is easy to hit in CI (a path that moved).
1307
+ refuseEarlyToStream(`no TypeScript sources under ${target}`);
1308
+ process.exit(2);
1309
+ }
319
1310
  // Builtin typings FALLBACK: the engine ships @types/node as its own dependency, so a target that
320
1311
  // hasn't installed it still resolves node:fs/node:net/… (found by the first npx-distribution
321
1312
  // probe: a bare fixture read Unknown for fs.readFileSync because nothing supplied the builtin
@@ -331,6 +1322,29 @@ if (!compilerOptions.typeRoots) {
331
1322
  compilerOptions.typeRoots = roots;
332
1323
  }
333
1324
  if (!outPrefix) outPrefix = path.join(rootDir, ".candor", "report");
1325
+ // ⟨0.28⟩ THE REPORT SET REACHES ITS SINK BY A SECOND ROUTE, AND THE INPUT RULE MUST COVER BOTH. The armer
1326
+ // asks the input exemption of every file it arms, so `--out tsconfig` over a `tsconfig.json` TARGET was
1327
+ // correctly left unarmed — and then the FINAL `writeAtomic(`${outPrefix}.json`)` destroyed it anyway, at
1328
+ // exit 0, on a run that had already scanned it: "wrote 0 effectful functions … to tsconfig.json" over the
1329
+ // operator's own tsconfig. Measured while verifying the target registration covered both sinks; it covered
1330
+ // the arm and not the write. Asked HERE, where every route to the report set converges (`--out`, the
1331
+ // legacy positional prefix, and the default), of the same `runInputs`/`sameArtifact` every other sink
1332
+ // guard uses, over the exact file set this engine writes. Refused, not skipped: silently withholding one
1333
+ // file of a four-file report set would be a pair no run has ever written.
1334
+ if (!wantJson) {
1335
+ for (const suffix of ["", ".callgraph", ".locs", ".hierarchy"]) {
1336
+ const w = `${outPrefix}${suffix}.json`;
1337
+ for (const [other, label] of runInputs(target, policyPath)) {
1338
+ if (sameArtifact(w, other)) {
1339
+ console.error(`candor-ts: the report set at --out ${outPrefix} includes ${w}, which is the SAME `
1340
+ + `FILE as ${label} ${other} — refusing (exit 2). Writing the report there would destroy an `
1341
+ + `input of this run. Nothing was scanned; give the report set its own prefix.`);
1342
+ refuseEarlyToStream(`the report set at ${outPrefix} would overwrite ${label} ${other}`);
1343
+ process.exit(2);
1344
+ }
1345
+ }
1346
+ }
1347
+ }
334
1348
  // --json prints the report to stdout and writes NOTHING, so skip creating the (otherwise default) .candor/ dir.
335
1349
  // The scanned package's name — the first half of the cross-package join key (SPEC §2 `hash`).
336
1350
  let pkgName = path.basename(rootDir);
@@ -798,16 +1812,69 @@ const corruptDepPkgs = new Set();
798
1812
  // --workspace's auto-scanned deps dir is prepended to the explicit CANDOR_DEPS/config spec (both chain).
799
1813
  const spec = [workspaceDepsDir, depInitsDir, process.env.CANDOR_DEPS ?? candorConfig.deps ?? ""].filter(Boolean).join(":");
800
1814
  const files = [];
801
- for (const tok of spec.split(/[\s:,]+/).filter(Boolean)) {
1815
+ // ASCII WHITESPACE ONLY, the same rule as the config loader above and as java, rust and swift. JS
1816
+ // `\s` includes U+00A0, so a dep path holding a non-breaking space split into two halves — and since
1817
+ // ⟨0.27⟩ made an unresolvable dep token FATAL, that turned a path the other three engines load into a
1818
+ // hard exit 2 naming a truncated path the operator never wrote. The config loader was fixed and this
1819
+ // one, which every config-declared dep is also routed through, was not: one rule, two spellings.
1820
+ for (const tok of spec.split(DEP_SEPARATORS).filter(Boolean)) {
802
1821
  try {
803
1822
  if (fs.statSync(tok).isDirectory())
804
1823
  for (const f of fs.readdirSync(tok)) if (f.endsWith(".json") && !f.endsWith(".callgraph.json") && !f.endsWith(".hierarchy.json") && !f.endsWith(".locs.json")) files.push(path.join(tok, f));
805
1824
  if (fs.statSync(tok).isFile()) files.push(tok);
806
- } catch { console.error(`candor-ts: CANDOR_DEPS entry unreadable, skipped: ${tok}`); }
1825
+ } catch {
1826
+ // ⟨0.27⟩ SPEC §2: A CONFIGURED DEP THAT CANNOT BE READ IS UNEVALUABLE, NOT REDUCED COVERAGE.
1827
+ // Skipping it continued the run, and the caller of that dep then serialised `inferred: []` — a
1828
+ // ⟨0.21⟩ purity claim, published in the REPORT, about a function whose dependency the operator
1829
+ // configured precisely so it would not be one. This engine's note said only "skipped", so the
1830
+ // omission was not even qualified in the channel a human reads, let alone the artifact a chained
1831
+ // consumer reads. java and swift already refused; this engine and rust continued.
1832
+ console.error(`candor-ts: CANDOR_DEPS names ${tok} but it is not a readable file or directory — `
1833
+ + `failing (exit 2, unevaluable). A configured dep that is not there is not reduced coverage: `
1834
+ + `its callers would serialise \`inferred: []\`, which is a purity claim about code this scan `
1835
+ + `never saw. Scan that dependency, or remove it from the \`deps\` config / CANDOR_DEPS.`);
1836
+ // The TOKEN arm needed this too. The read and parse arms below were routed to the stream and this
1837
+ // one was not — three exits for one rule, two of them answered on the machine channel and one
1838
+ // silent, which a conformance row (PART 36 b8) caught immediately once the cause was posed at all.
1839
+ refuseEarlyToStream(`configured dependency ${tok} is not a readable file or directory`);
1840
+ process.exit(2);
1841
+ }
807
1842
  }
808
1843
  for (const f of files) {
1844
+ // ⟨0.27⟩ READ AND PARSE OUTSIDE THE TRY, because SPEC §2 binds them and the try was swallowing them.
1845
+ // The rule is one sentence — a configured dep path that "does not exist OR CANNOT BE READ MUST exit
1846
+ // 2, naming it" — and the 0.27 work implemented only the first half, at the token check above. A path
1847
+ // that resolved to a file which then failed to open, or held malformed JSON, was SKIPPED at exit 0,
1848
+ // and the caller of that dep serialised `inferred: []`: the ⟨0.21⟩ purity claim the token check
1849
+ // exists to prevent, reached by a different door.
1850
+ //
1851
+ // Found by the 0.27 go/no-go panel, which tested this engine's own changelog claim instead of
1852
+ // believing it. java and swift refused on both halves already; this and rust made the family 2-v-2
1853
+ // on a MUST. The surviving `catch` below still guards the PROCESSING of a well-formed document,
1854
+ // which is a different failure and stays a skip.
1855
+ let raw;
809
1856
  try {
810
- const d = JSON.parse(fs.readFileSync(f, "utf8"));
1857
+ raw = fs.readFileSync(f, "utf8");
1858
+ } catch {
1859
+ console.error(`candor-ts: CANDOR_DEPS report ${f} could not be read —`);
1860
+ console.error(` failing (exit 2, unevaluable). A configured dep this scan cannot read is not`);
1861
+ console.error(` reduced coverage: its callers would serialise \`inferred: []\`, a purity claim`);
1862
+ console.error(` about code this scan never saw.`);
1863
+ refuseEarlyToStream(`configured dependency report ${f} could not be read`);
1864
+ process.exit(2);
1865
+ }
1866
+ let parsed;
1867
+ try {
1868
+ parsed = JSON.parse(raw);
1869
+ } catch {
1870
+ console.error(`candor-ts: CANDOR_DEPS report ${f} is not valid JSON —`);
1871
+ console.error(` failing (exit 2, unevaluable). Same reason as an unreadable one: a report`);
1872
+ console.error(` that cannot be parsed makes no claim, and continuing would publish one.`);
1873
+ refuseEarlyToStream(`configured dependency report ${f} is not valid JSON`);
1874
+ process.exit(2);
1875
+ }
1876
+ try {
1877
+ const d = parsed;
811
1878
  // A report whose version can't be VERIFIED is not trusted (§2.1) — a missing header is as
812
1879
  // untrustworthy as a mismatched one (the Rust engine's rule; the engines split on this).
813
1880
  const stale = d.candor?.version !== ENGINE_VERSION;
@@ -928,7 +1995,7 @@ const corruptDepPkgs = new Set();
928
1995
  if (!stale && strs(e.netClass).includes("unknown-host")) cell.netIncomplete = true;
929
1996
  crossDeps.set(e.hash, cell);
930
1997
  }
931
- } catch { console.error(`candor-ts: CANDOR_DEPS report unparsable, skipped: ${f}`); }
1998
+ } catch { console.error(`candor-ts: CANDOR_DEPS report could not be processed, skipped: ${f}`); }
932
1999
  }
933
2000
  // A package chained TWICE — once fresh, once stale — is covered by the fresh report, so it is not a
934
2001
  // stale-only package and must not pick up the disclosure below on top of a real answer.
@@ -1361,6 +2428,9 @@ function ollamaFromUrlArg(urlLit) {
1361
2428
  }
1362
2429
  // qualifies by the file's basename (`Cases.union_a`).
1363
2430
  const fns = new Map(); // qualified name -> { direct, edges, hosts, tables, cmds, paths, loc }
2431
+ // Units minted for a declaration with NO BODY (ambient `declare`, `.d.ts` member, `abstract` member).
2432
+ // Drained by the §3 body-less-declaration disclosure pass, after the class-CHA index is built.
2433
+ const bodylessDecls = new Map(); // qual -> {node, mod, abstract, owner, member}
1364
2434
  const unlistedSeen = new Map(); // the κ-coverage ledger: unlisted npm package -> call-site count
1365
2435
  const nodeName = new WeakMap(); // declaration node -> qualified name
1366
2436
  // ORM table declarations: `@Entity("user")` on a class maps that class to its table — the JVM's
@@ -1729,7 +2799,7 @@ for (const sf of sources) {
1729
2799
  const ctorQual = `${mod}.${namespacePrefixOf(node)}${node.name.text}.constructor`;
1730
2800
  if (!fns.has(ctorQual)) {
1731
2801
  const { line, character } = sf.getLineAndCharacterOfPosition(node.getStart());
1732
- fns.set(ctorQual, { local: `${node.name.text}.constructor`, direct: new Set(), edges: new Set(),
2802
+ fns.set(ctorQual, { local: `${node.name.text}.constructor`, direct: new Set(), fsKinds: new Set(), edges: new Set(),
1733
2803
  hosts: new Set(), tables: new Set(), cmds: new Set(), paths: new Set(),
1734
2804
  blind: new Set(), incomplete: new Set(), why: new Set(), entry: false,
1735
2805
  loc: `${path.relative(rootDir, sf.fileName)}:${line + 1}:${character + 1}`,
@@ -1753,11 +2823,30 @@ for (const sf of sources) {
1753
2823
  // producer's namespace nesting, so widening the hash would break report chaining.
1754
2824
  const nsp = namespacePrefixOf(node);
1755
2825
  const qual = isFunctionScoped(node) ? `${mod}.${nsp}${n}#${line + 1}:${character + 1}` : `${mod}.${nsp}${n}`;
1756
- fns.set(qual, { local: n, direct: new Set(), edges: new Set(), hosts: new Set(), tables: new Set(),
2826
+ fns.set(qual, { local: n, direct: new Set(), fsKinds: new Set(), edges: new Set(), hosts: new Set(), tables: new Set(),
1757
2827
  cmds: new Set(), paths: new Set(), blind: new Set(), incomplete: new Set(), why: new Set(), entry: false, isCjsExport,
1758
2828
  loc: `${path.relative(rootDir, sf.fileName)}:${line + 1}:${character + 1}`,
1759
2829
  endLine: sf.getLineAndCharacterOfPosition(node.getEnd()).line + 1 });
1760
2830
  nodeName.set(node, qual);
2831
+ // Record a unit minted for a declaration with NO BODY, for the §3 disclosure pass below (see it
2832
+ // for the argument). MIRROR `fns.set`'s last-write-wins EXACTLY — `.delete` on the bodied write is
2833
+ // load-bearing, not tidiness: an overload set is N body-less signatures followed by the
2834
+ // implementation under the SAME qual, so a plain `.add` would mark every real overloaded function
2835
+ // in the project unanalysable and charge its callers Unknown. That is the over-charge half of the
2836
+ // fix, and it is pinned by a fixture (`over`/`callsOver`, still `Fs` after this pass).
2837
+ {
2838
+ const hasBodySlot = ts.isFunctionDeclaration(node) || ts.isMethodDeclaration(node)
2839
+ || ts.isConstructorDeclaration(node) || ts.isGetAccessorDeclaration(node)
2840
+ || ts.isSetAccessorDeclaration(node);
2841
+ if (hasBodySlot && !node.body)
2842
+ bodylessDecls.set(qual, {
2843
+ node, mod,
2844
+ abstract: !!(ts.getCombinedModifierFlags(node) & ts.ModifierFlags.Abstract),
2845
+ owner: node.parent?.name?.getText?.(),
2846
+ member: node.name?.getText?.(),
2847
+ });
2848
+ else bodylessDecls.delete(qual);
2849
+ }
1761
2850
  if ((ts.isVariableDeclaration(node) || ts.isPropertyDeclaration(node)) && node.initializer)
1762
2851
  nodeName.set(node.initializer, qual);
1763
2852
  // Index a `Object.defineProperty` descriptor accessor by its target SYMBOL + key, so a forcing
@@ -2054,7 +3143,7 @@ function moduleUnit(sf) {
2054
3143
  const qual = `${mod}.<module>`;
2055
3144
  let rec = fns.get(qual);
2056
3145
  if (!rec) {
2057
- rec = { local: qual, direct: new Set(), edges: new Set(), hosts: new Set(), tables: new Set(),
3146
+ rec = { local: qual, direct: new Set(), fsKinds: new Set(), edges: new Set(), hosts: new Set(), tables: new Set(),
2058
3147
  cmds: new Set(), paths: new Set(), blind: new Set(), incomplete: new Set(), why: new Set(),
2059
3148
  entry: false, unitKind: "initializer",
2060
3149
  loc: `${path.relative(rootDir, sf.fileName)}:1:1`,
@@ -2077,7 +3166,7 @@ function staticBlockUnit(node) {
2077
3166
  const qual = `${mod}.${cname}.<static-init>`;
2078
3167
  let rec = fns.get(qual);
2079
3168
  if (!rec) {
2080
- rec = { local: "<static-init>", direct: new Set(), edges: new Set(), hosts: new Set(), tables: new Set(),
3169
+ rec = { local: "<static-init>", direct: new Set(), fsKinds: new Set(), edges: new Set(), hosts: new Set(), tables: new Set(),
2081
3170
  cmds: new Set(), paths: new Set(), blind: new Set(), incomplete: new Set(), why: new Set(),
2082
3171
  entry: false, unitKind: "initializer",
2083
3172
  loc: `${path.relative(rootDir, sf.fileName)}:${sf.getLineAndCharacterOfPosition(node.getStart()).line + 1}:1`,
@@ -3403,6 +4492,17 @@ function visitCalls(node) {
3403
4492
  eff = null;
3404
4493
  if (eff) {
3405
4494
  rec.direct.add(eff);
4495
+ // SPEC §2 `fs` — refine an Fs we just PROVED with the direction its verb implies. DIRECT only
4496
+ // (never propagated over edges): a caller reaching one writer and one undetermined callee would
4497
+ // otherwise inherit ["write"] and thereby claim "writes but never reads" — the partial claim §2
4498
+ // forbids. An unrecognised verb adds nothing, so the field stays absent rather than half-true.
4499
+ if (eff === "Fs") {
4500
+ // A verb revealing no direction records the POISON marker "?" rather than nothing. Abstaining
4501
+ // would let a caller inherit a neighbour's ["write"] and claim "writes but never reads" over a
4502
+ // reach whose kind was never determined — the partial claim §2 forbids. Suppressed at emit.
4503
+ const ks = fsKind(mod, member);
4504
+ if (ks.length === 0) rec.fsKinds.add("?"); else for (const k of ks) rec.fsKinds.add(k);
4505
+ }
3406
4506
  // a κ rule that resolves to the Unknown trust-marker (node:vm code execution) is a direct
3407
4507
  // Unknown SOURCE — SPEC §4 requires a why on it, like eval's `reflect:eval`. (The rest of
3408
4508
  // the κ table is concrete effects, which carry no why.)
@@ -4309,6 +5409,53 @@ function resolveDepEntryKey(pkg, subpath) {
4309
5409
  }
4310
5410
  }
4311
5411
 
5412
+ // ---- THE BODY-LESS LOCAL DECLARATION (SPEC §3 honesty invariant; §4 `native:`/`dispatch:`) ---------
5413
+ // A declaration the scan can SEE but whose BODY is not in the analyzed set — an ambient `declare
5414
+ // function`, any member of a `.d.ts`, an `abstract` member no local subclass overrides. `localName`
5415
+ // mints a unit for each (it keys on the declaration, not on the presence of a body), the call site
5416
+ // edges the caller to it, and the unit is EMPTY — so the caller unioned nothing and read PURE. That is
5417
+ // the cardinal sin: a purity claim over code the engine never saw.
5418
+ //
5419
+ // FOUND ON REAL CODE, and the corpus is why: candor-ts's whole report for `axios` is 54 `index.d.ts`
5420
+ // declarations while its 61 `.js` implementation files are never analyzed, so `deny Unknown` — the gate
5421
+ // whose entire purpose is "fail if candor cannot see what this reaches" — exited **0** where rust, java
5422
+ // and swift all exit 1 on the same shape. candor-ts was the four-way outlier:
5423
+ //
5424
+ // rust `extern "C" { fn ambient(…) }` caller -> Unknown[native:extern fn]
5425
+ // java `public static native int ambient` DECL -> Unknown[native:ambient], caller inherits
5426
+ // swift protocol req., no local conformer caller -> Unknown[dispatch:Ambient.ping]
5427
+ // ts `declare function ambient(…)` caller -> PURE <-- this
5428
+ //
5429
+ // We take java's shape (charge the DECLARATION, let the existing fixpoint carry it caller-ward) rather
5430
+ // than rust/swift's (charge at the edge), for one reason: ts already mints the unit and already forms
5431
+ // the edge, so mint-side is ONE place. Charging at the edge would mean touching every call and desugar
5432
+ // site — and that is the exact drift this file has been bitten by twice (`discloseUnanswerableKey` is
5433
+ // one function today because it was two, and the ⟨0.19⟩ reason class was added to neither).
5434
+ //
5435
+ // PRECISION HALF, and it is not optional — a fix that over-charges is how a fabrication gets introduced
5436
+ // while closing an under-report. A base member with a local bodied override is ALREADY resolved: the
5437
+ // class-CHA at the dispatch site edges the caller to the overrides and runs its own `allResolved` gate.
5438
+ // Charging the empty base too would manufacture uncertainty over code the engine can in fact see.
5439
+ // MEASURED on the corpus: hono's `EventProcessor` has six abstracts with three local subclasses and
5440
+ // zod's `ZodType._parse` one — excluding them takes zod's delta to +0 and hono's from +18 to +9, and
5441
+ // every one of the nine that remain is a true positive (`Deno.mkdir`/`writeFile` are Fs, `Deno.
5442
+ // upgradeWebSocket` and `FetcherLike.fetch` are Net, all declared in local `.d.ts` shims with no body).
5443
+ const answeredByLocalBody = (node) => (classOverrides.get(node) ?? []).some((om) => !!om.body);
5444
+ for (const [qual, meta] of bodylessDecls) {
5445
+ const rec = fns.get(qual);
5446
+ if (!rec || answeredByLocalBody(meta.node)) continue;
5447
+ rec.direct.add("Unknown");
5448
+ // §4's dividing line, same as everywhere else in this file. An `abstract` member IS an unresolved
5449
+ // DISPATCH — owner type and member are both nameable, which is what `dispatch:` reserves itself for,
5450
+ // and it is the spelling swift gives the same shape. Everything else (ambient/`declare`/`.d.ts`) is a
5451
+ // boundary to code we cannot analyse, which is §4's definition of `native:` verbatim; java spells it
5452
+ // `native:<method>` and rust `native:extern fn`. §4 makes the CLASS per-language and best-effort, so
5453
+ // ts emitting `native:` where its language model produces one is exactly the licensed use.
5454
+ rec.why.add(meta.abstract
5455
+ ? dispatchWhy(meta.owner ? `${meta.mod}.${meta.owner}` : null, meta.member)
5456
+ : `native:${rec.local}`);
5457
+ }
5458
+
4312
5459
  // ---- pass 3: the least fixpoint (SEMANTICS §5a), effects + the literal surfaces -------------------
4313
5460
  // WORKLIST least-fixpoint. The old `while (changed) { for [,rec] of fns }` swept every function on every
4314
5461
  // pass, so its pass count equalled the longest back-to-front call chain — O(V²) on deep whole-project
@@ -4339,7 +5486,10 @@ const inferred = new Map([...fns.keys()].map((k) => [k, new Set(fns.get(k).direc
4339
5486
  if (!queued.has(c)) { queued.add(c); queue.push(c); }
4340
5487
  }
4341
5488
  }
4342
- for (const m of ["hosts", "tables", "cmds", "paths", "blind", "incomplete"]) {
5489
+ // `fsKinds` joins the propagated surfaces: kinds TRAVEL the call graph (a caller that transitively only
5490
+ // writes IS a writer), and the "?" poison travels with them so a caller of an undetermined-kind function
5491
+ // inherits the SUPPRESSION rather than a half-answer. Pinned by conformance PART 31.
5492
+ for (const m of ["hosts", "tables", "cmds", "paths", "blind", "incomplete", "fsKinds"]) {
4343
5493
  const queue = [...fns.keys()];
4344
5494
  const queued = new Set(queue);
4345
5495
  for (let head = 0; head < queue.length; head++) {
@@ -4393,6 +5543,14 @@ for (const [name, rec] of fns) {
4393
5543
  if (inf.includes("Db") && rec.tables.size) entry.tables = [...rec.tables].sort();
4394
5544
  if (inf.includes("Exec") && rec.cmds.size) entry.cmds = [...rec.cmds].sort();
4395
5545
  if (inf.includes("Fs") && rec.paths.size) entry.paths = [...rec.paths].sort();
5546
+ // SPEC §2 `fs` — the read/write kinds this fn's OWN Fs calls revealed. Gated on `inferred` carrying Fs
5547
+ // (the spec: "applies only when `inferred` contains `Fs`") and omitted when empty.
5548
+ //
5549
+ // Kinds TRAVEL (see the propagation loop); the "?" poison is what stops a PARTIAL answer travelling with
5550
+ // them. Present ⇒ some contributing Fs had no determined kind ⇒ suppress the whole field, because
5551
+ // ["write"] there would claim "writes but never reads" about a function that may do both.
5552
+ if (inf.includes("Fs") && rec.fsKinds.size && !rec.fsKinds.has("?"))
5553
+ entry.fs = [...rec.fsKinds].sort();
4396
5554
  // ⟨0.6⟩ unknownWhy — REQUIRED on a DIRECT Unknown SOURCE (this fn's own body has the unresolvable call,
4397
5555
  // so `rec.direct` carries Unknown), absent on a purely-transitive Unknown. The rich per-site reasons
4398
5556
  // (rec.why: callback:/dispatch:/dynamic-key:) when recorded, else a generic fallback so a source is
@@ -4808,6 +5966,10 @@ if (process.env.CANDOR_WORKSPACE_CHAIN) {
4808
5966
  + `NO report written (a consumer cannot detect this from the outside):`);
4809
5967
  for (const c of contradictions.slice(0, 20)) console.error(` ${c}`);
4810
5968
  if (contradictions.length > 20) console.error(` … and ${contradictions.length - 20} more`);
5969
+ // ⟨0.28⟩ report stream: this exit-2 fires BEFORE the envelope is printed, so a `--json` run would
5970
+ // otherwise leave stdout empty for the whole class of trust-marker contradictions. The latch is
5971
+ // still unset at this point; the helper writes the fail-closed doc as stdout's only content.
5972
+ refuseEarlyToStream(`${contradictions.length} report entr${contradictions.length === 1 ? "y contradicts its" : "ies contradict their"} own trust markers`);
4811
5973
  process.exit(2);
4812
5974
  }
4813
5975
  }
@@ -4834,8 +5996,13 @@ function fnv1aHex(sortedQuals) {
4834
5996
 
4835
5997
  // `package` names what this report COVERS — a consumer chaining it registers coverage even when
4836
5998
  // `functions` is empty (an all-pure package's report is its purity claim, SPEC §2 rule 3).
5999
+ // ⟨0.27⟩ SPEC §2.1 `resolves`: the OPTIONAL refinement surfaces this producer computes. Without it the
6000
+ // absence of such a field is overloaded between "does not compute this" and "computed and could not
6001
+ // determine it", and a consumer cannot read the omission at all. candor-ts resolves `fs` read/write kinds,
6002
+ // so it says so. A producer MUST NOT list a surface it does not compute — that turns "unimplemented" into a
6003
+ // false "undetermined", which is the inversion the field exists to prevent.
4837
6004
  const envelope = { candor: { version: ENGINE_VERSION, toolchain: `node-${process.versions.node}`, spec: SPEC_VERSION },
4838
- package: pkgName, functions };
6005
+ resolves: ["fs"], package: pkgName, functions };
4839
6006
  // ⟨0.15 staged⟩ the κ-coverage ledger as DATA (COVERAGE-DESIGN.md §1): ONE sorted form (count desc,
4840
6007
  // name asc — exactly the stderr line's order) feeds the envelope field, the stderr receipt below, and
4841
6008
  // the --gate-json advisory, so the three can never tell different stories.
@@ -4874,10 +6041,18 @@ for (const [name, rec] of fns) cg[name] = [...rec.edges].sort();
4874
6041
  // `candor-ts-watch` re-scans (the recommended agent setup runs both) — must never observe a
4875
6042
  // half-written report. An in-place writeFileSync leaves a truncation window where JSON.parse throws;
4876
6043
  // rename(2) is atomic within a filesystem, so a reader sees either the old report or the new one whole.
4877
- const writeAtomic = (file, text) => { const tmp = `${file}.${process.pid}.tmp`; fs.writeFileSync(tmp, text); fs.renameSync(tmp, file); };
6044
+ // ⟨0.28⟩ …and through `writeSinkAtomic`, so a symlinked or multiply-linked destination is written where
6045
+ // the operator points rather than replaced. Reports have the same layout exposure as verdicts.
6046
+ const writeAtomic = (file, text) => writeSinkAtomic(file, text);
4878
6047
  // --json: print the §2 envelope to STDOUT instead of writing the report files (matches candor-scan/Rust).
4879
6048
  if (wantJson) {
4880
6049
  console.log(JSON.stringify(envelope, null, 1));
6050
+ // ⟨0.28⟩ REPORT STREAM LATCH — a successful envelope went to stdout, so a later exit-2 site (baseline
6051
+ // corrupt, policy refusal, gate NOT certified over unanalyzed) MUST NOT also write a fail-closed
6052
+ // placeholder there. Two documents on one stream parses as neither — the same shape the two-stream
6053
+ // refusal exists to prevent, arriving through a different door. The rust reference sets the mirror
6054
+ // `REPORT_STREAM_WRITTEN` OnceLock at the analog site (crates/candor-scan/src/scan.rs).
6055
+ reportStreamWritten = true;
4881
6056
  } else {
4882
6057
  writeAtomic(`${outPrefix}.json`, JSON.stringify(envelope, null, 1));
4883
6058
  writeAtomic(`${outPrefix}.callgraph.json`, JSON.stringify(cg, null, 1));
@@ -4928,6 +6103,12 @@ if (!wantJson) {
4928
6103
  writeAtomic(`${outPrefix}.hierarchy.json`, JSON.stringify(hierarchy, null, 1));
4929
6104
  console.error(`candor-ts: wrote ${functions.length} effectful functions (${fns.size} analyzed, ${sources.length} files) to ${outPrefix}.json`);
4930
6105
  }
6106
+ // ⟨0.28⟩ The run has finished writing its report set: hand back any file it armed and turned out not to
6107
+ // own (see disarmUnwrittenOutReports). Placed HERE rather than at the exit sites because everything
6108
+ // below — the policy gate, the baseline ratchet, the unanalyzed certification — can exit 1 or 2 having
6109
+ // already published a complete report, and a leftover placeholder past this point is a claim of
6110
+ // incompleteness this run did not experience.
6111
+ disarmUnwrittenOutReports();
4931
6112
  {
4932
6113
  // Effect breakdown — make the result visible at a glance, not just a count + a file path.
4933
6114
  const counts = {};
@@ -5014,7 +6195,17 @@ if (!wantJson) {
5014
6195
  // A qual is test code iff its recorded loc (file:line[:col]) lies on a test path — the same predicate
5015
6196
  // the scan already uses to keep test files out of the report.
5016
6197
  const isTestQual = (q) => { const l = locMap.get(q); return l ? isTestPath(l) : false; };
5017
- emitSurface(inferred, directMap, callsMap, locMap, isTestQual);
6198
+ // The cause this run can actually stand behind, in the order the evidence supports: packages the
6199
+ // classifier does not cover (already enumerated above) beats a tsconfig guess, and a tsconfig this run
6200
+ // READ rules the tsconfig guess out entirely.
6201
+ const unresolvedCause = unlistedSeen.size > 0
6202
+ ? `the ${uncoveredLedger.length} package${uncoveredLedger.length === 1 ? "" : "s"} named above are not `
6203
+ + `covered by the classifier, so calls into them resolve to Unknown`
6204
+ : (usedTsconfig
6205
+ ? `this scan read ${path.relative(rootDir, usedTsconfig) || path.basename(usedTsconfig)}, so the `
6206
+ + `cause is unresolvable imports rather than a missing tsconfig`
6207
+ : `no tsconfig.json was found for this target, so imports resolve poorly — point candor at one`);
6208
+ emitSurface(inferred, directMap, callsMap, locMap, isTestQual, console.error, unresolvedCause);
5018
6209
  }
5019
6210
 
5020
6211
  // ---- the gate surfaces: the AS-EFF-005 baseline guard + the standing §6.2 policy gate --------------
@@ -5023,6 +6214,14 @@ if (!wantJson) {
5023
6214
  // a `… | jq` / `… | candor-sarif` pipe never breaks.
5024
6215
  const emitViolation = (wantJson || gateJsonPath === "-") ? (l) => console.error(l) : (l) => console.log(l);
5025
6216
  let gateViolations = [];
6217
+ // ⟨0.27⟩ SPEC §4 `zeroMatch` — the raw text of every rule whose SCOPE bound no function, captured off the
6218
+ // gate evaluation and emitted onto the verdict document. The stderr lines alone left a machine consumer
6219
+ // unable to see that a rule bound nothing — the typo'd-scope silent green, one channel over. Disclosure
6220
+ // only: `ok` and the exit code never consult it.
6221
+ let gateZeroMatch = [];
6222
+ // ⟨0.28⟩ SPEC §6.2 `ignored` — the policy lines the parse DROPPED, for the verdict document. Declared
6223
+ // beside `gateZeroMatch` and for the same reason: the verdict is assembled long after the policy block.
6224
+ let gateIgnored = [];
5026
6225
  // ⟨0.24⟩ the `.candor/config` that supplied POLICY VOCABULARY this verdict actually used — named on the
5027
6226
  // document (SPEC §3.1 `99eb4e9`), null when no alias was referenced so the verdict stays byte-identical.
5028
6227
  let policyVocabulary = null;
@@ -5075,7 +6274,20 @@ const writeRefusal = (reason, unevaluated = null) => {
5075
6274
  // must not silently NARROW the guard back to report-only.
5076
6275
  if (baselinePath !== null) {
5077
6276
  const shownB = baselinePath === "" ? "(configured empty)" : baselinePath;
5078
- if (baselinePath !== "" && !fs.existsSync(baselinePath)) {
6277
+ if (baselinePath !== "" && !fs.existsSync(baselinePath) && baselineFromConfig) {
6278
+ // A CHECKED-IN DECLARATION IS NOT THE SAME ABSENCE — see baselineFromConfig above. Exit 2: the
6279
+ // gateless-green class, and an adopter review measured this as the second-likeliest first-commit
6280
+ // mistake (`.candor/` committed, the baseline not).
6281
+ console.error(`candor-ts: .candor/config declares \`baseline ${baselinePath}\` but that file is not `
6282
+ + `there — failing (exit 2). A checked-in declaration says this repo HAS a baseline, so an absent `
6283
+ + `one was deleted or never committed. Commit it, or record one: candor-ts <target> --out <prefix>.`);
6284
+ // WRITE THE REFUSAL DOCUMENT BEFORE EXITING. Without this the `--gate-json` file keeps whatever the
6285
+ // LAST run left there — so a CI wrapper that reads the artifact instead of the exit code sees the
6286
+ // previous run's `ok: true` and reports a pass, which is the stale-artifact false green this format
6287
+ // exists to refuse. java, rust and swift all overwrite on this branch; ts alone did not.
6288
+ writeRefusal(`.candor/config declares \`baseline ${baselinePath}\` but that file is not there`);
6289
+ process.exit(2);
6290
+ } else if (baselinePath !== "" && !fs.existsSync(baselinePath)) {
5079
6291
  console.error(`candor-ts: CANDOR_BASELINE ${baselinePath} does not exist — the regression guard is `
5080
6292
  + `not active (record one: candor-ts <target> --out <prefix>, then point at the report .json).`);
5081
6293
  } else {
@@ -5085,6 +6297,10 @@ if (baselinePath !== null) {
5085
6297
  if (!Array.isArray(arr)) {
5086
6298
  console.error(`candor-ts: baseline ${shownB} exists but could not be parsed (corrupt/truncated?) — `
5087
6299
  + `failing (exit 2); the guard must not silently pass on an unreadable baseline. Regenerate it with this build.`);
6300
+ // ⟨0.27⟩ the refusal document has no exempt cause AND no exempt sink (SPEC §3.1): a file sink holds
6301
+ // the armed placeholder, but `--gate-json -` is not armed, so without this write the stream carried
6302
+ // NOTHING on this cause. Writing here also replaces the placeholder with the specific reason.
6303
+ writeRefusal(`baseline ${shownB} exists but could not be parsed — guard NOT evaluated`);
5088
6304
  process.exit(2);
5089
6305
  }
5090
6306
  const baseVersion = !Array.isArray(root) && root.candor && typeof root.candor === "object"
@@ -5092,12 +6308,14 @@ if (baselinePath !== null) {
5092
6308
  if (baseVersion === null) {
5093
6309
  console.error(`candor-ts: the baseline ${shownB} has no provenance header (a legacy/bare-array report) — `
5094
6310
  + `a baseline is comparable only to its producing build (§2.1). Failing (exit 2); regenerate it with this build.`);
6311
+ writeRefusal(`baseline ${shownB} has no provenance header — guard NOT evaluated`); // ⟨0.27⟩ see above
5095
6312
  process.exit(2);
5096
6313
  }
5097
6314
  if (baseVersion !== ENGINE_VERSION) {
5098
6315
  console.error(`candor-ts: the baseline ${shownB} was produced by engine build ${baseVersion} but this is `
5099
6316
  + `build ${ENGINE_VERSION} — an engine swap is baseline-invalidating and the gate cannot evaluate `
5100
6317
  + `(exit 2; never a silent skip, never a bogus AS-EFF-005 wave). Regenerate deliberately with this build.`);
6318
+ writeRefusal(`baseline ${shownB} was produced by engine build ${baseVersion}, not this build — guard NOT evaluated`); // ⟨0.27⟩ see above
5101
6319
  process.exit(2);
5102
6320
  }
5103
6321
  const base = new Map();
@@ -5124,6 +6342,7 @@ if (baselinePath !== null) {
5124
6342
  console.error(`candor-ts: the baseline callgraph ${sidecarPath} is present but could not be parsed `
5125
6343
  + `(corrupt/truncated?) — failing (exit 2); a broken sidecar must not silently narrow the guard to `
5126
6344
  + `report-only. Regenerate the baseline with this build.`);
6345
+ writeRefusal(`baseline callgraph ${sidecarPath} could not be parsed — guard NOT evaluated`); // ⟨0.27⟩ see above
5127
6346
  process.exit(2);
5128
6347
  }
5129
6348
  // The node set = every caller key + every callee (a pure leaf appears only as a callee), exactly
@@ -5220,6 +6439,11 @@ if (policyPath !== null) {
5220
6439
  const unknownAliases = parseUnknownAliases(discoverConfigText(policyVocabularyAnchor(policyPath, target)), parseErrs);
5221
6440
  const gatePolicy = parsePolicy(text, unknownAliases);
5222
6441
  parseErrs.push(...gatePolicy.errors);
6442
+ // ⟨0.28⟩ SPEC §6.2 — the LINES THE PARSE DROPPED, for the verdict document below. Non-fatal by
6443
+ // construction (`parsePolicy` excludes the fatal kinds: a policy ERROR refuses the whole run and a
6444
+ // refused run has no verdict for a dropped line to have shrunk). Captured here because the verdict
6445
+ // is assembled far below and `gatePolicy` is scoped to this block.
6446
+ gateIgnored = gatePolicy.ignored ?? [];
5223
6447
  // ⟨0.24⟩ only the UNRECOGNISED VALUE TOKENS refuse. `errors` now also carries every LINE this parser
5224
6448
  // dropped whole (SPEC §3.1 `195d45a`), which is additive to the `parsepolicy` witness and deliberately
5225
6449
  // silent about the gate — refusing there would be a grammar change, not a token change.
@@ -5231,9 +6455,49 @@ if (policyPath !== null) {
5231
6455
  if (policyErrs.length) {
5232
6456
  const why = policyErrorText(policyPath, policyErrs);
5233
6457
  console.error(why);
5234
- // ⟨0.24⟩ ONE `unevaluated` ENTRY PER POLICY LINE THAT COULD NOT BE HONOURED — the SHARED builder, so
5235
- // this document and `gate --report`'s stay byte-equal (§3.1's acceptance test for the two routes).
5236
- policyRefusal = { why, unevaluated: policyErrorUnevaluated(policyErrs) };
6458
+ // ⟨0.27⟩ ONE `unevaluated` ENTRY PER RULE OF THE POLICY — not only the unhonourable lines (SPEC
6459
+ // §3.1's composed-document clause). Measured: listing only the bad token's line let a consumer read
6460
+ // `deny Fs`, absent from the exit-1 document's list, as evaluated-and-passed. The SHARED builder,
6461
+ // so this document and `gate --report`'s stay byte-equal (§3.1's acceptance test for the routes).
6462
+ policyRefusal = { why, unevaluated: policyRefusalUnevaluated(text, policyErrs) };
6463
+ } else if (!gatePolicy.deny.length && !gatePolicy.allow.length && !gatePolicy.forbid.length) {
6464
+ // ⟨0.28⟩ A CONFIGURED POLICY THAT YIELDED ZERO RULES IS A BROKEN GATE CONFIG (SPEC §6.2) — the same
6465
+ // refusal posture as the two branches above, and for the reason §6.2 already gives for an unreadable
6466
+ // FILE: "a typo'd policy path that runs green is a gate that silently passes everything". MEASURED
6467
+ // four-way 2026-08-10: `--policy <a README>` wrote `{"ok":true,"violations":[]}` and exited 0 on
6468
+ // every engine — byte-identical to a gate that ran and found nothing, AND byte-identical to the
6469
+ // no-gate-configured verdict (§3.3), so the one consumer this format exists for cannot tell "your
6470
+ // code is clean" from "your gate had no rules". The per-line `ignoring policy rule` warnings above
6471
+ // go to stderr, which is not the machine channel.
6472
+ //
6473
+ // The line-level ignore-with-a-warning leniency is UNTOUCHED and still right: silent reinterpretation
6474
+ // is the one thing a security gate must not do, and an engine meeting a rule kind from a newer rung
6475
+ // must not refuse the whole file over it. This rung is about what that leniency COMPOSES TO — a file
6476
+ // in which EVERY line was ignored is a gate that asked nothing.
6477
+ //
6478
+ // THE CONTROL, which is what makes this a rule and not a blanket: reaching here at all means a policy
6479
+ // was CONFIGURED (`--policy`, CANDOR_POLICY, or the `.candor/config` `policy` key — all three land in
6480
+ // `policyPath`). A run that configured no gate never enters this block and stays exit 0; that is the
6481
+ // honest way to say "I am not gating", and it is exactly why a configured zero-rule policy is never a
6482
+ // legitimate expression of that intent.
6483
+ //
6484
+ // EVERY RULE VECTOR, and rust's first draft of this check read only its `rules`. `parsePolicy` splits
6485
+ // the four kinds across THREE arrays — `deny` (both `deny` and `pure`), `allow` and `forbid` — so
6486
+ // keying on one made an allow-only policy (`allow Net api.stripe.com`, an ordinary allowlist gate) or
6487
+ // a forbid-only layer policy refuse as if it had no rules at all. A zero-rule test that inspects a
6488
+ // subset of the rule kinds is the same false-answer shape this rung exists to close, pointed the
6489
+ // other way.
6490
+ const { why, unevaluated } = policyZeroRules(policyPath);
6491
+ console.error(`candor-ts: ${why} — refusing (exit 2, gate NOT enforced). Every line was ignored (see `
6492
+ + `the \`ignoring policy rule\` warnings above), the file is empty, or it holds only comments. A `
6493
+ + `gate with no rules cannot have caught anything, and reporting \`ok: true\` here would be `
6494
+ + `indistinguishable from a gate that ran and found nothing. If you did not mean to gate this run, `
6495
+ + `remove the \`policy\` setting rather than pointing it at a file with no rules in it.`);
6496
+ // …RECORDED, NOT EXECUTED, so it takes the SAME precedence as both branches above (SPEC §3.1
6497
+ // `4c79958`): a certain violation dominates a refusal. No POLICY violation can exist with zero rules,
6498
+ // but an AS-EFF-005 baseline regression is a finding from evidence this run already carries, and it
6499
+ // rides exit 1 with this refusal disclosed beside it under `unevaluated`.
6500
+ policyRefusal = { why, unevaluated };
5237
6501
  } else {
5238
6502
  // ⟨0.24⟩ the config file that supplied vocabulary the verdict USED, so an ambient `.candor/config` — the
5239
6503
  // walk goes up through every parent, and CANDOR_CONFIG overrides it outright — cannot move a verdict while
@@ -5242,7 +6506,20 @@ if (policyPath !== null) {
5242
6506
  const p = discoverConfigPath(policyVocabularyAnchor(policyPath, target));
5243
6507
  if (p) policyVocabulary = { config: p, aliases: gatePolicy.aliasesUsed };
5244
6508
  }
5245
- gateViolations = gateViolations.concat(evaluatePolicy(gatePolicy, functions, cg, incompleteMap, netPartners));
6509
+ const gateOut = evaluatePolicy(gatePolicy, functions, cg, incompleteMap, netPartners);
6510
+ // ⟨0.27⟩ SPEC §4 — a rule that bound NO function is disclosed, never scored as satisfied. The exit
6511
+ // code is deliberately untouched: a zero-match rule is legitimate when one policy is shared across
6512
+ // repositories and a layer exists in only some of them, so refusal would make a shared policy
6513
+ // unusable. Printed before the violations so a typo'd layer name is visible above the verdict.
6514
+ for (const raw of gateOut.zeroMatch ?? []) {
6515
+ console.error(`candor: policy rule matched NO function — \`${raw}\`. It was evaluated and bound `
6516
+ + `nothing, so it cannot have caught anything. Legitimate when one policy is shared across `
6517
+ + `repos; a typo'd layer name otherwise.`);
6518
+ }
6519
+ // ⟨0.27⟩ captured BEFORE the concat below — `concat` returns a plain array, so the `zeroMatch`
6520
+ // property riding `gateOut` would be silently lost with it (see the gateZeroMatch declaration).
6521
+ gateZeroMatch = gateOut.zeroMatch ?? [];
6522
+ gateViolations = gateViolations.concat(gateOut);
5246
6523
  // Provable-purity DISCLOSURE (advisory — NEVER a violation, so the exit/verdict are untouched): functions
5247
6524
  // in a pure/deny scope that PASS but are Unknown (the Unknown could hide the forbidden effect — a
5248
6525
  // fn/closure-injected port). Surfaces the gap automatically (eval/fixloop/DISPATCH-NOTE.md).
@@ -5317,6 +6594,20 @@ if (gateJsonPath) {
5317
6594
  // consumer reading exit 1 must be able to see that the POLICY half of the gate never ran — the same
5318
6595
  // `unevaluated` key, in the same position, that `gate --report` uses for its answerability refusals.
5319
6596
  if (policyRefusal) verdictObj.unevaluated = policyRefusal.unevaluated;
6597
+ // ⟨0.27⟩ SPEC §4 `zeroMatch` — the same list the stderr lines carry, in the machine channel. Omitted
6598
+ // when empty so a fully-binding verdict is byte-identical; never consulted for `ok` or the exit code.
6599
+ if (gateZeroMatch.length) verdictObj.zeroMatch = gateZeroMatch;
6600
+ // ⟨0.28⟩ SPEC §6.2 `ignored: [{line, text, reason}]` — the policy lines the parse DROPPED, so a machine
6601
+ // consumer can see that the gate it is reading is SMALLER than the gate that was written. MEASURED
6602
+ // four-way: all four engines warn per ignored line on stderr while the verdict document stays silent,
6603
+ // and the zero-rule refusal fires only at ZERO survivors — so 1-of-10 rules parsing wrote
6604
+ // `{"ok": true, "violations": []}` at exit 0 with nothing said about the nine gates never asked.
6605
+ //
6606
+ // DISTINCT FROM `unevaluated`, and the distinction is load-bearing: `unevaluated` carries rules that
6607
+ // PARSED and could not be answered, `ignored` carries text that never became a rule at all. Omitted
6608
+ // when nothing was dropped, so a clean policy's verdict stays byte-identical. Never consulted for `ok`
6609
+ // or the exit code — the line-level leniency is UNCHANGED, only disclosed.
6610
+ if (gateIgnored.length) verdictObj.ignored = gateIgnored;
5320
6611
  // ⟨0.21⟩ (Gap 2) the machine-legible incompleteness: the units candor couldn't analyze, so a CI/agent
5321
6612
  // reading the JSON learns WHY the gate can't certify (the stderr warning alone used to hide this from a
5322
6613
  // machine). `incomplete:true` + the list; the run exits 2 (could-not-fully-evaluate) below. ok:false +