candor-ts 0.26.0 → 0.28.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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, policyZeroRules,
29
29
  fatalPolicyErrors, refusalVerdict,
30
30
  unanswerableScoped } from "./policy.mjs";
31
31
  import { hasReport } from "./query-core.mjs";
@@ -42,9 +42,11 @@ import { impact as coreImpact, path as corePath, gains as coreGains,
42
42
  containment as coreContainment, diff as coreDiff,
43
43
  where as coreWhere, map as coreMap, whatif as coreWhatif,
44
44
  fix as coreFix, fixGate as coreFixGate, unverified as coreUnverified,
45
- matches as coreMatches, gainsCoverage, parseClassFilter, ClassFilterError,
46
- loadReport, loadCallgraph, loadGateReport, reportVersion, reportPackage,
47
- reportUnanalyzed, advisoryAnswer } from "./query-core.mjs";
45
+ matches as coreMatches, gainsCoverage, gainsCompletenessFields, parseClassFilter, ClassFilterError,
46
+ loadReport, loadCallgraph, reportCallsGraph, loadGateReport, gateReportInputFiles,
47
+ reportVersion, reportPackage,
48
+ advisoryAnswer,
49
+ reportCompleteness, mustHedge, completenessFields, absorbCompleteness } from "./query-core.mjs";
48
50
  const emit = (v) => console.log(JSON.stringify(v, null, 1));
49
51
  // ⟨0.24⟩ SPEC §3.2 — THE OTHER CHANNEL. `advisoryAnswer` withdraws the claim from the JSON; this withdraws
50
52
  // it from the one a human reads, and the spec requires both because a test that reads one channel is
@@ -71,6 +73,79 @@ const advisoryUnevaluatedNote = (verb, unevaluated, tail) => {
71
73
  console.error(` ${tail}`);
72
74
  };
73
75
  const UNEVAL_TAIL_STRICT = "(`ok` is OMITTED — neither value is a statement this input licenses; no remedy is offered for a boundary the gate could not adjudicate; `--strict` exits 2, the could-not-evaluate code)";
76
+
77
+ // ---- ⟨0.28⟩ SPEC §2 — AN ADVISORY VERB OVER A CONFIGURED ZERO-RULE POLICY ANSWERS WITH THE CAVEAT
78
+ // DOCUMENT, RESULT KEYS WITHHELD, EXIT UNCHANGED.
79
+ //
80
+ // §6.2 makes the same condition an exit-2 REFUSAL for the GATE, on the ground that `ok: true` is a claim
81
+ // about the code no such run is entitled to make. These verbs share that loader and were not touched by
82
+ // the rung. They are ADVISORY — they set no verdict, so the gate's refusal posture is the wrong import.
83
+ // What they DO produce is an answer *relative to a policy*, and relative to no rules that answer is not a
84
+ // finding, it is an absence of questions. MEASURED here 2026-08-12 over `# no rules yet`:
85
+ //
86
+ // whatif {"of":[…],"affected":[…],"violations":[],"ok":true} exit 0
87
+ // fix {"crossing":false,"reason":"not-forbidden"} exit 0
88
+ // fix-gate {"ok":true,"remedies":[]} exit 0 (also --strict)
89
+ // unverified {"ok":true,"unverified":[]} exit 0 (also --strict)
90
+ //
91
+ // `not-forbidden` by a policy that forbids nothing is vacuously true — an all-clear produced by deleting
92
+ // the question. So the result keys are withheld: `unverified` does not emit an empty `unverified` list
93
+ // over a policy that asked nothing, for the same reason ⟨0.27⟩'s refusal document must not carry
94
+ // `violations`. And `fix` emits NO `crossing` KEY — ⟨0.28⟩ pins that key as present exactly when the verb
95
+ // answered, and here it did not.
96
+ //
97
+ // `fix` IS IN THE LIST BECAUSE THE LIST IS A CONDITION, NOT AN ENUMERATION. §2 names `whatif`/`fix-gate`/
98
+ // `unverified` because those were the three in front of the author, and records the divergence that
99
+ // created: candor-rust extended the rule to `fix` and flagged it, candor-swift read the list as closed and
100
+ // did not. Every verb that answers relative to a CONFIGURED policy takes this rule.
101
+ //
102
+ // NO NEW KEY. The document carries `unevaluated` with one entry naming the whole policy, in the EXACT
103
+ // spelling this engine's own gate routes already use for their zero-rule refusal (`policyZeroRules`, one
104
+ // builder, shared) — so the gate and the advisory verbs say the same thing about the same policy in the
105
+ // same words, which is what makes a cross-engine consumer possible at all.
106
+ //
107
+ // A policy that is NOT CONFIGURED is untouched: that remains the honest way to say "I am not gating"
108
+ // (§6.2), and it is exactly why a configured zero-rule policy is never a legitimate expression of it.
109
+ const policyAskedNothing = (pol) => !!pol && !pol.deny.length && !pol.allow.length && !pol.forbid.length;
110
+ const emitZeroRuleCaveat = (verb, policyFile, comp) => {
111
+ const { unevaluated } = policyZeroRules(policyFile);
112
+ console.error(`candor-ts: ${verb}: the policy at ${policyFile} yielded NO RULES — every line was ignored `
113
+ + `(see the \`ignoring policy rule\` warnings above), the file is empty, or it holds only comments. A `
114
+ + `policy with no rules ASKS NOTHING, so this verb has no answer to give relative to it: the result `
115
+ + `keys are WITHHELD and this caveat stands in their place (SPEC §2 ⟨0.28⟩). \`gate\` refuses outright `
116
+ + `over this policy (exit 2). If you did not mean to gate, remove the policy configuration rather than `
117
+ + `pointing it at a file with no rules in it.`);
118
+ // The report-completeness caveat rides the SAME document when it applies: the two disclosures are
119
+ // independent — one says the policy asked nothing, the other that the report could not see everything —
120
+ // and each says something the other does not.
121
+ emit({ unevaluated, ...completenessFields(comp) });
122
+ };
123
+ // ⟨0.28⟩ SPEC §2's OTHER cause on the ADVISORY channel — `analyzed.count: 0`, which `advisoryIncompleteNote`
124
+ // above could not say because it is written around an unread FILE and this report names none. Same two
125
+ // channels, different sentence, and the tail is the OPPOSITE one: the gate exits 0 over these bytes
126
+ // (⟨0.24⟩: a disclosure, not an exit code), so `--strict` does not move either and this note is all there is.
127
+ // ⟨0.28⟩ The THIRD cause on the advisory channel — a report file under the locator whose bytes could not
128
+ // be parsed. Its own sentence because the repair differs (fix or re-write that file, not "re-scan the
129
+ // sources"), and the gate tail is the `unanalyzed` one: `gate --report` REFUSES over a corrupt member
130
+ // (measured, exit 2), so `--strict`'s exit is bounded by the gate exactly as for an unread source file.
131
+ const advisoryUnreadableNote = (verb, unreadable) => {
132
+ console.error(`candor-ts: ${verb} could NOT fully evaluate — ${unreadable.length} report file(s) under this locator could not be parsed, and whatever they say is not in this answer:`);
133
+ for (const f of unreadable) console.error(` ${f}`);
134
+ console.error(" (`ok` is OMITTED — neither value is a statement this input licenses; `gate --report` exits 2 over these bytes. Fix or regenerate the corrupt report.)");
135
+ };
136
+ // ⟨0.28⟩ SPEC §2's THIRD ROW on the ADVISORY channel. Its own sentence beside the judged-nothing one
137
+ // below, for the reason `incompleteAnswerNote` gives: that note asserts `analyzed.count: 0`, which a
138
+ // row-3 report never said, and the repair is a producer that emits a manifest rather than a scan that
139
+ // reaches a conclusion.
140
+ const advisoryNoManifestNote = (verb, files) => {
141
+ console.error(`candor-ts: ${verb} could NOT fully evaluate — ${files.length} report(s) under this locator carry NO \`analyzed\` manifest at all (SPEC §2 row 3, a pre-⟨0.21⟩ producer), so they make no claim about what was judged and their silence licenses none either:`);
142
+ for (const f of files) console.error(` ${f}`);
143
+ console.error(" (`ok` is OMITTED — neither value is a statement this input licenses. `gate --report` exits 0 over these bytes, so this note is the whole of the warning. Re-scan with a current engine so the report carries its manifest.)");
144
+ };
145
+ const advisoryJudgedNothingNote = (verb) => {
146
+ console.error(`candor-ts: ${verb} could NOT fully evaluate — the report(s) under this locator say they JUDGED NOTHING (⟨0.24⟩ \`analyzed.count\` is 0, absent with no entries, or unreadable), so absence from \`functions\` licenses no purity claim about any unit and there is nothing here to certify`);
147
+ console.error(" (`ok` is OMITTED — neither value is a statement this input licenses. NOTHING DOWNSTREAM WILL CATCH THIS FOR YOU: `gate --report` exits 0 over a judged-nothing report and `--strict` does not move either, so this note is the whole of the warning. Re-scan the sources you meant to check.)");
148
+ };
74
149
  // The §6 effect vocabulary — used to reject a typo'd effect name in `where` (corpus-audit #3). Kept in step
75
150
  // with SPEC §6 / the umbrella's list; an unknown name PRESENT in a report (a spec extension) is still allowed.
76
151
  const KNOWN_EFFECTS = ["Net", "Fs", "Db", "Llm", "Exec", "Env", "Clock", "Ipc", "Log", "Rand", "Clipboard", "Unknown"];
@@ -95,26 +170,167 @@ const wantJsonOut = (a) =>
95
170
  a.includes("--json") || (!a.includes("--text") && !a.includes("--human") && !process.stdout.isTTY);
96
171
  // Emit the pinned JSON, or render prose via proseFn(data). Returns data so the caller can still exit on it.
97
172
  const put = (a, data, proseFn) => { if (!proseFn || wantJsonOut(a)) emit(data); else proseFn(data); return data; };
