candor-ts 0.38.2 → 0.39.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/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.2",
3
+ "version": "0.39.0",
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);
package/scan.mjs CHANGED
@@ -30,7 +30,7 @@ import { execFileSync } from "node:child_process";
30
30
  import os from "node:os";
31
31
  import { parsePolicy, evaluatePolicy, scopeMatches, parseUnknownAliases, parseNetPartners, discoverConfigText,
32
32
  reasonClass, discoverConfigPath, policyVocabularyAnchor, policyErrorText, policyRefusalUnevaluated, policyUnreadable, policyZeroRules, fatalPolicyErrors, refusalVerdict, sortViolations,
33
- netClassResolver, resolveReasonClasses } from "./policy.mjs";
33
+ netClassResolver, resolveReasonClasses, noteSyntheticHits } from "./policy.mjs";
34
34
  import { unverifiedHoleRule, ruleUpgrade, canonicalDenySet, byCodePoint, claimsToHaveJudgedNothing, reportCorruptKeys, entryCorruptKeys } from "./query-core.mjs";
35
35
  import { printAgents, writeStdoutSync, writeSinkAtomic, resolveSinkArtifact, isCandorConfigSink } from "./contract.mjs";
36
36
  import { isTestPath, kappa, kappaKnows, nodeCoreUnreviewed, fsKind, commandHeadEffects, hostLiteral,
@@ -49,7 +49,7 @@ const ENGINE_DIR = path.dirname(fileURLToPath(import.meta.url));
49
49
  // literal stamped into the envelope's `spec` field, so the doc lines and the report can never drift.
50
50
  // Reused, never re-littered.
51
51
  const PKG_VERSION = JSON.parse(fs.readFileSync(path.join(ENGINE_DIR, "package.json"), "utf8")).version;
52
- const SPEC_VERSION = "0.38";
52
+ const SPEC_VERSION = "0.39";
53
53
 
54
54
  // A TREE TOO DEEP TO WALK IS "COULD NOT EVALUATE" (exit 2), NEVER "FOUND A VIOLATION" (exit 1).
55
55
  //
@@ -1718,7 +1718,8 @@ const declaredButUninstalled = new Set();
1718
1718
  }
1719
1719
  // ⟨workspace chain⟩ --workspace: auto-discover the target's symlinked monorepo deps (a workspace link
1720
1720
  // points OUT of node_modules to the package's real source), scan each into `.candor/deps/` with
1721
- // interface-CHA union entries (CANDOR_WORKSPACE_CHAIN), and feed that dir into the CANDOR_DEPS spec below —
1721
+ // interface-CHA union entries (⟨0.39⟩: always, no longer behind an env flag), and feed that dir into the
1722
+ // CANDOR_DEPS spec below —
1722
1723
  // so a cross-package call (`client.get()` into `@ukri-tfs/common`) discloses the sibling's effects instead
1723
1724
  // of reading pure. The candor-ts analog of rust `--deps`. The child scan is spawned WITHOUT --workspace, so
1724
1725
  // there is no re-discovery recursion. TRANSITIVE: a dep's calls into ITS OWN workspace deps must also
