candor-ts 0.39.2 → 0.39.3

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.
Files changed (3) hide show
  1. package/README.md +4 -3
  2. package/package.json +1 -1
  3. package/scan.mjs +799 -50
package/README.md CHANGED
@@ -167,7 +167,8 @@ content-hash gate is its first increment).
167
167
 
168
168
  ## Trust contract (spec §4)
169
169
 
170
- Anything candor-ts can't resolve is `Unknown`, never silently pure: a function-valued parameter or
170
+ Anything candor-ts can't resolve is designed to read `Unknown`, never silently pure — known open gaps
171
+ are listed in candor-spec's SOUNDNESS.md (e.g. R780, R803, R804): a function-valued parameter or
171
172
  field being called, an `any`-typed callee, resolution landing on a type rather than a body.
172
173
 
173
174
  An **uncurated dependency** can opt out of `Unknown`/silent-pure by **declaring its effects** in its
@@ -184,7 +185,7 @@ literal is never a claim of absence.
184
185
  ## Cross-engine consistency — machine-checked
185
186
 
186
187
  candor-ts is one of the **four code engines** (with the reference engine candor-java, the Rust
187
- engines, and candor-swift) held together by the spec's **16-part conformance suite**: the shared
188
+ engines, and candor-swift) held together by the spec's **cross-engine conformance suite**: the shared
188
189
  effect-set oracle, the §6.2 policy-grammar battery (including `allow Db`), the §3.1 query-shape
189
190
  and match-ladder checks, the gate exit-code contracts, and the newer parts up through the
190
191
  pure-vs-Unknown ruling (PART 16) — the engines must answer identically, on every push to the spec.
@@ -219,7 +220,7 @@ read the Rust source".
219
220
 
220
221
  ## Status
221
222
 
