candor-ts 0.27.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-core.mjs CHANGED
@@ -20,8 +20,15 @@ function siblings(prefix, predicate) {
20
20
  const dir = nodePath.dirname(prefix) || ".";
21
21
  const base = nodePath.basename(prefix);
22
22
  try {
23
+ // SORTED, because readdir order is filesystem ENUMERATION order — stable on one machine, divergent
24
+ // across filesystems and checkouts, and everything downstream of this list inherits it: the merge
25
+ // order of multi-report functions, the concatenation order of the ⟨0.28⟩ `unanalyzed` disclosure,
26
+ // which duplicate callgraph key wins Object.assign. rust (candor-report lib.rs), java (Query.java)
27
+ // and swift (FixCLI.swift) all sort here; this engine was the outlier, so two engines over an
28
+ // IDENTICAL tree could emit differently-ordered arrays and a byte-diffing consumer read a change
29
+ // where there was none.
23
30
  return fs.readdirSync(dir).filter((f) => f.startsWith(base + ".") && f.endsWith(".json") && predicate(f))
24
- .map((f) => nodePath.join(dir, f));
31
+ .sort().map((f) => nodePath.join(dir, f));
25
32
  } catch { return []; }
26
33
  }
27
34
  // A sibling filename that is a real REPORT (not a callgraph sidecar, an encountered-crate ledger, a
