candor-ts 0.25.0 → 0.27.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, 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.25";
47
+ const SPEC_VERSION = "0.27";
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,164 @@ 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;
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
+ if (v === undefined || (v !== "-" && v.startsWith("--"))) continue;
160
+ if (a === "--gate-json") gate = v; else if (a === "--policy") policy = v;
161
+ i++;
162
+ continue;
163
+ }
164
+ // The scan TARGET, needed to discover the `.candor/config` whose `policy` key may name an input
165
+ // this sink must not overwrite.
166
+ if (!a.startsWith("-") && target === null) target = a;
167
+ }
168
+ return { gate, policy, target };
169
+ };
170
+
171
+ // Every path this run READS, whatever channel it arrived through (SPEC §3.3.1 ⟨0.27⟩).
172
+ //
173
+ // THE FIRST VERSION OF THIS GUARD KEYED ON THE FLAG. With the policy declared by `.candor/config` — the
174
+ // checked-in form, i.e. the one a CI job actually has — `--gate-json <that policy>` destroyed it and
175
+ // exited 0 with `"ok": true` in ALL FOUR ENGINES. A policy does not change what it is according to how
176
+ // the operator handed it over. The config is read LENIENTLY (no exit, no diagnostic): this runs before
177
+ // the real config load and must not pre-empt its refusal.
178
+ // Artifact identity, not string identity: `--policy /w/P --gate-json ./P` from /w is one file, and the
179
+ // engine that already had this guard compared path spellings and lost to exactly that. realpath resolves
180
+ // `.`, `..` and symlinks; for a sink that does not exist yet its parent is resolved instead.
181
+ const sameArtifact = (a, b) => {
182
+ if (!a || !b || a === "-" || b === "-") return false;
183
+ const resolve = (p) => {
184
+ try { return fs.realpathSync(p); } catch { /* not there yet — resolve the parent */ }
185
+ try { return path.join(fs.realpathSync(path.dirname(path.resolve(p))), path.basename(p)); } catch { return null; }
186
+ };
187
+ const x = resolve(a);
188
+ return x !== null && x === resolve(b);
189
+ };
190
+
191
+ const runInputs = (target, policyFlag) => {
192
+ const out = [];
193
+ if (policyFlag) out.push([policyFlag, "--policy"]);
194
+ for (const [v, label] of [["CANDOR_POLICY", "CANDOR_POLICY"], ["CANDOR_BASELINE", "CANDOR_BASELINE"],
195
+ ["CANDOR_CONFIG", "CANDOR_CONFIG"]]) {
196
+ if (process.env[v]) out.push([process.env[v], label]);
197
+ }
198
+ // ONE DEFINITION, shared with the loader — see DEP_SEPARATORS. This comment used to claim it was
199
+ // "the separator set the dep loader accepts" while spelling a DIFFERENT set one screen away, and the
200
+ // gap between the two was a silent green: a newline-separated `CANDOR_DEPS` registered here as one
201
+ // unresolvable token, so the guard protected nothing, while the loader split it into real paths.
202
+ // `--gate-json` naming one of those reports then DESTROYED it and the run exited 0 with `ok: true`
203
+ // written over the operator's input — §3.3.1's own words, "a machine-readable all-clear produced by
204
+ // deleting the question". Measured live before this change.
205
+ for (const d of (process.env.CANDOR_DEPS ?? "").split(DEP_SEPARATORS).filter(Boolean)) {
206
+ out.push([d, "a CANDOR_DEPS report"]);
207
+ // A DIRECTORY DEP IS EVERY REPORT INSIDE IT. `deps` accepts a directory — `--workspace` writes
208
+ // `.candor/deps/` and hands that back, so it is the common spelling — and the loader then walks it
209
+ // and reads each `*.json`. Registering only the DIRECTORY left those files unnamed, so
210
+ // `--gate-json <depdir>/lib.json` was unguarded: arming destroyed the operator's dep report, the
211
+ // run chained the wreckage and exited 0 with `ok: true` over it. Measured in all four engines.
212
+ //
213
+ // EXPANDED HERE, not by making `sameArtifact` directory-aware. That was tried and is far too
214
+ // broad: the scan TARGET is an input too, and a verdict written into the tree being scanned is
215
+ // ordinary usage — the general rule refused it and took 33 tests with it. Only a DEP directory has
216
+ // its CONTENTS read, so only a dep directory expands.
217
+ try {
218
+ if (fs.statSync(d).isDirectory()) {
219
+ for (const f of fs.readdirSync(d)) {
220
+ if (f.endsWith(".json") && !f.endsWith(".callgraph.json") && !f.endsWith(".hierarchy.json")
221
+ && !f.endsWith(".locs.json")) {
222
+ out.push([path.join(d, f), "a CANDOR_DEPS report"]);
223
+ }
224
+ }
225
+ }
226
+ } catch { /* not a directory, or unreadable — the token itself is still registered above */ }
227
+ }
228
+ // …AND THE CONFIG'S OWN KEYS, THROUGH THE ENGINE'S OWN LOADER AND ITS OWN DISCOVERY. This used to
229
+ // re-derive both, and a review took it apart: the home directory was computed as parent-of-parent
230
+ // unconditionally where the loader only steps out of a trailing `.candor/` segment, so an out-of-tree
231
+ // CANDOR_CONFIG had its relative values anchored one level too high and the guard protected a path
232
+ // the run never reads. A second parser is a second set of holes; `loadCandorConfig` is called inside a
233
+ // try so it can still refuse for real a moment later.
234
+ const cfgFile = discoverConfigFile(target ?? ".");
235
+ if (cfgFile) {
236
+ out.push([cfgFile, "the discovered .candor/config"]);
237
+ try {
238
+ const cfg = loadCandorConfig(target ?? ".", { lenient: true });
239
+ for (const key of ["policy", "baseline"]) {
240
+ if (cfg[key]) out.push([cfg[key], `the config's \`${key}\``]);
241
+ }
242
+ for (const one of (cfg.deps ?? "").split(":").filter(Boolean)) {
243
+ out.push([one, "the config's `deps`"]);
244
+ }
245
+ } catch { /* lenient: the real load refuses on its own terms */ }
246
+ }
247
+ return out;
248
+ };
249
+
250
+ {
251
+ const { gate, policy, target: preTarget } = preScan(argv);
252
+ for (const [other, flag] of (gate ? runInputs(preTarget, policy) : [])) {
253
+ if (gate && sameArtifact(gate, other)) {
254
+ console.error(`candor-ts: --gate-json ${gate} names the SAME FILE as ${flag} ${other} — refusing `
255
+ + `(exit 2). The verdict is armed before the policy is read, so this would overwrite your policy `
256
+ + `and then gate on the wreckage. Nothing was written; give the verdict its own path.`);
257
+ process.exit(2);
258
+ }
259
+ }
260
+ // `.candor/config` is never a verdict sink, wherever it is. The per-input checks above can only name
261
+ // inputs the run was TOLD about; the config is DISCOVERED by walking up from the target, so by the
262
+ // time its path is known the arming has already destroyed it. A check on the SHAPE needs no
263
+ // discovery, so it runs before the first write and covers a config found anywhere up the tree.
264
+ if (gate && gate !== "-") {
265
+ const abs = path.resolve(gate);
266
+ if (path.basename(abs) === "config" && path.basename(path.dirname(abs)) === ".candor") {
267
+ console.error(`candor-ts: --gate-json ${gate} is a .candor/config — refusing (exit 2). The verdict `
268
+ + `is armed before the config is read, so this would destroy the config that configures this `
269
+ + `run. Nothing was written; give the verdict its own path.`);
270
+ process.exit(2);
271
+ }
272
+ }
273
+ if (gate && sameArtifact(gate, process.env.CANDOR_CONFIG)) {
274
+ console.error(`candor-ts: --gate-json ${gate} names the SAME FILE as CANDOR_CONFIG — refusing (exit 2).`);
275
+ process.exit(2);
276
+ }
277
+ if (gate && gate !== "-") armGateJsonFailClosed(gate);
278
+ }
279
+ // ⟨0.27⟩ THE STREAM SINK'S ANALOG OF ARMING — SPEC §3.1's stream-sink clause. `--gate-json -` cannot be
280
+ // armed (a stream has no stale previous document, and a placeholder would put TWO documents in a
281
+ // consumer's pipe), but the document-on-every-exit rule applies in full: an exit-2 cause that fires
282
+ // before the gate tail — an unknown flag, a valueless gate-adjacent flag, a missing target — must still
283
+ // leave the fail-closed refusal as the stream's only content. Measured: an unhonourable policy wrote the
284
+ // refusal to stdout while an unknown flag exited 2 leaving stdout EMPTY — the same operator mistake,
285
+ // answered or not according to which early exit fired, and an empty stream throws the consumer back to
286
+ // scraping stderr. File sinks need nothing here: the arming above already left a refusal in place.
287
+ const preGateSink = preScan(argv).gate;
288
+ const refuseEarlyToStream = (why) => {
289
+ if (preGateSink === "-") console.log(JSON.stringify(refusalVerdict(SPEC_VERSION, why, null), null, 1));
290
+ };
122
291
  let target = null, outPrefix = null, policyPath = process.env.CANDOR_POLICY ?? null, gateJsonPath = null, allowJs = false, wantAgents = false, wantJson = false, wantWorkspace = false, wantDepInits = false;
