candor-ts 0.30.0 → 0.32.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
@@ -26,8 +26,8 @@ import { fileURLToPath } from "node:url";
26
26
  import { parsePolicy, scopeMatches, discoverConfigPolicy, parseUnknownAliases, discoverConfigText,
27
27
  evaluatePolicy, reportNetClasses, resolveReasonClasses, discoverConfigPath,
28
28
  policyVocabularyAnchor, policyErrorText, policyRefusalUnevaluated, policyUnreadable, policyZeroRules,
29
- fatalPolicyErrors, refusalVerdict,
30
- unanswerableScoped, wholePolicyUnanswerable } from "./policy.mjs";
29
+ fatalPolicyErrors, refusalVerdict, sortViolations,
30
+ unanswerableScoped, wholePolicyUnanswerable, reportUnits } from "./policy.mjs";
31
31
  import { hasReport } from "./query-core.mjs";
32
32
  import { printAgents, writeStdoutSync } from "./contract.mjs";
33
33
  import { bestFinds } from "./surface.mjs";
@@ -191,7 +191,18 @@ const put = (a, data, proseFn) => { if (!proseFn || wantJsonOut(a)) emit(data);
191
191
  // disclosure, not an exit code"), so the gate exits 0 there. A note that sends the reader to a CI job which
192
192
  // then passes teaches them the warning is noise — the disclosure discrediting itself. The count-0 sentence
193
193
  // is also the more urgent one and says so: nothing downstream fails closed on those bytes.
194
- const gateLine = (comp) => (comp.unanalyzed.length || comp.unreadable?.length
194
+ //
195
+ // ⟨0.32⟩ THE UNREAD-CLASS CAUSE GETS ITS OWN SENTENCE, because both of the others are FALSE of it in
196
+ // opposite directions: "exits 2 over these bytes" is unqualified and these verbs hold no policy to say it
197
+ // under, while "exits 0 over a judged-nothing report" names a cause that is not present and sends the
198
+ // reader to a CI job that will pass. `gate --report` can only ever evaluate a deny-family rule — measured
199
+ // 2026-08-24, all four engines refuse an `allow`-only policy as NO RULES and a `forbid` rule as
200
+ // unevaluable on that route — so the exit is a certainty once a policy exists; the gap is that none does
201
+ // here. Checked FIRST, because a report can carry an unread class and unanalyzed units together and this
202
+ // is the sentence naming the cause the other two do not.
203
+ const gateLine = (comp) => (comp.unread?.length && !comp.unanalyzed.length && !comp.unreadable?.length
204
+ ? "`gate --report` exits 2 over these bytes under any policy it can evaluate (they are all `deny`/`pure`), and this verb holds none — so NOTHING DOWNSTREAM IS FAILING CLOSED ON IT HERE and this note is the whole of the warning."
205
+ : comp.unanalyzed.length || comp.unreadable?.length
195
206
  ? "`gate --report` exits 2 over these bytes."
196
207
  : "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.");
197
208
 
@@ -225,6 +236,17 @@ const incompleteAnswerNote = (comp, soWhat, tail) => {
225
236
  causes.push(`include ${comp.noManifest.length} report(s) carrying NO \`analyzed\` manifest at all`);
226
237
  if (comp.unreadable?.length)
227
238
  causes.push(`include ${comp.unreadable.length} file(s) that could not be parsed at all`);
239
+ // ⟨0.30⟩/⟨0.32⟩ THE TWO SCOPE CAUSES, which `mustHedge` has counted since ⟨0.30⟩ and this sentence
240
+ // never named. Over a report whose ONLY cause is one of them the head came out as
241
+ // `⚠ INCOMPLETE — the report(s) under this locator , so …` — an EMPTY clause, because `causes` was
242
+ // built from the three manifest rows alone. A hedge whose sentence says nothing is the deleted
243
+ // disclosure arriving inside the disclosure; candor-rust and candor-java each measured the same line
244
+ // on the same rung. It was unreachable here while the unread cause was kept out of `mustHedge`, and
245
+ // reachable on nearly every no-policy report the moment it was not.
246
+ if (comp.outOfScope?.length)
247
+ causes.push(`name ${comp.outOfScope.length} function(s) OUTSIDE the scan's scope performing an effect the producing scan's policy DENIED`);
248
+ if (comp.unread?.length)
249
+ causes.push(`declare ${comp.unread.length} exclusion class(es) the scan did NOT READ (\`excluded[].peeked: false\`)`);
228
250
  console.log(`candor-ts: ⚠ INCOMPLETE — the report(s) under this locator ${causes.join(", and ")}, so ${soWhat}:`);
229
251
  for (const u of comp.unanalyzed) console.log(` ${u.path}${u.reason ? ` (${u.reason})` : ""}`);
230
252
  if (comp.judgedNothing.length)
@@ -233,6 +255,10 @@ const incompleteAnswerNote = (comp, soWhat, tail) => {
233
255
  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.`);
234
256
  for (const f of comp.unreadable ?? [])
235
257
  console.log(` ${f} — could not be parsed (corrupt or mid-write), so whatever it says is not in this answer`);
258
+ for (const o of comp.outOfScope ?? [])
259
+ console.log(` ${o.fn ?? "(unnamed)"} — OUTSIDE the producing scan's scope: it performs ${(o.effects ?? []).join(", ")}, and the gate did not judge it`);
260
+ for (const c of comp.unread ?? [])
261
+ console.log(` ${c} — this exclusion class went UNREAD (\`excluded[].peeked: false\`): its effects are absent because nothing looked, not because there are none. Re-run the producing scan WITH a \`deny\`/\`pure\` policy so the peek reads it (candor-ts <dir> --policy <file>)`);
236
262
  console.log(` ${tail} ${gateLine(comp)}`);
237
263
  };
238
264
 
@@ -243,8 +269,8 @@ const incompleteAnswerNote = (comp, soWhat, tail) => {
243
269
  // construct it. `map` was the one caller whose top level is a USER NAMESPACE, and it answered by MERGING
244
270
  // the hedge over a module literally named `incomplete` and disclosing the loss on stderr — a lost row the
245
271
  // operator was told about, which was the best available answer while the caveat had to ride the result.
246
- // It no longer does: `map` takes `putCaveatInstead` below, where the caveat REPLACES the document and
247
- // nothing is displaced. Every remaining caller has a FIXED key set of its own (`where`
272
+ // It no longer does: `map` takes `putNestedWithCaveat` below, where its namespace sits one level down
273
+ // under `modules` and nothing is displaced. Every remaining caller has a FIXED key set of its own (`where`
248
274
  // {effect,directly,inherited}, `reachable` {entryPoints,effects}, `containment` {contained,ambient},
249
275
  // `blindspots` {sources,totalUnknown}), so a collision here is not constructible from any report.
250
276
  // Deleted rather than kept as a dormant guard: a check whose condition cannot arise reads as coverage.
@@ -261,10 +287,9 @@ const putAnswer = (a, data, proseFn, comp, soWhat, tail) => {
261
287
  return data;
262
288
  };
263
289
 
264
- // ---- ⟨0.28⟩ RUNG A — SPEC §2: "A VERB WHOSE PINNED SHAPE CANNOT CARRY THE CAVEAT MUST EMIT THE CAVEAT
265
- // DOCUMENT INSTEAD OF ITS RESULT DOCUMENT." Not a result document with the caveat omitted, and not an
266
- // empty result of the pinned shape. `putAnswer` above SPREADS the caveat into the answer, which works for
267
- // every verb with a fixed key set; two verbs have no such place:
290
+ // ---- ⟨0.28⟩ RUNG A — SPEC §2: a verb whose pinned shape CANNOT CARRY the caveat changes SHAPE over a
291
+ // hedging report. `putAnswer` above SPREADS the caveat into the answer, which works for every verb with a
292
+ // fixed key set; two verbs have no such place at their root:
268
293
  //
269
294
  // show pinned to a TOP-LEVEL ARRAY — nowhere to put a key at all. MEASURED here 2026-08-12 over a
270
295
  // report declaring one `unanalyzed` unit: `[]`, exit 0, no caveat on ANY channel. *Nothing
@@ -278,10 +303,35 @@ const putAnswer = (a, data, proseFn, comp, soWhat, tail) => {
278
303
  // airtight in one namespace and merely unlikely in another is not a convention; it is a deferred
279
304
  // collision."
280
305
  //
281
- // THE TYPE CHANGE IS THE POINT. A consumer doing `for (const x of doc)` over `show` gets a TypeError
282
- // rather than a silent zero-iteration loop — the one case where breaking a consumer is the CORRECT
283
- // outcome, because the consumer was being lied to. The exit does not move (⟨0.24⟩: a disclosure, not an
284
- // exit code), and HEALTHY OUTPUT IS BYTE-IDENTICAL: the shape changes only on the hedge path.
306
+ // ⟨0.32⟩ **AND WHAT THE HEDGING DOCUMENT CONTAINS WAS RULED AGAIN ON 2026-08-25: THE RESULT *AND* THE
307
+ // WARNING, NEVER THE WARNING INSTEAD OF THE RESULT.** Rung A's original answer was the CAVEAT DOCUMENT
308
+ // INSTEAD of the result, written while the trigger was a manifest a scan had FAILED to produce — there
309
+ // was little result to lose. ⟨0.32⟩'s unread-class cause then armed the same substitution on nearly every
310
+ // no-policy report. MEASURED on a single-file scan with unparsed `.js`/`.mjs` siblings, and on a rust
311
+ // crate with one `tests/` dir:
312
+ //
313
+ // show <fn> --json -> {"incomplete": true} the rows are GONE
314
+ // map --json -> {"incomplete": true} the map is GONE
315
+ //
316
+ // and through mcp.mjs the SAME document reached an AGENT (`candor_show`/`candor_map`), which is the
317
+ // channel that cannot ask a follow-up question.
318
+ //
319
+ // **THE BOUNDARY, STATED: THESE TWO ARE DESCRIPTIVE — THEY CERTIFY NOTHING.** No `ok`, no verdict, no
320
+ // exit-code obligation, so there is no claim for a pessimism rule to protect and withholding the rows
321
+ // buys no soundness. The verbs on the OTHER side of the boundary are the ones that answer `ok` — `gate`,
322
+ // and `unverified`/`fix-gate`/`whatif`/`fix` under `--strict` — and they MUST keep refusing over these
323
+ // same bytes (⟨0.24⟩'s "never LESS sensitive than the gate"; conformance PARTs 62 and 67 pin it). Nothing
324
+ // in this helper touches them. The same codebase already answers the descriptive question this way one
325
+ // verb over: `gains --json` keeps its gained set and adds `incomplete: true` beside it.
326
+ //
327
+ // SO THE RESULT MOVES ONE LEVEL DOWN, under a key this helper is given, and the caveat sits at the root.
328
+ // That placement is what lets both properties hold at once: no reserved-key convention is needed (`map`'s
329
+ // module namespace is nested, so a module named `incomplete` cannot collide with the boolean), and no row
330
+ // is displaced. THE TYPE CHANGE IS STILL THE POINT — a consumer doing `for (const x of doc)` over `show`
331
+ // still gets a TypeError rather than a silent zero-iteration loop — and relative to the shape this
332
+ // replaces the change is purely ADDITIVE: a consumer that already handles the hedge sees one more key.
333
+ // The exit does not move (⟨0.24⟩: a disclosure, not an exit code), and HEALTHY OUTPUT IS BYTE-IDENTICAL:
334
+ // the shape changes only on the hedge path.
285
335
  //
286
336
  // The PROSE arm is `putAnswer`'s, verbatim — prose has no shape problem, and the clause is about the
287
337
  // machine document. Sharing the note + renderer call keeps the two channels from drifting.
@@ -290,14 +340,37 @@ const putAnswer = (a, data, proseFn, comp, soWhat, tail) => {
290
340
  // it delegated to `putAnswer`, whose `{ ...data, ...{} }` turned `show`'s ARRAY into `{"0": {…}}` — the
291
341
  // pinned top-level array destroyed on the very path this rung promises to leave byte-identical. Caught by
292
342
  // the intact-input control before it left the machine. So the healthy arm emits `data` itself.
293
- const putCaveatInstead = (a, data, proseFn, comp, soWhat, tail) => {
294
- if (!proseFn || wantJsonOut(a)) { emit(mustHedge(comp) ? completenessFields(comp) : data); return data; }
343
+ const putNestedWithCaveat = (a, key, data, proseFn, comp, soWhat, tail) => {
344
+ if (!proseFn || wantJsonOut(a)) {
345
+ emit(mustHedge(comp) ? { [key]: data, ...completenessFields(comp) } : data);
346
+ return data;
347
+ }
295
348
  incompleteAnswerNote(comp, soWhat, tail);
296
349
  proseFn(data, mustHedge(comp));
297
350
  return data;
298
351
  };
299
352
  // The one sentence every hedged prose arm ends with, so the six cannot drift into six wordings.
300
353
  const NOT_A = (claim) => `— but see the INCOMPLETE note above; this is NOT "${claim}"`;
354
+
355
+ // ---- ⟨0.32⟩ THE THREE GRAPH-TRAVERSAL VERBS THAT CARRIED NO COMPLETENESS READER AT ALL ----------------
356
+ //
357
+ // `callers`, `impact` and `path`. The ⟨0.28⟩ rung that widened SPEC §2's re-disclosure MUST to "any verb
358
+ // whose output could be read as a NEGATIVE FINDING about the code — a verdict, an empty result set, or a
359
+ // zero count" enumerated six verbs and skipped these three; the ⟨0.32⟩ unread-class cause then made the
360
+ // gap fire on nearly every no-policy report. MEASURED at HEAD 2026-08-25, over a report whose `excluded`
361
+ // names one class with `peeked: false`, all three answered FLAT at exit 0 with nothing on either channel —
362
+ // and the same three tools answered flat on the MCP surface, which is the consumer that cannot ask a
363
+ // follow-up question. Reproduced identically in candor-rust and candor-java.
364
+ //
365
+ // Each verb's own `soWhat`/`tail` pair, hoisted because `callers` has TWO answering sites (the complete
366
+ // graph and the effect-only fallback) and `path` has THREE. A wording written at one site and forgotten at
367
+ // the next is how this file's own comments say the last two fixes here went wrong.
368
+ const CALLERS_SOWHAT = "the caller set below covers only the call graph candor could see";
369
+ const CALLERS_TAIL = "A caller living in an unread unit is ABSENT from the graph, so it appears in neither `direct` nor `transitive` — and an EMPTY answer reads as *nothing calls this*, which is a claim this report cannot support. Re-scan before treating this as the blast radius.";
370
+ const IMPACT_SOWHAT = "the blast radius below covers only the callers candor could see";
371
+ const IMPACT_TAIL = "A caller living in an unread unit is ABSENT from the graph, so it is missing from this count and from the entry points — and `affectedCount: 0` reads as *safe to edit*, which is a claim this report cannot support. Re-scan before treating this as the blast radius.";
372
+ const PATH_SOWHAT = "the chain below is traced over only the call graph candor could see";
373
+ const PATH_TAIL = "A hop through an unread unit BREAKS the chain, so `path` can answer EMPTY for a function that really does reach the effect — and an empty `path` reads as *this function does not reach it*, which is a claim this report cannot support. Re-scan before treating an empty chain as an answer.";
301
374
  const csv = (xs) => (xs && xs.length ? xs.join(", ") : "none");
302
375
  const rows = (xs, pre = " ") => { for (const x of xs) console.log(pre + x); };
303
376
  // Per-verb prose renderers. Read the SAME shapes query-core returns (so JSON and prose can't drift); kept
@@ -318,15 +391,27 @@ const P = {
318
391
  if (d.directly.length) { console.log(` perform it directly (${d.directly.length}):`); rows(d.directly); }
319
392
  if (d.inherited.length) { console.log(` reach it transitively (${d.inherited.length}):`); rows(d.inherited); }
320
393
  },
321
- callers: (d) => {
394
+ callers: (d, hedge) => {
322
395
  // ⟨0.28⟩ UNREACHABLE FROM THE CLI since the callers verb split the empty-`of` case into its two real
323
396
  // causes below (no graph at all -> `unanswerable`, exit 2; a name absent from a real graph -> "no
324
397
  // function matching", exit 2). Kept as a renderer guard only; do NOT route a new caller through it —
325
398
  // this sentence is a determined negative and is the wrong answer when there is no call graph.
326
- if (!d.of.length) { console.log("candor: no function in the call graph matches that name."); return; }
399
+ if (!d.of?.length) {
400
+ // ⟨0.32⟩ …and the effect-only-fallback arm routes `{}` through here, where `d.of` is undefined. The
401
+ // hedged spelling is the one that matters: over a report naming an unread class this sentence is
402
+ // itself a determined negative.
403
+ console.log(hedge
404
+ ? `candor: no function in the call graph candor COULD SEE matches that name ${NOT_A("there is no such function")}.`
405
+ : "candor: no function in the call graph matches that name.");
406
+ return;
407
+ }
327
408
  console.log(`candor callers — who reaches \`${d.of.join("`, `")}\`:`);
328
409
  console.log(` direct callers (${d.direct.length}): ${csv(d.direct)}`);
329
410
  console.log(` transitive callers (${d.transitive.length}): ${csv(d.transitive)}`);
411
+ // ⟨0.32⟩ "0 callers" IS the prose spelling of `direct: []`, so the note alone is not enough — a fix
412
+ // that leaves the calm reading standing MOVES the false all-clear rather than removing it. Same shape
413
+ // `show` and `blindspots` already take.
414
+ if (hedge && !d.transitive.length) console.log(` ${NOT_A("nothing calls it")}.`);
330
415
  },
331
416
  show: (d, hedge) => {
332
417
  // ⟨0.28⟩ the empty sentence stops citing the ⟨0.21⟩ purity convention when the report cannot back it:
@@ -397,11 +482,14 @@ const P = {
397
482
  }
398
483
  for (const [e, v] of effs) console.log(` ${e}: ${v.count} (via ${csv(v.via)})`);
399
484
  },
400
- impact: (d) => {
485
+ impact: (d, hedge) => {
401
486
  console.log(`candor impact — the blast radius of \`${d.fn}\`:`);
402
487
  console.log(` ${d.affectedCount} effectful function(s) transitively call it${d.affected.length ? ":" : "."}`);
403
488
  if (d.affected.length) rows(d.affected);
404
489
  if (d.entryPoints.length) { console.log(` reachable from ${d.entryPoints.length} entry point(s):`); rows(d.entryPoints.map((ep) => `${ep.fn} [${csv(ep.inferred)}]`)); }
490
+ // ⟨0.32⟩ `affectedCount: 0` is this verb's strongest claim — *nothing calls this, safe to change* — and
491
+ // the sentence above is its prose spelling. Under a hedge it is exactly what the note withdrew.
492
+ if (hedge && !d.affected.length) console.log(` ${NOT_A("nothing calls it, safe to change")}.`);
405
493
  },
406
494
  blindspots: (d, hedge) => {
407
495
  if (!d.sources.length) {
@@ -462,7 +550,7 @@ const knownFnNames = (cg, fns) => [...new Set([...Object.keys(cg), ...fns.map((e
462
550
  // UNTOUCHED (conformance PART 5 pins `{effect, fn, path:[{fn,loc,source}]}` four-way): this path is
463
551
  // only taken when the caller did NOT pass --json, and it reads the SAME `path` array corePath computes.
464
552
  // Prints to stdout and returns nothing (matches the JSON-only verbs' fire-and-forget style).
465
- function renderPathHuman(fns, cg, fnQ, eff) {
553
+ function renderPathHuman(fns, cg, fnQ, eff, hedge = false) {
466
554
  // Resolve the start over the REPORT entries (as Rust does) — that's where `inferred` lives, and the
467
555
  // no-effect wording quotes it. The RESOLVED name (not the raw query) is then handed to corePath,
468
556
  // which re-resolves over the CALLGRAPH keys — a DIFFERENT name set: a raw partial query could pick
@@ -483,14 +571,19 @@ function renderPathHuman(fns, cg, fnQ, eff) {
483
571
  // `inferred` is printed in Rust's `{:?}` debug shape: each name quoted, ", "-joined, in `[...]`,
484
572
  // in the report's original order (unsorted). An empty set prints `[]`.
485
573
  const dbg = `[${inferred.map((e) => `"${e}"`).join(", ")}]`;
486
- console.log(`${start} does not perform ${eff} (inferred: ${dbg})`);
574
+ // ⟨0.32⟩ "does not perform" is the prose spelling of `path: []`, and over a report naming a class the
575
+ // scan never opened it is a determined negative the report cannot support — the effect could enter
576
+ // through a callee in the unread unit, which contributes no `inferred` entry at all.
577
+ console.log(`${start} does not perform ${eff} (inferred: ${dbg})`
578
+ + (hedge ? ` ${NOT_A(`${start} cannot reach ${eff}`)}` : ""));
487
579
  return;
488
580
  }
489
581
  const r = corePath(fns, cg, start, eff);
490
582
  if (r.path.length === 0) {
491
583
  // Inferred, but no LOCAL direct source on a `calls` path — reached cross-crate or via Unknown.
492
584
  console.log(`${start} performs ${eff} but its source is not a local function `
493
- + `(cross-crate, or via Unknown) — not statically traceable.`);
585
+ + `(cross-crate, or via Unknown) — not statically traceable.`
586
+ + (hedge ? ` ${NOT_A("there is no traceable local source")}` : ""));
494
587
  return;
495
588
  }
496
589
  console.log(`candor path — how \`${start}\` comes to perform ${eff}:\n`);
@@ -509,7 +602,7 @@ function renderPathHuman(fns, cg, fnQ, eff) {
509
602
  // package.json; SPEC_VERSION is the spec contract this build speaks. Reused, never re-littered.
510
603
  const QUERY_DIR = path.dirname(fileURLToPath(import.meta.url));
511
604
  const PKG_VERSION = JSON.parse(fs.readFileSync(path.join(QUERY_DIR, "package.json"), "utf8")).version;
512
- const SPEC_VERSION = "0.30";
605
+ const SPEC_VERSION = "0.32";
513
606
 
514
607
  // ---- the §3.3.1 canonical query grammar (⟨0.10⟩, additive over 0.9) --------------------------------
515
608
  // One shape for every verb: `<verb> <verb-args…> [--report <locator>] [--policy <file>] [--json]
@@ -552,7 +645,48 @@ function discoverReportPrefix() {
552
645
  // A null prefix (no --report AND nothing discovered) is a LOUD exit-2 failure — never a silent empty
553
646
  // answer. A resolved prefix that names NO report files is likewise loud (hasReport). One helper so every
554
647
  // verb's no-report path is identical to the Rust engine's (report_or_discover + the no-files check).
648
+ // ⟨0.32⟩ SPEC §3.3.1 — did the most recent attempt over these reports REFUSE?
649
+ //
650
+ // A consumer cannot work this out for itself. The hazard is an EVENT — a refusal that happened AFTER
651
+ // these bytes were written — witnessed only by the run that refused; no function of the report and the
652
+ // tree recovers it. So the refusing run writes a marker beside the reports and this reads it.
653
+ //
654
+ // Resolved for all three §3.3.1 locator forms. The DIRECT-FILE case is why the marker carries its own
655
+ // `prefix`: that form accepts any `.json` name whatever its dot-segments, so the prefix cannot come from
656
+ // the filename — the marker is found by scanning the file's directory and asking which recorded prefix
657
+ // covers it.
658
+ function refusalMarkerFor(prefix) {
659
+ const read = (f) => {
660
+ try {
661
+ const d = JSON.parse(fs.readFileSync(f, "utf8"));
662
+ return d && d.refused === true && typeof d.prefix === "string" ? d : null;
663
+ } catch { return null; }
664
+ };
665
+ try {
666
+ if (prefix.endsWith(".json") && fs.existsSync(prefix) && fs.statSync(prefix).isFile()) {
667
+ const dir = path.dirname(path.resolve(prefix));
668
+ const me = path.resolve(prefix);
669
+ for (const n of fs.readdirSync(dir)) {
670
+ if (!n.endsWith(".refused.json")) continue;
671
+ const d = read(path.join(dir, n));
672
+ if (d && me.startsWith(path.resolve(d.prefix))) return d;
673
+ }
674
+ return null;
675
+ }
676
+ } catch { return null; }
677
+ return read(`${prefix}.refused.json`);
678
+ }
679
+
555
680
  function requireReport(prefix) {
681
+ if (prefix !== null) {
682
+ const m = refusalMarkerFor(prefix);
683
+ if (m) {
684
+ console.error(`candor-ts: the most recent scan over '${m.prefix}' REFUSED — these reports are from `
685
+ + `an earlier run and this verb will not certify them. Cause: ${m.reason}. `
686
+ + `Re-scan ${m.target || "the target"}; a completing run clears the marker.`);
687
+ process.exit(2);
688
+ }
689
+ }
556
690
  if (prefix === null) {
557
691
  console.error("candor-ts: no report found (no --report and no .candor/ discovered) — scan the crate first.");
558
692
  process.exit(2);
@@ -1184,11 +1318,12 @@ switch (cmd) {
1184
1318
  // A missing/empty <query> is a LOUD usage error (exit 2, like candor-java) — never a silently-empty
1185
1319
  // `[]` at exit 0, which reads as an authoritative "no such function" over a question never asked.
1186
1320
  if (!q) { console.error("usage: candor-ts-query show <query> [--report <locator>] [--json]"); process.exit(2); }
1187
- // ⟨0.28⟩ RUNG A — `show`'s pinned shape is a TOP-LEVEL ARRAY, so over a hedging report it emits the
1188
- // CAVEAT DOCUMENT INSTEAD (see `putCaveatInstead`). Before this it took plain `put` and had no
1189
- // completeness reader at all: measured, `[]` at exit 0 over a report whose own manifest names a file
1190
- // candor could not read — the only verb of the six with no caveat on EITHER channel.
1191
- putCaveatInstead(args, coreShow(loadReportOrDie(prefix), q), P.show, reportCompleteness(prefix),
1321
+ // ⟨0.28⟩ RUNG A — `show`'s pinned shape is a TOP-LEVEL ARRAY, so over a hedging report it changes
1322
+ // shape (see `putNestedWithCaveat`). Before that rung it took plain `put` and had no completeness
1323
+ // reader at all: measured, `[]` at exit 0 over a report whose own manifest names a file candor could
1324
+ // not read — the only verb of the six with no caveat on EITHER channel. ⟨0.32⟩ the rows now travel
1325
+ // under `functions` BESIDE the caveat rather than being replaced by it: `show` certifies nothing.
1326
+ putNestedWithCaveat(args, "functions", coreShow(loadReportOrDie(prefix), q), P.show, reportCompleteness(prefix),
1192
1327
  `the function(s) shown below are only those candor could SEE match \`${q}\``,
1193
1328
  "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.");
1194
1329
  break;
@@ -1279,12 +1414,25 @@ switch (cmd) {
1279
1414
  // so the answer is empty at exit 0, never a fabricated "no such function" (rust corpus-audit #5,
1280
1415
  // byte-matching its two arms: `{}` on the machine channel, the re-scan pointer on the human one).
1281
1416
  if (!completeGraph) {
1282
- 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).`));
1417
+ // ⟨0.32⟩ AND THIS ARM TAKES THE HEDGE TOO. `{}` is the STRONGEST determined negative the format
1418
+ // has — every key a consumer reads defaults to empty, so `d.direct ?? []` cannot tell it from
1419
+ // `{"direct": []}`. The sidecar-absence sentence is a DIFFERENT limitation (an effect-only graph)
1420
+ // and does not cover a report whose own `excluded` names a class nothing opened.
1421
+ putAnswer(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).`),
1422
+ reportCompleteness(prefix), CALLERS_SOWHAT, CALLERS_TAIL);
1283
1423
  break;
1284
1424
  }
1285
1425
  console.error(`candor-ts-query callers: no function matching '${q}' in the call graph`); process.exit(2);
1286
1426
  }
1287
- put(args, cres, P.callers);
1427
+ // ⟨0.32⟩ `putAnswer`, not `put`: this verb carried NO completeness reader at all — measured at HEAD
1428
+ // over a report whose `excluded` names one class with `peeked: false`, `callers` answered
1429
+ // `{"of":[…],"direct":["a.top"],"transitive":["a.top"]}` at exit 0 with no caveat on EITHER channel,
1430
+ // and an EMPTY `direct` over the same bytes reads as *nothing calls this*. Its key set is FIXED, so
1431
+ // the caveat spreads in beside the answer exactly as it does for `where` — no nesting, no reserved-key
1432
+ // collision, and the exit does not move. `show`/`map` OVER-hedged (`putNestedWithCaveat` above, and
1433
+ // the descriptive/certifying boundary is stated there); these three UNDER-hedged, which is the
1434
+ // direction this family calls the cardinal sin.
1435
+ putAnswer(args, cres, P.callers, reportCompleteness(prefix), CALLERS_SOWHAT, CALLERS_TAIL);
1288
1436
  break;
1289
1437
  }
1290
1438
  case "map": {
@@ -1292,9 +1440,11 @@ switch (cmd) {
1292
1440
  const { prefix } = resolveReportVerb(args, 0);
1293
1441
  // ⟨0.28⟩ `map` answers `{}`, which SPEC §2 calls the STRONGEST determined negative there is: every key
1294
1442
  // a consumer reads defaults to empty, so `d["db"] ?? {}` cannot tell an empty map from an unexamined
1295
- // one. RUNG A: its top level is a USER NAMESPACE, so the caveat REPLACES the module map rather than
1296
- // merging into it — the merged shape displaced a real module row to make space for the hedge.
1297
- putCaveatInstead(args, coreMap(loadReportOrDie(prefix)), P.map, reportCompleteness(prefix),
1443
+ // one. RUNG A: its top level is a USER NAMESPACE, so the caveat may not merge into it — the merged
1444
+ // shape displaced a real module row to make space for the hedge. ⟨0.32⟩ the map now NESTS under
1445
+ // `modules` with the caveat at the root, which removes the collision instead of deferring it AND
1446
+ // keeps the answer: `map` certifies nothing, so there was never a claim to withhold.
1447
+ putNestedWithCaveat(args, "modules", coreMap(loadReportOrDie(prefix)), P.map, reportCompleteness(prefix),
1298
1448
  "the module rows below cover only the source candor read",
1299
1449
  "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.");
1300
1450
  break;
@@ -1453,7 +1603,11 @@ switch (cmd) {
1453
1603
  if (coreMatches(knownFnNames(impCg, impFns), q).length === 0) {
1454
1604
  console.error(`candor-ts-query impact: no function matching '${q}'`); process.exit(2);
1455
1605
  }
1456
- put(args, coreImpact(impFns, impCg, q), P.impact);
1606
+ // ⟨0.32⟩ `putAnswer`, not `put` — this verb had NO completeness reader at all (see `CALLERS_SOWHAT`
1607
+ // above for the measurement and the boundary). `affectedCount: 0` is the strongest claim in this
1608
+ // verb's vocabulary, and over a report whose own `excluded` names a class nothing opened it rests on
1609
+ // a graph missing whatever lives in that class. Fixed key set, so the caveat spreads in at the root.
1610
+ putAnswer(args, coreImpact(impFns, impCg, q), P.impact, reportCompleteness(prefix), IMPACT_SOWHAT, IMPACT_TAIL);
1457
1611
  break;
1458
1612
  }
1459
1613
  case "blindspots": {
@@ -1655,6 +1809,18 @@ switch (cmd) {
1655
1809
  // "does not perform undefined" at exit 0 — a false all-clear over a question that was never posed.
1656
1810
  if (!fn || !eff) { console.error("usage: candor-ts-query path <fn> <Effect> [--report <locator>] [--json]"); process.exit(2); }
1657
1811
  const fns = loadReportOrDie(prefix);
1812
+ // A TYPO'D EFFECT NAME IS A LOUD ERROR HERE TOO. `where` has refused an unknown effect since the
1813
+ // corpus audit; `path` did not, and answered "<fn> does not perform Fsz" at exit 0 — a typo scored
1814
+ // as a confident NEGATIVE, in the verb people reach for to check one specific claim.
1815
+ //
1816
+ // Same rule as `where`, deliberately: a KNOWN effect that is simply absent is a legitimate negative
1817
+ // answer, and an unknown name PRESENT in the report (a spec extension effect from another engine) is
1818
+ // allowed. The error is only for a name NEITHER known NOR present.
1819
+ if (!KNOWN_EFFECTS.includes(eff) && eff !== "Unknown"
1820
+ && !fns.some(f => (f.inferred || []).includes(eff))) {
1821
+ console.error(`candor-ts-query path: unknown effect '${eff}' (known: ${KNOWN_EFFECTS.join(", ")})`);
1822
+ process.exit(2);
1823
+ }
1658
1824
  // ⟨0.28⟩ Sidecar first, then the report's embedded `calls` edges (rust's `path` runs on the report's
1659
1825
  // edges alone — see the callers verb). The unanswerable arm below keeps the genuinely-absent case.
1660
1826
  let cg = loadCallgraph(prefix);
@@ -1703,13 +1869,23 @@ switch (cmd) {
1703
1869
  if (coreMatches(knownFnNames(cg, fns), fn).length === 0) {
1704
1870
  console.error(`candor-ts-query path: no function matching '${fn}'`); process.exit(2);
1705
1871
  }
1706
- if (wantJson) emit(corePath(fns, cg, fn, eff)); // conformance PART 5 shape — UNCHANGED
1872
+ // ⟨0.32⟩ THE COMPLETENESS READER THIS VERB DID NOT HAVE (see `PATH_SOWHAT` above). `path: []` is the
1873
+ // determined negative here — *this function does not reach that effect* — and a hop through an unread
1874
+ // unit BREAKS the chain, so a hedging report can produce that answer for a function that really does
1875
+ // reach it. Read once and used on BOTH arms; the key set is fixed, so the caveat spreads at the root
1876
+ // and the conformance PART 5 shape is unchanged on a complete report (`completenessFields` → `{}`).
1877
+ const pcomp = reportCompleteness(prefix);
1878
+ if (wantJson) emit(withCompleteness(corePath(fns, cg, fn, eff), pcomp));
1707
1879
  else {
1708
1880
  // The accepted 0.11 default change (the human chain replaced JSON as the no-flag output) gets a
1709
1881
  // ONE-line stderr breadcrumb, so a pre-0.11 pipeline that broke on the new default is pointed at
1710
1882
  // --json rather than left guessing. stderr only — stdout stays the human chain; --json untouched.
1711
1883
  console.error("candor-ts-query: tip — `--json` selects the machine-readable path shape (the default before 0.11)");
1712
- renderPathHuman(fns, cg, fn, eff);
1884
+ // The note goes on STDOUT with the answer it qualifies, like every other verb here: a caveat on the
1885
+ // other stream is one `2>/dev/null` from gone. `renderPathHuman` takes the hedge flag so its two
1886
+ // determined-negative sentences ("does not perform", "not statically traceable") can withdraw.
1887
+ incompleteAnswerNote(pcomp, PATH_SOWHAT, PATH_TAIL);
1888
+ renderPathHuman(fns, cg, fn, eff, mustHedge(pcomp));
1713
1889
  }
1714
1890
  break;
1715
1891
  }
@@ -1847,7 +2023,16 @@ switch (cmd) {
1847
2023
  emitZeroRuleCaveat("fix-gate", policyFile, fgComp);
1848
2024
  process.exit(fgUnan.length || fgComp.unreadable.length ? (strict ? 2 : 0) : 0);
1849
2025
  }
1850
- emit(advisoryAnswer(fgr, fgUnan, fgComp.judgedNothing, fgComp.unreadable, fgComp.noManifest));
2026
+ // ⟨0.32⟩ the classes the producer never opened, under the SAME condition the gate applies (only a
2027
+ // `deny`/`pure` rule's answer depends on code outside the scan's scope), computed ONCE and handed to
2028
+ // both the document and the exit below so the two cannot disagree about a run.
2029
+ const fgUnread = fgpol.deny.length ? (fgComp.unread ?? []) : [];
2030
+ // ⟨0.30⟩/⟨0.32⟩ THE TWO SCOPE CAUSES NOW REACH THE DOCUMENT, not just the exit. MEASURED before this
2031
+ // argument existed: over a report carrying `outOfScope`, this verb printed `{"ok": true, "remedies":
2032
+ // []}` AT EXIT 2 — the exit obeyed §3.2's "never MORE certain than the gate" while the document it
2033
+ // printed certified. The exit expression below is unchanged for `outOfScope` and gains the ⟨0.32⟩ arm.
2034
+ emit(advisoryAnswer(fgr, fgUnan, fgComp.judgedNothing, fgComp.unreadable, fgComp.noManifest,
2035
+ fgComp.outOfScope ?? [], fgUnread));
1851
2036
  // ⟨0.28⟩ `unreadable` joins the `--strict` exit-2 trigger (SPEC §3.2's pessimism relation): `gate
1852
2037
  // --report` REFUSES over a corrupt member — measured, exit 2 — so exiting 0/1 here claimed this verb
1853
2038
  // got FURTHER than the gate on identical bytes. The unreadable note above already SAID the exit was
@@ -1857,7 +2042,9 @@ switch (cmd) {
1857
2042
  // and "THE SAME RULE BINDS EVERY ADVISORY VERB THAT ANSWERS `ok` — `unverified`, `fix-gate`, and any
1858
2043
  // later sibling". ⟨0.30⟩ moved the gate to exit 2 on this cause and left these verbs certifying:
1859
2044
  // MEASURED, `gate --report` exited 2 while this printed a clean answer at exit 0 over the same bytes.
1860
- process.exit(fgUnan.length || fgComp.unreadable.length || fgComp.outOfScope?.length || fgr.unevaluated?.length ? (strict ? 2 : 0) : (strict && !fgr.ok ? 1 : 0));
2045
+ // ⟨0.32⟩ …and the class nothing OPENED, for the same MUST: `gate --report` exits 2 over it, so a verb
2046
+ // that answered 0 on the same bytes would have got FURTHER than the gate.
2047
+ process.exit(fgUnan.length || fgComp.unreadable.length || fgComp.outOfScope?.length || fgUnread.length || fgr.unevaluated?.length ? (strict ? 2 : 0) : (strict && !fgr.ok ? 1 : 0));
1861
2048
  break; // unreachable
1862
2049
  }
1863
2050
  case "unverified": {
@@ -1918,12 +2105,17 @@ switch (cmd) {
1918
2105
  emitZeroRuleCaveat("unverified", policyFile, uComp);
1919
2106
  process.exit(uUnan.length || uComp.unreadable.length ? (strict ? 2 : 0) : 0);
1920
2107
  }
1921
- emit(advisoryAnswer(r, uUnan, uComp.judgedNothing, uComp.unreadable, uComp.noManifest));
2108
+ // ⟨0.30⟩/⟨0.32⟩ the two scope causes, in the document as well as the exit — see the fix-gate site for
2109
+ // the measurement (`{"ok": true, "unverified": []}` at exit 2 over a report carrying `outOfScope`).
2110
+ const uUnread = upol.deny.length ? (uComp.unread ?? []) : [];
2111
+ emit(advisoryAnswer(r, uUnan, uComp.judgedNothing, uComp.unreadable, uComp.noManifest,
2112
+ uComp.outOfScope ?? [], uUnread));
1922
2113
  // ⟨0.28⟩ `unreadable` joins the `--strict` exit-2 trigger — see fix-gate above. Measured on this verb
1923
2114
  // before the fix: over one good report plus one unparsable sibling, `gate --report` exited 2 and
1924
2115
  // `unverified --strict` exited 0 — and `--strict` is how CI consumes it.
1925
2116
  // ⟨0.30⟩ the peek's findings too — see the fix-gate exit above for the ⟨0.24⟩ MUST this satisfies.
1926
- process.exit(uUnan.length || uComp.unreadable.length || uComp.outOfScope?.length || r.unevaluated?.length ? (strict ? 2 : 0) : (strict && !r.ok ? 1 : 0));
2117
+ // ⟨0.32⟩ …and the class nothing OPENED — see the fix-gate exit above.
2118
+ process.exit(uUnan.length || uComp.unreadable.length || uComp.outOfScope?.length || uUnread.length || r.unevaluated?.length ? (strict ? 2 : 0) : (strict && !r.ok ? 1 : 0));
1927
2119
  break; // unreachable
1928
2120
  }
1929
2121
  case "gate": {
@@ -2044,6 +2236,18 @@ switch (cmd) {
2044
2236
  // human channel simply did not show it.
2045
2237
  for (const u of gunevaluated) console.error(`candor-ts: gate: ${u.why}`);
2046
2238
  const g = loadGateReport(prefix);
2239
+ // ⟨0.32⟩ DOES THIS POLICY'S ANSWER DEPEND ON THE CODE THE PRODUCER LEFT UNREAD? — the ONE place that
2240
+ // condition is applied on this route (the rule itself is stated at the reading site in
2241
+ // query-core.mjs's `loadGateReport`). `deny` is the rule vector that carries `pure` too;
2242
+ // `allow`/`forbid`/`only` live in their own, and none of them is answered from code OUTSIDE the scan's
2243
+ // scope, so refusing them for want of a peek would be an over-charge — the same short-circuit
2244
+ // scan.mjs's peek applies to the same question.
2245
+ //
2246
+ // APPLIED TO THE VALUE, not repeated at the exit arm, and that is deliberate: this list feeds BOTH
2247
+ // `gincomplete` (hence `ok`/`incomplete` in the verdict document) and the exit code below, so a
2248
+ // condition stated at only one of them lets the two disagree — a document reading `ok: false,
2249
+ // incomplete: true` beside exit 0. candor-rust's copy of this rung had exactly that split.
2250
+ const gunread = gpol.deny.length ? g.unread : [];
2047
2251
  // ANY report under the locator that did not load cleanly REFUSES THE WHOLE GATE — not just the case
2048
2252
  // where they ALL failed. The old guard was `functions.length === 0 && hardFail`, i.e. it fired only
2049
2253
  // when nothing survived anywhere, so a multi-report prefix with ONE clean sibling and one truncated
@@ -2115,12 +2319,16 @@ switch (cmd) {
2115
2319
  // THE REPORT ROUTE'S `(S, D)`: `S` = each entry's `inferred`, verbatim; `D` = the reason classes
2116
2320
  // resolved over the entries' OWN `calls` edges (the sidecar is NOT passed — that is the MUST NOT), by
2117
2321
  // the SAME resolution the scan gate and `unverified --class` use. `netClass` verbatim, likewise.
2118
- const gnet = reportNetClasses(g.functions, { authoritative: true });
2119
- const gacc = resolveReasonClasses(g.functions, {});
2322
+ // ⟨0.32⟩ THE UNIT IDENTITY FOR A MULTI-REPORT PREFIX (SPEC §2.2). Built ONCE here and handed to every
2323
+ // accumulator, so the gate, its refusal and its verdict all key the same way — a private copy is how
2324
+ // the advisory verbs came to disagree with the gate before.
2325
+ const gunits = reportUnits(g.functions);
2326
+ const gnet = reportNetClasses(g.functions, { authoritative: true, units: gunits });
2327
+ const gacc = resolveReasonClasses(g.functions, {}, gunits);
2120
2328
  // THE THIRD REFUSAL — the only one that depends on the REPORT rather than the policy alone, and the
2121
2329
  // only one whose GRANULARITY is per (rule, function). See unanswerableScoped for why it is minimal, and
2122
2330
  // why it no longer short-circuits.
2123
- const { unevaluated: gscoped, withhold: gwithhold } = unanswerableScoped(gpol, g.functions, gacc, gnet);
2331
+ const { unevaluated: gscoped, withhold: gwithhold } = unanswerableScoped(gpol, g.functions, gacc, gnet, gunits);
2124
2332
  for (const u of gscoped) { console.error(`candor-ts: gate: ${u.why}`); gunevaluated.push(u); }
2125
2333
  // ONE matcher for both routes into the gate (SPEC §6.2: "THE GATE AND THE DISCLOSURE MUST APPLY THE
2126
2334
  // SAME RULE, AND SHOULD SHARE THE SAME CODE"). `scan --policy` and this verb both land in
@@ -2133,7 +2341,7 @@ switch (cmd) {
2133
2341
  // matcher would be the very evaluation-on-partial-evidence they are unanswerable FOR — `allow` would
2134
2342
  // fire AS-EFF-008 "no visible literal" on every report entry whose surface the wire does not carry.
2135
2343
  const gviol = evaluatePolicy(gwp.answerable,
2136
- g.functions, {}, new Map(), new Set(), gnet, gwithhold);
2344
+ g.functions, {}, new Map(), new Set(), gnet, gwithhold, gunits);
2137
2345
  // Route the human output exactly as a scan does: to stderr whenever stdout carries the verdict
2138
2346
  // document, so `candor-ts-query gate … --json | jq` sees pure JSON.
2139
2347
  const gsay = (json || gateJsonPath === "-") ? (l) => console.error(l) : (l) => console.log(l);
@@ -2156,7 +2364,10 @@ switch (cmd) {
2156
2364
  // peek's findings, which is what makes §3.1 byte-equality hold here by construction rather than by two
2157
2365
  // authors agreeing. ABSENT is not empty: a report produced with no policy was never asked.
2158
2366
  const gscope = g.outOfScope ?? [];
2159
- const gincomplete = g.unanalyzed.length > 0 || gscope.length > 0;
2367
+ // ⟨0.32⟩ the THIRD cause, read off `excluded[].peeked` — code the PRODUCER never opened. Distinct from
2368
+ // the ⟨0.30⟩ one above: that is "the peek looked and found the effect you deny", this is "nothing
2369
+ // looked at all", and the two want different repairs.
2370
+ const gincomplete = g.unanalyzed.length > 0 || gscope.length > 0 || gunread.length > 0;
2160
2371
  // The verdict document — the SAME builder shape scan.mjs writes, field for field and in the same key
2161
2372
  // order, because §3.1 ⟨0.24⟩ makes byte-equality with `scan --policy`'s `--gate-json` the acceptance
2162
2373
  // test. `analyzed.count`, `incomplete`/`unanalyzed` and the ⟨0.15⟩ coverage advisory all come off the
@@ -2170,7 +2381,17 @@ switch (cmd) {
2170
2381
  const gverdictObj = { spec: SPEC_VERSION, ok: gviol.length === 0 && !gincomplete,
2171
2382
  analyzed: { count: g.analyzed } };
2172
2383
  if (gvocab) gverdictObj.policyVocabulary = gvocab;
2173
- gverdictObj.violations = gviol;
2384
+ // ⟨0.31⟩ the partner declaration that moved the producer's classification, READ OFF THE REPORT —
2385
+ // never recomputed here. This route has no target to anchor `net-partner` at, and the note above
2386
+ // says why recomputing would be wrong anyway: `netClass` is read verbatim off the wire, so
2387
+ // re-classifying through THIS machine's config is the re-derivation §3.1 forbids and would make the
2388
+ // verdict depend on the consumer's CWD. The producer recorded which of its declared partners
2389
+ // actually participated; both routes copy that one record, and byte-equality holds by construction.
2390
+ // Same position as the scan route puts it, for the same reason as `policyVocabulary` above.
2391
+ if (g.netPartners?.length) gverdictObj.netPartners = g.netPartners;
2392
+ // ⟨0.32⟩ SPEC §2 — the sort key includes the unit identity. See `sortViolations`; the SAME call
2393
+ // is made on the scan route, because §3.1 makes the two documents byte-equal.
2394
+ gverdictObj.violations = sortViolations(gviol);
2174
2395
  if (gunevaluated.length) gverdictObj.unevaluated = gunevaluated;
2175
2396
  // ⟨0.27⟩ SPEC §4 `zeroMatch` — the same list the stderr lines above carry, in the machine channel,
2176
2397
  // in the same position the scan route puts it (§3.1's byte-equality MUST binds the two documents).
@@ -2204,9 +2425,15 @@ switch (cmd) {
2204
2425
  // ⟨0.30⟩ NAME THE CAUSE THAT ACTUALLY FIRED. Two causes reach this exit now, and a message that
2205
2426
  // always says "could not analyze" would report the wrong repair for the scope one — the operator
2206
2427
  // would go looking for a parse failure that is not there.
2428
+ // ⟨0.32⟩ …and a THIRD, last in the same order scan.mjs uses (`unanalyzed` → `outOfScope` → unread),
2429
+ // so a report that trips two of them names the same cause on both routes. Its message differs from
2430
+ // the `outOfScope` one because the repair does: that one wants a scan whose SELECTOR reaches the
2431
+ // code, this one wants a scan that was ASKED the question at all.
2207
2432
  const why = g.unanalyzed.length
2208
2433
  ? `gate NOT certified — the report declares ${g.unanalyzed.length} unit(s) candor could not analyze; a gate cannot be green over unanalyzed code`
2209
- : `gate NOT certified — the report names ${gscope.length} function(s) OUTSIDE the scan's scope performing an effect this policy denies; the gate did not judge them, so the verdict is incomplete rather than a pass`;
2434
+ : gscope.length
2435
+ ? `gate NOT certified — the report names ${gscope.length} function(s) OUTSIDE the scan's scope performing an effect this policy denies; the gate did not judge them, so the verdict is incomplete rather than a pass`
2436
+ : `gate NOT certified — the report says the scan did not READ ${gunread.join(", ")}. Their effects are absent because nothing looked, not because there are none, so the verdict is INCOMPLETE rather than a pass. Re-scan the sources with this policy (candor-ts <dir> --policy <file>) — a scan that was never asked cannot certify what it never opened`;
2210
2437
  console.error(`candor-ts: ${why}`);
2211
2438
  // The INCOMPLETE verdict is a JUDGEMENT, not a refusal: it names what was analyzed and what was not,
2212
2439
  // and §3.1 makes byte-equality with `scan --policy`'s document the acceptance test for exactly it.