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-core.mjs CHANGED
@@ -11,7 +11,8 @@
11
11
  import fs from "node:fs";
12
12
  import nodePath from "node:path";
13
13
  import { reasonClass, REASON_CLASSES, DYNAMIC_CLASSES, resolveReasonClasses, reasonClassesMatch,
14
- classFilterExcludes, netClassResolver, reportNetClasses, unanswerableScoped } from "./policy.mjs";
14
+ classFilterExcludes, netClassResolver, reportNetClasses, unanswerableScoped,
15
+ effectiveInferred, reportUnits } from "./policy.mjs";
15
16
 
16
17
  // Sibling report/callgraph files of a multi-report prefix (candor-scan writes <prefix>.<crate>.scan.json,
17
18
  // one per workspace member) — so the loaders read ANY engine's output, not just candor-ts's <prefix>.json.
@@ -37,7 +38,13 @@ function siblings(prefix, predicate) {
37
38
  // sibling is `.encountered-*`/`.calibrated.json` passes the existence check but loads ZERO functions →
38
39
  // an authoritative-empty result (silent under-report; review find). `.gate.json` has no functions array,
39
40
  // so merging it "disclosed a malformed report" on every query over the recommended CI layout — noisy, excluded.
40
- export const isReport = (f) => !f.endsWith(".callgraph.json") && !f.endsWith(".hierarchy.json") && !f.endsWith(".locs.json") && !f.includes(".encountered-") && !f.endsWith(".calibrated.json") && !f.endsWith(".gate.json");
41
+ // ⟨0.32⟩ `.refused.json` joins the reserved set (SPEC §2.2). It is a refusal MARKER, not a report — it
42
+ // carries no `functions` array, and a prefix whose only sibling is one would otherwise pass the existence
43
+ // check and load zero functions, which is the authoritative-empty silent under-report this predicate
44
+ // exists to prevent. CAUGHT BY PART 56 the moment ⟨0.32⟩ landed in this engine: a refusal "left a report",
45
+ // because discovery counted the marker the refusal had just written. Adding a file kind beside the
46
+ // reports means teaching discovery about it in the same change.
47
+ export const isReport = (f) => !f.endsWith(".callgraph.json") && !f.endsWith(".hierarchy.json") && !f.endsWith(".locs.json") && !f.includes(".encountered-") && !f.endsWith(".calibrated.json") && !f.endsWith(".gate.json") && !f.endsWith(".refused.json");
41
48
 
42
49
  // A report exists at the prefix if there's an exact `<prefix>.json` (candor-ts) OR a sibling
43
50
  // `<prefix>.<crate>.scan.json` (the candor-scan/Rust multi-report form) — the loaders read both, so a
@@ -560,6 +567,13 @@ export function reportCompleteness(prefix) {
560
567
  noManifest: reportNoManifestFiles(prefix),
561
568
  // ⟨0.30⟩ the peek's findings, so an advisory verb keying `--strict` on this object is at least
562
569
  // as pessimistic as the gate over the same bytes (the ⟨0.24⟩ MUST).
570
+ // ⟨0.32⟩ …and the classes the producing scan never OPENED. This IS an arm of `mustHedge`
571
+ // below — see the ruling there. It used to read *"NOT an arm … the verbs `mustHedge` serves
572
+ // carry no policy, so there is nothing to condition it on"*, and that was overturned four-way
573
+ // on 2026-08-24. The two verbs that DO carry a policy (`fix-gate`, `unverified`) still read
574
+ // this field directly for their EXIT code and apply the gate's own condition to it: only a
575
+ // `deny`/`pure` rule's answer depends on code outside the scan's scope.
576
+ unread: reportUnread(prefix),
563
577
  ...(() => {
564
578
  const o = reportOutOfScope(prefix);
565
579
  // A corrupt key rides `unreadable`, which is ALREADY an arm of the strict exit — so the
@@ -581,10 +595,46 @@ export function reportCompleteness(prefix) {
581
595
  * claim it got LESS far than the gate on identical input, the mirror of the over-claim the strict exit
582
596
  * exists to prevent. So count-0 reaches both DISCLOSURE channels through this predicate and stops at the
583
597
  * exit code; see `advisoryAnswer`, whose exit-bearing callers key `--strict` on manifest + unreadable.
598
+ *
599
+ * ⟨0.32⟩ **AND `unread` IS AN ARM OF THIS — RULED 2026-08-24 AFTER A FOUR-WAY DIVERGENCE. DO NOT
600
+ * RE-LITIGATE IT HERE.** Over a report whose `excluded` names a class the scan never opened, `tour`
601
+ * printed the bare *"nothing hidden — every effect sits where its name says it should"* at exit 0 in
602
+ * this engine, candor-rust and candor-swift, while candor-java hedged and named the class. **candor-java
603
+ * was right.**
604
+ *
605
+ * **IT IS A DISCLOSURE, NOT A VERDICT, AND IT MUST NOT MOVE AN EXIT CODE** — which is why the arm is
606
+ * here and nowhere near the `--strict` predicate. ⟨0.24⟩'s advisory-verb pessimism MUST binds verbs that
607
+ * answer `ok`; `tour` answers none and has no exit-code obligation, so that clause does not reach it.
608
+ * What reaches it is SPEC §2 ⟨0.28⟩, which widens the re-disclosure MUST to *"any verb whose output
609
+ * could be read as a negative finding about the code — a verdict, an empty result set, or a zero
610
+ * count"*, and SPEC §3.1 ⟨0.18⟩, which already forbids THIS EXACT SENTENCE over a ≥⅓-Unknown graph. An
611
+ * unread exclusion class is the same ignorance arriving by a different route, and the ⅓ threshold
612
+ * structurally CANNOT see it: an unread unit contributes no entry, so it moves neither the numerator
613
+ * nor the denominator.
614
+ *
615
+ * **AND THE ARGUMENT THAT KEPT IT OUT WAS THE WRONG WAY ROUND.** Three engines reasoned *"these verbs
616
+ * carry no policy, so there is nothing to condition it on"*. The condition ⟨0.32⟩ states is the QUESTION
617
+ * IN FORCE, and a verb with no policy is not asking a NARROWER question than `deny Exec` — it is asking
618
+ * the widest one there is, the whole effect surface. A `deny`/`pure` rule's answer can depend on unread
619
+ * code; an `allow`/`forbid`/`only`/`layer` policy's answer cannot; a descriptive verb's answer always
620
+ * can.
621
+ *
622
+ * **THE TRIGGER IS THE GATE'S, MINUS THE POLICY CONDITION**: `peeked !== true` with no
623
+ * `judgedElsewhere`, off the same key through the same reader (`reportUnread`), `count` IGNORED —
624
+ * measured 2026-08-24, all four gates refuse over a `count: 0` unread class and certify over a
625
+ * `judgedElsewhere: true` one. One matcher, so a report that earns an unhedged `tour` is exactly a
626
+ * report `gate --report` can certify. The NOISE objection — this fires on nearly every no-policy report
627
+ * — is real, and it is answered by the REMEDY rather than by silence: scan with the policy, the peek
628
+ * reads the class, `peeked` turns true and the hedge goes away.
629
+ *
630
+ * **KNOWN RESIDUAL, stated rather than asserted away:** `peeked: true` means the class was READ, not
631
+ * ANALYZED — the peek looks only for effects the PRODUCER's policy denied — so an undenied effect inside
632
+ * a peeked class is still outside `tour`'s graph and outside this hedge. That is the gate's residual
633
+ * too (SPEC §2 ⟨0.32⟩ files it), and closing it is a rung, not a fix.
584
634
  */
585
635
  export const mustHedge = (c) => !!(c && (c.unanalyzed?.length || c.judgedNothing?.length
586
636
  || c.noManifest?.length || c.unreadable?.length
587
- || c.outOfScope?.length));
637
+ || c.outOfScope?.length || c.unread?.length));
588
638
 
589
639
  /** ⟨0.30⟩ The peek's findings across the reports under a locator. Read leniently HERE (a malformed key is
590
640
  * the gate's refusal to make, and this feeds a disclosure) but non-emptiness raises the same hedge the
@@ -616,6 +666,29 @@ export function reportOutOfScope(prefix) {
616
666
  return { findings: out, corrupt };
617
667
  }
618
668
 
669
+ /** ⟨0.32⟩ The classes the producing scan never OPENED, across the reports under a locator — `excluded[]`
670
+ * entries that are neither `peeked` nor `judgedElsewhere`, deduped, in report order.
671
+ *
672
+ * LENIENT, like `reportOutOfScope` beside it and for the same reason: a malformed key is the GATE's
673
+ * refusal to make (it reads the same bytes strictly, in `loadGateReport`, and NAMES the key), and this
674
+ * feeds a disclosure. A non-boolean `peeked` is skipped here rather than trusted, so the leniency never
675
+ * runs in the fail-open direction — `"peeked": "no"` is TRUTHY in this language, and a lenient reader that
676
+ * simply believed it would turn an unread class into a peeked one. */
677
+ export function reportUnread(prefix) {
678
+ const files = (prefix.endsWith(".json") && fs.existsSync(prefix)) ? [prefix] : reportFilesAt(prefix);
679
+ const out = new Set();
680
+ for (const f of files) {
681
+ try {
682
+ const d = JSON.parse(fs.readFileSync(f, "utf8"));
683
+ if (!Array.isArray(d?.excluded)) continue;
684
+ for (const e of d.excluded)
685
+ if (e && typeof e === "object" && typeof e.class === "string" && e.class
686
+ && e.peeked !== true && e.judgedElsewhere !== true) out.add(e.class);
687
+ } catch { /* unparseable TEXT is `unreadable`'s business, not this key's */ }
688
+ }
689
+ return [...out];
690
+ }
691
+
619
692
  /**
620
693
  * ⟨0.28⟩ The disclosure KEYS, defined ONCE, for spreading into a verb's answer document — `{}` when there
621
694
  * is nothing to disclose, so `{ ...data, ...completenessFields(c) }` is byte-identical to `data` on a
@@ -649,6 +722,13 @@ export function completenessFields(c) {
649
722
  // safe under every cause. It is separate from `judgedNothing` because that key is pinned to "reports
650
723
  // declaring `analyzed.count: 0`" and a row-3 report declares nothing: merging them would make one key
651
724
  // mean two things and lose the distinction §2's three-row table exists to draw.
725
+ // ⟨0.32⟩ `unread` (an exclusion class nothing opened) raises the flag and adds NO key of its own
726
+ // either, by the same rule as `unreadable` one comment up — and deliberately, now that it hedges the
727
+ // descriptive verbs. candor-rust publishes an `unread` array on its ADVISORY documents and is the only
728
+ // engine that does (measured 2026-08-24: `fix-gate --json` over one unread report — rust
729
+ // `{"incomplete": true, "unread": […]}`, java and swift `{"incomplete": true}` alone). Minting it here,
730
+ // on six verbs where three engines publish nothing, is §3.3.1's "four independent guesses with a
731
+ // conformance failure scheduled". The prose note names the class; the flag is the wire surface.
652
732
  return { incomplete: true,
653
733
  ...(c.unanalyzed?.length ? { unanalyzed: c.unanalyzed } : {}),
654
734
  ...(c.judgedNothing?.length ? { judgedNothing: c.judgedNothing } : {}),
@@ -714,10 +794,11 @@ export const absorbCompleteness = (a, b) => ({
714
794
  * over-claim the strict exit exists to prevent. (`mustHedge` is the same distinction, stated for a verb
715
795
  * that has no exit code for it to matter to.)
716
796
  */
717
- export function advisoryAnswer(body, unanalyzed, judgedNothing = [], unreadable = [], noManifest = []) {
797
+ export function advisoryAnswer(body, unanalyzed, judgedNothing = [], unreadable = [], noManifest = [],
798
+ outOfScope = [], unread = []) {
718
799
  const unevaluated = body?.unevaluated;
719
800
  if (!unanalyzed?.length && !unevaluated?.length && !judgedNothing?.length && !unreadable?.length
720
- && !noManifest?.length)
801
+ && !noManifest?.length && !outOfScope?.length && !unread?.length)
721
802
  return body; // COMPLETE: unchanged, byte for byte, `ok` and all.
722
803
  const { ok, ...rest } = body; // eslint-disable-line no-unused-vars -- omitted BY DESIGN
723
804
  // The array of report paths, same key and same shape as `completenessFields` — ONE wire spelling for
@@ -728,10 +809,19 @@ export function advisoryAnswer(body, unanalyzed, judgedNothing = [], unreadable
728
809
  // never emitted a manifest cannot support it (row 3: *no manifest, no claim*).
729
810
  const judged = { ...(judgedNothing?.length ? { judgedNothing } : {}),
730
811
  ...(noManifest?.length ? { noManifest } : {}) };
812
+ // ⟨0.30⟩/⟨0.32⟩ THE TWO SCOPE CAUSES — the ones the `--strict` exit has consulted since ⟨0.30⟩ while this
813
+ // document did not, which is the document/exit split in its worse direction. MEASURED 2026-08-24 on
814
+ // `fix-gate --strict` and `unverified --strict` over a report carrying `outOfScope`: BOTH printed
815
+ // `{"ok": true, …: []}` **at exit 2** — the exit was right and the document certified. A CI wrapper reads
816
+ // the exit; an agent reads the document, and the agent channel is the one that cannot ask a follow-up
817
+ // question. Same keys and same order as the gate's verdict document, so one consumer parses both.
818
+ const scope = { ...(outOfScope?.length ? { outOfScope } : {}),
819
+ ...(unread?.length ? { unread } : {}) };
731
820
  // Key order matches the gate's verdict document: the finding, then `unevaluated`, then the manifest.
732
- if (unanalyzed?.length) return { ...rest, incomplete: true, unanalyzed, ...judged };
733
- return (judgedNothing?.length || noManifest?.length || unreadable?.length)
734
- ? { ...rest, incomplete: true, ...judged } : rest;
821
+ if (unanalyzed?.length) return { ...rest, incomplete: true, unanalyzed, ...scope, ...judged };
822
+ return (judgedNothing?.length || noManifest?.length || unreadable?.length
823
+ || outOfScope?.length || unread?.length)
824
+ ? { ...rest, incomplete: true, ...scope, ...judged } : rest;
735
825
  }
736
826
 
737
827
  export function loadReport(prefix) {
@@ -771,7 +861,7 @@ export function loadReport(prefix) {
771
861
  * `net-partner` re-mapping of the `netClass` this report already states. The reach the reason-class
772
862
  * fixpoint runs over is the entries' OWN §2 `calls` field — report data in, report data out.
773
863
  *
774
- * Returns `{functions, analyzed, unanalyzed, coverage, judgedNothing, hardFail, corrupt}`. `hardFail`
864
+ * Returns `{functions, analyzed, unanalyzed, coverage, judgedNothing, unread, hardFail, corrupt}`. `hardFail`
775
865
  * carries the `loadReport` meaning exactly: a file was FOUND and yielded nothing trustworthy (never "there
776
866
  * was no file" — the caller's `requireReport` owns that), so a corrupt report can be refused instead of
777
867
  * gated green. `corrupt` NAMES every present-but-unparseable §2 key found (SPEC §2 ⟨0.24⟩ requires the
@@ -780,7 +870,8 @@ export function loadReport(prefix) {
780
870
  */
781
871
  export function loadGateReport(prefix) {
782
872
  const files = reportFilesAt(prefix);
783
- const functions = [], unanalyzed = [], cov = new Map(), corrupt = [], outOfScope = [];
873
+ const functions = [], unanalyzed = [], cov = new Map(), corrupt = [], outOfScope = [], netPartners = [],
874
+ unread = [];
784
875
  let hardFail = false, analyzed = 0;
785
876
  // ⟨0.24⟩ did the report handed to the gate judge ANYTHING? Per FILE, then ANDed across the multi-report
786
877
  // siblings, because the union of several reports has judged something as soon as ONE of them has — the
@@ -858,6 +949,61 @@ export function loadGateReport(prefix) {
858
949
  if (e && typeof e === "object" && Array.isArray(e.effects) && e.effects.length)
859
950
  outOfScope.push(e);
860
951
  else corrupt.push(`${f}: \`outOfScope\` (an element is not an object carrying a non-empty \`effects\`)`);
952
+ // ⟨0.32⟩ THE CLASSES THE PRODUCER DID NOT READ, off `excluded[].peeked` — the ⟨0.32⟩ unread-code rule,
953
+ // which until now existed ONLY in scan.mjs. A user who scans in CI and gates the artifact later got a
954
+ // green over code nobody had opened; MEASURED at HEAD before this line existed, on a tree whose
955
+ // excluded `dist/shipped.js` runs `execSync`: `candor-ts <tree> --policy 'deny Exec'` exits 2 naming
956
+ // the function, while gating the report of a bare `candor-ts <tree> --out N` under the SAME policy
957
+ // exited 0 with `ok: true` and no disclosure.
958
+ //
959
+ // THE RULE, stated here and applied ONCE in query.mjs's gate: a class the producing scan did not READ
960
+ // licenses nothing, and whether that matters is decided by the policy applied NOW, not by the
961
+ // producer's history. `peeked: false` genuinely has two causes — "opened it and failed" and "never
962
+ // asked" — but from a REPORT they are indistinguishable, because they leave the identical hole: those
963
+ // files' effects are absent from `functions` because nothing looked, and ⟨0.21⟩ licenses a purity
964
+ // claim only over units the scan actually judged. The carve-out is about the QUESTION (only a
965
+ // `deny`/`pure` rule's answer depends on code outside the scan's scope), not about the history.
966
+ //
967
+ // `judgedElsewhere` is the PRODUCER's carve-out for a derived copy of already-judged code, and only
968
+ // the producer may set it: the class TOKENS are engine-chosen (`build-output` here, `build-output-
969
+ // archive` in candor-java), so a consumer inferring "derived" from a name would gate another engine's
970
+ // report differently from the engine that wrote it.
971
+ //
972
+ // STRICT, like every other verdict-bearing key on this route: `excluded` now MOVES the verdict, so a
973
+ // present-but-unparseable one is corrupt input NAMED in the refusal, never coerced to `[]` — the
974
+ // safe-LOOKING value, which is the claim "this scan excluded nothing" and deletes the rule. `peeked`
975
+ // is the sharpest case the language offers: `"peeked": "no"` is TRUTHY, so an unchecked read turns an
976
+ // unread class into a peeked one. ABSENT stays permissive: `excluded` is a ⟨0.29⟩ key, and refusing
977
+ // over its absence would refuse every report an older producer ever wrote.
978
+ const exc = parsed.excluded;
979
+ if (exc !== undefined && !Array.isArray(exc))
980
+ corrupt.push(`${f}: \`excluded\` is present and is not a list`);
981
+ else if (Array.isArray(exc))
982
+ for (const e of exc) {
983
+ if (!e || typeof e !== "object" || Array.isArray(e) || typeof e.class !== "string" || !e.class
984
+ || ("peeked" in e && typeof e.peeked !== "boolean")
985
+ || ("judgedElsewhere" in e && typeof e.judgedElsewhere !== "boolean")
986
+ || ("count" in e && !(typeof e.count === "number" && Number.isInteger(e.count) && e.count >= 0)))
987
+ corrupt.push(`${f}: \`excluded\` (an element is not \`{class, count, peeked, reason}\` — `
988
+ + `\`class\` a non-empty string, \`count\` a non-negative integer, \`peeked\`/\`judgedElsewhere\` booleans)`);
989
+ // An ABSENT `peeked` reads as FALSE, which is the fail-closed direction and what candor-rust's
990
+ // `#[serde(default)]` does on the same field: a producer that does not say it read the class has
991
+ // not said it read the class.
992
+ else if (e.peeked !== true && e.judgedElsewhere !== true) unread.push(e.class);
993
+ }
994
+ // ⟨0.31⟩ the producer's PARTNER PROVENANCE, carried through verbatim and never recomputed — this
995
+ // route has no target to anchor `net-partner` at, and re-classifying through the consumer's own
996
+ // config is the re-derivation §3.1 forbids. A prefix can match several reports (a workspace writes
997
+ // one per member), each anchoring its own config, so this is collected as a LIST even though a
998
+ // single report carries a single record. Shape-checked like every other key read off the wire: a
999
+ // malformed one is corruption, not something to quietly drop.
1000
+ const nps = parsed.netPartners;
1001
+ if (nps !== undefined) {
1002
+ if (nps && typeof nps === "object" && !Array.isArray(nps)
1003
+ && typeof nps.config === "string" && Array.isArray(nps.hosts))
1004
+ netPartners.push({ config: nps.config, hosts: nps.hosts.map(String) });
1005
+ else corrupt.push(`${f}: \`netPartners\` is present and is not { config: string, hosts: [string] }`);
1006
+ }
861
1007
  // ⟨0.15⟩ the κ ledger. Merged + re-sorted the PRODUCER's way (count desc, name asc by code point —
862
1008
  // reportCoverage's rule, and scan.mjs's), so a single-report prefix reproduces the emitted order exactly.
863
1009
  const unc = parsed.coverage?.uncovered;
@@ -870,7 +1016,12 @@ export function loadGateReport(prefix) {
870
1016
  }
871
1017
  const coverage = [...cov.entries()].sort((a, b) => b[1] - a[1] || byCodePoint(a[0], b[0]))
872
1018
  .map(([name, calls]) => ({ name, calls }));
873
- return { functions, analyzed, unanalyzed, coverage, judgedNothing, outOfScope, hardFail: hardFail || corrupt.length > 0, corrupt };
1019
+ // ⟨0.32⟩ `unread` is DEDUPED: a class is the unit of the answer (the producer publishes one entry per
1020
+ // class), and a multi-report prefix — a workspace writing one report per member — otherwise repeats the
1021
+ // same class once per member in a message whose job is to name what to re-scan. The verdict is unmoved
1022
+ // either way; only the sentence is.
1023
+ return { functions, analyzed, unanalyzed, coverage, judgedNothing, outOfScope, netPartners,
1024
+ unread: [...new Set(unread)], hardFail: hardFail || corrupt.length > 0, corrupt };
874
1025
  }
875
1026
  // The returned graph carries a non-enumerable `partial` flag (the loadReport `hardFail` precedent):
876
1027
  // true iff a sidecar file was MATCHED but failed to read/parse — its edges were DROPPED (disclosed on
@@ -1563,16 +1714,38 @@ export function whatif(cg, target, eff, policyParsed, scopeMatches) {
1563
1714
  // here, and the caller must DISCLOSE it rather than compute a remedy from it. `continue` is the whole
1564
1715
  // mechanism: the rule is skipped, a later rule may still deny, and if none does, `deniedLayer` returns null
1565
1716
  // and the caller reads `ctx.held(fn, eff)` to tell "nothing forbids this" from "the gate could not say".
1566
- function deniedLayer(fn, eff, policyParsed, scopeMatches, ctx = null) {
1717
+ function denyingRule(fn, eff, policyParsed, scopeMatches, ctx = null, unit = null) {
1718
+ const entry = unit ?? (ctx ? ctx.entry(fn) : null);
1567
1719
  for (const r of policyParsed.deny) {
1568
1720
  const denies = r.effects.length === 0 ? eff !== "Unknown" : r.effects.includes(eff);
1569
1721
  if (!denies || (r.scope && !scopeMatches(fn, r.scope))) continue;
1570
- if (ctx && classFilterExcludes(r, ctx.entry(fn), eff, ctx.reasonAcc, ctx.netClassOf)) continue;
1571
- if (ctx?.withhold && ctx.withhold(r, fn, eff)) continue;
1572
- return r.scope ?? "";
1722
+ if (ctx && classFilterExcludes(r, entry, eff, ctx.reasonAcc, ctx.netClassOf, ctx.units)) continue;
1723
+ if (ctx?.withhold && ctx.withhold(r, entry ?? fn, eff)) continue;
1724
+ return r;
1573
1725
  }
1574
1726
  return null;
1575
1727
  }
1728
+ function deniedLayer(fn, eff, policyParsed, scopeMatches, ctx = null, unit = null) {
1729
+ const r = denyingRule(fn, eff, policyParsed, scopeMatches, ctx, unit);
1730
+ return r === null ? null : (r.scope ?? "");
1731
+ }
1732
+
1733
+ /**
1734
+ * ⟨0.32⟩ An effect the MERGE contributed rather than the report — today only the `Unknown` a unit acquires
1735
+ * when a callee NAME it declares is declared by two or more units (SPEC §2.2; see `reportUnits`). There is
1736
+ * no `direct` site for it and therefore no span to hoist, so `fix`/`fix-gate` must DISCLOSE it rather than
1737
+ * invent a remedy — and must not fall silent either, or the gate charges a violation while the verb that
1738
+ * exists to fix it answers "nothing left to fix". That silence is what the ⟨0.24⟩ withhold ruling calls
1739
+ * trading a fabricated instruction for a false all-clear.
1740
+ */
1741
+ const mergeContributedHold = (entry, rule) => ({
1742
+ fn: entry.fn, rule: rule.raw, effect: "Unknown",
1743
+ why: `\`${rule.raw.trim()}\` fires on \`${entry.fn}\`, but the Unknown it fires on is this MERGE's: a `
1744
+ + "callee this entry names is declared by two or more units in the report set, so the reach cannot be "
1745
+ + "resolved and no site can be named (SPEC §2.2 — a consumer joins by `hash`, never by bare `fn`). "
1746
+ + "There is nothing to hoist, so NO remedy is offered; the gate still reports the violation. Gate at "
1747
+ + "scan time (candor-ts <src> --policy <file>), or gate the member's report on its own."
1748
+ });
1576
1749
 
1577
1750
  /**
1578
1751
  * ⟨0.24⟩ The narrowing context the class filter needs, over a LOADED report: a function's transitive
@@ -1620,14 +1793,20 @@ function deniedLayer(fn, eff, policyParsed, scopeMatches, ctx = null) {
1620
1793
  * whether or not the caller loaded a callgraph — which is why MCP's `candor_unverified`, which loads no
1621
1794
  * sidecar, gets the same narrowing as the CLI.
1622
1795
  */
1623
- export function narrowingContext(fns, cg = {}, policyParsed = null) {
1796
+ export function narrowingContext(fns, cg = {}, policyParsed = null, units = null) {
1624
1797
  const byName = indexFns(fns);
1625
- const reasonAcc = resolveReasonClasses(fns, cg);
1798
+ // ⟨0.32⟩ the unit identity the GATE uses over these same bytes — `null` is the identity, which is what
1799
+ // the scan-time note in scan.mjs wants. `uk` is how every accumulator on this page is keyed; `byName`
1800
+ // stays a NAME index, because the advisory verbs are called with the names a human typed.
1801
+ const uk = (e) => (units ? units.key(e) : e?.fn);
1802
+ /** A unit key from an ENTRY (exact) or from a NAME (resolved through the name index — see `classesOf`). */
1803
+ const unitOf = (x) => uk(typeof x === "string" ? (byName.get(x) ?? { fn: x }) : x);
1804
+ const reasonAcc = resolveReasonClasses(fns, cg, units);
1626
1805
  // VERBATIM OFF THE WIRE: every `Net`-bearing entry maps to its `netClass` or to the EMPTY set, so the
1627
1806
  // derivation is unreachable and the report is the only source of the class (reportNetClasses' argument).
1628
- const netMap = reportNetClasses(fns, { authoritative: true });
1807
+ const netMap = reportNetClasses(fns, { authoritative: true, units });
1629
1808
  const { unevaluated, withhold } = policyParsed
1630
- ? unanswerableScoped(policyParsed, fns, reasonAcc, netMap)
1809
+ ? unanswerableScoped(policyParsed, fns, reasonAcc, netMap, units)
1631
1810
  : { unevaluated: [], withhold: null };
1632
1811
  // The disclosure, keyed by FUNCTION. `unanswerableScoped` groups by RULE because that is the granularity
1633
1812
  // a gate verdict lists; an advisory verb names FUNCTIONS, so the same triples are indexed both ways here
@@ -1637,22 +1816,33 @@ export function narrowingContext(fns, cg = {}, policyParsed = null) {
1637
1816
  if (withhold)
1638
1817
  for (const f of fns)
1639
1818
  for (const r of policyParsed.deny)
1640
- for (const eff of f.inferred ?? []) {
1641
- if (!withhold(r, f.fn, eff)) continue;
1819
+ for (const eff of effectiveInferred(f, reasonAcc, units)) {
1820
+ if (!withhold(r, uk(f), eff)) continue;
1642
1821
  const u = byRaw.get(r.raw);
1643
1822
  if (!u) continue; // unreachable: every held triple has a group
1644
- const cur = heldByFn.get(f.fn) ?? new Map();
1823
+ // ⟨0.32⟩ indexed by UNIT, read back by unit — `held(fn)` still accepts a name for the
1824
+ // name-driven verbs, and `held(entry)` is exact where the caller is iterating entries.
1825
+ const cur = heldByFn.get(uk(f)) ?? new Map();
1645
1826
  cur.set(`${r.raw}\0${eff}`, { fn: f.fn, rule: r.raw, effect: eff, why: u.why });
1646
- heldByFn.set(f.fn, cur);
1827
+ heldByFn.set(uk(f), cur);
1647
1828
  }
1648
1829
  return {
1649
1830
  reasonAcc,
1650
- netClassOf: netClassResolver(new Map(), new Set(), netMap),
1831
+ netClassOf: netClassResolver(new Map(), new Set(), netMap, units),
1651
1832
  entry: (fn) => byName.get(fn) ?? null,
1652
- withhold,
1833
+ units,
1834
+ // ⟨0.32⟩ THE UNIT, FROM AN ENTRY WHERE THE CALLER HAS ONE AND FROM A NAME WHERE IT DOES NOT. The
1835
+ // advisory verbs are driven by names a human typed, so a name has to resolve; but a verb ITERATING
1836
+ // entries must pass the entry, or two same-named units both resolve to whichever one `byName` kept and
1837
+ // one of them is answered for by the other — the same §2.2 harm this rung closes, one layer out.
1838
+ classesOf: (x) => reasonAcc.get(unitOf(x)),
1839
+ // The withhold is keyed by UNIT (unanswerableScoped); resolving here rather than at each call site is
1840
+ // deliberate, because a name-keyed probe of a unit-keyed set does not error — it answers `false`,
1841
+ // which reads as "the gate could judge this" and puts back the fabricated remedy it exists to suppress.
1842
+ withhold: withhold ? (r, x, eff) => withhold(r, unitOf(x), eff) : null,
1653
1843
  unevaluated,
1654
- held: (fn, eff = null) =>
1655
- [...(heldByFn.get(fn)?.values() ?? [])].filter((h) => eff === null || h.effect === eff),
1844
+ held: (x, eff = null) =>
1845
+ [...(heldByFn.get(unitOf(x))?.values() ?? [])].filter((h) => eff === null || h.effect === eff),
1656
1846
  };
1657
1847
  }
1658
1848
 
@@ -1753,7 +1943,7 @@ function computeRemedy(start, eff, layer, cg, rev, byName, policyParsed, scopeMa
1753
1943
  // `107755b`): `refused: true`, the `unevaluated` disclosure, and **NO `crossing` KEY AT ALL** — absent, not
1754
1944
  // `false`, because a claim it cannot make must not be spelled as a claim it can.
1755
1945
  export function fix(cg, fns, target, eff, policyParsed, scopeMatches) {
1756
- const ctx = narrowingContext(fns, cg, policyParsed); // ⟨0.24⟩ the rule's class filter + the gate's refusal
1946
+ const ctx = narrowingContext(fns, cg, policyParsed, reportUnits(fns)); // ⟨0.24⟩ the rule's class filter + the gate's refusal
1757
1947
  // Resolve against REPORT function names only (not callgraph nodes, which include pure fns absent from the
1758
1948
  // report) — so `fix <pure-fn>` is a uniform "no such fn" across engines, not a TS-only crossing:false.
1759
1949
  // (/code-review — candor-query/java/swift all match report fns only.)
@@ -1763,9 +1953,14 @@ export function fix(cg, fns, target, eff, policyParsed, scopeMatches) {
1763
1953
  // prefer a match that actually performs the effect, so a bare leaf resolves to the violating function
1764
1954
  const start = m.find((n) => (byName.get(n)?.inferred ?? []).includes(eff)) ?? m[0];
1765
1955
  const se = byName.get(start);
1766
- if (!se || !(se.inferred ?? []).includes(eff))
1956
+ if (!se || !effectiveInferred(se, ctx.reasonAcc, ctx.units).includes(eff))
1767
1957
  return { fn: start, effect: eff, crossing: false, reason: "does-not-perform" };
1768
- const layer = deniedLayer(start, eff, policyParsed, scopeMatches, ctx);
1958
+ // ⟨0.32⟩ the same split `fix-gate` makes: an effect the MERGE contributed has no site to hoist, so it is
1959
+ // disclosed as unevaluated rather than answered `not-forbidden` (which would read as a clean boundary).
1960
+ const mcr = (se.inferred ?? []).includes(eff)
1961
+ ? null : denyingRule(start, eff, policyParsed, scopeMatches, ctx, se);
1962
+ if (mcr) return { fn: start, effect: eff, refused: true, unevaluated: unevaluatedOf([mergeContributedHold(se, mcr)]) };
1963
+ const layer = deniedLayer(start, eff, policyParsed, scopeMatches, ctx, se);
1769
1964
  if (layer === null) {
1770
1965
  // ORDER MATTERS: the withhold is checked only where NO rule denied, because a rule that fires on
1771
1966
  // evidence the report DOES carry is certain and dominates (PAPER3 Lemma 2 — the same precedence the
@@ -1792,7 +1987,7 @@ export function fix(cg, fns, target, eff, policyParsed, scopeMatches) {
1792
1987
  // EITHER. Dropping the plan without the disclosure would trade a fabricated instruction for a false
1793
1988
  // all-clear, which is the failure this project keeps measuring in fabrication repairs.
1794
1989
  export function fixGate(cg, fns, policyParsed, scopeMatches) {
1795
- const ctx = narrowingContext(fns, cg, policyParsed); // ⟨0.24⟩ the class filter + the gate's refusal
1990
+ const ctx = narrowingContext(fns, cg, policyParsed, reportUnits(fns)); // ⟨0.24⟩ the class filter + the gate's refusal
1796
1991
  const byName = indexFns(fns);
1797
1992
  const rev = reverseGraph(cg);
1798
1993
  const plans = new Map();
@@ -1800,14 +1995,20 @@ export function fixGate(cg, fns, policyParsed, scopeMatches) {
1800
1995
  // Iterate functions in sorted-name order so the first-writer-wins `fn` representative of a collapsed
1801
1996
  // remedy is deterministic across engines (candor-query/java/swift all iterate a sorted key set).
1802
1997
  for (const e of [...fns].sort((a, b) => (a.fn < b.fn ? -1 : a.fn > b.fn ? 1 : 0))) {
1803
- for (const eff of [...(e.inferred ?? [])].sort()) {
1804
- const layer = deniedLayer(e.fn, eff, policyParsed, scopeMatches, ctx);
1805
- if (layer !== null) {
1806
- const p = computeRemedy(e.fn, eff, layer, cg, rev, byName, policyParsed, scopeMatches, ctx);
1998
+ // ⟨0.32⟩ THE GATE'S EFFECT SET, not the wire's — this verb has to see every effect `evaluatePolicy`
1999
+ // sees or it answers "nothing left to fix" over a violation the gate reports (measured: gate exit 1,
2000
+ // `fix-gate --strict` exit 0 with `ok: true`). `wire` separates the two so the merge's own
2001
+ // contribution is DISCLOSED rather than remedied; see `mergeContributedHold`.
2002
+ const wire = new Set(e.inferred ?? []);
2003
+ for (const eff of [...effectiveInferred(e, ctx.reasonAcc, ctx.units)].sort()) {
2004
+ const r = denyingRule(e.fn, eff, policyParsed, scopeMatches, ctx, e);
2005
+ if (r !== null) {
2006
+ if (!wire.has(eff)) { held.push(mergeContributedHold(e, r)); continue; }
2007
+ const p = computeRemedy(e.fn, eff, r.scope ?? "", cg, rev, byName, policyParsed, scopeMatches, ctx);
1807
2008
  const key = `${p.effect}|${p.layer}|${p.site}|${p.hoistTo}`;
1808
2009
  if (!plans.has(key)) plans.set(key, p);
1809
2010
  } else {
1810
- held.push(...ctx.held(e.fn, eff)); // see `fix`: only where nothing certain denied
2011
+ held.push(...ctx.held(e, eff)); // see `fix`: only where nothing certain denied
1811
2012
  }
1812
2013
  }
1813
2014
  }
@@ -1885,14 +2086,16 @@ export function ruleUpgrade(r) {
1885
2086
  * a violation would silently drop the function from this list on the strength of a violation nobody made,
1886
2087
  * which is the same silence in a second place. Withheld ⇒ not a firing ⇒ the function stays a hole here,
1887
2088
  * the LESS-confident direction, which is the one this verb is allowed to move in. */
1888
- export function unverifiedHoleRule(fn, inferred, policyParsed, scopeMatches, ctx = null) {
2089
+ export function unverifiedHoleRule(fn, inferred, policyParsed, scopeMatches, ctx = null, unit = null) {
1889
2090
  const inf = inferred ?? [];
1890
2091
  if (!inf.includes("Unknown")) return null;
1891
- const entry = ctx ? ctx.entry(fn) : null;
2092
+ // ⟨0.32⟩ `unit` is the ENTRY when the caller is iterating entries (two units may share `fn`, and the
2093
+ // name index keeps only one of them); it falls back to the name lookup for the name-driven callers.
2094
+ const entry = unit ?? (ctx ? ctx.entry(fn) : null);
1892
2095
  // An effect the rule NAMES but whose class filter excludes here is not a violation — it is exactly what
1893
2096
  // the gate tolerates, so the pass is real, and the Unknown it passes with makes that pass unverified.
1894
- const fires = (r, x) => !(ctx && classFilterExcludes(r, entry, x, ctx.reasonAcc, ctx.netClassOf))
1895
- && !(ctx?.withhold && ctx.withhold(r, fn, x));
2097
+ const fires = (r, x) => !(ctx && classFilterExcludes(r, entry, x, ctx.reasonAcc, ctx.netClassOf, ctx.units))
2098
+ && !(ctx?.withhold && ctx.withhold(r, entry ?? fn, x));
1896
2099
  for (const r of policyParsed.deny) {
1897
2100
  if (r.scope && !scopeMatches(fn, r.scope)) continue;
1898
2101
  const violates = r.effects.length === 0
@@ -1935,20 +2138,22 @@ export function unverified(fns, policyParsed, scopeMatches, classSpec = null, cg
1935
2138
  // ⟨0.24⟩ the POLICY's own `Unknown[…]`/`Net[…]` narrowing, which is a different question from `--class`
1936
2139
  // (the reader's drill-down) and was never asked at all: `reasonAcc` here rides the ctx, so the two
1937
2140
  // narrowings resolve the class set exactly once and from the same code as the gate.
1938
- const ctx = narrowingContext(fns, cg, policyParsed);
2141
+ const ctx = narrowingContext(fns, cg, policyParsed, reportUnits(fns));
1939
2142
  const holes = [];
1940
2143
  const held = [];
1941
2144
  for (const e of fns) {
1942
2145
  // Same predicate + upgrade as the gate note (scan.mjs) — one source of truth for a hole.
1943
- const r = unverifiedHoleRule(e.fn, e.inferred, policyParsed, scopeMatches, ctx);
1944
- if (r && !(cf && !reasonClassesMatch(ctx.reasonAcc.get(e.fn), cf))) {
2146
+ // ⟨0.32⟩ the effect set the GATE reads, not the raw wire one — an entry whose callee name the merge
2147
+ // could not resolve carries `Unknown` here too, or this verb would clear a hole the gate refuses.
2148
+ const r = unverifiedHoleRule(e.fn, effectiveInferred(e, ctx.reasonAcc, ctx.units), policyParsed, scopeMatches, ctx, e);
2149
+ if (r && !(cf && !reasonClassesMatch(ctx.classesOf(e), cf))) {
1945
2150
  const [rule, upgrade] = ruleUpgrade(r);
1946
2151
  holes.push({ fn: e.fn, rule, unknownWhy: e.unknownWhy ?? [], upgrade });
1947
2152
  }
1948
2153
  // …and, INDEPENDENTLY of whether it is also a provable-purity hole, every rule the gate withheld on
1949
2154
  // this function: one entry per (function, rule), in report order, beside the holes rather than in a
1950
2155
  // separate array a consumer reading `unverified[]` would never look at.
1951
- const h = ctx.held(e.fn);
2156
+ const h = ctx.held(e);
1952
2157
  for (const u of unevaluatedOf(h)) holes.push({ fn: e.fn, rule: u.rule, why: u.why });
1953
2158
  held.push(...h);
1954
2159
  }