candor-ts 0.32.1 → 0.33.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/AGENTS.md CHANGED
@@ -21,7 +21,7 @@ the TypeScript-specific production + query surface.
21
21
  > **Already installed? Report the version and ask before upgrading — before you scan.** If this
22
22
  > project already has candor (a `.candor/` report directory, or `candor-ts` reachable via `npx`/a
23
23
  > global install), do this *first*: run `npx -y candor-ts --version` (offline) and **tell the user
24
- > plainly which version they're on** — e.g. *"This project is on candor-ts `<version>` (spec 0.32)."*
24
+ > plainly which version they're on** — e.g. *"This project is on candor-ts `<version>` (spec 0.33)."*
25
25
  > On a build too old for the flag, read `candor.version` / `candor.spec` from an existing
26
26
  > `.candor/report*.json`, or `npm ls -g candor-ts`.
27
27
  >
package/README.md CHANGED
@@ -198,7 +198,7 @@ pure-vs-Unknown ruling (PART 16) — the engines must answer identically, on eve
198
198
  | A call resolving to a *type* (function-typed field/param) → `Unknown`, never silent-pure | SPEC §4 |
199
199
  | Unmatched external calls contribute nothing (curated-classifier caveat) | SEMANTICS §8 C1 |
200
200
  | The literal surfaces `hosts`/`cmds`/`paths`/`tables`, literal-read only | SPEC §2 |
201
- | `{ candor: { version, toolchain, spec: "0.32" }, functions }` envelope; pure fns omitted | SPEC §2/§2.1 |
201
+ | `{ candor: { version, toolchain, spec: "0.33" }, functions }` envelope; pure fns omitted | SPEC §2/§2.1 |
202
202
  | Call-graph sidecar with **every** analyzed function a key | SPEC §2.2 |
203
203
  | The gate: AS-EFF-006 / 008 / 009, loud on an unreadable policy | SPEC §6.2 |
204
204
 
@@ -216,7 +216,7 @@ read the Rust source".
216
216
 
217
217
  ## Status
218
218
 