@@ -1817,7 +1818,7 @@ if (wantWorkspace) {
1817
1818
  for (const real of depPaths) {
1818
1819
  try {
1819
1820
  const out = execFileSync(process.execPath, [selfPath, real, "--json"],
1820
- { env: { ...process.env, CANDOR_WORKSPACE_CHAIN: "1", CANDOR_DEPS: workspaceDepsDir },
1821
+ { env: { ...process.env, CANDOR_DEPS: workspaceDepsDir },
1821
1822
  maxBuffer: 512 * 1024 * 1024, stdio: ["ignore", "pipe", "ignore"] });
1822
1823
  // ⟨ownership, half 1⟩ ONE derivation, shared with `failedDepName`, and TOTAL. The sweep's rule is
1823
1824
  // "a file candor would have OVERWRITTEN on success is the file it removes on failure", which is
@@ -1914,7 +1915,7 @@ if (wantDepInits) {
1914
1915
  const file = depCacheFileName(depInitsDir, pkg);
1915
1916
  try {
1916
1917
  const out = execFileSync(process.execPath, [selfPath2, dir, "--json", "--allow-js"],
1917
- { env: { ...process.env, CANDOR_WORKSPACE_CHAIN: "1" },
1918
+ { env: { ...process.env },
1918
1919
  maxBuffer: 512 * 1024 * 1024, stdio: ["ignore", "pipe", "ignore"] });
1919
1920
  fs.writeFileSync(file, out);
1920
1921
  answered.add(pkg); ownFiles.add(file);
@@ -2271,9 +2272,24 @@ const corruptDepPkgs = new Set();
2271
2272
  // package chained twice — is never clobbered by the bad one.
2272
2273
  if (!stale && entryCorruptKeys(e).length) continue;
2273
2274
  const hashPkg = e.hash.split("#")[0];
2274
- if (hashPkg) covers.add(hashPkg);
2275
- const cell = crossDeps.get(e.hash) ?? { inferred: new Set(), invisible: new Set(), why: new Set(), hosts: [], cmds: [], paths: [], tables: [], incomplete: new Set(), netIncomplete: false };
2275
+ // ⟨0.39⟩ A FOREIGN UNION ENTRY IS NOT COVERAGE OF THE PACKAGE IT NAMES. Obligation 2 makes a
2276
+ // package publish an `interfaceUnion` entry keyed under the abstraction's OWNER, so `effimpl`'s
2277
+ // report now carries `iface#Backend.size` — and reading that as "iface was analyzed" would delete
2278
+ // `invisible: [iface]` from every call into iface that nobody analysed. That is R475's OWN shape
2279
+ // manufactured by R475's own fix: a disclosure removed by a mechanism added to stop disclosures
2280
+ // being removed. One synthetic row about one member of somebody else's abstraction is a claim
2281
+ // about that MEMBER, never about that package.
2282
+ // Fail-CLOSED on a report with no `package`: a union entry then contributes no coverage at all,
2283
+ // because nothing in the file proves the key is the producer's own. Withholding coverage costs a
2284
+ // disclosure the consumer keeps (`invisible`, or half 1's `Unknown[dispatch:…]`); granting it
2285
+ // wrongly costs one it loses, and only one of those two errors is recoverable.
2286
+ if (hashPkg && !(e.interfaceUnion === true && hashPkg !== d.package)) covers.add(hashPkg);
2287
+ const cell = crossDeps.get(e.hash) ?? { inferred: new Set(), invisible: new Set(), why: new Set(), hosts: [], cmds: [], paths: [], tables: [], incomplete: new Set(), netIncomplete: false, dispatch: new Set() };
2276
2288
  for (const x of stale ? ["Unknown"] : strs(e.inferred)) cell.inferred.add(x);
2289
+ // ⟨0.39⟩ obligation 3's contributor list travels in the cell: which abstraction members this
2290
+ // dependency unit dispatches on. A STALE report's are not read — its assertions are from a build
2291
+ // this scan does not trust, and it is already downgraded to a bare Unknown above.
2292
+ if (!stale) for (const k of strs(e.dispatchesOn)) cell.dispatch.add(k);
2277
2293
  // ⟨0.19⟩ THE REASON CLASS TRAVELS WITH THE UNKNOWN. Without this the join copied `inferred` and
2278
2294
  // `invisible` only, so a dependency's `Unknown[reflect:eval]` arrived at the consumer as a bare
2279
2295
  // Unknown and fell back to the generic `unresolved` — and `deny Net Unknown[reflect]`, a rule
@@ -2347,6 +2363,43 @@ const corruptDepPkgs = new Set();
2347
2363
  }
2348
2364
  } catch { console.error(`candor-ts: CANDOR_DEPS report could not be processed, skipped: ${f}`); }
2349
2365
  }
2366
+ // ⟨0.39⟩ OBLIGATION 3, THE CHAINED HALF: union, PER KEY, every chained entry carrying it. Resolved
2367
+ // once here, over the whole loaded set, rather than at each call site — the contributors are exactly
2368
+ // the reports already read, and a key's own cell may itself dispatch (`app` -> `middle::mid_size` ->
2369
+ // `iface#Backend.size`), so this is a least fixpoint over `crossDeps` and not a single lookup.
2370
+ //
2371
+ // This adds a CONTRIBUTOR, not a resolution rule: ⟨0.25⟩'s ambiguous-key union already specifies how
2372
+ // several reports' entries under one hash combine, and the loader above has been unioning them into
2373
+ // one cell all along. What was missing is that `iface#termSize`'s cell had no way to reach
2374
+ // `iface#Backend.size`'s, so an effectful implementor published by a THIRD package sat in the map
2375
+ // unread while the consumer certified the dispatch pure.
2376
+ {
2377
+ const order = [...crossDeps.keys()];
2378
+ let changed = true;
2379
+ for (let round = 0; changed && round < 64; round++) { // bounded: a chain deeper than this is a cycle
2380
+ changed = false;
2381
+ for (const k of order) {
2382
+ const cell = crossDeps.get(k);
2383
+ for (const target of [...cell.dispatch]) {
2384
+ if (target === k) continue; // a member that dispatches on itself adds nothing
2385
+ const src = crossDeps.get(target);
2386
+ if (!src) continue; // nobody published a union under that key — silence IS purity
2387
+ const before = cell.inferred.size + cell.invisible.size + cell.why.size
2388
+ + cell.incomplete.size + cell.dispatch.size;
2389
+ for (const x of src.inferred) cell.inferred.add(x);
2390
+ for (const b of src.invisible) cell.invisible.add(b);
2391
+ for (const w of src.why) cell.why.add(w);
2392
+ for (const v of src.incomplete) cell.incomplete.add(v);
2393
+ for (const d2 of src.dispatch) cell.dispatch.add(d2);
2394
+ for (const m of ["hosts", "cmds", "paths", "tables"])
2395
+ for (const v of src[m]) if (!cell[m].includes(v)) cell[m].push(v);
2396
+ if (src.netIncomplete && !cell.netIncomplete) { cell.netIncomplete = true; changed = true; }
2397
+ if (cell.inferred.size + cell.invisible.size + cell.why.size
2398
+ + cell.incomplete.size + cell.dispatch.size !== before) changed = true;
2399
+ }
2400
+ }
2401
+ }
2402
+ }
2350
2403
  // A package chained TWICE — once fresh, once stale — is covered by the fresh report, so it is not a
2351
2404
  // stale-only package and must not pick up the disclosure below on top of a real answer.
2352
2405
  for (const p of depCoveredPkgs) staleDepPkgs.delete(p);
@@ -2620,6 +2673,32 @@ function nearestPackageName(file) {
2620
2673
  for (const d of seen) pkgNameCache.set(d, null);
2621
2674
  return null;
2622
2675
  }
2676
+ // ⟨0.39⟩ The nearest directory holding a `package.json`, at or above a file — the root `nearestPackageName`
2677
+ // found the name in. Used to spell a FOREIGN abstraction's `loc` in a report.
2678
+ function nearestPackageDir(file) {
2679
+ let dir = path.dirname(file);
2680
+ while (dir && dir !== path.dirname(dir)) {
2681
+ try { if (fs.existsSync(path.join(dir, "package.json"))) return dir; } catch { /* keep climbing */ }
2682
+ dir = path.dirname(dir);
2683
+ }
2684
+ return null;
2685
+ }
2686
+ // ⟨0.39⟩ WHERE a union entry's abstraction is declared. For a LOCAL one that is the ordinary
2687
+ // project-relative path. For a FOREIGN one `path.relative(rootDir, …)` produces a `../../..` chain out of
2688
+ // the tree — unreadable, and, worse, NOT REPRODUCIBLE: its length is a function of how deep the checkout
2689
+ // happens to sit, so two machines scanning the same sources emit different bytes and every
2690
+ // byte-comparison A/B and the chain-idempotence part read a difference that is not one. Spelled from the
2691
+ // OWNING package instead (`ifz/src/index.ts`), which is stable, readable, and says which package the
2692
+ // declaration belongs to — the same fact the hash already carries.
2693
+ function abstractionLoc(sf, line, character) {
2694
+ const abs = path.resolve(sf.fileName);
2695
+ const rel = path.relative(rootDir, abs);
2696
+ if (!rel.startsWith("..")) return `${rel}:${line + 1}:${character + 1}`;
2697
+ const owner = nearestPackageDir(abs);
2698
+ const name = owner && nearestPackageName(abs);
2699
+ const inside = owner ? path.relative(owner, abs) : path.basename(abs);
2700
+ return `${name ? `${name}/` : ""}${inside}:${line + 1}:${character + 1}`;
2701
+ }
2623
2702
  // Does reaching this declaration CROSS A PACKAGE BOUNDARY the scan cannot see into? This is the gate on
2624
2703
  // every κ-ledger / `invisible` disclosure arm (the unmodeled-external-call, the `new ExternalClass()`, the
2625
2704
  // external tagged template). It used to be spelled `/node_modules\//.test(file)` — true only of an
@@ -3381,6 +3460,33 @@ function isDynamicExportsDescriptor(call) {
3381
3460
  // `@Entity()` (naming-strategy-dependent) contributes nothing — never a guess.
3382
3461
  const entityTables = new Map(); // ClassDeclaration node -> table name
3383
3462
  const interfaceImpls = new Map(); // InterfaceDeclaration node -> implementing ClassDeclarations (CHA universe)
3463
+ // ⟨0.39⟩ obligation 2 — the SAME relation for an abstraction owned by a DEPENDENCY. Deliberately a
3464
+ // SECOND map rather than a widening of the one above: `interfaceImpls` is the in-scan dispatch site's CHA
3465
+ // universe, and a foreign declaration in it would put a `node_modules` interface into the resolution of
3466
+ // every local dispatch. These entries are published (keyed under the OWNER's package) and joined; they
3467
+ // are not a local resolution universe.
3468
+ const foreignInterfaceImpls = new Map(); // foreign InterfaceDeclaration -> local implementing classes
3469
+ // ⟨SOUNDNESS R512⟩ Is this interface DECLARATION one a foreign union entry may be published under? ONE
3470
+ // predicate, shared by the nominal `implements` arm and the STRUCTURAL arm below, because two copies of
3471
+ // one key rule is how a second spelling gets invented (§4 forbids exactly that for this key).
3472
+ //
3473
+ // The PLATFORM typing test is `declIsNodeTypes`, not a name check, for the reason `recordDispatch`'s own
3474
+ // comment gives: `declModule` derives `events`, `stream` and `process` out of `@types/node` and npm ships
3475
+ // real packages under all three names, so a name-keyed test hands node's review to an unrelated package.
3476
+ // It is included HERE and not only at `recordDispatch` because the two sides must agree: `recordDispatch`
3477
+ // refuses to mint `events#EventEmitter.on` at a consumer, so an entry published under that key is one no
3478
+ // consumer can ever form — unjoinable wire noise, and noise in a soundness field is how a real value
3479
+ // stops being read.
3480
+ const isPublishableForeignIface = (d) => {
3481
+ if (!d || !ts.isInterfaceDeclaration(d) || !d.name) return false;
3482
+ if (projectFiles.has(path.resolve(d.getSourceFile().fileName))) return false; // the LOCAL arm owns this one
3483
+ if (declIsNodeTypes(d)) return false;
3484
+ const m = declModule(d);
3485
+ // A nameable PACKAGE, and not our own under another spelling. `<es-lib>`/`<local>` mint no key; an
3486
+ // absolute path fallback (a file no package.json claims) is not a namespace any consumer can form a
3487
+ // hash in, and publishing under it would be the second spelling §4 forbids.
3488
+ return !!m && !m.startsWith("<") && !m.startsWith("/") && m !== pkgName && m !== rootOwnerPkg;
3489
+ };
3384
3490
  // ⟨CARDINAL SIN FIX, caller-path scope⟩ ifaceQual ("mod.Iface") -> Set<callerQual> that GENUINELY
3385
3491
  // dispatched through that interface's own signature and had it resolved by CHA below — populated AT THE
3386
3492
  // RESOLUTION SITE (pass 2's interface-CHA arm), not reconstructed afterward from the flat callgraph.
@@ -3823,19 +3929,43 @@ for (const sf of sources) {
3823
3929
  for (const st of eh.types) for (const sdecl of localInterfaceDecls(st.expression)) climb(sdecl);
3824
3930
  }
3825
3931
  };
3932
+ // ⟨0.39⟩ obligation 2: the same relation for an abstraction this package does NOT own. `class
3933
+ // Crossterm implements iface.Backend` is the measured instance's shape — the effectful implementor
3934
+ // lives in a THIRD package, neither the dispatching dependency nor the consumer, so a producer that
3935
+ // covers only LOCAL abstractions misses it entirely and no consumer can ever learn of it. Super-
3936
+ // interfaces are climbed on the same argument as the local arm: a dispatch resolves `base` to
3937
+ // whichever interface DECLARES it, which may be a foreign super of a foreign sub.
3938
+ const foreignInterfaceDecls = (typeExpr) => {
3939
+ const sym = checker.getSymbolAtLocation(typeExpr);
3940
+ const tgt = sym && sym.flags & ts.SymbolFlags.Alias ? checker.getAliasedSymbol(sym) : sym;
3941
+ return (tgt?.declarations ?? []).filter(isPublishableForeignIface);
3942
+ };
3943
+ const fClimbSeen = new Set();
3944
+ const fClimb = (iface) => {
3945
+ if (fClimbSeen.has(iface)) return;
3946
+ fClimbSeen.add(iface);
3947
+ if (!foreignInterfaceImpls.has(iface)) foreignInterfaceImpls.set(iface, []);
3948
+ const arr = foreignInterfaceImpls.get(iface);
3949
+ if (!arr.includes(node)) arr.push(node);
3950
+ for (const eh of iface.heritageClauses ?? []) {
3951
+ if (eh.token !== ts.SyntaxKind.ExtendsKeyword) continue;
3952
+ for (const st of eh.types) for (const sdecl of foreignInterfaceDecls(st.expression)) fClimb(sdecl);
3953
+ }
3954
+ };
3826
3955
  for (const h of node.heritageClauses ?? []) {
3827
3956
  if (h.token !== ts.SyntaxKind.ImplementsKeyword) continue;
3828
3957
  // Register under EVERY declaration of the interface symbol: a merged interface (two `interface
3829
3958
  // Store` blocks / module augmentation) resolves a method to whichever block declares it, and keying
3830
3959
  // only declarations[0] silently missed the others (/code-review).
3831
3960
  for (const t of h.types) for (const idecl of localInterfaceDecls(t.expression)) climb(idecl);
3961
+ for (const t of h.types) for (const idecl of foreignInterfaceDecls(t.expression)) fClimb(idecl);
3832
3962
  }
3833
3963
  const ctorQual = `${mod}.${namespacePrefixOf(node)}${node.name.text}.constructor`;
3834
3964
  if (!fns.has(ctorQual)) {
3835
3965
  const { line, character } = sf.getLineAndCharacterOfPosition(node.getStart());
3836
3966
  fns.set(ctorQual, { local: `${node.name.text}.constructor`, direct: new Set(), fsKinds: new Set(), edges: new Set(),
3837
3967
  hosts: new Set(), tables: new Set(), cmds: new Set(), paths: new Set(),
3838
- blind: new Set(), incomplete: new Set(), why: new Set(), entry: false,
3968
+ blind: new Set(), incomplete: new Set(), dispatch: new Set(), why: new Set(), entry: false,
3839
3969
  loc: `${path.relative(rootDir, sf.fileName)}:${line + 1}:${character + 1}`,
3840
3970
  endLine: sf.getLineAndCharacterOfPosition(node.getEnd()).line + 1 });
3841
3971
  }
@@ -3910,7 +4040,7 @@ for (const sf of sources) {
3910
4040
  const nsp = namespacePrefixOf(node);
3911
4041
  const qual = isFunctionScoped(node) ? `${mod}.${nsp}${n}#${line + 1}:${character + 1}` : `${mod}.${nsp}${n}`;
3912
4042
  fns.set(qual, { local: n, direct: new Set(), fsKinds: new Set(), edges: new Set(), hosts: new Set(), tables: new Set(),
3913
- cmds: new Set(), paths: new Set(), blind: new Set(), incomplete: new Set(), why: new Set(), entry: false, isCjsExport,
4043
+ cmds: new Set(), paths: new Set(), blind: new Set(), incomplete: new Set(), dispatch: new Set(), why: new Set(), entry: false, isCjsExport,
3914
4044
  loc: `${path.relative(rootDir, sf.fileName)}:${line + 1}:${character + 1}`,
3915
4045
  endLine: sf.getLineAndCharacterOfPosition(node.getEnd()).line + 1 });
3916
4046
  nodeName.set(node, qual);
@@ -4332,18 +4462,29 @@ function unwrapBind(node, depth = 0) {
4332
4462
  // check. Local-only, mirroring the nominal branch's own bound: a structural value arriving from outside
4333
4463
  // this scan's own source tree (an argument passed by an external caller, a dependency's own callback)
4334
4464
  // is no more visible here than an external nominal implementor already was.
4335
- function localInterfaceDeclsOfType(t) {
4336
- const out = [];
4465
+ //
4466
+ // ⟨SOUNDNESS R512⟩ …and the FOREIGN half of the same climb. "Local-only, mirroring the nominal branch's
4467
+ // own bound" was true of the nominal branch when that sentence was written and stopped being true when
4468
+ // ⟨0.39⟩ gave the nominal branch a foreign arm: `class Crossterm implements dep.Backend` publishes a
4469
+ // union entry under `dep`, and `const x: dep.Backend = { size() { …net… } }` — the SAME package supplying
4470
+ // the SAME effectful implementor of the SAME foreign abstraction — published nothing at all, so a chained
4471
+ // consumer read `inferred: []` with no `invisible`, which under ⟨0.21⟩ is a purity claim. That is R475's
4472
+ // own shape one SPELLING over, and a structural object literal is idiomatic TypeScript rather than an
4473
+ // exotic case. The bound that survives is the one that was doing the work: an implementor arriving from
4474
+ // OUTSIDE this scan's own source tree is still invisible here. A foreign implementor DECLARED here is not.
4475
+ function interfaceDeclsOfType(t) {
4476
+ const local = [], foreign = [];
4337
4477
  const consider = (ct) => {
4338
4478
  const sym = ct?.getSymbol?.() ?? ct?.symbol;
4339
4479
  for (const d of sym?.declarations ?? []) {
4340
- if (ts.isInterfaceDeclaration(d) && projectFiles.has(path.resolve(d.getSourceFile().fileName)) && !out.includes(d))
4341
- out.push(d);
4480
+ if (!ts.isInterfaceDeclaration(d)) continue;
4481
+ if (projectFiles.has(path.resolve(d.getSourceFile().fileName))) { if (!local.includes(d)) local.push(d); }
4482
+ else if (isPublishableForeignIface(d)) { if (!foreign.includes(d)) foreign.push(d); }
4342
4483
  }
4343
4484
  };
4344
- if (!t) return out;
4485
+ if (!t) return { local, foreign };
4345
4486
  if (t.isUnion?.()) { for (const ct of t.types) consider(ct); } else consider(t);
4346
- return out;
4487
+ return { local, foreign };
4347
4488
  }
4348
4489
  function registerStructuralImpl(ifaceDecl, implNode, seen = new Set()) {
4349
4490
  if (seen.has(ifaceDecl)) return;
@@ -4364,6 +4505,28 @@ function registerStructuralImpl(ifaceDecl, implNode, seen = new Set()) {
4364
4505
  }
4365
4506
  }
4366
4507
  }
4508
+ // ⟨SOUNDNESS R512⟩ The same registration for an abstraction this package does NOT own — into
4509
+ // `foreignInterfaceImpls`, NOT `interfaceImpls`, on that map's own stated grounds: these entries are
4510
+ // published under the OWNER's package and joined, they are not a local resolution universe, and a
4511
+ // `node_modules` declaration in the in-scan CHA universe would change what every local dispatch resolves
4512
+ // to. Super-interfaces are climbed on the nominal arm's argument: a dispatch resolves a member to
4513
+ // whichever interface DECLARES it, which may be a foreign super of a foreign sub.
4514
+ function registerForeignStructuralImpl(ifaceDecl, implNode, seen = new Set()) {
4515
+ if (seen.has(ifaceDecl)) return;
4516
+ seen.add(ifaceDecl);
4517
+ if (!foreignInterfaceImpls.has(ifaceDecl)) foreignInterfaceImpls.set(ifaceDecl, []);
4518
+ const arr = foreignInterfaceImpls.get(ifaceDecl);
4519
+ if (!arr.includes(implNode)) arr.push(implNode);
4520
+ for (const eh of ifaceDecl.heritageClauses ?? []) {
4521
+ if (eh.token !== ts.SyntaxKind.ExtendsKeyword) continue;
4522
+ for (const st of eh.types) {
4523
+ let sym; try { sym = checker.getSymbolAtLocation(st.expression); } catch { sym = undefined; }
4524
+ const tgt = sym && sym.flags & ts.SymbolFlags.Alias ? checker.getAliasedSymbol(sym) : sym;
4525
+ for (const d of tgt?.declarations ?? [])
4526
+ if (isPublishableForeignIface(d)) registerForeignStructuralImpl(d, implNode, seen);
4527
+ }
4528
+ }
4529
+ }
4367
4530
  // Is `param -> body` a PROVEN identity return — the function's ENTIRE body is exactly `return <param>;`
4368
4531
  // (block form) or exactly `<param>` (arrow concise-body form), with no destructuring, no default, no
4369
4532
  // rest, no cast, no conditional, nothing at all between the parameter and the value returned — and the
@@ -4399,8 +4562,11 @@ function isProvenIdentityReturn(decl, paramIdx) {
4399
4562
  // chain of calls.
4400
4563
  function contextualInterfaceDeclsFor(node, depth = 0) {
4401
4564
  let ct; try { ct = checker.getContextualType(node); } catch { ct = undefined; }
4402
- const direct = localInterfaceDeclsOfType(ct);
4403
- if (direct.length || depth >= 4) return direct;
4565
+ const direct = interfaceDeclsOfType(ct);
4566
+ // ⟨SOUNDNESS R512⟩ The climb continues while NEITHER partition answered. Keying it on the LOCAL list
4567
+ // alone would stop the `Object.assign` / proven-identity-wrapper climb dead for a foreign abstraction,
4568
+ // which is the same miss one level up.
4569
+ if (direct.local.length || direct.foreign.length || depth >= 4) return direct;
4404
4570
  const p = node.parent;
4405
4571
  if (!p || !ts.isCallExpression(p) || !(p.arguments ?? []).includes(node)) return direct;
4406
4572
  const calleeText = p.expression.getText().replace(/\s+/g, "");
@@ -4461,7 +4627,7 @@ function mintPositionalStructuralUnit(mod, sf, prop, name) {
4461
4627
  const { line, character } = sf.getLineAndCharacterOfPosition(prop.getStart());
4462
4628
  fns.set(qual, { local: `<structural>.${name}`, direct: new Set(), fsKinds: new Set(), edges: new Set(),
4463
4629
  hosts: new Set(), tables: new Set(), cmds: new Set(), paths: new Set(), blind: new Set(),
4464
- incomplete: new Set(), why: new Set(), entry: false,
4630
+ incomplete: new Set(), dispatch: new Set(), why: new Set(), entry: false,
4465
4631
  loc: `${path.relative(rootDir, sf.fileName)}:${line + 1}:${character + 1}`,
4466
4632
  endLine: sf.getLineAndCharacterOfPosition(prop.getEnd()).line + 1 });
4467
4633
  }
@@ -4476,8 +4642,9 @@ for (const sf of sources) {
4476
4642
  // never structurally satisfy an interface with any member on its own.
4477
4643
  if (ts.isObjectLiteralExpression(node) && node.properties.length > 0) {
4478
4644
  const decls = contextualInterfaceDeclsFor(node);
4479
- if (decls.length) {
4480
- for (const d of decls) registerStructuralImpl(d, node);
4645
+ if (decls.local.length || decls.foreign.length) {
4646
+ for (const d of decls.local) registerStructuralImpl(d, node);
4647
+ for (const d of decls.foreign) registerForeignStructuralImpl(d, node);
4481
4648
  mintStructuralMembers(node);
4482
4649
  }
4483
4650
  } else if (ts.isClassExpression(node)) {
@@ -4486,17 +4653,25 @@ for (const sf of sources) {
4486
4653
  // registered nowhere at all (not even as a candidate, unlike the object-literal shape) and its
4487
4654
  // methods were never minted units either (`localName`'s method branch requires a ClassDeclaration
4488
4655
  // parent). Reuse the SAME climb/registration and member-minting as the object-literal shape.
4656
+ // ⟨SOUNDNESS R512⟩ …and a FOREIGN `implements` on a class EXPRESSION registers under the OWNER, the
4657
+ // same as the class DECLARATION arm already does. The audit boundary is deliberately drawn past the
4658
+ // trigger: the row is about the object-literal spelling, and this branch is the identical hole one
4659
+ // construct over — an anonymous class expression supplying a dependency's abstraction effectfully.
4489
4660
  let registeredAny = false;
4490
4661
  for (const h of node.heritageClauses ?? []) {
4491
4662
  if (h.token !== ts.SyntaxKind.ImplementsKeyword) continue;
4492
4663
  for (const t of h.types) {
4493
4664
  let sym; try { sym = checker.getSymbolAtLocation(t.expression); } catch { sym = undefined; }
4494
4665
  const tgt = sym && sym.flags & ts.SymbolFlags.Alias ? checker.getAliasedSymbol(sym) : sym;
4495
- for (const d of tgt?.declarations ?? [])
4666
+ for (const d of tgt?.declarations ?? []) {
4496
4667
  if (ts.isInterfaceDeclaration(d) && projectFiles.has(path.resolve(d.getSourceFile().fileName))) {
4497
4668
  registerStructuralImpl(d, node);
4498
4669
  registeredAny = true;
4670
+ } else if (isPublishableForeignIface(d)) {
4671
+ registerForeignStructuralImpl(d, node);
4672
+ registeredAny = true;
4499
4673
  }
4674
+ }
4500
4675
  }
4501
4676
  }
4502
4677
  if (registeredAny) mintStructuralMembers(node);
@@ -5122,7 +5297,7 @@ function moduleUnit(sf) {
5122
5297
  let rec = fns.get(qual);
5123
5298
  if (!rec) {
5124
5299
  rec = { local: qual, direct: new Set(), fsKinds: new Set(), edges: new Set(), hosts: new Set(), tables: new Set(),
5125
- cmds: new Set(), paths: new Set(), blind: new Set(), incomplete: new Set(), why: new Set(),
5300
+ cmds: new Set(), paths: new Set(), blind: new Set(), incomplete: new Set(), dispatch: new Set(), why: new Set(),
5126
5301
  entry: false, unitKind: "initializer",
5127
5302
  loc: `${path.relative(rootDir, sf.fileName)}:1:1`,
5128
5303
  endLine: sf.getLineAndCharacterOfPosition(sf.getEnd()).line + 1 };
@@ -5145,7 +5320,7 @@ function staticBlockUnit(node) {
5145
5320
  let rec = fns.get(qual);
5146
5321
  if (!rec) {
5147
5322
  rec = { local: "<static-init>", direct: new Set(), fsKinds: new Set(), edges: new Set(), hosts: new Set(), tables: new Set(),
5148
- cmds: new Set(), paths: new Set(), blind: new Set(), incomplete: new Set(), why: new Set(),
5323
+ cmds: new Set(), paths: new Set(), blind: new Set(), incomplete: new Set(), dispatch: new Set(), why: new Set(),
5149
5324
  entry: false, unitKind: "initializer",
5150
5325
  loc: `${path.relative(rootDir, sf.fileName)}:${sf.getLineAndCharacterOfPosition(node.getStart()).line + 1}:1`,
5151
5326
  endLine: sf.getLineAndCharacterOfPosition(node.getEnd()).line + 1 };
@@ -5170,7 +5345,7 @@ function decoratorArgUnit(callNode) {
5170
5345
  let rec = fns.get(qual);
5171
5346
  if (!rec) {
5172
5347
  rec = { local, direct: new Set(), fsKinds: new Set(), edges: new Set(), hosts: new Set(), tables: new Set(),
5173
- cmds: new Set(), paths: new Set(), blind: new Set(), incomplete: new Set(), why: new Set(),
5348
+ cmds: new Set(), paths: new Set(), blind: new Set(), incomplete: new Set(), dispatch: new Set(), why: new Set(),
5174
5349
  entry: false, unitKind: "initializer",
5175
5350
  loc: `${path.relative(rootDir, sf.fileName)}:${sf.getLineAndCharacterOfPosition(callNode.getStart()).line + 1}:1`,
5176
5351
  endLine: sf.getLineAndCharacterOfPosition(callNode.getEnd()).line + 1 };
@@ -5386,6 +5561,70 @@ function memberSigOf(decl) {
5386
5561
  return decl && (ts.isFunctionTypeNode(decl) || ts.isCallSignatureDeclaration(decl)) && decl.parent
5387
5562
  && (ts.isPropertySignature(decl.parent) || ts.isMethodSignature(decl.parent)) ? decl.parent : decl;
5388
5563
  }
5564
+ // ⟨0.39⟩ obligation 1, THE KEY. One derivation, used by every site that records a dispatch and by the
5565
+ // union emitter that publishes under it — because two spellings of one wire key is exactly the ⟨0.34⟩
5566
+ // drift the clause spends a paragraph forbidding, and §4 says an engine MUST NOT invent a second one.
5567
+ // The shape is the one ⟨0.23⟩ already fixes for this engine's entry hashes: `<owning package>#<Iface>.<member>`.
5568
+ const dispatchKey = (pkg, ifaceName, member) => `${pkg}#${ifaceName}.${member}`;
5569
+ // The interface member a resolved declaration dispatches THROUGH, or null. A method/property signature
5570
+ // on an INTERFACE only: that is precisely the universe `interfaceUnion` publishes under, so a key
5571
+ // recorded here is always a key a producer could answer. A call signature, a type-literal member and an
5572
+ // abstract class member deliberately mint nothing — a `dispatchesOn` value no union entry can ever carry
5573
+ // is wire noise, and noise in a soundness field is how a real one stops being read.
5574
+ function dispatchedInterfaceMember(decl) {
5575
+ const sig = memberSigOf(decl);
5576
+ if (!sig || !(ts.isMethodSignature(sig) || ts.isPropertySignature(sig))) return null;
5577
+ if (!sig.parent || !ts.isInterfaceDeclaration(sig.parent) || !sig.parent.name) return null;
5578
+ const m = sig.name?.getText?.();
5579
+ return m ? { ifaceName: sig.parent.name.text, member: m } : null;
5580
+ }
5581
+ // Record a dispatch on an interface owned by `pkg`; returns the parts, or null. Called from the in-scan
5582
+ // bounded-CHA site (`pkgName`) and from both external-call arms (the dependency's name) — NOT gated on
5583
+ // what the CHA answered, because the toggle this rung closes runs between ZERO implementors and ONE, so
5584
+ // a field recorded only on the indeterminate branch is absent in exactly the arm that needs it.
5585
+ //
5586
+ // THE PLATFORM TYPE SURFACE IS EXCLUDED, and the test is on the FILE rather than the module name — the
5587
+ // same rule `nodeCoreUnreviewed`'s own comment states, and for the same reason: `declModule` derives
5588
+ // `events`, `buffer.buffer` and `process` out of `@types/node`, and npm ships real packages under those
5589
+ // names. The exclusion is not a precision preference: `@types/node` and the TypeScript lib are types
5590
+ // with no implementation any scan can analyse, so NO producer can ever publish a union under such a
5591
+ // key. A `dispatchesOn` value nothing can answer is noise, and noise in a soundness field is how a real
5592
+ // value stops being read. Measured before the exclusion: an over-charge control's genuinely-pure
5593
+ // function carried `dispatchesOn: [buffer.buffer#BufferConstructor.from, events#EventEmitter.emit,
5594
+ // events#EventEmitter.on, process#Process.cwd]` — four keys, zero of them answerable.
5595
+ function recordDispatch(rec, decl, pkg) {
5596
+ if (!rec || !decl || !pkg) return null;
5597
+ if (declIsNodeTypes(decl)) return null;
5598
+ const d = dispatchedInterfaceMember(decl);
5599
+ if (!d) return null;
5600
+ const key = dispatchKey(pkg, d.ifaceName, d.member);
5601
+ rec.dispatch.add(key);
5602
+ return { key, pkg, ...d };
5603
+ }
5604
+ // ⟨0.39⟩ obligation 3, THE LOCAL HALF AT THE JOIN: "its own visible implementors". A key resolved
5605
+ // through a dependency may also be answered by a class in THIS scan — an application supplying its own
5606
+ // backend to a library is the ordinary shape of it, and it is the shape the clause's measured case has.
5607
+ // Bounded by the SAME `CHA_FANOUT_LIMIT` the in-scan dispatch site and the union emitter apply, and
5608
+ // hedged on the SAME completeness condition: an implementor whose member resolves to no unit leaves the
5609
+ // candidate set incomplete, and edging the rest while staying silent about it would drop its effects.
5610
+ function joinLocalImpls(rec, d) {
5611
+ if (!rec || !d) return;
5612
+ const { targets, allResolved, decls } = localImplTargets(d.key);
5613
+ if (!targets.length) return;
5614
+ // The name means two things here, so no implementor set can be attributed to this key — §4 ⟨0.24⟩'s
5615
+ // `ambiguous:`, not `dispatch:`: the owner type is nameable, but WHICH declaration it names is not.
5616
+ if (decls.size > 1) {
5617
+ rec.direct.add("Unknown");
5618
+ rec.why.add(`ambiguous:${d.pkg}.${d.ifaceName}.${d.member}`);
5619
+ return;
5620
+ }
5621
+ if (targets.length > CHA_FANOUT_LIMIT || !allResolved) {
5622
+ rec.direct.add("Unknown");
5623
+ rec.why.add(dispatchWhy(`${d.pkg}.${d.ifaceName}`, d.member));
5624
+ return;
5625
+ }
5626
+ for (const t of targets) rec.edges.add(t);
5627
+ }
5389
5628
  // Apply ONE chained-dependency entry to the calling unit. There is exactly one of these because there used
5390
5629
  // to be two, drifted: the CallExpression arm and the desugared-declaration arm each spelled the copy out,
5391
5630
  // and the ⟨0.19⟩ reason class was added to neither. That is the same root cause candor-java's `6ab26e4`
@@ -5393,7 +5632,70 @@ function memberSigOf(decl) {
5393
5632
  // is most of the fix there too. The dep's `invisible` travels as this call's own — the transitive
5394
5633
  // disclosure has to cross the package edge, or a sibling's SNS reach reads pure here — and so does its
5395
5634
  // `unknownWhy`, so `deny E Unknown[<class>]` keeps its scope one boundary along.
5635
+ // ⟨0.39⟩ OBLIGATION 3, THE LOCAL HALF: "its own visible implementors". A key the dependency dispatches
5636
+ // on may be answered by a class in THIS scan — the ordinary shape of an application supplying its own
5637
+ // backend to a library. Built once, lazily, because it needs pass 1's `nodeName` minting; keyed by the
5638
+ // same `pkg#Iface.member` string the wire uses, over BOTH registries (our own abstractions, in case a
5639
+ // dependency dispatches on one of ours, and the foreign ones obligation 2 publishes under).
5640
+ //
5641
+ // The contribution is an EDGE, not an effect set, and that is load-bearing: at the moment a dep hit is
5642
+ // applied the implementor's own transitive effects have not been computed yet (pass 3 does that), so
5643
+ // copying them here would copy whatever happened to be known and silently under-report. An edge flows
5644
+ // through the SAME least fixpoint every other call does.
5645
+ let localImplTargetsByKey = null;
5646
+ function localImplTargets(key) {
5647
+ if (!localImplTargetsByKey) {
5648
+ localImplTargetsByKey = new Map();
5649
+ const add = (ownerPkg, ifaceDecl, implClasses) => {
5650
+ const ifaceName = ifaceDecl.name?.text;
5651
+ if (!ifaceName) return;
5652
+ for (const member of ifaceDecl.members ?? []) {
5653
+ const m = member.name?.getText?.();
5654
+ if (!m) continue;
5655
+ const k = dispatchKey(ownerPkg, ifaceName, m);
5656
+ if (!localImplTargetsByKey.has(k))
5657
+ localImplTargetsByKey.set(k, { targets: [], allResolved: true, decls: new Set() });
5658
+ const cell = localImplTargetsByKey.get(k);
5659
+ // NEVER GUESS WHICH `I` A NAME MEANS — the same guard the union EMITTER applies (`ifaceNameCounts`),
5660
+ // applied to the JOIN, because the two must not answer one question differently. Two declarations
5661
+ // of `Store` in one package both key `pkg#Store.save`, so unioning their implementors charges a
5662
+ // caller that dispatches on one with effects only the OTHER's implementor performs. That is the
5663
+ // cross-declaration fabrication this vein has produced confirmed instances of, and the emitter
5664
+ // refuses it — a join that did not would resolve, in the consumer, exactly what the producer
5665
+ // declined to publish. FOUND BY THE CORPUS A/B, not by a fixture: `apollo-server-core` is built to
5666
+ // both `src` and `dist`, so `ApolloServerPlugin` has two declarations, the emitter published
5667
+ // nothing for it, and this join was edging across both.
5668
+ cell.decls.add(ifaceDecl);
5669
+ for (const cls of implClasses) {
5670
+ const memberNodes = cls.members ?? cls.properties ?? [];
5671
+ const node = memberNodes.find((x) =>
5672
+ (ts.isMethodDeclaration(x) || ts.isPropertyDeclaration(x) || ts.isPropertyAssignment(x))
5673
+ && x.name?.getText?.() === m);
5674
+ const t = node && nodeName.get(node);
5675
+ // An implementor whose member is INHERITED from a base class, or otherwise not a minted unit,
5676
+ // is genuinely unresolved — the same condition the in-scan CHA site calls `allResolved`.
5677
+ if (!t) { cell.allResolved = false; continue; }
5678
+ if (!cell.targets.includes(t)) cell.targets.push(t);
5679
+ }
5680
+ }
5681
+ };
5682
+ for (const [ifaceDecl, impls] of interfaceImpls) add(pkgName, ifaceDecl, impls);
5683
+ for (const [ifaceDecl, impls] of foreignInterfaceImpls) {
5684
+ const ownerPkg = declModule(ifaceDecl);
5685
+ if (ownerPkg && !ownerPkg.startsWith("<") && !ownerPkg.startsWith("/")) add(ownerPkg, ifaceDecl, impls);
5686
+ }
5687
+ }
5688
+ return localImplTargetsByKey.get(key) ?? { targets: [], allResolved: true, decls: new Set() };
5689
+ }
5396
5690
  function applyDepHit(rec, hit) {
5691
+ // ⟨0.39⟩ The dispatched members travel with the hit — transitively, so a consumer of THIS report
5692
+ // learns of a dispatch two packages down — and each one is joined against what this scan can see.
5693
+ for (const k of hit.dispatch ?? []) {
5694
+ rec.dispatch.add(k);
5695
+ const [kp, rest] = [k.slice(0, k.indexOf("#")), k.slice(k.indexOf("#") + 1)];
5696
+ const dot = rest.lastIndexOf(".");
5697
+ if (dot > 0) joinLocalImpls(rec, { key: k, pkg: kp, ifaceName: rest.slice(0, dot), member: rest.slice(dot + 1) });
5698
+ }
5397
5699
  for (const x of hit.inferred) rec.direct.add(x);
5398
5700
  for (const b of hit.invisible ?? []) rec.blind.add(b);
5399
5701
  for (const w of hit.why ?? []) rec.why.add(w);
@@ -5674,6 +5976,11 @@ function chargeExternalDecl(rec, decl, tailOverride) {
5674
5976
  // Owner-prefixed first (`Owner.member` — how the dep's own scan hashes a method), bare member as the
5675
5977
  // fallback (a CJS dist scan hashes a top-level export under its bare name). Identical to the call arm.
5676
5978
  const owner = nameDecl.parent?.name?.getText?.();
5979
+ // ⟨0.39⟩ obligation 1, the DESUGARED half of the foreign arm — same call, same position, same reason as
5980
+ // the CallExpression arm's. This function exists because that arm and this one drifted once already.
5981
+ // …and obligation 3's local half on the SAME key, because a call that lands DIRECTLY on the
5982
+ // abstraction member has that member as its key and this scan's own implementors of it are visible.
5983
+ joinLocalImpls(rec, recordDispatch(rec, decl, pkg));
5677
5984
  const hit = tailOverride ? crossDeps.get(`${pkg}#${tailOverride}`)
5678
5985
  : member && ((owner ? crossDeps.get(`${pkg}#${owner}.${member}`) : undefined)
5679
5986
  ?? crossDeps.get(`${pkg}#${member}`));
@@ -7047,6 +7354,11 @@ function visitCalls(node) {
7047
7354
  let edged = false;
7048
7355
  if ((ts.isMethodSignature(sigDecl) || ts.isPropertySignature(sigDecl))
7049
7356
  && sigDecl.parent && ts.isInterfaceDeclaration(sigDecl.parent)) {
7357
+ // ⟨0.39⟩ obligation 1, LOCAL half. Recorded HERE — before the CHA is consulted — for the
7358
+ // reason the clause gives: the sin's toggle runs between zero implementors and one, so
7359
+ // recording only on the branch that could not resolve leaves the field absent in exactly
7360
+ // the arm where absence is the purity claim that deletes the consumer's disclosure.
7361
+ recordDispatch(rec, sigDecl, pkgName);
7050
7362
  const impls = interfaceImpls.get(sigDecl.parent) ?? [];
7051
7363
  if (impls.length > 0 && impls.length <= CHA_FANOUT_LIMIT) {
7052
7364
  const member = sigDecl.name?.getText?.();
@@ -7648,6 +7960,15 @@ function visitCalls(node) {
7648
7960
  rec.why.add(`native:${kMod.replace(/^node:/, "")}.`
7649
7961
  + `${isConstruction ? `new ${ctorClassName || ""}` : member}`);
7650
7962
  }
7963
+ // ⟨0.39⟩ obligation 1, FOREIGN half — the MIDDLE-PACKAGE case (SOUNDNESS R504). A package that
7964
+ // dispatches over a DEPENDENCY's abstraction owns neither the abstraction nor any implementor
7965
+ // of it, so an obligation-1 pass scoped to interfaces the producer DECLARES leaves the chain
7966
+ // one hop short and the consumer's row ABSENT — a purity claim. Recorded BEFORE the chained
7967
+ // lookup below and independently of whether it hits: whether this run happened to be chained
7968
+ // says nothing about what a consumer of THIS report will be able to see.
7969
+ if (!eff && !mod.startsWith("<"))
7970
+ joinLocalImpls(rec, recordDispatch(rec, decl,
7971
+ mod.startsWith("@types/") ? mod.slice("@types/".length) : mod));
7651
7972
  // CANDOR_DEPS: an unclassified call into a package with a loaded sibling report inherits
7652
7973
  // that function's recorded transitive effects (+ literal surfaces) by `hash`.
7653
7974
  let inheritedFromDep = false;
@@ -9010,7 +9331,14 @@ const inferred = new Map([...fns.keys()].map((k) => [k, new Set(fns.get(k).direc
9010
9331
  // `fsKinds` joins the propagated surfaces: kinds TRAVEL the call graph (a caller that transitively only
9011
9332
  // writes IS a writer), and the "?" poison travels with them so a caller of an undetermined-kind function
9012
9333
  // inherits the SUPPRESSION rather than a half-answer. Pinned by conformance PART 31.
9013
- for (const m of ["hosts", "tables", "cmds", "paths", "blind", "incomplete", "fsKinds"]) {
9334
+ // ⟨0.39⟩ `dispatch` rides this same sweep, and that is what satisfies obligation 1's "the member must
9335
+ // REACH the caller transitively". The clause permits either spelling — DIRECT members on the wire with
9336
+ // the consumer closing over the producer's `calls`, or the closure taken here — and taking it here is
9337
+ // free: the relation is a union over the same call graph the effects already traverse. (java had to take
9338
+ // the other route because JVM interfaces ARE how that platform dispatches and the closure could not be
9339
+ // serialised at all — `jooq` dead with 8 GB of heap. A TS package's interface surface is nothing like
9340
+ // that, and the corpus A/B is what says so here rather than the analogy.)
9341
+ for (const m of ["hosts", "tables", "cmds", "paths", "blind", "incomplete", "fsKinds", "dispatch"]) {
9014
9342
  const queue = [...fns.keys()];
9015
9343
  const queued = new Set(queue);
9016
9344
  for (let head = 0; head < queue.length; head++) {
@@ -9053,7 +9381,12 @@ for (const [name, rec] of fns) {
9053
9381
  const inf = [...inferred.get(name)].sort();
9054
9382
  // entry points stay visible even when pure; a BLIND fn stays too, so the honesty disclosure survives
9055
9383
  // on exactly the `inferred: []` fns that need it.
9056
- if (inf.length === 0 && !rec.entry && rec.blind.size === 0) continue;
9384
+ // ⟨0.39⟩ …AND A DISPATCHING ROW STAYS EVEN WHEN IT IS OTHERWISE PURE. This is the clause's deliberate
9385
+ // exception to §2 rule 3, and it is the whole left-hand side of the toggle: the row's ABSENCE was the
9386
+ // purity claim that deleted a consumer's disclosure the moment the library acquired one pure
9387
+ // implementor. A pure function that DISPATCHES is no longer a function about which there is nothing to
9388
+ // say — absence keeps its meaning, but this row is no longer absent.
9389
+ if (inf.length === 0 && !rec.entry && rec.blind.size === 0 && rec.dispatch.size === 0) continue;
9057
9390
  const entry = {
9058
9391
  fn: name,
9059
9392
  loc: rec.loc,
@@ -9074,6 +9407,11 @@ for (const [name, rec] of fns) {
9074
9407
  // `tour` falls back to these when the sidecar is empty (surface robustness — mirrors the Rust report, whose
9075
9408
  // entries carry `calls`); omitted when a fn has no outgoing edges to keep pure leaves lean.
9076
9409
  if (rec.edges.size) entry.calls = [...rec.edges].sort();
9410
+ // ⟨0.39⟩ obligation 1: the abstraction members this unit dispatches on, transitively, in the OWNING
9411
+ // package's entry-hash namespace (`pkg#Iface.member`) — the same key obligation 2 publishes a union
9412
+ // under, so a consumer joins it with its ORDINARY `crossDeps` lookup and no per-engine rule. Sorted,
9413
+ // because the wire is compared byte-for-byte by the chain-idempotence part and by every A/B.
9414
+ if (rec.dispatch.size) entry.dispatchesOn = [...rec.dispatch].sort();
9077
9415
  if (inf.includes("Net") && rec.hosts.size) entry.hosts = [...rec.hosts].sort();
9078
9416
  // ⟨0.20⟩ Net destination-class (NET-DESTINATION-CLASS-DESIGN.md): the classes present in this fn's
9079
9417
  // transitive Net surface — exact host-literal match, fail-closed unknown-host on a masked surface (rec
@@ -9127,7 +9465,9 @@ for (const [name, rec] of fns) {
9127
9465
  else if (rec.isCjsExport) entry.unitKind = "export"; // spec 0.5 draft, informative — per-unit, not by name
9128
9466
  functions.push(entry);
9129
9467
  }
9130
- // ⟨workspace-chain prototype — opt-in via CANDOR_WORKSPACE_CHAIN⟩ INTERFACE-CHA union entries for
9468
+ // ⟨0.23⟩, REQUIRED since ⟨0.39⟩ (it was opt-in via CANDOR_WORKSPACE_CHAIN until that rung; the env var
9469
+ // is gone, not kept as an alias for "on" — a flag that no longer changes anything is a flag the next
9470
+ // reader has to prove inert). INTERFACE-CHA union entries for
9131
9471
  // cross-package dispatch. A consumer of THIS package that calls an interface method (`ch.publish()` on an
9132
9472
  // imported `OutboundChannel`) resolves the call to the interface METHOD SIGNATURE — which has no body, so
9133
9473
  // no report entry, so the chain reads it pure even though every implementation reaches an effect. Emit a
@@ -9269,8 +9609,25 @@ function typingsInterfaceImpls() {
9269
9609
  const arr = byIface.get(idecl);
9270
9610
  if (!arr.includes(clsName)) arr.push(clsName);
9271
9611
  };
9612
+ // ⟨0.39⟩ obligation 2, THE PUBLISHED-PACKAGE SHAPE. This used to keep only `inPkg` declarations, and
9613
+ // the comment beside it read "an interface owned by another package is dropped rather than re-keyed
9614
+ // under ours (its union belongs under ITS `pkg#` prefix)". The first half was right and the second half
9615
+ // is now a REQUIREMENT rather than an aside: the entry belongs under the owner's prefix, so publish it
9616
+ // there instead of dropping it. Dropping is what makes a `dist` package that implements a dependency's
9617
+ // interface effectfully invisible to every consumer of that dependency — the measured R475 shape, in
9618
+ // the packaging every npm dependency actually ships.
9619
+ //
9620
+ // The owner is derived from the FILE (`declModule`), never from the module specifier a typing imports:
9621
+ // a name-based derivation is the cross-package leaf join this vein has produced confirmed fabrications
9622
+ // with, and `declModule` answers from the path.
9623
+ const ownerOfIface = (d) => {
9624
+ const f = d.getSourceFile().fileName;
9625
+ if (inPkg(f)) return pkgName;
9626
+ const m = declModule(d);
9627
+ return m && !m.startsWith("<") && !m.startsWith("/") && m !== pkgName && m !== rootOwnerPkg ? m : null;
9628
+ };
9272
9629
  const ifaceDeclsOf = (typeExpr) => (deAlias(tck.getSymbolAtLocation(typeExpr))?.declarations ?? [])
9273
- .filter((d) => ts.isInterfaceDeclaration(d) && inPkg(d.getSourceFile().fileName));
9630
+ .filter((d) => ts.isInterfaceDeclaration(d) && !!ownerOfIface(d));
9274
9631
  for (const root of roots) {
9275
9632
  const tsf = tprog.getSourceFile(root);
9276
9633
  const mod = tsf && tck.getSymbolAtLocation(tsf);
@@ -9301,10 +9658,16 @@ function typingsInterfaceImpls() {
9301
9658
  }
9302
9659
  }
9303
9660
  }
9304
- for (const [idecl, clsNames] of byIface) if (clsNames.length) out.push([idecl, clsNames]);
9661
+ for (const [idecl, clsNames] of byIface) if (clsNames.length) out.push([idecl, clsNames, ownerOfIface(idecl)]);
9305
9662
  return { arms: out, truncated: false };
9306
9663
  }
9307
- if (process.env.CANDOR_WORKSPACE_CHAIN) {
9664
+ // ⟨0.39⟩ NO LONGER GATED. `interfaceUnion` rode behind CANDOR_WORKSPACE_CHAIN while §2 ⟨0.23⟩ read
9665
+ // "gated/opt-in until a floor rung pins it". This is that rung, it is REQUIRED, and its absence is a
9666
+ // non-conformance — and the gate is precisely WHY the silent-purity toggle survived default scans: a
9667
+ // default scan of a library published no union entry, so a default scan of its consumer had nothing to
9668
+ // join. Keeping the env var as an alias for "on" was considered and refused: a flag that no longer
9669
+ // changes anything is a flag the next reader has to prove inert.
9670
+ {
9308
9671
  // rec.local -> {inferred:Set, blind:Set}, UNIONED over every unit sharing the tail rather than last-wins.
9309
9672
  // A dist package is routinely built twice (`dist/cjs/…/CucumberExpression.js` and `dist/esm/…` are the
9310
9673
  // same class), so a class name maps to several units; last-wins silently published ONE build's effects
@@ -9339,10 +9702,55 @@ if (process.env.CANDOR_WORKSPACE_CHAIN) {
9339
9702
  // not be — record whether any implementor was dropped, so the emission loop below can force the SAME
9340
9703
  // honest-Unknown widening it already applies when CHA_FANOUT_LIMIT is exceeded, instead of a narrower
9341
9704
  // claim than the evidence supports.
9705
+ //
9706
+ // ⟨SOUNDNESS R512⟩ THE NAME IS NOT THE ONLY HANDLE, AND TREATING IT AS ONE OVER-HEDGED. The paragraph
9707
+ // above is right that `localEffs` cannot answer for an implementor with no name; it is wrong that
9708
+ // nothing can. `localImplTargets` — obligation 3's LOCAL half, in this same rung — already resolves a
9709
+ // structural implementor's member PRECISELY, by finding the member node and reading `nodeName`, and the
9710
+ // in-scan dispatch site has always done the same. So the emitter now splits implementors by what can
9711
+ // ANSWER for them rather than by whether they have a name: a ClassDeclaration goes through `localEffs`
9712
+ // as before, and everything else (object literal, class expression — named or not) through its minted
9713
+ // member unit. `hadUnnamed`'s blanket Unknown survives, but only where the member really is
9714
+ // unaccountable, which is the same `allResolved` condition the join and the dispatch site use.
9715
+ //
9716
+ // This is NOT a cosmetic tightening. Under the blanket rule, R512's fix would have made every package
9717
+ // that writes `const plugin: SomeDepIface = { … }` — the idiomatic TS spelling, and the reason this row
9718
+ // exists — publish `dep#Iface.member -> ['Unknown']` whether or not the implementor does anything,
9719
+ // handing an inherited hedge to every consumer of it. ⟨0.39⟩'s cost model is explicit that nothing may
9720
+ // move from disclosed to silent AND that no implementation may start hedging; closing the silence by
9721
+ // flooding the other channel would have traded the sin for the thing the c3_pure_only control exists to
9722
+ // catch. It also unblocks the NAMED class EXPRESSION, which `.name?.text` classified as named while its
9723
+ // members are minted `<structural>.m` — so `localEffs` missed it and it contributed nothing at all.
9724
+ // ⟨0.39⟩ An arm is [ifaceDecl, implementing class NAMES, hadUnnamed, OWNING PACKAGE]. The fourth field
9725
+ // is obligation 2: a local abstraction's union is published under OUR package, a FOREIGN one's under
9726
+ // the package that declares it — fully qualified in that package's own entry-hash namespace, which is
9727
+ // the ⟨0.23⟩ `typeSurface` rule and NOT a new spelling. Everything downstream reads the owner off the
9728
+ // arm rather than assuming `pkgName`, because assuming it is what re-keys a dependency's abstraction
9729
+ // under ours and fabricates a key no consumer can resolve.
9730
+ // ⟨SOUNDNESS R512⟩ A ClassDeclaration is the ONLY implementor kind `localEffs` can answer for: its
9731
+ // members are minted under `${ClassName}.${member}`. Everything else `interfaceImpls` /
9732
+ // `foreignInterfaceImpls` hold — object literals and class expressions, named or anonymous — has its
9733
+ // members minted by `mintStructuralMembers` under `<structural>.${member}`, so a name lookup reads
9734
+ // nothing. Split on the MECHANISM, not on `.name`.
9735
+ const nominalName = (c) => (ts.isClassDeclaration(c) ? c.name?.text : undefined);
9736
+ const splitImpls = (impls) => ({
9737
+ names: impls.map(nominalName).filter(Boolean),
9738
+ nodes: impls.filter((c) => !nominalName(c)),
9739
+ });
9740
+ // The minted unit for `member` on a structural implementor, or undefined when nothing was minted for it
9741
+ // (a `.bind()` whose receiver cannot be pinned, a call result, a getter, an absent optional member).
9742
+ // Same shape as `localImplTargets`, deliberately — one question, one answer.
9743
+ const structuralMemberUnit = (container, m) => {
9744
+ const memberNodes = container.members ?? container.properties ?? [];
9745
+ const mn = memberNodes.find((x) =>
9746
+ (ts.isMethodDeclaration(x) || ts.isPropertyDeclaration(x) || ts.isPropertyAssignment(x))
9747
+ && x.name?.getText?.() === m);
9748
+ return mn ? nodeName.get(mn) : undefined;
9749
+ };
9342
9750
  const unionArms = [];
9343
9751
  for (const [ifaceDecl, implClasses] of interfaceImpls) {
9344
- const names = implClasses.map((c) => c.name?.text).filter(Boolean);
9345
- unionArms.push([ifaceDecl, names, names.length < implClasses.length]);
9752
+ const { names, nodes } = splitImpls(implClasses);
9753
+ unionArms.push([ifaceDecl, names, nodes.length > 0, pkgName, nodes]);
9346
9754
  }
9347
9755
  const inScanClassesByName = new Map(); // iface NAME -> every class the in-scan arms register under it
9348
9756
  for (const [d, cls] of unionArms) {
@@ -9379,9 +9787,26 @@ if (process.env.CANDOR_WORKSPACE_CHAIN) {
9379
9787
  for (const arm of typings.arms) {
9380
9788
  const n = arm[0].name?.text;
9381
9789
  if (!n) continue;
9382
- const inScan = inScanClassesByName.get(n);
9790
+ // ⟨0.39⟩ the SHADOW drop applies only to a LOCALLY-owned typings arm: the in-scan arm whose union it
9791
+ // would duplicate is keyed under `pkgName`, so it can be a superset of nothing under another prefix.
9792
+ const owner = arm[2] ?? pkgName;
9793
+ const inScan = owner === pkgName ? inScanClassesByName.get(n) : null;
9383
9794
  if (inScan && arm[1].every((c) => inScan.has(c))) continue;
9384
- unionArms.push(arm);
9795
+ unionArms.push([arm[0], arm[1], false, owner, []]);
9796
+ }
9797
+ // ⟨0.39⟩ obligation 2's arms, pushed LAST and deliberately AFTER `inScanClassesByName` was taken: a
9798
+ // foreign `Backend` and a local one are different keys under different prefixes, so neither may
9799
+ // suppress the other as "redundant" and neither may make the other's NAME ambiguous. Both mistakes run
9800
+ // in the withdrawal direction — a dropped union entry is a purity claim nobody made.
9801
+ for (const [ifaceDecl, implClasses] of foreignInterfaceImpls) {
9802
+ // The registration sites already refuse every declaration `isPublishableForeignIface` rejects, so
9803
+ // this is a belt-and-braces re-derivation of the SAME predicate rather than a second rule: an entry
9804
+ // keyed under a namespace no package owns is the invented second spelling §4 forbids, and this
9805
+ // family has now shipped that key twice (rust `io#Write::write_all`, swift `DepLib#String.lowercased`).
9806
+ if (!isPublishableForeignIface(ifaceDecl)) continue;
9807
+ const ownerPkg = declModule(ifaceDecl);
9808
+ const { names, nodes } = splitImpls(implClasses);
9809
+ unionArms.push([ifaceDecl, names, nodes.length > 0, ownerPkg, nodes]);
9385
9810
  }
9386
9811
  // A TRUNCATED typings census refuses the PUBLICATION, and it has to be here rather than at the census.
9387
9812
  // Dropping the typings arm on its own lands the refusal on the EVIDENCE side — and the evidence is the
@@ -9402,23 +9827,36 @@ if (process.env.CANDOR_WORKSPACE_CHAIN) {
9402
9827
  if (typings.truncated)
9403
9828
  console.error(`candor-ts: this package's typings census exceeded ${TYPINGS_CENSUS_CAP} declaration files — `
9404
9829
  + `publishing NO interface-CHA union entries (an incomplete census cannot tell a colliding interface name from a unique one)`);
9830
+ // ⟨0.39⟩ COUNTED PER KEY, not per bare NAME. The guard's question is "could a consumer forming this
9831
+ // hash mean a different declaration" — and a hash carries the owning package, so `iface#Backend` and
9832
+ // `effimpl#Backend` cannot be confused with one another however alike they read. Counting bare names
9833
+ // once obligation 2 exists would refuse BOTH over a collision that the key itself already resolves,
9834
+ // and a refusal is a withdrawn disclosure.
9405
9835
  const ifaceNameCounts = new Map();
9406
- for (const [ifaceDecl] of unionArms) {
9836
+ for (const [ifaceDecl, , , ownerPkg] of unionArms) {
9407
9837
  const n = ifaceDecl.name?.text;
9408
- if (n) ifaceNameCounts.set(n, (ifaceNameCounts.get(n) ?? 0) + 1);
9838
+ if (n) ifaceNameCounts.set(`${ownerPkg}#${n}`, (ifaceNameCounts.get(`${ownerPkg}#${n}`) ?? 0) + 1);
9409
9839
  }
9410
- for (const [ifaceDecl, implClasses, hadUnnamed] of unionArms) {
9840
+ for (const [ifaceDecl, implClasses, hadUnnamed, ownerPkg, implNodes = []] of unionArms) {
9411
9841
  const ifaceName = ifaceDecl.name?.text;
9412
9842
  // ⟨CARDINAL SIN FIX, structural-implementor gap⟩ `!implClasses.length` used to skip the arm outright
9413
9843
  // — correct when there are genuinely zero implementors, but an interface implemented ONLY
9414
9844
  // structurally (every implementor unnamed, `implClasses` empty, `hadUnnamed` true) would silently
9415
9845
  // publish NO union entry at all, which is a purity claim (SPEC §2 rule 3) this evidence does not
9416
- // support. `hadUnnamed` keeps the arm alive for that case so the `broad` forcing below can widen it
9417
- // to Unknown instead of the arm vanishing before `broad` is ever computed.
9846
+ // support. `hadUnnamed` keeps the arm alive for that case so the emission loop below can answer for
9847
+ // it at all, instead of the arm vanishing before anything is computed. ⟨SOUNDNESS R512⟩ what it then
9848
+ // answers is the member's real effects where they can be read and a disclosed Unknown where they
9849
+ // cannot — this predicate is unchanged, only what happens after it.
9418
9850
  if (!ifaceName || (!implClasses.length && !hadUnnamed)) continue;
9419
9851
  // Never guess which `I` a name means: two declarations of it, or a census that cannot prove there is
9420
9852
  // only one, are the same evidential position and take the same answer.
9421
- if (ifaceNameCounts.get(ifaceName) > 1 || typings.truncated) continue;
9853
+ // ⟨0.39⟩ …and a TRUNCATED census refuses only the keys it is evidence about. The census walks OUR
9854
+ // package's `.d.ts` tree, so the declarations it failed to reach could each be a second local
9855
+ // `Store`; none of them can be a second declaration inside a DEPENDENCY's namespace, which is where
9856
+ // a foreign key lives. Letting our own truncation withdraw a foreign union entry would refuse on
9857
+ // evidence that says nothing about it — and the owning package's own report publishes under that
9858
+ // same key anyway, where ⟨0.25⟩'s per-key union combines the two.
9859
+ if (ifaceNameCounts.get(`${ownerPkg}#${ifaceName}`) > 1 || (typings.truncated && ownerPkg === pkgName)) continue;
9422
9860
  // BOUNDED CHA — the same `CHA_FANOUT_LIMIT` the in-scan dispatch site applies. The union emitter
9423
9861
  // shipped without it, so the producer PUBLISHED what its own dispatch refuses to resolve: rxjs's
9424
9862
  // `Operator` has 70 implementers, sixteen of which reach Net, and rxjs's own `Observable.subscribe`
@@ -9434,9 +9872,14 @@ if (process.env.CANDOR_WORKSPACE_CHAIN) {
9434
9872
  // key. What silence would cost is this report's own honesty — the producer's `deny E
9435
9873
  // Unknown[dispatch]`, any consumer without half 1's conjuncts, and the entry that is read as data
9436
9874
  // rather than joined. The named tests that fail on that mutation are the producer-side three.
9437
- // `hadUnnamed` widens the same way: a structural implementor this union cannot name is exactly as
9438
- // unaccountable as the (CHA_FANOUT_LIMIT + 1)th named one.
9439
- const broad = implClasses.length > CHA_FANOUT_LIMIT || hadUnnamed;
9875
+ // `hadUnnamed` widens the same way — but only where the implementor really is unaccountable. See
9876
+ // ⟨SOUNDNESS R512⟩ above: a structural implementor's member that resolves to a MINTED UNIT is
9877
+ // accounted for exactly as precisely as a named class's, by the same `nodeName` lookup obligation 3's
9878
+ // `localImplTargets` and the in-scan dispatch site both use, so the widening is decided per MEMBER
9879
+ // (`unaccounted` below) and not per arm. The fan-out bound counts every implementor, named or not:
9880
+ // once an unnamed one contributes real effects it has to be inside the bound that decides whether
9881
+ // this hierarchy is open.
9882
+ const overFanout = implClasses.length + implNodes.length > CHA_FANOUT_LIMIT;
9440
9883
 
9441
9884
  for (const member of ifaceDecl.members ?? []) {
9442
9885
  // Both spellings of an interface method (see the in-scan site): `run(): void` and
@@ -9448,13 +9891,27 @@ if (process.env.CANDOR_WORKSPACE_CHAIN) {
9448
9891
  if (!member.name || !fnMember) continue;
9449
9892
  const m = member.name.getText();
9450
9893
  const infU = new Set(), blindU = new Set();
9451
- if (broad) infU.add("Unknown");
9452
- else for (const clsName of implClasses) {
9453
- const e = localEffs.get(`${clsName}.${m}`);
9454
- if (e) { for (const x of e.inferred) infU.add(x); for (const b of e.blind) blindU.add(b); }
9894
+ // An implementor this arm holds but cannot read a member unit for — the `allResolved` condition,
9895
+ // per member. A structural implementor with no minted unit for `m` (a `.bind()` whose receiver
9896
+ // cannot be pinned, a call result, a getter, an absent optional member) is exactly as unaccountable
9897
+ // as the (CHA_FANOUT_LIMIT + 1)th named one, and takes the same disclosed Unknown.
9898
+ let unaccounted = false;
9899
+ if (overFanout) infU.add("Unknown");
9900
+ else {
9901
+ for (const clsName of implClasses) {
9902
+ const e = localEffs.get(`${clsName}.${m}`);
9903
+ if (e) { for (const x of e.inferred) infU.add(x); for (const b of e.blind) blindU.add(b); }
9904
+ }
9905
+ for (const impl of implNodes) {
9906
+ const u = structuralMemberUnit(impl, m);
9907
+ if (!u) { unaccounted = true; infU.add("Unknown"); continue; }
9908
+ for (const x of inferred.get(u) ?? []) infU.add(x);
9909
+ for (const b of fns.get(u)?.blind ?? []) blindU.add(b);
9910
+ }
9455
9911
  }
9912
+ const broad = overFanout || unaccounted;
9456
9913
  if (infU.size === 0 && blindU.size === 0) continue; // pure across all impls — silence = purity
9457
- const hash = `${pkgName}#${ifaceName}.${m}`;
9914
+ const hash = dispatchKey(ownerPkg, ifaceName, m); // ⟨0.39⟩ the OWNER's namespace, not always ours
9458
9915
  if (emittedUnionHashes.has(hash)) continue;
9459
9916
  // A REAL entry already claiming this hash used to SUPPRESS the union, and that was a silent
9460
9917
  // under-report — the candor-java sibling is `48a5f18`, whose argument transfers whole: publishing
@@ -9492,7 +9949,7 @@ if (process.env.CANDOR_WORKSPACE_CHAIN) {
9492
9949
  emittedUnionHashes.add(hash);
9493
9950
  const sfIface = ifaceDecl.getSourceFile();
9494
9951
  const { line, character } = sfIface.getLineAndCharacterOfPosition(ifaceDecl.getStart());
9495
- const un = { fn: `${ifaceName}.${m}`, loc: `${path.relative(rootDir, sfIface.fileName)}:${line + 1}:${character + 1}`,
9952
+ const un = { fn: `${ifaceName}.${m}`, loc: abstractionLoc(sfIface, line, character),
9496
9953
  hash, inferred: [...infU].sort(), interfaceUnion: true };
9497
9954
  // The reason travels with the disclosure, spelled the way the CONSUMER of this entry spells the
9498
9955
  // same site (`dispatch:<pkg>.<Iface>.<member>`, half 1's form), so a `deny E Unknown[dispatch]` at
@@ -9508,7 +9965,7 @@ if (process.env.CANDOR_WORKSPACE_CHAIN) {
9508
9965
  // stays scoped to `broad`, and correctly: ⟨0.6⟩ requires `unknownWhy` on a DIRECT Unknown source, and
9509
9966
  // a union that inherited its Unknown from an implementer's body is not one.
9510
9967
  un.unresolved = infU.has("Unknown");
9511
- if (broad) un.unknownWhy = [`dispatch:${pkgName}.${ifaceName}.${m}`];
9968
+ if (broad) un.unknownWhy = [`dispatch:${ownerPkg}.${ifaceName}.${m}`];
9512
9969
  if (blindU.size) un.invisible = [...blindU].sort();
9513
9970
  functions.push(un);
9514
9971
  }
@@ -10805,6 +11262,9 @@ if (policyPath !== null) {
10805
11262
  for (const name of fns.keys()) { const h = unitHash(name); if (h) gateHashByName.set(name, h); }
10806
11263
  const gateOut = evaluatePolicy(gatePolicy, functions, cg, incompleteMap, netPartners,
10807
11264
  null, null, null, gateHashByName);
11265
+ // ⟨0.39⟩ the synthetic entries a rule matched — named, never scored. Same printer on both verdict
11266
+ // routes, so §3.1's byte-equality with `gate --report` covers the note too.
11267
+ noteSyntheticHits(gateOut);
10808
11268
  // ⟨0.27⟩ SPEC §4 — a rule that bound NO function is disclosed, never scored as satisfied. The exit
10809
11269
  // code is deliberately untouched: a zero-match rule is legitimate when one policy is shared across
10810
11270
  // repositories and a layer exists in only some of them, so refusal would make a shared policy