123
292
  for (let i = 0; i < argv.length; i++) {
124
293
  const a = argv[i];
@@ -132,7 +301,11 @@ for (let i = 0; i < argv.length; i++) {
132
301
  else if (a === "--dep-inits") wantDepInits = true;
133
302
  else if (a === "--out" || a === "--policy" || a === "--gate-json") {
134
303
  const v = argv[i + 1];
135
- if (v === undefined || v.startsWith("--")) { console.error(`candor-ts: ${a} requires a value (${usage})`); process.exit(2); }
304
+ if (v === undefined || v.startsWith("--")) {
305
+ console.error(`candor-ts: ${a} requires a value (${usage})`);
306
+ refuseEarlyToStream(`${a} requires a value`);
307
+ process.exit(2);
308
+ }
136
309
  if (a === "--out") outPrefix = v; else if (a === "--policy") policyPath = v; else gateJsonPath = v;
137
310
  i++;
138
311
  }
@@ -140,13 +313,27 @@ for (let i = 0; i < argv.length; i++) {
140
313
  // (SPEC §6.2/§7). `-h`/`-V`/`--help`/`--version` are print-and-exit modes consumed above, so by here
141
314
  // a single-dash token (`-x`, the typo `-policy`) can only be a mistake; treating it as the scan
142
315
  // target would silently scan the wrong thing.
143
- else if (a.startsWith("-")) { console.error(`candor-ts: unknown flag ${a} (${usage})`); process.exit(2); }
316
+ else if (a.startsWith("-")) {
317
+ console.error(`candor-ts: unknown flag ${a} (${usage})`);
318
+ // ⟨0.27⟩ §3.3 names an unknown flag as a broken-gate-config exit-2 cause; the stream sink gets the
319
+ // refusal document too (see refuseEarlyToStream — the file sink is already armed).
320
+ refuseEarlyToStream(`unknown flag ${a}`);
321
+ process.exit(2);
322
+ }
144
323
  else if (target === null) target = a;
145
324
  else if (outPrefix === null) outPrefix = a; // legacy positional prefix
146
- else { console.error(`candor-ts: unexpected extra argument ${a} (${usage})`); process.exit(2); }
325
+ else {
326
+ console.error(`candor-ts: unexpected extra argument ${a} (${usage})`);
327
+ refuseEarlyToStream(`unexpected extra argument ${a}`);
328
+ process.exit(2);
329
+ }
147
330
  }
148
331
  if (wantAgents) { printAgents(); process.exit(0); }
149
- if (target === null) { console.error(usage); process.exit(2); }
332
+ if (target === null) {
333
+ console.error(usage);
334
+ refuseEarlyToStream("no scan target");
335
+ process.exit(2);
336
+ }
150
337
 
151
338
  // ---- .candor/config (candor-spec §config; the checked-in alternative to the CANDOR_* env vars) -----
152
339
  // Discovery is anchored to the SCAN TARGET (walk up from the target dir to the repo root's
@@ -156,14 +343,15 @@ if (target === null) { console.error(usage); process.exit(2); }
156
343
  // never vanish silently (the §6.2 unreadable-policy posture). Only genuine absence is an empty config.
157
344
  // Keys are the shared FAMILY vocabulary; a key OUTSIDE it warns (typo protection: a misspelt `policy`
158
345
  // 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"]);
346
+ // ⟨0.27⟩ `engine` (SPEC §3.4) is RECOGNIZED and IMPLEMENTED here — see enforceEnginePin. It must be in
347
+ // BOTH sets: missing from the vocabulary it is reported as unknown, and missing from IMPLEMENTED it is
348
+ // disclosed as inert — both tell an operator their pin was ignored while the engine is enforcing it.
160
349
  // The subset this engine actually wires to a mode — `policy` (the gate), `baseline` (AS-EFF-005),
161
350
  // `deps` (the cross-package report chain) and `unknown-ratchet` (the baseline guard's opt-in). The rest
162
351
  // of the vocabulary is spec-inert HERE: it drives other engines' gates. But a checked-in enforcement key
163
352
  // that silently does nothing is a DECLARED-GATE-SILENTLY-OFF — the reader believes the gate is on — so
164
353
  // an inert recognized key DISCLOSES loudly (stderr only; verdict/report/exit code untouched) instead of
165
354
  // 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
355
  // The ANCHOR a config file's RELATIVE path values (policy/deps) resolve against: the repo the config
168
356
  // belongs to — the parent of its `.candor/` directory (the standard layout; candor-init scaffolds
169
357
  // `policy arch.policy` meaning the repo root's), else the config file's own directory. NEVER the
@@ -174,28 +362,139 @@ function configAnchor(file) {
174
362
  const dir = path.dirname(path.resolve(file));
175
363
  return path.basename(dir) === ".candor" ? path.dirname(dir) : dir;
176
364
  }
177
- function loadCandorConfig(targetPath) {
365
+ // ⟨0.27⟩ SPEC §3.4 `engine` — THE ENGINE↔BASELINE COUPLING, enforced instead of hoped for.
366
+ //
367
+ // The committed baseline is a snapshot of what ONE engine build reported, and an engine swap is
368
+ // baseline-invalidating. What a PIN adds over the provenance checks already in place is that it is
369
+ // DECLARATIVE — a build id is a hash nobody can write down, so the intended version lived in CI config,
370
+ // decoupled from the baseline it is married to. It also tells tooling which engine to FETCH, and it
371
+ // reaches a run with NO baseline configured at all.
372
+ //
373
+ // TWO OF THE FIVE VERDICTS MUST NOT CHANGE THE EXIT CODE: an ABSENT pin (the key is opt-in by
374
+ // construction) and an UNDETERMINED one, where §3.1's unanswerable-condition rule applies — disclosed,
375
+ // never scored, INCLUDING as satisfied. Exit 2 on a mismatch, never 1: unevaluable, not violating.
376
+ //
377
+ // A pin qualified for another implementation is not ours to check — one config serves the whole family,
378
+ // and the family versions as a LADDER, so a bare version in a polyglot repo would fail whichever engine
379
+ // had not yet caught up.
380
+ const ENGINE_IMPLS = new Set(["java", "rust", "ts", "swift", "agents"]);
381
+ function enginePinFor(text, implName) {
382
+ let wild = null, qual = null, bad = false;
383
+ for (const rawLine of (text ?? "").split("\n")) {
384
+ const line = rawLine.split("#")[0].trim();
385
+ if (!line) continue;
386
+ const parts = line.split(/\s+/);
387
+ if (parts[0].toLowerCase() !== "engine") continue;
388
+ const rest = parts.slice(1);
389
+ // Two lines that DISAGREE about the same key are kept BOTH, so they cannot parse as a version and
390
+ // surface as malformed. One silently discarding the other is the failure this key exists to stop.
391
+ const slot = (cur, v) => (cur !== null && cur !== v ? `${cur} / ${v}` : v);
392
+ // 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.
393
+ if (rest.length && ENGINE_IMPLS.has(rest[0].toLowerCase())) {
394
+ if (rest[0].toLowerCase() === implName) { if (rest.length === 2) qual = slot(qual, rest[1]); else bad = true; }
395
+ continue; // another impl's line, whatever follows it
396
+ }
397
+ if (rest.length === 0) bad = true;
398
+ else if (rest.length === 1) wild = slot(wild, rest[0]);
399
+ else bad = true;
400
+ }
401
+ if (bad) return "<unreadable>";
402
+ // AN UNREADABLE UNQUALIFIED LINE IS NOT HIDDEN BY A QUALIFIED PIN. `qual ?? wild` returned the qualifi
403
+ // ed value, so `engine garbage` beside a good qualified line passed SILENTLY here while candor-java exited
404
+ // 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.
405
+ if (wild !== null && normalizePinVersion(wild) === null) return wild;
406
+ return qual ?? wild;
407
+ }
408
+ function normalizePinVersion(raw) {
409
+ const s = String(raw ?? "").trim().replace(/^[vV]/, "");
410
+ if (!/^\d+\.\d+(\.\d+)?$/.test(s)) return null;
411
+ return s.split(".").length === 2 ? `${s}.0` : s;
412
+ }
413
+ function enforceEnginePin(targetPath) {
414
+ const pin = enginePinFor(discoverConfigText(targetPath), "ts");
415
+ if (pin === null || pin === undefined) return; // ABSENT
416
+ const want = normalizePinVersion(pin);
417
+ if (want === null) {
418
+ console.error(`candor-ts: .candor/config has an \`engine\` line that is not an engine version.`);
419
+ console.error(` want \`engine <version>\` (e.g. \`engine v${PKG_VERSION}\`) or \`engine <impl> <version>\``);
420
+ console.error(` (e.g. \`engine ts v${PKG_VERSION}\`) for a repo scanned by more than one engine.`);
421
+ console.error(` Failing (exit 2) rather than ignoring it: a pin that cannot be read is a`);
422
+ console.error(` guard the operator believes is on.`);
423
+ process.exit(2);
424
+ }
425
+ const running = normalizePinVersion(PKG_VERSION) ?? String(PKG_VERSION ?? "").trim();
426
+ if (!running || running === "unknown") { // UNDETERMINED — disclose, never score
427
+ console.error(`candor-ts: .candor/config pins engine ${pin}, and this build does not know its own release,`);
428
+ console.error(` so the pin CANNOT be checked. Disclosed, not scored — neither passed nor failed.`);
429
+ return;
430
+ }
431
+ if (want === running) return; // MATCH
432
+ console.error(`candor-ts: .candor/config pins engine ${pin} but this build is candor-ts ${PKG_VERSION}.`);
433
+ console.error(` The pin and the committed baseline move together — a newer engine resolves more`);
434
+ console.error(` dispatch, so its report is not comparable with a baseline the pinned engine wrote.`);
435
+ console.error(` Either run the pinned engine, or update the pin and regenerate the baseline in the`);
436
+ console.error(` same change. Exit 2 (unevaluable), not 1 — this is not a policy violation.`);
437
+ // ONE call, not two: an insertion script matched both pin branches to this single exit, and the
438
+ // duplicate put TWO documents on the stream — which parses as neither. The other branch (a build that
439
+ // cannot determine its own release) RETURNS rather than exiting: disclosed, not scored, so no refusal
440
+ // belongs there.
441
+ refuseEarlyToStream(`.candor/config pins engine ${pin}, which this build does not satisfy`);
442
+ process.exit(2);
443
+ }
444
+
445
+ // WHICH config file this run reads, with NO side effects (SPEC §3.4). Extracted so the §3.3.1 sink
446
+ // guard asks the same question the loader answers instead of re-deriving the walk — a review took the
447
+ // guard's own copy apart on exactly that divergence.
448
+ function discoverConfigFile(targetPath) {
449
+ const env = process.env.CANDOR_CONFIG;
450
+ if (env) {
451
+ try { return fs.statSync(env).isFile() ? env : null; } catch { return null; }
452
+ }
453
+ let dir = path.resolve(targetPath ?? ".");
454
+ try { if (!fs.statSync(dir).isDirectory()) dir = path.dirname(dir); } catch { dir = path.dirname(dir); }
455
+ for (let d = dir; ; d = path.dirname(d)) {
456
+ const cand = path.join(d, ".candor", "config");
457
+ if (fs.existsSync(cand)) return cand;
458
+ if (path.dirname(d) === d) break; // filesystem root
459
+ }
460
+ return fs.existsSync(".candor/config") ? ".candor/config" : null;
461
+ }
462
+
463
+ /// `lenient: true` THROWS where this would otherwise `process.exit(2)`.
464
+ ///
465
+ /// The collision pre-pass needs the config's DECLARED input paths before the sink is armed, and it
466
+ /// wrapped this call in a try under the comment "the real load refuses on its own terms" — which
467
+ /// assumes the failure arrives as an exception. It does not: `process.exit` is not catchable, so an
468
+ /// unreadable config killed the run INSIDE that try, before arming, leaving a pre-seeded green verdict
469
+ /// intact at the file sink. SPEC §3.3's own words for that: "a refusal that writes nothing leaves the
470
+ /// previous run's green document on disk." java answers the same input with `refused: true`.
471
+ ///
472
+ /// Nothing is lost by leniency here. If the config cannot be read it declares no inputs anyone can
473
+ /// name, so the collision check over them is vacuous; arming then proceeds and the REAL load refuses a
474
+ /// moment later — now with the sink armed, so the refusal reaches it. A second parser was the
475
+ /// alternative, and a second parser is a second set of holes.
476
+ function loadCandorConfig(targetPath, { lenient = false } = {}) {
178
477
  let file = process.env.CANDOR_CONFIG ?? null;
179
478
  if (file !== null) {
180
479
  if (!fs.existsSync(file) || !fs.statSync(file).isFile()) {
480
+ if (lenient) throw new Error(`CANDOR_CONFIG set but ${file} is not a readable file`);
181
481
  console.error(`candor-ts: CANDOR_CONFIG set but ${file} is not a readable file — failing (exit 2)`);
482
+ // The config is the EARLIEST exit-2 cause, and the one the stream sink is least likely to be
483
+ // armed for — which is exactly why it was the last one still leaving stdout empty. Found by
484
+ // PART 36 (b11), a row written before this line was.
485
+ refuseEarlyToStream(`CANDOR_CONFIG set but ${file} is not a readable file`);
182
486
  process.exit(2);
183
487
  }
184
488
  } 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";
489
+ file = discoverConfigFile(targetPath);
193
490
  if (file === null) return {};
194
491
  }
195
492
  let text;
196
493
  try { text = fs.readFileSync(file, "utf8"); }
197
494
  catch (e) {
495
+ if (lenient) throw new Error(`config ${file} exists but could not be read (${e.message})`);
198
496
  console.error(`candor-ts: config ${file} exists but could not be read (${e.message}) — failing (exit 2)`);
497
+ refuseEarlyToStream(`config ${file} exists but could not be read`);
199
498
  process.exit(2);
200
499
  }
201
500
  const cfg = {};
@@ -230,10 +529,50 @@ function loadCandorConfig(targetPath) {
230
529
  const anchor = configAnchor(file);
231
530
  if (cfg.policy) cfg.policy = path.resolve(anchor, cfg.policy);
232
531
  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(":");
532
+ // ASCII whitespace ONLY, like java and swift: these are PATHS, and JS `\s` includes U+00A0, so a dep
533
+ // path containing a non-breaking space split into two halves that were then both "skipped" — a green
534
+ // run with the dep silently unchained, where java and rust loaded it.
535
+ if (cfg.deps) cfg.deps = cfg.deps.split(/[ \t:,]+/).filter(Boolean).map((t) => path.resolve(anchor, t)).join(":");
234
536
  return cfg;
235
537
  }
538
+ // ⟨0.24⟩/⟨0.27⟩ ARM THE VERDICT FAIL-CLOSED. Every exit path then leaves a refusal behind unless the run
539
+ // got far enough to replace it with a real verdict. A review found the pin refusal leaving the PREVIOUS
540
+ // run's document on disk — a CI wrapper reading the artifact instead of the exit code then reports a pass
541
+ // over a run that refused. candor-java's `armGateJson` is the model; the wording is about the RUN, not
542
+ // about the code.
543
+ //
544
+ // A `function` declaration, not a `const`: it is CALLED from the pre-pass above the arg loop, and only a
545
+ // hoisted declaration can be. The write is inlined rather than calling `writeAtomic` for the same reason
546
+ // in reverse — that helper is a `const` declared ~4700 lines below, so calling it would be a
547
+ // temporal-dead-zone throw.
548
+ function armGateJsonFailClosed(p) {
549
+ try {
550
+ const _tmp = `${p}.${process.pid}.arm`;
551
+ fs.writeFileSync(_tmp, JSON.stringify({
552
+ spec: SPEC_VERSION, ok: false, refused: true,
553
+ reason: "the gate did not complete — this document was written when the run STARTED and was never "
554
+ + "replaced by a verdict, so the run failed, crashed or was killed before it could decide. It is "
555
+ + "NOT a verdict about the code; see the run's stderr for the cause.",
556
+ }, null, 1) + "\n");
557
+ fs.renameSync(_tmp, p);
558
+ } catch (e) {
559
+ console.error(`candor-ts: could not arm --gate-json ${p} fail-closed (${e.message}) — if this run `
560
+ + `does not complete, that path may still hold a PREVIOUS run's verdict`);
561
+ }
562
+ }
563
+ // MOVED ABOVE THE CONFIG LOAD. `loadCandorConfig` is ITSELF an exit-2 cause (an unusable
564
+ // CANDOR_CONFIG, or a committed `.candor/config` that cannot be read), and arming after it left a
565
+ // config refusal exiting 2 with the PREVIOUS run's green still on disk — while the comment below
566
+ // said "BEFORE ANYTHING THAT CAN EXIT". The rule only holds if the arming really is first.
567
+ // ⟨0.24⟩ ARM THE VERDICT FAIL-CLOSED BEFORE ANYTHING THAT CAN EXIT. A review found the pin refusal
568
+ // leaving the PREVIOUS run's `--gate-json` document on disk — a CI wrapper reading the artifact instead
569
+ // of the exit code then reports a pass over a run that refused. Arming at the START makes this a CLASS
570
+ // fix: every exit path leaves a refusal unless the run got far enough to replace it. candor-java's
571
+ // `armGateJson` is the model, and the wording is about the RUN, not about the code.
572
+ // (armed by the pre-pass above, before the arg loop — see SPEC §3.3.1 ⟨0.27⟩. Arming HERE was still
573
+ // after the loop's unknown-flag exit, so the contract depended on argv order.)
236
574
  const candorConfig = loadCandorConfig(target);
575
+ enforceEnginePin(target); // ⟨0.27⟩ §3.4 — AFTER the arming, so its exit 2 cannot leave a stale verdict
237
576
  // precedence: the --policy flag / CANDOR_POLICY env already populated policyPath; the config is the floor.
238
577
  // A BARE `policy` line ("" value) means configured-with-empty → the unreadable-policy path fails loud.
239
578
  if (policyPath === null && candorConfig.policy !== undefined) policyPath = candorConfig.policy;
@@ -241,7 +580,12 @@ if (policyPath === null && candorConfig.policy !== undefined) policyPath = cando
241
580
  // (path-valued keys are already resolved against the config's anchor above). No CLI flag — matching
242
581
  // candor-java, the reference engine (env/config only). A BARE `baseline` line ("") fails loud below.
243
582
  let baselinePath = process.env.CANDOR_BASELINE ?? null;
244
- if (baselinePath === null && candorConfig.baseline !== undefined) baselinePath = candorConfig.baseline;
583
+ // WHICH SOURCE supplied it decides what a MISSING file means: `CANDOR_BASELINE` is set unconditionally
584
+ // by the adopt workflow, so an absent path there is "the ratchet is not adopted yet"; a checked-in
585
+ // `baseline` line DECLARES this repo has one, so an absent path there was deleted or never committed —
586
+ // and the guard passing green over it is a gate that silently stopped gating.
587
+ let baselineFromConfig = false;
588
+ if (baselinePath === null && candorConfig.baseline !== undefined) { baselinePath = candorConfig.baseline; baselineFromConfig = true; }
245
589
  // ⟨unknown-ratchet⟩ OPT-IN (config `unknown-ratchet` / CANDOR_UNKNOWN_RATCHET, default OFF): flip an
246
590
  // Unknown-ONLY gain vs the baseline from advisory to an AS-EFF-005 failure (exit 1). Env-override truthy
247
591
  // semantics mirror candor-java's Config.flag exactly — env var PRESENCE means on (env can't express off);
@@ -290,7 +634,15 @@ function fromTsconfig(cfgPath, baseDir) {
290
634
  return names.filter((f) => !isTestPath(path.relative(baseDir, f)));
291
635
  }
292
636
  const stat = fs.existsSync(target) ? fs.statSync(target) : null;
293
- if (!stat) { console.error(`candor-ts: no such path: ${target}`); process.exit(2); }
637
+ if (!stat) {
638
+ console.error(`candor-ts: no such path: ${target}`);
639
+ // The SAME two lines as every other early exit in this file. `refuseEarlyToStream` WRITES AND
640
+ // RETURNS — it is not a `Never`, and calling it INSTEAD of the exit lets the run continue past its
641
+ // own refusal (measured, while getting this wrong: an unreadable dep wrote its refusal and then
642
+ // exited 0). rust's equivalent is typed `-> !`, which is why the same slip could not happen there.
643
+ refuseEarlyToStream(`no such path: ${target}`);
644
+ process.exit(2);
645
+ }
294
646
  if (stat.isFile() && /tsconfig.*\.json$/.test(path.basename(target))) {
295
647
  rootDir = path.dirname(path.resolve(target));
296
648
  fileNames = fromTsconfig(path.resolve(target), rootDir);
@@ -315,7 +667,13 @@ if (stat.isFile() && /tsconfig.*\.json$/.test(path.basename(target))) {
315
667
  })(rootDir);
316
668
  }
317
669
  }
318
- if (fileNames.length === 0) { console.error(`candor-ts: no TypeScript sources under ${target}`); process.exit(2); }
670
+ if (fileNames.length === 0) {
671
+ console.error(`candor-ts: no TypeScript sources under ${target}`);
672
+ // An empty scan is an exit-2 cause like any other: a consumer reading the stream after it must not
673
+ // get nothing. §3.1 exempts no cause, and this one is easy to hit in CI (a path that moved).
674
+ refuseEarlyToStream(`no TypeScript sources under ${target}`);
675
+ process.exit(2);
676
+ }
319
677
  // Builtin typings FALLBACK: the engine ships @types/node as its own dependency, so a target that
320
678
  // hasn't installed it still resolves node:fs/node:net/… (found by the first npx-distribution
321
679
  // probe: a bare fixture read Unknown for fs.readFileSync because nothing supplied the builtin
@@ -798,16 +1156,69 @@ const corruptDepPkgs = new Set();
798
1156
  // --workspace's auto-scanned deps dir is prepended to the explicit CANDOR_DEPS/config spec (both chain).
799
1157
  const spec = [workspaceDepsDir, depInitsDir, process.env.CANDOR_DEPS ?? candorConfig.deps ?? ""].filter(Boolean).join(":");
800
1158
  const files = [];
801
- for (const tok of spec.split(/[\s:,]+/).filter(Boolean)) {
1159
+ // ASCII WHITESPACE ONLY, the same rule as the config loader above and as java, rust and swift. JS
1160
+ // `\s` includes U+00A0, so a dep path holding a non-breaking space split into two halves — and since
1161
+ // ⟨0.27⟩ made an unresolvable dep token FATAL, that turned a path the other three engines load into a
1162
+ // hard exit 2 naming a truncated path the operator never wrote. The config loader was fixed and this
1163
+ // one, which every config-declared dep is also routed through, was not: one rule, two spellings.
1164
+ for (const tok of spec.split(DEP_SEPARATORS).filter(Boolean)) {
802
1165
  try {
803
1166
  if (fs.statSync(tok).isDirectory())
804
1167
  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
1168
  if (fs.statSync(tok).isFile()) files.push(tok);
806
- } catch { console.error(`candor-ts: CANDOR_DEPS entry unreadable, skipped: ${tok}`); }
1169
+ } catch {
1170
+ // ⟨0.27⟩ SPEC §2: A CONFIGURED DEP THAT CANNOT BE READ IS UNEVALUABLE, NOT REDUCED COVERAGE.
1171
+ // Skipping it continued the run, and the caller of that dep then serialised `inferred: []` — a
1172
+ // ⟨0.21⟩ purity claim, published in the REPORT, about a function whose dependency the operator
1173
+ // configured precisely so it would not be one. This engine's note said only "skipped", so the
1174
+ // omission was not even qualified in the channel a human reads, let alone the artifact a chained
1175
+ // consumer reads. java and swift already refused; this engine and rust continued.
1176
+ console.error(`candor-ts: CANDOR_DEPS names ${tok} but it is not a readable file or directory — `
1177
+ + `failing (exit 2, unevaluable). A configured dep that is not there is not reduced coverage: `
1178
+ + `its callers would serialise \`inferred: []\`, which is a purity claim about code this scan `
1179
+ + `never saw. Scan that dependency, or remove it from the \`deps\` config / CANDOR_DEPS.`);
1180
+ // The TOKEN arm needed this too. The read and parse arms below were routed to the stream and this
1181
+ // one was not — three exits for one rule, two of them answered on the machine channel and one
1182
+ // silent, which a conformance row (PART 36 b8) caught immediately once the cause was posed at all.
1183
+ refuseEarlyToStream(`configured dependency ${tok} is not a readable file or directory`);
1184
+ process.exit(2);
1185
+ }
807
1186
  }
808
1187
  for (const f of files) {
1188
+ // ⟨0.27⟩ READ AND PARSE OUTSIDE THE TRY, because SPEC §2 binds them and the try was swallowing them.
1189
+ // The rule is one sentence — a configured dep path that "does not exist OR CANNOT BE READ MUST exit
1190
+ // 2, naming it" — and the 0.27 work implemented only the first half, at the token check above. A path
1191
+ // that resolved to a file which then failed to open, or held malformed JSON, was SKIPPED at exit 0,
1192
+ // and the caller of that dep serialised `inferred: []`: the ⟨0.21⟩ purity claim the token check
1193
+ // exists to prevent, reached by a different door.
1194
+ //
1195
+ // Found by the 0.27 go/no-go panel, which tested this engine's own changelog claim instead of
1196
+ // believing it. java and swift refused on both halves already; this and rust made the family 2-v-2
1197
+ // on a MUST. The surviving `catch` below still guards the PROCESSING of a well-formed document,
1198
+ // which is a different failure and stays a skip.
1199
+ let raw;
1200
+ try {
1201
+ raw = fs.readFileSync(f, "utf8");
1202
+ } catch {
1203
+ console.error(`candor-ts: CANDOR_DEPS report ${f} could not be read —`);
1204
+ console.error(` failing (exit 2, unevaluable). A configured dep this scan cannot read is not`);
1205
+ console.error(` reduced coverage: its callers would serialise \`inferred: []\`, a purity claim`);
1206
+ console.error(` about code this scan never saw.`);
1207
+ refuseEarlyToStream(`configured dependency report ${f} could not be read`);
1208
+ process.exit(2);
1209
+ }
1210
+ let parsed;
809
1211
  try {
810
- const d = JSON.parse(fs.readFileSync(f, "utf8"));
1212
+ parsed = JSON.parse(raw);
1213
+ } catch {
1214
+ console.error(`candor-ts: CANDOR_DEPS report ${f} is not valid JSON —`);
1215
+ console.error(` failing (exit 2, unevaluable). Same reason as an unreadable one: a report`);
1216
+ console.error(` that cannot be parsed makes no claim, and continuing would publish one.`);
1217
+ refuseEarlyToStream(`configured dependency report ${f} is not valid JSON`);
1218
+ process.exit(2);
1219
+ }
1220
+ try {
1221
+ const d = parsed;
811
1222
  // A report whose version can't be VERIFIED is not trusted (§2.1) — a missing header is as
812
1223
  // untrustworthy as a mismatched one (the Rust engine's rule; the engines split on this).
813
1224
  const stale = d.candor?.version !== ENGINE_VERSION;
@@ -928,7 +1339,7 @@ const corruptDepPkgs = new Set();
928
1339
  if (!stale && strs(e.netClass).includes("unknown-host")) cell.netIncomplete = true;
929
1340
  crossDeps.set(e.hash, cell);
930
1341
  }
931
- } catch { console.error(`candor-ts: CANDOR_DEPS report unparsable, skipped: ${f}`); }
1342
+ } catch { console.error(`candor-ts: CANDOR_DEPS report could not be processed, skipped: ${f}`); }
932
1343
  }