173
+
174
+ // ---- ⟨0.28⟩ SPEC §2 — THE COMPLETENESS CAVEAT ON A *DESCRIPTIVE* VERB. `advisoryAnswer` +
175
+ // `advisoryIncompleteNote` above are the two channels for a verb that renders a VERDICT; the clause they
176
+ // implement was scoped to verdicts, and ⟨0.28⟩ widens it to "any verb whose output could be read as a
177
+ // negative finding about the code — a verdict, an empty result set, or a zero count". These three helpers
178
+ // are the SAME two channels for an ANSWER. One reader (`reportCompleteness`), one key set
179
+ // (`completenessFields`), one trigger (`mustHedge`) — a second mechanism is how the family ended up with
180
+ // two element rules for the manifest reader, and how one channel goes quiet while the other is asserted on.
181
+
182
+ // What `gate --report` does over THESE SAME BYTES, as one sentence — a function and not a constant because
183
+ // the two causes get OPPOSITE answers. §3.3 makes an incomplete analysis of the target's own code an exit-2
184
+ // gate cause, so "the gate refuses too" is true of `unanalyzed`; ⟨0.24⟩ ruled count-0 the other way ("a
185
+ // disclosure, not an exit code"), so the gate exits 0 there. A note that sends the reader to a CI job which
186
+ // then passes teaches them the warning is noise — the disclosure discrediting itself. The count-0 sentence
187
+ // is also the more urgent one and says so: nothing downstream fails closed on those bytes.
188
+ const gateLine = (comp) => (comp.unanalyzed.length || comp.unreadable?.length
189
+ ? "`gate --report` exits 2 over these bytes."
190
+ : "NOTHING DOWNSTREAM WILL CATCH THIS FOR YOU — `gate --report` exits 0 over a judged-nothing report (⟨0.24⟩: a disclosure, not an exit code), so this note is the whole of the warning.");
191
+
192
+ // The HUMAN half. A no-op when there is nothing to disclose, so an ordinary run stays byte-identical, and
193
+ // printed BEFORE the answer because it qualifies a NON-EMPTY result as much as an empty one: a function in
194
+ // an unread file performs the effect or does not, and no list below can say which.
195
+ //
196
+ // ON STDOUT, WITH THE ANSWER IT QUALIFIES — this engine had it on stderr, alone against three: java
197
+ // documents stdout as deliberate ("a caveat on the other stream is one `2>/dev/null` from gone"), and
198
+ // swift's log records catching and REVERTING exactly this stderr choice after diffing against rust. The
199
+ // caveat and the answer must travel the same pipe, or a `2>/dev/null` consumer keeps the reassurance and
200
+ // loses its withdrawal. Every caller is on the PROSE branch (`putAnswer`'s else-arm, `tour`'s human arm,
201
+ // `gains`' else-arm), so stdout is never carrying a JSON document when this prints — JSON-mode runs take
202
+ // the machine half (`completenessFields`) instead and this function is not called at all.
203
+ const incompleteAnswerNote = (comp, soWhat, tail) => {
204
+ if (!mustHedge(comp)) return;
205
+ // Three causes, one sentence each, and only true ones: `unreadable` is a report file whose BYTES could
206
+ // not be parsed — not "declared unanalyzed units" and not "judged nothing", both of which would send
207
+ // the reader to the wrong repair (rust names it separately for the same reason).
208
+ const causes = [];
209
+ if (comp.unanalyzed.length)
210
+ causes.push(`declare ${comp.unanalyzed.length} unit(s) candor could not analyze`);
211
+ if (comp.judgedNothing.length)
212
+ causes.push("say they JUDGED NOTHING (`analyzed.count: 0`)");
213
+ // ⟨0.28⟩ SPEC §2's THIRD ROW, and it gets its OWN sentence because the one above was FALSE for it:
214
+ // this engine told the reader a row-3 report "says it judged nothing (`analyzed.count: 0`)" when the
215
+ // report declares nothing at all, and this family rates a false disclosure worse than a missing one.
216
+ // The repairs differ too — row 1 wants a scan that reaches a conclusion, row 3 wants a producer that
217
+ // emits a manifest — so a reader given the wrong one goes to the wrong place.
218
+ if (comp.noManifest?.length)
219
+ causes.push(`include ${comp.noManifest.length} report(s) carrying NO \`analyzed\` manifest at all`);
220
+ if (comp.unreadable?.length)
221
+ causes.push(`include ${comp.unreadable.length} file(s) that could not be parsed at all`);
222
+ console.log(`candor-ts: ⚠ INCOMPLETE — the report(s) under this locator ${causes.join(", and ")}, so ${soWhat}:`);
223
+ for (const u of comp.unanalyzed) console.log(` ${u.path}${u.reason ? ` (${u.reason})` : ""}`);
224
+ if (comp.judgedNothing.length)
225
+ console.log(" (a report that judged nothing names no function at all — its silence is not a purity claim about any unit)");
226
+ for (const f of comp.noManifest ?? [])
227
+ console.log(` ${f} — no \`analyzed\` manifest (a pre-⟨0.21⟩ producer): it makes no claim about what was judged, so its silence licenses none either. Re-scan with a current engine.`);
228
+ for (const f of comp.unreadable ?? [])
229
+ console.log(` ${f} — could not be parsed (corrupt or mid-write), so whatever it says is not in this answer`);
230
+ console.log(` ${tail} ${gateLine(comp)}`);
231
+ };
232
+
233
+ // The MACHINE half. Spread LAST so the verb's own pinned key order is untouched (JS objects keep insertion
234
+ // order), and `{}` on a complete report — the whole document is then byte-identical to a pre-⟨0.28⟩ one.
235
+ //
236
+ // THE COLLISION GUARD THIS USED TO CARRY IS GONE, because ⟨0.28⟩ Rung A removed the only shape that could
237
+ // construct it. `map` was the one caller whose top level is a USER NAMESPACE, and it answered by MERGING
238
+ // the hedge over a module literally named `incomplete` and disclosing the loss on stderr — a lost row the
239
+ // operator was told about, which was the best available answer while the caveat had to ride the result.
240
+ // It no longer does: `map` takes `putCaveatInstead` below, where the caveat REPLACES the document and
241
+ // nothing is displaced. Every remaining caller has a FIXED key set of its own (`where`
242
+ // {effect,directly,inherited}, `reachable` {entryPoints,effects}, `containment` {contained,ambient},
243
+ // `blindspots` {sources,totalUnknown}), so a collision here is not constructible from any report.
244
+ // Deleted rather than kept as a dormant guard: a check whose condition cannot arise reads as coverage.
245
+ const withCompleteness = (data, comp) => ({ ...data, ...completenessFields(comp) });
246
+
247
+ // `put`, plus the caveat on BOTH channels from ONE trigger — a caller cannot get the JSON half and the prose
248
+ // half to disagree, which is exactly the mutant (`ec1a441`) that survived a whole suite in candor-rust.
249
+ // `proseFn` receives the hedge flag as its second argument so it can WITHDRAW its reassuring sentence; the
250
+ // note alone is not enough, because "no Unknown sources ✓" IS the prose spelling of the empty JSON.
251
+ const putAnswer = (a, data, proseFn, comp, soWhat, tail) => {
252
+ if (!proseFn || wantJsonOut(a)) { emit(withCompleteness(data, comp)); return data; }
253
+ incompleteAnswerNote(comp, soWhat, tail);
254
+ proseFn(data, mustHedge(comp));
255
+ return data;
256
+ };
257
+
258
+ // ---- ⟨0.28⟩ RUNG A — SPEC §2: "A VERB WHOSE PINNED SHAPE CANNOT CARRY THE CAVEAT MUST EMIT THE CAVEAT
259
+ // DOCUMENT INSTEAD OF ITS RESULT DOCUMENT." Not a result document with the caveat omitted, and not an
260
+ // empty result of the pinned shape. `putAnswer` above SPREADS the caveat into the answer, which works for
261
+ // every verb with a fixed key set; two verbs have no such place:
262
+ //
263
+ // show pinned to a TOP-LEVEL ARRAY — nowhere to put a key at all. MEASURED here 2026-08-12 over a
264
+ // report declaring one `unanalyzed` unit: `[]`, exit 0, no caveat on ANY channel. *Nothing
265
+ // performs this effect*, asserted about code nobody examined.
266
+ // map keyed by the operator's own MODULE names. MEASURED: the caveat keys merged INTO the module
267
+ // namespace, and the merged shape disclosed the collision loudly while still dropping the row.
268
+ //
269
+ // AND THE RULING NAMES THIS ENGINE FOR WHY THE `@`-PREFIX ESCAPE IS NOT AVAILABLE: §2.2's convention is
270
+ // airtight for a sidecar because a `@`-key cannot collide with a TYPE name — but an npm scoped package is
271
+ // spelled `@scope/name`, so `@incomplete` is a key a real ts module could own. "A convention that is
272
+ // airtight in one namespace and merely unlikely in another is not a convention; it is a deferred
273
+ // collision."
274
+ //
275
+ // THE TYPE CHANGE IS THE POINT. A consumer doing `for (const x of doc)` over `show` gets a TypeError
276
+ // rather than a silent zero-iteration loop — the one case where breaking a consumer is the CORRECT
277
+ // outcome, because the consumer was being lied to. The exit does not move (⟨0.24⟩: a disclosure, not an
278
+ // exit code), and HEALTHY OUTPUT IS BYTE-IDENTICAL: the shape changes only on the hedge path.
279
+ //
280
+ // The PROSE arm is `putAnswer`'s, verbatim — prose has no shape problem, and the clause is about the
281
+ // machine document. Sharing the note + renderer call keeps the two channels from drifting.
282
+ //
283
+ // THE HEALTHY JSON ARM MUST NOT GO THROUGH `withCompleteness`, and the first draft of this helper did:
284
+ // it delegated to `putAnswer`, whose `{ ...data, ...{} }` turned `show`'s ARRAY into `{"0": {…}}` — the
285
+ // pinned top-level array destroyed on the very path this rung promises to leave byte-identical. Caught by
286
+ // the intact-input control before it left the machine. So the healthy arm emits `data` itself.
287
+ const putCaveatInstead = (a, data, proseFn, comp, soWhat, tail) => {
288
+ if (!proseFn || wantJsonOut(a)) { emit(mustHedge(comp) ? completenessFields(comp) : data); return data; }
289
+ incompleteAnswerNote(comp, soWhat, tail);
290
+ proseFn(data, mustHedge(comp));
291
+ return data;
292
+ };
293
+ // The one sentence every hedged prose arm ends with, so the six cannot drift into six wordings.
294
+ const NOT_A = (claim) => `— but see the INCOMPLETE note above; this is NOT "${claim}"`;
98
295
  const csv = (xs) => (xs && xs.length ? xs.join(", ") : "none");
99
296
  const rows = (xs, pre = " ") => { for (const x of xs) console.log(pre + x); };
100
297
  // Per-verb prose renderers. Read the SAME shapes query-core returns (so JSON and prose can't drift); kept
101
298
  // terse and scannable, in candor's voice (cf. the existing `tour`/`path` human forms).
