candor-ts 0.26.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/AGENTS.md CHANGED
@@ -12,7 +12,7 @@ chains by hand.
12
12
  > **Already installed? Report the version and ask before upgrading — before you scan.** If this
13
13
  > project already has candor (a `.candor/` report directory, or `candor-ts` reachable via `npx`/a
14
14
  > global install), do this *first*: run `npx -y candor-ts --version` (offline) and **tell the user
15
- > plainly which version they're on** — e.g. *"This project is on candor-ts `<version>` (spec 0.26)."*
15
+ > plainly which version they're on** — e.g. *"This project is on candor-ts `<version>` (spec 0.27)."*
16
16
  > On a build too old for the flag, read `candor.version` / `candor.spec` from an existing
17
17
  > `.candor/report*.json`, or `npm ls -g candor-ts`.
18
18
  >
package/README.md CHANGED
@@ -192,7 +192,7 @@ pure-vs-Unknown ruling (PART 16) — the engines must answer identically, on eve
192
192
  | A call resolving to a *type* (function-typed field/param) → `Unknown`, never silent-pure | SPEC §4 |
193
193
  | Unmatched external calls contribute nothing (curated-classifier caveat) | SEMANTICS §8 C1 |
194
194
  | The literal surfaces `hosts`/`cmds`/`paths`/`tables`, literal-read only | SPEC §2 |
195
- | `{ candor: { version, toolchain, spec: "0.26" }, functions }` envelope; pure fns omitted | SPEC §2/§2.1 |
195
+ | `{ candor: { version, toolchain, spec: "0.27" }, functions }` envelope; pure fns omitted | SPEC §2/§2.1 |
196
196
  | Call-graph sidecar with **every** analyzed function a key | SPEC §2.2 |
197
197
  | The gate: AS-EFF-006 / 008 / 009, loud on an unreadable policy | SPEC §6.2 |
198
198
 
@@ -210,7 +210,7 @@ read the Rust source".
210
210
 
211
211
  ## Status
212
212
 