933
1344
  // A package chained TWICE — once fresh, once stale — is covered by the fresh report, so it is not a
934
1345
  // stale-only package and must not pick up the disclosure below on top of a real answer.
@@ -1729,7 +2140,7 @@ for (const sf of sources) {
1729
2140
  const ctorQual = `${mod}.${namespacePrefixOf(node)}${node.name.text}.constructor`;
1730
2141
  if (!fns.has(ctorQual)) {
1731
2142
  const { line, character } = sf.getLineAndCharacterOfPosition(node.getStart());
1732
- fns.set(ctorQual, { local: `${node.name.text}.constructor`, direct: new Set(), edges: new Set(),
2143
+ fns.set(ctorQual, { local: `${node.name.text}.constructor`, direct: new Set(), fsKinds: new Set(), edges: new Set(),
1733
2144
  hosts: new Set(), tables: new Set(), cmds: new Set(), paths: new Set(),
1734
2145
  blind: new Set(), incomplete: new Set(), why: new Set(), entry: false,
1735
2146
  loc: `${path.relative(rootDir, sf.fileName)}:${line + 1}:${character + 1}`,
@@ -1753,7 +2164,7 @@ for (const sf of sources) {
1753
2164
  // producer's namespace nesting, so widening the hash would break report chaining.
1754
2165
  const nsp = namespacePrefixOf(node);
1755
2166
  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(),
2167
+ fns.set(qual, { local: n, direct: new Set(), fsKinds: new Set(), edges: new Set(), hosts: new Set(), tables: new Set(),
1757
2168
  cmds: new Set(), paths: new Set(), blind: new Set(), incomplete: new Set(), why: new Set(), entry: false, isCjsExport,
1758
2169
  loc: `${path.relative(rootDir, sf.fileName)}:${line + 1}:${character + 1}`,
1759
2170
  endLine: sf.getLineAndCharacterOfPosition(node.getEnd()).line + 1 });
@@ -2054,7 +2465,7 @@ function moduleUnit(sf) {
2054
2465
  const qual = `${mod}.<module>`;
2055
2466
  let rec = fns.get(qual);
2056
2467
  if (!rec) {
2057
- rec = { local: qual, direct: new Set(), edges: new Set(), hosts: new Set(), tables: new Set(),
2468
+ rec = { local: qual, direct: new Set(), fsKinds: new Set(), edges: new Set(), hosts: new Set(), tables: new Set(),
2058
2469
  cmds: new Set(), paths: new Set(), blind: new Set(), incomplete: new Set(), why: new Set(),
2059
2470
  entry: false, unitKind: "initializer",
2060
2471
  loc: `${path.relative(rootDir, sf.fileName)}:1:1`,
@@ -2077,7 +2488,7 @@ function staticBlockUnit(node) {
2077
2488
  const qual = `${mod}.${cname}.<static-init>`;
2078
2489
  let rec = fns.get(qual);
2079
2490
  if (!rec) {
2080
- rec = { local: "<static-init>", direct: new Set(), edges: new Set(), hosts: new Set(), tables: new Set(),
2491
+ rec = { local: "<static-init>", direct: new Set(), fsKinds: new Set(), edges: new Set(), hosts: new Set(), tables: new Set(),
2081
2492
  cmds: new Set(), paths: new Set(), blind: new Set(), incomplete: new Set(), why: new Set(),
2082
2493
  entry: false, unitKind: "initializer",
2083
2494
  loc: `${path.relative(rootDir, sf.fileName)}:${sf.getLineAndCharacterOfPosition(node.getStart()).line + 1}:1`,
@@ -3403,6 +3814,17 @@ function visitCalls(node) {
3403
3814
  eff = null;
3404
3815
  if (eff) {
3405
3816
  rec.direct.add(eff);
3817
+ // SPEC §2 `fs` — refine an Fs we just PROVED with the direction its verb implies. DIRECT only
3818
+ // (never propagated over edges): a caller reaching one writer and one undetermined callee would
3819
+ // otherwise inherit ["write"] and thereby claim "writes but never reads" — the partial claim §2
3820
+ // forbids. An unrecognised verb adds nothing, so the field stays absent rather than half-true.
3821
+ if (eff === "Fs") {
3822
+ // A verb revealing no direction records the POISON marker "?" rather than nothing. Abstaining
3823
+ // would let a caller inherit a neighbour's ["write"] and claim "writes but never reads" over a
3824
+ // reach whose kind was never determined — the partial claim §2 forbids. Suppressed at emit.
3825
+ const ks = fsKind(mod, member);
3826
+ if (ks.length === 0) rec.fsKinds.add("?"); else for (const k of ks) rec.fsKinds.add(k);
3827
+ }
3406
3828
  // a κ rule that resolves to the Unknown trust-marker (node:vm code execution) is a direct
3407
3829
  // Unknown SOURCE — SPEC §4 requires a why on it, like eval's `reflect:eval`. (The rest of
3408
3830
  // the κ table is concrete effects, which carry no why.)
@@ -4339,7 +4761,10 @@ const inferred = new Map([...fns.keys()].map((k) => [k, new Set(fns.get(k).direc
4339
4761
  if (!queued.has(c)) { queued.add(c); queue.push(c); }
4340
4762
  }
4341
4763
  }
4342
- for (const m of ["hosts", "tables", "cmds", "paths", "blind", "incomplete"]) {
4764
+ // `fsKinds` joins the propagated surfaces: kinds TRAVEL the call graph (a caller that transitively only
4765
+ // writes IS a writer), and the "?" poison travels with them so a caller of an undetermined-kind function
4766
+ // inherits the SUPPRESSION rather than a half-answer. Pinned by conformance PART 31.
4767
+ for (const m of ["hosts", "tables", "cmds", "paths", "blind", "incomplete", "fsKinds"]) {
4343
4768
  const queue = [...fns.keys()];
4344
4769
  const queued = new Set(queue);
4345
4770
  for (let head = 0; head < queue.length; head++) {
@@ -4371,9 +4796,13 @@ for (const [name, rec] of fns) {
4371
4796
  hash: `${pkgName}#${rec.local}`, // SPEC §2: the cross-package join key (package + local tail)
4372
4797
  inferred: inf,
4373
4798
  direct: [...rec.direct].sort(),
4374
- declared: [],
4375
- undeclared: [],
4376
- overdeclared: [],
4799
+ // ⟨0.26⟩ `declared`/`undeclared`/`overdeclared` are DELIBERATELY ABSENT. They are the §5
4800
+ // capability-reconciliation outputs and this engine runs no such pass, so emitting `[]` would be a
4801
+ // positive claim — `undeclared: []` reads as "this function performs no undeclared effect", an
4802
+ // AS-EFF-001 all-clear from a check that never ran. SPEC §2 ⟨0.26⟩: present means the pass ran,
4803
+ // absent means it did not, and `[]` from an engine that computed nothing is forbidden.
4804
+ // They were emitted as hardcoded constants "for cross-engine schema parity" — a schema-parity check
4805
+ // is exactly what made that look conforming, which is why the rule now forbids requiring them.
4377
4806
  unresolved: inf.includes("Unknown"),
4378
4807
  };
4379
4808
  // Inline call edges (§2 `calls`) — the SAME edges the callgraph sidecar carries, embedded per entry so a
@@ -4389,6 +4818,14 @@ for (const [name, rec] of fns) {
4389
4818
  if (inf.includes("Db") && rec.tables.size) entry.tables = [...rec.tables].sort();
4390
4819
  if (inf.includes("Exec") && rec.cmds.size) entry.cmds = [...rec.cmds].sort();
4391
4820
  if (inf.includes("Fs") && rec.paths.size) entry.paths = [...rec.paths].sort();
4821
+ // SPEC §2 `fs` — the read/write kinds this fn's OWN Fs calls revealed. Gated on `inferred` carrying Fs
4822
+ // (the spec: "applies only when `inferred` contains `Fs`") and omitted when empty.
4823
+ //
4824
+ // Kinds TRAVEL (see the propagation loop); the "?" poison is what stops a PARTIAL answer travelling with
4825
+ // them. Present ⇒ some contributing Fs had no determined kind ⇒ suppress the whole field, because
4826
+ // ["write"] there would claim "writes but never reads" about a function that may do both.
4827
+ if (inf.includes("Fs") && rec.fsKinds.size && !rec.fsKinds.has("?"))
4828
+ entry.fs = [...rec.fsKinds].sort();
4392
4829
  // ⟨0.6⟩ unknownWhy — REQUIRED on a DIRECT Unknown SOURCE (this fn's own body has the unresolvable call,
4393
4830
  // so `rec.direct` carries Unknown), absent on a purely-transitive Unknown. The rich per-site reasons
4394
4831
  // (rec.why: callback:/dispatch:/dynamic-key:) when recorded, else a generic fallback so a source is
@@ -4830,8 +5267,13 @@ function fnv1aHex(sortedQuals) {
4830
5267
 
4831
5268
  // `package` names what this report COVERS — a consumer chaining it registers coverage even when
4832
5269
  // `functions` is empty (an all-pure package's report is its purity claim, SPEC §2 rule 3).
5270
+ // ⟨0.27⟩ SPEC §2.1 `resolves`: the OPTIONAL refinement surfaces this producer computes. Without it the
5271
+ // absence of such a field is overloaded between "does not compute this" and "computed and could not
5272
+ // determine it", and a consumer cannot read the omission at all. candor-ts resolves `fs` read/write kinds,
5273
+ // so it says so. A producer MUST NOT list a surface it does not compute — that turns "unimplemented" into a
5274
+ // false "undetermined", which is the inversion the field exists to prevent.
4833
5275
  const envelope = { candor: { version: ENGINE_VERSION, toolchain: `node-${process.versions.node}`, spec: SPEC_VERSION },
4834
- package: pkgName, functions };
5276
+ resolves: ["fs"], package: pkgName, functions };
4835
5277
  // ⟨0.15 staged⟩ the κ-coverage ledger as DATA (COVERAGE-DESIGN.md §1): ONE sorted form (count desc,
4836
5278
  // name asc — exactly the stderr line's order) feeds the envelope field, the stderr receipt below, and
4837
5279
  // the --gate-json advisory, so the three can never tell different stories.
@@ -4909,7 +5351,13 @@ for (const sf of sources) {
4909
5351
  supers.push(d && d.name ? `${moduleOf(d.getSourceFile())}.${namespacePrefixOf(d)}${d.name.getText()}` : t.expression.getText());
4910
5352
  }
4911
5353
  }
4912
- if (supers.length) hierarchy[`${mod}.${namespacePrefixOf(node)}${node.name.getText()}`] = supers;
5354
+ // ⟨0.26⟩ A KEY FOR EVERY TYPE INDEXED, `[]` INCLUDED — the key set IS the manifest (SPEC §2.2).
5355
+ // This was `if (supers.length)`, so a type with no supertypes was OMITTED and absence meant BOTH
5356
+ // "no supertypes" and "never indexed". Measured: removing one entry from a real sidecar silently
5357
+ // dropped a `callers --include-unknown` frontier row, while removing the sidecar ENTIRELY left it
5358
+ // correct — so LESS information was SAFER than partial information. §2.2's own example has always
5359
+ // shown `"app.Base": []`; this producer contradicted it.
5360
+ hierarchy[`${mod}.${namespacePrefixOf(node)}${node.name.getText()}`] = supers;
4913
5361
  }
4914
5362
  ts.forEachChild(node, walk);
4915
5363
  })(sf);
@@ -5013,6 +5461,11 @@ if (!wantJson) {
5013
5461
  // a `… | jq` / `… | candor-sarif` pipe never breaks.
5014
5462
  const emitViolation = (wantJson || gateJsonPath === "-") ? (l) => console.error(l) : (l) => console.log(l);
5015
5463
  let gateViolations = [];
5464
+ // ⟨0.27⟩ SPEC §4 `zeroMatch` — the raw text of every rule whose SCOPE bound no function, captured off the
5465
+ // gate evaluation and emitted onto the verdict document. The stderr lines alone left a machine consumer
5466
+ // unable to see that a rule bound nothing — the typo'd-scope silent green, one channel over. Disclosure
5467
+ // only: `ok` and the exit code never consult it.
5468
+ let gateZeroMatch = [];
5016
5469
  // ⟨0.24⟩ the `.candor/config` that supplied POLICY VOCABULARY this verdict actually used — named on the
5017
5470
  // document (SPEC §3.1 `99eb4e9`), null when no alias was referenced so the verdict stays byte-identical.
5018
5471
  let policyVocabulary = null;
@@ -5065,7 +5518,20 @@ const writeRefusal = (reason, unevaluated = null) => {
5065
5518
  // must not silently NARROW the guard back to report-only.
5066
5519
  if (baselinePath !== null) {
5067
5520
  const shownB = baselinePath === "" ? "(configured empty)" : baselinePath;
5068
- if (baselinePath !== "" && !fs.existsSync(baselinePath)) {
5521
+ if (baselinePath !== "" && !fs.existsSync(baselinePath) && baselineFromConfig) {
5522
+ // A CHECKED-IN DECLARATION IS NOT THE SAME ABSENCE — see baselineFromConfig above. Exit 2: the
5523
+ // gateless-green class, and an adopter review measured this as the second-likeliest first-commit
5524
+ // mistake (`.candor/` committed, the baseline not).
5525
+ console.error(`candor-ts: .candor/config declares \`baseline ${baselinePath}\` but that file is not `
5526
+ + `there — failing (exit 2). A checked-in declaration says this repo HAS a baseline, so an absent `
5527
+ + `one was deleted or never committed. Commit it, or record one: candor-ts <target> --out <prefix>.`);
5528
+ // WRITE THE REFUSAL DOCUMENT BEFORE EXITING. Without this the `--gate-json` file keeps whatever the
5529
+ // LAST run left there — so a CI wrapper that reads the artifact instead of the exit code sees the
5530
+ // previous run's `ok: true` and reports a pass, which is the stale-artifact false green this format
5531
+ // exists to refuse. java, rust and swift all overwrite on this branch; ts alone did not.
5532
+ writeRefusal(`.candor/config declares \`baseline ${baselinePath}\` but that file is not there`);
5533
+ process.exit(2);
5534
+ } else if (baselinePath !== "" && !fs.existsSync(baselinePath)) {
5069
5535
  console.error(`candor-ts: CANDOR_BASELINE ${baselinePath} does not exist — the regression guard is `
5070
5536
  + `not active (record one: candor-ts <target> --out <prefix>, then point at the report .json).`);
5071
5537
  } else {
@@ -5075,6 +5541,10 @@ if (baselinePath !== null) {
5075
5541
  if (!Array.isArray(arr)) {
5076
5542
  console.error(`candor-ts: baseline ${shownB} exists but could not be parsed (corrupt/truncated?) — `
5077
5543
  + `failing (exit 2); the guard must not silently pass on an unreadable baseline. Regenerate it with this build.`);
5544
+ // ⟨0.27⟩ the refusal document has no exempt cause AND no exempt sink (SPEC §3.1): a file sink holds
5545
+ // the armed placeholder, but `--gate-json -` is not armed, so without this write the stream carried
5546
+ // NOTHING on this cause. Writing here also replaces the placeholder with the specific reason.
5547
+ writeRefusal(`baseline ${shownB} exists but could not be parsed — guard NOT evaluated`);
5078
5548
  process.exit(2);
5079
5549
  }
5080
5550
  const baseVersion = !Array.isArray(root) && root.candor && typeof root.candor === "object"
@@ -5082,12 +5552,14 @@ if (baselinePath !== null) {
5082
5552
  if (baseVersion === null) {
5083
5553
  console.error(`candor-ts: the baseline ${shownB} has no provenance header (a legacy/bare-array report) — `
5084
5554
  + `a baseline is comparable only to its producing build (§2.1). Failing (exit 2); regenerate it with this build.`);
5555
+ writeRefusal(`baseline ${shownB} has no provenance header — guard NOT evaluated`); // ⟨0.27⟩ see above
5085
5556
  process.exit(2);
5086
5557
  }
5087
5558
  if (baseVersion !== ENGINE_VERSION) {
5088
5559
  console.error(`candor-ts: the baseline ${shownB} was produced by engine build ${baseVersion} but this is `
5089
5560
  + `build ${ENGINE_VERSION} — an engine swap is baseline-invalidating and the gate cannot evaluate `
5090
5561
  + `(exit 2; never a silent skip, never a bogus AS-EFF-005 wave). Regenerate deliberately with this build.`);
5562
+ writeRefusal(`baseline ${shownB} was produced by engine build ${baseVersion}, not this build — guard NOT evaluated`); // ⟨0.27⟩ see above
5091
5563
  process.exit(2);
5092
5564
  }
5093
5565
  const base = new Map();
@@ -5114,6 +5586,7 @@ if (baselinePath !== null) {
5114
5586
  console.error(`candor-ts: the baseline callgraph ${sidecarPath} is present but could not be parsed `
5115
5587
  + `(corrupt/truncated?) — failing (exit 2); a broken sidecar must not silently narrow the guard to `
5116
5588
  + `report-only. Regenerate the baseline with this build.`);
5589
+ writeRefusal(`baseline callgraph ${sidecarPath} could not be parsed — guard NOT evaluated`); // ⟨0.27⟩ see above
5117
5590
  process.exit(2);
5118
5591
  }
5119
5592
  // The node set = every caller key + every callee (a pure leaf appears only as a callee), exactly
@@ -5221,9 +5694,11 @@ if (policyPath !== null) {
5221
5694
  if (policyErrs.length) {
5222
5695
  const why = policyErrorText(policyPath, policyErrs);
5223
5696
  console.error(why);
5224
- // ⟨0.24⟩ ONE `unevaluated` ENTRY PER POLICY LINE THAT COULD NOT BE HONOURED — the SHARED builder, so
5225
- // this document and `gate --report`'s stay byte-equal (§3.1's acceptance test for the two routes).
5226
- policyRefusal = { why, unevaluated: policyErrorUnevaluated(policyErrs) };
5697
+ // ⟨0.27⟩ ONE `unevaluated` ENTRY PER RULE OF THE POLICY — not only the unhonourable lines (SPEC
5698
+ // §3.1's composed-document clause). Measured: listing only the bad token's line let a consumer read
5699
+ // `deny Fs`, absent from the exit-1 document's list, as evaluated-and-passed. The SHARED builder,
5700
+ // so this document and `gate --report`'s stay byte-equal (§3.1's acceptance test for the routes).
5701
+ policyRefusal = { why, unevaluated: policyRefusalUnevaluated(text, policyErrs) };
5227
5702
  } else {
5228
5703
  // ⟨0.24⟩ the config file that supplied vocabulary the verdict USED, so an ambient `.candor/config` — the
5229
5704
  // walk goes up through every parent, and CANDOR_CONFIG overrides it outright — cannot move a verdict while
@@ -5232,7 +5707,20 @@ if (policyPath !== null) {
5232
5707
  const p = discoverConfigPath(policyVocabularyAnchor(policyPath, target));
5233
5708
  if (p) policyVocabulary = { config: p, aliases: gatePolicy.aliasesUsed };
5234
5709
  }
5235
- gateViolations = gateViolations.concat(evaluatePolicy(gatePolicy, functions, cg, incompleteMap, netPartners));
5710
+ const gateOut = evaluatePolicy(gatePolicy, functions, cg, incompleteMap, netPartners);
5711
+ // ⟨0.27⟩ SPEC §4 — a rule that bound NO function is disclosed, never scored as satisfied. The exit
5712
+ // code is deliberately untouched: a zero-match rule is legitimate when one policy is shared across
5713
+ // repositories and a layer exists in only some of them, so refusal would make a shared policy
5714
+ // unusable. Printed before the violations so a typo'd layer name is visible above the verdict.
5715
+ for (const raw of gateOut.zeroMatch ?? []) {
5716
+ console.error(`candor: policy rule matched NO function — \`${raw}\`. It was evaluated and bound `
5717
+ + `nothing, so it cannot have caught anything. Legitimate when one policy is shared across `
5718
+ + `repos; a typo'd layer name otherwise.`);
5719
+ }
5720
+ // ⟨0.27⟩ captured BEFORE the concat below — `concat` returns a plain array, so the `zeroMatch`
5721
+ // property riding `gateOut` would be silently lost with it (see the gateZeroMatch declaration).
5722
+ gateZeroMatch = gateOut.zeroMatch ?? [];
5723
+ gateViolations = gateViolations.concat(gateOut);
5236
5724
  // Provable-purity DISCLOSURE (advisory — NEVER a violation, so the exit/verdict are untouched): functions
5237
5725
  // in a pure/deny scope that PASS but are Unknown (the Unknown could hide the forbidden effect — a
5238
5726
  // fn/closure-injected port). Surfaces the gap automatically (eval/fixloop/DISPATCH-NOTE.md).
@@ -5307,6 +5795,9 @@ if (gateJsonPath) {
5307
5795
  // consumer reading exit 1 must be able to see that the POLICY half of the gate never ran — the same
5308
5796
  // `unevaluated` key, in the same position, that `gate --report` uses for its answerability refusals.
5309
5797
  if (policyRefusal) verdictObj.unevaluated = policyRefusal.unevaluated;
5798
+ // ⟨0.27⟩ SPEC §4 `zeroMatch` — the same list the stderr lines carry, in the machine channel. Omitted
5799
+ // when empty so a fully-binding verdict is byte-identical; never consulted for `ok` or the exit code.
5800
+ if (gateZeroMatch.length) verdictObj.zeroMatch = gateZeroMatch;
5310
5801
  // ⟨0.21⟩ (Gap 2) the machine-legible incompleteness: the units candor couldn't analyze, so a CI/agent
5311
5802
  // reading the JSON learns WHY the gate can't certify (the stderr warning alone used to hide this from a
5312
5803
  // machine). `incomplete:true` + the list; the run exits 2 (could-not-fully-evaluate) below. ok:false +