219
- 0.30.0, speaking candor-spec 0.32: the analysis core, the gate (`--policy` / `--gate-json` /
219
+ 0.30.0, speaking candor-spec 0.33: the analysis core, the gate (`--policy` / `--gate-json` /
220
220
  `.candor/config`), the full §3.1 query surface (including `containment`, `blindspots`, the
221
221
  `--include-unknown` dispatch frontier, and ⟨0.24⟩ `gate --report` — the gate applied to an EXISTING
222
222
  report, byte-equivalent to `scan --policy`'s verdict), the MCP server, the LSP server, and the watch loop are
package/lsp.mjs CHANGED
@@ -402,11 +402,19 @@ const zeroRulePolicyWarn = (what) =>
402
402
  * same reason ("there is no line to pin it to"); the activity overlay's line-0 diagnostic is not a
403
403
  * counter-example, because its record NAMES the edited file and this one names no file in the workspace.
404
404
  */
405
- function discloseIncompleteness(unanalyzed, outOfScope, unread) {
405
+ // `certainViolation`: SPEC §3.1's precedence (`query.mjs`'s `gate --report`: violation (1) > refusal (2) >
406
+ // incomplete (2)) means a policy violation ELSEWHERE in this same report makes the real exit 1, not the 2
407
+ // this function used to assert unconditionally. Measured: a report with an unread file AND a certain `Fs`
408
+ // violation exits 1 over `gate --report` — the incompleteness is still real and still unjudged, but "exits
409
+ // 2 (INCOMPLETE)" / "CI exits 2 over these bytes" is a wrong, checkable claim in exactly that case. Two
410
+ // fixtures with the identical unread-file cause differ only in whether a violation coexists, and only the
411
+ // violation-free one made this text true.
412
+ function discloseIncompleteness(unanalyzed, outOfScope, unread, unaskedRules, certainViolation) {
406
413
  const causes = [];
407
- // The CLI's order (`unanalyzed` → `outOfScope` → `unread`), so a report tripping two of them reads the
408
- // same way here as it does in CI. The repairs genuinely differ — a parse to fix, a selector that
409
- // REACHES the code, a scan that was ASKED the question — so each cause carries its own.
414
+ // The CLI's order (`unanalyzed` → `outOfScope` → `unread` → `unaskedRules`), so a report tripping two
415
+ // of them reads the same way here as it does in CI. The repairs genuinely differ — a parse to fix, a
416
+ // selector that REACHES the code, a scan that was ASKED the question, a scan asked the SAME question —
417
+ // so each cause carries its own.
410
418
  if (unanalyzed.length)
411
419
  causes.push(`the report DECLARES ${unanalyzed.length} unit(s) candor could not analyze, and a gate `
412
420
  + `cannot be green over unanalyzed code:\n`
@@ -421,16 +429,39 @@ function discloseIncompleteness(unanalyzed, outOfScope, unread) {
421
429
  + `report because nothing looked, not because there are none, so nothing in them can be squiggled `
422
430
  + `here — re-scan the sources WITH this policy (candor-ts <dir> --policy <file>); a scan that was `
423
431
  + `never asked cannot certify what it never opened.`);
432
+ // ⟨0.33⟩ SPEC §2 ⟨0.33⟩ — a class the peek DID read, but under a deny set that does not cover this
433
+ // policy's own. Distinct from `unread` above: that is "nothing looked", this is "something looked, for
434
+ // a narrower question than the one this editor is asking now" — the peek is bounded to the PRODUCER's
435
+ // denied effects (⟨0.29⟩), so an empty finding there answers nothing about a rule it was never put.
436
+ if (unaskedRules?.length)
437
+ causes.push(`this report's peek was bounded by the deny set its producing scan held, and that set `
438
+ + `does not cover ${unaskedRules.length} rule(s) of this policy: ${unaskedRules.join(", ")}. The `
439
+ + `excluded files it reports as read were searched for OTHER effects, so nothing in them can be `
440
+ + `squiggled here — re-run the producing scan under THE SAME policy this editor is applying `
441
+ + `(candor-ts <dir> --policy <file>), not merely under a policy.`);
424
442
  if (!causes.length) return;
443
+ // ⟨lsp-precedence⟩ the exit-code claim is conditional on whether a certain violation ALSO fires: if one
444
+ // does, Lemma 2 makes it dominate (exit 1), and the incompleteness below is true but not what CI is red
445
+ // over. See `certainViolation`'s definition above.
446
+ const exitClaim = certainViolation
447
+ ? `a certain policy violation ELSEWHERE in this report makes \`gate --report\` over the same bytes exit `
448
+ + `1 — SPEC §3.1's precedence has a firing rule dominate a refusal, so CI is red on THAT, not on this. `
449
+ + `The incompleteness below is still real and still unjudged; it is just not what the exit code names`
450
+ : `\`gate --report\` over the same bytes exits 2 (INCOMPLETE), so the squiggles in this editor are NOT `
451
+ + `the whole verdict`;
452
+ const briefClaim = certainViolation
453
+ ? `candor gate: a certain violation elsewhere in this report already makes CI exit 1 over these bytes — `
454
+ + `but this report ALSO has unjudged code (see the candor log); fixing the violation alone will not `
455
+ + `make it complete.`
456
+ : `candor gate: INCOMPLETE — this report cannot support a green verdict (CI exits 2 over these bytes). `
457
+ + `See the candor log for what went unjudged and how to fix it.`;
425
458
  warnLoudOnce(
426
- `candor-lsp: this report cannot support a GREEN gate — \`gate --report\` over the same bytes exits 2 `
427
- + `(INCOMPLETE), so the squiggles in this editor are NOT the whole verdict:\n`
459
+ `candor-lsp: this report cannot support a GREEN gate — ${exitClaim}:\n`
428
460
  + causes.map((c) => ` · ${c}`).join("\n")
429
461
  + `\n NO diagnostic can be drawn for any of the above: the code it names is not in the report, so it `
430
462
  + `has no line in this editor to sit on. Its ABSENCE from the squiggles is the incompleteness itself, `
431
463
  + `not an all-clear.`,
432
- `candor gate: INCOMPLETE — this report cannot support a green verdict (CI exits 2 over these bytes). `
433
- + `See the candor log for what went unjudged and how to fix it.`);
464
+ briefClaim);
434
465
  }
435
466
 
436
467
  function diagnosticsFor(docPath) {
@@ -470,15 +501,29 @@ function diagnosticsFor(docPath) {
470
501
  warnOnce(`candor-lsp: ${dpol.ignored.length} line(s) of the configured policy were DROPPED by the `
471
502
  + `parse, so the gate you are seeing is SMALLER than the gate that was written (SPEC §6.2 ⟨0.28⟩):\n`
472
503
  + dpol.ignored.map((g) => ` line ${g.line}: ${g.text}`).join("\n"));
473
- // ⟨0.21⟩/⟨0.30⟩/⟨0.32⟩ …AND THE CAUSES THAT MAKE THE GATE ITSELF INCOMPLETE — see
504
+ // ⟨0.21⟩/⟨0.30⟩/⟨0.32⟩/⟨0.33⟩ …AND THE CAUSES THAT MAKE THE GATE ITSELF INCOMPLETE — see
474
505
  // `discloseIncompleteness` for the mechanism and the channel argument. Read through the SAME
475
506
  // `reportCompleteness` the CLI gate, `fix-gate`, `unverified` and the MCP tools read, so the editor
476
- // cannot come to a different view of one report from the CI job that judges the same commit. The
477
- // `unread` condition is applied HERE, to the value, for the reason the CLI states at its own call
478
- // site: one list, one condition, so two consumers of it cannot disagree about a run.
479
- const dcomp = Q.reportCompleteness(reportPrefix);
507
+ // cannot come to a different view of one report from the CI job that judges the same commit. `dpol.deny`
508
+ // rides along so `reportUnaskedRules` can compare THIS policy's own rules against the report's
509
+ // `scannedUnder` — the identical value the CLI reads for the same bytes (SPEC §2 ⟨0.33⟩). The `unread`
510
+ // condition is applied HERE, to the value, for the reason the CLI states at its own call site: one
511
+ // list, one condition, so two consumers of it cannot disagree about a run.
512
+ const dcomp = Q.reportCompleteness(reportPrefix, dpol.deny);
513
+ // Cheap, side-effect-free pre-check for `discloseIncompleteness`'s exit-code wording (see its own
514
+ // comment): does THIS report already carry a certain violation, over the WHOLE report the way
515
+ // `gate --report` reads it, not just this one document? A redundant pass over an already-loaded report
516
+ // — the real one runs again below, where its `dunevaluated`/`violations` are also needed for the
517
+ // diagnostics themselves and for OTHER `warnOnce` lines whose relative order this must not disturb.
518
+ const dwp0 = wholePolicyUnanswerable(dpol, "the editor's report route");
519
+ const dunits0 = reportUnits(fns);
520
+ const dnet0 = reportNetClasses(fns, { authoritative: true, units: dunits0 });
521
+ const { withhold: dwithhold0 } = unanswerableScoped(dpol, fns,
522
+ resolveReasonClasses(fns, Q.loadCallgraph(reportPrefix), dunits0), dnet0, dunits0);
523
+ const certainViolation = evaluatePolicy(dwp0.answerable, fns, Q.loadCallgraph(reportPrefix),
524
+ new Map(), new Set(), dnet0, dwithhold0, dunits0).length > 0;
480
525
  discloseIncompleteness(dcomp.unanalyzed ?? [], dcomp.outOfScope ?? [],
481
- dpol.deny.length ? (dcomp.unread ?? []) : []);
526
+ dpol.deny.length ? (dcomp.unread ?? []) : [], dcomp.unaskedRules ?? [], certainViolation);
482
527
  // ⟨0.24⟩ THE ANSWERABILITY WITHHOLD, which this surface ran WITHOUT — `evaluatePolicy` was called with no
483
528
  // `withhold` predicate and the DEFAULT netClass mode, so both directions of the §3.1 harm were live in the
484
529
  // editor. Measured against the CLI on one report and one policy: `deny Unknown[reflect]` drew NO squiggle
@@ -651,6 +696,18 @@ function runWhatif(a) {
651
696
  + `${zcallers.length} caller(s) would inherit ${a.effect})`);
652
697
  return { unevaluated: policyZeroRules(activePolicyPath ?? "(policy)").unevaluated };
653
698
  }
699
+ // PART 70 — this command returned the RAW `r` unconditionally, so the shipped editor experience
700
+ // (VS Code + JetBrains both bundle this server) certified `ok` over bytes `gate --report` refuses on:
701
+ // the ⟨0.30⟩/⟨0.32⟩/⟨0.33⟩ scope causes never reached this route at all. Read off the SAME
702
+ // `reportCompleteness` the CLI whatif and `candor_whatif`/`diagnosticsFor` use, `unread` gated on this
703
+ // call's OWN `deny`/`pure` rules exactly as those two gate it — so the three channels cannot disagree
704
+ // about one report. `discloseIncompleteness` is the SAME log+showMessage channel `diagnosticsFor`
705
+ // already uses for the standing gate squiggles (dedup on the message text), so a report already
706
+ // disclosed there says nothing new here, and one not yet seen surfaces on the FIRST channel that asks.
707
+ const wcomp = Q.reportCompleteness(reportPrefix, wpol?.deny ?? []);
708
+ const wUnread = wpol?.deny?.length ? (wcomp.unread ?? []) : [];
709
+ const wUnasked = wcomp.unaskedRules ?? [];
710
+ discloseIncompleteness(wcomp.unanalyzed ?? [], wcomp.outOfScope ?? [], wUnread, wUnasked);
654
711
  const callers = r.affected.filter((f) => !r.of.includes(f)); // affected minus the target(s) themselves
655
712
  const rules = [...new Set(r.violations.map((v) => v.rule))];
656
713
  // ⟨0.24⟩ THE EDITOR IS A CHANNEL THIS VERB ANSWERS ON, so the `conditional` has to reach it or this
@@ -685,7 +742,13 @@ function runWhatif(a) {
685
742
  }]);
686
743
  publishDiagnostics(a.uri);
687
744
  }