213
- 0.19.x, speaking candor-spec 0.26: the analysis core, the gate (`--policy` / `--gate-json` /
213
+ 0.19.x, speaking candor-spec 0.27: the analysis core, the gate (`--policy` / `--gate-json` /
214
214
  `.candor/config`), the full §3.1 query surface (including `containment`, `blindspots`, the
215
215
  `--include-unknown` dispatch frontier, and ⟨0.24⟩ `gate --report` — the gate applied to an EXISTING
216
216
  report, byte-equivalent to `scan --policy`'s verdict), the MCP server, the LSP server, and the watch loop are
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "candor-ts",
3
- "version": "0.26.0",
4
- "description": "candor for TypeScript \u2014 per-function side effects, transitively, with a policy gate (candor-spec 0.26)",
3
+ "version": "0.27.0",
4
+ "description": "candor for TypeScript \u2014 per-function side effects, transitively, with a policy gate (candor-spec 0.27)",
5
5
  "type": "module",
6
6
  "dependencies": {
7
7
  "@types/node": "^25.9.2",
package/policy.mjs CHANGED
@@ -796,6 +796,65 @@ export function evaluatePolicy(pol, functions, callgraph, incomplete = new Map()
796
796
  if (hit) push("AS-EFF-009", fn, [], `\`${fn}\` reaches into a forbidden layer (via \`${hit}\`), violating policy: \`${r.raw}\``);
797
797
  }
798
798
  }
799
+ // ⟨0.27⟩ SPEC §4 — A RULE WHOSE SCOPE BOUND NO FUNCTION IS UNANSWERABLE, AND IS DISCLOSED RATHER THAN
800
+ // SCORED AS SATISFIED. Measured on this engine before the fix: `deny Fs orders` exits 1 on a real
801
+ // violation while `deny Fs ordrs` exits 0 in silence — a one-character typo in a layer name is a
802
+ // permanently green gate, and `unverified` then calls the layer "PROVABLY clean". The asymmetry is the
803
+ // tell: a typo'd EFFECT token already exits 2 naming the accepted vocabulary.
804
+ //
805
+ // Carried as a PROPERTY on the returned array rather than as a second return value, so every existing
806
+ // caller keeps working unchanged and `--gate-json` is untouched: JSON.stringify ignores non-index
807
+ // properties on an array, so the verdict document cannot acquire a field the spec has not pinned.
808
+ //
809
+ // A `deny`/`pure` with NO scope applies to every function and so can never be this kind of typo —
810
+ // excluded. A `forbid` counts a match on either endpoint. Counted over the same names the gate saw.
811
+ const zeroCount = new Map();
812
+ for (const r of pol.deny) if (r.scope) zeroCount.set(r.raw, 0);
813
+ for (const r of pol.forbid) zeroCount.set(r.raw, 0);
814
+ if (zeroCount.size) {
815
+ const names = new Set(functions.map((f) => f.fn));
816
+ for (const k of Object.keys(callgraph ?? {})) names.add(k);
817
+ for (const n of names) {
818
+ for (const r of pol.deny) if (r.scope && scopeMatches(n, r.scope)) zeroCount.set(r.raw, zeroCount.get(r.raw) + 1);
819
+ for (const r of pol.forbid) {
820
+ if (scopeMatches(n, r.from) || scopeMatches(n, r.to)) zeroCount.set(r.raw, zeroCount.get(r.raw) + 1);
821
+ }
822
+ }
823
+ }
824
+ // ⟨0.27⟩ CODE-POINT order, explicitly — the `zeroMatch` verdict key pins the `viaDispatchOn` collation
825
+ // (SPEC §4), and JS's default sort orders by UTF-16 code unit, which disagrees above the BMP. The raw
826
+ // line is built from user identifiers, so this is reachable rather than theoretical.
827
+ out.zeroMatch = [...zeroCount].filter(([, c]) => c === 0).map(([raw]) => raw)
828
+ .sort((a, b) => {
829
+ const ai = [...a], bi = [...b];
830
+ for (let i = 0; i < Math.min(ai.length, bi.length); i++) {
831
+ const d = ai[i].codePointAt(0) - bi[i].codePointAt(0);
832
+ if (d) return d;
833
+ }
834
+ return ai.length - bi.length;
835
+ });
836
+ return out;
837
+ }
838
+
839
+ // ⟨0.27⟩ EVERY RULE OF A REFUSED POLICY, one `unevaluated` entry per raw line (SPEC §3.1's
840
+ // composed-document clause; candor-java `unhonouredRules` is the model). `policyErrorUnevaluated` above
841
+ // names only the UNHONOURABLE lines — measured, that let a consumer read `deny Fs`, absent from the list
842
+ // on an exit-1 document, as evaluated-and-passed: a per-rule false all-clear arriving through the
843
+ // disclosure itself. The unhonourable lines keep their specific `why`; every other rule line carries the
844
+ // whole-policy refusal, because a policy is evaluated as a whole or not at all. ONE builder for both gate
845
+ // routes, for the same byte-equality reason as its siblings above.
846
+ export function policyRefusalUnevaluated(policyText, errors) {
847
+ const fatal = new Map(policyErrorUnevaluated(errors).map((e) => [e.rule, e.why]));
848
+ const out = [];
849
+ for (const raw of policyText.split(/\r?\n/)) {
850
+ const line = raw.split("#", 1)[0].trim();
851
+ if (!line) continue;
852
+ out.push({ rule: line,
853
+ why: fatal.get(line)
854
+ ?? "NOT EVALUATED — a rule elsewhere in this policy cannot be honoured as written (named beside "
855
+ + "its own entry in this list), and a policy is evaluated as a whole or not at all: a verdict "
856
+ + "from its readable subset would be a verdict on a policy nobody wrote." });
857
+ }
799
858
  return out;
800
859
  }
801
860
 
@@ -813,7 +872,13 @@ export function discoverConfigPolicy(fromDir) {
813
872
  for (;;) {
814
873
  const cand = nodePath.join(dir, ".candor", "config");
815
874
  if (fs.existsSync(cand)) {
816
- const m = fs.readFileSync(cand, "utf8").split(/\r?\n/)
875
+ // A config that EXISTS but cannot be READ is configured-but-unusable, which §3.4 makes exit 2 —
876
+ // never a silent "absent" and never an uncaught throw. The bare `readFileSync` here let an EACCES
877
+ // escape as an uncaught exception: node exits 1, which is the POLICY VIOLATION code, and the
878
+ // armed sentinel survived because nothing replaced it. Two wrong answers from one missing catch.
879
+ // The siblings below swallow to null, which is the other wrong answer — an unreadable pin or
880
+ // policy silently not enforced — so all three now refuse the same way.
881
+ const m = readConfigOrRefuse(cand).split(/\r?\n/)
817
882
  .map((l) => l.split("#", 1)[0].trim()).filter(Boolean)
818
883
  .map((l) => l.match(/^(\S+)\s*(.*)$/)).find((mm) => mm && mm[1].toLowerCase() === "policy");
819
884
  if (!m) return null;
@@ -829,11 +894,11 @@ export function discoverConfigPolicy(fromDir) {
829
894
  // nearest `.candor/config` walking UP, else null. Read-only + lenient (the caller decides fail-closed).
830
895
  export function discoverConfigText(fromDir) {
831
896
  const env = process.env.CANDOR_CONFIG;
832
- if (env) { try { return fs.readFileSync(env, "utf8"); } catch { return null; } }
897
+ if (env) { return readConfigOrRefuse(env, "CANDOR_CONFIG"); }
833
898
  let dir = nodePath.resolve(fromDir);
834
899
  for (;;) {
835
900
  const cand = nodePath.join(dir, ".candor", "config");
836
- if (fs.existsSync(cand)) { try { return fs.readFileSync(cand, "utf8"); } catch { return null; } }
901
+ if (fs.existsSync(cand)) { return readConfigOrRefuse(cand); }
837
902
  const parent = nodePath.dirname(dir);
838
903
  if (parent === dir) return null;
839
904
  dir = parent;
@@ -846,9 +911,31 @@ export function discoverConfigText(fromDir) {
846
911
  // see named anywhere in the output — can decide the verdict. That is the ambient-input failure this format
847
912
  // exists to refuse, and the remedy is the usual one: not to forbid the input, but to make it unable to act
848
913
  // unnamed. Same walk as `discoverConfigText`, deliberately, so the two cannot name different files.
914
+ /**
915
+ * Read a `.candor/config` that DISCOVERY has already found, or refuse (exit 2).
916
+ *
917
+ * The three readers in this file each handled an unreadable-but-present config differently: one let the
918
+ * exception escape (node exits 1 — the POLICY VIOLATION code — with a stack trace, and an armed
919
+ * `--gate-json` sentinel left in place), and two swallowed it to `null`, which reads as "no config" and
920
+ * silently drops whatever the file configured: a policy, a baseline, or an engine pin the operator
921
+ * believes is guarding them. §3.4's posture is the unreadable-policy one — configured-but-unusable
922
+ * fails loud — so all three route through here.
923
+ */
924
+ function readConfigOrRefuse(p, via = null) {
925
+ try {
926
+ return fs.readFileSync(p, "utf8");
927
+ } catch (e) {
928
+ console.error(`candor-ts: ${via ? `${via}=` : ""}${p} exists but could not be read (${e.code ?? e.message}) `
929
+ + `— failing (exit 2, unevaluable). A config that cannot be read is a guard the operator believes `
930
+ + `is on: it may name a policy, a baseline or an engine pin, and treating it as absent would run `
931
+ + `without them.`);
932
+ process.exit(2);
933
+ }
934
+ }
935
+
849
936
  export function discoverConfigPath(fromDir) {
850
937
  const env = process.env.CANDOR_CONFIG;
851
- if (env) { try { fs.readFileSync(env, "utf8"); return nodePath.resolve(env); } catch { return null; } }
938
+ if (env) { readConfigOrRefuse(env, "CANDOR_CONFIG"); return nodePath.resolve(env); }
852
939
  let dir = nodePath.resolve(fromDir);
853
940
  for (;;) {
854
941
  const cand = nodePath.join(dir, ".candor", "config");
package/query.mjs CHANGED
@@ -25,7 +25,7 @@ import { fileURLToPath } from "node:url";
25
25
 
26
26
  import { parsePolicy, scopeMatches, discoverConfigPolicy, parseUnknownAliases, discoverConfigText,
27
27
  evaluatePolicy, reportNetClasses, resolveReasonClasses, discoverConfigPath,
28
- policyVocabularyAnchor, policyErrorText, policyErrorUnevaluated, policyUnreadable,
28
+ policyVocabularyAnchor, policyErrorText, policyRefusalUnevaluated, policyUnreadable,
29
29
  fatalPolicyErrors, refusalVerdict,
30
30
  unanswerableScoped } from "./policy.mjs";
31
31
  import { hasReport } from "./query-core.mjs";
@@ -232,7 +232,7 @@ function renderPathHuman(fns, cg, fnQ, eff) {
232
232
  // package.json; SPEC_VERSION is the spec contract this build speaks. Reused, never re-littered.
233
233
  const QUERY_DIR = path.dirname(fileURLToPath(import.meta.url));
234
234
  const PKG_VERSION = JSON.parse(fs.readFileSync(path.join(QUERY_DIR, "package.json"), "utf8")).version;
235
- const SPEC_VERSION = "0.26";
235
+ const SPEC_VERSION = "0.27";
236
236
 
237
237
  // ---- the §3.3.1 canonical query grammar (⟨0.10⟩, additive over 0.9) --------------------------------
238
238
  // One shape for every verb: `<verb> <verb-args…> [--report <locator>] [--policy <file>] [--json]
@@ -437,9 +437,122 @@ function resolveGateVerb(rawArgs, { strict = false } = {}) {
437
437
  // deprecated-alias machinery, because it has NO POSITIONALS: a stray argument is a usage error, never
438
438
  // probed as a report or a policy. Kept out of parseCanonical for that reason — the peel helpers exist to
439
439
  // accept the old grammar, and there is no old grammar for a verb introduced at ⟨0.24⟩.
440
+ // ── SPEC §3.3.1 ⟨0.27⟩ sink-arming helpers, shared by the gate verb. The scan entry point has its own
441
+ // copies (scan.mjs) because it must not import from this file; the RULES are the spec's, not shared code.
442
+
443
+ /** Learn `--gate-json` and `--policy` from a verb's argv with NO side effects. */
444
+ function preScanGateArgs(av) {
445
+ let gate = null, policy = null, report = null;
446
+ for (let i = 0; i < av.length; i++) {
447
+ const a = av[i], v = av[i + 1];
448
+ if (a !== "--gate-json" && a !== "--policy" && a !== "--report") continue;
449
+ if (v === undefined || (v.startsWith("-") && v !== "-")) continue;
450
+ if (a === "--gate-json") gate = v; else if (a === "--policy") policy = v; else report = v;
451
+ i++;
452
+ }
453
+ return { gate, policy, report };
454
+ }
455
+
456
+ /** Artifact identity, not string identity — `--policy /w/P --gate-json ./P` from /w is one file. */
457
+ function sameArtifactPath(a, b) {
458
+ if (!a || !b || a === "-" || b === "-") return false;
459
+ const resolve = (p) => {
460
+ try { return fs.realpathSync(p); } catch { /* not there yet — resolve the parent */ }
461
+ try { return path.join(fs.realpathSync(path.dirname(path.resolve(p))), path.basename(p)); }
462
+ catch { return null; }
463
+ };
464
+ const x = resolve(a);
465
+ return x !== null && x === resolve(b);
466
+ }
467
+
468
+ /** The files the CWD-discovered \`.candor/config\` names, resolved as the loader resolves them. */
469
+ function configDeclaredInputs() {
470
+ const out = [];
471
+ try {
472
+ const disc = discoverConfigPolicy(process.cwd());
473
+ if (disc?.policyPath) out.push([disc.policyPath, "the config policy key"]);
474
+ const cfgPath = discoverConfigPath(process.cwd());
475
+ if (cfgPath) out.push([cfgPath, 'the discovered .candor/config']);
476
+ } catch { /* lenient: the real load refuses on its own terms */ }
477
+ return out;
478
+ }
479
+
480
+ /** Refuse a sink that names an input of this run, having written nothing. */
481
+ function refuseGateJsonOverInput(gate, other, flag) {
482
+ if (!sameArtifactPath(gate, other)) return;
483
+ console.error(`candor-ts-query: --gate-json ${gate} names the SAME FILE as ${flag} ${other} — refusing `
484
+ + `(exit 2). The verdict is armed before the policy is read, so this would overwrite your policy and `
485
+ + `then gate on the wreckage. Nothing was written; give the verdict its own path.`);
486
+ process.exit(2);
487
+ }
488
+
489
+ /** `.candor/config` is never a verdict sink, wherever it is. */
490
+ function refuseGateJsonAtConfig(gate) {
491
+ if (!gate || gate === "-") return;
492
+ const abs = path.resolve(gate);
493
+ if (path.basename(abs) !== "config" || path.basename(path.dirname(abs)) !== ".candor") return;
494
+ console.error(`candor-ts-query: --gate-json ${gate} is a .candor/config — refusing (exit 2). This would `
495
+ + `destroy the config that configures this run. Nothing was written; give the verdict its own path.`);
496
+ process.exit(2);
497
+ }
498
+
499
+ /** Write the fail-closed refusal every later exit inherits unless a real verdict replaces it. */
500
+ function armQueryGateJson(p) {
501
+ try {
502
+ fs.writeFileSync(p, JSON.stringify(
503
+ refusalVerdict(SPEC_VERSION, "the gate did not complete — this document was written when the run "
504
+ + "STARTED and was never replaced by a verdict, so the run failed, crashed or was killed before "
505
+ + "it could decide. It is NOT a verdict about the code; see the run's stderr for the cause."),
506
+ null, 1) + "\n");
507
+ } catch (e) {
508
+ console.error(`candor-ts-query: could not arm --gate-json ${p} fail-closed (${e.message})`);
509
+ }
510
+ }
511
+
440
512
  function resolveGateReportVerb(rawArgs) {
441
513
  const usageLine = "usage: candor-ts-query gate --report <locator> --policy <file> [--json] [--gate-json <file>]";
442
514
  let reportLocator = null, policyFile = null, gateJsonPath = null, json = false;
515
+ // SPEC §3.3.1 ⟨0.27⟩ — ARM FIRST, AND NEVER OVER AN INPUT. A pre-pass with no side effects, so both
516
+ // the collision refusal and the arming precede every exit in the loop below. See the note where the
517
+ // arming used to live for why the previous ordering was wrong.
518
+ {
519
+ const { gate, policy, report } = preScanGateArgs(rawArgs);
520
+ if (gate) {
521
+ refuseGateJsonOverInput(gate, policy, "--policy");
522
+ // §3.3.1 names "a report being read (`gate --report`)" as an input. Writing the verdict there
523
+ // destroys the very report the gate was asked to judge, and the diagnostic then blames the report
524
+ // rather than the collision.
525
+ refuseGateJsonOverInput(gate, report, "--report");
526
+ refuseGateJsonOverInput(gate, process.env.CANDOR_POLICY, "CANDOR_POLICY");
527
+ // THE CONFIG-DECLARED POLICY. This verb's policy ladder falls back to the \`policy\` key of the
528
+ // config discovered from the CWD, and the guard checked only the flags — so the checked-in form,
529
+ // which is the one a CI job has, was destroyed at exit 0 while the flag form refused. The same
530
+ // hole the scan route closed, one route across.
531
+ for (const [p2, label] of configDeclaredInputs()) refuseGateJsonOverInput(gate, p2, label);
532
+ refuseGateJsonAtConfig(gate);
533
+ if (gate !== "-") armQueryGateJson(gate);
534
+ // …AND THE STREAM'S ANALOG OF ARMING. `armQueryGateJson` writes a fail-closed placeholder to a
535
+ // FILE; a stream cannot hold one, so the equivalent is a hook that emits the refusal on any
536
+ // exit-2 path that has not already written a verdict.
537
+ //
538
+ // Without it, this verb exited 2 during ARGUMENT PARSING with stdout EMPTY, while the same verb
539
+ // refusing later from inside the gate streamed the document — the same operator mistake, two
540
+ // answers, decided by how early it was caught. A machine consumer reading an empty stream after
541
+ // exit 2 cannot tell it from a clean gate.
542
+ //
543
+ // Measured at the 0.27 go/no-go: java, swift and ts all had this hole on the `gate` verb, and
544
+ // PART 36's stream rows never reached it because every one of them runs the SCAN route. The row
545
+ // that catches it is now there.
546
+ else {
547
+ process.on("exit", (code) => {
548
+ if (code === 2 && !globalThis.__candorGateVerdictWritten) {
549
+ console.log(JSON.stringify(refusalVerdict(SPEC_VERSION,
550
+ "the gate did not complete — this run exited before a verdict could be produced", null), null, 1));
551
+ }
552
+ });
553
+ }
554
+ }
555
+ }
443
556
  for (let i = 0; i < rawArgs.length; i++) {
444
557
  const a = rawArgs[i];
445
558
  if (a === "--report") {
@@ -469,8 +582,18 @@ function resolveGateReportVerb(rawArgs) {
469
582
  console.error(`candor-ts-query gate: unexpected argument '${a}' — \`gate\` takes no positionals; the report is a --report locator and the policy a --policy file\n ${usageLine}`);
470
583
  process.exit(2);
471
584
  }
585
+ // ARMING MOVED ABOVE THE FLAG LOOP (SPEC §3.3.1 ⟨0.27⟩).
586
+ //
587
+ // It used to sit here, and the comment justified it with a ⟨0.24⟩ ruling of my own: "a USAGE error was
588
+ // never a gate invocation, so it must write NOTHING". SPEC §3.3 says the opposite in terms — it names
589
+ // an unknown flag as a broken-gate-config exit-2 cause, and §3.1 adds that "if `--gate-json` was
590
+ // requested and the run exits 2 for ANY reason, a fail-closed document is written", calling a
591
+ // carve-out "a fail-open path with a reason attached". The ruling I built here was that carve-out, and
592
+ // the test pinning it pinned a reading the spec had already superseded. The stale green does not care
593
+ // that the operator's shell also failed.
594
+ const _policy = resolvePolicy(policyFile, null).policyFile;
472
595
  const prefix = requireReport(reportLocator !== null ? locatorToPrefix(reportLocator) : discoverReportPrefix());
473
- return { prefix, policyFile: resolvePolicy(policyFile, null).policyFile, gateJsonPath, json };
596
+ return { prefix, policyFile: _policy, gateJsonPath, json };
474
597
  }
475
598
 
476
599
  /**
@@ -1139,7 +1262,13 @@ switch (cmd) {
1139
1262
  const gwrite = (obj) => {
1140
1263
  const text = JSON.stringify(obj, null, 1);
1141
1264
  for (const dest of gdests) {
1142
- if (dest === "-") { console.log(text); continue; }
1265
+ // THE FLAG IS SET WHERE THE WRITE HAPPENS, not where the gate is entered. It was set on
1266
+ // entering this verb, under the comment "reaching here means the gate ran and will write its
1267
+ // own document" — which is a claim about the future, and false: the `--policy` fallback ladder
1268
+ // can still exit 2 below without writing. That suppressed the pre-pass hook and returned the
1269
+ // run to EMPTY stdout after exit 2, which is the exact channel the hook was added to close.
1270
+ // Caught by the second go/no-go panel; the first flag placement lasted about an hour.
1271
+ if (dest === "-") { globalThis.__candorGateVerdictWritten = true; console.log(text); continue; }
1143
1272
  // A SURFACING side-output: an unwritable path is one stderr line, never a raw ENOENT crash whose
1144
1273
  // exit 1 would read as a policy violation on a clean run (the scan path's rule).
1145
1274
  try {
@@ -1182,7 +1311,9 @@ switch (cmd) {
1182
1311
  // uses (SPEC §3.1 makes byte-equality between the two documents the acceptance test — the scan route
1183
1312
  // needed this list so a dominating baseline regression could carry the refusal beside it, and a list on
1184
1313
  // one route only would break the equality on the very change that repaired the precedence).
1185
- if (gfatal.length) { const why = policyErrorText(policyFile, gfatal); console.error(why); grefuse(why, policyErrorUnevaluated(gfatal)); }
1314
+ // ⟨0.27⟩ …listing EVERY rule of the refused policy, not only the unhonourable lines — the shared
1315
+ // builder with the scan route (SPEC §3.1's composed-document clause; byte-equality binds the two).
1316
+ if (gfatal.length) { const why = policyErrorText(policyFile, gfatal); console.error(why); grefuse(why, policyRefusalUnevaluated(gtext, gfatal)); }
1186
1317
  // ⟨0.24⟩ THE CONFIG FILE THAT SUPPLIED VOCABULARY THE VERDICT USED (SPEC §3.1 `99eb4e9`) — named on a
1187
1318
  // REFERENCE, not only on a firing, because the measured harm was a GREEN verdict a vocabulary file made
1188
1319
  // green. Omitted when no alias was used, so every other verdict stays byte-identical to before.
@@ -1305,6 +1436,17 @@ switch (cmd) {
1305
1436
  // Route the human output exactly as a scan does: to stderr whenever stdout carries the verdict
1306
1437
  // document, so `candor-ts-query gate … --json | jq` sees pure JSON.
1307
1438
  const gsay = (json || gateJsonPath === "-") ? (l) => console.error(l) : (l) => console.log(l);
1439
+ // ⟨0.27⟩ SPEC §4 — THE ZERO-MATCH DISCLOSURE BELONGS ON THIS ROUTE TOO. Its absence was found by a
1440
+ // cross-engine differential: java and swift disclosed on `gate --report`, rust and ts did not, so
1441
+ // the same typo'd policy was reported by two engines and silently scored as satisfied by two. §4's
1442
+ // MUST carries no route qualifier, and this is the SUPPLY-CHAIN gate — a consumer pointing a policy
1443
+ // at a report someone else produced. ALWAYS on stderr, never through `gsay`: this is a disclosure
1444
+ // about the policy, not a verdict line, and stdout may be carrying the verdict document.
1445
+ for (const raw of gviol.zeroMatch ?? []) {
1446
+ console.error(`candor: policy rule matched NO function — \`${raw}\`. It was evaluated and bound `
1447
+ + `nothing, so it cannot have caught anything. Legitimate when one policy is shared across `
1448
+ + `repos; a typo'd layer name otherwise.`);
1449
+ }
1308
1450
  for (const x of gviol) gsay(`[${x.rule}] ${x.detail}`);
1309
1451
  // ⟨0.21⟩ COMPLETENESS MANIFEST: a gate cannot be green over code candor never analyzed. The scan path
1310
1452
  // exits 2 on its OWN `unanalyzed`; here the same manifest travels ON the report, so the same verdict
@@ -1325,6 +1467,9 @@ switch (cmd) {
1325
1467
  if (gvocab) gverdictObj.policyVocabulary = gvocab;
1326
1468
  gverdictObj.violations = gviol;
1327
1469
  if (gunevaluated.length) gverdictObj.unevaluated = gunevaluated;
1470
+ // ⟨0.27⟩ SPEC §4 `zeroMatch` — the same list the stderr lines above carry, in the machine channel,
1471
+ // in the same position the scan route puts it (§3.1's byte-equality MUST binds the two documents).
1472
+ if (gviol.zeroMatch?.length) gverdictObj.zeroMatch = gviol.zeroMatch;
1328
1473
  if (gincomplete) { gverdictObj.incomplete = true; gverdictObj.unanalyzed = g.unanalyzed; }
1329
1474
  if (g.coverage.length)
1330
1475
  gverdictObj.coverage = { uncovered: g.coverage.length, packages: g.coverage.map((c) => c.name) };
package/scan-core.mjs CHANGED
@@ -215,6 +215,45 @@ export const MODEL_SDK_RE =
215
215
  export function isModelSdkPackage(moduleName) {
216
216
  return MODEL_SDK_RE.test(moduleName);
217
217
  }
218
+ /// SPEC §2 `fs` — for a call ALREADY classified `Fs`, the read/write direction its verb implies.
219
+ /// Returns ["read"], ["write"], ["read","write"], or [] when the verb does not say.
220
+ ///
221
+ /// THE EMPTY CASE IS THE POINT. §2: "when `Fs` is reached but its kind is unknown … the field MUST be
222
+ /// omitted rather than guessed. An empty or partial `fs` would be read as a positive claim ('reads but
223
+ /// never writes'), which is the §4 trust contract's forbidden direction." So an unrecognised verb
224
+ /// contributes nothing and the field stays absent — absence means "kind undetermined", never "read-only".
225
+ ///
226
+ /// A syntactic refinement of an effect candor already proved, NOT a soundness claim: a wrong direction
227
+ /// misreports a detail, a wrong EFFECT is the cardinal sin, and those are different failures. Deliberately
228
+ /// the same vocabulary and shape as candor-java's `fsKind` and candor-swift's — the surface is spec'd
229
+ /// four-way, and three engines inventing three verb tables for one field is how a shared field stops
230
+ /// meaning one thing. Node's sync/promise variants are handled by stripping the `Sync` suffix rather than
231
+ /// by listing every pair.
232
+ export function fsKind(moduleName, member) {
233
+ if (!member) return [];
234
+ const m = member.endsWith("Sync") ? member.slice(0, -4) : member;
235
+ // Reads the source AND writes the destination, in one call.
236
+ if (m === "copyFile" || m === "cp") return ["read", "write"];
237
+ const WRITE = new Set([
238
+ "writeFile", "appendFile", "write", "writev", "mkdir", "mkdtemp", "rmdir", "rm", "unlink",
239
+ "rename", "truncate", "ftruncate", "chmod", "fchmod", "lchmod", "chown", "fchown", "lchown",
240
+ "utimes", "futimes", "lutimes", "symlink", "link", "createWriteStream", "outputFile", "ensureDir",
241
+ "ensureFile", "emptyDir", "remove", "move", "outputJson", "writeJson", "writeJSON",
242
+ ]);
243
+ const READ = new Set([
244
+ "readFile", "readdir", "read", "readv", "stat", "lstat", "fstat", "statfs", "access", "exists",
245
+ "realpath", "readlink", "createReadStream", "opendir", "watch", "watchFile", "readJson",
246
+ "readJSON", "pathExists", "lstatSync",
247
+ ]);
248
+ if (WRITE.has(m)) return ["write"];
249
+ if (READ.has(m)) return ["read"];
250
+ // `open`/`openSync` take a MODE — "r", "w", "a" — so the verb alone does not say. Deliberately no claim
251
+ // rather than a guess at the common case.
252
+ if (m.startsWith("write") || m.startsWith("append")) return ["write"];
253
+ if (m.startsWith("read")) return ["read"];
254
+ return [];
255
+ }
256
+
218
257
  export function kappa(moduleName, member) {
219
258
  for (const [mre, vre, eff] of KAPPA_RULES) {
220
259
  if (mre.test(moduleName) && (!vre || vre.test(member))) return eff;
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.26";
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++) {
@@ -4393,6 +4818,14 @@ for (const [name, rec] of fns) {
4393
4818
  if (inf.includes("Db") && rec.tables.size) entry.tables = [...rec.tables].sort();
4394
4819
  if (inf.includes("Exec") && rec.cmds.size) entry.cmds = [...rec.cmds].sort();
4395
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();
4396
4829
  // ⟨0.6⟩ unknownWhy — REQUIRED on a DIRECT Unknown SOURCE (this fn's own body has the unresolvable call,
4397
4830
  // so `rec.direct` carries Unknown), absent on a purely-transitive Unknown. The rich per-site reasons
4398
4831
  // (rec.why: callback:/dispatch:/dynamic-key:) when recorded, else a generic fallback so a source is
@@ -4834,8 +5267,13 @@ function fnv1aHex(sortedQuals) {
4834
5267
 
4835
5268
  // `package` names what this report COVERS — a consumer chaining it registers coverage even when
4836
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.
4837
5275
  const envelope = { candor: { version: ENGINE_VERSION, toolchain: `node-${process.versions.node}`, spec: SPEC_VERSION },
4838
- package: pkgName, functions };
5276
+ resolves: ["fs"], package: pkgName, functions };
4839
5277
  // ⟨0.15 staged⟩ the κ-coverage ledger as DATA (COVERAGE-DESIGN.md §1): ONE sorted form (count desc,
4840
5278
  // name asc — exactly the stderr line's order) feeds the envelope field, the stderr receipt below, and
4841
5279
  // the --gate-json advisory, so the three can never tell different stories.
@@ -5023,6 +5461,11 @@ if (!wantJson) {
5023
5461
  // a `… | jq` / `… | candor-sarif` pipe never breaks.
5024
5462
  const emitViolation = (wantJson || gateJsonPath === "-") ? (l) => console.error(l) : (l) => console.log(l);
5025
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 = [];
5026
5469
  // ⟨0.24⟩ the `.candor/config` that supplied POLICY VOCABULARY this verdict actually used — named on the
5027
5470
  // document (SPEC §3.1 `99eb4e9`), null when no alias was referenced so the verdict stays byte-identical.
5028
5471
  let policyVocabulary = null;
@@ -5075,7 +5518,20 @@ const writeRefusal = (reason, unevaluated = null) => {
5075
5518
  // must not silently NARROW the guard back to report-only.
5076
5519
  if (baselinePath !== null) {
5077
5520
  const shownB = baselinePath === "" ? "(configured empty)" : baselinePath;
5078
- 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)) {
5079
5535
  console.error(`candor-ts: CANDOR_BASELINE ${baselinePath} does not exist — the regression guard is `
5080
5536
  + `not active (record one: candor-ts <target> --out <prefix>, then point at the report .json).`);
5081
5537
  } else {
@@ -5085,6 +5541,10 @@ if (baselinePath !== null) {
5085
5541
  if (!Array.isArray(arr)) {
5086
5542
  console.error(`candor-ts: baseline ${shownB} exists but could not be parsed (corrupt/truncated?) — `
5087
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`);
5088
5548
  process.exit(2);
5089
5549
  }
5090
5550
  const baseVersion = !Array.isArray(root) && root.candor && typeof root.candor === "object"
@@ -5092,12 +5552,14 @@ if (baselinePath !== null) {
5092
5552
  if (baseVersion === null) {
5093
5553
  console.error(`candor-ts: the baseline ${shownB} has no provenance header (a legacy/bare-array report) — `
5094
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
5095
5556
  process.exit(2);
5096
5557
  }
5097
5558
  if (baseVersion !== ENGINE_VERSION) {
5098
5559
  console.error(`candor-ts: the baseline ${shownB} was produced by engine build ${baseVersion} but this is `
5099
5560
  + `build ${ENGINE_VERSION} — an engine swap is baseline-invalidating and the gate cannot evaluate `
5100
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
5101
5563
  process.exit(2);
5102
5564
  }
5103
5565
  const base = new Map();
@@ -5124,6 +5586,7 @@ if (baselinePath !== null) {
5124
5586
  console.error(`candor-ts: the baseline callgraph ${sidecarPath} is present but could not be parsed `
5125
5587
  + `(corrupt/truncated?) — failing (exit 2); a broken sidecar must not silently narrow the guard to `
5126
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
5127
5590
  process.exit(2);
5128
5591
  }
5129
5592
  // The node set = every caller key + every callee (a pure leaf appears only as a callee), exactly
@@ -5231,9 +5694,11 @@ if (policyPath !== null) {
5231
5694
  if (policyErrs.length) {
5232
5695
  const why = policyErrorText(policyPath, policyErrs);
5233
5696
  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) };
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) };
5237
5702
  } else {
5238
5703
  // ⟨0.24⟩ the config file that supplied vocabulary the verdict USED, so an ambient `.candor/config` — the
5239
5704
  // walk goes up through every parent, and CANDOR_CONFIG overrides it outright — cannot move a verdict while
@@ -5242,7 +5707,20 @@ if (policyPath !== null) {
5242
5707
  const p = discoverConfigPath(policyVocabularyAnchor(policyPath, target));
5243
5708
  if (p) policyVocabulary = { config: p, aliases: gatePolicy.aliasesUsed };
5244
5709
  }
5245
- 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);
5246
5724
  // Provable-purity DISCLOSURE (advisory — NEVER a violation, so the exit/verdict are untouched): functions
5247
5725
  // in a pure/deny scope that PASS but are Unknown (the Unknown could hide the forbidden effect — a
5248
5726
  // fn/closure-injected port). Surfaces the gap automatically (eval/fixloop/DISPATCH-NOTE.md).
@@ -5317,6 +5795,9 @@ if (gateJsonPath) {
5317
5795
  // consumer reading exit 1 must be able to see that the POLICY half of the gate never ran — the same
5318
5796
  // `unevaluated` key, in the same position, that `gate --report` uses for its answerability refusals.
5319
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;
5320
5801
  // ⟨0.21⟩ (Gap 2) the machine-legible incompleteness: the units candor couldn't analyze, so a CI/agent
5321
5802
  // reading the JSON learns WHY the gate can't certify (the stderr warning alone used to hide this from a
5322
5803
  // machine). `incomplete:true` + the list; the run exits 2 (could-not-fully-evaluate) below. ok:false +