@@ -50,6 +57,43 @@ export function hasReport(p) {
50
57
  // narrower one would answer about a report the gate never gated.
51
58
  const reportFilesAt = (prefix) => (fs.existsSync(`${prefix}.json`) ? [`${prefix}.json`] : siblings(prefix, isReport));
52
59
 
60
+ // ⟨0.28⟩ SPEC §3.3.1 (3) — the FILES a gate `--report` locator names, for the verdict-sink guard:
61
+ // `reportFilesAt`'s expansion (the SAME enumeration `loadGateReport` below reads — kept adjacent so the
62
+ // guard and the loader cannot drift), plus each report's §2.2 sidecars. Exists because the guard
63
+ // compared the sink against the raw locator TOKEN while the loader reads the token's EXPANSION.
64
+ // MEASURED on this engine 2026-08-12:
65
+ //
66
+ // gate --report r --policy P --gate-json r.json
67
+ // → armQueryGateJson wrote the refusal placeholder OVER the operator's report, the load then
68
+ // failed on the wreckage ("has no functions array"), and the exit-2 refusal document was
69
+ // written over it AGAIN. The no-`--report` discovery spelling (sink = the discovered
70
+ // `.candor/report.json`) destroyed the discovered report identically.
71
+ //
72
+ // THE SIDECARS RIDE ALONG (same clause: a report locator names the PAIR). The gate opens no sidecar —
73
+ // that MUST NOT is `loadGateReport`'s — but a sink on the pair's other half is WORSE: the report loads
74
+ // fine, the gate runs, and a REAL verdict lands on the callgraph at a success exit; `callers`/`tour`
75
+ // then read a verdict document where the graph belongs. The segment list is the `isReport` denylist's
76
+ // pair-carrying members: `gate` is excluded because `<stem>.gate.json` is the verdict sink's own
77
+ // beside-the-report layout — the exact spelling `--gate-json` exists for, pinned by the control test —
78
+ // and `encountered-*` because it is engine-local scan bookkeeping no query reads. Existing files only:
79
+ // the guard protects data, and a sidecar not on disk has none to lose.
80
+ const PAIRED_SIDECAR_SEGMENTS = ["calibrated", "callgraph", "hierarchy", "layerreach", "locs"];
81
+ export function gateReportInputFiles(prefix) {
82
+ if (!prefix) return [];
83
+ const out = [];
84
+ for (const r of reportFilesAt(prefix)) {
85
+ if (r.endsWith(".json")) {
86
+ const stem = r.slice(0, -".json".length);
87
+ for (const seg of PAIRED_SIDECAR_SEGMENTS) {
88
+ const side = `${stem}.${seg}.json`;
89
+ if (fs.existsSync(side)) out.push(side);
90
+ }
91
+ }
92
+ out.push(r);
93
+ }
94
+ return out;
95
+ }
96
+
53
97
  // Defend the queries against a partial/old-engine/hand-edited report: the §2 required fields are
54
98
  // defaulted, and a WRONG-TYPE field is coerced — a non-array `inferred` (e.g. the string "Net") must
55
99
  // NOT survive, or `new Set("Net")` iterates characters into {N,e,t} (a fabricated effect set). Array
@@ -244,6 +288,48 @@ export function gainsCoverage(curPrefix, basePrefix) {
244
288
  return out;
245
289
  }
246
290
 
291
+ /**
292
+ * ⟨0.28⟩ SPEC §2 — AND THE SAME MUST CARRIES THE ⟨0.21⟩ MANIFEST, WHICH IS THE STRONGER CAVEAT.
293
+ *
294
+ * `gainsCoverage` above has carried the CURRENT report's `coverage` ledger into this verb since ⟨0.15⟩,
295
+ * for the reason §2 gives: *a "no gains" over an uncovered dep reads clean with false confidence*.
296
+ * MEASURED: the same verb, on the same report, in the same output, dropped `unanalyzed`.
297
+ * `coverage.uncovered` says *I could not see into this dependency*; `unanalyzed` says *I could not read
298
+ * this file of your own code*, and `analyzed.count: 0` says *I judged nothing at all*. The mechanism was
299
+ * already here and pointed at the weaker field.
300
+ *
301
+ * BOTH SIDES, DISCLOSED SEPARATELY, because a gains answer rests on TWO reports and they fail in
302
+ * different directions:
303
+ * · an incomplete CURRENT means the gained set may be SHORT — effects the reader is not being told
304
+ * about, which is the whole thing this alarm verb exists to name;
305
+ * · an incomplete BASELINE means the comparison FLOOR is soft, so the existing-vs-new `origin` split
306
+ * is unreliable and an effect that was always there can read as newly appeared.
307
+ * One combined flag would say "something here is incomplete" and leave a supply-chain reviewer unable to
308
+ * act on it. This is why gains does NOT use `absorbCompleteness` (which `containment <baseline>` does):
309
+ * that verb's answer is a single leak set the two manifests both qualify, while these two qualify
310
+ * different halves of the answer. The prefixed spelling mirrors the shape already used for coverage
311
+ * (`coverage` for the current, `coverageDelta` for the difference) rather than inventing a new one, and
312
+ * `incomplete`/`unanalyzed`/`baselineIncomplete`/`baselineUnanalyzed` are candor-scan's key names exactly
313
+ * (fe5d831) — this is a cross-engine wire surface.
314
+ *
315
+ * ONE READER (`reportCompleteness`) and ONE KEY SET (`completenessFields`), shared with the six
316
+ * descriptive verbs, so the ⟨0.24⟩ count-0 arm cannot be answered one way here and another there. Both
317
+ * halves are `{}` on a complete report, so an ordinary comparison stays byte-identical to ⟨0.27⟩.
318
+ *
319
+ * Verdict-preserving: no exit code moves. `gains` is advisory by default and `--strict` keys on the
320
+ * GAINED SET, which this does not touch.
321
+ */
322
+ export const gainsCompletenessFields = (cur, base) => ({
323
+ ...completenessFields(cur),
324
+ ...Object.fromEntries(Object.entries(completenessFields(base))
325
+ .map(([k, v]) => [`baseline${k[0].toUpperCase()}${k.slice(1)}`, v])),
326
+ });
327
+
328
+ /** The prefix-level form — the `gainsCoverage` signature, for callers that hold two locators and no
329
+ * completeness objects (the MCP `candor_gains` tool; the parity rule). */
330
+ export const gainsCompleteness = (curPrefix, basePrefix) =>
331
+ gainsCompletenessFields(reportCompleteness(curPrefix), reportCompleteness(basePrefix));
332
+
247
333
  // The returned array carries a non-enumerable `hardFail` flag: true iff a report file was FOUND but
248
334
  // yielded NO trustworthy functions — a parse failure OR a malformed shape (a `null`/array/wrong-typed
249
335
  // doc, a non-array `functions`, all-junk entries). The loud CLI wrapper (loadReportOrDie) needs it to
@@ -296,14 +382,84 @@ export const claimsToHaveJudgedNothing = (parsed, fns) => {
296
382
  export function reportJudgedNothing(prefix) {
297
383
  const files = reportFilesAt(prefix);
298
384
  if (!files.length) return false;
299
- return files.every((f) => {
300
- let parsed;
301
- try { parsed = JSON.parse(fs.readFileSync(f, "utf8")); } catch { return true; } // unreadable → no claim
302
- // The raw entries, read QUIETLY: this is an advisory, and its caller has already run the loud loader
303
- // over the same bytes, so re-disclosing a malformed shape here would double every such line.
304
- const raw = parsed && typeof parsed === "object" && parsed.functions !== undefined ? parsed.functions : parsed;
305
- return claimsToHaveJudgedNothing(parsed, Array.isArray(raw) ? raw : []);
306
- });
385
+ return files.every(fileClaimsJudgedNothing);
386
+ }
387
+ // The per-FILE question the two prefix-level readers above and below share — extracted so the boolean
388
+ // (the gate's ANDed "did this locator judge anything at all") and the ⟨0.28⟩ disclosure (WHICH member
389
+ // files judged nothing) cannot drift into two readings of the same integer.
390
+ function fileClaimsJudgedNothing(f) {
391
+ let parsed;
392
+ try { parsed = JSON.parse(fs.readFileSync(f, "utf8")); } catch { return true; } // unreadable → no claim
393
+ // The raw entries, read QUIETLY: this is an advisory, and its caller has already run the loud loader
394
+ // over the same bytes, so re-disclosing a malformed shape here would double every such line.
395
+ const raw = parsed && typeof parsed === "object" && parsed.functions !== undefined ? parsed.functions : parsed;
396
+ return claimsToHaveJudgedNothing(parsed, Array.isArray(raw) ? raw : []);
397
+ }
398
+ /** ⟨0.28⟩ The report FILES under a locator that say they judged nothing — one path per file, the shape
399
+ * the disclosure carries (see `completenessFields`). PER FILE, not the ANDed prefix answer: a locator
400
+ * naming several members must disclose EACH silent one by name (rust `report_completeness`, java
401
+ * `ReportCompleteness` — "one label per report file declaring `analyzed.count: 0`").
402
+ *
403
+ * A file that does not PARSE is excluded here and carried as `unreadable` instead — `judgedNothing` is
404
+ * the report's OWN `analyzed.count: 0` assertion, and a file whose bytes cannot be read asserted
405
+ * nothing. MEASURED (candor-spec conformance/gen_key_shapes.py corpus, 2026-08-12): over an intact
406
+ * report with a corrupt `.dep` sibling, rust and swift answer `incomplete: true` alone while this
407
+ * engine listed the corrupt file under `judgedNothing` — a fabricated claim about content nobody read,
408
+ * and a consumer told "re-scan, that report reached no conclusion" when the repair is "that file is
409
+ * corrupt". `fileClaimsJudgedNothing`'s own unreadable→true stays: `reportJudgedNothing` above is the
410
+ * gate's ANDed fail-closed question, where "no readable claim" must never read as "judged something". */
411
+ export function reportJudgedNothingFiles(prefix) {
412
+ return reportFilesAt(prefix).filter((f) => fileParses(f) && fileClaimsJudgedNothing(f) && !fileHasNoManifest(f));
413
+ }
414
+
415
+ /** ⟨0.28⟩ SPEC §2 — **THE THIRD ROW IS NOT THE FIRST ROW.** §2's three-row table distinguishes
416
+ * `analyzed.count: 0` (row 1 — *nothing was judged*, a claim the report MAKES) from `analyzed` ABSENT
417
+ * (row 3 — a pre-⟨0.21⟩ producer with no manifest at all, which claims nothing). MEASURED here
418
+ * 2026-08-12 over `{"candor":{…},"functions":[]}` with no `analyzed` key: this engine listed the file
419
+ * under `judgedNothing` and its note said the report *"say[s] they JUDGED NOTHING (`analyzed.count:
420
+ * 0`)"*. **The report declares nothing.** The HEDGE is the right direction — row 3's own instruction is
421
+ * *no manifest, no claim* — but the disclosure is FALSE, and this family rates a false disclosure worse
422
+ * than a missing one (§3.4's `net-partner` finding: an engine reported "ignoring unknown config key"
423
+ * while honouring it).
424
+ *
425
+ * It is also a hole in ⟨0.28⟩'s own pin, which defines `judgedNothing` as *reports declaring
426
+ * `analyzed.count: 0`*: putting a row-3 report there makes the key mean two things and loses the
427
+ * distinction the table exists to draw. The REPAIRS differ — row 1 wants a scan that reaches a
428
+ * conclusion, row 3 wants a producer that emits a manifest at all.
429
+ *
430
+ * A legacy BARE ARRAY report has no envelope and therefore no manifest either, so it is row 3 too when
431
+ * it is empty; when it LISTS entries it is not hedging at all (`claimsToHaveJudgedNothing` is false) and
432
+ * never reaches this filter. Asked of the same parsed bytes as its two siblings so the three cannot
433
+ * drift into three readings of one file. */
434
+ const fileHasNoManifest = (f) => {
435
+ let parsed;
436
+ try { parsed = JSON.parse(fs.readFileSync(f, "utf8")); } catch { return false; } // unreadable → `unreadable`
437
+ if (Array.isArray(parsed)) return true; // legacy bare array: no envelope, no manifest
438
+ return !!parsed && typeof parsed === "object" && !("analyzed" in parsed);
439
+ };
440
+
441
+ /** ⟨0.28⟩ The consulted report FILES carrying no `analyzed` key at all — SPEC §2's row 3, pinned to its
442
+ * own key (`noManifest`) in the rung that introduced it. Only the files that are also HEDGING: a row-3
443
+ * report that LISTS functions demonstrably judged units and said so the only way it could, and keeps the
444
+ * standing it has always had (`claimsToHaveJudgedNothing`'s manifest-absent row). */
445
+ export function reportNoManifestFiles(prefix) {
446
+ return reportFilesAt(prefix).filter((f) => fileParses(f) && fileHasNoManifest(f) && fileClaimsJudgedNothing(f));
447
+ }
448
+
449
+ /** ⟨0.28⟩ Does the file's TEXT parse at all — the line between "this report says X" and "this report
450
+ * says nothing readable". Parse-only on purpose: shape defects inside a parsed document are judged per
451
+ * key role (SPEC §2 ⟨0.24⟩) by the loaders, not here. */
452
+ const fileParses = (f) => {
453
+ try { JSON.parse(fs.readFileSync(f, "utf8")); return true; } catch { return false; }
454
+ };
455
+
456
+ /** ⟨0.28⟩ The report FILES under a locator that could not be read at all — the THIRD cause, and it is a
457
+ * cause of `incomplete` with NO wire key of its own (rust and swift both answer `incomplete: true` and
458
+ * name the file on the human channel only; matching them is the point — one wire shape per state).
459
+ * Before this arm existed, `reportUnanalyzed` skipped an unparseable member with a bare `catch` and the
460
+ * corrupt file surfaced only through the judged-nothing mislabel above. */
461
+ export function reportUnreadableFiles(prefix) {
462
+ return reportFilesAt(prefix).filter((f) => !fileParses(f));
307
463
  }
308
464
 
309
465
  // Load ONE report file → { entries, hardFail }. A read/parse throw, or an empty result over a doc that
@@ -356,6 +512,123 @@ export function reportUnanalyzed(prefix) {
356
512
  return out;
357
513
  }
358
514
 
515
+ /**
516
+ * ⟨0.28⟩ SPEC §2 — THE SAME MANIFEST, READ FOR A *DESCRIPTIVE* VERB. `reportUnanalyzed` above was written
517
+ * for the ADVISORY verbs, because the clause it implements was written over the instance it was found in:
518
+ * "a report-consuming verb whose VERDICT could change". ⟨0.28⟩ corrects the clause to the condition that
519
+ * makes it true — the obligation binds **any verb whose output could be read as a negative finding about
520
+ * the code: a verdict, an EMPTY RESULT SET, or a ZERO COUNT**. MEASURED on this engine over the standard
521
+ * post-⟨0.28⟩ artifact (`analyzed.count: 0` + a non-empty `unanalyzed`, what an armed run leaves on disk):
522
+ *
523
+ * where Fs {"effect":"Fs","directly":[],"inherited":[]} exit 0, no hedge
524
+ * map {} exit 0, no hedge
525
+ * blindspots {"sources":[],"totalUnknown":0} exit 0, no hedge
526
+ * reachable {"entryPoints":0,"effects":{}} exit 0, no hedge
527
+ * containment {"contained":[],"ambient":{}} exit 0, no hedge
528
+ * tour {"reaches":[]} exit 0, no hedge
529
+ *
530
+ * "no blind spots" out of a report whose own manifest names a file candor could not read. A consumer
531
+ * cannot distinguish *nobody performs Fs* from *nothing was examined*.
532
+ *
533
+ * ONE READER, TWO CAUSES, and the second cause is why this is a function rather than a call to
534
+ * `reportUnanalyzed` at six new sites:
535
+ *
536
+ * · `unanalyzed` non-empty — candor could not READ a file of the target's own code;
537
+ * · `analyzed.count: 0` — candor read what it read and JUDGED NOTHING. A report in that state carries
538
+ * no `unanalyzed` at all (there is no unread FILE to name), so a manifest-only reader sees a complete
539
+ * report and every verb answers `{}` over it just the same. SPEC §2 names both: *"a report-consuming
540
+ * verb MUST re-disclose a non-empty `unanalyzed`, **and an `analyzed.count` of 0**, on the same
541
+ * terms"*. Decided by `reportJudgedNothing` — the SAME predicate `gate --report`, the MCP gate and the
542
+ * LSP already ask — so a report cannot be judged-nothing on one route and not on another.
543
+ *
544
+ * A report that is BOTH complete and has judged something yields `mustHedge() === false`, and every
545
+ * consumer below is then a no-op: an ordinary run stays byte-identical. That is not a nicety — a hedge on
546
+ * every run trains the reader to ignore it, the same reason ⟨0.15⟩ omits `coverage` when nothing is
547
+ * uncovered.
548
+ */
549
+ export function reportCompleteness(prefix) {
550
+ // `judgedNothing` is the PER-FILE list, not the ANDed boolean the gate asks: the disclosure names
551
+ // WHICH report judged nothing, so a locator with one silent member among several still hedges — the
552
+ // semantics rust and java pin, and a repair the reader can aim (that file, not "somewhere here").
553
+ // ⟨0.28⟩ `unreadable`: the third cause, split out of the judged-nothing mislabel — see
554
+ // `reportUnreadableFiles`. It hedges the answer and names the file on the human channel only.
555
+ // ⟨0.28⟩ `noManifest`: SPEC §2's row 3, split out of the `judgedNothing` mislabel — see
556
+ // `reportNoManifestFiles`. It hedges like the others and carries its own wire key, because the two
557
+ // want different repairs and because `judgedNothing` is PINNED to "reports declaring `analyzed.count:
558
+ // 0`", which a row-3 report is not.
559
+ return { unanalyzed: reportUnanalyzed(prefix), judgedNothing: reportJudgedNothingFiles(prefix),
560
+ noManifest: reportNoManifestFiles(prefix), unreadable: reportUnreadableFiles(prefix) };
561
+ }
562
+
563
+ /**
564
+ * ⟨0.28⟩ Is there anything to disclose? **The trigger for an ANSWER, where a non-empty `unanalyzed` alone
565
+ * is the trigger for a VERDICT** — and the difference is an exit code, not a mood.
566
+ *
567
+ * `unverified --strict` / `fix-gate --strict` answer 2 ("the gate refuses over these bytes, so do I") off
568
+ * the `unanalyzed` arm — and ⟨0.28⟩ off the `unreadable` arm too, because `gate --report` REFUSES over a
569
+ * corrupt member (measured, exit 2) and §3.2's relation binds the exit as much as the document. ⟨0.24⟩
570
+ * ruled the count-0 arm explicitly the other way for exactly those bytes: it is *a disclosure, not an
571
+ * exit code* — `gate --report` exits 0 over a judged-nothing report, and a verb exiting 2 there would
572
+ * claim it got LESS far than the gate on identical input, the mirror of the over-claim the strict exit
573
+ * exists to prevent. So count-0 reaches both DISCLOSURE channels through this predicate and stops at the
574
+ * exit code; see `advisoryAnswer`, whose exit-bearing callers key `--strict` on manifest + unreadable.
575
+ */
576
+ export const mustHedge = (c) => !!(c && (c.unanalyzed?.length || c.judgedNothing?.length
577
+ || c.noManifest?.length || c.unreadable?.length));
578
+
579
+ /**
580
+ * ⟨0.28⟩ The disclosure KEYS, defined ONCE, for spreading into a verb's answer document — `{}` when there
581
+ * is nothing to disclose, so `{ ...data, ...completenessFields(c) }` is byte-identical to `data` on a
582
+ * complete report. (JS objects preserve insertion order, so the verb's own keys keep their pinned order
583
+ * and the caveat lands after them. candor-rust's first draft of this rung routed two verbs through a
584
+ * BTreeMap-backed serialiser to reach the same place and silently RE-SORTED both documents on ordinary
585
+ * runs — a disclosure rung must not reformat the answers it is disclosing about.)
586
+ *
587
+ * `incomplete: true` is the flag EITHER cause raises, so a consumer that only branches on it is safe under
588
+ * both; `judgedNothing`/`unanalyzed` name WHICH, because the two want different repairs — one wants a scan
589
+ * that can READ a file, the other a scan that reached a conclusion. Each is omitted when it does not
590
+ * apply, so a document raised by `unanalyzed` alone stays byte-identical to the pre-⟨0.28⟩ advisory shape.
591
+ *
592
+ * `judgedNothing` is the ARRAY OF REPORT PATHS, the shape rust, java and swift all emit — this engine
593
+ * first shipped `judgedNothing: true`, reusing the MCP gate tool's spelling, and a consumer doing
594
+ * `doc.judgedNothing.length` got a TypeError here while one doing `=== true` missed the other three.
595
+ * The array also answers a question the boolean cannot: WHICH report judged nothing, over a multi-report
596
+ * locator. The two spellings now deliberately differ because they are different surfaces: THIS key rides
597
+ * answer/advisory documents and is the cross-engine wire shape; the MCP gate tool's `judgedNothing: true`
598
+ * (mcp.mjs) is a flag on ONE gate verdict about ONE locator, where "which file" is carried by the
599
+ * adjacent ⟨0.24⟩ prose and no sibling engine serves that tool — the wire shape governs everywhere the
600
+ * engines can be diffed.
601
+ */
602
+ export function completenessFields(c) {
603
+ if (!mustHedge(c)) return {};
604
+ // `unreadable` raises the flag and adds NO key of its own — measured, rust and swift answer
605
+ // `incomplete: true` alone over a corrupt sibling, and a third spelling here would be the
606
+ // judgedNothing three-way split again, minted by the engine that was fixing it.
607
+ // ⟨0.28⟩ `noManifest` is the third NAMED cause, `omitted when empty` like the other two, and it RAISES
608
+ // `incomplete` exactly as they do — the flag stays the one key a consumer may branch on alone and be
609
+ // safe under every cause. It is separate from `judgedNothing` because that key is pinned to "reports
610
+ // declaring `analyzed.count: 0`" and a row-3 report declares nothing: merging them would make one key
611
+ // mean two things and lose the distinction §2's three-row table exists to draw.
612
+ return { incomplete: true,
613
+ ...(c.unanalyzed?.length ? { unanalyzed: c.unanalyzed } : {}),
614
+ ...(c.judgedNothing?.length ? { judgedNothing: c.judgedNothing } : {}),
615
+ ...(c.noManifest?.length ? { noManifest: c.noManifest } : {}) };
616
+ }
617
+
618
+ /**
619
+ * ⟨0.28⟩ Union in a SECOND locator's manifest — for `containment <baseline>`, whose answer is a DIFFERENCE
620
+ * and is therefore unsound if EITHER side is partial, in opposite directions: a leak living in an unread
621
+ * file of the CURRENT tree is missed (a false all-clear), while one living in an unread file of the
622
+ * BASELINE reads as newly appeared (a fabricated leak, at exit 1). One merged object rather than two
623
+ * notes, because the keys are fixed and a second write would overwrite the first side's manifest.
624
+ */
625
+ export const absorbCompleteness = (a, b) => ({
626
+ unanalyzed: [...(a.unanalyzed ?? []), ...(b.unanalyzed ?? [])],
627
+ judgedNothing: [...(a.judgedNothing ?? []), ...(b.judgedNothing ?? [])],
628
+ noManifest: [...(a.noManifest ?? []), ...(b.noManifest ?? [])],
629
+ unreadable: [...(a.unreadable ?? []), ...(b.unreadable ?? [])],
630
+ });
631
+
359
632
  /**
360
633
  * ⟨0.24⟩ SPEC §3.2 — THE OMIT-`ok` RULE, IN ONE PLACE so `unverified`, `fix-gate`, `whatif` and any later
361
634
  * sibling cannot drift apart on it. Over a report declaring `unanalyzed`, an advisory verb emits
@@ -387,13 +660,38 @@ export function reportUnanalyzed(prefix) {
387
660
  * the BODY rather than taken as a third parameter, so a verb that computes the disclosure cannot forget to
388
661
  * declare it — the ⟨0.24⟩ measurement that the CLI and MCP had drifted on exactly this began with a second
389
662
  * channel forgetting to pass an argument.
663
+ *
664
+ * ⟨0.28⟩ THE THIRD TRIGGER: `judgedNothing` — SPEC §2's `analyzed.count: 0` row, which this function did
665
+ * not read. MEASURED: over a report that judged nothing, `unverified` answered `{ok: true, unverified: []}`
666
+ * — the verb whose whole job is *"your green gate is not provably green"* certifying a package it never
667
+ * examined, from a report that names no unread FILE to trip the manifest arm. `ok` goes for the same
668
+ * reason it goes above: neither boolean is honest, and `if (r.ok)` must fail safe.
669
+ *
670
+ * AND THE EXIT CODE DOES NOT MOVE, which is the load-bearing half. Both `--strict` callers compute their
671
+ * exit from the MANIFEST alone, never from this document, because ⟨0.24⟩ ruled count-0 explicitly the other
672
+ * way: *"A DISCLOSURE, NOT AN EXIT CODE"*. `gate --report` exits 0 over these bytes, and an advisory verb
673
+ * exiting 2 there would claim it got LESS far than the gate on identical input — the mirror of the
674
+ * over-claim the strict exit exists to prevent. (`mustHedge` is the same distinction, stated for a verb
675
+ * that has no exit code for it to matter to.)
390
676
  */
391
- export function advisoryAnswer(body, unanalyzed) {
677
+ export function advisoryAnswer(body, unanalyzed, judgedNothing = [], unreadable = [], noManifest = []) {
392
678
  const unevaluated = body?.unevaluated;
393
- if (!unanalyzed?.length && !unevaluated?.length) return body; // COMPLETE: unchanged, byte for byte, `ok` and all.
679
+ if (!unanalyzed?.length && !unevaluated?.length && !judgedNothing?.length && !unreadable?.length
680
+ && !noManifest?.length)
681
+ return body; // COMPLETE: unchanged, byte for byte, `ok` and all.
394
682
  const { ok, ...rest } = body; // eslint-disable-line no-unused-vars -- omitted BY DESIGN
683
+ // The array of report paths, same key and same shape as `completenessFields` — ONE wire spelling for
684
+ // this key across the answer and advisory documents (see the shape ruling there). `unreadable` withdraws
685
+ // `ok` and raises `incomplete` with NO key of its own, exactly as `completenessFields` rules it.
686
+ // ⟨0.28⟩ `noManifest` DOES get a key of its own — SPEC §2 pins it — and it rides here for the same
687
+ // reason `judgedNothing` does: an advisory verb's `ok` is a claim about the CODE, and a report that
688
+ // never emitted a manifest cannot support it (row 3: *no manifest, no claim*).
689
+ const judged = { ...(judgedNothing?.length ? { judgedNothing } : {}),
690
+ ...(noManifest?.length ? { noManifest } : {}) };
395
691
  // Key order matches the gate's verdict document: the finding, then `unevaluated`, then the manifest.
396
- return unanalyzed?.length ? { ...rest, incomplete: true, unanalyzed } : rest;
692
+ if (unanalyzed?.length) return { ...rest, incomplete: true, unanalyzed, ...judged };
693
+ return (judgedNothing?.length || noManifest?.length || unreadable?.length)
694
+ ? { ...rest, incomplete: true, ...judged } : rest;
397
695
  }
398
696
 
399
697
  export function loadReport(prefix) {
@@ -546,6 +844,31 @@ export function loadCallgraph(prefix) {
546
844
  return tagPartial(norm(cg), partial);
547
845
  }
548
846
 
847
+ /**
848
+ * ⟨0.28⟩ The call graph EMBEDDED IN THE REPORT — each entry's §2 `calls` edges, keyed by fn. The
849
+ * fallback the graph verbs (`callers`/`impact`/`path`, CLI and MCP) run over when the §2.2 sidecar is
850
+ * absent, exactly as rust (callers.rs: "Fallback (no call-graph sidecar): build a graph from the
851
+ * report's effect-relevant `calls` edges and run the SAME query") and java do. Without it, a VALID
852
+ * report queried without its sidecar — a single hand-copied `report.json`, a locator §3.3.1 supports —
853
+ * answered `unanswerable` at exit 2 here while rust and java answered real callers at exit 0.
854
+ * `unanswerable` is for a graph that is genuinely ABSENT, not for one present by another route.
855
+ *
856
+ * EVERY entry is a key, even one with no calls — a leaf that performs its effect directly must still
857
+ * resolve as a target (rust's fallback map collects every entry). Same-named entries across merged
858
+ * workspace siblings UNION their edges (the effectsByFn lesson: last-wins silently drops a member's
859
+ * rows). Effect-relevant edges ONLY, so this graph under-approximates the sidecar: a pure fn appears at
860
+ * most as a CALLEE, never a key — which is why a no-match over the fallback is INCONCLUSIVE, not proof
861
+ * of absence (the callers verb keeps that distinction; rust corpus-audit #5).
862
+ */
863
+ export function reportCallsGraph(fns) {
864
+ const g = {};
865
+ for (const e of fns) {
866
+ const prev = g[e.fn];
867
+ g[e.fn] = prev ? [...new Set([...prev, ...(e.calls ?? [])])] : [...(e.calls ?? [])];
868
+ }
869
+ return g;
870
+ }
871
+
549
872
  // ---- the §3.1 match ladder: exact > segment-suffix > substring ------------------------------------
550
873
  function matchTier(name, q) {
551
874
  if (name === q) return 3;