102
299
  const P = {
103
- where: (d) => {
300
+ // ⟨0.28⟩ `hedge` is `mustHedge(comp)` (see `putAnswer`): the report could not support a determined
301
+ // negative, so the reassuring sentence — which IS the prose spelling of the empty JSON — is withdrawn.
302
+ // Never a manufactured finding in its place: the answer stands, its STANDING is what changes.
303
+ where: (d, hedge) => {
104
304
  const n = d.directly.length + d.inherited.length;
105
- if (n === 0) { console.log(`candor: 0 functions perform ${d.effect} in this report.`); return; }
305
+ if (n === 0) {
306
+ console.log(hedge
307
+ ? `candor: 0 functions candor COULD SEE perform ${d.effect} ${NOT_A(`nothing performs ${d.effect}`)}.`
308
+ : `candor: 0 functions perform ${d.effect} in this report.`);
309
+ return;
310
+ }
106
311
  console.log(`candor where ${d.effect} — ${n} function${n === 1 ? "" : "s"}:`);
107
312
  if (d.directly.length) { console.log(` perform it directly (${d.directly.length}):`); rows(d.directly); }
108
313
  if (d.inherited.length) { console.log(` reach it transitively (${d.inherited.length}):`); rows(d.inherited); }
109
314
  },
110
315
  callers: (d) => {
316
+ // ⟨0.28⟩ UNREACHABLE FROM THE CLI since the callers verb split the empty-`of` case into its two real
317
+ // causes below (no graph at all -> `unanswerable`, exit 2; a name absent from a real graph -> "no
318
+ // function matching", exit 2). Kept as a renderer guard only; do NOT route a new caller through it —
319
+ // this sentence is a determined negative and is the wrong answer when there is no call graph.
111
320
  if (!d.of.length) { console.log("candor: no function in the call graph matches that name."); return; }
112
321
  console.log(`candor callers — who reaches \`${d.of.join("`, `")}\`:`);
113
322
  console.log(` direct callers (${d.direct.length}): ${csv(d.direct)}`);
114
323
  console.log(` transitive callers (${d.transitive.length}): ${csv(d.transitive)}`);
115
324
  },
116
- show: (d) => {
117
- if (!d.length) { console.log("candor: no effectful function matches that name (pure functions are omitted from the report)."); return; }
325
+ show: (d, hedge) => {
326
+ // ⟨0.28⟩ the empty sentence stops citing the ⟨0.21⟩ purity convention when the report cannot back it:
327
+ // over these bytes an absent function is not evidence of purity, it is evidence of nothing.
328
+ if (!d.length) {
329
+ console.log(hedge
330
+ ? `candor: no effectful function candor COULD SEE matches that name ${NOT_A("this function is pure")} — absence from this report licenses no purity claim here.`
331
+ : "candor: no effectful function matches that name (pure functions are omitted from the report).");
332
+ return;
333
+ }
118
334
  d.forEach((e, i) => {
119
335
  if (i) console.log("");
120
336
  console.log(`${e.fn}`);
@@ -125,30 +341,54 @@ const P = {
125
341
  if (e.tables?.length) console.log(` tables: ${e.tables.join(", ")}`);
126
342
  });
127
343
  },
128
- map: (d) => {
344
+ map: (d, hedge) => {
129
345
  const mods = Object.entries(d);
130
- if (!mods.length) { console.log("candor: no effectful modules in this report."); return; }
346
+ if (!mods.length) {
347
+ console.log(hedge
348
+ ? `candor: no effectful module candor COULD SEE ${NOT_A("the code performs no effects")}.`
349
+ : "candor: no effectful modules in this report.");
350
+ return;
351
+ }
131
352
  console.log("candor map — effects by module:");
132
353
  for (const [m, v] of mods) console.log(` ${m} — ${csv(v.effects)} (${v.functions} fn${v.functions === 1 ? "" : "s"})`);
133
354
  },
134
- containment: (d) => {
355
+ containment: (d, hedge) => {
135
356
  if ("leaks" in d) { // ratchet (a baseline was given)
136
- if (!d.leaks.length) console.log("candor containment — no boundary effect reached a new layer vs the baseline. ✓");
357
+ // The ✓ is withdrawn from BOTH directions here, not just the empty one: this answer is a DIFFERENCE,
358
+ // so a partial side is unsound two ways — a leak in an unread CURRENT file is missed, one in an unread
359
+ // BASELINE file reads as newly appeared (a fabricated leak, at exit 1).
360
+ if (!d.leaks.length)
361
+ console.log(hedge
362
+ ? `candor containment — no boundary effect candor COULD SEE reached a new layer vs the baseline ${NOT_A("nothing leaked")}.`
363
+ : "candor containment — no boundary effect reached a new layer vs the baseline. ✓");
137
364
  else { console.log(`candor containment — ${d.leaks.length} boundary effect(s) reached a NEW layer (leak):`); rows(d.leaks); }
138
365
  if (d.cleanups && d.cleanups.length) { console.log(` no longer present (${d.cleanups.length}):`); rows(d.cleanups); }
139
366
  return;
140
367
  }
141
- if (!d.contained.length && !Object.keys(d.ambient).length) { console.log("candor containment — no boundary effects in this report."); return; }
368
+ if (!d.contained.length && !Object.keys(d.ambient).length) {
369
+ console.log(hedge
370
+ ? `candor containment — no boundary effect candor COULD SEE ${NOT_A("there are no boundary effects")}.`
371
+ : "candor containment — no boundary effects in this report.");
372
+ return;
373
+ }
142
374
  console.log("candor containment — how well each boundary effect stays in one layer:");
143
375
  for (const c of d.contained)
144
376
  console.log(` ${c.effect}: ${c.containmentPct}% in \`${c.owner}\` (spread across ${c.layers} layer${c.layers === 1 ? "" : "s"})`);
145
377
  const amb = Object.entries(d.ambient);
146
378
  if (amb.length) console.log(` ambient (reported, not scored): ${amb.map(([e, n]) => `${e}×${n}`).join(", ")}`);
147
379
  },
148
- reachable: (d) => {
380
+ reachable: (d, hedge) => {
149
381
  const effs = Object.entries(d.effects);
150
382
  console.log(`candor reachable — what the ${d.entryPoints} entry point${d.entryPoints === 1 ? "" : "s"} do at runtime:`);
151
- if (!effs.length) { console.log(" no effect reaches an entry point."); return; }
383
+ // "the program performs no effect at runtime" is the strongest claim this tool can make, and it stays a
384
+ // DETERMINED negative on good data (a library has no entry points) — which is exactly why the caveat
385
+ // must be said, rather than left for a reader to infer from the emptiness.
386
+ if (!effs.length) {
387
+ console.log(hedge
388
+ ? ` no effect reaches an entry point candor COULD SEE ${NOT_A("the program performs no effect at runtime")}.`
389
+ : " no effect reaches an entry point.");
390
+ return;
391
+ }
152
392
  for (const [e, v] of effs) console.log(` ${e}: ${v.count} (via ${csv(v.via)})`);
153
393
  },
154
394
  impact: (d) => {
@@ -157,19 +397,38 @@ const P = {
157
397
  if (d.affected.length) rows(d.affected);
158
398
  if (d.entryPoints.length) { console.log(` reachable from ${d.entryPoints.length} entry point(s):`); rows(d.entryPoints.map((ep) => `${ep.fn} [${csv(ep.inferred)}]`)); }
159
399
  },
160
- blindspots: (d) => {
161
- if (!d.sources.length) { console.log(`candor blindspots — no Unknown sources${d.totalUnknown ? " (all Unknown here is inherited, not rooted in a call)" : ""}. ✓`); return; }
400
+ blindspots: (d, hedge) => {
401
+ if (!d.sources.length) {
402
+ console.log(hedge
403
+ ? `candor blindspots — no Unknown source candor COULD SEE ${NOT_A("there are no blind spots")}.`
404
+ : `candor blindspots — no Unknown sources${d.totalUnknown ? " (all Unknown here is inherited, not rooted in a call)" : ""}. ✓`);
405
+ return;
406
+ }
162
407
  console.log(`candor blindspots — ${d.sources.length} Unknown source${d.sources.length === 1 ? "" : "s"} (of ${d.totalUnknown} function(s) carrying Unknown), most-smearing first:`);
163
408
  for (const s of d.sources) console.log(` \`${s.fn}\` — ${csv(s.why)}; reaches ${s.reaches} caller(s)`);
164
409
  },
165
- blindspotsStats: (d) => {
166
- if (!d.sources) { console.log("candor blindspots --stats — no Unknown sources (nothing to classify). ✓"); return; }
410
+ blindspotsStats: (d, hedge) => {
411
+ if (!d.sources) {
412
+ console.log(hedge
413
+ ? `candor blindspots --stats — no Unknown source candor COULD SEE (nothing to classify) ${NOT_A("there are no blind spots")}.`
414
+ : "candor blindspots --stats — no Unknown sources (nothing to classify). ✓");
415
+ return;
416
+ }
167
417
  console.log(`candor blindspots --stats — ${d.sources} Unknown source(s) by reason class (of ${d.totalUnknown} function(s) carrying Unknown) — size the blind-spot cost before \`deny E Unknown[…]\`:`);
168
418
  Object.entries(d.byClass).filter(([, v]) => v > 0).sort((a, b) => b[1] - a[1])
169
419
  .forEach(([k, v]) => console.log(` ${k.padEnd(12)} ${String(v).padStart(4)}${k === "setup" ? " ← fixable: the scan isn't configured, not a real blind spot" : ""}`));
170
420
  },
171
- gains: (d) => {
172
- if (!d.gained.length) { console.log("candor gains — no newly-reached effects vs the baseline. ✓"); return; }
421
+ // ⟨0.28⟩ `hedge` is true when EITHER side's report is incomplete (see the gains case). "No newly-reached
422
+ // effects ✓" IS the prose spelling of `gained: []`, and it is the determined negative this alarm verb
423
+ // exists to license — so it is withdrawn, not decorated. A non-empty list is left standing: it may be
424
+ // short, which the stderr note above says, but every entry in it was measured.
425
+ gains: (d, hedge) => {
426
+ if (!d.gained.length) {
427
+ console.log(hedge
428
+ ? `candor gains — no newly-reached effects candor COULD SEE vs the baseline ${NOT_A("this bump gained nothing")}.`
429
+ : "candor gains — no newly-reached effects vs the baseline. ✓");
430
+ return;
431
+ }
173
432
  console.log(`candor gains — the surface newly reaches: ${d.gained.join(", ")}`);
174
433
  for (const g of d.byFunction) console.log(` \`${g.fn}\` gained ${g.effect}${g.origin ? ` (${g.origin})` : ""}`);
175
434
  },
@@ -180,6 +439,18 @@ const P = {
180
439
  },
181
440
  };
182
441
 
442
+ // The fn-name UNIVERSE a `<fn>` target resolves against: the callgraph keys UNIONed with the report's fn
443
+ // names. Not a new set — this is byte-for-byte the one mcp.mjs's fn-existence guard already builds, so
444
+ // the CLI and the MCP surface refuse exactly the same names (a name the agent tool calls nonexistent must
445
+ // not be one the CLI silently answers `0` for). Used by the `impact`/`path` bad-target gates below.
446
+ //
447
+ // THE UNION IS THE SAFE DIRECTION. §2.2 makes every fn a callgraph key and the report is the effectful
448
+ // SUBSET, so today the union IS the keys; the union is what keeps that an observation rather than an
449
+ // assumption. Refusing only a name that resolves in NEITHER set means no answer these verbs can actually
450
+ // compute is ever withdrawn — gating on one set alone would be the ⟨0.24⟩ count-0 mirror defect the day
451
+ // the two disagreed.
452
+ const knownFnNames = (cg, fns) => [...new Set([...Object.keys(cg), ...fns.map((e) => e.fn)])];
453
+
183
454
  // Render `path` in HUMAN (non-`--json`) form — the indented provenance chain, BYTE-IDENTICAL to the
184
455
  // Rust reference (candor-query/src/callers.rs) and the Java port (Query.java). The `--json` shape is
185
456
  // UNTOUCHED (conformance PART 5 pins `{effect, fn, path:[{fn,loc,source}]}` four-way): this path is
@@ -232,7 +503,7 @@ function renderPathHuman(fns, cg, fnQ, eff) {
232
503
  // package.json; SPEC_VERSION is the spec contract this build speaks. Reused, never re-littered.
233
504
  const QUERY_DIR = path.dirname(fileURLToPath(import.meta.url));
234
505
  const PKG_VERSION = JSON.parse(fs.readFileSync(path.join(QUERY_DIR, "package.json"), "utf8")).version;
235
- const SPEC_VERSION = "0.26";
506
+ const SPEC_VERSION = "0.28";
236
507
 
237
508
  // ---- the §3.3.1 canonical query grammar (⟨0.10⟩, additive over 0.9) --------------------------------
238
509
  // One shape for every verb: `<verb> <verb-args…> [--report <locator>] [--policy <file>] [--json]
@@ -313,15 +584,26 @@ function parseCanonical(rawArgs, { policy = false, strict = false, includeUnknow
313
584
  const positionals = [];
314
585
  let reportLocator = null, policyFile = null, wantStrict = false, wantIncludeUnknown = false;
315
586
  let sawClass = false; // ⟨0.24⟩ `--class` takes ONE list and is NOT repeatable (SPEC §6.2)
587
+ // SPEC §3.2 ⟨0.28⟩ (every value-taking flag below): "given no value" MEANS the next token is
588
+ // flag-shaped, or the clause is unimplementable — consuming the token as the value made this very
589
+ // diagnostic unreachable and silently reinterpreted a flag. Measured on this file: `where Fs
590
+ // --report --json` diagnosed the wrong cause ("no report files at prefix '--json'"), and on a
591
+ // NON-policy verb `--policy --json` consumed-and-DISCARDED the next flag at exit 0 — a silently
592
+ // different command than the one on screen, the §6.2 unknown-flag reinterpretation one position
593
+ // over. A bare `-` stays a value and fails loud downstream (an unreadable file / unknown class);
594
+ // `./--weird` spells a file genuinely named like a flag.
595
+ const flagShaped = (v) => v !== undefined && v !== "-" && v.startsWith("-") && v.length > 1;
316
596
  for (let i = 0; i < rawArgs.length; i++) {
317
597
  const a = rawArgs[i];
318
598
  if (a === "--report") {
319
599
  // A `--report` with no following value is a LOUD usage error (exit 2), never a silent fall-back to
320
600
  // discovery and never an uncaught `locatorToPrefix(undefined)` TypeError (`where Fs --report`).
601
+ if (flagShaped(rawArgs[i + 1])) { console.error(`candor-ts: --report was given no value — the next token '${rawArgs[i + 1]}' is a flag, not a locator (a path really named that is spelled ./${rawArgs[i + 1]})`); process.exit(2); }
321
602
  if (i + 1 >= rawArgs.length) { console.error("candor-ts: --report requires a <locator> value (a directory, a .json report path, or a prefix)"); process.exit(2); }
322
603
  reportLocator = rawArgs[++i]; continue;
323
604
  }
324
605
  if (a === "--policy") { // consumed for EVERY verb (a valid candor flag); used only by policy verbs
606
+ if (flagShaped(rawArgs[i + 1])) { console.error(`candor-ts: --policy was given no value — the next token '${rawArgs[i + 1]}' is a flag, not a path (a file really named that is spelled ./${rawArgs[i + 1]})`); process.exit(2); }
325
607
  if (i + 1 >= rawArgs.length) { console.error("candor-ts: --policy requires a <file> value"); process.exit(2); }
326
608
  const v = rawArgs[++i]; if (policy) policyFile = v; continue;
327
609
  }
@@ -331,6 +613,7 @@ function parseCanonical(rawArgs, { policy = false, strict = false, includeUnknow
331
613
  if (a === "--include-unknown") { if (includeUnknown) wantIncludeUnknown = true; continue; } // used only by the verb that reads it
332
614
  if (a === "--stats") { continue; } // ⟨0.20⟩ tolerated everywhere; read by the `blindspots` case via args.includes
333
615
  if (a === "--class") { // ⟨0.20⟩ value flag; the value is read by the `blindspots` and `unverified` cases
616
+ if (flagShaped(rawArgs[i + 1])) { console.error(`candor-ts: --class was given no value — the next token '${rawArgs[i + 1]}' is a flag, not a <class,…> list`); process.exit(2); }
334
617
  if (i + 1 >= rawArgs.length) { console.error("candor-ts: --class requires a <class,…> value (reflect,dispatch,indirect,native,unresolved,setup; aliases: dynamic,*)"); process.exit(2); }
335
618
  // ⟨0.24⟩ SPEC §6.2's VALUE GRAMMAR, validated HERE — the one place every verb's args pass through, so
336
619
  // no verb can accept a value the filter will not honour. Two rules, one reason (see parseClassFilter):
@@ -437,16 +720,222 @@ function resolveGateVerb(rawArgs, { strict = false } = {}) {
437
720
  // deprecated-alias machinery, because it has NO POSITIONALS: a stray argument is a usage error, never
438
721
  // probed as a report or a policy. Kept out of parseCanonical for that reason — the peel helpers exist to
439
722
  // accept the old grammar, and there is no old grammar for a verb introduced at ⟨0.24⟩.
723
+ // ── SPEC §3.3.1 ⟨0.27⟩ sink-arming helpers, shared by the gate verb. The scan entry point has its own
724
+ // copies (scan.mjs) because it must not import from this file; the RULES are the spec's, not shared code.
725
+
726
+ /** Learn `--gate-json` and `--policy` from a verb's argv with NO side effects. */
727
+ function preScanGateArgs(av) {
728
+ let gate = null, policy = null, report = null;
729
+ for (let i = 0; i < av.length; i++) {
730
+ const a = av[i], v = av[i + 1];
731
+ if (a !== "--gate-json" && a !== "--policy" && a !== "--report") continue;
732
+ if (v === undefined || (v.startsWith("-") && v !== "-")) continue;
733
+ if (a === "--gate-json") gate = v; else if (a === "--policy") policy = v; else report = v;
734
+ i++;
735
+ }
736
+ return { gate, policy, report };
737
+ }
738
+
739
+ /** Artifact identity, not string identity — `--policy /w/P --gate-json ./P` from /w is one file. */
740
+ function sameArtifactPath(a, b) {
741
+ if (!a || !b || a === "-" || b === "-") return false;
742
+ const resolve = (p) => {
743
+ try { return fs.realpathSync(p); } catch { /* not there yet — resolve the parent */ }
744
+ try { return path.join(fs.realpathSync(path.dirname(path.resolve(p))), path.basename(p)); }
745
+ catch { return null; }
746
+ };
747
+ const x = resolve(a);
748
+ return x !== null && x === resolve(b);
749
+ }
750
+
751
+ /** The files the CWD-discovered \`.candor/config\` names, resolved as the loader resolves them. */
752
+ function configDeclaredInputs() {
753
+ const out = [];
754
+ try {
755
+ const disc = discoverConfigPolicy(process.cwd());
756
+ if (disc?.policyPath) out.push([disc.policyPath, "the config policy key"]);
757
+ const cfgPath = discoverConfigPath(process.cwd());
758
+ if (cfgPath) out.push([cfgPath, 'the discovered .candor/config']);
759
+ } catch { /* lenient: the real load refuses on its own terms */ }
760
+ return out;
761
+ }
762
+
763
+ /** Refuse a sink that names an input of this run, having written nothing. */
764
+ function refuseGateJsonOverInput(gate, other, flag) {
765
+ if (!sameArtifactPath(gate, other)) return;
766
+ console.error(`candor-ts-query: --gate-json ${gate} names the SAME FILE as ${flag} ${other} — refusing `
767
+ + `(exit 2). The verdict is armed before the policy is read, so this would overwrite your policy and `
768
+ + `then gate on the wreckage. Nothing was written; give the verdict its own path.`);
769
+ process.exit(2);
770
+ }
771
+
772
+ /** `.candor/config` is never a verdict sink, wherever it is. */
773
+ function refuseGateJsonAtConfig(gate) {
774
+ if (!gate || gate === "-") return;
775
+ const abs = path.resolve(gate);
776
+ if (path.basename(abs) !== "config" || path.basename(path.dirname(abs)) !== ".candor") return;
777
+ console.error(`candor-ts-query: --gate-json ${gate} is a .candor/config — refusing (exit 2). This would `
778
+ + `destroy the config that configures this run. Nothing was written; give the verdict its own path.`);
779
+ process.exit(2);
780
+ }
781
+
782
+ /** Write the fail-closed refusal every later exit inherits unless a real verdict replaces it. */
783
+ // ⟨0.28⟩ RESOLVE THE SINK TO ITS FINAL ARTIFACT BEFORE WRITING, and preserve the operator's layout.
784
+ // `renameSync` REPLACES a symlink rather than following it, so an `artifacts/verdict.json` linked into a
785
+ // shared directory kept a previous run's `{"ok": true}` while this run's document landed on the link — a
786
+ // stale green with a single `--gate-json` and no operator mistake. And rename gives the destination a NEW
787
+ // inode, so a multiply-linked target strands its other name with the previous document; there the write
788
+ // goes in place, trading the atomicity window for not publishing a stale verdict at a name the operator
789
+ // wired up. SPEC §3.3.1 states identity about ARTIFACTS; this family had it in the comparison only.
790
+ function resolveSinkArtifact(p) {
791
+ let cur = p;
792
+ for (let i = 0; i < 32; i++) {
793
+ let st;
794
+ try { st = fs.lstatSync(cur); } catch { return cur; }
795
+ if (!st.isSymbolicLink()) return cur;
796
+ let t;
797
+ try { t = fs.readlinkSync(cur); } catch { return cur; }
798
+ cur = path.isAbsolute(t) ? t : path.join(path.dirname(cur), t);
799
+ }
800
+ return cur;
801
+ }
802
+
803
+ function writeSinkAtomic(p, text) {
804
+ const target = resolveSinkArtifact(p);
805
+ try {
806
+ if (fs.statSync(target).nlink > 1) { fs.writeFileSync(target, text); return; }
807
+ } catch { /* not there yet — the ordinary temp+rename path is right */ }
808
+ const tmp = `${target}.${process.pid}.tmp`;
809
+ fs.writeFileSync(tmp, text);
810
+ fs.renameSync(tmp, target);
811
+ }
812
+
813
+ function armQueryGateJson(p) {
814
+ try {
815
+ writeSinkAtomic(p, JSON.stringify(
816
+ refusalVerdict(SPEC_VERSION, "the gate did not complete — this document was written when the run "
817
+ + "STARTED and was never replaced by a verdict, so the run failed, crashed or was killed before "
818
+ + "it could decide. It is NOT a verdict about the code; see the run's stderr for the cause."),
819
+ null, 1) + "\n");
820
+ } catch (e) {
821
+ console.error(`candor-ts-query: could not arm --gate-json ${p} fail-closed (${e.message})`);
822
+ }
823
+ }
824
+
440
825
  function resolveGateReportVerb(rawArgs) {
441
826
  const usageLine = "usage: candor-ts-query gate --report <locator> --policy <file> [--json] [--gate-json <file>]";
442
827
  let reportLocator = null, policyFile = null, gateJsonPath = null, json = false;
828
+ // SPEC §3.3.1 ⟨0.27⟩ — ARM FIRST, AND NEVER OVER AN INPUT. A pre-pass with no side effects, so both
829
+ // the collision refusal and the arming precede every exit in the loop below. See the note where the
830
+ // arming used to live for why the previous ordering was wrong.
831
+ {
832
+ const { gate, policy, report } = preScanGateArgs(rawArgs);
833
+ if (gate) {
834
+ // THE STREAM HOOK IS INSTALLED FIRST, before anything that can exit. It WRITES NOTHING until the
835
+ // process exits, so unlike the file arming below it cannot land on an input and has no reason to
836
+ // wait behind the collision checks.
837
+ //
838
+ // It used to be installed after them, and `configDeclaredInputs()` — one of those checks — reads
839
+ // the config, which exits 2 through a shared helper when the config is unreadable. So the earliest
840
+ // exit-2 cause there is left stdout EMPTY on this route while java and swift wrote the refusal.
841
+ // Found by the gate-verb cells in candor/bin/probe-causes.sh; candor-scan had the same gap through
842
+ // the same shared loader, one language across.
843
+ if (gate === "-") {
844
+ process.on("exit", (code) => {
845
+ if (code === 2 && !globalThis.__candorGateVerdictWritten) {
846
+ console.log(JSON.stringify(refusalVerdict(SPEC_VERSION,
847
+ "the gate did not complete — this run exited before a verdict could be produced", null), null, 1));
848
+ }
849
+ });
850
+ }
851
+ refuseGateJsonOverInput(gate, policy, "--policy");
852
+ // §3.3.1 names "a report being read (`gate --report`)" as an input. Writing the verdict there
853
+ // destroys the very report the gate was asked to judge, and the diagnostic then blames the report
854
+ // rather than the collision.
855
+ refuseGateJsonOverInput(gate, report, "--report");
856
+ // ⟨0.28⟩ …AND THE FILES THE LOCATOR EXPANDS TO, because the raw flag value above is not what the
857
+ // gate READS: a locator is a prefix/dir (or a discovery, when absent), and `loadGateReport` reads
858
+ // its expansion — so a sink naming one of the expanded reports, or one of their §2.2 sidecars,
859
+ // named an input the token comparison could not see. Enumerated by the loader-adjacent
860
+ // `gateReportInputFiles` (query-core.mjs); the measurement lives there.
861
+ const expandedInputs = gateReportInputFiles(report ? locatorToPrefix(report) : discoverReportPrefix());
862
+ for (const f of expandedInputs) refuseGateJsonOverInput(gate, f, "a file this gate reads —");
863
+ refuseGateJsonOverInput(gate, process.env.CANDOR_POLICY, "CANDOR_POLICY");
864
+ // THE CONFIG-DECLARED POLICY. This verb's policy ladder falls back to the \`policy\` key of the
865
+ // config discovered from the CWD, and the guard checked only the flags — so the checked-in form,
866
+ // which is the one a CI job has, was destroyed at exit 0 while the flag form refused. The same
867
+ // hole the scan route closed, one route across.
868
+ for (const [p2, label] of configDeclaredInputs()) refuseGateJsonOverInput(gate, p2, label);
869
+ refuseGateJsonAtConfig(gate);
870
+ // ⟨0.28⟩ THE RUNG BINDS EVERY ROUTE. It shipped on scan.mjs only, so this verb kept last-wins and a
871
+ // gate that FIRED left the first named sink holding a previous run's `{"ok": true}`. Every named
872
+ // sink also gets the input checks, and the input exemption covers that PATH, not the run — so the
873
+ // other sinks still receive the refusal.
874
+ const namedSinks = [];
875
+ for (let k = 0; k < rawArgs.length; k++) {
876
+ if (rawArgs[k] !== "--gate-json") continue;
877
+ const v = rawArgs[k + 1];
878
+ if (v === undefined || (v !== "-" && v.startsWith("-"))) continue;
879
+ if (!namedSinks.some((x) => x === v || (x !== "-" && v !== "-" && sameArtifactPath(x, v)))) namedSinks.push(v);
880
+ k++;
881
+ }
882
+ for (const g of namedSinks) {
883
+ refuseGateJsonOverInput(g, policy, "--policy");
884
+ refuseGateJsonOverInput(g, report, "--report");
885
+ // ⟨0.28⟩ the expanded report set (and its sidecars), exactly as the single-sink path asks it
886
+ // above — a duplicate must not smuggle an expanded input past the guard.
887
+ for (const f of expandedInputs) refuseGateJsonOverInput(g, f, "a file this gate reads —");
888
+ refuseGateJsonOverInput(g, process.env.CANDOR_POLICY, "CANDOR_POLICY");
889
+ refuseGateJsonOverInput(g, process.env.CANDOR_CONFIG, "CANDOR_CONFIG");
890
+ for (const [p2, label] of configDeclaredInputs()) refuseGateJsonOverInput(g, p2, label);
891
+ refuseGateJsonAtConfig(g);
892
+ }
893
+ if (namedSinks.length > 1) {
894
+ const list = namedSinks.join(", ");
895
+ console.error(`candor-ts-query: --gate-json given more than once (${list}) — refusing (exit 2). A `
896
+ + `gate publishes ONE verdict. Naming two sinks says where it goes twice, and the reader of the `
897
+ + `path that loses cannot tell it lost. Name one, or run the gate twice.`);
898
+ const doc = JSON.stringify(refusalVerdict(SPEC_VERSION,
899
+ `--gate-json was given more than once (${list}) — a run publishes one verdict to one sink`, null), null, 1);
900
+ for (const g of namedSinks) {
901
+ if (g === "-") { globalThis.__candorGateVerdictWritten = true; console.log(doc); continue; }
902
+ try { writeSinkAtomic(g, doc + "\n"); }
903
+ catch (e) { console.error(`candor-ts-query: could not write the refusal to --gate-json ${g} (${e.message})`); }
904
+ }
905
+ process.exit(2);
906
+ }
907
+ if (gate !== "-") armQueryGateJson(gate);
908
+ // …AND THE STREAM'S ANALOG OF ARMING — now installed at the top of this block, see the note there. `armQueryGateJson` writes a fail-closed placeholder to a
909
+ // FILE; a stream cannot hold one, so the equivalent is a hook that emits the refusal on any
910
+ // exit-2 path that has not already written a verdict.
911
+ //
912
+ // Without it, this verb exited 2 during ARGUMENT PARSING with stdout EMPTY, while the same verb
913
+ // refusing later from inside the gate streamed the document — the same operator mistake, two
914
+ // answers, decided by how early it was caught. A machine consumer reading an empty stream after
915
+ // exit 2 cannot tell it from a clean gate.
916
+ //
917
+ // Measured at the 0.27 go/no-go: java, swift and ts all had this hole on the `gate` verb, and
918
+ // PART 36's stream rows never reached it because every one of them runs the SCAN route. The row
919
+ // that catches it is now there.
920
+ }
921
+ }
922
+ // SPEC §3.2 ⟨0.28⟩: "given no value" MEANS the next token is flag-shaped — consuming it as a filename
923
+ // made this diagnostic unreachable and reinterpreted the command line: `--policy --gate-json -` read
924
+ // *policy = the file named `--gate-json`* and diagnosed the displaced `-` as an "unexpected argument".
925
+ // The sink the operator named is STILL a sink: the pre-pass above leaves a flag-shaped token live, so
926
+ // `--gate-json -` after the broken flag installed the stream hook (and a file sink was armed
927
+ // fail-closed) BEFORE this refusal fires — the exits below inherit that, like every other exit-2 in
928
+ // this loop. A bare `-` stays a value; `./--weird` spells a file genuinely named like a flag.
929
+ const flagShapedValue = (v) => v !== undefined && v !== "-" && v.startsWith("-") && v.length > 1;
443
930
  for (let i = 0; i < rawArgs.length; i++) {
444
931
  const a = rawArgs[i];
445
932
  if (a === "--report") {
933
+ if (flagShapedValue(rawArgs[i + 1])) { console.error(`candor-ts: --report was given no value — the next token '${rawArgs[i + 1]}' is a flag, not a locator (a path really named that is spelled ./${rawArgs[i + 1]})\n ${usageLine}`); process.exit(2); }
446
934
  if (i + 1 >= rawArgs.length) { console.error(`candor-ts: --report requires a <locator> value (a directory, a .json report path, or a prefix)\n ${usageLine}`); process.exit(2); }
447
935
  reportLocator = rawArgs[++i]; continue;
448
936
  }
449
937
  if (a === "--policy") {
938
+ if (flagShapedValue(rawArgs[i + 1])) { console.error(`candor-ts: --policy was given no value — the next token '${rawArgs[i + 1]}' is a flag, not a path (a file really named that is spelled ./${rawArgs[i + 1]})\n ${usageLine}`); process.exit(2); }
450
939
  if (i + 1 >= rawArgs.length) { console.error(`candor-ts: --policy requires a <file> value\n ${usageLine}`); process.exit(2); }
451
940
  policyFile = rawArgs[++i]; continue;
452
941
  }
@@ -469,8 +958,18 @@ function resolveGateReportVerb(rawArgs) {
469
958
  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
959
  process.exit(2);
471
960
  }
961
+ // ARMING MOVED ABOVE THE FLAG LOOP (SPEC §3.3.1 ⟨0.27⟩).
962
+ //
963
+ // It used to sit here, and the comment justified it with a ⟨0.24⟩ ruling of my own: "a USAGE error was
964
+ // never a gate invocation, so it must write NOTHING". SPEC §3.3 says the opposite in terms — it names
965
+ // an unknown flag as a broken-gate-config exit-2 cause, and §3.1 adds that "if `--gate-json` was
966
+ // requested and the run exits 2 for ANY reason, a fail-closed document is written", calling a
967
+ // carve-out "a fail-open path with a reason attached". The ruling I built here was that carve-out, and
968
+ // the test pinning it pinned a reading the spec had already superseded. The stale green does not care
969
+ // that the operator's shell also failed.
970
+ const _policy = resolvePolicy(policyFile, null).policyFile;
472
971
  const prefix = requireReport(reportLocator !== null ? locatorToPrefix(reportLocator) : discoverReportPrefix());
473
- return { prefix, policyFile: resolvePolicy(policyFile, null).policyFile, gateJsonPath, json };
972
+ return { prefix, policyFile: _policy, gateJsonPath, json };
474
973
  }
475
974
 
476
975
  /**
@@ -678,7 +1177,13 @@ switch (cmd) {
678
1177
  // A missing/empty <query> is a LOUD usage error (exit 2, like candor-java) — never a silently-empty
679
1178
  // `[]` at exit 0, which reads as an authoritative "no such function" over a question never asked.
680
1179
  if (!q) { console.error("usage: candor-ts-query show <query> [--report <locator>] [--json]"); process.exit(2); }
681
- put(args, coreShow(loadReportOrDie(prefix), q), P.show);
1180
+ // ⟨0.28⟩ RUNG A — `show`'s pinned shape is a TOP-LEVEL ARRAY, so over a hedging report it emits the
1181
+ // CAVEAT DOCUMENT INSTEAD (see `putCaveatInstead`). Before this it took plain `put` and had no
1182
+ // completeness reader at all: measured, `[]` at exit 0 over a report whose own manifest names a file
1183
+ // candor could not read — the only verb of the six with no caveat on EITHER channel.
1184
+ putCaveatInstead(args, coreShow(loadReportOrDie(prefix), q), P.show, reportCompleteness(prefix),
1185
+ `the function(s) shown below are only those candor could SEE match \`${q}\``,
1186
+ "A function in an unread unit is ABSENT from the report, so it cannot be shown here at all. Re-scan for a complete answer.");
682
1187
  break;
683
1188
  }
684
1189
  case "where": {
@@ -697,7 +1202,11 @@ switch (cmd) {
697
1202
  if (!KNOWN_EFFECTS.includes(eff) && !new Set(fnsW.flatMap((e) => e.inferred || [])).has(eff)) {
698
1203
  console.error(`candor-ts-query where: unknown effect '${eff}' (known: ${KNOWN_EFFECTS.join(", ")})`); process.exit(2);
699
1204
  }
700
- put(args, coreWhere(fnsW, eff), P.where);
1205
+ // ⟨0.28⟩ SPEC §2 — `{"directly":[],"inherited":[]}` is one of the four empty answers the clause names
1206
+ // by measurement. The caveat rides the SAME document (see `putAnswer`); the exit code does not move.
1207
+ putAnswer(args, coreWhere(fnsW, eff), P.where, reportCompleteness(prefix),
1208
+ `the function(s) named below are only those candor could SEE perform ${eff}`,
1209
+ `A function in an unread unit is ABSENT from the report, so it cannot appear in either list. Re-scan for a complete answer.`);
701
1210
  break;
702
1211
  }
703
1212
  case "callers": {
@@ -708,12 +1217,64 @@ switch (cmd) {
708
1217
  // A missing/empty <query> is a LOUD usage error (exit 2, like candor-java) — never an empty
709
1218
  // {of:[],direct:[],transitive:[]} at exit 0 (reads as "nothing reaches it" for a fn never named).
710
1219
  if (!q) { console.error("usage: candor-ts-query callers <query> [--include-unknown] [--report <locator>] [--json]"); process.exit(2); }
1220
+ // ⟨0.28⟩ PREFER THE §2.2 SIDECAR, FALL BACK TO THE REPORT'S OWN `calls` EDGES — rust's exact split
1221
+ // (callers.rs). The sidecar records EVERY function including pure ones, so it is the COMPLETE graph;
1222
+ // the report's embedded edges are effect-relevant only, but a report is a §3.3.1 locator in its own
1223
+ // right (a single hand-copied report.json) and rust/java answer real callers over it at exit 0 while
1224
+ // this engine refused `unanswerable` exit 2 — a one-engine divergence that broke a cross-engine
1225
+ // script. The fail-closed arm below now fires only when the graph is GENUINELY absent (no sidecar
1226
+ // AND no embedded edges — the armed pair), which narrows when it fires without removing it.
711
1227
  const cg = loadCallgraph(prefix);
712
- const cres = includeUnknown ? callersFrontier(cg, loadReportOrDie(prefix), loadHierarchy(prefix), q) : coreCallers(cg, q);
1228
+ const completeGraph = Object.keys(cg).length > 0;
1229
+ const graph = completeGraph ? cg : reportCallsGraph(loadReportOrDie(prefix));
1230
+ const cres = includeUnknown ? callersFrontier(graph, loadReportOrDie(prefix), loadHierarchy(prefix), q) : coreCallers(graph, q);
713
1231
  // A nonexistent function is a LOUD error (exit 2), like path/impact — never an empty {of:[],direct:[],
714
1232
  // transitive:[]} at exit 0, which reads as an authoritative "nothing calls it" for a fn that doesn't exist
715
1233
  // (corpus-audit #3). Gated on a NON-empty callgraph so a missing sidecar isn't misreported as "no such fn".
716
- if (Object.keys(cg).length > 0 && cres.of.length === 0) {
1234
+ if (cres.of.length === 0) {
1235
+ // ⟨0.28⟩ UNANSWERABLE MUST REACH THE MACHINE CHANNEL (SPEC §3.3.1). This branch printed
1236
+ // `{"of":[],"direct":[],"transitive":[]}` at exit 0 while the human arm said "no function in the
1237
+ // call graph matches that name" — and BOTH readings are a determined negative, which is worse than
1238
+ // the reference engines' split: rust/java's human arm at least said "no call graph in the report".
1239
+ // A consumer reading `direct`, or defaulting it (the fail-open idiom ⟨0.24⟩ names on every key in
1240
+ // this format), is told NOBODY CALLS this function: a blast radius of "safe to edit" over a pair
1241
+ // whose honest answer is "this run judged nothing". The ⟨0.28⟩ sidecar rule (scan.mjs, which
1242
+ // deletes the §2.2 sidecars with an armed report) did not dig this hole — an absent sidecar has
1243
+ // always answered this way — it aimed traffic at it by making no-sidecar the STANDARD state after
1244
+ // a failed run.
1245
+ //
1246
+ // BOTH CHANNELS FAIL CLOSED: the document names itself unanswerable AND the exit is non-zero.
1247
+ // §3.3.1 permits either, but each ALONE leaves a naive reader exposed — the key alone still lets
1248
+ // `d.direct ?? []` read as a determined negative, and the exit alone leaves a JSON consumer
1249
+ // holding an empty document. Same shape as rust `358e117` / java `927252c`.
1250
+ //
1251
+ // ONLY THIS ARM, and the control separation is the load-bearing half. An EMPTY graph means there
1252
+ // is no call graph AT ALL (no §2.2 sidecar, and ⟨0.24⟩ rules an empty/unparseable one identical to
1253
+ // an absent one), which is the unanswerable case. A function with genuinely no callers over a REAL
1254
+ // graph still answers `direct: []` at exit 0 below — a determined negative, and withdrawing it
1255
+ // would be the mirror defect (the ⟨0.24⟩ count-0 lesson) — and a name absent from a real graph
1256
+ // still exits 2 as "no function matching", because a graph WAS read there.
1257
+ //
1258
+ // ONE SITE, MEASURED NOT ASSUMED: the rust reference had this branch twice (callers_via_callgraph
1259
+ // + the frontier variant) and warned to grep for both; candor-ts folds `--include-unknown` through
1260
+ // this same block via `callersFrontier`, so `grep -n 'coreCallers\|callersFrontier' query.mjs`
1261
+ // finds exactly one CALL site (plus the import). Verified through the flag as well: `--json
1262
+ // --include-unknown` discloses too. The MCP surface was measured, not assumed, and already fails
1263
+ // closed — `candor_callers` over an armed pair returns `isError: true` from mcp.mjs's fn-existence
1264
+ // guard, which unions the callgraph keys with the report's fns, both empty here.
1265
+ if (Object.keys(graph).length === 0) {
1266
+ const why = "no call graph in the report — the §2.2 sidecar is absent, so who calls this function is UNANSWERABLE, not empty (SPEC §3.3.1 ⟨0.28⟩)";
1267
+ put(args, { of: [q], unanswerable: why }, () => console.log(`candor: ${why}`));
1268
+ process.exit(2);
1269
+ }
1270
+ // ⟨0.28⟩ Only the COMPLETE graph (the sidecar) can prove a name absent. Over the effect-only
1271
+ // fallback a miss is INCONCLUSIVE — a pure leaf called only by pure fns is simply invisible there —
1272
+ // so the answer is empty at exit 0, never a fabricated "no such function" (rust corpus-audit #5,
1273
+ // byte-matching its two arms: `{}` on the machine channel, the re-scan pointer on the human one).
1274
+ if (!completeGraph) {
1275
+ put(args, {}, () => console.log(`candor: no caller of \`${q}\` in the effect-relevant graph (the full call-graph sidecar is absent; re-scan with --out to see pure-only callers).`));
1276
+ break;
1277
+ }
717
1278
  console.error(`candor-ts-query callers: no function matching '${q}' in the call graph`); process.exit(2);
718
1279
  }
719
1280
  put(args, cres, P.callers);
@@ -722,7 +1283,13 @@ switch (cmd) {
722
1283
  case "map": {
723
1284
  // Shared query-core — the CLI and MCP `candor_map` are one implementation (see `where` above).
724
1285
  const { prefix } = resolveReportVerb(args, 0);
725
- put(args, coreMap(loadReportOrDie(prefix)), P.map);
1286
+ // ⟨0.28⟩ `map` answers `{}`, which SPEC §2 calls the STRONGEST determined negative there is: every key
1287
+ // a consumer reads defaults to empty, so `d["db"] ?? {}` cannot tell an empty map from an unexamined
1288
+ // one. RUNG A: its top level is a USER NAMESPACE, so the caveat REPLACES the module map rather than
1289
+ // merging into it — the merged shape displaced a real module row to make space for the hedge.
1290
+ putCaveatInstead(args, coreMap(loadReportOrDie(prefix)), P.map, reportCompleteness(prefix),
1291
+ "the module rows below cover only the source candor read",
1292
+ "A module living wholly in an unread unit is MISSING from this overview, and one that IS listed may be missing functions. Re-scan for a complete map.");
726
1293
  break;
727
1294
  }
728
1295
  case "containment": {
@@ -752,10 +1319,20 @@ switch (cmd) {
752
1319
  process.exit(2);
753
1320
  }
754
1321
  const r = coreContainment(loadReportOrDie(prefix), baseFns);
755
- put(args, r, P.containment);
1322
+ // ⟨0.28⟩ BOTH SIDES' MANIFESTS. This answer is a DIFFERENCE, so it is unsound if either side is
1323
+ // partial, and in OPPOSITE directions: a leak living in an unread file of the CURRENT tree is missed
1324
+ // (a false all-clear at exit 0), while one living in an unread file of the BASELINE reads as newly
1325
+ // appeared (a fabricated leak, at exit 1). A wholly-empty baseline already fails closed above; a
1326
+ // baseline that loaded but declares `unanalyzed` is the case that reached here silently.
1327
+ putAnswer(args, r, P.containment,
1328
+ absorbCompleteness(reportCompleteness(prefix), reportCompleteness(basePrefix)),
1329
+ "the leak set below is a difference over only the code candor read on BOTH sides",
1330
+ "An unread unit of the current tree hides a leak; an unread unit of the baseline manufactures one. Re-scan both before moving the ratchet.");
756
1331
  process.exit(r.leaks.length ? 1 : 0);
757
1332
  }
758
- put(args, coreContainment(loadReportOrDie(prefix)), P.containment);
1333
+ putAnswer(args, coreContainment(loadReportOrDie(prefix)), P.containment, reportCompleteness(prefix),
1334
+ "the containment scores below cover only the boundary effects candor could see",
1335
+ "A boundary effect in an unread unit is in no layer's count, so a dispersed effect can score as contained. Re-scan for a complete picture.");
759
1336
  break;
760
1337
  }
761
1338
  case "diff": {
@@ -800,9 +1377,13 @@ switch (cmd) {
800
1377
  const roots = fns.filter((e) => e.entryPoint);
801
1378
  const byEff = {};
802
1379
  for (const e of roots) for (const x of e.inferred) (byEff[x] ??= []).push(e.fn);
803
- put(args, { entryPoints: roots.length,
1380
+ // ⟨0.28⟩ `{"entryPoints":0,"effects":{}}` asserts *the program performs no effect at runtime* — the
1381
+ // strongest claim in this binary — and over a report that judged nothing it rests on no evidence.
1382
+ putAnswer(args, { entryPoints: roots.length,
804
1383
  effects: Object.fromEntries(Object.entries(byEff).sort()
805
- .map(([k, v]) => [k, { count: v.length, via: v.sort() }])) }, P.reachable);
1384
+ .map(([k, v]) => [k, { count: v.length, via: v.sort() }])) }, P.reachable, reportCompleteness(prefix),
1385
+ "the runtime effect set below is a union over only the entry points candor could see",
1386
+ "An entry point in an unread unit contributes NOTHING to this union, and neither does any effect it reaches. Re-scan before treating this as the program's runtime surface.");
806
1387
  break;
807
1388
  }
808
1389
  case "impact": {
@@ -812,7 +1393,60 @@ switch (cmd) {
812
1393
  // A missing/empty <query> is a LOUD usage error (exit 2, like candor-java) — never an
813
1394
  // affectedCount:0 blast radius at exit 0 for a function that was never named.
814
1395
  if (!q) { console.error("usage: candor-ts-query impact <query> [--report <locator>] [--json]"); process.exit(2); }
815
- put(args, coreImpact(loadReportOrDie(prefix), loadCallgraph(prefix), q), P.impact);
1396
+ const impFns = loadReportOrDie(prefix);
1397
+ // ⟨0.28⟩ Sidecar first, then the report's embedded `calls` edges — rust's `impact` runs on the
1398
+ // report's edges ALONE (callers.rs cmd_impact), so a sidecar-less report must answer here too; see
1399
+ // the callers verb. The unanswerable arm below keeps the genuinely-absent case (the armed pair).
1400
+ let impCg = loadCallgraph(prefix);
1401
+ if (Object.keys(impCg).length === 0) impCg = reportCallsGraph(impFns);
1402
+ // ⟨0.28⟩ UNANSWERABLE MUST REACH THE MACHINE CHANNEL (SPEC §3.3.1), the `callers` fix (5091905) on
1403
+ // the verb its own commit message named as still open. Over an armed pair — report armed to the
1404
+ // ⟨0.21⟩ Row-1 empty, §2.2 sidecars deleted, the STANDARD post-failure state since the ⟨0.28⟩
1405
+ // sidecar rung — this printed `{"fn":…,"affectedCount":0,"affected":[],"entryPoints":[]}` at exit 0.
1406
+ // `affectedCount: 0` is the blast-radius verb's strongest claim: NOTHING CALLS THIS, SAFE TO CHANGE.
1407
+ // With no call graph the engine has not judged that; it has judged nothing. rust/java exit 2 here.
1408
+ //
1409
+ // BOTH CHANNELS FAIL CLOSED (the exit alone leaves a JSON consumer holding a document; the key alone
1410
+ // lets `d.affectedCount ?? 0` read as a determined negative), and the empty ANSWER keys are OMITTED
1411
+ // rather than zeroed, so there is nothing left for a defaulting reader to mistake for a finding.
1412
+ //
1413
+ // THE GATE IS THE EMPTY GRAPH, NOT THE EMPTY ANSWER, and that separation is the load-bearing half.
1414
+ // `impact` resolves its target over the CALLGRAPH keys, so an absent graph makes every answer a
1415
+ // vacuous 0 — that is the unanswerable case. A function that genuinely affects nothing over a REAL
1416
+ // graph still answers `affectedCount: 0` at exit 0 below: a determined negative, and withdrawing it
1417
+ // would be the mirror defect (the ⟨0.24⟩ count-0 lesson, where the plausible fix withdrew 104 real
1418
+ // claims to catch 6). The MCP `candor_impact` was measured, not assumed, and already fails closed —
1419
+ // its fn-existence guard unions the callgraph keys with the report's, both empty here.
1420
+ if (Object.keys(impCg).length === 0) {
1421
+ const why = "no call graph in the report — the §2.2 sidecar is absent, so what this function affects is UNANSWERABLE, not empty (SPEC §3.3.1 ⟨0.28⟩)";
1422
+ put(args, { fn: q, unanswerable: why }, () => console.log(`candor: ${why}`));
1423
+ process.exit(2);
1424
+ }
1425
+ // BAD TARGET → LOUD exit 2 (corpus-audit #3), the rule `callers` applies one verb up and the rule
1426
+ // rust/java ALREADY apply here — measured four-way, array-quoted, on a valid report: rust and java
1427
+ // both exit 2 with `impact: no function matching '…'`, and candor-ts was the ONE arm that printed
1428
+ // `{"fn":"zzz_no_such_fn","affectedCount":0,"affected":[],"entryPoints":[]}` at exit 0. On the
1429
+ // BLAST-RADIUS verb, `affectedCount: 0` is the strongest claim in the vocabulary — "nothing calls
1430
+ // this, safe to change" — and here it was asserted about a function that does not exist. A typo in a
1431
+ // CI script or an agent's query reads back as reassurance (§4); the truth is the question was never
1432
+ // posed. This is the same shape conformance §17 (1b) already pins for `where`/`callers`, on the two
1433
+ // verbs whose comment there wrongly assumed "path/impact already gate".
1434
+ //
1435
+ // A DIFFERENT CONDITION FROM THE UNANSWERABLE GATE ABOVE, and keeping them distinguishable is the
1436
+ // load-bearing half. That one is "there is no call graph" — nothing was judged, so the machine
1437
+ // channel carries an `unanswerable` key. This one is "the graph is fine and the NAME does not
1438
+ // resolve" — a USAGE error, reported the way `where`/`callers`/`show` report one: stderr, and NO
1439
+ // `unanswerable` key, because the run judged plenty. Hence the ORDER: unanswerable first, so an
1440
+ // absent sidecar is never misreported as a bad name (a wrong cause reads as an answer, 5091905).
1441
+ //
1442
+ // NOT THE OTHER DIRECTION: a REAL fn that genuinely affects nothing still answers `affectedCount: 0`
1443
+ // at exit 0 below. "No such function" and "that function affects nothing" must not collapse into one
1444
+ // another — withdrawing the determined negative to catch the fabricated one is the ⟨0.24⟩ count-0
1445
+ // mirror defect, where the plausible fix withdrew 104 real claims to catch 6.
1446
+ if (coreMatches(knownFnNames(impCg, impFns), q).length === 0) {
1447
+ console.error(`candor-ts-query impact: no function matching '${q}'`); process.exit(2);
1448
+ }
1449
+ put(args, coreImpact(impFns, impCg, q), P.impact);
816
1450
  break;
817
1451
  }
818
1452
  case "blindspots": {
@@ -821,10 +1455,18 @@ switch (cmd) {
821
1455
  const { prefix } = resolveReportVerb(args, 0);
822
1456
  const ci = args.indexOf("--class");
823
1457
  const classFilter = ci >= 0 ? args[ci + 1] : null; // ⟨0.20⟩ drill-down by reason class
1458
+ // ⟨0.28⟩ THE SHARPEST OF THE SIX: `{"sources":[],"totalUnknown":0}` reports *no blind spots* out of a
1459
+ // report whose own manifest names a file candor could not read — the unread file is the blind spot, and
1460
+ // it contributes no entry, so nothing in the computation below can see it. BOTH forms, because
1461
+ // `--stats` is the same claim counted differently (the sibling-route habit: a rule applied where the
1462
+ // work is and never to the arm one line down).
1463
+ const bsComp = reportCompleteness(prefix);
1464
+ const bsSoWhat = "the Unknown sources below are only those rooted in a call candor could see";
1465
+ const bsTail = "An unread unit contributes no entry at all, so its own Unknowns are not counted here and cannot be. Re-scan before treating this as the blind-spot inventory.";
824
1466
  if (args.includes("--stats")) { // ⟨0.20⟩ the reason-class distribution, not the source list
825
- put(args, coreBlindspotsStats(loadReportOrDie(prefix), classFilter), P.blindspotsStats);
1467
+ putAnswer(args, coreBlindspotsStats(loadReportOrDie(prefix), classFilter), P.blindspotsStats, bsComp, bsSoWhat, bsTail);
826
1468
  } else {
827
- put(args, coreBlindspots(loadReportOrDie(prefix), loadCallgraph(prefix), classFilter), P.blindspots);
1469
+ putAnswer(args, coreBlindspots(loadReportOrDie(prefix), loadCallgraph(prefix), classFilter), P.blindspots, bsComp, bsSoWhat, bsTail);
828
1470
  }
829
1471
  break;
830
1472
  }
@@ -880,6 +1522,9 @@ switch (cmd) {
880
1522
  // The header names the report's §2 envelope `package` — meaningful and locator-independent, so every
881
1523
  // engine and every --report form print the SAME crate. Falls back to the prefix basename.
882
1524
  const crateName = reportPackage(prefix) ?? path.basename(prefix);
1525
+ // ⟨0.28⟩ read ONCE, before either channel branches, so the JSON half and the prose half cannot end up
1526
+ // triggered by two different readings of the same bytes (see `putAnswer`; tour renders its own output).
1527
+ const tourComp = reportCompleteness(prefix);
883
1528
  if (wantJson) {
884
1529
  // Pure JSON to STDOUT: {"reaches":[{effect,fn,hops,loc,score,source}, …]} — ALPHABETICAL keys, the
885
1530
  // same order Rust+Swift emit (loc is the SOURCE's file:line, "" when absent).
@@ -893,9 +1538,17 @@ switch (cmd) {
893
1538
  const teff = fns.filter((e) => (e.inferred ?? []).length > 0).length;
894
1539
  const tunk = fns.filter((e) => (e.inferred ?? []).includes("Unknown")).length;
895
1540
  if (teff > 0 && tunk * 3 >= teff) out.unknown = { count: tunk, total: teff };
896
- console.log(JSON.stringify(out));
1541
+ // ⟨0.28⟩ THE SAME ARGUMENT AS `unknown` ABOVE, ONE CAUSE OVER — and the ⅓ threshold cannot reach this
1542
+ // one. That field exists because a bare `{"reaches":[]}` read as clean to the agent loop over a
1543
+ // mostly-Unknown graph; a report that judged nothing, or that names a file it could not read, yields
1544
+ // the IDENTICAL empty array from strictly less evidence, and an unread unit contributes no entry, so
1545
+ // it moves neither `unknown` nor `total`. Spread last, `{}` on a complete report.
1546
+ console.log(JSON.stringify({ ...out, ...completenessFields(tourComp) }));
897
1547
  break;
898
1548
  }
1549
+ incompleteAnswerNote(tourComp,
1550
+ "the reaches below are ranked over only the call graph candor could see",
1551
+ "A surprising reach whose path runs through an unread unit is not ranked here at all, and cannot be. Re-scan for the full tour.");
899
1552
  if (finds.length === 0) {
900
1553
  // Effectful-but-nothing-surprising vs genuinely-pure both land here; the honest line is the useful
901
1554
  // answer (never a manufactured surprise) — mirrors the scan-note fallback + the Rust engine. BUT never
@@ -907,9 +1560,14 @@ switch (cmd) {
907
1560
  if (teff > 0 && tunk * 3 >= teff) {
908
1561
  console.log(
909
1562
  `candor: no surprising reaches — but ${tunk} of ${teff} function(s) are Unknown `
910
- + `(unresolved calls; their transitive effects are NOT analyzed). Run \`candor blindspots\`; `
911
- + `a missing tsconfig.json or unresolvable imports are the usual cause.`,
1563
+ + `(unresolved calls; their transitive effects are NOT analyzed). Run \`candor blindspots\` — `
1564
+ + `the report records a reason for each.`,
912
1565
  );
1566
+ } else if (mustHedge(tourComp)) {
1567
+ // "nothing hidden" is the single most reassuring sentence this binary prints, and over these bytes
1568
+ // it is the false all-clear in plain English. The ⅓-Unknown branch above cannot catch it, for the
1569
+ // reason given on the JSON arm.
1570
+ console.log(`candor: nothing hidden in what candor COULD SEE ${NOT_A("nothing is hidden")}.`);
913
1571
  } else {
914
1572
  console.log("candor: nothing hidden — every effect sits where its name says it should.");
915
1573
  }
@@ -954,9 +1612,26 @@ switch (cmd) {
954
1612
  // read as total), plus `coverageDelta` when the baseline names different blind packages. Both
955
1613
  // OMITTED when nothing applies, so a coverage-free comparison is byte-identical to ⟨0.14⟩.
956
1614
  // Shared with the MCP `candor_gains` tool (gainsCoverage — the parity rule).
1615
+ // ⟨0.28⟩ SPEC §2 — …AND THE ⟨0.21⟩ MANIFEST TRAVELS ON THE SAME TERMS, WHICH IS THE STRONGER CAVEAT.
1616
+ // The line above carries `coverage` because "no gains over an uncovered dep reads clean with false
1617
+ // confidence"; measured, this same call dropped `unanalyzed` — *I could not read a file of your own
1618
+ // code* — and `analyzed.count: 0` — *I judged nothing at all*. BOTH SIDES, disclosed separately
1619
+ // (`gainsCompletenessFields`): an incomplete CURRENT means the gained set may be SHORT, an incomplete
1620
+ // BASELINE means the comparison floor is soft and the existing/new split unreliable. Not `put`,
1621
+ // because ONE trigger must reach BOTH channels — the JSON-only half is the mutant that survived a
1622
+ // whole suite in candor-rust. Verdict-preserving: the exit below is untouched.
957
1623
  const gainsResult = coreGains(loadReportOrDie(curPrefix), loadReportOrDie(basePrefix), loadCallgraph(basePrefix));
958
- put(args, { baseline_version: gbv ?? "", engine_version: gv ?? "",
959
- ...gainsResult, ...gainsCoverage(curPrefix, basePrefix) }, P.gains);
1624
+ const gCur = reportCompleteness(curPrefix), gBase = reportCompleteness(basePrefix);
1625
+ const gDoc = { baseline_version: gbv ?? "", engine_version: gv ?? "",
1626
+ ...gainsResult, ...gainsCoverage(curPrefix, basePrefix), ...gainsCompletenessFields(gCur, gBase) };
1627
+ if (wantJsonOut(args)) emit(gDoc);
1628
+ else {
1629
+ incompleteAnswerNote(gCur, "the gained set below names only effects candor read in the CURRENT tree and may be SHORT",
1630
+ "An effect introduced in an unread unit of the current tree is not in the list below.");
1631
+ incompleteAnswerNote(gBase, "the BASELINE half of this comparison is itself partial and the floor it sets is soft",
1632
+ "An effect living in an unread unit of the baseline reads as NEWLY gained here — the existing/new origin split is unreliable until the baseline is re-scanned.");
1633
+ P.gains(gDoc, mustHedge(gCur) || mustHedge(gBase));
1634
+ }
960
1635
  // Advisory by default (exit 0 — gains is a diff view); `--strict` fails on ANY gained effect so a
961
1636
  // supply-chain CI job can require a bump introduce no new capability (mirrors `unverified --strict`).
962
1637
  process.exit(strict && (gainsResult.gained?.length ?? 0) > 0 ? 1 : 0);
@@ -973,7 +1648,54 @@ switch (cmd) {
973
1648
  // "does not perform undefined" at exit 0 — a false all-clear over a question that was never posed.
974
1649
  if (!fn || !eff) { console.error("usage: candor-ts-query path <fn> <Effect> [--report <locator>] [--json]"); process.exit(2); }
975
1650
  const fns = loadReportOrDie(prefix);
976
- const cg = loadCallgraph(prefix);
1651
+ // ⟨0.28⟩ Sidecar first, then the report's embedded `calls` edges (rust's `path` runs on the report's
1652
+ // edges alone — see the callers verb). The unanswerable arm below keeps the genuinely-absent case.
1653
+ let cg = loadCallgraph(prefix);
1654
+ if (Object.keys(cg).length === 0) cg = reportCallsGraph(fns);
1655
+ // ⟨0.28⟩ UNANSWERABLE MUST REACH THE MACHINE CHANNEL (SPEC §3.3.1) — the `impact` argument above,
1656
+ // on the verb that answers the OTHER direction. Over an armed pair this emitted
1657
+ // `{"effect":…,"fn":…,"path":[]}` at exit 0: "there is no route by which this function reaches that
1658
+ // effect", which is precisely the reassurance a reader asks `path` for, over a run that traced
1659
+ // nothing. rust/java exit 2 here.
1660
+ //
1661
+ // BEFORE THE SPLIT, so BOTH arms fail closed, and the human arm is REPAIRED not merely forwarded —
1662
+ // it exited 2 already, but said "no function matching 'f'", a determined negative about the NAME
1663
+ // when the truth is about the GRAPH. (Its other two exits are worse: with a valid report and no
1664
+ // sidecar, `path` reached "does not perform Fs" / "not statically traceable" at exit 0.) Same
1665
+ // correction `callers` needed in 5091905, for the same reason: a wrong cause reads as an answer.
1666
+ //
1667
+ // THE GATE IS THE EMPTY GRAPH, NOT THE EMPTY PATH. `path` resolves its start over the CALLGRAPH
1668
+ // keys, so with no graph every answer is a vacuous `[]`. A function that genuinely does not reach
1669
+ // the effect over a REAL graph still answers `path: []` at exit 0 below — the graph SAID no, and
1670
+ // withdrawing that is the ⟨0.24⟩ count-0 mirror defect. "The graph says no" and "there is no graph"
1671
+ // must stay distinguishable. MCP `candor_path` was measured and already fails closed (`isError`).
1672
+ if (Object.keys(cg).length === 0) {
1673
+ const why = "no call graph in the report — the §2.2 sidecar is absent, so whether this function reaches that effect is UNANSWERABLE, not a determined `no` (SPEC §3.3.1 ⟨0.28⟩)";
1674
+ if (wantJson) emit({ effect: eff, fn, unanswerable: why });
1675
+ else console.log(`candor: ${why}`);
1676
+ process.exit(2);
1677
+ }
1678
+ // BAD TARGET → LOUD exit 2 (corpus-audit #3), the `impact` argument above on the verb that answers
1679
+ // the OTHER direction, and the ONE-ENGINE divergence was HALF a divergence here: the HUMAN arm
1680
+ // (renderPathHuman) has always exited 2 with "no function matching", while `--json` printed
1681
+ // `{"effect":"Net","fn":"zzz_no_such_fn","path":[]}` at exit 0 — "there is no route by which this
1682
+ // function reaches that effect", the precise reassurance a reader asks `path` for, about a function
1683
+ // that does not exist. The MACHINE arm being the lenient one is the worse half: the human at least
1684
+ // saw an error. rust/java/swift all exit 2 on both arms (measured).
1685
+ //
1686
+ // HOISTED ABOVE THE SPLIT so ONE gate covers both arms — the divergence existed because the check
1687
+ // lived inside the human renderer only, and a rule that lives on one route is a rule the sibling
1688
+ // route does not have. renderPathHuman keeps its own resolution (over the REPORT entries, where
1689
+ // `inferred` lives) as the stricter downstream case; this gate refuses only what neither set knows.
1690
+ //
1691
+ // DISTINCT FROM THE UNANSWERABLE GATE ABOVE and ordered after it: "there is no graph" carries the
1692
+ // `unanswerable` key in the machine channel, "the graph is fine and the name is not in it" is a
1693
+ // usage error on stderr. NOT the mirror: a real fn that genuinely does not reach the effect still
1694
+ // answers `path: []` at exit 0 below — the graph SAID no, and "the graph says no", "there is no
1695
+ // graph" and "there is no such function" are three answers, not one.
1696
+ if (coreMatches(knownFnNames(cg, fns), fn).length === 0) {
1697
+ console.error(`candor-ts-query path: no function matching '${fn}'`); process.exit(2);
1698
+ }
977
1699
  if (wantJson) emit(corePath(fns, cg, fn, eff)); // conformance PART 5 shape — UNCHANGED
978
1700
  else {
979
1701
  // The accepted 0.11 default change (the human chain replaced JSON as the no-flag output) gets a
@@ -1017,10 +1739,21 @@ switch (cmd) {
1017
1739
  // consulted BEFORE an edit, where the alternative is the operator guessing. The exit is UNCHANGED:
1018
1740
  // this verb has no `--strict`, §3.2 rules no exit for it, and inventing one is the failure mode the
1019
1741
  // clause it lives beside exists to prevent.
1020
- const wunan = reportUnanalyzed(prefix);
1742
+ const wcomp = reportCompleteness(prefix);
1743
+ const wunan = wcomp.unanalyzed;
1021
1744
  if (wunan.length)
1022
1745
  console.error(`candor-ts: whatif is NOT a complete answer — the report declares ${wunan.length} unit(s) candor could not analyze (disclosed under \`unanalyzed\`); \`ok\` is omitted because neither value is a statement the input licenses`);
1023
- emit(advisoryAnswer(r, wunan));
1746
+ // ⟨0.28⟩ …and the count-0 cause, which reaches here through a LIVE §2.2 sidecar: the target resolves
1747
+ // over the call graph, so this verb answers `ok: true` where the report-only verbs exit 2 on the name.
1748
+ // MEASURED exactly so — the pre-edit gate check, green, over a report that judged nothing.
1749
+ if (wcomp.judgedNothing.length) advisoryJudgedNothingNote("whatif");
1750
+ if (wcomp.noManifest.length) advisoryNoManifestNote("whatif", wcomp.noManifest);
1751
+ if (wcomp.unreadable.length) advisoryUnreadableNote("whatif", wcomp.unreadable);
1752
+ // ⟨0.28⟩ SPEC §2 — a CONFIGURED policy that parsed to zero rules asked nothing, so the pre-edit
1753
+ // verdict AND the blast radius it qualifies are withheld in favour of the caveat document. The exit
1754
+ // is UNCHANGED (0: with no rules, `violations` was empty by construction on this path anyway).
1755
+ if (policyFile && policyAskedNothing(pol)) { emitZeroRuleCaveat("whatif", policyFile, wcomp); process.exit(0); }
1756
+ emit(advisoryAnswer(r, wunan, wcomp.judgedNothing, wcomp.unreadable, wcomp.noManifest));
1024
1757
  process.exit(r.violations.length ? 1 : 0);
1025
1758
  break; // unreachable (process.exit), but eslint can't prove it — defends against fallthrough
1026
1759
  }
@@ -1040,8 +1773,17 @@ switch (cmd) {
1040
1773
  // The sidecar is the ONLY graph a candor-ts report carries (it embeds no inline `calls`). Fail LOUD when
1041
1774
  // it's absent — never compute a degenerate empty-graph remedy that reads as a false "no clean hoist".
1042
1775
  if (!cg || Object.keys(cg).length === 0) { console.error(`candor: no call-graph sidecar for '${prefix}' — fix needs it (re-run: candor-ts <src> --out ${prefix})`); process.exit(2); }
1043
- const r = coreFix(cg, loadReportOrDie(prefix), target, eff, loadPolicyOrDie(policyFile, ptext), scopeMatches);
1776
+ const fpol = loadPolicyOrDie(policyFile, ptext);
1777
+ const r = coreFix(cg, loadReportOrDie(prefix), target, eff, fpol, scopeMatches);
1044
1778
  if (r === null) { console.error(`candor: no function matching \`${target}\` in the call graph`); process.exit(2); }
1779
+ // ⟨0.28⟩ SPEC §2 — this verb shares the policy loader with the three the clause names, and its every
1780
+ // answer is equally policy-relative: over a zero-rule policy it emitted `{"crossing": false,
1781
+ // "reason": "not-forbidden"}` at exit 0, and *not-forbidden* by a policy that forbids nothing is
1782
+ // vacuously true. Composed with the ⟨0.28⟩ `crossing` ruling, this emits NO `crossing` KEY — that key
1783
+ // is present exactly when the verb answered, and here it did not. Exit UNCHANGED (0; the
1784
+ // missing-function usage error above keeps its 2). Placed AFTER that error so a bad `fn` still
1785
+ // reports the bad `fn`.
1786
+ if (policyAskedNothing(fpol)) { emitZeroRuleCaveat("fix", policyFile, reportCompleteness(prefix)); process.exit(0); }
1045
1787
  // ⟨0.24⟩ SPEC §3.2 `4fd140c` — the printed channel for a REFUSED remedy (`refused: true`, no `crossing`
1046
1788
  // key). Without it the terminal shows a document with no plan in it and no reason for the absence.
1047
1789
  if (r.unevaluated?.length) advisoryUnevaluatedNote("fix", r.unevaluated,
@@ -1062,18 +1804,39 @@ switch (cmd) {
1062
1804
  catch { console.error(`candor: policy ${policyFile} could not be read — no fix computed`); process.exit(2); }
1063
1805
  const cg = loadCallgraph(prefix);
1064
1806
  if (!cg || Object.keys(cg).length === 0) { console.error(`candor: no call-graph sidecar for '${prefix}' — fix-gate needs it (re-run: candor-ts <src> --out ${prefix})`); process.exit(2); }
1065
- const fgr = coreFixGate(cg, loadReportOrDie(prefix), loadPolicyOrDie(policyFile, ptext), scopeMatches);
1807
+ const fgpol = loadPolicyOrDie(policyFile, ptext);
1808
+ const fgr = coreFixGate(cg, loadReportOrDie(prefix), fgpol, scopeMatches);
1066
1809
  // ⟨0.24⟩ SPEC §3.2 — see `advisoryAnswer`. Over a report declaring `unanalyzed` this OMITS `ok`, adds
1067
1810
  // the manifest, and `--strict` (the CI form) exits 2 — could-not-fully-evaluate, the same code the gate
1068
1811
  // uses for the same situation — rather than the 1 that would claim a finding or the 0 that certified.
1069
- const fgUnan = reportUnanalyzed(prefix);
1812
+ const fgComp = reportCompleteness(prefix);
1813
+ const fgUnan = fgComp.unanalyzed;
1070
1814
  if (fgUnan.length) advisoryIncompleteNote("fix-gate", fgUnan);
1815
+ // ⟨0.28⟩ …and the count-0 cause, which reaches the DOCUMENT and the PROSE but deliberately NOT the exit
1816
+ // below (see `advisoryAnswer`). Leaving it out would have let this verb print a remedy list beside
1817
+ // `ok: true` over a report that judged nothing — the same false all-clear, arriving by omission.
1818
+ if (fgComp.judgedNothing.length) advisoryJudgedNothingNote("fix-gate");
1819
+ if (fgComp.noManifest.length) advisoryNoManifestNote("fix-gate", fgComp.noManifest);
1820
+ if (fgComp.unreadable.length) advisoryUnreadableNote("fix-gate", fgComp.unreadable);
1071
1821
  // ⟨0.24⟩ SPEC §3.2 `4fd140c` — and the same posture for a rule the GATE refused: no remedy is computed
1072
1822
  // from evidence the gate declined to read, the refusal is disclosed on both channels, and `--strict`
1073
1823
  // exits 2 (could-not-evaluate) rather than the 0 that would read as "no crossings left to fix".
1074
1824
  if (fgr.unevaluated?.length) advisoryUnevaluatedNote("fix-gate", fgr.unevaluated, UNEVAL_TAIL_STRICT);
1075
- emit(advisoryAnswer(fgr, fgUnan));
1076
- process.exit(fgUnan.length || fgr.unevaluated?.length ? (strict ? 2 : 0) : (strict && !fgr.ok ? 1 : 0));
1825
+ // ⟨0.28⟩ SPEC §2 — an empty `remedies` beside `ok: true` here is a claim relative to a gate that never
1826
+ // asked a question. The caveat document replaces the result; the EXIT is the SAME expression the
1827
+ // result path computes, over the finding sets a zero-rule policy produces by construction (no
1828
+ // remedies, no unanswerable rule) — so it moves only with the REPORT's own incompleteness, exactly as
1829
+ // it does today.
1830
+ if (policyAskedNothing(fgpol)) {
1831
+ emitZeroRuleCaveat("fix-gate", policyFile, fgComp);
1832
+ process.exit(fgUnan.length || fgComp.unreadable.length ? (strict ? 2 : 0) : 0);
1833
+ }
1834
+ emit(advisoryAnswer(fgr, fgUnan, fgComp.judgedNothing, fgComp.unreadable, fgComp.noManifest));
1835
+ // ⟨0.28⟩ `unreadable` joins the `--strict` exit-2 trigger (SPEC §3.2's pessimism relation): `gate
1836
+ // --report` REFUSES over a corrupt member — measured, exit 2 — so exiting 0/1 here claimed this verb
1837
+ // got FURTHER than the gate on identical bytes. The unreadable note above already SAID the exit was
1838
+ // bounded by the gate's while this line did not read the field — a documented limitation, unmeasured.
1839
+ process.exit(fgUnan.length || fgComp.unreadable.length || fgr.unevaluated?.length ? (strict ? 2 : 0) : (strict && !fgr.ok ? 1 : 0));
1077
1840
  break; // unreachable
1078
1841
  }
1079
1842
  case "unverified": {
@@ -1099,19 +1862,37 @@ switch (cmd) {
1099
1862
  const ucg = uci >= 0 ? loadCallgraph(prefix) : {};
1100
1863
  if (uci >= 0 && Object.keys(ucg).length === 0 && !ucg.partial && !ufns.some((e) => (e.calls ?? []).length))
1101
1864
  console.error(`candor-ts: no call-graph sidecar for '${prefix}' and no \`calls\` edges in the report — \`--class\` resolved each hole's reason class from its OWN \`unknownWhy\` only; a hole whose Unknown is INHERITED reads \`unresolved\` here (re-run: candor-ts <src> --out ${prefix})`);
1102
- const r = coreUnverified(ufns, loadPolicyOrDie(policyFile, ptext), scopeMatches,
1865
+ const upol = loadPolicyOrDie(policyFile, ptext);
1866
+ const r = coreUnverified(ufns, upol, scopeMatches,
1103
1867
  uci >= 0 ? args[uci + 1] : null, ucg);
1104
1868
  // ⟨0.24⟩ SPEC §3.2 — see `advisoryAnswer`, and this is the SHARPEST case in the family: the verb whose
1105
1869
  // entire job is "your green gate is not provably green" was certifying a set it knows it cannot see all
1106
1870
  // of. A function in an unparsed file is absent from `functions`, so it cannot be enumerated as an
1107
1871
  // unverified pass — and that absence is exactly what this verb would have to report.
1108
- const uUnan = reportUnanalyzed(prefix);
1872
+ const uComp = reportCompleteness(prefix);
1873
+ const uUnan = uComp.unanalyzed;
1109
1874
  if (uUnan.length) advisoryIncompleteNote("unverified", uUnan);
1875
+ // ⟨0.28⟩ …and the count-0 cause. MEASURED before this line: `{ok: true, unverified: []}` over a report
1876
+ // that judged nothing — this verb certifying a package it never examined. The exit is untouched.
1877
+ if (uComp.judgedNothing.length) advisoryJudgedNothingNote("unverified");
1878
+ if (uComp.noManifest.length) advisoryNoManifestNote("unverified", uComp.noManifest);
1879
+ if (uComp.unreadable.length) advisoryUnreadableNote("unverified", uComp.unreadable);
1110
1880
  // ⟨0.24⟩ SPEC §3.2 `4fd140c` — the function the gate could not judge is NAMED in `unverified` above,
1111
1881
  // with the missing evidence as its reason; this is the human channel for the same fact.
1112
1882
  if (r.unevaluated?.length) advisoryUnevaluatedNote("unverified", r.unevaluated, UNEVAL_TAIL_STRICT);
1113
- emit(advisoryAnswer(r, uUnan));
1114
- process.exit(uUnan.length || r.unevaluated?.length ? (strict ? 2 : 0) : (strict && !r.ok ? 1 : 0));
1883
+ // ⟨0.28⟩ SPEC §2 — the sharpest of the four: the verb whose whole job is "your green gate is not
1884
+ // provably green" answered `{"ok": true, "unverified": []}` over a policy that asked nothing. The
1885
+ // empty list is withheld for ⟨0.27⟩'s reason (a refusal document must not carry `violations`), and the
1886
+ // exit follows the same expression the result path computes over empty finding sets.
1887
+ if (policyAskedNothing(upol)) {
1888
+ emitZeroRuleCaveat("unverified", policyFile, uComp);
1889
+ process.exit(uUnan.length || uComp.unreadable.length ? (strict ? 2 : 0) : 0);
1890
+ }
1891
+ emit(advisoryAnswer(r, uUnan, uComp.judgedNothing, uComp.unreadable, uComp.noManifest));
1892
+ // ⟨0.28⟩ `unreadable` joins the `--strict` exit-2 trigger — see fix-gate above. Measured on this verb
1893
+ // before the fix: over one good report plus one unparsable sibling, `gate --report` exited 2 and
1894
+ // `unverified --strict` exited 0 — and `--strict` is how CI consumes it.
1895
+ process.exit(uUnan.length || uComp.unreadable.length || r.unevaluated?.length ? (strict ? 2 : 0) : (strict && !r.ok ? 1 : 0));
1115
1896
  break; // unreachable
1116
1897
  }
1117
1898
  case "gate": {
@@ -1139,13 +1920,17 @@ switch (cmd) {
1139
1920
  const gwrite = (obj) => {
1140
1921
  const text = JSON.stringify(obj, null, 1);
1141
1922
  for (const dest of gdests) {
1142
- if (dest === "-") { console.log(text); continue; }
1923
+ // THE FLAG IS SET WHERE THE WRITE HAPPENS, not where the gate is entered. It was set on
1924
+ // entering this verb, under the comment "reaching here means the gate ran and will write its
1925
+ // own document" — which is a claim about the future, and false: the `--policy` fallback ladder
1926
+ // can still exit 2 below without writing. That suppressed the pre-pass hook and returned the
1927
+ // run to EMPTY stdout after exit 2, which is the exact channel the hook was added to close.
1928
+ // Caught by the second go/no-go panel; the first flag placement lasted about an hour.
1929
+ if (dest === "-") { globalThis.__candorGateVerdictWritten = true; console.log(text); continue; }
1143
1930
  // A SURFACING side-output: an unwritable path is one stderr line, never a raw ENOENT crash whose
1144
1931
  // exit 1 would read as a policy violation on a clean run (the scan path's rule).
1145
1932
  try {
1146
- const tmp = `${dest}.${process.pid}.tmp`;
1147
- fs.writeFileSync(tmp, text + "\n");
1148
- fs.renameSync(tmp, dest);
1933
+ writeSinkAtomic(dest, text + "\n");
1149
1934
  } catch (e) { console.error(`candor-ts: could not write --gate-json ${dest}: ${e.message}`); }
1150
1935
  }
1151
1936
  };
@@ -1182,7 +1967,23 @@ switch (cmd) {
1182
1967
  // uses (SPEC §3.1 makes byte-equality between the two documents the acceptance test — the scan route
1183
1968
  // needed this list so a dominating baseline regression could carry the refusal beside it, and a list on
1184
1969
  // 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)); }
1970
+ // ⟨0.27⟩ …listing EVERY rule of the refused policy, not only the unhonourable lines — the shared
1971
+ // builder with the scan route (SPEC §3.1's composed-document clause; byte-equality binds the two).
1972
+ if (gfatal.length) { const why = policyErrorText(policyFile, gfatal); console.error(why); grefuse(why, policyRefusalUnevaluated(gtext, gfatal)); }
1973
+ // ⟨0.28⟩ …AND A CONFIGURED POLICY THAT YIELDED ZERO RULES REFUSES THE SAME WAY (SPEC §6.2). The scan
1974
+ // route carries the same block with the same builder; this one is not optional beside it, on §6.2's own
1975
+ // words ("Measured on the `gate --report` verb too — a route is not covered by its sibling") and on
1976
+ // §3.1's byte-equality MUST, which a one-route rung would break on the `# nothing` policy. Every rule
1977
+ // vector, never a subset: `deny` (deny + pure), `allow`, `forbid` — keying on one would refuse an
1978
+ // ordinary allow-only or forbid-only gate as if it had no rules.
1979
+ if (!gpol.deny.length && !gpol.allow.length && !gpol.forbid.length) {
1980
+ const { why, unevaluated } = policyZeroRules(policyFile);
1981
+ console.error(`candor-ts: gate: ${why} — refusing (exit 2, gate NOT enforced). Every line was ignored `
1982
+ + `(see the \`ignoring policy rule\` warnings above), the file is empty, or it holds only comments. A `
1983
+ + `gate with no rules cannot have caught anything, and \`ok: true\` here would be indistinguishable `
1984
+ + `from a gate that ran and found nothing.`);
1985
+ grefuse(why, unevaluated);
1986
+ }
1186
1987
  // ⟨0.24⟩ THE CONFIG FILE THAT SUPPLIED VOCABULARY THE VERDICT USED (SPEC §3.1 `99eb4e9`) — named on a
1187
1988
  // REFERENCE, not only on a firing, because the measured harm was a GREEN verdict a vocabulary file made
1188
1989
  // green. Omitted when no alias was used, so every other verdict stays byte-identical to before.
@@ -1305,6 +2106,17 @@ switch (cmd) {
1305
2106
  // Route the human output exactly as a scan does: to stderr whenever stdout carries the verdict
1306
2107
  // document, so `candor-ts-query gate … --json | jq` sees pure JSON.
1307
2108
  const gsay = (json || gateJsonPath === "-") ? (l) => console.error(l) : (l) => console.log(l);
2109
+ // ⟨0.27⟩ SPEC §4 — THE ZERO-MATCH DISCLOSURE BELONGS ON THIS ROUTE TOO. Its absence was found by a
2110
+ // cross-engine differential: java and swift disclosed on `gate --report`, rust and ts did not, so
2111
+ // the same typo'd policy was reported by two engines and silently scored as satisfied by two. §4's
2112
+ // MUST carries no route qualifier, and this is the SUPPLY-CHAIN gate — a consumer pointing a policy
2113
+ // at a report someone else produced. ALWAYS on stderr, never through `gsay`: this is a disclosure
2114
+ // about the policy, not a verdict line, and stdout may be carrying the verdict document.
2115
+ for (const raw of gviol.zeroMatch ?? []) {
2116
+ console.error(`candor: policy rule matched NO function — \`${raw}\`. It was evaluated and bound `
2117
+ + `nothing, so it cannot have caught anything. Legitimate when one policy is shared across `
2118
+ + `repos; a typo'd layer name otherwise.`);
2119
+ }
1308
2120
  for (const x of gviol) gsay(`[${x.rule}] ${x.detail}`);
1309
2121
  // ⟨0.21⟩ COMPLETENESS MANIFEST: a gate cannot be green over code candor never analyzed. The scan path
1310
2122
  // exits 2 on its OWN `unanalyzed`; here the same manifest travels ON the report, so the same verdict
@@ -1325,6 +2137,16 @@ switch (cmd) {
1325
2137
  if (gvocab) gverdictObj.policyVocabulary = gvocab;
1326
2138
  gverdictObj.violations = gviol;
1327
2139
  if (gunevaluated.length) gverdictObj.unevaluated = gunevaluated;
2140
+ // ⟨0.27⟩ SPEC §4 `zeroMatch` — the same list the stderr lines above carry, in the machine channel,
2141
+ // in the same position the scan route puts it (§3.1's byte-equality MUST binds the two documents).
2142
+ if (gviol.zeroMatch?.length) gverdictObj.zeroMatch = gviol.zeroMatch;
2143
+ // ⟨0.28⟩ SPEC §6.2 `ignored: [{line, text, reason}]` — the policy lines the parse DROPPED, in the SAME
2144
+ // position the scan route puts them, because §3.1 makes byte-equality between the two documents the
2145
+ // acceptance test and §6.2 records this defect measured "on the `gate --report` verb too — a route is
2146
+ // not covered by its sibling". Distinct from `unevaluated`: that carries rules that PARSED and could
2147
+ // not be answered, this carries text that never became a rule at all. Omitted when empty; `ok` and
2148
+ // the exit do not consult it (the line-level leniency is unchanged, only disclosed).
2149
+ if (gpol.ignored?.length) gverdictObj.ignored = gpol.ignored;
1328
2150
  if (gincomplete) { gverdictObj.incomplete = true; gverdictObj.unanalyzed = g.unanalyzed; }
1329
2151
  if (g.coverage.length)
1330
2152
  gverdictObj.coverage = { uncovered: g.coverage.length, packages: g.coverage.map((c) => c.name) };