688
- return r; // the raw whatif result rides back as the executeCommand result (a thick client can render it)
745
+ // PART 70 — the executeCommand RESULT is the document a thick client renders, so it takes the SAME
746
+ // `ok`-withdrawal the CLI and `candor_whatif` apply: `incomplete: true` in its place, `affected`/
747
+ // `violations` (and the one-liner/diagnostic above, built off the raw `r`) unchanged. A no-op on a
748
+ // complete report — byte-identical to the pre-fix `return r` — so every fixture without a scope cause
749
+ // is untouched.
750
+ return Q.advisoryAnswer(r, wcomp.unanalyzed, wcomp.judgedNothing, wcomp.unreadable, wcomp.noManifest,
751
+ wcomp.outOfScope ?? [], wUnread, wUnasked);
689
752
  }
690
753
 
691
754
  // The candor.fix command: the SAME query-core `fix` the CLI (`query.mjs fix`) and MCP (`candor_fix`) run —
package/mcp.mjs CHANGED
@@ -345,7 +345,7 @@ const TOOLS = {
345
345
  run: (_a, p) => nestWithCaveat(p, "modules", () => Q.map(loadReportLoud(p))),
346
346
  },
347
347
  candor_whatif: {
348
- description: "Hypothetically add `effect` to `fn` and report the blast radius; with `policy`, also the deny-rule violations it would cause. Pre-edit gate check.",
348
+ description: "Hypothetically add `effect` to `fn` and report the blast radius; with `policy`, also the deny-rule violations it would cause. Pre-edit gate check. ALWAYS CHECK for the presence of `ok`, never just its value: over a report this route cannot fully evaluate, `ok` is ABSENT and `{incomplete:true, ...}` takes its place — `affected`/`violations` still ship (a partial answer beats a refusal; this tool is consulted BEFORE an edit), but neither `true` nor `false` is a claim the input licenses. The causes are the same ones `candor_gate`/`candor_unverified` disclose: `unanalyzed` (candor could not read a file of the target's own code), `outOfScope` (the peek found a denied effect outside the scan's reach), `unread` (a class the scan never opened — gated on this call's OWN `deny`/`pure` rules, since only those depend on code outside the scan's scope), and `unaskedRules` (a class something DID open, but under a narrower deny set than this policy's own).",
349
349
  schema: { type: "object", properties: { fn: { type: "string" }, effect: { type: "string" }, policy: { type: "string", description: "path to a CANDOR_POLICY file (optional)" }, ...reportArg }, required: ["fn", "effect"] },
350
350
  run: (a, p) => {
351
351
  // A GIVEN policy path is always read (confined, fail-closed) — the old `existsSync` guard made a
@@ -359,7 +359,17 @@ const TOOLS = {
359
359
  // blast radius it qualifies are withheld for the caveat document (see `zeroRuleCaveat`). A policy
360
360
  // that is NOT configured stays untouched: that is the honest way to say "I am not gating".
361
361
  if (policyAskedNothing(pol)) return zeroRuleCaveat(a.policy, p);
362
- return r;
362
+ // PART 70 — this tool returned the RAW `r` unconditionally, so the agent-facing surface certified
363
+ // `ok` over bytes `candor_gate`/`gate --report` refuse on: the ⟨0.30⟩/⟨0.32⟩/⟨0.33⟩ scope causes
364
+ // never reached it at all (measured RED four ways: outOfScope, unread class, cross-policy, and the
365
+ // pre-existing `unanalyzed` path this tool DID carry via `r` alone — none of them withdrew `ok`).
366
+ // Same `Q.advisoryAnswer` the CLI and `candor_unverified` apply, off the SAME `reportCompleteness`
367
+ // reader, so this channel cannot drift from the other two.
368
+ const wcomp = Q.reportCompleteness(p, pol?.deny ?? []);
369
+ const wUnread = pol?.deny?.length ? (wcomp.unread ?? []) : [];
370
+ const wUnasked = wcomp.unaskedRules ?? [];
371
+ return Q.advisoryAnswer(r, wcomp.unanalyzed, wcomp.judgedNothing, wcomp.unreadable, wcomp.noManifest,
372
+ wcomp.outOfScope ?? [], wUnread, wUnasked);
363
373
  },
364
374
  },
365
375
  candor_fix: {
@@ -391,7 +401,7 @@ const TOOLS = {
391
401
  },
392
402
  },
393
403
  candor_gate: {
394
- description: "The policy verdict over this report: { ok, violations:[{rule, fn, effects, detail}] } — 'would this repo pass its architecture gate?'. Uses `policy` if given, else the repo's checked-in .candor/config policy (spec §3.4). ALWAYS CHECK `ok`, never the length of `violations`: a rule whose narrowing evidence the report does not carry is NOT EVALUATED, and then the result is `{ ok:false, refused:true, reason, unevaluated:[{rule, why}] }` WITH NO `violations` KEY — an absent key, not an empty list, because the gate is making no claim there. `unevaluated` also rides a firing verdict (a certain violation dominates a refusal). `incomplete:true` means the gate CANNOT be green, and the key beside it says which of the three causes fired: `unanalyzed` (the report declares code candor could not analyze), `outOfScope` (the producer's peek NAMED a function outside the scan's scope performing an effect this policy denies), or `unread` (a class the producing scan never OPENED — its effects are absent because nothing looked, not because there are none; re-scan those sources WITH this policy). Computed from the report — the engine's own --gate-json run is the authoritative CI form: it additionally fails an allow rule whose literal surface is INCOMPLETE (a masked/invisible endpoint), which is not a report field, so a green here can still be red in CI.",
404
+ description: "The policy verdict over this report: { ok, violations:[{rule, fn, effects, detail}] } — 'would this repo pass its architecture gate?'. Uses `policy` if given, else the repo's checked-in .candor/config policy (spec §3.4). ALWAYS CHECK `ok`, never the length of `violations`: a rule whose narrowing evidence the report does not carry is NOT EVALUATED, and then the result is `{ ok:false, refused:true, reason, unevaluated:[{rule, why}] }` WITH NO `violations` KEY — an absent key, not an empty list, because the gate is making no claim there. `unevaluated` also rides a firing verdict (a certain violation dominates a refusal). `incomplete:true` means the gate CANNOT be green, and the key beside it says which of the four causes fired: `unanalyzed` (the report declares code candor could not analyze), `outOfScope` (the producer's peek NAMED a function outside the scan's scope performing an effect this policy denies), `unread` (a class the producing scan never OPENED — its effects are absent because nothing looked, not because there are none; re-scan those sources WITH this policy), or `unaskedRules` (a class the producing scan's peek DID read, but under a deny set that does not cover this one — re-scan under THE SAME policy this tool is applying, not merely under a policy). Computed from the report — the engine's own --gate-json run is the authoritative CI form: it additionally fails an allow rule whose literal surface is INCOMPLETE (a masked/invisible endpoint), which is not a report field, so a green here can still be red in CI.",
395
405
  schema: { type: "object", properties: { policy: { type: "string", description: "path to a §6.2 policy file (optional; defaults to the repo's .candor/config `policy`)" }, ...reportArg }, required: [] },
396
406
  run: (a, p) => {
397
407
  let text, polPath = a.policy ?? null;
@@ -428,7 +438,10 @@ const TOOLS = {
428
438
  // judged-nothing, and — newly — `unanalyzed`) could disagree with each other on a file another
429
439
  // process rewrites between them.
430
440
  const pol = policyOrThrow(text, polPath);
431
- const g = Q.loadGateReport(p);
441
+ // ⟨0.33⟩ this tool's OWN rules ride along so `loadGateReport` can compare them against the gated
442
+ // report's `scannedUnder` (SPEC §2 ⟨0.33⟩) — the identical computation `gate --report` makes, off
443
+ // the same reader, so this agent-facing route cannot certify what the CLI would refuse.
444
+ const g = Q.loadGateReport(p, pol.deny);
432
445
  if (g.hardFail)
433
446
  throw new Error((g.corrupt.length
434
447
  ? `the report at prefix \`${clip(p)}\` has ${g.corrupt.length} present-but-unparseable §2 key(s) — a key that is THERE but of the wrong shape is corrupt input, not an empty one (SPEC §2 ⟨0.24⟩); coercing it to its empty value would turn corruption into a purity claim: ${g.corrupt.join("; ")}. `
@@ -472,9 +485,15 @@ const TOOLS = {
472
485
  // effects are absent from `functions` because nothing looked. Decided by the policy applied NOW
473
486
  // (only a `deny`/`pure` rule's answer depends on code outside the scan's scope), which is the
474
487
  // same condition both CLI routes apply, from the same value, once.
488
+ // · `unaskedRules` (⟨0.33⟩) — a class the producer's peek DID read, but under a deny set that
489
+ // does not cover this policy's own. Read straight off `g` (computed by `loadGateReport` from
490
+ // the `pol.deny` handed to it above), never re-derived: the identical value the CLI's
491
+ // `gate --report` reads for the same bytes.
475
492
  const gscope = g.outOfScope ?? [];
476
493
  const gunread = pol.deny.length ? g.unread : [];
477
- const incomplete = g.unanalyzed.length > 0 || gscope.length > 0 || gunread.length > 0;
494
+ const gunasked = g.unaskedRules ?? [];
495
+ const incomplete = g.unanalyzed.length > 0 || gscope.length > 0 || gunread.length > 0
496
+ || gunasked.length > 0;
478
497
  // ⟨0.24⟩ …and a report that JUDGED NOTHING is not an all-clear (SPEC §2's three-row table, bound to
479
498
  // every report-reading route by §3.1: "the obligation is on the reading, not on the route by which
480
499
  // the report arrived"). This tool is exactly such a route — it gates whatever `report` points at,
@@ -531,7 +550,8 @@ const TOOLS = {
531
550
  ? { incomplete: true,
532
551
  ...(g.unanalyzed.length ? { unanalyzed: g.unanalyzed } : {}),
533
552
  ...(gscope.length ? { outOfScope: gscope } : {}),
534
- ...(gunread.length ? { unread: gunread } : {}) }
553
+ ...(gunread.length ? { unread: gunread } : {}),
554
+ ...(gunasked.length ? { unaskedRules: gunasked } : {}) }
535
555
  : {};
536
556
  // ⟨0.24⟩ PRECEDENCE (SPEC §3.1 `7271c69`/`4c79958`): violation (1) > refusal (2) > incomplete (2), and
537
557
  // the REFUSAL SHAPE is the one the CLI writes — `ok:false`, `refused:true`, and NO `violations` KEY AT
@@ -560,10 +580,12 @@ const TOOLS = {
560
580
  + "over a report declaring code candor could NOT analyze, `ok` IS ABSENT and `{incomplete:true, "
561
581
  + "unanalyzed}` takes its place — a function in an unanalyzed file is missing from the report "
562
582
  + "entirely, so it cannot be enumerated as an unverified pass, and an empty array there is not "
563
- + "an all-clear (spec §3.2). `incomplete:true` also rides the two SCOPE causes, on the same "
583
+ + "an all-clear (spec §3.2). `incomplete:true` also rides the three SCOPE causes, on the same "
564
584
  + "terms as candor_gate: `outOfScope` (the producer's peek named a function outside the scan's "
565
- + "scope performing a denied effect) and `unread` (a class the producing scan never OPENED — "
566
- + "re-scan those sources WITH this policy). `ok` is ABSENT for a further reason too, and the entries that come "
585
+ + "scope performing a denied effect), `unread` (a class the producing scan never OPENED — "
586
+ + "re-scan those sources WITH this policy), and `unaskedRules` (a class the producing scan's "
587
+ + "peek DID read, but under a deny set that does not cover this one — re-scan under THE SAME "
588
+ + "policy this tool is applying, not merely under a policy). `ok` is ABSENT for a further reason too, and the entries that come "
567
589
  + "with it are the sharpest ones: where the policy narrows on evidence this report does not carry, "
568
590
  + "candor_gate REFUSES — and this verb then NAMES each function the gate could not judge, as "
569
591
  + "`{fn, rule, why}` where `why` is THE MISSING EVIDENCE and never a derived class, plus the gate's "
@@ -588,8 +610,11 @@ const TOOLS = {
588
610
  // ⟨0.28⟩ …and the `analyzed.count: 0` cause on the same terms (SPEC §2), read through the SAME
589
611
  // `reportCompleteness` the CLI and the descriptive tools use — one reader, so the two channels
590
612
  // cannot disagree about which reports judged nothing.
591
- const ucomp = Q.reportCompleteness(p);
592
613
  const upol = policyOrThrow(text, polPath);
614
+ // ⟨0.33⟩ this tool's OWN deny/pure rules ride along, computed before this call so
615
+ // `reportCompleteness` can compare them against the report's `scannedUnder` — the same reader
616
+ // `unverified --strict` and `fix-gate --strict` use on the CLI.
617
+ const ucomp = Q.reportCompleteness(p, upol.deny);
593
618
  // ⟨0.28⟩ the sharpest of the three: the verb whose job is "your green gate is not provably green"
594
619
  // answered `{ok: true, unverified: []}` over a policy that asked nothing. The empty list is withheld
595
620
  // for ⟨0.27⟩'s reason — a document that made no evaluation must not carry the finding key.
@@ -606,9 +631,12 @@ const TOOLS = {
606
631
  // only a `deny`/`pure` rule's answer depends on code outside the scan's scope, and `pure` rides
607
632
  // the `deny` vector, so this is `deny.length` rather than a search for the token.
608
633
  const uUnread = upol.deny.length ? (ucomp.unread ?? []) : [];
634
+ // ⟨0.33⟩ …and the FOURTH cause — computed by `reportCompleteness` above (structurally `[]` when
635
+ // `upol.deny` is empty), never re-derived here.
636
+ const uUnasked = ucomp.unaskedRules ?? [];
609
637
  return Q.advisoryAnswer(Q.unverified(loadReportLoud(p), upol, scopeMatches),
610
638
  ucomp.unanalyzed, ucomp.judgedNothing, ucomp.unreadable, ucomp.noManifest,
611
- ucomp.outOfScope ?? [], uUnread);
639
+ ucomp.outOfScope ?? [], uUnread, uUnasked);
612
640
  },
613
641
  },
614
642
  candor_containment: {
@@ -629,8 +657,14 @@ const TOOLS = {
629
657
  // authoritative empty {changes:[]}; the CLI exits 2 on the same miss) — but NOT --root-confined:
630
658
  // see resolveBaseline for the out-of-tree-baseline trust argument.
631
659
  const b = resolveBaseline(a.baseline);
660
+ // ⟨0.33⟩ …and the ⟨0.28⟩ manifest on the same terms, BOTH SIDES separately — the SAME
661
+ // `Q.gainsCompleteness` the sibling `candor_gains` tool below spreads, because `diff` rests on the
662
+ // identical two-report shape and fails the identical two ways (a short CURRENT `changes`, a soft
663
+ // BASELINE floor). MEASURED: this tool answered a real 3-function gain over a CURRENT report naming
664
+ // an unread exclusion class with no caveat at all — the CLI carried the same gap (query.mjs `diff`
665
+ // case) and is fixed alongside this. No human sees this channel, so the JSON key is the whole fix.
632
666
  return { baseline_version: Q.reportVersion(b) ?? "", engine_version: Q.reportVersion(p) ?? "",
633
- ...Q.diff(loadReportLoud(p), loadReportLoud(b)) };
667
+ ...Q.diff(loadReportLoud(p), loadReportLoud(b)), ...Q.gainsCompleteness(p, b) };
634
668
  },
635
669
  },
636
670
  candor_gains: {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "candor-ts",
3
- "version": "0.32.1",
4
- "description": "candor for TypeScript \u2014 per-function side effects, transitively, with a policy gate (candor-spec 0.32)",
3
+ "version": "0.33.1",
4
+ "description": "candor for TypeScript \u2014 per-function side effects, transitively, with a policy gate (candor-spec 0.33)",
5
5
  "type": "module",
6
6
  "dependencies": {
7
7
  "@types/node": "^25.9.2",
package/query-core.mjs CHANGED
@@ -553,7 +553,7 @@ export function reportUnanalyzed(prefix) {
553
553
  * every run trains the reader to ignore it, the same reason ⟨0.15⟩ omits `coverage` when nothing is
554
554
  * uncovered.
555
555
  */
556
- export function reportCompleteness(prefix) {
556
+ export function reportCompleteness(prefix, denyRules = []) {
557
557
  // `judgedNothing` is the PER-FILE list, not the ANDed boolean the gate asks: the disclosure names
558
558
  // WHICH report judged nothing, so a locator with one silent member among several still hedges — the
559
559
  // semantics rust and java pin, and a repair the reader can aim (that file, not "somewhere here").
@@ -574,6 +574,13 @@ export function reportCompleteness(prefix) {
574
574
  // this field directly for their EXIT code and apply the gate's own condition to it: only a
575
575
  // `deny`/`pure` rule's answer depends on code outside the scan's scope.
576
576
  unread: reportUnread(prefix),
577
+ // ⟨0.33⟩ …and the rules THIS CALLER'S policy holds that a peeked class's producer was never
578
+ // asked about (`reportUnaskedRules`) — the SAME arm shape as `unread` one line up, and bound
579
+ // by the identical ⟨0.24⟩ pessimism MUST. `denyRules` defaults to `[]`, which is the
580
+ // STRUCTURAL carve-out for every caller of this function that carries no policy at all (the
581
+ // majority — `show`/`where`/`callers`/`map`/`tour`/… never pass one), so this key is a silent
582
+ // no-op for them rather than a sixth call site to remember the condition at.
583
+ unaskedRules: reportUnaskedRules(prefix, denyRules),
577
584
  ...(() => {
578
585
  const o = reportOutOfScope(prefix);
579
586
  // A corrupt key rides `unreadable`, which is ALREADY an arm of the strict exit — so the
@@ -634,7 +641,12 @@ export function reportCompleteness(prefix) {
634
641
  */
635
642
  export const mustHedge = (c) => !!(c && (c.unanalyzed?.length || c.judgedNothing?.length
636
643
  || c.noManifest?.length || c.unreadable?.length
637
- || c.outOfScope?.length || c.unread?.length));
644
+ || c.outOfScope?.length || c.unread?.length
645
+ // ⟨0.33⟩ a peek bounded by a deny set narrower than this
646
+ // caller's own — see `reportUnaskedRules`. Structurally `[]`
647
+ // for every caller (the majority) that never passes `denyRules`
648
+ // to `reportCompleteness`, so this arm is a no-op for them.
649
+ || c.unaskedRules?.length));
638
650
 
639
651
  /** ⟨0.30⟩ The peek's findings across the reports under a locator. Read leniently HERE (a malformed key is
640
652
  * the gate's refusal to make, and this feeds a disclosure) but non-emptiness raises the same hedge the
@@ -689,6 +701,57 @@ export function reportUnread(prefix) {
689
701
  return [...out];
690
702
  }
691
703
 
704
+ /**
705
+ * ⟨0.33⟩ THE ADVISORY TWIN of `loadGateReport`'s `scannedUnder` subset test — SPEC §2 ⟨0.33⟩'s "AND THE
706
+ * ADVISORY VERBS FOLLOW IT": the rules THIS caller's policy holds that a report's producer, under the
707
+ * SAME peek-was-bounded reasoning, was never asked about. One helper for the gate route and this one,
708
+ * deliberately, so the ⟨0.24⟩ pessimism relation (an advisory verb must never be LESS sensitive to
709
+ * incompleteness than the gate over the same bytes) cannot drift the way it already has three times in
710
+ * this family — `unanalyzed`, `outOfScope`, `excluded[].peeked` each reached `gate --report` before they
711
+ * reached `unverified`/`fix-gate`, and each time it was a NEW cause landing at one site and not its
712
+ * siblings.
713
+ *
714
+ * STRUCTURAL CARVE-OUT FIRST: `denyRules` empty means this caller's policy holds no `deny`/`pure` rule at
715
+ * all (an `allow`/`forbid`/`only`-only policy, or none) — an EMPTY canonical set, which is a subset of
716
+ * everything — so the per-report loop below is skipped rather than run to compute nothing. This is the
717
+ * same carve-out `loadGateReport` takes, and it is what keeps a policy-less verb like `tour` inert here:
718
+ * `reportCompleteness`'s default `denyRules = []` makes this function a no-op for every caller that never
719
+ * passes one.
720
+ *
721
+ * LENIENT, like `reportOutOfScope`/`reportUnread` beside it and for the same reason: a malformed
722
+ * `scannedUnder` is the GATE's refusal to make (`loadGateReport` reads the same bytes STRICTLY and NAMES
723
+ * the key in a refusal), and this feeds a disclosure rather than an exit code. A garbled or absent
724
+ * `scannedUnder` reads here as the EMPTY SET — the same fail-closed reading `loadGateReport` takes, just
725
+ * reached without a refusal document to carry it: this function may only ever be MORE cautious than
726
+ * silence, never less.
727
+ *
728
+ * PER REPORT, never over the union of a report set: `scannedUnder` and `peeked` are facts about ONE
729
+ * producing scan, so a locator naming a policy-scanned report beside a no-policy sibling must not let the
730
+ * first one's deny set answer for the second one's peeked classes. The per-file `theirs` set is built
731
+ * fresh for every file and never merged across them; only the resulting MISSING RULE STRINGS are unioned
732
+ * (deduplicated, code-point sorted) across a multi-report prefix, exactly as `loadGateReport` does.
733
+ */
734
+ export function reportUnaskedRules(prefix, denyRules) {
735
+ const mine = denyRules?.length ? canonicalDenySet(denyRules) : [];
736
+ if (!mine.length) return [];
737
+ const files = (prefix.endsWith(".json") && fs.existsSync(prefix)) ? [prefix] : reportFilesAt(prefix);
738
+ const out = new Set();
739
+ for (const f of files) {
740
+ try {
741
+ const d = JSON.parse(fs.readFileSync(f, "utf8"));
742
+ const exc = Array.isArray(d?.excluded) ? d.excluded : [];
743
+ const peeked = exc.some((e) => e && typeof e === "object" && e.peeked === true
744
+ && e.judgedElsewhere !== true);
745
+ if (!peeked) continue;
746
+ const su = d?.scannedUnder;
747
+ const theirs = (su && typeof su === "object" && !Array.isArray(su) && Array.isArray(su.deny))
748
+ ? new Set(su.deny.filter((x) => typeof x === "string")) : new Set();
749
+ for (const r of mine) if (!theirs.has(r)) out.add(r);
750
+ } catch { /* unparseable TEXT is `unreadable`'s business, not this key's */ }
751
+ }
752
+ return [...out].sort(byCodePoint);
753
+ }
754
+
692
755
  /**
693
756
  * ⟨0.28⟩ The disclosure KEYS, defined ONCE, for spreading into a verb's answer document — `{}` when there
694
757
  * is nothing to disclose, so `{ ...data, ...completenessFields(c) }` is byte-identical to `data` on a
@@ -795,10 +858,10 @@ export const absorbCompleteness = (a, b) => ({
795
858
  * that has no exit code for it to matter to.)
796
859
  */
797
860
  export function advisoryAnswer(body, unanalyzed, judgedNothing = [], unreadable = [], noManifest = [],
798
- outOfScope = [], unread = []) {
861
+ outOfScope = [], unread = [], unaskedRules = []) {
799
862
  const unevaluated = body?.unevaluated;
800
863
  if (!unanalyzed?.length && !unevaluated?.length && !judgedNothing?.length && !unreadable?.length
801
- && !noManifest?.length && !outOfScope?.length && !unread?.length)
864
+ && !noManifest?.length && !outOfScope?.length && !unread?.length && !unaskedRules?.length)
802
865
  return body; // COMPLETE: unchanged, byte for byte, `ok` and all.
803
866
  const { ok, ...rest } = body; // eslint-disable-line no-unused-vars -- omitted BY DESIGN
804
867
  // The array of report paths, same key and same shape as `completenessFields` — ONE wire spelling for
@@ -809,18 +872,21 @@ export function advisoryAnswer(body, unanalyzed, judgedNothing = [], unreadable
809
872
  // never emitted a manifest cannot support it (row 3: *no manifest, no claim*).
810
873
  const judged = { ...(judgedNothing?.length ? { judgedNothing } : {}),
811
874
  ...(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.
875
+ // ⟨0.30⟩/⟨0.32⟩/⟨0.33⟩ THE THREE SCOPE CAUSES — the ones the `--strict` exit has consulted since ⟨0.30⟩
876
+ // while this document did not, which is the document/exit split in its worse direction. MEASURED
877
+ // 2026-08-24 on `fix-gate --strict` and `unverified --strict` over a report carrying `outOfScope`: BOTH
878
+ // printed `{"ok": true, …: []}` **at exit 2** — the exit was right and the document certified. A CI
879
+ // wrapper reads the exit; an agent reads the document, and the agent channel is the one that cannot ask
880
+ // a follow-up question. Same keys and same order as the gate's verdict document, so one consumer parses
881
+ // both. `unaskedRules` is the ⟨0.33⟩ fourth cause — a peeked class read under a deny set narrower than
882
+ // this caller's own — riding beside its two siblings for the identical reason.
818
883
  const scope = { ...(outOfScope?.length ? { outOfScope } : {}),
819
- ...(unread?.length ? { unread } : {}) };
884
+ ...(unread?.length ? { unread } : {}),
885
+ ...(unaskedRules?.length ? { unaskedRules } : {}) };
820
886
  // Key order matches the gate's verdict document: the finding, then `unevaluated`, then the manifest.
821
887
  if (unanalyzed?.length) return { ...rest, incomplete: true, unanalyzed, ...scope, ...judged };
822
888
  return (judgedNothing?.length || noManifest?.length || unreadable?.length
823
- || outOfScope?.length || unread?.length)
889
+ || outOfScope?.length || unread?.length || unaskedRules?.length)
824
890
  ? { ...rest, incomplete: true, ...scope, ...judged } : rest;
825
891
  }
826
892
 
@@ -868,10 +934,20 @@ export function loadReport(prefix) {
868
934
  * refusal to name the key), entry-level and envelope-level; non-empty implies `hardFail`.
869
935
  * `judgedNothing` is the ⟨0.24⟩ reading of `analyzed.count` (see `claimsToHaveJudgedNothing`).
870
936
  */
871
- export function loadGateReport(prefix) {
937
+ export function loadGateReport(prefix, denyRules = []) {
872
938
  const files = reportFilesAt(prefix);
873
939
  const functions = [], unanalyzed = [], cov = new Map(), corrupt = [], outOfScope = [], netPartners = [],
874
940
  unread = [];
941
+ // ⟨0.33⟩ THIS GATE'S OWN canonical deny/pure rules, computed ONCE rather than per report — a subset
942
+ // test against ten sibling reports must ask the identical question ten times, not re-derive it. Empty
943
+ // when this route carries no `deny`/`pure` rule at all (an `allow`/`forbid`/`only`-only policy, or no
944
+ // policy), which is the STRUCTURAL carve-out SPEC §2 ⟨0.33⟩ requires: an empty rule set is a subset of
945
+ // everything, so the per-file loop below contributes nothing to `unasked` however the report reads.
946
+ const mine = denyRules?.length ? canonicalDenySet(denyRules) : [];
947
+ // ⟨0.33⟩ the rules NO gated report's producer was asked about — a SET, because a multi-report prefix
948
+ // otherwise repeats one missing rule once per sibling. Deduplicated and code-point sorted at the return
949
+ // below, the same collation `unread`'s dedup and the verdict's `zeroMatch` use.
950
+ const unasked = new Set();
875
951
  let hardFail = false, analyzed = 0;
876
952
  // ⟨0.24⟩ did the report handed to the gate judge ANYTHING? Per FILE, then ANDed across the multi-report
877
953
  // siblings, because the union of several reports has judged something as soon as ONE of them has — the
@@ -882,6 +958,12 @@ export function loadGateReport(prefix) {
882
958
  let judgedNothing = true;
883
959
  for (const f of files) {
884
960
  let parsed;
961
+ // ⟨0.33⟩ did THIS FILE's peek read a class it does not carry `judgedElsewhere: true` for? The
962
+ // precondition of the cross-policy refusal, reset per file because `scannedUnder` and `peeked` are
963
+ // facts about ONE producing scan (SPEC §2 ⟨0.33⟩: "the condition is PER REPORT, never over the union
964
+ // of a report set") — a policy-scanned sibling's deny set must never answer for a no-policy sibling's
965
+ // peeked classes.
966
+ let filePeeked = false;
885
967
  try { parsed = JSON.parse(fs.readFileSync(f, "utf8")); }
886
968
  catch { console.error(`candor-ts: report ${f} failed to parse — its functions are OMITTED from this gate (corrupt or mid-write); re-run the scan`); hardFail = true; continue; }
887
969
  const { entries, corrupt: entryCorrupt } = normFns(parsed, f);
@@ -990,7 +1072,31 @@ export function loadGateReport(prefix) {
990
1072
  // `#[serde(default)]` does on the same field: a producer that does not say it read the class has
991
1073
  // not said it read the class.
992
1074
  else if (e.peeked !== true && e.judgedElsewhere !== true) unread.push(e.class);
1075
+ // ⟨0.33⟩ …and the mirror case: a class the peek DID read (and that is not a `judgedElsewhere`
1076
+ // copy of already-judged code) is exactly the class whose clean answer was bounded by THIS
1077
+ // report's `scannedUnder` — the precondition below.
1078
+ else if (e.peeked === true && e.judgedElsewhere !== true) filePeeked = true;
993
1079
  }
1080
+ // ⟨0.33⟩ THE QUESTION THIS REPORT'S PEEK WAS PUT — read STRICTLY, like every other verdict-bearing §2
1081
+ // key on this route (`excluded`/`outOfScope` immediately above). A non-object, or a `deny` that is
1082
+ // not an array of strings, IMPEACHES THE DOCUMENT: the safe-LOOKING coercion here is the FAIL-OPEN
1083
+ // direction — "the producer held these rules" — the mirror of `peeked`'s own fail-open, where the
1084
+ // safe-looking coercion was "no exclusions". ABSENT is untouched: a pre-⟨0.33⟩ producer never carried
1085
+ // this key, and the subset test below reads that as the EMPTY SET (refuses), never as a licence.
1086
+ let fileScannedUnder = null;
1087
+ if ("scannedUnder" in parsed) {
1088
+ const su = parsed.scannedUnder;
1089
+ if (!su || typeof su !== "object" || Array.isArray(su) || !Array.isArray(su.deny)
1090
+ || su.deny.some((x) => typeof x !== "string"))
1091
+ corrupt.push(`${f}: \`scannedUnder\` (expected an object \`{deny: [string, …]}\`)`);
1092
+ else fileScannedUnder = new Set(su.deny);
1093
+ }
1094
+ // ⟨0.33⟩ …and, when this file's peek read something, which of THIS gate's own rules that report's
1095
+ // producer was never asked about. An absent `fileScannedUnder` reads as the empty set — never a
1096
+ // licence — so a pre-⟨0.33⟩ producer's `peeked: true` fails closed here exactly as SPEC §2 ⟨0.33⟩
1097
+ // requires. A class the peek never opened at all is `unread`'s gap, not this one's.
1098
+ if (mine.length && filePeeked)
1099
+ for (const r of mine) if (!(fileScannedUnder ?? new Set()).has(r)) unasked.add(r);
994
1100
  // ⟨0.31⟩ the producer's PARTNER PROVENANCE, carried through verbatim and never recomputed — this
995
1101
  // route has no target to anchor `net-partner` at, and re-classifying through the consumer's own
996
1102
  // config is the re-derivation §3.1 forbids. A prefix can match several reports (a workspace writes
@@ -1021,7 +1127,12 @@ export function loadGateReport(prefix) {
1021
1127
  // same class once per member in a message whose job is to name what to re-scan. The verdict is unmoved
1022
1128
  // either way; only the sentence is.
1023
1129
  return { functions, analyzed, unanalyzed, coverage, judgedNothing, outOfScope, netPartners,
1024
- unread: [...new Set(unread)], hardFail: hardFail || corrupt.length > 0, corrupt };
1130
+ unread: [...new Set(unread)],
1131
+ // ⟨0.33⟩ SPEC §2 — the gate's own rules that some gated report's producer was never asked
1132
+ // about; `[]` when `mine` is empty (no deny/pure rule at all) or every peeked class's producer
1133
+ // covered them.
1134
+ unaskedRules: [...unasked].sort(byCodePoint),
1135
+ hardFail: hardFail || corrupt.length > 0, corrupt };
1025
1136
  }
1026
1137
  // The returned graph carries a non-enumerable `partial` flag (the loadReport `hardFail` precedent):
1027
1138
  // true iff a sidecar file was MATCHED but failed to read/parse — its edges were DROPPED (disclosed on
@@ -2062,6 +2173,33 @@ export function ruleUpgrade(r) {
2062
2173
  return [`deny ${effs}${suffix}`, `deny ${effs} Unknown${suffix}`];
2063
2174
  }
2064
2175
 
2176
+ /**
2177
+ * ⟨0.33⟩ A deny/pure rule LIST as a canonical SET — the §6.2 spelling of every rule THE MATCHER USED,
2178
+ * deduplicated and CODE-POINT sorted (`byCodePoint`, the same collation the `zeroMatch` verdict list
2179
+ * already uses), so one policy produces one document however its lines were ordered, and a consumer's
2180
+ * subset test is a plain membership test rather than an order-sensitive comparison.
2181
+ *
2182
+ * RENDERED THROUGH `ruleUpgrade`'s own SOURCE spelling — the first element of the pair it already quotes
2183
+ * back to an operator for the provable-purity upgrade — rather than a second renderer. That is the same
2184
+ * move candor-java made (`Policy.canonicalDenyRule`, shared with `ruleUpgrade`): the string an operator is
2185
+ * quoted and the string a gate compares cannot become two spellings of one rule.
2186
+ *
2187
+ * NOT effect NAMES: `pure` is a rule with an EMPTY effect list meaning "every effect except Unknown", so
2188
+ * flattening to names loses it entirely and the STRICTEST policy would compare equal to an empty one — the
2189
+ * four-way false all-clear ⟨0.30⟩ closed on the peek itself, arriving one layer out. NOT the raw policy
2190
+ * line either: §3.1 already notes alias expansion breaks byte-equality, so two configs defining
2191
+ * `unknown-alias corp` differently would give the identical raw line `deny Unknown[corp]` two meanings.
2192
+ * `ruleUpgrade`'s rendering is the EXPANDED form — post-alias, post-`.candor/config` — because that is
2193
+ * what the peek actually asked (SPEC §2 ⟨0.33⟩, `scannedUnder.deny`).
2194
+ *
2195
+ * One element per RULE — a rule denying several effects is ONE element, not one per effect.
2196
+ */
2197
+ export function canonicalDenySet(rules) {
2198
+ const out = new Set();
2199
+ for (const r of rules ?? []) out.add(ruleUpgrade(r)[0]);
2200
+ return [...out].sort(byCodePoint);
2201
+ }
2202
+
2065
2203
  /** The single predicate for a provable-purity hole (eval/fixloop/DISPATCH-NOTE.md): a function that is
2066
2204
  * Unknown, sits in a pure/deny scope, and PASSES that rule (carries none of its forbidden real effects) —
2067
2205
  * so its compliance is asserted but not verified (the Unknown could hide the very effect the rule forbids;