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/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.25)."*
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.25" }, 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.25: 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.25.0",
4
- "description": "candor for TypeScript \u2014 per-function side effects, transitively, with a policy gate (candor-spec 0.25)",
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-core.mjs CHANGED
@@ -696,13 +696,37 @@ export const byCodePoint = (a, b) => {
696
696
  };
697
697
 
698
698
  // Reflexive+transitive subtype test over the hierarchy sidecar.
699
- function isSubtypeOf(type, owner, hierarchy) {
700
- if (type === owner) return true;
699
+ //
700
+ // ⟨0.26⟩ THREE-VALUED, because the format now distinguishes what it could not before. SPEC §2.2 makes the
701
+ // KEY SET the manifest: a producer emits a key for every type it indexed, `[]` included, so a type with NO
702
+ // key is one the pass never looked at. `hierarchy[t] ?? []` read those two cases alike and answered
703
+ // `false` — a positive claim about a type nobody analysed.
704
+ //
705
+ // MEASURED before the rung, doctoring only the sidecar of a real scan: removing the REACHING implementor's
706
+ // entry silently dropped the dispatcher from `possibleViaUnknownDispatch` (`[]` where the control gives
707
+ // `[Dispatcher.run]`), while removing the sidecar ENTIRELY left it correct — the ⟨0.24⟩ per-file rule
708
+ // over-lists. LESS information was SAFER than partial information. candor-java behaved identically, which
709
+ // is what said the defect was the FORMAT rather than either consumer.
710
+ //
711
+ // A POSITIVE DOMINATES: if a known path reaches `owner` the answer is YES even when another branch ran
712
+ // into an unindexed type — the relation is established and an unknown branch cannot un-establish it. NO is
713
+ // reserved for a walk that stayed entirely inside types the sidecar answers for.
714
+ function subtypeOf(type, owner, hierarchy) {
715
+ if (type === owner) return "YES";
716
+ let sawUnindexed = false;
701
717
  const seen = new Set(), stack = [type];
702
718
  while (stack.length) {
703
- for (const s of hierarchy[stack.pop()] ?? []) { if (s === owner) return true; if (!seen.has(s)) { seen.add(s); stack.push(s); } }
719
+ const cur = stack.pop();
720
+ if (!Object.prototype.hasOwnProperty.call(hierarchy, cur)) { sawUnindexed = true; continue; }
721
+ for (const s of hierarchy[cur]) { if (s === owner) return "YES"; if (!seen.has(s)) { seen.add(s); stack.push(s); } }
704
722
  }
705
- return false;
723
+ return sawUnindexed ? "UNANSWERABLE" : "NO";
724
+ }
725
+
726
+ // The two-valued form. UNANSWERABLE collapses to TRUE — disclose, never drop — which is the direction
727
+ // §2.2 ⟨0.26⟩ requires and the opposite of what absence used to do.
728
+ function isSubtypeOf(type, owner, hierarchy) {
729
+ return subtypeOf(type, owner, hierarchy) !== "NO";
706
730
  }
707
731
 
708
732
  // callers + the unresolved-dispatch frontier (--include-unknown, SPEC §3.1/§4 0.7): the CONFIRMED set,
@@ -1236,7 +1260,7 @@ export function narrowingContext(fns, cg = {}, policyParsed = null) {
1236
1260
  const u = byRaw.get(r.raw);
1237
1261
  if (!u) continue; // unreachable: every held triple has a group
1238
1262
  const cur = heldByFn.get(f.fn) ?? new Map();
1239
- cur.set(`${r.raw}${eff}`, { fn: f.fn, rule: r.raw, effect: eff, why: u.why });
1263
+ cur.set(`${r.raw}\0${eff}`, { fn: f.fn, rule: r.raw, effect: eff, why: u.why });
1240
1264
  heldByFn.set(f.fn, cur);
1241
1265
  }
1242
1266
  return {
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.25";
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;