candor-ts 0.38.3 → 0.39.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
@@ -23,7 +23,7 @@ the TypeScript-specific production + query surface.
23
23
  > **Already installed? Report the version and ask before upgrading — before you scan.** If this
24
24
  > project already has candor (a `.candor/` report directory, or `candor-ts` reachable via `npx`/a
25
25
  > global install), do this *first*: run `npx -y candor-ts --version` (offline) and **tell the user
26
- > plainly which version they're on** — e.g. *"This project is on candor-ts `<version>` (spec 0.38)."*
26
+ > plainly which version they're on** — e.g. *"This project is on candor-ts `<version>` (spec 0.39)."*
27
27
  > On a build too old for the flag, read `candor.version` / `candor.spec` from an existing
28
28
  > `.candor/report*.json`, or `npm ls -g candor-ts`.
29
29
  >
package/README.md CHANGED
@@ -201,7 +201,7 @@ pure-vs-Unknown ruling (PART 16) — the engines must answer identically, on eve
201
201
  | A call resolving to a *type* (function-typed field/param) → `Unknown`, never silent-pure | SPEC §4 |
202
202
  | Unmatched external calls contribute nothing (curated-classifier caveat) | SEMANTICS §8 C1 |
203
203
  | The literal surfaces `hosts`/`cmds`/`paths`/`tables`, literal-read only | SPEC §2 |
204
- | `{ candor: { version, toolchain, spec: "0.38" }, functions }` envelope; pure fns omitted | SPEC §2/§2.1 |
204
+ | `{ candor: { version, toolchain, spec: "0.39" }, functions }` envelope; pure fns omitted | SPEC §2/§2.1 |
205
205
  | Call-graph sidecar with **every** analyzed function a key | SPEC §2.2 |
206
206
  | The gate: AS-EFF-006 / 008 / 009, loud on an unreadable policy | SPEC §6.2 |
207
207
 
@@ -219,7 +219,7 @@ read the Rust source".
219
219
 
220
220
  ## Status
221
221
 