222
- 0.30.0, speaking candor-spec 0.39: the analysis core, the gate (`--policy` / `--gate-json` /
223
+ Speaking candor-spec 0.39 (the package version is in `package.json`): the analysis core, the gate (`--policy` / `--gate-json` /
223
224
  `.candor/config`), the full §3.1 query surface (including `containment`, `blindspots`, the
224
225
  `--include-unknown` dispatch frontier, and ⟨0.24⟩ `gate --report` — the gate applied to an EXISTING
225
226
  report, byte-equivalent to `scan --policy`'s verdict), the MCP server, the LSP server, and the watch loop are
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "candor-ts",
3
- "version": "0.39.2",
3
+ "version": "0.39.3",
4
4
  "mcpName": "io.github.tombaldwin/candor",
5
5
  "description": "candor for TypeScript — per-function side effects, transitively, with a policy gate (candor-spec 0.39)",
6
6
  "type": "module",
package/scan.mjs CHANGED
@@ -3201,6 +3201,79 @@ if (R416_HITS) process.on("exit", () => {
3201
3201
  if (total) process.stderr.write(`R416PROBE total=${total}`
3202
3202
  + rows.map(([k, n]) => ` ${k}=${n}`).join("") + "\n");
3203
3203
  });
3204
+ // SOUNDNESS R800 — WHICH POSITIONS ARE PATHS IS A FACT ABOUT THE SIGNATURE, SO ASK THE SIGNATURE.
3205
+ // `FS_TWO_PATH_MEMBERS` above is a hand-kept list of node's names, and it was the only place the engine
3206
+ // learned that a call has a SECOND path. `fs-extra` is κ whole-module `Fs` — the engine chose to model
3207
+ // it — and none of its twelve two-path verbs (`copy`/`copySync`, `move`/`moveSync`, `ensureLink`/
3208
+ // `ensureSymlink`, `createLink`/`createSymlink` and their Sync twins) is on the list, so a literal source
3209
+ // made the call `complete` and `allow Fs in <fn> <src-literal>` certified a copy to a caller-supplied
3210
+ // destination. EXECUTED: the copy moved the payload, `moveSync` removed the source, the links were real.
3211
+ //
3212
+ // The checker already knows: in @types/node AND @types/fs-extra every two-path operation declares its
3213
+ // first two parameters with a PATH type (`PathLike`, `string | URL`, or plain `string`), and no other
3214
+ // `Fs` operation does — a data/options/mode/uid second parameter is a union with Buffer/encoding/object
3215
+ // members, a number, or `any`. MEASURED by enumerating every callable export of `fs` and `fs-extra`
3216
+ // (230) and applying this predicate: it reproduces `FS_TWO_PATH_MEMBERS` EXACTLY on node (10 of 10, 0
3217
+ // extra) and adds exactly the twelve fs-extra verbs above, nothing else. The list is KEPT as a floor
3218
+ // (an unresolved or `any`-typed call still gets node's names), and the two are OR-ed, so this can only
3219
+ // make a call need MORE captured positions — fail-closed if the predicate ever over-reaches — and never
3220
+ // fewer. It also closes R801's first arm independently of the token fix below: `promisify(fs.copyFile)`
3221
+ // resolves to `copyFile.__promisify__(src: PathLike, dst: PathLike)`, which this reads by TYPE.
3222
+ const isPathParamType = (t) => {
3223
+ if (!t) return false;
3224
+ const parts = (t.isUnion?.() ? t.types : [t])
3225
+ .filter((x) => !(x.flags & (ts.TypeFlags.Undefined | ts.TypeFlags.Null)));
3226
+ if (parts.length === 0) return false;
3227
+ if (t.aliasSymbol?.name === "PathLike") return true;
3228
+ let sawString = false;
3229
+ for (const x of parts) {
3230
+ if (x.flags & ts.TypeFlags.String) { sawString = true; continue; }
3231
+ const n = x.symbol?.name ?? x.aliasSymbol?.name;
3232
+ if (n === "URL" || n === "Buffer" || n === "PathLike") continue;
3233
+ return false;
3234
+ }
3235
+ return sawString;
3236
+ };
3237
+ // `CANDOR_R801_HITS=1` — the R416 reach counter, same discipline (prints ONLY when non-zero): `tok:<verb>`
3238
+ // counts a `__promisify__` token recovered to its verb, `two:<member>` a call the signature made two-path
3239
+ // that `FS_TWO_PATH_MEMBERS` did not name. A byte-identical A/B means nothing until these are non-zero.
3240
+ const R801_HITS = process.env.CANDOR_R801_HITS ? new Map() : null;
3241
+ const r801Hit = (k) => { if (R801_HITS) R801_HITS.set(k, (R801_HITS.get(k) ?? 0) + 1); };
3242
+ if (R801_HITS) process.on("exit", () => {
3243
+ const rows = [...R801_HITS].sort();
3244
+ const total = rows.reduce((a, [, n]) => a + n, 0);
3245
+ if (total) process.stderr.write(`R801PROBE total=${total}` + rows.map(([k, n]) => ` ${k}=${n}`).join("") + "\n");
3246
+ });
3247
+ function signatureHasTwoPaths(node) {
3248
+ let sig;
3249
+ try { sig = checker.getResolvedSignature(node); } catch { return false; }
3250
+ const ps = sig?.getParameters?.() ?? [];
3251
+ if (ps.length < 2) return false;
3252
+ const ty = (p) => checker.getTypeOfSymbolAtLocation(p, node);
3253
+ return isPathParamType(ty(ps[0])) && isPathParamType(ty(ps[1]));
3254
+ }
3255
+ // SOUNDNESS R801 — THE MEMBER TOKEN IS THE VERB, NOT THE NAME TYPESCRIPT'S DECLARATION MERGING CHOSE.
3256
+ // @types/node declares a promisified overload as `namespace copyFile { function __promisify__(…) }`
3257
+ // (72 of them: fs, dns, child_process, crypto, zlib), and `fs-extra`'s `copyFile`/`writeFile`/`write`/…
3258
+ // re-exports are typed `typeof fs.copyFile.__promisify__ & …`. A call through either spelling resolves
3259
+ // to a declaration NAMED `__promisify__`, so the module stayed right while EVERY member-keyed table
3260
+ // missed: `FS_TWO_PATH_MEMBERS` (silent — R800's `fse.copyFile`), `NET_ESTABLISHING` (a masking bypass —
3261
+ // `promisify(dns.resolve)(host)` beside a benign fetch certified the resolver's host), `FS_USE_VERBS` (a
3262
+ // FALSE FAILURE — `fse.write(fd, …)` hedged a fully-captured surface), and κ's own member regexes
3263
+ // (`promisify(crypto.generateKeyPair)` matched no `generateKey…` rule and the crypto floor reviews every
3264
+ // other member pure — SILENT Rand). No table was wrong; the token was. So the fix is at the ONE place the
3265
+ // token is minted, which every table reads, rather than a fifth copy of `__promisify__` in four lists.
3266
+ // The verb is recoverable one node up: the namespace the declaration sits in.
3267
+ function declMemberToken(decl, name) {
3268
+ if (name !== "__promisify__") return name;
3269
+ for (let p = decl?.parent; p; p = p.parent) {
3270
+ if (ts.isModuleBlock(p) || ts.isVariableDeclarationList(p) || ts.isVariableStatement(p)
3271
+ || ts.isVariableDeclaration(p) || ts.isTypeLiteralNode(p) || ts.isFunctionTypeNode(p)) continue;
3272
+ if (ts.isModuleDeclaration(p) && ts.isIdentifier(p.name)) { r801Hit(`tok:${p.name.text}`); return p.name.text; }
3273
+ break;
3274
+ }
3275
+ return name;
3276
+ }
3204
3277
  function fsPathLiteral(node, member) {
3205
3278
  const args = node.arguments ?? [];
3206
3279
  const at = (i) => {
@@ -3212,7 +3285,9 @@ function fsPathLiteral(node, member) {
3212
3285
  return c;
3213
3286
  };
3214
3287
  const a0 = at(0);
3215
- const needsTwo = FS_TWO_PATH_MEMBERS.has(member);
3288
+ const listed = FS_TWO_PATH_MEMBERS.has(member);
3289
+ const needsTwo = listed || signatureHasTwoPaths(node);
3290
+ if (needsTwo && !listed) r801Hit(`two:${member}`);
3216
3291
  const a1 = needsTwo ? at(1) : null;
3217
3292
  const complete = a0 !== null && (!needsTwo || a1 !== null);
3218
3293
  // EVERY literal path position, not just the first. Publishing position 0 and calling a
@@ -3509,6 +3584,22 @@ const isPublishableForeignIface = (d) => {
3509
3584
  // hash in, and publishing under it would be the second spelling §4 forbids.
3510
3585
  return !!m && !m.startsWith("<") && !m.startsWith("/") && m !== pkgName && m !== rootOwnerPkg;
3511
3586
  };
3587
+ // ⟨SOUNDNESS R521⟩ THE ONE PLACE THE BOUNDARY HAND-OFF'S REACH IS COUNTED, and it counts the
3588
+ // REGISTRATION rather than the branch entry — R574/R583's lesson, which cost this family three probes
3589
+ // that fired one line before the work they were cited for could be rejected. `walk` is called
3590
+ // unconditionally; the probe fires only when the implementor really was newly recorded under `ifaceDecl`,
3591
+ // so a super already reached by the direct heritage path (the `seen`/`*ClimbSeen` guards) counts zero.
3592
+ // `CANDOR_R521_REACH=1` makes each such registration announce itself so `bin/corpus-ab.py --mark` can
3593
+ // COUNT them instead of anyone inferring reach from an unchanged row — a byte-identical A/B over a corpus
3594
+ // that never reaches the branch is the most flattering number available and the least informative.
3595
+ const handoffCount = (d) => (interfaceImpls.get(d)?.length ?? 0) + (foreignInterfaceImpls.get(d)?.length ?? 0);
3596
+ function probeHandoff(kind, ifaceDecl, walk) {
3597
+ if (!process.env.CANDOR_R521_REACH) { walk(ifaceDecl); return; }
3598
+ const before = handoffCount(ifaceDecl);
3599
+ walk(ifaceDecl);
3600
+ if (handoffCount(ifaceDecl) > before)
3601
+ console.error(`R521-REACH ${kind} ${declModule(ifaceDecl) ?? "?"}#${ifaceDecl.name?.text ?? "?"}`);
3602
+ }
3512
3603
  // ⟨CARDINAL SIN FIX, caller-path scope⟩ ifaceQual ("mod.Iface") -> Set<callerQual> that GENUINELY
3513
3604
  // dispatched through that interface's own signature and had it resolved by CHA below — populated AT THE
3514
3605
  // RESOLUTION SITE (pass 2's interface-CHA arm), not reconstructed afterward from the flat callgraph.
@@ -3948,7 +4039,22 @@ for (const sf of sources) {
3948
4039
  registerImpl(iface);
3949
4040
  for (const eh of iface.heritageClauses ?? []) {
3950
4041
  if (eh.token !== ts.SyntaxKind.ExtendsKeyword) continue;
3951
- for (const st of eh.types) for (const sdecl of localInterfaceDecls(st.expression)) climb(sdecl);
4042
+ for (const st of eh.types) {
4043
+ for (const sdecl of localInterfaceDecls(st.expression)) climb(sdecl);
4044
+ // ⟨SOUNDNESS R521⟩ …AND THE HAND-OFF ACROSS THE BOUNDARY, WHICH NEITHER CLIMBER MADE. Each
4045
+ // climber filtered its supers to its OWN side — this one to `projectFiles`, `fClimb` below to
4046
+ // `isPublishableForeignIface` — so `interface LocalSub extends dep.MethodShaped` fell BETWEEN
4047
+ // them: `class Impl implements LocalSub` registered under `LocalSub` only, nothing was ever
4048
+ // registered under `depiface#MethodShaped.run`, and a local dispatch on the FOREIGN type
4049
+ // (`function callFor(m: MethodShaped) { m.run() }`) had no key for ⟨0.39⟩ obligation 3's local
4050
+ // join to answer on. MEASURED at HEAD on a one-variable pair: with `impl` annotated `LocalSub`,
4051
+ // `src.index.callFor` and `src.index.entry` both read `inferred: []` and `deny Fs
4052
+ // src.index.entry` exited 0 over a body that provably writes the file; annotating the SAME
4053
+ // object `MethodShaped` (the direct spelling, one token) charged `['Fs']` and exited 1.
4054
+ // A super is reached by whichever walker OWNS its declaration, not by whichever one started —
4055
+ // the two `seen` sets are separate, so the mutual recursion terminates on the first revisit.
4056
+ for (const sdecl of foreignInterfaceDecls(st.expression)) probeHandoff("nominal", sdecl, fClimb);
4057
+ }
3952
4058
  }
3953
4059
  };
3954
4060
  // ⟨0.39⟩ obligation 2: the same relation for an abstraction this package does NOT own. `class
@@ -3971,6 +4077,21 @@ for (const sf of sources) {
3971
4077
  if (!arr.includes(node)) arr.push(node);
3972
4078
  for (const eh of iface.heritageClauses ?? []) {
3973
4079
  if (eh.token !== ts.SyntaxKind.ExtendsKeyword) continue;
4080
+ // ⟨SOUNDNESS R521⟩ AND THE MIRROR DIRECTION IS DELIBERATELY *NOT* HERE — §9 says state the
4081
+ // boundary and justify it, not that every widening is free. A foreign interface whose super is
4082
+ // declared in THIS project would register only under the foreign key, so the symmetric hand-off
4083
+ // (`localInterfaceDecls(st.expression)` -> `climb`) looks like the same one-line fix. It was
4084
+ // written, MEASURED over the 28-repo pinned TS roster, and REMOVED, because its only reach on
4085
+ // real code is a shape it answers WRONGLY: rollup's `src/rollup/types.d.ts` carries
4086
+ // `declare module 'estree' { interface BaseClass { … } }`, so `BaseClass` has TWO declarations
4087
+ // of ONE dependency-owned interface and the local one is a project file. Registering an
4088
+ // implementor under it mints `rollup#BaseClass.<m>` — our package's key for an abstraction
4089
+ // `estree` owns, the "invented second spelling" `isPublishableForeignIface`'s own comment
4090
+ // refuses at length, and a key no consumer can ever form. It also buys nothing: the FOREIGN
4091
+ // declaration of that same merged interface is registered by the existing filter, so the
4092
+ // owner-keyed answer is already present. The residual it leaves is a `node_modules` package
4093
+ // importing a project interface, which nothing in the roster does — recorded as a latent gap
4094
+ // rather than closed by a widening whose one measured instance was mis-keyed.
3974
4095
  for (const st of eh.types) for (const sdecl of foreignInterfaceDecls(st.expression)) fClimb(sdecl);
3975
4096
  }
3976
4097
  };
@@ -4521,9 +4642,18 @@ function registerStructuralImpl(ifaceDecl, implNode, seen = new Set()) {
4521
4642
  for (const st of eh.types) {
4522
4643
  let sym; try { sym = checker.getSymbolAtLocation(st.expression); } catch { sym = undefined; }
4523
4644
  const tgt = sym && sym.flags & ts.SymbolFlags.Alias ? checker.getAliasedSymbol(sym) : sym;
4524
- for (const d of tgt?.declarations ?? [])
4525
- if (ts.isInterfaceDeclaration(d) && projectFiles.has(path.resolve(d.getSourceFile().fileName)))
4526
- registerStructuralImpl(d, implNode, seen);
4645
+ for (const d of tgt?.declarations ?? []) {
4646
+ if (!ts.isInterfaceDeclaration(d)) continue;
4647
+ if (projectFiles.has(path.resolve(d.getSourceFile().fileName))) registerStructuralImpl(d, implNode, seen);
4648
+ // ⟨SOUNDNESS R521⟩ …and the FOREIGN super of a LOCAL interface, the structural spelling of the
4649
+ // hand-off the nominal climber above now makes. `const impl: LocalSub = { run(){ …fs… } }` where
4650
+ // `interface LocalSub extends dep.MethodShaped` registered under `LocalSub` and NOWHERE ELSE, so
4651
+ // nothing was published or joined under `depiface#MethodShaped.run`. The `seen` set is SHARED
4652
+ // across the hand-off (passed through, not re-created), so a diamond reached from both sides visits
4653
+ // each declaration once and the mutual recursion terminates.
4654
+ else if (isPublishableForeignIface(d))
4655
+ probeHandoff("structural", d, (x) => registerForeignStructuralImpl(x, implNode, seen));
4656
+ }
4527
4657
  }
4528
4658
  }
4529
4659
  }
@@ -4544,6 +4674,10 @@ function registerForeignStructuralImpl(ifaceDecl, implNode, seen = new Set()) {
4544
4674
  for (const st of eh.types) {
4545
4675
  let sym; try { sym = checker.getSymbolAtLocation(st.expression); } catch { sym = undefined; }
4546
4676
  const tgt = sym && sym.flags & ts.SymbolFlags.Alias ? checker.getAliasedSymbol(sym) : sym;
4677
+ // ⟨SOUNDNESS R521⟩ NO mirror hand-off to `registerStructuralImpl` here, for the reason the nominal
4678
+ // climber's own comment gives in full: measured over the pinned TS roster, the only shape that
4679
+ // reaches it is a project-file MODULE AUGMENTATION of a dependency's interface, where the key it
4680
+ // would mint names the wrong owner and the foreign declaration already answers.
4547
4681
  for (const d of tgt?.declarations ?? [])
4548
4682
  if (isPublishableForeignIface(d)) registerForeignStructuralImpl(d, implNode, seen);
4549
4683
  }
@@ -5480,12 +5614,16 @@ function moduleUnit(sf) {
5480
5614
  // `C.constructor` unit, so a static-init effect was MISLABELED as the instance ctor (and carried no
5481
5615
  // unitKind). Mint it as its own unit, lazily, mirroring `moduleUnit`. (An anonymous class expression's
5482
5616
  // static block keys under `<anonymous>`; there is at most one static-init unit per class name.)
5483
- function staticBlockUnit(node) {
5617
+ // The qual alone, WITHOUT minting — R782/R785's wiring pass asks "does this block's unit exist?" and must
5618
+ // not create an empty one to find out. One derivation for both, so the two cannot spell it differently.
5619
+ function staticBlockQual(node) {
5484
5620
  const cls = node.parent;
5485
- const sf = node.getSourceFile();
5486
- const mod = moduleOf(sf);
5487
5621
  const cname = (ts.isClassDeclaration(cls) || ts.isClassExpression(cls)) && cls.name ? cls.name.text : "<anonymous>";
5488
- const qual = `${mod}.${cname}.<static-init>`;
5622
+ return `${moduleOf(node.getSourceFile())}.${cname}.<static-init>`;
5623
+ }
5624
+ function staticBlockUnit(node) {
5625
+ const sf = node.getSourceFile();
5626
+ const qual = staticBlockQual(node);
5489
5627
  let rec = fns.get(qual);
5490
5628
  if (!rec) {
5491
5629
  rec = { local: "<static-init>", direct: new Set(), fsKinds: new Set(), edges: new Set(), hosts: new Set(), tables: new Set(),
@@ -5776,23 +5914,260 @@ function recordDispatch(rec, decl, pkg) {
5776
5914
  // Bounded by the SAME `CHA_FANOUT_LIMIT` the in-scan dispatch site and the union emitter apply, and
5777
5915
  // hedged on the SAME completeness condition: an implementor whose member resolves to no unit leaves the
5778
5916
  // candidate set incomplete, and edging the rest while staying silent about it would drop its effects.
5779
- function joinLocalImpls(rec, d) {
5780
- if (!rec || !d) return;
5917
+ // ⟨SOUNDNESS R574⟩ THE RECEIVER'S VALUE IS THE PACKAGE'S OWN PRODUCT — returns the producing
5918
+ // expression, or null. `const g = makeClient(); g.fetchIt(u)` where `makeClient` is declared by `pkg`
5919
+ // and takes NO ARGUMENT: the package was handed nothing it could hand back, so the value it returned
5920
+ // is its own, and a LOCAL `class MyClient implements Gettable` is not a candidate at this site.
5921
+ //
5922
+ // IT IS A DENYLIST AND IT FAILS TOWARDS OVER-CHARGE. Only the provenance it can PROVE is excluded —
5923
+ // a `const` with a single declaration whose initializer is a zero-argument call or `new` resolving
5924
+ // into the same package. A parameter, a field, a `let`, a property access on a package object, or a
5925
+ // factory that RECEIVED an argument all fall through and keep the full CHA join. Widening it to an
5926
+ // allowlist of "receivers that look local" is the inversion this family has been burned by
5927
+ // ([[candor-denylist-over-allowlist]]); a missing exclusion here costs precision, a wrong one costs
5928
+ // silence.
5929
+ //
5930
+ // THE RESIDUAL, NAMED RATHER THAN ASSERTED AWAY: a zero-argument factory that returns an implementor
5931
+ // the caller registered EARLIER through a global registry (`register(new MyClient()); makeClient()`)
5932
+ // is genuinely a local implementor and is excluded here. That is not a new hole — it is exactly the
5933
+ // answer `d46c098` gave for the same shape, since the whole join sat behind `!eff` — and the
5934
+ // `dispatchesOn` key R560 added is published either way, so a consumer joining that key against its
5935
+ // own implementors still recovers it. This is an ASSUMPTION about zero-argument factories, not a
5936
+ // proof, and it is worded as one.
5937
+ function packageProducedReceiver(recvExpr, pkg) {
5938
+ if (!recvExpr || !pkg) return null;
5939
+ const unwrap = (x) => {
5940
+ while (x && (ts.isAwaitExpression(x) || ts.isParenthesizedExpression(x) || ts.isNonNullExpression(x)
5941
+ || ts.isAsExpression(x))) x = x.expression;
5942
+ return x;
5943
+ };
5944
+ let e = unwrap(recvExpr);
5945
+ // ONE hop through a `const` binding. `const` and a SINGLE declaration deliberately: a `let`/`var`
5946
+ // can be reassigned anywhere in the scope, and two declarations mean two answers — both are the
5947
+ // open-slot shape ⟨R103⟩ already treats as an incomplete candidate set, so neither is provable here.
5948
+ if (e && ts.isIdentifier(e)) {
5949
+ const decls = checker.getSymbolAtLocation(e)?.declarations ?? [];
5950
+ if (decls.length !== 1) return null;
5951
+ const d = decls[0];
5952
+ if (!ts.isVariableDeclaration(d) || !d.initializer) return null;
5953
+ if (!(d.parent && ts.isVariableDeclarationList(d.parent) && (d.parent.flags & ts.NodeFlags.Const))) return null;
5954
+ e = unwrap(d.initializer);
5955
+ }
5956
+ if (!e || !(ts.isCallExpression(e) || ts.isNewExpression(e))) return null;
5957
+ if ((e.arguments?.length ?? 0) !== 0) return null; // it was handed nothing, so it returned nothing of ours
5958
+ const pd = checker.getResolvedSignature(e)?.declaration;
5959
+ if (!pd) return null;
5960
+ const pm = declModule(pd);
5961
+ return (pm === pkg || (pm.startsWith("@types/") ? pm.slice("@types/".length) : pm) === pkg) ? e : null;
5962
+ }
5963
+ // ⟨SOUNDNESS R574⟩ RETURNS WHAT IT DID, so a reach probe can count JOINS rather than branch entries.
5964
+ // `null` = this key named no visible implementor and nothing was contributed; otherwise the outcome
5965
+ // kind (`edge` / `ambiguous` / `hedge`). Every caller may ignore it; the value exists because
5966
+ // `R560-REACH` was published as "182 hits across 12 entries — the over-charge control WITH real reach"
5967
+ // while firing on BRANCH ENTRY, one line before `recordDispatch` could reject the declaration. A file
5968
+ // containing only `fs.writeFileSync` + `cp.execSync` emitted two such hits that changed no row. A reach
5969
+ // probe cited as evidence must count the thing it is cited for.
5970
+ // ⟨SOUNDNESS R583⟩ THE ONE PLACE A JOIN'S REACH IS COUNTED. §9 — the boundary is not drawn around
5971
+ // R574's trigger: `R560-REACH` fired on BRANCH ENTRY, and a grep of the MECHANISM rather than of the
5972
+ // row found `R524-REACH` and `R558-REACH` doing the same thing. Demonstrated on one file importing a
5973
+ // foreign index-signature interface and a foreign named member with NO local implementor of either:
5974
+ // both probes emit a hit, both rows come back EMPTY, and no join occurred. Neither published figure
5975
+ // was wrong — R524's 6 hits matched its 6 changed rows and R558's was 0 — so this is a LATENT
5976
+ // instrument defect, recorded as one rather than upgraded. Three probes answering one question from
5977
+ // three hand-placed call sites is the drift §G forbids; there is now one.
5978
+ const probeJoinReach = (mark, outcome, detail) => {
5979
+ if (outcome && process.env[`CANDOR_${mark.split("-")[0]}_REACH`]) console.error(`${mark} ${outcome} ${detail}`);
5980
+ };
5981
+ // `hedgeOnly` ⟨SOUNDNESS R574⟩: this key HAS visible implementors and the receiver provably is not one
5982
+ // of them, so take SPEC ⟨0.35⟩'s option (b) — `Unknown` with a `dispatch:` why — instead of option (a),
5983
+ // charging their effects. The clause makes the two equally sound and says so ("Both are sound; they
5984
+ // differ only in precision"). It is reached only when `targets.length > 0`, so it is exactly
5985
+ // co-extensive with the charge it replaces: where there is no implementor there is nothing to hedge.
5986
+ function joinLocalImpls(rec, d, hedgeOnly) {
5987
+ if (!rec || !d) return null;
5781
5988
  const { targets, allResolved, decls } = localImplTargets(d.key);
5782
- if (!targets.length) return;
5989
+ if (!targets.length) return null;
5783
5990
  // The name means two things here, so no implementor set can be attributed to this key — §4 ⟨0.24⟩'s
5784
5991
  // `ambiguous:`, not `dispatch:`: the owner type is nameable, but WHICH declaration it names is not.
5785
5992
  if (decls.size > 1) {
5786
5993
  rec.direct.add("Unknown");
5787
5994
  rec.why.add(`ambiguous:${d.pkg}.${d.ifaceName}.${d.member}`);
5788
- return;
5995
+ return "ambiguous";
5789
5996
  }
5790
- if (targets.length > CHA_FANOUT_LIMIT || !allResolved) {
5997
+ if (hedgeOnly || targets.length > CHA_FANOUT_LIMIT || !allResolved) {
5791
5998
  rec.direct.add("Unknown");
5792
5999
  rec.why.add(dispatchWhy(`${d.pkg}.${d.ifaceName}`, d.member));
5793
- return;
6000
+ return hedgeOnly ? "hedge-foreign-receiver" : "hedge";
5794
6001
  }
5795
6002
  for (const t of targets) rec.edges.add(t);
6003
+ return "edge";
6004
+ }
6005
+ // ⟨SOUNDNESS R524, shape (i)⟩ The interface whose INDEX SIGNATURE a resolved declaration dispatches
6006
+ // THROUGH, or null. `interface Handlers { [k: string]: (n: number) => number }` is not a member
6007
+ // signature, so `dispatchedInterfaceMember` returns null for it — correctly, because an index signature
6008
+ // names no member and therefore mints no key any producer's `interfaceUnion` could ever publish under
6009
+ // (the emitter's own loop requires `member.name`). That is the right answer for the WIRE and the wrong
6010
+ // one for obligation 3's LOCAL half, which does not need a publishable key: the implementor is in THIS
6011
+ // scan and the member name is sitting at the call site. ⟨0.35⟩ (SPEC.md:4655) binds the caller's
6012
+ // `inferred` to a visible structural implementor's effects or `Unknown` "where a synthesised or
6013
+ // structural implementor is VISIBLE to the engine's own resolution", with no restriction to LOCALLY
6014
+ // declared abstractions — so moving `Handlers` into `node_modules` may not change the answer.
6015
+ // The checker resolves `h.roll(n)` to the index signature's FUNCTION TYPE, not to the signature, so
6016
+ // both spellings are accepted here for the same reason `memberSigOf` accepts both spellings of a member.
6017
+ function indexSignatureIface(decl) {
6018
+ const sig = !decl ? null
6019
+ : ts.isIndexSignatureDeclaration(decl) ? decl
6020
+ : (decl.parent && ts.isIndexSignatureDeclaration(decl.parent) ? decl.parent : null);
6021
+ if (!sig || !sig.parent || !ts.isInterfaceDeclaration(sig.parent) || !sig.parent.name) return null;
6022
+ return sig.parent;
6023
+ }
6024
+ // The property name a CALL SITE accesses on its receiver — `h.roll(n)` and `h["roll"](n)` both name
6025
+ // `roll`. It has to come from the site because the resolved declaration has no name to give: that is
6026
+ // what an index signature IS. A computed, non-literal key (`h[k](n)`) names nothing and resolves
6027
+ // nothing — it is left exactly where it was rather than guessed at.
6028
+ function accessedMemberName(expr) {
6029
+ if (!expr) return null;
6030
+ if (ts.isPropertyAccessExpression(expr)) return expr.name?.text ?? null;
6031
+ if (ts.isElementAccessExpression(expr) && expr.argumentExpression
6032
+ && (ts.isStringLiteralLike(expr.argumentExpression) || ts.isNumericLiteral(expr.argumentExpression)))
6033
+ return expr.argumentExpression.text;
6034
+ return null;
6035
+ }
6036
+ // ⟨SOUNDNESS R587⟩ The DECLARATION a value-reference slot names — an identifier, a property access, or
6037
+ // an ELEMENT ACCESS with a literal key. It exists because `checker.getSymbolAtLocation` returns
6038
+ // undefined for an element access (MEASURED on all four arms of the R587 fixture — `d["roll"]` and
6039
+ // `i["roll"]` alike), so `realDecl(getSymbolAtLocation(a))` silently answered nothing and every caller
6040
+ // of the element-access spelling fell out of `functions[]` entirely. The call path already learned
6041
+ // this once — the dynamic-slot arm looks the member up on the RECEIVER's type for exactly the same
6042
+ // reason, and its comment records that its own first draft asserted the opposite and was false. This
6043
+ // is the reference-position sibling of that lookup, in ONE place for the two sites that need it.
6044
+ //
6045
+ // A DECLARED member resolves through `getPropertyOfType`; an INDEX SIGNATURE has no property symbol at
6046
+ // all and resolves through `getIndexInfosOfType`, which is the node `indexSignatureIface` already
6047
+ // accepts. A genuinely dynamic key yields no name and therefore no declaration — nothing is guessed.
6048
+ function refSlotDecl(expr) {
6049
+ if (!expr) return undefined;
6050
+ if (!ts.isElementAccessExpression(expr)) return realDecl(checker.getSymbolAtLocation(expr));
6051
+ const key = accessedMemberName(expr);
6052
+ if (!key) return undefined;
6053
+ let rt; try { rt = checker.getTypeAtLocation(expr.expression); } catch { return undefined; }
6054
+ if (!rt) return undefined;
6055
+ const prop = checker.getPropertyOfType(rt, key);
6056
+ if (prop) return realDecl(prop);
6057
+ return (checker.getIndexInfosOfType?.(rt) ?? []).map((i) => i.declaration).find(Boolean);
6058
+ }
6059
+ // Obligation 3's LOCAL half for the index-signature spelling. Deliberately NOT `recordDispatch`: it
6060
+ // records nothing on the wire, because `dispatchesOn` is a key a CONSUMER is expected to be able to
6061
+ // join against a published union, and no union is ever published for an index signature. A value there
6062
+ // that nothing can answer is the noise `recordDispatch`'s own comment refuses, and noise in a soundness
6063
+ // field is how a real value stops being read. The JOIN is the whole of the fix.
6064
+ //
6065
+ // PURELY ADDITIVE: `joinLocalImpls` either edges to units this scan minted or hedges `Unknown`, and the
6066
+ // `invisible`/ledger disclosure downstream of this call site still runs either way. Nothing that was
6067
+ // disclosed before stops being disclosed.
6068
+ // ⟨SOUNDNESS R574⟩ Returns its outcome for the same reason `joinLocalImpls` does — `null` when nothing
6069
+ // was contributed — so a reach probe counts joins rather than branch entries.
6070
+ function joinIndexSignatureImpls(rec, decl, pkg, memberName, hedgeOnly) {
6071
+ if (!rec || !decl || !pkg) return null; // NOT gated on `memberName`: null is the COMPUTED-key case below
6072
+ if (declIsNodeTypes(decl)) return null; // the platform type surface — same exclusion as `recordDispatch`
6073
+ const iface = indexSignatureIface(decl);
6074
+ if (!iface) return null;
6075
+ const ifaceName = iface.name.text;
6076
+ // A COMPUTED key — `h[name](n)`, the idiomatic dispatch-table spelling and the one an index signature
6077
+ // exists for — names no single member, so EVERY member a visible implementor supplies is genuinely
6078
+ // reachable from it. Joining each is the same answer `joinLocalImpls` gives for a named one, applied
6079
+ // to the whole reachable set; it is not a widening of the named case, and it charges nothing no
6080
+ // visible implementor performs. The audit boundary is deliberately drawn past R524's own fixture,
6081
+ // which uses a literal member: a rule that fired only on `h.roll(n)` would miss the spelling the
6082
+ // construct is FOR. Bounded by the same `CHA_FANOUT_LIMIT` as every other CHA site here — a table
6083
+ // too wide to enumerate is an open hierarchy and takes the disclosed Unknown, not silence.
6084
+ const names = memberName ? [memberName] : indexSigMemberNames(pkg, ifaceName);
6085
+ if (!names.length) return null;
6086
+ if (names.length > CHA_FANOUT_LIMIT) {
6087
+ rec.direct.add("Unknown");
6088
+ rec.why.add(dispatchWhy(`${pkg}.${ifaceName}`, memberName ?? undefined));
6089
+ return "hedge";
6090
+ }
6091
+ let out = null;
6092
+ for (const m of names)
6093
+ out = joinLocalImpls(rec, { key: dispatchKey(pkg, ifaceName, m), pkg, ifaceName, member: m }, hedgeOnly) ?? out;
6094
+ probeJoinReach("R524-REACH", out, `${pkg}#${ifaceName}.${memberName ?? "<computed>"}`);
6095
+ return out;
6096
+ }
6097
+ // ⟨SOUNDNESS R558⟩ AN INTERFACE MEMBER NAMED AS A FIRST-CLASS VALUE IS A DISPATCH, and it invokes
6098
+ // exactly what the CALL spelling invokes. `[n].map(d.roll)` and `d.roll(n)` reach the same body through
6099
+ // the same abstraction; only the first desugars AWAY from the CallExpression arm, where every
6100
+ // obligation-3 join in this file lives. MEASURED at HEAD (`d46c098`) before this fix, one file, five
6101
+ // arms, the only variable being what the reference NAMES: `refPlain` (a plain function ref) `['Fs']`,
6102
+ // `refClass` (a CLASS member ref) `['Fs']`, `callDeclared` (the SAME interface member, CALLED) `['Fs']`
6103
+ // — and `refDeclared` / `refIndexed` ABSENT FROM `functions[]` ENTIRELY, which SPEC §2 rule 3 makes an
6104
+ // affirmative purity claim. `pure refDeclared` and `pure refIndexed` exited 0 over a body that writes
6105
+ // to disk; `pure refClass` / `pure refPlain` exited 1 over the identical body.
6106
+ //
6107
+ // A NEW ADDITIVE PREDICATE, NOT A WIDENING OF THE HOF-REF ARM'S OPACITY INDEX. That index answers "is
6108
+ // this holder caller-controlled" — a different question with a different right answer, and widening it
6109
+ // to admit a resolved signature would hand every resolved dependency function the opaque-callback
6110
+ // `Unknown`. This one asks only "does this reference name an abstract member some VISIBLE implementor
6111
+ // answers", and routes a yes through the SAME `recordDispatch` / `joinLocalImpls` /
6112
+ // `joinIndexSignatureImpls` the call site uses — never a second implementation of the join, which is
6113
+ // how the two would drift (§G). candor-rust fixed its half of this class — R549 mechanism B,
6114
+ // `xs.front().map(Buf::chunk)` — the same way in `186e854`, with a new predicate rather than a widened
6115
+ // index; the severities differed (rust lost only the KEY, ts lost the whole ROW) and the shape did not.
6116
+ //
6117
+ // `argIsCallable` is required, not decorative: `recordDispatch` accepts a PropertySignature, and a
6118
+ // non-function property named as a value (`xs.map(cfg.label)`) is DATA, not a dispatch — charging it
6119
+ // would fabricate its implementors' effects onto a caller that invokes nothing.
6120
+ //
6121
+ // ⟨SOUNDNESS R587⟩ THIS PREDICATE IS NOW THE WHOLE OF THE DESUGARED ANSWER, at three call sites rather
6122
+ // than one — the HOF-ref arm R558 built it for, the REFLECTIVE-INVOKE funnel (`fn.call`/`fn.apply`/
6123
+ // `Reflect.apply`), and the ELEMENT-ACCESS reference spelling. It answers "does this reference name an
6124
+ // abstract member some VISIBLE implementor answers", and that question does not change with the
6125
+ // syntax that reaches it. `hedgeOnly` is ⟨0.35⟩ option (b), threaded through from the caller rather
6126
+ // than re-decided here: only the reflective funnel passes it, and only on the CallExpression arm's own
6127
+ // ⟨R574⟩ condition (κ answered AND `packageProducedReceiver`). The HOF-ref site passes nothing and is
6128
+ // byte-identical to R558's. `mark` names WHICH site reached the join so the two changes can be priced
6129
+ // apart in one A/B run; it still goes through the single ⟨R583⟩ `probeJoinReach`, so it counts the
6130
+ // join's returned OUTCOME and not entry to a branch — the property that invalidated R560's published
6131
+ // reach figure. Three marks through one probe is what R583 left; this is a fourth name, not a fourth
6132
+ // probe.
6133
+ function chargeMemberRefDispatch(rec, refExpr, d2, hedgeOnly, mark = "R558-REACH") {
6134
+ if (!rec || !d2 || !argIsCallable(refExpr)) return false;
6135
+ const m = declModule(d2);
6136
+ // ONLY `<local>` TAKES THIS PACKAGE'S NAME. Every other `<…>` pseudo-module is the platform type
6137
+ // surface and is REFUSED, not renamed — `declIsNodeTypes` (the exclusion `recordDispatch` applies)
6138
+ // tests `@types/node` and NOT the TypeScript ES lib, so the `!mod.startsWith("<")` guard at the
6139
+ // CallExpression site is what has always kept the lib out, and a helper that derives its own key has
6140
+ // to restate it. MEASURED, and this is why the corpus A/B is not optional: keying `<es-lib>` as
6141
+ // `pkgName` published `typeorm#ArrayConstructor.isArray` on **530 typeorm rows** and 2 nest rows from
6142
+ // `xs.some(Array.isArray)` — a key naming an interface typeorm does not own, that no producer could
6143
+ // ever answer, which is precisely the wire noise `recordDispatch`'s own comment refuses and precisely
6144
+ // candor-rust's R549 malformed-key class reproduced in a second engine. Nothing else in that A/B
6145
+ // moved: it was 532 rows of `dispatchesOn` and zero of anything else.
6146
+ const pkg = m === "<local>" ? pkgName
6147
+ : !m || m.startsWith("<") ? null
6148
+ : m.startsWith("@types/") ? m.slice("@types/".length) : m;
6149
+ if (!pkg) return false;
6150
+ // REACH PROBE, env-gated, same convention as `CANDOR_R519_REACH`/`CANDOR_R524_REACH` above: a
6151
+ // byte-identical A/B over a corpus that cannot reach the changed branch is the most flattering
6152
+ // number available and the least informative, and this family has mistaken one for evidence four
6153
+ // times. `bin/corpus-ab.py --mark R558-REACH` COUNTS these instead of anyone inferring reach.
6154
+ // ⟨SOUNDNESS R583⟩ counted on the JOIN'S OUTCOME, through the one shared probe, rather than on entry
6155
+ // to the branch — see `probeJoinReach`. As written it fired whenever `recordDispatch` minted a key,
6156
+ // which happens for every foreign interface member whether or not any implementor is visible.
6157
+ const probe = (kind, outcome) =>
6158
+ probeJoinReach(mark, outcome, `${kind} ${pkg} ${refExpr.getText().replace(/\s+/g, "").slice(0, 60)}`);
6159
+ // the DECLARED-member spelling — the reference resolves to the interface's own signature.
6160
+ const rd = recordDispatch(rec, d2, pkg);
6161
+ if (rd) { probe("declared", joinLocalImpls(rec, rd, hedgeOnly)); return true; }
6162
+ // the INDEX-SIGNATURE spelling — `i.roll` resolves to the `[k: string]: …` signature, which names no
6163
+ // member, so the name comes from the REFERENCE site exactly as R524 shape (i) takes it from the call
6164
+ // site. Measured, not assumed: `checker.getSymbolAtLocation(i.roll)` returns the `__index` symbol and
6165
+ // its declaration IS the IndexSignature, which is the node `indexSignatureIface` already accepts.
6166
+ if (indexSignatureIface(d2)) {
6167
+ probe("indexsig", joinIndexSignatureImpls(rec, d2, pkg, accessedMemberName(refExpr), hedgeOnly));
6168
+ return true;
6169
+ }
6170
+ return false;
5796
6171
  }
5797
6172
  // Apply ONE chained-dependency entry to the calling unit. There is exactly one of these because there used
5798
6173
  // to be two, drifted: the CallExpression arm and the desugared-declaration arm each spelled the copy out,
@@ -5812,15 +6187,20 @@ function joinLocalImpls(rec, d) {
5812
6187
  // copying them here would copy whatever happened to be known and silently under-report. An edge flows
5813
6188
  // through the SAME least fixpoint every other call does.
5814
6189
  let localImplTargetsByKey = null;
5815
- function localImplTargets(key) {
6190
+ // ⟨SOUNDNESS R524, shape (i)⟩ `<pkg>#<Iface>` -> every member name a VISIBLE implementor of that
6191
+ // index-signature interface supplies. Built in the same pass and off the same evidence as the target
6192
+ // index above, because a COMPUTED key (`h[k]()`) names no single member and every one of them is then
6193
+ // genuinely reachable. Separate map rather than a widening of the one above: these are NAMES, not
6194
+ // targets, and the ambiguity/fan-out guards that decide a target set must not be answered from here.
6195
+ let indexSigNamesByIface = null;
6196
+ function ensureLocalImplIndex() {
5816
6197
  if (!localImplTargetsByKey) {
5817
6198
  localImplTargetsByKey = new Map();
6199
+ indexSigNamesByIface = new Map();
5818
6200
  const add = (ownerPkg, ifaceDecl, implClasses) => {
5819
6201
  const ifaceName = ifaceDecl.name?.text;
5820
6202
  if (!ifaceName) return;
5821
- for (const member of ifaceDecl.members ?? []) {
5822
- const m = member.name?.getText?.();
5823
- if (!m) continue;
6203
+ const push = (m) => {
5824
6204
  const k = dispatchKey(ownerPkg, ifaceName, m);
5825
6205
  if (!localImplTargetsByKey.has(k))
5826
6206
  localImplTargetsByKey.set(k, { targets: [], allResolved: true, decls: new Set() });
@@ -5846,7 +6226,33 @@ function localImplTargets(key) {
5846
6226
  if (!t) { cell.allResolved = false; continue; }
5847
6227
  if (!cell.targets.includes(t)) cell.targets.push(t);
5848
6228
  }
6229
+ };
6230
+ for (const member of ifaceDecl.members ?? []) {
6231
+ const m = member.name?.getText?.();
6232
+ if (m) push(m);
5849
6233
  }
6234
+ // ⟨SOUNDNESS R524, shape (i)⟩ AN INDEX SIGNATURE DECLARES NO MEMBER, so the loop above indexes
6235
+ // nothing for `interface Handlers { [k: string]: (n: number) => number }` and obligation 3's join
6236
+ // had no key to answer on — which is the whole of R524's ts half. The member names such an
6237
+ // interface can be dispatched on are not on the DECLARATION at all; they are whatever its
6238
+ // implementors actually supply, so they are enumerated from the implementors. `push` is reused
6239
+ // verbatim rather than copied: it re-scans every implementor for that name and marks
6240
+ // `allResolved` false for any that cannot answer it, so an implementor set that disagrees about
6241
+ // which keys exist hedges instead of resolving — the same fail-closed condition the declared-member
6242
+ // arm already applies, reached the same way. A name no implementor supplies forms no key and is
6243
+ // untouched (`localImplTargets` returns its empty cell, `joinLocalImpls` returns early), which is
6244
+ // what keeps a foreign abstraction with NO visible implementor exactly where PART 92 c5/c10 pin it.
6245
+ if ((ifaceDecl.members ?? []).some((m) => ts.isIndexSignatureDeclaration(m) && m.type && ts.isFunctionTypeNode(m.type)))
6246
+ for (const cls of implClasses)
6247
+ for (const x of cls.members ?? cls.properties ?? []) {
6248
+ const m = (ts.isMethodDeclaration(x) || ts.isPropertyDeclaration(x) || ts.isPropertyAssignment(x))
6249
+ && x.name?.getText?.();
6250
+ if (!m) continue;
6251
+ push(m);
6252
+ const ik = `${ownerPkg}#${ifaceName}`;
6253
+ if (!indexSigNamesByIface.has(ik)) indexSigNamesByIface.set(ik, new Set());
6254
+ indexSigNamesByIface.get(ik).add(m);
6255
+ }
5850
6256
  };
5851
6257
  for (const [ifaceDecl, impls] of interfaceImpls) add(pkgName, ifaceDecl, impls);
5852
6258
  for (const [ifaceDecl, impls] of foreignInterfaceImpls) {
@@ -5854,8 +6260,16 @@ function localImplTargets(key) {
5854
6260
  if (ownerPkg && !ownerPkg.startsWith("<") && !ownerPkg.startsWith("/")) add(ownerPkg, ifaceDecl, impls);
5855
6261
  }
5856
6262
  }
6263
+ }
6264
+ function localImplTargets(key) {
6265
+ ensureLocalImplIndex();
5857
6266
  return localImplTargetsByKey.get(key) ?? { targets: [], allResolved: true, decls: new Set() };
5858
6267
  }
6268
+ // ⟨SOUNDNESS R524, shape (i)⟩ Every member name a visible implementor of `pkg#Iface` supplies.
6269
+ function indexSigMemberNames(pkg, ifaceName) {
6270
+ ensureLocalImplIndex();
6271
+ return [...(indexSigNamesByIface.get(`${pkg}#${ifaceName}`) ?? [])];
6272
+ }
5859
6273
  function applyDepHit(rec, hit) {
5860
6274
  // ⟨0.39⟩ The dispatched members travel with the hit — transitively, so a consumer of THIS report
5861
6275
  // learns of a dispatch two packages down — and each one is joined against what this scan can see.
@@ -6090,11 +6504,17 @@ const memberOwnerQual = (member) => {
6090
6504
  // here: the ledger's question is "did κ cover THIS call", and re-deriving the member from `decl` would
6091
6505
  // be a second spelling of it that could drift from the first (§G — where two paths compute one fact,
6092
6506
  // make them disagree; better still, do not have two).
6093
- function disclosureTail(rec, decl, pkg, file, member) {
6507
+ // ⟨SOUNDNESS R697⟩ `kappaAnswered` — κ HAS a rule for this call, so the caller is reaching the funnel for
6508
+ // the arms κ's rule does not speak to. Only the LEDGER arm is κ's question ("did κ cover this call"), and
6509
+ // it is suppressed; the manifest, the unanswerable-KEY arm and the dynamic-re-export arm ask questions a
6510
+ // package-level κ rule cannot answer, and they run. Passed rather than re-derived from `kappaKnows(pkg,
6511
+ // member)` because κ was asked with `kMod`, which is NOT always `mod` (a construction asks about the
6512
+ // class's own module), and a second spelling of one fact is §G.
6513
+ function disclosureTail(rec, decl, pkg, file, member, kappaAnswered) {
6094
6514
  const declared = packageManifestEffects(file);
6095
6515
  if (declared !== null) { for (const e of declared) rec.direct.add(e); return; } // [] = declared pure
6096
6516
  const abstraction = unanswerableKey(decl);
6097
- if (!kappaKnows(pkg, member) && !depCoveredPkgs.has(pkg) && crossesPackageBoundary(file)) {
6517
+ if (!kappaAnswered && !kappaKnows(pkg, member) && !depCoveredPkgs.has(pkg) && crossesPackageBoundary(file)) {
6098
6518
  unlistedSeen.set(pkg, (unlistedSeen.get(pkg) ?? 0) + 1);
6099
6519
  rec.blind.add(pkg);
6100
6520
  // ⟨0.21⟩ A package chained ONLY by a SELF-DECLARED-INCOMPLETE report reaches this arm because its
@@ -7268,6 +7688,32 @@ function visitCalls(node) {
7268
7688
  // (would flood the overwhelming-majority `arr.forEach(x => …)` shape). It is not an id/property-
7269
7689
  // access anyway, so it skips the ref arm below; guarded explicitly for clarity.
7270
7690
  if (ts.isArrowFunction(a) || ts.isFunctionExpression(a)) return;
7691
+ // ⟨SOUNDNESS R587⟩ THE ELEMENT-ACCESS SPELLING OF THE SAME REFERENCE. `[n].map(i["roll"])`
7692
+ // and `[n].map(i.roll)` name the same member of the same interface and invoke the same
7693
+ // body; only the second was admitted here, so the first got no edge, no Unknown and no
7694
+ // disclosure and its caller was ABSENT from `functions[]` — SPEC §2 rule 3's affirmative
7695
+ // purity claim, over a body ground-truthed by `node` to write a file. Measured at `6a639e6`
7696
+ // in all four cells: `fElemRef`/`lElemRef`/`fdElemRef`/`ldElemRef` all ABSENT while
7697
+ // `fRef`/`lRef` read `['Fs']`. `accessedMemberName` — written by R524 and already reading
7698
+ // `h["roll"]` — is the proof the SPELLING was anticipated and only the GATE was not.
7699
+ //
7700
+ // ADMITTED FOR THE DISPATCH JOIN ONLY, and that boundary is a decision (§9 in the other
7701
+ // direction). Letting an element access fall through to the arm's opaque-callback branch
7702
+ // would hand a NEW `Unknown` to every `xs.map(fns[0])` whose holder resolves to nothing —
7703
+ // an ecosystem-wide hedge widening, which is what ⟨0.39⟩ priced and declined at 2.60% of
7704
+ // functions, and it is not what this row is about. So the join runs and the arm returns.
7705
+ //
7706
+ // THE SCOPE CLAIM, AND WHAT BACKS IT. For a NON-element access nothing below changes at
7707
+ // all; for an element access the arm previously returned here doing nothing, so this can
7708
+ // only ADD. That much is structural — but "adds only what it should" is not, and it is
7709
+ // the half an assertion would hide (§K), so it is pinned by fixtures that would go red
7710
+ // were it false: CONTROL 2 (a PURE implementor gains nothing), CONTROL 3 (no visible
7711
+ // implementor gains neither an effect nor a hedge) and CONTROL 5 (a COMPUTED key resolves
7712
+ // nothing), each on both element-access spellings and both declaration sites.
7713
+ if (ts.isElementAccessExpression(a) && accessedMemberName(a)) {
7714
+ chargeMemberRefDispatch(rec, a, refSlotDecl(a), false, "R587-REACH");
7715
+ return;
7716
+ }
7271
7717
  if (!ts.isIdentifier(a) && !ts.isPropertyAccessExpression(a)) return;
7272
7718
  const d2 = realDecl(checker.getSymbolAtLocation(a));
7273
7719
  const t = (d2 && nodeName.get(d2)) || resolveFnRefUnit(a); // pin direct fn OR a local alias chain
@@ -7298,6 +7744,13 @@ function visitCalls(node) {
7298
7744
  // (3) CALLABILITY — `argIsCallable` (has a call signature, or `any`/`unknown`/unconstrained
7299
7745
  // generic that COULD hold a function).
7300
7746
  if (!hofInvokesArg(calleeName, argIdx, node)) return;
7747
+ // ⟨SOUNDNESS R558⟩ …and BEFORE the by-reference dependency charge below, without returning:
7748
+ // a FOREIGN interface member named as a value needs BOTH the visible-implementor join and
7749
+ // the `invisible`/ledger disclosure `chargeExternalDecl` already gives it, and the two
7750
+ // answer different halves of the same call. For a LOCAL one this is the whole answer, and
7751
+ // the opaque-callback branch further down declines it anyway (a MethodSignature is neither a
7752
+ // project value holder nor `!d2`), so no existing verdict is displaced — only absence is.
7753
+ chargeMemberRefDispatch(rec, a, d2);
7301
7754
  // The BY-REFERENCE dependency charge belongs BELOW guard (1), not above it. It was placed
7302
7755
  // first and returned early, so it ran at EVERY argument position: `xs.reduce(dep.merge,
7303
7756
  // dep.makeSeed)` and `promise.then(dep.onOk, dep.onErr)` charged the non-callback argument's
@@ -7338,8 +7791,15 @@ function visitCalls(node) {
7338
7791
  if ((m === "call" || m === "apply") && recvText !== "Reflect") invokedRef = recv;
7339
7792
  else if (recvText === "Reflect" && (m === "apply" || m === "construct"))
7340
7793
  invokedRef = (node.arguments ?? [])[0] ?? null;
7341
- if (invokedRef && (ts.isIdentifier(invokedRef) || ts.isPropertyAccessExpression(invokedRef))) {
7342
- const d2 = realDecl(checker.getSymbolAtLocation(invokedRef));
7794
+ // ⟨SOUNDNESS R587⟩ …and the ELEMENT-ACCESS spelling of the invoked reference, on the same
7795
+ // grounds as the HOF-ref arm above: `i["roll"].call(null, n)` invokes exactly what
7796
+ // `i.roll.call(null, n)` invokes. Measured ABSENT at `6a639e6` in every cell
7797
+ // (`fElemCall`/`lElemCall`/`fdElemCall`). Restricted to a LITERAL key by
7798
+ // `accessedMemberName` — a computed `i[k].call(…)` names nothing and is left where it was
7799
+ // rather than guessed at, which is the same line R524 drew.
7800
+ if (invokedRef && (ts.isIdentifier(invokedRef) || ts.isPropertyAccessExpression(invokedRef)
7801
+ || (ts.isElementAccessExpression(invokedRef) && accessedMemberName(invokedRef)))) {
7802
+ const d2 = refSlotDecl(invokedRef);
7343
7803
  // Resolve the receiver/arg0 to its function unit, FOLLOWING local-variable aliases
7344
7804
  // (`const m = effectful; m.call(…)`) — the direct-identifier form already landed on a minted
7345
7805
  // unit, but an aliased local var resolves to its VARIABLE decl (not a unit), which dropped the
@@ -7355,11 +7815,45 @@ function visitCalls(node) {
7355
7815
  // over-disclosure); an EFFECTFUL one (`fs.writeFileSync` → Fs, `dns.resolve` → Net) gets its effect.
7356
7816
  const kMod = d2 && declModule(d2);
7357
7817
  const kMember = d2?.name?.getText?.()
7358
- ?? (ts.isPropertyAccessExpression(invokedRef) ? invokedRef.name.text : ts.isIdentifier(invokedRef) ? invokedRef.text : null);
7818
+ ?? (ts.isIdentifier(invokedRef) ? invokedRef.text : accessedMemberName(invokedRef));
7359
7819
  // SELF-NAME GUARD (see `isOwnPackageDecl`): the reflectively-invoked reference may be OUR
7360
7820
  // OWN package's own function, reached through its own `dist/*.d.ts` — never let κ answer
7361
7821
  // for it, same reasoning as the (CLASSIFY) arm.
7362
7822
  const kEff = kMod && kMember && !isOwnPackageDecl(d2) ? kappa(kMod, kMember) : null;
7823
+ // ⟨SOUNDNESS R587⟩ THE DISPATCH JOIN, AT THE FUNNEL THAT HAD NEITHER HALF OF IT. This is
7824
+ // the site R573 was filed on and it was silent in EIGHT spellings, not the two the row
7825
+ // named. `chargeExternalDecl` below runs `joinLocalImpls(recordDispatch(…))` — obligation
7826
+ // 3's DECLARED half — and never `joinIndexSignatureImpls`, so a foreign index signature
7827
+ // reached `[]`; and `declIsLocal` keeps a LOCAL declaration out of that funnel entirely,
7828
+ // so both local spellings (index-signature AND declared-member) reached nothing at all
7829
+ // and their callers were ABSENT. Measured at `6a639e6`, one file, `tsc` clean, every body
7830
+ // ground-truthed by `node` to write a file:
7831
+ //
7832
+ // fCall/fApply/fReflect [] lCall/lApply/lReflect ABSENT
7833
+ // ldCall/ldApply ABSENT ← the LOCAL DECLARED half, which R573 does not name
7834
+ // fdCall/fdApply ['Fs'] fPlain/fdPlain/ldPlain ['Fs'] ← the controls
7835
+ //
7836
+ // and the local half is STRICTLY WORSE than its own baseline: `li.roll(n)` discloses
7837
+ // `Unknown` (`deny Unknown` exit 1) and the identical body through `.call` is silent.
7838
+ //
7839
+ // §G — THE SAME PREDICATE THE HOF-REF ARM CALLS, not a third join. Adding
7840
+ // `joinIndexSignatureImpls` to `chargeExternalDecl` (the remedy the review named) would
7841
+ // close three of these eight: that function receives no call-site expression, so it
7842
+ // cannot supply the member name an index signature has no declaration to give, and it is
7843
+ // unreachable for every LOCAL arm. The question "does this reference name an abstract
7844
+ // member some visible implementor answers" is one question and now has one implementation.
7845
+ //
7846
+ // ⟨R574⟩ IS CARRIED ACROSS RATHER THAN RE-DECIDED. Where κ answered AND the receiver is
7847
+ // demonstrably the package's own product (`const g = makeClient(); g.fetchIt.call(…)`),
7848
+ // this hedges — `Unknown` + `dispatch:` — exactly as the CallExpression arm does, on the
7849
+ // same `packageProducedReceiver` test with the same `eff ?` gate. Without it this fix
7850
+ // would reintroduce R574's fabricated concrete effect at a NEW site, which is how that
7851
+ // class spread the first time.
7852
+ const ownProduct = kEff && (ts.isPropertyAccessExpression(invokedRef) || ts.isElementAccessExpression(invokedRef))
7853
+ ? packageProducedReceiver(invokedRef.expression,
7854
+ kMod?.startsWith("@types/") ? kMod.slice("@types/".length) : kMod)
7855
+ : null;
7856
+ chargeMemberRefDispatch(rec, invokedRef, d2, !!ownProduct, "R587-REACH");
7363
7857
  if (kEff) {
7364
7858
  rec.direct.add(kEff);
7365
7859
  if (kEff === "Unknown") rec.why.add(`reflect:${kMod.replace(/^node:/, "")}.${kMember}`);
@@ -7912,7 +8406,7 @@ function visitCalls(node) {
7912
8406
  : (isConnectingCtor(declaredCtorClassName(decl)) ? declaredCtorClassName(decl) : ctorClassName);
7913
8407
  const member = isConnectingCtor(ctorRuleName) ? ctorRuleName
7914
8408
  : isConstruction ? "new"
7915
- : (decl.name ? decl.name.getText() : bindingName(decl));
8409
+ : declMemberToken(decl, decl.name ? decl.name.getText() : bindingName(decl)); // R801
7916
8410
  // ⟨0.32⟩ THE MODULE κ IS READ AGAINST — `mod` for everything except a construction whose
7917
8411
  // constructor came from somewhere else, which is re-keyed onto the CLASS's own module.
7918
8412
  //
@@ -8145,13 +8639,130 @@ function visitCalls(node) {
8145
8639
  // one hop short and the consumer's row ABSENT — a purity claim. Recorded BEFORE the chained
8146
8640
  // lookup below and independently of whether it hits: whether this run happened to be chained
8147
8641
  // says nothing about what a consumer of THIS report will be able to see.
8148
- if (!eff && !mod.startsWith("<"))
8149
- joinLocalImpls(rec, recordDispatch(rec, decl,
8150
- mod.startsWith("@types/") ? mod.slice("@types/".length) : mod));
8151
- // CANDOR_DEPS: an unclassified call into a package with a loaded sibling report inherits
8152
- // that function's recorded transitive effects (+ literal surfaces) by `hash`.
8642
+ //
8643
+ // ⟨SOUNDNESS R560⟩ NOT GATED ON `!eff`, AND THAT GUARD WAS THE WHOLE OF THE CARDINAL SIN.
8644
+ // A κ WHOLE-MODULE rule answers for the package, and it fired on a call whose only link to the
8645
+ // package is its TYPE — so the join that would have found the caller's OWN implementor never
8646
+ // ran. MEASURED at `d46c098`, one file, one variable (where the interface is declared):
8647
+ // `ev.save(n)` with `ev: DefaultEventsMap` (socket.io) and a LOCAL implementor that writes a
8648
+ // file read `inferred:['Net']` — `deny Fs` EXIT 0 over the write, `deny Net` EXIT 1 over
8649
+ // nothing dialling — while the local-interface twin `viaLocal` read `['Fs']` and gated
8650
+ // correctly. `emitNoop`, an EMPTY body typed the same way, also read `['Net']`.
8651
+ // §9 — THE BOUNDARY IS NOT DRAWN AROUND R560's TRIGGER: the row is written on the
8652
+ // index-signature spelling, and the DECLARED-member spelling of a foreign interface
8653
+ // (`TypedEventBroadcaster.emit`, a real socket.io interface with a named member) was measured
8654
+ // to lose the same effect the same way. Both spellings are joined here, so both are closed.
8655
+ // The engine already KNEW the answer in both — it published the union entry
8656
+ // `TypedEventBroadcaster.emit -> ['Fs']` in the same report whose caller row said `['Net']`.
8657
+ // Running the join unconditionally is PURELY ADDITIVE: `joinLocalImpls` only edges to units
8658
+ // this scan minted or hedges `Unknown`, so κ's own charge is never removed by it. The
8659
+ // FABRICATION half of R560 — κ's `Net` on a call that enters no package code — is NOT fixed
8660
+ // here and is NOT the same defect: removing it is a NARROWING of a sound over-approximation
8661
+ // and belongs to a ruling, not a patch. See the CHANGELOG entry for the measurement.
8662
+ if (!mod.startsWith("<")) {
8663
+ const dpkg = mod.startsWith("@types/") ? mod.slice("@types/".length) : mod;
8664
+ // REACH PROBE for the ⟨R560⟩ half, env-gated, same convention as ⟨R519⟩/⟨R524⟩'s.
8665
+ // ⟨SOUNDNESS R574⟩ IT FIRED ON BRANCH ENTRY AND IS NOW FIRED ON THE JOIN'S OUTCOME. As
8666
+ // published it sat HERE, above `recordDispatch`, so a file containing only
8667
+ // `fs.writeFileSync` + `cp.execSync` emitted two hits — both rejected by `recordDispatch`
8668
+ // one line later, both incapable of changing a row. The number it produced, "182 hits
8669
+ // across 12 entries — the over-charge control WITH real reach", therefore could not
8670
+ // distinguish 182 genuine joins from 182 node-core no-ops, and it was cited as if it
8671
+ // could. A probe placed before the filter it is evidence FOR measures the filter's input.
8672
+ // It now counts only outcomes that CONTRIBUTED to `rec` — an edge, an `ambiguous:` or a
8673
+ // `dispatch:` hedge — and prints WHICH, so the next reader can bucket without re-running.
8674
+ // ⟨SOUNDNESS R574⟩ …AND WHERE THE RECEIVER IS THE PACKAGE'S OWN PRODUCT, HEDGE INSTEAD OF
8675
+ // CHARGING. Removing the `!eff` guard was right and is not reverted — but it also ran the
8676
+ // CHA join on every foreign call κ had already answered, and a local implementor of the
8677
+ // package's interface is then charged to a call that cannot reach it. MEASURED at
8678
+ // `d534b62`, one file, `tsc` clean, `analyzed=6`, the only variable being the receiver's
8679
+ // PROVENANCE: `viaLib()` — `const g = makeClient(); g.fetchIt(u)`, the receiver genuinely
8680
+ // got's own object — read `['Net']` at `d46c098` and `['Exec','Net']` after, the `Exec`
8681
+ // coming from a local `class MyClient implements Gettable` the call never reaches.
8682
+ //
8683
+ // THE BOUNDARY IS `eff` AND THAT IS A DECISION, NOT AN OVERSIGHT (§9). It is confined to
8684
+ // the branch R560 ADDED, so nothing that existed before R560 can move: the `!eff` path is
8685
+ // byte-identical. The asymmetry has a reason rather than a convenience — where κ answered,
8686
+ // the package's OWN implementor is already charged by κ's rule, so declining a candidate
8687
+ // the receiver provably is not loses nothing about the receiver; where κ did not answer,
8688
+ // the CHA candidates are the only thing the engine knows about the call and declining them
8689
+ // is SILENCE, which is the trade this family refuses. **MEASURED, so the asymmetry is
8690
+ // priced and not assumed: at `d46c098` the identical consumer against a NON-κ package
8691
+ // already read `['Exec']` on `viaLib` through this same join.** The over-charge is
8692
+ // therefore the shipped ⟨0.35⟩/⟨0.39⟩ CHA over-approximation and R560 widened its SCOPE,
8693
+ // not its KIND — which is why the answer here is ⟨0.35⟩'s other sanctioned branch and not
8694
+ // a deletion.
8695
+ //
8696
+ // IT HEDGES, IT DOES NOT DELETE. `['Net','Unknown']` + `dispatch:got.Gettable.fetchIt`,
8697
+ // not `['Net']`: narrowing past a fabrication into silence converts an over-report into
8698
+ // the cardinal sin, and no row may lose an effect without gaining the disclosure that
8699
+ // replaces it. `recordDispatch` still runs, so the wire key R560 added is still published
8700
+ // and a consumer joining it against its own implementors recovers what this scan declined
8701
+ // to assert.
8702
+ const ownProduct = eff ? packageProducedReceiver(
8703
+ (ts.isPropertyAccessExpression(node.expression) || ts.isElementAccessExpression(node.expression))
8704
+ ? node.expression.expression : null, dpkg) : null;
8705
+ const rdOut = joinLocalImpls(rec, recordDispatch(rec, decl, dpkg), !!ownProduct);
8706
+ // ⟨SOUNDNESS R524, shape (i)⟩ …and the INDEX-SIGNATURE spelling of the same dispatch, which
8707
+ // `recordDispatch` returns null for because there is no member name on the declaration to
8708
+ // form a key from. A CallExpression is the only site that has the receiver access in hand,
8709
+ // and a member name is exactly what the site supplies and the declaration cannot. The
8710
+ // DESUGARED arm — `[n].map(h.roll)`, silent through an interface member in BOTH the local
8711
+ // and the foreign arm — was a wider hole with its own mechanism; it is ⟨R558⟩ and is now
8712
+ // closed at the HOF-ref site by `chargeMemberRefDispatch`, through these same two joins.
8713
+ const isOut = joinIndexSignatureImpls(rec, decl, dpkg, accessedMemberName(node.expression), !!ownProduct);
8714
+ // ⟨SOUNDNESS R583⟩ through the one shared probe, so the three reach marks in this file
8715
+ // cannot drift again. `eff &&` because the branch R560 ADDED is the κ-answered one.
8716
+ probeJoinReach("R560-REACH", eff && (rdOut ?? isOut), `${dpkg}.${member || "<computed>"} eff=${eff}`);
8717
+ }
8718
+ // CANDOR_DEPS: a call into a package with a loaded sibling report inherits that function's
8719
+ // recorded transitive effects (+ literal surfaces) by `hash`.
8720
+ //
8721
+ // ⟨SOUNDNESS R696⟩ NOT GATED ON `!eff`, AND THAT GUARD WAS THE WHOLE OF THE CARDINAL SIN —
8722
+ // the ts sibling of candor-java R685, where `crossDepJoin` was gated on `effect == null` so a
8723
+ // member the classifier had a rule for never had the dependency's OWN published row read at
8724
+ // all. Here it was κ: a κ rule for the package — a WHOLE-MODULE one in every measured case
8725
+ // (`axios|got|node-fetch|undici|ws|socket.io|nodemailer`→Net, `winston|pino|bunyan|npmlog`→Log,
8726
+ // `pg|mysql2|mongodb|…`→Db, `execa|cross-spawn|shelljs`→Exec, `fs-extra|glob|chokidar|…`→Fs) —
8727
+ // made `eff` truthy for EVERY member of the package, so the chained entry under that exact
8728
+ // hash was skipped and κ's one effect was published as the whole answer.
8729
+ //
8730
+ // MEASURED ON REAL CODE, one variable held apart: the corpus's real `got` 14.4.1 scanned into a
8731
+ // report (`got#Request.flush -> ['Clock','Net','Unknown']`, `unresolved: true`, four reason
8732
+ // classes), chained against a consumer doing `r.flush()`. Under the package's real name the
8733
+ // consumer row read `['Net']` with `unresolved: false` and no reasons — `deny Clock src.m.go`
8734
+ // and `deny Unknown src.m.go` BOTH exit 0. With the SAME report content renamed `gotx` so κ has
8735
+ // no rule, the same consumer read `['Clock','Net','Unknown']`, `unresolved: true`, and both
8736
+ // gates exit 1. The consumer was reading MORE CERTAINTY than the report it was handed, which is
8737
+ // the one thing §2's chained join exists to prevent.
8738
+ //
8739
+ // And the one-tree control, which is what makes it a contradiction rather than a preference:
8740
+ // eight shapes (plain method, dep-declared interface reached through the interface, `import
8741
+ // type`, abstract member, callback property, generic constrained to a dep interface, index
8742
+ // signature, bare function export) all read `['Exec']` with the dependency's body IN the scan
8743
+ // and `['Log']` once split behind a chained report named `winston`. Same source, two answers,
8744
+ // `deny Exec` exit 1 → exit 0, and `deny Exec Unknown` green too — no policy form caught it.
8745
+ //
8746
+ // PURELY ADDITIVE, which is why this is a patch and not a ruling: `applyDepHit` only ever calls
8747
+ // `rec.*.add`, so κ's own charge is never removed and no row can lose an effect — the union the
8748
+ // family's cross-dep rule asks for. `crossDeps.size > 0` still means an UNCHAINED scan is
8749
+ // byte-identical; the newly reached set is exactly (chained ∧ κ answered ∧ the report carries
8750
+ // the key). Distinct from the R574 hazard one branch up: THAT join guesses a LOCAL implementor
8751
+ // of a foreign abstraction, where the receiver's provenance decides whether the candidate is
8752
+ // reachable at all. This one reads the DEPENDENCY's own row for the very member the checker
8753
+ // resolved, so a receiver the PACKAGE produced is exactly what the row is about.
8754
+ // MEASURED rather than argued, because that is a safety assertion and this file's own §K rule
8755
+ // says those are the lines to attack: with a dep declaring `Sink`, implementing it as an Exec
8756
+ // `DepSink` returned from `makeSink()`, and the CONSUMER declaring its own Fs `LocalSink` for
8757
+ // the same interface, `const s = makeSink(); s.write(x)` gains the dependency's `Exec` beside
8758
+ // κ's `Log` and does NOT gain the consumer's `Fs`. Pinned in test.mjs §11h.
8759
+ // THE BOUNDARY, stated rather than overclaimed: this join can still over-charge to the extent
8760
+ // the DEPENDENCY's own ⟨0.39⟩ obligation-2 union entry over-approximates (a union naming an
8761
+ // implementor from a third package that this receiver provably is not). That is inherited from
8762
+ // the producer's row and is the same over-approximation the pre-R696 `!eff` path already
8763
+ // accepted on every κ-silent package; the error direction is over-charge, never silence.
8153
8764
  let inheritedFromDep = false;
8154
- if (!eff && crossDeps.size > 0 && !mod.startsWith("<")) {
8765
+ if (crossDeps.size > 0 && !mod.startsWith("<")) {
8155
8766
  const nameDecl = memberSigOf(decl); // a function-typed property names its member one level up
8156
8767
  let localTail = nameDecl.name ? nameDecl.name.getText() : null;
8157
8768
  const owner3 = nameDecl.parent && nameDecl.parent.name ? nameDecl.parent.name.getText() : null;
@@ -8173,7 +8784,23 @@ function visitCalls(node) {
8173
8784
  // fallback exactly the typed-consumer shape the chain targets never joined.
8174
8785
  const hit = localTail && (crossDeps.get(`${depMod}#${localTail}`)
8175
8786
  ?? (nameDecl.name ? crossDeps.get(`${depMod}#${nameDecl.name.getText()}`) : undefined));
8176
- if (hit) { inheritedFromDep = true; applyDepHit(rec, hit); }
8787
+ if (hit) {
8788
+ inheritedFromDep = true;
8789
+ // ⟨SOUNDNESS R696⟩ REACH PROBE for the branch this row ADDED — the κ-answered one — and it
8790
+ // counts the JOIN'S OUTCOME, not branch entry (R574/R583: a probe placed before the filter
8791
+ // it is evidence for measures the filter's input). `eff &&` because the pre-R696 `!eff` path
8792
+ // is byte-identical and cannot be what a reader is pricing. Env-gated, same convention as
8793
+ // ⟨R519⟩/⟨R524⟩/⟨R560⟩'s: CANDOR_R696_REACH=1.
8794
+ const before = eff ? `${rec.direct.size}/${rec.blind.size}/${rec.why.size}/${rec.incomplete.size}`
8795
+ + `/${rec.hosts.size}/${rec.cmds.size}/${rec.paths.size}/${rec.tables.size}/${rec.dispatch.size}` : null;
8796
+ applyDepHit(rec, hit);
8797
+ if (eff) {
8798
+ const after = `${rec.direct.size}/${rec.blind.size}/${rec.why.size}/${rec.incomplete.size}`
8799
+ + `/${rec.hosts.size}/${rec.cmds.size}/${rec.paths.size}/${rec.tables.size}/${rec.dispatch.size}`;
8800
+ probeJoinReach("R696-REACH", after === before ? null : "contributed",
8801
+ `${depMod}#${localTail} eff=${eff} ${before}->${after}`);
8802
+ }
8803
+ }
8177
8804
  }
8178
8805
  // unmatched external = (OPAQUE): contributes nothing — the curated-κ caveat C1. The
8179
8806
  // κ-coverage LEDGER makes the caveat per-scan evidence instead of a doc footnote: count
@@ -8181,7 +8808,22 @@ function visitCalls(node) {
8181
8808
  // report covers (the argon2 lesson — the blind spot landed on exactly the call a
8182
8809
  // security review cared about). Builtins are excluded: κ's builtin coverage is the
8183
8810
  // bounded frontier, and an unlisted builtin (path, util) is known-pure, not blind.
8184
- if (!eff && !inheritedFromDep && !mod.startsWith("<")) {
8811
+ //
8812
+ // ⟨SOUNDNESS R697⟩ AND THIS ARM'S `!eff` WAS THE SECOND HALF OF THE SAME PREEMPTION. The
8813
+ // unanswerable-KEY disclosure lives down this funnel, and κ's answer suppressed it: a call
8814
+ // through a member of a CHAINED package whose report is silent under that key got
8815
+ // `Unknown[dispatch:<pkg>.<Owner>.<member>]` when κ had no rule and NOTHING when it did.
8816
+ // MEASURED, same report content both arms, only the package NAME differing so that κ matches:
8817
+ // an `abstract act()` of a chained dep read `['Unknown'] why=['dispatch:depkit.Base.act']` and
8818
+ // `deny Exec Unknown src.m.go` exit 1; named `winston` the row read `['Log']` and the same
8819
+ // policy exit 0 — over a real `execSync`. Same for a dep-declared interface the dep does not
8820
+ // implement (`Unknown[dispatch:depkit.Runner.run]` vs nothing). κ's rule says what the PACKAGE
8821
+ // does; it never says WHICH IMPLEMENTATION runs behind an abstraction member, which is the only
8822
+ // question this arm asks — so it is not κ's to answer and not κ's to silence.
8823
+ // The LEDGER arm inside the funnel IS κ's question and stays suppressed (`kappaAnswered`).
8824
+ // `!inheritedFromDep` is KEPT: where the chained report carried the key it answered, and R696
8825
+ // above has already joined it.
8826
+ if (!inheritedFromDep && !mod.startsWith("<")) {
8185
8827
  // The REAL package name first: a typed consumer of an untyped package resolves into
8186
8828
  // @types/<pkg>, and κ's tables/review lists hold the real name (/code-review: lodash
8187
8829
  // via @types/lodash was falsely disclosed — kappaKnows saw the unstripped name).
@@ -8192,7 +8834,7 @@ function visitCalls(node) {
8192
8834
  // already ran its own chained-dep lookup above (`inheritedFromDep`, with its constructor/
8193
8835
  // owner-prefix spelling), so it hands the tail an already-external, already-unmatched `decl`
8194
8836
  // rather than re-deriving the join.
8195
- disclosureTail(rec, decl, pkg, file, member);
8837
+ disclosureTail(rec, decl, pkg, file, member, !!eff);
8196
8838
  }
8197
8839
  }
8198
8840
  }
@@ -8992,7 +9634,7 @@ function visitCalls(node) {
8992
9634
  // call gets (never silent-pure). A pure builtin tag (String.raw, from the TS lib, not node_modules) adds
8993
9635
  // nothing — no fabrication.
8994
9636
  const mod = declModule(decl);
8995
- const member = decl.name ? decl.name.getText() : "";
9637
+ const member = declMemberToken(decl, decl.name ? decl.name.getText() : ""); // R801 — same token
8996
9638
  // SELF-NAME GUARD (see `isOwnPackageDecl`): an external-looking tag may be OUR OWN package's
8997
9639
  // own function, reached through its own `dist/*.d.ts` — never let κ answer for it. `mod` stays
8998
9640
  // whatever `declModule` computed, so the ledger below (already keyed on
@@ -9122,6 +9764,94 @@ function mintCallTargetUnit(fn) {
9122
9764
  }
9123
9765
  for (const sf of sources) visitCalls(sf);
9124
9766
 
9767
+ // ---- pass 2a′: CLASS-DEFINITION-TIME WORK IS WIRED FROM THE UNIT THAT EVALUATES THE CLASS ----------------
9768
+ // SOUNDNESS R782 + R785. A JS class definition is EAGER and INLINE: `@Deco class Y {}` calls `Deco` —
9769
+ // and a `static { … }` block runs — at the moment the enclosing body reaches that statement, once per
9770
+ // evaluation. So the unit that EVALUATES the class performs that work: `<module>` for a top-level class,
9771
+ // `mk` for `function mk() { @Deco class Y {} return Y }`, `A.m` for a class declared inside a method.
9772
+ // ⟨0.14⟩ (SPEC §2, the initializer unit) already rules module top-level code in scope. Before this pass
9773
+ // every piece of it was a ROOT: `Deco` was reported as its own unit (correctly) and NOTHING edged to it,
9774
+ // `X.<static-init>` and `<decorator-arg>@N` were minted and never reached. MEASURED (EXECUTED — the
9775
+ // compiled module writes its witness on import, `mk` writes on every call): `deny Fs src.m.<module>`,
9776
+ // `deny Fs src.m.mk`, `deny Fs src.m.caller` and the README's own layer gate `deny Fs src.domain` over an
9777
+ // `infra` decorator applied to a `domain` class ALL exited 0, while the identical effect as a plain
9778
+ // statement exited 1. `staticBlockUnit` copied java's `<clinit>` shape, which is right on the JVM (class
9779
+ // init is lazy and JVM-triggered) and wrong here.
9780
+ //
9781
+ // THIS IS AN EDGE PASS, NOT A CHANGE TO `enclosing`, and that is the point of both constraints R782 names:
9782
+ // (1) The evaluating unit is `enclosing(cls.parent)` — the climb starts OUTSIDE the whole decorated class.
9783
+ // One node up from a METHOD decorator is the ClassDeclaration, which `nodeName` maps to
9784
+ // `X.constructor`; continuing the climb from the Decorator would FABRICATE the decorator's effects
9785
+ // onto the constructor and every `new X()` — exactly what `174f3cb`'s Decorator guard removed. The
9786
+ // guard in `enclosing` is untouched, so no call inside a decorator moves; this pass only ADDS edges.
9787
+ // (2) Only to a LOCAL, BODIED unit. An external decorator (`@Injectable()`, `@Entity()`) is R64 shape 3 —
9788
+ // wiring it would put the κ ledger's `Unknown`/`invisible` on `<module>` in every framework file, a
9789
+ // priced disclosure question filed elsewhere — and a body-less local declaration is the same: no body
9790
+ // this scan can see. A `<decorator-arg>@N` / `<static-init>` / anonymous `<decorator>@N` unit is by
9791
+ // construction local and bodied (it exists only because something in it was attributed there).
9792
+ // Targets, per decorator: the decorator's own APPLICATION (the value it evaluates to is CALLED with the
9793
+ // target — the resolved signature of the Decorator node itself), the factory CALL when the expression is
9794
+ // one (`@F("u")` calls `F`; the closure it returns is attributed to `F` lexically), and that call's
9795
+ // argument unit. Per class: every `static {}` block's unit. Classes are processed INNERMOST FIRST, so a
9796
+ // class defined inside an outer class's static block wires into that block's unit before the outer class
9797
+ // wires the block — no second sweep needed. `<module>` is minted only when there is something to edge.
9798
+ // `CANDOR_R782_HITS=1` counts each edge added (`deco`/`arg`/`static`), printed only when non-zero.
9799
+ {
9800
+ const R782_HITS = process.env.CANDOR_R782_HITS ? new Map() : null;
9801
+ const hit = (k) => { if (R782_HITS) R782_HITS.set(k, (R782_HITS.get(k) ?? 0) + 1); };
9802
+ if (R782_HITS) process.on("exit", () => {
9803
+ const rows = [...R782_HITS].sort();
9804
+ const total = rows.reduce((a, [, n]) => a + n, 0);
9805
+ if (total) process.stderr.write(`R782PROBE total=${total}` + rows.map(([k, n]) => ` ${k}=${n}`).join("") + "\n");
9806
+ });
9807
+ const hasBody = (d) => !!d && !!d.body
9808
+ && (ts.isFunctionDeclaration(d) || ts.isMethodDeclaration(d) || ts.isArrowFunction(d)
9809
+ || ts.isFunctionExpression(d) || ts.isGetAccessorDeclaration(d) || ts.isSetAccessorDeclaration(d));
9810
+ const localBodiedUnit = (callLike) => {
9811
+ let d;
9812
+ try { d = checker.getResolvedSignature(callLike)?.declaration; } catch { d = undefined; }
9813
+ if (!d || !declIsLocal(d) || !hasBody(d)) return undefined;
9814
+ return callTargetUnit(d);
9815
+ };
9816
+ const decoratedClass = (dec) => {
9817
+ for (let p = dec.parent; p; p = p.parent)
9818
+ if (ts.isClassDeclaration(p) || ts.isClassExpression(p)) return p;
9819
+ return undefined;
9820
+ };
9821
+ const classes = [];
9822
+ for (const sf of sources) (function findClasses(n) {
9823
+ if (ts.isClassDeclaration(n) || ts.isClassExpression(n)) classes.push(n);
9824
+ ts.forEachChild(n, findClasses);
9825
+ })(sf);
9826
+ // Document order is outer-before-inner; reversed, every nested class precedes its container.
9827
+ for (const cls of classes.reverse()) {
9828
+ const targets = [];
9829
+ const add = (q, k) => { if (q && fns.has(q)) targets.push([q, k]); };
9830
+ const decorators = [];
9831
+ (function findDecorators(n) {
9832
+ if (n !== cls && (ts.isClassDeclaration(n) || ts.isClassExpression(n))) return; // its own pass
9833
+ if (ts.isDecorator(n) && decoratedClass(n) === cls) decorators.push(n);
9834
+ ts.forEachChild(n, findDecorators);
9835
+ })(cls);
9836
+ for (const dec of decorators) {
9837
+ add(localBodiedUnit(dec), "deco"); // the application
9838
+ let e = dec.expression;
9839
+ while (ts.isParenthesizedExpression(e)) e = e.expression;
9840
+ if (ts.isCallExpression(e)) {
9841
+ add(localBodiedUnit(e), "deco"); // the factory call
9842
+ add(`${moduleOf(e.getSourceFile())}.${DECORATOR_ARG_LOCAL}${e.getStart()}`, "arg");
9843
+ }
9844
+ }
9845
+ for (const m of cls.members ?? [])
9846
+ if (ts.isClassStaticBlockDeclaration(m)) add(staticBlockQual(m), "static");
9847
+ if (!targets.length) continue;
9848
+ const from = enclosing(cls.parent);
9849
+ const rec = from && fns.get(from);
9850
+ if (!rec) continue;
9851
+ for (const [q, k] of targets) if (q !== from && !rec.edges.has(q)) { rec.edges.add(q); hit(k); }
9852
+ }
9853
+ }
9854
+
9125
9855
  // ---- pass 2b: callback-flow resolution (the callback_named move) ----------------------------------
9126
9856
  // A fn invoking its parameter i resolves to the named target(s) actually passed. Three shapes:
9127
9857
  // - NO visible call site (the fn may be exported; outside callers can pass anything) — honest Unknown
@@ -11064,21 +11794,40 @@ if (wantJson) {
11064
11794
  // `REPORT_STREAM_WRITTEN` OnceLock at the analog site (crates/candor-scan/src/scan.rs).
11065
11795
  reportStreamWritten = true;
11066
11796
  } else {
11067
- writeAtomic(`${outPrefix}.json`, JSON.stringify(envelope, null, 1));
11068
- writeAtomic(`${outPrefix}.callgraph.json`, JSON.stringify(cg, null, 1));
11069
- // ⟨verify⟩ ALL-FUNCTION SPAN index — the [start, end] line SPAN of EVERY analyzed fn, pure ones INCLUDED
11070
- // (the §2 report carries a start loc for effectful fns only). The dynamic honesty oracle (candor-ts-verify)
11071
- // maps a runtime effect site to its enclosing fn; it needs SPANS, not just starts, for two reasons: (1) a
11072
- // pure fn omitted from §2 has no anchor, so its effect would fold onto the nearest preceding effectful fn
11073
- // and its cardinal-sin escape would vanish (a silent MISS); (2) a start-only "nearest declaration below"
11074
- // rule misattributes a site that sits AFTER a nested fn but INSIDE the effectful outer fn to that nested
11075
- // (often pure) fn — manufacturing a FALSE violation (found corpus-testing a real app: an fs.readFileSync in
11076
- // a big `run()` bucketed onto a pure test-callback arrow declared earlier). With spans the oracle picks the
11077
- // INNERMOST fn whose [start,end] CONTAINS the site — correct in both cases. Format `{fn: {loc, end}}`;
11078
- // additive (no §2/callgraph consumer reads it); the oracle fails CLOSED (discloses) without it.
11079
- const locs = {};
11080
- for (const [name, rec] of fns) if (rec.loc) locs[name] = { loc: rec.loc, end: rec.endLine ?? null };
11081
- writeAtomic(`${outPrefix}.locs.json`, JSON.stringify(locs, null, 1));
11797
+ // ⟨SOUNDNESS R522⟩ THIS WRITE CAN FAIL FOR ORDINARY REASONS — a read-only filesystem, an unwritable
11798
+ // directory, a prefix with no parent at all — and until now none of them were caught. `writeAtomic`
11799
+ // -> `writeSinkAtomic` -> `fs.writeFileSync` threw straight out of `main`, past every refusal plumbing
11800
+ // in this file, as an UNCAUGHT exception: a raw Node stack trace on stderr and exit 1. SPEC §3.3.1
11801
+ // makes a broken sink an exit-2 usage error like any other unwritable `--out`/`--gate-json`; a stack
11802
+ // trace is neither a verdict nor a refusal, and a CI consumer parsing this run's exit code reads exit
11803
+ // 1 as "the policy failed", not "the tool couldn't write its own report". Measured:
11804
+ // `candor-ts . /also-bogus` (the legacy positional out-prefix, armed at parse time) died here with
11805
+ // `EROFS: read-only file system, open '/also-bogus.json.<pid>.tmp'` and no exit-2 refusal at all.
11806
+ //
11807
+ // `refuseEarly` is safe to call this late: `armedGateSink`/`refusalPrefix` were latched during argv
11808
+ // parsing, and `writeRefusalMarker` already fails OPEN on its own unwritable sink (see above) — so a
11809
+ // `--gate-json`/marker write that fails for the SAME reason this one just did does not mask the cause.
11810
+ try {
11811
+ writeAtomic(`${outPrefix}.json`, JSON.stringify(envelope, null, 1));
11812
+ writeAtomic(`${outPrefix}.callgraph.json`, JSON.stringify(cg, null, 1));
11813
+ // ⟨verify⟩ ALL-FUNCTION SPAN index — the [start, end] line SPAN of EVERY analyzed fn, pure ones INCLUDED
11814
+ // (the §2 report carries a start loc for effectful fns only). The dynamic honesty oracle (candor-ts-verify)
11815
+ // maps a runtime effect site to its enclosing fn; it needs SPANS, not just starts, for two reasons: (1) a
11816
+ // pure fn omitted from §2 has no anchor, so its effect would fold onto the nearest preceding effectful fn
11817
+ // and its cardinal-sin escape would vanish (a silent MISS); (2) a start-only "nearest declaration below"
11818
+ // rule misattributes a site that sits AFTER a nested fn but INSIDE the effectful outer fn to that nested
11819
+ // (often pure) fn — manufacturing a FALSE violation (found corpus-testing a real app: an fs.readFileSync in
11820
+ // a big `run()` bucketed onto a pure test-callback arrow declared earlier). With spans the oracle picks the
11821
+ // INNERMOST fn whose [start,end] CONTAINS the site — correct in both cases. Format `{fn: {loc, end}}`;
11822
+ // additive (no §2/callgraph consumer reads it); the oracle fails CLOSED (discloses) without it.
11823
+ const locs = {};
11824
+ for (const [name, rec] of fns) if (rec.loc) locs[name] = { loc: rec.loc, end: rec.endLine ?? null };
11825
+ writeAtomic(`${outPrefix}.locs.json`, JSON.stringify(locs, null, 1));
11826
+ } catch (e) {
11827
+ console.error(`candor-ts: could not write the report to ${outPrefix} (${e.message}) — refusing (exit 2)`);
11828
+ refuseEarly(`could not write the report to ${outPrefix}: ${e.message}`);
11829
+ process.exit(2);
11830
+ }
11082
11831
  }
11083
11832
  // Type-hierarchy sidecar (SPEC §4 / 0.7): each project class/interface (qualified `mod.Name`, matching
11084
11833
  // the `mod.Class.member` fn quals) -> its qualified direct supertypes/interfaces. Compact (O(types)),