222
- 0.30.0, speaking candor-spec 0.38: the analysis core, the gate (`--policy` / `--gate-json` /
222
+ 0.30.0, speaking candor-spec 0.39: the analysis core, the gate (`--policy` / `--gate-json` /
223
223
  `.candor/config`), the full §3.1 query surface (including `containment`, `blindspots`, the
224
224
  `--include-unknown` dispatch frontier, and ⟨0.24⟩ `gate --report` — the gate applied to an EXISTING
225
225
  report, byte-equivalent to `scan --policy`'s verdict), the MCP server, the LSP server, and the watch loop are
package/mcp.mjs CHANGED
@@ -344,6 +344,9 @@ const TOOLS = {
344
344
  // available to it. Fixed key sets, so the caveat spreads at the root and every pinned tool shape is
345
345
  // unchanged on a complete report (`completenessFields` → `{}`). These three certify nothing, so they
346
346
  // are on the descriptive side of the ⟨0.32⟩ boundary stated on `nestWithCaveat` below.
347
+ // SOUNDNESS R507 — see the dispatcher's `oneSubject` guard. `impact` answers about ONE function, so
348
+ // a selector naming several is REFUSED rather than silently resolved to an arbitrary one.
349
+ oneSubject: true,
347
350
  run: (a, p) => { const fns = loadReportLoud(p); return withCompleteness(p, capImpact(Q.impact(fns, graphOrReportEdges(p, fns), a.fn))); },
348
351
  },
349
352
  candor_where: {
@@ -361,6 +364,7 @@ const TOOLS = {
361
364
  schema: { type: "object", properties: { fn: { type: "string" }, effect: { type: "string" }, ...reportArg }, required: ["fn", "effect"] },
362
365
  // ⟨0.32⟩ see `candor_impact` above: `path: []` to an agent is *this function does not reach that
363
366
  // effect*, and a hop through an unread unit breaks the chain.
367
+ oneSubject: true, // SOUNDNESS R507 — see `candor_impact` and the dispatcher's guard
364
368
  run: (a, p) => { const fns = loadReportLoud(p); return withCompleteness(p, Q.path(fns, graphOrReportEdges(p, fns), a.fn, a.effect)); },
365
369
  },
366
370
  candor_callers: {
@@ -914,6 +918,14 @@ function handle(msg) {
914
918
  const names = [...new Set([...Object.keys(Q.loadCallgraph(prefix)), ...loadReportLoud(prefix).map((e) => e.fn)])];
915
919
  if (Q.matches(names, args.fn).length === 0)
916
920
  return result(id, { content: [{ type: "text", text: `candor: no function matching \`${clip(args.fn)}\` in this report` }], isError: true });
921
+ // SOUNDNESS R507 — the OTHER half of the same resolution, and scoped to the tools that answer
922
+ // about ONE subject. `candor_show`/`candor_callers`/`candor_whatif` answer over the WHOLE
923
+ // best-tier set, so several matches WIDEN their answer rather than substituting a subject;
924
+ // `candor_path`/`candor_impact` take one and print a confident verdict about it. Refusing here
925
+ // rather than inside the verbs keeps the decision beside the zero-match guard it is the twin of.
926
+ const amb = t.oneSubject ? Q.ambiguousSelector(names, args.fn) : null;
927
+ if (amb)
928
+ return result(id, { content: [{ type: "text", text: `candor: \`${clip(args.fn)}\` names ${amb.length} functions in this report — refusing to answer about one of them. Re-run with the full name: ${amb.map((n) => clip(n)).join(", ")}` }], isError: true });
917
929
  }
918
930
  const out = t.run(args, prefix);
919
931
  // Minified, not pretty-printed: the consumer is an AGENT (it parses the JSON), so the indentation
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "candor-ts",
3
- "version": "0.38.3",
3
+ "version": "0.39.1",
4
4
  "mcpName": "io.github.tombaldwin/candor",
5
- "description": "candor for TypeScript — per-function side effects, transitively, with a policy gate (candor-spec 0.38)",
5
+ "description": "candor for TypeScript — per-function side effects, transitively, with a policy gate (candor-spec 0.39)",
6
6
  "type": "module",
7
7
  "dependencies": {
8
8
  "@types/node": "^25.9.2",
package/policy.mjs CHANGED
@@ -670,6 +670,20 @@ export function refusalVerdict(spec, reason, unevaluated = null) {
670
670
  * ⟨0.24⟩ §3.3.1 requires every ordering to be locale-INDEPENDENT. `Array.prototype.sort` has been
671
671
  * stable since ES2019, so rows with an equal key keep their arrival order.
672
672
  */
673
+ // ⟨0.23⟩/⟨0.39⟩ Say out loud, WITHOUT moving the verdict, which synthetic `interfaceUnion` entries a
674
+ // rule matched. One printer, called from the two VERDICT routes (`scan --policy` and `gate --report`),
675
+ // so §3.1's byte-equality between them covers the note as well as the rows.
676
+ export function noteSyntheticHits(violations, err = console.error) {
677
+ const hits = violations?.syntheticHits ?? [];
678
+ if (!hits.length) return;
679
+ err(`candor-ts: note — ${hits.length} ⟨0.23⟩ interfaceUnion entr`
680
+ + (hits.length === 1 ? "y matches a policy rule and is NOT gated as a function"
681
+ : "ies match a policy rule and are NOT gated as functions")
682
+ + ": the key names a BODILESS declaration, and the effects under it are the CHA union over "
683
+ + "implementors, each of which IS gated under its own entry.");
684
+ for (const h of hits) err(` \`${h.fn}\` { ${h.effects.join(", ")} } would have matched \`${h.rule}\``);
685
+ }
686
+
673
687
  export function sortViolations(violations) {
674
688
  const c = (x, y) => (x < y ? -1 : x > y ? 1 : 0);
675
689
  return violations.sort((a, b) =>
@@ -1066,6 +1080,11 @@ export function classFilterExcludes(r, entry, eff, reasonAcc, netClassOf, units
1066
1080
 
1067
1081
  export function evaluatePolicy(pol, functions, callgraph, incomplete = new Map(), partners = new Set(), netClasses = null, withhold = null, units = null, hashByName = null) {
1068
1082
  const out = [];
1083
+ // ⟨0.39⟩ the synthetic `interfaceUnion` entries a rule MATCHED — carried on the returned array (a
1084
+ // non-index property, so `length`, iteration and `JSON.stringify` are all untouched) rather than
1085
+ // printed from here, because this function is also called for sub-evaluations that must stay silent.
1086
+ const syntheticHits = [];
1087
+ out.syntheticHits = syntheticHits;
1069
1088
  // `Llm` ⟨0.13⟩ reaches the SAME hosts surface as Net (an Llm host WAS captured as a Net host literal).
1070
1089
  const surfaces = { Net: "hosts", Llm: "hosts", Exec: "cmds", Fs: "paths", Db: "tables" };
1071
1090
  // §6.2 ⟨0.19⟩: `reasonClass` (all classes on the fn) rides an AS-EFF-006 Unknown violation; ⟨0.20⟩ `netClass`
@@ -1130,6 +1149,34 @@ export function evaluatePolicy(pol, functions, callgraph, incomplete = new Map()
1130
1149
  // The gate's TEST and the class list it REPORTS both read it, so the two can't disagree about one function.
1131
1150
  const netClassOf = netClassResolver(incomplete, partners, netClasses, units);
1132
1151
  for (const f of functions) {
1152
+ // ⟨0.23⟩/⟨0.39⟩ A SYNTHETIC `interfaceUnion` ENTRY IS NOT A UNIT, so it cannot PERFORM anything and
1153
+ // must not become a violation ROW. Its `fn` names a BODILESS declaration (`Store.save` on an
1154
+ // interface); the effects under it are the CHA union over implementors, published under that hash so
1155
+ // a CHAINED CONSUMER's dispatch resolves across the scan boundary. Every effect it carries is
1156
+ // already on an implementor's OWN entry in the same report — which IS a unit, and IS gated one loop
1157
+ // iteration away.
1158
+ //
1159
+ // MEASURED HERE, not inherited from the reference engine's argument: over `interface Entry { state()
1160
+ // }` with a single STRUCTURAL implementor, `deny Unknown` went exit 0 -> 1 the moment ⟨0.39⟩ un-gated
1161
+ // the union — while the package's own `stringify` resolved the dispatch PRECISELY (a real `calls`
1162
+ // edge to the structural member) and carried no Unknown at all. The union's Unknown is an artifact of
1163
+ // what the WIRE can name (a structural implementor has no class name to key on), never a fact about
1164
+ // a body this scan read, so gating on it is a fabricated violation in the producer's own verdict.
1165
+ // candor-java measured the identical flip (`Policy.java:778`) and takes the identical course.
1166
+ //
1167
+ // DISCLOSED, NOT DROPPED: the hits are recorded on the returned array and printed by the verdict
1168
+ // routes, because the one thing a reader of a chained dependency's report wants to know here is that
1169
+ // the dependency publishes a dispatch surface reaching a denied effect.
1170
+ if (f?.interfaceUnion === true) {
1171
+ for (const r of pol.deny) {
1172
+ if (r.scope && !scopeMatches(f.fn, r.scope)) continue;
1173
+ const inf = Array.isArray(f.inferred) ? f.inferred : [];
1174
+ const hits = r.effects.length === 0 ? inf.filter((e) => e !== "Unknown")
1175
+ : inf.filter((e) => r.effects.includes(e));
1176
+ if (hits.length) syntheticHits.push({ fn: f.fn, effects: hits.slice().sort(), rule: r.raw });
1177
+ }
1178
+ continue;
1179
+ }
1133
1180
  // ⟨0.32⟩ the KEY identifies the unit; `f.fn` is the NAME, and the name is what a policy SCOPE matches
1134
1181
  // and what the verdict row prints (§3.3.1 byte-equality with `scan --policy` rests on it).
1135
1182
  const uk = unitKey(units, f);
package/query-core.mjs CHANGED
@@ -196,7 +196,7 @@ function normFn(e) {
196
196
  // non-string in `unknownWhy` reaches `reasonClass()` and one in `hosts`/`netClass` reaches the ⟨0.20⟩
197
197
  // destination-class matcher.
198
198
  for (const k of ["unknownWhy", "netClass", "hosts", "cmds", "paths", "tables",
199
- "declared", "undeclared", "overdeclared"]) if (k in e) o[k] = arr(e[k]);
199
+ "declared", "undeclared", "overdeclared", "dispatchesOn"]) if (k in e) o[k] = arr(e[k]);
200
200
  return o;
201
201
  }
202
202
 
@@ -215,8 +215,12 @@ function normFn(e) {
215
215
  // on, the ⟨0.19⟩/⟨0.20⟩ class fields it scopes with, and the `calls` edges the reason-class fixpoint runs
216
216
  // over. `loc`/`hash`/`unitKind`/`invisible`/`unresolved` are deliberately NOT here: no verdict reads them,
217
217
  // so refusing on them would be a spurious refusal on a report whose gate-relevant content is intact.
218
+ // ⟨0.39⟩ `dispatchesOn` is a VERDICT key, not a diagnostic one: a chained consumer unions the effects
219
+ // of every implementor published under each key it names, so a present-but-unparseable one coerced to
220
+ // `[]` silently drops that union — an effect the consumer really reaches, gone, with no hedge. Exactly
221
+ // the fail-OPEN direction this list exists to refuse.
218
222
  const VERDICT_STR_ARRAY_KEYS = ["inferred", "direct", "calls", "unknownWhy", "netClass", "hosts",
219
- "declared", "undeclared", "overdeclared"];
223
+ "declared", "undeclared", "overdeclared", "dispatchesOn"];
220
224
  const isStrArray = (v) => Array.isArray(v) && v.every((x) => typeof x === "string");
221
225
  export function entryCorruptKeys(e) {
222
226
  if (!e || typeof e !== "object" || Array.isArray(e)) return ["<entry is not an object>"];
@@ -1382,6 +1386,29 @@ export function matches(names, q) {
1382
1386
  return best === 0 ? [] : names.filter((n) => matchTier(n, q) >= best);
1383
1387
  }
1384
1388
 
1389
+ /** SOUNDNESS R507/R497 — THE SELECTOR THAT NAMES SEVERAL FUNCTIONS, for the verbs that answer about ONE.
1390
+ *
1391
+ * Returns the distinct best-tier candidates when MORE THAN ONE survives, else null. The asymmetry it
1392
+ * closes is the whole defect: `path` and `impact` ALREADY refuse at exit 2 when ZERO functions match —
1393
+ * only MANY was answered silently, by taking `targets[0]` and printing a confident verdict about a
1394
+ * function the caller did not ask about. Measured in candor-java on `auth-2.25.60`: `path
1395
+ * resolveCredentials Exec` had FOURTEEN dot-anchored candidates and answered a confident negative about
1396
+ * `AnonymousCredentialsProvider` while three of the fourteen perform `Exec`. A negative is a claim in
1397
+ * this family; a negative about a substituted subject is a fabricated one.
1398
+ *
1399
+ * Anchoring is NOT this function's job and is already done: `matchTier` requires a `[.$#]` boundary
1400
+ * before a suffix match (tier 2), so `ProfileCredentialsProvider` never matches inside
1401
+ * `InstanceProfileCredentialsProvider` — that half of R497 is what candor-ts already had, and it is why
1402
+ * an EXACT match (tier 3) still resolves alone and is never counted as ambiguity.
1403
+ *
1404
+ * DISTINCT names, because the verbs resolve over the union of the callgraph's keys and the report's
1405
+ * `fn`s and one function is routinely in both — a duplicate is one subject, not two.
1406
+ */
1407
+ export function ambiguousSelector(names, q) {
1408
+ const m = [...new Set(matches(names, q))].sort(byCodePoint);
1409
+ return m.length > 1 ? m : null;
1410
+ }
1411
+
1385
1412
  // Exported for consumers that answer MANY caller-count questions over one loaded graph (the LSP
1386
1413
  // codeLens): building the inversion once per request instead of once per `callers()` call.
1387
1414
  export function reverseGraph(cg) {
@@ -1599,6 +1626,11 @@ export function callersFrontier(cg, fns, hierarchy, q) {
1599
1626
  const possible = [];
1600
1627
  for (const f of fns) {
1601
1628
  if (confirmed.has(f.fn)) continue;
1629
+ // ⟨0.39⟩: a synthetic `interfaceUnion` entry is the union over an abstraction member's implementors,
1630
+ // not a function with a body — it cannot CALL anything, so it is not a possible caller. Un-gating
1631
+ // ⟨0.23⟩ made these default rather than opt-in, and this arm then named the bodiless DECLARATION
1632
+ // beside the dispatcher. Found by the four-way frontier differential on (producer=java, consumer=ts).
1633
+ if (f.interfaceUnion) continue;
1602
1634
  const hits = new Set();
1603
1635
  for (const w of f.unknownWhy ?? []) {
1604
1636
  if (!w.startsWith("dispatch:")) continue;
package/query.mjs CHANGED
@@ -27,7 +27,7 @@ import { parsePolicy, scopeMatches, discoverConfigPolicy, parseUnknownAliases, d
27
27
  evaluatePolicy, reportNetClasses, resolveReasonClasses, discoverConfigPath,
28
28
  policyVocabularyAnchor, policyErrorText, policyRefusalUnevaluated, policyUnreadable, policyZeroRules,
29
29
  fatalPolicyErrors, refusalVerdict, sortViolations,
30
- unanswerableScoped, wholePolicyUnanswerable, reportUnits } from "./policy.mjs";
30
+ unanswerableScoped, wholePolicyUnanswerable, reportUnits, noteSyntheticHits } from "./policy.mjs";
31
31
  import { hasReport, refusalMarkerFor, refusalSentence } from "./query-core.mjs";
32
32
  import { printAgents, writeStdoutSync, writeSinkAtomic, isCandorConfigSink } from "./contract.mjs";
33
33
  import { bestFinds } from "./surface.mjs";
@@ -42,7 +42,7 @@ import { impact as coreImpact, path as corePath, gains as coreGains,
42
42
  containment as coreContainment, diff as coreDiff,
43
43
  where as coreWhere, map as coreMap, whatif as coreWhatif,
44
44
  fix as coreFix, fixGate as coreFixGate, unverified as coreUnverified,
45
- matches as coreMatches, gainsCoverage, gainsCompletenessFields, parseClassFilter, ClassFilterError,
45
+ matches as coreMatches, ambiguousSelector, gainsCoverage, gainsCompletenessFields, parseClassFilter, ClassFilterError,
46
46
  loadReport, loadCallgraph, reportCallsGraph, loadGateReport, gateReportInputFiles,
47
47
  reportVersion, reportPackage,
48
48
  advisoryAnswer,
@@ -679,6 +679,19 @@ function renderPathHuman(fns, cg, fnQ, eff, hedge = false) {
679
679
  console.error(`candor-query path: no function matching '${fnQ}'`);
680
680
  process.exit(2);
681
681
  }
682
+ // SOUNDNESS R507 — the human renderer's OWN resolution, refused on the same condition. The gate in the
683
+ // `path` case above resolves over the UNION of the callgraph keys and the report's `fn`s; this one
684
+ // resolves over the report alone, and the two name sets can put their best tier in different places
685
+ // (an exact callgraph key makes the union unambiguous while the report-only set still holds two
686
+ // suffix matches). Same helper, so the two cannot answer the question differently.
687
+ {
688
+ const ambH = ambiguousSelector(fns.map((e) => e.fn), fnQ);
689
+ if (ambH) {
690
+ console.error(`candor-ts-query path: '${fnQ}' names ${ambH.length} functions — refusing to answer `
691
+ + `about one of them. Re-run with the full name:\n ${ambH.join("\n ")}`);
692
+ process.exit(2);
693
+ }
694
+ }
682
695
  const startEntry = fns.find((e) => e.fn === start);
683
696
  const inferred = startEntry?.inferred ?? [];
684
697
  if (!inferred.includes(eff)) {
@@ -717,7 +730,7 @@ function renderPathHuman(fns, cg, fnQ, eff, hedge = false) {
717
730
  // package.json; SPEC_VERSION is the spec contract this build speaks. Reused, never re-littered.
718
731
  const QUERY_DIR = path.dirname(fileURLToPath(import.meta.url));
719
732
  const PKG_VERSION = JSON.parse(fs.readFileSync(path.join(QUERY_DIR, "package.json"), "utf8")).version;
720
- const SPEC_VERSION = "0.38";
733
+ const SPEC_VERSION = "0.39";
721
734
 
722
735
  // ---- the §3.3.1 canonical query grammar (⟨0.10⟩, additive over 0.9) --------------------------------
723
736
  // One shape for every verb: `<verb> <verb-args…> [--report <locator>] [--policy <file>] [--json]
@@ -1725,6 +1738,20 @@ switch (cmd) {
1725
1738
  if (coreMatches(knownFnNames(impCg, impFns), q).length === 0) {
1726
1739
  console.error(`candor-ts-query impact: no function matching '${q}'`); process.exit(2);
1727
1740
  }
1741
+ // SOUNDNESS R507, the same refusal on the verb that answers the OTHER direction — and here the
1742
+ // substitution was HALF-hidden, which is worse than `path`'s. `coreImpact` walks the reverse graph
1743
+ // from EVERY target (so `affectedCount` is a union over all of them) and then prints
1744
+ // `fn: targets[0]`: a blast radius computed for several functions, attributed to one. A reader
1745
+ // checking whether `save` is safe to change gets a count that may belong to a different `save`.
1746
+ // See the `path` gate above for the asymmetry and for why one resolution decides both outcomes.
1747
+ {
1748
+ const amb = ambiguousSelector(knownFnNames(impCg, impFns), q);
1749
+ if (amb) {
1750
+ console.error(`candor-ts-query impact: '${q}' names ${amb.length} functions — refusing to answer `
1751
+ + `about one of them. Re-run with the full name:\n ${amb.join("\n ")}`);
1752
+ process.exit(2);
1753
+ }
1754
+ }
1728
1755
  // ⟨0.32⟩ `putAnswer`, not `put` — this verb had NO completeness reader at all (see `CALLERS_SOWHAT`
1729
1756
  // above for the measurement and the boundary). `affectedCount: 0` is the strongest claim in this
1730
1757
  // verb's vocabulary, and over a report whose own `excluded` names a class nothing opened it rests on
@@ -1995,6 +2022,27 @@ switch (cmd) {
1995
2022
  if (coreMatches(knownFnNames(cg, fns), fn).length === 0) {
1996
2023
  console.error(`candor-ts-query path: no function matching '${fn}'`); process.exit(2);
1997
2024
  }
2025
+ // SOUNDNESS R507 — …AND THE OTHER HALF OF THE SAME RESOLUTION: MANY. Note the asymmetry that IS the
2026
+ // defect: this verb has refused at exit 2 for ZERO matches for a long time (the gate directly above),
2027
+ // while SEVERAL matches were answered silently — `targets[0]`, an arbitrary one, printed as a
2028
+ // confident verdict about a function the caller never named. Measured in candor-java on
2029
+ // `auth-2.25.60`: `path resolveCredentials Exec` had FOURTEEN anchored candidates and returned a
2030
+ // confident negative about `AnonymousCredentialsProvider` while three of the fourteen perform `Exec`.
2031
+ // A negative is a claim in this family, so a negative about a substituted subject is a fabricated one
2032
+ // — strictly worse than unhelpful.
2033
+ //
2034
+ // ONE RESOLUTION, TWO OUTCOMES: the same `knownFnNames` set and the same `matches` ladder decide
2035
+ // zero and many, so the two cannot drift apart the way a second, parallel check would. Anchoring is
2036
+ // already in `matchTier` (this engine has always had R497's first half), so an EXACT match resolves
2037
+ // alone and is never refused.
2038
+ {
2039
+ const amb = ambiguousSelector(knownFnNames(cg, fns), fn);
2040
+ if (amb) {
2041
+ console.error(`candor-ts-query path: '${fn}' names ${amb.length} functions — refusing to answer `
2042
+ + `about one of them. Re-run with the full name:\n ${amb.join("\n ")}`);
2043
+ process.exit(2);
2044
+ }
2045
+ }
1998
2046
  // ⟨0.32⟩ THE COMPLETENESS READER THIS VERB DID NOT HAVE (see `PATH_SOWHAT` above). `path: []` is the
1999
2047
  // determined negative here — *this function does not reach that effect* — and a hop through an unread
2000
2048
  // unit BREAKS the chain, so a hedging report can produce that answer for a function that really does
@@ -2500,6 +2548,8 @@ switch (cmd) {
2500
2548
  // fire AS-EFF-008 "no visible literal" on every report entry whose surface the wire does not carry.
2501
2549
  const gviol = evaluatePolicy(gwp.answerable,
2502
2550
  g.functions, {}, new Map(), new Set(), gnet, gwithhold, gunits);
2551
+ // ⟨0.39⟩ see scan.mjs's twin — one printer, both verdict routes.
2552
+ noteSyntheticHits(gviol);
2503
2553
  // Route the human output exactly as a scan does: to stderr whenever stdout carries the verdict
2504
2554
  // document, so `candor-ts-query gate … --json | jq` sees pure JSON.
2505
2555
  const gsay = (json || gateJsonPath === "-") ? (l) => console.error(l) : (l) => console.log(l);